feat: 完成 OpenNexus 第三阶段核心功能与生产化基础 #45
@@ -584,3 +584,13 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写
|
|||||||
- [CreateProcessW](https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessw) 文档说明通用继承在多线程并发创建时可能带入其他可继承句柄;[UpdateProcThreadAttribute](https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-updateprocthreadattribute) 仍要求 HANDLE_LIST 中的句柄具有继承标志。本轮因此同时保留显式列表并约束自有创建窗口。
|
- [CreateProcessW](https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessw) 文档说明通用继承在多线程并发创建时可能带入其他可继承句柄;[UpdateProcThreadAttribute](https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-updateprocthreadattribute) 仍要求 HANDLE_LIST 中的句柄具有继承标志。本轮因此同时保留显式列表并约束自有创建窗口。
|
||||||
- 该锁只约束已接入的自有 Host 路径,不约束绕开该入口的第三方库或系统组件。尚未完成全部依赖/平台进程创建审计,以及真实并发启动的完整跨进程句柄攻击矩阵,不能据此宣称整个 Host 继承问题已经完全验收。第三方扩展仍未启用。
|
- 该锁只约束已接入的自有 Host 路径,不约束绕开该入口的第三方库或系统组件。尚未完成全部依赖/平台进程创建审计,以及真实并发启动的完整跨进程句柄攻击矩阵,不能据此宣称整个 Host 继承问题已经完全验收。第三方扩展仍未启用。
|
||||||
- desktop 全目标测试累计 129 通过、12 ignored:库 114、Host 8、其余集成 7;覆盖原生扩展协议、后台实例、Core 生命周期/Workspace、凭据所有权及 Sync 故障恢复集成。本轮未显式重跑 ignored 长时资源/工具期限验收,辅助进程由其父测试驱动。日志 `.build/process-creation-full.log`。desktop 全目标 Clippy -D warnings 通过,日志 `.build/process-creation-clippy.log`。
|
- desktop 全目标测试累计 129 通过、12 ignored:库 114、Host 8、其余集成 7;覆盖原生扩展协议、后台实例、Core 生命周期/Workspace、凭据所有权及 Sync 故障恢复集成。本轮未显式重跑 ignored 长时资源/工具期限验收,辅助进程由其父测试驱动。日志 `.build/process-creation-full.log`。desktop 全目标 Clippy -D warnings 通过,日志 `.build/process-creation-clippy.log`。
|
||||||
|
|
||||||
|
|
||||||
|
## 增量:Sync spool 校验的有界读取
|
||||||
|
|
||||||
|
- 核查发现网络下载本身已经逐块写入临时文件,但下载命中缓存、sync_stage 和 sync_store_bytes 命中缓存仍通过 fs::read 校验整个对象。统一改为 payloads::verify,避免这些校验分支再为 100 MiB 对象分配整块 Vec。
|
||||||
|
- payloads::verify 使用固定 64 KiB 缓冲,并同时校验实际读取长度与 SHA-256;即使元数据检查后输入继续增长,也最多读取声明长度加 1 字节,不再一直读取到变化文件的 EOF。打开后的句柄也检查长度。open_verified 返回回到起点的同一文件句柄,上传使用它完成预校验后继续分块上传,不为校验再重开路径。
|
||||||
|
- 该辅助函数明确不是沙箱安全打开器,路径约束仍由调用方负责;同一打开句柄不等于外部写入被完全禁止。本轮不宣称消除所有路径替换/同时写入竞争。已有网络下载的摘要校验与原子无覆盖发布行为保留。
|
||||||
|
- 新增生成式 100 MiB 读取测试,检查每次请求不超过 64 KiB;增长输入精确在声明长度加 1 字节时拒绝,截断、同长度内容损坏均拒绝,空对象正常。另用实际文件检查返回句柄从头读取与同长度内容修改被拒绝。两项通过,日志 `.build/sync-spool-bounded-tests.log`;生成流测试不是操作系统 RSS 基准。
|
||||||
|
- 尚有 sync_apply_pending、Workspace journal 恢复/应用、冲突保留和外部变更扫描等路径读取整块内容;因此大附件端到端固定内存目标仍未完成,接下来仍需将已有持久化日志/操作回执/CAS 语义保持不变地接到流式 payload 应用路径。
|
||||||
|
- desktop 全目标累计 131 通过、12 ignored(库 116、Host 8、其他集成 7),包含真实 Sync 服务有序推送/重复提交及故障恢复集成,日志 `.build/sync-spool-bounded-full.log`;desktop 全目标 Clippy -D warnings 通过,日志 `.build/sync-spool-bounded-clippy.log`。本轮未将这组结果视为大附件端到端 RSS 或完整生产化验收通过。
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use rusqlite::{params, OptionalExtension};
|
|||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use std::{
|
use std::{
|
||||||
fs,
|
fs,
|
||||||
io::{Read, Write},
|
io::{Read, Seek, SeekFrom, Write},
|
||||||
path::Path,
|
path::Path,
|
||||||
};
|
};
|
||||||
impl Workspace {
|
impl Workspace {
|
||||||
@@ -59,6 +59,11 @@ impl Workspace {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub(crate) fn verify(path: &Path, digest: &str, size: u64) -> Result<()> {
|
pub(crate) fn verify(path: &Path, digest: &str, size: u64) -> Result<()> {
|
||||||
|
open_verified(path, digest, size).map(drop)
|
||||||
|
}
|
||||||
|
/// Return the verified handle, rewound for use by a streaming caller.
|
||||||
|
/// Path containment is the caller's responsibility; this is not a sandbox opener.
|
||||||
|
pub(crate) fn open_verified(path: &Path, digest: &str, size: u64) -> Result<fs::File> {
|
||||||
let meta = fs::symlink_metadata(path)?;
|
let meta = fs::symlink_metadata(path)?;
|
||||||
if meta.file_type().is_symlink() || !meta.is_file() || meta.len() != size {
|
if meta.file_type().is_symlink() || !meta.is_file() || meta.len() != size {
|
||||||
return Err(HostError::new("SYNC_SPOOL_CORRUPT"));
|
return Err(HostError::new("SYNC_SPOOL_CORRUPT"));
|
||||||
@@ -71,17 +76,118 @@ pub(crate) fn verify(path: &Path, digest: &str, size: u64) -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mut stream = fs::File::open(path)?;
|
let mut stream = fs::File::open(path)?;
|
||||||
|
if stream.metadata()?.len() != size {
|
||||||
|
return Err(HostError::new("SYNC_SPOOL_CORRUPT"));
|
||||||
|
}
|
||||||
|
verify_reader(&mut stream, digest, size)?;
|
||||||
|
stream.seek(SeekFrom::Start(0))?;
|
||||||
|
Ok(stream)
|
||||||
|
}
|
||||||
|
const VERIFY_BUFFER_BYTES: usize = 64 * 1024;
|
||||||
|
fn verify_reader(stream: &mut impl Read, digest: &str, size: u64) -> Result<()> {
|
||||||
let mut hasher = Sha256::new();
|
let mut hasher = Sha256::new();
|
||||||
let mut buffer = vec![0; 1024 * 1024];
|
let mut buffer = vec![0; VERIFY_BUFFER_BYTES];
|
||||||
|
let mut length = 0u64;
|
||||||
loop {
|
loop {
|
||||||
let count = stream.read(&mut buffer)?;
|
// Even if a file grows after metadata inspection, consume at most the
|
||||||
|
// declared payload plus one byte, never an unbounded changing stream.
|
||||||
|
let limit = size
|
||||||
|
.saturating_sub(length)
|
||||||
|
.saturating_add(1)
|
||||||
|
.min(buffer.len() as u64) as usize;
|
||||||
|
let count = stream.read(&mut buffer[..limit])?;
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
length += count as u64;
|
||||||
|
if length > size {
|
||||||
|
return Err(HostError::new("SYNC_SPOOL_CORRUPT"));
|
||||||
|
}
|
||||||
hasher.update(&buffer[..count]);
|
hasher.update(&buffer[..count]);
|
||||||
}
|
}
|
||||||
if format!("{:x}", hasher.finalize()) != digest {
|
if length != size || format!("{:x}", hasher.finalize()) != digest {
|
||||||
return Err(HostError::new("SYNC_SPOOL_CORRUPT"));
|
return Err(HostError::new("SYNC_SPOOL_CORRUPT"));
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
struct Generated {
|
||||||
|
remaining: u64,
|
||||||
|
consumed: u64,
|
||||||
|
max_request: usize,
|
||||||
|
}
|
||||||
|
impl Read for Generated {
|
||||||
|
fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
|
||||||
|
self.max_request = self.max_request.max(buffer.len());
|
||||||
|
let count = self.remaining.min(buffer.len() as u64) as usize;
|
||||||
|
buffer[..count].fill(7);
|
||||||
|
self.remaining -= count as u64;
|
||||||
|
self.consumed += count as u64;
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn hundred_mib_verification_is_bounded_and_rejects_growth_or_truncation() {
|
||||||
|
let size = 100 * 1024 * 1024u64;
|
||||||
|
let block = vec![7u8; VERIFY_BUFFER_BYTES];
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
for _ in 0..size / block.len() as u64 {
|
||||||
|
hasher.update(&block);
|
||||||
|
}
|
||||||
|
let digest = format!("{:x}", hasher.finalize());
|
||||||
|
let mut stream = Generated {
|
||||||
|
remaining: size,
|
||||||
|
consumed: 0,
|
||||||
|
max_request: 0,
|
||||||
|
};
|
||||||
|
verify_reader(&mut stream, &digest, size).unwrap();
|
||||||
|
assert_eq!(stream.consumed, size);
|
||||||
|
assert_eq!(stream.max_request, VERIFY_BUFFER_BYTES);
|
||||||
|
let mut growing = Generated {
|
||||||
|
remaining: u64::MAX,
|
||||||
|
consumed: 0,
|
||||||
|
max_request: 0,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
verify_reader(&mut growing, &hash(&[7; 8]), 8)
|
||||||
|
.unwrap_err()
|
||||||
|
.code,
|
||||||
|
"SYNC_SPOOL_CORRUPT"
|
||||||
|
);
|
||||||
|
assert_eq!(growing.consumed, 9);
|
||||||
|
assert_eq!(
|
||||||
|
verify_reader(&mut &[7; 7][..], &hash(&[7; 8]), 8)
|
||||||
|
.unwrap_err()
|
||||||
|
.code,
|
||||||
|
"SYNC_SPOOL_CORRUPT"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
verify_reader(&mut &[8; 8][..], &hash(&[7; 8]), 8)
|
||||||
|
.unwrap_err()
|
||||||
|
.code,
|
||||||
|
"SYNC_SPOOL_CORRUPT"
|
||||||
|
);
|
||||||
|
verify_reader(&mut &[][..], &hash(&[]), 0).unwrap();
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn verified_file_is_rewound_and_corruption_is_rejected() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let path = temp.path().join("payload");
|
||||||
|
fs::write(&path, b"verified payload").unwrap();
|
||||||
|
let mut file = open_verified(&path, &hash(b"verified payload"), 16).unwrap();
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
file.read_to_end(&mut bytes).unwrap();
|
||||||
|
assert_eq!(bytes, b"verified payload");
|
||||||
|
drop(file);
|
||||||
|
fs::write(&path, b"modified payload").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
verify(&path, &hash(b"verified payload"), 16)
|
||||||
|
.unwrap_err()
|
||||||
|
.code,
|
||||||
|
"SYNC_SPOOL_CORRUPT"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -449,11 +449,9 @@ impl SyncClient {
|
|||||||
ws.sync_spool(digest)
|
ws.sync_spool(digest)
|
||||||
})?;
|
})?;
|
||||||
if target.exists() {
|
if target.exists() {
|
||||||
let bytes = std::fs::read(&target)?;
|
crate::payloads::verify(&target, digest, revision.size as u64)?;
|
||||||
if bytes.len() as i64 == revision.size && crate::workspace::hash(&bytes) == digest {
|
workspace.access(|ws| ws.check_binding(&binding.id))?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
|
||||||
return Err(SyncError::new("SYNC_SPOOL_CORRUPT"));
|
|
||||||
}
|
}
|
||||||
let url = self
|
let url = self
|
||||||
.endpoint
|
.endpoint
|
||||||
@@ -511,22 +509,9 @@ impl SyncClient {
|
|||||||
job: &Job,
|
job: &Job,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let path = workspace.access(|ws| ws.sync_spool(&job.hash))?;
|
let path = workspace.access(|ws| ws.sync_spool(&job.hash))?;
|
||||||
let mut file = std::fs::File::open(path)?;
|
let mut file = crate::payloads::open_verified(&path, &job.hash, job.size as u64)?;
|
||||||
if file.metadata()?.len() != job.size as u64 {
|
workspace.access(|ws| ws.check_binding(&binding.id))?;
|
||||||
return Err(SyncError::new("SYNC_SPOOL_CORRUPT"));
|
|
||||||
}
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
let mut buffer = vec![0u8; 1048576];
|
let mut buffer = vec![0u8; 1048576];
|
||||||
loop {
|
|
||||||
let count = file.read(&mut buffer)?;
|
|
||||||
if count == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
hasher.update(&buffer[..count]);
|
|
||||||
}
|
|
||||||
if format!("{:x}", hasher.finalize()) != job.hash {
|
|
||||||
return Err(SyncError::new("SYNC_SPOOL_CORRUPT"));
|
|
||||||
}
|
|
||||||
let base = format!("sync/v1/vaults/{}/uploads", binding.remote_vault);
|
let base = format!("sync/v1/vaults/{}/uploads", binding.remote_vault);
|
||||||
let mut upload_id = job.upload_id.clone();
|
let mut upload_id = job.upload_id.clone();
|
||||||
let mut offset = 0;
|
let mut offset = 0;
|
||||||
|
|||||||
@@ -115,11 +115,7 @@ impl Workspace {
|
|||||||
let digest = hash(bytes);
|
let digest = hash(bytes);
|
||||||
let path = self.sync_spool(&digest)?;
|
let path = self.sync_spool(&digest)?;
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
if fs::symlink_metadata(&path)?.file_type().is_symlink()
|
crate::payloads::verify(&path, &digest, bytes.len() as u64)?;
|
||||||
|| hash(&fs::read(&path)?) != digest
|
|
||||||
{
|
|
||||||
return Err(HostError::new("SYNC_SPOOL_CORRUPT"));
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
let mut temp = tempfile::NamedTempFile::new_in(path.parent().unwrap())?;
|
let mut temp = tempfile::NamedTempFile::new_in(path.parent().unwrap())?;
|
||||||
temp.write_all(bytes)?;
|
temp.write_all(bytes)?;
|
||||||
@@ -150,10 +146,7 @@ impl Workspace {
|
|||||||
return Err(HostError::new("SYNC_CURSOR_INVALID"));
|
return Err(HostError::new("SYNC_CURSOR_INVALID"));
|
||||||
}
|
}
|
||||||
if let Some(digest) = &revision.hash {
|
if let Some(digest) = &revision.hash {
|
||||||
let bytes = fs::read(self.sync_spool(digest)?)?;
|
crate::payloads::verify(&self.sync_spool(digest)?, digest, revision.size as u64)?;
|
||||||
if bytes.len() as i64 != revision.size || hash(&bytes) != *digest {
|
|
||||||
return Err(HostError::new("SYNC_SPOOL_CORRUPT"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let encoded =
|
let encoded =
|
||||||
serde_json::to_string(revision).map_err(|_| HostError::new("SYNC_RESPONSE_INVALID"))?;
|
serde_json::to_string(revision).map_err(|_| HostError::new("SYNC_RESPONSE_INVALID"))?;
|
||||||
|
|||||||
Reference in New Issue
Block a user