diff --git a/rust/README.md b/rust/README.md index 879893e0e6..a418e7af32 100644 --- a/rust/README.md +++ b/rust/README.md @@ -130,6 +130,9 @@ ordinary-spawn retry after terminating the original. This logged fallback uses best-effort Job assignment: early descendants can escape, and assignment failure leaves only direct-child cleanup, without crash-safe SDK-host-exit ownership. +Descendants explicitly launched with `CREATE_BREAKAWAY_FROM_JOB` can leave the +SDK-owned Job and survive its cleanup. + **`ClientOptions`:** | Field | Type | Description | diff --git a/rust/src/process_tree.rs b/rust/src/process_tree.rs index ade2554771..992d1988a4 100644 --- a/rust/src/process_tree.rs +++ b/rust/src/process_tree.rs @@ -72,9 +72,9 @@ mod platform { CreateToolhelp32Snapshot, TH32CS_SNAPTHREAD, THREADENTRY32, Thread32First, Thread32Next, }; use windows_sys::Win32::System::JobObjects::{ - AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, - JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, - SetInformationJobObject, TerminateJobObject, + AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_BREAKAWAY_OK, + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, + JobObjectExtendedLimitInformation, SetInformationJobObject, TerminateJobObject, }; use windows_sys::Win32::System::Threading::{ CREATE_NO_WINDOW, CREATE_SUSPENDED, GetCurrentProcess, GetProcessIdOfThread, OpenThread, @@ -251,7 +251,10 @@ mod platform { let job = OwnedHandle(raw_job); let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); - limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + // Permit tool launches with CREATE_BREAKAWAY_FROM_JOB without silently + // letting ordinary children escape. + limits.BasicLimitInformation.LimitFlags = + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_BREAKAWAY_OK; // SAFETY: `limits` has the layout required by the selected info class. if unsafe { SetInformationJobObject( diff --git a/rust/src/process_tree/tests.rs b/rust/src/process_tree/tests.rs index 49dacd2a76..6b17c631ca 100644 --- a/rust/src/process_tree/tests.rs +++ b/rust/src/process_tree/tests.rs @@ -23,7 +23,11 @@ use windows_sys::Win32::Security::{ use windows_sys::Win32::System::Diagnostics::ToolHelp::{ CreateToolhelp32Snapshot, TH32CS_SNAPTHREAD, THREADENTRY32, Thread32First, Thread32Next, }; -use windows_sys::Win32::System::JobObjects::IsProcessInJob; +use windows_sys::Win32::System::JobObjects::{ + IsProcessInJob, JOB_OBJECT_LIMIT_BREAKAWAY_OK, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, + JobObjectExtendedLimitInformation, QueryInformationJobObject, +}; use windows_sys::Win32::System::Threading::{ GetCurrentProcess, GetCurrentProcessId, GetCurrentThread, GetCurrentThreadId, OpenProcess, OpenProcessToken, OpenThread, OpenThreadToken, PROCESS_SYNCHRONIZE, SetThreadToken, @@ -778,6 +782,53 @@ async fn initial_thread_executes_only_after_private_job_assignment() { drop(tree); } +#[tokio::test] +async fn private_job_allows_only_explicit_breakaway() { + let _reset = ResetHooks; + BEFORE_RESUME.with_borrow_mut(|hook| { + *hook = Some(Box::new(|_, job| { + let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + // SAFETY: the Job remains owned and `limits` matches the requested information class. + let queried = unsafe { + QueryInformationJobObject( + job, + JobObjectExtendedLimitInformation, + ptr::from_mut(&mut limits).cast(), + std::mem::size_of::() as u32, + ptr::null_mut(), + ) + }; + assert_ne!(queried, 0, "{}", io::Error::last_os_error()); + let flags = limits.BasicLimitInformation.LimitFlags; + let required = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_BREAKAWAY_OK; + assert_eq!( + flags & required, + required, + "private Job must retain kill-on-close and permit explicit breakaway" + ); + assert_eq!( + flags & JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK, + 0, + "ordinary descendants must not silently escape the private Job" + ); + Ok(()) + })); + }); + let mut command = command(); + command.args(["exit", "/b", "0"]); + let (mut child, tree) = super::spawn(&mut command).expect("spawn owned child"); + assert!( + BEFORE_RESUME.with_borrow(Option::is_none), + "job policy must be checked before resume" + ); + let status = tokio::time::timeout(Duration::from_secs(5), child.wait()) + .await + .expect("child must exit") + .expect("reap child"); + drop(tree); + assert!(status.success()); +} + #[tokio::test] async fn lookup_failures_reap_suspended_root_and_release_pss_resources() { let _reset = ResetHooks;