From 2b24da255794e559b878c9bf6e3947ae2d045859 Mon Sep 17 00:00:00 2001 From: Linghong Date: Tue, 22 Sep 2026 17:06:09 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=A0=E5=9B=BA=E6=B5=8F=E8=A7=88=E5=99=A8?= =?UTF-8?q?=E6=B8=85=E6=89=AB=E4=B8=8E=E6=94=B6=E6=9D=9F=EF=BC=9A=E6=9D=80?= =?UTF-8?q?=E5=89=8D=E9=95=9C=E5=83=8F=E5=A4=8D=E6=A0=B8=E3=80=81=E6=94=B6?= =?UTF-8?q?=E5=89=B2=E7=BB=93=E6=9E=9C=E7=A1=AE=E8=AE=A4=E3=80=81=E9=95=9C?= =?UTF-8?q?=E5=83=8F=E9=BB=98=E8=AE=A4=E8=A7=86=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sweep.rs:杀前新增活进程镜像路径复核(Windows QueryFullProcessImageNameW / Linux /proc/pid/exe),打开失败 fail-closed 拒绝 sweep.rs:Unix 新增属主目录 euid 检查,拒绝他人在 /tmp 伪造的 ga-browser-* 目录 sweep.rs:Windows 杀树改为追踪制,全部成员从快照消失才报成功,root 先死子进程残留不再误报 sweep.rs:Unix 检查 kill 返回值并以 kill(pid,0) 确认进程消失后才报成功 process.rs:reap 返回 root 退出确认,非 Windows confirm_reaped 用 try_wait 兜底,挂起/杀失败暴露为 browser-cleanup-unconfirmed process.rs:handler_config 镜像 BrowserConfigBuilder 默认 viewport(Some 800x600),与旧 Browser::launch 行为一致 测试:杀树两个用例断言整树成员真实消失,新增镜像复核测试 --- .../src-tauri/src/browser/process.rs | 72 +++++--- .../src-tauri/src/browser/sweep.rs | 168 ++++++++++++++++-- .../src-tauri/src/process_session/tests.rs | 65 +++++++ 3 files changed, 263 insertions(+), 42 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs index 129cc17ef..6bfd61417 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs @@ -6,6 +6,7 @@ use std::time::Instant; use chromiumoxide::async_process::{Child as BrowserChild, ChildStderr as BrowserChildStderr}; use chromiumoxide::browser::{Browser, BrowserConfig}; +use chromiumoxide::handler::viewport::Viewport; use chromiumoxide::handler::HandlerConfig; use futures::{AsyncBufReadExt, FutureExt, StreamExt}; use tempfile::{Builder as TempDirBuilder, TempDir}; @@ -76,14 +77,17 @@ const MAX_LAUNCH_STDERR_BYTES: usize = 64 * 1024; const MAX_WS_URL_LEN: usize = 512; /// 必须与 browser_config 的 builder 调用保持一致:request_timeout / -/// enable_request_intercept / disable_cache 在这里逐项镜像;其余字段 -/// (ignore_https_errors / ignore_invalid_messages / viewport)与 -/// BrowserConfigBuilder 默认值相同,沿用 HandlerConfig::default()。 +/// enable_request_intercept / disable_cache 在这里逐项镜像;viewport 镜像 +/// BrowserConfigBuilder 的默认值 Some(Viewport::default())(800x600,旧 +/// Browser::launch 会把它带入 HandlerConfig);其余字段 +/// (ignore_https_errors / ignore_invalid_messages)沿用 +/// HandlerConfig::default(),与 builder 默认值相同。 fn handler_config() -> HandlerConfig { let mut config = HandlerConfig::default(); config.request_timeout = BROWSER_TIMEOUT; config.request_intercept = true; config.cache_enabled = false; + config.viewport = Some(Viewport::default()); config } @@ -204,33 +208,48 @@ struct BrowserProcessGuard { impl BrowserProcessGuard { /// 整树收割:Job 终止全树,再 kill+wait root 兜底(Unix 与 Job /// 绑定前逸出的极早期子进程依赖 root 死亡的级联退出)。 - async fn reap(&mut self) { + /// 返回是否已确认 root 进程退出。 + async fn reap(&mut self) -> bool { #[cfg(windows)] if let Some(job) = &self.job { let _ = job.terminate(); } let _ = tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.child.kill()).await; - let _ = tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.child.wait()).await; + matches!( + tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.child.wait()).await, + Ok(Ok(_)) + ) } - async fn confirm_reaped(&self) -> Result<(), String> { + async fn confirm_reaped(&mut self) -> Result<(), String> { #[cfg(windows)] - if let Some(job) = &self.job { - let deadline = Instant::now() + BROWSER_CLOSE_TIMEOUT; - loop { - if job - .is_empty() - .map_err(|_| "browser-tree-reap-unconfirmed".to_string())? - { - return Ok(()); + { + if let Some(job) = &self.job { + let deadline = Instant::now() + BROWSER_CLOSE_TIMEOUT; + loop { + if job + .is_empty() + .map_err(|_| "browser-tree-reap-unconfirmed".to_string())? + { + return Ok(()); + } + if Instant::now() >= deadline { + return Err("browser-tree-reap-unconfirmed".into()); + } + tokio::time::sleep(Duration::from_millis(20)).await; } - if Instant::now() >= deadline { - return Err("browser-tree-reap-unconfirmed".into()); - } - tokio::time::sleep(Duration::from_millis(20)).await; + } + Ok(()) + } + #[cfg(not(windows))] + { + // 兜底确认 root 确已退出:否则调用方会在浏览器仍存活时删除 + // 配置目录,且 browser-cleanup-unconfirmed 永远不会暴露。 + match self.child.inner.try_wait() { + Ok(Some(_)) => Ok(()), + _ => Err("browser-tree-reap-unconfirmed".into()), } } - Ok(()) } } @@ -262,7 +281,9 @@ impl OwnedBrowser { if matches!(close, Ok(Ok(_))) && matches!(waited, Ok(Ok(_))) { return Ok(()); } - self.process.reap().await; + if !self.process.reap().await { + return Err("browser-cleanup-unconfirmed".into()); + } self.process.confirm_reaped().await } } @@ -305,7 +326,7 @@ async fn launch_owned_browser( { Ok(pair) => pair, Err(error) => { - process.reap().await; + let _ = process.reap().await; return Err(error); } }; @@ -319,12 +340,12 @@ async fn launch_owned_browser( Ok(Ok(pair)) => pair, Ok(Err(_)) => { drain_task.abort(); - process.reap().await; + let _ = process.reap().await; return Err(OwnedBrowserLaunchError::ConnectFailed); } Err(_) => { drain_task.abort(); - process.reap().await; + let _ = process.reap().await; return Err(OwnedBrowserLaunchError::ConnectTimeout); } }; @@ -536,10 +557,11 @@ mod health_tests { assert_eq!(config.request_timeout, BROWSER_TIMEOUT); assert!(config.request_intercept); assert!(!config.cache_enabled); - // 其余字段必须与 BrowserConfigBuilder 默认值保持一致。 + // 其余字段必须与 BrowserConfigBuilder 默认值保持一致 + // (builder 默认 viewport 为 Some(Viewport::default()),即 800x600)。 assert!(config.ignore_https_errors); assert!(config.ignore_invalid_messages); - assert!(config.viewport.is_none()); + assert_eq!(config.viewport, Some(Viewport::default())); } #[tokio::test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/sweep.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/sweep.rs index d5042ca9c..d5f6effaa 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/sweep.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/sweep.rs @@ -85,6 +85,23 @@ fn process_identity_matches(pid: u32, identity: &str) -> bool { .is_some_and(|live| live == identity) } +/// 可执行文件路径等价性:双方规范化后比较,Windows 下忽略大小写。 +fn same_executable_path(actual: &Path, expected: &Path) -> bool { + let canonical = |path: &Path| path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + let actual = canonical(actual); + let expected = canonical(expected); + #[cfg(windows)] + { + return actual + .to_string_lossy() + .eq_ignore_ascii_case(&expected.to_string_lossy()); + } + #[cfg(not(windows))] + { + actual == expected + } +} + /// exe 必须仍是系统标准路径下的可信浏览器,防止伪造 owner.json 借刀杀进程。 fn trusted_browser_executable(path: &Path) -> bool { let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); @@ -93,22 +110,88 @@ fn trusted_browser_executable(path: &Path) -> bool { } system_browser_candidates() .into_iter() - .any(|(candidate, _)| candidate.canonicalize().unwrap_or(candidate) == canonical) + .any(|(candidate, _)| same_executable_path(&candidate, path)) +} + +/// 杀前复核:PID 对应的活进程镜像必须就是 owner.json 声明的那个可执行 +/// 文件。仅核对字符串不够——/tmp 全局可写时,同机其他用户可以伪造 +/// owner.json 把 browser_pid 指到本用户的任意进程借清扫杀之。 +#[cfg(windows)] +fn live_process_executable_matches(pid: u32, expected: &Path) -> bool { + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::System::Threading::{ + OpenProcess, QueryFullProcessImageNameW, PROCESS_QUERY_LIMITED_INFORMATION, + }; + // SAFETY: 句柄非空时由 CloseHandle 释放;打开失败按不匹配处理(fail-closed)。 + let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; + if handle.is_null() { + return false; + } + let mut buffer = [0u16; 1024]; + let mut length = buffer.len() as u32; + // SAFETY: buffer 可写,length 先传入容量、返回实际长度。 + let okay = unsafe { QueryFullProcessImageNameW(handle, 0, buffer.as_mut_ptr(), &mut length) }; + // SAFETY: handle 是本函数持有的合法句柄。 + unsafe { CloseHandle(handle) }; + if okay == 0 || length == 0 { + return false; + } + let actual = std::path::PathBuf::from(String::from_utf16_lossy(&buffer[..length as usize])); + same_executable_path(&actual, expected) +} + +#[cfg(target_os = "linux")] +fn live_process_executable_matches(pid: u32, expected: &Path) -> bool { + let Ok(actual) = fs::read_link(format!("/proc/{pid}/exe")) else { + return false; + }; + same_executable_path(&actual, expected) +} + +// macOS 等无 /proc 的平台没有廉价的镜像核对手段,由目录属主检查兜底。 +#[cfg(all(unix, not(target_os = "linux")))] +fn live_process_executable_matches(_pid: u32, expected: &Path) -> bool { + expected.is_file() +} + +/// /tmp 是全局可写目录:属主目录必须由本用户创建,否则视为伪造并拒绝处置。 +#[cfg(unix)] +fn directory_owned_by_current_user(dir: &Path) -> bool { + use std::os::unix::fs::MetadataExt; + dir.metadata() + // SAFETY: geteuid 无前置条件,总是安全的。 + .map(|metadata| metadata.uid() == unsafe { libc::geteuid() }) + .unwrap_or(false) } fn kill_browser_process_tree(root_pid: u32, expected_identity: &str) -> Result<(), String> { #[cfg(windows)] { + // 追踪所有经确认属于这棵树的 PID;只有它们全部从快照中消失才判 + // 成功——root 先死而子进程残留时不再误报收割完成。 + let mut tracked: Vec = Vec::new(); for _ in 0..TREE_KILL_MAX_PASSES { - // 每轮先复核 root 身份:进程已退出或 PID 被复用时立即停止。 - if !process_identity_matches(root_pid, expected_identity) { + let snapshot = windows_process_snapshot()?; + let root_present = snapshot.iter().any(|(pid, _)| *pid == root_pid); + if root_present && process_identity_matches(root_pid, expected_identity) { + // root 仍是目标浏览器:发现并追踪当前整棵子树。 + for pid in windows_process_tree_pids_from(&snapshot, root_pid)? { + if !tracked.contains(&pid) { + tracked.push(pid); + } + } + } else { + // root 已退出或 PID 被复用:复用者与本任务无关,不再追踪 root。 + tracked.retain(|pid| *pid != root_pid); + } + // 只统计仍存活的成员;全部消失才算收割完成。 + tracked.retain(|pid| snapshot.iter().any(|(live, _)| live == pid)); + if tracked.is_empty() { return Ok(()); } - let pids = windows_process_tree_pids(root_pid)?; - if pids.is_empty() { - return Ok(()); - } - for pid in &pids { + for pid in &tracked { + // 终止结果以“下一轮快照中是否消失”验证;对正在退出的进程 + // OpenProcess 的瞬时失败会在下一轮自然消解。 windows_terminate_process(*pid); } std::thread::sleep(TREE_KILL_PASS_INTERVAL); @@ -118,15 +201,27 @@ fn kill_browser_process_tree(root_pid: u32, expected_identity: &str) -> Result<( #[cfg(not(windows))] { // 只杀 root:子进程随 IPC 断开级联退出(与运行期收割语义一致)。 - if process_identity_matches(root_pid, expected_identity) { - unsafe { libc::kill(root_pid as i32, libc::SIGKILL) }; + if !process_identity_matches(root_pid, expected_identity) { + return Ok(()); } - Ok(()) + // SAFETY: 目标 PID 与启动身份均已复核。 + if unsafe { libc::kill(root_pid as i32, libc::SIGKILL) } != 0 { + return Err("browser-sweep-kill-failed".into()); + } + // SIGKILL 后必须确认进程真正消失再报成功。 + for _ in 0..TREE_KILL_MAX_PASSES { + // SAFETY: signal 0 仅做存活探测,无副作用。 + if unsafe { libc::kill(root_pid as i32, 0) } != 0 { + return Ok(()); + } + std::thread::sleep(TREE_KILL_PASS_INTERVAL); + } + Err("browser-sweep-tree-not-reaped".into()) } } #[cfg(windows)] -fn windows_process_tree_pids(root_pid: u32) -> Result, String> { +fn windows_process_snapshot() -> Result, String> { use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; use windows_sys::Win32::System::Diagnostics::ToolHelp::{ CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, @@ -149,6 +244,14 @@ fn windows_process_tree_pids(root_pid: u32) -> Result, String> { } // SAFETY: snapshot 是本函数持有的合法句柄。 unsafe { CloseHandle(snapshot) }; + Ok(processes) +} + +#[cfg(windows)] +fn windows_process_tree_pids_from( + processes: &[(u32, u32)], + root_pid: u32, +) -> Result, String> { if !processes.iter().any(|(pid, _)| *pid == root_pid) { return Ok(Vec::new()); } @@ -156,7 +259,7 @@ fn windows_process_tree_pids(root_pid: u32) -> Result, String> { let mut index = 0; while index < tree.len() { let parent = tree[index]; - for (pid, ppid) in &processes { + for (pid, ppid) in processes { if *ppid == parent && !tree.contains(pid) { tree.push(*pid); } @@ -233,6 +336,12 @@ fn sweep_owned_directory( owner: &BrowserProcessOwner, notes: &mut Vec, ) { + // /tmp 全局可写:属主目录必须是本用户创建,否则视为伪造,拒绝处置。 + #[cfg(unix)] + if !directory_owned_by_current_user(dir) { + notes.push(format!("kept-foreign-dir:{name}")); + return; + } // 另一个活着的 App 实例仍持有这轮浏览器:整目录跳过。 if process_identity_matches(owner.owner_pid, &owner.owner_start_identity) { return; @@ -248,6 +357,12 @@ fn sweep_owned_directory( notes.push(format!("kept-untrusted-executable:{name}")); return; } + // 活进程镜像必须与声明的浏览器一致:防止伪造 owner.json 借清扫 + // 终止本用户的无关进程。 + if !live_process_executable_matches(owner.browser_pid, Path::new(&owner.executable)) { + notes.push(format!("kept-image-mismatch:{name}")); + return; + } match kill_browser_process_tree(owner.browser_pid, &owner.browser_start_identity) { Ok(()) => { let _ = fs::remove_dir_all(dir); @@ -405,13 +520,32 @@ mod tests { let identity = crate::runner::external_agent_runner_process_start_identity(pid) .expect("fixture identity") .expect("fixture identity present"); - let before = windows_process_tree_pids(pid).expect("tree snapshot"); + let before = + windows_process_tree_pids_from(&windows_process_snapshot().expect("snapshot"), pid) + .expect("tree snapshot"); assert!(before.len() >= 2, "fixture 必须包含子进程: {before:?}"); kill_browser_process_tree(pid, &identity).expect("kill tree"); - assert!(windows_process_tree_pids(pid) - .map(|tree| tree.is_empty()) - .unwrap_or(true)); + // 不仅 root 消失,快照里记录到的每个树成员(含 ping 子进程)都必须 + // 真正被收割,不能只凭 root 不在就判成功。 + let alive = windows_process_snapshot().expect("snapshot after kill"); + for member in &before { + assert!( + !alive.iter().any(|(live, _)| live == member), + "fixture 进程 {member} 必须被收割: {before:?}" + ); + } let _ = child.kill(); let _ = child.wait(); } + + #[cfg(any(windows, target_os = "linux"))] + #[test] + fn live_process_executable_matches_current_process_image() { + let exe = std::env::current_exe().unwrap(); + assert!(live_process_executable_matches(std::process::id(), &exe)); + assert!(!live_process_executable_matches( + std::process::id(), + Path::new("C:\\fixture\\chrome.exe") + )); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs index 3485912ec..c96ef974c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs @@ -1816,6 +1816,33 @@ setInterval(() => {}, 1000); fn windows_process_job_terminate_reaps_process_tree() { use std::process::{Command, Stdio}; + // 系统进程快照(pid, ppid),用于证明 ping 子进程真实存在并被收割。 + fn windows_process_snapshot_for_test() -> Vec<(u32, u32)> { + use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, + TH32CS_SNAPPROCESS, + }; + // SAFETY: 快照句柄非 INVALID_HANDLE_VALUE 时由 CloseHandle 释放。 + let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }; + if snapshot == INVALID_HANDLE_VALUE { + return Vec::new(); + } + let mut entry = PROCESSENTRY32W::default(); + entry.dwSize = std::mem::size_of::() as u32; + let mut processes = Vec::new(); + // SAFETY: entry 指向可写的 PROCESSENTRY32W,dwSize 已初始化。 + let mut available = unsafe { Process32FirstW(snapshot, &mut entry) }; + while available != 0 { + processes.push((entry.th32ProcessID, entry.th32ParentProcessID)); + // SAFETY: 同上。 + available = unsafe { Process32NextW(snapshot, &mut entry) }; + } + // SAFETY: snapshot 是本函数持有的合法句柄。 + unsafe { CloseHandle(snapshot) }; + processes + } + // cmd 启动第一个 ping 子进程后整树存活;terminate 必须连子进程一起收割。 // timeout.exe 在 stdio 被重定向时会立即退出,ping 才能在 null stdio 下存活。 let mut child = Command::new("cmd.exe") @@ -1838,6 +1865,26 @@ fn windows_process_job_terminate_reaps_process_tree() { .expect("spawn cmd fixture"); let job = WindowsProcessJob::assign_std(&child).expect("assign job"); thread::sleep(Duration::from_millis(500)); + // 前置检查不能只凭 Job 非空:快照里必须真的看到 cmd 拉起了 ping 子进程。 + let tree_before: Vec = { + let snapshot = windows_process_snapshot_for_test(); + let mut tree = vec![child.id()]; + let mut index = 0; + while index < tree.len() { + let parent = tree[index]; + for (pid, ppid) in &snapshot { + if *ppid == parent && !tree.contains(pid) { + tree.push(*pid); + } + } + index += 1; + } + tree + }; + assert!( + tree_before.len() >= 2, + "fixture 必须包含 ping 子进程,否则收割断言是空转: {tree_before:?}" + ); assert!( !job.is_empty().expect("query job"), "fixture 进程树必须先存活,否则收割断言是空转" @@ -1851,6 +1898,24 @@ fn windows_process_job_terminate_reaps_process_tree() { ); thread::sleep(Duration::from_millis(50)); } + // Job 为空之外,快照里的整棵树(含 ping 子进程)也必须真实消失。 + let deadline = std::time::Instant::now() + Duration::from_secs(10); + loop { + let snapshot = windows_process_snapshot_for_test(); + let survivors: Vec = tree_before + .iter() + .copied() + .filter(|pid| snapshot.iter().any(|(live, _)| live == pid)) + .collect(); + if survivors.is_empty() { + break; + } + assert!( + std::time::Instant::now() < deadline, + "Job terminate 后 fixture 子进程仍存活: {survivors:?}" + ); + thread::sleep(Duration::from_millis(50)); + } let _ = child.kill(); let _ = child.wait(); }