diff --git a/.rust-mutants.toml b/.rust-mutants.toml index 80c71ee..67b63a3 100644 --- a/.rust-mutants.toml +++ b/.rust-mutants.toml @@ -15,3 +15,125 @@ locked = true # The documented examples repeat integration tests and would cost a rustdoc build per routed mutant. doctests = false test_binary_args = ["--test-threads=1"] + +# Survivors that no test can observe, each with the reason it is not a gap. + +[[mutation.expect]] +path = "src/child.rs" +item = "Child::try_wait" +rule = "condition-to-false" +original = "self.exit.is_some()" +reason = "Equivalence: re-querying an exited process returns the cached status; child_pipes_try_wait_and_cached_lifecycle_work holds the cache." +outcome = "survived" + +[[mutation.expect]] +path = "src/sys.rs" +item = "::drop" +rule = "false-to-true" +original = "false" +reason = "Equivalence: the local copy closes as the call returns; its inheritability matters only to a concurrent broad-inheritance spawn (ADR 0005)." +outcome = "survived" + +[[mutation.expect]] +path = "src/sys.rs" +item = "create_process" +rule = "condition-to-true" +original = "request.attributes.is_some()" +count = 2 +reason = "Equivalence: CreateProcessW accepts the STARTUPINFOEXW size and EXTENDED_STARTUPINFO_PRESENT with a null attribute list; every spawn test holds creation." +outcome = "survived" + +[[mutation.expect]] +path = "src/sys.rs" +item = "read_handle" +rule = "condition-to-false" +original = "buffer.is_empty()" +reason = "Equivalence: ReadFile with a zero length returns zero without consuming input." +outcome = "survived" + +[[mutation.expect]] +path = "src/sys.rs" +item = "write_handle" +rule = "condition-to-false" +original = "buffer.is_empty()" +reason = "Equivalence: WriteFile with a zero length writes nothing and returns zero." +outcome = "survived" + +[[mutation.expect]] +path = "src/sys.rs" +item = "environment_strings" +rule = "condition-to-false" +original = "base.is_null()" +reason = "Fail-open: GetEnvironmentStringsW returns null only when out of memory." +outcome = "survived" + +[[mutation.expect]] +path = "src/sys.rs" +item = "environment_strings" +rule = "question-to-unwrap" +original = "?" +count = 2 +reason = "Fail-open: an environment entry or block cannot approach usize::MAX units." +outcome = "survived" + +[[mutation.expect]] +path = "src/transaction.rs" +item = "SpawnTransaction::new" +rule = "condition-to-false" +original = "attribute_count == 0" +reason = "Equivalence: CreateProcessW accepts an empty attribute list." +outcome = "survived" + +[[mutation.expect]] +path = "src/transaction.rs" +item = "build_environment" +rule = "condition-to-false" +original = "!command.env_clear && command.env_ops.is_empty()" +reason = "Equivalence: a block built from the unmodified environment equals the inherited one." +outcome = "survived" + +[[mutation.expect]] +path = "src/transaction.rs" +item = "build_environment" +rule = "condition-to-true" +original = "block.is_empty()" +reason = "Equivalence: CreateProcessW ignores an extra NUL after the block's terminator." +outcome = "survived" + +[[mutation.expect]] +path = "src/transaction.rs" +item = "broker_command_line" +rule = "condition-to-false" +original = "absolute.contains("E)" +reason = "Fail-open: GetFullPathNameW cannot return a double quote, which Windows paths cannot contain." +outcome = "survived" + +[[mutation.expect]] +path = "src/transaction.rs" +item = "resolve_executable" +rule = "condition-to-false" +original = "has_exe_suffix" +reason = "Equivalence: appending .exe to a path ending in .exe names no file, so the original path is returned." +outcome = "survived" + +# Code no test can reach, or a fault observable only as a hang. + +[[mutation.skip]] +path = "src/sys.rs" +lines = "1004-1004" +reason = "Unreachable: GetEnvironmentStringsW returns null only when out of memory." + +[[mutation.skip]] +path = "src/sys.rs" +lines = "1012-1012" +reason = "Hang-only: continuing past the block terminator loops forever." + +[[mutation.skip]] +path = "src/transaction.rs" +lines = "492-495" +reason = "Unreachable: GetFullPathNameW cannot return a double quote." + +[[mutation.skip]] +path = "src/transaction.rs" +lines = "106-106" +reason = "Unreachable: wide_nul fails only on an interior NUL, which validate_command rejects first; rejects_every_malformed_text_component holds it." diff --git a/docs/adr/0012-win32-failures-are-injected-in-tests.md b/docs/adr/0012-win32-failures-are-injected-in-tests.md new file mode 100644 index 0000000..2318ab6 --- /dev/null +++ b/docs/adr/0012-win32-failures-are-injected-in-tests.md @@ -0,0 +1,23 @@ +# 0012 — Win32 failures are injected in tests + +Status: accepted (2026-09-25) + +## Context + +The first mutation run left about a hundred `?` propagations and error branches unverified: no test could make a Win32 call fail at a chosen point. + +## Decision + +`sys::fault`, compiled only for tests, fails the Nth fallible wrapper call on the current thread. +Each fallible wrapper checks it on entry with a `#[cfg(test)]` statement, so non-test builds contain nothing of it. + +`failure_tests` records the wrapper calls of each public operation, then fails each call in turn and requires exactly the injected error back; a panic, success, or other error fails the test. +An isolated run repeats every failure twice and requires an unchanged process handle count. + +Logic after a Win32 call is tested with real failures instead: handles without the needed access, and invalid inputs. + +## Consequences + +- Every error propagation of the public operations runs in tests. +- A new fallible wrapper needs a `Call` and an operation that reaches it. +- Reader threads are outside the plan, which is thread-local; their errors are tested directly. diff --git a/src/child.rs b/src/child.rs index 5734e12..6ea4b5c 100644 --- a/src/child.rs +++ b/src/child.rs @@ -323,6 +323,60 @@ mod tests { use super::*; + /// Named to run first, so a spawn that wrongly starts suspended fails before a test waits on it. + #[test] + fn a_running_spawn_is_not_suspended() { + let mut command = crate::Command::new("cmd.exe"); + command + .args(["/D", "/C", "set /p x="]) + .stdin(crate::Stdio::piped()) + .stdout(crate::Stdio::null()) + .stderr(crate::Stdio::null()); + let plan = crate::plan::SpawnPlan::new_running( + &command, + crate::SpawnOptions::new(), + crate::plan::IoMode::Spawn, + ) + .unwrap(); + let transaction = crate::transaction::SpawnTransaction::new(&plan).unwrap(); + assert_eq!( + sys::test_support::suspend_count(transaction.primary_thread()), + 0 + ); + let mut child = transaction.commit_child(); + child.wait().unwrap(); + } + + #[test] + fn reader_errors_reach_the_caller() { + let (reader, writer) = sys::test_support::pipe(); + assert!(drain_output(writer.as_handle()).is_err()); + drop(reader); + + let mut exited = crate::Command::new("cmd.exe"); + exited + .args(["/D", "/C", "exit /b 0"]) + .stdin(crate::Stdio::null()) + .stdout(crate::Stdio::null()) + .stderr(crate::Stdio::null()); + let source = exited.spawn().unwrap(); + let (unreadable_reader, unreadable) = sys::test_support::pipe(); + let child = Child::new( + sys::test_support::duplicate(source.as_handle()), + source.id(), + None, + None, + Some(unreadable), + None, + ); + assert!(child.wait_with_output().is_err()); + drop(unreadable_reader); + + let failed = thread::spawn(|| -> io::Result> { Err(io::Error::other("read")) }); + let succeeded = thread::spawn(|| Ok(b"out".to_vec())); + assert!(join_readers(Some(succeeded), Some(failed)).is_err()); + } + #[test] fn absent_and_panicked_output_readers_become_results() { assert!(join_reader(None).unwrap().is_empty()); diff --git a/src/command.rs b/src/command.rs index 3bfd9ec..712a5db 100644 --- a/src/command.rs +++ b/src/command.rs @@ -324,3 +324,46 @@ impl Command { .wait_with_output() } } + +#[cfg(test)] +mod tests { + use std::fs::File; + + use super::*; + use crate::sys::test_support::is_inheritable; + + #[test] + fn validation_errors_propagate_from_every_entry_point() { + let mut batch = Command::new("script.cmd"); + for result in [ + batch.spawn().map(drop), + batch.spawn_with(SpawnOptions::new()).map(drop), + batch.spawn_suspended().map(drop), + batch.spawn_suspended_with(SpawnOptions::new()).map(drop), + batch.status().map(drop), + batch.status_with(SpawnOptions::new()).map(drop), + batch.output().map(drop), + batch.output_with(SpawnOptions::new()).map(drop), + ] { + assert_eq!(result.unwrap_err().kind(), io::ErrorKind::InvalidInput); + } + } + + #[test] + fn stored_handle_duplicates_are_private() -> io::Result<()> { + let nul = File::open("NUL")?; + let mut command = Command::new("cmd.exe"); + command + .arg_handle(&nul)? + .env_handle("WINDOWS_SPAWN_HANDLE", &nul)?; + let Some(Arg::Handle(argument)) = command.args.first() else { + panic!("arg_handle stores a handle argument"); + }; + assert!(!is_inheritable(argument.as_handle())); + let Some(EnvOp::Set(_, EnvValue::Handle(value))) = command.env_ops.first() else { + panic!("env_handle stores a handle value"); + }; + assert!(!is_inheritable(value.as_handle())); + Ok(()) + } +} diff --git a/src/failure_tests.rs b/src/failure_tests.rs new file mode 100644 index 0000000..12c5815 --- /dev/null +++ b/src/failure_tests.rs @@ -0,0 +1,391 @@ +//! Every Win32 failure point returns its own error and releases what the operation acquired. + +use std::fs::File; +use std::io::{self, Read, Write}; +use std::os::windows::io::{AsHandle, AsRawHandle, OwnedHandle}; +use std::thread; + +use windows_sys::Win32::Foundation::HANDLE; +use windows_sys::Win32::System::Console::{ClosePseudoConsole, CreatePseudoConsole, COORD, HPCON}; + +use crate::sys::fault::{self, Call}; +use crate::sys::test_support::{ + current_process, isolated, pipe, process_handle_count, ProcessExitGuard, +}; +use crate::{ + AsPseudoConsole, Command, DropPolicy, Job, Mitigation, MitigationPolicy, ParentProcess, + SpawnOptions, Stdio, SuspendedChild, +}; + +/// An operation whose every Win32 call is failed in turn. +#[derive(Clone, Copy, Debug)] +enum Operation { + SpawnAndWait, + SpawnSuspendedAndResume, + OutputWithKillTree, + Status, + PipeThroughChild, + KillRunningChild, + JobAssignAndTerminate, + OpenParentProcess, + ConfigureHandleInputs, + RenderCommandLine, + TryWaitExitedChild, + SpawnOnPseudoConsole, +} + +const OPERATIONS: [Operation; 12] = [ + Operation::SpawnAndWait, + Operation::SpawnSuspendedAndResume, + Operation::OutputWithKillTree, + Operation::Status, + Operation::PipeThroughChild, + Operation::KillRunningChild, + Operation::JobAssignAndTerminate, + Operation::OpenParentProcess, + Operation::ConfigureHandleInputs, + Operation::RenderCommandLine, + Operation::TryWaitExitedChild, + Operation::SpawnOnPseudoConsole, +]; + +/// Capabilities the operations borrow, created before any call is recorded. +struct Fixture { + nul: File, + job: Job, + parent: ParentProcess, + host: SuspendedChild, + _host_guard: ProcessExitGuard, + console: TestConsole, +} + +impl Fixture { + fn new() -> io::Result { + let mut host_command = Command::new("cmd.exe"); + host_command + .args(["/D", "/C", "exit /b 0"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let host = host_command.spawn_suspended()?; + let host_guard = ProcessExitGuard::watch(host.as_handle()); + Ok(Self { + nul: File::options().read(true).write(true).open("NUL")?, + job: Job::create()?, + parent: ParentProcess::open(host.id())?, + host, + _host_guard: host_guard, + console: TestConsole::create(), + }) + } + + /// A command that exits 0 whatever follows it and touches every configurable input. + fn rich_command(&self) -> io::Result { + let mut command = Command::new("cmd.exe"); + command + .args(["/D", "/C", "rem"]) + .arg_handle(&self.nul)? + .env_handle("WINDOWS_SPAWN_FAULT_HANDLE", &self.nul)? + .env("WINDOWS_SPAWN_FAULT_TEXT", "value") + .current_dir(std::env::temp_dir()) + .stdin(Stdio::inherit()) + .stdout(Stdio::from_borrowed(&self.nul)?) + .stderr(Stdio::piped()); + Ok(command) + } + + fn run(&self, operation: Operation) -> io::Result<()> { + match operation { + Operation::SpawnAndWait => self.spawn_and_wait(), + Operation::SpawnSuspendedAndResume => self.spawn_suspended_and_resume(), + Operation::OutputWithKillTree => Self::output_with_kill_tree(), + Operation::Status => Self::status(), + Operation::PipeThroughChild => Self::pipe_through_child(), + Operation::KillRunningChild => Self::kill_running_child(), + Operation::JobAssignAndTerminate => Self::job_assign_and_terminate(), + Operation::OpenParentProcess => self.open_parent_process(), + Operation::ConfigureHandleInputs => self.configure_handle_inputs(), + Operation::RenderCommandLine => Self::render_command_line(), + Operation::TryWaitExitedChild => Self::try_wait_exited_child(), + Operation::SpawnOnPseudoConsole => self.spawn_on_pseudoconsole(), + } + } + + fn spawn_and_wait(&self) -> io::Result<()> { + let mitigation = MitigationPolicy::new().disable_extension_points(Mitigation::AlwaysOn); + let options = SpawnOptions::new() + .job(&self.job) + .parent_process(&self.parent) + .mitigation(mitigation) + .drop_policy(DropPolicy::KillTree); + let mut child = self.rich_command()?.spawn_with(options)?; + child.wait()?; + child.try_wait()?; + Ok(()) + } + + fn spawn_suspended_and_resume(&self) -> io::Result<()> { + let mitigation = MitigationPolicy::new().disable_extension_points(Mitigation::AlwaysOn); + let options = SpawnOptions::new().job(&self.job).mitigation(mitigation); + let suspended = self.rich_command()?.spawn_suspended_with(options)?; + suspended.resume()?.wait()?; + Ok(()) + } + + fn output_with_kill_tree() -> io::Result<()> { + let mut command = Command::new("cmd.exe"); + command.args(["/D", "/C", "echo out"]); + command.output_with(SpawnOptions::new().drop_policy(DropPolicy::KillTree))?; + Ok(()) + } + + fn status() -> io::Result<()> { + let mut command = Command::new("cmd.exe"); + command.args(["/D", "/C", "exit /b 0"]); + command.status()?; + Ok(()) + } + + /// Writes and reads once each, so the call sequence does not depend on how the pipe splits output. + fn pipe_through_child() -> io::Result<()> { + let mut command = Command::new("cmd.exe"); + command + .args(["/D", "/C", "more"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + let mut child = command.spawn()?; + let mut stdin = child.stdin.take().expect("piped stdin"); + let written = stdin.write(b"line\r\n"); + drop(stdin); + let mut output = [0_u8; 1]; + let read = child.stdout.take().expect("piped stdout").read(&mut output); + child.wait()?; + written?; + read?; + Ok(()) + } + + fn kill_running_child() -> io::Result<()> { + let mut child = gated_command().spawn()?; + let running = child.try_wait(); + let killed = child.kill(); + child.wait()?; + assert!(running?.is_none()); + killed + } + + fn job_assign_and_terminate() -> io::Result<()> { + let job = Job::create()?; + job.set_kill_on_close(true)?; + let adopted = Job::from_handle(crate::sys::duplicate_local(job.as_handle(), false)?)?; + let duplicate = adopted.duplicate()?; + let mut child = gated_command().spawn()?; + let terminated = duplicate + .assign(&child) + .and_then(|()| duplicate.terminate(7)); + if terminated.is_err() { + child.kill()?; + } + child.wait()?; + terminated + } + + fn open_parent_process(&self) -> io::Result<()> { + ParentProcess::open(std::process::id())?; + ParentProcess::from_handle(crate::sys::duplicate_local(self.host.as_handle(), false)?)?; + Ok(()) + } + + fn configure_handle_inputs(&self) -> io::Result<()> { + let mut command = Command::new("cmd.exe"); + command + .arg_handle(&self.nul)? + .env_handle("WINDOWS_SPAWN_FAULT_HANDLE", &self.nul)?; + Stdio::from_borrowed(&self.nul)?; + Ok(()) + } + + fn try_wait_exited_child() -> io::Result<()> { + let mut command = Command::new("cmd.exe"); + command + .args(["/D", "/C", "exit /b 0"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let mut child = command.spawn()?; + ProcessExitGuard::watch(child.as_handle()).exit_code(); + assert!(child.try_wait()?.is_some()); + Ok(()) + } + + /// Reuses one pseudoconsole: closing one per run can block on some Windows Server 2022 builds and hold handles. + fn spawn_on_pseudoconsole(&self) -> io::Result<()> { + let mut command = Command::new("cmd.exe"); + command.args(["/D", "/C", "exit /b 0"]); + command + .spawn_with(SpawnOptions::new().pseudoconsole(&self.console))? + .wait()?; + Ok(()) + } + + fn render_command_line() -> io::Result<()> { + let mut on_path = Command::new("cmd.exe"); + on_path + .arg("two words") + .env("WINDOWS_SPAWN_FAULT_TEXT", "value"); + on_path.to_command_line()?; + let mut in_windows_directory = Command::new("explorer"); + in_windows_directory.env_clear(); + in_windows_directory.to_command_line()?; + Ok(()) + } + + /// Records `operation`'s calls, then fails each in turn and requires exactly that error back. + fn assert_every_failure_propagates(&self, operation: Operation) -> Vec { + let calls = { + let plan = fault::record(); + self.run(operation) + .unwrap_or_else(|error| panic!("{operation:?} fails without injection: {error}")); + plan.calls() + }; + assert!(!calls.is_empty(), "{operation:?} made no Win32 call"); + for (index, call) in calls.iter().enumerate() { + let result = { + let _plan = fault::fail_at(index); + self.run(operation) + }; + match result { + Err(error) if fault::is_injected(&error) => {} + Err(error) => panic!( + "{operation:?}: failing call {index} ({call:?}) returned another error: {error}" + ), + Ok(()) => panic!("{operation:?}: failing call {index} ({call:?}) was ignored"), + } + } + calls + } +} + +/// A command that blocks on its piped standard input until killed or given EOF. +fn gated_command() -> Command { + let mut command = Command::new("cmd.exe"); + command + .args(["/D", "/C", "set /p x="]) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + command +} + +#[test] +fn every_win32_failure_propagates_as_the_same_error() -> io::Result<()> { + let fixture = Fixture::new()?; + let mut reached = Vec::new(); + for operation in OPERATIONS { + reached.extend(fixture.assert_every_failure_propagates(operation)); + } + for call in [ + Call::DuplicateLocal, + Call::DuplicateRemote, + Call::StandardHandle, + Call::NullHandle, + Call::CreatePipe, + Call::OpenProcess, + Call::ValidateProcess, + Call::CreateJob, + Call::QueryJob, + Call::SetJob, + Call::AssignJob, + Call::TerminateJob, + Call::AttributeList, + Call::UpdateAttribute, + Call::CreateProcess, + Call::WaitProcess, + Call::TryWaitProcess, + Call::ExitStatus, + Call::TerminateProcess, + Call::ResumeThread, + Call::ReadHandle, + Call::WriteHandle, + Call::EnvironmentStrings, + Call::MaximumPath, + ] { + assert!(reached.contains(&call), "no operation reaches {call:?}"); + } + Ok(()) +} + +/// Compares this process's handle count, so it runs alone in a fresh test process. +#[test] +fn failed_operations_release_their_handles() -> io::Result<()> { + if !isolated("failure_tests::failed_operations_release_their_handles") { + return Ok(()); + } + let fixture = Fixture::new()?; + let run_all = || { + for operation in OPERATIONS { + fixture.assert_every_failure_propagates(operation); + } + }; + run_all(); + let before = process_handle_count(current_process())?; + run_all(); + let after = process_handle_count(current_process())?; + assert_eq!(before, after, "failed operations leaked handles"); + Ok(()) +} + +/// A pseudoconsole created with raw Win32 calls, outside any recorded plan. +struct TestConsole { + value: HPCON, + input: Option, +} + +impl TestConsole { + fn create() -> Self { + let (input_reader, input_writer) = pipe(); + let (output_reader, output_writer) = pipe(); + let mut value = 0; + // SAFETY: both pipe ends are valid for the call and `value` is writable; this type owns the HPCON. + let created = unsafe { + CreatePseudoConsole( + COORD { X: 80, Y: 25 }, + input_reader.as_raw_handle() as HANDLE, + output_writer.as_raw_handle() as HANDLE, + 0, + &mut value, + ) + }; + assert!(created >= 0, "CreatePseudoConsole failed with {created:#x}"); + drop((input_reader, output_writer)); + let mut output = File::from(output_reader); + let _ = thread::spawn(move || { + let mut buffer = [0_u8; 4096]; + while matches!(output.read(&mut buffer), Ok(read) if read > 0) {} + }); + Self { + value, + input: Some(input_writer), + } + } +} + +/// The close runs on a detached thread because some Windows Server 2022 builds block in it. +impl Drop for TestConsole { + fn drop(&mut self) { + drop(self.input.take()); + let value = self.value; + let _ = thread::spawn(move || { + // SAFETY: this type uniquely owns the HPCON. + unsafe { ClosePseudoConsole(value) }; + }); + } +} + +// SAFETY: TestConsole owns a live HPCON for every borrow and keeps ownership. +unsafe impl AsPseudoConsole for TestConsole { + fn raw_pseudoconsole(&self) -> isize { + self.value + } +} diff --git a/src/handles.rs b/src/handles.rs index 3b93aab..a91f6dc 100644 --- a/src/handles.rs +++ b/src/handles.rs @@ -212,6 +212,20 @@ mod tests { use super::*; + #[test] + fn stored_duplicates_are_private() -> io::Result<()> { + use crate::sys::test_support::is_inheritable; + + let stdio = Stdio::from_borrowed(&File::open("NUL")?)?; + let StdioInner::Owned(owned) = &stdio.inner else { + panic!("from_borrowed stores an owned handle"); + }; + assert!(!is_inheritable(owned.as_handle())); + let duplicate = Job::create()?.duplicate()?; + assert!(!is_inheritable(duplicate.as_handle())); + Ok(()) + } + #[test] fn owned_handle_adoption_validates_resource_kind() { let mut host_command = crate::Command::new("cmd.exe"); diff --git a/src/lib.rs b/src/lib.rs index 7dd4f25..fee797c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,9 @@ mod readme_examples {} mod child; #[cfg(windows)] mod command; +#[cfg(all(windows, test))] +#[allow(unsafe_code)] +mod failure_tests; #[cfg(windows)] mod handles; #[cfg(windows)] diff --git a/src/plan.rs b/src/plan.rs index 9818c0c..2e5272a 100644 --- a/src/plan.rs +++ b/src/plan.rs @@ -263,12 +263,9 @@ mod tests { #[test] fn rejects_batch_and_empty_programs() { let empty = Command::new(""); - assert_eq!( - SpawnPlan::new_running(&empty, SpawnOptions::new(), IoMode::Spawn,) - .unwrap_err() - .kind(), - io::ErrorKind::InvalidInput - ); + let error = SpawnPlan::new_running(&empty, SpawnOptions::new(), IoMode::Spawn).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + assert_eq!(error.to_string(), "program must not be empty"); for script in ["thing.cmd", "THING.BAT"] { let command = Command::new(script); diff --git a/src/sys.rs b/src/sys.rs index 1a7b612..4ea087c 100644 --- a/src/sys.rs +++ b/src/sys.rs @@ -84,6 +84,8 @@ pub(crate) fn duplicate_local( source: BorrowedHandle<'_>, inheritable: bool, ) -> io::Result { + #[cfg(test)] + fault::check(fault::Call::DuplicateLocal)?; // SAFETY: `GetCurrentProcess` cannot fail and returns a pseudo-handle that stays valid and is never closed. let current = unsafe { GetCurrentProcess() }; duplicate_between( @@ -153,6 +155,8 @@ pub(crate) fn duplicate_remote<'a>( target_process: BorrowedHandle<'a>, inheritable: bool, ) -> io::Result> { + #[cfg(test)] + fault::check(fault::Call::DuplicateRemote)?; let mut value = ptr::null_mut(); // SAFETY: both process handles and `source` are valid; the returned value belongs to `target_process` and is owned by `RemoteHandle`. if unsafe { @@ -176,6 +180,8 @@ pub(crate) fn duplicate_remote<'a>( } pub(crate) fn standard_handle(stream: StandardStream) -> io::Result> { + #[cfg(test)] + fault::check(fault::Call::StandardHandle)?; let id = match stream { StandardStream::Input => STD_INPUT_HANDLE, StandardStream::Output => STD_OUTPUT_HANDLE, @@ -192,6 +198,8 @@ pub(crate) fn standard_handle(stream: StandardStream) -> io::Result io::Result { + #[cfg(test)] + fault::check(fault::Call::NullHandle)?; let name = [u16::from(b'N'), u16::from(b'U'), u16::from(b'L'), 0]; let desired = match access { NullAccess::Read => GENERIC_READ, @@ -224,6 +232,8 @@ pub(crate) struct Pipe { } pub(crate) fn create_pipe(parent_reads: bool) -> io::Result { + #[cfg(test)] + fault::check(fault::Call::CreatePipe)?; let mut read = ptr::null_mut(); let mut write = ptr::null_mut(); // SAFETY: both output pointers are valid, and null security attributes make both handles non-inheritable. @@ -251,12 +261,16 @@ pub(crate) fn create_pipe(parent_reads: bool) -> io::Result { } pub(crate) fn open_parent_process(pid: u32) -> io::Result { + #[cfg(test)] + fault::check(fault::Call::OpenProcess)?; // SAFETY: OpenProcess has no pointer preconditions. let handle = unsafe { OpenProcess(PROCESS_CREATE_PROCESS | PROCESS_DUP_HANDLE, 0, pid) }; owned(handle) } pub(crate) fn validate_process_handle(handle: BorrowedHandle<'_>) -> io::Result<()> { + #[cfg(test)] + fault::check(fault::Call::ValidateProcess)?; // SAFETY: the borrowed handle is valid for the query. if unsafe { GetProcessId(raw(handle)) } == 0 { Err(io::Error::last_os_error()) @@ -266,6 +280,8 @@ pub(crate) fn validate_process_handle(handle: BorrowedHandle<'_>) -> io::Result< } pub(crate) fn create_job() -> io::Result { + #[cfg(test)] + fault::check(fault::Call::CreateJob)?; // SAFETY: null arguments request an unnamed Job with default security. owned(unsafe { CreateJobObjectW(ptr::null(), ptr::null()) }) } @@ -275,6 +291,8 @@ pub(crate) fn validate_job_handle(handle: BorrowedHandle<'_>) -> io::Result<()> } pub(crate) fn set_job_kill_on_close(handle: BorrowedHandle<'_>, enable: bool) -> io::Result<()> { + #[cfg(test)] + fault::check(fault::Call::SetJob)?; let mut limits = query_job_limits(handle)?; if enable { limits.BasicLimitInformation.LimitFlags |= JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; @@ -301,6 +319,8 @@ pub(crate) fn set_job_kill_on_close(handle: BorrowedHandle<'_>, enable: bool) -> fn query_job_limits( handle: BorrowedHandle<'_>, ) -> io::Result { + #[cfg(test)] + fault::check(fault::Call::QueryJob)?; let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); // SAFETY: `limits` is writable storage of the size this information class requires, and the returned-size pointer is null. if unsafe { @@ -321,11 +341,15 @@ fn query_job_limits( } pub(crate) fn assign_job(job: BorrowedHandle<'_>, process: BorrowedHandle<'_>) -> io::Result<()> { + #[cfg(test)] + fault::check(fault::Call::AssignJob)?; // SAFETY: both handles are valid for the call. bool_result(unsafe { AssignProcessToJobObject(raw(job), raw(process)) }) } pub(crate) fn terminate_job(job: BorrowedHandle<'_>, exit_code: u32) -> io::Result<()> { + #[cfg(test)] + fault::check(fault::Call::TerminateJob)?; // SAFETY: the Job handle is valid for the call. bool_result(unsafe { TerminateJobObject(raw(job), exit_code) }) } @@ -336,28 +360,14 @@ pub(crate) struct AttributeList { impl AttributeList { pub(crate) fn new(count: u32) -> io::Result { + #[cfg(test)] + fault::check(fault::Call::AttributeList)?; let mut bytes = 0_usize; // SAFETY: the documented size query passes a null list. let probe = unsafe { InitializeProcThreadAttributeList(ptr::null_mut(), count, 0, &mut bytes) }; - let probe_error = io::Error::last_os_error(); - if probe != 0 - || probe_error.raw_os_error() - != Some( - i32::try_from(ERROR_INSUFFICIENT_BUFFER).expect("Win32 error code fits i32"), - ) - || bytes == 0 - { - return Err(if probe != 0 { - io::Error::other("attribute-list size probe unexpectedly succeeded") - } else { - probe_error - }); - } - let words = bytes - .checked_add(size_of::() - 1) - .ok_or_else(|| io::Error::other("attribute list is too large"))? - / size_of::(); + let bytes = probed_size(probe, io::Error::last_os_error(), bytes)?; + let words = storage_words(bytes); let mut storage = vec![0_usize; words].into_boxed_slice(); let pointer = storage.as_mut_ptr().cast(); let mut actual = words * size_of::(); @@ -410,6 +420,8 @@ impl AttributeList { } fn update(&mut self, attribute: usize, value: *const c_void, bytes: usize) -> io::Result<()> { + #[cfg(test)] + fault::check(fault::Call::UpdateAttribute)?; // SAFETY: the list is initialized, `value` points to `bytes` readable bytes or is the `HPCON` value, and the transaction keeps every backing allocation stable until CreateProcessW returns. if unsafe { UpdateProcThreadAttribute( @@ -434,6 +446,25 @@ impl AttributeList { } } +/// Returns the size a null-list size probe reported, or why the probe is unusable. +fn probed_size(probe: i32, error: io::Error, bytes: usize) -> io::Result { + if probe != 0 { + return Err(io::Error::other( + "attribute-list size probe unexpectedly succeeded", + )); + } + let insufficient = i32::try_from(ERROR_INSUFFICIENT_BUFFER).expect("Win32 error code fits i32"); + if error.raw_os_error() != Some(insufficient) || bytes == 0 { + return Err(error); + } + Ok(bytes) +} + +/// Returns how many words hold `bytes`. +fn storage_words(bytes: usize) -> usize { + bytes.div_ceil(size_of::()) +} + impl Drop for AttributeList { fn drop(&mut self) { #[cfg(test)] @@ -475,6 +506,8 @@ pub(crate) struct CreatedProcess { } pub(crate) fn create_process(request: &mut ProcessRequest<'_>) -> io::Result { + #[cfg(test)] + fault::check(fault::Call::CreateProcess)?; let mut startup = STARTUPINFOEXW::default(); startup.StartupInfo.cb = if request.attributes.is_some() { u32::try_from(size_of::()).expect("startup structure size fits u32") @@ -543,6 +576,8 @@ fn set_standard_handles(startup: &mut STARTUPINFOEXW, stdio: StartupStdio) { } pub(crate) fn wait_process(process: BorrowedHandle<'_>) -> io::Result<()> { + #[cfg(test)] + fault::check(fault::Call::WaitProcess)?; // SAFETY: the process handle is valid for the wait. match unsafe { WaitForSingleObject(raw(process), INFINITE) } { WAIT_OBJECT_0 => Ok(()), @@ -551,6 +586,8 @@ pub(crate) fn wait_process(process: BorrowedHandle<'_>) -> io::Result<()> { } pub(crate) fn try_wait_process(process: BorrowedHandle<'_>) -> io::Result { + #[cfg(test)] + fault::check(fault::Call::TryWaitProcess)?; // SAFETY: the process handle is valid for the query. match unsafe { WaitForSingleObject(raw(process), 0) } { WAIT_OBJECT_0 => Ok(true), @@ -559,6 +596,130 @@ pub(crate) fn try_wait_process(process: BorrowedHandle<'_>) -> io::Result } } +/// Test-only failure injection: a test fails the Nth wrapper call made on its thread. +#[cfg(test)] +pub(crate) mod fault { + use std::cell::RefCell; + use std::fmt; + use std::io; + + /// A fallible Win32 wrapper. + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub(crate) enum Call { + DuplicateLocal, + DuplicateRemote, + StandardHandle, + NullHandle, + CreatePipe, + OpenProcess, + ValidateProcess, + CreateJob, + QueryJob, + SetJob, + AssignJob, + TerminateJob, + AttributeList, + UpdateAttribute, + CreateProcess, + WaitProcess, + TryWaitProcess, + ExitStatus, + TerminateProcess, + ResumeThread, + ReadHandle, + WriteHandle, + EnvironmentStrings, + MaximumPath, + } + + struct State { + fail_at: Option, + seen: Vec, + } + + thread_local! { + static STATE: RefCell> = const { RefCell::new(None) }; + } + + /// Records this thread's wrapper calls, and fails one of them, until dropped. + pub(crate) struct Plan(()); + + impl Plan { + /// Returns the calls recorded so far. + pub(crate) fn calls(&self) -> Vec { + let Self(()) = self; + STATE.with(|state| { + state + .borrow() + .as_ref() + .map(|state| state.seen.clone()) + .unwrap_or_default() + }) + } + } + + impl Drop for Plan { + fn drop(&mut self) { + STATE.with(|state| *state.borrow_mut() = None); + } + } + + /// Records calls without failing any. + pub(crate) fn record() -> Plan { + install(None) + } + + /// Fails the call at `index` in this thread's call order. + pub(crate) fn fail_at(index: usize) -> Plan { + install(Some(index)) + } + + fn install(fail_at: Option) -> Plan { + STATE.with(|state| { + *state.borrow_mut() = Some(State { + fail_at, + seen: Vec::new(), + }); + }); + Plan(()) + } + + /// Fails this call if the installed plan says so. + pub(crate) fn check(call: Call) -> io::Result<()> { + STATE.with(|state| { + let mut state = state.borrow_mut(); + let Some(state) = state.as_mut() else { + return Ok(()); + }; + let index = state.seen.len(); + state.seen.push(call); + if state.fail_at == Some(index) { + Err(io::Error::other(Injected(call))) + } else { + Ok(()) + } + }) + } + + /// Returns true if `error` is the injected failure. + pub(crate) fn is_injected(error: &io::Error) -> bool { + error + .get_ref() + .is_some_and(::is::) + } + + #[derive(Debug)] + struct Injected(Call); + + impl fmt::Display for Injected { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "injected failure of {:?}", self.0) + } + } + + impl std::error::Error for Injected {} +} + /// Test helpers that call Win32 directly, so mutants of the production wrappers cannot disable them. #[cfg(test)] pub(crate) mod test_support { @@ -567,13 +728,66 @@ pub(crate) mod test_support { }; use std::ptr; - use windows_sys::Win32::Foundation::{DuplicateHandle, DUPLICATE_SAME_ACCESS, WAIT_OBJECT_0}; + use windows_sys::Win32::Foundation::{ + DuplicateHandle, GetHandleInformation, DUPLICATE_SAME_ACCESS, HANDLE_FLAG_INHERIT, + WAIT_OBJECT_0, + }; use windows_sys::Win32::System::Pipes::CreatePipe; use windows_sys::Win32::System::Threading::{ - GetCurrentProcess, GetExitCodeProcess, ResumeThread, TerminateProcess, WaitForSingleObject, - INFINITE, + GetCurrentProcess, GetExitCodeProcess, GetProcessHandleCount, ResumeThread, SuspendThread, + TerminateProcess, WaitForSingleObject, INFINITE, }; + const ISOLATED_ARGUMENT: &str = "windows-spawn-isolated"; + const ISOLATED_VARIABLE: &str = "WINDOWS_SPAWN_ISOLATED"; + + /// Reruns `test` alone in a fresh test process and returns false, unless this is that process. + /// + /// The marker travels as both an argument and a variable, so a mutant that loses one cannot make the rerun recurse. + pub(crate) fn isolated(test: &str) -> bool { + if std::env::var_os(ISOLATED_VARIABLE).is_some() + || std::env::args().any(|argument| argument == ISOLATED_ARGUMENT) + { + return true; + } + let status = crate::Command::new(std::env::current_exe().expect("the test binary path")) + .args(["--exact", test, "--test-threads=1", ISOLATED_ARGUMENT]) + .env(ISOLATED_VARIABLE, "1") + .stdin(crate::Stdio::null()) + .status() + .expect("the isolated test process starts"); + assert!(status.success(), "{test} failed in isolation"); + false + } + + /// Returns the current-process pseudo-handle. + pub(crate) fn current_process() -> BorrowedHandle<'static> { + // SAFETY: `GetCurrentProcess` cannot fail and returns a pseudo-handle valid for the process lifetime. + // `BorrowedHandle` never closes it, so a `'static` borrow cannot dangle or double-close. + unsafe { BorrowedHandle::borrow_raw(GetCurrentProcess() as RawHandle) } + } + + pub(crate) fn process_handle_count(process: BorrowedHandle<'_>) -> std::io::Result { + let mut count = 0; + // SAFETY: `process` is valid and `count` is writable DWORD storage. + if unsafe { GetProcessHandleCount(process.as_raw_handle(), &mut count) } == 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(count) + } + } + + /// Returns a thread's suspend count, leaving it unchanged. + pub(crate) fn suspend_count(thread: BorrowedHandle<'_>) -> u32 { + // SAFETY: the thread handle is valid and has THREAD_SUSPEND_RESUME access. + let previous = unsafe { SuspendThread(thread.as_raw_handle()) }; + assert_ne!(previous, u32::MAX, "SuspendThread failed"); + // SAFETY: the same handle is valid; this undoes the suspension above. + let resumed = unsafe { ResumeThread(thread.as_raw_handle()) }; + assert_ne!(resumed, u32::MAX, "ResumeThread failed"); + previous + } + /// Returns a non-inheritable duplicate with the same access. pub(crate) fn duplicate(handle: BorrowedHandle<'_>) -> OwnedHandle { let mut duplicate = ptr::null_mut(); @@ -594,6 +808,35 @@ pub(crate) mod test_support { unsafe { OwnedHandle::from_raw_handle(duplicate as RawHandle) } } + /// Returns a non-inheritable duplicate limited to `access`. + pub(crate) fn duplicate_with_access(handle: BorrowedHandle<'_>, access: u32) -> OwnedHandle { + let mut duplicate = ptr::null_mut(); + // SAFETY: both pseudo-handles and `handle` are valid, and `duplicate` is writable. + let duplicated = unsafe { + DuplicateHandle( + GetCurrentProcess(), + handle.as_raw_handle(), + GetCurrentProcess(), + &mut duplicate, + access, + 0, + 0, + ) + }; + assert_ne!(duplicated, 0, "DuplicateHandle failed"); + // SAFETY: DuplicateHandle returned a new, uniquely owned handle. + unsafe { OwnedHandle::from_raw_handle(duplicate as RawHandle) } + } + + /// Returns true if `handle` is inheritable. + pub(crate) fn is_inheritable(handle: BorrowedHandle<'_>) -> bool { + let mut flags = 0; + // SAFETY: `handle` is valid and `flags` is writable. + let queried = unsafe { GetHandleInformation(handle.as_raw_handle(), &mut flags) }; + assert_ne!(queried, 0, "GetHandleInformation failed"); + flags & HANDLE_FLAG_INHERIT != 0 + } + /// Returns a non-inheritable pipe as (reader, writer). pub(crate) fn pipe() -> (OwnedHandle, OwnedHandle) { let mut read = ptr::null_mut(); @@ -661,6 +904,9 @@ pub(crate) mod test_support { pub(crate) fn exit_status(process: BorrowedHandle<'_>) -> io::Result { use std::os::windows::process::ExitStatusExt; + #[cfg(test)] + fault::check(fault::Call::ExitStatus)?; + let mut code = 0_u32; // SAFETY: `code` is writable and the process handle is valid. if unsafe { GetExitCodeProcess(raw(process), &mut code) } == 0 { @@ -671,11 +917,15 @@ pub(crate) fn exit_status(process: BorrowedHandle<'_>) -> io::Result } pub(crate) fn terminate_process(process: BorrowedHandle<'_>, exit_code: u32) -> io::Result<()> { + #[cfg(test)] + fault::check(fault::Call::TerminateProcess)?; // SAFETY: the process handle is valid for the call. bool_result(unsafe { TerminateProcess(raw(process), exit_code) }) } pub(crate) fn resume_thread(thread: BorrowedHandle<'_>) -> io::Result { + #[cfg(test)] + fault::check(fault::Call::ResumeThread)?; // SAFETY: the thread handle is valid for the call. let previous = unsafe { ResumeThread(raw(thread)) }; if previous == u32::MAX { @@ -686,6 +936,8 @@ pub(crate) fn resume_thread(thread: BorrowedHandle<'_>) -> io::Result { } pub(crate) fn read_handle(handle: BorrowedHandle<'_>, buffer: &mut [u8]) -> io::Result { + #[cfg(test)] + fault::check(fault::Call::ReadHandle)?; if buffer.is_empty() { return Ok(0); } @@ -719,6 +971,8 @@ pub(crate) fn read_handle(handle: BorrowedHandle<'_>, buffer: &mut [u8]) -> io:: } pub(crate) fn write_handle(handle: BorrowedHandle<'_>, buffer: &[u8]) -> io::Result { + #[cfg(test)] + fault::check(fault::Call::WriteHandle)?; if buffer.is_empty() { return Ok(0); } @@ -742,6 +996,8 @@ pub(crate) fn write_handle(handle: BorrowedHandle<'_>, buffer: &[u8]) -> io::Res } pub(crate) fn environment_strings() -> io::Result> { + #[cfg(test)] + fault::check(fault::Call::EnvironmentStrings)?; // SAFETY: GetEnvironmentStringsW returns a double-NUL block that stays valid until FreeEnvironmentStringsW. let base = unsafe { GetEnvironmentStringsW() }; if base.is_null() { @@ -840,6 +1096,8 @@ pub(crate) fn full_path(path: &[u16]) -> io::Result> { /// `fill` returns the written length without the terminator, or zero on failure. /// A maximum-size buffer avoids a size-query retry loop. fn maximum_path(fill: impl FnOnce(*mut u16, u32) -> u32) -> io::Result> { + #[cfg(test)] + fault::check(fault::Call::MaximumPath)?; let mut buffer = vec![0_u16; 32_768]; let capacity = u32::try_from(buffer.len()).expect("maximum Windows path fits u32"); let length = fill(buffer.as_mut_ptr(), capacity) as usize; @@ -887,40 +1145,13 @@ mod tests { use std::os::windows::io::{AsHandle, AsRawHandle}; use std::path::PathBuf; + use super::test_support::{current_process, isolated, process_handle_count}; use super::*; - use windows_sys::Win32::System::Threading::GetProcessHandleCount; - - fn process_handle_count(process: BorrowedHandle<'_>) -> io::Result { - let mut count = 0; - // SAFETY: `process` is valid and `count` is writable DWORD storage. - if unsafe { GetProcessHandleCount(raw(process), &mut count) } == 0 { - Err(io::Error::last_os_error()) - } else { - Ok(count) - } - } - - fn current_process() -> BorrowedHandle<'static> { - // SAFETY: `GetCurrentProcess` cannot fail and returns a pseudo-handle valid for the process lifetime. - // `BorrowedHandle` never closes it, so a `'static` borrow cannot dangle or double-close. - unsafe { BorrowedHandle::borrow_raw(GetCurrentProcess() as RawHandle) } - } /// Compares this process's handle count, so it reruns alone in a fresh test process. #[test] fn pipe_null_and_duplicate_primitives_preserve_ownership() -> io::Result<()> { - const ISOLATED: &str = "WINDOWS_SPAWN_ISOLATED_HANDLE_COUNT"; - if std::env::var_os(ISOLATED).is_none() { - let status = crate::Command::new(std::env::current_exe()?) - .args([ - "--exact", - "sys::tests::pipe_null_and_duplicate_primitives_preserve_ownership", - "--test-threads=1", - ]) - .env(ISOLATED, "1") - .stdin(crate::Stdio::null()) - .status()?; - assert!(status.success(), "the isolated handle-count test failed"); + if !isolated("sys::tests::pipe_null_and_duplicate_primitives_preserve_ownership") { return Ok(()); } assert_eq!( @@ -1094,4 +1325,110 @@ mod tests { assert!(validate_job_handle(file.as_handle()).is_err()); Ok(()) } + + #[test] + fn attribute_list_sizing_classifies_every_probe_result() { + let insufficient = || { + io::Error::from_raw_os_error( + i32::try_from(ERROR_INSUFFICIENT_BUFFER).expect("Win32 error code fits i32"), + ) + }; + assert_eq!(probed_size(0, insufficient(), 48).unwrap(), 48); + assert!(probed_size(1, insufficient(), 48).is_err()); + assert!(probed_size(0, io::Error::from_raw_os_error(5), 48).is_err()); + assert!(probed_size(0, insufficient(), 0).is_err()); + + let word = size_of::(); + assert_eq!(storage_words(1), 1); + assert_eq!(storage_words(word), 1); + assert_eq!(storage_words(word + 1), 2); + assert_eq!(storage_words(usize::MAX), usize::MAX / word + 1); + } + + #[test] + fn failed_win32_calls_return_their_errors() -> io::Result<()> { + use windows_sys::Win32::System::Threading::{ + GetCurrentThread, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, + }; + + /// `JOB_OBJECT_QUERY` from winnt.h, outside the enabled windows-sys features. + const JOB_OBJECT_QUERY: u32 = 0x0004; + + let unwaitable = test_support::duplicate_with_access( + current_process(), + PROCESS_QUERY_LIMITED_INFORMATION, + ); + assert!(wait_process(unwaitable.as_handle()).is_err()); + assert!(try_wait_process(unwaitable.as_handle()).is_err()); + let unqueryable = + test_support::duplicate_with_access(current_process(), PROCESS_SYNCHRONIZE); + assert!(exit_status(unqueryable.as_handle()).is_err()); + + // SAFETY: `GetCurrentThread` cannot fail and returns a pseudo-handle valid for this call. + let thread = unsafe { BorrowedHandle::borrow_raw(GetCurrentThread() as RawHandle) }; + let unresumable = test_support::duplicate_with_access(thread, PROCESS_SYNCHRONIZE); + assert!(resume_thread(unresumable.as_handle()).is_err()); + + let (reader, writer) = test_support::pipe(); + assert!(read_handle(writer.as_handle(), &mut [0_u8; 1]).is_err()); + assert!(write_handle(reader.as_handle(), b"x").is_err()); + + let job = create_job()?; + let query_only = test_support::duplicate_with_access(job.as_handle(), JOB_OBJECT_QUERY); + assert!(set_job_kill_on_close(query_only.as_handle(), true).is_err()); + + assert!(AttributeList::new(u32::MAX).is_err()); + let mut full = AttributeList::new(1)?; + let jobs = [job.as_raw_handle() as isize]; + full.set_jobs(&jobs)?; + let words = [1_u64, 0_u64]; + assert!(full.set_mitigation(&words).is_err()); + + assert_eq!( + full_path(&[u16::from(b'a')]).unwrap_err().kind(), + io::ErrorKind::InvalidInput + ); + assert!(maximum_path(|_, _| 0).is_err()); + assert!(maximum_path(|_, capacity| capacity).is_err()); + let written = maximum_path(|buffer, _| { + // SAFETY: `maximum_path` passes a buffer writable for its capacity, which exceeds one unit. + unsafe { buffer.write(u16::from(b'x')) }; + 1 + })?; + assert_eq!(written, [u16::from(b'x')]); + Ok(()) + } + + #[test] + fn system_and_windows_directories_are_distinct() -> io::Result<()> { + let system = system_directory()?.to_string_lossy().to_lowercase(); + let windows = windows_directory()?.to_string_lossy().to_lowercase(); + assert_ne!(system, windows); + assert!(system.starts_with(&windows)); + assert!(system.ends_with("system32")); + Ok(()) + } + + #[test] + fn standard_handles_are_private_duplicates() -> io::Result<()> { + let output = + standard_handle(StandardStream::Output)?.expect("the test has standard output"); + assert!(!test_support::is_inheritable(output.as_handle())); + Ok(()) + } + + /// Changes this process's standard error slot, so it runs alone in a fresh test process. + #[test] + fn a_missing_standard_handle_is_none() -> io::Result<()> { + use windows_sys::Win32::System::Console::SetStdHandle; + + if !isolated("sys::tests::a_missing_standard_handle_is_none") { + return Ok(()); + } + // SAFETY: this isolated process no longer needs its standard error slot. + let cleared = unsafe { SetStdHandle(STD_ERROR_HANDLE, ptr::null_mut()) }; + assert_ne!(cleared, 0); + assert!(standard_handle(StandardStream::Error)?.is_none()); + Ok(()) + } } diff --git a/src/transaction.rs b/src/transaction.rs index 2e2b933..6314a63 100644 --- a/src/transaction.rs +++ b/src/transaction.rs @@ -34,6 +34,18 @@ pub(crate) struct SpawnTransaction { state: PhantomData, } +impl SpawnTransaction { + /// Borrows the created process's primary thread. + #[cfg(test)] + pub(crate) fn primary_thread(&self) -> BorrowedHandle<'_> { + self.created + .as_ref() + .expect("an uncommitted transaction owns its process") + .thread + .as_handle() + } +} + impl SpawnTransaction { #[allow(clippy::too_many_lines)] pub(crate) fn new<'command, 'options>( @@ -622,7 +634,9 @@ mod tests { use super::*; use crate::handles::Stdio; - use crate::sys::test_support::{duplicate, pipe, tempt_resume, ProcessExitGuard}; + use crate::sys::test_support::{ + duplicate, pipe, suspend_count, tempt_resume, ProcessExitGuard, + }; fn decode(value: &[u16]) -> String { String::from_utf16_lossy(value.strip_suffix(&[0]).unwrap_or(value)) @@ -791,15 +805,77 @@ mod tests { cleared.env("PATH", "before").env_clear(); let mut cleared_then_set = Command::new("cmd.exe"); cleared_then_set.env_clear().env("pAtH", "after"); + let mut set_then_other_removed = Command::new("cmd.exe"); + set_then_other_removed + .env("PATH", "kept") + .env_remove("WINDOWS_SPAWN_OTHER"); assert_eq!(child_path(&Command::new("cmd.exe")).unwrap(), None); - for command in [inherited, replaced, removed, cleared, cleared_then_set] { + assert_eq!( + child_path(&set_then_other_removed).unwrap(), + Some(OsString::from("kept")) + ); + for command in [ + inherited, + replaced, + removed, + cleared, + cleared_then_set, + set_then_other_removed, + ] { let mut transfer = HandleTransfer::new(None); let environment = build_environment(&command, &mut transfer).unwrap(); assert_eq!(child_path(&command).unwrap(), environment.path); } } + #[test] + fn executable_resolution_rejects_or_skips_nul_paths() { + assert_eq!( + resolve_executable(OsStr::new("dir\\a\0b"), None) + .unwrap_err() + .kind(), + io::ErrorKind::InvalidInput + ); + let system = PathBuf::from(sys::system_directory().unwrap()); + assert_eq!( + decode(&resolve_executable(OsStr::new("cmd"), Some(OsStr::new("a\0b"))).unwrap()) + .to_lowercase(), + system.join("cmd.exe").to_string_lossy().to_lowercase() + ); + } + + #[test] + fn executable_resolution_searches_the_application_directory() { + let mut directory = env::current_exe().unwrap(); + directory.pop(); + let name = format!("windows-spawn-app-dir-{}.exe", std::process::id()); + let path = directory.join(&name); + File::create(&path).unwrap(); + let found = resolve_executable(OsStr::new(&name), None); + std::fs::remove_file(&path).unwrap(); + assert_eq!(decode(&found.unwrap()), path.to_string_lossy()); + } + + #[test] + fn executable_resolution_keeps_other_extensions_and_missing_paths() { + let system = PathBuf::from(sys::system_directory().unwrap()); + assert_eq!( + decode(&resolve_executable(OsStr::new("more.com"), None).unwrap()).to_lowercase(), + system.join("more.com").to_string_lossy().to_lowercase() + ); + assert_eq!( + decode(&resolve_executable(OsStr::new(r"nested\tool"), None).unwrap()), + r"nested\tool" + ); + let mut system_only = Command::new("cmd"); + system_only.env_clear(); + assert_eq!( + broker_command_line(&system_only).unwrap(), + OsString::from(format!("\"{}\"", system.join("cmd.exe").display())) + ); + } + #[test] fn executable_resolution_covers_explicit_child_path_and_not_found() { let system = PathBuf::from(sys::system_directory().unwrap()); @@ -862,8 +938,13 @@ mod tests { ) .unwrap(); let transaction = SpawnTransaction::new(&plan).unwrap(); - let mut process = - ProcessExitGuard::watch(transaction.created.as_ref().unwrap().process.as_handle()); + let created = transaction.created.as_ref().unwrap(); + let mut process = ProcessExitGuard::watch(created.process.as_handle()); + assert_eq!( + suspend_count(created.thread.as_handle()), + 0, + "a running spawn must not start suspended" + ); drop(plan); drop(command); diff --git a/tests/support/mod.rs b/tests/support/mod.rs index c001bac..a8b7abd 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -340,6 +340,27 @@ pub(crate) fn suspend_count(thread: BorrowedHandle<'_>) -> io::Result { Ok(previous) } +const ISOLATED_ARGUMENT: &str = "windows-spawn-isolated"; +const ISOLATED_VARIABLE: &str = "WINDOWS_SPAWN_ISOLATED"; + +/// Reruns `test` alone in a fresh test process and returns false, unless this is that process. +/// +/// The marker travels as both an argument and a variable, so a mutant that loses one cannot make the rerun recurse. +pub(crate) fn isolated(test: &str) -> io::Result { + if std::env::var_os(ISOLATED_VARIABLE).is_some() + || std::env::args().any(|argument| argument == ISOLATED_ARGUMENT) + { + return Ok(true); + } + let status = Command::new(std::env::current_exe()?) + .args(["--exact", test, "--test-threads=1", ISOLATED_ARGUMENT]) + .env(ISOLATED_VARIABLE, "1") + .stdin(Stdio::null()) + .status()?; + assert!(status.success(), "{test} failed in isolation"); + Ok(false) +} + /// A new directory under the temporary directory, removed on drop. pub(crate) struct TempDir(PathBuf); diff --git a/tests/windows_spawn.rs b/tests/windows_spawn.rs index a44ab86..4b81a0c 100644 --- a/tests/windows_spawn.rs +++ b/tests/windows_spawn.rs @@ -28,8 +28,9 @@ use windows_sys::Win32::Storage::FileSystem::{ FILE_TYPE_UNKNOWN, }; use windows_sys::Win32::System::Console::{ - ClosePseudoConsole, CreatePseudoConsole, GetConsoleCP, GetStdHandle, COORD, HPCON, - STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, + ClosePseudoConsole, CreatePseudoConsole, GetConsoleCP, GetConsoleScreenBufferInfo, + GetStdHandle, CONSOLE_SCREEN_BUFFER_INFO, COORD, HPCON, STD_ERROR_HANDLE, STD_INPUT_HANDLE, + STD_OUTPUT_HANDLE, }; use windows_sys::Win32::System::Pipes::CreatePipe; use windows_sys::Win32::System::Threading::{ @@ -42,6 +43,20 @@ const PCON_STDIO_PROBE: &str = "WINDOWS_SPAWN_PCON_STDIO_PROBE"; const PCON_STDIN_MARKER: &[u8] = b"windows-spawn-pcon-stdin"; const PCON_STDOUT_MARKER: &[u8] = b"windows-spawn-pcon-stdout"; const PCON_STDERR_MARKER: &[u8] = b"windows-spawn-pcon-stderr"; +/// An unusual pseudoconsole size, so a probe can tell it is attached to the test's pseudoconsole. +const PCON_SIZE: COORD = COORD { X: 97, Y: 31 }; + +/// Returns true if this process's console has the test pseudoconsole's size. +fn attached_to_test_pseudoconsole() -> bool { + let Ok(output) = File::options().read(true).write(true).open("CONOUT$") else { + return false; + }; + let mut information = CONSOLE_SCREEN_BUFFER_INFO::default(); + // SAFETY: `output` is an open console handle and `information` is writable. + let queried = + unsafe { GetConsoleScreenBufferInfo(output.as_raw_handle() as HANDLE, &mut information) }; + queried != 0 && information.dwSize.X == PCON_SIZE.X && information.dwSize.Y == PCON_SIZE.Y +} fn cmd(script: &str) -> Command { let mut command = Command::new("cmd.exe"); @@ -313,7 +328,6 @@ fn requested_mitigation_is_visible_on_the_real_process() -> io::Result<()> { #[test] fn failed_transactions_do_not_leak_handles() -> io::Result<()> { - const PROBE: &str = "WINDOWS_SPAWN_HANDLE_LEAK_PROBE"; fn handle_count() -> io::Result { let mut count = 0; // SAFETY: the pseudo-handle is valid and `count` is writable DWORD storage. @@ -337,16 +351,7 @@ fn failed_transactions_do_not_leak_handles() -> io::Result<()> { assert_ne!(error.kind(), io::ErrorKind::InvalidInput); } - if std::env::var_os(PROBE).is_none() { - let status = Command::new(std::env::current_exe()?) - .args([ - "--exact", - "failed_transactions_do_not_leak_handles", - "--test-threads=1", - ]) - .env(PROBE, "1") - .status()?; - assert!(status.success(), "isolated handle-leak probe failed"); + if !support::isolated("failed_transactions_do_not_leak_handles")? { return Ok(()); } @@ -589,7 +594,7 @@ impl TestPseudoConsole { // SAFETY: the pipe handles are valid for the call and `value` is writable; this type owns the HPCON. let result = unsafe { CreatePseudoConsole( - COORD { X: 80, Y: 25 }, + PCON_SIZE, input_reader.as_raw_handle() as HANDLE, output_writer.as_raw_handle() as HANDLE, 0, @@ -696,7 +701,7 @@ fn pseudoconsole_attribute_connects_the_child_console() -> io::Result<()> { Ok(()) } -/// A nonzero console code page proves the child is attached to a console; `CONOUT$` is an extra check. +/// The console's size proves the child is attached to the test pseudoconsole, so a missing attribute fails before any output wait. #[test] fn pseudoconsole_child_probe() { if std::env::var_os("WINDOWS_SPAWN_PCON_PROBE").is_none() { @@ -704,6 +709,7 @@ fn pseudoconsole_child_probe() { } // SAFETY: GetConsoleCP has no pointer preconditions. assert_ne!(unsafe { GetConsoleCP() }, 0); + assert!(attached_to_test_pseudoconsole()); let mut output = File::options().write(true).open("CONOUT$").unwrap(); output.write_all(b"windows-spawn-pcon-attached").unwrap(); } @@ -802,6 +808,9 @@ fn pseudoconsole_regular_stdio_probe() -> io::Result<()> { if std::env::var_os(PCON_STDIO_PROBE).is_none() { return Ok(()); } + if !attached_to_test_pseudoconsole() { + return Err(io::Error::other("not attached to the test pseudoconsole")); + } let mut line = String::new(); io::stdin().read_line(&mut line)?;