From dd92b15b9b3da8f29bfa76f8e8eded5ecf7df3e0 Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Tue, 8 Sep 2026 22:51:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(sandbox):=20=E7=BC=96=E7=A0=81=E5=8E=9F?= =?UTF-8?q?=E7=94=9F=E5=91=BD=E4=BB=A4=E8=A1=8C=E5=8F=82=E6=95=B0=E5=B9=B6?= =?UTF-8?q?=E6=98=8E=E7=A1=AE=E9=9A=94=E7=A6=BB=E7=8E=AF=E5=A2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OpenNexus生产化实施进度-2026-09-08.md | 10 + frontend/src-tauri/src/extension_container.rs | 66 ++++- .../src-tauri/src/extension_launch_data.rs | 227 ++++++++++++++++++ frontend/src-tauri/src/lib.rs | 3 + .../tests/fixtures/sandbox_network_probe.rs | 31 +++ 5 files changed, 330 insertions(+), 7 deletions(-) create mode 100644 frontend/src-tauri/src/extension_launch_data.rs diff --git a/docs/development/OpenNexus生产化实施进度-2026-09-08.md b/docs/development/OpenNexus生产化实施进度-2026-09-08.md index a92cb26..2e151b0 100644 --- a/docs/development/OpenNexus生产化实施进度-2026-09-08.md +++ b/docs/development/OpenNexus生产化实施进度-2026-09-08.md @@ -349,3 +349,13 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写 - 初次要求 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 配额等仍待完成,整体目标继续进行。 + + +## 增量:原生启动参数与隔离环境构造 + +- 新增 Windows LaunchData,为明确的绝对 .exe 入口编码 CRT 参数数组,正确处理空参数、空格、引号前反斜杠和末尾反斜杠;拒绝相对入口、非 .exe、路径引号/NUL、参数 NUL、数量与长度超限。入口仍须由调用方校验并持有,构造器本身不验证包、许可或沙箱能力,也不允许据此执行 shell 配置。 +- 环境块只使用 Host 提供的 SystemRoot、容器 LOCALAPPDATA、scratch TEMP/TMP 和明确声明/解析的变量,不读取父环境。变量名限 ASCII、大小写归一排序,拒绝重复和覆盖保留变量、非法名称与 NUL。原生路径使用 UTF-16 保留未配对代理项,避免 display() 有损转换。环境块含双 NUL,并要求调用方使用 CREATE_UNICODE_ENVIRONMENT。 +- 参数与环境均限制在 32767 个 UTF-16 单元内,预分配容量并在加入内容前检查界限,避免构造过程因扩容留下额外值副本;Drop 清零两个缓冲区。此清理不替代调用方对已解析凭据的生命周期和清理责任。 +- 真实 AppContainer 探针使用该构造器启动,比较完整 argv 和完整环境变量集合:空串、空格、中文/emoji、引号/反斜杠、换行及 &|%PATH% 均原样收到,环境恰好为四个基础变量加 CUSTOM,未继承 PATH 等 Host 环境。测试也保留容器 SID/零能力/先挂起后入 Job 的核对。 +- 45 项扩展回归通过、3 项 Job 辅助入口 ignored(由父测试实际调用),全目标 Clippy -D warnings 通过。日志 `.build/extension-launch-regression-tests.log`、`.build/extension-launch-clippy.log`。API 依据 [Microsoft CRT 参数解析](https://learn.microsoft.com/en-us/cpp/c-language/parsing-c-command-line-arguments?view=msvc-170) 和 [CreateProcessW](https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessw)。尚未覆盖所有第三方运行时的自定义命令行解析。 +- 当前仍未开放第三方执行:正式启动器的许可/包句柄绑定、生产环境凭据解析、scratch 限额、broker 与完整端到端验收继续实施,不能据本轮参数测试认定生产化完成。 diff --git a/frontend/src-tauri/src/extension_container.rs b/frontend/src-tauri/src/extension_container.rs index 587e792..2ef9c13 100644 --- a/frontend/src-tauri/src/extension_container.rs +++ b/frontend/src-tauri/src/extension_container.rs @@ -318,6 +318,16 @@ mod tests { executable: &std::path::Path, command: Option, ) -> Option { + checked_executable_data(profile, executable, command, None) + } + + fn checked_executable_data( + profile: &Profile, + executable: &std::path::Path, + command: Option, + mut data: Option, + ) -> Option { + let resume = command.is_some() || data.is_some(); let mut attributes = Attributes::new(); let caps = SECURITY_CAPABILITIES { AppContainerSid: profile.sid(), @@ -361,16 +371,25 @@ mod tests { .as_ref() .map(|command| command.encode_utf16().chain(Some(0)).collect()) .unwrap_or_default(); + let environment_ptr = data + .as_ref() + .map_or(environment.as_ptr(), |d| d.environment().as_ptr()); + let command_ptr = data.as_mut().map_or_else( + || { + if command_line.is_empty() { + std::ptr::null_mut() + } else { + command_line.as_mut_ptr() + } + }, + |d| d.command_mut().as_mut_ptr(), + ); let mut info = PROCESS_INFORMATION::default(); assert_ne!( unsafe { CreateProcessW( executable.as_ptr(), - if command_line.is_empty() { - std::ptr::null_mut() - } else { - command_line.as_mut_ptr() - }, + command_ptr, std::ptr::null(), std::ptr::null(), 0, @@ -378,7 +397,7 @@ mod tests { | CREATE_NO_WINDOW | EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT, - environment.as_ptr().cast(), + environment_ptr.cast(), std::ptr::null(), &startup.StartupInfo, &mut info, @@ -475,7 +494,7 @@ mod tests { ); assert_eq!(unsafe { *capabilities.as_ptr().cast::() }, 0); - let exit = command.map(|_| { + let exit = resume.then(|| { assert_ne!( unsafe { ResumeThread(process._thread.as_raw_handle()) }, u32::MAX @@ -625,6 +644,39 @@ mod tests { let entry = open(&executable); 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 = [ + "launch", + "", + "space value", + "引号🦀", + "trailing\\", + "a\"b", + "slash\\\"quote", + "&|%PATH%", + "line\nbreak", + ] + .into_iter() + .map(str::to_owned) + .collect::>(); + let folder = profile.folder().unwrap(); + let system = std::path::PathBuf::from(std::env::var_os("SystemRoot").unwrap()); + let data = crate::extension_launch_data::LaunchData::new( + &executable, + &args, + &system, + &folder, + &folder.join("Temp"), + &[("CUSTOM".to_owned(), "declared=值".to_owned())] + .into_iter() + .collect(), + ) + .unwrap(); + assert_eq!( + checked_executable_data(&profile, &executable, None, Some(data)), + Some(0) + ); for ip in ["127.0.0.1:0", "[::1]:0"] { let tcp = TcpListener::bind(ip).unwrap(); let udp = UdpSocket::bind(ip).unwrap(); diff --git a/frontend/src-tauri/src/extension_launch_data.rs b/frontend/src-tauri/src/extension_launch_data.rs new file mode 100644 index 0000000..4e7b1e1 --- /dev/null +++ b/frontend/src-tauri/src/extension_launch_data.rs @@ -0,0 +1,227 @@ +//! Native argv/environment encoding. This does not authorize or launch a process. +use crate::workspace::{HostError, Result}; +use std::{collections::BTreeMap, os::windows::ffi::OsStrExt, path::Path}; +use zeroize::Zeroize; + +pub struct LaunchData { + command: Vec, + environment: Vec, +} +impl Drop for LaunchData { + fn drop(&mut self) { + self.command.zeroize(); + self.environment.zeroize(); + } +} +impl LaunchData { + /// The Host supplies verified absolute paths and explicitly declared/resolved + /// environment values. Never reads the parent environment. CRT argv rules + /// apply to native executables, not cmd.exe, batch files or shell interpreters. + pub fn new( + executable: &Path, + arguments: &[String], + system_root: &Path, + local_app_data: &Path, + scratch: &Path, + declared: &BTreeMap, + ) -> Result { + let bad = || HostError::new("EXTENSION_LAUNCH_DATA_INVALID"); + if !executable.is_absolute() + || !executable + .extension() + .is_some_and(|v| v.eq_ignore_ascii_case("exe")) + || arguments.len() > 128 + || declared.len() > 128 + { + return Err(bad()); + } + let executable: Vec = executable.as_os_str().encode_wide().collect(); + if executable.contains(&0) || executable.contains(&34) || executable.len() > 32764 { + return Err(bad()); + } + let mut result = Self { + command: Vec::with_capacity(32767), + environment: Vec::with_capacity(32767), + }; + result.command.push(34); + result.command.extend(executable); + result.command.push(34); + for argument in arguments { + if argument.len() > 8192 || argument.contains('\0') { + return Err(bad()); + } + // Reserve the full bounded buffers once: do not leave earlier + // copies of resolved values behind through Vec reallocations. + let mut encoded_len = 0; + let mut trailing = 0; + for unit in argument.encode_utf16() { + if unit == 92 { + trailing += 1; + } else { + encoded_len += if unit == 34 { + trailing * 2 + 2 + } else { + trailing + 1 + }; + trailing = 0; + } + } + encoded_len += trailing * 2; + if result.command.len() + 3 + encoded_len + 1 > 32767 { + return Err(bad()); + } + result.command.extend([32, 34]); + let mut slashes = 0; + for unit in argument.encode_utf16() { + if unit == 92 { + slashes += 1; + continue; + } + result.command.extend(std::iter::repeat_n( + 92, + if unit == 34 { slashes * 2 + 1 } else { slashes }, + )); + result.command.push(unit); + slashes = 0; + } + result.command.extend(std::iter::repeat_n(92, slashes * 2)); + result.command.push(34); + if result.command.len() >= 32767 { + return Err(bad()); + } + } + result.command.push(0); + if result.command.len() > 32767 { + return Err(bad()); + } + // ASCII names give a deterministic Windows case-insensitive order. + // Values remain borrowed until encoded so there are no secret clones. + let mut fields: BTreeMap = BTreeMap::new(); + for (name, path) in [ + ("SYSTEMROOT", system_root), + ("LOCALAPPDATA", local_app_data), + ("TEMP", scratch), + ("TMP", scratch), + ] { + if !path.is_absolute() { + return Err(bad()); + } + fields.insert(name.to_owned(), path.as_os_str()); + } + for (name, value) in declared { + if name.is_empty() + || name.len() > 128 + || !name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') + || value.len() > 8192 + { + return Err(bad()); + } + if fields + .insert(name.to_ascii_uppercase(), value.as_ref()) + .is_some() + { + return Err(bad()); + } + } + for (name, value) in fields { + if result.environment.len() + name.len() + value.encode_wide().count() + 3 > 32767 { + return Err(bad()); + } + result.environment.extend(name.encode_utf16()); + result.environment.push(61); + for unit in value.encode_wide() { + if unit == 0 { + return Err(bad()); + } + result.environment.push(unit); + } + result.environment.push(0); + if result.environment.len() >= 32767 { + return Err(bad()); + } + } + result.environment.push(0); + Ok(result) + } + pub fn command_mut(&mut self) -> &mut [u16] { + &mut self.command + } + /// Pass with CREATE_UNICODE_ENVIRONMENT; never substitute a null pointer. + pub fn environment(&self) -> &[u16] { + &self.environment + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn build(args: &[String], vars: &BTreeMap) -> Result { + LaunchData::new( + Path::new(r"C:\package\probe.exe"), + args, + Path::new(r"C:\Windows"), + Path::new(r"C:\container"), + Path::new(r"C:\scratch"), + vars, + ) + } + #[test] + fn rejects_ambiguous_environment_names_reserved_overrides_nul_and_limits() { + for fields in [ + vec![("temp", "x")], + vec![("a", "1"), ("A", "2")], + vec![("=C:", "x")], + vec![("SAFE", "x\0y")], + ] { + assert!(build( + &[], + &fields + .into_iter() + .map(|(k, v)| (k.into(), v.into())) + .collect() + ) + .is_err()); + } + assert!(build(&["x\0y".into()], &BTreeMap::new()).is_err()); + assert!(build(&vec!["x".repeat(8192); 4], &BTreeMap::new()).is_err()); + assert!(build( + &[], + &(0..4) + .map(|i| (format!("V{i}"), "x".repeat(8192))) + .collect() + ) + .is_err()); + assert!(build(&vec![String::new(); 129], &BTreeMap::new()).is_err()); + } + #[test] + fn refuses_relative_or_non_native_entry_and_preserves_wide_paths() { + for entry in [ + r"relative.exe", + r"C:\package\entry.cmd", + "C:\\bad\"name.exe", + ] { + assert!(LaunchData::new( + Path::new(entry), + &[], + Path::new(r"C:\Windows"), + Path::new(r"C:\container"), + Path::new(r"C:\scratch"), + &BTreeMap::new() + ) + .is_err()); + } + use std::os::windows::ffi::OsStringExt; + let wide = std::ffi::OsString::from_wide(&[67, 58, 92, 0xd800]); + let data = LaunchData::new( + Path::new(r"C:\probe.exe"), + &[], + Path::new(r"C:\Windows"), + Path::new(&wide), + Path::new(&wide), + &BTreeMap::new(), + ) + .unwrap(); + assert!(data.environment().contains(&0xd800)); + assert!(data.environment().ends_with(&[0, 0])); + } +} diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index fe9fcc1..a7b8663 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -57,3 +57,6 @@ pub mod extension_job; #[cfg(windows)] pub mod extension_container; + +#[cfg(windows)] +pub mod extension_launch_data; diff --git a/frontend/src-tauri/tests/fixtures/sandbox_network_probe.rs b/frontend/src-tauri/tests/fixtures/sandbox_network_probe.rs index 0ba4668..3022d5c 100644 --- a/frontend/src-tauri/tests/fixtures/sandbox_network_probe.rs +++ b/frontend/src-tauri/tests/fixtures/sandbox_network_probe.rs @@ -3,6 +3,37 @@ use std::net::{SocketAddr, TcpStream, UdpSocket}; use std::time::Duration; fn main() { let args: Vec<_> = std::env::args().collect(); + if args.get(1).is_some_and(|s| s == "launch") { + let expected = [ + "launch", + "", + "space value", + "引号🦀", + "trailing\\", + "a\"b", + "slash\\\"quote", + "&|%PATH%", + "line\nbreak", + ]; + if args[1..] != expected { + std::process::exit(82); + } + let environment: std::collections::BTreeMap<_, _> = std::env::vars_os().collect(); + let names: std::collections::BTreeSet<_> = + environment.keys().map(|k| k.to_str().unwrap()).collect(); + if names + != ["CUSTOM", "LOCALAPPDATA", "SYSTEMROOT", "TEMP", "TMP"] + .into_iter() + .collect() + || std::env::var("CUSTOM").as_deref() != Ok("declared=值") + || std::env::var_os("TEMP") != std::env::var_os("TMP") + || std::path::PathBuf::from(std::env::var_os("LOCALAPPDATA").unwrap()).join("Temp") + != std::path::PathBuf::from(std::env::var_os("TEMP").unwrap()) + { + std::process::exit(83); + } + std::process::exit(0); + } if args.len() != 3 { std::process::exit(79); }