From b8d201cea075a06073fa8706b3249bf1ebc2a7f5 Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Tue, 8 Sep 2026 23:15:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(sandbox):=20=E6=89=A7=E8=A1=8C=E6=9C=9F?= =?UTF-8?q?=E9=97=B4=E4=BF=9D=E7=95=99=E5=B7=B2=E9=AA=8C=E8=AF=81=E5=8C=85?= =?UTF-8?q?=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 | 9 + frontend/src-tauri/src/extension_container.rs | 60 +++++- frontend/src-tauri/src/extension_pinned.rs | 172 ++++++++++++++++++ frontend/src-tauri/src/lib.rs | 3 + 4 files changed, 240 insertions(+), 4 deletions(-) create mode 100644 frontend/src-tauri/src/extension_pinned.rs diff --git a/docs/development/OpenNexus生产化实施进度-2026-09-08.md b/docs/development/OpenNexus生产化实施进度-2026-09-08.md index 6530c5d..b664250 100644 --- a/docs/development/OpenNexus生产化实施进度-2026-09-08.md +++ b/docs/development/OpenNexus生产化实施进度-2026-09-08.md @@ -389,3 +389,12 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写 - 长时测试以 --ignored --exact 显式执行,1 项通过;常规 AppContainer 5 项回归通过,长时条目在常规运行中保留 ignored 标记避免每次都额外等待一分钟。全目标 Clippy -D warnings 通过。日志 `.build/extension-deadline-sixty-seconds.log`、`.build/extension-deadline-container-regression.log`、`.build/extension-deadline-acceptance-clippy.log`。这补足上一条记录中未执行完整 60 秒和容器子树验证的缺口,不覆盖计时线程故障注入或高并发压力。 - 远端只读复查:HTTP /health 200、/ready 503,SSH 22 连接仍在认证前返回空 banner;未修改远端配置,仍需要恢复就绪依赖与 SSH 可用入口。 - C-04 的 CPU 持续超限、scratch 配额、broker 限流及完整桌面证据仍未齐备,不能将本测试扩大为 C-04 全项通过。整体生产化目标继续进行。 + + +## 增量:校验后持续持有 Windows 包对象句柄 + +- 新增 PinnedPackage,从 Host 已验证的目录 capability 和 Inventory 出发,逐级以 nofollow、FILE_SHARE_READ 和读取/ACL 权限打开根目录、全部父目录及包文件,保留这些句柄到实例结束。先持有全部现有对象,再复用完整树校验核对文件集合、摘要、总大小、硬链接与重解析限制,并匹配预期展开树摘要;任一步失败释放整组句柄。 +- 该对象提供按持有句柄授予实例只读执行权限;不接受 renderer 任意路径,不在授权时重新通过字符串查找文件。数量、路径长度、目录数和展开体积继续有界。实际容器探针已通过该对象持有包后启动,参数/环境、网络行为、取消及短期限回归通过。 +- 两项实际 Windows 句柄测试验证持有期间写入、删除、文件重命名和子目录重命名被拒绝;已存在写入句柄时锁定失败;错误树摘要和指向包外的硬链接拒绝;失败或释放对象后不残留写入锁。授权后文件内容保持一致。 +- 50 项扩展回归通过,4 项 ignored 中三个是父测试实际调用的 Job 辅助入口,另一个是上一轮已显式通过的 60 秒长时验收,本轮未重跑该长时项。全目标 Clippy -D warnings 通过。日志 `.build/extension-pinned-regression-tests.log`、`.build/extension-pinned-clippy.log`。 +- 此原语固定已有包对象,不是只读文件系统挂载:包根目录上层路径绑定、并发新增未知文件、已有可写映射等场景仍未形成完整证明;不据此通过整个 C-02。正式启动器还需将许可、精确入口、完整包句柄和策略检查连接起来,extensions capability 继续关闭,整体生产化继续实施。 diff --git a/frontend/src-tauri/src/extension_container.rs b/frontend/src-tauri/src/extension_container.rs index 89e05f3..f19e2fe 100644 --- a/frontend/src-tauri/src/extension_container.rs +++ b/frontend/src-tauri/src/extension_container.rs @@ -652,8 +652,34 @@ mod tests { }; let root = open(package.path()); let entry = open(&executable); - profile.grant_package_read_execute(&root).unwrap(); - profile.grant_package_read_execute(&entry).unwrap(); + #[cfg(feature = "desktop")] + let _pinned = { + use sha2::{Digest, Sha256}; + let bytes = std::fs::read(&executable).unwrap(); + let inventory = crate::extension_package::Inventory { + files: [( + "network-probe.exe".to_owned(), + format!("{:x}", Sha256::digest(&bytes)), + )] + .into_iter() + .collect(), + expanded_size: bytes.len() as u64, + manifest: "network-probe.exe".to_owned(), + }; + let dir = + cap_std::fs::Dir::open_ambient_dir(package.path(), cap_std::ambient_authority()) + .unwrap(); + let hash = crate::extension_unpack::verify_tree(&dir, &inventory).unwrap(); + let pinned = + crate::extension_pinned::PinnedPackage::open(&dir, &inventory, &hash).unwrap(); + pinned.grant_read_execute(&profile).unwrap(); + pinned + }; + #[cfg(not(feature = "desktop"))] + { + profile.grant_package_read_execute(&root).unwrap(); + profile.grant_package_read_execute(&entry).unwrap(); + } // Exercise the actual builder, not a test-side quote decoder. The child // compares argv and its entire environment without logging values. let args = [ @@ -860,8 +886,34 @@ mod tests { }; let root = open(package.path()); let entry = open(&executable); - profile.grant_package_read_execute(&root).unwrap(); - profile.grant_package_read_execute(&entry).unwrap(); + #[cfg(feature = "desktop")] + let _pinned = { + use sha2::{Digest, Sha256}; + let bytes = std::fs::read(&executable).unwrap(); + let inventory = crate::extension_package::Inventory { + files: [( + "network-probe.exe".to_owned(), + format!("{:x}", Sha256::digest(&bytes)), + )] + .into_iter() + .collect(), + expanded_size: bytes.len() as u64, + manifest: "network-probe.exe".to_owned(), + }; + let dir = + cap_std::fs::Dir::open_ambient_dir(package.path(), cap_std::ambient_authority()) + .unwrap(); + let hash = crate::extension_unpack::verify_tree(&dir, &inventory).unwrap(); + let pinned = + crate::extension_pinned::PinnedPackage::open(&dir, &inventory, &hash).unwrap(); + pinned.grant_read_execute(&profile).unwrap(); + pinned + }; + #[cfg(not(feature = "desktop"))] + { + profile.grant_package_read_execute(&root).unwrap(); + profile.grant_package_read_execute(&entry).unwrap(); + } let folder = profile.folder().unwrap(); let system = std::path::PathBuf::from(std::env::var_os("SystemRoot").unwrap()); diff --git a/frontend/src-tauri/src/extension_pinned.rs b/frontend/src-tauri/src/extension_pinned.rs new file mode 100644 index 0000000..f4dfc4a --- /dev/null +++ b/frontend/src-tauri/src/extension_pinned.rs @@ -0,0 +1,172 @@ +//! Windows package handles retained across verification and launch. This pins +//! existing objects; it is not a read-only mount or an ancestor-path proof. +use crate::{ + extension_container::Profile, + extension_package::Inventory, + workspace::{HostError, Result}, +}; +use cap_fs_ext::{FollowSymlinks, OpenOptionsFollowExt, OpenOptionsMaybeDirExt}; +use cap_std::fs::{Dir, OpenOptions, OpenOptionsExt}; +use std::{collections::BTreeMap, fs::File, os::windows::fs::MetadataExt}; +use windows_sys::Win32::Storage::FileSystem::*; + +pub struct PinnedPackage { + directories: BTreeMap, + files: BTreeMap, + tree_sha256: String, +} +fn options() -> OpenOptions { + let mut options = OpenOptions::new(); + options + .read(true) + .access_mode(FILE_GENERIC_READ | WRITE_DAC) + .share_mode(FILE_SHARE_READ) + .follow(FollowSymlinks::No) + .maybe_dir(true); + options +} +fn directory(parent: &Dir, name: &str) -> Result { + let handle = parent.open_with(name, &options())?.into_std(); + let metadata = handle.metadata()?; + if !metadata.is_dir() || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(HostError::new("EXTENSION_STORE_UNSAFE")); + } + Ok(Dir::from_std_file(handle)) +} +impl PinnedPackage { + /// `root` and inventory originate from the verified Host store. Keep this + /// owner until the instance stops; no renderer-supplied filesystem path is + /// accepted here. Callers must also constrain ancestors used by native launch. + pub fn open(root: &Dir, inventory: &Inventory, expected_tree: &str) -> Result { + let bad = || HostError::new("EXTENSION_STORE_CORRUPT"); + if inventory.files.is_empty() + || inventory.files.len() > 2048 + || inventory.expanded_size > 50 * 1024 * 1024 + || expected_tree.len() != 64 + { + return Err(bad()); + } + let mut pinned = Self { + directories: BTreeMap::new(), + files: BTreeMap::new(), + tree_sha256: String::new(), + }; + pinned + .directories + .insert(String::new(), directory(root, ".")?); + for path in inventory.files.keys() { + if path.len() > 1024 { + return Err(bad()); + } + let parts: Vec<_> = path.split('/').collect(); + if parts.iter().any(|p| { + p.is_empty() + || *p == "." + || *p == ".." + || p.contains(['\\', ':']) + || p.chars().any(char::is_control) + }) { + return Err(bad()); + } + let mut parent = String::new(); + for part in &parts[..parts.len() - 1] { + let key = if parent.is_empty() { + (*part).to_owned() + } else { + format!("{parent}/{part}") + }; + if !pinned.directories.contains_key(&key) { + if pinned.directories.len() >= 10000 { + return Err(bad()); + } + let dir = directory(&pinned.directories[&parent], part)?; + pinned.directories.insert(key.clone(), dir); + } + parent = key; + } + let handle = pinned.directories[&parent] + .open_with(parts.last().unwrap(), &options())? + .into_std(); + let metadata = handle.metadata()?; + if !metadata.is_file() || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 + { + return Err(bad()); + } + pinned.files.insert(path.clone(), handle); + } + // All existing objects are already pinned when verification reopens + // them. Sharing violations or hash mismatches release the entire set. + pinned.tree_sha256 = + crate::extension_unpack::verify_tree(&pinned.directories[""], inventory)?; + if pinned.tree_sha256 != expected_tree { + return Err(bad()); + } + Ok(pinned) + } + pub fn tree_sha256(&self) -> &str { + &self.tree_sha256 + } + pub fn grant_read_execute(&self, profile: &Profile) -> Result<()> { + for dir in self.directories.values() { + profile.grant_package_read_execute(&dir.try_clone()?.into_std_file())?; + } + for file in self.files.values() { + profile.grant_package_read_execute(file)?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use sha2::{Digest, Sha256}; + fn fixture() -> (tempfile::TempDir, Dir, Inventory, String) { + let temp = tempfile::tempdir().unwrap(); + std::fs::create_dir(temp.path().join("sub")).unwrap(); + std::fs::write(temp.path().join("sub/entry.exe"), b"verified bytes").unwrap(); + let root = Dir::open_ambient_dir(temp.path(), cap_std::ambient_authority()).unwrap(); + let inventory = Inventory { + files: [( + "sub/entry.exe".into(), + format!("{:x}", Sha256::digest(b"verified bytes")), + )] + .into_iter() + .collect(), + expanded_size: 14, + manifest: "sub/entry.exe".into(), + }; + let hash = crate::extension_unpack::verify_tree(&root, &inventory).unwrap(); + (temp, root, inventory, hash) + } + #[test] + fn retained_handles_block_writes_deletes_and_renames_until_owner_is_dropped() { + let (temp, root, inventory, hash) = fixture(); + let pinned = PinnedPackage::open(&root, &inventory, &hash).unwrap(); + assert_eq!(pinned.tree_sha256(), hash); + let file = temp.path().join("sub/entry.exe"); + assert!(std::fs::write(&file, b"changed").is_err()); + assert!(std::fs::remove_file(&file).is_err()); + assert!(std::fs::rename(&file, temp.path().join("sub/other.exe")).is_err()); + assert!(std::fs::rename(temp.path().join("sub"), temp.path().join("moved")).is_err()); + let profile = Profile::create().unwrap(); + pinned.grant_read_execute(&profile).unwrap(); + assert_eq!(std::fs::read(&file).unwrap(), b"verified bytes"); + drop(pinned); + std::fs::write(&file, b"changed").unwrap(); + profile.remove().unwrap(); + } + #[test] + fn existing_writer_wrong_tree_and_hardlinks_fail_without_retaining_locks() { + let (temp, root, inventory, hash) = fixture(); + let file = temp.path().join("sub/entry.exe"); + let writer = std::fs::OpenOptions::new().write(true).open(&file).unwrap(); + assert!(PinnedPackage::open(&root, &inventory, &hash).is_err()); + drop(writer); + assert!(PinnedPackage::open(&root, &inventory, &"0".repeat(64)).is_err()); + let outside = tempfile::tempdir().unwrap(); + std::fs::hard_link(&file, outside.path().join("alias.exe")).unwrap(); + assert!(PinnedPackage::open(&root, &inventory, &hash).is_err()); + std::fs::write(file, b"unlocked after failures").unwrap(); + } +} diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index c0f8ef9..e1c8046 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -66,3 +66,6 @@ pub mod extension_process; #[cfg(windows)] pub mod extension_deadline; + +#[cfg(all(windows, feature = "desktop"))] +pub mod extension_pinned;