From 71585486e232064046a9ff31120923d0068ffadf Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Tue, 8 Sep 2026 22:44:33 +0800 Subject: [PATCH] =?UTF-8?q?test(sandbox):=20=E9=AA=8C=E8=AF=81=E5=8E=9F?= =?UTF-8?q?=E7=94=9F=20TCP=20=E4=B8=8E=20UDP=20=E5=9B=9E=E7=8E=AF=E9=9A=94?= =?UTF-8?q?=E7=A6=BB?= 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 | 125 ++++++++++++++++-- .../tests/fixtures/sandbox_network_probe.rs | 27 ++++ 3 files changed, 153 insertions(+), 8 deletions(-) create mode 100644 frontend/src-tauri/tests/fixtures/sandbox_network_probe.rs diff --git a/docs/development/OpenNexus生产化实施进度-2026-09-08.md b/docs/development/OpenNexus生产化实施进度-2026-09-08.md index 0bfe589..a92cb26 100644 --- a/docs/development/OpenNexus生产化实施进度-2026-09-08.md +++ b/docs/development/OpenNexus生产化实施进度-2026-09-08.md @@ -340,3 +340,12 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写 - 首轮使用 type 输出到 nul 的测试返回非零,改用 set /p 从文件重定向读取后验证成功;没有改变生产权限掩码来迁就该测试。系统 cmd.exe 仅用于这些固定测试命令,生产扩展入口仍禁止 shell 拼接。 - API 依据 [Microsoft SetSecurityInfo 文档](https://learn.microsoft.com/en-us/windows/win32/api/aclapi/nf-aclapi-setsecurityinfo)。当前未完成生产启动器、包树授权编排、scratch 配额、网络/文件 broker 和恶意程序矩阵;extensions capability 仍关闭,整体生产化目标继续进行。 - 本轮完整回归:cargo test --features desktop 共 98 项通过、5 项 ignored;其中 4 项为父测试实际驱动的 Job/Sync 辅助进程入口,另一项是需要单独执行的打包 Core 20 次冷启动,本轮没有重跑该项。真实 Core/Stronghold、笔记桥接、凭据所有权和 Sync 中断恢复集成全部通过。前端全量 101 个文件、525 项通过;Rust 全目标 Clippy -D warnings 通过。日志分别为 `.build/host-production-full-tests.log`、`.build/frontend-production-full-tests.log`、`.build/host-production-clippy.log`。全库 diff 检查提示用户 Vault 文件已有尾部空行,未修改用户内容;本次代码与文档单独检查通过。 + + +## 增量:AppContainer 环回网络行为验收夹具 + +- 新增独立原生 Rust 网络探针,由测试编译到独立临时包,按句柄授予容器读取执行权限。直接以绝对入口创建挂起进程,检查容器身份、精确 SID、零能力并加入 Job 后执行;不经 shell。探针仅属于测试夹具,不参与生产扩展启动。 +- 在 Windows 11 24H2(10.0.26100.4061)、Rust 1.98.1 x86_64-pc-windows-gnu 上,分别对 IPv4 127.0.0.1 和 IPv6 ::1 的动态 TCP/UDP 监听端验证。相同探针在容器外、隔离尝试前后均成功访问;容器内 TCP 在 2 秒连接期限内超时,未被监听端接受;UDP send_to 返回成功,但监听端在进程结束后额外观察 200 ms 仍无数据。测试不把 UDP send 成功等同于报文送达。 +- 初次要求 Winsock 10013 的断言失败;进一步探测明确为 TCP 超时,并分别保留超时、访问拒绝、其他错误退出码。当前断言检查允许的网络失败类型和监听端无接收,并排除加载失败、参数错误、控制端不可用。Microsoft 文档说明环回隔离可能表现为丢包:[Windows 防火墙 UWP 连接排查](https://learn.microsoft.com/zh-cn/windows/security/operating-system-security/network-security/windows-firewall/troubleshooting-uwp-firewall)。此次未捕获 WFP 事件,不能将超时唯一归因到某条过滤规则。 +- 全部 5 项 AppContainer 测试和全目标 Clippy -D warnings 通过,日志 `.build/extension-container-network-tests.log` 与 `.build/extension-container-network-clippy.log`。测试没有增加 loopback exemption 或修改系统防火墙设置。 +- 这只是当前机器上 TCP/UDP 环回行为的有限时间观察,不覆盖公网、私网实际服务、DNS、原始 socket、跨机器或最低 OS 版本,也不能据此通过完整 C-01/C-03。生产启动器、受限 broker、scratch 配额等仍待完成,整体目标继续进行。 diff --git a/frontend/src-tauri/src/extension_container.rs b/frontend/src-tauri/src/extension_container.rs index 0f2b6a2..587e792 100644 --- a/frontend/src-tauri/src/extension_container.rs +++ b/frontend/src-tauri/src/extension_container.rs @@ -304,6 +304,20 @@ mod tests { // Only tests use cmd.exe, with fixed commands and controlled temporary paths. // A production extension launcher must use a verified entry, never a shell. fn checked_process(profile: &Profile, command: Option<&str>) -> Option { + let executable = std::path::PathBuf::from(std::env::var_os("SystemRoot").unwrap()) + .join("System32/cmd.exe"); + checked_executable( + profile, + &executable, + command.map(|c| format!("cmd.exe /d /c {c}")), + ) + } + + fn checked_executable( + profile: &Profile, + executable: &std::path::Path, + command: Option, + ) -> Option { let mut attributes = Attributes::new(); let caps = SECURITY_CAPABILITIES { AppContainerSid: profile.sid(), @@ -328,8 +342,6 @@ mod tests { let mut startup = STARTUPINFOEXW::default(); startup.StartupInfo.cb = size_of::() as u32; startup.lpAttributeList = attributes.buffer.as_mut_ptr().cast(); - let executable = std::path::PathBuf::from(std::env::var_os("SystemRoot").unwrap()) - .join("System32/cmd.exe"); let executable: Vec = executable .as_os_str() .encode_wide() @@ -346,12 +358,8 @@ mod tests { .encode_utf16() .collect(); let mut command_line: Vec = command - .map(|command| { - format!("cmd.exe /d /c {command}") - .encode_utf16() - .chain(Some(0)) - .collect() - }) + .as_ref() + .map(|command| command.encode_utf16().chain(Some(0)).collect()) .unwrap_or_default(); let mut info = PROCESS_INFORMATION::default(); assert_ne!( @@ -581,4 +589,105 @@ mod tests { drop(handle); profile.remove().unwrap(); } + #[test] + fn real_container_cannot_reach_ipv4_or_ipv6_loopback_listeners() { + use std::{ + net::{TcpListener, UdpSocket}, + os::windows::fs::OpenOptionsExt, + }; + use windows_sys::Win32::Storage::FileSystem::*; + let profile = Profile::create().unwrap(); + let package = tempfile::tempdir().unwrap(); + let executable = package.path().join("network-probe.exe"); + let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/sandbox_network_probe.rs"); + let compile = std::process::Command::new("rustc") + .arg("--edition=2021") + .arg(&fixture) + .arg("-o") + .arg(&executable) + .output() + .unwrap(); + assert!( + compile.status.success(), + "{}", + String::from_utf8_lossy(&compile.stderr) + ); + let open = |path: &std::path::Path| { + std::fs::OpenOptions::new() + .access_mode(READ_CONTROL | WRITE_DAC) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) + .open(path) + .unwrap() + }; + let root = open(package.path()); + let entry = open(&executable); + profile.grant_package_read_execute(&root).unwrap(); + profile.grant_package_read_execute(&entry).unwrap(); + for ip in ["127.0.0.1:0", "[::1]:0"] { + let tcp = TcpListener::bind(ip).unwrap(); + let udp = UdpSocket::bind(ip).unwrap(); + for (mode, address) in [ + ("tcp", tcp.local_addr().unwrap()), + ("udp", udp.local_addr().unwrap()), + ] { + let address = address.to_string(); + // The exact executable and target work outside containment. + assert!(std::process::Command::new(&executable) + .args([mode, &address]) + .status() + .unwrap() + .success()); + if mode == "tcp" { + tcp.set_nonblocking(true).unwrap(); + tcp.accept().unwrap(); + } else { + udp.set_read_timeout(Some(std::time::Duration::from_secs(2))) + .unwrap(); + let mut bytes = [0u8; 8]; + assert_eq!(udp.recv(&mut bytes).unwrap(), 5); + assert_eq!(&bytes[..5], b"probe"); + udp.set_nonblocking(true).unwrap(); + } + let command = format!("\"{}\" {mode} {address}", executable.display()); + // Loopback isolation can silently drop packets. TCP must + // explicitly report denial or timeout; UDP send may succeed, + // but no datagram may reach the controlled listener below. + let exit = checked_executable(&profile, &executable, Some(command)); + eprintln!("container network probe {mode} {address}: {exit:?}"); + if mode == "tcp" { + assert!(matches!(exit, Some(77 | 80)), "{mode} {address}: {exit:?}"); + } else { + assert!(matches!(exit, Some(0 | 77)), "{mode} {address}: {exit:?}"); + std::thread::sleep(std::time::Duration::from_millis(200)); + } + if mode == "tcp" { + assert_eq!( + tcp.accept().unwrap_err().kind(), + std::io::ErrorKind::WouldBlock + ); + } else { + assert_eq!( + udp.recv(&mut [0u8; 8]).unwrap_err().kind(), + std::io::ErrorKind::WouldBlock + ); + } + assert!(std::process::Command::new(&executable) + .args([mode, &address]) + .status() + .unwrap() + .success()); + if mode == "tcp" { + tcp.accept().unwrap(); + } else { + udp.set_nonblocking(false).unwrap(); + assert_eq!(udp.recv(&mut [0u8; 8]).unwrap(), 5); + } + } + } + drop(entry); + drop(root); + profile.remove().unwrap(); + } } diff --git a/frontend/src-tauri/tests/fixtures/sandbox_network_probe.rs b/frontend/src-tauri/tests/fixtures/sandbox_network_probe.rs new file mode 100644 index 0000000..0ba4668 --- /dev/null +++ b/frontend/src-tauri/tests/fixtures/sandbox_network_probe.rs @@ -0,0 +1,27 @@ +//! Standalone native test probe; never shipped or used to launch extensions. +use std::net::{SocketAddr, TcpStream, UdpSocket}; +use std::time::Duration; +fn main() { + let args: Vec<_> = std::env::args().collect(); + if args.len() != 3 { + std::process::exit(79); + } + let address: SocketAddr = args[2].parse().unwrap(); + let result = match args[1].as_str() { + "tcp" => TcpStream::connect_timeout(&address, Duration::from_secs(2)).map(|_| ()), + "udp" => UdpSocket::bind(if address.is_ipv4() { + "0.0.0.0:0" + } else { + "[::]:0" + }) + .and_then(|socket| socket.send_to(b"probe", address)) + .map(|_| ()), + _ => std::process::exit(79), + }; + std::process::exit(match result { + Ok(()) => 0, + Err(error) if error.raw_os_error() == Some(10013) => 77, + Err(error) if error.kind() == std::io::ErrorKind::TimedOut => 80, + Err(_) => 81, + }); +}