diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..35049cb --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[alias] +xtask = "run --package xtask --" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index b073965..006e33c 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -11,6 +11,6 @@ - [ ] I ran the relevant local tests, including `just ci` and `just coverage` when applicable. - [ ] I added or updated deterministic tests for behavior changes. - [ ] I documented new or changed ownership, safety, or cleanup contracts. -- [ ] I reviewed public API compatibility and intentionally updated the snapshot if needed. +- [ ] I reviewed public API compatibility and updated the snapshot if needed. - [ ] I did not include secrets or public vulnerability details. - [ ] I kept this pull request focused and updated relevant documentation. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81c2fc1..f22472c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,7 @@ jobs: - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - uses: taiki-e/install-action@41049aa56687c35e0afa74eed4f09cec4f9afabf # v2.85.2 with: - tool: just@1.57.0,cargo-public-api@0.52.0,cargo-deny@0.19.4 + tool: just@1.57.0,cargo-public-api@0.52.0,cargo-deny@0.19.4,typos-cli@1.48.0 - run: python -m pip install "reuse[charset-normalizer]==6.2.0" - run: just fmt - run: just clippy @@ -43,6 +43,7 @@ jobs: - run: just cross-targets - run: just supply-chain - run: just reuse + - run: just typos test: name: Windows ${{ matrix.os }} / Rust ${{ matrix.rust }} @@ -72,7 +73,7 @@ jobs: - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 with: toolchain: stable - - run: cargo check --locked + - run: cargo xtask linux-empty coverage: name: Coverage diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1efddd6..a510be5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,7 +37,7 @@ jobs: - name: Verify existing tag, commit, and Cargo version env: RELEASE_TAG: ${{ inputs.tag }} - run: .\scripts\verify-release-tag.ps1 -Tag $env:RELEASE_TAG + run: cargo xtask verify-release-tag "$env:RELEASE_TAG" release: name: Approved release @@ -64,16 +64,11 @@ jobs: toolchain: stable - uses: taiki-e/install-action@41049aa56687c35e0afa74eed4f09cec4f9afabf # v2.85.2 with: - tool: just@1.57.0,cargo-cyclonedx@0.5.9 + tool: cargo-cyclonedx@0.5.9 - run: python -m pip install "reuse[charset-normalizer]==6.2.0" - name: Build and verify release candidate id: candidate - run: | - just release-candidate - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - $sbom = @(Get-ChildItem -LiteralPath target\release-candidate -Filter *.cdx.json) - if ($sbom.Count -ne 1) { throw "Expected exactly one CycloneDX SBOM" } - "sbom=$($sbom[0].FullName)" >> $env:GITHUB_OUTPUT + run: cargo xtask release-candidate --github-output - name: Attest SLSA v1 provenance uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4 @@ -94,23 +89,14 @@ jobs: id: github-release env: GH_TOKEN: ${{ github.token }} - run: | - $artifacts = @( - Get-ChildItem -LiteralPath target\release-candidate -File | - ForEach-Object { $_.FullName } - ) - $url = & gh release create $env:RELEASE_TAG @artifacts --verify-tag --draft --generate-notes --title $env:RELEASE_TAG - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - "url=$url" >> $env:GITHUB_OUTPUT + run: cargo xtask draft-release "$env:RELEASE_TAG" --github-output - name: Select crates.io authentication mode id: crates-mode if: ${{ inputs.publish_crates_io }} env: - BOOTSTRAP_TOKEN: ${{ secrets.CRATES_IO_BOOTSTRAP_TOKEN }} - run: | - $bootstrap = -not [string]::IsNullOrEmpty($env:BOOTSTRAP_TOKEN) - "bootstrap=$($bootstrap.ToString().ToLowerInvariant())" >> $env:GITHUB_OUTPUT + CRATES_IO_BOOTSTRAP_TOKEN: ${{ secrets.CRATES_IO_BOOTSTRAP_TOKEN }} + run: cargo xtask crates-io-auth-mode --github-output - name: Authenticate with crates.io trusted publishing id: crates-auth diff --git a/CHANGELOG.md b/CHANGELOG.md index c03f1b6..94d6b51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,6 @@ # Changelog -All notable changes to this project are documented here. The format follows -[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and Semantic +Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and Semantic Versioning with Cargo's pre-1.0 compatibility rules. ## [Unreleased] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8f4c3ec..f2e402b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,7 +1,6 @@ # Contributing -Thank you for helping improve `windows-spawn`. Contributions that make Windows -process creation safer, more predictable, or better documented are welcome. +Contributions must preserve the crate's ownership and cleanup contracts. ## Before opening a change @@ -42,12 +41,11 @@ does not publish anything. - Preserve the documented ownership and cleanup behavior, including on errors. - Give every `unsafe` block a specific safety justification. - Add deterministic tests for behavior changes and avoid timing-only assertions. -- Keep the public API snapshot unchanged unless the pull request intentionally - changes the public API and explains the compatibility impact. +- Keep the public API snapshot unchanged unless the pull request changes the + public API and explains the compatibility impact. - Update the crate documentation, ADRs, or security boundary when contracts change. - Keep dependencies minimal and compatible with the MSRV. -All required GitHub checks must pass and review conversations must be resolved -before merge. The repository uses squash merges so each pull request becomes -one focused commit on `main`. +Required checks and review conversations must be complete before merge. Pull +requests are squash-merged into `main`. diff --git a/Cargo.lock b/Cargo.lock index 9c57ae8..cbef9d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,184 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "windows-link" version = "0.2.1" @@ -23,3 +201,19 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] + +[[package]] +name = "xtask" +version = "0.0.0" +dependencies = [ + "semver", + "serde_json", + "sha2", + "windows-spawn", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index f182b13..a4e3dc0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Yasunobu Sakashita"] edition = "2021" rust-version = "1.75" license = "MIT OR Apache-2.0" -description = "Advanced Windows process creation: CreateProcessW with STARTUPINFOEX and PROC_THREAD_ATTRIBUTE_LIST, with attribute-value lifetimes enforced by the type system" +description = "Windows process creation with explicit handle, Job, mitigation, ConPTY, and child ownership" documentation = "https://docs.rs/windows-spawn" repository = "https://github.com/P4suta/windows-spawn" readme = "README.md" @@ -50,13 +50,21 @@ features = [ "Win32_System_Threading", ] -[lints.rust] +[workspace] +members = ["xtask"] +default-members = ["."] +resolver = "2" + +[workspace.lints.rust] missing_docs = "deny" rust_2018_idioms = { level = "deny", priority = -1 } unsafe_op_in_unsafe_fn = "deny" unreachable_pub = "deny" unused_qualifications = "deny" -[lints.clippy] +[workspace.lints.clippy] all = { level = "deny", priority = -1 } pedantic = { level = "deny", priority = -1 } + +[lints] +workspace = true diff --git a/README.md b/README.md index a0e7923..ea8f684 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,17 @@ # windows-spawn [![CI](https://github.com/P4suta/windows-spawn/actions/workflows/ci.yml/badge.svg)](https://github.com/P4suta/windows-spawn/actions/workflows/ci.yml) -[![CodeQL](https://github.com/P4suta/windows-spawn/actions/workflows/codeql.yml/badge.svg)](https://github.com/P4suta/windows-spawn/actions/workflows/codeql.yml) -Ownership-oriented Windows process creation for the parts of `CreateProcessW` -that stable `std::process` cannot express safely: explicit handle transfer, -ordered Job attachment, process mitigations, ConPTY, and suspended creation. +Windows process creation with explicit handle transfer, ordered Job attachment, +mitigation policies, ConPTY, and suspended inspection. -## Supported environment +## Use and platform -`windows-spawn` is Windows-only. Its core process-creation and ConPTY baseline -is Windows 10 version 1809. It uses Rust 2021 and supports Rust 1.75 and later. -Non-Windows targets expose no public API, so cross-platform dependency graphs -can still be checked. +Use `std::process::Command` for portable child processes. Use this crate for +`CreateProcessW` features that require explicit ownership and rollback. + +The crate requires Windows 10 version 1809 or later and Rust 1.75 or later. +Non-Windows targets expose no public API. ## Installation @@ -23,48 +22,26 @@ windows-spawn = "0.1" ## Minimal example -This captures output and owns the descendant tree, ensuring that a grandchild -cannot keep the output pipe open after the root process exits: - ```rust,no_run -#[cfg(windows)] -fn main() -> std::io::Result<()> { - use windows_spawn::{Command, DropPolicy, SpawnOptions}; - - let mut command = Command::new(r"C:\Windows\System32\cmd.exe"); - command.args(["/D", "/S", "/C"]).raw_arg("echo hello"); +use windows_spawn::{Command, DropPolicy, SpawnOptions}; - let output = command.output_with( - SpawnOptions::new().drop_policy(DropPolicy::KillTree), - )?; - assert!(output.status.success()); - Ok(()) -} +let mut command = Command::new(r"C:\Windows\System32\cmd.exe"); +command.args(["/D", "/S", "/C"]).raw_arg("echo hello"); -#[cfg(not(windows))] -fn main() {} +let output = command.output_with( + SpawnOptions::new().drop_policy(DropPolicy::KillTree), +)?; +assert!(output.status.success()); +# Ok::<(), std::io::Error>(()) ``` -## Safety and ownership - -- `DropPolicy::KillTree` owns and terminates the whole descendant tree; - dropping a normal child detaches by default. -- Handle-handoff methods transfer private duplicates. Child-visible decimal - handle values belong to the child process and may differ from source values. -- Windows has a process-wide reverse inheritance race while temporary - inheritable duplicates exist. Do not concurrently perform broad-inheritance - spawns when transferred handles are sensitive. -- This crate is a process-creation primitive, not a sandbox or process - supervisor. Review the documented security boundary before using it as part - of an isolation design. - ## Documentation -- [API documentation and behavioral contracts](https://docs.rs/windows-spawn) -- [Compile-checked examples](https://github.com/P4suta/windows-spawn/tree/main/examples) -- [Architecture decision records](https://github.com/P4suta/windows-spawn/tree/main/docs/adr) +- [API and behavioral contracts](https://docs.rs/windows-spawn) +- [Examples](https://github.com/P4suta/windows-spawn/tree/main/examples) +- [Architecture decisions](https://github.com/P4suta/windows-spawn/tree/main/docs/adr) - [Security policy](https://github.com/P4suta/windows-spawn/security/policy) ## License -Licensed under either Apache-2.0 or MIT, at your option. +Apache-2.0 OR MIT. diff --git a/REUSE.toml b/REUSE.toml index 7a72ff8..87dd133 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -17,9 +17,9 @@ path = [ "examples/**", "justfile", "public-api/**", - "scripts/**", "src/**", "tests/**", + "xtask/**", ] precedence = "override" SPDX-FileCopyrightText = "2026 Yasunobu Sakashita" diff --git a/SECURITY.md b/SECURITY.md index ce30adb..57cd4f6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,9 +2,8 @@ ## Supported versions -Security fixes are provided for the latest published minor release line. Before -the first release, reports affecting the `main` branch are welcome. Older minor -lines may receive a fix when practical, but are not guaranteed support. +Security fixes target the latest published minor release line. Before the first +release, reports may target `main`. Older minor lines are not guaranteed fixes. ## Reporting a vulnerability diff --git a/docs/adr/0001-why-not-firehazard.md b/docs/adr/0001-why-not-firehazard.md index 50d9585..7cd7f6b 100644 --- a/docs/adr/0001-why-not-firehazard.md +++ b/docs/adr/0001-why-not-firehazard.md @@ -4,30 +4,20 @@ Status: accepted (2026-08-01) ## Context -`firehazard` already solves this problem, and solves it well: a safe RAII -`ThreadAttributeList` builder covering 20+ `PROC_THREAD_ATTRIBUTE_*` values, -including every attribute `windows-spawn` targets. Writing a new crate in the -presence of working prior art needs a reason better than "I want to". - -Three facts shape the decision. It has been published as version `0.0.0` with -no crates.io release since September 2022 (5,441 downloads total). Its scope is -a sandboxing research toolkit — tokens, ACLs, AppContainers, debugging — of -which process creation is one corner. And the surrounding ecosystem has not -picked it up: `process-wrap` (9.9M downloads) and `win32job` (1.1M) handle job -objects without ever touching an attribute list, and applications keep writing -their own (854 GitHub hits for `InitializeProcThreadAttributeList`). +`firehazard` provides a safe RAII `ThreadAttributeList` builder for the +attributes targeted here. Its published version remains `0.0.0`, its broader +sandboxing scope includes tokens, ACLs, AppContainers, and debugging, and Job +libraries do not provide equivalent process-attribute integration. ## Decision -Write a new crate scoped to process creation only, and treat release discipline -as a feature rather than an afterthought. +Build a crate limited to process creation, with stable releases and release +gates. ## Consequences -- Duplicated effort against `firehazard`, knowingly. If `firehazard` ships a - stable 0.1 with a release cadence, `windows-spawn` has lost its reason to exist and - the README should say so. -- The narrow scope is a constraint, not just a description: token and ACL - features get rejected, and users are pointed at `rappct`/`firehazard`. -- `windows-spawn` must be adoptable *next to* the incumbents, not instead of them — - hence adopting foreign job handles rather than insisting on its own (ADR 0004). +- This duplicates part of `firehazard`. Reconsider the crate if `firehazard` + publishes a stable process-creation API with regular releases. +- Reject token and ACL features; direct users to `rappct` or `firehazard`. +- Interoperate with existing Job libraries by adopting foreign Job handles + (ADR 0004). diff --git a/docs/adr/0002-own-createprocess.md b/docs/adr/0002-own-createprocess.md index a0d930b..3e74e82 100644 --- a/docs/adr/0002-own-createprocess.md +++ b/docs/adr/0002-own-createprocess.md @@ -4,28 +4,25 @@ Status: accepted (2026-08-02) ## Context -Stable `std::process::Command` cannot receive a `STARTUPINFOEXW` attribute -list. Its raw-attribute extension is nightly-only and unsafe. Handing a public -attribute-list wrapper to callers would also split ownership of command-line -lowering, standard streams, inheritance, process handles, and rollback across -two APIs. +Stable `std::process::Command` cannot supply a `STARTUPINFOEXW` attribute list; +its raw extension is nightly-only and unsafe. A public attribute-list wrapper +would split command-line lowering, standard I/O, inheritance, process handles, +and rollback across APIs. ## Decision -windows-spawn owns the entire call. `Command` stores reusable intent, `SpawnPlan` -performs pure validation and normalization, and `SpawnTransaction` acquires all -temporary OS resources. The private `sys` layer is the only place that calls -Win32. +`Command` stores reusable intent, `SpawnPlan` validates and normalizes it, and +`SpawnTransaction` owns temporary OS resources. Only the private `sys` module +calls Win32. -The public result uses `std::process::ExitStatus`, `std::process::Output`, and -`std::io::Error`. `Child` is a distinct owning process type because stable std -cannot adopt the process and pipe handles produced by this transaction. +Return `std::process::ExitStatus`, `std::process::Output`, and `std::io::Error`. +Use a distinct `Child` because stable std cannot adopt this transaction's +process and pipe handles. ## Consequences -- windows-spawn must test Windows argument quoting, environment ordering, executable - lookup, standard streams, output draining, and exit-code preservation. -- Every process-creation failure has one rollback owner. -- Raw Win32 flags, attribute lists, and `windows-sys` types stay private. -- If std stabilizes a safe, sufficiently complete attribute interface, this - decision can be revisited without changing the high-level capability types. +- Test quoting, environment ordering, executable lookup, standard I/O, output + draining, and exit codes in this crate. +- One transaction owns rollback for every process-creation error. +- Keep raw flags, attribute lists, and `windows-sys` types private. +- Revisit this decision if std gains a safe, complete attribute interface. diff --git a/docs/adr/0003-attribute-lifetime-model.md b/docs/adr/0003-attribute-lifetime-model.md index 5a1913f..aa4709f 100644 --- a/docs/adr/0003-attribute-lifetime-model.md +++ b/docs/adr/0003-attribute-lifetime-model.md @@ -4,30 +4,24 @@ Status: accepted (2026-08-02) ## Context -`UpdateProcThreadAttribute` retains pointers until `CreateProcessW` consumes -the list. The list backing store must be suitably aligned, and every pointed-to -value must stay at a stable address. A stack temporary or a reallocating vector -can therefore turn otherwise plausible Rust into invalid FFI. - -The pseudoconsole attribute is exceptional: Microsoft requires the `HPCON` -value itself as `lpValue`, whereas the other supported attributes receive the -address of stable storage. +`UpdateProcThreadAttribute` retains pointers until `CreateProcessW`. The list +requires aligned storage and each value requires a stable address. The +pseudoconsole is an exception: its `HPCON` value, rather than its address, is +passed as `lpValue`. ## Decision -The attribute list is private and cannot outlive one `SpawnTransaction`. Its -backing allocation is word-aligned. Every normal attribute value has its own -stable owned allocation, and the transaction retains those allocations until -after `DeleteProcThreadAttributeList`. The pseudoconsole path passes its -borrowed value according to the documented exception. +Keep the attribute list private to one `SpawnTransaction`. Give its backing +allocation word alignment and each normal value stable owned storage. Retain +both until after `DeleteProcThreadAttributeList`. Pass the borrowed +pseudoconsole value according to its Win32 contract. `SpawnOptions<'a>` carries the lifetime of borrowed Jobs, parent process, and -pseudoconsole capability. `Command` does not borrow them and remains reusable. +pseudoconsole capabilities; reusable `Command` does not borrow them. ## Consequences -- There is no public raw attribute API and no self-referential public builder. +- No public raw attribute API or self-referential builder. - Attribute pointers cannot outlive their values. -- The list is deleted before either its values or aligned backing storage are - released. -- The unsafe lifetime proof remains local to the private `sys` module. +- Delete the list before its values and backing storage. +- Keep the unsafe lifetime proof inside `sys`. diff --git a/docs/adr/0004-job-attachment.md b/docs/adr/0004-job-attachment.md index 0fa68db..c09c614 100644 --- a/docs/adr/0004-job-attachment.md +++ b/docs/adr/0004-job-attachment.md @@ -4,30 +4,24 @@ Status: accepted (2026-08-02) ## Context -Post-creation `AssignProcessToJobObject` leaves a window in which the child is -outside the Job. `PROC_THREAD_ATTRIBUTE_JOB_LIST` attaches Jobs as part of -creation and accepts an ordered list from root to innermost. - -Tree teardown is also a separate semantic choice. A boolean "kill on drop" -would obscure whether it changes a caller-owned Job or creates library-owned -state. +Post-creation `AssignProcessToJobObject` leaves the child outside the Job for a +time. `PROC_THREAD_ATTRIBUTE_JOB_LIST` attaches Jobs during creation in +root-to-innermost order. Tree teardown must not change a caller-owned Job. ## Decision -`SpawnOptions::job` may be called repeatedly and preserves root-to-inner order. -`Job::assign` remains the explicit post-creation escape hatch. Job limit -updates first query the existing extended limits and replace only the requested -flag. +Repeated `SpawnOptions::job` calls preserve root-to-innermost order. +`Job::assign` remains an explicit post-creation operation. Job limit updates +query existing limits and change only the requested flag. -`DropPolicy::KillTree` creates a private windows-spawn-owned Job and appends it as -the innermost Job. `DropPolicy::Detach` is the default and matches std. A Child -never shares raw ownership of a Job handle; it owns a duplicate when continued -ownership is required. +`DropPolicy::KillTree` appends a private, crate-owned innermost Job. +`DropPolicy::Detach` remains the default. `Child` owns a duplicate of any Job +handle it must retain. ## Consequences -- Atomic attachment is the normal route and multiple Jobs remain composable. -- `wait_with_output` can terminate the private tree after root exit so inherited - pipe handles held by descendants cannot prevent EOF indefinitely. -- Calling `Job::assign` after spawn is visibly weaker and may fail under host +- Atomic attachment is the default and supports multiple Jobs. +- `wait_with_output` can terminate the private tree after root exit, ensuring + EOF when descendants retain pipe handles. +- `Job::assign` exposes the weaker post-creation path and may fail under host Job restrictions. diff --git a/docs/adr/0005-handle-transfer-and-reverse-race.md b/docs/adr/0005-handle-transfer-and-reverse-race.md index bd383ab..e0a676a 100644 --- a/docs/adr/0005-handle-transfer-and-reverse-race.md +++ b/docs/adr/0005-handle-transfer-and-reverse-race.md @@ -4,35 +4,33 @@ Status: accepted (2026-08-02) ## Context -`PROC_THREAD_ATTRIBUTE_HANDLE_LIST` limits what the target child inherits, but -Windows requires listed handles to be inheritable while `CreateProcessW` runs. -Changing the source handle's flag would mutate caller-owned state. A temporary -inheritable duplicate avoids that mutation, but another concurrent broad -inheritance spawn in the same process can still inherit the duplicate. +`PROC_THREAD_ATTRIBUTE_HANDLE_LIST` limits target-child inheritance, but every +listed handle must be inheritable during `CreateProcessW`. Mutating the source +handle would change caller-owned state. A temporary inheritable duplicate +avoids that mutation but can still leak to a concurrent broad-inheritance spawn +in the same source process. -A selected parent process introduces another handle table. Standard streams and -high-level `arg_handle`/`env_handle` values must be duplicated into that table -before their numeric values have meaning. +An alternate parent has a different handle table. Standard streams and +`arg_handle`/`env_handle` values require duplication into that table before +their child-visible numeric values are known. ## Decision -windows-spawn makes inheritable local duplicates immediately before spawn and keeps -their lifetime as short as possible. It documents, but cannot eliminate, the -reverse race. The 0.1 series does not introduce a helper process because doing -so changes parent identity and failure semantics. +Create inheritable local duplicates immediately before spawn and close them +when process creation returns. Document the process-wide reverse race. The 0.1 +series does not use a helper process because it would change parent identity +and failure semantics. -High-level handle arguments and environment values are privately duplicated at -configuration time, then remotely duplicated and lowered only for the selected -parent. Remote temporaries are reclaimed with `DuplicateHandle` close-source -semantics on both success and failure. Arbitrary pre-inheritable handles are not -accepted by the public API: handles enter a child only as standard I/O or -through the argument/environment handoff protocol. +Duplicate configured handle arguments and environment values privately, then +duplicate and lower them for the selected parent during each spawn. Reclaim +remote temporaries with `DuplicateHandle` close-source semantics on success and +failure. Accept handles only through standard I/O or the argument/environment +handoff protocol. ## Consequences -- Source handles are never made inheritable in place. -- The public API cannot keep an arbitrary inheritable duplicate alive between - spawn calls. -- Target-child over-inheritance is prevented; process-wide reverse leakage must - still be considered by applications that concurrently use broad inheritance. -- Ownership and cleanup of both local and remote duplicates are deterministic. +- Never make source handles inheritable in place. +- Do not retain arbitrary inheritable duplicates between spawns. +- Prevent target-child over-inheritance; callers must avoid concurrent broad + inheritance when transferred handles are sensitive. +- Give local and remote duplicates one deterministic cleanup owner. diff --git a/docs/adr/0006-conpty-boundary.md b/docs/adr/0006-conpty-boundary.md index f68aa59..bd31627 100644 --- a/docs/adr/0006-conpty-boundary.md +++ b/docs/adr/0006-conpty-boundary.md @@ -4,29 +4,23 @@ Status: accepted (2026-08-02) ## Context -windows-spawn must attach an existing pseudoconsole without depending on one -particular terminal library or taking ownership of its `HPCON`. Exposing a raw -safe constructor would let callers supply invalid or prematurely closed values. +The crate must attach an existing pseudoconsole without depending on a terminal +library or owning its `HPCON`. A safe raw constructor could accept invalid or +prematurely closed values. ## Decision -The boundary is the unsafe trait `AsPseudoConsole`. Implementors guarantee that -the returned pseudoconsole value remains valid for the borrow and that ownership -is not transferred. `conpty-oxide::Pcon` implements the trait once; its users -pass a normal borrow through `SpawnOptions::pseudoconsole`. +Use the unsafe `AsPseudoConsole` trait. Implementors guarantee a stable, +nonzero, live `HPCON` for the full borrow and retain ownership. The raw method +is public so terminal libraries can implement the bridge. -`AsPseudoConsole::raw_pseudoconsole` is visible in rustdoc so external terminal -libraries can implement the bridge without relying on hidden API. Its safety -contract requires a stable, nonzero, live `HPCON` for the complete borrow. - -The dependency is one-way: conpty-oxide depends on windows-spawn. It retains ConPTY -creation, its pipes, registered waits, Tokio integration, and lifecycle API; -windows-spawn owns command lowering, attributes, Jobs, and `CreateProcessW`. +`conpty-oxide` depends on windows-spawn and implements the trait. It owns +ConPTY creation, pipes, waits, Tokio integration, and lifecycle. windows-spawn +owns command lowering, attributes, Jobs, and `CreateProcessW`. ## Consequences -- Ordinary users do not construct raw HPCON values or write unsafe code. -- windows-spawn has no dependency on or knowledge of conpty-oxide. -- A pseudoconsole conflicts with explicit standard streams during planning, - and the process starts with invalid ordinary standard handles as required by - ConPTY. +- Ordinary users pass a safe borrow and do not construct raw `HPCON` values. +- windows-spawn does not depend on a terminal library. +- Pseudoconsole use conflicts with explicit standard streams and creates the + process with invalid ordinary standard handles, as required by ConPTY. diff --git a/docs/adr/0007-spawn-transaction.md b/docs/adr/0007-spawn-transaction.md index ff44d3a..03cb31d 100644 --- a/docs/adr/0007-spawn-transaction.md +++ b/docs/adr/0007-spawn-transaction.md @@ -4,28 +4,25 @@ Status: accepted (2026-08-02) ## Context -A single spawn can acquire pipes, null handles, local inheritable duplicates, -remote duplicates, a private Job, attribute storage, and process/thread handles. -Failures can happen between any two acquisitions. Distributed cleanup flags -make double-close and leak states representable. +A spawn can acquire pipes, null handles, local and remote duplicates, a private +Job, attribute storage, and process/thread handles. Any acquisition can fail; +distributed cleanup state permits leaks and double closes. ## Decision -`SpawnTransaction` is the sole owner of temporary resources. Before commit, its -Drop implementation rolls everything back and terminates any created process. -After a successful create and Job setup, `commit` moves only the process handle, -public pipe endpoints, cached lifecycle policy, and any required Job ownership -into `Child`. Thread and temporary handles remain transaction-owned and close -immediately. +`SpawnTransaction` exclusively owns temporary resources. Before commit, its +`Drop` implementation terminates a created process and rolls back all state. +Commit moves only the process handle, public pipe endpoints, lifecycle policy, +and retained Job ownership into `Child`; thread and temporary handles close. -`SuspendedChild` is a separate state. Its consuming `resume` is the only normal -transition to `Child`; Drop before that transition always terminates the process -or its private Job. +`SuspendedChild` represents the suspended state. Its consuming `resume` is the +only normal transition to `Child`; dropping it first terminates the process or +its private Job. ## Consequences -- No raw handle has shared ownership. -- Partial initialization is not observable through the public API. -- Cleanup follows ownership rather than error-site bookkeeping. -- Failure-injection and handle-count integration tests can verify the invariant - without exposing transaction internals. +- No shared ownership of raw handles. +- No public partial-initialization state. +- Cleanup follows ownership instead of error-site flags. +- Failure-injection and handle-count tests can verify rollback without exposing + transaction internals. diff --git a/docs/adr/0008-mutation-testing.md b/docs/adr/0008-mutation-testing.md index ee0f6e9..69ca132 100644 --- a/docs/adr/0008-mutation-testing.md +++ b/docs/adr/0008-mutation-testing.md @@ -4,46 +4,36 @@ Status: accepted (2026-08-02) ## Context -The Windows integration suite includes process trees, suspended processes, and -pipe EOF behavior. A complete mutation run is too expensive for every pull -request, but blanket exclusions would hide precisely the ownership bugs the -crate is intended to prevent. +Process-tree, suspended-process, and pipe-EOF tests make a complete mutation +run too expensive for each pull request. Broad exclusions would hide ownership +and cleanup faults. ## Decision -Four mutation shards run weekly and on manual dispatch. Every survivor is -either addressed by a focused test or individually excluded only when the -generated expression is provably identical for all values allowed at that -site. The narrow regular expressions live in `.cargo/mutants.toml`; whole files -and broad mutation classes are never excluded. - -Tests that deliberately create a suspended process retain an independent -process handle and perform direct Win32 cleanup before reporting a failed -termination assertion. Cleanup must not call the crate path under mutation: -mutants that disable `Drop`, `Child::kill`, or `TerminateProcess` must not leave -a permanently suspended process behind on the test host. - -Both the local full run and the CI shards start `cargo mutants` suspended, -assign it to a kill-on-close Windows Job, and only then resume it. Closing the -runner's last Job handle therefore removes descendants that escape a mutant, -timeout, or aborted test process. Local runs use a unique ignored output -directory; CI keeps `mutants.out` at the workspace root for artifact upload. - -## Recorded equivalences - -- `MitigationPolicy::replace` clears both destination bits before inserting the - new value. OR and XOR therefore receive zero on the left at those positions - and are identical. -- `DUPLICATE_SAME_ACCESS` and `DUPLICATE_CLOSE_SOURCE` occupy disjoint bits, so - OR and XOR produce the same remote-close option word. -- `PROCESS_CREATE_PROCESS` and `PROCESS_DUP_HANDLE` occupy disjoint bits, so OR - and XOR request the same minimal alternate-parent rights. -- Public `CreationFlags` cannot contain `CREATE_UNICODE_ENVIRONMENT`, which is - private and added by `create_process`. OR and XOR therefore produce the same - word at that insertion point. +Run four shards weekly and on manual dispatch. Address each survivor with a +test or exclude its exact expression only when it is equivalent for every +value allowed at that site. `.cargo/mutants.toml` may not exclude a whole file +or mutation class. + +Suspended-process tests retain an independent process handle and use direct +Win32 cleanup before reporting a failed termination assertion. Cleanup bypasses +the mutated crate path so changes to `Drop`, `Child::kill`, or +`TerminateProcess` cannot leave a suspended process on the runner. + +The xtask starts `cargo mutants` through `windows-spawn` with +`DropPolicy::KillTree`. The private kill-on-close Job contains descendants on +normal exit, timeout, test failure, or runner termination. Local runs use a +unique ignored output directory; CI writes the upload under `mutants.out`. + +The four exclusions are equivalent because: + +- `MitigationPolicy::replace` clears the destination bits before OR or XOR. +- `DUPLICATE_SAME_ACCESS` and `DUPLICATE_CLOSE_SOURCE` occupy disjoint bits. +- `PROCESS_CREATE_PROCESS` and `PROCESS_DUP_HANDLE` occupy disjoint bits. +- Public `CreationFlags` cannot contain the private + `CREATE_UNICODE_ENVIRONMENT` bit added by `create_process`. ## Consequences -Mutation results remain actionable, expensive process tests run off the pull -request path, and every accepted equivalent stays reviewable beside the design -reason that makes it safe. +Mutation results remain actionable, the expensive suite stays off the pull +request path, and each exclusion has a reviewable equivalence proof. diff --git a/docs/crate.md b/docs/crate.md index 5735b8f..23ac9b0 100644 --- a/docs/crate.md +++ b/docs/crate.md @@ -1,14 +1,12 @@ # Windows process creation with explicit ownership -`windows-spawn` owns one complete `CreateProcessW` transaction for Windows -features that stable [`std::process`] cannot express safely. Use -[`std::process::Command`] for ordinary portable child processes. Use this crate -when process creation needs explicit handle transfer, ordered Job attachment, -typed mitigation policies, `ConPTY`, or suspended inspection. +`windows-spawn` owns a complete `CreateProcessW` transaction for explicit +handle transfer, ordered Job attachment, typed mitigation policies, `ConPTY`, +and suspended inspection. Use [`std::process::Command`] for portable child +processes. -The crate intentionally exposes no public API on non-Windows targets. This -allows cross-platform dependency graphs to be checked without implying runtime -support outside Windows. +Non-Windows targets expose no public API. They support dependency-graph checks, +not process creation. # Platform contract @@ -33,15 +31,14 @@ subject to Jobs already imposed by the host. - [`Command::raw_arg`] appends already-encoded Windows command-line syntax. It does not invoke a shell and must only receive syntax appropriate for the target executable's parser. -- Raw attribute injection, raw creation flags, and raw mitigation constructors - are intentionally absent. +- Raw attribute injection, creation flags, and mitigation constructors are not + exposed. # Handle and capability ownership -[`Command`] is reusable and stores execution intent. [`Command::arg_handle`] -and [`Command::env_handle`] take a private, non-inheritable duplicate when they -are configured. The source handle may therefore be closed immediately, and -each later spawn can perform a fresh transfer. +[`Command`] stores reusable execution intent. [`Command::arg_handle`] and +[`Command::env_handle`] take a private, non-inheritable duplicate. The source +handle may then be closed; each spawn transfers a new duplicate. Immediately before `CreateProcessW`, the crate creates only the inheritable duplicates required for standard I/O and argument or environment handoff. It @@ -61,12 +58,11 @@ Handle-handoff values form an application protocol: - With an alternate parent, the resource is duplicated into the effective parent's handle table before the child-visible value is lowered. -Windows retains a process-wide reverse race: while the short-lived inheritable -duplicates exist, unrelated code in the same source process that performs -broad handle inheritance can receive one. Avoid concurrent broad-inheritance -spawns when the handles are sensitive. A helper process could close the race, -but would change parent identity and the failure model; version 0.1 deliberately -does not use one. See +Windows retains a process-wide reverse race: unrelated broad-inheritance spawns +can receive a short-lived inheritable duplicate. Avoid concurrent broad +inheritance when transferred handles are sensitive. Version 0.1 does not use a +helper process because that would change parent identity and failure semantics. +See [ADR 0005](https://github.com/P4suta/windows-spawn/blob/main/docs/adr/0005-handle-transfer-and-reverse-race.md). [`SpawnOptions`] borrows one-spawn capabilities such as Jobs, an alternate @@ -91,11 +87,10 @@ pipes close and when terminal EOF occurs. # Transaction and security boundary -Process creation is split into a pure validation plan and an owning -transaction. The transaction owns pipes, temporary duplicates, attributes, -Jobs, and process/thread handles until success commits exactly the durable -resources to [`Child`] or [`SuspendedChild`]. Every error path rolls back the -rest. +Process creation uses a validation plan and an owning transaction. The +transaction owns pipes, temporary duplicates, attributes, Jobs, and +process/thread handles. Success transfers durable resources to [`Child`] or +[`SuspendedChild`]; errors roll back the rest. This crate is not a sandbox, cross-platform process facade, async runtime, or process supervisor. Tokens, ACLs, `AppContainer`, LPAC, capability SIDs, and diff --git a/docs/releasing.md b/docs/releasing.md index c5e1742..4128770 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,15 +1,13 @@ # Release procedure -Releases are deliberately manual. Pushing a branch or tag never starts the -release workflow. The workflow accepts an existing `v`-prefixed SemVer tag, -validates that its version matches `Cargo.toml` and that it resolves to the -checked-out commit, then waits for approval from GitHub's protected `release` +Pushing a branch or tag does not start a release. The manually dispatched +workflow accepts an existing `v`-prefixed SemVer tag, verifies its Cargo +version and commit, then waits for approval from the protected `release` environment. ## Local candidate -Install stable Rust, `just` 1.57.0, `cargo-cyclonedx` 0.5.9, and REUSE 6.2.0. -The developer-facing commands are: +Install stable Rust, `just` 1.57.0, `cargo-cyclonedx` 0.5.9, and REUSE 6.2.0: ```powershell just reuse @@ -26,9 +24,9 @@ SBOM, validates both SBOMs, and writes `target/release-candidate/SHA256SUMS`. ## Repository setup -Before adding any publishing credential, create a GitHub environment named -`release` and configure at least one required reviewer. Keep deployment branch -rules as restrictive as the repository's release policy permits. +Create a GitHub environment named `release` with at least one required reviewer +before adding a publishing credential. Apply the repository's release branch +restrictions. The workflow needs the repository's default `GITHUB_TOKEN` permissions only; its job requests `contents: write`, `id-token: write`, `attestations: write`, @@ -48,9 +46,9 @@ For the first publication only: 4. Configure the crate's crates.io trusted publisher for this repository, `.github/workflows/release.yml`, and the `release` environment. -For every later publication, leave `CRATES_IO_BOOTSTRAP_TOKEN` absent. The -workflow then obtains a short-lived OIDC token with the official crates.io -authentication action and revokes it automatically when the job completes. +For later publications, leave `CRATES_IO_BOOTSTRAP_TOKEN` absent. The workflow +uses the crates.io authentication action to obtain and revoke a short-lived +OIDC token. ## Publishing and verification @@ -67,7 +65,6 @@ provenance with: gh attestation verify .\windows-spawn-0.1.0.crate --repo P4suta/windows-spawn ``` -VEX is added only when there is a concrete vulnerability status to communicate. -Separate GPG and Cosign signatures are intentionally omitted while they would -not add an independently managed identity or policy beyond GitHub Artifact -Attestations. +Add VEX only for a concrete vulnerability status. GitHub Artifact Attestations +provide the repository's signing identity and policy; no separate GPG or +Cosign signatures are produced. diff --git a/justfile b/justfile index 87b7aba..34738d7 100644 --- a/justfile +++ b/justfile @@ -4,63 +4,61 @@ default: @just --list fmt: - cargo fmt --all -- --check + cargo xtask fmt clippy: - cargo clippy --all-targets --locked -- -D warnings + cargo xtask clippy test: - cargo test --all-targets --locked -- --test-threads=1 - cargo test --doc --locked + cargo xtask test doc: - $env:RUSTDOCFLAGS = '-D warnings'; cargo doc --no-deps --locked + cargo xtask doc msrv: - cargo +1.75.0 check --all-targets --locked + cargo xtask msrv cross-targets: - cargo check --locked --target x86_64-pc-windows-msvc - cargo check --locked --target i686-pc-windows-msvc - cargo check --locked --target aarch64-pc-windows-msvc + cargo xtask cross-targets linux-empty: - cargo check --all-targets --locked --target x86_64-unknown-linux-gnu + cargo xtask linux-empty public-api: - $actual = @(cargo +nightly-2026-07-02 public-api --simplified); if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; $expected = @(Get-Content -LiteralPath 'public-api/windows-spawn.txt'); $difference = @(Compare-Object -ReferenceObject $expected -DifferenceObject $actual -SyncWindow 0); if ($difference.Count -ne 0) { $difference | Format-Table | Out-String | Write-Error; exit 1 } + cargo xtask public-api public-api-update: - cargo +nightly-2026-07-02 public-api --simplified | Set-Content -LiteralPath public-api/windows-spawn.txt -Encoding utf8 + cargo xtask public-api --update supply-chain: - cargo deny --all-features --locked check + cargo xtask supply-chain reuse: - python -m reuse lint + cargo xtask reuse + +typos: + cargo xtask typos sbom: - & '.\scripts\generate-sboms.ps1' + cargo xtask sbom coverage: - cargo llvm-cov clean --workspace - cargo llvm-cov --all-targets --locked -- --test-threads=1 - cargo llvm-cov report --fail-under-lines 92 --fail-under-regions 92 --fail-under-functions 92 + cargo xtask coverage mutants: - & '.\scripts\run-mutants-contained.ps1' + cargo xtask mutants -- mutants-ci shard: - $env:CARGO_MUTANTS_OUTPUT = '.'; & '.\scripts\run-mutants-contained.ps1' --in-place --shard {{ shard }}/4 --timeout 90 --build-timeout 180 --no-shuffle -vV + cargo xtask mutants --output . -- --in-place --shard {{ shard }}/4 --timeout 90 --build-timeout 180 --no-shuffle -vV package-check: - cargo package --locked - & '.\scripts\check-packaged-reuse.ps1' + cargo xtask package-check release-candidate: - & '.\scripts\release-candidate.ps1' + cargo xtask release-candidate release-verify tag: - & '.\scripts\verify-release-tag.ps1' -Tag '{{ tag }}' + cargo xtask verify-release-tag "{{ tag }}" -ci: fmt clippy test doc msrv cross-targets linux-empty public-api supply-chain reuse package-check +ci: + cargo xtask ci diff --git a/scripts/check-packaged-reuse.ps1 b/scripts/check-packaged-reuse.ps1 deleted file mode 100644 index 657bbff..0000000 --- a/scripts/check-packaged-reuse.ps1 +++ /dev/null @@ -1,49 +0,0 @@ -[CmdletBinding()] -param() - -$ErrorActionPreference = "Stop" -Set-StrictMode -Version Latest - -$repositoryRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..")) -Push-Location -LiteralPath $repositoryRoot -try { - $metadataJson = & cargo metadata --locked --no-deps --format-version 1 - if ($LASTEXITCODE -ne 0) { - throw "cargo metadata failed with exit code $LASTEXITCODE" - } - - $metadata = $metadataJson | ConvertFrom-Json - $manifestPath = [System.IO.Path]::GetFullPath( - (Join-Path $repositoryRoot "Cargo.toml") - ) - $package = @( - $metadata.packages | - Where-Object { - [System.IO.Path]::GetFullPath($_.manifest_path) -eq $manifestPath - } - ) - if ($package.Count -ne 1) { - throw "Could not identify the root Cargo package" - } - - $expandedPackage = Join-Path $repositoryRoot ( - "target\package\{0}-{1}" -f $package[0].name, $package[0].version - ) - if (-not (Test-Path -LiteralPath $expandedPackage -PathType Container)) { - throw "Expanded package not found: $expandedPackage. Run cargo package first." - } - - Push-Location -LiteralPath $expandedPackage - try { - & python -m reuse lint - if ($LASTEXITCODE -ne 0) { - throw "REUSE lint failed for the expanded package" - } - } - finally { - Pop-Location - } -} -finally { - Pop-Location -} diff --git a/scripts/generate-sboms.ps1 b/scripts/generate-sboms.ps1 deleted file mode 100644 index c8c8808..0000000 --- a/scripts/generate-sboms.ps1 +++ /dev/null @@ -1,114 +0,0 @@ -[CmdletBinding()] -param( - [string] $OutputDirectory = "target\release-candidate" -) - -$ErrorActionPreference = "Stop" -Set-StrictMode -Version Latest - -$repositoryRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..")) -if ([System.IO.Path]::IsPathRooted($OutputDirectory)) { - $outputPath = [System.IO.Path]::GetFullPath($OutputDirectory) -} -else { - $outputPath = [System.IO.Path]::GetFullPath( - (Join-Path $repositoryRoot $OutputDirectory) - ) -} - -New-Item -ItemType Directory -Force -Path $outputPath | Out-Null - -Push-Location -LiteralPath $repositoryRoot -try { - $metadataJson = & cargo metadata --locked --no-deps --format-version 1 - if ($LASTEXITCODE -ne 0) { - throw "cargo metadata failed with exit code $LASTEXITCODE" - } - $metadata = $metadataJson | ConvertFrom-Json - $manifestPath = [System.IO.Path]::GetFullPath( - (Join-Path $repositoryRoot "Cargo.toml") - ) - $package = @( - $metadata.packages | - Where-Object { - [System.IO.Path]::GetFullPath($_.manifest_path) -eq $manifestPath - } - ) - if ($package.Count -ne 1) { - throw "Could not identify the root Cargo package" - } - - $packageName = [string]$package[0].name - $packageVersion = [string]$package[0].version - $cycloneDxBaseName = "$packageName-$packageVersion.cdx" - $cycloneDxName = "$cycloneDxBaseName.json" - $generatedCycloneDx = Join-Path $repositoryRoot $cycloneDxName - $cycloneDxPath = Join-Path $outputPath $cycloneDxName - $reuseSpdxPath = Join-Path $outputPath ( - "$packageName-$packageVersion.reuse.spdx" - ) - - if (Test-Path -LiteralPath $generatedCycloneDx) { - Remove-Item -Force -LiteralPath $generatedCycloneDx - } - - & cargo cyclonedx --format json --spec-version 1.5 --target all --all-features --no-build-deps --override-filename $cycloneDxBaseName - if ($LASTEXITCODE -ne 0) { - throw "cargo cyclonedx failed with exit code $LASTEXITCODE" - } - if (-not (Test-Path -LiteralPath $generatedCycloneDx -PathType Leaf)) { - throw "cargo cyclonedx did not create $generatedCycloneDx" - } - Move-Item -Force -LiteralPath $generatedCycloneDx -Destination $cycloneDxPath - - & python -m reuse spdx -o $reuseSpdxPath - if ($LASTEXITCODE -ne 0) { - throw "REUSE SPDX generation failed with exit code $LASTEXITCODE" - } - - $bom = Get-Content -LiteralPath $cycloneDxPath -Raw | ConvertFrom-Json - if ( - $bom.bomFormat -ne "CycloneDX" -or - [string]$bom.specVersion -ne "1.5" - ) { - throw "CycloneDX SBOM is not JSON conforming to specification 1.5" - } - if ( - [string]$bom.metadata.component.name -ne $packageName -or - [string]$bom.metadata.component.version -ne $packageVersion - ) { - throw "CycloneDX SBOM has incorrect root package metadata" - } - - $components = @($bom.metadata.component) + @($bom.components) - foreach ($component in $components) { - if (@($component.licenses).Count -eq 0) { - throw "CycloneDX component lacks license data: $($component.name)" - } - } - if (@($bom.components).Count -eq 0 -or @($bom.dependencies).Count -eq 0) { - throw "CycloneDX SBOM does not contain dependency components and relationships" - } - - $reuseSpdx = Get-Content -LiteralPath $reuseSpdxPath -Raw - if ( - $reuseSpdx -notmatch '(?m)^SPDXVersion: SPDX-2\.1\r?$' -or - $reuseSpdx -notmatch "(?m)^DocumentName: $([regex]::Escape($packageName))\r?$" -or - $reuseSpdx -notmatch '(?m)^LicenseInfoInFile: ' - ) { - throw "REUSE SPDX SBOM is missing format, package, or license information" - } - - Write-Host "Generated and validated:" - Write-Host " $cycloneDxPath" - Write-Host " $reuseSpdxPath" -} -finally { - if ( - $null -ne (Get-Variable generatedCycloneDx -ErrorAction SilentlyContinue) -and - (Test-Path -LiteralPath $generatedCycloneDx) - ) { - Remove-Item -Force -LiteralPath $generatedCycloneDx - } - Pop-Location -} diff --git a/scripts/release-candidate.ps1 b/scripts/release-candidate.ps1 deleted file mode 100644 index 1b8ba6a..0000000 --- a/scripts/release-candidate.ps1 +++ /dev/null @@ -1,140 +0,0 @@ -[CmdletBinding()] -param( - [switch] $AllowDirty -) - -$ErrorActionPreference = "Stop" -Set-StrictMode -Version Latest - -function Get-Sha256Hex { - param( - [Parameter(Mandatory)] - [string] $LiteralPath - ) - - $stream = [System.IO.File]::OpenRead($LiteralPath) - try { - $algorithm = [System.Security.Cryptography.SHA256]::Create() - try { - $bytes = $algorithm.ComputeHash($stream) - return [System.BitConverter]::ToString($bytes).Replace("-", "") - } - finally { - $algorithm.Dispose() - } - } - finally { - $stream.Dispose() - } -} - -$repositoryRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..")) -$outputPath = [System.IO.Path]::GetFullPath( - (Join-Path $repositoryRoot "target\release-candidate") -) -$expectedOutputPath = [System.IO.Path]::GetFullPath( - (Join-Path $repositoryRoot "target\release-candidate") -) -if ($outputPath -ne $expectedOutputPath) { - throw "Refusing to clean unexpected output directory: $outputPath" -} - -if (Test-Path -LiteralPath $outputPath) { - Remove-Item -Recurse -Force -LiteralPath $outputPath -} -New-Item -ItemType Directory -Path $outputPath | Out-Null - -Push-Location -LiteralPath $repositoryRoot -try { - $metadataJson = & cargo metadata --locked --no-deps --format-version 1 - if ($LASTEXITCODE -ne 0) { - throw "cargo metadata failed with exit code $LASTEXITCODE" - } - $metadata = $metadataJson | ConvertFrom-Json - $manifestPath = [System.IO.Path]::GetFullPath( - (Join-Path $repositoryRoot "Cargo.toml") - ) - $package = @( - $metadata.packages | - Where-Object { - [System.IO.Path]::GetFullPath($_.manifest_path) -eq $manifestPath - } - ) - if ($package.Count -ne 1) { - throw "Could not identify the root Cargo package" - } - - $packageName = [string]$package[0].name - $packageVersion = [string]$package[0].version - $crateName = "$packageName-$packageVersion.crate" - $cargoCrate = Join-Path $repositoryRoot "target\package\$crateName" - $candidateCrate = Join-Path $outputPath $crateName - $packageArguments = @("package", "--locked") - if ($AllowDirty) { - Write-Warning "Generating a non-release candidate from a dirty worktree" - $packageArguments += "--allow-dirty" - } - - & cargo @packageArguments - if ($LASTEXITCODE -ne 0) { - throw "First cargo package run failed with exit code $LASTEXITCODE" - } - Copy-Item -LiteralPath $cargoCrate -Destination $candidateCrate - $firstHash = (Get-Sha256Hex -LiteralPath $candidateCrate) - - & cargo @packageArguments - if ($LASTEXITCODE -ne 0) { - throw "Second cargo package run failed with exit code $LASTEXITCODE" - } - $secondHash = (Get-Sha256Hex -LiteralPath $cargoCrate) - if ($firstHash -ne $secondHash) { - throw "cargo package is not reproducible: $firstHash differs from $secondHash" - } - - & (Join-Path $PSScriptRoot "check-packaged-reuse.ps1") - if ($LASTEXITCODE -ne 0) { - throw "Packaged REUSE verification failed" - } - - & (Join-Path $PSScriptRoot "generate-sboms.ps1") -OutputDirectory $outputPath - if ($LASTEXITCODE -ne 0) { - throw "SBOM generation failed" - } - - $artifacts = @( - Get-ChildItem -LiteralPath $outputPath -File | - Where-Object { - $_.Extension -eq ".crate" -or - $_.Name.EndsWith(".cdx.json") -or - $_.Name.EndsWith(".reuse.spdx") - } | - Sort-Object -Property Name - ) - if ($artifacts.Count -ne 3) { - throw "Expected one crate and two SBOMs, found $($artifacts.Count)" - } - - $checksumLines = @( - foreach ($artifact in $artifacts) { - $hash = ( - Get-Sha256Hex -LiteralPath $artifact.FullName - ).ToLowerInvariant() - "$hash $($artifact.Name)" - } - ) - $checksumPath = Join-Path $outputPath "SHA256SUMS" - $utf8WithoutBom = New-Object System.Text.UTF8Encoding($false) - [System.IO.File]::WriteAllLines( - $checksumPath, - [string[]]$checksumLines, - $utf8WithoutBom - ) - - Write-Host "Release candidate is reproducible and validated:" - Get-ChildItem -LiteralPath $outputPath -File | - Sort-Object -Property Name | - ForEach-Object { Write-Host " $($_.FullName)" } -} -finally { - Pop-Location -} diff --git a/scripts/run-mutants-contained.ps1 b/scripts/run-mutants-contained.ps1 deleted file mode 100644 index cb6f662..0000000 --- a/scripts/run-mutants-contained.ps1 +++ /dev/null @@ -1,275 +0,0 @@ -[CmdletBinding()] -param( - [Parameter(ValueFromRemainingArguments = $true)] - [string[]] $MutantsArguments = @() -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' - -$nativeSource = @' -using System; -using System.ComponentModel; -using System.Runtime.InteropServices; -using System.Text; - -namespace WindowsSpawn.Tools -{ - public static class MutationJob - { - private const uint CreateSuspended = 0x00000004; - private const uint KillOnJobClose = 0x00002000; - private const int ExtendedLimitInformationClass = 9; - private const uint Infinite = 0xffffffff; - private const uint WaitObject0 = 0; - - [StructLayout(LayoutKind.Sequential)] - private struct StartupInfo - { - internal uint cb; - internal IntPtr lpReserved; - internal IntPtr lpDesktop; - internal IntPtr lpTitle; - internal uint dwX; - internal uint dwY; - internal uint dwXSize; - internal uint dwYSize; - internal uint dwXCountChars; - internal uint dwYCountChars; - internal uint dwFillAttribute; - internal uint dwFlags; - internal ushort wShowWindow; - internal ushort cbReserved2; - internal IntPtr lpReserved2; - internal IntPtr hStdInput; - internal IntPtr hStdOutput; - internal IntPtr hStdError; - } - - [StructLayout(LayoutKind.Sequential)] - private struct ProcessInformation - { - internal IntPtr hProcess; - internal IntPtr hThread; - internal uint dwProcessId; - internal uint dwThreadId; - } - - [StructLayout(LayoutKind.Sequential)] - private struct BasicLimits - { - internal long PerProcessUserTimeLimit; - internal long PerJobUserTimeLimit; - internal uint LimitFlags; - internal UIntPtr MinimumWorkingSetSize; - internal UIntPtr MaximumWorkingSetSize; - internal uint ActiveProcessLimit; - internal UIntPtr Affinity; - internal uint PriorityClass; - internal uint SchedulingClass; - } - - [StructLayout(LayoutKind.Sequential)] - private struct IoCounters - { - internal ulong ReadOperationCount; - internal ulong WriteOperationCount; - internal ulong OtherOperationCount; - internal ulong ReadTransferCount; - internal ulong WriteTransferCount; - internal ulong OtherTransferCount; - } - - [StructLayout(LayoutKind.Sequential)] - private struct ExtendedLimits - { - internal BasicLimits BasicLimitInformation; - internal IoCounters IoInfo; - internal UIntPtr ProcessMemoryLimit; - internal UIntPtr JobMemoryLimit; - internal UIntPtr PeakProcessMemoryUsed; - internal UIntPtr PeakJobMemoryUsed; - } - - [DllImport("kernel32.dll", EntryPoint = "CreateJobObjectW", - ExactSpelling = true, CharSet = CharSet.Unicode, SetLastError = true)] - private static extern IntPtr CreateJobObject(IntPtr attributes, string name); - - [DllImport("kernel32.dll", ExactSpelling = true, SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool SetInformationJobObject( - IntPtr job, int informationClass, ref ExtendedLimits information, uint length); - - [DllImport("kernel32.dll", EntryPoint = "CreateProcessW", - ExactSpelling = true, CharSet = CharSet.Unicode, SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool CreateProcess( - string applicationName, StringBuilder commandLine, IntPtr processAttributes, - IntPtr threadAttributes, [MarshalAs(UnmanagedType.Bool)] bool inheritHandles, - uint creationFlags, IntPtr environment, string currentDirectory, - ref StartupInfo startupInfo, out ProcessInformation processInformation); - - [DllImport("kernel32.dll", ExactSpelling = true, SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process); - - [DllImport("kernel32.dll", ExactSpelling = true, SetLastError = true)] - private static extern uint ResumeThread(IntPtr thread); - - [DllImport("kernel32.dll", ExactSpelling = true, SetLastError = true)] - private static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds); - - [DllImport("kernel32.dll", ExactSpelling = true, SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool GetExitCodeProcess(IntPtr process, out uint exitCode); - - [DllImport("kernel32.dll", ExactSpelling = true, SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool TerminateProcess(IntPtr process, uint exitCode); - - [DllImport("kernel32.dll", ExactSpelling = true, SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool CloseHandle(IntPtr handle); - - public static int Run(string cargoPath, string commandLine, string currentDirectory) - { - IntPtr job = IntPtr.Zero; - ProcessInformation process = new ProcessInformation(); - try - { - job = CreateJobObject(IntPtr.Zero, null); - if (job == IntPtr.Zero) - ThrowLastError("CreateJobObject"); - - ExtendedLimits limits = new ExtendedLimits(); - limits.BasicLimitInformation.LimitFlags = KillOnJobClose; - if (!SetInformationJobObject( - job, ExtendedLimitInformationClass, ref limits, - (uint)Marshal.SizeOf(typeof(ExtendedLimits)))) - ThrowLastError("SetInformationJobObject"); - - StartupInfo startup = new StartupInfo(); - startup.cb = (uint)Marshal.SizeOf(typeof(StartupInfo)); - if (!CreateProcess( - cargoPath, new StringBuilder(commandLine), IntPtr.Zero, IntPtr.Zero, - true, CreateSuspended, IntPtr.Zero, currentDirectory, ref startup, - out process)) - ThrowLastError("CreateProcess"); - - if (!AssignProcessToJobObject(job, process.hProcess)) - { - int error = Marshal.GetLastWin32Error(); - TerminateAndWait(process.hProcess); - throw new Win32Exception(error, "AssignProcessToJobObject failed"); - } - if (ResumeThread(process.hThread) == uint.MaxValue) - { - int error = Marshal.GetLastWin32Error(); - TerminateAndWait(process.hProcess); - throw new Win32Exception(error, "ResumeThread failed"); - } - if (WaitForSingleObject(process.hProcess, Infinite) != WaitObject0) - ThrowLastError("WaitForSingleObject"); - - uint exitCode; - if (!GetExitCodeProcess(process.hProcess, out exitCode)) - ThrowLastError("GetExitCodeProcess"); - return unchecked((int)exitCode); - } - catch - { - if (process.hProcess != IntPtr.Zero) - TerminateAndWait(process.hProcess); - throw; - } - finally - { - if (process.hThread != IntPtr.Zero) - CloseHandle(process.hThread); - if (process.hProcess != IntPtr.Zero) - CloseHandle(process.hProcess); - // The last Job handle kills descendants left behind by cargo or its tests. - if (job != IntPtr.Zero) - CloseHandle(job); - } - } - - private static void ThrowLastError(string operation) - { - throw new Win32Exception(Marshal.GetLastWin32Error(), operation + " failed"); - } - - private static void TerminateAndWait(IntPtr process) - { - TerminateProcess(process, 1); - WaitForSingleObject(process, Infinite); - } - } -} -'@ - -if (-not ('WindowsSpawn.Tools.MutationJob' -as [type])) { - Add-Type -TypeDefinition $nativeSource -Language CSharp -} - -function ConvertTo-WindowsCommandLineArgument { - param([AllowEmptyString()][string] $Argument) - - if ($Argument.Length -gt 0 -and $Argument -notmatch '[\s"]') { - return $Argument - } - - $quoted = [Text.StringBuilder]::new() - [void] $quoted.Append([char] 0x22) - $backslashes = 0 - foreach ($character in $Argument.ToCharArray()) { - if ($character -eq [char] 0x5c) { - $backslashes++ - continue - } - $copies = if ($character -eq [char] 0x22) { - 2 * $backslashes + 1 - } else { - $backslashes - } - for ($index = 0; $index -lt $copies; $index++) { - [void] $quoted.Append([char] 0x5c) - } - $backslashes = 0 - [void] $quoted.Append($character) - } - for ($index = 0; $index -lt (2 * $backslashes); $index++) { - [void] $quoted.Append([char] 0x5c) - } - [void] $quoted.Append([char] 0x22) - return $quoted.ToString() -} - -$outputPath = [Environment]::GetEnvironmentVariable('CARGO_MUTANTS_OUTPUT') -if ([string]::IsNullOrWhiteSpace($outputPath)) { - $runsDirectory = Join-Path $PWD.ProviderPath 'target\mutants\runs' - [void] [IO.Directory]::CreateDirectory($runsDirectory) - $runName = '{0}-{1}' -f [DateTime]::UtcNow.ToString('yyyyMMddTHHmmssfffZ'), $PID - $outputPath = Join-Path $runsDirectory $runName - $env:CARGO_MUTANTS_OUTPUT = $outputPath -} -Write-Host "cargo-mutants output: $outputPath" - -$cargo = Get-Command 'cargo.exe' -CommandType Application -ErrorAction Stop | - Select-Object -First 1 -$arguments = @($cargo.Path, 'mutants') + @($MutantsArguments) -$commandLine = ($arguments | ForEach-Object { - ConvertTo-WindowsCommandLineArgument -Argument $_ - }) -join ' ' - -try { - $exitCode = [WindowsSpawn.Tools.MutationJob]::Run( - $cargo.Path, - $commandLine, - $PWD.ProviderPath) -} catch { - Write-Error -ErrorRecord $_ - exit 1 -} - -exit $exitCode diff --git a/scripts/verify-release-tag.ps1 b/scripts/verify-release-tag.ps1 deleted file mode 100644 index 52817e3..0000000 --- a/scripts/verify-release-tag.ps1 +++ /dev/null @@ -1,50 +0,0 @@ -[CmdletBinding()] -param( - [Parameter(Mandatory = $true)] - [string] $Tag -) - -$ErrorActionPreference = "Stop" -Set-StrictMode -Version Latest - -$semverTag = '^v(?0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$' -if ($Tag -notmatch $semverTag) { - throw "Release tag must be v-prefixed SemVer, for example v1.2.3" -} -$tagVersion = $Tag.Substring(1) - -& git show-ref --verify --quiet "refs/tags/$Tag" -if ($LASTEXITCODE -ne 0) { - throw "Tag does not exist in this checkout: $Tag" -} - -$tagCommit = (& git rev-list -n 1 "refs/tags/$Tag").Trim() -$headCommit = (& git rev-parse HEAD).Trim() -if ($LASTEXITCODE -ne 0 -or $tagCommit -ne $headCommit) { - throw "Tag $Tag does not resolve to checked-out commit $headCommit" -} - -$metadataJson = & cargo metadata --locked --no-deps --format-version 1 -if ($LASTEXITCODE -ne 0) { - throw "cargo metadata failed with exit code $LASTEXITCODE" -} -$metadata = $metadataJson | ConvertFrom-Json -$repositoryRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..")) -$manifestPath = [System.IO.Path]::GetFullPath( - (Join-Path $repositoryRoot "Cargo.toml") -) -$package = @( - $metadata.packages | - Where-Object { - [System.IO.Path]::GetFullPath($_.manifest_path) -eq $manifestPath - } -) -if ($package.Count -ne 1 -or [string]$package[0].version -ne $tagVersion) { - throw "Tag version $tagVersion does not match the root Cargo package version" -} - -if (& git status --porcelain) { - throw "Release checkout is not clean" -} - -Write-Host "Verified $Tag at $headCommit for package version $tagVersion" diff --git a/src/mitigation.rs b/src/mitigation.rs index 78e40a9..ed8e19f 100644 --- a/src/mitigation.rs +++ b/src/mitigation.rs @@ -175,19 +175,16 @@ pub enum BlockNonCetBinaries { /// A complete SDK 10.0.22621 process-creation mitigation policy. /// -/// Setters replace exactly one field. Reserved field values cannot be -/// represented, and combining independently-built raw policy words is -/// intentionally unsupported. +/// Setters replace one field. Reserved values and combined raw policy words +/// cannot be represented. /// /// # Runtime support /// /// This type mirrors the policy fields in Windows SDK 10.0.22621; it is not a /// claim that every field works on every supported Windows installation. /// Availability varies by individual policy, Windows release, processor -/// architecture, hardware, and child executable. windows-spawn deliberately does -/// not guess or silently weaken a requested policy. If the host cannot apply -/// it, the spawn operation returns the operating-system error from process -/// creation. +/// architecture, hardware, and child executable. windows-spawn does not weaken +/// a requested policy. Unsupported policies return the process-creation error. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] pub struct MitigationPolicy { words: [u64; 2], diff --git a/src/options.rs b/src/options.rs index 797b982..e9bc2c1 100644 --- a/src/options.rs +++ b/src/options.rs @@ -18,8 +18,8 @@ pub enum DropPolicy { /// Safe, named `CreateProcessW` creation flags. /// -/// Unicode-environment, extended-startup-info, and suspended flags are owned -/// by windows-spawn and deliberately absent. There is no raw-bits constructor. +/// Unicode-environment, extended-startup-info, and suspended flags are set +/// internally. There is no raw-bits constructor. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] pub struct CreationFlags(u32); diff --git a/src/sys.rs b/src/sys.rs index 0f8c208..b41e85d 100644 --- a/src/sys.rs +++ b/src/sys.rs @@ -560,7 +560,7 @@ pub(crate) fn wait_process_for_test( #[cfg(test)] pub(crate) fn cleanup_process_for_test(process: BorrowedHandle<'_>) { - // This path deliberately bypasses the production wrapper under mutation. + // Bypass the production wrapper so its mutants cannot disable cleanup. // SAFETY: tests pass a duplicate with the source process handle's access. let _ = unsafe { TerminateProcess(raw(process), 1) }; // SAFETY: the same borrowed process handle remains valid for the wait. @@ -599,8 +599,8 @@ pub(crate) fn read_handle(handle: BorrowedHandle<'_>, buffer: &mut [u8]) -> io:: } let length = u32::try_from(buffer.len()).unwrap_or(u32::MAX); let mut read = 0_u32; - // SAFETY: buffer is writable for `length` bytes and the synchronous handle - // remains valid. The OVERLAPPED pointer is intentionally null. + // SAFETY: buffer is writable for `length` bytes, the synchronous handle + // remains valid, and a null OVERLAPPED requests synchronous I/O. if unsafe { ReadFile( raw(handle), @@ -633,8 +633,8 @@ pub(crate) fn write_handle(handle: BorrowedHandle<'_>, buffer: &[u8]) -> io::Res } let length = u32::try_from(buffer.len()).unwrap_or(u32::MAX); let mut written = 0_u32; - // SAFETY: buffer is readable for `length` bytes and the synchronous handle - // remains valid. The OVERLAPPED pointer is intentionally null. + // SAFETY: buffer is readable for `length` bytes, the synchronous handle + // remains valid, and a null OVERLAPPED requests synchronous I/O. if unsafe { WriteFile( raw(handle), diff --git a/tests/windows_spawn.rs b/tests/windows_spawn.rs index 62a8558..f74aa33 100644 --- a/tests/windows_spawn.rs +++ b/tests/windows_spawn.rs @@ -121,7 +121,7 @@ impl ProcessExitGuard { impl Drop for ProcessExitGuard { fn drop(&mut self) { if self.armed { - // Cleanup deliberately bypasses the crate path under mutation. + // Bypass the crate path so its mutants cannot disable cleanup. // SAFETY: the duplicate has the source process handle's access. let _ = unsafe { TerminateProcess(self.process.as_raw_handle(), 1) }; // SAFETY: the same owned process handle remains valid here. diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml new file mode 100644 index 0000000..6a791fb --- /dev/null +++ b/xtask/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "xtask" +version = "0.0.0" +edition = "2021" +rust-version = "1.75" +publish = false + +[dependencies] +semver = "1.0.28" +serde_json = "1.0.151" +sha2 = "0.10.9" + +[target.'cfg(windows)'.dependencies] +windows-spawn = { path = ".." } + +[lints] +workspace = true diff --git a/xtask/src/cli.rs b/xtask/src/cli.rs new file mode 100644 index 0000000..f713913 --- /dev/null +++ b/xtask/src/cli.rs @@ -0,0 +1,259 @@ +use std::path::PathBuf; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum SimpleTask { + Fmt, + Clippy, + Test, + Doc, + Msrv, + CrossTargets, + LinuxEmpty, + SupplyChain, + Reuse, + Typos, + Coverage, + Ci, +} + +#[derive(Debug, Eq, PartialEq)] +pub(crate) enum Task { + Simple(SimpleTask), + PublicApi { + update: bool, + }, + PackageCheck { + allow_dirty: bool, + }, + Sbom { + output: Option, + }, + ReleaseCandidate { + allow_dirty: bool, + github_output: bool, + }, + VerifyReleaseTag { + tag: String, + }, + Mutants { + output: Option, + forwarded: Vec, + }, + DraftRelease { + tag: String, + github_output: bool, + }, + CratesIoAuthMode { + github_output: bool, + }, + Help, +} + +pub(crate) fn parse(arguments: impl IntoIterator) -> Result { + let mut arguments = arguments.into_iter(); + let Some(command) = arguments.next() else { + return Ok(Task::Help); + }; + let rest: Vec = arguments.collect(); + + match command.as_str() { + "fmt" => no_arguments(&rest, Task::Simple(SimpleTask::Fmt)), + "clippy" => no_arguments(&rest, Task::Simple(SimpleTask::Clippy)), + "test" => no_arguments(&rest, Task::Simple(SimpleTask::Test)), + "doc" => no_arguments(&rest, Task::Simple(SimpleTask::Doc)), + "msrv" => no_arguments(&rest, Task::Simple(SimpleTask::Msrv)), + "cross-targets" => no_arguments(&rest, Task::Simple(SimpleTask::CrossTargets)), + "linux-empty" => no_arguments(&rest, Task::Simple(SimpleTask::LinuxEmpty)), + "supply-chain" => no_arguments(&rest, Task::Simple(SimpleTask::SupplyChain)), + "reuse" => no_arguments(&rest, Task::Simple(SimpleTask::Reuse)), + "typos" => no_arguments(&rest, Task::Simple(SimpleTask::Typos)), + "coverage" => no_arguments(&rest, Task::Simple(SimpleTask::Coverage)), + "ci" => no_arguments(&rest, Task::Simple(SimpleTask::Ci)), + "public-api" => parse_public_api(&rest), + "package-check" => parse_single_flag(&rest, "--allow-dirty") + .map(|allow_dirty| Task::PackageCheck { allow_dirty }), + "sbom" => parse_output(&rest).map(|output| Task::Sbom { output }), + "release-candidate" => parse_release_candidate(&rest), + "verify-release-tag" => parse_tag(&rest).map(|tag| Task::VerifyReleaseTag { tag }), + "mutants" => parse_mutants(&rest), + "draft-release" => parse_tag_and_github_output(&rest) + .map(|(tag, github_output)| Task::DraftRelease { tag, github_output }), + "crates-io-auth-mode" => parse_single_flag(&rest, "--github-output") + .map(|github_output| Task::CratesIoAuthMode { github_output }), + "help" | "-h" | "--help" => no_arguments(&rest, Task::Help), + _ => Err(format!("unknown xtask command: {command}")), + } +} + +fn no_arguments(rest: &[String], task: Task) -> Result { + if rest.is_empty() { + Ok(task) + } else { + Err(format!("unexpected argument: {}", rest[0])) + } +} + +fn parse_public_api(rest: &[String]) -> Result { + match rest { + [] => Ok(Task::PublicApi { update: false }), + [flag] if flag == "--update" => Ok(Task::PublicApi { update: true }), + [argument, ..] => Err(format!("unexpected public-api argument: {argument}")), + } +} + +fn parse_single_flag(rest: &[String], expected: &str) -> Result { + match rest { + [] => Ok(false), + [flag] if flag == expected => Ok(true), + [argument, ..] => Err(format!("unexpected argument: {argument}")), + } +} + +fn parse_output(rest: &[String]) -> Result, String> { + match rest { + [] => Ok(None), + [flag, value] if flag == "--output" => Ok(Some(PathBuf::from(value))), + [flag] if flag == "--output" => Err("--output requires a directory".to_owned()), + [argument, ..] => Err(format!("unexpected sbom argument: {argument}")), + } +} + +fn parse_release_candidate(rest: &[String]) -> Result { + let mut allow_dirty = false; + let mut github_output = false; + for argument in rest { + match argument.as_str() { + "--allow-dirty" if !allow_dirty => allow_dirty = true, + "--github-output" if !github_output => github_output = true, + _ => return Err(format!("unexpected release-candidate argument: {argument}")), + } + } + Ok(Task::ReleaseCandidate { + allow_dirty, + github_output, + }) +} + +fn parse_tag(rest: &[String]) -> Result { + match rest { + [tag] => Ok(tag.clone()), + [] => Err("a v-prefixed release tag is required".to_owned()), + [_, argument, ..] => Err(format!("unexpected argument: {argument}")), + } +} + +fn parse_tag_and_github_output(rest: &[String]) -> Result<(String, bool), String> { + match rest { + [tag] => Ok((tag.clone(), false)), + [tag, flag] if flag == "--github-output" => Ok((tag.clone(), true)), + [] => Err("a release tag is required".to_owned()), + [_, argument, ..] => Err(format!("unexpected draft-release argument: {argument}")), + } +} + +fn parse_mutants(rest: &[String]) -> Result { + let delimiter = rest.iter().position(|argument| argument == "--"); + let (options, forwarded) = delimiter.map_or((rest, &[][..]), |index| { + (&rest[..index], &rest[index + 1..]) + }); + let output = parse_output(options).map_err(|error| error.replace("sbom", "mutants"))?; + reject_mutation_scope(forwarded)?; + Ok(Task::Mutants { + output, + forwarded: forwarded.to_vec(), + }) +} + +fn reject_mutation_scope(arguments: &[String]) -> Result<(), String> { + let prohibited = ["--workspace", "--all", "--package", "-p", "--manifest-path"]; + for (index, argument) in arguments.iter().enumerate() { + if prohibited.contains(&argument.as_str()) + || argument.starts_with("--workspace=") + || argument.starts_with("--all=") + || argument.starts_with("--package=") + || argument.starts_with("--manifest-path=") + || (argument.starts_with("-p") && argument.len() > 2) + { + return Err(format!( + "mutation package selection is fixed to windows-spawn: {argument}" + )); + } + if index > 0 && prohibited[..].contains(&arguments[index - 1].as_str()) { + return Err(format!( + "mutation package selection is fixed to windows-spawn: {argument}" + )); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn strings(values: &[&str]) -> Vec { + values.iter().map(ToString::to_string).collect() + } + + #[test] + fn parses_flags_and_forwarded_mutant_arguments() { + assert_eq!( + parse(strings(&["public-api", "--update"])).unwrap(), + Task::PublicApi { update: true } + ); + assert_eq!( + parse(strings(&[ + "mutants", "--output", "out", "--", "--list", "-vV" + ])) + .unwrap(), + Task::Mutants { + output: Some(PathBuf::from("out")), + forwarded: strings(&["--list", "-vV"]), + } + ); + } + + #[test] + fn rejects_unknown_and_workspace_mutation_arguments() { + assert!(parse(strings(&["no-such-command"])).is_err()); + assert!(parse(strings(&["mutants", "--", "--workspace"])).is_err()); + assert!(parse(strings(&["mutants", "--", "--workspace=true"])).is_err()); + assert!(parse(strings(&["mutants", "--", "-p", "other"])).is_err()); + assert!(parse(strings(&["mutants", "--list"])).is_err()); + } + + #[test] + fn parses_release_and_artifact_options() { + assert_eq!( + parse(strings(&[ + "release-candidate", + "--github-output", + "--allow-dirty" + ])) + .unwrap(), + Task::ReleaseCandidate { + allow_dirty: true, + github_output: true, + } + ); + assert_eq!( + parse(strings(&["sbom", "--output", "artifacts"])).unwrap(), + Task::Sbom { + output: Some(PathBuf::from("artifacts")), + } + ); + assert_eq!( + parse(strings(&["verify-release-tag", "v1.2.3"])).unwrap(), + Task::VerifyReleaseTag { + tag: "v1.2.3".to_owned(), + } + ); + assert!(parse(strings(&[ + "release-candidate", + "--allow-dirty", + "--allow-dirty" + ])) + .is_err()); + assert!(parse(strings(&["sbom", "--output"])).is_err()); + } +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs new file mode 100644 index 0000000..267a3ff --- /dev/null +++ b/xtask/src/main.rs @@ -0,0 +1,23 @@ +//! Repository automation for windows-spawn. + +mod cli; +mod tasks; + +use std::env; +use std::io; +use std::process; + +fn main() { + let arguments = env::args().skip(1); + let result = match cli::parse(arguments) { + Ok(task) => tasks::execute(task), + Err(error) => Err(io::Error::new(io::ErrorKind::InvalidInput, error).into()), + }; + match result { + Ok(code) => process::exit(code), + Err(error) => { + eprintln!("error: {error}"); + process::exit(1); + } + } +} diff --git a/xtask/src/tasks.rs b/xtask/src/tasks.rs new file mode 100644 index 0000000..f6ea404 --- /dev/null +++ b/xtask/src/tasks.rs @@ -0,0 +1,1010 @@ +use crate::cli::{SimpleTask, Task}; +use semver::Version; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::env; +use std::error::Error; +use std::ffi::{OsStr, OsString}; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read, Write}; +use std::path::{Component, Path, PathBuf}; +use std::process::{Command, Output}; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub(crate) type Result = std::result::Result>; + +const PACKAGE_NAME: &str = "windows-spawn"; +const PUBLIC_API_TOOLCHAIN: &str = "nightly-2026-07-02"; + +#[derive(Clone, Debug, Eq, PartialEq)] +struct PackageInfo { + name: String, + version: Version, +} + +pub(crate) fn execute(task: Task) -> Result { + let root = repository_root()?; + match task { + Task::Simple(task) => run_simple(&root, task)?, + Task::PublicApi { update } => public_api(&root, update)?, + Task::PackageCheck { allow_dirty } => package_check(&root, allow_dirty)?, + Task::Sbom { output } => { + let output = output.unwrap_or_else(|| PathBuf::from("target/release-candidate")); + generate_sboms(&root, &output, true)?; + } + Task::ReleaseCandidate { + allow_dirty, + github_output, + } => release_candidate(&root, allow_dirty, github_output)?, + Task::VerifyReleaseTag { tag } => verify_release_tag(&root, &tag)?, + Task::Mutants { output, forwarded } => return run_mutants(&root, output, &forwarded), + Task::DraftRelease { tag, github_output } => { + draft_release(&root, &tag, github_output)?; + } + Task::CratesIoAuthMode { github_output } => crates_io_auth_mode(github_output)?, + Task::Help => print_help(), + } + Ok(0) +} + +fn print_help() { + println!( + "\ +Repository tasks: + cargo xtask fmt|clippy|test|doc|msrv|cross-targets|linux-empty + cargo xtask supply-chain|reuse|typos|coverage|ci + cargo xtask public-api [--update] + cargo xtask package-check [--allow-dirty] + cargo xtask sbom [--output DIR] + cargo xtask release-candidate [--allow-dirty] [--github-output] + cargo xtask verify-release-tag TAG + cargo xtask mutants [--output DIR] -- [cargo-mutants arguments] + cargo xtask draft-release TAG [--github-output] + cargo xtask crates-io-auth-mode [--github-output]" + ); +} + +fn run_simple(root: &Path, task: SimpleTask) -> Result<()> { + match task { + SimpleTask::Fmt => run_cargo(root, &["fmt", "--all", "--", "--check"]), + SimpleTask::Clippy => run_cargo( + root, + &[ + "clippy", + "--workspace", + "--all-targets", + "--locked", + "--", + "-D", + "warnings", + ], + ), + SimpleTask::Test => { + run_cargo( + root, + &[ + "test", + "--workspace", + "--all-targets", + "--locked", + "--", + "--test-threads=1", + ], + )?; + run_cargo(root, &["test", "--workspace", "--doc", "--locked"]) + } + SimpleTask::Doc => { + let mut command = cargo(root); + command + .args(["doc", "--workspace", "--no-deps", "--locked"]) + .env("RUSTDOCFLAGS", "-D warnings"); + run(&mut command) + } + SimpleTask::Msrv => run_cargo_with_toolchain( + root, + "1.75", + &["check", "--workspace", "--all-targets", "--locked"], + ), + SimpleTask::CrossTargets => { + for target in [ + "x86_64-pc-windows-msvc", + "i686-pc-windows-msvc", + "aarch64-pc-windows-msvc", + ] { + run_cargo( + root, + &[ + "check", + "--package", + PACKAGE_NAME, + "--locked", + "--target", + target, + ], + )?; + } + Ok(()) + } + SimpleTask::LinuxEmpty => run_cargo( + root, + &[ + "check", + "--package", + PACKAGE_NAME, + "--all-targets", + "--locked", + "--target", + "x86_64-unknown-linux-gnu", + ], + ), + SimpleTask::SupplyChain => { + run_cargo(root, &["deny", "--all-features", "--locked", "check"]) + } + SimpleTask::Reuse => run_program(root, "python", &["-m", "reuse", "lint"]), + SimpleTask::Typos => run_program(root, "typos", &[]), + SimpleTask::Coverage => coverage(root), + SimpleTask::Ci => run_ci(root), + } +} + +fn run_ci(root: &Path) -> Result<()> { + for check in [ + SimpleTask::Fmt, + SimpleTask::Clippy, + SimpleTask::Test, + SimpleTask::Doc, + SimpleTask::Msrv, + SimpleTask::CrossTargets, + SimpleTask::LinuxEmpty, + SimpleTask::SupplyChain, + SimpleTask::Reuse, + SimpleTask::Typos, + ] { + run_simple(root, check)?; + } + public_api(root, false)?; + package_check(root, false) +} + +fn coverage(root: &Path) -> Result<()> { + run_cargo(root, &["llvm-cov", "clean", "--workspace"])?; + run_cargo( + root, + &[ + "llvm-cov", + "--package", + PACKAGE_NAME, + "--all-targets", + "--locked", + "--", + "--test-threads=1", + ], + )?; + run_cargo( + root, + &[ + "llvm-cov", + "report", + "--fail-under-lines", + "92", + "--fail-under-regions", + "92", + "--fail-under-functions", + "92", + ], + ) +} + +fn public_api(root: &Path, update: bool) -> Result<()> { + let mut command = cargo_with_toolchain(root, PUBLIC_API_TOOLCHAIN); + command.args(["public-api", "--package", PACKAGE_NAME, "--simplified"]); + let actual = capture(&mut command)?; + let snapshot = root.join("public-api/windows-spawn.txt"); + if update { + fs::write(&snapshot, actual.as_bytes())?; + println!("updated {}", snapshot.display()); + return Ok(()); + } + + let expected = fs::read_to_string(&snapshot)?; + compare_snapshot(&expected, &actual).map_err(Into::into) +} + +fn compare_snapshot(expected: &str, actual: &str) -> std::result::Result<(), String> { + let expected: Vec<&str> = expected.lines().collect(); + let actual: Vec<&str> = actual.lines().collect(); + if expected == actual { + return Ok(()); + } + + let first = expected + .iter() + .zip(&actual) + .position(|(left, right)| left != right) + .unwrap_or_else(|| expected.len().min(actual.len())); + Err(format!( + "public API differs at line {}\nexpected: {}\n actual: {}\nrun `cargo xtask public-api --update` for an intentional change", + first + 1, + expected.get(first).copied().unwrap_or(""), + actual.get(first).copied().unwrap_or("") + )) +} + +fn package_check(root: &Path, allow_dirty: bool) -> Result<()> { + cargo_package(root, allow_dirty)?; + check_packaged_reuse(root) +} + +fn cargo_package(root: &Path, allow_dirty: bool) -> Result<()> { + let mut arguments = vec!["package", "--package", PACKAGE_NAME, "--locked"]; + if allow_dirty { + arguments.push("--allow-dirty"); + } + run_cargo(root, &arguments) +} + +fn check_packaged_reuse(root: &Path) -> Result<()> { + let package = root_package(root)?; + let expanded = expanded_package_path(root, &package); + if !expanded.is_dir() { + return fail(format!( + "expanded package not found: {}; run cargo package first", + expanded.display() + )); + } + run_program(&expanded, "python", &["-m", "reuse", "lint"]) +} + +fn prepare_sbom_package(root: &Path) -> Result<()> { + run_cargo( + root, + &[ + "package", + "--package", + PACKAGE_NAME, + "--locked", + "--allow-dirty", + "--no-verify", + ], + ) +} + +fn expanded_package_path(root: &Path, package: &PackageInfo) -> PathBuf { + root.join("target/package") + .join(format!("{}-{}", package.name, package.version)) +} + +#[derive(Debug)] +struct SbomArtifacts { + cyclone_dx: PathBuf, + reuse_spdx: PathBuf, +} + +fn generate_sboms( + root: &Path, + requested_output: &Path, + prepare_package: bool, +) -> Result { + let output = resolve_directory(root, requested_output)?; + let package = root_package(root)?; + if prepare_package { + prepare_sbom_package(root)?; + } + let package_root = expanded_package_path(root, &package); + if !package_root.is_dir() { + return fail(format!( + "expanded package not found: {}; run cargo package first", + package_root.display() + )); + } + let base_name = format!("{}-{}.cdx", package.name, package.version); + let cyclone_name = format!("{base_name}.json"); + let generated = package_root.join(&cyclone_name); + let cyclone_dx = output.join(&cyclone_name); + let reuse_spdx = output.join(format!("{}-{}.reuse.spdx", package.name, package.version)); + + if generated.exists() && generated != cyclone_dx { + fs::remove_file(&generated)?; + } + + let result = (|| { + let mut cyclone = cargo(root); + cyclone + .arg("cyclonedx") + .arg("--manifest-path") + .arg(package_root.join("Cargo.toml")) + .args([ + "--format", + "json", + "--spec-version", + "1.5", + "--target", + "all", + "--all-features", + "--no-build-deps", + "--override-filename", + &base_name, + ]); + run(&mut cyclone)?; + if !generated.is_file() { + return fail(format!( + "cargo cyclonedx did not create {}", + generated.display() + )); + } + if generated != cyclone_dx { + fs::copy(&generated, &cyclone_dx)?; + fs::remove_file(&generated)?; + } + + let mut reuse = Command::new("python"); + reuse + .current_dir(root) + .args(["-m", "reuse", "spdx", "-o"]) + .arg(&reuse_spdx); + run(&mut reuse)?; + + validate_cyclonedx(&cyclone_dx, &package)?; + validate_reuse_spdx(&reuse_spdx, &package)?; + Ok(SbomArtifacts { + cyclone_dx: cyclone_dx.clone(), + reuse_spdx, + }) + })(); + + if generated.exists() && generated != cyclone_dx { + fs::remove_file(&generated)?; + } + let artifacts = result?; + println!("generated and validated:"); + println!(" {}", artifacts.cyclone_dx.display()); + println!(" {}", artifacts.reuse_spdx.display()); + Ok(artifacts) +} + +fn validate_cyclonedx(path: &Path, package: &PackageInfo) -> Result<()> { + let document: Value = serde_json::from_slice(&fs::read(path)?)?; + if document.get("bomFormat").and_then(Value::as_str) != Some("CycloneDX") + || document.get("specVersion").and_then(Value::as_str) != Some("1.5") + { + return fail("CycloneDX SBOM is not JSON conforming to specification 1.5"); + } + let component = document + .pointer("/metadata/component") + .ok_or_else(|| invalid_data("CycloneDX SBOM has no root component"))?; + if component.get("name").and_then(Value::as_str) != Some(package.name.as_str()) + || component.get("version").and_then(Value::as_str) + != Some(package.version.to_string().as_str()) + { + return fail("CycloneDX SBOM has incorrect root package metadata"); + } + require_licenses(component)?; + + let components = document + .get("components") + .and_then(Value::as_array) + .filter(|components| !components.is_empty()) + .ok_or_else(|| invalid_data("CycloneDX SBOM has no dependency components"))?; + for dependency in components { + require_licenses(dependency)?; + } + if document + .get("dependencies") + .and_then(Value::as_array) + .map_or(true, Vec::is_empty) + { + return fail("CycloneDX SBOM has no dependency relationships"); + } + Ok(()) +} + +fn require_licenses(component: &Value) -> Result<()> { + if component + .get("licenses") + .and_then(Value::as_array) + .map_or(true, Vec::is_empty) + { + let name = component + .get("name") + .and_then(Value::as_str) + .unwrap_or(""); + return fail(format!("CycloneDX component lacks license data: {name}")); + } + Ok(()) +} + +fn validate_reuse_spdx(path: &Path, package: &PackageInfo) -> Result<()> { + let document = fs::read_to_string(path)?; + validate_reuse_spdx_text(&document, &package.name).map_err(Into::into) +} + +fn validate_reuse_spdx_text(document: &str, package_name: &str) -> std::result::Result<(), String> { + let document_name = format!("DocumentName: {package_name}"); + let valid = document.lines().any(|line| line == "SPDXVersion: SPDX-2.1") + && document.lines().any(|line| line == document_name) + && document + .lines() + .any(|line| line.starts_with("LicenseInfoInFile: ")); + if valid { + Ok(()) + } else { + Err("REUSE SPDX SBOM is missing format, package, or license information".to_owned()) + } +} + +fn release_candidate(root: &Path, allow_dirty: bool, github_output: bool) -> Result<()> { + let output = root.join("target/release-candidate"); + validate_release_output(root, &output)?; + if output.exists() { + fs::remove_dir_all(&output)?; + } + fs::create_dir_all(&output)?; + + if allow_dirty { + eprintln!("warning: generating a non-release candidate from a dirty worktree"); + } + let package = root_package(root)?; + let crate_name = format!("{}-{}.crate", package.name, package.version); + let cargo_crate = root.join("target/package").join(&crate_name); + let candidate_crate = output.join(&crate_name); + + cargo_package(root, allow_dirty)?; + fs::copy(&cargo_crate, &candidate_crate)?; + let first_hash = sha256(&candidate_crate)?; + cargo_package(root, allow_dirty)?; + let second_hash = sha256(&cargo_crate)?; + if first_hash != second_hash { + return fail(format!( + "cargo package is not reproducible: {first_hash} differs from {second_hash}" + )); + } + + check_packaged_reuse(root)?; + let sboms = generate_sboms(root, &output, false)?; + let artifacts = release_artifacts(&output)?; + if artifacts.len() != 3 { + return fail(format!( + "expected one crate and two SBOMs, found {}", + artifacts.len() + )); + } + write_checksums(&output.join("SHA256SUMS"), &artifacts)?; + if github_output { + write_github_output("sbom", &sboms.cyclone_dx)?; + } + + println!("release candidate is reproducible and validated:"); + for artifact in all_files_sorted(&output)? { + println!(" {}", artifact.display()); + } + Ok(()) +} + +fn validate_release_output(root: &Path, output: &Path) -> Result<()> { + let expected = normalize_path(&root.join("target/release-candidate")); + if normalize_path(output) != expected { + return fail(format!( + "refusing to clean unexpected output directory: {}", + output.display() + )); + } + let canonical_root = root.canonicalize()?; + if output.exists() { + let metadata = fs::symlink_metadata(output)?; + if metadata.file_type().is_symlink() || !output.canonicalize()?.starts_with(&canonical_root) + { + return fail(format!( + "refusing to clean output outside the repository: {}", + output.display() + )); + } + } else { + let parent = output + .parent() + .ok_or_else(|| invalid_data("release output has no parent"))?; + fs::create_dir_all(parent)?; + if !parent.canonicalize()?.starts_with(&canonical_root) { + return fail(format!( + "refusing to create output outside the repository: {}", + output.display() + )); + } + } + Ok(()) +} + +fn release_artifacts(directory: &Path) -> Result> { + let mut artifacts: Vec = all_files_sorted(directory)? + .into_iter() + .filter(|path| { + let name = path.file_name().and_then(OsStr::to_str).unwrap_or_default(); + is_release_artifact_name(name) + }) + .collect(); + artifacts.sort_by(|left, right| left.file_name().cmp(&right.file_name())); + Ok(artifacts) +} + +fn is_release_artifact_name(name: &str) -> bool { + let Some((stem, extension)) = name.rsplit_once('.') else { + return false; + }; + match extension { + "crate" => true, + "json" => stem.rsplit_once('.').is_some_and(|(_, kind)| kind == "cdx"), + "spdx" => stem + .rsplit_once('.') + .is_some_and(|(_, kind)| kind == "reuse"), + _ => false, + } +} + +fn all_files_sorted(directory: &Path) -> Result> { + let mut files = Vec::new(); + for entry in fs::read_dir(directory)? { + let entry = entry?; + if entry.file_type()?.is_file() { + files.push(entry.path()); + } + } + files.sort_by(|left, right| left.file_name().cmp(&right.file_name())); + Ok(files) +} + +fn write_checksums(path: &Path, artifacts: &[PathBuf]) -> Result<()> { + let mut checksum_file = File::create(path)?; + for artifact in artifacts { + let name = artifact + .file_name() + .and_then(OsStr::to_str) + .ok_or_else(|| invalid_data("release artifact name is not Unicode"))?; + writeln!(checksum_file, "{} {name}", sha256(artifact)?)?; + } + Ok(()) +} + +fn sha256(path: &Path) -> Result { + let mut file = File::open(path)?; + let mut digest = Sha256::new(); + let mut buffer = [0_u8; 16 * 1024]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + digest.update(&buffer[..read]); + } + Ok(format!("{:x}", digest.finalize())) +} + +fn verify_release_tag(root: &Path, tag: &str) -> Result<()> { + let version = parse_release_tag(tag).map_err(|error| invalid_input(&error))?; + let reference = format!("refs/tags/{tag}"); + let mut exists = Command::new("git"); + exists + .current_dir(root) + .args(["show-ref", "--verify", "--quiet", &reference]); + if !exists.status()?.success() { + return fail(format!("tag does not exist in this checkout: {tag}")); + } + + let tag_commit = capture_program(root, "git", &["rev-list", "-n", "1", &reference])?; + let head_commit = capture_program(root, "git", &["rev-parse", "HEAD"])?; + let tag_commit = tag_commit.trim(); + let head_commit = head_commit.trim(); + if tag_commit != head_commit { + return fail(format!( + "tag {tag} does not resolve to checked-out commit {head_commit}" + )); + } + let package = root_package(root)?; + if package.version != version { + return fail(format!( + "tag version {version} does not match package version {}", + package.version + )); + } + if !capture_program(root, "git", &["status", "--porcelain"])?.is_empty() { + return fail("release checkout is not clean"); + } + println!("verified {tag} at {head_commit} for package version {version}"); + Ok(()) +} + +fn parse_release_tag(tag: &str) -> std::result::Result { + let version = tag + .strip_prefix('v') + .ok_or_else(|| "release tag must be v-prefixed SemVer, for example v1.2.3".to_owned())?; + Version::parse(version) + .map_err(|_| "release tag must be v-prefixed SemVer, for example v1.2.3".to_owned()) +} + +fn draft_release(root: &Path, tag: &str, github_output: bool) -> Result<()> { + let output = root.join("target/release-candidate"); + let artifacts = all_files_sorted(&output)?; + if artifacts.is_empty() { + return fail("release candidate has no artifacts"); + } + let mut command = Command::new("gh"); + command.current_dir(root).args(["release", "create", tag]); + command.args(&artifacts); + command.args([ + "--verify-tag", + "--draft", + "--generate-notes", + "--title", + tag, + ]); + let url = capture(&mut command)?; + let url = url.trim(); + println!("{url}"); + if github_output { + write_github_output_value("url", url)?; + } + Ok(()) +} + +fn crates_io_auth_mode(github_output: bool) -> Result<()> { + let bootstrap = env::var_os("CRATES_IO_BOOTSTRAP_TOKEN").is_some_and(|value| !value.is_empty()); + println!("bootstrap={bootstrap}"); + if github_output { + write_github_output_value("bootstrap", &bootstrap.to_string())?; + } + Ok(()) +} + +fn write_github_output(key: &str, value: &Path) -> Result<()> { + write_github_output_value(key, &value.to_string_lossy()) +} + +fn write_github_output_value(key: &str, value: &str) -> Result<()> { + let path = + env::var_os("GITHUB_OUTPUT").ok_or_else(|| invalid_input("GITHUB_OUTPUT is not set"))?; + append_github_output(Path::new(&path), key, value) +} + +fn append_github_output(path: &Path, key: &str, value: &str) -> Result<()> { + if key.contains(['\r', '\n']) || value.contains(['\r', '\n']) { + return fail("GitHub output keys and values must be single-line"); + } + let mut file = OpenOptions::new().create(true).append(true).open(path)?; + writeln!(file, "{key}={value}")?; + Ok(()) +} + +#[cfg(windows)] +fn run_mutants(root: &Path, output: Option, forwarded: &[String]) -> Result { + use windows_spawn::{Command as SpawnCommand, DropPolicy, SpawnOptions}; + + let output = mutation_output(root, output)?; + println!("cargo-mutants output: {}", output.display()); + let cargo = env::var_os("CARGO").unwrap_or_else(|| OsString::from("cargo.exe")); + let mut command = SpawnCommand::new(cargo); + command + .args(["mutants", "--package", PACKAGE_NAME]) + .args(forwarded) + .env("CARGO_MUTANTS_OUTPUT", &output) + .current_dir(root); + let status = command.status_with(SpawnOptions::new().drop_policy(DropPolicy::KillTree))?; + Ok(status.code().unwrap_or(1)) +} + +#[cfg(not(windows))] +fn run_mutants(_root: &Path, _output: Option, _forwarded: &[String]) -> Result { + fail("mutation containment requires Windows") +} + +fn mutation_output(root: &Path, requested: Option) -> Result { + if let Some(output) = requested { + return Ok(output); + } + if let Some(output) = env::var_os("CARGO_MUTANTS_OUTPUT").filter(|value| !value.is_empty()) { + return Ok(PathBuf::from(output)); + } + let millis = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis(); + let output = root + .join("target/mutants/runs") + .join(format!("{millis}-{}", std::process::id())); + fs::create_dir_all(&output)?; + Ok(output) +} + +fn resolve_directory(root: &Path, requested: &Path) -> Result { + let path = if requested.is_absolute() { + requested.to_path_buf() + } else { + root.join(requested) + }; + fs::create_dir_all(&path)?; + Ok(normalize_path(&path)) +} + +fn capture_program(root: &Path, program: &str, arguments: &[&str]) -> Result { + let mut command = Command::new(program); + command.current_dir(root).args(arguments); + capture(&mut command) +} + +fn invalid_data(message: impl Into) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message.into()) +} + +fn invalid_input(message: impl Into) -> io::Error { + io::Error::new(io::ErrorKind::InvalidInput, message.into()) +} + +fn root_package(root: &Path) -> Result { + let mut command = cargo(root); + command.args(["metadata", "--locked", "--no-deps", "--format-version", "1"]); + let output = capture(&mut command)?; + let metadata: Value = serde_json::from_str(&output)?; + select_root_package(&metadata, root).map_err(Into::into) +} + +fn select_root_package(metadata: &Value, root: &Path) -> std::result::Result { + let packages = metadata + .get("packages") + .and_then(Value::as_array) + .ok_or_else(|| "cargo metadata has no packages array".to_owned())?; + let root_manifest = normalize_path(&root.join("Cargo.toml")); + let mut matches = packages.iter().filter(|package| { + package + .get("manifest_path") + .and_then(Value::as_str) + .is_some_and(|path| normalize_path(Path::new(path)) == root_manifest) + }); + let package = matches + .next() + .ok_or_else(|| "could not identify the root Cargo package".to_owned())?; + if matches.next().is_some() { + return Err("cargo metadata contains duplicate root packages".to_owned()); + } + let name = package + .get("name") + .and_then(Value::as_str) + .ok_or_else(|| "root package has no name".to_owned())?; + let version = package + .get("version") + .and_then(Value::as_str) + .ok_or_else(|| "root package has no version".to_owned())?; + Ok(PackageInfo { + name: name.to_owned(), + version: Version::parse(version).map_err(|error| error.to_string())?, + }) +} + +fn normalize_path(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + _ => normalized.push(component.as_os_str()), + } + } + normalized +} + +fn repository_root() -> Result { + let manifest = Path::new(env!("CARGO_MANIFEST_DIR")); + let root = manifest + .parent() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "xtask has no parent"))?; + Ok(normalize_path(root)) +} + +fn cargo(root: &Path) -> Command { + let executable = env::var_os("CARGO").unwrap_or_else(|| OsString::from("cargo")); + let mut command = Command::new(executable); + command.current_dir(root); + command +} + +fn cargo_with_toolchain(root: &Path, toolchain: &str) -> Command { + let mut command = Command::new("rustup"); + command.current_dir(root).args(["run", toolchain, "cargo"]); + command +} + +fn run_cargo(root: &Path, arguments: &[&str]) -> Result<()> { + let mut command = cargo(root); + command.args(arguments); + run(&mut command) +} + +fn run_cargo_with_toolchain(root: &Path, toolchain: &str, arguments: &[&str]) -> Result<()> { + let mut command = cargo_with_toolchain(root, toolchain); + command.args(arguments); + run(&mut command) +} + +fn run_program(root: &Path, program: &str, arguments: &[&str]) -> Result<()> { + let mut command = Command::new(program); + command.current_dir(root).args(arguments); + run(&mut command) +} + +fn run(command: &mut Command) -> Result<()> { + println!("+ {command:?}"); + let status = command.status()?; + if status.success() { + Ok(()) + } else { + fail(format!("command failed with {status}: {command:?}")) + } +} + +fn capture(command: &mut Command) -> Result { + println!("+ {command:?}"); + let output = command.output()?; + ensure_success(command, &output)?; + String::from_utf8(output.stdout).map_err(Into::into) +} + +fn ensure_success(command: &Command, output: &Output) -> Result<()> { + if output.status.success() { + return Ok(()); + } + io::stderr().write_all(&output.stderr)?; + io::stdout().write_all(&output.stdout)?; + fail(format!( + "command failed with {}: {command:?}", + output.status + )) +} + +fn fail(message: impl Into) -> Result { + Err(io::Error::other(message.into()).into()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn selects_only_the_root_manifest_package() { + let root = repository_root().unwrap(); + let metadata = json!({ + "packages": [ + { + "name": "xtask", + "version": "0.0.0", + "manifest_path": root.join("xtask/Cargo.toml") + }, + { + "name": PACKAGE_NAME, + "version": "0.1.0", + "manifest_path": root.join("Cargo.toml") + } + ] + }); + assert_eq!( + select_root_package(&metadata, &root).unwrap(), + PackageInfo { + name: PACKAGE_NAME.to_owned(), + version: Version::new(0, 1, 0), + } + ); + } + + #[test] + fn snapshot_comparison_reports_the_first_change() { + assert!(compare_snapshot("one\ntwo\n", "one\ntwo\n").is_ok()); + let error = compare_snapshot("one\ntwo\n", "one\nthree\n").unwrap_err(); + assert!(error.contains("line 2")); + assert!(error.contains("expected: two")); + } + + #[test] + fn validates_semver_release_tags() { + assert_eq!( + parse_release_tag("v1.2.3-rc.1+build.4").unwrap(), + Version::parse("1.2.3-rc.1+build.4").unwrap() + ); + assert!(parse_release_tag("1.2.3").is_err()); + assert!(parse_release_tag("v01.2.3").is_err()); + assert!(parse_release_tag("v1.2").is_err()); + } + + #[test] + fn validates_sbom_formats_and_package_identity() { + let package = PackageInfo { + name: PACKAGE_NAME.to_owned(), + version: Version::new(0, 1, 0), + }; + let directory = temporary_directory("sbom"); + let cyclone = directory.join("test.cdx.json"); + let document = json!({ + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "metadata": {"component": { + "name": PACKAGE_NAME, + "version": "0.1.0", + "licenses": [{"license": {"id": "MIT"}}] + }}, + "components": [{ + "name": "dependency", + "licenses": [{"license": {"id": "MIT"}}] + }], + "dependencies": [{"ref": "root", "dependsOn": ["dependency"]}] + }); + fs::write(&cyclone, serde_json::to_vec(&document).unwrap()).unwrap(); + validate_cyclonedx(&cyclone, &package).unwrap(); + assert!(validate_reuse_spdx_text( + "SPDXVersion: SPDX-2.1\nDocumentName: windows-spawn\nLicenseInfoInFile: MIT\n", + PACKAGE_NAME + ) + .is_ok()); + assert!(validate_reuse_spdx_text("SPDXVersion: SPDX-2.1\n", PACKAGE_NAME).is_err()); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn hashes_and_sorts_release_artifacts() { + let directory = temporary_directory("artifacts"); + fs::write(directory.join("z.reuse.spdx"), b"reuse").unwrap(); + fs::write(directory.join("a.crate"), b"abc").unwrap(); + fs::write(directory.join("m.cdx.json"), b"cyclone").unwrap(); + fs::write(directory.join("ignored.txt"), b"ignored").unwrap(); + let artifacts = release_artifacts(&directory).unwrap(); + let names: Vec<&OsStr> = artifacts + .iter() + .map(|path| path.file_name().unwrap()) + .collect(); + assert_eq!( + names, + [ + OsStr::new("a.crate"), + OsStr::new("m.cdx.json"), + OsStr::new("z.reuse.spdx") + ] + ); + assert_eq!( + sha256(&directory.join("a.crate")).unwrap(), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + let sums = directory.join("SHA256SUMS"); + write_checksums(&sums, &artifacts).unwrap(); + assert!(fs::read_to_string(sums) + .unwrap() + .lines() + .next() + .unwrap() + .ends_with(" a.crate")); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn release_cleanup_guard_and_github_output_are_exact() { + let root = repository_root().unwrap(); + assert!(validate_release_output(&root, &root.join("target/release-candidate")).is_ok()); + assert!(validate_release_output(&root, &root.join("target/other")).is_err()); + + let directory = temporary_directory("github-output"); + let output = directory.join("output.txt"); + append_github_output(&output, "sbom", r"C:\candidate\bom.json").unwrap(); + append_github_output(&output, "bootstrap", "false").unwrap(); + assert_eq!( + fs::read_to_string(&output).unwrap(), + "sbom=C:\\candidate\\bom.json\nbootstrap=false\n" + ); + assert!(append_github_output(&output, "bad", "two\nlines").is_err()); + fs::remove_dir_all(directory).unwrap(); + } + + fn temporary_directory(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = env::temp_dir().join(format!( + "windows-spawn-xtask-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&path).unwrap(); + path + } +}