Skip to content
23 changes: 21 additions & 2 deletions docs/architecture/managed-dependency-storage-authority-v1.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ base: upstream/main@08bcf324b

同一个 canonical storage root 在同一时刻只能存在一个该 authority:同进程由 module-private owner claim 拒绝重复实例,跨进程由稳定 lock artifact 上的 OS exclusive lock 拒绝第二个 owner。lock 在 receipt database 或 artifact 被读取、修复、发布、租用、GC 之前取得,只在 authority 完整关闭或初始化失败时释放;active lease 导致的 close 拒绝不会释放 owner。

本 PR 不包含:
最初的 storage authority PR 不包含以下内容;producer 基础层的后续交付状态见第 7 节:

- bundled npm 的实现与网络策略;
- `node_modules/.bin` symlink 的 producer 配额扫描;
Expand Down Expand Up @@ -110,7 +110,8 @@ Windows 上只读 dependency file 是合法输入。authority 只在自己的 un

| 场景 | 唯一合法结果 |
| ------------------------------------ | -------------------------------------- |
| producer 中断 | staging 清理,无 artifact/receipt |
| producer 中断且已确认进程树退出 | staging 清理,无 artifact/receipt |
| producer 进程树退出无法确认 | 保留 staging,不发布、不进入普通失败清理 |
| 完整 tree durable seal 前中断 | 无 receipt;重启清理 staging/orphan |
| tree seal 完成、artifact rename 前中断 | 无 receipt;重启清理 staging/orphan |
| artifact rename 后进程退出 | 重启删除无 receipt artifact,再物化 |
Expand Down Expand Up @@ -165,3 +166,21 @@ Windows 上只读 dependency file 是合法输入。authority 只在自己的 un
PR 1–3 只是堆叠地基;PR 4 合并前不能把 M1.3 描述为用户可用。PR 2 不得通过拒绝所有 POSIX symlink 规避 `.bin`:producer inventory 应计量合法 link,最终 storage authority 与 worker 仍负责 target containment;Windows 继续拒绝 reparse point。

验证时必须执行 storage build、focused authority tests、真实 Windows ADS test,以及三个 child-process crash failpoint。后续 PR 不得通过放宽本 authority 的 fail-closed 规则来提高采用率。

## 7. Producer 基础层交付状态(Refs #4326)

Runtime Host 内部的 `managed-dependency-producer.ts` 已提供 Windows/Linux 离线 npm producer runner;这补齐上文 PR 2 的执行基础层,不改变 PR 1 的 storage authority 接口,也不启用 Desktop 的生产 consumer。macOS 在准备 staging 之前返回 `unsupported`。

调用方显式提供 Node、npm CLI、supervisor 的绝对路径、manifest/lockfile bytes 和独占 staging。Windows 复用 Rust launcher 的原子 Job 绑定、禁止 breakaway、owner 控制管道 EOF 回收及 empty-Job 检查;Linux 使用 bubblewrap PID namespace,PID 1 退出触发内核回收后代,并核对 bubblewrap 的最终退出报告。取消、超时与正常结束都必须经过同一退出确认边界;发出终止请求不构成证明。缺失监管能力不降级为直接启动 npm。

runner 返回 `completed`、`failed` 或 `unsettled`。前两者证明进程已退出,调用方才能发布或清理;`unsettled` 保留 staging,不得转换为现有 `provision()` 的普通 rejection,否则 authority 会提前删除 staging。生产 adapter 的 quarantine/Host 生命周期接线仍待 PR 4。当前 storage 交接测试只覆盖已确认退出的结果;没有安装生产 capability 或 consumer。

固定执行 `npm ci --offline --ignore-scripts --no-audit --no-fund`,以预填充缓存测试 registry-shaped lockfile v3,使用 staging 内的配置、缓存和临时目录,清空继承环境中的 npm token、代理和用户配置。Windows 的私有 `USERPROFILE` 相对固定 staging cwd 解析,避免 libuv 深路径 home 查询溢出;配置、缓存和临时路径保持绝对路径。Windows ACL 检查和 icacls 操作支持 npm 缓存长路径,仍拒绝 hardlink/reparse point。测试 Node 必须是独立普通文件,不能直接使用 npm `node` 包创建的 hardlink。

默认期限 10 分钟、逻辑字节 2 GiB、条目 250,000(包含 root、scratch 和复制的缓存);测试可传入较小值。每次扫描完成 250 ms 后调度下一次扫描,确保不重叠;确认退出后追加最终扫描。扫描只检测超限,不预留磁盘、不保证瞬时使用绝不超限。Linux 不跟随 symlink,计量 link 文本并允许 npm `.bin`;最终 target containment 仍由 storage authority 校验。输出持续排空,仅保留 64 KiB,截断时丢弃末尾不完整行,返回脱敏后的最多 4,096 字符诊断。

Linux 当前只读挂载 host filesystem 并隔离网络;这不是完整的未来 hermetic filesystem policy。Bundled runtime 身份与供应链、在线 registry 获取策略、打包发布、完整 Host 生命周期及 Desktop 功能均未交付。不得据此关闭 #4326 或宣称 M1.3 已可用。

验证入口:先构建 core/storage/runtime/runtime-host,在真实 Windows 上构建 `experiments/windows-sandbox/launcher`;Linux 安装支持 PID/user/network namespace 的 bubblewrap。设置 `MAKA_PRODUCER_SUPERVISOR` 与 `MAKA_TEST_NPM_CLI` 为绝对路径,运行 `node --test scripts/managed-dependency-producer.integration.test.mjs`。未设置环境变量的 native 测试会跳过,不能计为平台通过。对应 unit tests 位于 runtime-host 的 `managed-dependency-producer.test.ts`,现有 authority/crash 测试继续验证 receipt、清理和 Windows ADS。

Windows 进程树证据分两层记录:AppContainer 集成 fixture 在当前环境拒绝普通后代创建;`windows_job_tests.rs` 则在真实原子绑定的 Job 中明确启动后代,关闭全部 stdio 后持续写入。后者调用生产使用的 `settle_job`,验证主进程先退出和取消时都必须获得空 Job,再确认没有后续写入;还在无 AppContainer 的条件下验证 breakaway 被 Job 拒绝。它验证 Job 监管边界,不宣称已经证明 AppContainer 允许后代,也没有为生产增加绕过 AppContainer 的路径。
7 changes: 5 additions & 2 deletions docs/windows-test-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ Locations intentionally omit line numbers so unrelated edits do not invalidate t

| Classification | Count |
|---|---:|
| windows-backend-gap | 27 |
| windows-backend-gap | 30 |
| portable-candidate | 32 |
| platform-contract | 36 |

Total Windows-excluded declarations: **95**
Total Windows-excluded declarations: **98**

## Inventory

Expand Down Expand Up @@ -60,6 +60,9 @@ Total Windows-excluded declarations: **95**
| platform-contract | `packages/runtime-host/src/__tests__/host-kernel.test.ts` publishes private POSIX endpoint and registration permissions | `process.platform === 'win32'` |
| windows-backend-gap | `packages/runtime-host/src/__tests__/managed-activation.test.ts` two real managed activations converge on one Host and exit at true idle | `process.platform === 'win32' ? 'requires a POSIX package-entrypoint symlink' : false` |
| windows-backend-gap | `packages/runtime-host/src/__tests__/memory-two-client-uds.test.ts` two UDS clients share one recoverable Memory authority across Host death | `process.platform === 'win32' ? 'POSIX process death gate' : false` |
| windows-backend-gap | `packages/runtime-host/src/__tests__/oauth-execution-authority.test.ts` reconciles a published OAuth lease claim before the next demand | `process.platform === 'win32' ? 'POSIX directory-sync publication failure only' : false` |
| windows-backend-gap | `packages/runtime-host/src/__tests__/oauth-execution-authority.test.ts` reconciles a published OAuth refresh finalization before the next demand | `process.platform === 'win32' ? 'POSIX directory-sync publication failure only' : false` |
| windows-backend-gap | `packages/runtime-host/src/__tests__/oauth-execution-authority.test.ts` reconciles a published OAuth lease release before retrying refresh | `process.platform === 'win32' ? 'POSIX directory-sync publication failure only' : false` |
| windows-backend-gap | `packages/runtime-host/src/__tests__/project-catalog-two-client-uds.test.ts` two UDS clients converge on one Host-owned Project Catalog | `process.platform === 'win32'` |
| windows-backend-gap | `packages/runtime-host/src/__tests__/runtime-policy-coordinator.test.ts` invalidates when a real published mutation loses its commit reply | `process.platform === 'win32'` |
| windows-backend-gap | `packages/runtime-host/src/__tests__/runtime-resource-process.test.ts` real Host Runtime Resource process lifecycle | `process.platform === 'win32'` |
Expand Down
76 changes: 40 additions & 36 deletions experiments/windows-sandbox/launcher/src/acl_ledger.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,16 @@ use std::fs::{self, OpenOptions};
use std::io::Write;
use std::iter;
use std::os::windows::ffi::OsStrExt;
use std::os::windows::fs::MetadataExt;
use std::os::windows::fs::{MetadataExt, OpenOptionsExt};
use std::os::windows::io::AsRawHandle;
use std::path::{Path, PathBuf};
use std::process::Command;

use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use windows_sys::Win32::Foundation::{
CloseHandle, ERROR_ALREADY_EXISTS, GetLastError, HANDLE, INVALID_HANDLE_VALUE, LocalFree,
WAIT_ABANDONED, WAIT_OBJECT_0, WAIT_TIMEOUT,
CloseHandle, ERROR_ALREADY_EXISTS, GetLastError, HANDLE, LocalFree, WAIT_ABANDONED,
WAIT_OBJECT_0, WAIT_TIMEOUT,
};
use windows_sys::Win32::Security::Authorization::{
ConvertStringSecurityDescriptorToSecurityDescriptorW, GetSecurityInfo, SDDL_REVISION_1,
Expand All @@ -41,9 +42,9 @@ use windows_sys::Win32::Security::{
OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES,
};
use windows_sys::Win32::Storage::FileSystem::{
BY_HANDLE_FILE_INFORMATION, CreateFileW, FILE_ATTRIBUTE_REPARSE_POINT,
FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE, FILE_SHARE_READ,
FILE_SHARE_WRITE, GetFileInformationByHandle, OPEN_EXISTING,
BY_HANDLE_FILE_INFORMATION, FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_BACKUP_SEMANTICS,
FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE,
GetFileInformationByHandle,
};
use windows_sys::Win32::System::Threading::{CreateMutexW, ReleaseMutex, WaitForSingleObject};

Expand Down Expand Up @@ -270,7 +271,7 @@ impl LedgerLock {
let descriptor = lock_security_descriptor(&sddl)?;
let mut attributes: SECURITY_ATTRIBUTES = unsafe { std::mem::zeroed() };
attributes.nLength = size_of::<SECURITY_ATTRIBUTES>() as u32;
attributes.lpSecurityDescriptor = descriptor as *mut std::ffi::c_void;
attributes.lpSecurityDescriptor = descriptor;
attributes.bInheritHandle = 0;
let name = wide(name);
let handle = unsafe { CreateMutexW(&attributes, 0, name.as_ptr()) };
Expand All @@ -281,15 +282,13 @@ impl LedgerLock {
} else {
None
};
unsafe { LocalFree(descriptor as *mut std::ffi::c_void) };
unsafe { LocalFree(descriptor) };
if let Some(error) = create_error {
return Err(error);
}
if already_exists {
if let Err(error) = validate_existing_lock_owner(handle, user_sid) {
unsafe { CloseHandle(handle) };
return Err(error);
}
if already_exists && let Err(error) = validate_existing_lock_owner(handle, user_sid) {
unsafe { CloseHandle(handle) };
return Err(error);
}
let wait = unsafe { WaitForSingleObject(handle, timeout_ms) };
if wait == WAIT_OBJECT_0 || wait == WAIT_ABANDONED {
Expand Down Expand Up @@ -353,7 +352,7 @@ fn validate_existing_lock_owner(handle: HANDLE, user_sid: &str) -> Result<(), St
));
}
let rendered = unsafe { sid_string(owner) };
unsafe { LocalFree(descriptor as *mut std::ffi::c_void) };
unsafe { LocalFree(descriptor) };
let rendered = rendered?;
if rendered.eq_ignore_ascii_case(user_sid)
|| rendered == SYSTEM_SID
Expand Down Expand Up @@ -485,27 +484,17 @@ fn reject_aliased_entries(path: &Path) -> Result<fs::Metadata, String> {
/// grant mutates belongs to the file object shared by every hard link, so a
/// path-keyed admission that only sees one alias must not grant through it.
fn reject_multi_link_file(path: &Path) -> Result<(), String> {
let wide_path = wide(&path.to_string_lossy());
let handle = unsafe {
CreateFileW(
wide_path.as_ptr(),
0,
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
std::ptr::null(),
OPEN_EXISTING,
FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT,
std::ptr::null_mut(),
)
};
if handle == INVALID_HANDLE_VALUE {
return Err(last_error(&format!(
"CreateFileW(inspect ACL root {})",
path.display()
)));
}
// std applies Windows verbatim-path handling for long npm cache paths.
// Keep the same metadata-only access and non-following open flags; do not
// canonicalize through a reparse point to make a long path readable.
let file = OpenOptions::new()
.access_mode(0)
.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE)
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
.open(path)
.map_err(|error| format!("open ACL root {} failed: {error}", path.display()))?;
let mut information: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() };
let queried = unsafe { GetFileInformationByHandle(handle, &mut information) };
unsafe { CloseHandle(handle) };
let queried = unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut information) };
if queried == 0 {
return Err(last_error(&format!(
"GetFileInformationByHandle(ACL root {})",
Expand Down Expand Up @@ -601,12 +590,13 @@ pub(crate) fn ledger_lease_name(user_sid: &str, request_id: &str) -> String {
}

fn grant(path: &str, sid: &str, access: &str, recursive: bool) -> Result<(), String> {
let path = verbatim_acl_path(path);
let permission = if recursive {
format!("*{sid}:(OI)(CI){access}")
} else {
format!("*{sid}:{access}")
};
let mut args = vec![path, "/grant", &permission, "/L", "/Q"];
let mut args = vec![path.as_str(), "/grant", &permission, "/L", "/Q"];
if recursive {
args.push("/T");
}
Expand Down Expand Up @@ -638,14 +628,28 @@ fn remove_grants(ledger: &Ledger) -> Result<(), String> {
}

fn remove_sid_grants(path: &str, sid: &str, recursive: bool) -> Result<(), String> {
let path = verbatim_acl_path(path);
let principal = format!("*{sid}");
let mut args = vec![path, "/remove", &principal, "/L", "/Q"];
let mut args = vec![path.as_str(), "/remove", &principal, "/L", "/Q"];
if recursive {
args.push("/T");
}
run_icacls(&args, "remove")
}

// Prefix only: canonicalizing here could follow a reparse point between
// admission and grant. icacls /L and the alias admission checks stay intact.
fn verbatim_acl_path(path: &str) -> String {
let path = path.replace('/', "\\");
if path.starts_with(r"\\?\") {
path
} else if let Some(unc) = path.strip_prefix(r"\\") {
format!(r"\\?\UNC\{unc}")
} else {
format!(r"\\?\{path}")
}
}

/// Preserve an unreadable ledger for inspection instead of letting one corrupt
/// file permanently block every future sandbox launch. The rename keeps the
/// evidence (`*.json.quarantined` no longer matches the recovery scan) and the
Expand Down
22 changes: 22 additions & 0 deletions experiments/windows-sandbox/launcher/src/acl_ledger_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,28 @@ mod tests {
}
}

#[test]
fn long_cache_paths_keep_the_hard_link_admission_check() {
let fixture = Fixture::new("long-cache-path");
let mut directory = fixture.target.clone();
while directory.as_os_str().len() < 320 {
directory = directory.join("nested-npm-cache-component");
}
fs::create_dir_all(&directory).expect("create long directory");
let payload = directory.join("payload");
fs::write(&payload, "cache bytes").expect("write long-path payload");
let request = launch_request(
vec![fixture.target_str()],
Vec::new(),
Vec::new(),
Vec::new(),
);
collect_roots(&request).expect("single-link long path admits");
fs::hard_link(&payload, fixture.target.join("alias")).expect("create long-path hard link");
let error = collect_roots(&request).expect_err("long paths still reject hard links");
assert!(error.contains("multi-link"), "{error}");
}

#[test]
fn multi_link_file_root_fails_closed() {
let fixture = Fixture::new("hardlink-root");
Expand Down
58 changes: 29 additions & 29 deletions experiments/windows-sandbox/launcher/src/broker_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,35 +190,6 @@ pub fn run(pipe_name: &str, manifest_path: &str) -> Result<u8, String> {
}
}

#[cfg(test)]
mod tests {
use super::{parent_process_id, validate_owner_process};
use windows_sys::Win32::Foundation::CloseHandle;
use windows_sys::Win32::Storage::FileSystem::SYNCHRONIZE;
use windows_sys::Win32::System::Threading::{
GetCurrentProcessId, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
};

#[test]
fn resolves_a_real_parent_process_without_self_binding() {
let parent = parent_process_id().expect("test harness parent process");
let current = unsafe { GetCurrentProcessId() };
assert_ne!(parent, 0);
assert_ne!(parent, current);
}

#[test]
fn validates_a_real_parent_process_before_waiting_on_it() {
let parent = parent_process_id().expect("test harness parent process");
let handle =
unsafe { OpenProcess(SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, 0, parent) };
assert!(!handle.is_null(), "OpenProcess(test harness parent)");
let result = validate_owner_process(handle, parent);
unsafe { CloseHandle(handle) };
result.expect("parent process identity must be pinned before waiting");
}
}

fn connect(pipe_name: &str) -> Result<HANDLE, String> {
let name = wide(pipe_name);
let deadline = Instant::now() + Duration::from_secs(10);
Expand Down Expand Up @@ -317,3 +288,32 @@ fn wide(value: &str) -> Vec<u16> {
fn last_error(operation: &str) -> String {
format!("{operation} failed: {}", std::io::Error::last_os_error())
}

#[cfg(test)]
mod tests {
use super::{parent_process_id, validate_owner_process};
use windows_sys::Win32::Foundation::CloseHandle;
use windows_sys::Win32::Storage::FileSystem::SYNCHRONIZE;
use windows_sys::Win32::System::Threading::{
GetCurrentProcessId, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
};

#[test]
fn resolves_a_real_parent_process_without_self_binding() {
let parent = parent_process_id().expect("test harness parent process");
let current = unsafe { GetCurrentProcessId() };
assert_ne!(parent, 0);
assert_ne!(parent, current);
}

#[test]
fn validates_a_real_parent_process_before_waiting_on_it() {
let parent = parent_process_id().expect("test harness parent process");
let handle =
unsafe { OpenProcess(SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, 0, parent) };
assert!(!handle.is_null(), "OpenProcess(test harness parent)");
let result = validate_owner_process(handle, parent);
unsafe { CloseHandle(handle) };
result.expect("parent process identity must be pinned before waiting");
}
}
4 changes: 2 additions & 2 deletions experiments/windows-sandbox/launcher/src/broker_pipe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ unsafe fn serve_once_with_security(
}
let mut attributes: SECURITY_ATTRIBUTES = unsafe { zeroed() };
attributes.nLength = size_of::<SECURITY_ATTRIBUTES>() as u32;
attributes.lpSecurityDescriptor = descriptor as *mut c_void;
attributes.lpSecurityDescriptor = descriptor;
attributes.bInheritHandle = 0;
let pipe_name_wide = wide(pipe_name);
let pipe = unsafe {
Expand All @@ -91,7 +91,7 @@ unsafe fn serve_once_with_security(
&attributes,
)
};
unsafe { LocalFree(descriptor as *mut c_void) };
unsafe { LocalFree(descriptor) };
if pipe == INVALID_HANDLE_VALUE {
return Err(last_error("CreateNamedPipeW"));
}
Expand Down
Loading