From 3edf6bfd0411369b89d24c597c3bfc1985ae01ba Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Tue, 8 Sep 2026 21:08:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(extensions):=20=E9=80=9A=E8=BF=87=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E8=83=BD=E5=8A=9B=E6=8F=90=E5=8F=96=E5=B7=B2=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OpenNexus生产化实施进度-2026-09-08.md | 9 + frontend/src-tauri/Cargo.lock | 109 +++++++ frontend/src-tauri/Cargo.toml | 2 + frontend/src-tauri/src/extension_unpack.rs | 292 ++++++++++++++++++ frontend/src-tauri/src/lib.rs | 3 + 5 files changed, 415 insertions(+) create mode 100644 frontend/src-tauri/src/extension_unpack.rs diff --git a/docs/development/OpenNexus生产化实施进度-2026-09-08.md b/docs/development/OpenNexus生产化实施进度-2026-09-08.md index 56ebc7a..da102e5 100644 --- a/docs/development/OpenNexus生产化实施进度-2026-09-08.md +++ b/docs/development/OpenNexus生产化实施进度-2026-09-08.md @@ -182,3 +182,12 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写 - 预算为同源 4096 候选 / 64 MiB 元数据、200 解析包、10000 搜索展开与 4 MiB 预览编码。测试覆盖输入乱序、菱形回溯、环、缺包、精确版本、平台不兼容、来源/命名空间隔离、深图和过大预览。 - Rust desktop 全目标 69 项通过(两个既有特殊入口 ignored);其后补充预览编码上限,4 项依赖测试和最终 Clippy `-D warnings` 通过。日志 `.build/extension-dependencies-rust-tests.log`。依赖使用已锁定的 semver 1.0.28。 - 正式安装的在线撤回复核、权限确认、解包与包/配置原子切换、健康检查和回滚仍未完成,不能据此通过完整 D-03/D-04。 + + +## 增量:目录句柄内安全解包 + +- 新增 extension_unpack,入口重新验证签名、ZIP 和类型清单,在应用私有 staging 根目录下创建随机 UUID 目录。子目录逐级以 nofollow 句柄打开,输出使用 create_new;ZIP 内路径不用于环境绝对路径写入。依赖锁定 cap-std / cap-fs-ext 4.0.3。 +- 写入后重新遍历,核对精确文件集合、内容摘要和总展开大小;拒绝多余空目录、缺失/额外文件、符号链接、硬链接和 Windows reparse 文件。树摘要使用路径长度、路径、文件长度及文件内容组成的明确编码,独立于 ZIP 摘要和既有 Python package_digest,不可混用。 +- 文件逐个 sync_all,Unix 同步目录项;失败目录保留隔离状态,不标记为当前安装。未验证 Windows 断电持久性,也未将静态链接测试等同于完整并发替换攻击防护。 +- 17 项扩展模块回归和全目标 Clippy -D warnings 通过,日志 `.build/extension-unpack-tests.log`。新增实际 Windows junction 越界拒绝、硬链接替换拒绝、原文件不变、篡改/缺失/额外文件及空目录拒绝测试。 +- 此模块尚未接入持久安装操作、在线撤回复核、权限审批、原子切换或健康检查;扩展执行能力仍关闭。远端 Sync ready=503 和 SSH 认证前断开问题也尚未解决,不宣称整体生产化验收完成。 diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index ae99d68..1a73da3 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -91,6 +91,12 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "ambient-authority" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" + [[package]] name = "android_system_properties" version = "0.1.6" @@ -582,6 +588,48 @@ dependencies = [ "serde_core", ] +[[package]] +name = "cap-fs-ext" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56ff379b70af8e08307a8f65e7040c7301cb4a572538ade16b4984f0da77847f" +dependencies = [ + "cap-primitives", + "cap-std", + "io-lifetimes 3.0.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "cap-primitives" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b5f74729fd2f44701d1a8eb47e906cdb3ccd9ec0f02baad85a744b791940b18" +dependencies = [ + "ambient-authority", + "fs-set-times", + "io-extras", + "io-lifetimes 3.0.1", + "ipnet", + "maybe-owned", + "rustix", + "rustix-linux-procfs", + "windows-sys 0.61.2", + "winx", +] + +[[package]] +name = "cap-std" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1ec78e242cfa2cfe276807ac2ecc00315a6c97786977414bcd1c3963b6c91b8" +dependencies = [ + "cap-primitives", + "io-extras", + "io-lifetimes 3.0.1", + "rustix", +] + [[package]] name = "cargo-platform" version = "0.1.9" @@ -1637,6 +1685,17 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs-set-times" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" +dependencies = [ + "io-lifetimes 2.0.4", + "rustix", + "windows-sys 0.59.0", +] + [[package]] name = "fs2" version = "0.4.3" @@ -2436,6 +2495,28 @@ dependencies = [ "generic-array", ] +[[package]] +name = "io-extras" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f" +dependencies = [ + "io-lifetimes 3.0.1", + "windows-sys 0.59.0", +] + +[[package]] +name = "io-lifetimes" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" + +[[package]] +name = "io-lifetimes" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0fb0570afe1fed943c5c3d4102d5358592d8625fda6a0007fdbe65a92fba96" + [[package]] name = "iota-crypto" version = "0.23.2" @@ -2847,6 +2928,12 @@ dependencies = [ "web_atoms", ] +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + [[package]] name = "memchr" version = "2.8.3" @@ -3031,6 +3118,8 @@ version = "0.3.0-alpha.1" dependencies = [ "argon2", "base64 0.22.1", + "cap-fs-ext", + "cap-std", "chacha20poly1305", "command-group", "ed25519-dalek", @@ -4127,6 +4216,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustix-linux-procfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" +dependencies = [ + "once_cell", + "rustix", +] + [[package]] name = "rustls" version = "0.23.44" @@ -6449,6 +6548,16 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "winx" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags 2.13.1", + "windows-sys 0.59.0", +] + [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index 51ba5e6..0e06b62 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -43,6 +43,8 @@ unicode-normalization = "0.1" unicode-casefold = "0.2" serde-saphyr = { version = "1.2", default-features = false, features = ["deserialize"] } semver = { version = "1", features = ["serde"] } +cap-std = "4.0.2" +cap-fs-ext = "4.0.2" [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_RemoteDesktop", "Win32_UI_WindowsAndMessaging", "Win32_Graphics_Gdi", "Win32_System_LibraryLoader"] } diff --git a/frontend/src-tauri/src/extension_unpack.rs b/frontend/src-tauri/src/extension_unpack.rs new file mode 100644 index 0000000..5aa867d --- /dev/null +++ b/frontend/src-tauri/src/extension_unpack.rs @@ -0,0 +1,292 @@ +//! Private, capability-relative extraction. Prepared trees are not executable installs. +use crate::{ + extension_package::{Inventory, Release}, + workspace::{HostError, Result}, +}; +use cap_fs_ext::{DirExt, FollowSymlinks, OpenOptionsFollowExt}; +use cap_std::fs::{Dir, OpenOptions}; +use sha2::{Digest, Sha256}; +use std::{ + collections::BTreeSet, + io::{Cursor, Read, Write}, +}; + +pub struct Prepared { + pub directory: String, + pub tree_sha256: String, + pub inventory: Inventory, +} + +fn sync_dir(dir: &Dir) -> Result<()> { + #[cfg(unix)] + dir.try_clone()?.into_std_file().sync_all()?; + #[cfg(not(unix))] + let _ = dir; + Ok(()) +} + +fn parent(root: &Dir, path: &str, create: bool) -> Result<(Dir, String)> { + let mut dir = root.try_clone()?; + let mut parts = path.split('/').peekable(); + while let Some(part) = parts.next() { + if part.is_empty() || part == "." || part == ".." || part.contains(['\\', ':']) { + return Err(HostError::new("EXTENSION_STORE_UNSAFE")); + } + if parts.peek().is_none() { + return Ok((dir, part.into())); + } + if create { + match dir.create_dir(part) { + Ok(()) => { + sync_dir(&dir)?; + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(e) => return Err(e.into()), + } + } + dir = dir.open_dir_nofollow(part)?; + } + Err(HostError::new("EXTENSION_STORE_UNSAFE")) +} + +fn collect( + dir: &Dir, + prefix: &str, + found: &mut BTreeSet, + remaining: &mut usize, + inventory: &Inventory, +) -> Result<()> { + for item in dir.entries()? { + if *remaining == 0 { + return Err(HostError::new("EXTENSION_STORE_CORRUPT")); + } + *remaining -= 1; + let item = item?; + let name = item + .file_name() + .into_string() + .map_err(|_| HostError::new("EXTENSION_STORE_UNSAFE"))?; + let path = format!("{prefix}{name}"); + let meta = dir.symlink_metadata(&name)?; + if meta.file_type().is_symlink() { + return Err(HostError::new("EXTENSION_STORE_UNSAFE")); + } + if meta.is_dir() { + if !inventory + .files + .keys() + .any(|p| p.starts_with(&format!("{path}/"))) + { + return Err(HostError::new("EXTENSION_STORE_CORRUPT")); + } + collect( + &dir.open_dir_nofollow(&name)?, + &format!("{path}/"), + found, + remaining, + inventory, + )?; + } else if meta.is_file() { + found.insert(path); + } else { + return Err(HostError::new("EXTENSION_STORE_UNSAFE")); + } + } + Ok(()) +} + +/// Re-read the exact file set and all content through directory capabilities. +pub fn verify_tree(root: &Dir, inventory: &Inventory) -> Result { + let mut found = BTreeSet::new(); + collect(root, "", &mut found, &mut 10000, inventory)?; + if found != inventory.files.keys().cloned().collect() { + return Err(HostError::new("EXTENSION_STORE_CORRUPT")); + } + let mut total = 0u64; + let mut tree = Sha256::new(); + for (path, expected) in &inventory.files { + let (dir, name) = parent(root, path, false)?; + let mut options = OpenOptions::new(); + options.read(true).follow(FollowSymlinks::No); + let file = dir.open_with(name, &options)?.into_std(); + let metadata = file.metadata()?; + if !metadata.is_file() { + return Err(HostError::new("EXTENSION_STORE_UNSAFE")); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if metadata.nlink() != 1 { + return Err(HostError::new("EXTENSION_STORE_UNSAFE")); + } + } + #[cfg(windows)] + { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{ + GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION, + }; + let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() }; + if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut info) } == 0 + || info.nNumberOfLinks != 1 + || info.dwFileAttributes & 0x400 != 0 + { + return Err(HostError::new("EXTENSION_STORE_UNSAFE")); + } + } + let mut content = Sha256::new(); + let mut reader = file.take(inventory.expanded_size.saturating_sub(total) + 1); + tree.update((path.len() as u64).to_be_bytes()); + tree.update(path.as_bytes()); + tree.update(metadata.len().to_be_bytes()); + let mut buffer = [0u8; 65536]; + loop { + let n = reader.read(&mut buffer)?; + if n == 0 { + break; + } + total += n as u64; + if total > inventory.expanded_size { + return Err(HostError::new("EXTENSION_STORE_CORRUPT")); + } + content.update(&buffer[..n]); + tree.update(&buffer[..n]); + } + if format!("{:x}", content.finalize()) != *expected { + return Err(HostError::new("EXTENSION_STORE_CORRUPT")); + } + } + if total != inventory.expanded_size { + return Err(HostError::new("EXTENSION_STORE_CORRUPT")); + } + Ok(format!("{:x}", tree.finalize())) +} + +/// `root` must be an application-owned private staging directory. Trust freshness +/// and permission grants remain installation-layer responsibilities. +pub fn prepare( + root: &Dir, + release: &Release, + key: &[u8; 32], + key_id: &str, + namespace: &str, + archive: &[u8], +) -> Result { + let (inventory, _) = release.verify_package(key, key_id, namespace, false, false, archive)?; + let directory = uuid::Uuid::new_v4().to_string(); + root.create_dir(&directory)?; + sync_dir(root)?; + let target = root.open_dir_nofollow(&directory)?; + let mut zip = zip::ZipArchive::new(Cursor::new(archive)) + .map_err(|_| HostError::new("EXTENSION_ZIP_INVALID"))?; + for (path, expected) in &inventory.files { + let mut entry = zip + .by_name(path) + .map_err(|_| HostError::new("EXTENSION_ZIP_INVALID"))?; + let (dir, name) = parent(&target, path, true)?; + let mut options = OpenOptions::new(); + options + .write(true) + .create_new(true) + .follow(FollowSymlinks::No); + let mut output = dir.open_with(name, &options)?; + let mut digest = Sha256::new(); + let mut buffer = [0u8; 65536]; + loop { + let n = entry.read(&mut buffer)?; + if n == 0 { + break; + } + output.write_all(&buffer[..n])?; + digest.update(&buffer[..n]); + } + output.sync_all()?; + sync_dir(&dir)?; + if format!("{:x}", digest.finalize()) != *expected { + return Err(HostError::new("EXTENSION_STORE_CORRUPT")); + } + } + let tree_sha256 = verify_tree(&target, &inventory)?; + // Failed preparations remain isolated UUID directories; never expose them as current. + Ok(Prepared { + directory, + tree_sha256, + inventory, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::workspace::hash; + use base64::{engine::general_purpose::STANDARD, Engine}; + #[cfg(windows)] + #[test] + fn junction_parent_cannot_redirect_output() { + let temp = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let junction = temp.path().join("redirect"); + let status = std::process::Command::new("cmd.exe") + .args(["/d", "/c", "mklink", "/J"]) + .arg(&junction) + .arg(outside.path()) + .output() + .unwrap(); + assert!(status.status.success(), "junction fixture creation failed"); + let root = Dir::open_ambient_dir(temp.path(), cap_std::ambient_authority()).unwrap(); + assert!(parent(&root, "redirect/escaped.txt", true).is_err()); + assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0); + std::fs::remove_dir(junction).unwrap(); + } + #[test] + fn signed_package_extracts_and_rejects_modified_extra_missing_and_linked_files() { + let mut data: serde_json::Value = serde_json::from_str(include_str!( + "../../src/services/fixtures/community-python-vector.json" + )) + .unwrap(); + for field in ["release_id", "withdrawn", "download_path"] { + data["release"].as_object_mut().unwrap().remove(field); + } + let release: Release = serde_json::from_value(data["release"].clone()).unwrap(); + let bytes = STANDARD + .decode(data["archive_base64"].as_str().unwrap()) + .unwrap(); + let key: [u8; 32] = STANDARD + .decode(data["key"]["public_key"].as_str().unwrap()) + .unwrap() + .try_into() + .unwrap(); + let temp = tempfile::tempdir().unwrap(); + let root = Dir::open_ambient_dir(temp.path(), cap_std::ambient_authority()).unwrap(); + let prepared = prepare(&root, &release, &key, "test-key", "examples", &bytes).unwrap(); + let target = root.open_dir_nofollow(&prepared.directory).unwrap(); + assert_eq!( + verify_tree(&target, &prepared.inventory).unwrap(), + prepared.tree_sha256 + ); + target.create_dir("unexpected-empty").unwrap(); + assert!(verify_tree(&target, &prepared.inventory).is_err()); + target.remove_dir("unexpected-empty").unwrap(); + target.write("extra", b"x").unwrap(); + assert!(verify_tree(&target, &prepared.inventory).is_err()); + target.remove_file("extra").unwrap(); + let original = target.read("persona.json").unwrap(); + target.write("persona.json", b"bad").unwrap(); + assert!(verify_tree(&target, &prepared.inventory).is_err()); + target.remove_file("persona.json").unwrap(); + assert!(verify_tree(&target, &prepared.inventory).is_err()); + let outside = temp.path().join("outside"); + std::fs::write(&outside, &original).unwrap(); + std::fs::hard_link( + &outside, + temp.path().join(&prepared.directory).join("persona.json"), + ) + .unwrap(); + assert!(verify_tree(&target, &prepared.inventory).is_err()); + assert_eq!(hash(&std::fs::read(outside).unwrap()), hash(&original)); + assert!(parent(&target, "../outside", true).is_err()); + let mut corrupt = bytes.clone(); + corrupt[0] ^= 1; + assert!(prepare(&root, &release, &key, "test-key", "examples", &corrupt).is_err()); + } +} diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index a0c7ecb..6c2f481 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -36,3 +36,6 @@ pub mod extension_store; #[cfg(feature = "desktop")] pub mod extension_dependencies; + +#[cfg(feature = "desktop")] +pub mod extension_unpack;