feat(extensions): 通过目录能力提取已验证包
This commit is contained in:
@@ -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 认证前断开问题也尚未解决,不宣称整体生产化验收完成。
|
||||
|
||||
Generated
+109
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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<String>,
|
||||
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<String> {
|
||||
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<Prepared> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -36,3 +36,6 @@ pub mod extension_store;
|
||||
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mod extension_dependencies;
|
||||
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mod extension_unpack;
|
||||
|
||||
Reference in New Issue
Block a user