From 75a9e49cd862455e546c53c5983bacc446754330 Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Tue, 8 Sep 2026 23:23:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(sandbox):=20=E5=B0=86=E5=8E=9F=E7=94=9F?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E7=BB=91=E5=AE=9A=E5=88=B0=E5=8D=B7=E4=B8=8E?= =?UTF-8?q?=E7=A5=96=E5=85=88=E5=8F=A5=E6=9F=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OpenNexus生产化实施进度-2026-09-08.md | 10 + frontend/src-tauri/src/extension_container.rs | 20 ++ frontend/src-tauri/src/extension_pinned.rs | 172 +++++++++++++++++- frontend/src-tauri/src/extension_process.rs | 16 ++ 4 files changed, 217 insertions(+), 1 deletion(-) diff --git a/docs/development/OpenNexus生产化实施进度-2026-09-08.md b/docs/development/OpenNexus生产化实施进度-2026-09-08.md index b664250..68dcd4e 100644 --- a/docs/development/OpenNexus生产化实施进度-2026-09-08.md +++ b/docs/development/OpenNexus生产化实施进度-2026-09-08.md @@ -398,3 +398,13 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写 - 两项实际 Windows 句柄测试验证持有期间写入、删除、文件重命名和子目录重命名被拒绝;已存在写入句柄时锁定失败;错误树摘要和指向包外的硬链接拒绝;失败或释放对象后不残留写入锁。授权后文件内容保持一致。 - 50 项扩展回归通过,4 项 ignored 中三个是父测试实际调用的 Job 辅助入口,另一个是上一轮已显式通过的 60 秒长时验收,本轮未重跑该长时项。全目标 Clippy -D warnings 通过。日志 `.build/extension-pinned-regression-tests.log`、`.build/extension-pinned-clippy.log`。 - 此原语固定已有包对象,不是只读文件系统挂载:包根目录上层路径绑定、并发新增未知文件、已有可写映射等场景仍未形成完整证明;不据此通过整个 C-02。正式启动器还需将许可、精确入口、完整包句柄和策略检查连接起来,extensions capability 继续关闭,整体生产化继续实施。 + + +## 增量:原生入口卷路径、祖先句柄与进程生命周期绑定 + +- PinnedPackage 新增 bind_entry,仅允许已验证清单中的入口。通过 GetFinalPathNameByHandleW 从持有文件句柄取得卷 GUID 路径,逐级持有卷根至入口的对象句柄,拒绝重解析对象并阻止祖先目录删除共享;最后用卷序列号和文件索引核对重新打开的入口与原持有文件身份一致。 +- BoundEntry 保留全部祖先句柄并借用完整 PinnedPackage。Suspended::create_bound 将此守卫借用延续到进程结束,使该创建路径不能在进程仍存活时释放入口或包句柄。挂起创建失败仍按既有所有权规则清理,不产生可恢复进程。 +- 无法取得卷 GUID、不是本地卷格式、路径过长或超过 256 个路径分量时明确拒绝,不退回 UNC、驱动器字母搜索或普通子进程。原有低层 create 仍供受控探针等调用使用,正式包运行编排应使用 create_bound 并完成其余许可与策略检查。 +- 实际 Windows 测试确认绑定路径为卷 GUID 路径、内容对应已持有文件、缺失/越界入口拒绝、父目录重命名失败;全部所有者释放后重命名成功。真实 AppContainer 原生参数/环境探针通过 create_bound 启动成功,网络/取消/短期限探针也使用持续持有的绑定路径运行。 +- 51 项扩展回归通过,4 项 ignored 为三个由父测试实际驱动的 Job 辅助入口及需显式执行的 60 秒验收,本轮未重跑长时项。Clippy 首次指出冗余 Ok/?,修正后全目标 -D warnings 通过。日志 `.build/extension-entry-binding-tests.log`、`.build/extension-entry-binding-clippy.log`。API 依据 [Microsoft GetFinalPathNameByHandleW](https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-getfinalpathnamebyhandlew)。 +- 这补上了普通祖先路径替换的绑定原语与实测,仍未完成并发新增未知文件、已有可写映射、完整攻击矩阵、许可原子核验或所有目标系统验证,不能据此认定 C-02 或整体生产化完成。 diff --git a/frontend/src-tauri/src/extension_container.rs b/frontend/src-tauri/src/extension_container.rs index f19e2fe..4ae4fc3 100644 --- a/frontend/src-tauri/src/extension_container.rs +++ b/frontend/src-tauri/src/extension_container.rs @@ -675,6 +675,10 @@ mod tests { pinned.grant_read_execute(&profile).unwrap(); pinned }; + #[cfg(feature = "desktop")] + let bound_entry = _pinned.bind_entry("network-probe.exe").unwrap(); + #[cfg(feature = "desktop")] + let executable = bound_entry.path().to_owned(); #[cfg(not(feature = "desktop"))] { profile.grant_package_read_execute(&root).unwrap(); @@ -709,6 +713,18 @@ mod tests { .collect(), ) .unwrap(); + #[cfg(feature = "desktop")] + { + let suspended = + crate::extension_process::Suspended::create_bound(&profile, &bound_entry, data) + .unwrap(); + let running = unsafe { suspended.resume().unwrap() }; + assert_eq!( + running.wait(std::time::Duration::from_secs(5)).unwrap(), + Some(0) + ); + } + #[cfg(not(feature = "desktop"))] assert_eq!( checked_executable_data(&profile, &executable, None, Some(data)), Some(0) @@ -909,6 +925,10 @@ mod tests { pinned.grant_read_execute(&profile).unwrap(); pinned }; + #[cfg(feature = "desktop")] + let bound_entry = _pinned.bind_entry("network-probe.exe").unwrap(); + #[cfg(feature = "desktop")] + let executable = bound_entry.path().to_owned(); #[cfg(not(feature = "desktop"))] { profile.grant_package_read_execute(&root).unwrap(); diff --git a/frontend/src-tauri/src/extension_pinned.rs b/frontend/src-tauri/src/extension_pinned.rs index f4dfc4a..8da65af 100644 --- a/frontend/src-tauri/src/extension_pinned.rs +++ b/frontend/src-tauri/src/extension_pinned.rs @@ -1,5 +1,5 @@ //! Windows package handles retained across verification and launch. This pins -//! existing objects; it is not a read-only mount or an ancestor-path proof. +//! existing objects; it is not a read-only filesystem mount. use crate::{ extension_container::Profile, extension_package::Inventory, @@ -15,6 +15,63 @@ pub struct PinnedPackage { files: BTreeMap, tree_sha256: String, } +/// The package borrow and all ancestor handles must outlive the process using +/// this path. Only files present in the verified package can produce this guard. +pub struct BoundEntry<'a> { + path: std::path::PathBuf, + _ancestors: Vec, + _package: &'a PinnedPackage, +} +impl BoundEntry<'_> { + pub fn path(&self) -> &std::path::Path { + &self.path + } +} +fn identity(file: &File) -> Result<(u32, u32, u32)> { + use std::os::windows::io::AsRawHandle; + let mut info = BY_HANDLE_FILE_INFORMATION::default(); + if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut info) } == 0 + || info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 + || info.nNumberOfLinks != 1 + { + return Err(HostError::new("EXTENSION_ENTRY_BINDING_FAILED")); + } + Ok(( + info.dwVolumeSerialNumber, + info.nFileIndexHigh, + info.nFileIndexLow, + )) +} +fn final_volume_path(file: &File) -> Result { + use std::os::windows::{ffi::OsStringExt, io::AsRawHandle}; + let bad = || HostError::new("EXTENSION_ENTRY_BINDING_FAILED"); + let length = unsafe { + GetFinalPathNameByHandleW( + file.as_raw_handle(), + std::ptr::null_mut(), + 0, + VOLUME_NAME_GUID, + ) + }; + if length == 0 || length >= 32767 { + return Err(bad()); + } + let mut buffer = vec![0u16; length as usize + 1]; + let written = unsafe { + GetFinalPathNameByHandleW( + file.as_raw_handle(), + buffer.as_mut_ptr(), + buffer.len() as u32, + VOLUME_NAME_GUID, + ) + }; + if written == 0 || written as usize >= buffer.len() { + return Err(bad()); + } + Ok(std::path::PathBuf::from(std::ffi::OsString::from_wide( + &buffer[..written as usize], + ))) +} fn options() -> OpenOptions { let mut options = OpenOptions::new(); options @@ -103,6 +160,79 @@ impl PinnedPackage { } Ok(pinned) } + /// Resolve through the owned file handle, then pin the volume-rooted path + /// component by component and compare native file identity. No drive-letter + /// or UNC fallback is permitted if volume GUID lookup is unavailable. + pub fn bind_entry(&self, name: &str) -> Result> { + use std::{ + os::windows::fs::OpenOptionsExt as _, + path::{Component, Prefix}, + }; + let bad = || HostError::new("EXTENSION_ENTRY_BINDING_FAILED"); + let expected = self.files.get(name).ok_or_else(bad)?; + let path = final_volume_path(expected)?; + let mut components = path.components(); + let Some(Component::Prefix(prefix)) = components.next() else { + return Err(bad()); + }; + let Prefix::Verbatim(volume) = prefix.kind() else { + return Err(bad()); + }; + let volume = volume.to_str().ok_or_else(bad)?; + let guid = volume + .strip_prefix("Volume{") + .and_then(|s| s.strip_suffix('}')) + .ok_or_else(bad)?; + uuid::Uuid::parse_str(guid).map_err(|_| bad())?; + if components.next() != Some(Component::RootDir) { + return Err(bad()); + } + let parts = components + .map(|part| match part { + Component::Normal(part) => Ok(part.to_owned()), + _ => Err(bad()), + }) + .collect::>>()?; + if parts.is_empty() || parts.len() > 256 { + return Err(bad()); + } + let mut current = std::path::PathBuf::from(format!(r"\\?\{volume}\")); + let mut handles = Vec::new(); + let open = |path: &std::path::Path| -> Result { + std::fs::OpenOptions::new() + .access_mode(FILE_READ_ATTRIBUTES) + .share_mode(FILE_SHARE_READ) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) + .open(path) + .map_err(|_| bad()) + }; + let volume_handle = open(¤t)?; + let metadata = volume_handle.metadata().map_err(|_| bad())?; + if !metadata.is_dir() || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(bad()); + } + handles.push(volume_handle); + for (index, part) in parts.iter().enumerate() { + current.push(part); + let handle = open(¤t)?; + let metadata = handle.metadata().map_err(|_| bad())?; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 + || (index + 1 == parts.len() && !metadata.is_file()) + || (index + 1 < parts.len() && !metadata.is_dir()) + { + return Err(bad()); + } + handles.push(handle); + } + if identity(handles.last().ok_or_else(bad)?)? != identity(expected)? { + return Err(bad()); + } + Ok(BoundEntry { + path: current, + _ancestors: handles, + _package: self, + }) + } pub fn tree_sha256(&self) -> &str { &self.tree_sha256 } @@ -169,4 +299,44 @@ mod tests { assert!(PinnedPackage::open(&root, &inventory, &hash).is_err()); std::fs::write(file, b"unlocked after failures").unwrap(); } + #[test] + fn bound_entry_uses_volume_identity_and_holds_ancestor_rename_locks() { + let temp = tempfile::tempdir().unwrap(); + let parent = temp.path().join("parent"); + let package = parent.join("package"); + std::fs::create_dir_all(&package).unwrap(); + std::fs::write(package.join("entry.exe"), b"verified bytes").unwrap(); + let root = Dir::open_ambient_dir(&package, cap_std::ambient_authority()).unwrap(); + let inventory = Inventory { + files: [( + "entry.exe".into(), + format!("{:x}", Sha256::digest(b"verified bytes")), + )] + .into_iter() + .collect(), + expanded_size: 14, + manifest: "entry.exe".into(), + }; + let hash = crate::extension_unpack::verify_tree(&root, &inventory).unwrap(); + let pinned = PinnedPackage::open(&root, &inventory, &hash).unwrap(); + assert!(pinned.bind_entry("../entry.exe").is_err()); + assert!(pinned.bind_entry("missing.exe").is_err()); + let bound = pinned.bind_entry("entry.exe").unwrap(); + assert!(bound + .path() + .as_os_str() + .to_string_lossy() + .starts_with(r"\\?\Volume{")); + assert_eq!(std::fs::read(bound.path()).unwrap(), b"verified bytes"); + let moved = temp.path().join("moved"); + assert!(std::fs::rename(&parent, &moved).is_err()); + drop(bound); + drop(pinned); + drop(root); + std::fs::rename(&parent, &moved).unwrap(); + assert_eq!( + std::fs::read(moved.join("package/entry.exe")).unwrap(), + b"verified bytes" + ); + } } diff --git a/frontend/src-tauri/src/extension_process.rs b/frontend/src-tauri/src/extension_process.rs index bbb65db..f4f1b0a 100644 --- a/frontend/src-tauri/src/extension_process.rs +++ b/frontend/src-tauri/src/extension_process.rs @@ -76,6 +76,8 @@ struct Process<'a> { handles: Handles, job: Job, _profile: &'a Profile, + #[cfg(feature = "desktop")] + _bound_entry: Option<&'a crate::extension_pinned::BoundEntry<'a>>, } impl Drop for Process<'_> { fn drop(&mut self) { @@ -158,10 +160,24 @@ impl<'a> Suspended<'a> { handles, job, _profile: profile, + #[cfg(feature = "desktop")] + _bound_entry: None, }; verify_identity(&process.handles, profile)?; Ok(Self(process)) } + /// Package launch path: retain the entry guard (and its package/ancestor + /// handles) for the entire suspended/running process lifetime. + #[cfg(feature = "desktop")] + pub fn create_bound( + profile: &'a Profile, + entry: &'a crate::extension_pinned::BoundEntry<'a>, + data: LaunchData, + ) -> Result { + let mut value = Self::create(profile, entry.path(), data)?; + value.0._bound_entry = Some(entry); + Ok(value) + } /// # Safety /// Caller must hold the verified package/entry handles and revalidate the /// current execution permit, trust, Vault binding, environment declarations,