feat(sandbox): 执行期间保留已验证包句柄

This commit is contained in:
2026-09-08 23:15:47 +08:00
parent ec7271a47e
commit b8d201cea0
4 changed files with 240 additions and 4 deletions
@@ -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 503SSH 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 继续关闭,整体生产化继续实施。
@@ -652,8 +652,34 @@ mod tests {
};
let root = open(package.path());
let entry = open(&executable);
#[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);
#[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());
+172
View File
@@ -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<String, Dir>,
files: BTreeMap<String, File>,
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<Dir> {
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<Self> {
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();
}
}
+3
View File
@@ -66,3 +66,6 @@ pub mod extension_process;
#[cfg(windows)]
pub mod extension_deadline;
#[cfg(all(windows, feature = "desktop"))]
pub mod extension_pinned;