Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions .rust-mutants.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<RemoteHandle as Drop>::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(&QUOTE)"
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."
23 changes: 23 additions & 0 deletions docs/adr/0012-win32-failures-are-injected-in-tests.md
Original file line number Diff line number Diff line change
@@ -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.
54 changes: 54 additions & 0 deletions src/child.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<u8>> { 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());
Expand Down
43 changes: 43 additions & 0 deletions src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
}
Loading
Loading