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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ jobs:
- run: python -m pip install "reuse[charset-normalizer]==6.2.0"
- run: just fmt
- run: just clippy
- run: just gates
- run: just doc
- run: just public-api
- run: just msrv
Expand Down
26 changes: 4 additions & 22 deletions .rust-mutants.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,6 @@ 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"
Expand All @@ -67,15 +58,6 @@ 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"
Expand Down Expand Up @@ -120,20 +102,20 @@ outcome = "survived"

[[mutation.skip]]
path = "src/sys.rs"
lines = "1004-1004"
lines = "997-997"
reason = "Unreachable: GetEnvironmentStringsW returns null only when out of memory."

[[mutation.skip]]
path = "src/sys.rs"
lines = "1012-1012"
lines = "1005-1005"
reason = "Hang-only: continuing past the block terminator loops forever."

[[mutation.skip]]
path = "src/transaction.rs"
lines = "492-495"
lines = "494-497"
reason = "Unreachable: GetFullPathNameW cannot return a double quote."

[[mutation.skip]]
path = "src/transaction.rs"
lines = "106-106"
lines = "104-104"
reason = "Unreachable: wide_nul fails only on an interior NUL, which validate_command rejects first; rejects_every_malformed_text_component holds it."
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ gitleaks dir . --redact --no-banner

- Keep the documented ownership and cleanup behavior, including on errors.
- Give every `unsafe` block a specific safety justification.
- Write only doc comments and `// SAFETY:` comments; the reason for a change goes in its commit message (ADR 0013).
- Register every `#[allow]` in `xtask/src/gates.rs` with its reason; `just gates` checks the registry and the other repository laws.
- Add deterministic tests for behavior changes; tests wait for events, never for time (ADR 0010).
- Change the public API snapshot only with an API change, and state its compatibility impact.
- Update the crate docs, ADRs, or security boundary when a contract changes.
Expand Down
45 changes: 43 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,56 @@ default-members = ["."]
resolver = "2"

[workspace.lints.rust]
missing_docs = "deny"
future_incompatible = { level = "deny", priority = -1 }
rust_2018_idioms = { level = "deny", priority = -1 }
unsafe_op_in_unsafe_fn = "deny"
unused = { level = "deny", priority = -1 }
let_underscore_drop = "deny"
meta_variable_misuse = "deny"
missing_copy_implementations = "deny"
missing_debug_implementations = "deny"
missing_docs = "deny"
non_ascii_idents = "deny"
single_use_lifetimes = "deny"
trivial_casts = "deny"
trivial_numeric_casts = "deny"
unreachable_pub = "deny"
unsafe_op_in_unsafe_fn = "deny"
unused_lifetimes = "deny"
unused_qualifications = "deny"
variant_size_differences = "deny"

[workspace.lints.clippy]
all = { level = "deny", priority = -1 }
cargo = { level = "deny", priority = -1 }
nursery = { level = "deny", priority = -1 }
pedantic = { level = "deny", priority = -1 }
arithmetic_side_effects = "deny"
as_conversions = "deny"
clone_on_ref_ptr = "deny"
dbg_macro = "deny"
disallowed_methods = "deny"
disallowed_types = "deny"
error_impl_error = "deny"
expect_used = "deny"
fallible_impl_from = "deny"
indexing_slicing = "deny"
let_underscore_must_use = "deny"
map_err_ignore = "deny"
mem_forget = "deny"
panic = "deny"
shadow_unrelated = "deny"
str_to_string = "deny"
string_slice = "deny"
todo = "deny"
try_err = "deny"
unimplemented = "deny"
unreachable = "deny"
unused_result_ok = "deny"
unwrap_in_result = "deny"
unwrap_used = "deny"
wildcard_enum_match_arm = "deny"
# Contradicts rustc's `unreachable_pub`, which this crate keeps: items of private modules stay `pub(crate)`.
redundant_pub_crate = "allow"

# Enforces CONTRIBUTING's rule that every `unsafe` block has a safety justification.
#
Expand Down
9 changes: 9 additions & 0 deletions clippy.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
allow-expect-in-tests = true
allow-indexing-slicing-in-tests = true
allow-panic-in-tests = true
allow-unwrap-in-tests = true

# Time decides nothing (docs/adr/0010-time-decides-nothing.md).
disallowed-types = [
{ path = "std::time::Duration", reason = "time decides nothing; wait for the event itself" },
Expand All @@ -13,4 +18,8 @@ disallowed-methods = [
{ path = "std::fs::Metadata::modified", reason = "file times decide nothing; compare content" },
{ path = "std::fs::Metadata::accessed", reason = "file times decide nothing; compare content" },
{ path = "std::fs::Metadata::created", reason = "file times decide nothing; compare content" },
{ path = "std::mem::forget", reason = "leaks destructors; use ManuallyDrop" },
{ path = "std::process::exit", reason = "skips destructors; return an exit code" },
{ path = "std::env::set_var", reason = "races with other threads; pass the value explicitly" },
{ path = "std::env::remove_var", reason = "races with other threads; pass the value explicitly" },
]
39 changes: 39 additions & 0 deletions docs/adr/0013-repository-laws-are-enforced-mechanically.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# 0013 — Repository laws are enforced mechanically

Status: accepted (2026-09-25)

## Context

Rules kept by review alone erode: timed waits, explanatory comments, and silent conversions came back after they were removed.
Rust 1.75, the MSRV, has neither `#[expect]` nor `reason =`, so a lint exception can neither state its reason nor prove it is still needed.

## Decision

Every law has a mechanism, and CI runs it.

- Code fails loudly and converts explicitly.
`[workspace.lints]` denies the Clippy `all`, `pedantic`, `nursery`, and `cargo` groups and a restriction set: no `unwrap`, `expect`, `panic`, indexing, unchecked arithmetic, `as`, or discarded `Result`.
`clippy.toml` permits `unwrap`, `expect`, panics, and indexing in tests only.
- Time decides nothing (ADR 0010).
`clippy.toml` bans the time types, timed waits, and file times.
`cargo xtask gates` bans time-named identifiers, delay commands in strings, and `WaitForSingleObject` with a bound other than `INFINITE` or `0`.
- Reasons belong in commit messages.
The gate allows doc comments and whole-line `// SAFETY:` runs, and nothing else.
- Every lint exception is registered.
The gate collects each `#[allow]` lint and requires an entry with the same file, lint, and count, with a reason, in `xtask/src/gates.rs`.
An entry no attribute uses also fails.
- Defaults are chosen explicitly.
The gate bans `#[default]`; a documented `impl Default` states the choice.
- Types are closed or generic.
The gate bans `Box<dyn …>`; a closed set is an enum, and an open one is a type parameter.
- Nothing escapes ownership or mutates process state.
`clippy.toml` bans `mem::forget`, `process::exit`, `env::set_var`, and `env::remove_var`.

The gate is a lexer in xtask with no dependencies, because `syn` needs `unicode-ident`, whose license `deny.toml` does not allow.
Rustc lints are limited to those Rust 1.75 recognizes.

## Consequences

- Changing a law changes the lint table, `clippy.toml`, or the gate, with its tests.
- Pointer-integer and `DWORD` width conversions keep `as` behind registered exceptions in `sys`, because Rust 1.75 has no `From` form for them.
- When the MSRV reaches Rust 1.81, `#[expect(…, reason = …)]` replaces the registry.
3 changes: 3 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ fmt:
clippy:
cargo xtask clippy

gates:
cargo xtask gates

test:
cargo xtask test

Expand Down
26 changes: 13 additions & 13 deletions public-api/windows-spawn.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ impl core::cmp::Eq for windows_spawn::BlockNonCetBinaries
impl core::cmp::PartialEq for windows_spawn::BlockNonCetBinaries
pub fn windows_spawn::BlockNonCetBinaries::eq(&self, &windows_spawn::BlockNonCetBinaries) -> bool
impl core::default::Default for windows_spawn::BlockNonCetBinaries
pub fn windows_spawn::BlockNonCetBinaries::default() -> windows_spawn::BlockNonCetBinaries
pub fn windows_spawn::BlockNonCetBinaries::default() -> Self
impl core::fmt::Debug for windows_spawn::BlockNonCetBinaries
pub fn windows_spawn::BlockNonCetBinaries::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
impl core::hash::Hash for windows_spawn::BlockNonCetBinaries
Expand All @@ -35,7 +35,7 @@ impl core::cmp::Eq for windows_spawn::CetShadowStacks
impl core::cmp::PartialEq for windows_spawn::CetShadowStacks
pub fn windows_spawn::CetShadowStacks::eq(&self, &windows_spawn::CetShadowStacks) -> bool
impl core::default::Default for windows_spawn::CetShadowStacks
pub fn windows_spawn::CetShadowStacks::default() -> windows_spawn::CetShadowStacks
pub fn windows_spawn::CetShadowStacks::default() -> Self
impl core::fmt::Debug for windows_spawn::CetShadowStacks
pub fn windows_spawn::CetShadowStacks::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
impl core::hash::Hash for windows_spawn::CetShadowStacks
Expand All @@ -60,7 +60,7 @@ impl core::cmp::Eq for windows_spawn::ControlFlowGuard
impl core::cmp::PartialEq for windows_spawn::ControlFlowGuard
pub fn windows_spawn::ControlFlowGuard::eq(&self, &windows_spawn::ControlFlowGuard) -> bool
impl core::default::Default for windows_spawn::ControlFlowGuard
pub fn windows_spawn::ControlFlowGuard::default() -> windows_spawn::ControlFlowGuard
pub fn windows_spawn::ControlFlowGuard::default() -> Self
impl core::fmt::Debug for windows_spawn::ControlFlowGuard
pub fn windows_spawn::ControlFlowGuard::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
impl core::hash::Hash for windows_spawn::ControlFlowGuard
Expand All @@ -83,7 +83,7 @@ impl core::cmp::Eq for windows_spawn::DropPolicy
impl core::cmp::PartialEq for windows_spawn::DropPolicy
pub fn windows_spawn::DropPolicy::eq(&self, &windows_spawn::DropPolicy) -> bool
impl core::default::Default for windows_spawn::DropPolicy
pub fn windows_spawn::DropPolicy::default() -> windows_spawn::DropPolicy
pub fn windows_spawn::DropPolicy::default() -> Self
impl core::fmt::Debug for windows_spawn::DropPolicy
pub fn windows_spawn::DropPolicy::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
impl core::hash::Hash for windows_spawn::DropPolicy
Expand All @@ -108,7 +108,7 @@ impl core::cmp::Eq for windows_spawn::DynamicCode
impl core::cmp::PartialEq for windows_spawn::DynamicCode
pub fn windows_spawn::DynamicCode::eq(&self, &windows_spawn::DynamicCode) -> bool
impl core::default::Default for windows_spawn::DynamicCode
pub fn windows_spawn::DynamicCode::default() -> windows_spawn::DynamicCode
pub fn windows_spawn::DynamicCode::default() -> Self
impl core::fmt::Debug for windows_spawn::DynamicCode
pub fn windows_spawn::DynamicCode::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
impl core::hash::Hash for windows_spawn::DynamicCode
Expand All @@ -133,7 +133,7 @@ impl core::cmp::Eq for windows_spawn::FontDisable
impl core::cmp::PartialEq for windows_spawn::FontDisable
pub fn windows_spawn::FontDisable::eq(&self, &windows_spawn::FontDisable) -> bool
impl core::default::Default for windows_spawn::FontDisable
pub fn windows_spawn::FontDisable::default() -> windows_spawn::FontDisable
pub fn windows_spawn::FontDisable::default() -> Self
impl core::fmt::Debug for windows_spawn::FontDisable
pub fn windows_spawn::FontDisable::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
impl core::hash::Hash for windows_spawn::FontDisable
Expand All @@ -158,7 +158,7 @@ impl core::cmp::Eq for windows_spawn::LoaderIntegrity
impl core::cmp::PartialEq for windows_spawn::LoaderIntegrity
pub fn windows_spawn::LoaderIntegrity::eq(&self, &windows_spawn::LoaderIntegrity) -> bool
impl core::default::Default for windows_spawn::LoaderIntegrity
pub fn windows_spawn::LoaderIntegrity::default() -> windows_spawn::LoaderIntegrity
pub fn windows_spawn::LoaderIntegrity::default() -> Self
impl core::fmt::Debug for windows_spawn::LoaderIntegrity
pub fn windows_spawn::LoaderIntegrity::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
impl core::hash::Hash for windows_spawn::LoaderIntegrity
Expand All @@ -182,7 +182,7 @@ impl core::cmp::Eq for windows_spawn::Mitigation
impl core::cmp::PartialEq for windows_spawn::Mitigation
pub fn windows_spawn::Mitigation::eq(&self, &windows_spawn::Mitigation) -> bool
impl core::default::Default for windows_spawn::Mitigation
pub fn windows_spawn::Mitigation::default() -> windows_spawn::Mitigation
pub fn windows_spawn::Mitigation::default() -> Self
impl core::fmt::Debug for windows_spawn::Mitigation
pub fn windows_spawn::Mitigation::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
impl core::hash::Hash for windows_spawn::Mitigation
Expand All @@ -207,7 +207,7 @@ impl core::cmp::Eq for windows_spawn::ModuleTampering
impl core::cmp::PartialEq for windows_spawn::ModuleTampering
pub fn windows_spawn::ModuleTampering::eq(&self, &windows_spawn::ModuleTampering) -> bool
impl core::default::Default for windows_spawn::ModuleTampering
pub fn windows_spawn::ModuleTampering::default() -> windows_spawn::ModuleTampering
pub fn windows_spawn::ModuleTampering::default() -> Self
impl core::fmt::Debug for windows_spawn::ModuleTampering
pub fn windows_spawn::ModuleTampering::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
impl core::hash::Hash for windows_spawn::ModuleTampering
Expand All @@ -232,7 +232,7 @@ impl core::cmp::Eq for windows_spawn::RelocateImages
impl core::cmp::PartialEq for windows_spawn::RelocateImages
pub fn windows_spawn::RelocateImages::eq(&self, &windows_spawn::RelocateImages) -> bool
impl core::default::Default for windows_spawn::RelocateImages
pub fn windows_spawn::RelocateImages::default() -> windows_spawn::RelocateImages
pub fn windows_spawn::RelocateImages::default() -> Self
impl core::fmt::Debug for windows_spawn::RelocateImages
pub fn windows_spawn::RelocateImages::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
impl core::hash::Hash for windows_spawn::RelocateImages
Expand All @@ -257,7 +257,7 @@ impl core::cmp::Eq for windows_spawn::SignedBinaries
impl core::cmp::PartialEq for windows_spawn::SignedBinaries
pub fn windows_spawn::SignedBinaries::eq(&self, &windows_spawn::SignedBinaries) -> bool
impl core::default::Default for windows_spawn::SignedBinaries
pub fn windows_spawn::SignedBinaries::default() -> windows_spawn::SignedBinaries
pub fn windows_spawn::SignedBinaries::default() -> Self
impl core::fmt::Debug for windows_spawn::SignedBinaries
pub fn windows_spawn::SignedBinaries::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
impl core::hash::Hash for windows_spawn::SignedBinaries
Expand All @@ -282,7 +282,7 @@ impl core::cmp::Eq for windows_spawn::UserCetContextIpValidation
impl core::cmp::PartialEq for windows_spawn::UserCetContextIpValidation
pub fn windows_spawn::UserCetContextIpValidation::eq(&self, &windows_spawn::UserCetContextIpValidation) -> bool
impl core::default::Default for windows_spawn::UserCetContextIpValidation
pub fn windows_spawn::UserCetContextIpValidation::default() -> windows_spawn::UserCetContextIpValidation
pub fn windows_spawn::UserCetContextIpValidation::default() -> Self
impl core::fmt::Debug for windows_spawn::UserCetContextIpValidation
pub fn windows_spawn::UserCetContextIpValidation::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
impl core::hash::Hash for windows_spawn::UserCetContextIpValidation
Expand Down Expand Up @@ -528,7 +528,7 @@ pub const fn windows_spawn::SpawnOptions<'a>::drop_policy(self, windows_spawn::D
pub fn windows_spawn::SpawnOptions<'a>::job(self, &'a windows_spawn::Job) -> Self
pub const fn windows_spawn::SpawnOptions<'a>::mitigation(self, windows_spawn::MitigationPolicy) -> Self
pub fn windows_spawn::SpawnOptions<'a>::new() -> Self
pub fn windows_spawn::SpawnOptions<'a>::parent_process(self, &'a windows_spawn::ParentProcess) -> Self
pub const fn windows_spawn::SpawnOptions<'a>::parent_process(self, &'a windows_spawn::ParentProcess) -> Self
pub fn windows_spawn::SpawnOptions<'a>::pseudoconsole<T: windows_spawn::AsPseudoConsole>(self, &'a T) -> Self
impl core::default::Default for windows_spawn::SpawnOptions<'_>
pub fn windows_spawn::SpawnOptions<'_>::default() -> Self
Expand Down
10 changes: 6 additions & 4 deletions src/child.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,15 +207,15 @@ fn drain_output(handle: BorrowedHandle<'_>) -> io::Result<Vec<u8>> {
let Some(read) = std::num::NonZeroUsize::new(sys::read_handle(handle, &mut buffer)?) else {
return Ok(bytes);
};
bytes.extend_from_slice(&buffer[..read.get()]);
bytes.extend(buffer.iter().take(read.get()));
}
}

fn join_reader(reader: Option<thread::JoinHandle<io::Result<Vec<u8>>>>) -> io::Result<Vec<u8>> {
match reader {
Some(reader) => reader
.join()
.map_err(|_| io::Error::other("output reader thread panicked"))?,
.map_err(|_payload| io::Error::other("output reader thread panicked"))?,
None => Ok(Vec::new()),
}
}
Expand Down Expand Up @@ -251,7 +251,7 @@ pub struct SuspendedChild {
}

impl SuspendedChild {
pub(crate) fn new(child: Child, main_thread: OwnedHandle) -> Self {
pub(crate) const fn new(child: Child, main_thread: OwnedHandle) -> Self {
Self {
child: Some(child),
main_thread,
Expand All @@ -264,6 +264,7 @@ impl SuspendedChild {
///
/// Panics only if an internal ownership invariant is broken.
#[must_use]
#[allow(clippy::expect_used)]
pub fn id(&self) -> u32 {
self.child
.as_ref()
Expand Down Expand Up @@ -300,6 +301,7 @@ impl SuspendedChild {
}

impl AsHandle for SuspendedChild {
#[allow(clippy::expect_used)]
fn as_handle(&self) -> BorrowedHandle<'_> {
self.child
.as_ref()
Expand All @@ -311,7 +313,7 @@ impl AsHandle for SuspendedChild {
impl Drop for SuspendedChild {
fn drop(&mut self) {
if let Some(child) = &mut self.child {
let _ = child.kill();
drop(child.kill());
}
}
}
Expand Down
Loading
Loading