From 5826af62679c982cd080805bb3505ceec2079da9 Mon Sep 17 00:00:00 2001 From: Yasunobu <42543015+P4suta@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:47:34 +0900 Subject: [PATCH 1/7] ci: exercise the release path on every pull request Every defect found in the release path so far was invisible to CI, because CI tests the crate and nothing runs the release. The list, all from real failed releases: an action missing from the repository allowlist, and then two more that release-plz itself calls; a release-plz configuration that disagreed with a manifest; a runner that cannot build the crate `cargo publish` verifies; a `release_always` setting that made the crate permanently unreleasable. Each one was found by attempting a release and reading the failure. Run the real release action with `dry_run`. It downloads the same actions, including the ones release-plz calls internally, parses the same configuration, resolves the same versions, and performs the same `cargo publish` verification build. It uploads nothing. That covers the whole class: an unlisted action fails when it is downloaded, a configuration conflict fails when it is parsed, and a runner that cannot build the crate fails in the verification build -- all on the pull request instead of during a release. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 36c7374..30f5b89 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -120,10 +120,40 @@ jobs: - run: python -m pip install "reuse[charset-normalizer]==6.2.0" - run: just release-candidate + # Everything above tests the crate. Nothing above touches the release path, + # which is why every defect in it so far only appeared during a release: + # an action missing from the repository allowlist, a release-plz + # configuration that disagreed with a manifest, a runner that cannot build + # the crate `cargo publish` verifies. This job runs the real release action + # with `dry_run`, so it downloads the same actions (including the ones + # release-plz itself calls), parses the same configuration, and performs the + # same publish verification build -- without uploading anything. + release-dry-run: + name: Release dry run + runs-on: windows-2022 + timeout-minutes: 45 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + with: + toolchain: stable + - uses: release-plz/action@2eb1d8bcb770b4c48ccfaad919734b38b51958c9 # v0.5.131 + with: + command: release + version: 0.3.160 + dry_run: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ci-required: name: CI required if: always() - needs: [hygiene, test, i686-smoke, linux, coverage, package] + needs: [hygiene, test, i686-smoke, linux, coverage, package, release-dry-run] runs-on: ubuntu-latest steps: - if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') From 01bd6f77fbfed077d41291ee77aa0502d6f00716 Mon Sep 17 00:00:00 2001 From: Yasunobu <42543015+P4suta@users.noreply.github.com> Date: Sat, 19 Sep 2026 21:02:01 +0900 Subject: [PATCH 2/7] refactor!: enforce spawn invariants mechanically --- .cargo/mutants.toml | 9 - .config/nextest.toml | 17 + .github/dependabot.yml | 5 - .github/pull_request_template.md | 13 +- .github/workflows/ci.yml | 179 +++- .github/workflows/codeql.yml | 8 +- .github/workflows/deep-verification.yml | 53 ++ .github/workflows/dependency-review.yml | 2 +- .github/workflows/fuzz.yml | 65 ++ .github/workflows/mutation.yml | 13 +- .github/workflows/release-finalize.yml | 18 +- .github/workflows/release-plz.yml | 49 +- .gitignore | 2 + CHANGELOG.md | 32 +- CONTRIBUTING.md | 22 +- Cargo.lock | 144 ++- Cargo.toml | 23 +- README.md | 11 +- _typos.toml | 3 - deny.toml | 2 +- docs/adr/0004-job-attachment.md | 6 +- docs/adr/0008-mutation-testing.md | 35 +- docs/crate.md | 72 +- docs/migration-0.2.md | 27 + examples/handle_handoff.rs | 2 - examples/managed_output.rs | 5 +- fuzz/Cargo.lock | 89 ++ fuzz/Cargo.toml | 25 + fuzz/fuzz_targets/command_line.rs | 24 + fuzz/fuzz_targets/mitigation_fields.rs | 27 + justfile | 17 +- public-api/windows-spawn.txt | 485 ++++++++-- quality/invariants.toml | 106 +++ release-plz.toml | 15 - src/backend.rs | 236 +++++ src/child.rs | 468 ++++++---- src/command.rs | 130 ++- src/core_logic.rs | 272 ++++++ src/error.rs | 472 ++++++++++ src/handles.rs | 213 +++-- src/lib.rs | 29 +- src/mitigation.rs | 284 ++++-- src/options.rs | 271 +++--- src/plan.rs | 449 ++++----- src/resource.rs | 109 +++ src/sys.rs | 438 ++++++--- src/trace.rs | 64 ++ src/transaction.rs | 926 +++++++++++++------ supply-chain/audits.toml | 4 + supply-chain/config.toml | 156 ++++ supply-chain/imports.lock | 208 +++++ tests/argv_roundtrip.rs | 2 - tests/windows_spawn.rs | 330 ++++--- xtask/Cargo.toml | 3 + xtask/src/cli.rs | 10 + xtask/src/comment_policy.rs | 1110 +++++++++++++++++++++++ xtask/src/invariant_registry.rs | 130 +++ xtask/src/main.rs | 3 + xtask/src/source_policy.rs | 403 ++++++++ xtask/src/tasks.rs | 148 ++- 60 files changed, 6830 insertions(+), 1643 deletions(-) create mode 100644 .config/nextest.toml create mode 100644 .github/workflows/deep-verification.yml create mode 100644 .github/workflows/fuzz.yml create mode 100644 docs/migration-0.2.md create mode 100644 fuzz/Cargo.lock create mode 100644 fuzz/Cargo.toml create mode 100644 fuzz/fuzz_targets/command_line.rs create mode 100644 fuzz/fuzz_targets/mitigation_fields.rs create mode 100644 quality/invariants.toml create mode 100644 src/backend.rs create mode 100644 src/core_logic.rs create mode 100644 src/error.rs create mode 100644 src/resource.rs create mode 100644 src/trace.rs create mode 100644 supply-chain/audits.toml create mode 100644 supply-chain/config.toml create mode 100644 supply-chain/imports.lock create mode 100644 xtask/src/comment_policy.rs create mode 100644 xtask/src/invariant_registry.rs create mode 100644 xtask/src/source_policy.rs diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index 0a88c66..2f63c67 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -3,12 +3,3 @@ timeout_multiplier = 5.0 minimum_test_timeout = 60.0 gitignore = true cap_lints = false - -# Each exclusion is an audited equivalent transformation documented in -# docs/adr/0008-mutation-testing.md. No file or mutation class is excluded. -exclude_re = [ - 'src/mitigation\.rs:.*replace \| with \^ in replace', - 'src/sys\.rs:.*replace \| with \^ in +Describe the problem and the focused change. ## Validation - - -## Checklist - -- [ ] 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 updated the snapshot if needed. -- [ ] I did not include secrets or public vulnerability details. -- [ ] I kept this pull request focused and updated relevant documentation. +Link the machine-readable `cargo xtask ci` evidence artifact for this revision. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30f5b89..646ce6c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,23 +18,30 @@ jobs: runs-on: windows-latest timeout-minutes: 45 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 with: toolchain: "1.75" - - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 with: toolchain: nightly-2026-07-02 - - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 + with: + toolchain: "1.98.0" + - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 with: toolchain: stable components: rustfmt, clippy targets: x86_64-pc-windows-msvc, i686-pc-windows-msvc, aarch64-pc-windows-msvc - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - - uses: taiki-e/install-action@6a1bd70eaac3c8bdf093356838d7ee09fda951cf # v2.85.5 + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 + - uses: taiki-e/install-action@6a1bd70eaac3c8bdf093356838d7ee09fda951cf with: - tool: just@1.57.0,cargo-public-api@0.52.0,cargo-deny@0.19.4,typos-cli@1.48.0 + tool: just@1.57.0,cargo-public-api@0.52.0,cargo-deny@0.19.4,cargo-vet@0.10.2,typos-cli@1.48.0 - run: python -m pip install "reuse[charset-normalizer]==6.2.0" + - run: just ocomment + - run: just comment-policy + - run: just invariants + - run: just source-policy - run: just fmt - run: just clippy - run: just doc @@ -50,58 +57,162 @@ jobs: strategy: fail-fast: false matrix: - os: [windows-2022, windows-latest] + os: [windows-2019, windows-2022, windows-latest] rust: [stable, "1.75"] runs-on: ${{ matrix.os }} timeout-minutes: 15 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 with: toolchain: ${{ matrix.rust }} - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - - uses: taiki-e/install-action@6a1bd70eaac3c8bdf093356838d7ee09fda951cf # v2.85.5 + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 + - uses: taiki-e/install-action@6a1bd70eaac3c8bdf093356838d7ee09fda951cf with: - tool: just@1.57.0 + tool: just@1.57.0,cargo-nextest@0.9.145 - run: just test + - if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: junit-${{ matrix.os }}-${{ matrix.rust }} + path: target/nextest/*.xml + if-no-files-found: error i686-smoke: name: Windows i686 execution smoke runs-on: windows-latest timeout-minutes: 15 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 with: toolchain: stable targets: i686-pc-windows-msvc - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 - name: Run the ConPTY stdio isolation regression as a 32-bit process run: >- cargo test --target i686-pc-windows-msvc --test windows_spawn pseudoconsole_regular_stdio_stays_off_parent_pipes -- --exact + stress-x64: + name: Windows x64 spawn cleanup stress + runs-on: windows-latest + timeout-minutes: 120 + env: + WINDOWS_SPAWN_STRESS_CASES: "10000" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 + with: + toolchain: stable + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 + - run: cargo test --test windows_spawn spawn_cleanup_stress -- --ignored --exact --test-threads=1 + + stress-i686: + name: Windows i686 spawn cleanup stress + runs-on: windows-latest + timeout-minutes: 90 + env: + WINDOWS_SPAWN_STRESS_CASES: "2000" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 + with: + toolchain: stable + targets: i686-pc-windows-msvc + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 + - run: cargo test --target i686-pc-windows-msvc --test windows_spawn spawn_cleanup_stress -- --ignored --exact --test-threads=1 + linux: name: Linux empty API runs-on: ubuntu-latest steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 with: toolchain: stable - run: cargo xtask linux-empty + kani: + name: Kani proofs + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 + with: + toolchain: stable + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 + - run: cargo install --locked kani-verifier --version 0.68.0 + - run: cargo kani setup + - run: cargo xtask kani + + miri: + name: Miri platform-neutral core + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 + with: + toolchain: nightly-2026-07-02 + components: miri + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 + - run: cargo miri setup + - run: cargo miri test --package windows-spawn --lib core_logic::tests:: + + sanitizer: + name: Address sanitizer pure logic + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + RUSTFLAGS: -Zsanitizer=address + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 + with: + toolchain: nightly-2026-07-02 + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 + - run: cargo test --package windows-spawn --target x86_64-unknown-linux-gnu --lib core_logic::tests:: + + careful: + name: Windows cargo-careful + runs-on: windows-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 + with: + toolchain: stable + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 + - uses: taiki-e/install-action@6a1bd70eaac3c8bdf093356838d7ee09fda951cf + with: + tool: cargo-careful@0.4.10 + - run: cargo careful test --workspace --all-targets --all-features --locked -- --test-threads=1 + + arm64-smoke: + name: Windows 11 ARM64 smoke + runs-on: windows-11-arm + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 + with: + toolchain: stable + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 + - run: cargo test --package windows-spawn --lib --locked + coverage: name: Coverage runs-on: windows-latest steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 with: - toolchain: stable + toolchain: nightly-2026-07-02 components: llvm-tools-preview - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - - uses: taiki-e/install-action@6a1bd70eaac3c8bdf093356838d7ee09fda951cf # v2.85.5 + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 + - uses: taiki-e/install-action@6a1bd70eaac3c8bdf093356838d7ee09fda951cf with: tool: just@1.57.0,cargo-llvm-cov@0.8.7 - run: just coverage @@ -110,24 +221,16 @@ jobs: name: Reproducible release candidate runs-on: windows-latest steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 with: toolchain: stable - - uses: taiki-e/install-action@6a1bd70eaac3c8bdf093356838d7ee09fda951cf # v2.85.5 + - uses: taiki-e/install-action@6a1bd70eaac3c8bdf093356838d7ee09fda951cf with: tool: just@1.57.0,cargo-cyclonedx@0.5.9 - run: python -m pip install "reuse[charset-normalizer]==6.2.0" - run: just release-candidate - # Everything above tests the crate. Nothing above touches the release path, - # which is why every defect in it so far only appeared during a release: - # an action missing from the repository allowlist, a release-plz - # configuration that disagreed with a manifest, a runner that cannot build - # the crate `cargo publish` verifies. This job runs the real release action - # with `dry_run`, so it downloads the same actions (including the ones - # release-plz itself calls), parses the same configuration, and performs the - # same publish verification build -- without uploading anything. release-dry-run: name: Release dry run runs-on: windows-2022 @@ -135,14 +238,14 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: fetch-depth: 0 persist-credentials: false - - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 with: toolchain: stable - - uses: release-plz/action@2eb1d8bcb770b4c48ccfaad919734b38b51958c9 # v0.5.131 + - uses: release-plz/action@2eb1d8bcb770b4c48ccfaad919734b38b51958c9 with: command: release version: 0.3.160 @@ -153,7 +256,7 @@ jobs: ci-required: name: CI required if: always() - needs: [hygiene, test, i686-smoke, linux, coverage, package, release-dry-run] + needs: [hygiene, test, i686-smoke, stress-x64, stress-i686, linux, kani, miri, sanitizer, careful, arm64-smoke, coverage, package, release-dry-run] runs-on: ubuntu-latest steps: - if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 26d6921..0ed6046 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -33,18 +33,18 @@ jobs: - language: actions os: ubuntu-latest steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: persist-credentials: false - if: matrix.language == 'rust' - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 with: toolchain: stable - - uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 + - uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 with: languages: ${{ matrix.language }} build-mode: none queries: security-extended - - uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 + - uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 with: category: /language:${{ matrix.language }} diff --git a/.github/workflows/deep-verification.yml b/.github/workflows/deep-verification.yml new file mode 100644 index 0000000..6a7c0e1 --- /dev/null +++ b/.github/workflows/deep-verification.yml @@ -0,0 +1,53 @@ +name: Deep verification + +"on": + schedule: + - cron: "41 2 * * *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + property: + name: One million property cases + runs-on: windows-latest + timeout-minutes: 45 + env: + WINDOWS_SPAWN_PROPERTY_CASES: "1000000" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 + with: + toolchain: stable + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 + - run: cargo test --lib core_logic::tests::generated_quoting_and_fields_preserve_properties -- --exact + + stress-x64: + name: Windows x64 nightly stress + runs-on: windows-latest + timeout-minutes: 600 + env: + WINDOWS_SPAWN_STRESS_CASES: "100000" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 + with: + toolchain: stable + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 + - run: cargo test --test windows_spawn spawn_cleanup_stress -- --ignored --exact --test-threads=1 + + stress-i686: + name: Windows i686 nightly stress + runs-on: windows-latest + timeout-minutes: 360 + env: + WINDOWS_SPAWN_STRESS_CASES: "20000" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 + with: + toolchain: stable + targets: i686-pc-windows-msvc + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 + - run: cargo test --target i686-pc-windows-msvc --test windows_spawn spawn_cleanup_stress -- --ignored --exact --test-threads=1 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 1baaa73..6a01b23 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -11,6 +11,6 @@ jobs: name: Dependency review runs-on: ubuntu-latest steps: - - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 + - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 with: fail-on-severity: moderate diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 0000000..d94c998 --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,65 @@ +name: Fuzz + +"on": + pull_request: + schedule: + - cron: "23 4 * * *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + pull-request: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + target: [command_line, mitigation_fields] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 + with: + toolchain: nightly-2026-07-02 + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 + - uses: taiki-e/install-action@6a1bd70eaac3c8bdf093356838d7ee09fda951cf + with: + tool: cargo-fuzz@0.13.2 + - run: cargo fuzz run ${{ matrix.target }} -- -max_total_time=1800 + - if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: fuzz-${{ matrix.target }} + path: | + fuzz/artifacts/${{ matrix.target }} + fuzz/corpus/${{ matrix.target }} + if-no-files-found: warn + + nightly: + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 375 + strategy: + fail-fast: false + matrix: + target: [command_line, mitigation_fields] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 + with: + toolchain: nightly-2026-07-02 + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 + - uses: taiki-e/install-action@6a1bd70eaac3c8bdf093356838d7ee09fda951cf + with: + tool: cargo-fuzz@0.13.2 + - run: cargo fuzz run ${{ matrix.target }} -- -max_total_time=21600 + - if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: fuzz-nightly-${{ matrix.target }} + path: | + fuzz/artifacts/${{ matrix.target }} + fuzz/corpus/${{ matrix.target }} + if-no-files-found: warn diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index 5fce24c..317e5f1 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -1,8 +1,7 @@ name: Mutation testing "on": - schedule: - - cron: "17 3 * * 0" + pull_request: workflow_dispatch: permissions: @@ -18,17 +17,17 @@ jobs: matrix: shard: [0, 1, 2, 3] steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 with: toolchain: stable - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - - uses: taiki-e/install-action@6a1bd70eaac3c8bdf093356838d7ee09fda951cf # v2.85.5 + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 + - uses: taiki-e/install-action@6a1bd70eaac3c8bdf093356838d7ee09fda951cf with: tool: just@1.57.0,cargo-mutants@27.1.0 - run: just mutants-ci ${{ matrix.shard }} - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: mutants-${{ matrix.shard }}-of-4 path: mutants.out diff --git a/.github/workflows/release-finalize.yml b/.github/workflows/release-finalize.yml index 5cf21b0..3b27aaf 100644 --- a/.github/workflows/release-finalize.yml +++ b/.github/workflows/release-finalize.yml @@ -1,9 +1,5 @@ name: Finalize release -# Called by release-plz.yml once release-plz has published the crate and created -# the tag. `git_release_enable = false` leaves the GitHub release to this -# workflow, because the crate, SBOM, checksums, and attestations have to be -# attached before it is published. "on": workflow_call: inputs: @@ -36,15 +32,15 @@ jobs: RELEASE_TAG: ${{ inputs.tag }} RELEASE_VERSION: ${{ inputs.version }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: ref: ${{ inputs.tag }} fetch-depth: 0 persist-credentials: false - - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 with: toolchain: stable - - uses: taiki-e/install-action@6a1bd70eaac3c8bdf093356838d7ee09fda951cf # v2.85.5 + - uses: taiki-e/install-action@6a1bd70eaac3c8bdf093356838d7ee09fda951cf with: tool: cargo-cyclonedx@0.5.9 - run: python -m pip install "reuse[charset-normalizer]==6.2.0" @@ -54,10 +50,6 @@ jobs: id: candidate run: cargo xtask release-candidate --github-output - # The attestations below cover the archive built here, so it has to be - # the archive crates.io actually serves. `cargo package` is deterministic - # for a given source tree, and release-plz published from this same tag, - # so a mismatch means something diverged and the release must not proceed. - name: Require the built archive to match the published crate shell: pwsh run: | @@ -76,7 +68,7 @@ jobs: "built and published archives match: $built" | Write-Output - name: Attest SLSA v1 provenance - uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4 + uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d with: subject-path: | target/release-candidate/*.crate @@ -85,7 +77,7 @@ jobs: target/release-candidate/SHA256SUMS - name: Attest CycloneDX SBOM - uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4 + uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d with: subject-path: target/release-candidate/*.crate sbom-path: ${{ steps.candidate.outputs.sbom }} diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml index 3971d23..a0d6a61 100644 --- a/.github/workflows/release-plz.yml +++ b/.github/workflows/release-plz.yml @@ -1,29 +1,10 @@ --- -# SPDX-FileCopyrightText: 2026 Yasunobu Sakashita -# -# SPDX-License-Identifier: MIT OR Apache-2.0 -# The upstream-recommended release-plz workflow. Two deviations, both of which -# release-plz documents: -# -# * A GitHub App token instead of GITHUB_TOKEN. The default token cannot -# trigger other workflows, so CI would never run on a release pull request. -# * `environment: release` on both jobs. The App credentials are scoped to it, -# and it is where the crates.io credential lives. -# -# Windows, not the ubuntu-latest of the upstream template: `cargo publish` -# verifies the tarball by building it, and this crate does not build on Linux -# because its windows-sys dependency sits behind `cfg(windows)`. name: Release-plz "on": push: branches: [main] - # A release that fails partway cannot be recovered by re-running: a run that - # fails at startup is not retryable, and `main` only moves through pull - # requests. Manual dispatch is the retry path. It is not a second way to - # release -- release-plz still publishes only when the manifest version is - # ahead of the registry. workflow_dispatch: permissions: {} @@ -50,23 +31,19 @@ jobs: steps: - name: Mint the release GitHub App token id: app-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 with: client-id: ${{ vars.RELEASE_PLZ_APP_CLIENT_ID }} private-key: ${{ secrets.RELEASE_PLZ_APP_PRIVATE_KEY }} permission-contents: write permission-pull-requests: read - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: fetch-depth: 0 persist-credentials: false - - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 with: toolchain: stable - # A trusted publisher can only be registered against a crate that already - # exists. The bootstrap token covered 0.1.0; once a trusted publisher is - # registered for this workflow the secret can be deleted and the OpenID - # Connect exchange takes over. - name: Select the crates.io credential id: auth-mode env: @@ -75,10 +52,10 @@ jobs: - name: Authenticate with crates.io trusted publishing id: crates-auth if: steps.auth-mode.outputs.bootstrap == 'false' - uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5 + uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 - name: Run release-plz id: run - uses: release-plz/action@2eb1d8bcb770b4c48ccfaad919734b38b51958c9 # v0.5.131 + uses: release-plz/action@2eb1d8bcb770b4c48ccfaad919734b38b51958c9 with: command: release version: 0.3.160 @@ -97,9 +74,6 @@ jobs: set -euo pipefail jq -r '.[0] | "tag=\(.tag)", "version=\(.version)"' <<< "$RELEASES" >> "$GITHUB_OUTPUT" - # release-plz publishes the crate and creates the tag; the GitHub release is - # built here so the crate, SBOM, checksums, and attestations are attached - # before it is published. finalize: name: Attest and publish the release needs: release @@ -116,8 +90,6 @@ jobs: release-pr: name: Release-plz PR - # Ordered after `release` so a release pull request is never opened for a - # version that is being published in the same run. needs: release if: always() && github.repository == 'P4suta/windows-spawn' runs-on: windows-2022 @@ -131,25 +103,24 @@ jobs: steps: - name: Mint the release GitHub App token id: app-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 with: client-id: ${{ vars.RELEASE_PLZ_APP_CLIENT_ID }} private-key: ${{ secrets.RELEASE_PLZ_APP_PRIVATE_KEY }} permission-contents: write permission-pull-requests: write - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: fetch-depth: 0 persist-credentials: false - - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 with: toolchain: stable - # `semver_check = true` in release-plz.toml needs this on PATH. - - uses: taiki-e/install-action@6a1bd70eaac3c8bdf093356838d7ee09fda951cf # v2.85.5 + - uses: taiki-e/install-action@6a1bd70eaac3c8bdf093356838d7ee09fda951cf with: tool: cargo-semver-checks@0.45.0 - name: Run release-plz - uses: release-plz/action@2eb1d8bcb770b4c48ccfaad919734b38b51958c9 # v0.5.131 + uses: release-plz/action@2eb1d8bcb770b4c48ccfaad919734b38b51958c9 with: command: release-pr version: 0.3.160 diff --git a/.gitignore b/.gitignore index 6712e17..c84a136 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ /target /mutants.out* +/fuzz/artifacts +/fuzz/target diff --git a/CHANGELOG.md b/CHANGELOG.md index b89b316..2b4a0a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,35 @@ Versioning with Cargo's pre-1.0 compatibility rules. ## [Unreleased] +## [0.2.0] - 2026-09-19 + +### Changed + +- Replaced the spawn transaction with consuming states from validated intent + through suspended creation, reclamation, and resume. Ordinary spawns are now + created suspended internally and resume only after temporary resources are + reclaimed. +- Replaced `io::Result` at the public boundary with typed validation, Windows, + and nonempty cleanup errors. Converting the typed error to `io::Error` + preserves it as the source. +- Replaced boolean mitigation choices, drop policy, terminal flags, and free + creation flags with closed policy enums and dedicated setters. +- Bound pseudoconsole values to their owner lifetime through + `BorrowedPseudoConsole` and the unsafe `AsPseudoConsole` contract. + +### Added + +- Typed resource kinds and handle-table markers, pinned process-attribute + backing, explicit process-tree cleanup, and a single-operation unsafe lint. +- An OComment-backed repository comment policy built from pinned source and a + typed invariant registry whose enforcement values are limited to type, + const, lint, and Kani. + +### Removed + +- `DropPolicy`, `CreationFlags`, raw pseudoconsole values, and boolean DEP, + ATL-thunk, and SEHOP setters. No compatibility shims are provided. + ## [0.1.0] - 2026-08-03 Initial release. @@ -51,5 +80,6 @@ Initial release. this crate's 1.75 minimum. The release-artifact checksum helper builds against both 0.10 and 0.11. -[Unreleased]: https://github.com/P4suta/windows-spawn/compare/v0.1.0...HEAD +[Unreleased]: https://github.com/P4suta/windows-spawn/compare/v0.2.0...HEAD +[0.2.0]: https://github.com/P4suta/windows-spawn/compare/v0.1.0...v0.2.0 [0.1.0]: https://github.com/P4suta/windows-spawn/releases/tag/v0.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f2e402b..d50c13b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,9 +17,10 @@ explain the safety invariant they preserve. Development and integration tests require Windows 10 version 1809 or later. The crate supports Rust 1.75 and later. Install the tool versions used by CI, -then run: +then run. The first command builds the pinned OComment revision from source: ```powershell +just ocomment just ci just coverage ``` @@ -36,16 +37,11 @@ gitleaks dir . --redact --no-banner `just release-candidate` validates packaging and reproducibility locally; it does not publish anything. -## Code and documentation expectations +## Machine-enforced change contract -- 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 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. - -Required checks and review conversations must be complete before merge. Pull -requests are squash-merged into `main`. +`cargo xtask ci` begins with the OComment-backed comment policy and the typed +invariant registry. It then checks formatting, lints, tests, documentation, +MSRV, targets, supply chain, spelling, public API, and packaging. A pull request +links the resulting evidence artifact; it does not carry a manual correctness +checklist. Pull requests are squash-merged into `main` only after required +checks succeed. diff --git a/Cargo.lock b/Cargo.lock index cbef9d4..38e5161 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -46,6 +46,12 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "generic-array" version = "0.14.7" @@ -56,6 +62,22 @@ dependencies = [ "version_check", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" + +[[package]] +name = "indexmap" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "itoa" version = "1.0.18" @@ -74,6 +96,18 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "proc-macro2" version = "1.0.107" @@ -105,6 +139,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", + "serde_derive", ] [[package]] @@ -124,7 +159,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -140,6 +175,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "sha2" version = "0.10.9" @@ -151,6 +195,17 @@ dependencies = [ "digest", ] +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "3.0.3" @@ -162,6 +217,78 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + [[package]] name = "typenum" version = "1.20.1" @@ -188,8 +315,9 @@ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-spawn" -version = "0.1.0" +version = "0.2.0" dependencies = [ + "tracing", "windows-sys", ] @@ -202,13 +330,25 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "xtask" version = "0.0.0" dependencies = [ "semver", + "serde", "serde_json", "sha2", + "syn 2.0.119", + "toml", "windows-spawn", ] diff --git a/Cargo.toml b/Cargo.toml index d2a1639..174d7fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windows-spawn" -version = "0.1.0" +version = "0.2.0" authors = ["Yasunobu Sakashita"] edition = "2021" rust-version = "1.75" @@ -17,6 +17,7 @@ include = [ "/examples/**", "/docs/adr/**", "/docs/crate.md", + "/docs/migration-0.2.md", "/README.md", "/CHANGELOG.md", "/REUSE.toml", @@ -26,9 +27,14 @@ include = [ ] [package.metadata.docs.rs] +all-features = true default-target = "x86_64-pc-windows-msvc" targets = ["x86_64-pc-windows-msvc", "aarch64-pc-windows-msvc"] +[features] +default = [] +tracing = ["dep:tracing"] + [[test]] name = "argv_roundtrip" path = "tests/argv_roundtrip.rs" @@ -50,8 +56,13 @@ features = [ "Win32_System_Threading", ] +[target.'cfg(windows)'.dependencies.tracing] +version = "0.1.44" +optional = true + [workspace] members = ["xtask"] +exclude = ["fuzz"] default-members = ["."] resolver = "2" @@ -61,20 +72,14 @@ rust_2018_idioms = { level = "deny", priority = -1 } unsafe_op_in_unsafe_fn = "deny" unreachable_pub = "deny" unused_qualifications = "deny" +unexpected_cfgs = { level = "deny", check-cfg = ['cfg(kani)', 'cfg(coverage_nightly)'] } [workspace.lints.clippy] all = { level = "deny", priority = -1 } pedantic = { level = "deny", priority = -1 } -# CONTRIBUTING requires a specific safety justification on every `unsafe` block. -# This restriction lint is what makes that rule machine-checked rather than a -# convention, so a missing justification fails the build. -# -# `multiple_unsafe_ops_per_block` is deliberately not enabled: several blocks -# here acquire two OS resources that must be adopted together to stay -# exception-safe (see `pipe`), and splitting them would widen the window in -# which one handle can leak. undocumented_unsafe_blocks = "deny" +multiple_unsafe_ops_per_block = "deny" [lints] workspace = true diff --git a/README.md b/README.md index f3a226f..dc985b4 100644 --- a/README.md +++ b/README.md @@ -25,13 +25,13 @@ cargo add windows-spawn ## Minimal example ```rust -use windows_spawn::{Command, DropPolicy, SpawnOptions}; +use windows_spawn::{Command, JobClosePolicy, SpawnOptions}; let mut command = Command::new(r"C:\Windows\System32\cmd.exe"); command.args(["/D", "/S", "/C"]).raw_arg("echo hello"); let output = command.output_with( - SpawnOptions::new().drop_policy(DropPolicy::KillTree), + SpawnOptions::new().job_close_policy(JobClosePolicy::TerminateProcesses), )?; assert!(output.status.success()); # Ok::<(), std::io::Error>(()) @@ -44,6 +44,13 @@ assert!(output.status.success()); - [Architecture decisions](https://github.com/P4suta/windows-spawn/tree/main/docs/adr) - [Security policy](https://github.com/P4suta/windows-spawn/security/policy) +## Diagnostics + +The optional `tracing` feature emits typed phase, operation, resource, outcome, +Win32-code, and duration fields at `TRACE` level. Events never accept command +lines, environment contents, or numeric handle values, so those values cannot +be recorded by this instrumentation path. + ## License Apache-2.0 OR MIT. diff --git a/_typos.toml b/_typos.toml index c513c77..c5b88d2 100644 --- a/_typos.toml +++ b/_typos.toml @@ -1,6 +1,3 @@ -# SPDX-FileCopyrightText: 2026 Yasunobu Sakashita -# -# SPDX-License-Identifier: MIT OR Apache-2.0 [files] ignore-hidden = false diff --git a/deny.toml b/deny.toml index d651535..9831805 100644 --- a/deny.toml +++ b/deny.toml @@ -16,7 +16,7 @@ yanked = "deny" [licenses] version = 2 -allow = ["Apache-2.0", "MIT"] +allow = ["Apache-2.0", "MIT", "Unicode-3.0"] confidence-threshold = 1.0 exceptions = [] include-build = true diff --git a/docs/adr/0004-job-attachment.md b/docs/adr/0004-job-attachment.md index c09c614..35cc291 100644 --- a/docs/adr/0004-job-attachment.md +++ b/docs/adr/0004-job-attachment.md @@ -14,9 +14,9 @@ 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` appends a private, crate-owned innermost Job. -`DropPolicy::Detach` remains the default. `Child` owns a duplicate of any Job -handle it must retain. +`JobClosePolicy::TerminateProcesses` appends a private, crate-owned innermost +Job. `JobClosePolicy::PreserveProcesses` remains the default. `Child` owns the +private Job and process together in the owner that enforces their close order. ## Consequences diff --git a/docs/adr/0008-mutation-testing.md b/docs/adr/0008-mutation-testing.md index 69ca132..01dec36 100644 --- a/docs/adr/0008-mutation-testing.md +++ b/docs/adr/0008-mutation-testing.md @@ -1,19 +1,19 @@ -# 0008 — Mutation testing is a scheduled audited gate +# 0008 — Mutation testing is a pull-request gate Status: accepted (2026-08-02) ## Context -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. +Process-tree, suspended-process, and pipe-EOF behavior contains ownership and +cleanup faults that ordinary examples do not exercise. Mutation analysis must +therefore run against the same revision before merge. ## Decision -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. +Run every generated mutant in four shards on each pull request and on manual +dispatch. Survivors and timeouts fail their shard. An equivalent mutant may be +excluded only after its exact source expression is registered to a successful +Kani proof artifact. Suspended-process tests retain an independent process handle and use direct Win32 cleanup before reporting a failed termination assertion. Cleanup bypasses @@ -21,19 +21,12 @@ 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`. +`JobClosePolicy::TerminateProcesses`. 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`. ## Consequences -Mutation results remain actionable, the expensive suite stays off the pull -request path, and each exclusion has a reviewable equivalence proof. +Mutation results are part of the merge decision and no unproved exclusion can +silently remove production expressions from analysis. diff --git a/docs/crate.md b/docs/crate.md index 74c6371..168e6ad 100644 --- a/docs/crate.md +++ b/docs/crate.md @@ -18,8 +18,10 @@ pointer authentication is ARM64-specific. The crate does not preflight or silently weaken requested mitigations. When Windows cannot apply a requested combination, spawning returns the operating -system error from `CreateProcessW`. Nested Job behavior likewise remains -subject to Jobs already imposed by the host. +system error from `CreateProcessW`. [`Error`] preserves the phase, operation, +and Win32 code. [`CleanupError`] always contains at least one reclamation +failure. Nested Job behavior likewise remains subject to Jobs already imposed +by the host. # Commands and executable lookup @@ -32,7 +34,8 @@ subject to Jobs already imposed by the host. does not invoke a shell and must only receive syntax appropriate for the target executable's parser. - Raw attribute injection, creation flags, and mitigation constructors are not - exposed. + exposed. Supported creation behavior is selected through [`ConsoleMode`] and + dedicated [`SpawnOptions`] setters. # Handle and capability ownership @@ -56,19 +59,22 @@ Handle-handoff values form an application protocol: - A child-visible numeric value belongs to the child's handle table and must not be assumed to match the source process's value. - With an alternate parent, the resource is duplicated into the effective - parent's handle table before the child-visible value is lowered. + parent's handle table before the child-visible value is lowered. Internal + handle kinds and handle-table ownership are distinct types. 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 +inheritance when transferred handles are sensitive. The crate 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 parent, or a pseudoconsole. A borrowed `ConPTY` remains owned by the terminal -library implementing [`AsPseudoConsole`]. That library defines when terminal -pipes close and when terminal EOF occurs. Pseudoconsole process creation sets +library implementing [`AsPseudoConsole`]. [`BorrowedPseudoConsole`] carries the +owner lifetime, so it cannot outlive that terminal object. That library defines +when terminal pipes close and when terminal EOF occurs. Pseudoconsole process +creation sets `STARTF_USESTDHANDLES` with all three standard-handle slots null and does not put standard handles in the inheritance list. This prevents a hosted child from falling back to redirected standard handles owned by the parent. @@ -77,13 +83,13 @@ from falling back to redirected standard handles owned by the parent. - Dropping a normal [`Child`] detaches by default, matching [`std::process::Child`]. -- [`DropPolicy::KillTree`] owns a private innermost Job. Dropping the child - terminates the root and its descendants. +- [`JobClosePolicy::TerminateProcesses`] owns a private innermost Job. Dropping + the child terminates the root and its descendants. - [`Child::wait_with_output`] drains stdout and stderr concurrently. With - `KillTree`, it terminates remaining descendants after the root exits before - joining the readers. This guarantees pipe EOF even when a grandchild - inherited a writer. Both reader threads are joined even when one reader - fails or panics. + `TerminateProcesses`, it terminates remaining descendants after the root + exits before joining the readers. This guarantees pipe EOF even when a + grandchild inherited a writer. Both reader threads are joined even when one + reader fails or panics. - Dropping [`SuspendedChild`] before [`SuspendedChild::resume`] terminates the suspended process. Its ID, process handle, and primary-thread handle are available before resume. `resume(self)` is consuming, so a second transition @@ -98,9 +104,42 @@ transaction owns pipes, temporary duplicates, attributes, Jobs, and process/thread handles. Success transfers durable resources to [`Child`] or [`SuspendedChild`]; errors roll back the rest. -The private validation plan and transaction carry running or suspended marker -types. Their state-specific commits make a mismatched internal transition -unrepresentable. +Every process is initially created suspended. The private pipeline consumes +`ValidatedPlan`, `PreparedSpawn`, `CreatedSuspended`, and `Reclaimed` in order. +Attribute backing is pinned and owned by its attribute list. Remote duplicates +are reclaimed before the only state that can resume the primary thread exists. +An explicitly suspended request returns [`SuspendedChild`] instead of resuming. + +# Compile-time migration checks + +Boolean mitigation policies are not accepted. + +```rust,compile_fail +use windows_spawn::MitigationPolicy; + +let _ = MitigationPolicy::new().dep(true); +``` + +Raw creation flags are not part of the public API. + +```rust,compile_fail +use windows_spawn::{CreationFlags, SpawnOptions}; + +let _ = SpawnOptions::new().creation_flags(CreationFlags::NEW_CONSOLE); +``` + +Resuming consumes the suspended state. + +```rust,compile_fail +use windows_spawn::{Command, Result}; + +fn resume_twice(command: &mut Command) -> Result<()> { + let suspended = command.spawn_suspended()?; + let _running = suspended.resume()?; + let _ = suspended.id(); + Ok(()) +} +``` This crate is not a sandbox, cross-platform process facade, async runtime, or process supervisor. Tokens, ACLs, `AppContainer`, LPAC, capability SIDs, and @@ -110,5 +149,6 @@ must supply and audit those controls separately. # Further reading - [Compile-checked examples](https://github.com/P4suta/windows-spawn/tree/main/examples) +- [Version 0.2 migration](https://github.com/P4suta/windows-spawn/blob/main/docs/migration-0.2.md) - [Architecture decision records](https://github.com/P4suta/windows-spawn/tree/main/docs/adr) - [Security policy](https://github.com/P4suta/windows-spawn/security/policy) diff --git a/docs/migration-0.2.md b/docs/migration-0.2.md new file mode 100644 index 0000000..607c65c --- /dev/null +++ b/docs/migration-0.2.md @@ -0,0 +1,27 @@ +# Migrating to 0.2 + +Version 0.2 deliberately removes weak representations. Compiler errors identify +each call site that needs a decision. + +| 0.1 API | 0.2 replacement | +| --- | --- | +| `DropPolicy::Detach` | `JobClosePolicy::PreserveProcesses` | +| `DropPolicy::KillTree` | `JobClosePolicy::TerminateProcesses` | +| `SpawnOptions::drop_policy` | `SpawnOptions::job_close_policy` | +| `CreationFlags::NEW_CONSOLE` | `SpawnOptions::terminal(TerminalMode::Console(ConsoleMode::NewConsole))` | +| Other supported creation bits | Dedicated `SpawnOptions` setters | +| `MitigationPolicy::dep(bool)` | `MitigationPolicy::dep(DepPolicy)` | +| `MitigationPolicy::dep_atl_thunk(bool)` | `MitigationPolicy::dep_atl_thunk(AtlThunkPolicy)` | +| `MitigationPolicy::sehop(bool)` | `MitigationPolicy::sehop(SehopPolicy)` | +| `AsPseudoConsole::raw_pseudoconsole` | `AsPseudoConsole::as_pseudo_console` returning `BorrowedPseudoConsole` | +| Public `io::Result` | `windows_spawn::Result` | + +`BorrowedPseudoConsole::from_raw` is the sole raw pseudoconsole constructor. It +is unsafe and requires a borrow of the owner so the capability cannot outlive +that owner. + +Ordinary spawn behavior remains available through `Command`, `Child`, and +`SuspendedChild`. The internal implementation always creates the process +suspended, reclaims temporary resources, and only then resumes it. Callers that +request suspension still receive a consuming `SuspendedChild::resume` +transition. diff --git a/examples/handle_handoff.rs b/examples/handle_handoff.rs index dae6cd9..d854a4d 100644 --- a/examples/handle_handoff.rs +++ b/examples/handle_handoff.rs @@ -10,8 +10,6 @@ fn main() -> std::io::Result<()> { let mut command = Command::new("worker.exe"); command.arg("--log-handle").arg_handle(&log)?; - // arg_handle stored a private non-inheritable duplicate. The worker - // protocol parses the following decimal argument as its borrowed handle. drop(log); let status = command.status()?; assert!(status.success()); diff --git a/examples/managed_output.rs b/examples/managed_output.rs index 2dd6d23..31eefa9 100644 --- a/examples/managed_output.rs +++ b/examples/managed_output.rs @@ -2,13 +2,14 @@ #[cfg(windows)] fn main() -> std::io::Result<()> { - use windows_spawn::{Command, DropPolicy, SpawnOptions}; + use windows_spawn::{Command, JobClosePolicy, SpawnOptions}; let mut command = Command::new(r"C:\Windows\System32\cmd.exe"); command .args(["/D", "/S", "/C"]) .raw_arg("echo managed output"); - let output = command.output_with(SpawnOptions::new().drop_policy(DropPolicy::KillTree))?; + let output = command + .output_with(SpawnOptions::new().job_close_policy(JobClosePolicy::TerminateProcesses))?; assert!(output.status.success()); print!("{}", String::from_utf8_lossy(&output.stdout)); diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock new file mode 100644 index 0000000..272f395 --- /dev/null +++ b/fuzz/Cargo.lock @@ -0,0 +1,89 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "cc" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "find-msvc-tools" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "windows-spawn-fuzz" +version = "0.0.0" +dependencies = [ + "libfuzzer-sys", +] diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000..8ab90ef --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "windows-spawn-fuzz" +version = "0.0.0" +edition = "2021" +publish = false + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "=0.4.13" + +[[bin]] +name = "command_line" +path = "fuzz_targets/command_line.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "mitigation_fields" +path = "fuzz_targets/mitigation_fields.rs" +test = false +doc = false +bench = false diff --git a/fuzz/fuzz_targets/command_line.rs b/fuzz/fuzz_targets/command_line.rs new file mode 100644 index 0000000..b53f46c --- /dev/null +++ b/fuzz/fuzz_targets/command_line.rs @@ -0,0 +1,24 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; + +#[path = "../../src/core_logic.rs"] +mod core_logic; + +fuzz_target!(|data: &[u8]| { + let units: Vec = data + .chunks(2) + .take(1_024) + .map(|bytes| match bytes { + [low, high] => u16::from_le_bytes([*low, *high]), + [low] => u16::from(*low), + _ => 0, + }) + .collect(); + let _ = core_logic::quoted_argument_len(&units); + if let Ok(mut line) = core_logic::CommandLine::new(&units) { + let _ = line.push_regular(&units); + let _ = line.push_raw(&units); + let _ = line.finish(); + } +}); diff --git a/fuzz/fuzz_targets/mitigation_fields.rs b/fuzz/fuzz_targets/mitigation_fields.rs new file mode 100644 index 0000000..f769153 --- /dev/null +++ b/fuzz/fuzz_targets/mitigation_fields.rs @@ -0,0 +1,27 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; + +#[path = "../../src/core_logic.rs"] +mod core_logic; + +fuzz_target!(|data: &[u8]| { + let Some(word_bytes) = data.get(..8) else { + return; + }; + let Some(value_bytes) = data.get(8..16) else { + return; + }; + let Some(shift_byte) = data.get(16) else { + return; + }; + let mut word_array = [0_u8; 8]; + let mut value_array = [0_u8; 8]; + word_array.copy_from_slice(word_bytes); + value_array.copy_from_slice(value_bytes); + let word = u64::from_le_bytes(word_array); + let value = u64::from_le_bytes(value_array); + let shift = u32::from(*shift_byte % 64); + let _ = core_logic::replace_one_bit_field(word, shift, value); + let _ = core_logic::replace_two_bit_field(word, shift, value); +}); diff --git a/justfile b/justfile index 34738d7..5d91cb8 100644 --- a/justfile +++ b/justfile @@ -6,6 +6,21 @@ default: fmt: cargo xtask fmt +ocomment: + cargo +1.98.0 xtask build-ocomment + +comment-policy: + cargo xtask comment-policy + +invariants: + cargo xtask invariant-registry + +source-policy: + cargo xtask source-policy + +kani: + cargo xtask kani + clippy: cargo xtask clippy @@ -60,5 +75,5 @@ release-candidate: release-verify tag: cargo xtask verify-release-tag "{{ tag }}" -ci: +ci: ocomment cargo xtask ci diff --git a/public-api/windows-spawn.txt b/public-api/windows-spawn.txt index 21466fa..7ec265c 100644 --- a/public-api/windows-spawn.txt +++ b/public-api/windows-spawn.txt @@ -1,4 +1,27 @@ pub mod windows_spawn +#[repr(u64)] pub enum windows_spawn::AtlThunkPolicy +pub windows_spawn::AtlThunkPolicy::Defer = 0 +pub windows_spawn::AtlThunkPolicy::Disable = 1 +impl core::clone::Clone for windows_spawn::AtlThunkPolicy +pub fn windows_spawn::AtlThunkPolicy::clone(&self) -> windows_spawn::AtlThunkPolicy +impl core::cmp::Eq for windows_spawn::AtlThunkPolicy +impl core::cmp::PartialEq for windows_spawn::AtlThunkPolicy +pub fn windows_spawn::AtlThunkPolicy::eq(&self, &windows_spawn::AtlThunkPolicy) -> bool +impl core::default::Default for windows_spawn::AtlThunkPolicy +pub fn windows_spawn::AtlThunkPolicy::default() -> windows_spawn::AtlThunkPolicy +impl core::fmt::Debug for windows_spawn::AtlThunkPolicy +pub fn windows_spawn::AtlThunkPolicy::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::hash::Hash for windows_spawn::AtlThunkPolicy +pub fn windows_spawn::AtlThunkPolicy::hash<__H: core::hash::Hasher>(&self, &mut __H) +impl core::marker::Copy for windows_spawn::AtlThunkPolicy +impl core::marker::StructuralPartialEq for windows_spawn::AtlThunkPolicy +impl core::marker::Freeze for windows_spawn::AtlThunkPolicy +impl core::marker::Send for windows_spawn::AtlThunkPolicy +impl core::marker::Sync for windows_spawn::AtlThunkPolicy +impl core::marker::Unpin for windows_spawn::AtlThunkPolicy +impl core::marker::UnsafeUnpin for windows_spawn::AtlThunkPolicy +impl core::panic::unwind_safe::RefUnwindSafe for windows_spawn::AtlThunkPolicy +impl core::panic::unwind_safe::UnwindSafe for windows_spawn::AtlThunkPolicy #[repr(u64)] pub enum windows_spawn::BlockNonCetBinaries pub windows_spawn::BlockNonCetBinaries::AlwaysOff = 2 pub windows_spawn::BlockNonCetBinaries::AlwaysOn = 1 @@ -49,6 +72,31 @@ impl core::marker::Unpin for windows_spawn::CetShadowStacks impl core::marker::UnsafeUnpin for windows_spawn::CetShadowStacks impl core::panic::unwind_safe::RefUnwindSafe for windows_spawn::CetShadowStacks impl core::panic::unwind_safe::UnwindSafe for windows_spawn::CetShadowStacks +pub enum windows_spawn::ConsoleMode +pub windows_spawn::ConsoleMode::Detached +pub windows_spawn::ConsoleMode::Inherit +pub windows_spawn::ConsoleMode::NewConsole +pub windows_spawn::ConsoleMode::NoWindow +impl core::clone::Clone for windows_spawn::ConsoleMode +pub fn windows_spawn::ConsoleMode::clone(&self) -> windows_spawn::ConsoleMode +impl core::cmp::Eq for windows_spawn::ConsoleMode +impl core::cmp::PartialEq for windows_spawn::ConsoleMode +pub fn windows_spawn::ConsoleMode::eq(&self, &windows_spawn::ConsoleMode) -> bool +impl core::default::Default for windows_spawn::ConsoleMode +pub fn windows_spawn::ConsoleMode::default() -> windows_spawn::ConsoleMode +impl core::fmt::Debug for windows_spawn::ConsoleMode +pub fn windows_spawn::ConsoleMode::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::hash::Hash for windows_spawn::ConsoleMode +pub fn windows_spawn::ConsoleMode::hash<__H: core::hash::Hasher>(&self, &mut __H) +impl core::marker::Copy for windows_spawn::ConsoleMode +impl core::marker::StructuralPartialEq for windows_spawn::ConsoleMode +impl core::marker::Freeze for windows_spawn::ConsoleMode +impl core::marker::Send for windows_spawn::ConsoleMode +impl core::marker::Sync for windows_spawn::ConsoleMode +impl core::marker::Unpin for windows_spawn::ConsoleMode +impl core::marker::UnsafeUnpin for windows_spawn::ConsoleMode +impl core::panic::unwind_safe::RefUnwindSafe for windows_spawn::ConsoleMode +impl core::panic::unwind_safe::UnwindSafe for windows_spawn::ConsoleMode #[repr(u64)] pub enum windows_spawn::ControlFlowGuard pub windows_spawn::ControlFlowGuard::AlwaysOff = 2 pub windows_spawn::ControlFlowGuard::AlwaysOn = 1 @@ -74,29 +122,29 @@ impl core::marker::Unpin for windows_spawn::ControlFlowGuard impl core::marker::UnsafeUnpin for windows_spawn::ControlFlowGuard impl core::panic::unwind_safe::RefUnwindSafe for windows_spawn::ControlFlowGuard impl core::panic::unwind_safe::UnwindSafe for windows_spawn::ControlFlowGuard -pub enum windows_spawn::DropPolicy -pub windows_spawn::DropPolicy::Detach -pub windows_spawn::DropPolicy::KillTree -impl core::clone::Clone for windows_spawn::DropPolicy -pub fn windows_spawn::DropPolicy::clone(&self) -> windows_spawn::DropPolicy -impl core::cmp::Eq for windows_spawn::DropPolicy -impl core::cmp::PartialEq for windows_spawn::DropPolicy -pub fn windows_spawn::DropPolicy::eq(&self, &windows_spawn::DropPolicy) -> bool -impl core::default::Default for windows_spawn::DropPolicy -pub fn windows_spawn::DropPolicy::default() -> windows_spawn::DropPolicy -impl core::fmt::Debug for windows_spawn::DropPolicy -pub fn windows_spawn::DropPolicy::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result -impl core::hash::Hash for windows_spawn::DropPolicy -pub fn windows_spawn::DropPolicy::hash<__H: core::hash::Hasher>(&self, &mut __H) -impl core::marker::Copy for windows_spawn::DropPolicy -impl core::marker::StructuralPartialEq for windows_spawn::DropPolicy -impl core::marker::Freeze for windows_spawn::DropPolicy -impl core::marker::Send for windows_spawn::DropPolicy -impl core::marker::Sync for windows_spawn::DropPolicy -impl core::marker::Unpin for windows_spawn::DropPolicy -impl core::marker::UnsafeUnpin for windows_spawn::DropPolicy -impl core::panic::unwind_safe::RefUnwindSafe for windows_spawn::DropPolicy -impl core::panic::unwind_safe::UnwindSafe for windows_spawn::DropPolicy +#[repr(u64)] pub enum windows_spawn::DepPolicy +pub windows_spawn::DepPolicy::Defer = 0 +pub windows_spawn::DepPolicy::Enable = 1 +impl core::clone::Clone for windows_spawn::DepPolicy +pub fn windows_spawn::DepPolicy::clone(&self) -> windows_spawn::DepPolicy +impl core::cmp::Eq for windows_spawn::DepPolicy +impl core::cmp::PartialEq for windows_spawn::DepPolicy +pub fn windows_spawn::DepPolicy::eq(&self, &windows_spawn::DepPolicy) -> bool +impl core::default::Default for windows_spawn::DepPolicy +pub fn windows_spawn::DepPolicy::default() -> windows_spawn::DepPolicy +impl core::fmt::Debug for windows_spawn::DepPolicy +pub fn windows_spawn::DepPolicy::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::hash::Hash for windows_spawn::DepPolicy +pub fn windows_spawn::DepPolicy::hash<__H: core::hash::Hasher>(&self, &mut __H) +impl core::marker::Copy for windows_spawn::DepPolicy +impl core::marker::StructuralPartialEq for windows_spawn::DepPolicy +impl core::marker::Freeze for windows_spawn::DepPolicy +impl core::marker::Send for windows_spawn::DepPolicy +impl core::marker::Sync for windows_spawn::DepPolicy +impl core::marker::Unpin for windows_spawn::DepPolicy +impl core::marker::UnsafeUnpin for windows_spawn::DepPolicy +impl core::panic::unwind_safe::RefUnwindSafe for windows_spawn::DepPolicy +impl core::panic::unwind_safe::UnwindSafe for windows_spawn::DepPolicy #[repr(u64)] pub enum windows_spawn::DynamicCode pub windows_spawn::DynamicCode::Allow = 2 pub windows_spawn::DynamicCode::Defer = 0 @@ -122,6 +170,25 @@ impl core::marker::Unpin for windows_spawn::DynamicCode impl core::marker::UnsafeUnpin for windows_spawn::DynamicCode impl core::panic::unwind_safe::RefUnwindSafe for windows_spawn::DynamicCode impl core::panic::unwind_safe::UnwindSafe for windows_spawn::DynamicCode +pub enum windows_spawn::Error +pub windows_spawn::Error::Cleanup(windows_spawn::CleanupError) +pub windows_spawn::Error::Validation(windows_spawn::ValidationError) +pub windows_spawn::Error::Windows(windows_spawn::WindowsError) +impl core::convert::From for core::io::error::Error +pub fn core::io::error::Error::from(windows_spawn::Error) -> Self +impl core::error::Error for windows_spawn::Error +pub fn windows_spawn::Error::source(&self) -> core::option::Option<&(dyn core::error::Error + 'static)> +impl core::fmt::Debug for windows_spawn::Error +pub fn windows_spawn::Error::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::fmt::Display for windows_spawn::Error +pub fn windows_spawn::Error::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::Freeze for windows_spawn::Error +impl core::marker::Send for windows_spawn::Error +impl core::marker::Sync for windows_spawn::Error +impl core::marker::Unpin for windows_spawn::Error +impl core::marker::UnsafeUnpin for windows_spawn::Error +impl !core::panic::unwind_safe::RefUnwindSafe for windows_spawn::Error +impl !core::panic::unwind_safe::UnwindSafe for windows_spawn::Error #[repr(u64)] pub enum windows_spawn::FontDisable pub windows_spawn::FontDisable::Allow = 2 pub windows_spawn::FontDisable::Audit = 3 @@ -147,6 +214,55 @@ impl core::marker::Unpin for windows_spawn::FontDisable impl core::marker::UnsafeUnpin for windows_spawn::FontDisable impl core::panic::unwind_safe::RefUnwindSafe for windows_spawn::FontDisable impl core::panic::unwind_safe::UnwindSafe for windows_spawn::FontDisable +#[non_exhaustive] pub enum windows_spawn::InputField +pub windows_spawn::InputField::Argument +pub windows_spawn::InputField::CurrentDirectory +pub windows_spawn::InputField::EnvironmentName +pub windows_spawn::InputField::EnvironmentValue +pub windows_spawn::InputField::Program +impl core::clone::Clone for windows_spawn::InputField +pub fn windows_spawn::InputField::clone(&self) -> windows_spawn::InputField +impl core::cmp::Eq for windows_spawn::InputField +impl core::cmp::PartialEq for windows_spawn::InputField +pub fn windows_spawn::InputField::eq(&self, &windows_spawn::InputField) -> bool +impl core::fmt::Debug for windows_spawn::InputField +pub fn windows_spawn::InputField::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::fmt::Display for windows_spawn::InputField +pub fn windows_spawn::InputField::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::hash::Hash for windows_spawn::InputField +pub fn windows_spawn::InputField::hash<__H: core::hash::Hasher>(&self, &mut __H) +impl core::marker::Copy for windows_spawn::InputField +impl core::marker::StructuralPartialEq for windows_spawn::InputField +impl core::marker::Freeze for windows_spawn::InputField +impl core::marker::Send for windows_spawn::InputField +impl core::marker::Sync for windows_spawn::InputField +impl core::marker::Unpin for windows_spawn::InputField +impl core::marker::UnsafeUnpin for windows_spawn::InputField +impl core::panic::unwind_safe::RefUnwindSafe for windows_spawn::InputField +impl core::panic::unwind_safe::UnwindSafe for windows_spawn::InputField +pub enum windows_spawn::JobClosePolicy +pub windows_spawn::JobClosePolicy::PreserveProcesses +pub windows_spawn::JobClosePolicy::TerminateProcesses +impl core::clone::Clone for windows_spawn::JobClosePolicy +pub fn windows_spawn::JobClosePolicy::clone(&self) -> windows_spawn::JobClosePolicy +impl core::cmp::Eq for windows_spawn::JobClosePolicy +impl core::cmp::PartialEq for windows_spawn::JobClosePolicy +pub fn windows_spawn::JobClosePolicy::eq(&self, &windows_spawn::JobClosePolicy) -> bool +impl core::default::Default for windows_spawn::JobClosePolicy +pub fn windows_spawn::JobClosePolicy::default() -> windows_spawn::JobClosePolicy +impl core::fmt::Debug for windows_spawn::JobClosePolicy +pub fn windows_spawn::JobClosePolicy::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::hash::Hash for windows_spawn::JobClosePolicy +pub fn windows_spawn::JobClosePolicy::hash<__H: core::hash::Hasher>(&self, &mut __H) +impl core::marker::Copy for windows_spawn::JobClosePolicy +impl core::marker::StructuralPartialEq for windows_spawn::JobClosePolicy +impl core::marker::Freeze for windows_spawn::JobClosePolicy +impl core::marker::Send for windows_spawn::JobClosePolicy +impl core::marker::Sync for windows_spawn::JobClosePolicy +impl core::marker::Unpin for windows_spawn::JobClosePolicy +impl core::marker::UnsafeUnpin for windows_spawn::JobClosePolicy +impl core::panic::unwind_safe::RefUnwindSafe for windows_spawn::JobClosePolicy +impl core::panic::unwind_safe::UnwindSafe for windows_spawn::JobClosePolicy #[repr(u64)] pub enum windows_spawn::LoaderIntegrity pub windows_spawn::LoaderIntegrity::AlwaysOff = 2 pub windows_spawn::LoaderIntegrity::AlwaysOn = 1 @@ -221,6 +337,73 @@ impl core::marker::Unpin for windows_spawn::ModuleTampering impl core::marker::UnsafeUnpin for windows_spawn::ModuleTampering impl core::panic::unwind_safe::RefUnwindSafe for windows_spawn::ModuleTampering impl core::panic::unwind_safe::UnwindSafe for windows_spawn::ModuleTampering +#[non_exhaustive] pub enum windows_spawn::Operation +pub windows_spawn::Operation::AssignJob +pub windows_spawn::Operation::BuildCommandLine +pub windows_spawn::Operation::ConfigureAttributes +pub windows_spawn::Operation::ConfigureJob +pub windows_spawn::Operation::CreateJob +pub windows_spawn::Operation::CreatePipe +pub windows_spawn::Operation::CreateProcess +pub windows_spawn::Operation::DuplicateLocalHandle +pub windows_spawn::Operation::DuplicateRemoteHandle +pub windows_spawn::Operation::JoinOutputReader +pub windows_spawn::Operation::OpenFile +pub windows_spawn::Operation::OpenProcess +pub windows_spawn::Operation::QueryExitCode +pub windows_spawn::Operation::QuerySystemDirectory +pub windows_spawn::Operation::ReadEnvironment +pub windows_spawn::Operation::ReadPipe +pub windows_spawn::Operation::ReclaimRemoteHandle +pub windows_spawn::Operation::ResolveExecutable +pub windows_spawn::Operation::ResumeThread +pub windows_spawn::Operation::TerminateJob +pub windows_spawn::Operation::TerminateProcess +pub windows_spawn::Operation::WaitProcess +pub windows_spawn::Operation::WritePipe +impl core::clone::Clone for windows_spawn::Operation +pub fn windows_spawn::Operation::clone(&self) -> windows_spawn::Operation +impl core::cmp::Eq for windows_spawn::Operation +impl core::cmp::PartialEq for windows_spawn::Operation +pub fn windows_spawn::Operation::eq(&self, &windows_spawn::Operation) -> bool +impl core::fmt::Debug for windows_spawn::Operation +pub fn windows_spawn::Operation::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::hash::Hash for windows_spawn::Operation +pub fn windows_spawn::Operation::hash<__H: core::hash::Hasher>(&self, &mut __H) +impl core::marker::Copy for windows_spawn::Operation +impl core::marker::StructuralPartialEq for windows_spawn::Operation +impl core::marker::Freeze for windows_spawn::Operation +impl core::marker::Send for windows_spawn::Operation +impl core::marker::Sync for windows_spawn::Operation +impl core::marker::Unpin for windows_spawn::Operation +impl core::marker::UnsafeUnpin for windows_spawn::Operation +impl core::panic::unwind_safe::RefUnwindSafe for windows_spawn::Operation +impl core::panic::unwind_safe::UnwindSafe for windows_spawn::Operation +#[non_exhaustive] pub enum windows_spawn::Phase +pub windows_spawn::Phase::Cleanup +pub windows_spawn::Phase::Creation +pub windows_spawn::Phase::Preparation +pub windows_spawn::Phase::Reclamation +pub windows_spawn::Phase::Resume +pub windows_spawn::Phase::Runtime +impl core::clone::Clone for windows_spawn::Phase +pub fn windows_spawn::Phase::clone(&self) -> windows_spawn::Phase +impl core::cmp::Eq for windows_spawn::Phase +impl core::cmp::PartialEq for windows_spawn::Phase +pub fn windows_spawn::Phase::eq(&self, &windows_spawn::Phase) -> bool +impl core::fmt::Debug for windows_spawn::Phase +pub fn windows_spawn::Phase::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::hash::Hash for windows_spawn::Phase +pub fn windows_spawn::Phase::hash<__H: core::hash::Hasher>(&self, &mut __H) +impl core::marker::Copy for windows_spawn::Phase +impl core::marker::StructuralPartialEq for windows_spawn::Phase +impl core::marker::Freeze for windows_spawn::Phase +impl core::marker::Send for windows_spawn::Phase +impl core::marker::Sync for windows_spawn::Phase +impl core::marker::Unpin for windows_spawn::Phase +impl core::marker::UnsafeUnpin for windows_spawn::Phase +impl core::panic::unwind_safe::RefUnwindSafe for windows_spawn::Phase +impl core::panic::unwind_safe::UnwindSafe for windows_spawn::Phase #[repr(u64)] pub enum windows_spawn::RelocateImages pub windows_spawn::RelocateImages::AlwaysOff = 2 pub windows_spawn::RelocateImages::AlwaysOn = 1 @@ -246,6 +429,29 @@ impl core::marker::Unpin for windows_spawn::RelocateImages impl core::marker::UnsafeUnpin for windows_spawn::RelocateImages impl core::panic::unwind_safe::RefUnwindSafe for windows_spawn::RelocateImages impl core::panic::unwind_safe::UnwindSafe for windows_spawn::RelocateImages +#[repr(u64)] pub enum windows_spawn::SehopPolicy +pub windows_spawn::SehopPolicy::Defer = 0 +pub windows_spawn::SehopPolicy::Enable = 1 +impl core::clone::Clone for windows_spawn::SehopPolicy +pub fn windows_spawn::SehopPolicy::clone(&self) -> windows_spawn::SehopPolicy +impl core::cmp::Eq for windows_spawn::SehopPolicy +impl core::cmp::PartialEq for windows_spawn::SehopPolicy +pub fn windows_spawn::SehopPolicy::eq(&self, &windows_spawn::SehopPolicy) -> bool +impl core::default::Default for windows_spawn::SehopPolicy +pub fn windows_spawn::SehopPolicy::default() -> windows_spawn::SehopPolicy +impl core::fmt::Debug for windows_spawn::SehopPolicy +pub fn windows_spawn::SehopPolicy::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::hash::Hash for windows_spawn::SehopPolicy +pub fn windows_spawn::SehopPolicy::hash<__H: core::hash::Hasher>(&self, &mut __H) +impl core::marker::Copy for windows_spawn::SehopPolicy +impl core::marker::StructuralPartialEq for windows_spawn::SehopPolicy +impl core::marker::Freeze for windows_spawn::SehopPolicy +impl core::marker::Send for windows_spawn::SehopPolicy +impl core::marker::Sync for windows_spawn::SehopPolicy +impl core::marker::Unpin for windows_spawn::SehopPolicy +impl core::marker::UnsafeUnpin for windows_spawn::SehopPolicy +impl core::panic::unwind_safe::RefUnwindSafe for windows_spawn::SehopPolicy +impl core::panic::unwind_safe::UnwindSafe for windows_spawn::SehopPolicy #[repr(u64)] pub enum windows_spawn::SignedBinaries pub windows_spawn::SignedBinaries::AlwaysOff = 2 pub windows_spawn::SignedBinaries::Defer = 0 @@ -271,6 +477,29 @@ impl core::marker::Unpin for windows_spawn::SignedBinaries impl core::marker::UnsafeUnpin for windows_spawn::SignedBinaries impl core::panic::unwind_safe::RefUnwindSafe for windows_spawn::SignedBinaries impl core::panic::unwind_safe::UnwindSafe for windows_spawn::SignedBinaries +pub enum windows_spawn::TerminalMode<'a> +pub windows_spawn::TerminalMode::Console(windows_spawn::ConsoleMode) +pub windows_spawn::TerminalMode::PseudoConsole(windows_spawn::BorrowedPseudoConsole<'a>) +impl core::default::Default for windows_spawn::TerminalMode<'_> +pub fn windows_spawn::TerminalMode<'_>::default() -> Self +impl<'a> core::clone::Clone for windows_spawn::TerminalMode<'a> +pub fn windows_spawn::TerminalMode<'a>::clone(&self) -> windows_spawn::TerminalMode<'a> +impl<'a> core::cmp::Eq for windows_spawn::TerminalMode<'a> +impl<'a> core::cmp::PartialEq for windows_spawn::TerminalMode<'a> +pub fn windows_spawn::TerminalMode<'a>::eq(&self, &windows_spawn::TerminalMode<'a>) -> bool +impl<'a> core::fmt::Debug for windows_spawn::TerminalMode<'a> +pub fn windows_spawn::TerminalMode<'a>::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl<'a> core::hash::Hash for windows_spawn::TerminalMode<'a> +pub fn windows_spawn::TerminalMode<'a>::hash<__H: core::hash::Hasher>(&self, &mut __H) +impl<'a> core::marker::Copy for windows_spawn::TerminalMode<'a> +impl<'a> core::marker::StructuralPartialEq for windows_spawn::TerminalMode<'a> +impl<'a> core::marker::Freeze for windows_spawn::TerminalMode<'a> +impl<'a> core::marker::Send for windows_spawn::TerminalMode<'a> +impl<'a> core::marker::Sync for windows_spawn::TerminalMode<'a> +impl<'a> core::marker::Unpin for windows_spawn::TerminalMode<'a> +impl<'a> core::marker::UnsafeUnpin for windows_spawn::TerminalMode<'a> +impl<'a> core::panic::unwind_safe::RefUnwindSafe for windows_spawn::TerminalMode<'a> +impl<'a> core::panic::unwind_safe::UnwindSafe for windows_spawn::TerminalMode<'a> #[repr(u64)] pub enum windows_spawn::UserCetContextIpValidation pub windows_spawn::UserCetContextIpValidation::AlwaysOff = 2 pub windows_spawn::UserCetContextIpValidation::AlwaysOn = 1 @@ -296,16 +525,75 @@ impl core::marker::Unpin for windows_spawn::UserCetContextIpValidation impl core::marker::UnsafeUnpin for windows_spawn::UserCetContextIpValidation impl core::panic::unwind_safe::RefUnwindSafe for windows_spawn::UserCetContextIpValidation impl core::panic::unwind_safe::UnwindSafe for windows_spawn::UserCetContextIpValidation +#[non_exhaustive] pub enum windows_spawn::ValidationError +pub windows_spawn::ValidationError::AlternateParentNeedsStdio +pub windows_spawn::ValidationError::BatchFile +pub windows_spawn::ValidationError::CommandLineTooLong +pub windows_spawn::ValidationError::EmptyEnvironmentName +pub windows_spawn::ValidationError::EmptyProgram +pub windows_spawn::ValidationError::InteriorNul +pub windows_spawn::ValidationError::InteriorNul::field: windows_spawn::InputField +pub windows_spawn::ValidationError::InvalidEnvironmentName +pub windows_spawn::ValidationError::MissingProgramFileName +pub windows_spawn::ValidationError::PseudoConsoleWithOutputCapture +pub windows_spawn::ValidationError::PseudoConsoleWithStdio +pub windows_spawn::ValidationError::QuotedProgram +pub windows_spawn::ValidationError::SizeOverflow +pub windows_spawn::ValidationError::UnexpectedSuspendCount +pub windows_spawn::ValidationError::UnexpectedSuspendCount::actual: u32 +impl core::clone::Clone for windows_spawn::ValidationError +pub fn windows_spawn::ValidationError::clone(&self) -> windows_spawn::ValidationError +impl core::cmp::Eq for windows_spawn::ValidationError +impl core::cmp::PartialEq for windows_spawn::ValidationError +pub fn windows_spawn::ValidationError::eq(&self, &windows_spawn::ValidationError) -> bool +impl core::error::Error for windows_spawn::ValidationError +impl core::fmt::Debug for windows_spawn::ValidationError +pub fn windows_spawn::ValidationError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::fmt::Display for windows_spawn::ValidationError +pub fn windows_spawn::ValidationError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::hash::Hash for windows_spawn::ValidationError +pub fn windows_spawn::ValidationError::hash<__H: core::hash::Hasher>(&self, &mut __H) +impl core::marker::Copy for windows_spawn::ValidationError +impl core::marker::StructuralPartialEq for windows_spawn::ValidationError +impl core::marker::Freeze for windows_spawn::ValidationError +impl core::marker::Send for windows_spawn::ValidationError +impl core::marker::Sync for windows_spawn::ValidationError +impl core::marker::Unpin for windows_spawn::ValidationError +impl core::marker::UnsafeUnpin for windows_spawn::ValidationError +impl core::panic::unwind_safe::RefUnwindSafe for windows_spawn::ValidationError +impl core::panic::unwind_safe::UnwindSafe for windows_spawn::ValidationError +pub struct windows_spawn::BorrowedPseudoConsole<'a> +impl<'a> windows_spawn::BorrowedPseudoConsole<'a> +pub unsafe fn windows_spawn::BorrowedPseudoConsole<'a>::from_raw(core::num::nonzero::NonZeroIsize, &'a T) -> Self +impl<'a> core::clone::Clone for windows_spawn::BorrowedPseudoConsole<'a> +pub fn windows_spawn::BorrowedPseudoConsole<'a>::clone(&self) -> windows_spawn::BorrowedPseudoConsole<'a> +impl<'a> core::cmp::Eq for windows_spawn::BorrowedPseudoConsole<'a> +impl<'a> core::cmp::PartialEq for windows_spawn::BorrowedPseudoConsole<'a> +pub fn windows_spawn::BorrowedPseudoConsole<'a>::eq(&self, &windows_spawn::BorrowedPseudoConsole<'a>) -> bool +impl<'a> core::fmt::Debug for windows_spawn::BorrowedPseudoConsole<'a> +pub fn windows_spawn::BorrowedPseudoConsole<'a>::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl<'a> core::hash::Hash for windows_spawn::BorrowedPseudoConsole<'a> +pub fn windows_spawn::BorrowedPseudoConsole<'a>::hash<__H: core::hash::Hasher>(&self, &mut __H) +impl<'a> core::marker::Copy for windows_spawn::BorrowedPseudoConsole<'a> +impl<'a> core::marker::StructuralPartialEq for windows_spawn::BorrowedPseudoConsole<'a> +impl<'a> core::marker::Freeze for windows_spawn::BorrowedPseudoConsole<'a> +impl<'a> core::marker::Send for windows_spawn::BorrowedPseudoConsole<'a> +impl<'a> core::marker::Sync for windows_spawn::BorrowedPseudoConsole<'a> +impl<'a> core::marker::Unpin for windows_spawn::BorrowedPseudoConsole<'a> +impl<'a> core::marker::UnsafeUnpin for windows_spawn::BorrowedPseudoConsole<'a> +impl<'a> core::panic::unwind_safe::RefUnwindSafe for windows_spawn::BorrowedPseudoConsole<'a> +impl<'a> core::panic::unwind_safe::UnwindSafe for windows_spawn::BorrowedPseudoConsole<'a> pub struct windows_spawn::Child pub windows_spawn::Child::stderr: core::option::Option pub windows_spawn::Child::stdin: core::option::Option pub windows_spawn::Child::stdout: core::option::Option impl windows_spawn::Child -pub const fn windows_spawn::Child::id(&self) -> u32 -pub fn windows_spawn::Child::kill(&mut self) -> core::io::error::Result<()> -pub fn windows_spawn::Child::try_wait(&mut self) -> core::io::error::Result> -pub fn windows_spawn::Child::wait(&mut self) -> core::io::error::Result -pub fn windows_spawn::Child::wait_with_output(self) -> core::io::error::Result +pub fn windows_spawn::Child::cleanup(self) -> windows_spawn::Result<()> +pub fn windows_spawn::Child::id(&self) -> u32 +pub fn windows_spawn::Child::kill(&mut self) -> windows_spawn::Result<()> +pub fn windows_spawn::Child::try_wait(&mut self) -> windows_spawn::Result> +pub fn windows_spawn::Child::wait(&mut self) -> windows_spawn::Result +pub fn windows_spawn::Child::wait_with_output(self) -> windows_spawn::Result impl core::fmt::Debug for windows_spawn::Child pub fn windows_spawn::Child::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl std::os::windows::io::handle::AsHandle for windows_spawn::Child @@ -360,29 +648,46 @@ impl core::marker::Unpin for windows_spawn::ChildStdout impl core::marker::UnsafeUnpin for windows_spawn::ChildStdout impl core::panic::unwind_safe::RefUnwindSafe for windows_spawn::ChildStdout impl core::panic::unwind_safe::UnwindSafe for windows_spawn::ChildStdout +pub struct windows_spawn::CleanupError +impl windows_spawn::CleanupError +pub fn windows_spawn::CleanupError::failures(&self) -> impl core::iter::traits::iterator::Iterator +pub const fn windows_spawn::CleanupError::primary(&self) -> core::option::Option<&windows_spawn::WindowsError> +impl core::error::Error for windows_spawn::CleanupError +pub fn windows_spawn::CleanupError::source(&self) -> core::option::Option<&(dyn core::error::Error + 'static)> +impl core::fmt::Debug for windows_spawn::CleanupError +pub fn windows_spawn::CleanupError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::fmt::Display for windows_spawn::CleanupError +pub fn windows_spawn::CleanupError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::Freeze for windows_spawn::CleanupError +impl core::marker::Send for windows_spawn::CleanupError +impl core::marker::Sync for windows_spawn::CleanupError +impl core::marker::Unpin for windows_spawn::CleanupError +impl core::marker::UnsafeUnpin for windows_spawn::CleanupError +impl !core::panic::unwind_safe::RefUnwindSafe for windows_spawn::CleanupError +impl !core::panic::unwind_safe::UnwindSafe for windows_spawn::CleanupError pub struct windows_spawn::Command impl windows_spawn::Command pub fn windows_spawn::Command::arg>(&mut self, S) -> &mut Self -pub fn windows_spawn::Command::arg_handle(&mut self, &T) -> core::io::error::Result<&mut Self> +pub fn windows_spawn::Command::arg_handle(&mut self, &T) -> windows_spawn::Result<&mut Self> pub fn windows_spawn::Command::args(&mut self, I) -> &mut Self where I: core::iter::traits::collect::IntoIterator, S: core::convert::AsRef pub fn windows_spawn::Command::current_dir>(&mut self, P) -> &mut Self pub fn windows_spawn::Command::env(&mut self, K, V) -> &mut Self where K: core::convert::AsRef, V: core::convert::AsRef pub fn windows_spawn::Command::env_clear(&mut self) -> &mut Self -pub fn windows_spawn::Command::env_handle, T: std::os::windows::io::handle::AsHandle>(&mut self, K, &T) -> core::io::error::Result<&mut Self> +pub fn windows_spawn::Command::env_handle, T: std::os::windows::io::handle::AsHandle>(&mut self, K, &T) -> windows_spawn::Result<&mut Self> pub fn windows_spawn::Command::env_remove>(&mut self, K) -> &mut Self pub fn windows_spawn::Command::envs(&mut self, I) -> &mut Self where I: core::iter::traits::collect::IntoIterator, K: core::convert::AsRef, V: core::convert::AsRef pub fn windows_spawn::Command::get_current_dir(&self) -> core::option::Option<&std::path::Path> pub fn windows_spawn::Command::get_program(&self) -> &std::ffi::os_str::OsStr pub fn windows_spawn::Command::new>(S) -> Self -pub fn windows_spawn::Command::output(&mut self) -> core::io::error::Result -pub fn windows_spawn::Command::output_with(&mut self, windows_spawn::SpawnOptions<'_>) -> core::io::error::Result +pub fn windows_spawn::Command::output(&mut self) -> windows_spawn::Result +pub fn windows_spawn::Command::output_with(&mut self, windows_spawn::SpawnOptions<'_>) -> windows_spawn::Result pub fn windows_spawn::Command::raw_arg>(&mut self, S) -> &mut Self -pub fn windows_spawn::Command::spawn(&mut self) -> core::io::error::Result -pub fn windows_spawn::Command::spawn_suspended(&mut self) -> core::io::error::Result -pub fn windows_spawn::Command::spawn_suspended_with(&mut self, windows_spawn::SpawnOptions<'_>) -> core::io::error::Result -pub fn windows_spawn::Command::spawn_with(&mut self, windows_spawn::SpawnOptions<'_>) -> core::io::error::Result -pub fn windows_spawn::Command::status(&mut self) -> core::io::error::Result -pub fn windows_spawn::Command::status_with(&mut self, windows_spawn::SpawnOptions<'_>) -> core::io::error::Result +pub fn windows_spawn::Command::spawn(&mut self) -> windows_spawn::Result +pub fn windows_spawn::Command::spawn_suspended(&mut self) -> windows_spawn::Result +pub fn windows_spawn::Command::spawn_suspended_with(&mut self, windows_spawn::SpawnOptions<'_>) -> windows_spawn::Result +pub fn windows_spawn::Command::spawn_with(&mut self, windows_spawn::SpawnOptions<'_>) -> windows_spawn::Result +pub fn windows_spawn::Command::status(&mut self) -> windows_spawn::Result +pub fn windows_spawn::Command::status_with(&mut self, windows_spawn::SpawnOptions<'_>) -> windows_spawn::Result pub fn windows_spawn::Command::stderr>(&mut self, T) -> &mut Self pub fn windows_spawn::Command::stdin>(&mut self, T) -> &mut Self pub fn windows_spawn::Command::stdout>(&mut self, T) -> &mut Self @@ -395,50 +700,14 @@ impl core::marker::Unpin for windows_spawn::Command impl core::marker::UnsafeUnpin for windows_spawn::Command impl core::panic::unwind_safe::RefUnwindSafe for windows_spawn::Command impl core::panic::unwind_safe::UnwindSafe for windows_spawn::Command -pub struct windows_spawn::CreationFlags(_) -impl windows_spawn::CreationFlags -pub const windows_spawn::CreationFlags::BREAKAWAY_FROM_JOB: Self -pub const windows_spawn::CreationFlags::DEFAULT_ERROR_MODE: Self -pub const windows_spawn::CreationFlags::DETACHED_PROCESS: Self -pub const windows_spawn::CreationFlags::INHERIT_PARENT_AFFINITY: Self -pub const windows_spawn::CreationFlags::NEW_CONSOLE: Self -pub const windows_spawn::CreationFlags::NEW_PROCESS_GROUP: Self -pub const windows_spawn::CreationFlags::NO_WINDOW: Self -pub const windows_spawn::CreationFlags::PRESERVE_CODE_AUTHZ_LEVEL: Self -pub const fn windows_spawn::CreationFlags::new() -> Self -impl core::clone::Clone for windows_spawn::CreationFlags -pub fn windows_spawn::CreationFlags::clone(&self) -> windows_spawn::CreationFlags -impl core::cmp::Eq for windows_spawn::CreationFlags -impl core::cmp::PartialEq for windows_spawn::CreationFlags -pub fn windows_spawn::CreationFlags::eq(&self, &windows_spawn::CreationFlags) -> bool -impl core::default::Default for windows_spawn::CreationFlags -pub fn windows_spawn::CreationFlags::default() -> windows_spawn::CreationFlags -impl core::fmt::Debug for windows_spawn::CreationFlags -pub fn windows_spawn::CreationFlags::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result -impl core::hash::Hash for windows_spawn::CreationFlags -pub fn windows_spawn::CreationFlags::hash<__H: core::hash::Hasher>(&self, &mut __H) -impl core::marker::Copy for windows_spawn::CreationFlags -impl core::marker::StructuralPartialEq for windows_spawn::CreationFlags -impl core::ops::bit::BitOr for windows_spawn::CreationFlags -pub type windows_spawn::CreationFlags::Output = windows_spawn::CreationFlags -pub fn windows_spawn::CreationFlags::bitor(self, Self) -> Self::Output -impl core::ops::bit::BitOrAssign for windows_spawn::CreationFlags -pub fn windows_spawn::CreationFlags::bitor_assign(&mut self, Self) -impl core::marker::Freeze for windows_spawn::CreationFlags -impl core::marker::Send for windows_spawn::CreationFlags -impl core::marker::Sync for windows_spawn::CreationFlags -impl core::marker::Unpin for windows_spawn::CreationFlags -impl core::marker::UnsafeUnpin for windows_spawn::CreationFlags -impl core::panic::unwind_safe::RefUnwindSafe for windows_spawn::CreationFlags -impl core::panic::unwind_safe::UnwindSafe for windows_spawn::CreationFlags pub struct windows_spawn::Job impl windows_spawn::Job -pub fn windows_spawn::Job::assign(&self, &windows_spawn::Child) -> core::io::error::Result<()> -pub fn windows_spawn::Job::create() -> core::io::error::Result -pub fn windows_spawn::Job::duplicate(&self) -> core::io::error::Result -pub fn windows_spawn::Job::from_handle(std::os::windows::io::handle::OwnedHandle) -> core::io::error::Result -pub fn windows_spawn::Job::set_kill_on_close(&self, bool) -> core::io::error::Result<()> -pub fn windows_spawn::Job::terminate(&self, u32) -> core::io::error::Result<()> +pub fn windows_spawn::Job::assign(&self, &windows_spawn::Child) -> windows_spawn::Result<()> +pub fn windows_spawn::Job::create() -> windows_spawn::Result +pub fn windows_spawn::Job::duplicate(&self) -> windows_spawn::Result +pub fn windows_spawn::Job::from_handle(std::os::windows::io::handle::OwnedHandle) -> windows_spawn::Result +pub fn windows_spawn::Job::set_close_policy(&self, windows_spawn::JobClosePolicy) -> windows_spawn::Result<()> +pub fn windows_spawn::Job::terminate(&self, u32) -> windows_spawn::Result<()> impl core::fmt::Debug for windows_spawn::Job pub fn windows_spawn::Job::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl std::os::windows::io::handle::AsHandle for windows_spawn::Job @@ -460,8 +729,8 @@ pub const fn windows_spawn::MitigationPolicy::bottom_up_aslr(self, windows_spawn pub const fn windows_spawn::MitigationPolicy::cet_dynamic_apis_out_of_process(self, windows_spawn::Mitigation) -> Self pub const fn windows_spawn::MitigationPolicy::cet_user_shadow_stacks(self, windows_spawn::CetShadowStacks) -> Self pub const fn windows_spawn::MitigationPolicy::control_flow_guard(self, windows_spawn::ControlFlowGuard) -> Self -pub const fn windows_spawn::MitigationPolicy::dep(self, bool) -> Self -pub const fn windows_spawn::MitigationPolicy::dep_atl_thunk(self, bool) -> Self +pub const fn windows_spawn::MitigationPolicy::dep(self, windows_spawn::DepPolicy) -> Self +pub const fn windows_spawn::MitigationPolicy::dep_atl_thunk(self, windows_spawn::AtlThunkPolicy) -> Self pub const fn windows_spawn::MitigationPolicy::disable_extension_points(self, windows_spawn::Mitigation) -> Self pub const fn windows_spawn::MitigationPolicy::disable_fsctl_system_calls(self, windows_spawn::Mitigation) -> Self pub const fn windows_spawn::MitigationPolicy::disable_speculative_store_bypass(self, windows_spawn::Mitigation) -> Self @@ -479,7 +748,7 @@ pub const fn windows_spawn::MitigationPolicy::prefer_system32_images(self, windo pub const fn windows_spawn::MitigationPolicy::relocate_images(self, windows_spawn::RelocateImages) -> Self pub const fn windows_spawn::MitigationPolicy::restrict_core_sharing(self, windows_spawn::Mitigation) -> Self pub const fn windows_spawn::MitigationPolicy::restrict_indirect_branch_prediction(self, windows_spawn::Mitigation) -> Self -pub const fn windows_spawn::MitigationPolicy::sehop(self, bool) -> Self +pub const fn windows_spawn::MitigationPolicy::sehop(self, windows_spawn::SehopPolicy) -> Self pub const fn windows_spawn::MitigationPolicy::signed_binaries(self, windows_spawn::SignedBinaries) -> Self pub const fn windows_spawn::MitigationPolicy::strict_control_flow_guard(self, windows_spawn::Mitigation) -> Self pub const fn windows_spawn::MitigationPolicy::strict_handle_checks(self, windows_spawn::Mitigation) -> Self @@ -507,8 +776,8 @@ impl core::panic::unwind_safe::RefUnwindSafe for windows_spawn::MitigationPolicy impl core::panic::unwind_safe::UnwindSafe for windows_spawn::MitigationPolicy pub struct windows_spawn::ParentProcess impl windows_spawn::ParentProcess -pub fn windows_spawn::ParentProcess::from_handle(std::os::windows::io::handle::OwnedHandle) -> core::io::error::Result -pub fn windows_spawn::ParentProcess::open(u32) -> core::io::error::Result +pub fn windows_spawn::ParentProcess::from_handle(std::os::windows::io::handle::OwnedHandle) -> windows_spawn::Result +pub fn windows_spawn::ParentProcess::open(u32) -> windows_spawn::Result impl core::fmt::Debug for windows_spawn::ParentProcess pub fn windows_spawn::ParentProcess::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl std::os::windows::io::handle::AsHandle for windows_spawn::ParentProcess @@ -522,27 +791,32 @@ impl core::panic::unwind_safe::RefUnwindSafe for windows_spawn::ParentProcess impl core::panic::unwind_safe::UnwindSafe for windows_spawn::ParentProcess pub struct windows_spawn::SpawnOptions<'a> impl<'a> windows_spawn::SpawnOptions<'a> -pub const fn windows_spawn::SpawnOptions<'a>::creation_flags(self, windows_spawn::CreationFlags) -> Self -pub const fn windows_spawn::SpawnOptions<'a>::drop_policy(self, windows_spawn::DropPolicy) -> Self +pub const fn windows_spawn::SpawnOptions<'a>::breakaway_from_job(self) -> Self +pub const fn windows_spawn::SpawnOptions<'a>::default_error_mode(self) -> Self +pub const fn windows_spawn::SpawnOptions<'a>::inherit_parent_affinity(self) -> Self pub fn windows_spawn::SpawnOptions<'a>::job(self, &'a windows_spawn::Job) -> Self +pub const fn windows_spawn::SpawnOptions<'a>::job_close_policy(self, windows_spawn::JobClosePolicy) -> Self pub const fn windows_spawn::SpawnOptions<'a>::mitigation(self, windows_spawn::MitigationPolicy) -> Self pub fn windows_spawn::SpawnOptions<'a>::new() -> Self +pub const fn windows_spawn::SpawnOptions<'a>::new_process_group(self) -> Self pub fn windows_spawn::SpawnOptions<'a>::parent_process(self, &'a windows_spawn::ParentProcess) -> Self -pub fn windows_spawn::SpawnOptions<'a>::pseudoconsole(self, &'a T) -> Self +pub const fn windows_spawn::SpawnOptions<'a>::preserve_code_authorization(self) -> Self +pub fn windows_spawn::SpawnOptions<'a>::pseudo_console(self, &'a T) -> Self +pub const fn windows_spawn::SpawnOptions<'a>::terminal(self, windows_spawn::TerminalMode<'a>) -> Self impl core::default::Default for windows_spawn::SpawnOptions<'_> pub fn windows_spawn::SpawnOptions<'_>::default() -> Self impl core::fmt::Debug for windows_spawn::SpawnOptions<'_> pub fn windows_spawn::SpawnOptions<'_>::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl<'a> core::marker::Freeze for windows_spawn::SpawnOptions<'a> -impl<'a> !core::marker::Send for windows_spawn::SpawnOptions<'a> -impl<'a> !core::marker::Sync for windows_spawn::SpawnOptions<'a> +impl<'a> core::marker::Send for windows_spawn::SpawnOptions<'a> +impl<'a> core::marker::Sync for windows_spawn::SpawnOptions<'a> impl<'a> core::marker::Unpin for windows_spawn::SpawnOptions<'a> impl<'a> core::marker::UnsafeUnpin for windows_spawn::SpawnOptions<'a> -impl<'a> !core::panic::unwind_safe::RefUnwindSafe for windows_spawn::SpawnOptions<'a> -impl<'a> !core::panic::unwind_safe::UnwindSafe for windows_spawn::SpawnOptions<'a> +impl<'a> core::panic::unwind_safe::RefUnwindSafe for windows_spawn::SpawnOptions<'a> +impl<'a> core::panic::unwind_safe::UnwindSafe for windows_spawn::SpawnOptions<'a> pub struct windows_spawn::Stdio impl windows_spawn::Stdio -pub fn windows_spawn::Stdio::from_borrowed(&T) -> core::io::error::Result +pub fn windows_spawn::Stdio::from_borrowed(&T) -> windows_spawn::Result pub const fn windows_spawn::Stdio::inherit() -> Self pub const fn windows_spawn::Stdio::null() -> Self pub const fn windows_spawn::Stdio::piped() -> Self @@ -563,11 +837,9 @@ pub struct windows_spawn::SuspendedChild impl windows_spawn::SuspendedChild pub fn windows_spawn::SuspendedChild::id(&self) -> u32 pub fn windows_spawn::SuspendedChild::primary_thread_handle(&self) -> std::os::windows::io::handle::BorrowedHandle<'_> -pub fn windows_spawn::SuspendedChild::resume(self) -> core::io::error::Result +pub fn windows_spawn::SuspendedChild::resume(self) -> windows_spawn::Result impl core::fmt::Debug for windows_spawn::SuspendedChild pub fn windows_spawn::SuspendedChild::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result -impl core::ops::drop::Drop for windows_spawn::SuspendedChild -pub fn windows_spawn::SuspendedChild::drop(&mut self) impl std::os::windows::io::handle::AsHandle for windows_spawn::SuspendedChild pub fn windows_spawn::SuspendedChild::as_handle(&self) -> std::os::windows::io::handle::BorrowedHandle<'_> impl core::marker::Freeze for windows_spawn::SuspendedChild @@ -577,5 +849,24 @@ impl core::marker::Unpin for windows_spawn::SuspendedChild impl core::marker::UnsafeUnpin for windows_spawn::SuspendedChild impl core::panic::unwind_safe::RefUnwindSafe for windows_spawn::SuspendedChild impl core::panic::unwind_safe::UnwindSafe for windows_spawn::SuspendedChild +pub struct windows_spawn::WindowsError +impl windows_spawn::WindowsError +pub const fn windows_spawn::WindowsError::code(&self) -> u32 +pub const fn windows_spawn::WindowsError::operation(&self) -> windows_spawn::Operation +pub const fn windows_spawn::WindowsError::phase(&self) -> windows_spawn::Phase +impl core::error::Error for windows_spawn::WindowsError +pub fn windows_spawn::WindowsError::source(&self) -> core::option::Option<&(dyn core::error::Error + 'static)> +impl core::fmt::Debug for windows_spawn::WindowsError +pub fn windows_spawn::WindowsError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::fmt::Display for windows_spawn::WindowsError +pub fn windows_spawn::WindowsError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::Freeze for windows_spawn::WindowsError +impl core::marker::Send for windows_spawn::WindowsError +impl core::marker::Sync for windows_spawn::WindowsError +impl core::marker::Unpin for windows_spawn::WindowsError +impl core::marker::UnsafeUnpin for windows_spawn::WindowsError +impl !core::panic::unwind_safe::RefUnwindSafe for windows_spawn::WindowsError +impl !core::panic::unwind_safe::UnwindSafe for windows_spawn::WindowsError pub unsafe trait windows_spawn::AsPseudoConsole -pub fn windows_spawn::AsPseudoConsole::raw_pseudoconsole(&self) -> isize +pub fn windows_spawn::AsPseudoConsole::as_pseudo_console(&self) -> windows_spawn::BorrowedPseudoConsole<'_> +pub type windows_spawn::Result = core::result::Result diff --git a/quality/invariants.toml b/quality/invariants.toml new file mode 100644 index 0000000..e7be9d6 --- /dev/null +++ b/quality/invariants.toml @@ -0,0 +1,106 @@ +version = 1 + +[[invariant]] +id = "INV-001" +target = "spawn stages advance only by consuming the preceding stage" +enforcement = ["type"] +artifacts = ["src/plan.rs", "src/transaction.rs"] +trust_boundaries = ["rust-compiler", "standard-library"] + +[[invariant]] +id = "INV-002" +target = "resource kinds and handle tables cannot be mixed" +enforcement = ["type"] +artifacts = ["src/resource.rs"] +trust_boundaries = ["rust-compiler", "windows-api"] + +[[invariant]] +id = "INV-003" +target = "process attribute backing remains pinned through process creation" +enforcement = ["type"] +artifacts = ["src/transaction.rs"] +trust_boundaries = ["rust-compiler", "standard-library", "windows-api"] + +[[invariant]] +id = "INV-004" +target = "a pseudoconsole capability cannot outlive its owner" +enforcement = ["type"] +artifacts = ["src/handles.rs"] +trust_boundaries = ["rust-compiler", "unsafe-pseudoconsole-implementor", "windows-api"] + +[[invariant]] +id = "INV-005" +target = "cleanup errors always contain at least one failure" +enforcement = ["type"] +artifacts = ["src/error.rs"] +trust_boundaries = ["rust-compiler", "standard-library"] + +[[invariant]] +id = "INV-006" +target = "semantic process policy choices are closed enums" +enforcement = ["type"] +artifacts = ["src/options.rs", "src/mitigation.rs"] +trust_boundaries = ["rust-compiler", "windows-api"] + +[[invariant]] +id = "INV-007" +target = "FFI sizes fit their Windows integer representations" +enforcement = ["const"] +artifacts = ["src/sys.rs"] +trust_boundaries = ["rust-compiler", "windows-api"] + +[[invariant]] +id = "INV-008" +target = "unsafe operations are confined to declared raw boundaries" +enforcement = ["lint"] +artifacts = ["Cargo.toml", "src/lib.rs", "src/sys.rs", "src/handles.rs"] +trust_boundaries = ["rust-compiler", "windows-api", "unsafe-pseudoconsole-implementor"] + +[[invariant]] +id = "INV-009" +target = "job and process cleanup executes in an explicit ownership order" +enforcement = ["type"] +artifacts = ["src/child.rs"] +trust_boundaries = ["rust-compiler", "standard-library", "windows-api"] + +[[invariant]] +id = "INV-010" +target = "remote handle reclamation precedes primary thread resume" +enforcement = ["type"] +artifacts = ["src/transaction.rs", "src/child.rs"] +trust_boundaries = ["rust-compiler", "windows-api"] + +[[invariant]] +id = "INV-011" +target = "diagnostic events cannot receive command lines environments or handle values" +enforcement = ["type"] +artifacts = ["src/trace.rs"] +trust_boundaries = ["rust-compiler", "standard-library"] + +[[invariant]] +id = "INV-012" +target = "production source rejects semantic booleans panic paths unchecked indexing casts raw handles and custom synchronization" +enforcement = ["lint"] +artifacts = ["xtask/src/source_policy.rs", "src"] +trust_boundaries = ["rust-compiler", "standard-library"] + +[[invariant]] +id = "INV-013" +target = "command line quoting length and mitigation field replacement are panic free and bounded" +enforcement = ["kani"] +artifacts = ["src/core_logic.rs", ".github/workflows/ci.yml"] +trust_boundaries = ["rust-compiler", "standard-library"] + +[[invariant]] +id = "INV-014" +target = "dependency changes preserve license advisory and audit requirements" +enforcement = ["lint"] +artifacts = ["deny.toml", "supply-chain/config.toml", "supply-chain/audits.toml", "xtask/src/tasks.rs"] +trust_boundaries = ["rust-compiler", "standard-library"] + +[[invariant]] +id = "INV-015" +target = "production and deterministic fault execution share the typed spawn backend boundary" +enforcement = ["type", "lint"] +artifacts = ["src/backend.rs", "src/transaction.rs"] +trust_boundaries = ["rust-compiler", "standard-library", "windows-api"] diff --git a/release-plz.toml b/release-plz.toml index ea2578b..7353724 100644 --- a/release-plz.toml +++ b/release-plz.toml @@ -1,21 +1,10 @@ -# SPDX-FileCopyrightText: 2026 Yasunobu Sakashita -# -# SPDX-License-Identifier: MIT OR Apache-2.0 [workspace] allow_dirty = false changelog_update = true dependencies_update = false -# `release_always` is left at its default of true. Setting it to false makes -# release-plz publish only from a commit it authored through its own release -# pull request, so a version bumped by hand leaves the crate unreleasable: no -# release pull request is proposed because the version is already ahead of the -# registry, and no release happens because there was no release pull request. git_tag_enable = true git_tag_name = "v{{ version }}" -# release-plz publishes the crate and creates the tag. The GitHub release is -# left to release-finalize.yml, which needs to attach the crate, SBOM, and -# attestations before anything is published. git_release_enable = false publish = true semver_check = true @@ -23,10 +12,6 @@ semver_check = true [[package]] name = "xtask" release = false -# `release = false` stops release-plz from releasing xtask, but the workspace -# `publish = true` above still reaches it, and release-plz rejects a package -# whose Cargo.toml says `publish = false` while its configuration says -# otherwise. Restate it here. publish = false [changelog] diff --git a/src/backend.rs b/src/backend.rs new file mode 100644 index 0000000..f2ec0ab --- /dev/null +++ b/src/backend.rs @@ -0,0 +1,236 @@ +use std::ffi::OsString; +use std::io; +use std::os::windows::io::{BorrowedHandle, OwnedHandle}; + +use crate::handles::Job; +use crate::resource::ChildHandleValue; +use crate::sys::{self, Inheritability, NullAccess, PipeDirection, StandardStream}; +use crate::{BorrowedPseudoConsole, JobClosePolicy}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum BackendCall { + StandardHandle, + OpenNull, + CreatePipe, + DuplicateLocal, + DuplicateRemote, + CreateJob, + ConfigureJob, + CreateAttributes, + SetHandleList, + SetParent, + SetMitigation, + SetJobs, + SetPseudoConsole, + ReadEnvironment, + QuerySystemDirectory, + QueryWindowsDirectory, + CreateProcess, + ReclaimRemote, + ResumeThread, +} + +pub(crate) struct AttributeAddress<'a, Value>(&'a Value); + +impl<'a, Value> AttributeAddress<'a, Value> { + pub(crate) const fn new(value: &'a Value) -> Self { + Self(value) + } + + const fn get(&self) -> &'a Value { + self.0 + } +} + +pub(crate) trait SpawnBackend: Sized { + fn before(_: BackendCall) -> io::Result<()> { + Ok(()) + } + + fn standard_handle(stream: StandardStream) -> io::Result> { + Self::before(BackendCall::StandardHandle)?; + sys::standard_handle(stream) + } + + fn null_handle(access: NullAccess) -> io::Result { + Self::before(BackendCall::OpenNull)?; + sys::null_handle(access) + } + + fn create_pipe(direction: PipeDirection) -> io::Result { + Self::before(BackendCall::CreatePipe)?; + sys::create_pipe(direction) + } + + fn duplicate_local( + source: BorrowedHandle<'_>, + inheritability: Inheritability, + ) -> io::Result { + Self::before(BackendCall::DuplicateLocal)?; + sys::duplicate_local(source, inheritability) + } + + fn duplicate_remote<'a>( + source: BorrowedHandle<'_>, + target: BorrowedHandle<'a>, + inheritability: Inheritability, + ) -> io::Result> { + Self::before(BackendCall::DuplicateRemote)?; + sys::duplicate_remote(source, target, inheritability) + } + + fn create_job() -> io::Result { + Self::before(BackendCall::CreateJob)?; + sys::create_job().map(Job::from_system) + } + + fn configure_job(job: &Job, policy: JobClosePolicy) -> io::Result<()> { + use std::os::windows::io::AsHandle; + + Self::before(BackendCall::ConfigureJob)?; + sys::set_job_close_policy(job.as_handle(), policy) + } + + fn create_attributes(count: u32) -> io::Result { + Self::before(BackendCall::CreateAttributes)?; + sys::AttributeList::new(count) + } + + fn set_handle_list( + attributes: &mut sys::AttributeList, + handles: &[ChildHandleValue
], + ) -> io::Result<()> { + Self::before(BackendCall::SetHandleList)?; + attributes.set_handle_list(handles) + } + + fn set_parent
( + attributes: &mut sys::AttributeList, + parent: AttributeAddress<'_, ChildHandleValue
>, + ) -> io::Result<()> { + Self::before(BackendCall::SetParent)?; + attributes.set_parent(parent.get()) + } + + fn set_mitigation(attributes: &mut sys::AttributeList, words: &[u64; 2]) -> io::Result<()> { + Self::before(BackendCall::SetMitigation)?; + attributes.set_mitigation(words) + } + + fn set_jobs
( + attributes: &mut sys::AttributeList, + jobs: &[ChildHandleValue
], + ) -> io::Result<()> { + Self::before(BackendCall::SetJobs)?; + attributes.set_jobs(jobs) + } + + fn set_pseudoconsole( + attributes: &mut sys::AttributeList, + pseudoconsole: BorrowedPseudoConsole<'_>, + ) -> io::Result<()> { + Self::before(BackendCall::SetPseudoConsole)?; + attributes.set_pseudoconsole(pseudoconsole) + } + + fn environment_strings() -> io::Result> { + Self::before(BackendCall::ReadEnvironment)?; + sys::environment_strings() + } + + fn system_directory() -> io::Result { + Self::before(BackendCall::QuerySystemDirectory)?; + sys::system_directory() + } + + fn windows_directory() -> io::Result { + Self::before(BackendCall::QueryWindowsDirectory)?; + sys::windows_directory() + } + + fn create_process
( + request: &mut sys::ProcessRequest<'_, Table>, + ) -> io::Result { + Self::before(BackendCall::CreateProcess)?; + sys::create_process(request) + } + + fn reclaim_remote(handle: sys::RemoteHandle<'_>) -> io::Result<()> { + Self::before(BackendCall::ReclaimRemote)?; + handle.reclaim() + } + + fn resume_thread(thread: BorrowedHandle<'_>) -> io::Result { + Self::before(BackendCall::ResumeThread)?; + sys::resume_thread(thread) + } +} + +pub(crate) struct WindowsBackend; + +impl SpawnBackend for WindowsBackend {} + +#[cfg(test)] +pub(crate) use fault::{ + calls as fault_calls, configure as configure_fault, configure_many as configure_faults, + FaultBackend, +}; + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod fault { + use std::cell::RefCell; + use std::collections::BTreeSet; + use std::io; + + use super::{BackendCall, SpawnBackend}; + + #[derive(Default)] + struct FaultPlan { + fail_at: BTreeSet, + calls: Vec, + } + + std::thread_local! { + static PLAN: RefCell = RefCell::new(FaultPlan::default()); + } + + pub(crate) struct FaultBackend; + + impl SpawnBackend for FaultBackend { + fn before(call: BackendCall) -> io::Result<()> { + PLAN.with(|plan| { + let mut plan = plan.borrow_mut(); + let index = plan.calls.len(); + plan.calls.push(call); + if plan.fail_at.contains(&index) { + Err(io::Error::from_raw_os_error(5)) + } else { + Ok(()) + } + }) + } + } + + pub(crate) fn configure(fail_at: Option) { + let fail_at = fail_at.into_iter().collect(); + configure_set(fail_at); + } + + pub(crate) fn configure_many(fail_at: &[usize]) { + configure_set(fail_at.iter().copied().collect()); + } + + fn configure_set(fail_at: BTreeSet) { + PLAN.with(|plan| { + *plan.borrow_mut() = FaultPlan { + fail_at, + calls: Vec::new(), + }; + }); + } + + pub(crate) fn calls() -> Vec { + PLAN.with(|plan| plan.borrow().calls.clone()) + } +} diff --git a/src/child.rs b/src/child.rs index 87a9aed..eb2bc45 100644 --- a/src/child.rs +++ b/src/child.rs @@ -1,22 +1,28 @@ -//! Owned child process and suspended type state. - use std::io::{self, Read, Write}; -use std::os::windows::io::{AsHandle, BorrowedHandle, OwnedHandle}; +use std::os::windows::io::{AsHandle, BorrowedHandle, OwnedHandle as SystemOwnedHandle}; use std::process::{ExitStatus, Output}; use std::thread; +use crate::error::{Error, Operation, Phase, Result, ValidationError}; use crate::handles::Job; +use crate::resource::{CurrentTable, OwnedHandle, PipeKind, ProcessKind, ThreadKind}; use crate::sys; +use crate::trace::{self, ResourceKind}; /// The writable parent end of a child's standard-input pipe. #[derive(Debug)] pub struct ChildStdin { - handle: OwnedHandle, + handle: OwnedHandle, } impl Write for ChildStdin { fn write(&mut self, buffer: &[u8]) -> io::Result { - sys::write_handle(self.handle.as_handle(), buffer) + trace::io( + Phase::Runtime, + Operation::WritePipe, + ResourceKind::Pipe, + || sys::write_handle(self.handle.as_handle(), buffer), + ) } fn flush(&mut self) -> io::Result<()> { @@ -33,12 +39,17 @@ impl AsHandle for ChildStdin { /// The readable parent end of a child's standard-output pipe. #[derive(Debug)] pub struct ChildStdout { - handle: OwnedHandle, + handle: OwnedHandle, } impl Read for ChildStdout { fn read(&mut self, buffer: &mut [u8]) -> io::Result { - sys::read_handle(self.handle.as_handle(), buffer) + trace::io( + Phase::Runtime, + Operation::ReadPipe, + ResourceKind::Pipe, + || sys::read_handle(self.handle.as_handle(), buffer), + ) } } @@ -51,12 +62,17 @@ impl AsHandle for ChildStdout { /// The readable parent end of a child's standard-error pipe. #[derive(Debug)] pub struct ChildStderr { - handle: OwnedHandle, + handle: OwnedHandle, } impl Read for ChildStderr { fn read(&mut self, buffer: &mut [u8]) -> io::Result { - sys::read_handle(self.handle.as_handle(), buffer) + trace::io( + Phase::Runtime, + Operation::ReadPipe, + ResourceKind::Pipe, + || sys::read_handle(self.handle.as_handle(), buffer), + ) } } @@ -66,77 +82,249 @@ impl AsHandle for ChildStderr { } } -/// A running or exited process whose handle is owned exactly once. +#[derive(Debug)] +enum ExecutionState { + InitialSuspended, + Running, +} + +#[derive(Debug)] +pub(crate) struct ProcessOwner { + process: OwnedHandle, + thread: OwnedHandle, + pid: u32, + state: ExecutionState, +} + +impl ProcessOwner { + pub(crate) fn new(created: sys::CreatedProcess) -> Self { + Self { + process: OwnedHandle::from_system(created.process), + thread: OwnedHandle::from_system(created.thread), + pid: created.pid, + state: ExecutionState::InitialSuspended, + } + } + + pub(crate) fn process_handle(&self) -> BorrowedHandle<'_> { + self.process.as_handle() + } + + fn thread_handle(&self) -> BorrowedHandle<'_> { + self.thread.as_handle() + } + + fn resume_with( + &mut self, + resume: impl FnOnce(BorrowedHandle<'_>) -> io::Result, + ) -> Result<()> { + let previous = trace::io( + Phase::Resume, + Operation::ResumeThread, + ResourceKind::Thread, + || resume(self.thread_handle()), + ) + .map_err(|error| Error::windows(Phase::Resume, Operation::ResumeThread, error))?; + if previous != 1 { + return Err(Error::Validation(ValidationError::UnexpectedSuspendCount { + actual: previous, + })); + } + self.state = ExecutionState::Running; + Ok(()) + } +} + +impl Drop for ProcessOwner { + fn drop(&mut self) { + if matches!(self.state, ExecutionState::InitialSuspended) { + let _ = trace::io( + Phase::Cleanup, + Operation::TerminateProcess, + ResourceKind::Process, + || sys::terminate_process(self.process_handle(), 1), + ); + } + } +} + +#[derive(Debug)] +pub(crate) enum JobOwnership { + Preserve, + Terminate(Job), +} + +#[derive(Debug)] +enum ProcessTree { + Preserve(ProcessOwner), + Terminate { + job: Job, + process: ProcessOwner, + cleanup: CleanupState, + }, +} + +#[derive(Debug)] +enum CleanupState { + Pending, + Complete, +} + +impl ProcessTree { + fn new(process: ProcessOwner, job: JobOwnership) -> Self { + match job { + JobOwnership::Preserve => Self::Preserve(process), + JobOwnership::Terminate(job) => Self::Terminate { + job, + process, + cleanup: CleanupState::Pending, + }, + } + } + + fn process(&self) -> &ProcessOwner { + match self { + Self::Preserve(process) | Self::Terminate { process, .. } => process, + } + } + + fn process_mut(&mut self) -> &mut ProcessOwner { + match self { + Self::Preserve(process) | Self::Terminate { process, .. } => process, + } + } + + fn terminate_descendants(&mut self) -> Result<()> { + match self { + Self::Preserve(_) => Ok(()), + Self::Terminate { job, cleanup, .. } => match cleanup { + CleanupState::Complete => Ok(()), + CleanupState::Pending => { + job.terminate(1)?; + *cleanup = CleanupState::Complete; + Ok(()) + } + }, + } + } +} + +impl Drop for ProcessTree { + fn drop(&mut self) { + if let Self::Terminate { + job, + cleanup: CleanupState::Pending, + .. + } = self + { + let _ = job.terminate(1); + } + } +} + +/// A running or exited process whose resources are owned exactly once. #[derive(Debug)] pub struct Child { - // Declared first so kill-on-close takes effect before pipe and process - // handles are released by Rust's field drop order. - kill_job: Option, + tree: ProcessTree, /// A pipe connected to the child's standard input, when requested. pub stdin: Option, /// A pipe connected to the child's standard output, when requested. pub stdout: Option, /// A pipe connected to the child's standard error, when requested. pub stderr: Option, - process: OwnedHandle, - pid: u32, exit: Option, } impl Child { pub(crate) fn new( - process: OwnedHandle, - pid: u32, - kill_job: Option, - stdin: Option, - stdout: Option, - stderr: Option, + process: ProcessOwner, + job: JobOwnership, + stdin: Option, + stdout: Option, + stderr: Option, ) -> Self { Self { - kill_job, - stdin: stdin.map(|handle| ChildStdin { handle }), - stdout: stdout.map(|handle| ChildStdout { handle }), - stderr: stderr.map(|handle| ChildStderr { handle }), - process, - pid, + tree: ProcessTree::new(process, job), + stdin: stdin.map(|handle| ChildStdin { + handle: OwnedHandle::from_system(handle), + }), + stdout: stdout.map(|handle| ChildStdout { + handle: OwnedHandle::from_system(handle), + }), + stderr: stderr.map(|handle| ChildStderr { + handle: OwnedHandle::from_system(handle), + }), exit: None, } } pub(crate) fn process_handle(&self) -> BorrowedHandle<'_> { - self.process.as_handle() + self.tree.process().process_handle() + } + + fn primary_thread_handle(&self) -> BorrowedHandle<'_> { + self.tree.process().thread_handle() + } + + pub(crate) fn resume_initial(&mut self) -> Result<()> { + self.resume_initial_with(sys::resume_thread) + } + + pub(crate) fn resume_initial_with( + &mut self, + resume: impl FnOnce(BorrowedHandle<'_>) -> io::Result, + ) -> Result<()> { + self.tree.process_mut().resume_with(resume) } /// Returns the process identifier captured at creation. #[must_use] - pub const fn id(&self) -> u32 { - self.pid + pub fn id(&self) -> u32 { + self.tree.process().pid } /// Terminates the root process. /// /// # Errors /// - /// Returns the operating-system error from `TerminateProcess`. - pub fn kill(&mut self) -> io::Result<()> { + /// Returns a typed Windows failure from `TerminateProcess`. + pub fn kill(&mut self) -> Result<()> { if self.exit.is_some() { return Ok(()); } - sys::terminate_process(self.process.as_handle(), 1) + trace::io( + Phase::Runtime, + Operation::TerminateProcess, + ResourceKind::Process, + || sys::terminate_process(self.process_handle(), 1), + ) + .map_err(|error| Error::windows(Phase::Runtime, Operation::TerminateProcess, error)) } /// Waits for exit and caches the status. /// /// # Errors /// - /// Returns an error if waiting or retrieving the exit code fails. - pub fn wait(&mut self) -> io::Result { + /// Returns a typed failure if waiting or retrieving the exit code fails. + pub fn wait(&mut self) -> Result { if let Some(status) = self.exit { return Ok(status); } drop(self.stdin.take()); - sys::wait_process(self.process.as_handle())?; - let status = sys::exit_status(self.process.as_handle())?; + trace::io( + Phase::Runtime, + Operation::WaitProcess, + ResourceKind::Process, + || sys::wait_process(self.process_handle()), + ) + .map_err(|error| Error::windows(Phase::Runtime, Operation::WaitProcess, error))?; + let status = trace::io( + Phase::Runtime, + Operation::QueryExitCode, + ResourceKind::Process, + || sys::exit_status(self.process_handle()), + ) + .map_err(|error| Error::windows(Phase::Runtime, Operation::QueryExitCode, error))?; self.exit = Some(status); Ok(status) } @@ -145,213 +333,189 @@ impl Child { /// /// # Errors /// - /// Returns an error if querying the process or its exit code fails. - pub fn try_wait(&mut self) -> io::Result> { + /// Returns a typed failure if querying the process or its exit code fails. + pub fn try_wait(&mut self) -> Result> { if self.exit.is_some() { return Ok(self.exit); } - if !sys::try_wait_process(self.process.as_handle())? { + if !trace::io( + Phase::Runtime, + Operation::WaitProcess, + ResourceKind::Process, + || sys::try_wait_process(self.process_handle()), + ) + .map_err(|error| Error::windows(Phase::Runtime, Operation::WaitProcess, error))? + { return Ok(None); } - let status = sys::exit_status(self.process.as_handle())?; + let status = trace::io( + Phase::Runtime, + Operation::QueryExitCode, + ResourceKind::Process, + || sys::exit_status(self.process_handle()), + ) + .map_err(|error| Error::windows(Phase::Runtime, Operation::QueryExitCode, error))?; self.exit = Some(status); Ok(self.exit) } /// Waits while draining both output pipes concurrently. /// - /// Under [`crate::DropPolicy::KillTree`], descendants are terminated after - /// the root exits and before reader threads are joined. This guarantees EOF - /// even when a grandchild retained a pipe handle. - /// /// # Errors /// - /// Returns an error from process waiting, pipe reading, or Job termination. - pub fn wait_with_output(mut self) -> io::Result { + /// Returns a typed failure from waiting, reading, joining, or Job cleanup. + pub fn wait_with_output(mut self) -> Result { drop(self.stdin.take()); - let stdout_reader = self .stdout .take() - .map(|stream| thread::spawn(move || drain_output(stream.handle.as_handle()))); + .map(|stream| thread::spawn(move || drain_output(stream.handle))); let stderr_reader = self .stderr .take() - .map(|stream| thread::spawn(move || drain_output(stream.handle.as_handle()))); - + .map(|stream| thread::spawn(move || drain_output(stream.handle))); let status = self.wait(); - let termination = self - .kill_job - .as_ref() - .map_or(Ok(()), |job| job.terminate(1)); - let (stdout, stderr) = join_readers(stdout_reader, stderr_reader)?; + let termination = self.tree.terminate_descendants(); + let readers = join_readers(stdout_reader, stderr_reader); + let (stdout, stderr) = readers?; termination?; - Ok(Output { status: status?, stdout, stderr, }) } + + /// Performs policy-driven process-tree cleanup and consumes the child. + /// + /// # Errors + /// + /// Returns a typed cleanup failure if the Job cannot be terminated. + pub fn cleanup(mut self) -> Result<()> { + self.tree.terminate_descendants() + } } impl AsHandle for Child { fn as_handle(&self) -> BorrowedHandle<'_> { - self.process.as_handle() + self.process_handle() } } -fn drain_output(handle: BorrowedHandle<'_>) -> io::Result> { +fn drain_output(handle: OwnedHandle) -> Result> { let mut bytes = Vec::new(); let mut buffer = [0_u8; 8_192]; loop { - let Some(read) = std::num::NonZeroUsize::new(sys::read_handle(handle, &mut buffer)?) else { + let read = trace::io( + Phase::Runtime, + Operation::ReadPipe, + ResourceKind::Pipe, + || sys::read_handle(handle.as_handle(), &mut buffer), + ) + .map_err(|error| Error::windows(Phase::Runtime, Operation::ReadPipe, error))?; + let Some(read) = std::num::NonZeroUsize::new(read) else { + drop(handle); return Ok(bytes); }; - bytes.extend_from_slice(&buffer[..read.get()]); + let chunk = buffer + .get(..read.get()) + .ok_or(Error::Validation(ValidationError::SizeOverflow))?; + bytes.extend_from_slice(chunk); } } -fn join_reader(reader: Option>>>) -> io::Result> { +type Reader = thread::JoinHandle>>; + +fn join_reader(reader: Option) -> Result> { match reader { - Some(reader) => reader - .join() - .map_err(|_| io::Error::other("output reader thread panicked"))?, + Some(reader) => reader.join().map_err(|_| { + Error::windows( + Phase::Runtime, + Operation::JoinOutputReader, + io::Error::other("output reader thread panicked"), + ) + })?, None => Ok(Vec::new()), } } -fn join_readers( - stdout: Option>>>, - stderr: Option>>>, -) -> io::Result<(Vec, Vec)> { +fn join_readers(stdout: Option, stderr: Option) -> Result<(Vec, Vec)> { let stdout = join_reader(stdout); let stderr = join_reader(stderr); Ok((stdout?, stderr?)) } /// A process whose primary thread has not yet been resumed. -/// -/// Dropping this value without resuming always terminates the process. -/// The consuming transition makes a second resume unrepresentable: -/// -/// ```compile_fail -/// use windows_spawn::Command; -/// -/// let mut command = Command::new("cmd.exe"); -/// let suspended = command.spawn_suspended().unwrap(); -/// let _child = suspended.resume().unwrap(); -/// let _second = suspended.resume().unwrap(); -/// ``` #[derive(Debug)] #[must_use = "dropping a suspended child terminates it"] pub struct SuspendedChild { - child: Option, - main_thread: OwnedHandle, + child: Child, } impl SuspendedChild { - pub(crate) fn new(child: Child, main_thread: OwnedHandle) -> Self { - Self { - child: Some(child), - main_thread, - } + pub(crate) fn new(child: Child) -> Self { + Self { child } } /// Returns the process identifier captured at creation. - /// - /// # Panics - /// - /// Panics only if an internal ownership invariant was violated and the - /// process was removed before this suspended value was consumed. #[must_use] pub fn id(&self) -> u32 { - self.child - .as_ref() - .expect("a suspended child owns its process until resume") - .id() + self.child.id() } /// Borrows the suspended process's primary thread handle. - /// - /// This handle is available for supported thread configuration and - /// inspection before [`Self::resume`] consumes the suspended state. - /// #[must_use] pub fn primary_thread_handle(&self) -> BorrowedHandle<'_> { - self.main_thread.as_handle() + self.child.primary_thread_handle() } /// Resumes the primary thread and transitions to an ordinary [`Child`]. /// /// # Errors /// - /// Returns the operating-system error when the primary thread cannot be - /// resumed. It also returns `InvalidData` when external suspension or - /// resumption changed the expected suspend count of exactly one. The - /// process is terminated during either rollback. - pub fn resume(mut self) -> io::Result { - let previous = sys::resume_thread(self.main_thread.as_handle())?; - if previous != 1 { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - format!("primary thread suspend count was {previous}, expected 1"), - )); - } - self.child - .take() - .ok_or_else(|| io::Error::other("suspended child lost its process")) + /// Returns a typed resume failure. Any failed transition terminates the + /// still-owned process during emergency cleanup. + pub fn resume(mut self) -> Result { + self.child.resume_initial()?; + Ok(self.child) } } impl AsHandle for SuspendedChild { fn as_handle(&self) -> BorrowedHandle<'_> { - self.child - .as_ref() - .expect("a suspended child owns its process until resume") - .as_handle() - } -} - -impl Drop for SuspendedChild { - fn drop(&mut self) { - if let Some(child) = &mut self.child { - let _ = child.kill(); - } + self.child.as_handle() } } #[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] mod tests { - use std::sync::atomic::{AtomicBool, Ordering}; - use std::sync::Arc; + use std::io; use super::*; + use crate::{Command, JobClosePolicy, SpawnOptions}; #[test] - fn absent_and_panicked_output_readers_become_results() { - assert!(join_reader(None).unwrap().is_empty()); - let panicked = thread::spawn(|| -> io::Result> { panic!("reader panic") }); - assert_eq!( - join_reader(Some(panicked)).unwrap_err().kind(), - io::ErrorKind::Other - ); + fn completed_job_cleanup_is_idempotent() -> Result<()> { + let mut command = Command::new("cmd.exe"); + command.args(["/D", "/C", "exit /b 0"]); + let mut child = command + .spawn_with(SpawnOptions::new().job_close_policy(JobClosePolicy::TerminateProcesses))?; + child.tree.terminate_descendants()?; + child.tree.terminate_descendants()?; + let _status = child.wait()?; + Ok(()) } #[test] - fn both_output_readers_are_joined_when_the_first_panics() { - let joined = Arc::new(AtomicBool::new(false)); - let stdout = thread::spawn(|| -> io::Result> { panic!("stdout panic") }); - let stderr_joined = Arc::clone(&joined); - let stderr = thread::spawn(move || { - stderr_joined.store(true, Ordering::Release); - Ok(Vec::new()) - }); - - assert_eq!( - join_readers(Some(stdout), Some(stderr)).unwrap_err().kind(), - io::ErrorKind::Other - ); - assert!(joined.load(Ordering::Acquire)); + fn reader_panics_become_typed_join_failures() -> io::Result<()> { + let failed = thread::spawn(|| -> Result> { panic!("synthetic reader panic") }); + let completed = thread::spawn(|| Ok(vec![1_u8])); + let error = join_readers(Some(failed), Some(completed)).unwrap_err(); + let Error::Windows(error) = error else { + return Err(io::Error::other("Windows error expected")); + }; + assert_eq!(error.operation(), Operation::JoinOutputReader); + Ok(()) } } diff --git a/src/command.rs b/src/command.rs index 9305893..1ddee9e 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1,17 +1,36 @@ -//! Reusable process-launch intent. - use std::ffi::{OsStr, OsString}; use std::io; -use std::os::windows::io::{AsHandle, OwnedHandle}; +use std::os::windows::io::{AsHandle, BorrowedHandle, OwnedHandle}; use std::path::{Path, PathBuf}; use std::process::{ExitStatus, Output}; use crate::child::{Child, SuspendedChild}; +use crate::error::{Error, Operation, Phase, Result}; use crate::handles::Stdio; use crate::options::SpawnOptions; -use crate::plan::{IoMode, SpawnPlan}; +use crate::plan::{IoMode, ValidatedPlan}; use crate::sys; -use crate::transaction::SpawnTransaction; +use crate::trace::{self, ResourceKind}; +use crate::transaction::PreparedSpawn; + +fn duplicate_command_handle(handle: &T) -> Result { + duplicate_command_handle_with(handle.as_handle(), |source| { + sys::duplicate_local(source, sys::Inheritability::Private) + }) +} + +fn duplicate_command_handle_with( + source: BorrowedHandle<'_>, + duplicate: impl FnOnce(BorrowedHandle<'_>) -> io::Result, +) -> Result { + trace::io( + Phase::Preparation, + Operation::DuplicateLocalHandle, + ResourceKind::Handle, + || duplicate(source), + ) + .map_err(|error| Error::windows(Phase::Preparation, Operation::DuplicateLocalHandle, error)) +} #[derive(Debug)] pub(crate) enum Arg { @@ -32,6 +51,12 @@ pub(crate) enum EnvValue { Handle(OwnedHandle), } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum EnvironmentBase { + Inherit, + Empty, +} + /// A reusable description of a Windows process launch. /// /// Handles embedded by [`Self::arg_handle`] and [`Self::env_handle`] are @@ -45,24 +70,26 @@ pub(crate) enum EnvValue { /// descendants it leaves behind: /// /// ``` -/// use windows_spawn::{Command, DropPolicy, SpawnOptions}; +/// use windows_spawn::{Command, JobClosePolicy, SpawnOptions}; /// /// // `.bat` and `.cmd` are rejected, so a shell boundary is always explicit. /// let shell = std::env::var_os("COMSPEC").expect("COMSPEC is set on Windows"); /// let mut command = Command::new(shell); /// command.args(["/D", "/S", "/C"]).raw_arg("echo hello"); /// -/// let output = command.output_with(SpawnOptions::new().drop_policy(DropPolicy::KillTree))?; +/// let output = command.output_with( +/// SpawnOptions::new().job_close_policy(JobClosePolicy::TerminateProcesses), +/// )?; /// /// assert!(output.status.success()); /// assert!(String::from_utf8_lossy(&output.stdout).contains("hello")); -/// # Ok::<(), std::io::Error>(()) +/// # Ok::<(), windows_spawn::Error>(()) /// ``` #[derive(Debug)] pub struct Command { pub(crate) program: OsString, pub(crate) args: Vec, - pub(crate) env_clear: bool, + pub(crate) environment_base: EnvironmentBase, pub(crate) env_ops: Vec, pub(crate) cwd: Option, pub(crate) stdin: Option, @@ -77,7 +104,7 @@ impl Command { Self { program: program.as_ref().to_os_string(), args: Vec::new(), - env_clear: false, + environment_base: EnvironmentBase::Inherit, env_ops: Vec::new(), cwd: None, stdin: None, @@ -118,11 +145,9 @@ impl Command { /// # Errors /// /// Returns an error if the source handle cannot be duplicated. - pub fn arg_handle(&mut self, handle: &T) -> io::Result<&mut Self> { - self.args.push(Arg::Handle(sys::duplicate_local( - handle.as_handle(), - false, - )?)); + pub fn arg_handle(&mut self, handle: &T) -> Result<&mut Self> { + self.args + .push(Arg::Handle(duplicate_command_handle(handle)?)); Ok(self) } @@ -161,10 +186,10 @@ impl Command { &mut self, key: K, handle: &T, - ) -> io::Result<&mut Self> { + ) -> Result<&mut Self> { self.env_ops.push(EnvOp::Set( key.as_ref().to_os_string(), - EnvValue::Handle(sys::duplicate_local(handle.as_handle(), false)?), + EnvValue::Handle(duplicate_command_handle(handle)?), )); Ok(self) } @@ -178,7 +203,7 @@ impl Command { /// Clears the inherited environment and prior recorded modifications. pub fn env_clear(&mut self) -> &mut Self { - self.env_clear = true; + self.environment_base = EnvironmentBase::Empty; self.env_ops.clear(); self } @@ -224,7 +249,7 @@ impl Command { /// # Errors /// /// Returns validation, resource-acquisition, or process-creation errors. - pub fn spawn(&mut self) -> io::Result { + pub fn spawn(&mut self) -> Result { self.spawn_with(SpawnOptions::new()) } @@ -233,9 +258,12 @@ impl Command { /// # Errors /// /// Returns validation, resource-acquisition, or process-creation errors. - pub fn spawn_with(&mut self, options: SpawnOptions<'_>) -> io::Result { - let plan = SpawnPlan::new_running(self, options, IoMode::Spawn)?; - Ok(SpawnTransaction::new(&plan)?.commit_child()) + pub fn spawn_with(&mut self, options: SpawnOptions<'_>) -> Result { + let plan = ValidatedPlan::running(self, options, IoMode::Spawn)?; + PreparedSpawn::prepare(plan)? + .create_suspended()? + .reclaim()? + .resume() } /// Spawns in the suspended type state with default options. @@ -243,7 +271,7 @@ impl Command { /// # Errors /// /// Returns validation, resource-acquisition, or process-creation errors. - pub fn spawn_suspended(&mut self) -> io::Result { + pub fn spawn_suspended(&mut self) -> Result { self.spawn_suspended_with(SpawnOptions::new()) } @@ -252,12 +280,12 @@ impl Command { /// # Errors /// /// Returns validation, resource-acquisition, or process-creation errors. - pub fn spawn_suspended_with( - &mut self, - options: SpawnOptions<'_>, - ) -> io::Result { - let plan = SpawnPlan::new_suspended(self, options, IoMode::Spawn)?; - Ok(SpawnTransaction::new(&plan)?.commit_suspended()) + pub fn spawn_suspended_with(&mut self, options: SpawnOptions<'_>) -> Result { + let plan = ValidatedPlan::suspended(self, options, IoMode::Spawn)?; + Ok(PreparedSpawn::prepare(plan)? + .create_suspended()? + .reclaim()? + .into_suspended()) } /// Runs the process and waits for its status using default options. @@ -265,7 +293,7 @@ impl Command { /// # Errors /// /// Returns an error from spawning, waiting, or retrieving the exit code. - pub fn status(&mut self) -> io::Result { + pub fn status(&mut self) -> Result { self.status_with(SpawnOptions::new()) } @@ -274,7 +302,7 @@ impl Command { /// # Errors /// /// Returns an error from spawning, waiting, or retrieving the exit code. - pub fn status_with(&mut self, options: SpawnOptions<'_>) -> io::Result { + pub fn status_with(&mut self, options: SpawnOptions<'_>) -> Result { self.spawn_with(options)?.wait() } @@ -283,7 +311,7 @@ impl Command { /// # Errors /// /// Returns an error from spawning, waiting, reading, or Job termination. - pub fn output(&mut self) -> io::Result { + pub fn output(&mut self) -> Result { self.output_with(SpawnOptions::new()) } @@ -292,10 +320,40 @@ impl Command { /// # Errors /// /// Returns an error from spawning, waiting, reading, or Job termination. - pub fn output_with(&mut self, options: SpawnOptions<'_>) -> io::Result { - let plan = SpawnPlan::new_running(self, options, IoMode::Output)?; - SpawnTransaction::new(&plan)? - .commit_child() + pub fn output_with(&mut self, options: SpawnOptions<'_>) -> Result { + let plan = ValidatedPlan::running(self, options, IoMode::Output)?; + PreparedSpawn::prepare(plan)? + .create_suspended()? + .reclaim()? + .resume()? .wait_with_output() } } + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use std::fs::File; + + use super::*; + + #[test] + fn bulk_environment_builder_records_each_pair() { + let mut command = Command::new("program.exe"); + command.envs([("A", "1"), ("B", "2")]); + assert_eq!(command.env_ops.len(), 2); + } + + #[test] + fn duplicate_failures_keep_the_typed_operation() { + let file = File::open("NUL").unwrap(); + let error = duplicate_command_handle_with(file.as_handle(), |_| { + Err(io::Error::from_raw_os_error(5)) + }) + .unwrap_err(); + let Error::Windows(error) = error else { + panic!("Windows error expected"); + }; + assert_eq!(error.operation(), Operation::DuplicateLocalHandle); + } +} diff --git a/src/core_logic.rs b/src/core_logic.rs new file mode 100644 index 0000000..de46ad8 --- /dev/null +++ b/src/core_logic.rs @@ -0,0 +1,272 @@ +use std::collections::TryReserveError; + +const QUOTE: u16 = 0x22; +const BACKSLASH: u16 = 0x5c; +const SPACE: u16 = 0x20; +const TAB: u16 = 0x09; +const PIPE: u16 = 0x7c; + +pub(crate) const MAX_COMMAND_LINE_UNITS: usize = 32_767; + +#[derive(Debug)] +pub(crate) enum CommandLineError { + TooLong, + Allocation(TryReserveError), +} + +pub(crate) struct CommandLine { + units: Vec, +} + +impl CommandLine { + pub(crate) fn new(program: &[u16]) -> Result { + let mut value = Self { units: Vec::new() }; + let additional = program + .len() + .checked_add(2) + .ok_or(CommandLineError::TooLong)?; + value.reserve(additional)?; + value.units.push(QUOTE); + value.units.extend_from_slice(program); + value.units.push(QUOTE); + Ok(value) + } + + pub(crate) fn push_regular(&mut self, argument: &[u16]) -> Result<(), CommandLineError> { + let encoded = quoted_argument_len(argument).ok_or(CommandLineError::TooLong)?; + let additional = encoded.checked_add(1).ok_or(CommandLineError::TooLong)?; + self.reserve(additional)?; + self.units.push(SPACE); + append_regular(&mut self.units, argument); + Ok(()) + } + + pub(crate) fn push_raw(&mut self, argument: &[u16]) -> Result<(), CommandLineError> { + let additional = argument + .len() + .checked_add(1) + .ok_or(CommandLineError::TooLong)?; + self.reserve(additional)?; + self.units.push(SPACE); + self.units.extend_from_slice(argument); + Ok(()) + } + + pub(crate) fn finish(mut self) -> Result, CommandLineError> { + self.reserve(0)?; + self.units.push(0); + Ok(self.units) + } + + fn reserve(&mut self, additional: usize) -> Result<(), CommandLineError> { + let with_content = self + .units + .len() + .checked_add(additional) + .ok_or(CommandLineError::TooLong)?; + let with_terminator = with_content + .checked_add(1) + .ok_or(CommandLineError::TooLong)?; + if with_terminator > MAX_COMMAND_LINE_UNITS { + return Err(CommandLineError::TooLong); + } + let reservation = additional.checked_add(1).ok_or(CommandLineError::TooLong)?; + self.units + .try_reserve(reservation) + .map_err(CommandLineError::Allocation) + } +} + +pub(crate) fn quoted_argument_len(argument: &[u16]) -> Option { + let quoted = needs_quotes(argument); + let mut length = argument.len(); + if quoted { + length = length.checked_add(2)?; + } + let mut backslashes = 0_usize; + for unit in argument { + if *unit == BACKSLASH { + backslashes = backslashes.checked_add(1)?; + } else { + if *unit == QUOTE { + length = length.checked_add(backslashes.checked_add(1)?)?; + } + backslashes = 0; + } + } + if quoted { + length = length.checked_add(backslashes)?; + } + Some(length) +} + +fn append_regular(command: &mut Vec, argument: &[u16]) { + let quoted = needs_quotes(argument); + if quoted { + command.push(QUOTE); + } + let mut backslashes = 0_usize; + for unit in argument { + if *unit == BACKSLASH { + backslashes = backslashes.saturating_add(1); + } else { + if *unit == QUOTE { + command.extend(std::iter::repeat(BACKSLASH).take(backslashes.saturating_add(1))); + } + backslashes = 0; + } + command.push(*unit); + } + if quoted { + command.extend(std::iter::repeat(BACKSLASH).take(backslashes)); + command.push(QUOTE); + } +} + +fn needs_quotes(argument: &[u16]) -> bool { + argument.is_empty() + || argument + .iter() + .any(|unit| matches!(*unit, SPACE | TAB | QUOTE | PIPE)) +} + +pub(crate) const fn replace_two_bit_field(word: u64, shift: u32, value: u64) -> u64 { + let Some(mask) = 3_u64.checked_shl(shift) else { + return word; + }; + let encoded = value << shift; + (word & !mask) | (encoded & mask) +} + +pub(crate) const fn replace_one_bit_field(word: u64, shift: u32, value: u64) -> u64 { + let Some(mask) = 1_u64.checked_shl(shift) else { + return word; + }; + let encoded = value << shift; + (word & !mask) | (encoded & mask) +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::*; + + #[test] + fn quoting_length_matches_emission() { + for argument in [ + &[][..], + &[u16::from(b'a')], + &[SPACE], + &[BACKSLASH, QUOTE], + &[BACKSLASH, BACKSLASH, SPACE, BACKSLASH], + ] { + let mut emitted = Vec::new(); + append_regular(&mut emitted, argument); + assert_eq!(Some(emitted.len()), quoted_argument_len(argument)); + } + } + + #[test] + fn builder_includes_separators_and_terminator() { + let mut line = CommandLine::new(&[u16::from(b'p')]).unwrap(); + line.push_regular(&[SPACE]).unwrap(); + line.push_raw(&[u16::from(b'r')]).unwrap(); + let line = line.finish().unwrap(); + assert_eq!(line.last(), Some(&0)); + assert!(line.len() <= MAX_COMMAND_LINE_UNITS); + } + + #[test] + fn builder_rejects_the_windows_limit_and_invalid_bit_shifts_are_noops() { + let oversized = vec![u16::from(b'x'); MAX_COMMAND_LINE_UNITS]; + assert!(matches!( + CommandLine::new(&oversized), + Err(CommandLineError::TooLong) + )); + assert_eq!(replace_two_bit_field(7, 64, 0), 7); + assert_eq!(replace_two_bit_field(7, 65, 0), 7); + assert_eq!(replace_one_bit_field(7, 64, 0), 7); + assert_eq!(replace_one_bit_field(7, 65, 0), 7); + } + + #[test] + fn field_replacement_preserves_other_bits() { + let word = u64::MAX; + assert_eq!(replace_two_bit_field(word, 12, 0), word & !(3_u64 << 12)); + assert_eq!(replace_one_bit_field(word, 7, 0), word & !(1_u64 << 7)); + } + + #[test] + fn generated_quoting_and_fields_preserve_properties() { + let cases = std::env::var("WINDOWS_SPAWN_PROPERTY_CASES") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(10_000); + let mut state = 0x8f3c_2d19_7a65_b401_u64; + for case in 0..cases { + let length = usize::try_from(next(&mut state) % 65).unwrap_or_default(); + let mut argument = Vec::with_capacity(length); + for _ in 0..length { + let [low, high, ..] = next(&mut state).to_le_bytes(); + argument.push(u16::from_le_bytes([low, high])); + } + let mut emitted = Vec::new(); + append_regular(&mut emitted, &argument); + assert_eq!( + Some(emitted.len()), + quoted_argument_len(&argument), + "{case}" + ); + + let word = next(&mut state); + let value = next(&mut state); + let shift = u32::try_from((next(&mut state) % 31) * 2).unwrap_or_default(); + let mask = 3_u64 << shift; + let replaced = replace_two_bit_field(word, shift, value); + assert_eq!(replaced & !mask, word & !mask, "{case}"); + assert_eq!(replaced & mask, (value << shift) & mask, "{case}"); + } + } + + fn next(state: &mut u64) -> u64 { + *state = state + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1_442_695_040_888_963_407); + *state + } +} + +#[cfg(kani)] +mod proofs { + use super::*; + + #[kani::proof] + fn quoting_length_is_bounded() { + let argument: [u16; 16] = kani::any(); + let Some(length) = quoted_argument_len(&argument) else { + return; + }; + assert!(length >= argument.len()); + assert!(length <= argument.len() * 2 + 2); + } + + #[kani::proof] + fn two_bit_replacement_changes_only_its_field() { + let word: u64 = kani::any(); + let value: u64 = kani::any(); + let result = replace_two_bit_field(word, 36, value); + let mask = 3_u64 << 36; + assert_eq!(result & !mask, word & !mask); + assert_eq!(result & mask, (value << 36) & mask); + } + + #[kani::proof] + fn one_bit_replacement_changes_only_its_field() { + let word: u64 = kani::any(); + let value: u64 = kani::any(); + let result = replace_one_bit_field(word, 2, value); + let mask = 1_u64 << 2; + assert_eq!(result & !mask, word & !mask); + assert_eq!(result & mask, (value << 2) & mask); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..753710d --- /dev/null +++ b/src/error.rs @@ -0,0 +1,472 @@ +use std::error; +use std::fmt; +use std::io; + +/// The result type returned by windows-spawn operations. +pub type Result = std::result::Result; + +/// A process-spawn failure with its validation, Windows, or cleanup category preserved. +#[derive(Debug)] +pub enum Error { + /// Input was rejected before operating-system resources were acquired. + Validation(ValidationError), + /// A Windows operation failed. + Windows(WindowsError), + /// One or more resource-reclamation operations failed. + Cleanup(CleanupError), +} + +impl Error { + pub(crate) fn windows(phase: Phase, operation: Operation, source: io::Error) -> Self { + Self::Windows(WindowsError::new(phase, operation, source)) + } + + fn io_kind(&self) -> io::ErrorKind { + match self { + Self::Validation(ValidationError::UnexpectedSuspendCount { .. }) => { + io::ErrorKind::InvalidData + } + Self::Validation(_) => io::ErrorKind::InvalidInput, + Self::Windows(error) => error.source.kind(), + Self::Cleanup(_) => io::ErrorKind::Other, + } + } +} + +impl fmt::Display for Error { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Validation(error) => error.fmt(formatter), + Self::Windows(error) => error.fmt(formatter), + Self::Cleanup(error) => error.fmt(formatter), + } + } +} + +impl error::Error for Error { + fn source(&self) -> Option<&(dyn error::Error + 'static)> { + match self { + Self::Validation(error) => Some(error), + Self::Windows(error) => Some(error), + Self::Cleanup(error) => Some(error), + } + } +} + +impl From for io::Error { + fn from(error: Error) -> Self { + Self::new(error.io_kind(), error) + } +} + +/// The phase in which a Windows operation was attempted. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +#[non_exhaustive] +pub enum Phase { + /// Resources needed for creation were being prepared. + Preparation, + /// The process was being created in its initial suspended state. + Creation, + /// Temporary resources were being reclaimed before execution. + Reclamation, + /// The primary thread was being resumed. + Resume, + /// A running process was being observed or controlled. + Runtime, + /// Resources were being explicitly cleaned up. + Cleanup, +} + +/// The Windows operation associated with a failure. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +#[non_exhaustive] +pub enum Operation { + /// A handle was duplicated in the current process. + DuplicateLocalHandle, + /// A handle was duplicated into an alternate parent process. + DuplicateRemoteHandle, + /// A remotely duplicated handle was reclaimed. + ReclaimRemoteHandle, + /// A process handle was opened. + OpenProcess, + /// A Job object was created. + CreateJob, + /// Job limits were queried or changed. + ConfigureJob, + /// A process was assigned to a Job. + AssignJob, + /// A Job was terminated. + TerminateJob, + /// An anonymous pipe was created. + CreatePipe, + /// The null device or another file was opened. + OpenFile, + /// A process-thread attribute list was created or populated. + ConfigureAttributes, + /// An executable path was resolved. + ResolveExecutable, + /// The process was created. + CreateProcess, + /// A process was terminated. + TerminateProcess, + /// The primary thread was resumed. + ResumeThread, + /// A process wait was performed. + WaitProcess, + /// A process exit code was queried. + QueryExitCode, + /// A pipe was read. + ReadPipe, + /// A pipe was written. + WritePipe, + /// An output reader thread failed. + JoinOutputReader, + /// The process environment was read. + ReadEnvironment, + /// A system directory was queried. + QuerySystemDirectory, + /// A checked Windows command line was encoded. + BuildCommandLine, +} + +/// A validated description of why user input was rejected. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +#[non_exhaustive] +pub enum ValidationError { + /// The program name was empty. + EmptyProgram, + /// A Windows string contained an interior NUL code unit. + InteriorNul { + /// The input field containing the NUL. + field: InputField, + }, + /// The program name contained a double quote. + QuotedProgram, + /// The program path had no file name. + MissingProgramFileName, + /// A batch file was supplied without an explicit command shell. + BatchFile, + /// An environment-variable name was empty. + EmptyEnvironmentName, + /// An environment-variable name contained `=`. + InvalidEnvironmentName, + /// A pseudoconsole was combined with ordinary standard I/O. + PseudoConsoleWithStdio, + /// Output capture was requested for pseudoconsole-owned streams. + PseudoConsoleWithOutputCapture, + /// An alternate parent was used without three explicit standard streams. + AlternateParentNeedsStdio, + /// The primary thread did not have the expected initial suspend count. + UnexpectedSuspendCount { + /// The suspend count returned by Windows. + actual: u32, + }, + /// A checked size calculation exceeded its supported range. + SizeOverflow, + /// The encoded command line exceeded the Windows process-creation limit. + CommandLineTooLong, +} + +impl fmt::Display for ValidationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptyProgram => formatter.write_str("program must not be empty"), + Self::InteriorNul { field } => write!(formatter, "{field} contains an interior NUL"), + Self::QuotedProgram => formatter.write_str("program must not contain a double quote"), + Self::MissingProgramFileName => formatter.write_str("program path has no file name"), + Self::BatchFile => { + formatter.write_str("batch files must be invoked through an explicit command shell") + } + Self::EmptyEnvironmentName => { + formatter.write_str("environment variable name must not be empty") + } + Self::InvalidEnvironmentName => { + formatter.write_str("environment variable name must not contain `=`") + } + Self::PseudoConsoleWithStdio => { + formatter.write_str("a pseudoconsole conflicts with explicit standard I/O") + } + Self::PseudoConsoleWithOutputCapture => { + formatter.write_str("output capture conflicts with pseudoconsole standard I/O") + } + Self::AlternateParentNeedsStdio => formatter.write_str( + "an alternate parent requires all three standard streams to be explicit", + ), + Self::UnexpectedSuspendCount { actual } => write!( + formatter, + "primary thread suspend count was {actual}, expected 1" + ), + Self::SizeOverflow => formatter.write_str("a checked size calculation overflowed"), + Self::CommandLineTooLong => { + formatter.write_str("the encoded command line exceeds the Windows limit") + } + } + } +} + +impl error::Error for ValidationError {} + +/// A field accepted as Windows text. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +#[non_exhaustive] +pub enum InputField { + /// The executable program. + Program, + /// A command-line argument. + Argument, + /// An environment-variable name. + EnvironmentName, + /// An environment-variable value. + EnvironmentValue, + /// The current directory. + CurrentDirectory, +} + +impl fmt::Display for InputField { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = match self { + Self::Program => "program", + Self::Argument => "argument", + Self::EnvironmentName => "environment variable name", + Self::EnvironmentValue => "environment value", + Self::CurrentDirectory => "current directory", + }; + formatter.write_str(name) + } +} + +/// A Windows failure annotated with its phase and operation. +#[derive(Debug)] +pub struct WindowsError { + phase: Phase, + operation: Operation, + code: u32, + source: io::Error, +} + +impl WindowsError { + pub(crate) fn new(phase: Phase, operation: Operation, source: io::Error) -> Self { + let code = source + .raw_os_error() + .and_then(|value| u32::try_from(value).ok()) + .unwrap_or(0); + Self { + phase, + operation, + code, + source, + } + } + + /// Returns the phase in which the failure occurred. + #[must_use] + pub const fn phase(&self) -> Phase { + self.phase + } + + /// Returns the operation that failed. + #[must_use] + pub const fn operation(&self) -> Operation { + self.operation + } + + /// Returns the Win32 error code, or zero for a non-Win32 I/O failure. + #[must_use] + pub const fn code(&self) -> u32 { + self.code + } +} + +impl fmt::Display for WindowsError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "{:?} failed during {:?} with Win32 code {}: {}", + self.operation, self.phase, self.code, self.source + ) + } +} + +impl error::Error for WindowsError { + fn source(&self) -> Option<&(dyn error::Error + 'static)> { + Some(&self.source) + } +} + +/// A nonempty collection of resource-reclamation failures. +#[derive(Debug)] +pub struct CleanupError { + primary: Option, + first: WindowsError, + rest: Vec, +} + +impl CleanupError { + pub(crate) fn new(first: WindowsError) -> Self { + Self { + primary: None, + first, + rest: Vec::new(), + } + } + + pub(crate) fn push(&mut self, error: WindowsError) { + self.rest.push(error); + } + + pub(crate) fn set_primary(&mut self, primary: WindowsError) { + self.primary = Some(primary); + } + + /// Returns the primary operation failure that preceded cleanup, if any. + #[must_use] + pub const fn primary(&self) -> Option<&WindowsError> { + self.primary.as_ref() + } + + /// Iterates over every cleanup failure in occurrence order. + pub fn failures(&self) -> impl Iterator { + std::iter::once(&self.first).chain(&self.rest) + } +} + +impl fmt::Display for CleanupError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + if let Some(primary) = &self.primary { + write!( + formatter, + "primary failure: {primary}; {} cleanup operation(s) failed; first cleanup failure: {}", + self.rest.len() + 1, + self.first + ) + } else { + write!( + formatter, + "{} cleanup operation(s) failed; first failure: {}", + self.rest.len() + 1, + self.first + ) + } + } +} + +impl error::Error for CleanupError { + fn source(&self) -> Option<&(dyn error::Error + 'static)> { + Some(&self.first) + } +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use std::error::Error as _; + use std::io; + + use super::*; + + #[test] + fn validation_messages_and_input_fields_cover_the_closed_sets() { + let fields = [ + InputField::Program, + InputField::Argument, + InputField::EnvironmentName, + InputField::EnvironmentValue, + InputField::CurrentDirectory, + ]; + for field in fields { + assert!(!field.to_string().is_empty()); + } + + let errors = [ + ValidationError::EmptyProgram, + ValidationError::InteriorNul { + field: InputField::Program, + }, + ValidationError::QuotedProgram, + ValidationError::MissingProgramFileName, + ValidationError::BatchFile, + ValidationError::EmptyEnvironmentName, + ValidationError::InvalidEnvironmentName, + ValidationError::PseudoConsoleWithStdio, + ValidationError::PseudoConsoleWithOutputCapture, + ValidationError::AlternateParentNeedsStdio, + ValidationError::UnexpectedSuspendCount { actual: 2 }, + ValidationError::SizeOverflow, + ValidationError::CommandLineTooLong, + ]; + for error in errors { + assert!(!error.to_string().is_empty()); + assert!(error.source().is_none()); + } + } + + #[test] + fn typed_errors_preserve_kind_source_phase_operation_and_code() { + let validation = Error::Validation(ValidationError::EmptyProgram); + assert_eq!(validation.io_kind(), io::ErrorKind::InvalidInput); + assert!(validation.source().is_some()); + assert!(!validation.to_string().is_empty()); + + let suspend = Error::Validation(ValidationError::UnexpectedSuspendCount { actual: 3 }); + assert_eq!(suspend.io_kind(), io::ErrorKind::InvalidData); + + let windows = WindowsError::new( + Phase::Creation, + Operation::CreateProcess, + io::Error::from_raw_os_error(5), + ); + assert_eq!(windows.phase(), Phase::Creation); + assert_eq!(windows.operation(), Operation::CreateProcess); + assert_eq!(windows.code(), 5); + assert!(windows.source().is_some()); + assert!(!windows.to_string().is_empty()); + let windows = Error::Windows(windows); + assert_eq!(windows.io_kind(), io::ErrorKind::PermissionDenied); + assert!(windows.source().is_some()); + assert!(!windows.to_string().is_empty()); + + let no_code = WindowsError::new( + Phase::Runtime, + Operation::WaitProcess, + io::Error::other("synthetic"), + ); + assert_eq!(no_code.code(), 0); + } + + #[test] + fn cleanup_errors_are_nonempty_and_retain_optional_primary_failure() { + let first = WindowsError::new( + Phase::Reclamation, + Operation::ReclaimRemoteHandle, + io::Error::from_raw_os_error(5), + ); + let mut cleanup = CleanupError::new(first); + cleanup.push(WindowsError::new( + Phase::Cleanup, + Operation::TerminateJob, + io::Error::from_raw_os_error(6), + )); + assert_eq!(cleanup.failures().count(), 2); + assert!(cleanup.primary().is_none()); + assert!(!cleanup.to_string().is_empty()); + cleanup.set_primary(WindowsError::new( + Phase::Creation, + Operation::CreateProcess, + io::Error::from_raw_os_error(87), + )); + assert_eq!( + cleanup.primary().map(WindowsError::operation), + Some(Operation::CreateProcess) + ); + assert!(cleanup.source().is_some()); + assert!(!cleanup.to_string().is_empty()); + + let error = Error::Cleanup(cleanup); + assert_eq!(error.io_kind(), io::ErrorKind::Other); + assert!(error.source().is_some()); + assert!(!error.to_string().is_empty()); + let converted = io::Error::from(error); + assert_eq!(converted.kind(), io::ErrorKind::Other); + assert!(converted.get_ref().is_some()); + } +} diff --git a/src/handles.rs b/src/handles.rs index 65efe1e..222cf0d 100644 --- a/src/handles.rs +++ b/src/handles.rs @@ -1,12 +1,24 @@ -//! Owned capability wrappers used by process creation. - use std::fmt; use std::fs::File; use std::io; -use std::os::windows::io::{AsHandle, BorrowedHandle, OwnedHandle}; +use std::marker::PhantomData; +use std::num::NonZeroIsize; +use std::os::windows::io::{AsHandle, BorrowedHandle, OwnedHandle as SystemOwnedHandle}; use crate::child::Child; +use crate::resource::{CurrentTable, IoKind, JobKind, OwnedHandle, ProcessKind}; use crate::sys; +use crate::trace::{self, ResourceKind}; + +fn typed_io( + phase: crate::Phase, + operation: crate::Operation, + resource: ResourceKind, + call: impl FnOnce() -> io::Result, +) -> crate::Result { + trace::io(phase, operation, resource, call) + .map_err(|error| crate::Error::windows(phase, operation, error)) +} /// Describes a standard stream source while keeping any supplied handle owned. pub struct Stdio { @@ -17,7 +29,7 @@ pub(crate) enum StdioInner { Inherit, Null, Piped, - Owned(OwnedHandle), + Owned(OwnedHandle), } impl fmt::Debug for Stdio { @@ -64,29 +76,34 @@ impl Stdio { /// # Errors /// /// Returns the operating-system error if duplication fails. - pub fn from_borrowed(source: &T) -> io::Result { - Ok(Self::from(sys::duplicate_local(source.as_handle(), false)?)) + pub fn from_borrowed(source: &T) -> crate::Result { + Ok(Self::from(typed_io( + crate::Phase::Preparation, + crate::Operation::DuplicateLocalHandle, + ResourceKind::Handle, + || sys::duplicate_local(source.as_handle(), sys::Inheritability::Private), + )?)) } } -impl From for Stdio { - fn from(handle: OwnedHandle) -> Self { +impl From for Stdio { + fn from(handle: SystemOwnedHandle) -> Self { Self { - inner: StdioInner::Owned(handle), + inner: StdioInner::Owned(OwnedHandle::from_system(handle)), } } } impl From for Stdio { fn from(file: File) -> Self { - Self::from(OwnedHandle::from(file)) + Self::from(SystemOwnedHandle::from(file)) } } /// A process handle validated for use as `PROC_THREAD_ATTRIBUTE_PARENT_PROCESS`. #[derive(Debug)] pub struct ParentProcess { - handle: OwnedHandle, + handle: OwnedHandle, } impl ParentProcess { @@ -95,9 +112,14 @@ impl ParentProcess { /// # Errors /// /// Returns an error if the PID cannot be opened with the required rights. - pub fn open(pid: u32) -> io::Result { + pub fn open(pid: u32) -> crate::Result { Ok(Self { - handle: sys::open_parent_process(pid)?, + handle: OwnedHandle::from_system(typed_io( + crate::Phase::Preparation, + crate::Operation::OpenProcess, + ResourceKind::Process, + || sys::open_parent_process(pid), + )?), }) } @@ -106,9 +128,16 @@ impl ParentProcess { /// # Errors /// /// Returns an error if the handle does not identify a process. - pub fn from_handle(handle: OwnedHandle) -> io::Result { - sys::validate_process_handle(handle.as_handle())?; - Ok(Self { handle }) + pub fn from_handle(handle: SystemOwnedHandle) -> crate::Result { + typed_io( + crate::Phase::Preparation, + crate::Operation::OpenProcess, + ResourceKind::Process, + || sys::validate_process_handle(handle.as_handle()), + )?; + Ok(Self { + handle: OwnedHandle::from_system(handle), + }) } } @@ -121,19 +150,28 @@ impl AsHandle for ParentProcess { /// An owned Windows Job object. #[derive(Debug)] pub struct Job { - handle: OwnedHandle, + handle: OwnedHandle, } impl Job { + pub(crate) fn from_system(handle: SystemOwnedHandle) -> Self { + Self { + handle: OwnedHandle::from_system(handle), + } + } + /// Creates an unnamed Job object. /// /// # Errors /// /// Returns the operating-system error if Job creation fails. - pub fn create() -> io::Result { - Ok(Self { - handle: sys::create_job()?, - }) + pub fn create() -> crate::Result { + Ok(Self::from_system(typed_io( + crate::Phase::Preparation, + crate::Operation::CreateJob, + ResourceKind::Job, + sys::create_job, + )?)) } /// Adopts an existing handle after verifying it is a Job handle. @@ -141,9 +179,16 @@ impl Job { /// # Errors /// /// Returns an error if Job limit information cannot be queried. - pub fn from_handle(handle: OwnedHandle) -> io::Result { - sys::validate_job_handle(handle.as_handle())?; - Ok(Self { handle }) + pub fn from_handle(handle: SystemOwnedHandle) -> crate::Result { + typed_io( + crate::Phase::Preparation, + crate::Operation::ConfigureJob, + ResourceKind::Job, + || sys::validate_job_handle(handle.as_handle()), + )?; + Ok(Self { + handle: OwnedHandle::from_system(handle), + }) } /// Creates an independent duplicate of this Job handle. @@ -151,9 +196,14 @@ impl Job { /// # Errors /// /// Returns the operating-system error if duplication fails. - pub fn duplicate(&self) -> io::Result { + pub fn duplicate(&self) -> crate::Result { Ok(Self { - handle: sys::duplicate_local(self.handle.as_handle(), false)?, + handle: OwnedHandle::from_system(typed_io( + crate::Phase::Preparation, + crate::Operation::DuplicateLocalHandle, + ResourceKind::Handle, + || sys::duplicate_local(self.handle.as_handle(), sys::Inheritability::Private), + )?), }) } @@ -162,8 +212,13 @@ impl Job { /// # Errors /// /// Returns an error when Windows rejects the Job assignment. - pub fn assign(&self, child: &Child) -> io::Result<()> { - sys::assign_job(self.handle.as_handle(), child.process_handle()) + pub fn assign(&self, child: &Child) -> crate::Result<()> { + typed_io( + crate::Phase::Runtime, + crate::Operation::AssignJob, + ResourceKind::Job, + || sys::assign_job(self.handle.as_handle(), child.process_handle()), + ) } /// Terminates every process in the Job with `exit_code`. @@ -171,17 +226,27 @@ impl Job { /// # Errors /// /// Returns the operating-system error if Job termination fails. - pub fn terminate(&self, exit_code: u32) -> io::Result<()> { - sys::terminate_job(self.handle.as_handle(), exit_code) + pub fn terminate(&self, exit_code: u32) -> crate::Result<()> { + typed_io( + crate::Phase::Cleanup, + crate::Operation::TerminateJob, + ResourceKind::Job, + || sys::terminate_job(self.handle.as_handle(), exit_code), + ) } - /// Enables or disables kill-on-close without overwriting other Job limits. + /// Selects close behavior without overwriting other Job limits. /// /// # Errors /// /// Returns an error if querying or updating Job limits fails. - pub fn set_kill_on_close(&self, enable: bool) -> io::Result<()> { - sys::set_job_kill_on_close(self.handle.as_handle(), enable) + pub fn set_close_policy(&self, policy: crate::JobClosePolicy) -> crate::Result<()> { + typed_io( + crate::Phase::Preparation, + crate::Operation::ConfigureJob, + ResourceKind::Job, + || sys::set_job_close_policy(self.handle.as_handle(), policy), + ) } } @@ -191,30 +256,57 @@ impl AsHandle for Job { } } +/// A nonzero pseudoconsole value borrowed from its owner. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct BorrowedPseudoConsole<'a> { + pub(crate) value: NonZeroIsize, + owner: PhantomData<&'a ()>, +} + +impl<'a> BorrowedPseudoConsole<'a> { + /// Borrows a raw `HPCON` value for no longer than `owner` remains alive. + /// + /// # Safety + /// + /// `value` must identify an open pseudoconsole owned by `owner`. The owner + /// must keep that value open and unchanged for the returned lifetime. + #[allow(unsafe_code)] + pub unsafe fn from_raw(value: NonZeroIsize, owner: &'a T) -> Self { + let _ = owner; + Self { + value, + owner: PhantomData, + } + } +} + /// A borrowed pseudoconsole capability. /// /// # Safety /// /// Implementations must return a valid, nonzero `HPCON` and keep it open and /// unchanged for the full lifetime of every borrow passed to -/// [`crate::SpawnOptions::pseudoconsole`]. The implementation retains +/// [`crate::SpawnOptions::pseudo_console`]. The implementation retains /// ownership: windows-spawn borrows the value for process creation and never closes /// or releases it. #[allow(unsafe_code)] pub unsafe trait AsPseudoConsole { - /// Returns the borrowed raw `HPCON` value. - /// - /// Library implementations use this method to bridge their owned - /// pseudoconsole type to windows-spawn. Applications should normally pass the - /// implementing object to [`crate::SpawnOptions::pseudoconsole`] instead - /// of reading the numeric value. - /// - /// The returned value must satisfy the trait's safety contract. Calling - /// this method does not transfer ownership. - fn raw_pseudoconsole(&self) -> isize; + /// Returns a typed borrow of the owned pseudoconsole. + fn as_pseudo_console(&self) -> BorrowedPseudoConsole<'_>; } #[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +pub(crate) fn borrowed_pseudoconsole_for_test(_owner: &()) -> BorrowedPseudoConsole<'_> { + let value = NonZeroIsize::new(1).unwrap_or(NonZeroIsize::MIN); + BorrowedPseudoConsole { + value, + owner: PhantomData, + } +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] mod tests { use std::os::windows::io::AsRawHandle; @@ -222,15 +314,24 @@ mod tests { #[test] fn owned_handle_adoption_validates_resource_kind() { + let synthetic = typed_io::<()>( + crate::Phase::Preparation, + crate::Operation::OpenProcess, + ResourceKind::Process, + || Err(io::Error::from_raw_os_error(5)), + ) + .unwrap_err(); + assert!(matches!(synthetic, crate::Error::Windows(_))); let mut host = std::process::Command::new("cmd.exe") .args(["/D", "/C", "ping -n 5 127.0.0.1 >nul"]) .spawn() .unwrap(); let parent = ParentProcess::open(host.id()).unwrap(); assert!(format!("{parent:?}").contains("ParentProcess")); - let adopted_parent = - ParentProcess::from_handle(sys::duplicate_local(host.as_handle(), false).unwrap()) - .unwrap(); + let adopted_parent = ParentProcess::from_handle( + sys::duplicate_local(host.as_handle(), sys::Inheritability::Private).unwrap(), + ) + .unwrap(); assert_ne!( adopted_parent.as_handle().as_raw_handle(), std::ptr::null_mut() @@ -238,14 +339,22 @@ mod tests { let job = Job::create().unwrap(); let duplicate = job.duplicate().unwrap(); - let adopted_job = Job::from_handle(duplicate.handle).unwrap(); - adopted_job.set_kill_on_close(true).unwrap(); - adopted_job.set_kill_on_close(false).unwrap(); + let adopted_job = Job::from_handle( + sys::duplicate_local(duplicate.as_handle(), sys::Inheritability::Private).unwrap(), + ) + .unwrap(); + adopted_job + .set_close_policy(crate::JobClosePolicy::TerminateProcesses) + .unwrap(); + adopted_job + .set_close_policy(crate::JobClosePolicy::PreserveProcesses) + .unwrap(); let file = File::open("NUL").unwrap(); - let not_process = sys::duplicate_local(file.as_handle(), false).unwrap(); + let not_process = + sys::duplicate_local(file.as_handle(), sys::Inheritability::Private).unwrap(); assert!(ParentProcess::from_handle(not_process).is_err()); - let not_job = sys::duplicate_local(file.as_handle(), false).unwrap(); + let not_job = sys::duplicate_local(file.as_handle(), sys::Inheritability::Private).unwrap(); assert!(Job::from_handle(not_job).is_err()); let _ = host.kill(); let _ = host.wait(); diff --git a/src/lib.rs b/src/lib.rs index 2e82ed3..dc9b564 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,19 +1,22 @@ #![doc = include_str!("../docs/crate.md")] #![deny(unsafe_code)] +#![cfg_attr(coverage_nightly, feature(coverage_attribute))] -// The rendered crate documentation comes from `docs/crate.md`, so the README -// would otherwise ship to crates.io without ever being compiled. Including it -// under `cfg(doctest)` type-checks and runs its examples without adding a -// second copy of the front page to the rendered docs. #[cfg(doctest)] #[doc = include_str!("../README.md")] mod readme_examples {} +mod core_logic; + +#[cfg(windows)] +mod backend; #[cfg(windows)] mod child; #[cfg(windows)] mod command; #[cfg(windows)] +mod error; +#[cfg(windows)] mod handles; #[cfg(windows)] mod mitigation; @@ -22,9 +25,13 @@ mod options; #[cfg(windows)] mod plan; #[cfg(windows)] +mod resource; +#[cfg(windows)] #[allow(unsafe_code)] mod sys; #[cfg(windows)] +mod trace; +#[cfg(windows)] mod transaction; #[cfg(windows)] @@ -32,12 +39,16 @@ pub use crate::child::{Child, ChildStderr, ChildStdin, ChildStdout, SuspendedChi #[cfg(windows)] pub use crate::command::Command; #[cfg(windows)] -pub use crate::handles::{AsPseudoConsole, Job, ParentProcess, Stdio}; +pub use crate::error::{ + CleanupError, Error, InputField, Operation, Phase, Result, ValidationError, WindowsError, +}; +#[cfg(windows)] +pub use crate::handles::{AsPseudoConsole, BorrowedPseudoConsole, Job, ParentProcess, Stdio}; #[cfg(windows)] pub use crate::mitigation::{ - BlockNonCetBinaries, CetShadowStacks, ControlFlowGuard, DynamicCode, FontDisable, - LoaderIntegrity, Mitigation, MitigationPolicy, ModuleTampering, RelocateImages, SignedBinaries, - UserCetContextIpValidation, + AtlThunkPolicy, BlockNonCetBinaries, CetShadowStacks, ControlFlowGuard, DepPolicy, DynamicCode, + FontDisable, LoaderIntegrity, Mitigation, MitigationPolicy, ModuleTampering, RelocateImages, + SehopPolicy, SignedBinaries, UserCetContextIpValidation, }; #[cfg(windows)] -pub use crate::options::{CreationFlags, DropPolicy, SpawnOptions}; +pub use crate::options::{ConsoleMode, JobClosePolicy, SpawnOptions, TerminalMode}; diff --git a/src/mitigation.rs b/src/mitigation.rs index ed8e19f..7da657b 100644 --- a/src/mitigation.rs +++ b/src/mitigation.rs @@ -1,4 +1,37 @@ -//! Typed process-creation mitigation policy encoding. +use crate::core_logic::{replace_one_bit_field as set_legacy, replace_two_bit_field as replace}; + +/// Data Execution Prevention policy. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +#[repr(u64)] +pub enum DepPolicy { + /// Defer to the executable and operating system. + #[default] + Defer = 0, + /// Enable Data Execution Prevention. + Enable = 1, +} + +/// DEP ATL thunk-emulation policy. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +#[repr(u64)] +pub enum AtlThunkPolicy { + /// Defer to the executable and operating system. + #[default] + Defer = 0, + /// Disable ATL thunk emulation. + Disable = 1, +} + +/// Structured Exception Handler Overwrite Protection policy. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +#[repr(u64)] +pub enum SehopPolicy { + /// Defer to the executable and operating system. + #[default] + Defer = 0, + /// Enable SEHOP. + Enable = 1, +} /// The ordinary two-bit mitigation states used by the Windows SDK. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] @@ -173,6 +206,98 @@ pub enum BlockNonCetBinaries { NonEhContinuation = 3, } +macro_rules! mitigation_bits { + ($type:ty, $($variant:path => $bits:literal),+ $(,)?) => { + impl $type { + const fn bits(self) -> u64 { + match self { + $($variant => $bits,)+ + } + } + } + }; +} + +mitigation_bits!(DepPolicy, DepPolicy::Defer => 0, DepPolicy::Enable => 1); +mitigation_bits!(AtlThunkPolicy, AtlThunkPolicy::Defer => 0, AtlThunkPolicy::Disable => 1); +mitigation_bits!(SehopPolicy, SehopPolicy::Defer => 0, SehopPolicy::Enable => 1); +mitigation_bits!( + Mitigation, + Mitigation::Defer => 0, + Mitigation::AlwaysOn => 1, + Mitigation::AlwaysOff => 2, +); +mitigation_bits!( + RelocateImages, + RelocateImages::Defer => 0, + RelocateImages::AlwaysOn => 1, + RelocateImages::AlwaysOff => 2, + RelocateImages::RequireRelocations => 3, +); +mitigation_bits!( + DynamicCode, + DynamicCode::Defer => 0, + DynamicCode::Prohibit => 1, + DynamicCode::Allow => 2, + DynamicCode::ProhibitWithOptOut => 3, +); +mitigation_bits!( + ControlFlowGuard, + ControlFlowGuard::Defer => 0, + ControlFlowGuard::AlwaysOn => 1, + ControlFlowGuard::AlwaysOff => 2, + ControlFlowGuard::ExportSuppression => 3, +); +mitigation_bits!( + SignedBinaries, + SignedBinaries::Defer => 0, + SignedBinaries::MicrosoftOnly => 1, + SignedBinaries::AlwaysOff => 2, + SignedBinaries::MicrosoftAndStore => 3, +); +mitigation_bits!( + FontDisable, + FontDisable::Defer => 0, + FontDisable::Block => 1, + FontDisable::Allow => 2, + FontDisable::Audit => 3, +); +mitigation_bits!( + LoaderIntegrity, + LoaderIntegrity::Defer => 0, + LoaderIntegrity::AlwaysOn => 1, + LoaderIntegrity::AlwaysOff => 2, + LoaderIntegrity::Audit => 3, +); +mitigation_bits!( + ModuleTampering, + ModuleTampering::Defer => 0, + ModuleTampering::AlwaysOn => 1, + ModuleTampering::AlwaysOff => 2, + ModuleTampering::NoInherit => 3, +); +mitigation_bits!( + CetShadowStacks, + CetShadowStacks::Defer => 0, + CetShadowStacks::AlwaysOn => 1, + CetShadowStacks::AlwaysOff => 2, + CetShadowStacks::Strict => 3, +); +mitigation_bits!( + UserCetContextIpValidation, + UserCetContextIpValidation::Defer => 0, + UserCetContextIpValidation::AlwaysOn => 1, + UserCetContextIpValidation::AlwaysOff => 2, + UserCetContextIpValidation::Relaxed => 3, +); +mitigation_bits!( + BlockNonCetBinaries, + BlockNonCetBinaries::Defer => 0, + BlockNonCetBinaries::AlwaysOn => 1, + BlockNonCetBinaries::AlwaysOff => 2, + BlockNonCetBinaries::NonEhContinuation => 3, +); + /// A complete SDK 10.0.22621 process-creation mitigation policy. /// /// Setters replace one field. Reserved values and combined raw policy words @@ -187,187 +312,198 @@ pub enum BlockNonCetBinaries { /// a requested policy. Unsupported policies return the process-creation error. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] pub struct MitigationPolicy { - words: [u64; 2], + words: MitigationWords, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +struct MitigationWords { + first: u64, + second: u64, } impl MitigationPolicy { /// Creates a policy which defers every field. #[must_use] pub const fn new() -> Self { - Self { words: [0, 0] } + Self { + words: MitigationWords { + first: 0, + second: 0, + }, + } } /// Returns the encoded words for diagnostics. #[must_use] pub const fn words(self) -> [u64; 2] { - self.words + [self.words.first, self.words.second] } - /// Enables or removes the legacy DEP-enable bit. + /// Sets the legacy DEP policy. #[must_use] - pub const fn dep(mut self, enable: bool) -> Self { - self.words[0] = set_bit(self.words[0], 0, enable); + pub const fn dep(mut self, policy: DepPolicy) -> Self { + self.words.first = set_legacy(self.words.first, 0, policy.bits()); self } - /// Enables or removes the legacy DEP ATL-thunk-emulation bit. + /// Sets the legacy DEP ATL-thunk-emulation policy. #[must_use] - pub const fn dep_atl_thunk(mut self, enable: bool) -> Self { - self.words[0] = set_bit(self.words[0], 1, enable); + pub const fn dep_atl_thunk(mut self, policy: AtlThunkPolicy) -> Self { + self.words.first = set_legacy(self.words.first, 1, policy.bits()); self } - /// Enables or removes the legacy SEHOP bit. + /// Sets the legacy SEHOP policy. #[must_use] - pub const fn sehop(mut self, enable: bool) -> Self { - self.words[0] = set_bit(self.words[0], 2, enable); + pub const fn sehop(mut self, policy: SehopPolicy) -> Self { + self.words.first = set_legacy(self.words.first, 2, policy.bits()); self } /// Sets mandatory image relocation. #[must_use] pub const fn relocate_images(mut self, value: RelocateImages) -> Self { - self.words[0] = replace(self.words[0], 8, value as u64); + self.words.first = replace(self.words.first, 8, value.bits()); self } /// Sets heap termination on corruption. #[must_use] pub const fn heap_terminate(mut self, value: Mitigation) -> Self { - self.words[0] = replace(self.words[0], 12, value as u64); + self.words.first = replace(self.words.first, 12, value.bits()); self } /// Sets bottom-up ASLR. #[must_use] pub const fn bottom_up_aslr(mut self, value: Mitigation) -> Self { - self.words[0] = replace(self.words[0], 16, value as u64); + self.words.first = replace(self.words.first, 16, value.bits()); self } /// Sets high-entropy ASLR. #[must_use] pub const fn high_entropy_aslr(mut self, value: Mitigation) -> Self { - self.words[0] = replace(self.words[0], 20, value as u64); + self.words.first = replace(self.words.first, 20, value.bits()); self } /// Sets strict invalid-handle checking. #[must_use] pub const fn strict_handle_checks(mut self, value: Mitigation) -> Self { - self.words[0] = replace(self.words[0], 24, value as u64); + self.words.first = replace(self.words.first, 24, value.bits()); self } /// Sets the Win32k system-call-disable mitigation. #[must_use] pub const fn disable_win32k_system_calls(mut self, value: Mitigation) -> Self { - self.words[0] = replace(self.words[0], 28, value as u64); + self.words.first = replace(self.words.first, 28, value.bits()); self } /// Sets extension-point disabling. #[must_use] pub const fn disable_extension_points(mut self, value: Mitigation) -> Self { - self.words[0] = replace(self.words[0], 32, value as u64); + self.words.first = replace(self.words.first, 32, value.bits()); self } /// Sets dynamic-code policy. #[must_use] pub const fn dynamic_code(mut self, value: DynamicCode) -> Self { - self.words[0] = replace(self.words[0], 36, value as u64); + self.words.first = replace(self.words.first, 36, value.bits()); self } /// Sets Control Flow Guard policy. #[must_use] pub const fn control_flow_guard(mut self, value: ControlFlowGuard) -> Self { - self.words[0] = replace(self.words[0], 40, value as u64); + self.words.first = replace(self.words.first, 40, value.bits()); self } /// Sets signed-binary loading policy. #[must_use] pub const fn signed_binaries(mut self, value: SignedBinaries) -> Self { - self.words[0] = replace(self.words[0], 44, value as u64); + self.words.first = replace(self.words.first, 44, value.bits()); self } /// Sets non-system-font policy. #[must_use] pub const fn font_disable(mut self, value: FontDisable) -> Self { - self.words[0] = replace(self.words[0], 48, value as u64); + self.words.first = replace(self.words.first, 48, value.bits()); self } /// Sets remote-image blocking. #[must_use] pub const fn block_remote_images(mut self, value: Mitigation) -> Self { - self.words[0] = replace(self.words[0], 52, value as u64); + self.words.first = replace(self.words.first, 52, value.bits()); self } /// Sets low-integrity-label image blocking. #[must_use] pub const fn block_low_label_images(mut self, value: Mitigation) -> Self { - self.words[0] = replace(self.words[0], 56, value as u64); + self.words.first = replace(self.words.first, 56, value.bits()); self } /// Sets System32 image preference. #[must_use] pub const fn prefer_system32_images(mut self, value: Mitigation) -> Self { - self.words[0] = replace(self.words[0], 60, value as u64); + self.words.first = replace(self.words.first, 60, value.bits()); self } /// Sets loader integrity continuity. #[must_use] pub const fn loader_integrity(mut self, value: LoaderIntegrity) -> Self { - self.words[1] = replace(self.words[1], 4, value as u64); + self.words.second = replace(self.words.second, 4, value.bits()); self } /// Sets strict Control Flow Guard. #[must_use] pub const fn strict_control_flow_guard(mut self, value: Mitigation) -> Self { - self.words[1] = replace(self.words[1], 8, value as u64); + self.words.second = replace(self.words.second, 8, value.bits()); self } /// Sets module-tampering protection. #[must_use] pub const fn module_tampering(mut self, value: ModuleTampering) -> Self { - self.words[1] = replace(self.words[1], 12, value as u64); + self.words.second = replace(self.words.second, 12, value.bits()); self } /// Sets restricted indirect branch prediction. #[must_use] pub const fn restrict_indirect_branch_prediction(mut self, value: Mitigation) -> Self { - self.words[1] = replace(self.words[1], 16, value as u64); + self.words.second = replace(self.words.second, 16, value.bits()); self } /// Sets permission for a broker to downgrade dynamic-code policy. #[must_use] pub const fn allow_downgrade_dynamic_code(mut self, value: Mitigation) -> Self { - self.words[1] = replace(self.words[1], 20, value as u64); + self.words.second = replace(self.words.second, 20, value.bits()); self } /// Sets speculative-store-bypass disabling. #[must_use] pub const fn disable_speculative_store_bypass(mut self, value: Mitigation) -> Self { - self.words[1] = replace(self.words[1], 24, value as u64); + self.words.second = replace(self.words.second, 24, value.bits()); self } /// Sets CET user shadow stacks. #[must_use] pub const fn cet_user_shadow_stacks(mut self, value: CetShadowStacks) -> Self { - self.words[1] = replace(self.words[1], 28, value as u64); + self.words.second = replace(self.words.second, 28, value.bits()); self } @@ -377,69 +513,66 @@ impl MitigationPolicy { mut self, value: UserCetContextIpValidation, ) -> Self { - self.words[1] = replace(self.words[1], 32, value as u64); + self.words.second = replace(self.words.second, 32, value.bits()); self } /// Sets blocking of binaries without CET metadata. #[must_use] pub const fn block_non_cet_binaries(mut self, value: BlockNonCetBinaries) -> Self { - self.words[1] = replace(self.words[1], 36, value as u64); + self.words.second = replace(self.words.second, 36, value.bits()); self } /// Sets extended Control Flow Guard. #[must_use] pub const fn extended_control_flow_guard(mut self, value: Mitigation) -> Self { - self.words[1] = replace(self.words[1], 40, value as u64); + self.words.second = replace(self.words.second, 40, value.bits()); self } /// Sets ARM64 user-mode instruction-pointer authentication. #[must_use] pub const fn pointer_authentication(mut self, value: Mitigation) -> Self { - self.words[1] = replace(self.words[1], 44, value as u64); + self.words.second = replace(self.words.second, 44, value.bits()); self } /// Sets CET dynamic APIs to out-of-process-only mode. #[must_use] pub const fn cet_dynamic_apis_out_of_process(mut self, value: Mitigation) -> Self { - self.words[1] = replace(self.words[1], 48, value as u64); + self.words.second = replace(self.words.second, 48, value.bits()); self } /// Sets restricted CPU-core sharing. #[must_use] pub const fn restrict_core_sharing(mut self, value: Mitigation) -> Self { - self.words[1] = replace(self.words[1], 52, value as u64); + self.words.second = replace(self.words.second, 52, value.bits()); self } /// Sets FSCTL system-call disabling. #[must_use] pub const fn disable_fsctl_system_calls(mut self, value: Mitigation) -> Self { - self.words[1] = replace(self.words[1], 56, value as u64); + self.words.second = replace(self.words.second, 56, value.bits()); self } } -const fn replace(word: u64, shift: u32, value: u64) -> u64 { - (word & !(3_u64 << shift)) | (value << shift) -} - -const fn set_bit(word: u64, shift: u32, value: bool) -> u64 { - if value { - word | (1_u64 << shift) - } else { - word & !(1_u64 << shift) - } -} - #[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] mod tests { use super::*; + macro_rules! field { + ($policy:expr, $word:expr, $shift:expr, $value:expr) => {{ + let mut expected = [0_u64; 2]; + expected[$word] = ($value as u64) << $shift; + assert_eq!($policy.words(), expected); + }}; + } + #[test] fn setters_replace_only_their_field() { let first = MitigationPolicy::new() @@ -458,30 +591,35 @@ mod tests { fn legacy_bits_do_not_touch_two_bit_fields() { let policy = MitigationPolicy::new() .relocate_images(RelocateImages::RequireRelocations) - .dep(true) - .dep_atl_thunk(true) - .sehop(true); + .dep(DepPolicy::Enable) + .dep_atl_thunk(AtlThunkPolicy::Disable) + .sehop(SehopPolicy::Enable); assert_eq!(policy.words()[0], 7 | (3_u64 << 8)); - let cleared = policy.dep(false).dep_atl_thunk(false).sehop(false); + let cleared = policy + .dep(DepPolicy::Defer) + .dep_atl_thunk(AtlThunkPolicy::Defer) + .sehop(SehopPolicy::Defer); assert_eq!(cleared.words()[0], 3_u64 << 8); - assert_eq!(MitigationPolicy::new().dep(true).dep(true).words()[0], 1); + assert_eq!( + MitigationPolicy::new() + .dep(DepPolicy::Enable) + .dep(DepPolicy::Enable) + .words()[0], + 1 + ); } #[test] - #[allow(clippy::too_many_lines)] - fn every_sdk_22621_field_has_the_expected_encoding() { - macro_rules! field { - ($policy:expr, $word:expr, $shift:expr, $value:expr) => {{ - let mut expected = [0_u64; 2]; - expected[$word] = ($value as u64) << $shift; - assert_eq!($policy.words(), expected); - }}; - } - - field!(MitigationPolicy::new().dep(true), 0, 0, 1); - field!(MitigationPolicy::new().dep_atl_thunk(true), 0, 1, 1); - field!(MitigationPolicy::new().sehop(true), 0, 2, 1); + fn first_sdk_22621_word_has_the_expected_encoding() { + field!(MitigationPolicy::new().dep(DepPolicy::Enable), 0, 0, 1); + field!( + MitigationPolicy::new().dep_atl_thunk(AtlThunkPolicy::Disable), + 0, + 1, + 1 + ); + field!(MitigationPolicy::new().sehop(SehopPolicy::Enable), 0, 2, 1); field!( MitigationPolicy::new().relocate_images(RelocateImages::RequireRelocations), 0, @@ -566,6 +704,10 @@ mod tests { 60, 1 ); + } + + #[test] + fn second_sdk_22621_word_has_the_expected_encoding() { field!( MitigationPolicy::new().loader_integrity(LoaderIntegrity::Audit), 1, diff --git a/src/options.rs b/src/options.rs index 166008e..8a4d836 100644 --- a/src/options.rs +++ b/src/options.rs @@ -1,8 +1,4 @@ -//! Per-spawn capabilities and creation policy. - use std::fmt; -use std::marker::PhantomData; -use std::ops::{BitOr, BitOrAssign}; use windows_sys::Win32::System::Threading::{ CREATE_BREAKAWAY_FROM_JOB, CREATE_DEFAULT_ERROR_MODE, CREATE_NEW_CONSOLE, @@ -10,112 +6,80 @@ use windows_sys::Win32::System::Threading::{ INHERIT_PARENT_AFFINITY, }; -use crate::handles::{AsPseudoConsole, Job, ParentProcess}; +use crate::handles::{AsPseudoConsole, BorrowedPseudoConsole, Job, ParentProcess}; use crate::mitigation::MitigationPolicy; -/// What dropping a live [`crate::Child`] does to its process tree. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] -pub enum DropPolicy { - /// Close windows-spawn's process handle without terminating the process. +/// The action taken when a live [`crate::Child`] releases its private Job. +#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)] +pub enum JobClosePolicy { + /// Preserve the child and any descendants after owned handles are closed. #[default] - Detach, - /// Terminate the child and all descendants in windows-spawn's private Job. - KillTree, + PreserveProcesses, + /// Terminate the child and descendants when the private Job is closed. + TerminateProcesses, } -/// Safe, named `CreateProcessW` creation flags. -/// -/// 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); - -impl CreationFlags { - /// Creates an empty flag set. - #[must_use] - pub const fn new() -> Self { - Self(0) - } - - /// Creates a process without inheriting a console. - pub const DETACHED_PROCESS: Self = Self(DETACHED_PROCESS); - /// Gives the child a new console. - pub const NEW_CONSOLE: Self = Self(CREATE_NEW_CONSOLE); - /// Makes the child the root of a new process group. - pub const NEW_PROCESS_GROUP: Self = Self(CREATE_NEW_PROCESS_GROUP); - /// Inherits the parent's processor affinity. - pub const INHERIT_PARENT_AFFINITY: Self = Self(INHERIT_PARENT_AFFINITY); - /// Allows the child to break away from the caller's Job when permitted. - pub const BREAKAWAY_FROM_JOB: Self = Self(CREATE_BREAKAWAY_FROM_JOB); - /// Preserves the caller's code-authorization level. - pub const PRESERVE_CODE_AUTHZ_LEVEL: Self = Self(CREATE_PRESERVE_CODE_AUTHZ_LEVEL); - /// Prevents the child from inheriting the caller's hard-error mode. - pub const DEFAULT_ERROR_MODE: Self = Self(CREATE_DEFAULT_ERROR_MODE); - /// Runs a console application without creating a console window. - pub const NO_WINDOW: Self = Self(CREATE_NO_WINDOW); - - pub(crate) const fn bits(self) -> u32 { - self.0 - } +/// A mutually exclusive Windows console-creation mode. +#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)] +pub enum ConsoleMode { + /// Inherit the caller's console relationship. + #[default] + Inherit, + /// Start without inheriting a console. + Detached, + /// Allocate a new console. + NewConsole, + /// Run a console application without a console window. + NoWindow, +} - pub(crate) const fn contains(self, other: Self) -> bool { - self.0 & other.0 == other.0 +impl ConsoleMode { + pub(crate) const fn creation_bits(self) -> u32 { + match self { + Self::Inherit => 0, + Self::Detached => DETACHED_PROCESS, + Self::NewConsole => CREATE_NEW_CONSOLE, + Self::NoWindow => CREATE_NO_WINDOW, + } } } -struct PseudoConsole<'a> { - raw: isize, - _borrow: PhantomData<&'a dyn AsPseudoConsole>, +/// The source of the child's terminal connection. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum TerminalMode<'a> { + /// Use an ordinary Windows console mode. + Console(ConsoleMode), + /// Connect the child to a borrowed pseudoconsole. + PseudoConsole(BorrowedPseudoConsole<'a>), } -impl<'a> PseudoConsole<'a> { - fn new(pseudoconsole: &'a T) -> Self { - Self { - raw: pseudoconsole.raw_pseudoconsole(), - _borrow: PhantomData, - } +impl Default for TerminalMode<'_> { + fn default() -> Self { + Self::Console(ConsoleMode::Inherit) } } -impl BitOr for CreationFlags { - type Output = Self; +#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)] +pub(crate) struct CreationPolicy(u32); - fn bitor(self, rhs: Self) -> Self::Output { - Self(self.0 | rhs.0) +impl CreationPolicy { + pub(crate) const fn bits(self) -> u32 { + self.0 } -} -impl BitOrAssign for CreationFlags { - fn bitor_assign(&mut self, rhs: Self) { - self.0 |= rhs.0; + const fn with(self, flag: u32) -> Self { + Self(self.0 | flag) } } -/// Capabilities and policy needed only for one spawn operation. -/// -/// Jobs are retained in call order, from the root Job to the innermost Job. -/// The options borrow every capability; they never assume ownership of a Job, -/// parent process, or pseudoconsole. -/// -/// The borrow cannot escape the capability it protects: -/// -/// ```compile_fail -/// use windows_spawn::{Command, Job, SpawnOptions}; -/// -/// let options; -/// { -/// let job = Job::create().unwrap(); -/// options = SpawnOptions::new().job(&job); -/// } -/// Command::new("cmd.exe").spawn_with(options).unwrap(); -/// ``` +/// Capabilities and closed creation policies used by one spawn operation. pub struct SpawnOptions<'a> { pub(crate) jobs: Vec<&'a Job>, pub(crate) parent: Option<&'a ParentProcess>, pub(crate) mitigation: MitigationPolicy, - pseudoconsole: Option>, - pub(crate) creation_flags: CreationFlags, - pub(crate) drop_policy: DropPolicy, + pub(crate) terminal: TerminalMode<'a>, + pub(crate) creation: CreationPolicy, + pub(crate) job_close: JobClosePolicy, } impl fmt::Debug for SpawnOptions<'_> { @@ -125,12 +89,9 @@ impl fmt::Debug for SpawnOptions<'_> { .field("jobs", &self.jobs) .field("parent", &self.parent) .field("mitigation", &self.mitigation) - .field( - "pseudoconsole", - &self.pseudoconsole.as_ref().map(|_| "borrowed"), - ) - .field("creation_flags", &self.creation_flags) - .field("drop_policy", &self.drop_policy) + .field("terminal", &self.terminal) + .field("creation", &self.creation) + .field("job_close", &self.job_close) .finish() } } @@ -141,9 +102,9 @@ impl Default for SpawnOptions<'_> { jobs: Vec::new(), parent: None, mitigation: MitigationPolicy::new(), - pseudoconsole: None, - creation_flags: CreationFlags::new(), - drop_policy: DropPolicy::Detach, + terminal: TerminalMode::default(), + creation: CreationPolicy::default(), + job_close: JobClosePolicy::PreserveProcesses, } } } @@ -155,14 +116,14 @@ impl<'a> SpawnOptions<'a> { Self::default() } - /// Appends a Job, preserving root-to-inner ordering. + /// Appends a Job in root-to-innermost assignment order. #[must_use] pub fn job(mut self, job: &'a Job) -> Self { self.jobs.push(job); self } - /// Chooses another process as the logical parent. + /// Selects another process as the logical parent. #[must_use] pub fn parent_process(mut self, parent: &'a ParentProcess) -> Self { self.parent = Some(parent); @@ -176,76 +137,94 @@ impl<'a> SpawnOptions<'a> { self } - /// Attaches the child to a borrowed pseudoconsole. + /// Selects exactly one ordinary-console or pseudoconsole mode. #[must_use] - pub fn pseudoconsole(mut self, pseudoconsole: &'a T) -> Self { - self.pseudoconsole = Some(PseudoConsole::new(pseudoconsole)); + pub const fn terminal(mut self, terminal: TerminalMode<'a>) -> Self { + self.terminal = terminal; self } - /// Adds named process-creation flags. + /// Borrows a pseudoconsole and selects it as the terminal mode. #[must_use] - pub const fn creation_flags(mut self, flags: CreationFlags) -> Self { - self.creation_flags = flags; + pub fn pseudo_console(mut self, owner: &'a T) -> Self { + self.terminal = TerminalMode::PseudoConsole(owner.as_pseudo_console()); self } - /// Chooses the live-child drop policy. + /// Makes the child the root of a new process group. #[must_use] - pub const fn drop_policy(mut self, policy: DropPolicy) -> Self { - self.drop_policy = policy; + pub const fn new_process_group(mut self) -> Self { + self.creation = self.creation.with(CREATE_NEW_PROCESS_GROUP); self } - pub(crate) fn pseudoconsole_raw(&self) -> Option { - self.pseudoconsole - .as_ref() - .map(|pseudoconsole| pseudoconsole.raw) + /// Inherits the logical parent's processor affinity. + #[must_use] + pub const fn inherit_parent_affinity(mut self) -> Self { + self.creation = self.creation.with(INHERIT_PARENT_AFFINITY); + self } -} -#[cfg(test)] -#[allow(unsafe_code)] -mod tests { - use std::cell::Cell; + /// Requests permission to break away from the caller's Job. + #[must_use] + pub const fn breakaway_from_job(mut self) -> Self { + self.creation = self.creation.with(CREATE_BREAKAWAY_FROM_JOB); + self + } - use super::*; + /// Preserves the caller's code-authorization level. + #[must_use] + pub const fn preserve_code_authorization(mut self) -> Self { + self.creation = self.creation.with(CREATE_PRESERVE_CODE_AUTHZ_LEVEL); + self + } - struct TestPseudoConsole(Cell); + /// Prevents inheritance of the caller's hard-error mode. + #[must_use] + pub const fn default_error_mode(mut self) -> Self { + self.creation = self.creation.with(CREATE_DEFAULT_ERROR_MODE); + self + } - // SAFETY: tests use the value only as a snapshot and never pass it to Win32. - unsafe impl AsPseudoConsole for TestPseudoConsole { - fn raw_pseudoconsole(&self) -> isize { - self.0.get() - } + /// Selects how the private process-tree Job behaves when released. + #[must_use] + pub const fn job_close_policy(mut self, policy: JobClosePolicy) -> Self { + self.job_close = policy; + self } +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::*; #[test] - fn creation_flags_combine_idempotently_and_options_keep_job_order() { - let combined = CreationFlags::NEW_PROCESS_GROUP | CreationFlags::DEFAULT_ERROR_MODE; - assert_eq!(combined.bits(), 0x0400_0200); + fn console_and_creation_policies_encode_every_closed_choice() { + assert_eq!(ConsoleMode::Inherit.creation_bits(), 0); + assert_eq!(ConsoleMode::Detached.creation_bits(), DETACHED_PROCESS); + assert_eq!(ConsoleMode::NewConsole.creation_bits(), CREATE_NEW_CONSOLE); + assert_eq!(ConsoleMode::NoWindow.creation_bits(), CREATE_NO_WINDOW); + + let options = SpawnOptions::new() + .terminal(TerminalMode::Console(ConsoleMode::NewConsole)) + .new_process_group() + .inherit_parent_affinity() + .breakaway_from_job() + .preserve_code_authorization() + .default_error_mode(); assert_eq!( - (CreationFlags::NEW_PROCESS_GROUP | CreationFlags::NEW_PROCESS_GROUP).bits(), - CreationFlags::NEW_PROCESS_GROUP.bits() + options.creation.bits(), + CREATE_NEW_PROCESS_GROUP + | INHERIT_PARENT_AFFINITY + | CREATE_BREAKAWAY_FROM_JOB + | CREATE_PRESERVE_CODE_AUTHZ_LEVEL + | CREATE_DEFAULT_ERROR_MODE ); - let mut assigned = CreationFlags::NEW_PROCESS_GROUP; - assigned |= CreationFlags::DEFAULT_ERROR_MODE; - assert_eq!(assigned.bits(), combined.bits()); - - let outer = Job::create().unwrap(); - let inner = Job::create().unwrap(); - let options = SpawnOptions::new().job(&outer).job(&inner); - assert_eq!(options.jobs.len(), 2); - assert!(std::ptr::eq(options.jobs[0], &outer)); - assert!(std::ptr::eq(options.jobs[1], &inner)); - } - - #[test] - fn pseudoconsole_builder_snapshots_the_raw_value() { - let pseudoconsole = TestPseudoConsole(Cell::new(42)); - let options = SpawnOptions::new().pseudoconsole(&pseudoconsole); - pseudoconsole.0.set(99); - assert_eq!(options.pseudoconsole_raw(), Some(42)); - assert!(format!("{options:?}").contains("borrowed")); + assert!(matches!( + options.terminal, + TerminalMode::Console(ConsoleMode::NewConsole) + )); + assert!(!format!("{options:?}").is_empty()); } } diff --git a/src/plan.rs b/src/plan.rs index 0adc913..b958a6e 100644 --- a/src/plan.rs +++ b/src/plan.rs @@ -1,14 +1,12 @@ -//! Pure normalization and validation before any OS resource is acquired. - use std::ffi::OsStr; -use std::io; use std::marker::PhantomData; use std::os::windows::ffi::OsStrExt; use std::path::Path; use crate::command::{Arg, Command, EnvOp, EnvValue}; +use crate::error::{Error, InputField, Result, ValidationError}; use crate::handles::Stdio; -use crate::options::{CreationFlags, SpawnOptions}; +use crate::options::{SpawnOptions, TerminalMode}; #[derive(Debug)] pub(crate) struct Running; @@ -16,19 +14,19 @@ pub(crate) struct Running; #[derive(Debug)] pub(crate) struct Suspended; -pub(crate) trait SpawnState { - const SUSPENDED: bool; -} +mod sealed { + pub(crate) trait Sealed {} -impl SpawnState for Running { - const SUSPENDED: bool = false; + impl Sealed for super::Running {} + impl Sealed for super::Suspended {} } -impl SpawnState for Suspended { - const SUSPENDED: bool = true; -} +pub(crate) trait DesiredState: sealed::Sealed {} -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +impl DesiredState for Running {} +impl DesiredState for Suspended {} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum IoMode { Spawn, Output, @@ -56,68 +54,76 @@ pub(crate) enum StandardIo<'a> { } #[derive(Debug)] -pub(crate) struct SpawnPlan<'command, 'options, M> { +pub(crate) struct ValidatedPlan<'command, 'options, State> { pub(crate) command: &'command Command, pub(crate) options: SpawnOptions<'options>, pub(crate) stdio: StandardIo<'command>, - state: PhantomData, + state: PhantomData, } -impl<'command, 'options> SpawnPlan<'command, 'options, Running> { - pub(crate) fn new_running( +impl<'command, 'options> ValidatedPlan<'command, 'options, Running> { + pub(crate) fn running( command: &'command Command, options: SpawnOptions<'options>, io_mode: IoMode, - ) -> io::Result { + ) -> Result { Self::build(command, options, io_mode) } } -impl<'command, 'options> SpawnPlan<'command, 'options, Suspended> { - pub(crate) fn new_suspended( +impl<'command, 'options> ValidatedPlan<'command, 'options, Suspended> { + pub(crate) fn suspended( command: &'command Command, options: SpawnOptions<'options>, io_mode: IoMode, - ) -> io::Result { + ) -> Result { Self::build(command, options, io_mode) } } -impl<'command, 'options, M> SpawnPlan<'command, 'options, M> { +impl<'command, 'options, State> ValidatedPlan<'command, 'options, State> { + pub(crate) fn into_parts( + self, + ) -> ( + &'command Command, + SpawnOptions<'options>, + StandardIo<'command>, + ) { + let Self { + command, + options, + stdio, + state: _, + } = self; + (command, options, stdio) + } + fn build( command: &'command Command, options: SpawnOptions<'options>, io_mode: IoMode, - ) -> io::Result { + ) -> Result { validate_command(command)?; - validate_creation_flags( - options.creation_flags, - options.pseudoconsole_raw().is_some(), - )?; - + let pseudo_console = matches!(options.terminal, TerminalMode::PseudoConsole(_)); let explicit_stdio = command.stdin.is_some() || command.stdout.is_some() || command.stderr.is_some(); - if options.pseudoconsole_raw().is_some() { - if explicit_stdio { - return Err(invalid( - "a pseudoconsole conflicts with explicit standard I/O", - )); - } - if io_mode == IoMode::Output { - return Err(invalid( - "output capture conflicts with pseudoconsole standard I/O", - )); - } + if pseudo_console && explicit_stdio { + return Err(Error::Validation(ValidationError::PseudoConsoleWithStdio)); + } + if pseudo_console && io_mode == IoMode::Output { + return Err(Error::Validation( + ValidationError::PseudoConsoleWithOutputCapture, + )); } if options.parent.is_some() - && options.pseudoconsole_raw().is_none() + && !pseudo_console && (command.stdin.is_none() || command.stdout.is_none() || command.stderr.is_none()) { - return Err(invalid( - "an alternate parent requires all three standard streams to be explicit", + return Err(Error::Validation( + ValidationError::AlternateParentNeedsStdio, )); } - let stdio = if options.pseudoconsole_raw().is_some() { + let stdio = if pseudo_console { StandardIo::PseudoConsole } else { let handles = match io_mode { @@ -134,7 +140,6 @@ impl<'command, 'options, M> SpawnPlan<'command, 'options, M> { }; StandardIo::Ordinary(handles) }; - Ok(Self { command, options, @@ -148,32 +153,27 @@ fn configured_or<'a>(value: Option<&'a Stdio>, default: StdioSpec<'a>) -> StdioS value.map_or(default, StdioSpec::Configured) } -fn validate_command(command: &Command) -> io::Result<()> { +fn validate_command(command: &Command) -> Result<()> { if command.program.is_empty() { - return Err(invalid("program must not be empty")); + return Err(Error::Validation(ValidationError::EmptyProgram)); } - no_nul(&command.program, "program contains an interior NUL")?; + no_nul(&command.program, InputField::Program)?; if command.program.as_encoded_bytes().contains(&b'\"') { - return Err(invalid("program must not contain a double quote")); + return Err(Error::Validation(ValidationError::QuotedProgram)); } let program_path = Path::new(&command.program); if program_path.file_name().is_none() { - return Err(invalid("program path has no file name")); + return Err(Error::Validation(ValidationError::MissingProgramFileName)); } if let Some(extension) = program_path.extension() { let extension = extension.as_encoded_bytes(); if extension.eq_ignore_ascii_case(b"bat") || extension.eq_ignore_ascii_case(b"cmd") { - return Err(invalid( - "batch files must be invoked through an explicit command shell", - )); + return Err(Error::Validation(ValidationError::BatchFile)); } } - for arg in &command.args { match arg { - Arg::Text(text) | Arg::Raw(text) => { - no_nul(text, "argument contains an interior NUL")?; - } + Arg::Text(text) | Arg::Raw(text) => no_nul(text, InputField::Argument)?, Arg::Handle(_) => {} } } @@ -182,242 +182,193 @@ fn validate_command(command: &Command) -> io::Result<()> { EnvOp::Set(key, value) => { validate_env_key(key)?; if let EnvValue::Text(value) = value { - no_nul(value, "environment value contains an interior NUL")?; + no_nul(value, InputField::EnvironmentValue)?; } } EnvOp::Remove(key) => validate_env_key(key)?, } } if let Some(cwd) = &command.cwd { - no_nul( - cwd.as_os_str(), - "current directory contains an interior NUL", - )?; + no_nul(cwd.as_os_str(), InputField::CurrentDirectory)?; } Ok(()) } -fn validate_env_key(key: &OsStr) -> io::Result<()> { +fn validate_env_key(key: &OsStr) -> Result<()> { if key.is_empty() { - return Err(invalid("environment variable name must not be empty")); + return Err(Error::Validation(ValidationError::EmptyEnvironmentName)); } - no_nul(key, "environment variable name contains an interior NUL")?; + no_nul(key, InputField::EnvironmentName)?; if key.as_encoded_bytes().contains(&b'=') { - return Err(invalid("environment variable name must not contain `=`")); + return Err(Error::Validation(ValidationError::InvalidEnvironmentName)); } Ok(()) } -fn no_nul(value: &OsStr, message: &'static str) -> io::Result<()> { +fn no_nul(value: &OsStr, field: InputField) -> Result<()> { if value.encode_wide().any(|unit| unit == 0) { - Err(invalid(message)) + Err(Error::Validation(ValidationError::InteriorNul { field })) } else { Ok(()) } } -fn validate_creation_flags(flags: CreationFlags, pseudoconsole: bool) -> io::Result<()> { - let detached = flags.contains(CreationFlags::DETACHED_PROCESS); - let new_console = flags.contains(CreationFlags::NEW_CONSOLE); - let no_window = flags.contains(CreationFlags::NO_WINDOW); - if detached && new_console { - return Err(invalid( - "DETACHED_PROCESS and NEW_CONSOLE are mutually exclusive", - )); - } - if no_window && (detached || new_console) { - return Err(invalid( - "NO_WINDOW cannot be combined with DETACHED_PROCESS or NEW_CONSOLE", - )); - } - if pseudoconsole && (detached || new_console || no_window) { - return Err(invalid( - "console creation flags conflict with a pseudoconsole", - )); - } - Ok(()) -} - -fn invalid(message: &'static str) -> io::Error { - io::Error::new(io::ErrorKind::InvalidInput, message) -} - #[cfg(test)] -#[allow(unsafe_code)] +#[cfg_attr(coverage_nightly, coverage(off))] mod tests { - use std::ffi::OsString; + use std::ffi::{OsStr, OsString}; use std::os::windows::ffi::OsStringExt; use super::*; - use crate::handles::{AsPseudoConsole, ParentProcess}; - - struct InvalidPseudoConsole; - - // SAFETY: every test rejects the request during pure planning, before the - // sentinel value can reach the system layer. - unsafe impl AsPseudoConsole for InvalidPseudoConsole { - fn raw_pseudoconsole(&self) -> isize { - 1 - } - } - - #[test] - fn rejects_batch_and_empty_programs() { - let empty = Command::new(""); - assert_eq!( - SpawnPlan::new_running(&empty, SpawnOptions::new(), IoMode::Spawn,) - .unwrap_err() - .kind(), - io::ErrorKind::InvalidInput - ); - - for script in ["thing.cmd", "THING.BAT"] { - let command = Command::new(script); - assert!(SpawnPlan::new_running(&command, SpawnOptions::new(), IoMode::Spawn,).is_err()); - } + use crate::handles::{borrowed_pseudoconsole_for_test, ParentProcess, Stdio}; + + fn assert_validation( + command: &Command, + options: SpawnOptions<'_>, + mode: IoMode, + expected: ValidationError, + ) { + let result = ValidatedPlan::::running(command, options, mode); + let Err(Error::Validation(actual)) = result else { + panic!("validation error expected"); + }; + assert_eq!(actual, expected); } - #[test] - fn rejects_conflicting_console_flags() { - let command = Command::new("cmd.exe"); - let options = SpawnOptions::new() - .creation_flags(CreationFlags::DETACHED_PROCESS | CreationFlags::NEW_CONSOLE); - assert!(SpawnPlan::new_running(&command, options, IoMode::Spawn).is_err()); - - for flags in [ - CreationFlags::NO_WINDOW | CreationFlags::DETACHED_PROCESS, - CreationFlags::NO_WINDOW | CreationFlags::NEW_CONSOLE, - ] { - let options = SpawnOptions::new().creation_flags(flags); - assert!(SpawnPlan::new_running(&command, options, IoMode::Spawn).is_err()); - } + fn nul() -> OsString { + OsString::from_wide(&[u16::from(b'x'), 0]) } #[test] - fn rejects_every_malformed_text_component() { - let nul = OsString::from_wide(&[u16::from(b'x'), 0, u16::from(b'y')]); - let mut cases = vec![Command::new(nul.clone()), Command::new("bad\"program.exe")]; - cases.push(Command::new(r"C:\")); - - let mut text_arg = Command::new("cmd.exe"); - text_arg.arg(&nul); - cases.push(text_arg); - let mut raw_arg = Command::new("cmd.exe"); - raw_arg.raw_arg(&nul); - cases.push(raw_arg); - let mut empty_key = Command::new("cmd.exe"); - empty_key.env("", "value"); - cases.push(empty_key); - let mut equals_key = Command::new("cmd.exe"); - equals_key.env("A=B", "value"); - cases.push(equals_key); - let mut nul_key = Command::new("cmd.exe"); - nul_key.env(&nul, "value"); - cases.push(nul_key); - let mut nul_value = Command::new("cmd.exe"); - nul_value.env("KEY", &nul); - cases.push(nul_value); - let mut removed_key = Command::new("cmd.exe"); - removed_key.env_remove("A=B"); - cases.push(removed_key); - let mut cwd = Command::new("cmd.exe"); - cwd.current_dir(&nul); - cases.push(cwd); + fn command_validation_rejects_every_invalid_text_shape() { + assert_validation( + &Command::new(""), + SpawnOptions::new(), + IoMode::Spawn, + ValidationError::EmptyProgram, + ); + assert_validation( + &Command::new("bad\"name.exe"), + SpawnOptions::new(), + IoMode::Spawn, + ValidationError::QuotedProgram, + ); + assert_validation( + &Command::new("C:\\"), + SpawnOptions::new(), + IoMode::Spawn, + ValidationError::MissingProgramFileName, + ); + assert_validation( + &Command::new("script.cmd"), + SpawnOptions::new(), + IoMode::Spawn, + ValidationError::BatchFile, + ); - for command in cases { - assert_eq!( - SpawnPlan::new_running(&command, SpawnOptions::new(), IoMode::Spawn,) - .unwrap_err() - .kind(), - io::ErrorKind::InvalidInput - ); - } + let mut argument = Command::new("program.exe"); + argument.arg(nul()); + assert_validation( + &argument, + SpawnOptions::new(), + IoMode::Spawn, + ValidationError::InteriorNul { + field: InputField::Argument, + }, + ); + let mut raw = Command::new("program.exe"); + raw.raw_arg(nul()); + assert_validation( + &raw, + SpawnOptions::new(), + IoMode::Spawn, + ValidationError::InteriorNul { + field: InputField::Argument, + }, + ); - let valid_command = Command::new("cmd.exe"); - for flags in [ - CreationFlags::DETACHED_PROCESS, - CreationFlags::NEW_CONSOLE, - CreationFlags::NO_WINDOW, - ] { - let options = SpawnOptions::new().creation_flags(flags); - assert!(SpawnPlan::new_running(&valid_command, options, IoMode::Spawn).is_ok()); - } + let mut empty_name = Command::new("program.exe"); + empty_name.env("", "value"); + assert_validation( + &empty_name, + SpawnOptions::new(), + IoMode::Spawn, + ValidationError::EmptyEnvironmentName, + ); + let mut equals_name = Command::new("program.exe"); + equals_name.env("a=b", "value"); + assert_validation( + &equals_name, + SpawnOptions::new(), + IoMode::Spawn, + ValidationError::InvalidEnvironmentName, + ); + let mut nul_name = Command::new("program.exe"); + nul_name.env(nul(), "value"); + assert_validation( + &nul_name, + SpawnOptions::new(), + IoMode::Spawn, + ValidationError::InteriorNul { + field: InputField::EnvironmentName, + }, + ); + let mut nul_value = Command::new("program.exe"); + nul_value.env("key", nul()); + assert_validation( + &nul_value, + SpawnOptions::new(), + IoMode::Spawn, + ValidationError::InteriorNul { + field: InputField::EnvironmentValue, + }, + ); + let mut removed = Command::new("program.exe"); + removed.env_remove(OsStr::new("")); + assert_validation( + &removed, + SpawnOptions::new(), + IoMode::Spawn, + ValidationError::EmptyEnvironmentName, + ); + let mut directory = Command::new("program.exe"); + directory.current_dir(nul()); + assert_validation( + &directory, + SpawnOptions::new(), + IoMode::Spawn, + ValidationError::InteriorNul { + field: InputField::CurrentDirectory, + }, + ); } #[test] - fn pseudoconsole_and_parent_conflicts_are_rejected() { - let pseudoconsole = InvalidPseudoConsole; - let mut explicit = Command::new("cmd.exe"); - explicit.stdin(Stdio::null()); - assert!(SpawnPlan::new_running( + fn capability_validation_rejects_conflicting_states() -> Result<()> { + let owner = (); + let terminal = TerminalMode::PseudoConsole(borrowed_pseudoconsole_for_test(&owner)); + let mut explicit = Command::new("program.exe"); + explicit.stdout(Stdio::null()); + assert_validation( &explicit, - SpawnOptions::new().pseudoconsole(&pseudoconsole), + SpawnOptions::new().terminal(terminal), IoMode::Spawn, - ) - .is_err()); - - let plain = Command::new("cmd.exe"); - assert!(SpawnPlan::new_running( - &plain, - SpawnOptions::new().pseudoconsole(&pseudoconsole), + ValidationError::PseudoConsoleWithStdio, + ); + assert_validation( + &Command::new("program.exe"), + SpawnOptions::new().terminal(terminal), IoMode::Output, - ) - .is_err()); - assert!(SpawnPlan::new_running( - &plain, - SpawnOptions::new() - .pseudoconsole(&pseudoconsole) - .creation_flags(CreationFlags::NEW_CONSOLE), - IoMode::Spawn, - ) - .is_err()); + ValidationError::PseudoConsoleWithOutputCapture, + ); - let parent = ParentProcess::open(std::process::id()).unwrap(); - assert!(SpawnPlan::new_running( - &plain, + let parent = ParentProcess::open(std::process::id())?; + assert_validation( + &Command::new("program.exe"), SpawnOptions::new().parent_process(&parent), IoMode::Spawn, - ) - .is_err()); - - for missing in 0..3 { - let mut command = Command::new("cmd.exe"); - if missing != 0 { - command.stdin(Stdio::null()); - } - if missing != 1 { - command.stdout(Stdio::null()); - } - if missing != 2 { - command.stderr(Stdio::null()); - } - assert!(SpawnPlan::new_running( - &command, - SpawnOptions::new().parent_process(&parent), - IoMode::Spawn, - ) - .is_err()); - } - } - - #[test] - fn successful_plans_choose_the_expected_stdio_modes() { - let command = Command::new("cmd.exe"); - let output = SpawnPlan::new_running(&command, SpawnOptions::new(), IoMode::Output).unwrap(); - let StandardIo::Ordinary(output_stdio) = output.stdio else { - panic!("output capture must use ordinary standard I/O"); - }; - assert!(matches!(output_stdio.stdin, StdioSpec::Null)); - assert!(matches!(output_stdio.stdout, StdioSpec::Piped)); - assert!(matches!(output_stdio.stderr, StdioSpec::Piped)); - - let pseudoconsole = InvalidPseudoConsole; - let pcon = SpawnPlan::new_suspended( - &command, - SpawnOptions::new().pseudoconsole(&pseudoconsole), - IoMode::Spawn, - ) - .unwrap(); - assert!(matches!(pcon.stdio, StandardIo::PseudoConsole)); + ValidationError::AlternateParentNeedsStdio, + ); + Ok(()) } } diff --git a/src/resource.rs b/src/resource.rs new file mode 100644 index 0000000..4f36d22 --- /dev/null +++ b/src/resource.rs @@ -0,0 +1,109 @@ +use std::fmt; +use std::marker::PhantomData; +use std::os::windows::io::{AsHandle, BorrowedHandle, OwnedHandle as SystemOwnedHandle}; + +pub(crate) enum ProcessKind {} +pub(crate) enum ThreadKind {} +pub(crate) enum JobKind {} +pub(crate) enum PipeKind {} +pub(crate) enum IoKind {} +pub(crate) enum RemoteKind {} + +#[derive(Clone, Copy, Debug)] +pub(crate) enum CurrentTable {} +#[derive(Clone, Copy, Debug)] +pub(crate) struct AlternateTable<'parent>(PhantomData<&'parent ()>); +#[derive(Clone, Copy, Debug)] +pub(crate) struct SelectedTable<'parent>(PhantomData<(CurrentTable, AlternateTable<'parent>)>); + +#[repr(transparent)] +pub(crate) struct ChildHandleValue
{ + raw: isize, + table: PhantomData
, +} + +impl
ChildHandleValue
{ + pub(crate) const fn from_raw(raw: isize) -> Self { + Self { + raw, + table: PhantomData, + } + } + + pub(crate) const fn as_raw(self) -> isize { + self.raw + } +} + +impl
Clone for ChildHandleValue
{ + fn clone(&self) -> Self { + *self + } +} + +impl
Copy for ChildHandleValue
{} + +impl
PartialEq for ChildHandleValue
{ + fn eq(&self, other: &Self) -> bool { + self.raw == other.raw + } +} + +impl
Eq for ChildHandleValue
{} + +impl
fmt::Display for ChildHandleValue
{ + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.raw.fmt(formatter) + } +} + +impl
fmt::Debug for ChildHandleValue
{ + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("ChildHandleValue") + } +} + +pub(crate) struct OwnedHandle { + inner: SystemOwnedHandle, + kind: PhantomData, + table: PhantomData
, +} + +impl OwnedHandle { + pub(crate) fn from_system(inner: SystemOwnedHandle) -> Self { + Self { + inner, + kind: PhantomData, + table: PhantomData, + } + } +} + +impl AsHandle for OwnedHandle { + fn as_handle(&self) -> BorrowedHandle<'_> { + self.inner.as_handle() + } +} + +impl fmt::Debug for OwnedHandle { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("OwnedHandle") + .field(&self.inner) + .finish() + } +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::*; + + #[test] + fn child_values_format_without_exposing_debug_numbers() { + let value = ChildHandleValue::::from_raw(42); + assert_eq!(value.to_string(), "42"); + assert_eq!(format!("{value:?}"), "ChildHandleValue"); + assert_eq!(value.clone(), value); + } +} diff --git a/src/sys.rs b/src/sys.rs index d71c303..2d22817 100644 --- a/src/sys.rs +++ b/src/sys.rs @@ -1,8 +1,7 @@ -//! The only Win32 FFI boundary in the crate. - use std::cmp::Ordering; use std::ffi::{c_void, OsString}; use std::io; +use std::marker::PhantomData; use std::mem::{size_of, size_of_val}; use std::os::windows::ffi::OsStringExt; use std::os::windows::io::{AsRawHandle, BorrowedHandle, FromRawHandle, OwnedHandle, RawHandle}; @@ -33,6 +32,8 @@ use windows_sys::Win32::System::JobObjects::{ }; use windows_sys::Win32::System::Pipes::CreatePipe; use windows_sys::Win32::System::SystemInformation::{GetSystemDirectoryW, GetWindowsDirectoryW}; +#[cfg(test)] +use windows_sys::Win32::System::Threading::GetProcessHandleCount; use windows_sys::Win32::System::Threading::{ CreateProcessW, DeleteProcThreadAttributeList, GetCurrentProcess, GetExitCodeProcess, GetProcessId, InitializeProcThreadAttributeList, OpenProcess, ResumeThread, TerminateProcess, @@ -44,7 +45,12 @@ use windows_sys::Win32::System::Threading::{ STARTF_USESTDHANDLES, STARTUPINFOEXW, }; -pub(crate) const INVALID_RAW_HANDLE: isize = -1; +use crate::resource::ChildHandleValue; + +const MAXIMUM_WINDOWS_PATH_UNITS: u32 = 32_768; +const _: () = assert!(size_of::() <= u32::MAX as usize); +const _: () = + assert!(size_of::() <= u32::MAX as usize); struct EnvironmentBlock(*mut u16); @@ -81,9 +87,41 @@ pub(crate) enum NullAccess { Write, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum Inheritability { + Private, + Inheritable, +} + +impl Inheritability { + const fn as_win32(self) -> i32 { + match self { + Self::Private => 0, + Self::Inheritable => 1, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum PipeDirection { + ParentReads, + ParentWrites, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum InitialState { + Suspended, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum SystemDirectory { + System, + Windows, +} + pub(crate) fn duplicate_local( source: BorrowedHandle<'_>, - inheritable: bool, + inheritability: Inheritability, ) -> io::Result { // SAFETY: `GetCurrentProcess` takes no arguments, cannot fail, and returns // the current-process pseudo-handle. The value is a constant that stays @@ -93,7 +131,7 @@ pub(crate) fn duplicate_local( current, raw(source), current, - inheritable, + inheritability, DUPLICATE_SAME_ACCESS, ) } @@ -102,7 +140,7 @@ fn duplicate_between( source_process: HANDLE, source: HANDLE, target_process: HANDLE, - inheritable: bool, + inheritability: Inheritability, options: u32, ) -> io::Result { let mut duplicate = ptr::null_mut(); @@ -115,7 +153,7 @@ fn duplicate_between( target_process, &mut duplicate, 0, - i32::from(inheritable), + inheritability.as_win32(), options, ) } == 0 @@ -125,53 +163,92 @@ fn duplicate_between( owned(duplicate) } +#[derive(Debug)] +enum RemoteState<'a> { + Open { + process: BorrowedHandle<'a>, + value: HANDLE, + }, + Reclaimed, +} + #[derive(Debug)] pub(crate) struct RemoteHandle<'a> { - process: BorrowedHandle<'a>, - value: HANDLE, + state: RemoteState<'a>, + marker: PhantomData<( + crate::resource::RemoteKind, + crate::resource::AlternateTable<'a>, + )>, } impl RemoteHandle<'_> { - pub(crate) fn value(&self) -> isize { - self.value as isize + pub(crate) fn value
(&self) -> ChildHandleValue
{ + ChildHandleValue::from_raw(match self.state { + RemoteState::Open { value, .. } => value as isize, + RemoteState::Reclaimed => -1, + }) + } + + pub(crate) fn reclaim(mut self) -> io::Result<()> { + let state = std::mem::replace(&mut self.state, RemoteState::Reclaimed); + match state { + RemoteState::Open { process, value } => { + if let Err(error) = close_remote(process, value) { + self.state = RemoteState::Open { process, value }; + Err(error) + } else { + Ok(()) + } + } + RemoteState::Reclaimed => Ok(()), + } } } impl Drop for RemoteHandle<'_> { fn drop(&mut self) { - // SAFETY: `GetCurrentProcess` takes no arguments, cannot fail, and - // returns the current-process pseudo-handle. The value is a constant - // that stays valid for the lifetime of the process and is never closed. - let current = unsafe { GetCurrentProcess() }; - // `duplicate_between` turns the temporary local copy into an - // `OwnedHandle`; discarding the result closes it immediately. The - // close-source option atomically removes the remote value. - let _ = duplicate_between( - raw(self.process), - self.value, - current, - false, - DUPLICATE_SAME_ACCESS | DUPLICATE_CLOSE_SOURCE, - ); + let state = std::mem::replace(&mut self.state, RemoteState::Reclaimed); + let RemoteState::Open { process, value } = state else { + return; + }; + let _ = close_remote(process, value); } } +fn close_remote(process: BorrowedHandle<'_>, value: HANDLE) -> io::Result<()> { + // SAFETY: `GetCurrentProcess` takes no arguments, cannot fail, and + // returns the current-process pseudo-handle. The value is a constant + // that stays valid for the lifetime of the process and is never closed. + let current = unsafe { GetCurrentProcess() }; + duplicate_between( + raw(process), + value, + current, + Inheritability::Private, + DUPLICATE_SAME_ACCESS | DUPLICATE_CLOSE_SOURCE, + ) + .map(drop) +} + pub(crate) fn duplicate_remote<'a>( source: BorrowedHandle<'_>, target_process: BorrowedHandle<'a>, - inheritable: bool, + inheritability: Inheritability, ) -> io::Result> { let mut value = ptr::null_mut(); + // SAFETY: `GetCurrentProcess` has no preconditions and returns a stable + // pseudo-handle which is not closed by this module. + let current = unsafe { GetCurrentProcess() }; // SAFETY: both process handles and `source` remain valid. The returned // numeric handle belongs to `target_process` and is owned by RemoteHandle. if unsafe { DuplicateHandle( - GetCurrentProcess(), + current, raw(source), raw(target_process), &mut value, 0, - i32::from(inheritable), + inheritability.as_win32(), DUPLICATE_SAME_ACCESS, ) } == 0 @@ -179,8 +256,11 @@ pub(crate) fn duplicate_remote<'a>( return Err(io::Error::last_os_error()); } Ok(RemoteHandle { - process: target_process, - value, + state: RemoteState::Open { + process: target_process, + value, + }, + marker: PhantomData, }) } @@ -198,7 +278,7 @@ pub(crate) fn standard_handle(stream: StandardStream) -> io::Result io::Result { @@ -234,7 +314,7 @@ pub(crate) struct Pipe { pub(crate) child: OwnedHandle, } -pub(crate) fn create_pipe(parent_reads: bool) -> io::Result { +pub(crate) fn create_pipe(direction: PipeDirection) -> io::Result { let mut read = ptr::null_mut(); let mut write = ptr::null_mut(); // SAFETY: both output pointers are valid. Null security attributes make @@ -243,15 +323,13 @@ pub(crate) fn create_pipe(parent_reads: bool) -> io::Result { if unsafe { CreatePipe(&mut read, &mut write, ptr::null::(), 0) } == 0 { return Err(io::Error::last_os_error()); } - // SAFETY: successful CreatePipe guarantees two valid, distinct handles; - // ownership of both is transferred together before either can be lost. - let (read, write) = unsafe { - ( - OwnedHandle::from_raw_handle(read as RawHandle), - OwnedHandle::from_raw_handle(write as RawHandle), - ) - }; - if parent_reads { + // SAFETY: successful CreatePipe guarantees a valid read handle and + // transfers its sole local ownership here. + let read = unsafe { OwnedHandle::from_raw_handle(read as RawHandle) }; + // SAFETY: successful CreatePipe guarantees a distinct valid write handle + // and transfers its sole local ownership here. + let write = unsafe { OwnedHandle::from_raw_handle(write as RawHandle) }; + if direction == PipeDirection::ParentReads { Ok(Pipe { parent: read, child: write, @@ -288,12 +366,19 @@ pub(crate) fn validate_job_handle(handle: BorrowedHandle<'_>) -> io::Result<()> query_job_limits(handle).map(drop) } -pub(crate) fn set_job_kill_on_close(handle: BorrowedHandle<'_>, enable: bool) -> io::Result<()> { +pub(crate) fn set_job_close_policy( + handle: BorrowedHandle<'_>, + policy: crate::JobClosePolicy, +) -> io::Result<()> { let mut limits = query_job_limits(handle)?; - if enable { - limits.BasicLimitInformation.LimitFlags |= JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; - } else { - limits.BasicLimitInformation.LimitFlags &= !JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + let limit_size = checked_u32_size::()?; + match policy { + crate::JobClosePolicy::PreserveProcesses => { + limits.BasicLimitInformation.LimitFlags &= !JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + } + crate::JobClosePolicy::TerminateProcesses => { + limits.BasicLimitInformation.LimitFlags |= JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + } } // SAFETY: `limits` is the exact structure required by the information // class and remains readable for the call. @@ -302,8 +387,7 @@ pub(crate) fn set_job_kill_on_close(handle: BorrowedHandle<'_>, enable: bool) -> raw(handle), JobObjectExtendedLimitInformation, ptr::addr_of!(limits).cast(), - u32::try_from(size_of::()) - .expect("Job limit structure size fits u32"), + limit_size, ) } == 0 { @@ -317,6 +401,7 @@ fn query_job_limits( handle: BorrowedHandle<'_>, ) -> io::Result { let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + let limit_size = checked_u32_size::()?; // SAFETY: `limits` is correctly sized writable storage for the selected // information class; the optional returned-size pointer is null. if unsafe { @@ -324,8 +409,7 @@ fn query_job_limits( raw(handle), JobObjectExtendedLimitInformation, ptr::addr_of_mut!(limits).cast(), - u32::try_from(size_of::()) - .expect("Job limit structure size fits u32"), + limit_size, ptr::null_mut(), ) } == 0 @@ -357,13 +441,9 @@ impl AttributeList { let probe = unsafe { InitializeProcThreadAttributeList(ptr::null_mut(), count, 0, &mut bytes) }; let probe_error = io::Error::last_os_error(); - if probe != 0 - || probe_error.raw_os_error() - != Some( - i32::try_from(ERROR_INSUFFICIENT_BUFFER).expect("Win32 error code fits i32"), - ) - || bytes == 0 - { + let insufficient_buffer = i32::try_from(ERROR_INSUFFICIENT_BUFFER) + .map_err(|_| io::Error::other("Win32 error code does not fit i32"))?; + if probe != 0 || probe_error.raw_os_error() != Some(insufficient_buffer) || bytes == 0 { return Err(if probe != 0 { io::Error::other("attribute-list size probe unexpectedly succeeded") } else { @@ -385,7 +465,10 @@ impl AttributeList { Ok(Self { storage }) } - pub(crate) fn set_handle_list(&mut self, handles: &[isize]) -> io::Result<()> { + pub(crate) fn set_handle_list
( + &mut self, + handles: &[ChildHandleValue
], + ) -> io::Result<()> { self.update( PROC_THREAD_ATTRIBUTE_HANDLE_LIST as usize, handles.as_ptr().cast(), @@ -393,10 +476,10 @@ impl AttributeList { ) } - pub(crate) fn set_parent(&mut self, parent: &isize) -> io::Result<()> { + pub(crate) fn set_parent
(&mut self, parent: &ChildHandleValue
) -> io::Result<()> { self.update( PROC_THREAD_ATTRIBUTE_PARENT_PROCESS as usize, - (parent as *const isize).cast(), + (parent as *const ChildHandleValue
).cast(), size_of::(), ) } @@ -409,7 +492,7 @@ impl AttributeList { ) } - pub(crate) fn set_jobs(&mut self, jobs: &[isize]) -> io::Result<()> { + pub(crate) fn set_jobs
(&mut self, jobs: &[ChildHandleValue
]) -> io::Result<()> { self.update( PROC_THREAD_ATTRIBUTE_JOB_LIST as usize, jobs.as_ptr().cast(), @@ -417,12 +500,13 @@ impl AttributeList { ) } - pub(crate) fn set_pseudoconsole(&mut self, pseudoconsole: isize) -> io::Result<()> { - // PSEUDOCONSOLE is the sole attribute whose lpValue is the HPCON value - // itself, matching Microsoft's ConPTY sample, not `&HPCON`. + pub(crate) fn set_pseudoconsole( + &mut self, + pseudoconsole: crate::BorrowedPseudoConsole<'_>, + ) -> io::Result<()> { self.update( PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE as usize, - pseudoconsole as *const c_void, + pseudoconsole.value.get() as *const c_void, size_of::(), ) } @@ -464,27 +548,27 @@ impl Drop for AttributeList { } #[derive(Clone, Copy)] -pub(crate) struct StandardHandles { - pub(crate) stdin: isize, - pub(crate) stdout: isize, - pub(crate) stderr: isize, +pub(crate) struct StandardHandles
{ + pub(crate) stdin: ChildHandleValue
, + pub(crate) stdout: ChildHandleValue
, + pub(crate) stderr: ChildHandleValue
, } #[derive(Clone, Copy)] -pub(crate) enum StartupStdio { - Ordinary(StandardHandles), +pub(crate) enum StartupStdio
{ + Ordinary(StandardHandles
), PseudoConsole, } -pub(crate) struct ProcessRequest<'a> { +pub(crate) struct ProcessRequest<'a, Table> { pub(crate) application: &'a [u16], pub(crate) command_line: &'a mut [u16], pub(crate) environment: Option<&'a [u16]>, pub(crate) current_dir: Option<&'a [u16]>, - pub(crate) stdio: StartupStdio, - pub(crate) inherit_handles: bool, + pub(crate) stdio: StartupStdio
, + pub(crate) inheritability: Inheritability, pub(crate) creation_flags: u32, - pub(crate) suspended: bool, + pub(crate) initial_state: InitialState, pub(crate) attributes: Option<&'a AttributeList>, } @@ -494,22 +578,23 @@ pub(crate) struct CreatedProcess { pub(crate) pid: u32, } -pub(crate) fn create_process(request: &mut ProcessRequest<'_>) -> io::Result { +pub(crate) fn create_process
( + request: &mut ProcessRequest<'_, Table>, +) -> io::Result { let mut startup = STARTUPINFOEXW::default(); startup.StartupInfo.cb = if request.attributes.is_some() { - u32::try_from(size_of::()).expect("startup structure size fits u32") + checked_u32_size::()? } else { - u32::try_from(size_of::()) - .expect("startup structure size fits u32") + checked_u32_size::()? }; - set_standard_handles(&mut startup, request.stdio); + set_standard_handles(&mut startup, &request.stdio); startup.lpAttributeList = request .attributes .map_or(ptr::null_mut(), AttributeList::pointer); let mut flags = request.creation_flags | CREATE_UNICODE_ENVIRONMENT; - if request.suspended { - flags |= CREATE_SUSPENDED; + match request.initial_state { + InitialState::Suspended => flags |= CREATE_SUSPENDED, } if request.attributes.is_some() { flags |= EXTENDED_STARTUPINFO_PRESENT; @@ -529,7 +614,7 @@ pub(crate) fn create_process(request: &mut ProcessRequest<'_>) -> io::Result) -> io::Result) -> io::Result(startup: &mut STARTUPINFOEXW, stdio: &StartupStdio
) { startup.StartupInfo.dwFlags |= STARTF_USESTDHANDLES; if let StartupStdio::Ordinary(handles) = stdio { - startup.StartupInfo.hStdInput = handles.stdin as HANDLE; - startup.StartupInfo.hStdOutput = handles.stdout as HANDLE; - startup.StartupInfo.hStdError = handles.stderr as HANDLE; + startup.StartupInfo.hStdInput = handles.stdin.as_raw() as HANDLE; + startup.StartupInfo.hStdOutput = handles.stdout.as_raw() as HANDLE; + startup.StartupInfo.hStdError = handles.stderr.as_raw() as HANDLE; } } +pub(crate) fn child_handle_value
(handle: BorrowedHandle<'_>) -> ChildHandleValue
{ + ChildHandleValue::from_raw(handle.as_raw_handle() as isize) +} + +pub(crate) const fn invalid_child_handle
() -> ChildHandleValue
{ + ChildHandleValue::from_raw(-1) +} + pub(crate) fn wait_process(process: BorrowedHandle<'_>) -> io::Result<()> { // SAFETY: the process handle remains valid while waiting. match unsafe { WaitForSingleObject(raw(process), INFINITE) } { @@ -583,6 +673,7 @@ pub(crate) fn try_wait_process(process: BorrowedHandle<'_>) -> io::Result } #[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] pub(crate) fn wait_process_for_test( process: BorrowedHandle<'_>, timeout_millis: u32, @@ -596,8 +687,8 @@ pub(crate) fn wait_process_for_test( } #[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] pub(crate) fn cleanup_process_for_test(process: BorrowedHandle<'_>) { - // 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. @@ -653,8 +744,8 @@ pub(crate) fn read_handle(handle: BorrowedHandle<'_>, buffer: &mut [u8]) -> io:: if matches!( error.raw_os_error(), Some(code) - if code == i32::try_from(ERROR_BROKEN_PIPE).expect("Win32 error code fits i32") - || code == i32::try_from(ERROR_HANDLE_EOF).expect("Win32 error code fits i32") + if win32_code_is(code, ERROR_BROKEN_PIPE) + || win32_code_is(code, ERROR_HANDLE_EOF) ) { Ok(0) } else { @@ -706,23 +797,38 @@ pub(crate) fn environment_strings() -> io::Result> { break; } let mut length = 0_usize; - // SAFETY: the OS-provided current entry is NUL-terminated. - while unsafe { *cursor.add(length) } != 0 { + loop { + // SAFETY: the OS-provided current entry is NUL-terminated and + // `length` advances only within that entry. + let unit = unsafe { cursor.add(length) }; + // SAFETY: `unit` addresses the current NUL-terminated entry. + if unsafe { *unit } == 0 { + break; + } length = length .checked_add(1) .ok_or_else(|| io::Error::other("environment entry is too large"))?; } // SAFETY: the just-computed range lies within the current entry. let entry = unsafe { std::slice::from_raw_parts(cursor, length) }; - if let Some(separator) = entry[1..] + let searchable = entry + .get(1..) + .ok_or_else(|| io::Error::other("environment entry is empty"))?; + if let Some(separator) = searchable .iter() .position(|unit| *unit == u16::from(b'=')) .map(|index| index + 1) { - entries.push(( - OsString::from_wide(&entry[..separator]), - OsString::from_wide(&entry[separator + 1..]), - )); + let name = entry + .get(..separator) + .ok_or_else(|| io::Error::other("environment name range is invalid"))?; + let value_start = separator + .checked_add(1) + .ok_or_else(|| io::Error::other("environment value range overflowed"))?; + let value = entry + .get(value_start..) + .ok_or_else(|| io::Error::other("environment value range is invalid"))?; + entries.push((OsString::from_wide(name), OsString::from_wide(value))); } let advance = length .checked_add(1) @@ -752,30 +858,23 @@ pub(crate) fn program_exists(path: &[u16]) -> bool { } pub(crate) fn system_directory() -> io::Result { - system_path(false) + system_path(SystemDirectory::System) } pub(crate) fn windows_directory() -> io::Result { - system_path(true) -} - -fn system_path(windows: bool) -> io::Result { - // Windows paths cannot exceed 32,767 UTF-16 code units. A single maximum - // sized allocation avoids a retry loop whose termination would otherwise - // depend on a length reported by the operating system. - let mut buffer = vec![0_u16; 32_768]; - // SAFETY: buffer is writable for its reported length. - let length = unsafe { - if windows { - GetWindowsDirectoryW( - buffer.as_mut_ptr(), - u32::try_from(buffer.len()).expect("maximum Windows path fits u32"), - ) - } else { - GetSystemDirectoryW( - buffer.as_mut_ptr(), - u32::try_from(buffer.len()).expect("maximum Windows path fits u32"), - ) + system_path(SystemDirectory::Windows) +} + +fn system_path(directory: SystemDirectory) -> io::Result { + let mut buffer = vec![0_u16; MAXIMUM_WINDOWS_PATH_UNITS as usize]; + let length = match directory { + SystemDirectory::Windows => { + // SAFETY: buffer is writable for its reported length. + unsafe { GetWindowsDirectoryW(buffer.as_mut_ptr(), MAXIMUM_WINDOWS_PATH_UNITS) } + } + SystemDirectory::System => { + // SAFETY: buffer is writable for its reported length. + unsafe { GetSystemDirectoryW(buffer.as_mut_ptr(), MAXIMUM_WINDOWS_PATH_UNITS) } } } as usize; if length == 0 { @@ -794,6 +893,14 @@ fn raw(handle: BorrowedHandle<'_>) -> HANDLE { handle.as_raw_handle() as HANDLE } +fn checked_u32_size() -> io::Result { + u32::try_from(size_of::()).map_err(|_| io::Error::other("FFI structure is too large")) +} + +fn win32_code_is(actual: i32, expected: u32) -> bool { + i32::try_from(expected).ok() == Some(actual) +} + fn owned(handle: HANDLE) -> io::Result { if is_valid_handle(handle) { // SAFETY: callers pass a newly-created or newly-duplicated handle and @@ -817,6 +924,21 @@ fn bool_result(result: i32) -> io::Result<()> { } #[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +pub(crate) fn current_process_handle_count() -> io::Result { + let mut count = 0; + // SAFETY: GetCurrentProcess has no preconditions and returns a stable pseudo-handle. + let process = unsafe { GetCurrentProcess() }; + // SAFETY: the pseudo-handle remains valid and count is writable DWORD storage. + if unsafe { GetProcessHandleCount(process, &mut count) } == 0 { + Err(io::Error::last_os_error()) + } else { + Ok(count) + } +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] mod tests { use std::fs::File; use std::os::windows::ffi::OsStrExt; @@ -824,8 +946,6 @@ mod tests { use std::path::PathBuf; use super::*; - use windows_sys::Win32::System::Threading::GetProcessHandleCount; - fn process_handle_count(process: BorrowedHandle<'_>) -> io::Result { let mut count = 0; // SAFETY: `process` remains valid and count is writable DWORD storage. @@ -841,7 +961,10 @@ mod tests { // current-process pseudo-handle, a constant that stays valid for the // whole process lifetime. `BorrowedHandle` never closes what it borrows, // so a `'static` borrow of it can never dangle or double-close. - unsafe { BorrowedHandle::borrow_raw(GetCurrentProcess() as RawHandle) } + let current = unsafe { GetCurrentProcess() }; + // SAFETY: the pseudo-handle above has process lifetime and is never + // closed through the returned borrow. + unsafe { BorrowedHandle::borrow_raw(current as RawHandle) } } #[test] @@ -856,19 +979,19 @@ mod tests { assert_eq!(write_handle(writable_null.as_handle(), &[])?, 0); assert_eq!(write_handle(writable_null.as_handle(), b"discard")?, 7); - let parent_reads = create_pipe(true)?; + let parent_reads = create_pipe(PipeDirection::ParentReads)?; assert_eq!(write_handle(parent_reads.child.as_handle(), b"a")?, 1); let mut byte = [0_u8; 1]; assert_eq!(read_handle(parent_reads.parent.as_handle(), &mut byte)?, 1); assert_eq!(byte, [b'a']); - let parent_writes = create_pipe(false)?; + let parent_writes = create_pipe(PipeDirection::ParentWrites)?; assert_eq!(write_handle(parent_writes.parent.as_handle(), b"b")?, 1); assert_eq!(read_handle(parent_writes.child.as_handle(), &mut byte)?, 1); assert_eq!(byte, [b'b']); - let private = duplicate_local(writable_null.as_handle(), false)?; - let inheritable = duplicate_local(writable_null.as_handle(), true)?; + let private = duplicate_local(writable_null.as_handle(), Inheritability::Private)?; + let inheritable = duplicate_local(writable_null.as_handle(), Inheritability::Inheritable)?; drop((private, inheritable)); let mut host = std::process::Command::new("cmd.exe") @@ -878,8 +1001,12 @@ mod tests { drop(target); let before = process_handle_count(host.as_handle())?; let local_before = process_handle_count(current_process())?; - let remote = duplicate_remote(writable_null.as_handle(), host.as_handle(), true)?; - assert_ne!(remote.value(), 0); + let remote = duplicate_remote( + writable_null.as_handle(), + host.as_handle(), + Inheritability::Inheritable, + )?; + assert_ne!(remote.value::().as_raw(), 0); assert!(process_handle_count(host.as_handle())? > before); drop(remote); assert_eq!(process_handle_count(host.as_handle())?, before); @@ -894,7 +1021,7 @@ mod tests { let drops_before = ATTRIBUTE_LIST_DROPS.load(std::sync::atomic::Ordering::Relaxed); let job = create_job()?; validate_job_handle(job.as_handle())?; - set_job_kill_on_close(job.as_handle(), true)?; + set_job_close_policy(job.as_handle(), crate::JobClosePolicy::TerminateProcesses)?; assert_ne!( query_job_limits(job.as_handle())? .BasicLimitInformation @@ -902,7 +1029,7 @@ mod tests { & JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, 0 ); - set_job_kill_on_close(job.as_handle(), false)?; + set_job_close_policy(job.as_handle(), crate::JobClosePolicy::PreserveProcesses)?; assert_eq!( query_job_limits(job.as_handle())? .BasicLimitInformation @@ -911,10 +1038,14 @@ mod tests { 0 ); let process = open_parent_process(std::process::id())?; - let inherited = duplicate_local(job.as_handle(), true)?; - let handles = [inherited.as_raw_handle() as isize]; - let jobs = [job.as_raw_handle() as isize]; - let parent = process.as_raw_handle() as isize; + let inherited = duplicate_local(job.as_handle(), Inheritability::Inheritable)?; + let handles = [child_handle_value::( + inherited.as_handle(), + )]; + let jobs = [child_handle_value::( + job.as_handle(), + )]; + let parent = child_handle_value::(process.as_handle()); let words = [1_u64, 0_u64]; let mut attributes = AttributeList::new(4)?; attributes.set_handle_list(&handles)?; @@ -924,7 +1055,12 @@ mod tests { drop(attributes); let mut pseudoconsole = AttributeList::new(1)?; - let _ = pseudoconsole.set_pseudoconsole(1); + let owner = (); + let value = std::num::NonZeroIsize::new(1) + .ok_or_else(|| io::Error::other("nonzero test pseudoconsole expected"))?; + // SAFETY: the sentinel is used only to exercise attribute encoding and is never submitted to CreateProcessW. + let borrowed = unsafe { crate::BorrowedPseudoConsole::from_raw(value, &owner) }; + let _ = pseudoconsole.set_pseudoconsole(borrowed); drop(pseudoconsole); assert!(ATTRIBUTE_LIST_DROPS.load(std::sync::atomic::Ordering::Relaxed) > drops_before); @@ -932,14 +1068,20 @@ mod tests { let output = standard_handle(StandardStream::Output)?; let error = standard_handle(StandardStream::Error)?; assert!(input.is_some() || output.is_some() || error.is_some()); - assert_eq!(INVALID_RAW_HANDLE, -1); + assert_eq!( + invalid_child_handle::().as_raw(), + -1 + ); Ok(()) } #[test] fn startup_info_distinguishes_pseudoconsole_and_ordinary_stdio() { let mut conpty = STARTUPINFOEXW::default(); - set_standard_handles(&mut conpty, StartupStdio::PseudoConsole); + set_standard_handles( + &mut conpty, + &StartupStdio::::PseudoConsole, + ); assert_ne!(conpty.StartupInfo.dwFlags & STARTF_USESTDHANDLES, 0); assert!(conpty.StartupInfo.hStdInput.is_null()); assert!(conpty.StartupInfo.hStdOutput.is_null()); @@ -948,10 +1090,10 @@ mod tests { let mut ordinary = STARTUPINFOEXW::default(); set_standard_handles( &mut ordinary, - StartupStdio::Ordinary(StandardHandles { - stdin: 1, - stdout: 2, - stderr: 3, + &StartupStdio::Ordinary(StandardHandles { + stdin: ChildHandleValue::::from_raw(1), + stdout: ChildHandleValue::::from_raw(2), + stderr: ChildHandleValue::::from_raw(3), }), ); assert_ne!(ordinary.StartupInfo.dwFlags & STARTF_USESTDHANDLES, 0); diff --git a/src/trace.rs b/src/trace.rs new file mode 100644 index 0000000..96d3dd3 --- /dev/null +++ b/src/trace.rs @@ -0,0 +1,64 @@ +use std::io; + +use crate::{Operation, Phase}; + +#[derive(Clone, Copy, Debug)] +pub(crate) enum ResourceKind { + Handle, + Process, + Thread, + Job, + Pipe, + RemoteHandle, +} + +#[cfg(feature = "tracing")] +#[derive(Clone, Copy, Debug)] +enum Outcome { + Success, + Failure, +} + +#[cfg(feature = "tracing")] +pub(crate) fn io( + phase: Phase, + operation: Operation, + resource: ResourceKind, + call: impl FnOnce() -> io::Result, +) -> io::Result { + let start = std::time::Instant::now(); + let result = call(); + let (outcome, win32_code) = match &result { + Ok(_) => (Outcome::Success, 0), + Err(error) => { + let code: u32 = error + .raw_os_error() + .and_then(|value| u32::try_from(value).ok()) + .unwrap_or_default(); + (Outcome::Failure, code) + } + }; + let elapsed = start.elapsed().as_micros(); + let duration_micros = u64::try_from(elapsed).unwrap_or(u64::MAX); + tracing::event!( + target: "windows_spawn", + tracing::Level::TRACE, + phase = ?phase, + operation = ?operation, + resource = ?resource, + outcome = ?outcome, + win32_code, + duration_micros + ); + result +} + +#[cfg(not(feature = "tracing"))] +pub(crate) fn io( + _phase: Phase, + _operation: Operation, + _resource: ResourceKind, + call: impl FnOnce() -> io::Result, +) -> io::Result { + call() +} diff --git a/src/transaction.rs b/src/transaction.rs index be7792b..9300e18 100644 --- a/src/transaction.rs +++ b/src/transaction.rs @@ -1,232 +1,399 @@ -//! Resource-acquiring spawn transaction and rollback. - use std::cmp::Ordering; use std::collections::btree_map::{BTreeMap, Entry}; use std::env; use std::ffi::{OsStr, OsString}; use std::io; -use std::iter; use std::marker::PhantomData; use std::os::windows::ffi::OsStrExt; -use std::os::windows::io::{AsHandle, AsRawHandle, BorrowedHandle, OwnedHandle}; +use std::os::windows::io::{AsHandle, BorrowedHandle, OwnedHandle}; use std::path::{Path, PathBuf}; +use std::pin::Pin; -use crate::child::{Child, SuspendedChild}; -use crate::command::{Arg, Command, EnvOp, EnvValue}; -use crate::handles::{Job, StdioInner}; -use crate::options::DropPolicy; +use crate::backend::{AttributeAddress, SpawnBackend, WindowsBackend}; +use crate::child::{Child, JobOwnership, ProcessOwner, SuspendedChild}; +use crate::command::{Arg, Command, EnvOp, EnvValue, EnvironmentBase}; +use crate::core_logic::{CommandLine, CommandLineError}; +use crate::error::{CleanupError, Error, Operation, Phase, Result, WindowsError}; +use crate::handles::StdioInner; +use crate::options::{ConsoleMode, JobClosePolicy, SpawnOptions, TerminalMode}; use crate::plan::{ - Running, SpawnPlan, SpawnState, StandardHandles, StandardIo, StdioSpec, Suspended, + DesiredState, Running, StandardHandles, StandardIo, StdioSpec, Suspended, ValidatedPlan, }; -use crate::sys::{self, NullAccess, StandardStream}; +use crate::resource::{ChildHandleValue, CurrentTable, SelectedTable}; +use crate::sys::{self, Inheritability, InitialState, NullAccess, PipeDirection, StandardStream}; +use crate::trace::{self, ResourceKind}; -const QUOTE: u16 = 0x22; -const BACKSLASH: u16 = 0x5c; -const SPACE: u16 = 0x20; +const MAX_ATTRIBUTE_COUNT: u32 = 5; -/// Owns the process between `CreateProcessW` success and an explicit commit. -/// Dropping before commit terminates the partially-created process. -pub(crate) struct SpawnTransaction { - created: Option, - kill_job: Option, - stdio: StandardHandles>, - state: PhantomData, -} - -impl SpawnTransaction { - #[allow(clippy::too_many_lines)] - pub(crate) fn new<'command, 'options>( - plan: &SpawnPlan<'command, 'options, M>, - ) -> io::Result { - let parent: Option> = plan.options.parent.map(AsHandle::as_handle); - let mut transfer = HandleTransfer::new(parent); - let (stdio_values, stdio) = match &plan.stdio { - StandardIo::Ordinary(specs) => { - let prepared = prepare_standard_handles(specs, &mut transfer)?; - let values = sys::StartupStdio::Ordinary(sys::StandardHandles { - stdin: prepared.stdin.child, - stdout: prepared.stdout.child, - stderr: prepared.stderr.child, - }); - let owners = StandardHandles { - stdin: prepared.stdin.parent, - stdout: prepared.stdout.parent, - stderr: prepared.stderr.parent, - }; - (values, owners) - } - StandardIo::PseudoConsole => ( - sys::StartupStdio::PseudoConsole, - StandardHandles { - stdin: None, - stdout: None, - stderr: None, - }, - ), - }; - let kill_job = if plan.options.drop_policy == DropPolicy::KillTree { - let job = Job::create()?; - job.set_kill_on_close(true)?; - Some(job) - } else { - None - }; - let mut job_values: Vec = plan - .options - .jobs - .iter() - .map(|job| job.as_handle().as_raw_handle() as isize) - .collect(); - if let Some(job) = &kill_job { - job_values.push(job.as_handle().as_raw_handle() as isize); - } +struct AttributeBacking<'a, Table> { + inherited_values: Box<[ChildHandleValue
]>, + parent_value: Option>>, + mitigation_value: Option>, + job_values: Box<[ChildHandleValue]>, + pseudoconsole: Option>, +} - let command_line = build_command_line(plan.command, &mut transfer)?; - let environment = build_environment(plan.command, &mut transfer)?; - let child_path = environment.path.as_deref(); - let application = resolve_executable(&plan.command.program, child_path)?; - let current_dir = plan - .command - .cwd - .as_ref() - .map(|path| wide_nul(path.as_os_str())) - .transpose()?; - - // Freeze every pointer-valued attribute before adding it to the list. - let inherited_values = transfer.inherited_values().to_vec().into_boxed_slice(); - let parent_value = transfer - .parent() - .map(|handle| Box::new(handle.as_raw_handle() as isize)); - let mitigation_words = plan.options.mitigation.words(); - let mitigation_value = (mitigation_words != [0, 0]).then(|| Box::new(mitigation_words)); - let job_values = job_values.into_boxed_slice(); - let pseudoconsole = plan.options.pseudoconsole_raw(); - - let attribute_count = u32::from(!inherited_values.is_empty()) - + u32::from(parent_value.is_some()) - + u32::from(mitigation_value.is_some()) - + u32::from(!job_values.is_empty()) - + u32::from(pseudoconsole.is_some()); - let mut attributes = if attribute_count == 0 { +struct ProcessAttributeList<'a, Table> { + list: Option, + _backing: Pin>>, +} + +impl<'a, Table> ProcessAttributeList<'a, Table> { + fn new(backing: AttributeBacking<'a, Table>) -> Result { + let backing = Box::pin(backing); + let values = backing.as_ref().get_ref(); + let attribute_count = u32::from(!values.inherited_values.is_empty()) + + u32::from(values.parent_value.is_some()) + + u32::from(values.mitigation_value.is_some()) + + u32::from(!values.job_values.is_empty()) + + u32::from(values.pseudoconsole.is_some()); + if attribute_count > MAX_ATTRIBUTE_COUNT { + return Err(Error::Validation(crate::ValidationError::SizeOverflow)); + } + let mut list = if attribute_count == 0 { None } else { - Some(sys::AttributeList::new(attribute_count)?) + Some(Backend::create_attributes(attribute_count).map_err(attribute_error)?) }; - if let Some(list) = &mut attributes { - if !inherited_values.is_empty() { - list.set_handle_list(&inherited_values)?; + if let Some(attributes) = &mut list { + if !values.inherited_values.is_empty() { + Backend::set_handle_list(attributes, &values.inherited_values) + .map_err(attribute_error)?; } - if let Some(value) = &parent_value { - list.set_parent(value)?; + if let Some(parent) = &values.parent_value { + Backend::set_parent(attributes, AttributeAddress::new(parent.as_ref())) + .map_err(attribute_error)?; } - if let Some(value) = &mitigation_value { - list.set_mitigation(value)?; + if let Some(mitigation) = &values.mitigation_value { + Backend::set_mitigation(attributes, mitigation).map_err(attribute_error)?; } - if !job_values.is_empty() { - list.set_jobs(&job_values)?; + if !values.job_values.is_empty() { + Backend::set_jobs(attributes, &values.job_values).map_err(attribute_error)?; } - if let Some(value) = pseudoconsole { - list.set_pseudoconsole(value)?; + if let Some(pseudoconsole) = values.pseudoconsole { + Backend::set_pseudoconsole(attributes, pseudoconsole).map_err(attribute_error)?; } } + Ok(Self { + list, + _backing: backing, + }) + } - let mut command_line = command_line; - let mut request = sys::ProcessRequest { - application: &application, - command_line: &mut command_line, - environment: environment.block.as_deref(), - current_dir: current_dir.as_deref(), - stdio: stdio_values, - inherit_handles: !inherited_values.is_empty(), - creation_flags: plan.options.creation_flags.bits(), - suspended: M::SUSPENDED, - attributes: attributes.as_ref(), - }; - let created = sys::create_process(&mut request)?; + fn as_list(&self) -> Option<&sys::AttributeList> { + self.list.as_ref() + } +} - // Attribute backing, local inheritable duplicates, and alternate-parent - // remote sources all roll back here. The child now owns inherited copies. - drop(attributes); - drop(transfer); +fn attribute_error(error: io::Error) -> Error { + Error::windows(Phase::Preparation, Operation::ConfigureAttributes, error) +} + +pub(crate) struct PreparedSpawn<'options, State, Table, Backend = WindowsBackend> { + application: Vec, + command_line: Vec, + environment: Environment, + current_dir: Option>, + stdio_values: sys::StartupStdio
, + stdio: StandardHandles>, + job: JobOwnership, + transfer: HandleTransfer<'options, Backend>, + attributes: ProcessAttributeList<'options, Table>, + creation_flags: u32, + state: PhantomData<(State, Backend)>, +} + +impl<'options, State: DesiredState> + PreparedSpawn<'options, State, SelectedTable<'options>, WindowsBackend> +{ + pub(crate) fn prepare(plan: ValidatedPlan<'_, 'options, State>) -> Result { + Self::prepare_with_backend(plan) + } +} +impl<'options, State: DesiredState, Backend: SpawnBackend> + PreparedSpawn<'options, State, SelectedTable<'options>, Backend> +{ + fn prepare_with_backend(plan: ValidatedPlan<'_, 'options, State>) -> Result { + let (command, options, stdio_plan) = plan.into_parts(); + let parent: Option> = options.parent.map(AsHandle::as_handle); + let mut transfer = HandleTransfer::::new(parent); + let (stdio_values, stdio) = prepare_standard_io::(&stdio_plan, &mut transfer)?; + let (job, job_values) = prepare_job::(&options)?; + + let command_line = build_command_line::(command, &mut transfer)?; + let environment = + build_environment::(command, &mut transfer).map_err(|error| { + Error::windows(Phase::Preparation, Operation::ReadEnvironment, error) + })?; + let child_path = environment.path.as_deref(); + let application = + resolve_executable::(&command.program, child_path).map_err(|error| { + Error::windows(Phase::Preparation, Operation::ResolveExecutable, error) + })?; + let current_dir = command + .cwd + .as_ref() + .map(|path| wide_nul(path.as_os_str())) + .transpose() + .map_err(|error| { + Error::windows(Phase::Preparation, Operation::ResolveExecutable, error) + })?; + + let attributes = prepare_attributes::(&options, &transfer, job_values)?; + let console_flags = match options.terminal { + TerminalMode::Console(mode) => mode.creation_bits(), + TerminalMode::PseudoConsole(_) => ConsoleMode::Inherit.creation_bits(), + }; Ok(Self { - created: Some(created), - kill_job, + application, + command_line, + environment, + current_dir, + stdio_values, stdio, + job, + transfer, + attributes, + creation_flags: options.creation.bits() | console_flags, state: PhantomData, }) } - fn commit_parts(mut self) -> (Child, OwnedHandle) { - let created = self - .created - .take() - .expect("an uncommitted transaction owns its process"); - let child = Child::new( - created.process, - created.pid, - self.kill_job.take(), - self.stdio.stdin.take(), - self.stdio.stdout.take(), - self.stdio.stderr.take(), + pub(crate) fn create_suspended( + mut self, + ) -> Result, Backend>> { + let mut request = sys::ProcessRequest { + application: &self.application, + command_line: &mut self.command_line, + environment: self.environment.block.as_deref(), + current_dir: self.current_dir.as_deref(), + stdio: self.stdio_values, + inheritability: if self.transfer.inherited_values().is_empty() { + Inheritability::Private + } else { + Inheritability::Inheritable + }, + creation_flags: self.creation_flags, + initial_state: InitialState::Suspended, + attributes: self.attributes.as_list(), + }; + let created = trace::io( + Phase::Creation, + Operation::CreateProcess, + ResourceKind::Process, + || Backend::create_process(&mut request), ); - (child, created.thread) + match created { + Ok(created) => Ok(CreatedSuspended { + process: ProcessOwner::new(created), + job: self.job, + stdio: self.stdio, + transfer: self.transfer, + attributes: self.attributes, + state: PhantomData, + }), + Err(source) => { + let primary = WindowsError::new(Phase::Creation, Operation::CreateProcess, source); + drop(self.attributes); + match self.transfer.reclaim() { + Ok(()) => Err(Error::Windows(primary)), + Err(mut cleanup) => { + cleanup.set_primary(primary); + Err(Error::Cleanup(cleanup)) + } + } + } + } } } -impl SpawnTransaction { - pub(crate) fn commit_child(self) -> Child { - let (child, thread) = self.commit_parts(); - drop(thread); - child +fn prepare_standard_io<'options, Backend: SpawnBackend>( + plan: &StandardIo<'_>, + transfer: &mut HandleTransfer<'options, Backend>, +) -> Result<( + sys::StartupStdio>, + StandardHandles>, +)> { + match plan { + StandardIo::Ordinary(specs) => { + let prepared = + prepare_standard_handles::(specs, transfer).map_err(|error| { + Error::windows(Phase::Preparation, Operation::DuplicateLocalHandle, error) + })?; + let values = sys::StartupStdio::Ordinary(sys::StandardHandles { + stdin: prepared.stdin.child, + stdout: prepared.stdout.child, + stderr: prepared.stderr.child, + }); + let owners = StandardHandles { + stdin: prepared.stdin.parent, + stdout: prepared.stdout.parent, + stderr: prepared.stderr.parent, + }; + Ok((values, owners)) + } + StandardIo::PseudoConsole => Ok(( + sys::StartupStdio::PseudoConsole, + StandardHandles { + stdin: None, + stdout: None, + stderr: None, + }, + )), } } -impl SpawnTransaction { - pub(crate) fn commit_suspended(self) -> SuspendedChild { - let (child, thread) = self.commit_parts(); - SuspendedChild::new(child, thread) +fn prepare_job( + options: &SpawnOptions<'_>, +) -> Result<(JobOwnership, Vec>)> { + let job = match options.job_close { + JobClosePolicy::PreserveProcesses => JobOwnership::Preserve, + JobClosePolicy::TerminateProcesses => { + let job = trace::io( + Phase::Preparation, + Operation::CreateJob, + ResourceKind::Job, + Backend::create_job, + ) + .map_err(|error| Error::windows(Phase::Preparation, Operation::CreateJob, error))?; + trace::io( + Phase::Preparation, + Operation::ConfigureJob, + ResourceKind::Job, + || Backend::configure_job(&job, JobClosePolicy::TerminateProcesses), + ) + .map_err(|error| Error::windows(Phase::Preparation, Operation::ConfigureJob, error))?; + JobOwnership::Terminate(job) + } + }; + let mut values: Vec> = options + .jobs + .iter() + .map(|job| sys::child_handle_value(job.as_handle())) + .collect(); + if let JobOwnership::Terminate(job) = &job { + values.push(sys::child_handle_value(job.as_handle())); } + Ok((job, values)) } -impl Drop for SpawnTransaction { - fn drop(&mut self) { - if let Some(created) = &self.created { - let _ = sys::terminate_process(created.process.as_handle(), 1); - } - drop(self.kill_job.take()); +fn prepare_attributes<'options, Backend: SpawnBackend>( + options: &SpawnOptions<'options>, + transfer: &HandleTransfer<'options, Backend>, + job_values: Vec>, +) -> Result>> { + let inherited_values = transfer.inherited_values().to_vec().into_boxed_slice(); + let parent_value = transfer + .parent() + .map(|handle| Box::new(sys::child_handle_value(handle))); + let mitigation_words = options.mitigation.words(); + let mitigation_value = (mitigation_words != [0, 0]).then(|| Box::new(mitigation_words)); + let pseudoconsole = match options.terminal { + TerminalMode::Console(_) => None, + TerminalMode::PseudoConsole(value) => Some(value), + }; + ProcessAttributeList::new::(AttributeBacking { + inherited_values, + parent_value, + mitigation_value, + job_values: job_values.into_boxed_slice(), + pseudoconsole, + }) +} + +pub(crate) struct CreatedSuspended<'options, State, Table, Backend = WindowsBackend> { + process: ProcessOwner, + job: JobOwnership, + stdio: StandardHandles>, + transfer: HandleTransfer<'options, Backend>, + attributes: ProcessAttributeList<'options, Table>, + state: PhantomData<(State, Backend)>, +} + +impl CreatedSuspended<'_, State, Table, Backend> { + pub(crate) fn reclaim(self) -> Result> { + let Self { + process, + job, + stdio, + transfer, + attributes, + state: _, + } = self; + drop(attributes); + transfer.reclaim().map_err(Error::Cleanup)?; + Ok(Reclaimed { + process, + job, + stdio, + state: PhantomData, + }) + } +} + +pub(crate) struct Reclaimed { + process: ProcessOwner, + job: JobOwnership, + stdio: StandardHandles>, + state: PhantomData<(State, Table, Backend)>, +} + +impl Reclaimed { + pub(crate) fn resume(self) -> Result { + let mut child = Child::new( + self.process, + self.job, + self.stdio.stdin, + self.stdio.stdout, + self.stdio.stderr, + ); + child.resume_initial_with(Backend::resume_thread)?; + Ok(child) } } -struct PreparedStdio { - child: isize, +impl
Reclaimed { + pub(crate) fn into_suspended(self) -> SuspendedChild { + SuspendedChild::new(Child::new( + self.process, + self.job, + self.stdio.stdin, + self.stdio.stdout, + self.stdio.stderr, + )) + } +} + +struct PreparedStdio<'parent> { + child: ChildHandleValue>, parent: Option, } -fn prepare_standard_handles( +fn prepare_standard_handles<'parent, Backend: SpawnBackend>( specs: &StandardHandles>, - transfer: &mut HandleTransfer<'_>, -) -> io::Result> { + transfer: &mut HandleTransfer<'parent, Backend>, +) -> io::Result>> { Ok(StandardHandles { - stdin: prepare_stdio(specs.stdin, StandardStream::Input, transfer)?, - stdout: prepare_stdio(specs.stdout, StandardStream::Output, transfer)?, - stderr: prepare_stdio(specs.stderr, StandardStream::Error, transfer)?, + stdin: prepare_stdio::(specs.stdin, StandardStream::Input, transfer)?, + stdout: prepare_stdio::(specs.stdout, StandardStream::Output, transfer)?, + stderr: prepare_stdio::(specs.stderr, StandardStream::Error, transfer)?, }) } -fn prepare_stdio( +fn prepare_stdio<'parent, Backend: SpawnBackend>( spec: StdioSpec<'_>, stream: StandardStream, - transfer: &mut HandleTransfer<'_>, -) -> io::Result { + transfer: &mut HandleTransfer<'parent, Backend>, +) -> io::Result> { match spec { - StdioSpec::Inherit => prepare_inherit(stream, transfer), - StdioSpec::Null => prepare_null(stream, transfer), - StdioSpec::Piped => prepare_pipe(stream, transfer), + StdioSpec::Inherit => prepare_inherit::(stream, transfer), + StdioSpec::Null => prepare_null::(stream, transfer), + StdioSpec::Piped => prepare_pipe::(stream, transfer), StdioSpec::Configured(stdio) => match &stdio.inner { - StdioInner::Inherit => prepare_inherit(stream, transfer), - StdioInner::Null => prepare_null(stream, transfer), - StdioInner::Piped => prepare_pipe(stream, transfer), + StdioInner::Inherit => prepare_inherit::(stream, transfer), + StdioInner::Null => prepare_null::(stream, transfer), + StdioInner::Piped => prepare_pipe::(stream, transfer), StdioInner::Owned(handle) => Ok(PreparedStdio { child: transfer.lower(handle.as_handle())?, parent: None, @@ -235,42 +402,51 @@ fn prepare_stdio( } } -fn prepare_inherit( +fn prepare_inherit<'parent, Backend: SpawnBackend>( stream: StandardStream, - transfer: &mut HandleTransfer<'_>, -) -> io::Result { - match sys::standard_handle(stream)? { + transfer: &mut HandleTransfer<'parent, Backend>, +) -> io::Result> { + match Backend::standard_handle(stream)? { Some(handle) => Ok(PreparedStdio { child: transfer.lower(handle.as_handle())?, parent: None, }), None => Ok(PreparedStdio { - child: sys::INVALID_RAW_HANDLE, + child: sys::invalid_child_handle(), parent: None, }), } } -fn prepare_null( +fn prepare_null<'parent, Backend: SpawnBackend>( stream: StandardStream, - transfer: &mut HandleTransfer<'_>, -) -> io::Result { + transfer: &mut HandleTransfer<'parent, Backend>, +) -> io::Result> { let access = match stream { StandardStream::Input => NullAccess::Read, StandardStream::Output | StandardStream::Error => NullAccess::Write, }; - let handle = sys::null_handle(access)?; + let handle = Backend::null_handle(access)?; Ok(PreparedStdio { child: transfer.lower(handle.as_handle())?, parent: None, }) } -fn prepare_pipe( +fn prepare_pipe<'parent, Backend: SpawnBackend>( stream: StandardStream, - transfer: &mut HandleTransfer<'_>, -) -> io::Result { - let pipe = sys::create_pipe(!matches!(stream, StandardStream::Input))?; + transfer: &mut HandleTransfer<'parent, Backend>, +) -> io::Result> { + let direction = match stream { + StandardStream::Input => PipeDirection::ParentWrites, + StandardStream::Output | StandardStream::Error => PipeDirection::ParentReads, + }; + let pipe = trace::io( + Phase::Preparation, + Operation::CreatePipe, + ResourceKind::Pipe, + || Backend::create_pipe(direction), + )?; let child = transfer.lower(pipe.child.as_handle())?; Ok(PreparedStdio { child, @@ -278,32 +454,47 @@ fn prepare_pipe( }) } -struct HandleTransfer<'a> { +struct HandleTransfer<'a, Backend = WindowsBackend> { parent: Option>, local: Vec, remote: Vec>, - inherited: Vec, + inherited: Vec>>, + backend: PhantomData, } -impl<'a> HandleTransfer<'a> { +impl<'a, Backend: SpawnBackend> HandleTransfer<'a, Backend> { fn new(parent: Option>) -> Self { Self { parent, local: Vec::new(), remote: Vec::new(), inherited: Vec::new(), + backend: PhantomData, } } - fn lower(&mut self, source: BorrowedHandle<'_>) -> io::Result { + fn lower( + &mut self, + source: BorrowedHandle<'_>, + ) -> io::Result>> { let value = if let Some(parent) = self.parent { - let handle = sys::duplicate_remote(source, parent, true)?; + let handle = trace::io( + Phase::Preparation, + Operation::DuplicateRemoteHandle, + ResourceKind::RemoteHandle, + || Backend::duplicate_remote(source, parent, Inheritability::Inheritable), + )?; let value = handle.value(); self.remote.push(handle); value } else { - let handle = sys::duplicate_local(source, true)?; - let value = handle.as_raw_handle() as isize; + let handle = trace::io( + Phase::Preparation, + Operation::DuplicateLocalHandle, + ResourceKind::Handle, + || Backend::duplicate_local(source, Inheritability::Inheritable), + )?; + let value = sys::child_handle_value(handle.as_handle()); self.local.push(handle); value }; @@ -317,9 +508,43 @@ impl<'a> HandleTransfer<'a> { self.parent } - fn inherited_values(&self) -> &[isize] { + fn duplicate_operation(&self) -> Operation { + match self.parent { + Some(_) => Operation::DuplicateRemoteHandle, + None => Operation::DuplicateLocalHandle, + } + } + + fn inherited_values(&self) -> &[ChildHandleValue>] { &self.inherited } + + fn reclaim(self) -> std::result::Result<(), CleanupError> { + let mut failures = Vec::new(); + for handle in self.remote { + if let Err(error) = trace::io( + Phase::Reclamation, + Operation::ReclaimRemoteHandle, + ResourceKind::RemoteHandle, + || Backend::reclaim_remote(handle), + ) { + failures.push(WindowsError::new( + Phase::Reclamation, + Operation::ReclaimRemoteHandle, + error, + )); + } + } + let mut failures = failures.into_iter(); + let Some(first) = failures.next() else { + return Ok(()); + }; + let mut cleanup = CleanupError::new(first); + for failure in failures { + cleanup.push(failure); + } + Err(cleanup) + } } struct Environment { @@ -360,11 +585,11 @@ impl PartialEq for EnvKey { impl Eq for EnvKey {} -fn build_environment( +fn build_environment( command: &Command, - transfer: &mut HandleTransfer<'_>, + transfer: &mut HandleTransfer<'_, Backend>, ) -> io::Result { - if !command.env_clear && command.env_ops.is_empty() { + if command.environment_base == EnvironmentBase::Inherit && command.env_ops.is_empty() { return Ok(Environment { block: None, path: None, @@ -372,8 +597,8 @@ fn build_environment( } let mut map: BTreeMap = BTreeMap::new(); - if !command.env_clear { - for (key, value) in sys::environment_strings()? { + if command.environment_base == EnvironmentBase::Inherit { + for (key, value) in Backend::environment_strings()? { map.insert(EnvKey::new(key), value); } } @@ -419,57 +644,50 @@ fn build_environment( }) } -fn build_command_line( +fn build_command_line( command: &Command, - transfer: &mut HandleTransfer<'_>, -) -> io::Result> { - let mut result = Vec::new(); - result.push(QUOTE); - result.extend(command.program.encode_wide()); - result.push(QUOTE); + transfer: &mut HandleTransfer<'_, Backend>, +) -> Result> { + let program: Vec = command.program.encode_wide().collect(); + let mut result = CommandLine::new(&program).map_err(command_line_error)?; for argument in &command.args { - result.push(SPACE); match argument { - Arg::Text(text) => append_regular_arg(&mut result, text), - Arg::Raw(text) => result.extend(text.encode_wide()), + Arg::Text(text) => { + let units: Vec = text.encode_wide().collect(); + result.push_regular(&units).map_err(command_line_error)?; + } + Arg::Raw(text) => { + let units: Vec = text.encode_wide().collect(); + result.push_raw(&units).map_err(command_line_error)?; + } Arg::Handle(handle) => { - let text = OsString::from(transfer.lower(handle.as_handle())?.to_string()); - append_regular_arg(&mut result, &text); + let operation = transfer.duplicate_operation(); + let value = transfer + .lower(handle.as_handle()) + .map_err(|error| Error::windows(Phase::Preparation, operation, error))?; + let units: Vec = value.to_string().encode_utf16().collect(); + result.push_regular(&units).map_err(command_line_error)?; } } } - result.push(0); - Ok(result) + result.finish().map_err(command_line_error) } -fn append_regular_arg(command: &mut Vec, argument: &OsStr) { - let bytes = argument.as_encoded_bytes(); - let quote = bytes.is_empty() - || bytes - .iter() - .any(|byte| matches!(*byte, b' ' | b'\t' | b'"' | b'|')); - if quote { - command.push(QUOTE); - } - let mut backslashes = 0_usize; - for unit in argument.encode_wide() { - if unit == BACKSLASH { - backslashes += 1; - } else { - if unit == QUOTE { - command.extend(iter::repeat(BACKSLASH).take(backslashes + 1)); - } - backslashes = 0; - } - command.push(unit); - } - if quote { - command.extend(iter::repeat(BACKSLASH).take(backslashes)); - command.push(QUOTE); +fn command_line_error(error: CommandLineError) -> Error { + match error { + CommandLineError::TooLong => Error::Validation(crate::ValidationError::CommandLineTooLong), + CommandLineError::Allocation(source) => Error::windows( + Phase::Preparation, + Operation::BuildCommandLine, + io::Error::other(source), + ), } } -fn resolve_executable(program: &OsStr, child_path: Option<&OsStr>) -> io::Result> { +fn resolve_executable( + program: &OsStr, + child_path: Option<&OsStr>, +) -> io::Result> { let path = Path::new(program); let has_exe_suffix = program .as_encoded_bytes() @@ -513,10 +731,10 @@ fn resolve_executable(program: &OsStr, child_path: Option<&OsStr>) -> io::Result return Ok(found); } } - if let Some(found) = search(PathBuf::from(sys::system_directory()?)) { + if let Some(found) = search(PathBuf::from(Backend::system_directory()?)) { return Ok(found); } - if let Some(found) = search(PathBuf::from(sys::windows_directory()?)) { + if let Some(found) = search(PathBuf::from(Backend::windows_directory()?)) { return Ok(found); } if let Some(paths) = env::var_os("PATH") { @@ -542,16 +760,85 @@ fn wide_nul(value: &OsStr) -> io::Result> { } #[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] mod tests { use std::fs::File; use std::os::windows::ffi::OsStringExt; use super::*; + use crate::backend::{ + configure_fault, configure_faults, fault_calls, BackendCall, FaultBackend, + }; + use crate::plan::IoMode; + use crate::{DepPolicy, MitigationPolicy, ParentProcess, Stdio}; fn decode(value: &[u16]) -> String { String::from_utf16_lossy(value.strip_suffix(&[0]).unwrap_or(value)) } + fn run_local_fault_case(fail_at: Option) -> Result<()> { + configure_fault(fail_at); + let mut command = Command::new("cmd.exe"); + command.args(["/D", "/C", "exit /b 0"]); + command.env("WINDOWS_SPAWN_FAULT_BACKEND", "1"); + let options = SpawnOptions::new() + .mitigation(MitigationPolicy::new().dep(DepPolicy::Enable)) + .job_close_policy(JobClosePolicy::TerminateProcesses); + let plan = ValidatedPlan::running(&command, options, IoMode::Output)?; + let mut child = + PreparedSpawn::, FaultBackend>::prepare_with_backend(plan)? + .create_suspended()? + .reclaim()? + .resume()?; + let _status = child.wait()?; + child.cleanup() + } + + fn run_remote_fault_case(fail_at: Option) -> Result<()> { + configure_fault(fail_at); + execute_remote_fault_case() + } + + fn execute_remote_fault_case() -> Result<()> { + let parent = ParentProcess::open(std::process::id())?; + let source = File::open("NUL") + .map_err(|error| Error::windows(Phase::Preparation, Operation::OpenFile, error))?; + let mut command = Command::new("cmd.exe"); + command.args(["/D", "/C", "exit /b 0"]); + command.arg_handle(&source)?; + command.stdin(Stdio::null()); + command.stdout(Stdio::null()); + command.stderr(Stdio::null()); + let options = SpawnOptions::new().parent_process(&parent); + let plan = ValidatedPlan::running(&command, options, IoMode::Spawn)?; + let mut child = + PreparedSpawn::, FaultBackend>::prepare_with_backend(plan)? + .create_suspended()? + .reclaim()? + .resume()?; + let _status = child.wait()?; + child.cleanup() + } + + fn assert_faults_release_every_handle( + run_case: fn(Option) -> Result<()>, + ) -> io::Result> { + run_case(None).map_err(io::Error::from)?; + let baseline = fault_calls(); + assert!(!baseline.is_empty()); + for index in 0..baseline.len() { + let before = sys::current_process_handle_count()?; + let result = run_case(Some(index)); + assert!(result.is_err(), "fault {index} was not observed"); + let observed = fault_calls(); + assert_eq!(observed.get(index), baseline.get(index), "fault {index}"); + assert_eq!(&observed[..=index], &baseline[..=index], "fault {index}"); + let after = sys::current_process_handle_count()?; + assert_eq!(after, before, "fault {index} leaked a handle"); + } + Ok(baseline) + } + struct ProcessExitGuard { process: OwnedHandle, armed: bool, @@ -583,18 +870,84 @@ mod tests { } } + #[test] + fn fault_backend_exhausts_local_spawn_calls_without_leaks() -> io::Result<()> { + let calls = assert_faults_release_every_handle(run_local_fault_case)?; + for required in [ + BackendCall::OpenNull, + BackendCall::CreatePipe, + BackendCall::DuplicateLocal, + BackendCall::CreateJob, + BackendCall::ConfigureJob, + BackendCall::ReadEnvironment, + BackendCall::SetHandleList, + BackendCall::SetMitigation, + BackendCall::SetJobs, + BackendCall::CreateProcess, + BackendCall::ResumeThread, + ] { + assert!(calls.contains(&required), "missing {required:?}"); + } + Ok(()) + } + + #[test] + fn fault_backend_exhausts_remote_spawn_calls_without_leaks() -> io::Result<()> { + let calls = assert_faults_release_every_handle(run_remote_fault_case)?; + for required in [ + BackendCall::DuplicateRemote, + BackendCall::SetParent, + BackendCall::ReclaimRemote, + ] { + assert!(calls.contains(&required), "missing {required:?}"); + } + Ok(()) + } + + #[test] + fn primary_and_cleanup_failures_are_both_preserved() -> io::Result<()> { + run_remote_fault_case(None).map_err(io::Error::from)?; + let calls = fault_calls(); + let create = calls + .iter() + .position(|call| *call == BackendCall::CreateProcess) + .ok_or_else(|| io::Error::other("create call was not observed"))?; + let reclaim: Vec = calls + .iter() + .enumerate() + .filter_map(|(index, call)| (*call == BackendCall::ReclaimRemote).then_some(index)) + .collect(); + let mut failures = Vec::with_capacity(reclaim.len() + 1); + failures.push(create); + failures.extend(reclaim.iter().copied()); + configure_faults(&failures); + let before = sys::current_process_handle_count()?; + let result = execute_remote_fault_case(); + let after = sys::current_process_handle_count()?; + assert_eq!(after, before); + let Error::Cleanup(cleanup) = result.expect_err("combined failure expected") else { + return Err(io::Error::other("combined failure lost its cleanup type")); + }; + let primary = cleanup + .primary() + .ok_or_else(|| io::Error::other("primary failure was not retained"))?; + assert_eq!(primary.operation(), Operation::CreateProcess); + assert_eq!(cleanup.failures().count(), reclaim.len()); + Ok(()) + } + #[test] fn quotes_regular_and_preserves_raw_arguments() { let mut command = Command::new("program.exe"); command.arg("a b").arg("a\"b").raw_arg("x&&y"); - let mut transfer = HandleTransfer::new(None); + let mut transfer = HandleTransfer::::new(None); let line = build_command_line(&command, &mut transfer).unwrap(); assert_eq!(decode(&line), r#""program.exe" "a b" "a\"b" x&&y"#); } #[test] fn executable_search_finds_system_command_without_current_directory() { - let command = resolve_executable(OsStr::new("cmd"), None).unwrap(); + let command = resolve_executable::(OsStr::new("cmd"), None).unwrap(); assert!(decode(&command).to_ascii_lowercase().ends_with("cmd.exe")); } @@ -602,7 +955,7 @@ mod tests { fn cleared_environment_is_double_nul() { let mut command = Command::new("cmd.exe"); command.env_clear(); - let mut transfer = HandleTransfer::new(None); + let mut transfer = HandleTransfer::::new(None); let environment = build_environment(&command, &mut transfer).unwrap(); assert_eq!(environment.block.unwrap(), vec![0, 0]); } @@ -615,7 +968,7 @@ mod tests { .env("PATH", "second") .env("REMOVE_ME", "value") .env_remove("remove_me"); - let mut transfer = HandleTransfer::new(None); + let mut transfer = HandleTransfer::::new(None); let environment = build_environment(&command, &mut transfer).unwrap(); assert_eq!(environment.path, Some(OsString::from("second"))); let block = environment.block.unwrap(); @@ -639,7 +992,7 @@ mod tests { .env("ſ", "long-s") .env("Μ", "greek-mu") .env("µ", "micro-sign"); - let mut transfer = HandleTransfer::new(None); + let mut transfer = HandleTransfer::::new(None); let block = build_environment(&command, &mut transfer) .unwrap() .block @@ -668,7 +1021,7 @@ mod tests { fn quoting_covers_empty_and_trailing_backslashes() { let mut command = Command::new("program.exe"); command.arg("").arg(r"C:\path with spaces\"); - let mut transfer = HandleTransfer::new(None); + let mut transfer = HandleTransfer::::new(None); let line = decode(&build_command_line(&command, &mut transfer).unwrap()); assert_eq!(line, r#""program.exe" "" "C:\path with spaces\\""#); } @@ -678,23 +1031,26 @@ mod tests { let system = PathBuf::from(sys::system_directory().unwrap()); let executable = system.join("cmd.exe"); assert_eq!( - decode(&resolve_executable(executable.as_os_str(), None).unwrap()), + decode(&resolve_executable::(executable.as_os_str(), None).unwrap()), executable.to_string_lossy() ); let without_extension = system.join("cmd"); assert_eq!( - decode(&resolve_executable(without_extension.as_os_str(), None).unwrap()), + decode( + &resolve_executable::(without_extension.as_os_str(), None).unwrap(), + ), executable.to_string_lossy() ); assert!(decode( - &resolve_executable(OsStr::new("cmd.exe"), Some(system.as_os_str())).unwrap() + &resolve_executable::(OsStr::new("cmd.exe"), Some(system.as_os_str()),) + .unwrap() ) .to_ascii_lowercase() .ends_with("cmd.exe")); let missing = format!("windows-spawn-missing-{}.exe", std::process::id()); assert_eq!( - resolve_executable(OsStr::new(&missing), Some(OsStr::new(""))) + resolve_executable::(OsStr::new(&missing), Some(OsStr::new(""))) .unwrap_err() .kind(), io::ErrorKind::NotFound @@ -703,9 +1059,12 @@ mod tests { let empty_path_probe_path = env::current_dir().unwrap().join(&empty_path_probe); let probe = File::create(&empty_path_probe_path).unwrap(); assert_eq!( - resolve_executable(OsStr::new(&empty_path_probe), Some(OsStr::new(";"))) - .unwrap_err() - .kind(), + resolve_executable::( + OsStr::new(&empty_path_probe), + Some(OsStr::new(";")), + ) + .unwrap_err() + .kind(), io::ErrorKind::NotFound ); drop(probe); @@ -721,22 +1080,16 @@ mod tests { fn uncommitted_running_and_suspended_transactions_roll_back() { let mut running_command = Command::new("cmd.exe"); running_command.args(["/D", "/C", "ping -n 10 127.0.0.1 >nul"]); - let running = SpawnPlan::new_running( - &running_command, - crate::SpawnOptions::new(), - crate::plan::IoMode::Spawn, - ) - .unwrap(); - let running_transaction = SpawnTransaction::new(&running).unwrap(); + let running = + ValidatedPlan::running(&running_command, SpawnOptions::new(), IoMode::Spawn).unwrap(); + let running_transaction = PreparedSpawn::prepare(running) + .unwrap() + .create_suspended() + .unwrap(); let mut running_process = ProcessExitGuard::new( sys::duplicate_local( - running_transaction - .created - .as_ref() - .unwrap() - .process - .as_handle(), - false, + running_transaction.process.process_handle(), + Inheritability::Private, ) .unwrap(), ); @@ -745,22 +1098,17 @@ mod tests { let mut suspended_command = Command::new("cmd.exe"); suspended_command.args(["/D", "/C", "ping -n 10 127.0.0.1 >nul"]); - let suspended = SpawnPlan::new_suspended( - &suspended_command, - crate::SpawnOptions::new(), - crate::plan::IoMode::Spawn, - ) - .unwrap(); - let suspended_transaction = SpawnTransaction::new(&suspended).unwrap(); + let suspended = + ValidatedPlan::suspended(&suspended_command, SpawnOptions::new(), IoMode::Spawn) + .unwrap(); + let suspended_transaction = PreparedSpawn::prepare(suspended) + .unwrap() + .create_suspended() + .unwrap(); let mut suspended_process = ProcessExitGuard::new( sys::duplicate_local( - suspended_transaction - .created - .as_ref() - .unwrap() - .process - .as_handle(), - false, + suspended_transaction.process.process_handle(), + Inheritability::Private, ) .unwrap(), ); @@ -772,21 +1120,19 @@ mod tests { fn suspended_child_drop_terminates_the_process() { let mut command = Command::new("cmd.exe"); command.args(["/D", "/C", "exit /b 0"]); - let plan = SpawnPlan::new_suspended( - &command, - crate::SpawnOptions::new(), - crate::plan::IoMode::Spawn, - ) - .unwrap(); - let transaction = SpawnTransaction::new(&plan).unwrap(); + let plan = ValidatedPlan::suspended(&command, SpawnOptions::new(), IoMode::Spawn).unwrap(); + let transaction = PreparedSpawn::prepare(plan) + .unwrap() + .create_suspended() + .unwrap(); let mut process = ProcessExitGuard::new( sys::duplicate_local( - transaction.created.as_ref().unwrap().process.as_handle(), - false, + transaction.process.process_handle(), + Inheritability::Private, ) .unwrap(), ); - drop(transaction.commit_suspended()); + drop(transaction.reclaim().unwrap().into_suspended()); process.assert_exited("dropping SuspendedChild did not terminate the process"); } } diff --git a/supply-chain/audits.toml b/supply-chain/audits.toml new file mode 100644 index 0000000..2772ccb --- /dev/null +++ b/supply-chain/audits.toml @@ -0,0 +1,4 @@ + +# cargo-vet audits file + +[audits] diff --git a/supply-chain/config.toml b/supply-chain/config.toml new file mode 100644 index 0000000..9895b25 --- /dev/null +++ b/supply-chain/config.toml @@ -0,0 +1,156 @@ + +# cargo-vet config file + +[cargo-vet] +version = "0.10" + +[imports.actix] +url = "https://raw.githubusercontent.com/actix/supply-chain/main/audits.toml" + +[imports.ariel-os] +url = "https://raw.githubusercontent.com/ariel-os/ariel-os/main/supply-chain/audits.toml" + +[imports.bytecode-alliance] +url = "https://raw.githubusercontent.com/bytecodealliance/wasmtime/main/supply-chain/audits.toml" + +[imports.embark-studios] +url = "https://raw.githubusercontent.com/EmbarkStudios/rust-ecosystem/main/audits.toml" + +[imports.fermyon] +url = "https://raw.githubusercontent.com/fermyon/spin/main/supply-chain/audits.toml" + +[imports.google] +url = "https://raw.githubusercontent.com/google/supply-chain/main/audits.toml" + +[imports.isrg] +url = "https://raw.githubusercontent.com/divviup/libprio-rs/main/supply-chain/audits.toml" + +[imports.mozilla] +url = "https://raw.githubusercontent.com/mozilla/supply-chain/main/audits.toml" + +[imports.zcash] +url = "https://raw.githubusercontent.com/zcash/rust-ecosystem/main/supply-chain/audits.toml" + +[[exemptions.cpufeatures]] +version = "0.2.17" +criteria = "safe-to-deploy" + +[[exemptions.crypto-common]] +version = "0.1.7" +criteria = "safe-to-deploy" + +[[exemptions.digest]] +version = "0.10.7" +criteria = "safe-to-deploy" + +[[exemptions.generic-array]] +version = "0.14.7" +criteria = "safe-to-deploy" + +[[exemptions.hashbrown]] +version = "0.15.5" +criteria = "safe-to-deploy" + +[[exemptions.itoa]] +version = "1.0.18" +criteria = "safe-to-deploy" + +[[exemptions.libc]] +version = "0.2.189" +criteria = "safe-to-deploy" + +[[exemptions.memchr]] +version = "2.8.3" +criteria = "safe-to-deploy" + +[[exemptions.once_cell]] +version = "1.21.4" +criteria = "safe-to-deploy" + +[[exemptions.pin-project-lite]] +version = "0.2.17" +criteria = "safe-to-deploy" + +[[exemptions.proc-macro2]] +version = "1.0.107" +criteria = "safe-to-deploy" + +[[exemptions.serde]] +version = "1.0.229" +criteria = "safe-to-deploy" + +[[exemptions.serde_core]] +version = "1.0.229" +criteria = "safe-to-deploy" + +[[exemptions.serde_derive]] +version = "1.0.229" +criteria = "safe-to-deploy" + +[[exemptions.serde_json]] +version = "1.0.151" +criteria = "safe-to-deploy" + +[[exemptions.serde_spanned]] +version = "0.6.9" +criteria = "safe-to-deploy" + +[[exemptions.syn]] +version = "2.0.119" +criteria = "safe-to-deploy" + +[[exemptions.syn]] +version = "3.0.3" +criteria = "safe-to-deploy" + +[[exemptions.toml]] +version = "0.8.23" +criteria = "safe-to-deploy" + +[[exemptions.toml_datetime]] +version = "0.6.11" +criteria = "safe-to-deploy" + +[[exemptions.toml_edit]] +version = "0.22.27" +criteria = "safe-to-deploy" + +[[exemptions.toml_write]] +version = "0.1.2" +criteria = "safe-to-deploy" + +[[exemptions.tracing]] +version = "0.1.44" +criteria = "safe-to-deploy" + +[[exemptions.tracing-attributes]] +version = "0.1.31" +criteria = "safe-to-deploy" + +[[exemptions.tracing-core]] +version = "0.1.36" +criteria = "safe-to-deploy" + +[[exemptions.typenum]] +version = "1.20.1" +criteria = "safe-to-deploy" + +[[exemptions.unicode-ident]] +version = "1.0.24" +criteria = "safe-to-deploy" + +[[exemptions.version_check]] +version = "0.9.5" +criteria = "safe-to-deploy" + +[[exemptions.windows-sys]] +version = "0.61.2" +criteria = "safe-to-deploy" + +[[exemptions.winnow]] +version = "0.7.15" +criteria = "safe-to-deploy" + +[[exemptions.zmij]] +version = "1.0.23" +criteria = "safe-to-deploy" diff --git a/supply-chain/imports.lock b/supply-chain/imports.lock new file mode 100644 index 0000000..4df7ac6 --- /dev/null +++ b/supply-chain/imports.lock @@ -0,0 +1,208 @@ + +# cargo-vet imports lock + +[audits.actix.audits] + +[audits.ariel-os.audits] + +[[audits.bytecode-alliance.audits.block-buffer]] +who = "Benjamin Bouvier " +criteria = "safe-to-deploy" +delta = "0.9.0 -> 0.10.2" + +[[audits.bytecode-alliance.audits.cfg-if]] +who = "Alex Crichton " +criteria = "safe-to-deploy" +version = "1.0.0" +notes = "I am the author of this crate." + +[[audits.bytecode-alliance.audits.semver]] +who = "Pat Hickey " +criteria = "safe-to-deploy" +version = "1.0.17" +notes = "plenty of unsafe pointer and vec tricks, but in well-structured and commented code that appears to be correct" + +[audits.embark-studios.audits] + +[audits.fermyon.audits] + +[[audits.google.audits.equivalent]] +who = "George Burgess IV " +criteria = "safe-to-deploy" +version = "1.0.1" +aggregated-from = "https://chromium.googlesource.com/chromiumos/third_party/rust_crates/+/refs/heads/main/cargo-vet/audits.toml?format=TEXT" + +[[audits.google.audits.equivalent]] +who = "Jonathan Hao " +criteria = "safe-to-deploy" +delta = "1.0.1 -> 1.0.2" +notes = "No changes to any .rs files or Rust code." +aggregated-from = "https://chromium.googlesource.com/chromium/src/+/main/third_party/rust/chromium_crates_io/supply-chain/audits.toml?format=TEXT" + +[[audits.google.audits.indexmap]] +who = "Lukasz Anforowicz " +criteria = "safe-to-deploy" +version = "2.7.1" +notes = ''' +Grepped for `-i cipher`, `-i crypto`, `'\bfs\b'`, `'\bnet\b'` +and there were no hits. + +There is a little bit of `unsafe` Rust code - the audit can be found at +https://chromium-review.googlesource.com/c/chromium/src/+/6187726/2 +''' +aggregated-from = "https://chromium.googlesource.com/chromium/src/+/main/third_party/rust/chromium_crates_io/supply-chain/audits.toml?format=TEXT" + +[[audits.google.audits.quote]] +who = "Lukasz Anforowicz " +criteria = "safe-to-deploy" +version = "1.0.35" +notes = """ +Grepped for "unsafe", "crypt", "cipher", "fs", "net" - there were no hits +(except for benign "net" hit in tests and "fs" hit in README.md) +""" +aggregated-from = "https://chromium.googlesource.com/chromium/src/+/main/third_party/rust/chromium_crates_io/supply-chain/audits.toml?format=TEXT" + +[[audits.google.audits.quote]] +who = "Adrian Taylor " +criteria = "safe-to-deploy" +delta = "1.0.35 -> 1.0.36" +aggregated-from = "https://chromium.googlesource.com/chromium/src/+/main/third_party/rust/chromium_crates_io/supply-chain/audits.toml?format=TEXT" + +[[audits.google.audits.quote]] +who = "Lukasz Anforowicz " +criteria = "safe-to-deploy" +delta = "1.0.36 -> 1.0.37" +notes = """ +The delta just 1) inlines/expands `impl ToTokens` that used to be handled via +`primitive!` macro and 2) adds `impl ToTokens` for `CStr` and `CString`. +""" +aggregated-from = "https://chromium.googlesource.com/chromium/src/+/main/third_party/rust/chromium_crates_io/supply-chain/audits.toml?format=TEXT" + +[[audits.google.audits.quote]] +who = "Dustin J. Mitchell " +criteria = "safe-to-deploy" +delta = "1.0.37 -> 1.0.38" +notes = "Still no unsafe" +aggregated-from = "https://chromium.googlesource.com/chromium/src/+/main/third_party/rust/chromium_crates_io/supply-chain/audits.toml?format=TEXT" + +[[audits.google.audits.quote]] +who = "Daniel Cheng " +criteria = "safe-to-deploy" +delta = "1.0.38 -> 1.0.39" +notes = "Only minor changes for clippy lints and documentation." +aggregated-from = "https://chromium.googlesource.com/chromium/src/+/main/third_party/rust/chromium_crates_io/supply-chain/audits.toml?format=TEXT" + +[[audits.google.audits.quote]] +who = "Lukasz Anforowicz " +criteria = "safe-to-deploy" +delta = "1.0.39 -> 1.0.40" +notes = """ +The delta is just a simplification of how `tokens.extend(...)` call is made. +Still no `unsafe` anywhere. +""" +aggregated-from = "https://chromium.googlesource.com/chromium/src/+/main/third_party/rust/chromium_crates_io/supply-chain/audits.toml?format=TEXT" + +[[audits.isrg.audits.block-buffer]] +who = "David Cook " +criteria = "safe-to-deploy" +version = "0.9.0" + +[[audits.isrg.audits.cfg-if]] +who = "David Cook " +criteria = "safe-to-deploy" +delta = "1.0.0 -> 1.0.1" + +[[audits.isrg.audits.cfg-if]] +who = "J.C. Jones " +criteria = "safe-to-deploy" +delta = "1.0.1 -> 1.0.3" + +[[audits.isrg.audits.cfg-if]] +who = "David Cook " +criteria = "safe-to-deploy" +delta = "1.0.3 -> 1.0.4" + +[[audits.isrg.audits.sha2]] +who = "David Cook " +criteria = "safe-to-deploy" +version = "0.10.2" + +[[audits.isrg.audits.sha2]] +who = "David Cook " +criteria = "safe-to-deploy" +delta = "0.10.8 -> 0.10.9" + +[[audits.mozilla.audits.block-buffer]] +who = "Mike Hommey " +criteria = "safe-to-deploy" +delta = "0.10.2 -> 0.10.3" +aggregated-from = "https://hg.mozilla.org/mozilla-central/raw-file/tip/supply-chain/audits.toml" + +[[audits.mozilla.audits.quote]] +who = "Jan-Erik Rediger " +criteria = "safe-to-deploy" +delta = "1.0.40 -> 1.0.45" +aggregated-from = "https://raw.githubusercontent.com/mozilla/glean/main/supply-chain/audits.toml" + +[[audits.mozilla.audits.quote]] +who = "Jan-Erik Rediger " +criteria = "safe-to-deploy" +delta = "1.0.45 -> 1.0.47" +aggregated-from = "https://raw.githubusercontent.com/mozilla/glean/main/supply-chain/audits.toml" + +[[audits.mozilla.audits.semver]] +who = "Ben Dean-Kawamura " +criteria = "safe-to-deploy" +delta = "1.0.16 -> 1.0.28" +notes = "Very few changes, mostly removes support for older Rust versions. Some unsafe code refactored, but it seemed to be functionally equivalent to me." +aggregated-from = "https://hg.mozilla.org/mozilla-central/raw-file/tip/supply-chain/audits.toml" + +[[audits.mozilla.audits.semver]] +who = "Bobby Holley " +criteria = "safe-to-deploy" +delta = "1.0.17 -> 1.0.16" +aggregated-from = "https://hg.mozilla.org/mozilla-central/raw-file/tip/supply-chain/audits.toml" + +[[audits.mozilla.audits.sha2]] +who = "Mike Hommey " +criteria = "safe-to-deploy" +delta = "0.10.2 -> 0.10.6" +aggregated-from = "https://hg.mozilla.org/mozilla-central/raw-file/tip/supply-chain/audits.toml" + +[[audits.mozilla.audits.sha2]] +who = "Jeff Muizelaar " +criteria = "safe-to-deploy" +delta = "0.10.6 -> 0.10.8" +notes = """ +The bulk of this is https://github.com/RustCrypto/hashes/pull/490 which adds aarch64 support along with another PR adding longson. +I didn't check the implementation thoroughly but there wasn't anything obviously nefarious. 0.10.8 has been out for more than a year +which suggests no one else has found anything either. +""" +aggregated-from = "https://hg.mozilla.org/mozilla-central/raw-file/tip/supply-chain/audits.toml" + +[[audits.mozilla.audits.windows-link]] +who = "Mark Hammond " +criteria = "safe-to-deploy" +version = "0.1.1" +notes = "A microsoft crate allowing unsafe calls to windows apis." +aggregated-from = "https://hg.mozilla.org/mozilla-central/raw-file/tip/supply-chain/audits.toml" + +[[audits.mozilla.audits.windows-link]] +who = "Erich Gubler " +criteria = "safe-to-deploy" +delta = "0.1.1 -> 0.2.0" +aggregated-from = "https://hg.mozilla.org/mozilla-central/raw-file/tip/supply-chain/audits.toml" + +[[audits.zcash.audits.block-buffer]] +who = "Jack Grigg " +criteria = "safe-to-deploy" +delta = "0.10.3 -> 0.10.4" +notes = "Adds panics to prevent a block size of zero from causing unsoundness." +aggregated-from = "https://raw.githubusercontent.com/zcash/zcash/master/qa/supply-chain/audits.toml" + +[[audits.zcash.audits.windows-link]] +who = "Jack Grigg " +criteria = "safe-to-deploy" +delta = "0.2.0 -> 0.2.1" +notes = "No code changes at all." +aggregated-from = "https://raw.githubusercontent.com/zcash/librustzcash/main/supply-chain/audits.toml" diff --git a/tests/argv_roundtrip.rs b/tests/argv_roundtrip.rs index 61b2391..0508699 100644 --- a/tests/argv_roundtrip.rs +++ b/tests/argv_roundtrip.rs @@ -89,8 +89,6 @@ mod windows { .env(REMOVE, "must disappear") .env_remove("windows_spawn_roundtrip_remove"); if let Some(profile) = std::env::var_os("LLVM_PROFILE_FILE") { - // LLVM writes a default `*.profraw` in the working directory when - // env_clear removes this instrumentation-only destination. command.env("LLVM_PROFILE_FILE", profile); } let status = command.status()?; diff --git a/tests/windows_spawn.rs b/tests/windows_spawn.rs index c3639e8..a650f5b 100644 --- a/tests/windows_spawn.rs +++ b/tests/windows_spawn.rs @@ -4,16 +4,16 @@ //! End-to-end Windows process creation tests. use std::fs::{self, File}; -use std::io::{self, Read, Write}; +use std::io::{self, BufRead, Read, Write}; use std::mem::size_of_val; use std::os::windows::io::{AsHandle, AsRawHandle, FromRawHandle, OwnedHandle, RawHandle}; use std::path::PathBuf; use std::thread; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use windows_spawn::{ - AsPseudoConsole, Command, CreationFlags, DropPolicy, Job, Mitigation, MitigationPolicy, - ParentProcess, SpawnOptions, Stdio, + AsPseudoConsole, BorrowedPseudoConsole, Command, DepPolicy, Job, JobClosePolicy, Mitigation, + MitigationPolicy, ParentProcess, SpawnOptions, Stdio, }; use windows_sys::Win32::Foundation::{ DuplicateHandle, DUPLICATE_SAME_ACCESS, HANDLE, INVALID_HANDLE_VALUE, WAIT_OBJECT_0, @@ -27,7 +27,7 @@ use windows_sys::Win32::System::Console::{ ClosePseudoConsole, CreatePseudoConsole, GetConsoleCP, GetStdHandle, COORD, HPCON, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, }; -use windows_sys::Win32::System::Pipes::{CreatePipe, PeekNamedPipe}; +use windows_sys::Win32::System::Pipes::CreatePipe; use windows_sys::Win32::System::Threading::{ GetCurrentProcess, GetProcessHandleCount, GetProcessId, GetProcessMitigationPolicy, GetThreadId, ProcessExtensionPointDisablePolicy, SuspendThread, TerminateProcess, @@ -46,6 +46,10 @@ fn cmd(script: &str) -> Command { command } +fn error_kind(error: windows_spawn::Error) -> io::ErrorKind { + io::Error::from(error).kind() +} + fn temporary_path(label: &str) -> PathBuf { let nonce = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -57,18 +61,39 @@ fn temporary_path(label: &str) -> PathBuf { )) } -fn local_duplicate(source: &T, inheritable: bool) -> io::Result { +#[derive(Clone, Copy)] +enum TestInheritability { + Private, + Inheritable, +} + +impl TestInheritability { + const fn as_win32(self) -> i32 { + match self { + Self::Private => 0, + Self::Inheritable => 1, + } + } +} + +fn local_duplicate( + source: &T, + inheritability: TestInheritability, +) -> io::Result { let mut duplicate = std::ptr::null_mut(); + // SAFETY: GetCurrentProcess has no preconditions and returns a stable + // pseudo-handle. + let current = unsafe { GetCurrentProcess() }; // SAFETY: both process pseudo-handles are valid, the source remains // borrowed for the call, and `duplicate` is writable output storage. let success = unsafe { DuplicateHandle( - GetCurrentProcess(), + current, source.as_handle().as_raw_handle(), - GetCurrentProcess(), + current, &mut duplicate, 0, - i32::from(inheritable), + inheritability.as_win32(), DUPLICATE_SAME_ACCESS, ) }; @@ -81,7 +106,7 @@ fn local_duplicate(source: &T, inheritable: bool) -> io::Result(source: &T) -> io::Result { - local_duplicate(source, true) + local_duplicate(source, TestInheritability::Inheritable) } fn file_identity(handle: HANDLE) -> io::Result<(u32, u64)> { @@ -128,7 +153,6 @@ impl ProcessExitGuard { impl Drop for ProcessExitGuard { fn drop(&mut self) { if self.armed { - // 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. @@ -138,15 +162,24 @@ impl Drop for ProcessExitGuard { } fn wait_bounded(child: &mut windows_spawn::Child) -> io::Result> { - let deadline = Instant::now() + Duration::from_secs(5); - loop { - if let Some(status) = child.try_wait()? { - return Ok(Some(status)); - } - if Instant::now() >= deadline { - return Ok(None); - } - thread::sleep(Duration::from_millis(10)); + let timeout = u32::try_from(Duration::from_secs(5).as_millis()).unwrap_or(u32::MAX); + // SAFETY: the child owns a process handle which remains valid during the wait. + match unsafe { WaitForSingleObject(child.as_handle().as_raw_handle(), timeout) } { + WAIT_OBJECT_0 => Ok(child.try_wait()?), + WAIT_TIMEOUT => Ok(None), + _ => Err(io::Error::last_os_error()), + } +} + +fn current_handle_count() -> io::Result { + let mut count = 0_u32; + // SAFETY: GetCurrentProcess has no preconditions and returns a stable pseudo-handle. + let current = unsafe { GetCurrentProcess() }; + // SAFETY: `count` is writable and the process pseudo-handle remains valid. + if unsafe { GetProcessHandleCount(current, &mut count) } == 0 { + Err(io::Error::last_os_error()) + } else { + Ok(count) } } @@ -219,13 +252,7 @@ fn suspended_child_resumes_once_into_normal_state() -> io::Result<()> { let mut child = suspended.resume()?; assert_eq!(child.id(), pid); assert_eq!(child.as_handle().as_raw_handle(), process_handle); - let status = (0..200).find_map(|_| { - let status = child.try_wait().expect("resumed child must be queryable"); - if status.is_none() { - thread::sleep(Duration::from_millis(10)); - } - status - }); + let status = wait_bounded(&mut child)?; if status.is_none() { let _ = child.kill(); let _ = child.wait(); @@ -238,7 +265,8 @@ fn suspended_child_resumes_once_into_normal_state() -> io::Result<()> { fn resume_rejects_an_externally_changed_suspend_count() -> io::Result<()> { let mut command = cmd("ping -n 10 127.0.0.1 >nul"); let suspended = command.spawn_suspended()?; - let mut process = ProcessExitGuard::new(local_duplicate(&suspended, false)?); + let mut process = + ProcessExitGuard::new(local_duplicate(&suspended, TestInheritability::Private)?); // SAFETY: the primary thread handle remains owned by SuspendedChild and // has THREAD_SUSPEND_RESUME access from CreateProcessW. let previous_suspend_count = @@ -246,7 +274,7 @@ fn resume_rejects_an_externally_changed_suspend_count() -> io::Result<()> { assert_eq!(previous_suspend_count, 1); assert_eq!( - suspended.resume().unwrap_err().kind(), + error_kind(suspended.resume().unwrap_err()), io::ErrorKind::InvalidData ); assert!(process.wait(Duration::from_secs(5))?); @@ -345,22 +373,11 @@ fn explicit_job_attachment_and_kill_tree_output_complete() -> io::Result<()> { let options = SpawnOptions::new().job(&outer_job).job(&inner_job); assert!(ordinary.status_with(options)?.success()); - // The background grandchild inherits stdout. Without terminating the - // private Job after root exit, wait_with_output would never observe EOF. - // Keep the natural grandchild lifetime well beyond the assertion budget. - // This preserves the EOF proof without making a three-second wall-clock - // deadline flaky when the full integration suite creates processes in - // parallel on a loaded CI host. let mut tree = cmd("start \"\" /b cmd.exe /D /C \"ping -n 20 127.0.0.1 >nul\" & echo root"); - let started = Instant::now(); - let output = tree.output_with(SpawnOptions::new().drop_policy(DropPolicy::KillTree))?; - let elapsed = started.elapsed(); + let output = + tree.output_with(SpawnOptions::new().job_close_policy(JobClosePolicy::TerminateProcesses))?; assert!(output.status.success()); assert!(String::from_utf8_lossy(&output.stdout).contains("root")); - assert!( - elapsed < Duration::from_secs(10), - "root-bounded output waited {elapsed:?} for the background grandchild" - ); Ok(()) } @@ -394,9 +411,12 @@ fn failed_transactions_do_not_leak_handles() -> io::Result<()> { const PROBE: &str = "WINDOWS_SPAWN_HANDLE_LEAK_PROBE"; fn handle_count() -> io::Result { let mut count = 0; - // SAFETY: GetCurrentProcess returns a valid pseudo-handle and `count` - // points to writable DWORD storage for the duration of the call. - let success = unsafe { GetProcessHandleCount(GetCurrentProcess(), &mut count) }; + // SAFETY: GetCurrentProcess has no preconditions and returns a stable + // pseudo-handle. + let current = unsafe { GetCurrentProcess() }; + // SAFETY: the process pseudo-handle is valid and `count` points to + // writable DWORD storage for the duration of the call. + let success = unsafe { GetProcessHandleCount(current, &mut count) }; if success == 0 { Err(io::Error::last_os_error()) } else { @@ -411,9 +431,9 @@ fn failed_transactions_do_not_leak_handles() -> io::Result<()> { .stdout(Stdio::piped()) .stderr(Stdio::piped()); let error = command - .spawn_with(SpawnOptions::new().drop_policy(DropPolicy::KillTree)) + .spawn_with(SpawnOptions::new().job_close_policy(JobClosePolicy::TerminateProcesses)) .expect_err("a missing current directory must fail after resources are acquired"); - assert_ne!(error.kind(), io::ErrorKind::InvalidInput); + assert_ne!(error_kind(error), io::ErrorKind::InvalidInput); } if std::env::var_os(PROBE).is_none() { @@ -447,7 +467,7 @@ fn failed_transactions_do_not_leak_handles() -> io::Result<()> { fn validation_happens_before_process_creation() { let mut batch = Command::new("script.CMD"); assert_eq!( - batch.spawn().unwrap_err().kind(), + error_kind(batch.spawn().unwrap_err()), io::ErrorKind::InvalidInput ); } @@ -609,13 +629,7 @@ fn child_pipes_try_wait_and_cached_lifecycle_work() -> io::Result<()> { exited.kill()?; let mut polled = cmd("exit /b 42").spawn()?; - let status = (0..100).find_map(|_| { - let status = polled.try_wait().expect("try_wait must query the child"); - if status.is_none() { - thread::sleep(Duration::from_millis(10)); - } - status - }); + let status = wait_bounded(&mut polled)?; assert_eq!(status.and_then(|status| status.code()), Some(42)); let mut silent = cmd("exit /b 0"); @@ -630,8 +644,10 @@ struct InvalidPseudoConsole; // SAFETY: this implementation is used only in validation tests that reject // the request before the numeric value reaches the Win32 transaction. unsafe impl AsPseudoConsole for InvalidPseudoConsole { - fn raw_pseudoconsole(&self) -> isize { - 1 + fn as_pseudo_console(&self) -> BorrowedPseudoConsole<'_> { + let value = std::num::NonZeroIsize::new(1).expect("one is nonzero"); + // SAFETY: validation rejects this sentinel before process creation. + unsafe { BorrowedPseudoConsole::from_raw(value, self) } } } @@ -651,13 +667,12 @@ impl TestPseudoConsole { if unsafe { CreatePipe(&mut read, &mut write, std::ptr::null(), 0) } == 0 { return Err(io::Error::last_os_error()); } - // SAFETY: CreatePipe succeeded and returned two distinct handles. - Ok(unsafe { - ( - OwnedHandle::from_raw_handle(read as RawHandle), - OwnedHandle::from_raw_handle(write as RawHandle), - ) - }) + // SAFETY: CreatePipe succeeded and transferred the read handle. + let read = unsafe { OwnedHandle::from_raw_handle(read as RawHandle) }; + // SAFETY: CreatePipe succeeded and transferred the distinct write + // handle. + let write = unsafe { OwnedHandle::from_raw_handle(write as RawHandle) }; + Ok((read, write)) } let (input_reader, input_writer) = pipe()?; @@ -723,69 +738,51 @@ impl TestPseudoConsole { .output_reader .as_ref() .expect("a live pseudoconsole retains its output reader"); - let deadline = Instant::now() + Duration::from_secs(5); let mut received = Vec::new(); loop { - let mut available = 0_u32; - // SAFETY: the pipe handle remains owned by self, available is - // writable, and the unused optional output pointers are null. + let mut buffer = [0_u8; 4_096]; + let mut read = 0_u32; + let length = u32::try_from(buffer.len()) + .map_err(|_| io::Error::other("test buffer is too large"))?; + // SAFETY: buffer is writable for its reported length, read is + // writable, and the owned synchronous pipe handle remains valid. if unsafe { - PeekNamedPipe( + ReadFile( output.as_raw_handle(), - std::ptr::null_mut(), - 0, - std::ptr::null_mut(), - &mut available, + buffer.as_mut_ptr(), + length, + &mut read, std::ptr::null_mut(), ) } == 0 { return Err(io::Error::last_os_error()); } - if available != 0 { - let mut buffer = vec![0_u8; available as usize]; - let mut read = 0_u32; - // SAFETY: buffer is writable for its length, read is writable, - // and the owned synchronous pipe handle remains valid. - if unsafe { - ReadFile( - output.as_raw_handle(), - buffer.as_mut_ptr(), - available, - &mut read, - std::ptr::null_mut(), - ) - } == 0 - { - return Err(io::Error::last_os_error()); - } - received.extend_from_slice(&buffer[..read as usize]); - if expected.iter().all(|marker| { - received - .windows(marker.len()) - .any(|window| window == *marker) - }) { - return Ok(true); - } - } - if Instant::now() >= deadline { + if read == 0 { return Ok(false); } - thread::sleep(Duration::from_millis(10)); + let read = usize::try_from(read) + .map_err(|_| io::Error::other("read byte count does not fit usize"))?; + let bytes = buffer + .get(..read) + .ok_or_else(|| io::Error::other("read byte count exceeds the buffer"))?; + received.extend_from_slice(bytes); + if expected.iter().all(|marker| { + received + .windows(marker.len()) + .any(|window| window == *marker) + }) { + return Ok(true); + } } } } impl Drop for TestPseudoConsole { fn drop(&mut self) { - // Closing both client pipe ends first prevents ClosePseudoConsole from - // waiting on an undrained output reader on affected Windows releases. drop(self.input_writer.take()); drop(self.output_reader.take()); - // Some Windows Server 2022 builds can still block indefinitely in - // ClosePseudoConsole after the attached child exits. Keep this test's - // teardown bounded; process teardown is the fallback for a stuck close. let value = self.value; let _ = thread::spawn(move || { // SAFETY: this type uniquely owned the HPCON returned by creation. @@ -797,8 +794,11 @@ impl Drop for TestPseudoConsole { // SAFETY: TestPseudoConsole owns a live HPCON for its full borrow and does not // transfer that ownership to windows-spawn. unsafe impl AsPseudoConsole for TestPseudoConsole { - fn raw_pseudoconsole(&self) -> isize { - self.value + fn as_pseudo_console(&self) -> BorrowedPseudoConsole<'_> { + let value = std::num::NonZeroIsize::new(self.value) + .expect("CreatePseudoConsole returned a nonzero HPCON"); + // SAFETY: this owner keeps the HPCON open and unchanged for the borrow. + unsafe { BorrowedPseudoConsole::from_raw(value, self) } } } @@ -809,7 +809,7 @@ fn pseudoconsole_attribute_connects_the_child_console() -> io::Result<()> { command .args(["--exact", "pseudoconsole_child_probe", "--nocapture"]) .env("WINDOWS_SPAWN_PCON_PROBE", "1"); - let mut child = command.spawn_with(SpawnOptions::new().pseudoconsole(&pseudoconsole))?; + let mut child = command.spawn_with(SpawnOptions::new().pseudo_console(&pseudoconsole))?; let status = if let Some(status) = wait_bounded(&mut child)? { status } else { @@ -889,7 +889,7 @@ fn pseudoconsole_stdio_isolation_helper() -> io::Result<()> { "--nocapture", ]) .env(PCON_STDIO_PROBE, "1"); - let mut child = command.spawn_with(SpawnOptions::new().pseudoconsole(&pseudoconsole))?; + let mut child = command.spawn_with(SpawnOptions::new().pseudo_console(&pseudoconsole))?; let mut input = PCON_STDIN_MARKER.to_vec(); input.extend_from_slice(b"\r\n"); pseudoconsole.write_input(&input)?; @@ -942,29 +942,28 @@ fn capability_wrappers_and_all_option_builders_are_exercised() -> io::Result<()> let _ = parent.as_handle(); assert!(format!("{parent:?}").contains("ParentProcess")); let job = Job::create()?; - job.set_kill_on_close(true)?; - job.set_kill_on_close(false)?; + job.set_close_policy(JobClosePolicy::TerminateProcesses)?; + job.set_close_policy(JobClosePolicy::PreserveProcesses)?; let duplicate_job = job.duplicate()?; let _ = duplicate_job.as_handle(); assert!(format!("{duplicate_job:?}").contains("Job")); - let mut flags = CreationFlags::NEW_PROCESS_GROUP; - flags |= CreationFlags::DEFAULT_ERROR_MODE; let pseudoconsole = InvalidPseudoConsole; let options = SpawnOptions::new() .job(&job) .parent_process(&parent) - .mitigation(MitigationPolicy::new().dep(true)) - .pseudoconsole(&pseudoconsole) - .creation_flags(flags) - .drop_policy(DropPolicy::KillTree); + .mitigation(MitigationPolicy::new().dep(DepPolicy::Enable)) + .pseudo_console(&pseudoconsole) + .new_process_group() + .default_error_mode() + .job_close_policy(JobClosePolicy::TerminateProcesses); let rendered = format!("{options:?}"); - assert!(rendered.contains("borrowed") && rendered.contains("KillTree")); + assert!(rendered.contains("PseudoConsole") && rendered.contains("TerminateProcesses")); let mut invalid = cmd("exit /b 0"); invalid.stdout(Stdio::null()); assert_eq!( - invalid.spawn_with(options).unwrap_err().kind(), + error_kind(invalid.spawn_with(options).unwrap_err()), io::ErrorKind::InvalidInput ); @@ -1039,20 +1038,41 @@ fn handle_list_excludes_an_unlisted_inheritable_handle() -> io::Result<()> { fn dropping_a_kill_tree_child_terminates_the_root() -> io::Result<()> { let marker = temporary_path("kill-tree-drop"); let script = format!( - "Start-Sleep -Milliseconds 600; Set-Content -LiteralPath '{}' -Value ran", + "[Console]::Out.WriteLine('ready'); [Console]::Out.Flush(); $null=[Console]::In.ReadLine(); Set-Content -LiteralPath '{}' -Value ran", marker.display() ); let mut command = Command::new("powershell.exe"); - command.args([ - "-NoLogo", - "-NoProfile", - "-NonInteractive", - "-Command", - &script, - ]); - let child = command.spawn_with(SpawnOptions::new().drop_policy(DropPolicy::KillTree))?; + command + .args([ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + &script, + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()); + let mut child = command + .spawn_with(SpawnOptions::new().job_close_policy(JobClosePolicy::TerminateProcesses))?; + let mut process = ProcessExitGuard::new(local_duplicate(&child, TestInheritability::Private)?); + let stdout = child + .stdout + .take() + .ok_or_else(|| io::Error::other("handshake output pipe is missing"))?; + let mut reader = io::BufReader::new(stdout); + let mut ready = String::new(); + reader.read_line(&mut ready)?; + if ready.trim_end() != "ready" { + return Err(io::Error::other("child handshake did not become ready")); + } + drop(reader); drop(child); - thread::sleep(Duration::from_millis(900)); + if !process.wait(Duration::from_secs(5))? { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "kill-tree child did not terminate", + )); + } assert!(!marker.exists()); Ok(()) } @@ -1060,18 +1080,12 @@ fn dropping_a_kill_tree_child_terminates_the_root() -> io::Result<()> { #[test] fn public_job_kill_on_close_terminates_assigned_process() -> io::Result<()> { let job = Job::create()?; - job.set_kill_on_close(true)?; + job.set_close_policy(JobClosePolicy::TerminateProcesses)?; let mut child = cmd("ping -n 20 127.0.0.1 >nul").spawn()?; job.assign(&child)?; drop(job); - let status = (0..200).find_map(|_| { - let status = child.try_wait().expect("assigned child must be queryable"); - if status.is_none() { - thread::sleep(Duration::from_millis(10)); - } - status - }); + let status = wait_bounded(&mut child)?; if status.is_none() { let _ = child.kill(); let _ = child.wait(); @@ -1110,8 +1124,8 @@ fn dropping_suspended_child_prevents_execution() -> io::Result<()> { .args(["--exact", "suspended_execution_probe", "--nocapture"]) .env("WINDOWS_SPAWN_SUSPENDED_PROBE", &path); let suspended = command.spawn_suspended_with(SpawnOptions::new())?; - let mut process = ProcessExitGuard::new(local_duplicate(&suspended, false)?); - thread::sleep(Duration::from_millis(500)); + let mut process = + ProcessExitGuard::new(local_duplicate(&suspended, TestInheritability::Private)?); assert!(!path.exists()); drop(suspended); assert!( @@ -1129,3 +1143,39 @@ fn suspended_execution_probe() -> io::Result<()> { }; fs::write(path, b"ran") } + +#[test] +#[ignore = "explicit spawn and cleanup stress gate"] +fn spawn_cleanup_stress() -> io::Result<()> { + let cases = std::env::var("WINDOWS_SPAWN_STRESS_CASES") + .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))? + .parse::() + .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; + for _ in 0..16 { + let status = cmd("exit /b 0").status()?; + if !status.success() { + return Err(io::Error::other("stress warmup child failed")); + } + } + let before = current_handle_count()?; + for case in 0..cases { + let mut command = cmd("exit /b 0"); + let status = if case % 2 == 0 { + let suspended = command.spawn_suspended()?; + let mut child = suspended.resume()?; + child.wait()? + } else { + command.status()? + }; + if !status.success() { + return Err(io::Error::other(format!("stress child {case} failed"))); + } + } + let after = current_handle_count()?; + if after > before { + return Err(io::Error::other(format!( + "handle count grew from {before} to {after}" + ))); + } + Ok(()) +} diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index 6a791fb..d3d450f 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -7,8 +7,11 @@ publish = false [dependencies] semver = "1.0.28" +serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" sha2 = "0.10.9" +syn = { version = "2.0.119", features = ["full", "visit"] } +toml = "0.8.23" [target.'cfg(windows)'.dependencies] windows-spawn = { path = ".." } diff --git a/xtask/src/cli.rs b/xtask/src/cli.rs index f713913..158d102 100644 --- a/xtask/src/cli.rs +++ b/xtask/src/cli.rs @@ -2,6 +2,11 @@ use std::path::PathBuf; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum SimpleTask { + BuildOComment, + CommentPolicy, + InvariantRegistry, + SourcePolicy, + Kani, Fmt, Clippy, Test, @@ -57,6 +62,11 @@ pub(crate) fn parse(arguments: impl IntoIterator) -> Result = arguments.collect(); match command.as_str() { + "build-ocomment" => no_arguments(&rest, Task::Simple(SimpleTask::BuildOComment)), + "comment-policy" => no_arguments(&rest, Task::Simple(SimpleTask::CommentPolicy)), + "invariant-registry" => no_arguments(&rest, Task::Simple(SimpleTask::InvariantRegistry)), + "source-policy" => no_arguments(&rest, Task::Simple(SimpleTask::SourcePolicy)), + "kani" => no_arguments(&rest, Task::Simple(SimpleTask::Kani)), "fmt" => no_arguments(&rest, Task::Simple(SimpleTask::Fmt)), "clippy" => no_arguments(&rest, Task::Simple(SimpleTask::Clippy)), "test" => no_arguments(&rest, Task::Simple(SimpleTask::Test)), diff --git a/xtask/src/comment_policy.rs b/xtask/src/comment_policy.rs new file mode 100644 index 0000000..a4f7463 --- /dev/null +++ b/xtask/src/comment_policy.rs @@ -0,0 +1,1110 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::env; +use std::fmt::Write as _; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use serde_json::Value; + +const OCOMMENT_REVISION: &str = "d7528bf0f9c8f50bc5cafcb330a77a3aeb3d72d4"; +const OCOMMENT_VERSION: &str = "ocomment 0.1.0"; +const OCOMMENT_REPOSITORY: &str = "https://github.com/P4suta/OComment.git"; +const OCOMMENT_TOOLCHAIN: &str = "1.98.0"; + +pub(crate) fn build_from_source(root: &Path) -> Result<(), String> { + let tools = root.join("target").join("tools"); + let source = tools.join("ocomment-checkout"); + let target = tools.join("ocomment-source"); + std::fs::create_dir_all(&tools) + .map_err(|error| format!("failed to create {}: {error}", tools.display()))?; + if !source.exists() { + run_status( + Command::new("git") + .current_dir(root) + .args(["clone", "--filter=blob:none", "--no-checkout"]) + .arg(OCOMMENT_REPOSITORY) + .arg(&source), + "clone OComment", + )?; + } + if !source.join(".git").is_dir() { + return Err(format!( + "OComment source directory is not a Git checkout: {}", + source.display() + )); + } + run_status( + Command::new("git").current_dir(&source).args([ + "fetch", + "--depth", + "1", + "origin", + OCOMMENT_REVISION, + ]), + "fetch pinned OComment revision", + )?; + run_status( + Command::new("git") + .current_dir(&source) + .args(["checkout", "--detach", OCOMMENT_REVISION]), + "check out pinned OComment revision", + )?; + let revision = Command::new("git") + .current_dir(&source) + .args(["rev-parse", "HEAD"]) + .output() + .map_err(|error| format!("failed to identify OComment revision: {error}"))?; + if !revision.status.success() { + return Err(format!( + "git rev-parse failed with status {}", + revision.status + )); + } + let revision = String::from_utf8(revision.stdout) + .map_err(|_| "OComment revision is not UTF-8".to_owned())?; + if revision.trim() != OCOMMENT_REVISION { + return Err(format!( + "OComment checkout mismatch: expected {OCOMMENT_REVISION}, got {}", + revision.trim() + )); + } + run_status( + Command::new("cargo") + .current_dir(&source) + .arg(format!("+{OCOMMENT_TOOLCHAIN}")) + .args(["build", "--release", "--locked", "-p", "ocomment"]) + .arg("--manifest-path") + .arg(source.join("rust").join("Cargo.toml")) + .arg("--target-dir") + .arg(&target), + "build OComment from source", + )?; + std::fs::write(target.join("REVISION"), format!("{OCOMMENT_REVISION}\n")) + .map_err(|error| format!("failed to record OComment revision: {error}"))?; + Ok(()) +} + +fn run_status(command: &mut Command, operation: &str) -> Result<(), String> { + let status = command + .status() + .map_err(|error| format!("failed to {operation}: {error}"))?; + if status.success() { + Ok(()) + } else { + Err(format!("{operation} failed with status {status}")) + } +} + +#[derive(Debug, Eq, PartialEq)] +struct Violation { + line: usize, + reason: &'static str, +} + +pub(crate) fn check_repository(root: &Path) -> Result<(), String> { + let output = Command::new("git") + .current_dir(root) + .args([ + "ls-files", + "-z", + "--cached", + "--others", + "--exclude-standard", + ]) + .output() + .map_err(|error| format!("failed to enumerate tracked files: {error}"))?; + if !output.status.success() { + return Err(format!("git ls-files failed with status {}", output.status)); + } + let mut sources = BTreeMap::new(); + for encoded in output + .stdout + .split(|byte| *byte == 0) + .filter(|path| !path.is_empty()) + { + let relative = String::from_utf8(encoded.to_vec()) + .map_err(|_| "tracked path is not valid UTF-8".to_owned())?; + let path = root.join(&relative); + let bytes = + std::fs::read(&path).map_err(|error| format!("failed to read {relative}: {error}"))?; + let source = String::from_utf8(bytes) + .map_err(|_| format!("tracked text file is not UTF-8: {relative}"))?; + scan(Path::new(&relative), &source)?; + sources.insert(normalize_path(&relative), source); + } + + let mut findings = BTreeSet::new(); + let ocomment_paths: Vec<&str> = sources + .keys() + .filter(|path| ocomment_supported(Path::new(path.as_str()))) + .map(String::as_str) + .collect(); + for (path, violation) in scan_with_ocomment(root, &ocomment_paths, &sources)? { + findings.insert((path, violation.line, violation.reason)); + } + for (path, source) in &sources { + if ocomment_supported(Path::new(path)) { + continue; + } + for violation in scan(Path::new(path), source)? { + findings.insert((path.clone(), violation.line, violation.reason)); + } + } + + let mut report = String::new(); + for (path, line, reason) in findings { + let _ = writeln!(report, "{path}:{line}: {reason}"); + } + if report.is_empty() { + Ok(()) + } else { + Err(format!("comment policy violations:\n{report}")) + } +} + +fn normalize_path(path: &str) -> String { + path.replace('\\', "/") +} + +fn ocomment_supported(path: &Path) -> bool { + matches!( + path.extension().and_then(|value| value.to_str()), + Some("rs" | "toml" | "yml" | "yaml" | "sh" | "bash" | "md" | "json") + ) && path.file_name().and_then(|value| value.to_str()) != Some("Cargo.lock") +} + +fn ocomment_program(root: &Path) -> Result { + if let Some(program) = env::var_os("OCOMMENT").filter(|value| !value.is_empty()) { + let revision = env::var("OCOMMENT_REVISION") + .map_err(|_| "OCOMMENT_REVISION is required with OCOMMENT".to_owned())?; + if revision != OCOMMENT_REVISION { + return Err(format!( + "OCOMMENT_REVISION mismatch: expected {OCOMMENT_REVISION}, got {revision}" + )); + } + return Ok(PathBuf::from(program)); + } + let executable = if cfg!(windows) { + "ocomment.exe" + } else { + "ocomment" + }; + let source_build = root + .join("target") + .join("tools") + .join("ocomment-source") + .join("release") + .join(executable); + if source_build.is_file() { + let marker = std::fs::read_to_string( + root.join("target") + .join("tools") + .join("ocomment-source") + .join("REVISION"), + ) + .map_err(|error| format!("OComment source revision marker is missing: {error}"))?; + if marker.trim() != OCOMMENT_REVISION { + return Err(format!( + "OComment source revision mismatch: expected {OCOMMENT_REVISION}, got {}", + marker.trim() + )); + } + Ok(source_build) + } else { + Err(format!( + "OComment is required; run `cargo xtask build-ocomment` to build revision {OCOMMENT_REVISION} from source" + )) + } +} + +fn scan_with_ocomment( + root: &Path, + paths: &[&str], + sources: &BTreeMap, +) -> Result, String> { + if paths.is_empty() { + return Ok(Vec::new()); + } + let program = ocomment_program(root)?; + validate_ocomment_version(root, &program)?; + let document = ocomment_document(root, &program, paths)?; + analyze_ocomment_document(&document, paths, sources) +} + +fn validate_ocomment_version(root: &Path, program: &Path) -> Result<(), String> { + let version = Command::new(program) + .current_dir(root) + .arg("--version") + .output() + .map_err(|error| { + format!("OComment is required; build revision {OCOMMENT_REVISION} from source: {error}") + })?; + if !version.status.success() { + return Err(format!( + "OComment --version failed with status {}", + version.status + )); + } + let actual_version = String::from_utf8(version.stdout) + .map_err(|_| "OComment --version output is not UTF-8".to_owned())?; + if actual_version.trim() != OCOMMENT_VERSION { + return Err(format!( + "OComment version mismatch: expected {OCOMMENT_VERSION} from {OCOMMENT_REVISION}, got {}", + actual_version.trim() + )); + } + Ok(()) +} + +fn ocomment_document(root: &Path, program: &Path, paths: &[&str]) -> Result { + let mut command = Command::new(program); + command.current_dir(root).args([ + "scan", + "--format", + "json", + "--no-preview", + "--color", + "never", + "--progress", + "never", + "--policy", + "all", + ]); + command.args(paths); + let output = command + .output() + .map_err(|error| format!("failed to execute OComment: {error}"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!( + "OComment scan failed with status {}: {}", + output.status, + stderr.trim() + )); + } + let document: Value = serde_json::from_slice(&output.stdout) + .map_err(|error| format!("invalid OComment JSON: {error}"))?; + Ok(document) +} + +fn analyze_ocomment_document( + document: &Value, + paths: &[&str], + sources: &BTreeMap, +) -> Result, String> { + if document.get("version").and_then(Value::as_u64) != Some(1) { + return Err("unsupported OComment JSON protocol".to_owned()); + } + let skipped = document + .get("skipped") + .and_then(Value::as_array) + .ok_or_else(|| "OComment JSON has no skipped array".to_owned())?; + if !skipped.is_empty() { + return Err(format!("OComment skipped tracked inputs: {skipped:?}")); + } + let files = document + .get("files") + .and_then(Value::as_array) + .ok_or_else(|| "OComment JSON has no files array".to_owned())?; + let expected: BTreeSet = paths.iter().map(|path| normalize_path(path)).collect(); + let mut seen = BTreeSet::new(); + let mut violations = Vec::new(); + for file in files { + let path = file + .get("path") + .and_then(Value::as_str) + .map(normalize_path) + .ok_or_else(|| "OComment file has no path".to_owned())?; + if !expected.contains(&path) { + return Err(format!("OComment reported an unrequested path: {path}")); + } + if !seen.insert(path.clone()) { + return Err(format!("OComment reported a path twice: {path}")); + } + let report = file + .get("report") + .ok_or_else(|| format!("OComment report is missing for {path}"))?; + if report.get("valid").and_then(Value::as_bool) != Some(true) { + return Err(format!("OComment could not parse {path}: {report}")); + } + let comments = report + .get("comments") + .and_then(Value::as_array) + .ok_or_else(|| format!("OComment comments are missing for {path}"))?; + let source = sources + .get(&path) + .ok_or_else(|| format!("OComment returned an unknown path: {path}"))?; + let mut safety_line = None; + for comment in comments { + let span = comment + .get("span") + .ok_or_else(|| format!("OComment span is missing for {path}"))?; + let start = json_usize(span, "start", &path)?; + let end = json_usize(span, "end", &path)?; + let line = json_usize(comment, "line", &path)?; + let kind = comment + .get("kind") + .and_then(Value::as_str) + .ok_or_else(|| format!("OComment kind is missing for {path}:{line}"))?; + let allowed = comment_allowed( + Path::new(&path), + source, + kind, + start, + end, + line, + &mut safety_line, + )?; + if !allowed { + violations.push(( + path.clone(), + Violation { + line, + reason: violation_reason(Path::new(&path), kind), + }, + )); + } + } + } + if seen != expected { + let missing: Vec<_> = expected.difference(&seen).cloned().collect(); + return Err(format!( + "OComment did not report tracked inputs: {missing:?}" + )); + } + Ok(violations) +} + +fn json_usize(value: &Value, field: &str, path: &str) -> Result { + let raw = value + .get(field) + .and_then(Value::as_u64) + .ok_or_else(|| format!("OComment {field} is missing for {path}"))?; + usize::try_from(raw).map_err(|_| format!("OComment {field} is too large for {path}")) +} + +fn comment_allowed( + path: &Path, + source: &str, + kind: &str, + start: usize, + end: usize, + line: usize, + safety_line: &mut Option, +) -> Result { + let text = source + .get(start..end) + .ok_or_else(|| format!("OComment returned an invalid span for {}", path.display()))?; + if path.extension().and_then(|value| value.to_str()) != Some("rs") { + *safety_line = None; + return Ok((kind == "shebang" && line == 1 && text.starts_with("#!")) + || exact_tool_directive(path, line, text)); + } + let line_start = source[..start].rfind('\n').map_or(0, |at| at + 1); + let standalone = source[line_start..start].trim().is_empty(); + let allowed = match kind { + "doc-line" | "doc-block" => { + *safety_line = None; + if text.starts_with("//!") || text.starts_with("/*!") { + is_crate_root(path) + } else { + outer_doc_is_public(source, start, end) + } + } + "line" => { + let begins = standalone && text.starts_with("// SAFETY:"); + let continues = standalone + && safety_line.is_some_and(|previous| previous + 1 == line) + && text.starts_with("//"); + if begins || continues { + *safety_line = Some(line); + true + } else { + *safety_line = None; + false + } + } + _ => { + *safety_line = None; + false + } + }; + Ok(allowed) +} + +fn violation_reason(path: &Path, kind: &str) -> &'static str { + if path.extension().and_then(|value| value.to_str()) == Some("rs") { + match kind { + "doc-line" | "doc-block" => "Rustdoc is allowed only at crate roots and on public API", + "block" => "ordinary Rust block comment is forbidden", + _ => "ordinary Rust line comment is forbidden", + } + } else { + "comment is forbidden in this file" + } +} + +fn scan(path: &Path, source: &str) -> Result, String> { + let extension = path.extension().and_then(|value| value.to_str()); + match extension { + Some("rs") => Ok(scan_rust(path, source)), + Some("toml") => Ok(scan_tool_toml(path, source)), + Some("yml" | "yaml") => Ok(scan_yaml(source, 0)), + Some("ps1") => Ok(scan_hash(source, HashDialect::PowerShell, 0)), + Some("sh" | "bash") => Ok(scan_hash(source, HashDialect::Shell, 0)), + Some("md") => Ok(scan_markdown(source)), + Some("json") => Ok(scan_json(source, 0)), + Some("lock") if path.file_name().and_then(|value| value.to_str()) == Some("Cargo.lock") => { + Ok(scan_cargo_lock(source)) + } + Some("lock") if path == Path::new("supply-chain/imports.lock") => { + Ok(scan_tool_toml(path, source)) + } + Some("txt") => Ok(Vec::new()), + None => scan_extensionless(path, source), + Some(other) => Err(format!( + "comment policy has no scanner for {} files: {}", + other, + path.display() + )), + } +} + +fn scan_tool_toml(path: &Path, source: &str) -> Vec { + let mut violations = scan_hash(source, HashDialect::Toml, 0); + violations.retain(|violation| { + let text = source + .lines() + .nth(violation.line.saturating_sub(1)) + .unwrap_or(""); + !exact_tool_directive(path, violation.line, text) + }); + violations +} + +fn exact_tool_directive(path: &Path, line: usize, text: &str) -> bool { + if line != 2 { + return false; + } + matches!( + (path, text), + (path, "# cargo-vet config file") + if path == Path::new("supply-chain/config.toml") + ) || matches!( + (path, text), + (path, "# cargo-vet audits file") + if path == Path::new("supply-chain/audits.toml") + ) || matches!( + (path, text), + (path, "# cargo-vet imports lock") + if path == Path::new("supply-chain/imports.lock") + ) +} + +fn scan_extensionless(path: &Path, source: &str) -> Result, String> { + let name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or(""); + if matches!( + name, + "justfile" | ".gitignore" | ".gitattributes" | "CODEOWNERS" + ) { + return Ok(scan_hash(source, HashDialect::Shell, 0)); + } + if name.starts_with("LICENSE") { + return Ok(Vec::new()); + } + Err(format!( + "comment policy has no scanner for extensionless file: {}", + path.display() + )) +} + +fn scan_cargo_lock(source: &str) -> Vec { + let mut violations = scan_hash(source, HashDialect::Toml, 0); + violations.retain(|violation| { + let line = source.lines().nth(violation.line.saturating_sub(1)); + !matches!( + (violation.line, line), + (1, Some("# This file is automatically @generated by Cargo.")) + | (2, Some("# It is not intended for manual editing.")) + ) + }); + violations +} + +fn scan_rust(path: &Path, source: &str) -> Vec { + let bytes = source.as_bytes(); + let mut violations = Vec::new(); + let mut index = 0; + let mut safety_line = None; + while index < bytes.len() { + if let Some(end) = raw_string_end(bytes, index) { + index = end; + continue; + } + match bytes[index] { + b'"' => { + index = quoted_end(bytes, index, b'"'); + } + b'\'' if char_literal_starts(bytes, index) => { + index = quoted_end(bytes, index, b'\''); + } + b'/' if bytes.get(index + 1) == Some(&b'/') => { + let end = bytes[index..] + .iter() + .position(|byte| *byte == b'\n') + .map_or(bytes.len(), |offset| index + offset); + let text = &source[index..end]; + let line = line_number(source, index); + let line_start = source[..index].rfind('\n').map_or(0, |at| at + 1); + let standalone = source[line_start..index].trim().is_empty(); + let outer_doc = text.starts_with("///") && !text.starts_with("////"); + let inner_doc = text.starts_with("//!"); + let safety = text.starts_with("// SAFETY:") && standalone; + let continuation = standalone + && safety_line.is_some_and(|previous| previous + 1 == line) + && !outer_doc + && !inner_doc; + if safety || continuation { + safety_line = Some(line); + } else { + safety_line = None; + if inner_doc { + if !is_crate_root(path) { + violations.push(Violation { + line, + reason: "Rustdoc is allowed only at crate roots and on public API", + }); + } + } else if outer_doc { + if !outer_doc_is_public(source, index, end) { + violations.push(Violation { + line, + reason: "Rustdoc on a private item is forbidden", + }); + } + } else { + violations.push(Violation { + line, + reason: "ordinary Rust line comment is forbidden", + }); + } + } + index = end; + } + b'/' if bytes.get(index + 1) == Some(&b'*') => { + let (end, terminated) = nested_block_end(bytes, index); + let text = &source[index..end]; + let line = line_number(source, index); + let outer_doc = text.starts_with("/**") && !text.starts_with("/***"); + let inner_doc = text.starts_with("/*!"); + if !terminated { + violations.push(Violation { + line, + reason: "unterminated Rust block comment", + }); + } else if inner_doc { + if !is_crate_root(path) { + violations.push(Violation { + line, + reason: "Rustdoc is allowed only at crate roots and on public API", + }); + } + } else if outer_doc { + if !outer_doc_is_public(source, index, end) { + violations.push(Violation { + line, + reason: "Rustdoc on a private item is forbidden", + }); + } + } else { + violations.push(Violation { + line, + reason: "ordinary Rust block comment is forbidden", + }); + } + safety_line = None; + index = end; + } + _ => { + index += 1; + } + } + } + violations +} + +fn raw_string_end(bytes: &[u8], start: usize) -> Option { + let mut cursor = start; + if bytes.get(cursor) == Some(&b'b') { + cursor += 1; + } + if bytes.get(cursor) != Some(&b'r') { + return None; + } + cursor += 1; + let hashes_start = cursor; + while bytes.get(cursor) == Some(&b'#') { + cursor += 1; + } + if bytes.get(cursor) != Some(&b'"') { + return None; + } + let hashes = cursor - hashes_start; + cursor += 1; + while cursor < bytes.len() { + if bytes[cursor] == b'"' + && bytes + .get(cursor + 1..cursor + 1 + hashes) + .is_some_and(|tail| tail.iter().all(|byte| *byte == b'#')) + { + return Some(cursor + 1 + hashes); + } + cursor += 1; + } + Some(bytes.len()) +} + +fn quoted_end(bytes: &[u8], start: usize, quote: u8) -> usize { + let mut cursor = start + 1; + while cursor < bytes.len() { + if bytes[cursor] == b'\\' { + cursor = (cursor + 2).min(bytes.len()); + } else if bytes[cursor] == quote { + return cursor + 1; + } else { + cursor += 1; + } + } + bytes.len() +} + +fn char_literal_starts(bytes: &[u8], start: usize) -> bool { + let Some(next) = bytes.get(start + 1) else { + return false; + }; + if *next == b'\\' { + bytes.get(start + 3) == Some(&b'\'') + } else { + bytes.get(start + 2) == Some(&b'\'') + } +} + +fn nested_block_end(bytes: &[u8], start: usize) -> (usize, bool) { + let mut depth = 1_usize; + let mut cursor = start + 2; + while cursor < bytes.len() { + if bytes.get(cursor..cursor + 2) == Some(b"/*") { + depth += 1; + cursor += 2; + } else if bytes.get(cursor..cursor + 2) == Some(b"*/") { + depth -= 1; + cursor += 2; + if depth == 0 { + return (cursor, true); + } + } else { + cursor += 1; + } + } + (bytes.len(), false) +} + +fn is_crate_root(path: &Path) -> bool { + let name = path.file_name().and_then(|value| value.to_str()); + name == Some("lib.rs") + || name == Some("main.rs") + || path.starts_with("tests") + || path.starts_with("examples") +} + +fn outer_doc_is_public(source: &str, start: usize, end: usize) -> bool { + if inside_public_enum_or_trait(source, start) { + return true; + } + let mut cursor = end; + loop { + cursor = skip_whitespace(source, cursor); + let tail = &source[cursor..]; + if tail.starts_with("///") { + cursor = tail + .find('\n') + .map_or(source.len(), |offset| cursor + offset + 1); + continue; + } + if tail.starts_with("#[") { + cursor = skip_attribute(source, cursor); + continue; + } + break; + } + let tail = &source[cursor..]; + tail.starts_with("pub ") + || tail.starts_with("pub\n") + || tail.starts_with("pub\t") + || tail.starts_with("unsafe pub ") + || tail.starts_with("pub unsafe ") +} + +fn inside_public_enum_or_trait(source: &str, position: usize) -> bool { + for marker in ["pub enum ", "pub trait ", "pub unsafe trait ", "pub union "] { + let Some(start) = source[..position].rfind(marker) else { + continue; + }; + let Some(open_offset) = source[start..position].find('{') else { + continue; + }; + let open = start + open_offset; + let mut depth = 0_i32; + for byte in source.as_bytes()[open..position].iter().copied() { + match byte { + b'{' => depth += 1, + b'}' => depth -= 1, + _ => {} + } + } + if depth > 0 { + return true; + } + } + false +} + +fn skip_whitespace(source: &str, mut cursor: usize) -> usize { + while source + .as_bytes() + .get(cursor) + .is_some_and(u8::is_ascii_whitespace) + { + cursor += 1; + } + cursor +} + +fn skip_attribute(source: &str, start: usize) -> usize { + let bytes = source.as_bytes(); + let mut cursor = start; + let mut depth = 0_i32; + while cursor < bytes.len() { + match bytes[cursor] { + b'[' => depth += 1, + b']' => { + depth -= 1; + if depth == 0 { + return cursor + 1; + } + } + b'"' => { + cursor = quoted_end(bytes, cursor, b'"'); + continue; + } + _ => {} + } + cursor += 1; + } + bytes.len() +} + +#[derive(Clone, Copy)] +enum HashDialect { + Toml, + PowerShell, + Shell, +} + +fn scan_hash(source: &str, dialect: HashDialect, line_offset: usize) -> Vec { + let mut violations = Vec::new(); + let mut multiline = None; + for (line_index, line) in source.lines().enumerate() { + let line_number = line_offset + line_index + 1; + let bytes = line.as_bytes(); + let mut index = 0; + let mut quote = multiline; + while index < bytes.len() { + if let Some((delimiter, triple)) = quote { + if triple && bytes.get(index..index + 3) == Some([delimiter; 3].as_slice()) { + quote = None; + index += 3; + } else if !triple && bytes[index] == delimiter { + quote = None; + index += 1; + } else if delimiter == b'"' && bytes[index] == b'\\' { + index = (index + 2).min(bytes.len()); + } else { + index += 1; + } + continue; + } + match bytes[index] { + b'\'' | b'"' => { + let delimiter = bytes[index]; + let triple = matches!(dialect, HashDialect::Toml) + && bytes.get(index..index + 3) == Some([delimiter; 3].as_slice()); + quote = Some((delimiter, triple)); + index += if triple { 3 } else { 1 }; + } + b'#' => { + let directive = match dialect { + HashDialect::PowerShell => line.trim_start().starts_with("#Requires "), + HashDialect::Shell => line_number == 1 && line.starts_with("#!"), + HashDialect::Toml => false, + }; + if !directive { + violations.push(Violation { + line: line_number, + reason: "ordinary hash comment is forbidden", + }); + } + break; + } + b'\\' if matches!(dialect, HashDialect::PowerShell) => { + index = (index + 2).min(bytes.len()); + } + _ => index += 1, + } + } + multiline = match dialect { + HashDialect::Toml => quote.filter(|(_, triple)| *triple), + HashDialect::PowerShell | HashDialect::Shell => None, + }; + } + violations +} + +fn scan_yaml(source: &str, line_offset: usize) -> Vec { + let mut violations = Vec::new(); + let mut block_indent = None; + for (line_index, line) in source.lines().enumerate() { + let line_number = line_offset + line_index + 1; + let indent = line.bytes().take_while(|byte| *byte == b' ').count(); + if let Some(parent_indent) = block_indent { + if line.trim().is_empty() || indent > parent_indent { + continue; + } + block_indent = None; + } + let bytes = line.as_bytes(); + let mut quote = None; + let mut index = 0; + let mut scalar_marker = false; + while index < bytes.len() { + match quote { + Some(b'"') if bytes[index] == b'\\' => { + index = (index + 2).min(bytes.len()); + } + Some(delimiter) if bytes[index] == delimiter => { + quote = None; + index += 1; + } + Some(_) => index += 1, + None => match bytes[index] { + b'\'' | b'"' => { + quote = Some(bytes[index]); + index += 1; + } + b'#' => { + violations.push(Violation { + line: line_number, + reason: "ordinary YAML comment is forbidden", + }); + break; + } + b'|' | b'>' => { + let prefix = line[..index].trim_end(); + if prefix.ends_with(':') || prefix.ends_with('-') { + scalar_marker = true; + } + index += 1; + } + _ => index += 1, + }, + } + } + if scalar_marker { + block_indent = Some(indent); + } + } + violations +} + +fn scan_json(source: &str, line_offset: usize) -> Vec { + let bytes = source.as_bytes(); + let mut violations = Vec::new(); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'"' { + index = quoted_end(bytes, index, b'"'); + } else if bytes.get(index..index + 2) == Some(b"//") + || bytes.get(index..index + 2) == Some(b"/*") + { + violations.push(Violation { + line: line_offset + line_number(source, index), + reason: "JSON comments are forbidden", + }); + index += 2; + } else { + index += 1; + } + } + violations +} + +fn scan_markdown(source: &str) -> Vec { + let mut violations = Vec::new(); + let mut fence: Option<(String, String, usize, String)> = None; + for (line_index, line) in source.lines().enumerate() { + let line_number = line_index + 1; + let trimmed = line.trim_start(); + if let Some((delimiter, language, start, content)) = &mut fence { + if trimmed.starts_with(delimiter.as_str()) + && trimmed[delimiter.len()..].trim().is_empty() + { + let mut nested = match language.as_str() { + "rust" => scan_rust(Path::new("examples/fenced.rs"), content), + "toml" => scan_hash(content, HashDialect::Toml, 0), + "yaml" | "yml" => scan_yaml(content, 0), + "powershell" => scan_hash(content, HashDialect::PowerShell, 0), + "sh" | "bash" => scan_hash(content, HashDialect::Shell, 0), + "json" => scan_json(content, 0), + "console" | "text" => Vec::new(), + _ => vec![Violation { + line: 1, + reason: "Markdown fence language has no comment scanner", + }], + }; + for violation in &mut nested { + violation.line += *start; + } + violations.extend(nested); + fence = None; + } else { + content.push_str(line); + content.push('\n'); + } + continue; + } + if trimmed.starts_with("```") || trimmed.starts_with("~~~") { + let delimiter = if trimmed.starts_with("```") { + "```" + } else { + "~~~" + }; + let language = trimmed[delimiter.len()..].trim(); + fence = Some(( + delimiter.to_owned(), + language.to_ascii_lowercase(), + line_number, + String::new(), + )); + } + if line.contains("") { + violations.push(Violation { + line: line_number, + reason: "HTML comments are forbidden", + }); + } + } + if let Some((_, _, start, _)) = fence { + violations.push(Violation { + line: start, + reason: "unterminated Markdown fence", + }); + } + violations +} + +fn line_number(source: &str, position: usize) -> usize { + source + .get(..position) + .map_or(1, |prefix| prefix.split('\n').count()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rust_distinguishes_raw_strings_urls_nested_comments_docs_and_safety() { + let accepted = r####" +#![allow(dead_code)] +//! crate docs +pub const URL: &str = "https://example.invalid/a//b"; +pub const RAW: &str = r###"/* data */ // data"###; +/// Public docs. +pub struct Public; +fn raw_boundary() { + // SAFETY: the test models one raw operation. + unsafe { std::hint::unreachable_unchecked() }; +} +"####; + assert!(scan_rust(Path::new("src/lib.rs"), accepted).is_empty()); + let rejected = "fn private() {}\n/// private docs\nfn hidden() {}\n/* outer /* inner */ end */\n// note\n"; + let violations = scan_rust(Path::new("src/module.rs"), rejected); + assert_eq!(violations.len(), 3); + } + + #[test] + fn yaml_block_scalars_and_quoted_hashes_are_data() { + let accepted = "url: \"https://example.invalid/#part\"\nscript: |\n # data\n echo ok # data\nnext: value\n"; + assert!(scan_yaml(accepted, 0).is_empty()); + assert_eq!(scan_yaml("key: value # explanation\n", 0).len(), 1); + } + + #[test] + fn markdown_scans_fences_and_html_comments() { + let accepted = + "Text with https://example.invalid/a//b.\n```rust\nlet value = r#\"// data\"#;\n```\n"; + assert!(scan_markdown(accepted).is_empty()); + assert_eq!(scan_markdown("\n").len(), 1); + assert_eq!(scan_markdown("```rust\n// hidden\n```\n").len(), 1); + } + + #[test] + fn exact_directives_are_the_only_hash_exceptions() { + assert!(scan_hash("#!/bin/sh\necho ok\n", HashDialect::Shell, 0).is_empty()); + assert!(scan_hash("#Requires -Version 7\n", HashDialect::PowerShell, 0).is_empty()); + assert_eq!(scan_hash("# explanation\n", HashDialect::Toml, 0).len(), 1); + } + + #[test] + fn generated_cargo_lock_headers_are_path_independent_and_exact() { + let accepted = + "# This file is automatically @generated by Cargo.\n# It is not intended for manual editing.\nversion = 4\n"; + assert!(scan(Path::new("fuzz/Cargo.lock"), accepted) + .expect("Cargo.lock has a scanner") + .is_empty()); + assert_eq!( + scan(Path::new("nested/Cargo.lock"), "# explanation\n") + .expect("Cargo.lock has a scanner") + .len(), + 1 + ); + } + + #[test] + fn cargo_vet_headers_are_exact_tool_directives() { + assert!(scan( + Path::new("supply-chain/config.toml"), + "\n# cargo-vet config file\n[cargo-vet]\nversion = \"0.10\"\n" + ) + .expect("cargo-vet config has a scanner") + .is_empty()); + assert_eq!( + scan( + Path::new("supply-chain/config.toml"), + "\n# cargo-vet configuration\n" + ) + .expect("cargo-vet config has a scanner") + .len(), + 1 + ); + assert!(scan( + Path::new("supply-chain/imports.lock"), + "\n# cargo-vet imports lock\n" + ) + .expect("cargo-vet imports lock has a scanner") + .is_empty()); + } +} diff --git a/xtask/src/invariant_registry.rs b/xtask/src/invariant_registry.rs new file mode 100644 index 0000000..c277c07 --- /dev/null +++ b/xtask/src/invariant_registry.rs @@ -0,0 +1,130 @@ +use std::collections::BTreeSet; +use std::path::{Component, Path}; + +use serde::Deserialize; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Registry { + version: u32, + invariant: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Invariant { + id: String, + target: String, + enforcement: Vec, + artifacts: Vec, + trust_boundaries: Vec, +} + +#[derive(Clone, Copy, Deserialize)] +#[serde(rename_all = "kebab-case")] +enum Enforcement { + Type, + Const, + Lint, + Kani, +} + +#[derive(Clone, Copy, Deserialize)] +#[serde(rename_all = "kebab-case")] +enum TrustBoundary { + RustCompiler, + StandardLibrary, + WindowsApi, + UnsafePseudoconsoleImplementor, +} + +pub(crate) fn check(root: &Path) -> Result<(), String> { + let path = root.join("quality").join("invariants.toml"); + let source = std::fs::read_to_string(&path) + .map_err(|error| format!("failed to read {}: {error}", path.display()))?; + let registry: Registry = + toml::from_str(&source).map_err(|error| format!("invalid invariant registry: {error}"))?; + validate(root, ®istry) +} + +fn validate(root: &Path, registry: &Registry) -> Result<(), String> { + if registry.version != 1 { + return Err(format!( + "unsupported invariant registry version: {}", + registry.version + )); + } + if registry.invariant.is_empty() { + return Err("invariant registry must not be empty".to_owned()); + } + let mut ids = BTreeSet::new(); + for invariant in ®istry.invariant { + if !valid_id(&invariant.id) { + return Err(format!("invalid invariant id: {}", invariant.id)); + } + if !ids.insert(invariant.id.as_str()) { + return Err(format!("duplicate invariant id: {}", invariant.id)); + } + if invariant.target.trim().is_empty() { + return Err(format!("{} has an empty target", invariant.id)); + } + if invariant.enforcement.is_empty() { + return Err(format!("{} has no static enforcement", invariant.id)); + } + if invariant.artifacts.is_empty() { + return Err(format!("{} has no evidence artifact", invariant.id)); + } + if invariant.trust_boundaries.is_empty() { + return Err(format!("{} has no trust boundary", invariant.id)); + } + for artifact in &invariant.artifacts { + validate_artifact(root, &invariant.id, artifact)?; + } + } + Ok(()) +} + +fn valid_id(id: &str) -> bool { + id.len() == 7 && id.starts_with("INV-") && id.as_bytes()[4..].iter().all(u8::is_ascii_digit) +} + +fn validate_artifact(root: &Path, id: &str, artifact: &str) -> Result<(), String> { + let path = Path::new(artifact); + if path.is_absolute() + || path + .components() + .any(|component| matches!(component, Component::ParentDir | Component::RootDir)) + { + return Err(format!("{id} has an invalid artifact path: {artifact}")); + } + if !root.join(path).exists() { + return Err(format!("{id} artifact does not exist: {artifact}")); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn enforcement_and_trust_boundaries_are_closed_sets() { + let forbidden = r#" +version = 1 +[[invariant]] +id = "INV-001" +target = "example" +enforcement = ["comment"] +artifacts = ["Cargo.toml"] +trust_boundaries = ["reviewer"] +"#; + assert!(toml::from_str::(forbidden).is_err()); + } + + #[test] + fn ids_have_a_canonical_shape() { + assert!(valid_id("INV-001")); + assert!(!valid_id("INV-1")); + assert!(!valid_id("inv-001")); + } +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index ae08b1f..d65bd00 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -1,6 +1,9 @@ //! Repository automation for windows-spawn. mod cli; +mod comment_policy; +mod invariant_registry; +mod source_policy; mod tasks; use std::env; diff --git a/xtask/src/source_policy.rs b/xtask/src/source_policy.rs new file mode 100644 index 0000000..1d0a68d --- /dev/null +++ b/xtask/src/source_policy.rs @@ -0,0 +1,403 @@ +use std::collections::BTreeSet; +use std::fmt::Write as _; +use std::fs; +use std::path::{Path, PathBuf}; + +use syn::visit::{self, Visit}; +use syn::{Attribute, Expr, FnArg, Item, Meta, Signature, Type}; + +pub(crate) fn check(root: &Path) -> Result<(), String> { + let source_root = root.join("src"); + let mut paths = Vec::new(); + collect_rust_files(&source_root, &mut paths)?; + paths.sort(); + let mut report = String::new(); + for path in paths { + let source = fs::read_to_string(&path) + .map_err(|error| format!("failed to read {}: {error}", path.display()))?; + let syntax = syn::parse_file(&source) + .map_err(|error| format!("failed to parse {}: {error}", path.display()))?; + let relative = path + .strip_prefix(root) + .map_err(|error| format!("failed to relativize {}: {error}", path.display()))?; + let raw_boundary = relative == Path::new("src").join("sys.rs"); + let pseudoconsole_boundary = relative == Path::new("src").join("handles.rs"); + let handle_value_boundary = relative == Path::new("src").join("resource.rs"); + let violations = inspect( + &syntax, + raw_boundary, + pseudoconsole_boundary, + handle_value_boundary, + ); + for violation in violations { + let _ = writeln!(report, "{}: {violation}", relative.display()); + } + } + if report.is_empty() { + Ok(()) + } else { + Err(report) + } +} + +fn collect_rust_files(directory: &Path, paths: &mut Vec) -> Result<(), String> { + let entries = fs::read_dir(directory) + .map_err(|error| format!("failed to read {}: {error}", directory.display()))?; + for entry in entries { + let entry = entry + .map_err(|error| format!("failed to enumerate {}: {error}", directory.display()))?; + let path = entry.path(); + let file_type = entry + .file_type() + .map_err(|error| format!("failed to inspect {}: {error}", path.display()))?; + if file_type.is_dir() { + collect_rust_files(&path, paths)?; + } else if path.extension().and_then(|value| value.to_str()) == Some("rs") { + paths.push(path); + } + } + Ok(()) +} + +fn inspect( + file: &syn::File, + raw_boundary: bool, + pseudoconsole_boundary: bool, + handle_value_boundary: bool, +) -> BTreeSet<&'static str> { + let mut inspector = Inspector { + raw_boundary, + pseudoconsole_boundary, + handle_value_boundary, + violations: BTreeSet::new(), + }; + inspector.check_attributes(&file.attrs, raw_boundary); + inspector.visit_file(file); + inspector.violations +} + +struct Inspector { + raw_boundary: bool, + pseudoconsole_boundary: bool, + handle_value_boundary: bool, + violations: BTreeSet<&'static str>, +} + +impl Inspector { + fn reject(&mut self, message: &'static str) { + self.violations.insert(message); + } + + fn check_attributes(&mut self, attributes: &[Attribute], permits_unsafe_allow: bool) { + for attribute in attributes { + if attribute.path().is_ident("allow") || attribute.path().is_ident("expect") { + let permitted = permits_unsafe_allow + && attribute.path().is_ident("allow") + && attribute_tokens(attribute) == "unsafe_code"; + if !permitted { + self.reject("lint suppression is forbidden"); + } + } + if attribute.path().is_ident("cfg_attr") { + let tokens = attribute_tokens(attribute); + if tokens.contains("allow") || tokens.contains("expect") { + self.reject("conditional lint suppression is forbidden"); + } + } + } + } + + fn check_signature(&mut self, signature: &Signature) { + for input in &signature.inputs { + if let FnArg::Typed(input) = input { + if is_named_type(&input.ty, "bool") { + self.reject("semantic bool parameter is forbidden"); + } + } + } + let allowed_unsafe = + self.raw_boundary || (self.pseudoconsole_boundary && signature.ident == "from_raw"); + if signature.unsafety.is_some() && !allowed_unsafe { + self.reject("unsafe function outside a declared boundary"); + } + } + + fn check_item(&mut self, item: &Item) { + let attributes = match item { + Item::Const(value) => &value.attrs, + Item::Enum(value) => &value.attrs, + Item::ExternCrate(value) => &value.attrs, + Item::Fn(value) => &value.attrs, + Item::ForeignMod(value) => &value.attrs, + Item::Impl(value) => &value.attrs, + Item::Macro(value) => &value.attrs, + Item::Mod(value) => &value.attrs, + Item::Static(value) => &value.attrs, + Item::Struct(value) => &value.attrs, + Item::Trait(value) => &value.attrs, + Item::TraitAlias(value) => &value.attrs, + Item::Type(value) => &value.attrs, + Item::Union(value) => &value.attrs, + Item::Use(value) => &value.attrs, + _ => return, + }; + self.check_attributes(attributes, self.raw_boundary); + } +} + +impl<'ast> Visit<'ast> for Inspector { + fn visit_item(&mut self, item: &'ast Item) { + if item_attributes(item).is_some_and(is_test_only) { + return; + } + if !matches!( + item, + Item::Fn(_) | Item::Impl(_) | Item::Mod(_) | Item::Trait(_) + ) { + self.check_item(item); + } + visit::visit_item(self, item); + } + + fn visit_item_mod(&mut self, item: &'ast syn::ItemMod) { + if is_test_only(&item.attrs) { + return; + } + let permits_unsafe_allow = self.raw_boundary || item.ident == "sys"; + self.check_attributes(&item.attrs, permits_unsafe_allow); + visit::visit_item_mod(self, item); + } + + fn visit_item_fn(&mut self, item: &'ast syn::ItemFn) { + if is_test_only(&item.attrs) { + return; + } + self.check_attributes( + &item.attrs, + self.raw_boundary || item.sig.unsafety.is_some(), + ); + self.check_signature(&item.sig); + visit::visit_item_fn(self, item); + } + + fn visit_impl_item_fn(&mut self, item: &'ast syn::ImplItemFn) { + if is_test_only(&item.attrs) { + return; + } + self.check_attributes( + &item.attrs, + self.raw_boundary || item.sig.unsafety.is_some(), + ); + self.check_signature(&item.sig); + visit::visit_impl_item_fn(self, item); + } + + fn visit_trait_item_fn(&mut self, item: &'ast syn::TraitItemFn) { + self.check_attributes( + &item.attrs, + self.raw_boundary || item.sig.unsafety.is_some(), + ); + self.check_signature(&item.sig); + visit::visit_trait_item_fn(self, item); + } + + fn visit_item_trait(&mut self, item: &'ast syn::ItemTrait) { + if is_test_only(&item.attrs) { + return; + } + self.check_attributes(&item.attrs, self.raw_boundary || item.unsafety.is_some()); + if item.unsafety.is_some() && !self.raw_boundary { + let allowed = self.pseudoconsole_boundary && item.ident == "AsPseudoConsole"; + if !allowed { + self.reject("unsafe trait outside a declared boundary"); + } + } + visit::visit_item_trait(self, item); + } + + fn visit_item_impl(&mut self, item: &'ast syn::ItemImpl) { + if is_test_only(&item.attrs) { + return; + } + self.check_attributes(&item.attrs, self.raw_boundary || item.unsafety.is_some()); + if item.unsafety.is_some() && !self.raw_boundary { + self.reject("unsafe impl outside a declared boundary"); + } + visit::visit_item_impl(self, item); + } + + fn visit_field(&mut self, field: &'ast syn::Field) { + self.check_attributes(&field.attrs, self.raw_boundary); + if is_named_type(&field.ty, "bool") { + self.reject("semantic bool field is forbidden"); + } + visit::visit_field(self, field); + } + + fn visit_expr_method_call(&mut self, expression: &'ast syn::ExprMethodCall) { + match expression.method.to_string().as_str() { + "unwrap" => self.reject("unwrap is forbidden in production code"), + "expect" => self.reject("expect is forbidden in production code"), + _ => {} + } + visit::visit_expr_method_call(self, expression); + } + + fn visit_expr_macro(&mut self, expression: &'ast syn::ExprMacro) { + if let Some(name) = expression.mac.path.segments.last() { + match name.ident.to_string().as_str() { + "panic" | "todo" | "unimplemented" | "unreachable" => { + self.reject("panic path is forbidden in production code"); + } + _ => {} + } + } + visit::visit_expr_macro(self, expression); + } + + fn visit_expr_call(&mut self, expression: &'ast syn::ExprCall) { + if let Expr::Path(path) = expression.func.as_ref() { + let segments: Vec = path + .path + .segments + .iter() + .map(|segment| segment.ident.to_string()) + .collect(); + if segments.ends_with(&["mem".to_owned(), "forget".to_owned()]) { + self.reject("mem::forget is forbidden in production code"); + } + } + visit::visit_expr_call(self, expression); + } + + fn visit_expr_index(&mut self, expression: &'ast syn::ExprIndex) { + self.reject("unchecked indexing is forbidden in production code"); + visit::visit_expr_index(self, expression); + } + + fn visit_expr_cast(&mut self, expression: &'ast syn::ExprCast) { + if !self.raw_boundary { + self.reject("unchecked cast outside the raw adapter is forbidden"); + } + visit::visit_expr_cast(self, expression); + } + + fn visit_expr_unsafe(&mut self, expression: &'ast syn::ExprUnsafe) { + if !self.raw_boundary { + self.reject("unsafe block outside the raw adapter is forbidden"); + } + visit::visit_expr_unsafe(self, expression); + } + + fn visit_type_path(&mut self, path: &'ast syn::TypePath) { + if let Some(segment) = path.path.segments.last() { + let name = segment.ident.to_string(); + let raw_representation_allowed = self.raw_boundary || self.handle_value_boundary; + if !raw_representation_allowed + && matches!(name.as_str(), "isize" | "RawHandle" | "HANDLE") + { + self.reject("raw handle representation outside the raw adapter is forbidden"); + } + if matches!( + name.as_str(), + "ManuallyDrop" + | "Mutex" + | "RwLock" + | "AtomicBool" + | "AtomicI8" + | "AtomicI16" + | "AtomicI32" + | "AtomicI64" + | "AtomicIsize" + | "AtomicPtr" + | "AtomicU8" + | "AtomicU16" + | "AtomicU32" + | "AtomicU64" + | "AtomicUsize" + | "UnsafeCell" + ) { + self.reject("unregistered ownership or synchronization primitive is forbidden"); + } + } + visit::visit_type_path(self, path); + } +} + +fn item_attributes(item: &Item) -> Option<&[Attribute]> { + match item { + Item::Const(value) => Some(&value.attrs), + Item::Enum(value) => Some(&value.attrs), + Item::ExternCrate(value) => Some(&value.attrs), + Item::Fn(value) => Some(&value.attrs), + Item::ForeignMod(value) => Some(&value.attrs), + Item::Impl(value) => Some(&value.attrs), + Item::Macro(value) => Some(&value.attrs), + Item::Mod(value) => Some(&value.attrs), + Item::Static(value) => Some(&value.attrs), + Item::Struct(value) => Some(&value.attrs), + Item::Trait(value) => Some(&value.attrs), + Item::TraitAlias(value) => Some(&value.attrs), + Item::Type(value) => Some(&value.attrs), + Item::Union(value) => Some(&value.attrs), + Item::Use(value) => Some(&value.attrs), + _ => None, + } +} + +fn is_test_only(attributes: &[Attribute]) -> bool { + attributes.iter().any(|attribute| { + attribute.path().is_ident("cfg") && attribute_tokens(attribute).replace(' ', "") == "test" + }) +} + +fn attribute_tokens(attribute: &Attribute) -> String { + match &attribute.meta { + Meta::List(list) => list.tokens.to_string(), + Meta::NameValue(_) | Meta::Path(_) => String::new(), + } +} + +fn is_named_type(value: &Type, expected: &str) -> bool { + let Type::Path(path) = value else { + return false; + }; + path.path + .segments + .last() + .is_some_and(|segment| segment.ident == expected) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn violations(source: &str) -> BTreeSet<&'static str> { + inspect(&syn::parse_file(source).unwrap(), false, false, false) + } + + #[test] + fn accepts_closed_inputs_and_checked_access() { + let source = "enum Policy { On, Off } fn apply(value: Policy, items: &[u8]) -> Option<&u8> { items.get(0) }"; + assert!(violations(source).is_empty()); + } + + #[test] + fn rejects_forbidden_production_constructs() { + for source in [ + "fn apply(value: bool) {}", + "fn apply(value: Option) { value.unwrap(); }", + "fn apply(values: &[u8]) -> u8 { values[0] }", + "fn apply(value: u8) -> u64 { value as u64 }", + "fn apply() { let _: std::sync::Mutex; }", + ] { + assert!(!violations(source).is_empty(), "{source}"); + } + } + + #[test] + fn ignores_test_only_modules() { + let source = "#[cfg(test)] mod tests { fn assertion() { panic!() } }"; + assert!(violations(source).is_empty()); + } +} diff --git a/xtask/src/tasks.rs b/xtask/src/tasks.rs index fa2f686..3c37602 100644 --- a/xtask/src/tasks.rs +++ b/xtask/src/tasks.rs @@ -89,6 +89,8 @@ impl From for TaskError { const PACKAGE_NAME: &str = "windows-spawn"; const PUBLIC_API_TOOLCHAIN: &str = "nightly-2026-07-02"; +const KANI_VERSION: &str = "0.68.0"; +const CARGO_VET_VERSION: &str = "0.10.2"; #[derive(Clone, Debug, Eq, PartialEq)] struct PackageInfo { @@ -126,6 +128,7 @@ fn print_help() { "\ Repository tasks: cargo xtask fmt|clippy|test|doc|msrv|cross-targets|linux-empty + cargo xtask build-ocomment|comment-policy|invariant-registry|source-policy|kani cargo xtask supply-chain|reuse|typos|coverage|ci cargo xtask public-api [--update] cargo xtask package-check [--allow-dirty] @@ -140,6 +143,17 @@ Repository tasks: fn run_simple(root: &Path, task: SimpleTask) -> Result<()> { match task { + SimpleTask::BuildOComment => { + crate::comment_policy::build_from_source(root).map_err(TaskError::from) + } + SimpleTask::CommentPolicy => { + crate::comment_policy::check_repository(root).map_err(TaskError::from) + } + SimpleTask::InvariantRegistry => { + crate::invariant_registry::check(root).map_err(TaskError::from) + } + SimpleTask::SourcePolicy => crate::source_policy::check(root).map_err(TaskError::from), + SimpleTask::Kani => kani(root), SimpleTask::Fmt => run_cargo(root, &["fmt", "--all", "--", "--check"]), SimpleTask::Clippy => run_cargo( root, @@ -147,37 +161,37 @@ fn run_simple(root: &Path, task: SimpleTask) -> Result<()> { "clippy", "--workspace", "--all-targets", + "--all-features", "--locked", "--", "-D", "warnings", ], ), - SimpleTask::Test => { - run_cargo( - root, - &[ - "test", - "--workspace", - "--all-targets", - "--locked", - "--", - "--test-threads=1", - ], - )?; - run_cargo(root, &["test", "--workspace", "--doc", "--locked"]) - } + SimpleTask::Test => run_tests(root), SimpleTask::Doc => { let mut command = cargo(root); command - .args(["doc", "--workspace", "--no-deps", "--locked"]) + .args([ + "doc", + "--workspace", + "--all-features", + "--no-deps", + "--locked", + ]) .env("RUSTDOCFLAGS", "-D warnings"); run(&mut command) } SimpleTask::Msrv => run_cargo_with_toolchain( root, "1.75", - &["check", "--workspace", "--all-targets", "--locked"], + &[ + "check", + "--workspace", + "--all-targets", + "--all-features", + "--locked", + ], ), SimpleTask::CrossTargets => { for target in [ @@ -191,6 +205,7 @@ fn run_simple(root: &Path, task: SimpleTask) -> Result<()> { "check", "--package", PACKAGE_NAME, + "--all-features", "--locked", "--target", target, @@ -211,9 +226,7 @@ fn run_simple(root: &Path, task: SimpleTask) -> Result<()> { "x86_64-unknown-linux-gnu", ], ), - SimpleTask::SupplyChain => { - run_cargo(root, &["deny", "--all-features", "--locked", "check"]) - } + SimpleTask::SupplyChain => supply_chain(root), SimpleTask::Reuse => run_program(root, "python", &["-m", "reuse", "lint"]), SimpleTask::Typos => run_program(root, "typos", &[]), SimpleTask::Coverage => coverage(root), @@ -221,8 +234,71 @@ fn run_simple(root: &Path, task: SimpleTask) -> Result<()> { } } +fn run_tests(root: &Path) -> Result<()> { + run_cargo( + root, + &[ + "nextest", + "run", + "--workspace", + "--all-targets", + "--locked", + "--profile", + "ci-default", + ], + )?; + run_cargo( + root, + &[ + "nextest", + "run", + "--workspace", + "--all-targets", + "--all-features", + "--locked", + "--profile", + "ci-all", + ], + )?; + run_cargo(root, &["test", "--workspace", "--doc", "--locked"])?; + run_cargo( + root, + &["test", "--workspace", "--doc", "--all-features", "--locked"], + ) +} + +fn kani(root: &Path) -> Result<()> { + let mut version_command = cargo(root); + version_command.args(["kani", "--version"]); + let version = capture(&mut version_command)?; + if !version.contains(KANI_VERSION) { + return fail(format!( + "Kani version mismatch: expected {KANI_VERSION}, got {}", + version.trim() + )); + } + run_cargo(root, &["kani", "--package", PACKAGE_NAME]) +} + +fn supply_chain(root: &Path) -> Result<()> { + run_cargo(root, &["deny", "--all-features", "--locked", "check"])?; + let mut version_command = cargo(root); + version_command.args(["vet", "--version"]); + let version = capture(&mut version_command)?; + if !version.contains(CARGO_VET_VERSION) { + return fail(format!( + "cargo-vet version mismatch: expected {CARGO_VET_VERSION}, got {}", + version.trim() + )); + } + run_cargo(root, &["vet", "--locked"]) +} + fn run_ci(root: &Path) -> Result<()> { for check in [ + SimpleTask::CommentPolicy, + SimpleTask::InvariantRegistry, + SimpleTask::SourcePolicy, SimpleTask::Fmt, SimpleTask::Clippy, SimpleTask::Test, @@ -241,30 +317,37 @@ fn run_ci(root: &Path) -> Result<()> { } fn coverage(root: &Path) -> Result<()> { - run_cargo(root, &["llvm-cov", "clean", "--workspace"])?; - run_cargo( + run_cargo_with_toolchain( + root, + PUBLIC_API_TOOLCHAIN, + &["llvm-cov", "clean", "--workspace"], + )?; + run_cargo_with_toolchain( root, + PUBLIC_API_TOOLCHAIN, &[ "llvm-cov", "--package", PACKAGE_NAME, "--all-targets", + "--all-features", "--locked", "--", "--test-threads=1", ], )?; - run_cargo( + run_cargo_with_toolchain( root, + PUBLIC_API_TOOLCHAIN, &[ "llvm-cov", "report", "--fail-under-lines", - "92", + "100", "--fail-under-regions", - "92", + "100", "--fail-under-functions", - "92", + "100", ], ) } @@ -651,15 +734,12 @@ fn sha256(path: &Path) -> Result { Ok(lower_hex(&digest.finalize())) } -// `sha2` 0.11 returns `hybrid_array::Array` instead of `GenericArray`, and that -// type no longer implements `LowerHex`. Formatting the bytes ourselves keeps the -// checksum output identical across both generations of the crate. fn lower_hex(bytes: &[u8]) -> String { - use std::fmt::Write as _; - + const HEX: &[u8; 16] = b"0123456789abcdef"; let mut output = String::with_capacity(bytes.len() * 2); for byte in bytes { - write!(&mut output, "{byte:02x}").expect("writing to a String cannot fail"); + output.push(char::from(HEX[usize::from(byte >> 4)])); + output.push(char::from(HEX[usize::from(byte & 0x0f)])); } output } @@ -761,7 +841,7 @@ fn append_github_output(path: &Path, key: &str, value: &str) -> Result<()> { #[cfg(windows)] fn run_mutants(root: &Path, output: Option, forwarded: &[String]) -> Result { - use windows_spawn::{Command as SpawnCommand, DropPolicy, SpawnOptions}; + use windows_spawn::{Command as SpawnCommand, JobClosePolicy, SpawnOptions}; let output = mutation_output(root, output)?; println!("cargo-mutants output: {}", output.display()); @@ -772,7 +852,9 @@ fn run_mutants(root: &Path, output: Option, forwarded: &[String]) -> Re .args(forwarded) .env("CARGO_MUTANTS_OUTPUT", &output) .current_dir(root); - let status = command.status_with(SpawnOptions::new().drop_policy(DropPolicy::KillTree))?; + let status = command + .status_with(SpawnOptions::new().job_close_policy(JobClosePolicy::TerminateProcesses)) + .map_err(io::Error::from)?; Ok(status.code().unwrap_or(1)) } From cb2c15ba12647c2efda48a49999a328e859756a0 Mon Sep 17 00:00:00 2001 From: Yasunobu <42543015+P4suta@users.noreply.github.com> Date: Sat, 19 Sep 2026 21:18:54 +0900 Subject: [PATCH 3/7] ci: isolate REUSE with pinned uvx --- .github/workflows/ci.yml | 14 +++++++++----- .github/workflows/deep-verification.yml | 2 +- .github/workflows/release-finalize.yml | 4 +++- REUSE.toml | 13 ++++++++++++- xtask/src/tasks.rs | 18 ++++++++++++++---- 5 files changed, 39 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 646ce6c..1e1a9c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,9 @@ jobs: - uses: taiki-e/install-action@6a1bd70eaac3c8bdf093356838d7ee09fda951cf with: tool: just@1.57.0,cargo-public-api@0.52.0,cargo-deny@0.19.4,cargo-vet@0.10.2,typos-cli@1.48.0 - - run: python -m pip install "reuse[charset-normalizer]==6.2.0" + - uses: astral-sh/setup-uv@94527f2e458b27549849d47d273a16bec83a01e9 + with: + version: 0.12.16 - run: just ocomment - run: just comment-policy - run: just invariants @@ -57,7 +59,7 @@ jobs: strategy: fail-fast: false matrix: - os: [windows-2019, windows-2022, windows-latest] + os: [windows-2022, windows-2025] rust: [stable, "1.75"] runs-on: ${{ matrix.os }} timeout-minutes: 15 @@ -159,7 +161,7 @@ jobs: components: miri - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 - run: cargo miri setup - - run: cargo miri test --package windows-spawn --lib core_logic::tests:: + - run: 'cargo miri test --package windows-spawn --lib core_logic::tests::' sanitizer: name: Address sanitizer pure logic @@ -173,7 +175,7 @@ jobs: with: toolchain: nightly-2026-07-02 - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 - - run: cargo test --package windows-spawn --target x86_64-unknown-linux-gnu --lib core_logic::tests:: + - run: 'cargo test --package windows-spawn --target x86_64-unknown-linux-gnu --lib core_logic::tests::' careful: name: Windows cargo-careful @@ -228,7 +230,9 @@ jobs: - uses: taiki-e/install-action@6a1bd70eaac3c8bdf093356838d7ee09fda951cf with: tool: just@1.57.0,cargo-cyclonedx@0.5.9 - - run: python -m pip install "reuse[charset-normalizer]==6.2.0" + - uses: astral-sh/setup-uv@94527f2e458b27549849d47d273a16bec83a01e9 + with: + version: 0.12.16 - run: just release-candidate release-dry-run: diff --git a/.github/workflows/deep-verification.yml b/.github/workflows/deep-verification.yml index 6a7c0e1..78fa4ba 100644 --- a/.github/workflows/deep-verification.yml +++ b/.github/workflows/deep-verification.yml @@ -21,7 +21,7 @@ jobs: with: toolchain: stable - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 - - run: cargo test --lib core_logic::tests::generated_quoting_and_fields_preserve_properties -- --exact + - run: 'cargo test --lib core_logic::tests::generated_quoting_and_fields_preserve_properties -- --exact' stress-x64: name: Windows x64 nightly stress diff --git a/.github/workflows/release-finalize.yml b/.github/workflows/release-finalize.yml index 3b27aaf..c1142cb 100644 --- a/.github/workflows/release-finalize.yml +++ b/.github/workflows/release-finalize.yml @@ -43,7 +43,9 @@ jobs: - uses: taiki-e/install-action@6a1bd70eaac3c8bdf093356838d7ee09fda951cf with: tool: cargo-cyclonedx@0.5.9 - - run: python -m pip install "reuse[charset-normalizer]==6.2.0" + - uses: astral-sh/setup-uv@94527f2e458b27549849d47d273a16bec83a01e9 + with: + version: 0.12.16 - name: Verify tag, commit, and Cargo version run: cargo xtask verify-release-tag "$env:RELEASE_TAG" - name: Build and verify release candidate diff --git a/REUSE.toml b/REUSE.toml index 45f3482..374f7e9 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -4,6 +4,7 @@ version = 1 path = [ ".github/**", ".cargo/**", + ".config/**", ".gitattributes", ".gitignore", "_typos.toml", @@ -16,9 +17,15 @@ path = [ "deny.toml", "docs/**", "examples/**", + "fuzz/Cargo.toml", + "fuzz/fuzz_targets/**", "justfile", "public-api/**", + "quality/**", + "release-plz.toml", "src/**", + "supply-chain/audits.toml", + "supply-chain/config.toml", "tests/**", "xtask/**", ] @@ -27,7 +34,11 @@ SPDX-FileCopyrightText = "2026 Yasunobu Sakashita" SPDX-License-Identifier = "MIT OR Apache-2.0" [[annotations]] -path = "Cargo.lock" +path = [ + "Cargo.lock", + "fuzz/Cargo.lock", + "supply-chain/imports.lock", +] precedence = "override" SPDX-FileCopyrightText = "NONE" SPDX-License-Identifier = "CC0-1.0" diff --git a/xtask/src/tasks.rs b/xtask/src/tasks.rs index 3c37602..f01749c 100644 --- a/xtask/src/tasks.rs +++ b/xtask/src/tasks.rs @@ -91,6 +91,7 @@ const PACKAGE_NAME: &str = "windows-spawn"; const PUBLIC_API_TOOLCHAIN: &str = "nightly-2026-07-02"; const KANI_VERSION: &str = "0.68.0"; const CARGO_VET_VERSION: &str = "0.10.2"; +const REUSE_PACKAGE: &str = "reuse[charset-normalizer]==6.2.0"; #[derive(Clone, Debug, Eq, PartialEq)] struct PackageInfo { @@ -227,7 +228,7 @@ fn run_simple(root: &Path, task: SimpleTask) -> Result<()> { ], ), SimpleTask::SupplyChain => supply_chain(root), - SimpleTask::Reuse => run_program(root, "python", &["-m", "reuse", "lint"]), + SimpleTask::Reuse => run_reuse(root, &["lint"]), SimpleTask::Typos => run_program(root, "typos", &[]), SimpleTask::Coverage => coverage(root), SimpleTask::Ci => run_ci(root), @@ -409,7 +410,16 @@ fn check_packaged_reuse(root: &Path) -> Result<()> { expanded.display() )); } - run_program(&expanded, "python", &["-m", "reuse", "lint"]) + run_reuse(&expanded, &["lint"]) +} + +fn run_reuse(root: &Path, arguments: &[&str]) -> Result<()> { + let mut command = Command::new("uvx"); + command + .current_dir(root) + .args(["--from", REUSE_PACKAGE, "reuse"]) + .args(arguments); + run(&mut command) } fn prepare_sbom_package(root: &Path) -> Result<()> { @@ -494,10 +504,10 @@ fn generate_sboms( fs::remove_file(&generated)?; } - let mut reuse = Command::new("python"); + let mut reuse = Command::new("uvx"); reuse .current_dir(root) - .args(["-m", "reuse", "spdx", "-o"]) + .args(["--from", REUSE_PACKAGE, "reuse", "spdx", "-o"]) .arg(&reuse_spdx); run(&mut reuse)?; From d603643845400dee1d2fc90314f0b8bc54d63fa7 Mon Sep 17 00:00:00 2001 From: Yasunobu <42543015+P4suta@users.noreply.github.com> Date: Sat, 19 Sep 2026 21:24:04 +0900 Subject: [PATCH 4/7] ci: make fuzz and mutation runners deterministic --- .github/workflows/fuzz.yml | 4 ++-- .github/workflows/mutation.yml | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index d94c998..5ef4e76 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -27,7 +27,7 @@ jobs: - uses: taiki-e/install-action@6a1bd70eaac3c8bdf093356838d7ee09fda951cf with: tool: cargo-fuzz@0.13.2 - - run: cargo fuzz run ${{ matrix.target }} -- -max_total_time=1800 + - run: cargo fuzz run ${{ matrix.target }} --target x86_64-unknown-linux-gnu -- -max_total_time=1800 - if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: @@ -54,7 +54,7 @@ jobs: - uses: taiki-e/install-action@6a1bd70eaac3c8bdf093356838d7ee09fda951cf with: tool: cargo-fuzz@0.13.2 - - run: cargo fuzz run ${{ matrix.target }} -- -max_total_time=21600 + - run: cargo fuzz run ${{ matrix.target }} --target x86_64-unknown-linux-gnu -- -max_total_time=21600 - if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index 317e5f1..2518145 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -12,6 +12,8 @@ jobs: name: Mutants ${{ matrix.shard }}/4 runs-on: windows-latest timeout-minutes: 240 + env: + RUST_TEST_THREADS: "1" strategy: fail-fast: false matrix: From cf4f842a0ce3dd508a5675cc1d271eb1dddff197 Mon Sep 17 00:00:00 2001 From: Yasunobu <42543015+P4suta@users.noreply.github.com> Date: Sat, 19 Sep 2026 21:30:29 +0900 Subject: [PATCH 5/7] ci: install uv through the approved action --- .github/workflows/ci.yml | 10 ++-------- .github/workflows/release-finalize.yml | 5 +---- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e1a9c1..866d344 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,10 +36,7 @@ jobs: - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 - uses: taiki-e/install-action@6a1bd70eaac3c8bdf093356838d7ee09fda951cf with: - tool: just@1.57.0,cargo-public-api@0.52.0,cargo-deny@0.19.4,cargo-vet@0.10.2,typos-cli@1.48.0 - - uses: astral-sh/setup-uv@94527f2e458b27549849d47d273a16bec83a01e9 - with: - version: 0.12.16 + tool: just@1.57.0,cargo-public-api@0.52.0,cargo-deny@0.19.4,cargo-vet@0.10.2,typos-cli@1.48.0,uv@0.12.0 - run: just ocomment - run: just comment-policy - run: just invariants @@ -229,10 +226,7 @@ jobs: toolchain: stable - uses: taiki-e/install-action@6a1bd70eaac3c8bdf093356838d7ee09fda951cf with: - tool: just@1.57.0,cargo-cyclonedx@0.5.9 - - uses: astral-sh/setup-uv@94527f2e458b27549849d47d273a16bec83a01e9 - with: - version: 0.12.16 + tool: just@1.57.0,cargo-cyclonedx@0.5.9,uv@0.12.0 - run: just release-candidate release-dry-run: diff --git a/.github/workflows/release-finalize.yml b/.github/workflows/release-finalize.yml index c1142cb..15bdb9e 100644 --- a/.github/workflows/release-finalize.yml +++ b/.github/workflows/release-finalize.yml @@ -42,10 +42,7 @@ jobs: toolchain: stable - uses: taiki-e/install-action@6a1bd70eaac3c8bdf093356838d7ee09fda951cf with: - tool: cargo-cyclonedx@0.5.9 - - uses: astral-sh/setup-uv@94527f2e458b27549849d47d273a16bec83a01e9 - with: - version: 0.12.16 + tool: cargo-cyclonedx@0.5.9,uv@0.12.0 - name: Verify tag, commit, and Cargo version run: cargo xtask verify-release-tag "$env:RELEASE_TAG" - name: Build and verify release candidate From 14ac638d3c6261870227514bcab334b75bc49631 Mon Sep 17 00:00:00 2001 From: Yasunobu <42543015+P4suta@users.noreply.github.com> Date: Sun, 20 Sep 2026 03:57:59 +0900 Subject: [PATCH 6/7] refactor!: enforce static spawn invariants --- .cargo/mutants.toml | 11 +- .config/nextest.toml | 4 +- .gitattributes | 1 + .github/workflows/ci.yml | 7 +- .github/workflows/mutation.yml | 4 + Cargo.lock | 2 + ...0009-static-dispatch-and-explicit-state.md | 33 + public-api/windows-spawn.txt | 21 +- quality/invariants.toml | 44 +- src/backend.rs | 317 ++++-- src/child.rs | 472 +++++++-- src/command.rs | 100 +- src/core_logic.rs | 97 +- src/error.rs | 15 +- src/handles.rs | 82 +- src/lib.rs | 5 +- src/options.rs | 74 ++ src/plan.rs | 190 ++-- src/resource.rs | 12 + src/sys.rs | 943 ++++++++++++++---- src/trace.rs | 89 +- src/transaction.rs | 943 ++++++++++++++---- tests/windows_spawn.rs | 228 +++-- xtask/Cargo.toml | 2 + xtask/src/comment_policy.rs | 2 +- xtask/src/invariant_registry.rs | 112 ++- xtask/src/main.rs | 5 + xtask/src/source_policy.rs | 403 +++++++- xtask/src/tasks.rs | 29 +- xtask/src/windows.rs | 26 + 30 files changed, 3410 insertions(+), 863 deletions(-) create mode 100644 docs/adr/0009-static-dispatch-and-explicit-state.md create mode 100644 xtask/src/windows.rs diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index 2f63c67..0b60f32 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -1,5 +1,14 @@ -additional_cargo_args = ["--locked"] +additional_cargo_args = ["--locked", "--all-features"] +additional_cargo_test_args = ["--", "--test-threads=1"] timeout_multiplier = 5.0 minimum_test_timeout = 60.0 gitignore = true cap_lints = false +test_tool = "cargo" +exclude_re = [ + '^src/core_logic\.rs:[0-9]+:[0-9]+: replace proofs::quoting_length_is_bounded with \(\)$', + '^src/core_logic\.rs:[0-9]+:[0-9]+: replace proofs::two_bit_replacement_changes_only_its_field with \(\)$', + '^src/core_logic\.rs:[0-9]+:[0-9]+: replace << with >> in proofs::two_bit_replacement_changes_only_its_field$', + '^src/core_logic\.rs:[0-9]+:[0-9]+: replace proofs::one_bit_replacement_changes_only_its_field with \(\)$', + '^src/core_logic\.rs:[0-9]+:[0-9]+: replace << with >> in proofs::one_bit_replacement_changes_only_its_field$', +] diff --git a/.config/nextest.toml b/.config/nextest.toml index 8cad83c..ae9b8e5 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -5,7 +5,7 @@ status-level = "pass" final-status-level = "slow" [profile.ci-default.junit] -path = "target/nextest/ci-default.xml" +path = "junit.xml" [profile.ci-all] fail-fast = false @@ -14,4 +14,4 @@ status-level = "pass" final-status-level = "slow" [profile.ci-all.junit] -path = "target/nextest/ci-all.xml" +path = "junit.xml" diff --git a/.gitattributes b/.gitattributes index adf2f5b..365e1ed 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ +* text=auto eol=lf public-api/*.txt text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 866d344..72bdf42 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,7 +74,7 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: junit-${{ matrix.os }}-${{ matrix.rust }} - path: target/nextest/*.xml + path: target/nextest/*/junit.xml if-no-files-found: error i686-smoke: @@ -182,7 +182,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 with: - toolchain: stable + toolchain: nightly-2026-07-02 - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 - uses: taiki-e/install-action@6a1bd70eaac3c8bdf093356838d7ee09fda951cf with: @@ -193,6 +193,8 @@ jobs: name: Windows 11 ARM64 smoke runs-on: windows-11-arm timeout-minutes: 30 + env: + RUST_TEST_THREADS: "1" steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 @@ -240,6 +242,7 @@ jobs: with: fetch-depth: 0 persist-credentials: false + ref: ${{ github.head_ref || github.ref_name }} - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 with: toolchain: stable diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index 2518145..0b6060f 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -13,7 +13,11 @@ jobs: runs-on: windows-latest timeout-minutes: 240 env: + CARGO_INCREMENTAL: "0" + CARGO_PROFILE_DEV_DEBUG: "0" + CARGO_PROFILE_TEST_DEBUG: "0" RUST_TEST_THREADS: "1" + RUSTFLAGS: "-C linker=rust-lld" strategy: fail-fast: false matrix: diff --git a/Cargo.lock b/Cargo.lock index 38e5161..6ab6530 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -343,6 +343,7 @@ dependencies = [ name = "xtask" version = "0.0.0" dependencies = [ + "proc-macro2", "semver", "serde", "serde_json", @@ -350,6 +351,7 @@ dependencies = [ "syn 2.0.119", "toml", "windows-spawn", + "windows-sys", ] [[package]] diff --git a/docs/adr/0009-static-dispatch-and-explicit-state.md b/docs/adr/0009-static-dispatch-and-explicit-state.md new file mode 100644 index 0000000..e4b03c8 --- /dev/null +++ b/docs/adr/0009-static-dispatch-and-explicit-state.md @@ -0,0 +1,33 @@ +# ADR 0009: Static dispatch and explicit state + +## Status + +Accepted. + +## Context + +Spawn correctness depends on ownership, handle-table identity, resource kind, validation state, and transition order remaining visible to the compiler. Application-defined trait objects, runtime reflection, shared interior mutability, global caches, and nondeterministic collections erase information needed to enforce those invariants. + +## Decision + +All handwritten Rust uses enums, generics, sealed traits, owned state, and validated newtypes in preference to runtime erasure. + +Owned trait objects are forbidden. Borrowed trait objects are also forbidden except for the exact `std::error::Error::source` contract. Error categories remain concrete and typed before that standard diagnostic boundary. + +Concrete `Box` remains available when heap ownership has a specific representation purpose. `Pin>` is required where Win32 retains addresses through process creation. This does not permit `Box`. + +Production library code cannot introduce `Rc`, `Arc`, cell types, locks, atomics, one-time global initialization, runtime reflection, or hash-based maps and sets. Test-only fault injection may use thread-local mutation and counters because it does not enter the production artifact. Any future production concurrency must first introduce a typed synchronization boundary and model-checking artifact. + +Dependencies that provide erased errors, erased serialization, runtime registration, asynchronous trait erasure, or implicit global state are forbidden at manifest validation time, including renamed dependencies. + +## Enforcement + +`cargo xtask source-policy` parses every tracked Rust file and rejects trait objects outside the standard error-source boundary, including trait objects nested in owner types or macro tokens. It separately applies the production ownership and determinism rules to the library source. The same command parses every tracked Cargo manifest and rejects forbidden dependencies by package name. + +Positive and negative scanner tests cover generics, the standard error-source boundary, borrowed and owned trait objects, runtime reflection, import aliases, macro tokens, target-specific dependencies, and renamed dependencies. + +`quality/invariants.toml` registers this decision as `INV-016`, so the repository cannot represent it as review-only guidance. + +## Consequences + +Extensibility is expressed through closed enums or monomorphized generic implementations. This can increase compile time and code size, but keeps invalid combinations visible to the type checker and prevents hidden allocation, vtable dispatch, downcasting, and shared mutation. diff --git a/public-api/windows-spawn.txt b/public-api/windows-spawn.txt index 7ec265c..9a87164 100644 --- a/public-api/windows-spawn.txt +++ b/public-api/windows-spawn.txt @@ -72,6 +72,25 @@ impl core::marker::Unpin for windows_spawn::CetShadowStacks impl core::marker::UnsafeUnpin for windows_spawn::CetShadowStacks impl core::panic::unwind_safe::RefUnwindSafe for windows_spawn::CetShadowStacks impl core::panic::unwind_safe::UnwindSafe for windows_spawn::CetShadowStacks +pub enum windows_spawn::CleanupOutcome +pub windows_spawn::CleanupOutcome::Preserved +pub windows_spawn::CleanupOutcome::Terminated +impl core::clone::Clone for windows_spawn::CleanupOutcome +pub fn windows_spawn::CleanupOutcome::clone(&self) -> windows_spawn::CleanupOutcome +impl core::cmp::Eq for windows_spawn::CleanupOutcome +impl core::cmp::PartialEq for windows_spawn::CleanupOutcome +pub fn windows_spawn::CleanupOutcome::eq(&self, &windows_spawn::CleanupOutcome) -> bool +impl core::fmt::Debug for windows_spawn::CleanupOutcome +pub fn windows_spawn::CleanupOutcome::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::Copy for windows_spawn::CleanupOutcome +impl core::marker::StructuralPartialEq for windows_spawn::CleanupOutcome +impl core::marker::Freeze for windows_spawn::CleanupOutcome +impl core::marker::Send for windows_spawn::CleanupOutcome +impl core::marker::Sync for windows_spawn::CleanupOutcome +impl core::marker::Unpin for windows_spawn::CleanupOutcome +impl core::marker::UnsafeUnpin for windows_spawn::CleanupOutcome +impl core::panic::unwind_safe::RefUnwindSafe for windows_spawn::CleanupOutcome +impl core::panic::unwind_safe::UnwindSafe for windows_spawn::CleanupOutcome pub enum windows_spawn::ConsoleMode pub windows_spawn::ConsoleMode::Detached pub windows_spawn::ConsoleMode::Inherit @@ -588,7 +607,7 @@ pub windows_spawn::Child::stderr: core::option::Option pub windows_spawn::Child::stdout: core::option::Option impl windows_spawn::Child -pub fn windows_spawn::Child::cleanup(self) -> windows_spawn::Result<()> +pub fn windows_spawn::Child::cleanup(self) -> windows_spawn::Result pub fn windows_spawn::Child::id(&self) -> u32 pub fn windows_spawn::Child::kill(&mut self) -> windows_spawn::Result<()> pub fn windows_spawn::Child::try_wait(&mut self) -> windows_spawn::Result> diff --git a/quality/invariants.toml b/quality/invariants.toml index e7be9d6..4184e74 100644 --- a/quality/invariants.toml +++ b/quality/invariants.toml @@ -88,9 +88,44 @@ trust_boundaries = ["rust-compiler", "standard-library"] id = "INV-013" target = "command line quoting length and mitigation field replacement are panic free and bounded" enforcement = ["kani"] -artifacts = ["src/core_logic.rs", ".github/workflows/ci.yml"] +artifacts = ["src/core_logic.rs", ".cargo/mutants.toml", ".github/workflows/ci.yml"] trust_boundaries = ["rust-compiler", "standard-library"] +[[mutation_exemption]] +matcher = '^src/core_logic\.rs:[0-9]+:[0-9]+: replace proofs::quoting_length_is_bounded with \(\)$' +source = "src/core_logic.rs" +expression = "fn quoting_length_is_bounded()" +proof = "quoting_length_is_bounded" +invariant = "INV-013" + +[[mutation_exemption]] +matcher = '^src/core_logic\.rs:[0-9]+:[0-9]+: replace proofs::two_bit_replacement_changes_only_its_field with \(\)$' +source = "src/core_logic.rs" +expression = "fn two_bit_replacement_changes_only_its_field()" +proof = "two_bit_replacement_changes_only_its_field" +invariant = "INV-013" + +[[mutation_exemption]] +matcher = '^src/core_logic\.rs:[0-9]+:[0-9]+: replace << with >> in proofs::two_bit_replacement_changes_only_its_field$' +source = "src/core_logic.rs" +expression = "3_u64 << 36" +proof = "two_bit_replacement_changes_only_its_field" +invariant = "INV-013" + +[[mutation_exemption]] +matcher = '^src/core_logic\.rs:[0-9]+:[0-9]+: replace proofs::one_bit_replacement_changes_only_its_field with \(\)$' +source = "src/core_logic.rs" +expression = "fn one_bit_replacement_changes_only_its_field()" +proof = "one_bit_replacement_changes_only_its_field" +invariant = "INV-013" + +[[mutation_exemption]] +matcher = '^src/core_logic\.rs:[0-9]+:[0-9]+: replace << with >> in proofs::one_bit_replacement_changes_only_its_field$' +source = "src/core_logic.rs" +expression = "1_u64 << 2" +proof = "one_bit_replacement_changes_only_its_field" +invariant = "INV-013" + [[invariant]] id = "INV-014" target = "dependency changes preserve license advisory and audit requirements" @@ -104,3 +139,10 @@ target = "production and deterministic fault execution share the typed spawn bac enforcement = ["type", "lint"] artifacts = ["src/backend.rs", "src/transaction.rs"] trust_boundaries = ["rust-compiler", "standard-library", "windows-api"] + +[[invariant]] +id = "INV-016" +target = "handwritten Rust uses static dispatch and rejects owned trait objects runtime reflection nondeterministic maps and shared interior mutability" +enforcement = ["type", "lint"] +artifacts = ["xtask/src/source_policy.rs", "Cargo.toml", "xtask/Cargo.toml", "src", "docs/adr/0009-static-dispatch-and-explicit-state.md"] +trust_boundaries = ["rust-compiler", "standard-library"] diff --git a/src/backend.rs b/src/backend.rs index f2ec0ab..b277be2 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -30,6 +30,7 @@ pub(crate) enum BackendCall { ResumeThread, } +#[derive(Clone, Copy)] pub(crate) struct AttributeAddress<'a, Value>(&'a Value); impl<'a, Value> AttributeAddress<'a, Value> { @@ -42,138 +43,187 @@ impl<'a, Value> AttributeAddress<'a, Value> { } } +#[derive(Clone, Copy)] +pub(crate) enum BackendAdapter { + Windows, + #[cfg(test)] + Fault, +} + pub(crate) trait SpawnBackend: Sized { - fn before(_: BackendCall) -> io::Result<()> { - Ok(()) + fn adapter() -> BackendAdapter; +} + +impl BackendAdapter { + fn execute(self, call: BackendCall, operation: Operation) -> io::Result + where + Operation: FnOnce() -> io::Result, + { + #[cfg(test)] + if matches!(self, Self::Fault) { + fault::gate(call)?; + } + #[cfg(not(test))] + { + let Self::Windows = self; + let _ = call; + } + operation() } - fn standard_handle(stream: StandardStream) -> io::Result> { - Self::before(BackendCall::StandardHandle)?; - sys::standard_handle(stream) + pub(crate) fn standard_handle(self, stream: StandardStream) -> io::Result> { + self.execute(BackendCall::StandardHandle, || { + #[cfg(test)] + if matches!(self, Self::Fault) && fault::missing_standard_handle() { + return Ok(None); + } + sys::standard_handle(stream) + }) } - fn null_handle(access: NullAccess) -> io::Result { - Self::before(BackendCall::OpenNull)?; - sys::null_handle(access) + pub(crate) fn null_handle(self, access: NullAccess) -> io::Result { + self.execute(BackendCall::OpenNull, || sys::null_handle(access)) } - fn create_pipe(direction: PipeDirection) -> io::Result { - Self::before(BackendCall::CreatePipe)?; - sys::create_pipe(direction) + pub(crate) fn create_pipe(self, direction: PipeDirection) -> io::Result { + self.execute(BackendCall::CreatePipe, || sys::create_pipe(direction)) } - fn duplicate_local( + pub(crate) fn duplicate_local( + self, source: BorrowedHandle<'_>, inheritability: Inheritability, ) -> io::Result { - Self::before(BackendCall::DuplicateLocal)?; - sys::duplicate_local(source, inheritability) + self.execute(BackendCall::DuplicateLocal, || { + sys::duplicate_local(source, inheritability) + }) } - fn duplicate_remote<'a>( + pub(crate) fn duplicate_remote<'a>( + self, source: BorrowedHandle<'_>, target: BorrowedHandle<'a>, inheritability: Inheritability, ) -> io::Result> { - Self::before(BackendCall::DuplicateRemote)?; - sys::duplicate_remote(source, target, inheritability) + self.execute(BackendCall::DuplicateRemote, || { + sys::duplicate_remote(source, target, inheritability) + }) } - fn create_job() -> io::Result { - Self::before(BackendCall::CreateJob)?; - sys::create_job().map(Job::from_system) + pub(crate) fn create_job(self) -> io::Result { + self.execute(BackendCall::CreateJob, || { + sys::create_job().map(Job::from_system) + }) } - fn configure_job(job: &Job, policy: JobClosePolicy) -> io::Result<()> { + pub(crate) fn configure_job(self, job: &Job, policy: JobClosePolicy) -> io::Result<()> { use std::os::windows::io::AsHandle; - Self::before(BackendCall::ConfigureJob)?; - sys::set_job_close_policy(job.as_handle(), policy) + self.execute(BackendCall::ConfigureJob, || { + sys::set_job_close_policy(job.as_handle(), policy) + }) } - fn create_attributes(count: u32) -> io::Result { - Self::before(BackendCall::CreateAttributes)?; - sys::AttributeList::new(count) + pub(crate) fn create_attributes(self, count: u32) -> io::Result { + self.execute(BackendCall::CreateAttributes, || { + sys::AttributeList::new(count) + }) } - fn set_handle_list
( + pub(crate) fn set_handle_list
( + self, attributes: &mut sys::AttributeList, handles: &[ChildHandleValue
], ) -> io::Result<()> { - Self::before(BackendCall::SetHandleList)?; - attributes.set_handle_list(handles) + self.execute(BackendCall::SetHandleList, || { + attributes.set_handle_list(handles) + }) } - fn set_parent
( + pub(crate) fn set_parent
( + self, attributes: &mut sys::AttributeList, parent: AttributeAddress<'_, ChildHandleValue
>, ) -> io::Result<()> { - Self::before(BackendCall::SetParent)?; - attributes.set_parent(parent.get()) + self.execute(BackendCall::SetParent, || { + attributes.set_parent(parent.get()) + }) } - fn set_mitigation(attributes: &mut sys::AttributeList, words: &[u64; 2]) -> io::Result<()> { - Self::before(BackendCall::SetMitigation)?; - attributes.set_mitigation(words) + pub(crate) fn set_mitigation( + self, + attributes: &mut sys::AttributeList, + words: &[u64; 2], + ) -> io::Result<()> { + self.execute(BackendCall::SetMitigation, || { + attributes.set_mitigation(words) + }) } - fn set_jobs
( + pub(crate) fn set_jobs
( + self, attributes: &mut sys::AttributeList, jobs: &[ChildHandleValue
], ) -> io::Result<()> { - Self::before(BackendCall::SetJobs)?; - attributes.set_jobs(jobs) + self.execute(BackendCall::SetJobs, || attributes.set_jobs(jobs)) } - fn set_pseudoconsole( + pub(crate) fn set_pseudoconsole( + self, attributes: &mut sys::AttributeList, pseudoconsole: BorrowedPseudoConsole<'_>, ) -> io::Result<()> { - Self::before(BackendCall::SetPseudoConsole)?; - attributes.set_pseudoconsole(pseudoconsole) + self.execute(BackendCall::SetPseudoConsole, || { + attributes.set_pseudoconsole(pseudoconsole) + }) } - fn environment_strings() -> io::Result> { - Self::before(BackendCall::ReadEnvironment)?; - sys::environment_strings() + pub(crate) fn environment_strings(self) -> io::Result> { + self.execute(BackendCall::ReadEnvironment, sys::environment_strings) } - fn system_directory() -> io::Result { - Self::before(BackendCall::QuerySystemDirectory)?; - sys::system_directory() + pub(crate) fn system_directory(self) -> io::Result { + self.execute(BackendCall::QuerySystemDirectory, sys::system_directory) } - fn windows_directory() -> io::Result { - Self::before(BackendCall::QueryWindowsDirectory)?; - sys::windows_directory() + pub(crate) fn windows_directory(self) -> io::Result { + self.execute(BackendCall::QueryWindowsDirectory, sys::windows_directory) } - fn create_process
( + pub(crate) fn create_process
( + self, request: &mut sys::ProcessRequest<'_, Table>, ) -> io::Result { - Self::before(BackendCall::CreateProcess)?; - sys::create_process(request) + self.execute(BackendCall::CreateProcess, || sys::create_process(request)) } - fn reclaim_remote(handle: sys::RemoteHandle<'_>) -> io::Result<()> { - Self::before(BackendCall::ReclaimRemote)?; - handle.reclaim() + pub(crate) fn reclaim_remote( + self, + handle: sys::RemoteHandle<'_>, + ) -> io::Result { + self.execute(BackendCall::ReclaimRemote, || handle.reclaim()) } - fn resume_thread(thread: BorrowedHandle<'_>) -> io::Result { - Self::before(BackendCall::ResumeThread)?; - sys::resume_thread(thread) + pub(crate) fn resume_thread( + self, + thread: BorrowedHandle<'_>, + ) -> io::Result { + self.execute(BackendCall::ResumeThread, || sys::resume_thread(thread)) } } pub(crate) struct WindowsBackend; -impl SpawnBackend for WindowsBackend {} +impl SpawnBackend for WindowsBackend { + fn adapter() -> BackendAdapter { + BackendAdapter::Windows + } +} #[cfg(test)] pub(crate) use fault::{ calls as fault_calls, configure as configure_fault, configure_many as configure_faults, - FaultBackend, + configure_missing_standard_handle, FaultBackend, }; #[cfg(test)] @@ -183,12 +233,13 @@ mod fault { use std::collections::BTreeSet; use std::io; - use super::{BackendCall, SpawnBackend}; + use super::{BackendAdapter, BackendCall, SpawnBackend}; #[derive(Default)] struct FaultPlan { fail_at: BTreeSet, calls: Vec, + missing_standard_handle: bool, } std::thread_local! { @@ -198,20 +249,24 @@ mod fault { pub(crate) struct FaultBackend; impl SpawnBackend for FaultBackend { - fn before(call: BackendCall) -> io::Result<()> { - PLAN.with(|plan| { - let mut plan = plan.borrow_mut(); - let index = plan.calls.len(); - plan.calls.push(call); - if plan.fail_at.contains(&index) { - Err(io::Error::from_raw_os_error(5)) - } else { - Ok(()) - } - }) + fn adapter() -> BackendAdapter { + BackendAdapter::Fault } } + pub(super) fn gate(call: BackendCall) -> io::Result<()> { + PLAN.with(|plan| { + let mut plan = plan.borrow_mut(); + let index = plan.calls.len(); + plan.calls.push(call); + if plan.fail_at.contains(&index) { + Err(io::Error::from_raw_os_error(5)) + } else { + Ok(()) + } + }) + } + pub(crate) fn configure(fail_at: Option) { let fail_at = fail_at.into_iter().collect(); configure_set(fail_at); @@ -221,16 +276,132 @@ mod fault { configure_set(fail_at.iter().copied().collect()); } + pub(crate) fn configure_missing_standard_handle() { + PLAN.with(|plan| { + *plan.borrow_mut() = FaultPlan { + fail_at: BTreeSet::new(), + calls: Vec::new(), + missing_standard_handle: true, + }; + }); + } + fn configure_set(fail_at: BTreeSet) { PLAN.with(|plan| { *plan.borrow_mut() = FaultPlan { fail_at, calls: Vec::new(), + missing_standard_handle: false, }; }); } + pub(super) fn missing_standard_handle() -> bool { + PLAN.with(|plan| plan.borrow().missing_standard_handle) + } + pub(crate) fn calls() -> Vec { PLAN.with(|plan| plan.borrow().calls.clone()) } } + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use std::fs::File; + use std::io; + use std::os::windows::io::AsHandle; + + use super::{ + configure_fault, configure_missing_standard_handle, fault_calls, BackendCall, FaultBackend, + SpawnBackend, WindowsBackend, + }; + use crate::handles::{borrowed_pseudoconsole_for_test, Job}; + use crate::sys::{self, AttributeList, StandardStream}; + use crate::JobClosePolicy; + + #[test] + fn resume_fault_is_observed_before_the_raw_backend() { + configure_fault(Some(0)); + let file = File::open("NUL").unwrap(); + assert!(FaultBackend::adapter() + .resume_thread(file.as_handle()) + .is_err()); + assert_eq!(fault_calls(), vec![BackendCall::ResumeThread]); + } + + #[test] + fn every_default_adapter_checks_faults_before_touching_windows() -> io::Result<()> { + configure_fault(Some(0)); + assert!(FaultBackend::adapter() + .standard_handle(StandardStream::Input) + .is_err()); + assert_eq!(fault_calls(), vec![BackendCall::StandardHandle]); + + let owner = (); + let pseudoconsole = borrowed_pseudoconsole_for_test(&owner); + let mut attributes = AttributeList::new(1)?; + configure_fault(Some(0)); + assert!(FaultBackend::adapter() + .set_pseudoconsole(&mut attributes, pseudoconsole) + .is_err()); + assert_eq!(fault_calls(), vec![BackendCall::SetPseudoConsole]); + + configure_fault(Some(0)); + assert!(FaultBackend::adapter().windows_directory().is_err()); + assert_eq!(fault_calls(), vec![BackendCall::QueryWindowsDirectory]); + + configure_missing_standard_handle(); + assert!(FaultBackend::adapter() + .standard_handle(StandardStream::Output)? + .is_none()); + assert!(WindowsBackend::adapter() + .standard_handle(StandardStream::Output)? + .is_some()); + Ok(()) + } + + #[test] + fn windows_backend_configures_the_requested_job_policy() -> crate::Result<()> { + let job = Job::create()?; + WindowsBackend::adapter() + .configure_job(&job, JobClosePolicy::TerminateProcesses) + .map_err(|error| { + crate::Error::windows( + crate::Phase::Preparation, + crate::Operation::ConfigureJob, + error, + ) + })?; + assert_eq!( + sys::job_close_policy(job.as_handle()).map_err(|error| crate::Error::windows( + crate::Phase::Preparation, + crate::Operation::ConfigureJob, + error, + ))?, + JobClosePolicy::TerminateProcesses + ); + WindowsBackend::adapter() + .configure_job(&job, JobClosePolicy::PreserveProcesses) + .map_err(|error| { + crate::Error::windows( + crate::Phase::Preparation, + crate::Operation::ConfigureJob, + error, + ) + })?; + assert_eq!( + sys::job_close_policy(job.as_handle()).map_err(|error| crate::Error::windows( + crate::Phase::Preparation, + crate::Operation::ConfigureJob, + error, + ))?, + JobClosePolicy::PreserveProcesses + ); + let file = File::open("NUL").map_err(|error| { + crate::Error::windows(crate::Phase::Runtime, crate::Operation::OpenFile, error) + })?; + assert!(sys::job_close_policy(file.as_handle()).is_err()); + Ok(()) + } +} diff --git a/src/child.rs b/src/child.rs index eb2bc45..4b71837 100644 --- a/src/child.rs +++ b/src/child.rs @@ -23,6 +23,7 @@ impl Write for ChildStdin { ResourceKind::Pipe, || sys::write_handle(self.handle.as_handle(), buffer), ) + .map(|count| count.0) } fn flush(&mut self) -> io::Result<()> { @@ -50,6 +51,7 @@ impl Read for ChildStdout { ResourceKind::Pipe, || sys::read_handle(self.handle.as_handle(), buffer), ) + .map(|count| count.0) } } @@ -73,6 +75,7 @@ impl Read for ChildStderr { ResourceKind::Pipe, || sys::read_handle(self.handle.as_handle(), buffer), ) + .map(|count| count.0) } } @@ -88,6 +91,9 @@ enum ExecutionState { Running, } +#[derive(Debug)] +pub(crate) struct ResumeCompleted(()); + #[derive(Debug)] pub(crate) struct ProcessOwner { process: OwnedHandle, @@ -116,22 +122,25 @@ impl ProcessOwner { fn resume_with( &mut self, - resume: impl FnOnce(BorrowedHandle<'_>) -> io::Result, - ) -> Result<()> { - let previous = trace::io( + resume: fn(BorrowedHandle<'_>) -> io::Result, + ) -> Result { + trace::io( Phase::Resume, Operation::ResumeThread, ResourceKind::Thread, || resume(self.thread_handle()), ) - .map_err(|error| Error::windows(Phase::Resume, Operation::ResumeThread, error))?; - if previous != 1 { - return Err(Error::Validation(ValidationError::UnexpectedSuspendCount { - actual: previous, - })); - } - self.state = ExecutionState::Running; - Ok(()) + .map_err(|error| Error::windows(Phase::Resume, Operation::ResumeThread, error)) + .and_then(|previous| { + if previous.0 == 1 { + self.state = ExecutionState::Running; + Ok(ResumeCompleted(())) + } else { + Err(Error::Validation(ValidationError::UnexpectedSuspendCount { + actual: previous.0, + })) + } + }) } } @@ -157,67 +166,98 @@ pub(crate) enum JobOwnership { #[derive(Debug)] enum ProcessTree { Preserve(ProcessOwner), - Terminate { - job: Job, - process: ProcessOwner, - cleanup: CleanupState, - }, + Terminate(TerminatingProcessTree), +} + +#[derive(Debug)] +enum JobCleanupState { + Armed, + Attempted, } #[derive(Debug)] -enum CleanupState { - Pending, - Complete, +struct TerminatingProcessTree { + job: Job, + process: ProcessOwner, + cleanup: JobCleanupState, + #[cfg(test)] + emergency_cleanup: fn(&Job, u32) -> Result<()>, +} + +impl TerminatingProcessTree { + fn new(job: Job, process: ProcessOwner) -> Self { + Self { + job, + process, + cleanup: JobCleanupState::Armed, + #[cfg(test)] + emergency_cleanup: Job::terminate, + } + } + + fn terminate_with(mut self, terminate: fn(&Job, u32) -> Result<()>) -> Result { + self.cleanup = JobCleanupState::Attempted; + terminate(&self.job, 1).map(|()| CleanupOutcome::Terminated) + } +} + +impl Drop for TerminatingProcessTree { + fn drop(&mut self) { + if matches!(self.cleanup, JobCleanupState::Armed) { + self.cleanup = JobCleanupState::Attempted; + #[cfg(test)] + let terminate = self.emergency_cleanup; + #[cfg(not(test))] + let terminate = Job::terminate; + let _emergency_cleanup = terminate(&self.job, 1); + } + } +} + +/// The process-tree action completed by [`Child::cleanup`]. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CleanupOutcome { + /// No owned terminating Job was present. + Preserved, + /// The owned terminating Job was terminated. + Terminated, } impl ProcessTree { fn new(process: ProcessOwner, job: JobOwnership) -> Self { match job { JobOwnership::Preserve => Self::Preserve(process), - JobOwnership::Terminate(job) => Self::Terminate { - job, - process, - cleanup: CleanupState::Pending, - }, + JobOwnership::Terminate(job) => { + Self::Terminate(TerminatingProcessTree::new(job, process)) + } } } fn process(&self) -> &ProcessOwner { match self { - Self::Preserve(process) | Self::Terminate { process, .. } => process, + Self::Preserve(process) => process, + Self::Terminate(tree) => &tree.process, } } fn process_mut(&mut self) -> &mut ProcessOwner { match self { - Self::Preserve(process) | Self::Terminate { process, .. } => process, + Self::Preserve(process) => process, + Self::Terminate(tree) => &mut tree.process, } } - fn terminate_descendants(&mut self) -> Result<()> { - match self { - Self::Preserve(_) => Ok(()), - Self::Terminate { job, cleanup, .. } => match cleanup { - CleanupState::Complete => Ok(()), - CleanupState::Pending => { - job.terminate(1)?; - *cleanup = CleanupState::Complete; - Ok(()) - } - }, - } + fn terminate_descendants(self) -> Result { + self.terminate_descendants_with(Job::terminate) } -} -impl Drop for ProcessTree { - fn drop(&mut self) { - if let Self::Terminate { - job, - cleanup: CleanupState::Pending, - .. - } = self - { - let _ = job.terminate(1); + fn terminate_descendants_with( + self, + terminate: fn(&Job, u32) -> Result<()>, + ) -> Result { + match self { + Self::Preserve(_) => Ok(CleanupOutcome::Preserved), + Self::Terminate(tree) => tree.terminate_with(terminate), } } } @@ -266,14 +306,14 @@ impl Child { self.tree.process().thread_handle() } - pub(crate) fn resume_initial(&mut self) -> Result<()> { + pub(crate) fn resume_initial(&mut self) -> Result { self.resume_initial_with(sys::resume_thread) } pub(crate) fn resume_initial_with( &mut self, - resume: impl FnOnce(BorrowedHandle<'_>) -> io::Result, - ) -> Result<()> { + resume: fn(BorrowedHandle<'_>) -> io::Result, + ) -> Result { self.tree.process_mut().resume_with(resume) } @@ -289,6 +329,13 @@ impl Child { /// /// Returns a typed Windows failure from `TerminateProcess`. pub fn kill(&mut self) -> Result<()> { + self.kill_with(sys::terminate_process) + } + + fn kill_with( + &mut self, + terminate: fn(BorrowedHandle<'_>, u32) -> io::Result<()>, + ) -> Result<()> { if self.exit.is_some() { return Ok(()); } @@ -296,7 +343,7 @@ impl Child { Phase::Runtime, Operation::TerminateProcess, ResourceKind::Process, - || sys::terminate_process(self.process_handle(), 1), + || terminate(self.process_handle(), 1), ) .map_err(|error| Error::windows(Phase::Runtime, Operation::TerminateProcess, error)) } @@ -307,6 +354,14 @@ impl Child { /// /// Returns a typed failure if waiting or retrieving the exit code fails. pub fn wait(&mut self) -> Result { + self.wait_with(sys::wait_process, sys::exit_status) + } + + fn wait_with( + &mut self, + wait: fn(BorrowedHandle<'_>) -> io::Result<()>, + status: fn(BorrowedHandle<'_>) -> io::Result, + ) -> Result { if let Some(status) = self.exit { return Ok(status); } @@ -315,18 +370,22 @@ impl Child { Phase::Runtime, Operation::WaitProcess, ResourceKind::Process, - || sys::wait_process(self.process_handle()), + || wait(self.process_handle()), ) - .map_err(|error| Error::windows(Phase::Runtime, Operation::WaitProcess, error))?; - let status = trace::io( - Phase::Runtime, - Operation::QueryExitCode, - ResourceKind::Process, - || sys::exit_status(self.process_handle()), - ) - .map_err(|error| Error::windows(Phase::Runtime, Operation::QueryExitCode, error))?; - self.exit = Some(status); - Ok(status) + .map_err(|error| Error::windows(Phase::Runtime, Operation::WaitProcess, error)) + .and_then(|()| { + trace::io( + Phase::Runtime, + Operation::QueryExitCode, + ResourceKind::Process, + || status(self.process_handle()), + ) + .map_err(|error| Error::windows(Phase::Runtime, Operation::QueryExitCode, error)) + }) + .map(|status| { + self.exit = Some(status); + status + }) } /// Checks for exit without blocking, returning the cached status thereafter. @@ -335,28 +394,42 @@ impl Child { /// /// Returns a typed failure if querying the process or its exit code fails. pub fn try_wait(&mut self) -> Result> { + self.try_wait_with(sys::try_wait_process, sys::exit_status) + } + + fn try_wait_with( + &mut self, + wait: fn(BorrowedHandle<'_>) -> io::Result, + status: fn(BorrowedHandle<'_>) -> io::Result, + ) -> Result> { if self.exit.is_some() { return Ok(self.exit); } - if !trace::io( + trace::io( Phase::Runtime, Operation::WaitProcess, ResourceKind::Process, - || sys::try_wait_process(self.process_handle()), + || wait(self.process_handle()), ) - .map_err(|error| Error::windows(Phase::Runtime, Operation::WaitProcess, error))? - { - return Ok(None); - } - let status = trace::io( - Phase::Runtime, - Operation::QueryExitCode, - ResourceKind::Process, - || sys::exit_status(self.process_handle()), - ) - .map_err(|error| Error::windows(Phase::Runtime, Operation::QueryExitCode, error))?; - self.exit = Some(status); - Ok(self.exit) + .map_err(|error| Error::windows(Phase::Runtime, Operation::WaitProcess, error)) + .and_then(|exited| { + if exited { + trace::io( + Phase::Runtime, + Operation::QueryExitCode, + ResourceKind::Process, + || status(self.process_handle()), + ) + .map_err(|error| Error::windows(Phase::Runtime, Operation::QueryExitCode, error)) + .map(Some) + } else { + Ok(None) + } + }) + .map(|status| { + self.exit = status; + self.exit + }) } /// Waits while draining both output pipes concurrently. @@ -377,13 +450,7 @@ impl Child { let status = self.wait(); let termination = self.tree.terminate_descendants(); let readers = join_readers(stdout_reader, stderr_reader); - let (stdout, stderr) = readers?; - termination?; - Ok(Output { - status: status?, - stdout, - stderr, - }) + output_result(status, termination, readers) } /// Performs policy-driven process-tree cleanup and consumes the child. @@ -391,7 +458,7 @@ impl Child { /// # Errors /// /// Returns a typed cleanup failure if the Job cannot be terminated. - pub fn cleanup(mut self) -> Result<()> { + pub fn cleanup(self) -> Result { self.tree.terminate_descendants() } } @@ -403,6 +470,13 @@ impl AsHandle for Child { } fn drain_output(handle: OwnedHandle) -> Result> { + drain_output_with(handle, sys::read_handle) +} + +fn drain_output_with( + handle: OwnedHandle, + read_handle: fn(BorrowedHandle<'_>, &mut [u8]) -> io::Result, +) -> Result> { let mut bytes = Vec::new(); let mut buffer = [0_u8; 8_192]; loop { @@ -410,10 +484,10 @@ fn drain_output(handle: OwnedHandle) -> Result> Phase::Runtime, Operation::ReadPipe, ResourceKind::Pipe, - || sys::read_handle(handle.as_handle(), &mut buffer), + || read_handle(handle.as_handle(), &mut buffer), ) .map_err(|error| Error::windows(Phase::Runtime, Operation::ReadPipe, error))?; - let Some(read) = std::num::NonZeroUsize::new(read) else { + let Some(read) = std::num::NonZeroUsize::new(read.0) else { drop(handle); return Ok(bytes); }; @@ -445,6 +519,20 @@ fn join_readers(stdout: Option, stderr: Option) -> Result<(Vec, + termination: Result, + readers: Result<(Vec, Vec)>, +) -> Result { + let (stdout, stderr) = readers?; + let _cleanup = termination?; + Ok(Output { + status: status?, + stdout, + stderr, + }) +} + /// A process whose primary thread has not yet been resumed. #[derive(Debug)] #[must_use = "dropping a suspended child terminates it"] @@ -491,24 +579,103 @@ impl AsHandle for SuspendedChild { #[cfg_attr(coverage_nightly, coverage(off))] mod tests { use std::io; + use std::os::windows::process::ExitStatusExt; use super::*; use crate::{Command, JobClosePolicy, SpawnOptions}; #[test] - fn completed_job_cleanup_is_idempotent() -> Result<()> { + fn cleanup_outcome_follows_job_ownership() -> Result<()> { + let mut command = Command::new("cmd.exe"); + command.args(["/D", "/C", "exit /b 0"]); + assert_eq!(command.spawn()?.cleanup()?, CleanupOutcome::Preserved); + + let mut command = Command::new("cmd.exe"); + command.args(["/D", "/C", "exit /b 0"]); + assert_eq!( + command + .spawn_with( + SpawnOptions::new().job_close_policy(JobClosePolicy::TerminateProcesses) + )? + .cleanup()?, + CleanupOutcome::Terminated + ); + Ok(()) + } + + #[test] + fn emergency_cleanup_is_an_explicit_drop_transition() -> Result<()> { + fn panic_on_cleanup(_: &Job, _: u32) -> Result<()> { + panic!("emergency cleanup invoked"); + } + let mut command = Command::new("cmd.exe"); command.args(["/D", "/C", "exit /b 0"]); let mut child = command .spawn_with(SpawnOptions::new().job_close_policy(JobClosePolicy::TerminateProcesses))?; - child.tree.terminate_descendants()?; - child.tree.terminate_descendants()?; - let _status = child.wait()?; + match &mut child.tree { + ProcessTree::Terminate(tree) => tree.emergency_cleanup = panic_on_cleanup, + ProcessTree::Preserve(_) => { + return Err(Error::Validation(ValidationError::SizeOverflow)); + } + } + let dropped = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| drop(child))); + assert!(dropped.is_err()); + Ok(()) + } + + #[test] + fn failed_resume_is_reported_without_starting_the_process() -> Result<()> { + let mut command = Command::new("cmd.exe"); + command.args(["/D", "/C", "exit /b 0"]); + let mut suspended = command.spawn_suspended()?; + let error = suspended + .child + .resume_initial_with(|_| Err(io::Error::from_raw_os_error(5))) + .unwrap_err(); + let Error::Windows(error) = error else { + return Err(Error::Validation(ValidationError::SizeOverflow)); + }; + assert_eq!(error.operation(), Operation::ResumeThread); + assert!(matches!( + suspended + .child + .resume_initial_with(|_| Ok(sys::PreviousSuspendCount(2))), + Err(Error::Validation(ValidationError::UnexpectedSuspendCount { + actual: 2 + })) + )); + Ok(()) + } + + #[test] + fn child_stdout_read_returns_the_emitted_byte() -> Result<()> { + let pipe = sys::create_pipe(sys::PipeDirection::ParentReads) + .map_err(|error| Error::windows(Phase::Preparation, Operation::CreatePipe, error))?; + let written = sys::write_handle(pipe.child.as_handle(), b"x") + .map_err(|error| Error::windows(Phase::Runtime, Operation::WritePipe, error))?; + assert_eq!(written.0, 1); + drop(pipe.child); + let mut stdout = ChildStdout { + handle: OwnedHandle::from_system(pipe.parent), + }; + let mut buffer = [0_u8; 4]; + let read = stdout + .read(&mut buffer) + .map_err(|error| Error::windows(Phase::Runtime, Operation::ReadPipe, error))?; + assert_eq!(read, 1); + assert_eq!(buffer.first(), Some(&b'x')); Ok(()) } #[test] fn reader_panics_become_typed_join_failures() -> io::Result<()> { + assert!(join_reader(None).map_err(io::Error::from)?.is_empty()); + let inner_failure = thread::spawn(|| -> Result> { + Err(Error::Validation(ValidationError::SizeOverflow)) + }); + assert!(join_reader(Some(inner_failure)).is_err()); + let failed = thread::spawn(|| -> Result> { panic!("synthetic reader panic") }); let completed = thread::spawn(|| Ok(vec![1_u8])); let error = join_readers(Some(failed), Some(completed)).unwrap_err(); @@ -516,6 +683,127 @@ mod tests { return Err(io::Error::other("Windows error expected")); }; assert_eq!(error.operation(), Operation::JoinOutputReader); + + let completed = thread::spawn(|| Ok(vec![1_u8])); + let failed = thread::spawn(|| -> Result> { panic!("synthetic reader panic") }); + assert!(join_readers(Some(completed), Some(failed)).is_err()); + Ok(()) + } + + #[test] + fn runtime_failures_are_typed_before_os_resources_are_released() -> Result<()> { + let mut command = Command::new("cmd.exe"); + command.args(["/D", "/C", "exit /b 0"]); + let mut suspended = command.spawn_suspended_with( + SpawnOptions::new().job_close_policy(JobClosePolicy::TerminateProcesses), + )?; + let child = &mut suspended.child; + assert!(child + .kill_with(|_, _| Err(io::Error::from_raw_os_error(5))) + .is_err()); + assert!(child + .wait_with( + |_| Err(io::Error::from_raw_os_error(5)), + |_| Ok(ExitStatus::from_raw(0)), + ) + .is_err()); + assert!(child + .wait_with(|_| Ok(()), |_| Err(io::Error::from_raw_os_error(5)),) + .is_err()); + assert!(child + .try_wait_with( + |_| Err(io::Error::from_raw_os_error(5)), + |_| Ok(ExitStatus::from_raw(0)), + ) + .is_err()); + assert_eq!( + child.try_wait_with(|_| Ok(false), |_| Ok(ExitStatus::from_raw(0)))?, + None + ); + assert!(child + .try_wait_with(|_| Ok(true), |_| Err(io::Error::from_raw_os_error(5)),) + .is_err()); + assert_eq!( + child + .try_wait_with(|_| Ok(true), |_| Ok(ExitStatus::from_raw(7)))? + .and_then(|status| status.code()), + Some(7) + ); + child.kill_with(|_, _| Err(io::Error::from_raw_os_error(5)))?; + assert_eq!( + child + .wait_with(|_| Ok(()), |_| Ok(ExitStatus::from_raw(0)))? + .code(), + Some(7) + ); + assert_eq!( + child.try_wait_with(|_| Ok(false), |_| Ok(ExitStatus::from_raw(0)))?, + child.exit + ); + child.exit = None; + let child = suspended.child; + assert!(child + .tree + .terminate_descendants_with(|_, _| { + Err(Error::Validation(ValidationError::SizeOverflow)) + }) + .is_err()); + Ok(()) + } + + #[test] + fn drain_and_output_composition_cover_all_results() -> Result<()> { + let successful_read = sys::create_pipe(sys::PipeDirection::ParentReads) + .map_err(|error| Error::windows(Phase::Preparation, Operation::CreatePipe, error))?; + sys::write_handle(successful_read.child.as_handle(), b"x") + .map_err(|error| Error::windows(Phase::Runtime, Operation::WritePipe, error))?; + drop(successful_read.child); + let handle = OwnedHandle::from_system(successful_read.parent); + assert_eq!(drain_output_with(handle, sys::read_handle)?, b"x"); + + let failed_read = sys::create_pipe(sys::PipeDirection::ParentReads) + .map_err(|error| Error::windows(Phase::Preparation, Operation::CreatePipe, error))?; + let handle = OwnedHandle::from_system(failed_read.parent); + assert!( + drain_output_with(handle, |_, _| { Err(io::Error::from_raw_os_error(5)) }).is_err() + ); + drop(failed_read.child); + + let oversized_read = sys::create_pipe(sys::PipeDirection::ParentReads) + .map_err(|error| Error::windows(Phase::Preparation, Operation::CreatePipe, error))?; + let handle = OwnedHandle::from_system(oversized_read.parent); + assert!(drain_output_with(handle, |_, buffer| { + Ok(sys::ReadCount(buffer.len().saturating_add(1))) + }) + .is_err()); + drop(oversized_read.child); + + let status = || Ok(ExitStatus::from_raw(0)); + assert!(output_result( + status(), + Ok(CleanupOutcome::Preserved), + Err(Error::Validation(ValidationError::SizeOverflow)), + ) + .is_err()); + assert!(output_result( + status(), + Err(Error::Validation(ValidationError::SizeOverflow)), + Ok((Vec::new(), Vec::new())), + ) + .is_err()); + assert!(output_result( + Err(Error::Validation(ValidationError::SizeOverflow)), + Ok(CleanupOutcome::Preserved), + Ok((Vec::new(), Vec::new())) + ) + .is_err()); + let output = output_result( + status(), + Ok(CleanupOutcome::Preserved), + Ok((vec![1], vec![2])), + )?; + assert_eq!(output.stdout, vec![1]); + assert_eq!(output.stderr, vec![2]); Ok(()) } } diff --git a/src/command.rs b/src/command.rs index 1ddee9e..776687c 100644 --- a/src/command.rs +++ b/src/command.rs @@ -4,24 +4,21 @@ use std::os::windows::io::{AsHandle, BorrowedHandle, OwnedHandle}; use std::path::{Path, PathBuf}; use std::process::{ExitStatus, Output}; +use crate::backend::WindowsBackend; use crate::child::{Child, SuspendedChild}; use crate::error::{Error, Operation, Phase, Result}; use crate::handles::Stdio; use crate::options::SpawnOptions; -use crate::plan::{IoMode, ValidatedPlan}; +use crate::plan::IoMode; use crate::sys; use crate::trace::{self, ResourceKind}; -use crate::transaction::PreparedSpawn; - -fn duplicate_command_handle(handle: &T) -> Result { - duplicate_command_handle_with(handle.as_handle(), |source| { - sys::duplicate_local(source, sys::Inheritability::Private) - }) -} +use crate::transaction::{ + output_with_backend, spawn_running_with_backend, spawn_suspended_with_backend, +}; fn duplicate_command_handle_with( source: BorrowedHandle<'_>, - duplicate: impl FnOnce(BorrowedHandle<'_>) -> io::Result, + duplicate: fn(BorrowedHandle<'_>) -> io::Result, ) -> Result { trace::io( Phase::Preparation, @@ -146,9 +143,20 @@ impl Command { /// /// Returns an error if the source handle cannot be duplicated. pub fn arg_handle(&mut self, handle: &T) -> Result<&mut Self> { - self.args - .push(Arg::Handle(duplicate_command_handle(handle)?)); - Ok(self) + self.arg_handle_with(handle, |source| { + sys::duplicate_local(source, sys::Inheritability::Private) + }) + } + + fn arg_handle_with( + &mut self, + handle: &T, + duplicate: fn(BorrowedHandle<'_>) -> io::Result, + ) -> Result<&mut Self> { + duplicate_command_handle_with(handle.as_handle(), duplicate).map(|handle| { + self.args.push(Arg::Handle(handle)); + self + }) } /// Sets one environment variable. @@ -187,11 +195,24 @@ impl Command { key: K, handle: &T, ) -> Result<&mut Self> { - self.env_ops.push(EnvOp::Set( - key.as_ref().to_os_string(), - EnvValue::Handle(duplicate_command_handle(handle)?), - )); - Ok(self) + self.env_handle_with(key, handle, |source| { + sys::duplicate_local(source, sys::Inheritability::Private) + }) + } + + fn env_handle_with, T: AsHandle>( + &mut self, + key: K, + handle: &T, + duplicate: fn(BorrowedHandle<'_>) -> io::Result, + ) -> Result<&mut Self> { + duplicate_command_handle_with(handle.as_handle(), duplicate).map(|handle| { + self.env_ops.push(EnvOp::Set( + key.as_ref().to_os_string(), + EnvValue::Handle(handle), + )); + self + }) } /// Removes an environment variable case-insensitively. @@ -259,11 +280,7 @@ impl Command { /// /// Returns validation, resource-acquisition, or process-creation errors. pub fn spawn_with(&mut self, options: SpawnOptions<'_>) -> Result { - let plan = ValidatedPlan::running(self, options, IoMode::Spawn)?; - PreparedSpawn::prepare(plan)? - .create_suspended()? - .reclaim()? - .resume() + spawn_running_with_backend::(self, options, IoMode::Spawn) } /// Spawns in the suspended type state with default options. @@ -281,11 +298,7 @@ impl Command { /// /// Returns validation, resource-acquisition, or process-creation errors. pub fn spawn_suspended_with(&mut self, options: SpawnOptions<'_>) -> Result { - let plan = ValidatedPlan::suspended(self, options, IoMode::Spawn)?; - Ok(PreparedSpawn::prepare(plan)? - .create_suspended()? - .reclaim()? - .into_suspended()) + spawn_suspended_with_backend::(self, options) } /// Runs the process and waits for its status using default options. @@ -303,7 +316,7 @@ impl Command { /// /// Returns an error from spawning, waiting, or retrieving the exit code. pub fn status_with(&mut self, options: SpawnOptions<'_>) -> Result { - self.spawn_with(options)?.wait() + self.spawn_with(options).and_then(|mut child| child.wait()) } /// Runs the process and captures output using default options. @@ -321,12 +334,7 @@ impl Command { /// /// Returns an error from spawning, waiting, reading, or Job termination. pub fn output_with(&mut self, options: SpawnOptions<'_>) -> Result { - let plan = ValidatedPlan::running(self, options, IoMode::Output)?; - PreparedSpawn::prepare(plan)? - .create_suspended()? - .reclaim()? - .resume()? - .wait_with_output() + output_with_backend::(self, options) } } @@ -355,5 +363,29 @@ mod tests { panic!("Windows error expected"); }; assert_eq!(error.operation(), Operation::DuplicateLocalHandle); + + let mut command = Command::new("program.exe"); + assert!(command + .arg_handle_with(&file, |_| Err(io::Error::from_raw_os_error(5))) + .is_err()); + assert!(command + .env_handle_with("HANDLE", &file, |_| Err(io::Error::from_raw_os_error(5))) + .is_err()); + command + .arg_handle_with(&file, |source| { + sys::duplicate_local(source, sys::Inheritability::Private) + }) + .unwrap(); + command + .env_handle_with("HANDLE", &file, |source| { + sys::duplicate_local(source, sys::Inheritability::Private) + }) + .unwrap(); + + let mut invalid = Command::new(""); + assert!(invalid.status_with(SpawnOptions::new()).is_err()); + let mut valid = Command::new("cmd.exe"); + valid.args(["/D", "/C", "exit /b 0"]); + assert!(valid.status_with(SpawnOptions::new()).unwrap().success()); } } diff --git a/src/core_logic.rs b/src/core_logic.rs index de46ad8..e0ffd05 100644 --- a/src/core_logic.rs +++ b/src/core_logic.rs @@ -21,10 +21,7 @@ pub(crate) struct CommandLine { impl CommandLine { pub(crate) fn new(program: &[u16]) -> Result { let mut value = Self { units: Vec::new() }; - let additional = program - .len() - .checked_add(2) - .ok_or(CommandLineError::TooLong)?; + let additional = program.len().saturating_add(2); value.reserve(additional)?; value.units.push(QUOTE); value.units.extend_from_slice(program); @@ -33,8 +30,8 @@ impl CommandLine { } pub(crate) fn push_regular(&mut self, argument: &[u16]) -> Result<(), CommandLineError> { - let encoded = quoted_argument_len(argument).ok_or(CommandLineError::TooLong)?; - let additional = encoded.checked_add(1).ok_or(CommandLineError::TooLong)?; + let encoded = quoted_argument_len(argument); + let additional = encoded.saturating_add(1); self.reserve(additional)?; self.units.push(SPACE); append_regular(&mut self.units, argument); @@ -42,62 +39,58 @@ impl CommandLine { } pub(crate) fn push_raw(&mut self, argument: &[u16]) -> Result<(), CommandLineError> { - let additional = argument - .len() - .checked_add(1) - .ok_or(CommandLineError::TooLong)?; + let additional = argument.len().saturating_add(1); self.reserve(additional)?; self.units.push(SPACE); self.units.extend_from_slice(argument); Ok(()) } - pub(crate) fn finish(mut self) -> Result, CommandLineError> { - self.reserve(0)?; + pub(crate) fn finish(mut self) -> Vec { self.units.push(0); - Ok(self.units) + self.units } fn reserve(&mut self, additional: usize) -> Result<(), CommandLineError> { - let with_content = self - .units - .len() - .checked_add(additional) - .ok_or(CommandLineError::TooLong)?; - let with_terminator = with_content - .checked_add(1) - .ok_or(CommandLineError::TooLong)?; + self.reserve_with(additional, Vec::try_reserve) + } + + fn reserve_with( + &mut self, + additional: usize, + reserve: impl FnOnce(&mut Vec, usize) -> Result<(), TryReserveError>, + ) -> Result<(), CommandLineError> { + let with_content = self.units.len().saturating_add(additional); + let with_terminator = with_content.saturating_add(1); if with_terminator > MAX_COMMAND_LINE_UNITS { return Err(CommandLineError::TooLong); } - let reservation = additional.checked_add(1).ok_or(CommandLineError::TooLong)?; - self.units - .try_reserve(reservation) - .map_err(CommandLineError::Allocation) + let reservation = additional.saturating_add(1); + reserve(&mut self.units, reservation).map_err(CommandLineError::Allocation) } } -pub(crate) fn quoted_argument_len(argument: &[u16]) -> Option { +pub(crate) fn quoted_argument_len(argument: &[u16]) -> usize { let quoted = needs_quotes(argument); let mut length = argument.len(); if quoted { - length = length.checked_add(2)?; + length = length.saturating_add(2); } let mut backslashes = 0_usize; for unit in argument { if *unit == BACKSLASH { - backslashes = backslashes.checked_add(1)?; + backslashes = backslashes.saturating_add(1); } else { if *unit == QUOTE { - length = length.checked_add(backslashes.checked_add(1)?)?; + length = length.saturating_add(backslashes.saturating_add(1)); } backslashes = 0; } } if quoted { - length = length.checked_add(backslashes)?; + length = length.saturating_add(backslashes); } - Some(length) + length } fn append_regular(command: &mut Vec, argument: &[u16]) { @@ -135,7 +128,7 @@ pub(crate) const fn replace_two_bit_field(word: u64, shift: u32, value: u64) -> return word; }; let encoded = value << shift; - (word & !mask) | (encoded & mask) + (word & !mask).wrapping_add(encoded & mask) } pub(crate) const fn replace_one_bit_field(word: u64, shift: u32, value: u64) -> u64 { @@ -143,7 +136,7 @@ pub(crate) const fn replace_one_bit_field(word: u64, shift: u32, value: u64) -> return word; }; let encoded = value << shift; - (word & !mask) | (encoded & mask) + (word & !mask).wrapping_add(encoded & mask) } #[cfg(test)] @@ -162,7 +155,7 @@ mod tests { ] { let mut emitted = Vec::new(); append_regular(&mut emitted, argument); - assert_eq!(Some(emitted.len()), quoted_argument_len(argument)); + assert_eq!(emitted.len(), quoted_argument_len(argument)); } } @@ -171,22 +164,44 @@ mod tests { let mut line = CommandLine::new(&[u16::from(b'p')]).unwrap(); line.push_regular(&[SPACE]).unwrap(); line.push_raw(&[u16::from(b'r')]).unwrap(); - let line = line.finish().unwrap(); + let line = line.finish(); assert_eq!(line.last(), Some(&0)); assert!(line.len() <= MAX_COMMAND_LINE_UNITS); } #[test] fn builder_rejects_the_windows_limit_and_invalid_bit_shifts_are_noops() { + let largest_program = vec![u16::from(b'x'); MAX_COMMAND_LINE_UNITS - 3]; + let largest_line = CommandLine::new(&largest_program).unwrap().finish(); + assert_eq!(largest_line.len(), MAX_COMMAND_LINE_UNITS); let oversized = vec![u16::from(b'x'); MAX_COMMAND_LINE_UNITS]; assert!(matches!( CommandLine::new(&oversized), Err(CommandLineError::TooLong) )); + let mut regular = CommandLine::new(&[u16::from(b'p')]).unwrap(); + assert!(matches!( + regular.push_regular(&oversized), + Err(CommandLineError::TooLong) + )); + let mut raw = CommandLine::new(&[u16::from(b'p')]).unwrap(); + assert!(matches!( + raw.push_raw(&oversized), + Err(CommandLineError::TooLong) + )); assert_eq!(replace_two_bit_field(7, 64, 0), 7); assert_eq!(replace_two_bit_field(7, 65, 0), 7); assert_eq!(replace_one_bit_field(7, 64, 0), 7); assert_eq!(replace_one_bit_field(7, 65, 0), 7); + + let allocation = Vec::::new().try_reserve(usize::MAX).unwrap_err(); + let mut line = CommandLine::new(&[u16::from(b'p')]).unwrap(); + match line.reserve_with(0, |_, _| Err(allocation)) { + Err(CommandLineError::Allocation(source)) => { + assert!(!source.to_string().is_empty()); + } + _ => panic!("allocation failure expected"), + } } #[test] @@ -212,11 +227,7 @@ mod tests { } let mut emitted = Vec::new(); append_regular(&mut emitted, &argument); - assert_eq!( - Some(emitted.len()), - quoted_argument_len(&argument), - "{case}" - ); + assert_eq!(emitted.len(), quoted_argument_len(&argument), "{case}"); let word = next(&mut state); let value = next(&mut state); @@ -238,14 +249,12 @@ mod tests { #[cfg(kani)] mod proofs { - use super::*; + use super::{quoted_argument_len, replace_one_bit_field, replace_two_bit_field}; #[kani::proof] fn quoting_length_is_bounded() { let argument: [u16; 16] = kani::any(); - let Some(length) = quoted_argument_len(&argument) else { - return; - }; + let length = quoted_argument_len(&argument); assert!(length >= argument.len()); assert!(length <= argument.len() * 2 + 2); } diff --git a/src/error.rs b/src/error.rs index 753710d..4b13066 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,4 +1,3 @@ -use std::error; use std::fmt; use std::io; @@ -43,8 +42,8 @@ impl fmt::Display for Error { } } -impl error::Error for Error { - fn source(&self) -> Option<&(dyn error::Error + 'static)> { +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::Validation(error) => Some(error), Self::Windows(error) => Some(error), @@ -204,7 +203,7 @@ impl fmt::Display for ValidationError { } } -impl error::Error for ValidationError {} +impl std::error::Error for ValidationError {} /// A field accepted as Windows text. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] @@ -287,8 +286,8 @@ impl fmt::Display for WindowsError { } } -impl error::Error for WindowsError { - fn source(&self) -> Option<&(dyn error::Error + 'static)> { +impl std::error::Error for WindowsError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { Some(&self.source) } } @@ -350,8 +349,8 @@ impl fmt::Display for CleanupError { } } -impl error::Error for CleanupError { - fn source(&self) -> Option<&(dyn error::Error + 'static)> { +impl std::error::Error for CleanupError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { Some(&self.first) } } diff --git a/src/handles.rs b/src/handles.rs index 222cf0d..9363fa2 100644 --- a/src/handles.rs +++ b/src/handles.rs @@ -77,12 +77,22 @@ impl Stdio { /// /// Returns the operating-system error if duplication fails. pub fn from_borrowed(source: &T) -> crate::Result { - Ok(Self::from(typed_io( + Self::from_borrowed_with(source, |handle| { + sys::duplicate_local(handle, sys::Inheritability::Private) + }) + } + + fn from_borrowed_with( + source: &T, + duplicate: impl FnOnce(BorrowedHandle<'_>) -> io::Result, + ) -> crate::Result { + typed_io( crate::Phase::Preparation, crate::Operation::DuplicateLocalHandle, ResourceKind::Handle, - || sys::duplicate_local(source.as_handle(), sys::Inheritability::Private), - )?)) + || duplicate(source.as_handle()), + ) + .map(Self::from) } } @@ -113,13 +123,21 @@ impl ParentProcess { /// /// Returns an error if the PID cannot be opened with the required rights. pub fn open(pid: u32) -> crate::Result { - Ok(Self { - handle: OwnedHandle::from_system(typed_io( - crate::Phase::Preparation, - crate::Operation::OpenProcess, - ResourceKind::Process, - || sys::open_parent_process(pid), - )?), + Self::open_with(pid, sys::open_parent_process) + } + + fn open_with( + pid: u32, + open: impl FnOnce(u32) -> io::Result, + ) -> crate::Result { + typed_io( + crate::Phase::Preparation, + crate::Operation::OpenProcess, + ResourceKind::Process, + || open(pid), + ) + .map(|handle| Self { + handle: OwnedHandle::from_system(handle), }) } @@ -166,12 +184,17 @@ impl Job { /// /// Returns the operating-system error if Job creation fails. pub fn create() -> crate::Result { - Ok(Self::from_system(typed_io( + Self::create_with(sys::create_job) + } + + fn create_with(create: impl FnOnce() -> io::Result) -> crate::Result { + typed_io( crate::Phase::Preparation, crate::Operation::CreateJob, ResourceKind::Job, - sys::create_job, - )?)) + create, + ) + .map(Self::from_system) } /// Adopts an existing handle after verifying it is a Job handle. @@ -197,13 +220,21 @@ impl Job { /// /// Returns the operating-system error if duplication fails. pub fn duplicate(&self) -> crate::Result { - Ok(Self { - handle: OwnedHandle::from_system(typed_io( - crate::Phase::Preparation, - crate::Operation::DuplicateLocalHandle, - ResourceKind::Handle, - || sys::duplicate_local(self.handle.as_handle(), sys::Inheritability::Private), - )?), + self.duplicate_with(|handle| sys::duplicate_local(handle, sys::Inheritability::Private)) + } + + fn duplicate_with( + &self, + duplicate: impl FnOnce(BorrowedHandle<'_>) -> io::Result, + ) -> crate::Result { + typed_io( + crate::Phase::Preparation, + crate::Operation::DuplicateLocalHandle, + ResourceKind::Handle, + || duplicate(self.handle.as_handle()), + ) + .map(|handle| Self { + handle: OwnedHandle::from_system(handle), }) } @@ -351,6 +382,17 @@ mod tests { .unwrap(); let file = File::open("NUL").unwrap(); + assert!( + Stdio::from_borrowed_with(&file, |_| { Err(io::Error::from_raw_os_error(5)) }).is_err() + ); + assert!( + ParentProcess::open_with(u32::MAX, |_| { Err(io::Error::from_raw_os_error(5)) }) + .is_err() + ); + assert!(Job::create_with(|| Err(io::Error::from_raw_os_error(5))).is_err()); + assert!(job + .duplicate_with(|_| Err(io::Error::from_raw_os_error(5))) + .is_err()); let not_process = sys::duplicate_local(file.as_handle(), sys::Inheritability::Private).unwrap(); assert!(ParentProcess::from_handle(not_process).is_err()); diff --git a/src/lib.rs b/src/lib.rs index dc9b564..bf77a19 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ #[doc = include_str!("../README.md")] mod readme_examples {} +#[cfg(any(windows, test, kani))] mod core_logic; #[cfg(windows)] @@ -35,7 +36,9 @@ mod trace; mod transaction; #[cfg(windows)] -pub use crate::child::{Child, ChildStderr, ChildStdin, ChildStdout, SuspendedChild}; +pub use crate::child::{ + Child, ChildStderr, ChildStdin, ChildStdout, CleanupOutcome, SuspendedChild, +}; #[cfg(windows)] pub use crate::command::Command; #[cfg(windows)] diff --git a/src/options.rs b/src/options.rs index 8a4d836..6e1b95e 100644 --- a/src/options.rs +++ b/src/options.rs @@ -72,6 +72,54 @@ impl CreationPolicy { } } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct CreationFlags(u32); + +impl CreationFlags { + pub(crate) const fn new(policy: CreationPolicy, console: ConsoleMode) -> Self { + Self(policy.bits().wrapping_add(console.creation_bits())) + } + + pub(crate) const fn bits(self) -> u32 { + self.0 + } +} + +const _: () = { + let empty = CreationPolicy(0); + assert!(empty.bits() == 0); + assert!(empty.with(CREATE_NEW_PROCESS_GROUP).bits() == CREATE_NEW_PROCESS_GROUP); + assert!(empty.with(INHERIT_PARENT_AFFINITY).bits() == INHERIT_PARENT_AFFINITY); + assert!(empty.with(CREATE_BREAKAWAY_FROM_JOB).bits() == CREATE_BREAKAWAY_FROM_JOB); + assert!( + empty.with(CREATE_PRESERVE_CODE_AUTHZ_LEVEL).bits() == CREATE_PRESERVE_CODE_AUTHZ_LEVEL + ); + assert!(empty.with(CREATE_DEFAULT_ERROR_MODE).bits() == CREATE_DEFAULT_ERROR_MODE); + assert!(ConsoleMode::Inherit.creation_bits() == 0); + assert!(ConsoleMode::Detached.creation_bits() == DETACHED_PROCESS); + assert!(ConsoleMode::NewConsole.creation_bits() == CREATE_NEW_CONSOLE); + assert!(ConsoleMode::NoWindow.creation_bits() == CREATE_NO_WINDOW); + assert!(CreationFlags::new(empty, ConsoleMode::Inherit).bits() == 0); + assert!(CreationFlags::new(empty, ConsoleMode::Detached).bits() == DETACHED_PROCESS); + assert!(CreationFlags::new(empty, ConsoleMode::NewConsole).bits() == CREATE_NEW_CONSOLE); + assert!(CreationFlags::new(empty, ConsoleMode::NoWindow).bits() == CREATE_NO_WINDOW); +}; +const _: () = assert!(CREATE_NEW_PROCESS_GROUP & DETACHED_PROCESS == 0); +const _: () = assert!(CREATE_NEW_PROCESS_GROUP & CREATE_NEW_CONSOLE == 0); +const _: () = assert!(CREATE_NEW_PROCESS_GROUP & CREATE_NO_WINDOW == 0); +const _: () = assert!(INHERIT_PARENT_AFFINITY & DETACHED_PROCESS == 0); +const _: () = assert!(INHERIT_PARENT_AFFINITY & CREATE_NEW_CONSOLE == 0); +const _: () = assert!(INHERIT_PARENT_AFFINITY & CREATE_NO_WINDOW == 0); +const _: () = assert!(CREATE_BREAKAWAY_FROM_JOB & DETACHED_PROCESS == 0); +const _: () = assert!(CREATE_BREAKAWAY_FROM_JOB & CREATE_NEW_CONSOLE == 0); +const _: () = assert!(CREATE_BREAKAWAY_FROM_JOB & CREATE_NO_WINDOW == 0); +const _: () = assert!(CREATE_PRESERVE_CODE_AUTHZ_LEVEL & DETACHED_PROCESS == 0); +const _: () = assert!(CREATE_PRESERVE_CODE_AUTHZ_LEVEL & CREATE_NEW_CONSOLE == 0); +const _: () = assert!(CREATE_PRESERVE_CODE_AUTHZ_LEVEL & CREATE_NO_WINDOW == 0); +const _: () = assert!(CREATE_DEFAULT_ERROR_MODE & DETACHED_PROCESS == 0); +const _: () = assert!(CREATE_DEFAULT_ERROR_MODE & CREATE_NEW_CONSOLE == 0); +const _: () = assert!(CREATE_DEFAULT_ERROR_MODE & CREATE_NO_WINDOW == 0); + /// Capabilities and closed creation policies used by one spawn operation. pub struct SpawnOptions<'a> { pub(crate) jobs: Vec<&'a Job>, @@ -199,6 +247,16 @@ impl<'a> SpawnOptions<'a> { mod tests { use super::*; + struct PseudoConsoleOwner(()); + + // SAFETY: the test-only typed value is never submitted to a Win32 call. + #[allow(unsafe_code)] + unsafe impl AsPseudoConsole for PseudoConsoleOwner { + fn as_pseudo_console(&self) -> BorrowedPseudoConsole<'_> { + crate::handles::borrowed_pseudoconsole_for_test(&self.0) + } + } + #[test] fn console_and_creation_policies_encode_every_closed_choice() { assert_eq!(ConsoleMode::Inherit.creation_bits(), 0); @@ -226,5 +284,21 @@ mod tests { TerminalMode::Console(ConsoleMode::NewConsole) )); assert!(!format!("{options:?}").is_empty()); + + let job = Job::create().unwrap(); + let owner = PseudoConsoleOwner(()); + let repeated = SpawnOptions::new() + .job(&job) + .pseudo_console(&owner) + .new_process_group() + .new_process_group(); + assert_eq!(repeated.jobs.len(), 1); + assert!(std::ptr::eq(repeated.jobs[0], &job)); + assert!(matches!(repeated.terminal, TerminalMode::PseudoConsole(_))); + assert_eq!( + repeated.creation.bits(), + CREATE_NEW_PROCESS_GROUP, + "closed flags are idempotent" + ); } } diff --git a/src/plan.rs b/src/plan.rs index b958a6e..376a908 100644 --- a/src/plan.rs +++ b/src/plan.rs @@ -58,16 +58,24 @@ pub(crate) struct ValidatedPlan<'command, 'options, State> { pub(crate) command: &'command Command, pub(crate) options: SpawnOptions<'options>, pub(crate) stdio: StandardIo<'command>, + current_dir: Option>, state: PhantomData, } +struct ValidatedParts<'command, 'options> { + command: &'command Command, + options: SpawnOptions<'options>, + stdio: StandardIo<'command>, + current_dir: Option>, +} + impl<'command, 'options> ValidatedPlan<'command, 'options, Running> { pub(crate) fn running( command: &'command Command, options: SpawnOptions<'options>, io_mode: IoMode, ) -> Result { - Self::build(command, options, io_mode) + validate_plan(command, options, io_mode).map(Self::from_parts) } } @@ -77,76 +85,93 @@ impl<'command, 'options> ValidatedPlan<'command, 'options, Suspended> { options: SpawnOptions<'options>, io_mode: IoMode, ) -> Result { - Self::build(command, options, io_mode) + validate_plan(command, options, io_mode).map(Self::from_parts) } } impl<'command, 'options, State> ValidatedPlan<'command, 'options, State> { + fn from_parts(parts: ValidatedParts<'command, 'options>) -> Self { + Self { + command: parts.command, + options: parts.options, + stdio: parts.stdio, + current_dir: parts.current_dir, + state: PhantomData, + } + } + pub(crate) fn into_parts( self, ) -> ( &'command Command, SpawnOptions<'options>, StandardIo<'command>, + Option>, ) { let Self { command, options, stdio, + current_dir, state: _, } = self; - (command, options, stdio) + (command, options, stdio, current_dir) } +} - fn build( - command: &'command Command, - options: SpawnOptions<'options>, - io_mode: IoMode, - ) -> Result { - validate_command(command)?; - let pseudo_console = matches!(options.terminal, TerminalMode::PseudoConsole(_)); - let explicit_stdio = - command.stdin.is_some() || command.stdout.is_some() || command.stderr.is_some(); - if pseudo_console && explicit_stdio { - return Err(Error::Validation(ValidationError::PseudoConsoleWithStdio)); - } - if pseudo_console && io_mode == IoMode::Output { - return Err(Error::Validation( - ValidationError::PseudoConsoleWithOutputCapture, - )); - } - if options.parent.is_some() - && !pseudo_console - && (command.stdin.is_none() || command.stdout.is_none() || command.stderr.is_none()) - { - return Err(Error::Validation( - ValidationError::AlternateParentNeedsStdio, - )); - } - let stdio = if pseudo_console { - StandardIo::PseudoConsole - } else { - let handles = match io_mode { - IoMode::Spawn => StandardHandles { - stdin: configured_or(command.stdin.as_ref(), StdioSpec::Inherit), - stdout: configured_or(command.stdout.as_ref(), StdioSpec::Inherit), - stderr: configured_or(command.stderr.as_ref(), StdioSpec::Inherit), - }, - IoMode::Output => StandardHandles { - stdin: configured_or(command.stdin.as_ref(), StdioSpec::Null), - stdout: configured_or(command.stdout.as_ref(), StdioSpec::Piped), - stderr: configured_or(command.stderr.as_ref(), StdioSpec::Piped), - }, - }; - StandardIo::Ordinary(handles) - }; - Ok(Self { - command, - options, - stdio, - state: PhantomData, - }) +fn validate_plan<'command, 'options>( + command: &'command Command, + options: SpawnOptions<'options>, + io_mode: IoMode, +) -> Result> { + validate_command(command)?; + let current_dir = command.cwd.as_ref().map(|path| { + let mut encoded: Vec = path.as_os_str().encode_wide().collect(); + encoded.push(0); + encoded + }); + let pseudo_console = matches!(options.terminal, TerminalMode::PseudoConsole(_)); + let explicit_stdio = + command.stdin.is_some() || command.stdout.is_some() || command.stderr.is_some(); + if pseudo_console && explicit_stdio { + return Err(Error::Validation(ValidationError::PseudoConsoleWithStdio)); + } + if pseudo_console && io_mode == IoMode::Output { + return Err(Error::Validation( + ValidationError::PseudoConsoleWithOutputCapture, + )); } + if options.parent.is_some() + && !pseudo_console + && (command.stdin.is_none() || command.stdout.is_none() || command.stderr.is_none()) + { + return Err(Error::Validation( + ValidationError::AlternateParentNeedsStdio, + )); + } + let stdio = if pseudo_console { + StandardIo::PseudoConsole + } else { + let handles = match io_mode { + IoMode::Spawn => StandardHandles { + stdin: configured_or(command.stdin.as_ref(), StdioSpec::Inherit), + stdout: configured_or(command.stdout.as_ref(), StdioSpec::Inherit), + stderr: configured_or(command.stderr.as_ref(), StdioSpec::Inherit), + }, + IoMode::Output => StandardHandles { + stdin: configured_or(command.stdin.as_ref(), StdioSpec::Null), + stdout: configured_or(command.stdout.as_ref(), StdioSpec::Piped), + stderr: configured_or(command.stderr.as_ref(), StdioSpec::Piped), + }, + }; + StandardIo::Ordinary(handles) + }; + Ok(ValidatedParts { + command, + options, + stdio, + current_dir, + }) } fn configured_or<'a>(value: Option<&'a Stdio>, default: StdioSpec<'a>) -> StdioSpec<'a> { @@ -217,6 +242,7 @@ fn no_nul(value: &OsStr, field: InputField) -> Result<()> { #[cfg_attr(coverage_nightly, coverage(off))] mod tests { use std::ffi::{OsStr, OsString}; + use std::fs::File; use std::os::windows::ffi::OsStringExt; use super::*; @@ -240,13 +266,21 @@ mod tests { } #[test] - fn command_validation_rejects_every_invalid_text_shape() { + fn command_validation_rejects_invalid_programs_and_arguments() { assert_validation( &Command::new(""), SpawnOptions::new(), IoMode::Spawn, ValidationError::EmptyProgram, ); + assert_validation( + &Command::new(nul()), + SpawnOptions::new(), + IoMode::Spawn, + ValidationError::InteriorNul { + field: InputField::Program, + }, + ); assert_validation( &Command::new("bad\"name.exe"), SpawnOptions::new(), @@ -287,6 +321,17 @@ mod tests { }, ); + let valid_without_extension = Command::new("program"); + assert!(ValidatedPlan::::running( + &valid_without_extension, + SpawnOptions::new(), + IoMode::Spawn, + ) + .is_ok()); + } + + #[test] + fn command_validation_rejects_invalid_environment_and_directory_text() { let mut empty_name = Command::new("program.exe"); empty_name.env("", "value"); assert_validation( @@ -341,6 +386,15 @@ mod tests { field: InputField::CurrentDirectory, }, ); + let source = File::open("NUL").unwrap(); + let mut handle_environment = Command::new("program.exe"); + handle_environment.env_handle("HANDLE", &source).unwrap(); + assert!(ValidatedPlan::::running( + &handle_environment, + SpawnOptions::new(), + IoMode::Spawn, + ) + .is_ok()); } #[test] @@ -361,14 +415,34 @@ mod tests { IoMode::Output, ValidationError::PseudoConsoleWithOutputCapture, ); + let pseudo_command = Command::new("program.exe"); + let pseudo = ValidatedPlan::::running( + &pseudo_command, + SpawnOptions::new().terminal(terminal), + IoMode::Spawn, + )?; + assert!(matches!(pseudo.stdio, StandardIo::PseudoConsole)); let parent = ParentProcess::open(std::process::id())?; - assert_validation( - &Command::new("program.exe"), - SpawnOptions::new().parent_process(&parent), - IoMode::Spawn, - ValidationError::AlternateParentNeedsStdio, - ); + let mut missing_stdin = Command::new("program.exe"); + missing_stdin.stdout(Stdio::null()).stderr(Stdio::null()); + let mut missing_stdout = Command::new("program.exe"); + missing_stdout.stdin(Stdio::null()).stderr(Stdio::null()); + let mut missing_stderr = Command::new("program.exe"); + missing_stderr.stdin(Stdio::null()).stdout(Stdio::null()); + for command in [ + Command::new("program.exe"), + missing_stdin, + missing_stdout, + missing_stderr, + ] { + assert_validation( + &command, + SpawnOptions::new().parent_process(&parent), + IoMode::Spawn, + ValidationError::AlternateParentNeedsStdio, + ); + } Ok(()) } } diff --git a/src/resource.rs b/src/resource.rs index 4f36d22..95c5802 100644 --- a/src/resource.rs +++ b/src/resource.rs @@ -23,6 +23,8 @@ pub(crate) struct ChildHandleValue
{ } impl
ChildHandleValue
{ + pub(crate) const INVALID: Self = Self::from_raw(-1); + pub(crate) const fn from_raw(raw: isize) -> Self { Self { raw, @@ -35,6 +37,8 @@ impl
ChildHandleValue
{ } } +const _: () = assert!(ChildHandleValue::::INVALID.as_raw() == -1); + impl
Clone for ChildHandleValue
{ fn clone(&self) -> Self { *self @@ -97,6 +101,8 @@ impl fmt::Debug for OwnedHandle { #[cfg(test)] #[cfg_attr(coverage_nightly, coverage(off))] mod tests { + use std::fs::File; + use super::*; #[test] @@ -105,5 +111,11 @@ mod tests { assert_eq!(value.to_string(), "42"); assert_eq!(format!("{value:?}"), "ChildHandleValue"); assert_eq!(value.clone(), value); + assert_ne!(value, ChildHandleValue::::from_raw(43)); + + let file = File::open("NUL").unwrap(); + let owned = OwnedHandle::::from_system(file.into()); + let formatted = format!("{owned:?}"); + assert!(formatted.starts_with("OwnedHandle(")); } } diff --git a/src/sys.rs b/src/sys.rs index 2d22817..83f482c 100644 --- a/src/sys.rs +++ b/src/sys.rs @@ -5,6 +5,7 @@ use std::marker::PhantomData; use std::mem::{size_of, size_of_val}; use std::os::windows::ffi::OsStringExt; use std::os::windows::io::{AsRawHandle, BorrowedHandle, FromRawHandle, OwnedHandle, RawHandle}; +use std::os::windows::process::ExitStatusExt; use std::process::ExitStatus; use std::ptr; @@ -37,22 +38,105 @@ use windows_sys::Win32::System::Threading::GetProcessHandleCount; use windows_sys::Win32::System::Threading::{ CreateProcessW, DeleteProcThreadAttributeList, GetCurrentProcess, GetExitCodeProcess, GetProcessId, InitializeProcThreadAttributeList, OpenProcess, ResumeThread, TerminateProcess, - UpdateProcThreadAttribute, WaitForSingleObject, CREATE_SUSPENDED, CREATE_UNICODE_ENVIRONMENT, - EXTENDED_STARTUPINFO_PRESENT, INFINITE, LPPROC_THREAD_ATTRIBUTE_LIST, PROCESS_CREATE_PROCESS, - PROCESS_DUP_HANDLE, PROCESS_INFORMATION, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, + UpdateProcThreadAttribute, WaitForSingleObject, CREATE_BREAKAWAY_FROM_JOB, + CREATE_DEFAULT_ERROR_MODE, CREATE_NEW_CONSOLE, CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW, + CREATE_PRESERVE_CODE_AUTHZ_LEVEL, CREATE_SUSPENDED, CREATE_UNICODE_ENVIRONMENT, + DETACHED_PROCESS, EXTENDED_STARTUPINFO_PRESENT, INFINITE, INHERIT_PARENT_AFFINITY, + PROCESS_CREATE_PROCESS, PROCESS_DUP_HANDLE, PROCESS_INFORMATION, + PROCESS_QUERY_LIMITED_INFORMATION, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, PROC_THREAD_ATTRIBUTE_JOB_LIST, PROC_THREAD_ATTRIBUTE_MITIGATION_POLICY, PROC_THREAD_ATTRIBUTE_PARENT_PROCESS, PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, STARTF_USESTDHANDLES, STARTUPINFOEXW, }; +use crate::options::CreationFlags; use crate::resource::ChildHandleValue; const MAXIMUM_WINDOWS_PATH_UNITS: u32 = 32_768; -const _: () = assert!(size_of::() <= u32::MAX as usize); -const _: () = - assert!(size_of::() <= u32::MAX as usize); +const INSUFFICIENT_BUFFER_CODE: i32 = 122; +#[cfg(target_pointer_width = "64")] +const JOB_LIMITS_SIZE: u32 = 144; +#[cfg(target_pointer_width = "32")] +const JOB_LIMITS_SIZE: u32 = 112; +#[cfg(target_pointer_width = "64")] +const STARTUP_INFO_SIZE: u32 = 104; +#[cfg(target_pointer_width = "32")] +const STARTUP_INFO_SIZE: u32 = 68; +#[cfg(target_pointer_width = "64")] +const STARTUP_INFO_EX_SIZE: u32 = 112; +#[cfg(target_pointer_width = "32")] +const STARTUP_INFO_EX_SIZE: u32 = 72; +const REMOTE_CLOSE_OPTIONS: u32 = DUPLICATE_SAME_ACCESS.wrapping_add(DUPLICATE_CLOSE_SOURCE); +const PARENT_PROCESS_ACCESS: u32 = PROCESS_CREATE_PROCESS + .wrapping_add(PROCESS_DUP_HANDLE) + .wrapping_add(PROCESS_QUERY_LIMITED_INFORMATION); +const _: () = assert!(DUPLICATE_SAME_ACCESS & DUPLICATE_CLOSE_SOURCE == 0); +const _: () = assert!(PROCESS_CREATE_PROCESS & PROCESS_DUP_HANDLE == 0); +const _: () = assert!(PROCESS_CREATE_PROCESS & PROCESS_QUERY_LIMITED_INFORMATION == 0); +const _: () = assert!(PROCESS_DUP_HANDLE & PROCESS_QUERY_LIMITED_INFORMATION == 0); +const _: () = assert!(CREATE_UNICODE_ENVIRONMENT & CREATE_SUSPENDED == 0); +const _: () = assert!(CREATE_UNICODE_ENVIRONMENT & EXTENDED_STARTUPINFO_PRESENT == 0); +const _: () = assert!(CREATE_SUSPENDED & EXTENDED_STARTUPINFO_PRESENT == 0); +const _: () = assert!(CREATE_NEW_PROCESS_GROUP & CREATE_UNICODE_ENVIRONMENT == 0); +const _: () = assert!(CREATE_NEW_PROCESS_GROUP & CREATE_SUSPENDED == 0); +const _: () = assert!(CREATE_NEW_PROCESS_GROUP & EXTENDED_STARTUPINFO_PRESENT == 0); +const _: () = assert!(INHERIT_PARENT_AFFINITY & CREATE_UNICODE_ENVIRONMENT == 0); +const _: () = assert!(INHERIT_PARENT_AFFINITY & CREATE_SUSPENDED == 0); +const _: () = assert!(INHERIT_PARENT_AFFINITY & EXTENDED_STARTUPINFO_PRESENT == 0); +const _: () = assert!(CREATE_BREAKAWAY_FROM_JOB & CREATE_UNICODE_ENVIRONMENT == 0); +const _: () = assert!(CREATE_BREAKAWAY_FROM_JOB & CREATE_SUSPENDED == 0); +const _: () = assert!(CREATE_BREAKAWAY_FROM_JOB & EXTENDED_STARTUPINFO_PRESENT == 0); +const _: () = assert!(CREATE_PRESERVE_CODE_AUTHZ_LEVEL & CREATE_UNICODE_ENVIRONMENT == 0); +const _: () = assert!(CREATE_PRESERVE_CODE_AUTHZ_LEVEL & CREATE_SUSPENDED == 0); +const _: () = assert!(CREATE_PRESERVE_CODE_AUTHZ_LEVEL & EXTENDED_STARTUPINFO_PRESENT == 0); +const _: () = assert!(CREATE_DEFAULT_ERROR_MODE & CREATE_UNICODE_ENVIRONMENT == 0); +const _: () = assert!(CREATE_DEFAULT_ERROR_MODE & CREATE_SUSPENDED == 0); +const _: () = assert!(CREATE_DEFAULT_ERROR_MODE & EXTENDED_STARTUPINFO_PRESENT == 0); +const _: () = assert!(DETACHED_PROCESS & CREATE_UNICODE_ENVIRONMENT == 0); +const _: () = assert!(DETACHED_PROCESS & CREATE_SUSPENDED == 0); +const _: () = assert!(DETACHED_PROCESS & EXTENDED_STARTUPINFO_PRESENT == 0); +const _: () = assert!(CREATE_NEW_CONSOLE & CREATE_UNICODE_ENVIRONMENT == 0); +const _: () = assert!(CREATE_NEW_CONSOLE & CREATE_SUSPENDED == 0); +const _: () = assert!(CREATE_NEW_CONSOLE & EXTENDED_STARTUPINFO_PRESENT == 0); +const _: () = assert!(CREATE_NO_WINDOW & CREATE_UNICODE_ENVIRONMENT == 0); +const _: () = assert!(CREATE_NO_WINDOW & CREATE_SUSPENDED == 0); +const _: () = assert!(CREATE_NO_WINDOW & EXTENDED_STARTUPINFO_PRESENT == 0); +const _: () = assert!(ERROR_INSUFFICIENT_BUFFER == 122); +#[cfg(target_pointer_width = "64")] +const _: () = assert!(size_of::() == 144); +#[cfg(target_pointer_width = "32")] +const _: () = assert!(size_of::() == 112); +#[cfg(target_pointer_width = "64")] +const _: () = assert!(size_of::() == 112); +#[cfg(target_pointer_width = "32")] +const _: () = assert!(size_of::() == 72); +#[cfg(target_pointer_width = "64")] +const _: () = assert!(size_of::() == 104); +#[cfg(target_pointer_width = "32")] +const _: () = assert!(size_of::() == 68); + +struct EnvironmentBlock(ptr::NonNull); + +enum EnvironmentUnit { + Terminator, + Content, +} + +const fn classify_environment_unit(unit: u16) -> EnvironmentUnit { + match unit { + 0 => EnvironmentUnit::Terminator, + _ => EnvironmentUnit::Content, + } +} -struct EnvironmentBlock(*mut u16); +const _: () = assert!(matches!( + classify_environment_unit(0), + EnvironmentUnit::Terminator +)); +const _: () = assert!(matches!( + classify_environment_unit(1), + EnvironmentUnit::Content +)); #[cfg(test)] static ENVIRONMENT_BLOCK_DROPS: std::sync::atomic::AtomicUsize = @@ -69,7 +153,7 @@ impl Drop for EnvironmentBlock { // SAFETY: the pointer came from GetEnvironmentStringsW and is // released exactly once. unsafe { - FreeEnvironmentStringsW(self.0); + FreeEnvironmentStringsW(self.0.as_ptr()); } } } @@ -146,7 +230,7 @@ fn duplicate_between( let mut duplicate = ptr::null_mut(); // SAFETY: process and source handles are valid for the call; `duplicate` // points to writable storage and becomes uniquely owned on success. - if unsafe { + let result = unsafe { DuplicateHandle( source_process, source, @@ -156,76 +240,65 @@ fn duplicate_between( inheritability.as_win32(), options, ) - } == 0 - { - return Err(io::Error::last_os_error()); + }; + match classify_win32_call(result) { + Win32CallOutcome::Failed => return Err(io::Error::last_os_error()), + Win32CallOutcome::Succeeded => {} } owned(duplicate) } #[derive(Debug)] -enum RemoteState<'a> { - Open { - process: BorrowedHandle<'a>, - value: HANDLE, - }, +enum ReclaimState { + Open, Reclaimed, } #[derive(Debug)] pub(crate) struct RemoteHandle<'a> { - state: RemoteState<'a>, + process: BorrowedHandle<'a>, + value: std::num::NonZeroIsize, + state: ReclaimState, marker: PhantomData<( crate::resource::RemoteKind, crate::resource::AlternateTable<'a>, )>, } +pub(crate) struct ReclaimedRemote(()); + impl RemoteHandle<'_> { pub(crate) fn value
(&self) -> ChildHandleValue
{ - ChildHandleValue::from_raw(match self.state { - RemoteState::Open { value, .. } => value as isize, - RemoteState::Reclaimed => -1, - }) + ChildHandleValue::from_raw(self.value.get()) } - pub(crate) fn reclaim(mut self) -> io::Result<()> { - let state = std::mem::replace(&mut self.state, RemoteState::Reclaimed); - match state { - RemoteState::Open { process, value } => { - if let Err(error) = close_remote(process, value) { - self.state = RemoteState::Open { process, value }; - Err(error) - } else { - Ok(()) - } - } - RemoteState::Reclaimed => Ok(()), - } + pub(crate) fn reclaim(mut self) -> io::Result { + close_remote(self.process, self.value).map(|()| { + self.state = ReclaimState::Reclaimed; + ReclaimedRemote(()) + }) } } impl Drop for RemoteHandle<'_> { fn drop(&mut self) { - let state = std::mem::replace(&mut self.state, RemoteState::Reclaimed); - let RemoteState::Open { process, value } = state else { - return; - }; - let _ = close_remote(process, value); + if matches!(self.state, ReclaimState::Open) { + let _ = close_remote(self.process, self.value); + } } } -fn close_remote(process: BorrowedHandle<'_>, value: HANDLE) -> io::Result<()> { +fn close_remote(process: BorrowedHandle<'_>, value: std::num::NonZeroIsize) -> io::Result<()> { // SAFETY: `GetCurrentProcess` takes no arguments, cannot fail, and // returns the current-process pseudo-handle. The value is a constant // that stays valid for the lifetime of the process and is never closed. let current = unsafe { GetCurrentProcess() }; duplicate_between( raw(process), - value, + value.get() as HANDLE, current, Inheritability::Private, - DUPLICATE_SAME_ACCESS | DUPLICATE_CLOSE_SOURCE, + REMOTE_CLOSE_OPTIONS, ) .map(drop) } @@ -241,7 +314,7 @@ pub(crate) fn duplicate_remote<'a>( let current = unsafe { GetCurrentProcess() }; // SAFETY: both process handles and `source` remain valid. The returned // numeric handle belongs to `target_process` and is owned by RemoteHandle. - if unsafe { + let result = unsafe { DuplicateHandle( current, raw(source), @@ -251,15 +324,18 @@ pub(crate) fn duplicate_remote<'a>( inheritability.as_win32(), DUPLICATE_SAME_ACCESS, ) - } == 0 - { - return Err(io::Error::last_os_error()); + }; + match classify_win32_call(result) { + Win32CallOutcome::Failed => return Err(io::Error::last_os_error()), + Win32CallOutcome::Succeeded => {} } + // SAFETY: successful DuplicateHandle returns a non-null handle value in + // the target process and ownership transfers to RemoteHandle. + let value = unsafe { std::num::NonZeroIsize::new_unchecked(value as isize) }; Ok(RemoteHandle { - state: RemoteState::Open { - process: target_process, - value, - }, + process: target_process, + value, + state: ReclaimState::Open, marker: PhantomData, }) } @@ -272,6 +348,10 @@ pub(crate) fn standard_handle(stream: StandardStream) -> io::Result io::Result> { if !is_valid_handle(handle) { return Ok(None); } @@ -314,37 +394,62 @@ pub(crate) struct Pipe { pub(crate) child: OwnedHandle, } +enum Win32CallOutcome { + Failed, + Succeeded, +} + +const fn classify_win32_call(result: i32) -> Win32CallOutcome { + match result { + 0 => Win32CallOutcome::Failed, + _ => Win32CallOutcome::Succeeded, + } +} + +const _: () = assert!(matches!(classify_win32_call(0), Win32CallOutcome::Failed)); +const _: () = assert!(matches!( + classify_win32_call(1), + Win32CallOutcome::Succeeded +)); + pub(crate) fn create_pipe(direction: PipeDirection) -> io::Result { + create_pipe_with(direction, |read, write| { + // SAFETY: both output pointers are valid. Null security attributes make + // both initial handles private; the child end is duplicated immediately + // before CreateProcessW. + unsafe { CreatePipe(read, write, ptr::null::(), 0) } + }) +} + +fn create_pipe_with( + direction: PipeDirection, + create: fn(&mut HANDLE, &mut HANDLE) -> i32, +) -> io::Result { let mut read = ptr::null_mut(); let mut write = ptr::null_mut(); - // SAFETY: both output pointers are valid. Null security attributes make - // both initial handles private; the child end is duplicated immediately - // before CreateProcessW. - if unsafe { CreatePipe(&mut read, &mut write, ptr::null::(), 0) } == 0 { - return Err(io::Error::last_os_error()); - } - // SAFETY: successful CreatePipe guarantees a valid read handle and - // transfers its sole local ownership here. + match classify_win32_call(create(&mut read, &mut write)) { + Win32CallOutcome::Failed => return Err(io::Error::last_os_error()), + Win32CallOutcome::Succeeded => {} + } + // SAFETY: successful CreatePipe returns a valid owned read handle. let read = unsafe { OwnedHandle::from_raw_handle(read as RawHandle) }; - // SAFETY: successful CreatePipe guarantees a distinct valid write handle - // and transfers its sole local ownership here. + // SAFETY: successful CreatePipe returns a distinct valid owned write handle. let write = unsafe { OwnedHandle::from_raw_handle(write as RawHandle) }; - if direction == PipeDirection::ParentReads { - Ok(Pipe { + match direction { + PipeDirection::ParentReads => Ok(Pipe { parent: read, child: write, - }) - } else { - Ok(Pipe { + }), + PipeDirection::ParentWrites => Ok(Pipe { parent: write, child: read, - }) + }), } } pub(crate) fn open_parent_process(pid: u32) -> io::Result { // SAFETY: OpenProcess has no pointer preconditions. - let handle = unsafe { OpenProcess(PROCESS_CREATE_PROCESS | PROCESS_DUP_HANDLE, 0, pid) }; + let handle = unsafe { OpenProcess(PARENT_PROCESS_ACCESS, 0, pid) }; owned(handle) } @@ -371,7 +476,6 @@ pub(crate) fn set_job_close_policy( policy: crate::JobClosePolicy, ) -> io::Result<()> { let mut limits = query_job_limits(handle)?; - let limit_size = checked_u32_size::()?; match policy { crate::JobClosePolicy::PreserveProcesses => { limits.BasicLimitInformation.LimitFlags &= !JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; @@ -380,14 +484,21 @@ pub(crate) fn set_job_close_policy( limits.BasicLimitInformation.LimitFlags |= JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; } } + set_job_limits(handle, &limits) +} + +fn set_job_limits( + handle: BorrowedHandle<'_>, + limits: &JOBOBJECT_EXTENDED_LIMIT_INFORMATION, +) -> io::Result<()> { // SAFETY: `limits` is the exact structure required by the information // class and remains readable for the call. if unsafe { SetInformationJobObject( raw(handle), JobObjectExtendedLimitInformation, - ptr::addr_of!(limits).cast(), - limit_size, + (limits as *const JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(), + JOB_LIMITS_SIZE, ) } == 0 { @@ -401,7 +512,6 @@ fn query_job_limits( handle: BorrowedHandle<'_>, ) -> io::Result { let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); - let limit_size = checked_u32_size::()?; // SAFETY: `limits` is correctly sized writable storage for the selected // information class; the optional returned-size pointer is null. if unsafe { @@ -409,7 +519,7 @@ fn query_job_limits( raw(handle), JobObjectExtendedLimitInformation, ptr::addr_of_mut!(limits).cast(), - limit_size, + JOB_LIMITS_SIZE, ptr::null_mut(), ) } == 0 @@ -420,6 +530,16 @@ fn query_job_limits( } } +#[cfg(test)] +pub(crate) fn job_close_policy(handle: BorrowedHandle<'_>) -> io::Result { + let flags = query_job_limits(handle)?.BasicLimitInformation.LimitFlags; + if flags & JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE == 0 { + Ok(crate::JobClosePolicy::PreserveProcesses) + } else { + Ok(crate::JobClosePolicy::TerminateProcesses) + } +} + pub(crate) fn assign_job(job: BorrowedHandle<'_>, process: BorrowedHandle<'_>) -> io::Result<()> { // SAFETY: both handles remain valid for the call. bool_result(unsafe { AssignProcessToJobObject(raw(job), raw(process)) }) @@ -431,38 +551,134 @@ pub(crate) fn terminate_job(job: BorrowedHandle<'_>, exit_code: u32) -> io::Resu } pub(crate) struct AttributeList { - storage: Box<[usize]>, + _storage: Box<[usize]>, + pointer: ptr::NonNull, +} + +struct AttributeLayout { + words: usize, + bytes: usize, +} + +struct AttributeInitialization; + +#[derive(Clone, Copy)] +enum AttributeProbeMode { + Native, + #[cfg(test)] + Failure, +} + +impl AttributeProbeMode { + fn invoke(self, count: u32, bytes: &mut usize) -> (i32, io::Error) { + match self { + Self::Native => { + // SAFETY: a null list pointer is the documented size probe and + // `bytes` is writable for the returned size. + let result = + unsafe { InitializeProcThreadAttributeList(ptr::null_mut(), count, 0, bytes) }; + (result, io::Error::last_os_error()) + } + #[cfg(test)] + Self::Failure => (0, io::Error::from_raw_os_error(5)), + } + } +} + +#[derive(Clone, Copy)] +enum AttributeInitializationMode { + Native, + #[cfg(test)] + Failure, +} + +impl AttributeInitializationMode { + fn invoke( + self, + pointer: ptr::NonNull, + count: u32, + bytes: &mut usize, + ) -> (i32, io::Error) { + match self { + Self::Native => { + // SAFETY: the pointer addresses stable writable storage sized + // from the successful probe and `bytes` reports that size. + let result = + unsafe { InitializeProcThreadAttributeList(pointer.as_ptr(), count, 0, bytes) }; + (result, io::Error::last_os_error()) + } + #[cfg(test)] + Self::Failure => (0, io::Error::from_raw_os_error(5)), + } + } +} + +fn attribute_layout( + probe: i32, + probe_error: io::Error, + bytes: usize, +) -> io::Result { + match classify_win32_call(probe) { + Win32CallOutcome::Failed => {} + Win32CallOutcome::Succeeded => { + return Err(io::Error::other( + "attribute-list size probe unexpectedly succeeded", + )); + } + } + if probe_error.raw_os_error() != Some(INSUFFICIENT_BUFFER_CODE) { + return Err(probe_error); + } + if bytes == 0 { + return Err(probe_error); + } + let Some(rounded_bytes) = bytes.checked_add(size_of::() - 1) else { + return Err(io::Error::other("attribute list is too large")); + }; + let words = rounded_bytes / size_of::(); + let bytes = rounded_bytes - (rounded_bytes % size_of::()); + Ok(AttributeLayout { words, bytes }) +} + +fn attribute_initialization( + initialized: i32, + initialize_error: io::Error, +) -> io::Result { + match classify_win32_call(initialized) { + Win32CallOutcome::Failed => Err(initialize_error), + Win32CallOutcome::Succeeded => Ok(AttributeInitialization), + } } impl AttributeList { pub(crate) fn new(count: u32) -> io::Result { + Self::new_with_modes( + count, + AttributeProbeMode::Native, + AttributeInitializationMode::Native, + ) + } + + fn new_with_modes( + count: u32, + probe_mode: AttributeProbeMode, + initialization_mode: AttributeInitializationMode, + ) -> io::Result { let mut bytes = 0_usize; - // SAFETY: the documented first call uses a null list to obtain size. - let probe = - unsafe { InitializeProcThreadAttributeList(ptr::null_mut(), count, 0, &mut bytes) }; - let probe_error = io::Error::last_os_error(); - let insufficient_buffer = i32::try_from(ERROR_INSUFFICIENT_BUFFER) - .map_err(|_| io::Error::other("Win32 error code does not fit i32"))?; - if probe != 0 || probe_error.raw_os_error() != Some(insufficient_buffer) || bytes == 0 { - return Err(if probe != 0 { - io::Error::other("attribute-list size probe unexpectedly succeeded") - } else { - probe_error - }); - } - let words = bytes - .checked_add(size_of::() - 1) - .ok_or_else(|| io::Error::other("attribute list is too large"))? - / size_of::(); - let mut storage = vec![0_usize; words].into_boxed_slice(); - let pointer = storage.as_mut_ptr().cast(); - let mut actual = words * size_of::(); - // SAFETY: Box<[usize]> is word-aligned, stable, and at least `bytes` - // bytes long. It remains owned by the returned AttributeList. - if unsafe { InitializeProcThreadAttributeList(pointer, count, 0, &mut actual) } == 0 { - return Err(io::Error::last_os_error()); - } - Ok(Self { storage }) + let (probe, probe_error) = probe_mode.invoke(count, &mut bytes); + let layout = attribute_layout(probe, probe_error, bytes)?; + let mut storage = vec![0_usize; layout.words].into_boxed_slice(); + // SAFETY: slice pointers are non-null even for an empty slice; the + // positive validated probe additionally makes this allocation nonempty. + let pointer = unsafe { ptr::NonNull::new_unchecked(storage.as_mut_ptr().cast()) }; + let mut actual = layout.bytes; + let (initialized, initialize_error) = + initialization_mode.invoke(pointer, count, &mut actual); + let _initialization = attribute_initialization(initialized, initialize_error)?; + Ok(Self { + _storage: storage, + pointer, + }) } pub(crate) fn set_handle_list
( @@ -517,7 +733,7 @@ impl AttributeList { // every backing allocation stable through CreateProcessW. if unsafe { UpdateProcThreadAttribute( - self.pointer(), + self.pointer().as_ptr(), 0, attribute, value, @@ -533,8 +749,8 @@ impl AttributeList { } } - fn pointer(&self) -> LPPROC_THREAD_ATTRIBUTE_LIST { - self.storage.as_ptr().cast_mut().cast() + fn pointer(&self) -> ptr::NonNull { + self.pointer } } @@ -543,7 +759,7 @@ impl Drop for AttributeList { #[cfg(test)] ATTRIBUTE_LIST_DROPS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); // SAFETY: initialization succeeded once and this is its sole owner. - unsafe { DeleteProcThreadAttributeList(self.pointer()) }; + unsafe { DeleteProcThreadAttributeList(self.pointer().as_ptr()) }; } } @@ -567,7 +783,7 @@ pub(crate) struct ProcessRequest<'a, Table> { pub(crate) current_dir: Option<&'a [u16]>, pub(crate) stdio: StartupStdio
, pub(crate) inheritability: Inheritability, - pub(crate) creation_flags: u32, + pub(crate) creation_flags: CreationFlags, pub(crate) initial_state: InitialState, pub(crate) attributes: Option<&'a AttributeList>, } @@ -583,21 +799,24 @@ pub(crate) fn create_process
( ) -> io::Result { let mut startup = STARTUPINFOEXW::default(); startup.StartupInfo.cb = if request.attributes.is_some() { - checked_u32_size::()? + STARTUP_INFO_EX_SIZE } else { - checked_u32_size::()? + STARTUP_INFO_SIZE }; set_standard_handles(&mut startup, &request.stdio); startup.lpAttributeList = request .attributes - .map_or(ptr::null_mut(), AttributeList::pointer); + .map_or(ptr::null_mut(), |attributes| attributes.pointer().as_ptr()); - let mut flags = request.creation_flags | CREATE_UNICODE_ENVIRONMENT; + let mut flags = request + .creation_flags + .bits() + .wrapping_add(CREATE_UNICODE_ENVIRONMENT); match request.initial_state { - InitialState::Suspended => flags |= CREATE_SUSPENDED, + InitialState::Suspended => flags = flags.wrapping_add(CREATE_SUSPENDED), } if request.attributes.is_some() { - flags |= EXTENDED_STARTUPINFO_PRESENT; + flags = flags.wrapping_add(EXTENDED_STARTUPINFO_PRESENT); } let environment = request .environment @@ -608,7 +827,7 @@ pub(crate) fn create_process
( // SAFETY: all UTF-16 buffers are correctly terminated and remain live; // command_line is writable as required by CreateProcessW. Startup handles // and every attribute backing allocation remain live for the call. - if unsafe { + let result = unsafe { CreateProcessW( request.application.as_ptr(), request.command_line.as_mut_ptr(), @@ -621,15 +840,15 @@ pub(crate) fn create_process
( &startup.StartupInfo, &mut information, ) - } == 0 - { - return Err(io::Error::last_os_error()); + }; + match classify_win32_call(result) { + Win32CallOutcome::Failed => return Err(io::Error::last_os_error()), + Win32CallOutcome::Succeeded => {} } - // SAFETY: successful CreateProcessW transfers a valid process handle here. + // SAFETY: successful CreateProcessW transfers a valid process handle. let process = unsafe { OwnedHandle::from_raw_handle(information.hProcess as RawHandle) }; - // SAFETY: successful CreateProcessW transfers a distinct valid primary - // thread handle here. + // SAFETY: successful CreateProcessW transfers a distinct valid thread handle. let thread = unsafe { OwnedHandle::from_raw_handle(information.hThread as RawHandle) }; Ok(CreatedProcess { process, @@ -651,13 +870,14 @@ pub(crate) fn child_handle_value
(handle: BorrowedHandle<'_>) -> ChildHand ChildHandleValue::from_raw(handle.as_raw_handle() as isize) } -pub(crate) const fn invalid_child_handle
() -> ChildHandleValue
{ - ChildHandleValue::from_raw(-1) -} - pub(crate) fn wait_process(process: BorrowedHandle<'_>) -> io::Result<()> { // SAFETY: the process handle remains valid while waiting. - match unsafe { WaitForSingleObject(raw(process), INFINITE) } { + let result = unsafe { WaitForSingleObject(raw(process), INFINITE) }; + wait_result(result) +} + +fn wait_result(result: u32) -> io::Result<()> { + match result { WAIT_OBJECT_0 => Ok(()), _ => Err(io::Error::last_os_error()), } @@ -665,7 +885,12 @@ pub(crate) fn wait_process(process: BorrowedHandle<'_>) -> io::Result<()> { pub(crate) fn try_wait_process(process: BorrowedHandle<'_>) -> io::Result { // SAFETY: the process handle remains valid while querying. - match unsafe { WaitForSingleObject(raw(process), 0) } { + let result = unsafe { WaitForSingleObject(raw(process), 0) }; + try_wait_result(result) +} + +fn try_wait_result(result: u32) -> io::Result { + match result { WAIT_OBJECT_0 => Ok(true), WAIT_TIMEOUT => Ok(false), _ => Err(io::Error::last_os_error()), @@ -696,11 +921,14 @@ pub(crate) fn cleanup_process_for_test(process: BorrowedHandle<'_>) { } pub(crate) fn exit_status(process: BorrowedHandle<'_>) -> io::Result { - use std::os::windows::process::ExitStatusExt; - let mut code = 0_u32; // SAFETY: `code` is writable and the process handle remains valid. - if unsafe { GetExitCodeProcess(raw(process), &mut code) } == 0 { + let succeeded = unsafe { GetExitCodeProcess(raw(process), &mut code) }; + exit_status_result(succeeded, code) +} + +fn exit_status_result(succeeded: i32, code: u32) -> io::Result { + if succeeded == 0 { Err(io::Error::last_os_error()) } else { Ok(ExitStatus::from_raw(code)) @@ -712,19 +940,29 @@ pub(crate) fn terminate_process(process: BorrowedHandle<'_>, exit_code: u32) -> bool_result(unsafe { TerminateProcess(raw(process), exit_code) }) } -pub(crate) fn resume_thread(thread: BorrowedHandle<'_>) -> io::Result { +pub(crate) struct PreviousSuspendCount(pub(crate) u32); + +pub(crate) fn resume_thread(thread: BorrowedHandle<'_>) -> io::Result { // SAFETY: the thread handle remains valid for the call. let previous = unsafe { ResumeThread(raw(thread)) }; + resume_result(previous) +} + +fn resume_result(previous: u32) -> io::Result { if previous == u32::MAX { Err(io::Error::last_os_error()) } else { - Ok(previous) + Ok(PreviousSuspendCount(previous)) } } -pub(crate) fn read_handle(handle: BorrowedHandle<'_>, buffer: &mut [u8]) -> io::Result { +pub(crate) struct ReadCount(pub(crate) usize); + +pub(crate) struct WriteCount(pub(crate) usize); + +pub(crate) fn read_handle(handle: BorrowedHandle<'_>, buffer: &mut [u8]) -> io::Result { if buffer.is_empty() { - return Ok(0); + return Ok(ReadCount(0)); } let length = u32::try_from(buffer.len()).unwrap_or(u32::MAX); let mut read = 0_u32; @@ -741,24 +979,28 @@ pub(crate) fn read_handle(handle: BorrowedHandle<'_>, buffer: &mut [u8]) -> io:: } == 0 { let error = io::Error::last_os_error(); - if matches!( - error.raw_os_error(), - Some(code) - if win32_code_is(code, ERROR_BROKEN_PIPE) - || win32_code_is(code, ERROR_HANDLE_EOF) - ) { - Ok(0) - } else { - Err(error) - } + read_error(error) } else { - Ok(read as usize) + Ok(ReadCount(read as usize)) } } -pub(crate) fn write_handle(handle: BorrowedHandle<'_>, buffer: &[u8]) -> io::Result { +fn read_error(error: io::Error) -> io::Result { + if matches!( + error.raw_os_error(), + Some(code) + if win32_code_is(code, ERROR_BROKEN_PIPE) + || win32_code_is(code, ERROR_HANDLE_EOF) + ) { + Ok(ReadCount(0)) + } else { + Err(error) + } +} + +pub(crate) fn write_handle(handle: BorrowedHandle<'_>, buffer: &[u8]) -> io::Result { if buffer.is_empty() { - return Ok(0); + return Ok(WriteCount(0)); } let length = u32::try_from(buffer.len()).unwrap_or(u32::MAX); let mut written = 0_u32; @@ -776,25 +1018,32 @@ pub(crate) fn write_handle(handle: BorrowedHandle<'_>, buffer: &[u8]) -> io::Res { Err(io::Error::last_os_error()) } else { - Ok(written as usize) + Ok(WriteCount(written as usize)) } } pub(crate) fn environment_strings() -> io::Result> { + environment_strings_with(get_environment_strings) +} + +fn get_environment_strings() -> *mut u16 { // SAFETY: GetEnvironmentStringsW returns a process-owned double-NUL block // which remains valid until FreeEnvironmentStringsW below. - let base = unsafe { GetEnvironmentStringsW() }; - if base.is_null() { - return Err(io::Error::last_os_error()); - } + unsafe { GetEnvironmentStringsW() } +} + +fn environment_strings_with(get: fn() -> *mut u16) -> io::Result> { + let base = get(); + let base = environment_base(base)?; let guard = EnvironmentBlock(base); let mut entries = Vec::new(); - let mut cursor = guard.0; + let mut cursor = guard.0.as_ptr(); loop { // SAFETY: cursor walks one NUL-terminated entry at a time inside the // double-NUL-terminated environment block. - if unsafe { *cursor } == 0 { - break; + match classify_environment_unit(unsafe { *cursor }) { + EnvironmentUnit::Terminator => break, + EnvironmentUnit::Content => {} } let mut length = 0_usize; loop { @@ -802,37 +1051,16 @@ pub(crate) fn environment_strings() -> io::Result> { // `length` advances only within that entry. let unit = unsafe { cursor.add(length) }; // SAFETY: `unit` addresses the current NUL-terminated entry. - if unsafe { *unit } == 0 { - break; + match classify_environment_unit(unsafe { *unit }) { + EnvironmentUnit::Terminator => break, + EnvironmentUnit::Content => {} } - length = length - .checked_add(1) - .ok_or_else(|| io::Error::other("environment entry is too large"))?; + length = length.saturating_add(1); } // SAFETY: the just-computed range lies within the current entry. let entry = unsafe { std::slice::from_raw_parts(cursor, length) }; - let searchable = entry - .get(1..) - .ok_or_else(|| io::Error::other("environment entry is empty"))?; - if let Some(separator) = searchable - .iter() - .position(|unit| *unit == u16::from(b'=')) - .map(|index| index + 1) - { - let name = entry - .get(..separator) - .ok_or_else(|| io::Error::other("environment name range is invalid"))?; - let value_start = separator - .checked_add(1) - .ok_or_else(|| io::Error::other("environment value range overflowed"))?; - let value = entry - .get(value_start..) - .ok_or_else(|| io::Error::other("environment value range is invalid"))?; - entries.push((OsString::from_wide(name), OsString::from_wide(value))); - } - let advance = length - .checked_add(1) - .ok_or_else(|| io::Error::other("environment block is too large"))?; + entries.extend(parse_environment_entry(entry)); + let advance = length.saturating_add(1); // SAFETY: `advance` moves to the first unit after this entry's // terminator, which is still inside the double-NUL-terminated block. cursor = unsafe { cursor.add(advance) }; @@ -840,11 +1068,36 @@ pub(crate) fn environment_strings() -> io::Result> { Ok(entries) } +fn environment_base(base: *mut u16) -> io::Result> { + ptr::NonNull::new(base).ok_or_else(io::Error::last_os_error) +} + +fn parse_environment_entry(entry: &[u16]) -> Option<(OsString, OsString)> { + let searchable = entry.get(1..)?; + let separator = searchable + .iter() + .position(|unit| *unit == u16::from(b'='))? + .saturating_add(1); + let name: Vec = entry.iter().take(separator).copied().collect(); + let value: Vec = entry + .iter() + .skip(separator.saturating_add(1)) + .copied() + .collect(); + Some((OsString::from_wide(&name), OsString::from_wide(&value))) +} + pub(crate) fn compare_ordinal(left: &[u16], right: &[u16]) -> Ordering { let left_len = i32::try_from(left.len()).unwrap_or(i32::MAX); let right_len = i32::try_from(right.len()).unwrap_or(i32::MAX); // SAFETY: both pointers are readable for their checked lengths. - match unsafe { CompareStringOrdinal(left.as_ptr(), left_len, right.as_ptr(), right_len, 1) } { + let comparison = + unsafe { CompareStringOrdinal(left.as_ptr(), left_len, right.as_ptr(), right_len, 1) }; + ordinal_result(comparison, left, right) +} + +fn ordinal_result(comparison: i32, left: &[u16], right: &[u16]) -> Ordering { + match comparison { CSTR_LESS_THAN => Ordering::Less, CSTR_EQUAL => Ordering::Equal, CSTR_GREATER_THAN => Ordering::Greater, @@ -857,15 +1110,24 @@ pub(crate) fn program_exists(path: &[u16]) -> bool { unsafe { GetFileAttributesW(path.as_ptr()) != INVALID_FILE_ATTRIBUTES } } -pub(crate) fn system_directory() -> io::Result { +#[derive(Debug)] +pub(crate) struct SystemPath(OsString); + +impl SystemPath { + pub(crate) fn into_path_buf(self) -> std::path::PathBuf { + std::path::PathBuf::from(self.0) + } +} + +pub(crate) fn system_directory() -> io::Result { system_path(SystemDirectory::System) } -pub(crate) fn windows_directory() -> io::Result { +pub(crate) fn windows_directory() -> io::Result { system_path(SystemDirectory::Windows) } -fn system_path(directory: SystemDirectory) -> io::Result { +fn system_path(directory: SystemDirectory) -> io::Result { let mut buffer = vec![0_u16; MAXIMUM_WINDOWS_PATH_UNITS as usize]; let length = match directory { SystemDirectory::Windows => { @@ -876,27 +1138,33 @@ fn system_path(directory: SystemDirectory) -> io::Result { // SAFETY: buffer is writable for its reported length. unsafe { GetSystemDirectoryW(buffer.as_mut_ptr(), MAXIMUM_WINDOWS_PATH_UNITS) } } - } as usize; + }; + let error = io::Error::last_os_error(); + decode_system_path(buffer, length, error) +} + +fn decode_system_path( + mut buffer: Vec, + length: u32, + error: io::Error, +) -> io::Result { if length == 0 { - return Err(io::Error::last_os_error()); + return Err(error); } - if length >= buffer.len() { + if length >= MAXIMUM_WINDOWS_PATH_UNITS { return Err(io::Error::other( "Windows directory exceeds the maximum path length", )); } + let length = length as usize; buffer.truncate(length); - Ok(OsString::from_wide(&buffer)) + Ok(SystemPath(OsString::from_wide(&buffer))) } fn raw(handle: BorrowedHandle<'_>) -> HANDLE { handle.as_raw_handle() as HANDLE } -fn checked_u32_size() -> io::Result { - u32::try_from(size_of::()).map_err(|_| io::Error::other("FFI structure is too large")) -} - fn win32_code_is(actual: i32, expected: u32) -> bool { i32::try_from(expected).ok() == Some(actual) } @@ -916,10 +1184,9 @@ fn is_valid_handle(handle: HANDLE) -> bool { } fn bool_result(result: i32) -> io::Result<()> { - if result == 0 { - Err(io::Error::last_os_error()) - } else { - Ok(()) + match classify_win32_call(result) { + Win32CallOutcome::Failed => Err(io::Error::last_os_error()), + Win32CallOutcome::Succeeded => Ok(()), } } @@ -943,7 +1210,6 @@ mod tests { use std::fs::File; use std::os::windows::ffi::OsStrExt; use std::os::windows::io::{AsHandle, AsRawHandle}; - use std::path::PathBuf; use super::*; fn process_handle_count(process: BorrowedHandle<'_>) -> io::Result { @@ -969,25 +1235,33 @@ mod tests { #[test] fn pipe_null_and_duplicate_primitives_preserve_ownership() -> io::Result<()> { + assert_eq!(Inheritability::Private.as_win32(), 0); + assert_eq!(Inheritability::Inheritable.as_win32(), 1); assert_eq!( null_share_mode(), FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE ); let readable_null = null_handle(NullAccess::Read)?; let writable_null = null_handle(NullAccess::Write)?; - assert_eq!(read_handle(readable_null.as_handle(), &mut [])?, 0); - assert_eq!(write_handle(writable_null.as_handle(), &[])?, 0); - assert_eq!(write_handle(writable_null.as_handle(), b"discard")?, 7); + assert_eq!(read_handle(readable_null.as_handle(), &mut [])?.0, 0); + assert_eq!(write_handle(writable_null.as_handle(), &[])?.0, 0); + assert_eq!(write_handle(writable_null.as_handle(), b"discard")?.0, 7); let parent_reads = create_pipe(PipeDirection::ParentReads)?; - assert_eq!(write_handle(parent_reads.child.as_handle(), b"a")?, 1); + assert_eq!(write_handle(parent_reads.child.as_handle(), b"a")?.0, 1); let mut byte = [0_u8; 1]; - assert_eq!(read_handle(parent_reads.parent.as_handle(), &mut byte)?, 1); + assert_eq!( + read_handle(parent_reads.parent.as_handle(), &mut byte)?.0, + 1 + ); assert_eq!(byte, [b'a']); let parent_writes = create_pipe(PipeDirection::ParentWrites)?; - assert_eq!(write_handle(parent_writes.parent.as_handle(), b"b")?, 1); - assert_eq!(read_handle(parent_writes.child.as_handle(), &mut byte)?, 1); + assert_eq!(write_handle(parent_writes.parent.as_handle(), b"b")?.0, 1); + assert_eq!( + read_handle(parent_writes.child.as_handle(), &mut byte)?.0, + 1 + ); assert_eq!(byte, [b'b']); let private = duplicate_local(writable_null.as_handle(), Inheritability::Private)?; @@ -1008,6 +1282,15 @@ mod tests { )?; assert_ne!(remote.value::().as_raw(), 0); assert!(process_handle_count(host.as_handle())? > before); + let _reclaimed = remote.reclaim()?; + assert_eq!(process_handle_count(host.as_handle())?, before); + + let remote = duplicate_remote( + writable_null.as_handle(), + host.as_handle(), + Inheritability::Inheritable, + )?; + assert!(process_handle_count(host.as_handle())? > before); drop(remote); assert_eq!(process_handle_count(host.as_handle())?, before); assert_eq!(process_handle_count(current_process())?, local_before); @@ -1016,6 +1299,76 @@ mod tests { Ok(()) } + #[test] + fn resume_reports_the_previous_suspend_count() -> io::Result<()> { + let system = system_directory()?.into_path_buf(); + let mut application: Vec = system.as_os_str().encode_wide().collect(); + if !application.ends_with(&[u16::from(b'\\')]) { + application.push(u16::from(b'\\')); + } + application.extend("cmd.exe".encode_utf16()); + application.push(0); + let mut command_line: Vec = "\"cmd.exe\" /D /C exit /b 0\0".encode_utf16().collect(); + let input = null_handle(NullAccess::Read)?; + let output = null_handle(NullAccess::Write)?; + let mut request = ProcessRequest { + application: &application, + command_line: &mut command_line, + environment: None, + current_dir: None, + stdio: StartupStdio::Ordinary(StandardHandles { + stdin: child_handle_value::>( + input.as_handle(), + ), + stdout: child_handle_value::>( + output.as_handle(), + ), + stderr: child_handle_value::>( + output.as_handle(), + ), + }), + inheritability: Inheritability::Private, + creation_flags: CreationFlags::new( + crate::options::CreationPolicy::default(), + crate::ConsoleMode::Inherit, + ), + initial_state: InitialState::Suspended, + attributes: None, + }; + let created = create_process(&mut request)?; + assert!(!try_wait_process(created.process.as_handle())?); + let job = create_job()?; + assign_job(job.as_handle(), created.process.as_handle())?; + let first = resume_thread(created.thread.as_handle()); + let second = resume_thread(created.thread.as_handle()); + cleanup_process_for_test(created.process.as_handle()); + assert_eq!(first?.0, 1); + assert_eq!(second?.0, 0); + + let mut missing_application: Vec = + format!("C:\\windows-spawn-missing-{}.exe", std::process::id()) + .encode_utf16() + .collect(); + missing_application.push(0); + let mut missing_command: Vec = "windows-spawn-missing.exe\0".encode_utf16().collect(); + let mut missing_request = ProcessRequest { + application: &missing_application, + command_line: &mut missing_command, + environment: None, + current_dir: None, + stdio: request.stdio, + inheritability: Inheritability::Private, + creation_flags: CreationFlags::new( + crate::options::CreationPolicy::default(), + crate::ConsoleMode::Inherit, + ), + initial_state: InitialState::Suspended, + attributes: None, + }; + assert!(create_process(&mut missing_request).is_err()); + Ok(()) + } + #[test] fn jobs_attributes_and_standard_handles_cover_all_ffi_shapes() -> io::Result<()> { let drops_before = ATTRIBUTE_LIST_DROPS.load(std::sync::atomic::Ordering::Relaxed); @@ -1069,7 +1422,7 @@ mod tests { let error = standard_handle(StandardStream::Error)?; assert!(input.is_some() || output.is_some() || error.is_some()); assert_eq!( - invalid_child_handle::().as_raw(), + ChildHandleValue::::INVALID.as_raw(), -1 ); Ok(()) @@ -1124,10 +1477,10 @@ mod tests { Ordering::Greater ); - let system = system_directory()?; - let windows = windows_directory()?; - assert!(!system.is_empty() && !windows.is_empty()); - let mut executable = PathBuf::from(system); + let system = system_directory()?.into_path_buf(); + let windows = windows_directory()?.into_path_buf(); + assert!(!system.as_os_str().is_empty() && !windows.as_os_str().is_empty()); + let mut executable = system; executable.push("cmd.exe"); let mut wide: Vec = executable.as_os_str().encode_wide().collect(); wide.push(0); @@ -1144,6 +1497,11 @@ mod tests { assert!(owned(INVALID_HANDLE_VALUE).is_err()); assert!(bool_result(0).is_err()); bool_result(1)?; + assert!(win32_code_is( + i32::try_from(ERROR_BROKEN_PIPE).unwrap_or_default(), + ERROR_BROKEN_PIPE + )); + assert!(!win32_code_is(123, ERROR_BROKEN_PIPE)); assert!(open_parent_process(u32::MAX).is_err()); @@ -1155,4 +1513,173 @@ mod tests { assert!(validate_job_handle(file.as_handle()).is_err()); Ok(()) } + + #[test] + fn deterministic_raw_failures_are_classified_without_leaks() -> io::Result<()> { + let file = File::open("NUL")?; + assert!(duplicate_between( + ptr::null_mut(), + ptr::null_mut(), + ptr::null_mut(), + Inheritability::Private, + DUPLICATE_SAME_ACCESS, + ) + .is_err()); + assert!( + duplicate_remote(file.as_handle(), file.as_handle(), Inheritability::Private,).is_err() + ); + assert!(standard_handle_value(ptr::null_mut())?.is_none()); + assert!(create_pipe_with(PipeDirection::ParentReads, |_, _| 0).is_err()); + + let failed_remote = RemoteHandle { + process: file.as_handle(), + value: std::num::NonZeroIsize::new(1) + .ok_or_else(|| io::Error::other("nonzero remote handle expected"))?, + state: ReclaimState::Open, + marker: PhantomData, + }; + assert!(failed_remote.reclaim().is_err()); + + assert!( + set_job_close_policy(file.as_handle(), crate::JobClosePolicy::TerminateProcesses,) + .is_err() + ); + assert!(set_job_limits( + file.as_handle(), + &JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(), + ) + .is_err()); + + let probe_success = attribute_layout(1, io::Error::from_raw_os_error(5), 0); + let Err(probe_success) = probe_success else { + return Err(io::Error::other("size probe failure expected")); + }; + assert_eq!( + probe_success.to_string(), + "attribute-list size probe unexpectedly succeeded" + ); + let wrong_probe_error = + attribute_layout(0, io::Error::from_raw_os_error(5), size_of::()); + let Err(wrong_probe_error) = wrong_probe_error else { + return Err(io::Error::other("wrong probe code failure expected")); + }; + assert_eq!(wrong_probe_error.raw_os_error(), Some(5)); + let empty_probe = + attribute_layout(0, io::Error::from_raw_os_error(INSUFFICIENT_BUFFER_CODE), 0); + let Err(empty_probe) = empty_probe else { + return Err(io::Error::other("empty probe failure expected")); + }; + assert_eq!(empty_probe.raw_os_error(), Some(INSUFFICIENT_BUFFER_CODE)); + let oversized_probe = attribute_layout( + 0, + io::Error::from_raw_os_error(INSUFFICIENT_BUFFER_CODE), + usize::MAX, + ); + assert!(oversized_probe.is_err()); + let layout = attribute_layout( + 0, + io::Error::from_raw_os_error(INSUFFICIENT_BUFFER_CODE), + size_of::(), + )?; + assert_eq!(layout.words, 1); + assert_eq!(layout.bytes, size_of::()); + let rounded_layout = attribute_layout( + 0, + io::Error::from_raw_os_error(INSUFFICIENT_BUFFER_CODE), + size_of::().saturating_add(1), + )?; + assert_eq!(rounded_layout.words, 2); + assert_eq!(rounded_layout.bytes, size_of::().saturating_mul(2)); + assert!(attribute_initialization(0, io::Error::from_raw_os_error(5)).is_err()); + let _initialization = attribute_initialization(1, io::Error::from_raw_os_error(5))?; + assert!(AttributeList::new_with_modes( + 1, + AttributeProbeMode::Failure, + AttributeInitializationMode::Native, + ) + .is_err()); + assert!(AttributeList::new_with_modes( + 1, + AttributeProbeMode::Native, + AttributeInitializationMode::Failure, + ) + .is_err()); + let mut attributes = AttributeList::new(1)?; + assert!(attributes.update(usize::MAX, ptr::null(), 0).is_err()); + Ok(()) + } + + #[test] + fn deterministic_wait_and_pipe_failures_are_classified() -> io::Result<()> { + assert!(wait_result(u32::MAX).is_err()); + assert!(try_wait_result(WAIT_OBJECT_0)?); + assert!(!try_wait_result(WAIT_TIMEOUT)?); + assert!(try_wait_result(u32::MAX).is_err()); + assert!(exit_status_result(0, 0).is_err()); + assert_eq!(resume_result(0)?.0, 0); + assert!(resume_result(u32::MAX).is_err()); + + let parent_reads = create_pipe(PipeDirection::ParentReads)?; + let mut byte = [0_u8; 1]; + assert!(read_handle(parent_reads.child.as_handle(), &mut byte).is_err()); + assert!(write_handle(parent_reads.parent.as_handle(), b"x").is_err()); + assert_eq!( + read_error(io::Error::from_raw_os_error( + i32::try_from(ERROR_BROKEN_PIPE).unwrap_or_default(), + ))? + .0, + 0 + ); + assert_eq!( + read_error(io::Error::from_raw_os_error( + i32::try_from(ERROR_HANDLE_EOF).unwrap_or_default(), + ))? + .0, + 0 + ); + assert!(read_error(io::Error::from_raw_os_error(5)).is_err()); + Ok(()) + } + + #[test] + fn deterministic_environment_and_path_failures_are_classified() -> io::Result<()> { + assert!(environment_strings_with(ptr::null_mut).is_err()); + assert!(environment_base(ptr::null_mut()).is_err()); + let mut unit = 1_u16; + let unit_pointer = ptr::addr_of_mut!(unit); + assert_eq!(environment_base(unit_pointer)?.as_ptr(), unit_pointer); + assert!(parse_environment_entry(&[]).is_none()); + assert!(parse_environment_entry(&[u16::from(b'A')]).is_none()); + let parsed = parse_environment_entry(&[ + u16::from(b'='), + u16::from(b'C'), + u16::from(b':'), + u16::from(b'='), + u16::from(b'x'), + ]) + .ok_or_else(|| io::Error::other("environment entry expected"))?; + assert_eq!(parsed.0, OsString::from("=C:")); + assert_eq!(parsed.1, OsString::from("x")); + + assert_eq!(ordinal_result(0, &[2], &[1]), Ordering::Greater); + let failure = io::Error::from_raw_os_error(5); + assert_eq!( + decode_system_path(vec![0; 4], 0, failure) + .unwrap_err() + .raw_os_error(), + Some(5) + ); + assert!(decode_system_path( + vec![0; MAXIMUM_WINDOWS_PATH_UNITS as usize], + MAXIMUM_WINDOWS_PATH_UNITS, + io::Error::from_raw_os_error(5), + ) + .is_err()); + assert_eq!( + decode_system_path(vec![u16::from(b'X'), 0], 1, io::Error::from_raw_os_error(5),)? + .into_path_buf(), + std::path::PathBuf::from("X") + ); + Ok(()) + } } diff --git a/src/trace.rs b/src/trace.rs index 96d3dd3..3c49866 100644 --- a/src/trace.rs +++ b/src/trace.rs @@ -19,46 +19,73 @@ enum Outcome { Failure, } -#[cfg(feature = "tracing")] pub(crate) fn io( phase: Phase, operation: Operation, resource: ResourceKind, call: impl FnOnce() -> io::Result, ) -> io::Result { + #[cfg(feature = "tracing")] let start = std::time::Instant::now(); let result = call(); - let (outcome, win32_code) = match &result { - Ok(_) => (Outcome::Success, 0), - Err(error) => { - let code: u32 = error - .raw_os_error() - .and_then(|value| u32::try_from(value).ok()) - .unwrap_or_default(); - (Outcome::Failure, code) - } - }; - let elapsed = start.elapsed().as_micros(); - let duration_micros = u64::try_from(elapsed).unwrap_or(u64::MAX); - tracing::event!( - target: "windows_spawn", - tracing::Level::TRACE, - phase = ?phase, - operation = ?operation, - resource = ?resource, - outcome = ?outcome, - win32_code, - duration_micros - ); + #[cfg(feature = "tracing")] + { + let (outcome, win32_code) = match &result { + Ok(_) => (Outcome::Success, 0), + Err(error) => { + let code: u32 = error + .raw_os_error() + .and_then(|value| u32::try_from(value).ok()) + .unwrap_or_default(); + (Outcome::Failure, code) + } + }; + let elapsed = start.elapsed().as_micros(); + let duration_micros = u64::try_from(elapsed).unwrap_or(u64::MAX); + tracing::event!( + target: "windows_spawn", + tracing::Level::TRACE, + phase = ?phase, + operation = ?operation, + resource = ?resource, + outcome = ?outcome, + win32_code, + duration_micros + ); + } + #[cfg(not(feature = "tracing"))] + let _ = (phase, operation, resource); result } -#[cfg(not(feature = "tracing"))] -pub(crate) fn io( - _phase: Phase, - _operation: Operation, - _resource: ResourceKind, - call: impl FnOnce() -> io::Result, -) -> io::Result { - call() +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::*; + + #[test] + fn io_invokes_the_call_and_preserves_both_result_variants() -> io::Result<()> { + let mut called = false; + let value = io( + Phase::Runtime, + Operation::ReadPipe, + ResourceKind::Pipe, + || { + called = true; + Ok(41_u8) + }, + )?; + assert!(called); + assert_eq!(value, 41); + let error = io( + Phase::Runtime, + Operation::WritePipe, + ResourceKind::Pipe, + || Err::<(), _>(io::Error::from_raw_os_error(5)), + ) + .err() + .ok_or_else(|| io::Error::other("traced failure expected"))?; + assert_eq!(error.raw_os_error(), Some(5)); + Ok(()) + } } diff --git a/src/transaction.rs b/src/transaction.rs index 9300e18..23b7617 100644 --- a/src/transaction.rs +++ b/src/transaction.rs @@ -9,21 +9,81 @@ use std::os::windows::io::{AsHandle, BorrowedHandle, OwnedHandle}; use std::path::{Path, PathBuf}; use std::pin::Pin; -use crate::backend::{AttributeAddress, SpawnBackend, WindowsBackend}; +#[cfg(test)] +use crate::backend::WindowsBackend; +use crate::backend::{AttributeAddress, BackendAdapter, SpawnBackend}; use crate::child::{Child, JobOwnership, ProcessOwner, SuspendedChild}; use crate::command::{Arg, Command, EnvOp, EnvValue, EnvironmentBase}; use crate::core_logic::{CommandLine, CommandLineError}; use crate::error::{CleanupError, Error, Operation, Phase, Result, WindowsError}; use crate::handles::StdioInner; -use crate::options::{ConsoleMode, JobClosePolicy, SpawnOptions, TerminalMode}; +use crate::options::{ConsoleMode, CreationFlags, JobClosePolicy, SpawnOptions, TerminalMode}; use crate::plan::{ - DesiredState, Running, StandardHandles, StandardIo, StdioSpec, Suspended, ValidatedPlan, + DesiredState, IoMode, Running, StandardHandles, StandardIo, StdioSpec, Suspended, ValidatedPlan, }; use crate::resource::{ChildHandleValue, CurrentTable, SelectedTable}; use crate::sys::{self, Inheritability, InitialState, NullAccess, PipeDirection, StandardStream}; use crate::trace::{self, ResourceKind}; -const MAX_ATTRIBUTE_COUNT: u32 = 5; +pub(crate) fn spawn_running_with_backend( + command: &Command, + options: SpawnOptions<'_>, + io_mode: IoMode, +) -> Result { + spawn_running(command, options, io_mode, Backend::adapter()) +} + +fn spawn_running( + command: &Command, + options: SpawnOptions<'_>, + io_mode: IoMode, + backend: BackendAdapter, +) -> Result { + ValidatedPlan::running(command, options, io_mode) + .and_then(|plan| PreparedSpawn::>::prepare(plan, backend)) + .and_then(PreparedSpawn::create_suspended) + .and_then(CreatedSuspended::reclaim) + .and_then(Reclaimed::resume) +} + +pub(crate) fn spawn_suspended_with_backend( + command: &Command, + options: SpawnOptions<'_>, +) -> Result { + spawn_suspended(command, options, Backend::adapter()) +} + +fn spawn_suspended( + command: &Command, + options: SpawnOptions<'_>, + backend: BackendAdapter, +) -> Result { + ValidatedPlan::suspended(command, options, IoMode::Spawn) + .and_then(|plan| PreparedSpawn::>::prepare(plan, backend)) + .and_then(PreparedSpawn::create_suspended) + .and_then(CreatedSuspended::reclaim) + .map(Reclaimed::into_suspended) +} + +pub(crate) fn output_with_backend( + command: &Command, + options: SpawnOptions<'_>, +) -> Result { + output(command, options, Backend::adapter()) +} + +fn output( + command: &Command, + options: SpawnOptions<'_>, + backend: BackendAdapter, +) -> Result { + ValidatedPlan::running(command, options, IoMode::Output) + .and_then(|plan| PreparedSpawn::>::prepare(plan, backend)) + .and_then(PreparedSpawn::create_suspended) + .and_then(CreatedSuspended::reclaim) + .and_then(Reclaimed::resume) + .and_then(Child::wait_with_output) +} struct AttributeBacking<'a, Table> { inherited_values: Box<[ChildHandleValue
]>, @@ -39,7 +99,7 @@ struct ProcessAttributeList<'a, Table> { } impl<'a, Table> ProcessAttributeList<'a, Table> { - fn new(backing: AttributeBacking<'a, Table>) -> Result { + fn new(backing: AttributeBacking<'a, Table>, backend: BackendAdapter) -> Result { let backing = Box::pin(backing); let values = backing.as_ref().get_ref(); let attribute_count = u32::from(!values.inherited_values.is_empty()) @@ -47,31 +107,40 @@ impl<'a, Table> ProcessAttributeList<'a, Table> { + u32::from(values.mitigation_value.is_some()) + u32::from(!values.job_values.is_empty()) + u32::from(values.pseudoconsole.is_some()); - if attribute_count > MAX_ATTRIBUTE_COUNT { - return Err(Error::Validation(crate::ValidationError::SizeOverflow)); - } let mut list = if attribute_count == 0 { None } else { - Some(Backend::create_attributes(attribute_count).map_err(attribute_error)?) + Some( + backend + .create_attributes(attribute_count) + .map_err(attribute_error)?, + ) }; if let Some(attributes) = &mut list { if !values.inherited_values.is_empty() { - Backend::set_handle_list(attributes, &values.inherited_values) + backend + .set_handle_list(attributes, &values.inherited_values) .map_err(attribute_error)?; } if let Some(parent) = &values.parent_value { - Backend::set_parent(attributes, AttributeAddress::new(parent.as_ref())) + backend + .set_parent(attributes, AttributeAddress::new(parent.as_ref())) .map_err(attribute_error)?; } if let Some(mitigation) = &values.mitigation_value { - Backend::set_mitigation(attributes, mitigation).map_err(attribute_error)?; + backend + .set_mitigation(attributes, mitigation) + .map_err(attribute_error)?; } if !values.job_values.is_empty() { - Backend::set_jobs(attributes, &values.job_values).map_err(attribute_error)?; + backend + .set_jobs(attributes, &values.job_values) + .map_err(attribute_error)?; } if let Some(pseudoconsole) = values.pseudoconsole { - Backend::set_pseudoconsole(attributes, pseudoconsole).map_err(attribute_error)?; + backend + .set_pseudoconsole(attributes, pseudoconsole) + .map_err(attribute_error)?; } } Ok(Self { @@ -89,7 +158,12 @@ fn attribute_error(error: io::Error) -> Error { Error::windows(Phase::Preparation, Operation::ConfigureAttributes, error) } -pub(crate) struct PreparedSpawn<'options, State, Table, Backend = WindowsBackend> { +pub(crate) struct PreparedSpawn<'options, State, Table> { + resources: PreparedResources<'options, Table>, + state: PhantomData, +} + +struct PreparedResources<'options, Table> { application: Vec, command_line: Vec, environment: Environment, @@ -97,54 +171,62 @@ pub(crate) struct PreparedSpawn<'options, State, Table, Backend = WindowsBackend stdio_values: sys::StartupStdio
, stdio: StandardHandles>, job: JobOwnership, - transfer: HandleTransfer<'options, Backend>, + transfer: HandleTransfer<'options>, attributes: ProcessAttributeList<'options, Table>, - creation_flags: u32, - state: PhantomData<(State, Backend)>, + creation_flags: CreationFlags, } -impl<'options, State: DesiredState> - PreparedSpawn<'options, State, SelectedTable<'options>, WindowsBackend> -{ - pub(crate) fn prepare(plan: ValidatedPlan<'_, 'options, State>) -> Result { - Self::prepare_with_backend(plan) +impl<'options, State: DesiredState> PreparedSpawn<'options, State, SelectedTable<'options>> { + fn prepare(plan: ValidatedPlan<'_, 'options, State>, backend: BackendAdapter) -> Result { + let (command, options, stdio_plan, current_dir) = plan.into_parts(); + PreparedResources::>::prepare( + command, + &options, + &stdio_plan, + current_dir, + backend, + ) + .map(|resources| Self { + resources, + state: PhantomData, + }) + } + + pub(crate) fn create_suspended( + self, + ) -> Result>> { + self.resources + .create_suspended() + .map(|resources| CreatedSuspended { + resources, + state: PhantomData, + }) } } -impl<'options, State: DesiredState, Backend: SpawnBackend> - PreparedSpawn<'options, State, SelectedTable<'options>, Backend> -{ - fn prepare_with_backend(plan: ValidatedPlan<'_, 'options, State>) -> Result { - let (command, options, stdio_plan) = plan.into_parts(); +impl<'options> PreparedResources<'options, SelectedTable<'options>> { + fn prepare( + command: &Command, + options: &SpawnOptions<'options>, + stdio_plan: &StandardIo<'_>, + current_dir: Option>, + backend: BackendAdapter, + ) -> Result { let parent: Option> = options.parent.map(AsHandle::as_handle); - let mut transfer = HandleTransfer::::new(parent); - let (stdio_values, stdio) = prepare_standard_io::(&stdio_plan, &mut transfer)?; - let (job, job_values) = prepare_job::(&options)?; - - let command_line = build_command_line::(command, &mut transfer)?; - let environment = - build_environment::(command, &mut transfer).map_err(|error| { - Error::windows(Phase::Preparation, Operation::ReadEnvironment, error) - })?; + let mut transfer = HandleTransfer::new(parent, backend); + let (stdio_values, stdio) = prepare_standard_io(stdio_plan, &mut transfer)?; + let (job, job_values) = prepare_job(options, backend)?; + + let command_line = build_command_line(command, &mut transfer)?; + let environment = build_environment(command, &mut transfer).map_err(|error| { + Error::windows(Phase::Preparation, Operation::ReadEnvironment, error) + })?; let child_path = environment.path.as_deref(); let application = - resolve_executable::(&command.program, child_path).map_err(|error| { + resolve_executable(&command.program, child_path, backend).map_err(|error| { Error::windows(Phase::Preparation, Operation::ResolveExecutable, error) })?; - let current_dir = command - .cwd - .as_ref() - .map(|path| wide_nul(path.as_os_str())) - .transpose() - .map_err(|error| { - Error::windows(Phase::Preparation, Operation::ResolveExecutable, error) - })?; - - let attributes = prepare_attributes::(&options, &transfer, job_values)?; - let console_flags = match options.terminal { - TerminalMode::Console(mode) => mode.creation_bits(), - TerminalMode::PseudoConsole(_) => ConsoleMode::Inherit.creation_bits(), - }; + let attributes = prepare_attributes(options, &transfer, job_values)?; Ok(Self { application, command_line, @@ -155,25 +237,24 @@ impl<'options, State: DesiredState, Backend: SpawnBackend> job, transfer, attributes, - creation_flags: options.creation.bits() | console_flags, - state: PhantomData, + creation_flags: CreationFlags::new( + options.creation, + match options.terminal { + TerminalMode::Console(mode) => mode, + TerminalMode::PseudoConsole(_) => ConsoleMode::Inherit, + }, + ), }) } - pub(crate) fn create_suspended( - mut self, - ) -> Result, Backend>> { + fn create_suspended(mut self) -> Result>> { let mut request = sys::ProcessRequest { application: &self.application, command_line: &mut self.command_line, environment: self.environment.block.as_deref(), current_dir: self.current_dir.as_deref(), stdio: self.stdio_values, - inheritability: if self.transfer.inherited_values().is_empty() { - Inheritability::Private - } else { - Inheritability::Inheritable - }, + inheritability: process_inheritability(self.transfer.inherited_values()), creation_flags: self.creation_flags, initial_state: InitialState::Suspended, attributes: self.attributes.as_list(), @@ -182,16 +263,15 @@ impl<'options, State: DesiredState, Backend: SpawnBackend> Phase::Creation, Operation::CreateProcess, ResourceKind::Process, - || Backend::create_process(&mut request), + || self.transfer.backend().create_process(&mut request), ); match created { - Ok(created) => Ok(CreatedSuspended { + Ok(created) => Ok(CreatedResources { process: ProcessOwner::new(created), job: self.job, stdio: self.stdio, transfer: self.transfer, attributes: self.attributes, - state: PhantomData, }), Err(source) => { let primary = WindowsError::new(Phase::Creation, Operation::CreateProcess, source); @@ -208,19 +288,28 @@ impl<'options, State: DesiredState, Backend: SpawnBackend> } } -fn prepare_standard_io<'options, Backend: SpawnBackend>( +fn process_inheritability( + inherited_values: &[ChildHandleValue>], +) -> Inheritability { + if inherited_values.is_empty() { + Inheritability::Private + } else { + Inheritability::Inheritable + } +} + +fn prepare_standard_io<'options>( plan: &StandardIo<'_>, - transfer: &mut HandleTransfer<'options, Backend>, + transfer: &mut HandleTransfer<'options>, ) -> Result<( sys::StartupStdio>, StandardHandles>, )> { match plan { StandardIo::Ordinary(specs) => { - let prepared = - prepare_standard_handles::(specs, transfer).map_err(|error| { - Error::windows(Phase::Preparation, Operation::DuplicateLocalHandle, error) - })?; + let prepared = prepare_standard_handles(specs, transfer).map_err(|error| { + Error::windows(Phase::Preparation, Operation::DuplicateLocalHandle, error) + })?; let values = sys::StartupStdio::Ordinary(sys::StandardHandles { stdin: prepared.stdin.child, stdout: prepared.stdout.child, @@ -244,8 +333,9 @@ fn prepare_standard_io<'options, Backend: SpawnBackend>( } } -fn prepare_job( +fn prepare_job( options: &SpawnOptions<'_>, + backend: BackendAdapter, ) -> Result<(JobOwnership, Vec>)> { let job = match options.job_close { JobClosePolicy::PreserveProcesses => JobOwnership::Preserve, @@ -254,14 +344,14 @@ fn prepare_job( Phase::Preparation, Operation::CreateJob, ResourceKind::Job, - Backend::create_job, + || backend.create_job(), ) .map_err(|error| Error::windows(Phase::Preparation, Operation::CreateJob, error))?; trace::io( Phase::Preparation, Operation::ConfigureJob, ResourceKind::Job, - || Backend::configure_job(&job, JobClosePolicy::TerminateProcesses), + || backend.configure_job(&job, JobClosePolicy::TerminateProcesses), ) .map_err(|error| Error::windows(Phase::Preparation, Operation::ConfigureJob, error))?; JobOwnership::Terminate(job) @@ -278,9 +368,9 @@ fn prepare_job( Ok((job, values)) } -fn prepare_attributes<'options, Backend: SpawnBackend>( +fn prepare_attributes<'options>( options: &SpawnOptions<'options>, - transfer: &HandleTransfer<'options, Backend>, + transfer: &HandleTransfer<'options>, job_values: Vec>, ) -> Result>> { let inherited_values = transfer.inherited_values().to_vec().into_boxed_slice(); @@ -293,75 +383,107 @@ fn prepare_attributes<'options, Backend: SpawnBackend>( TerminalMode::Console(_) => None, TerminalMode::PseudoConsole(value) => Some(value), }; - ProcessAttributeList::new::(AttributeBacking { - inherited_values, - parent_value, - mitigation_value, - job_values: job_values.into_boxed_slice(), - pseudoconsole, - }) + ProcessAttributeList::new( + AttributeBacking { + inherited_values, + parent_value, + mitigation_value, + job_values: job_values.into_boxed_slice(), + pseudoconsole, + }, + transfer.backend(), + ) +} + +pub(crate) struct CreatedSuspended<'options, State, Table> { + resources: CreatedResources<'options, Table>, + state: PhantomData, } -pub(crate) struct CreatedSuspended<'options, State, Table, Backend = WindowsBackend> { +struct CreatedResources<'options, Table> { process: ProcessOwner, job: JobOwnership, stdio: StandardHandles>, - transfer: HandleTransfer<'options, Backend>, + transfer: HandleTransfer<'options>, attributes: ProcessAttributeList<'options, Table>, - state: PhantomData<(State, Backend)>, } -impl CreatedSuspended<'_, State, Table, Backend> { - pub(crate) fn reclaim(self) -> Result> { +impl CreatedSuspended<'_, State, Table> { + pub(crate) fn reclaim(self) -> Result> { + self.resources.reclaim().map(|resources| Reclaimed { + resources, + state: PhantomData, + }) + } +} + +impl
CreatedResources<'_, Table> { + fn reclaim(self) -> Result { let Self { process, job, stdio, transfer, attributes, - state: _, } = self; + let backend = transfer.backend(); drop(attributes); - transfer.reclaim().map_err(Error::Cleanup)?; - Ok(Reclaimed { - process, - job, - stdio, - state: PhantomData, - }) + transfer + .reclaim() + .map_err(Error::Cleanup) + .map(|()| ReclaimedResources { + process, + job, + stdio, + backend, + }) } } -pub(crate) struct Reclaimed { +pub(crate) struct Reclaimed { + resources: ReclaimedResources, + state: PhantomData<(State, Table)>, +} + +struct ReclaimedResources { process: ProcessOwner, job: JobOwnership, stdio: StandardHandles>, - state: PhantomData<(State, Table, Backend)>, + backend: BackendAdapter, } -impl Reclaimed { - pub(crate) fn resume(self) -> Result { - let mut child = Child::new( +impl ReclaimedResources { + fn into_child(self) -> Child { + Child::new( self.process, self.job, self.stdio.stdin, self.stdio.stdout, self.stdio.stderr, - ); - child.resume_initial_with(Backend::resume_thread)?; - Ok(child) + ) } } -impl
Reclaimed { +impl
Reclaimed { + pub(crate) fn resume(self) -> Result { + let backend = self.resources.backend; + let mut child = self.resources.into_child(); + let resumed = match backend { + BackendAdapter::Windows => { + child.resume_initial_with(|thread| BackendAdapter::Windows.resume_thread(thread)) + } + #[cfg(test)] + BackendAdapter::Fault => { + child.resume_initial_with(|thread| BackendAdapter::Fault.resume_thread(thread)) + } + }; + resumed.map(|_| child) + } +} + +impl
Reclaimed { pub(crate) fn into_suspended(self) -> SuspendedChild { - SuspendedChild::new(Child::new( - self.process, - self.job, - self.stdio.stdin, - self.stdio.stdout, - self.stdio.stderr, - )) + SuspendedChild::new(self.resources.into_child()) } } @@ -370,30 +492,30 @@ struct PreparedStdio<'parent> { parent: Option, } -fn prepare_standard_handles<'parent, Backend: SpawnBackend>( +fn prepare_standard_handles<'parent>( specs: &StandardHandles>, - transfer: &mut HandleTransfer<'parent, Backend>, + transfer: &mut HandleTransfer<'parent>, ) -> io::Result>> { Ok(StandardHandles { - stdin: prepare_stdio::(specs.stdin, StandardStream::Input, transfer)?, - stdout: prepare_stdio::(specs.stdout, StandardStream::Output, transfer)?, - stderr: prepare_stdio::(specs.stderr, StandardStream::Error, transfer)?, + stdin: prepare_stdio(specs.stdin, StandardStream::Input, transfer)?, + stdout: prepare_stdio(specs.stdout, StandardStream::Output, transfer)?, + stderr: prepare_stdio(specs.stderr, StandardStream::Error, transfer)?, }) } -fn prepare_stdio<'parent, Backend: SpawnBackend>( +fn prepare_stdio<'parent>( spec: StdioSpec<'_>, stream: StandardStream, - transfer: &mut HandleTransfer<'parent, Backend>, + transfer: &mut HandleTransfer<'parent>, ) -> io::Result> { match spec { - StdioSpec::Inherit => prepare_inherit::(stream, transfer), - StdioSpec::Null => prepare_null::(stream, transfer), - StdioSpec::Piped => prepare_pipe::(stream, transfer), + StdioSpec::Inherit => prepare_inherit(stream, transfer), + StdioSpec::Null => prepare_null(stream, transfer), + StdioSpec::Piped => prepare_pipe(stream, transfer), StdioSpec::Configured(stdio) => match &stdio.inner { - StdioInner::Inherit => prepare_inherit::(stream, transfer), - StdioInner::Null => prepare_null::(stream, transfer), - StdioInner::Piped => prepare_pipe::(stream, transfer), + StdioInner::Inherit => prepare_inherit(stream, transfer), + StdioInner::Null => prepare_null(stream, transfer), + StdioInner::Piped => prepare_pipe(stream, transfer), StdioInner::Owned(handle) => Ok(PreparedStdio { child: transfer.lower(handle.as_handle())?, parent: None, @@ -402,40 +524,40 @@ fn prepare_stdio<'parent, Backend: SpawnBackend>( } } -fn prepare_inherit<'parent, Backend: SpawnBackend>( +fn prepare_inherit<'parent>( stream: StandardStream, - transfer: &mut HandleTransfer<'parent, Backend>, + transfer: &mut HandleTransfer<'parent>, ) -> io::Result> { - match Backend::standard_handle(stream)? { + match transfer.backend().standard_handle(stream)? { Some(handle) => Ok(PreparedStdio { child: transfer.lower(handle.as_handle())?, parent: None, }), None => Ok(PreparedStdio { - child: sys::invalid_child_handle(), + child: ChildHandleValue::INVALID, parent: None, }), } } -fn prepare_null<'parent, Backend: SpawnBackend>( +fn prepare_null<'parent>( stream: StandardStream, - transfer: &mut HandleTransfer<'parent, Backend>, + transfer: &mut HandleTransfer<'parent>, ) -> io::Result> { let access = match stream { StandardStream::Input => NullAccess::Read, StandardStream::Output | StandardStream::Error => NullAccess::Write, }; - let handle = Backend::null_handle(access)?; + let handle = transfer.backend().null_handle(access)?; Ok(PreparedStdio { child: transfer.lower(handle.as_handle())?, parent: None, }) } -fn prepare_pipe<'parent, Backend: SpawnBackend>( +fn prepare_pipe<'parent>( stream: StandardStream, - transfer: &mut HandleTransfer<'parent, Backend>, + transfer: &mut HandleTransfer<'parent>, ) -> io::Result> { let direction = match stream { StandardStream::Input => PipeDirection::ParentWrites, @@ -445,7 +567,7 @@ fn prepare_pipe<'parent, Backend: SpawnBackend>( Phase::Preparation, Operation::CreatePipe, ResourceKind::Pipe, - || Backend::create_pipe(direction), + || transfer.backend().create_pipe(direction), )?; let child = transfer.lower(pipe.child.as_handle())?; Ok(PreparedStdio { @@ -454,22 +576,22 @@ fn prepare_pipe<'parent, Backend: SpawnBackend>( }) } -struct HandleTransfer<'a, Backend = WindowsBackend> { +struct HandleTransfer<'a> { parent: Option>, local: Vec, remote: Vec>, inherited: Vec>>, - backend: PhantomData, + backend: BackendAdapter, } -impl<'a, Backend: SpawnBackend> HandleTransfer<'a, Backend> { - fn new(parent: Option>) -> Self { +impl<'a> HandleTransfer<'a> { + fn new(parent: Option>, backend: BackendAdapter) -> Self { Self { parent, local: Vec::new(), remote: Vec::new(), inherited: Vec::new(), - backend: PhantomData, + backend, } } @@ -482,7 +604,10 @@ impl<'a, Backend: SpawnBackend> HandleTransfer<'a, Backend> { Phase::Preparation, Operation::DuplicateRemoteHandle, ResourceKind::RemoteHandle, - || Backend::duplicate_remote(source, parent, Inheritability::Inheritable), + || { + self.backend + .duplicate_remote(source, parent, Inheritability::Inheritable) + }, )?; let value = handle.value(); self.remote.push(handle); @@ -492,15 +617,16 @@ impl<'a, Backend: SpawnBackend> HandleTransfer<'a, Backend> { Phase::Preparation, Operation::DuplicateLocalHandle, ResourceKind::Handle, - || Backend::duplicate_local(source, Inheritability::Inheritable), + || { + self.backend + .duplicate_local(source, Inheritability::Inheritable) + }, )?; let value = sys::child_handle_value(handle.as_handle()); self.local.push(handle); value }; - if !self.inherited.contains(&value) { - self.inherited.push(value); - } + self.inherited.push(value); Ok(value) } @@ -508,6 +634,10 @@ impl<'a, Backend: SpawnBackend> HandleTransfer<'a, Backend> { self.parent } + fn backend(&self) -> BackendAdapter { + self.backend + } + fn duplicate_operation(&self) -> Operation { match self.parent { Some(_) => Operation::DuplicateRemoteHandle, @@ -526,7 +656,7 @@ impl<'a, Backend: SpawnBackend> HandleTransfer<'a, Backend> { Phase::Reclamation, Operation::ReclaimRemoteHandle, ResourceKind::RemoteHandle, - || Backend::reclaim_remote(handle), + || self.backend.reclaim_remote(handle), ) { failures.push(WindowsError::new( Phase::Reclamation, @@ -585,9 +715,9 @@ impl PartialEq for EnvKey { impl Eq for EnvKey {} -fn build_environment( +fn build_environment( command: &Command, - transfer: &mut HandleTransfer<'_, Backend>, + transfer: &mut HandleTransfer<'_>, ) -> io::Result { if command.environment_base == EnvironmentBase::Inherit && command.env_ops.is_empty() { return Ok(Environment { @@ -598,7 +728,7 @@ fn build_environment( let mut map: BTreeMap = BTreeMap::new(); if command.environment_base == EnvironmentBase::Inherit { - for (key, value) in Backend::environment_strings()? { + for (key, value) in transfer.backend().environment_strings()? { map.insert(EnvKey::new(key), value); } } @@ -644,10 +774,7 @@ fn build_environment( }) } -fn build_command_line( - command: &Command, - transfer: &mut HandleTransfer<'_, Backend>, -) -> Result> { +fn build_command_line(command: &Command, transfer: &mut HandleTransfer<'_>) -> Result> { let program: Vec = command.program.encode_wide().collect(); let mut result = CommandLine::new(&program).map_err(command_line_error)?; for argument in &command.args { @@ -670,7 +797,7 @@ fn build_command_line( } } } - result.finish().map_err(command_line_error) + Ok(result.finish()) } fn command_line_error(error: CommandLineError) -> Error { @@ -684,9 +811,26 @@ fn command_line_error(error: CommandLineError) -> Error { } } -fn resolve_executable( +fn resolve_executable( program: &OsStr, child_path: Option<&OsStr>, + backend: BackendAdapter, +) -> io::Result> { + resolve_executable_with( + program, + child_path, + env::current_exe(), + env::var_os("PATH").as_deref(), + backend, + ) +} + +fn resolve_executable_with( + program: &OsStr, + child_path: Option<&OsStr>, + current_application: io::Result, + process_path: Option<&OsStr>, + backend: BackendAdapter, ) -> io::Result> { let path = Path::new(program); let has_exe_suffix = program @@ -725,20 +869,20 @@ fn resolve_executable( } } } - if let Ok(mut application) = env::current_exe() { + if let Ok(mut application) = current_application { application.pop(); if let Some(found) = search(application) { return Ok(found); } } - if let Some(found) = search(PathBuf::from(Backend::system_directory()?)) { + if let Some(found) = search(backend.system_directory()?.into_path_buf()) { return Ok(found); } - if let Some(found) = search(PathBuf::from(Backend::windows_directory()?)) { + if let Some(found) = search(backend.windows_directory()?.into_path_buf()) { return Ok(found); } - if let Some(paths) = env::var_os("PATH") { - for directory in env::split_paths(&paths).filter(|path| !path.as_os_str().is_empty()) { + if let Some(paths) = process_path { + for directory in env::split_paths(paths).filter(|path| !path.as_os_str().is_empty()) { if let Some(found) = search(directory) { return Ok(found); } @@ -767,10 +911,261 @@ mod tests { use super::*; use crate::backend::{ - configure_fault, configure_faults, fault_calls, BackendCall, FaultBackend, + configure_fault, configure_faults, configure_missing_standard_handle, fault_calls, + BackendCall, FaultBackend, }; - use crate::plan::IoMode; - use crate::{DepPolicy, MitigationPolicy, ParentProcess, Stdio}; + use crate::{ConsoleMode, DepPolicy, MitigationPolicy, ParentProcess, Stdio, TerminalMode}; + use windows_sys::Win32::System::Threading::{CREATE_NEW_CONSOLE, CREATE_NEW_PROCESS_GROUP}; + + fn windows_backend() -> BackendAdapter { + WindowsBackend::adapter() + } + + fn fault_backend() -> BackendAdapter { + FaultBackend::adapter() + } + + struct ProbeFile { + path: PathBuf, + } + + impl ProbeFile { + fn create(path: PathBuf) -> Self { + drop(File::create(&path).unwrap()); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } + } + + impl Drop for ProbeFile { + fn drop(&mut self) { + match std::fs::remove_file(&self.path) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => eprintln!("test probe cleanup failed: {error}"), + } + } + } + + fn attribute_backing( + mitigation_value: Option>, + ) -> AttributeBacking<'static, SelectedTable<'static>> { + AttributeBacking { + inherited_values: Vec::new().into_boxed_slice(), + parent_value: None, + mitigation_value, + job_values: Vec::new().into_boxed_slice(), + pseudoconsole: None, + } + } + + #[test] + fn attribute_presence_and_disjoint_creation_flags_are_observable() -> Result<()> { + let empty = ProcessAttributeList::>::new( + attribute_backing(None), + windows_backend(), + )?; + assert!(empty.as_list().is_none()); + let populated = ProcessAttributeList::>::new( + attribute_backing(Some(Box::new([1, 0]))), + windows_backend(), + )?; + assert!(populated.as_list().is_some()); + + configure_fault(None); + let command = Command::new("cmd.exe"); + let options = SpawnOptions::new() + .terminal(TerminalMode::Console(ConsoleMode::NewConsole)) + .new_process_group(); + let plan = ValidatedPlan::running(&command, options, IoMode::Spawn)?; + let prepared = PreparedSpawn::>::prepare(plan, fault_backend())?; + assert_eq!( + prepared.resources.creation_flags.bits(), + CREATE_NEW_PROCESS_GROUP | CREATE_NEW_CONSOLE + ); + + let job = crate::Job::create()?; + let job_options = SpawnOptions::new().job(&job); + let (_, job_values) = prepare_job(&job_options, windows_backend())?; + assert_eq!(job_values.len(), 1); + Ok(()) + } + + #[test] + fn pseudoconsole_preparation_uses_the_terminal_only_path() -> Result<()> { + configure_fault(None); + let owner = (); + let terminal = + TerminalMode::PseudoConsole(crate::handles::borrowed_pseudoconsole_for_test(&owner)); + let command = Command::new("cmd.exe"); + let plan = ValidatedPlan::running( + &command, + SpawnOptions::new().terminal(terminal), + IoMode::Spawn, + )?; + let prepared = PreparedSpawn::>::prepare(plan, fault_backend())?; + assert!(matches!( + prepared.resources.stdio_values, + sys::StartupStdio::PseudoConsole + )); + assert!(prepared.resources.stdio.stdin.is_none()); + assert!(prepared.resources.stdio.stdout.is_none()); + assert!(prepared.resources.stdio.stderr.is_none()); + assert!(matches!( + process_inheritability(&[]), + Inheritability::Private + )); + let inherited = [ChildHandleValue::>::INVALID]; + assert!(matches!( + process_inheritability(&inherited), + Inheritability::Inheritable + )); + Ok(()) + } + + #[test] + fn prepared_stdio_and_attributes_propagate_each_backend_failure() -> io::Result<()> { + let inherited = Stdio::inherit(); + configure_missing_standard_handle(); + let mut missing_transfer = HandleTransfer::new(None, fault_backend()); + let prepared = prepare_stdio( + StdioSpec::Configured(&inherited), + StandardStream::Input, + &mut missing_transfer, + )?; + assert_eq!(prepared.child.as_raw(), -1); + assert!(prepared.parent.is_none()); + + configure_fault(None); + let piped = Stdio::piped(); + let mut piped_transfer = HandleTransfer::new(None, fault_backend()); + let piped = prepare_stdio( + StdioSpec::Configured(&piped), + StandardStream::Input, + &mut piped_transfer, + )?; + assert!(piped.parent.is_some()); + + configure_fault(Some(0)); + let mut fault_transfer = HandleTransfer::new(None, fault_backend()); + assert!(prepare_stdio( + StdioSpec::Inherit, + StandardStream::Input, + &mut fault_transfer, + ) + .is_err()); + let owned = Stdio::from( + File::open("NUL") + .map_err(|error| Error::windows(Phase::Preparation, Operation::OpenFile, error))?, + ); + configure_fault(Some(0)); + let mut failed_transfer = HandleTransfer::new(None, fault_backend()); + assert!(prepare_stdio( + StdioSpec::Configured(&owned), + StandardStream::Input, + &mut failed_transfer, + ) + .is_err()); + configure_fault(None); + let mut owned_transfer = HandleTransfer::new(None, fault_backend()); + let prepared_owned = prepare_stdio( + StdioSpec::Configured(&owned), + StandardStream::Input, + &mut owned_transfer, + )?; + assert!(prepared_owned.parent.is_none()); + + configure_fault(Some(1)); + let pseudo_owner = (); + let pseudoconsole = crate::handles::borrowed_pseudoconsole_for_test(&pseudo_owner); + let backing = AttributeBacking { + inherited_values: Vec::new().into_boxed_slice(), + parent_value: None, + mitigation_value: None, + job_values: Vec::new().into_boxed_slice(), + pseudoconsole: Some(pseudoconsole), + }; + assert!( + ProcessAttributeList::>::new(backing, fault_backend()).is_err() + ); + Ok(()) + } + + #[test] + fn output_pipeline_runs_through_the_backend_state_machine() -> Result<()> { + configure_fault(None); + let mut command = Command::new("cmd.exe"); + command.args(["/D", "/C", "exit /b 0"]); + let output = output_with_backend::(&command, SpawnOptions::new())?; + assert!(output.status.success()); + Ok(()) + } + + #[test] + fn dynamic_builders_preserve_all_typed_failure_categories() -> Result<()> { + let allocation = Vec::::new().try_reserve(usize::MAX).unwrap_err(); + assert!(matches!( + command_line_error(CommandLineError::TooLong), + Error::Validation(crate::ValidationError::CommandLineTooLong) + )); + let allocation_error = command_line_error(CommandLineError::Allocation(allocation)); + let Error::Windows(windows) = allocation_error else { + return Err(Error::Validation(crate::ValidationError::SizeOverflow)); + }; + assert_eq!(windows.operation(), Operation::BuildCommandLine); + + let source = File::open("NUL") + .map_err(|error| Error::windows(Phase::Preparation, Operation::OpenFile, error))?; + let mut argument = Command::new("program.exe"); + argument.arg_handle(&source)?; + configure_fault(Some(0)); + let mut transfer = HandleTransfer::new(None, fault_backend()); + assert!(build_command_line(&argument, &mut transfer).is_err()); + + let mut environment = Command::new("program.exe"); + environment.env_clear(); + environment.env_handle("HANDLE", &source)?; + configure_fault(Some(0)); + let mut transfer = HandleTransfer::new(None, fault_backend()); + assert!(build_environment(&environment, &mut transfer).is_err()); + configure_fault(None); + let mut transfer = HandleTransfer::new(None, fault_backend()); + let lowered_environment = + build_environment(&environment, &mut transfer).map_err(|error| { + Error::windows(Phase::Preparation, Operation::ReadEnvironment, error) + })?; + assert!(lowered_environment.block.is_some()); + + let oversized = vec![u16::from(b'x'); crate::core_logic::MAX_COMMAND_LINE_UNITS]; + let oversized_text = OsString::from_wide(&oversized); + let oversized_program = Command::new(&oversized_text); + let mut transfer = HandleTransfer::new(None, windows_backend()); + assert!(build_command_line(&oversized_program, &mut transfer).is_err()); + + let mut regular = Command::new("program.exe"); + regular.arg(&oversized_text); + let mut transfer = HandleTransfer::new(None, windows_backend()); + assert!(build_command_line(®ular, &mut transfer).is_err()); + + let mut raw = Command::new("program.exe"); + raw.raw_arg(&oversized_text); + let mut transfer = HandleTransfer::new(None, windows_backend()); + assert!(build_command_line(&raw, &mut transfer).is_err()); + + let largest_program = OsString::from_wide(&vec![ + u16::from(b'p'); + crate::core_logic::MAX_COMMAND_LINE_UNITS + - 3 + ]); + let mut handle_at_limit = Command::new(largest_program); + handle_at_limit.arg_handle(&source)?; + let mut transfer = HandleTransfer::new(None, windows_backend()); + assert!(build_command_line(&handle_at_limit, &mut transfer).is_err()); + Ok(()) + } fn decode(value: &[u16]) -> String { String::from_utf16_lossy(value.strip_suffix(&[0]).unwrap_or(value)) @@ -784,14 +1179,20 @@ mod tests { let options = SpawnOptions::new() .mitigation(MitigationPolicy::new().dep(DepPolicy::Enable)) .job_close_policy(JobClosePolicy::TerminateProcesses); - let plan = ValidatedPlan::running(&command, options, IoMode::Output)?; let mut child = - PreparedSpawn::, FaultBackend>::prepare_with_backend(plan)? - .create_suspended()? - .reclaim()? - .resume()?; - let _status = child.wait()?; - child.cleanup() + spawn_running_with_backend::(&command, options, IoMode::Output)?; + wait_for_fault_child(&mut child)?; + child.cleanup().map(drop) + } + + fn run_suspended_fault_case(fail_at: Option) -> Result<()> { + configure_fault(fail_at); + let mut command = Command::new("cmd.exe"); + command.args(["/D", "/C", "exit /b 0"]); + let suspended = + spawn_suspended_with_backend::(&command, SpawnOptions::new())?; + drop(suspended); + Ok(()) } fn run_remote_fault_case(fail_at: Option) -> Result<()> { @@ -810,14 +1211,25 @@ mod tests { command.stdout(Stdio::null()); command.stderr(Stdio::null()); let options = SpawnOptions::new().parent_process(&parent); - let plan = ValidatedPlan::running(&command, options, IoMode::Spawn)?; let mut child = - PreparedSpawn::, FaultBackend>::prepare_with_backend(plan)? - .create_suspended()? - .reclaim()? - .resume()?; + spawn_running_with_backend::(&command, options, IoMode::Spawn)?; + wait_for_fault_child(&mut child)?; + child.cleanup().map(drop) + } + + fn wait_for_fault_child(child: &mut Child) -> Result<()> { + let exited = sys::wait_process_for_test(child.process_handle(), 5_000) + .map_err(|error| Error::windows(Phase::Runtime, Operation::WaitProcess, error))?; + if !exited { + sys::cleanup_process_for_test(child.process_handle()); + return Err(Error::windows( + Phase::Runtime, + Operation::WaitProcess, + io::Error::new(io::ErrorKind::TimedOut, "fault-backend child did not exit"), + )); + } let _status = child.wait()?; - child.cleanup() + Ok(()) } fn assert_faults_release_every_handle( @@ -904,6 +1316,24 @@ mod tests { Ok(()) } + #[test] + fn fault_backend_exhausts_suspended_spawn_calls_without_leaks() -> io::Result<()> { + let calls = assert_faults_release_every_handle(run_suspended_fault_case)?; + assert!(calls.contains(&BackendCall::StandardHandle)); + let invalid = Command::new(""); + assert!( + spawn_suspended_with_backend::(&invalid, SpawnOptions::new(),).is_err() + ); + assert!(spawn_running_with_backend::( + &invalid, + SpawnOptions::new(), + IoMode::Spawn, + ) + .is_err()); + assert!(output_with_backend::(&invalid, SpawnOptions::new()).is_err()); + Ok(()) + } + #[test] fn primary_and_cleanup_failures_are_both_preserved() -> io::Result<()> { run_remote_fault_case(None).map_err(io::Error::from)?; @@ -940,14 +1370,14 @@ mod tests { fn quotes_regular_and_preserves_raw_arguments() { let mut command = Command::new("program.exe"); command.arg("a b").arg("a\"b").raw_arg("x&&y"); - let mut transfer = HandleTransfer::::new(None); + let mut transfer = HandleTransfer::new(None, windows_backend()); let line = build_command_line(&command, &mut transfer).unwrap(); assert_eq!(decode(&line), r#""program.exe" "a b" "a\"b" x&&y"#); } #[test] fn executable_search_finds_system_command_without_current_directory() { - let command = resolve_executable::(OsStr::new("cmd"), None).unwrap(); + let command = resolve_executable(OsStr::new("cmd"), None, windows_backend()).unwrap(); assert!(decode(&command).to_ascii_lowercase().ends_with("cmd.exe")); } @@ -955,7 +1385,7 @@ mod tests { fn cleared_environment_is_double_nul() { let mut command = Command::new("cmd.exe"); command.env_clear(); - let mut transfer = HandleTransfer::::new(None); + let mut transfer = HandleTransfer::new(None, windows_backend()); let environment = build_environment(&command, &mut transfer).unwrap(); assert_eq!(environment.block.unwrap(), vec![0, 0]); } @@ -968,7 +1398,7 @@ mod tests { .env("PATH", "second") .env("REMOVE_ME", "value") .env_remove("remove_me"); - let mut transfer = HandleTransfer::::new(None); + let mut transfer = HandleTransfer::new(None, windows_backend()); let environment = build_environment(&command, &mut transfer).unwrap(); assert_eq!(environment.path, Some(OsString::from("second"))); let block = environment.block.unwrap(); @@ -992,7 +1422,7 @@ mod tests { .env("ſ", "long-s") .env("Μ", "greek-mu") .env("µ", "micro-sign"); - let mut transfer = HandleTransfer::::new(None); + let mut transfer = HandleTransfer::new(None, windows_backend()); let block = build_environment(&command, &mut transfer) .unwrap() .block @@ -1021,54 +1451,62 @@ mod tests { fn quoting_covers_empty_and_trailing_backslashes() { let mut command = Command::new("program.exe"); command.arg("").arg(r"C:\path with spaces\"); - let mut transfer = HandleTransfer::::new(None); + let mut transfer = HandleTransfer::new(None, windows_backend()); let line = decode(&build_command_line(&command, &mut transfer).unwrap()); assert_eq!(line, r#""program.exe" "" "C:\path with spaces\\""#); } #[test] - fn executable_resolution_covers_explicit_child_path_and_not_found() { - let system = PathBuf::from(sys::system_directory().unwrap()); + fn executable_resolution_covers_explicit_paths_and_not_found() { + let system = sys::system_directory().unwrap().into_path_buf(); let executable = system.join("cmd.exe"); assert_eq!( - decode(&resolve_executable::(executable.as_os_str(), None).unwrap()), + decode(&resolve_executable(executable.as_os_str(), None, windows_backend()).unwrap(),), executable.to_string_lossy() ); let without_extension = system.join("cmd"); assert_eq!( decode( - &resolve_executable::(without_extension.as_os_str(), None).unwrap(), + &resolve_executable(without_extension.as_os_str(), None, windows_backend()) + .unwrap(), ), executable.to_string_lossy() ); assert!(decode( - &resolve_executable::(OsStr::new("cmd.exe"), Some(system.as_os_str()),) - .unwrap() + &resolve_executable( + OsStr::new("cmd.exe"), + Some(system.as_os_str()), + windows_backend(), + ) + .unwrap() ) .to_ascii_lowercase() .ends_with("cmd.exe")); let missing = format!("windows-spawn-missing-{}.exe", std::process::id()); assert_eq!( - resolve_executable::(OsStr::new(&missing), Some(OsStr::new(""))) - .unwrap_err() - .kind(), + resolve_executable( + OsStr::new(&missing), + Some(OsStr::new("")), + windows_backend(), + ) + .unwrap_err() + .kind(), io::ErrorKind::NotFound ); let empty_path_probe = format!("windows-spawn-empty-path-{}.exe", std::process::id()); let empty_path_probe_path = env::current_dir().unwrap().join(&empty_path_probe); - let probe = File::create(&empty_path_probe_path).unwrap(); + let _probe = ProbeFile::create(empty_path_probe_path); assert_eq!( - resolve_executable::( + resolve_executable( OsStr::new(&empty_path_probe), Some(OsStr::new(";")), + windows_backend(), ) .unwrap_err() .kind(), io::ErrorKind::NotFound ); - drop(probe); - std::fs::remove_file(empty_path_probe_path).unwrap(); let nul = OsString::from_wide(&[u16::from(b'x'), 0]); assert_eq!( wide_nul(&nul).unwrap_err().kind(), @@ -1076,19 +1514,122 @@ mod tests { ); } + #[test] + fn executable_resolution_searches_process_system_and_environment_paths() { + let explicit_missing = env::temp_dir().join(format!( + "windows-spawn-explicit-missing-{}", + std::process::id() + )); + assert_eq!( + decode( + &resolve_executable_with( + explicit_missing.as_os_str(), + None, + Err(io::Error::other("current application unavailable")), + None, + windows_backend(), + ) + .unwrap(), + ), + explicit_missing.to_string_lossy() + ); + + let current = env::current_exe().unwrap(); + let current_name = current.file_name().unwrap(); + assert_eq!( + decode( + &resolve_executable_with( + current_name, + None, + Ok(current.clone()), + None, + windows_backend(), + ) + .unwrap(), + ), + current.to_string_lossy() + ); + + let explorer = resolve_executable_with( + OsStr::new("explorer.exe"), + None, + Err(io::Error::other("current application unavailable")), + None, + windows_backend(), + ) + .unwrap(); + assert!(decode(&explorer) + .to_ascii_lowercase() + .ends_with("explorer.exe")); + + let path_probe = format!("windows-spawn-path-probe-{}.exe", std::process::id()); + let path_probe_file = env::temp_dir().join(&path_probe); + let probe = ProbeFile::create(path_probe_file); + assert_eq!( + decode( + &resolve_executable_with( + OsStr::new(&path_probe), + None, + Err(io::Error::other("current application unavailable")), + Some(env::temp_dir().as_os_str()), + windows_backend(), + ) + .unwrap(), + ), + probe.path().to_string_lossy() + ); + + let missing = format!("windows-spawn-missing-{}.exe", std::process::id()); + configure_fault(Some(1)); + assert!(resolve_executable_with( + OsStr::new(&missing), + None, + Err(io::Error::other("current application unavailable")), + None, + fault_backend(), + ) + .is_err()); + configure_fault(None); + + let nul_path = OsString::from_wide(&[u16::from(b'X'), 0]); + assert!(resolve_executable_with( + OsStr::new(&missing), + Some(nul_path.as_os_str()), + Err(io::Error::other("current application unavailable")), + None, + windows_backend(), + ) + .is_err()); + let nul_program = OsString::from_wide(&[ + u16::from(b'C'), + u16::from(b':'), + u16::from(b'\\'), + u16::from(b'X'), + 0, + ]); + assert!(resolve_executable_with( + nul_program.as_os_str(), + None, + Err(io::Error::other("current application unavailable")), + None, + windows_backend(), + ) + .is_err()); + } + #[test] fn uncommitted_running_and_suspended_transactions_roll_back() { let mut running_command = Command::new("cmd.exe"); running_command.args(["/D", "/C", "ping -n 10 127.0.0.1 >nul"]); let running = ValidatedPlan::running(&running_command, SpawnOptions::new(), IoMode::Spawn).unwrap(); - let running_transaction = PreparedSpawn::prepare(running) + let running_transaction = PreparedSpawn::prepare(running, windows_backend()) .unwrap() .create_suspended() .unwrap(); let mut running_process = ProcessExitGuard::new( sys::duplicate_local( - running_transaction.process.process_handle(), + running_transaction.resources.process.process_handle(), Inheritability::Private, ) .unwrap(), @@ -1101,13 +1642,13 @@ mod tests { let suspended = ValidatedPlan::suspended(&suspended_command, SpawnOptions::new(), IoMode::Spawn) .unwrap(); - let suspended_transaction = PreparedSpawn::prepare(suspended) + let suspended_transaction = PreparedSpawn::prepare(suspended, windows_backend()) .unwrap() .create_suspended() .unwrap(); let mut suspended_process = ProcessExitGuard::new( sys::duplicate_local( - suspended_transaction.process.process_handle(), + suspended_transaction.resources.process.process_handle(), Inheritability::Private, ) .unwrap(), @@ -1121,13 +1662,13 @@ mod tests { let mut command = Command::new("cmd.exe"); command.args(["/D", "/C", "exit /b 0"]); let plan = ValidatedPlan::suspended(&command, SpawnOptions::new(), IoMode::Spawn).unwrap(); - let transaction = PreparedSpawn::prepare(plan) + let transaction = PreparedSpawn::prepare(plan, windows_backend()) .unwrap() .create_suspended() .unwrap(); let mut process = ProcessExitGuard::new( sys::duplicate_local( - transaction.process.process_handle(), + transaction.resources.process.process_handle(), Inheritability::Private, ) .unwrap(), diff --git a/tests/windows_spawn.rs b/tests/windows_spawn.rs index a650f5b..24ea461 100644 --- a/tests/windows_spawn.rs +++ b/tests/windows_spawn.rs @@ -16,8 +16,8 @@ use windows_spawn::{ MitigationPolicy, ParentProcess, SpawnOptions, Stdio, }; use windows_sys::Win32::Foundation::{ - DuplicateHandle, DUPLICATE_SAME_ACCESS, HANDLE, INVALID_HANDLE_VALUE, WAIT_OBJECT_0, - WAIT_TIMEOUT, + DuplicateHandle, DUPLICATE_SAME_ACCESS, ERROR_BROKEN_PIPE, ERROR_HANDLE_EOF, HANDLE, + INVALID_HANDLE_VALUE, WAIT_OBJECT_0, WAIT_TIMEOUT, }; use windows_sys::Win32::Storage::FileSystem::{ GetFileInformationByHandle, GetFileType, ReadFile, WriteFile, BY_HANDLE_FILE_INFORMATION, @@ -61,6 +61,70 @@ fn temporary_path(label: &str) -> PathBuf { )) } +struct AlternateParentHost { + child: Option, +} + +impl AlternateParentHost { + fn start() -> io::Result { + let mut child = std::process::Command::new("powershell.exe") + .args([ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + "[Console]::Out.WriteLine('ready'); [Console]::Out.Flush(); $null=[Console]::In.ReadLine()", + ]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .spawn()?; + let stdout = child + .stdout + .take() + .ok_or_else(|| io::Error::other("alternate parent handshake pipe is missing"))?; + let mut reader = io::BufReader::new(stdout); + let mut ready = String::new(); + reader.read_line(&mut ready)?; + if ready.trim_end() != "ready" { + return Err(io::Error::other( + "alternate parent handshake did not become ready", + )); + } + Ok(Self { child: Some(child) }) + } + + fn id(&self) -> io::Result { + self.child + .as_ref() + .map(std::process::Child::id) + .ok_or_else(|| io::Error::other("alternate parent already completed")) + } + + fn finish(mut self) -> io::Result<()> { + let mut child = self + .child + .take() + .ok_or_else(|| io::Error::other("alternate parent already completed"))?; + drop(child.stdin.take()); + let status = child.wait()?; + if status.success() { + Ok(()) + } else { + Err(io::Error::other("alternate parent exited unsuccessfully")) + } + } +} + +impl Drop for AlternateParentHost { + fn drop(&mut self) { + if let Some(mut child) = self.child.take() { + drop(child.stdin.take()); + let _ = child.kill(); + let _ = child.wait(); + } + } +} + #[derive(Clone, Copy)] enum TestInheritability { Private, @@ -373,6 +437,13 @@ fn explicit_job_attachment_and_kill_tree_output_complete() -> io::Result<()> { let options = SpawnOptions::new().job(&outer_job).job(&inner_job); assert!(ordinary.status_with(options)?.success()); + let terminating_job = Job::create()?; + terminating_job.set_close_policy(JobClosePolicy::TerminateProcesses)?; + let mut assigned = + cmd("ping -n 20 127.0.0.1 >nul").spawn_with(SpawnOptions::new().job(&terminating_job))?; + drop(terminating_job); + assert!(wait_bounded(&mut assigned)?.is_some()); + let mut tree = cmd("start \"\" /b cmd.exe /D /C \"ping -n 20 127.0.0.1 >nul\" & echo root"); let output = tree.output_with(SpawnOptions::new().job_close_policy(JobClosePolicy::TerminateProcesses))?; @@ -470,6 +541,28 @@ fn validation_happens_before_process_creation() { error_kind(batch.spawn().unwrap_err()), io::ErrorKind::InvalidInput ); + + let pseudoconsole = InvalidPseudoConsole; + let mut captured = Command::new("cmd.exe"); + assert_eq!( + error_kind( + captured + .output_with(SpawnOptions::new().pseudo_console(&pseudoconsole)) + .unwrap_err() + ), + io::ErrorKind::InvalidInput + ); + + let parent = ParentProcess::open(std::process::id()).unwrap(); + let mut incomplete = Command::new("cmd.exe"); + assert_eq!( + error_kind( + incomplete + .spawn_with(SpawnOptions::new().parent_process(&parent)) + .unwrap_err() + ), + io::ErrorKind::InvalidInput + ); } #[test] @@ -522,10 +615,8 @@ fn argument_and_environment_handles_are_lowered_after_duplication() -> io::Resul #[test] fn alternate_parent_receives_remote_stdio_duplicates() -> io::Result<()> { - let mut host = std::process::Command::new("cmd.exe") - .args(["/D", "/C", "ping -n 10 127.0.0.1 >nul"]) - .spawn()?; - let parent = ParentProcess::open(host.id())?; + let host = AlternateParentHost::start()?; + let parent = ParentProcess::open(host.id()?)?; let mut command = cmd("echo alternate-parent"); command @@ -533,10 +624,10 @@ fn alternate_parent_receives_remote_stdio_duplicates() -> io::Result<()> { .stdout(Stdio::piped()) .stderr(Stdio::piped()); let result = command.output_with(SpawnOptions::new().parent_process(&parent)); - let _ = host.kill(); - let _ = host.wait(); + let host_result = host.finish(); let output = result?; + host_result?; assert!(output.status.success()); assert!(String::from_utf8_lossy(&output.stdout).contains("alternate-parent")); Ok(()) @@ -548,10 +639,8 @@ fn alternate_parent_receives_remote_argument_and_environment_handles() -> io::Re let environment_path = temporary_path("remote-environment-handle"); let argument_file = File::create(&argument_path)?; let environment_file = File::create(&environment_path)?; - let mut host = std::process::Command::new("cmd.exe") - .args(["/D", "/C", "ping -n 10 127.0.0.1 >nul"]) - .spawn()?; - let parent = ParentProcess::open(host.id())?; + let host = AlternateParentHost::start()?; + let parent = ParentProcess::open(host.id()?)?; let script = concat!( "& { param($argumentHandle)", @@ -582,10 +671,10 @@ fn alternate_parent_receives_remote_argument_and_environment_handles() -> io::Re drop((argument_file, environment_file)); let result = command.status_with(SpawnOptions::new().parent_process(&parent)); - let _ = host.kill(); - let _ = host.wait(); + let host_result = host.finish(); assert!(result?.success()); + host_result?; assert_eq!(fs::read_to_string(&argument_path)?, "argument"); assert_eq!(fs::read_to_string(&environment_path)?, "environment"); fs::remove_file(argument_path)?; @@ -733,48 +822,62 @@ impl TestPseudoConsole { Ok(()) } - fn wait_for_output_markers(&self, expected: &[&[u8]]) -> io::Result { + fn wait_for_output_markers(mut self, expected: &[&[u8]]) -> io::Result { + drop(self.input_writer.take()); let output = self .output_reader - .as_ref() + .take() .expect("a live pseudoconsole retains its output reader"); - let mut received = Vec::new(); - loop { - let mut buffer = [0_u8; 4_096]; - let mut read = 0_u32; - let length = u32::try_from(buffer.len()) - .map_err(|_| io::Error::other("test buffer is too large"))?; - // SAFETY: buffer is writable for its reported length, read is - // writable, and the owned synchronous pipe handle remains valid. - if unsafe { - ReadFile( - output.as_raw_handle(), - buffer.as_mut_ptr(), - length, - &mut read, - std::ptr::null_mut(), - ) - } == 0 - { - return Err(io::Error::last_os_error()); - } - if read == 0 { - return Ok(false); - } - let read = usize::try_from(read) - .map_err(|_| io::Error::other("read byte count does not fit usize"))?; - let bytes = buffer - .get(..read) - .ok_or_else(|| io::Error::other("read byte count exceeds the buffer"))?; - received.extend_from_slice(bytes); - if expected.iter().all(|marker| { - received - .windows(marker.len()) - .any(|window| window == *marker) - }) { - return Ok(true); + let reader = thread::spawn(move || { + let mut received = Vec::new(); + loop { + let mut buffer = [0_u8; 4_096]; + let mut read = 0_u32; + let length = u32::try_from(buffer.len()) + .map_err(|_| io::Error::other("test buffer is too large"))?; + // SAFETY: buffer is writable for its reported length, read is + // writable, and the owned synchronous pipe handle remains valid. + if unsafe { + ReadFile( + output.as_raw_handle(), + buffer.as_mut_ptr(), + length, + &mut read, + std::ptr::null_mut(), + ) + } == 0 + { + let error = io::Error::last_os_error(); + let broken_pipe = i32::try_from(ERROR_BROKEN_PIPE).unwrap_or_default(); + let handle_eof = i32::try_from(ERROR_HANDLE_EOF).unwrap_or_default(); + if matches!(error.raw_os_error(), Some(code) if code == broken_pipe || code == handle_eof) + { + return Ok(received); + } + return Err(error); + } + if read == 0 { + return Ok(received); + } + let read = usize::try_from(read) + .map_err(|_| io::Error::other("read byte count does not fit usize"))?; + let bytes = buffer + .get(..read) + .ok_or_else(|| io::Error::other("read byte count exceeds the buffer"))?; + received.extend_from_slice(bytes); } - } + }); + let value = std::mem::replace(&mut self.value, 0); + // SAFETY: this type uniquely owns the live HPCON and closes it once. + unsafe { ClosePseudoConsole(value) }; + let received = reader + .join() + .map_err(|_| io::Error::other("pseudoconsole reader thread panicked"))??; + Ok(expected.iter().all(|marker| { + received + .windows(marker.len()) + .any(|window| window == *marker) + })) } } @@ -783,11 +886,13 @@ impl Drop for TestPseudoConsole { drop(self.input_writer.take()); drop(self.output_reader.take()); - let value = self.value; - let _ = thread::spawn(move || { - // SAFETY: this type uniquely owned the HPCON returned by creation. - unsafe { ClosePseudoConsole(value) }; - }); + if self.value != 0 { + let value = self.value; + let _ = thread::spawn(move || { + // SAFETY: this type uniquely owned the HPCON returned by creation. + unsafe { ClosePseudoConsole(value) }; + }); + } } } @@ -941,12 +1046,21 @@ fn capability_wrappers_and_all_option_builders_are_exercised() -> io::Result<()> let parent = ParentProcess::open(std::process::id())?; let _ = parent.as_handle(); assert!(format!("{parent:?}").contains("ParentProcess")); + let adopted_parent = + ParentProcess::from_handle(local_duplicate(&parent, TestInheritability::Private)?)?; + let _ = adopted_parent.as_handle(); let job = Job::create()?; job.set_close_policy(JobClosePolicy::TerminateProcesses)?; job.set_close_policy(JobClosePolicy::PreserveProcesses)?; let duplicate_job = job.duplicate()?; let _ = duplicate_job.as_handle(); assert!(format!("{duplicate_job:?}").contains("Job")); + let adopted_job = Job::from_handle(local_duplicate(&job, TestInheritability::Private)?)?; + let _ = adopted_job.as_handle(); + assert!( + ParentProcess::from_handle(local_duplicate(&file, TestInheritability::Private,)?).is_err() + ); + assert!(Job::from_handle(local_duplicate(&file, TestInheritability::Private)?).is_err()); let pseudoconsole = InvalidPseudoConsole; let options = SpawnOptions::new() diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index d3d450f..aebd8c9 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -6,6 +6,7 @@ rust-version = "1.75" publish = false [dependencies] +proc-macro2 = "1.0.107" semver = "1.0.28" serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" @@ -15,6 +16,7 @@ toml = "0.8.23" [target.'cfg(windows)'.dependencies] windows-spawn = { path = ".." } +windows-sys = { version = "0.61", features = ["Win32_System_Diagnostics_Debug"] } [lints] workspace = true diff --git a/xtask/src/comment_policy.rs b/xtask/src/comment_policy.rs index a4f7463..64f1633 100644 --- a/xtask/src/comment_policy.rs +++ b/xtask/src/comment_policy.rs @@ -6,7 +6,7 @@ use std::process::Command; use serde_json::Value; -const OCOMMENT_REVISION: &str = "d7528bf0f9c8f50bc5cafcb330a77a3aeb3d72d4"; +const OCOMMENT_REVISION: &str = "897c441e50a800817bfc16746bfe5c289b955ce5"; const OCOMMENT_VERSION: &str = "ocomment 0.1.0"; const OCOMMENT_REPOSITORY: &str = "https://github.com/P4suta/OComment.git"; const OCOMMENT_TOOLCHAIN: &str = "1.98.0"; diff --git a/xtask/src/invariant_registry.rs b/xtask/src/invariant_registry.rs index c277c07..75c1c8d 100644 --- a/xtask/src/invariant_registry.rs +++ b/xtask/src/invariant_registry.rs @@ -2,12 +2,15 @@ use std::collections::BTreeSet; use std::path::{Component, Path}; use serde::Deserialize; +use syn::visit::{self, Visit}; #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct Registry { version: u32, invariant: Vec, + #[serde(default)] + mutation_exemption: Vec, } #[derive(Deserialize)] @@ -20,6 +23,22 @@ struct Invariant { trust_boundaries: Vec, } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct MutationExemption { + matcher: String, + source: String, + expression: String, + proof: String, + invariant: String, +} + +#[derive(Deserialize)] +struct MutantsConfig { + #[serde(default)] + exclude_re: Vec, +} + #[derive(Clone, Copy, Deserialize)] #[serde(rename_all = "kebab-case")] enum Enforcement { @@ -44,7 +63,98 @@ pub(crate) fn check(root: &Path) -> Result<(), String> { .map_err(|error| format!("failed to read {}: {error}", path.display()))?; let registry: Registry = toml::from_str(&source).map_err(|error| format!("invalid invariant registry: {error}"))?; - validate(root, ®istry) + validate(root, ®istry)?; + validate_mutation_exemptions(root, ®istry) +} + +fn validate_mutation_exemptions(root: &Path, registry: &Registry) -> Result<(), String> { + let config_path = root.join(".cargo").join("mutants.toml"); + let config_source = std::fs::read_to_string(&config_path) + .map_err(|error| format!("failed to read {}: {error}", config_path.display()))?; + let config: MutantsConfig = toml::from_str(&config_source) + .map_err(|error| format!("invalid cargo-mutants config: {error}"))?; + let configured: BTreeSet<&str> = config.exclude_re.iter().map(String::as_str).collect(); + let registered: BTreeSet<&str> = registry + .mutation_exemption + .iter() + .map(|entry| entry.matcher.as_str()) + .collect(); + if configured != registered { + return Err("cargo-mutants exclusions must exactly match the proof registry".to_owned()); + } + let kani_invariants: BTreeSet<&str> = registry + .invariant + .iter() + .filter(|entry| { + entry + .enforcement + .iter() + .any(|value| matches!(value, Enforcement::Kani)) + }) + .map(|entry| entry.id.as_str()) + .collect(); + let mut matchers = BTreeSet::new(); + for exemption in ®istry.mutation_exemption { + if !matchers.insert(exemption.matcher.as_str()) { + return Err(format!( + "duplicate mutation exemption: {}", + exemption.matcher + )); + } + if !exemption.matcher.starts_with('^') || !exemption.matcher.ends_with('$') { + return Err(format!( + "mutation exemption is not anchored: {}", + exemption.matcher + )); + } + if !kani_invariants.contains(exemption.invariant.as_str()) { + return Err(format!( + "mutation exemption does not name a Kani invariant: {}", + exemption.invariant + )); + } + validate_artifact(root, &exemption.invariant, &exemption.source)?; + let source_path = root.join(&exemption.source); + let source = std::fs::read_to_string(&source_path) + .map_err(|error| format!("failed to read {}: {error}", source_path.display()))?; + if !source.contains(&exemption.expression) { + return Err(format!( + "mutation exemption expression is absent from {}: {}", + exemption.source, exemption.expression + )); + } + let syntax = syn::parse_file(&source) + .map_err(|error| format!("failed to parse {}: {error}", exemption.source))?; + let mut proofs = KaniProofs::default(); + proofs.visit_file(&syntax); + if !proofs.names.contains(exemption.proof.as_str()) { + return Err(format!( + "mutation exemption proof is not a Kani harness: {}", + exemption.proof + )); + } + } + Ok(()) +} + +#[derive(Default)] +struct KaniProofs { + names: BTreeSet, +} + +impl<'ast> Visit<'ast> for KaniProofs { + fn visit_item_fn(&mut self, item: &'ast syn::ItemFn) { + let is_proof = item.attrs.iter().any(|attribute| { + let mut segments = attribute.path().segments.iter(); + matches!(segments.next(), Some(segment) if segment.ident == "kani") + && matches!(segments.next(), Some(segment) if segment.ident == "proof") + && segments.next().is_none() + }); + if is_proof { + self.names.insert(item.sig.ident.to_string()); + } + visit::visit_item_fn(self, item); + } } fn validate(root: &Path, registry: &Registry) -> Result<(), String> { diff --git a/xtask/src/main.rs b/xtask/src/main.rs index d65bd00..073290c 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -1,10 +1,15 @@ //! Repository automation for windows-spawn. +#![deny(unsafe_code)] + mod cli; mod comment_policy; mod invariant_registry; mod source_policy; mod tasks; +#[cfg(windows)] +#[allow(unsafe_code)] +mod windows; use std::env; use std::process; diff --git a/xtask/src/source_policy.rs b/xtask/src/source_policy.rs index 1d0a68d..47e3f10 100644 --- a/xtask/src/source_policy.rs +++ b/xtask/src/source_policy.rs @@ -2,16 +2,33 @@ use std::collections::BTreeSet; use std::fmt::Write as _; use std::fs; use std::path::{Path, PathBuf}; +use std::process::Command; use syn::visit::{self, Visit}; -use syn::{Attribute, Expr, FnArg, Item, Meta, Signature, Type}; +use syn::{ + Attribute, Expr, FnArg, Item, Meta, PathArguments, ReturnType, Signature, Type, UseTree, +}; pub(crate) fn check(root: &Path) -> Result<(), String> { + let tracked_paths = tracked_rust_files(root)?; + let mut report = String::new(); + for path in &tracked_paths { + let source = fs::read_to_string(path) + .map_err(|error| format!("failed to read {}: {error}", path.display()))?; + let syntax = syn::parse_file(&source) + .map_err(|error| format!("failed to parse {}: {error}", path.display()))?; + let relative = path + .strip_prefix(root) + .map_err(|error| format!("failed to relativize {}: {error}", path.display()))?; + for violation in inspect_structural(&syntax) { + let _ = writeln!(report, "{}: {violation}", relative.display()); + } + } + let source_root = root.join("src"); let mut paths = Vec::new(); collect_rust_files(&source_root, &mut paths)?; paths.sort(); - let mut report = String::new(); for path in paths { let source = fs::read_to_string(&path) .map_err(|error| format!("failed to read {}: {error}", path.display()))?; @@ -33,6 +50,13 @@ pub(crate) fn check(root: &Path) -> Result<(), String> { let _ = writeln!(report, "{}: {violation}", relative.display()); } } + for (path, dependency) in forbidden_manifest_dependencies(root)? { + let _ = writeln!( + report, + "{}: dependency {dependency} is forbidden by the static-dispatch policy", + path.display() + ); + } if report.is_empty() { Ok(()) } else { @@ -40,6 +64,110 @@ pub(crate) fn check(root: &Path) -> Result<(), String> { } } +fn tracked_rust_files(root: &Path) -> Result, String> { + let mut paths: Vec = tracked_files(root)? + .into_iter() + .filter(|path| path.extension().and_then(|value| value.to_str()) == Some("rs")) + .collect(); + paths.sort(); + Ok(paths) +} + +fn tracked_files(root: &Path) -> Result, String> { + let output = Command::new("git") + .current_dir(root) + .args(["ls-files", "-z"]) + .output() + .map_err(|error| format!("failed to enumerate tracked files: {error}"))?; + if !output.status.success() { + return Err(format!("git ls-files failed with status {}", output.status)); + } + let mut paths = Vec::new(); + for encoded in output.stdout.split(|byte| *byte == 0) { + if encoded.is_empty() { + continue; + } + let relative = std::str::from_utf8(encoded) + .map_err(|error| format!("tracked path is not UTF-8: {error}"))?; + paths.push(root.join(relative)); + } + paths.sort(); + Ok(paths) +} + +fn forbidden_manifest_dependencies(root: &Path) -> Result, String> { + let mut violations = Vec::new(); + for path in tracked_files(root)? + .into_iter() + .filter(|path| path.file_name().is_some_and(|name| name == "Cargo.toml")) + { + let source = fs::read_to_string(&path) + .map_err(|error| format!("failed to read {}: {error}", path.display()))?; + let manifest: toml::Value = toml::from_str(&source) + .map_err(|error| format!("failed to parse {}: {error}", path.display()))?; + for dependency in forbidden_dependencies(&manifest) { + let relative = path + .strip_prefix(root) + .map_err(|error| format!("failed to relativize {}: {error}", path.display()))?; + violations.push((relative.to_path_buf(), dependency)); + } + } + Ok(violations) +} + +fn forbidden_dependencies(manifest: &toml::Value) -> BTreeSet { + let mut dependencies = BTreeSet::new(); + collect_forbidden_dependencies(manifest, &mut dependencies); + dependencies +} + +fn collect_forbidden_dependencies(value: &toml::Value, dependencies: &mut BTreeSet) { + let Some(table) = value.as_table() else { + return; + }; + for (name, child) in table { + if matches!( + name.as_str(), + "dependencies" | "dev-dependencies" | "build-dependencies" + ) { + if let Some(entries) = child.as_table() { + for (dependency, specification) in entries { + let package = specification + .as_table() + .and_then(|table| table.get("package")) + .and_then(toml::Value::as_str) + .unwrap_or(dependency); + if forbidden_dependency(package) { + dependencies.insert(package.to_owned()); + } + } + } + } + collect_forbidden_dependencies(child, dependencies); + } +} + +fn forbidden_dependency(name: &str) -> bool { + matches!( + name, + "anyhow" + | "arc-swap" + | "async-trait" + | "color-eyre" + | "dashmap" + | "downcast-rs" + | "erased-serde" + | "eyre" + | "inventory" + | "lazy_static" + | "linkme" + | "once_cell" + | "parking_lot" + | "serde_traitobject" + | "typetag" + ) +} + fn collect_rust_files(directory: &Path, paths: &mut Vec) -> Result<(), String> { let entries = fs::read_dir(directory) .map_err(|error| format!("failed to read {}: {error}", directory.display()))?; @@ -76,6 +204,128 @@ fn inspect( inspector.violations } +fn inspect_structural(file: &syn::File) -> BTreeSet<&'static str> { + let mut inspector = StructuralInspector { + error_impl: false, + error_source_method: false, + error_source_return: false, + violations: BTreeSet::new(), + }; + inspector.visit_file(file); + inspector.violations +} + +struct StructuralInspector { + error_impl: bool, + error_source_method: bool, + error_source_return: bool, + violations: BTreeSet<&'static str>, +} + +impl StructuralInspector { + fn reject(&mut self, message: &'static str) { + self.violations.insert(message); + } +} + +impl<'ast> Visit<'ast> for StructuralInspector { + fn visit_item_impl(&mut self, item: &'ast syn::ItemImpl) { + let previous = self.error_impl; + self.error_impl = item + .trait_ + .as_ref() + .is_some_and(|(_, path, _)| is_standard_error_path(path)); + visit::visit_item_impl(self, item); + self.error_impl = previous; + } + + fn visit_impl_item_fn(&mut self, item: &'ast syn::ImplItemFn) { + let previous = self.error_source_method; + self.error_source_method = self.error_impl && item.sig.ident == "source"; + visit::visit_impl_item_fn(self, item); + self.error_source_method = previous; + } + + fn visit_return_type(&mut self, value: &'ast ReturnType) { + let previous = self.error_source_return; + self.error_source_return = self.error_source_method; + visit::visit_return_type(self, value); + self.error_source_return = previous; + } + + fn visit_type_trait_object(&mut self, value: &'ast syn::TypeTraitObject) { + if !self.error_source_return { + self.reject("trait objects are forbidden outside std::error::Error::source"); + } + visit::visit_type_trait_object(self, value); + } + + fn visit_type_path(&mut self, value: &'ast syn::TypePath) { + if let Some(segment) = value.path.segments.last() { + let name = segment.ident.to_string(); + if matches!(name.as_str(), "Box" | "Arc" | "Rc") + && arguments_contain_trait_object(&segment.arguments) + { + self.reject("owned trait objects are forbidden"); + } + if matches!(name.as_str(), "Any" | "TypeId") { + self.reject("runtime type reflection is forbidden"); + } + } + visit::visit_type_path(self, value); + } + + fn visit_macro(&mut self, value: &'ast syn::Macro) { + if token_stream_contains_ident(&value.tokens, "dyn") { + self.reject("trait objects hidden in macro tokens are forbidden"); + } + visit::visit_macro(self, value); + } +} + +fn is_standard_error_path(path: &syn::Path) -> bool { + let segments: Vec = path + .segments + .iter() + .map(|segment| segment.ident.to_string()) + .collect(); + segments == ["std", "error", "Error"] || segments == ["core", "error", "Error"] +} + +fn token_stream_contains_ident(stream: &proc_macro2::TokenStream, expected: &str) -> bool { + stream.clone().into_iter().any(|token| match token { + proc_macro2::TokenTree::Group(group) => { + token_stream_contains_ident(&group.stream(), expected) + } + proc_macro2::TokenTree::Ident(identifier) => identifier == expected, + proc_macro2::TokenTree::Literal(_) | proc_macro2::TokenTree::Punct(_) => false, + }) +} + +fn arguments_contain_trait_object(arguments: &PathArguments) -> bool { + let PathArguments::AngleBracketed(arguments) = arguments else { + return false; + }; + arguments.args.iter().any(|argument| { + let syn::GenericArgument::Type(value) = argument else { + return false; + }; + let mut finder = TraitObjectFinder { found: false }; + finder.visit_type(value); + finder.found + }) +} + +struct TraitObjectFinder { + found: bool, +} + +impl<'ast> Visit<'ast> for TraitObjectFinder { + fn visit_type_trait_object(&mut self, _value: &'ast syn::TypeTraitObject) { + self.found = true; + } +} + struct Inspector { raw_boundary: bool, pseudoconsole_boundary: bool, @@ -90,6 +340,14 @@ impl Inspector { fn check_attributes(&mut self, attributes: &[Attribute], permits_unsafe_allow: bool) { for attribute in attributes { + if attribute + .path() + .segments + .last() + .is_some_and(|segment| segment.ident == "async_trait") + { + self.reject("async trait erasure is forbidden"); + } if attribute.path().is_ident("allow") || attribute.path().is_ident("expect") { let permitted = permits_unsafe_allow && attribute.path().is_ident("allow") @@ -255,6 +513,19 @@ impl<'ast> Visit<'ast> for Inspector { visit::visit_expr_macro(self, expression); } + fn visit_macro(&mut self, value: &'ast syn::Macro) { + if let Some(name) = value.path.segments.last() { + match name.ident.to_string().as_str() { + "dbg" | "eprint" | "eprintln" | "lazy_static" | "print" | "println" + | "thread_local" => { + self.reject("ad hoc diagnostics or global state macro is forbidden"); + } + _ => {} + } + } + visit::visit_macro(self, value); + } + fn visit_expr_call(&mut self, expression: &'ast syn::ExprCall) { if let Expr::Path(path) = expression.func.as_ref() { let segments: Vec = path @@ -289,6 +560,18 @@ impl<'ast> Visit<'ast> for Inspector { visit::visit_expr_unsafe(self, expression); } + fn visit_item_use(&mut self, item: &'ast syn::ItemUse) { + if use_tree_contains_glob(&item.tree) { + self.reject("wildcard import is forbidden in production code"); + } + if use_tree_contains_forbidden_type(&item.tree) { + self.reject( + "runtime erasure nondeterminism or shared interior mutability is forbidden", + ); + } + visit::visit_item_use(self, item); + } + fn visit_type_path(&mut self, path: &'ast syn::TypePath) { if let Some(segment) = path.path.segments.last() { let name = segment.ident.to_string(); @@ -298,32 +581,65 @@ impl<'ast> Visit<'ast> for Inspector { { self.reject("raw handle representation outside the raw adapter is forbidden"); } - if matches!( - name.as_str(), - "ManuallyDrop" - | "Mutex" - | "RwLock" - | "AtomicBool" - | "AtomicI8" - | "AtomicI16" - | "AtomicI32" - | "AtomicI64" - | "AtomicIsize" - | "AtomicPtr" - | "AtomicU8" - | "AtomicU16" - | "AtomicU32" - | "AtomicU64" - | "AtomicUsize" - | "UnsafeCell" - ) { - self.reject("unregistered ownership or synchronization primitive is forbidden"); + if forbidden_runtime_type(&name) { + self.reject( + "runtime erasure nondeterminism or shared interior mutability is forbidden", + ); } } visit::visit_type_path(self, path); } } +fn use_tree_contains_glob(tree: &UseTree) -> bool { + match tree { + UseTree::Glob(_) => true, + UseTree::Group(group) => group.items.iter().any(use_tree_contains_glob), + UseTree::Name(_) | UseTree::Rename(_) => false, + UseTree::Path(path) => use_tree_contains_glob(&path.tree), + } +} + +fn use_tree_contains_forbidden_type(tree: &UseTree) -> bool { + match tree { + UseTree::Glob(_) => false, + UseTree::Group(group) => group.items.iter().any(use_tree_contains_forbidden_type), + UseTree::Name(name) => forbidden_runtime_type(&name.ident.to_string()), + UseTree::Path(path) => { + forbidden_runtime_type(&path.ident.to_string()) + || use_tree_contains_forbidden_type(&path.tree) + } + UseTree::Rename(rename) => forbidden_runtime_type(&rename.ident.to_string()), + } +} + +fn forbidden_runtime_type(name: &str) -> bool { + matches!( + name, + "Any" + | "Arc" + | "Barrier" + | "Cell" + | "Condvar" + | "HashMap" + | "HashSet" + | "LazyCell" + | "LazyLock" + | "ManuallyDrop" + | "MaybeUninit" + | "Mutex" + | "Once" + | "OnceCell" + | "OnceLock" + | "Rc" + | "RefCell" + | "RwLock" + | "TypeId" + | "UnsafeCell" + | "Weak" + ) || name.starts_with("Atomic") +} + fn item_attributes(item: &Item) -> Option<&[Attribute]> { match item { Item::Const(value) => Some(&value.attrs), @@ -376,6 +692,10 @@ mod tests { inspect(&syn::parse_file(source).unwrap(), false, false, false) } + fn structural_violations(source: &str) -> BTreeSet<&'static str> { + inspect_structural(&syn::parse_file(source).unwrap()) + } + #[test] fn accepts_closed_inputs_and_checked_access() { let source = "enum Policy { On, Off } fn apply(value: Policy, items: &[u8]) -> Option<&u8> { items.get(0) }"; @@ -390,11 +710,50 @@ mod tests { "fn apply(values: &[u8]) -> u8 { values[0] }", "fn apply(value: u8) -> u64 { value as u64 }", "fn apply() { let _: std::sync::Mutex; }", + "fn apply() { let _: std::rc::Rc; }", + "fn apply() { let _: std::collections::HashMap; }", + "use std::sync::Arc as Shared; fn apply() { let _: Shared; }", + "use crate::*;", + "fn apply() { println!(\"diagnostic\"); }", ] { assert!(!violations(source).is_empty(), "{source}"); } } + #[test] + fn permits_generics_and_the_standard_error_source_contract() { + let source = "trait Work {} fn apply(value: &Value) {} struct Failure; impl std::error::Error for Failure { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { None } }"; + assert!(structural_violations(source).is_empty()); + } + + #[test] + fn rejects_trait_objects_ownership_and_runtime_reflection_everywhere() { + for source in [ + "trait Work {} fn apply(value: &dyn Work) {}", + "trait Work {} fn apply(value: Box) {}", + "trait Work {} fn apply(value: std::sync::Arc) {}", + "fn apply(value: &std::any::Any) {}", + "fn apply(value: std::any::TypeId) {}", + "macro_rules! erased { () => { Box } }", + "trait Error {} trait Work {} struct Failure; impl Error for Failure { fn source(&self) -> Option<&dyn Work> { None } }", + "trait Work {} struct Failure; impl std::error::Error for Failure { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { let _: Option> = None; None } }", + ] { + assert!(!structural_violations(source).is_empty(), "{source}"); + } + } + + #[test] + fn rejects_erasure_and_global_state_dependencies() { + let manifest: toml::Value = toml::from_str( + "[dependencies]\nrenamed = { package = \"anyhow\", version = \"1\" }\n[target.'cfg(windows)'.dev-dependencies]\nparking_lot = \"1\"\n", + ) + .unwrap(); + assert_eq!( + forbidden_dependencies(&manifest), + BTreeSet::from(["anyhow".to_owned(), "parking_lot".to_owned()]) + ); + } + #[test] fn ignores_test_only_modules() { let source = "#[cfg(test)] mod tests { fn assertion() { panic!() } }"; diff --git a/xtask/src/tasks.rs b/xtask/src/tasks.rs index f01749c..0441327 100644 --- a/xtask/src/tasks.rs +++ b/xtask/src/tasks.rs @@ -3,7 +3,6 @@ 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::fmt; use std::fs::{self, File, OpenOptions}; @@ -38,8 +37,8 @@ impl fmt::Display for TaskError { } } -impl Error for TaskError { - fn source(&self) -> Option<&(dyn Error + 'static)> { +impl std::error::Error for TaskError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::Io(error) => Some(error), Self::Json(error) => Some(error), @@ -414,7 +413,7 @@ fn check_packaged_reuse(root: &Path) -> Result<()> { } fn run_reuse(root: &Path, arguments: &[&str]) -> Result<()> { - let mut command = Command::new("uvx"); + let mut command = Command::new(uvx_program()?); command .current_dir(root) .args(["--from", REUSE_PACKAGE, "reuse"]) @@ -504,7 +503,7 @@ fn generate_sboms( fs::remove_file(&generated)?; } - let mut reuse = Command::new("uvx"); + let mut reuse = Command::new(uvx_program()?); reuse .current_dir(root) .args(["--from", REUSE_PACKAGE, "reuse", "spdx", "-o"]) @@ -853,6 +852,7 @@ fn append_github_output(path: &Path, key: &str, value: &str) -> Result<()> { fn run_mutants(root: &Path, output: Option, forwarded: &[String]) -> Result { use windows_spawn::{Command as SpawnCommand, JobClosePolicy, SpawnOptions}; + let _fault_dialog_guard = crate::windows::FaultDialogGuard::suppress(); 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")); @@ -976,6 +976,24 @@ fn repository_root() -> Result { Ok(normalize_path(root)) } +fn uvx_program() -> Result { + if let Some(program) = env::var_os("UVX").filter(|value| !value.is_empty()) { + return Ok(PathBuf::from(program)); + } + let executable = if cfg!(windows) { "uvx.exe" } else { "uvx" }; + let local = repository_root()? + .join("target") + .join("tools") + .join("uv") + .join("bin") + .join(executable); + if local.is_file() { + Ok(local) + } else { + Ok(PathBuf::from(executable)) + } +} + fn cargo(root: &Path) -> Command { let executable = env::var_os("CARGO").unwrap_or_else(|| OsString::from("cargo")); let mut command = Command::new(executable); @@ -1044,6 +1062,7 @@ fn fail(message: impl Into) -> Result { mod tests { use super::*; use serde_json::json; + use std::error::Error as _; use std::time::Duration; #[test] diff --git a/xtask/src/windows.rs b/xtask/src/windows.rs new file mode 100644 index 0000000..606f521 --- /dev/null +++ b/xtask/src/windows.rs @@ -0,0 +1,26 @@ +use windows_sys::Win32::System::Diagnostics::Debug::{ + GetErrorMode, SetErrorMode, SEM_FAILCRITICALERRORS, SEM_NOGPFAULTERRORBOX, +}; + +pub(crate) struct FaultDialogGuard(u32); + +impl FaultDialogGuard { + pub(crate) fn suppress() -> Self { + // SAFETY: GetErrorMode has no preconditions and only reads process state. + let previous = unsafe { GetErrorMode() }; + // SAFETY: SetErrorMode accepts every bit pattern represented by its u32 mode. + unsafe { + SetErrorMode(previous | SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX); + } + Self(previous) + } +} + +impl Drop for FaultDialogGuard { + fn drop(&mut self) { + // SAFETY: the saved value was returned by GetErrorMode in this process. + unsafe { + SetErrorMode(self.0); + } + } +} From 09408fcca729651561f86e8364dbc3fa27974aba Mon Sep 17 00:00:00 2001 From: Yasunobu <42543015+P4suta@users.noreply.github.com> Date: Sun, 20 Sep 2026 07:21:30 +0900 Subject: [PATCH 7/7] ci: adopt non-release Dependabot updates --- .github/workflows/ci.yml | 32 ++++++++++++------------- .github/workflows/codeql.yml | 4 ++-- .github/workflows/deep-verification.yml | 6 ++--- .github/workflows/fuzz.yml | 8 +++---- .github/workflows/mutation.yml | 4 ++-- 5 files changed, 27 insertions(+), 27 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72bdf42..78e1bb5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,8 +33,8 @@ jobs: toolchain: stable components: rustfmt, clippy targets: x86_64-pc-windows-msvc, i686-pc-windows-msvc, aarch64-pc-windows-msvc - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 - - uses: taiki-e/install-action@6a1bd70eaac3c8bdf093356838d7ee09fda951cf + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 + - uses: taiki-e/install-action@5bf6ce016fd2e72eefc647cbca1e4213f65955b8 with: tool: just@1.57.0,cargo-public-api@0.52.0,cargo-deny@0.19.4,cargo-vet@0.10.2,typos-cli@1.48.0,uv@0.12.0 - run: just ocomment @@ -65,8 +65,8 @@ jobs: - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 with: toolchain: ${{ matrix.rust }} - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 - - uses: taiki-e/install-action@6a1bd70eaac3c8bdf093356838d7ee09fda951cf + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 + - uses: taiki-e/install-action@5bf6ce016fd2e72eefc647cbca1e4213f65955b8 with: tool: just@1.57.0,cargo-nextest@0.9.145 - run: just test @@ -87,7 +87,7 @@ jobs: with: toolchain: stable targets: i686-pc-windows-msvc - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 - name: Run the ConPTY stdio isolation regression as a 32-bit process run: >- cargo test --target i686-pc-windows-msvc --test windows_spawn @@ -104,7 +104,7 @@ jobs: - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 with: toolchain: stable - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 - run: cargo test --test windows_spawn spawn_cleanup_stress -- --ignored --exact --test-threads=1 stress-i686: @@ -119,7 +119,7 @@ jobs: with: toolchain: stable targets: i686-pc-windows-msvc - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 - run: cargo test --target i686-pc-windows-msvc --test windows_spawn spawn_cleanup_stress -- --ignored --exact --test-threads=1 linux: @@ -141,7 +141,7 @@ jobs: - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 with: toolchain: stable - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 - run: cargo install --locked kani-verifier --version 0.68.0 - run: cargo kani setup - run: cargo xtask kani @@ -156,7 +156,7 @@ jobs: with: toolchain: nightly-2026-07-02 components: miri - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 - run: cargo miri setup - run: 'cargo miri test --package windows-spawn --lib core_logic::tests::' @@ -171,7 +171,7 @@ jobs: - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 with: toolchain: nightly-2026-07-02 - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 - run: 'cargo test --package windows-spawn --target x86_64-unknown-linux-gnu --lib core_logic::tests::' careful: @@ -183,8 +183,8 @@ jobs: - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 with: toolchain: nightly-2026-07-02 - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 - - uses: taiki-e/install-action@6a1bd70eaac3c8bdf093356838d7ee09fda951cf + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 + - uses: taiki-e/install-action@5bf6ce016fd2e72eefc647cbca1e4213f65955b8 with: tool: cargo-careful@0.4.10 - run: cargo careful test --workspace --all-targets --all-features --locked -- --test-threads=1 @@ -200,7 +200,7 @@ jobs: - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 with: toolchain: stable - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 - run: cargo test --package windows-spawn --lib --locked coverage: @@ -212,8 +212,8 @@ jobs: with: toolchain: nightly-2026-07-02 components: llvm-tools-preview - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 - - uses: taiki-e/install-action@6a1bd70eaac3c8bdf093356838d7ee09fda951cf + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 + - uses: taiki-e/install-action@5bf6ce016fd2e72eefc647cbca1e4213f65955b8 with: tool: just@1.57.0,cargo-llvm-cov@0.8.7 - run: just coverage @@ -226,7 +226,7 @@ jobs: - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 with: toolchain: stable - - uses: taiki-e/install-action@6a1bd70eaac3c8bdf093356838d7ee09fda951cf + - uses: taiki-e/install-action@5bf6ce016fd2e72eefc647cbca1e4213f65955b8 with: tool: just@1.57.0,cargo-cyclonedx@0.5.9,uv@0.12.0 - run: just release-candidate diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 0ed6046..6752f2c 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -40,11 +40,11 @@ jobs: uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 with: toolchain: stable - - uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 + - uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 with: languages: ${{ matrix.language }} build-mode: none queries: security-extended - - uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 + - uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 with: category: /language:${{ matrix.language }} diff --git a/.github/workflows/deep-verification.yml b/.github/workflows/deep-verification.yml index 78fa4ba..9596144 100644 --- a/.github/workflows/deep-verification.yml +++ b/.github/workflows/deep-verification.yml @@ -20,7 +20,7 @@ jobs: - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 with: toolchain: stable - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 - run: 'cargo test --lib core_logic::tests::generated_quoting_and_fields_preserve_properties -- --exact' stress-x64: @@ -34,7 +34,7 @@ jobs: - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 with: toolchain: stable - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 - run: cargo test --test windows_spawn spawn_cleanup_stress -- --ignored --exact --test-threads=1 stress-i686: @@ -49,5 +49,5 @@ jobs: with: toolchain: stable targets: i686-pc-windows-msvc - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 - run: cargo test --target i686-pc-windows-msvc --test windows_spawn spawn_cleanup_stress -- --ignored --exact --test-threads=1 diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 5ef4e76..7122ee8 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -23,8 +23,8 @@ jobs: - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 with: toolchain: nightly-2026-07-02 - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 - - uses: taiki-e/install-action@6a1bd70eaac3c8bdf093356838d7ee09fda951cf + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 + - uses: taiki-e/install-action@5bf6ce016fd2e72eefc647cbca1e4213f65955b8 with: tool: cargo-fuzz@0.13.2 - run: cargo fuzz run ${{ matrix.target }} --target x86_64-unknown-linux-gnu -- -max_total_time=1800 @@ -50,8 +50,8 @@ jobs: - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 with: toolchain: nightly-2026-07-02 - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 - - uses: taiki-e/install-action@6a1bd70eaac3c8bdf093356838d7ee09fda951cf + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 + - uses: taiki-e/install-action@5bf6ce016fd2e72eefc647cbca1e4213f65955b8 with: tool: cargo-fuzz@0.13.2 - run: cargo fuzz run ${{ matrix.target }} --target x86_64-unknown-linux-gnu -- -max_total_time=21600 diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index 0b6060f..f2cbf3b 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -27,8 +27,8 @@ jobs: - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 with: toolchain: stable - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 - - uses: taiki-e/install-action@6a1bd70eaac3c8bdf093356838d7ee09fda951cf + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 + - uses: taiki-e/install-action@5bf6ce016fd2e72eefc647cbca1e4213f65955b8 with: tool: just@1.57.0,cargo-mutants@27.1.0 - run: just mutants-ci ${{ matrix.shard }}