diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index 0a88c66..0b60f32 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -1,14 +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 - -# Each exclusion is an audited equivalent transformation documented in -# docs/adr/0008-mutation-testing.md. No file or mutation class is excluded. +test_tool = "cargo" exclude_re = [ - 'src/mitigation\.rs:.*replace \| with \^ in replace', - 'src/sys\.rs:.*replace \| with \^ in > 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 new file mode 100644 index 0000000..ae9b8e5 --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,17 @@ +[profile.ci-default] +fail-fast = false +slow-timeout = { period = "60s", terminate-after = 2 } +status-level = "pass" +final-status-level = "slow" + +[profile.ci-default.junit] +path = "junit.xml" + +[profile.ci-all] +fail-fast = false +slow-timeout = { period = "60s", terminate-after = 2 } +status-level = "pass" +final-status-level = "slow" + +[profile.ci-all.junit] +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/dependabot.yml b/.github/dependabot.yml index fef3aab..c381248 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -13,11 +13,6 @@ updates: - rust commit-message: prefix: deps - # `just msrv` and the Rust 1.75 test legs check the whole workspace, so an - # xtask dependency that raises its own MSRV above ours breaks CI even though - # it never ships. sha2 0.11 requires Rust 1.85; the checksum code already - # handles both generations, so this only needs lifting when the crate MSRV - # moves past 1.85. ignore: - dependency-name: sha2 update-types: diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 006e33c..d8bd21a 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,16 +1,7 @@ ## Summary - +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 36c7374..78e1bb5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,23 +18,29 @@ 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@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 + - uses: taiki-e/install-action@5bf6ce016fd2e72eefc647cbca1e4213f65955b8 with: - tool: just@1.57.0,cargo-public-api@0.52.0,cargo-deny@0.19.4,typos-cli@1.48.0 - - run: python -m pip install "reuse[charset-normalizer]==6.2.0" + 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 + - run: just source-policy - run: just fmt - run: just clippy - run: just doc @@ -50,58 +56,164 @@ jobs: strategy: fail-fast: false matrix: - os: [windows-2022, windows-latest] + os: [windows-2022, windows-2025] 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@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 + - uses: taiki-e/install-action@5bf6ce016fd2e72eefc647cbca1e4213f65955b8 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/*/junit.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@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 - 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@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 + - 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@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 + - 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@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 + - 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@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 + - 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@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 + - 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: nightly-2026-07-02 + - 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 + + arm64-smoke: + 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 + with: + toolchain: stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 + - 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@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 + - uses: taiki-e/install-action@5bf6ce016fd2e72eefc647cbca1e4213f65955b8 with: tool: just@1.57.0,cargo-llvm-cov@0.8.7 - run: just coverage @@ -110,20 +222,42 @@ 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@5bf6ce016fd2e72eefc647cbca1e4213f65955b8 with: - tool: just@1.57.0,cargo-cyclonedx@0.5.9 - - run: python -m pip install "reuse[charset-normalizer]==6.2.0" + tool: just@1.57.0,cargo-cyclonedx@0.5.9,uv@0.12.0 - run: just release-candidate + release-dry-run: + name: Release dry run + runs-on: windows-2022 + timeout-minutes: 45 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ github.head_ref || github.ref_name }} + - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 + with: + toolchain: stable + - uses: release-plz/action@2eb1d8bcb770b4c48ccfaad919734b38b51958c9 + 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, 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..6752f2c 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@cdf488f595d80d6e07e03d4674febd5ab45fa938 with: languages: ${{ matrix.language }} build-mode: none queries: security-extended - - uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 + - 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 new file mode 100644 index 0000000..9596144 --- /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@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 + - 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@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 + - 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@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/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..7122ee8 --- /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@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 + - 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@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 + - 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..f2cbf3b 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: @@ -13,22 +12,28 @@ jobs: name: Mutants ${{ matrix.shard }}/4 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: 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@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 + - uses: taiki-e/install-action@5bf6ce016fd2e72eefc647cbca1e4213f65955b8 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..15bdb9e 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,28 +32,23 @@ 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" + 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 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 +67,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 +76,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..6ab6530 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,14 +330,28 @@ 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 = [ + "proc-macro2", "semver", + "serde", "serde_json", "sha2", + "syn 2.0.119", + "toml", "windows-spawn", + "windows-sys", ] [[package]] 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/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/_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/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/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..9a87164 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,50 @@ 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 +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 +141,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 +189,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 +233,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 +356,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 +448,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 +496,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 +544,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 +667,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 +719,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 +748,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 +767,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 +795,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 +810,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 +856,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 +868,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..4184e74 --- /dev/null +++ b/quality/invariants.toml @@ -0,0 +1,148 @@ +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", ".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" +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"] + +[[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/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..b277be2 --- /dev/null +++ b/src/backend.rs @@ -0,0 +1,407 @@ +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, +} + +#[derive(Clone, Copy)] +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 + } +} + +#[derive(Clone, Copy)] +pub(crate) enum BackendAdapter { + Windows, + #[cfg(test)] + Fault, +} + +pub(crate) trait SpawnBackend: Sized { + 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() + } + + 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) + }) + } + + pub(crate) fn null_handle(self, access: NullAccess) -> io::Result { + self.execute(BackendCall::OpenNull, || sys::null_handle(access)) + } + + pub(crate) fn create_pipe(self, direction: PipeDirection) -> io::Result { + self.execute(BackendCall::CreatePipe, || sys::create_pipe(direction)) + } + + pub(crate) fn duplicate_local( + self, + source: BorrowedHandle<'_>, + inheritability: Inheritability, + ) -> io::Result { + self.execute(BackendCall::DuplicateLocal, || { + sys::duplicate_local(source, inheritability) + }) + } + + pub(crate) fn duplicate_remote<'a>( + self, + source: BorrowedHandle<'_>, + target: BorrowedHandle<'a>, + inheritability: Inheritability, + ) -> io::Result> { + self.execute(BackendCall::DuplicateRemote, || { + sys::duplicate_remote(source, target, inheritability) + }) + } + + pub(crate) fn create_job(self) -> io::Result { + self.execute(BackendCall::CreateJob, || { + sys::create_job().map(Job::from_system) + }) + } + + pub(crate) fn configure_job(self, job: &Job, policy: JobClosePolicy) -> io::Result<()> { + use std::os::windows::io::AsHandle; + + self.execute(BackendCall::ConfigureJob, || { + sys::set_job_close_policy(job.as_handle(), policy) + }) + } + + pub(crate) fn create_attributes(self, count: u32) -> io::Result { + self.execute(BackendCall::CreateAttributes, || { + sys::AttributeList::new(count) + }) + } + + pub(crate) fn set_handle_list( + self, + attributes: &mut sys::AttributeList, + handles: &[ChildHandleValue
], + ) -> io::Result<()> { + self.execute(BackendCall::SetHandleList, || { + attributes.set_handle_list(handles) + }) + } + + pub(crate) fn set_parent
( + self, + attributes: &mut sys::AttributeList, + parent: AttributeAddress<'_, ChildHandleValue
>, + ) -> io::Result<()> { + self.execute(BackendCall::SetParent, || { + attributes.set_parent(parent.get()) + }) + } + + pub(crate) fn set_mitigation( + self, + attributes: &mut sys::AttributeList, + words: &[u64; 2], + ) -> io::Result<()> { + self.execute(BackendCall::SetMitigation, || { + attributes.set_mitigation(words) + }) + } + + pub(crate) fn set_jobs
( + self, + attributes: &mut sys::AttributeList, + jobs: &[ChildHandleValue
], + ) -> io::Result<()> { + self.execute(BackendCall::SetJobs, || attributes.set_jobs(jobs)) + } + + pub(crate) fn set_pseudoconsole( + self, + attributes: &mut sys::AttributeList, + pseudoconsole: BorrowedPseudoConsole<'_>, + ) -> io::Result<()> { + self.execute(BackendCall::SetPseudoConsole, || { + attributes.set_pseudoconsole(pseudoconsole) + }) + } + + pub(crate) fn environment_strings(self) -> io::Result> { + self.execute(BackendCall::ReadEnvironment, sys::environment_strings) + } + + pub(crate) fn system_directory(self) -> io::Result { + self.execute(BackendCall::QuerySystemDirectory, sys::system_directory) + } + + pub(crate) fn windows_directory(self) -> io::Result { + self.execute(BackendCall::QueryWindowsDirectory, sys::windows_directory) + } + + pub(crate) fn create_process
( + self, + request: &mut sys::ProcessRequest<'_, Table>, + ) -> io::Result { + self.execute(BackendCall::CreateProcess, || sys::create_process(request)) + } + + pub(crate) fn reclaim_remote( + self, + handle: sys::RemoteHandle<'_>, + ) -> io::Result { + self.execute(BackendCall::ReclaimRemote, || handle.reclaim()) + } + + 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 { + fn adapter() -> BackendAdapter { + BackendAdapter::Windows + } +} + +#[cfg(test)] +pub(crate) use fault::{ + calls as fault_calls, configure as configure_fault, configure_many as configure_faults, + configure_missing_standard_handle, FaultBackend, +}; + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod fault { + use std::cell::RefCell; + use std::collections::BTreeSet; + use std::io; + + use super::{BackendAdapter, BackendCall, SpawnBackend}; + + #[derive(Default)] + struct FaultPlan { + fail_at: BTreeSet, + calls: Vec, + missing_standard_handle: bool, + } + + std::thread_local! { + static PLAN: RefCell = RefCell::new(FaultPlan::default()); + } + + pub(crate) struct FaultBackend; + + impl SpawnBackend for FaultBackend { + 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); + } + + pub(crate) fn configure_many(fail_at: &[usize]) { + 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 87a9aed..4b71837 100644 --- a/src/child.rs +++ b/src/child.rs @@ -1,22 +1,29 @@ -//! 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), + ) + .map(|count| count.0) } fn flush(&mut self) -> io::Result<()> { @@ -33,12 +40,18 @@ 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), + ) + .map(|count| count.0) } } @@ -51,12 +64,18 @@ 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), + ) + .map(|count| count.0) } } @@ -66,292 +85,725 @@ 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 ResumeCompleted(()); + +#[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: 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)) + .and_then(|previous| { + if previous.0 == 1 { + self.state = ExecutionState::Running; + Ok(ResumeCompleted(())) + } else { + Err(Error::Validation(ValidationError::UnexpectedSuspendCount { + actual: previous.0, + })) + } + }) + } +} + +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(TerminatingProcessTree), +} + +#[derive(Debug)] +enum JobCleanupState { + Armed, + Attempted, +} + +#[derive(Debug)] +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(TerminatingProcessTree::new(job, process)) + } + } + } + + fn process(&self) -> &ProcessOwner { + match self { + Self::Preserve(process) => process, + Self::Terminate(tree) => &tree.process, + } + } + + fn process_mut(&mut self) -> &mut ProcessOwner { + match self { + Self::Preserve(process) => process, + Self::Terminate(tree) => &mut tree.process, + } + } + + fn terminate_descendants(self) -> Result { + self.terminate_descendants_with(Job::terminate) + } + + 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), + } + } +} + +/// 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: fn(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<()> { + 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(()); } - sys::terminate_process(self.process.as_handle(), 1) + trace::io( + Phase::Runtime, + Operation::TerminateProcess, + ResourceKind::Process, + || terminate(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 { + 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); } drop(self.stdin.take()); - sys::wait_process(self.process.as_handle())?; - let status = sys::exit_status(self.process.as_handle())?; - self.exit = Some(status); - Ok(status) + trace::io( + Phase::Runtime, + Operation::WaitProcess, + ResourceKind::Process, + || wait(self.process_handle()), + ) + .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. /// /// # 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> { + 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 !sys::try_wait_process(self.process.as_handle())? { - return Ok(None); - } - let status = sys::exit_status(self.process.as_handle())?; - self.exit = Some(status); - Ok(self.exit) + trace::io( + Phase::Runtime, + Operation::WaitProcess, + ResourceKind::Process, + || wait(self.process_handle()), + ) + .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. /// - /// 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)?; - termination?; - - Ok(Output { - status: status?, - stdout, - stderr, - }) + let termination = self.tree.terminate_descendants(); + let readers = join_readers(stdout_reader, stderr_reader); + output_result(status, termination, readers) + } + + /// 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(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> { + 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 { - let Some(read) = std::num::NonZeroUsize::new(sys::read_handle(handle, &mut buffer)?) else { + let read = trace::io( + Phase::Runtime, + Operation::ReadPipe, + ResourceKind::Pipe, + || 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.0) 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?)) } +fn output_result( + status: Result, + 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. -/// -/// 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 std::os::windows::process::ExitStatusExt; 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") }); + 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!( - join_reader(Some(panicked)).unwrap_err().kind(), - io::ErrorKind::Other + 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))?; + 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 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()) + 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(); + let Error::Windows(error) = error else { + 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!( - join_readers(Some(stdout), Some(stderr)).unwrap_err().kind(), - io::ErrorKind::Other + child.try_wait_with(|_| Ok(false), |_| Ok(ExitStatus::from_raw(0)))?, + None ); - assert!(joined.load(Ordering::Acquire)); + 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 9305893..776687c 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1,17 +1,33 @@ -//! 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::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, SpawnPlan}; +use crate::plan::IoMode; use crate::sys; -use crate::transaction::SpawnTransaction; +use crate::trace::{self, ResourceKind}; +use crate::transaction::{ + output_with_backend, spawn_running_with_backend, spawn_suspended_with_backend, +}; + +fn duplicate_command_handle_with( + source: BorrowedHandle<'_>, + duplicate: fn(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 +48,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 +67,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 +101,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,12 +142,21 @@ 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, - )?)); - Ok(self) + pub fn arg_handle(&mut self, handle: &T) -> Result<&mut 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. @@ -161,12 +194,25 @@ impl Command { &mut self, key: K, handle: &T, - ) -> io::Result<&mut Self> { - self.env_ops.push(EnvOp::Set( - key.as_ref().to_os_string(), - EnvValue::Handle(sys::duplicate_local(handle.as_handle(), false)?), - )); - Ok(self) + ) -> Result<&mut 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. @@ -178,7 +224,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 +270,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 +279,8 @@ 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 { + spawn_running_with_backend::(self, options, IoMode::Spawn) } /// Spawns in the suspended type state with default options. @@ -243,7 +288,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 +297,8 @@ 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 { + spawn_suspended_with_backend::(self, options) } /// Runs the process and waits for its status using default options. @@ -265,7 +306,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,8 +315,8 @@ impl Command { /// # Errors /// /// Returns an error from spawning, waiting, or retrieving the exit code. - pub fn status_with(&mut self, options: SpawnOptions<'_>) -> io::Result { - self.spawn_with(options)?.wait() + pub fn status_with(&mut self, options: SpawnOptions<'_>) -> Result { + self.spawn_with(options).and_then(|mut child| child.wait()) } /// Runs the process and captures output using default options. @@ -283,7 +324,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 +333,59 @@ 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() - .wait_with_output() + pub fn output_with(&mut self, options: SpawnOptions<'_>) -> Result { + output_with_backend::(self, options) + } +} + +#[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); + + 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 new file mode 100644 index 0000000..e0ffd05 --- /dev/null +++ b/src/core_logic.rs @@ -0,0 +1,281 @@ +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().saturating_add(2); + 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); + let additional = encoded.saturating_add(1); + 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().saturating_add(1); + self.reserve(additional)?; + self.units.push(SPACE); + self.units.extend_from_slice(argument); + Ok(()) + } + + pub(crate) fn finish(mut self) -> Vec { + self.units.push(0); + self.units + } + + fn reserve(&mut self, additional: usize) -> Result<(), CommandLineError> { + 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.saturating_add(1); + reserve(&mut self.units, reservation).map_err(CommandLineError::Allocation) + } +} + +pub(crate) fn quoted_argument_len(argument: &[u16]) -> usize { + let quoted = needs_quotes(argument); + let mut length = argument.len(); + if quoted { + length = length.saturating_add(2); + } + let mut backslashes = 0_usize; + for unit in argument { + if *unit == BACKSLASH { + backslashes = backslashes.saturating_add(1); + } else { + if *unit == QUOTE { + length = length.saturating_add(backslashes.saturating_add(1)); + } + backslashes = 0; + } + } + if quoted { + length = length.saturating_add(backslashes); + } + 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).wrapping_add(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).wrapping_add(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!(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(); + 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] + 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!(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::{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 length = quoted_argument_len(&argument); + 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..4b13066 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,471 @@ +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 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), + 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 std::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 std::error::Error for WindowsError { + fn source(&self) -> Option<&(dyn std::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 std::error::Error for CleanupError { + fn source(&self) -> Option<&(dyn std::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..9363fa2 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,44 @@ 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 { + 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, + || duplicate(source.as_handle()), + ) + .map(Self::from) } } -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 +122,22 @@ impl ParentProcess { /// # Errors /// /// Returns an error if the PID cannot be opened with the required rights. - pub fn open(pid: u32) -> io::Result { - Ok(Self { - handle: sys::open_parent_process(pid)?, + pub fn open(pid: u32) -> crate::Result { + 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), }) } @@ -106,9 +146,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 +168,33 @@ 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 { + 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, + create, + ) + .map(Self::from_system) } /// Adopts an existing handle after verifying it is a Job handle. @@ -141,9 +202,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 +219,22 @@ impl Job { /// # Errors /// /// Returns the operating-system error if duplication fails. - pub fn duplicate(&self) -> io::Result { - Ok(Self { - handle: sys::duplicate_local(self.handle.as_handle(), false)?, + pub fn duplicate(&self) -> crate::Result { + 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), }) } @@ -162,8 +243,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 +257,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 +287,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 +345,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 +370,33 @@ 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(); + 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()); - 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..bf77a19 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,19 +1,23 @@ #![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 {} +#[cfg(any(windows, test, kani))] +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,22 +26,32 @@ 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)] -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)] -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..6e1b95e 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,128 @@ 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); +/// 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, +} -impl CreationFlags { - /// Creates an empty flag set. - #[must_use] - pub const fn new() -> Self { - Self(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, + } } +} - /// 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 - } +/// 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>), +} - pub(crate) const fn contains(self, other: Self) -> bool { - self.0 & other.0 == other.0 +impl Default for TerminalMode<'_> { + fn default() -> Self { + Self::Console(ConsoleMode::Inherit) } } -struct PseudoConsole<'a> { - raw: isize, - _borrow: PhantomData<&'a dyn AsPseudoConsole>, -} +#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)] +pub(crate) struct CreationPolicy(u32); -impl<'a> PseudoConsole<'a> { - fn new(pseudoconsole: &'a T) -> Self { - Self { - raw: pseudoconsole.raw_pseudoconsole(), - _borrow: PhantomData, - } +impl CreationPolicy { + pub(crate) const fn bits(self) -> u32 { + self.0 + } + + const fn with(self, flag: u32) -> Self { + Self(self.0 | flag) } } -impl BitOr for CreationFlags { - type Output = Self; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct CreationFlags(u32); - fn bitor(self, rhs: Self) -> Self::Output { - Self(self.0 | rhs.0) +impl CreationFlags { + pub(crate) const fn new(policy: CreationPolicy, console: ConsoleMode) -> Self { + Self(policy.bits().wrapping_add(console.creation_bits())) } -} -impl BitOrAssign for CreationFlags { - fn bitor_assign(&mut self, rhs: Self) { - self.0 |= rhs.0; + pub(crate) const fn bits(self) -> u32 { + self.0 } } -/// 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(); -/// ``` +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>, 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 +137,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 +150,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 +164,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 +185,120 @@ 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 new_process_group(mut self) -> Self { + self.creation = self.creation.with(CREATE_NEW_PROCESS_GROUP); + self + } + + /// 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 + } + + /// Requests permission to break away from the caller's Job. #[must_use] - pub const fn drop_policy(mut self, policy: DropPolicy) -> Self { - self.drop_policy = policy; + pub const fn breakaway_from_job(mut self) -> Self { + self.creation = self.creation.with(CREATE_BREAKAWAY_FROM_JOB); self } - pub(crate) fn pseudoconsole_raw(&self) -> Option { - self.pseudoconsole - .as_ref() - .map(|pseudoconsole| pseudoconsole.raw) + /// 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 + } + + /// 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 + } + + /// 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)] -#[allow(unsafe_code)] +#[cfg_attr(coverage_nightly, coverage(off))] mod tests { - use std::cell::Cell; - use super::*; - struct TestPseudoConsole(Cell); + struct PseudoConsoleOwner(()); - // 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() + // 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 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 + ); + assert!(matches!( + options.terminal, + 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" ); - 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")); } } diff --git a/src/plan.rs b/src/plan.rs index 0adc913..376a908 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 {} + +impl DesiredState for Running {} +impl DesiredState for Suspended {} -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum IoMode { Spawn, Output, @@ -56,124 +54,151 @@ 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, + current_dir: Option>, + state: PhantomData, } -impl<'command, 'options> SpawnPlan<'command, 'options, Running> { - pub(crate) fn new_running( - command: &'command Command, - options: SpawnOptions<'options>, - io_mode: IoMode, - ) -> io::Result { - Self::build(command, options, io_mode) - } +struct ValidatedParts<'command, 'options> { + command: &'command Command, + options: SpawnOptions<'options>, + stdio: StandardIo<'command>, + current_dir: Option>, } -impl<'command, 'options> SpawnPlan<'command, 'options, Suspended> { - pub(crate) fn new_suspended( +impl<'command, 'options> ValidatedPlan<'command, 'options, Running> { + pub(crate) fn running( command: &'command Command, options: SpawnOptions<'options>, io_mode: IoMode, - ) -> io::Result { - Self::build(command, options, io_mode) + ) -> Result { + validate_plan(command, options, io_mode).map(Self::from_parts) } } -impl<'command, 'options, M> SpawnPlan<'command, 'options, M> { - fn build( +impl<'command, 'options> ValidatedPlan<'command, 'options, Suspended> { + pub(crate) fn suspended( command: &'command Command, options: SpawnOptions<'options>, io_mode: IoMode, - ) -> io::Result { - validate_command(command)?; - validate_creation_flags( - options.creation_flags, - options.pseudoconsole_raw().is_some(), - )?; + ) -> Result { + validate_plan(command, options, io_mode).map(Self::from_parts) + } +} - 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 options.parent.is_some() - && options.pseudoconsole_raw().is_none() - && (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", - )); +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, } - let stdio = if options.pseudoconsole_raw().is_some() { - 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 { + pub(crate) fn into_parts( + self, + ) -> ( + &'command Command, + SpawnOptions<'options>, + StandardIo<'command>, + Option>, + ) { + let Self { command, options, stdio, - state: PhantomData, - }) + current_dir, + state: _, + } = self; + (command, options, stdio, current_dir) + } +} + +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> { 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 +207,242 @@ 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::fs::File; use std::os::windows::ffi::OsStringExt; use super::*; - use crate::handles::{AsPseudoConsole, ParentProcess}; - - struct InvalidPseudoConsole; + 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); + } - // 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 - } + fn nul() -> OsString { + OsString::from_wide(&[u16::from(b'x'), 0]) } #[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 + 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(), + 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 script in ["thing.cmd", "THING.BAT"] { - let command = Command::new(script); - assert!(SpawnPlan::new_running(&command, SpawnOptions::new(), IoMode::Spawn,).is_err()); - } - } + 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, + }, + ); - #[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()); - } + let valid_without_extension = Command::new("program"); + assert!(ValidatedPlan::::running( + &valid_without_extension, + SpawnOptions::new(), + IoMode::Spawn, + ) + .is_ok()); } #[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); - - for command in cases { - assert_eq!( - SpawnPlan::new_running(&command, SpawnOptions::new(), IoMode::Spawn,) - .unwrap_err() - .kind(), - io::ErrorKind::InvalidInput - ); - } - - 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()); - } + fn command_validation_rejects_invalid_environment_and_directory_text() { + 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, + }, + ); + 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] - 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()); - - let parent = ParentProcess::open(std::process::id()).unwrap(); - assert!(SpawnPlan::new_running( - &plain, - SpawnOptions::new().parent_process(&parent), + ValidationError::PseudoConsoleWithOutputCapture, + ); + let pseudo_command = Command::new("program.exe"); + let pseudo = ValidatedPlan::::running( + &pseudo_command, + SpawnOptions::new().terminal(terminal), 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( + )?; + assert!(matches!(pseudo.stdio, StandardIo::PseudoConsole)); + + let parent = ParentProcess::open(std::process::id())?; + 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, - ) - .is_err()); + ValidationError::AlternateParentNeedsStdio, + ); } - } - - #[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)); + Ok(()) } } diff --git a/src/resource.rs b/src/resource.rs new file mode 100644 index 0000000..95c5802 --- /dev/null +++ b/src/resource.rs @@ -0,0 +1,121 @@ +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 INVALID: Self = Self::from_raw(-1); + + pub(crate) const fn from_raw(raw: isize) -> Self { + Self { + raw, + table: PhantomData, + } + } + + pub(crate) const fn as_raw(self) -> isize { + self.raw + } +} + +const _: () = assert!(ChildHandleValue::::INVALID.as_raw() == -1); + +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 std::fs::File; + + 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); + 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 d71c303..83f482c 100644 --- a/src/sys.rs +++ b/src/sys.rs @@ -1,11 +1,11 @@ -//! 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}; +use std::os::windows::process::ExitStatusExt; use std::process::ExitStatus; use std::ptr; @@ -33,20 +33,110 @@ 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, - 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, }; -pub(crate) const INVALID_RAW_HANDLE: isize = -1; +use crate::options::CreationFlags; +use crate::resource::ChildHandleValue; + +const MAXIMUM_WINDOWS_PATH_UNITS: u32 = 32_768; +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 = @@ -63,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()); } } } @@ -81,9 +171,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 +215,7 @@ pub(crate) fn duplicate_local( current, raw(source), current, - inheritable, + inheritability, DUPLICATE_SAME_ACCESS, ) } @@ -102,85 +224,119 @@ 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(); // 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, target_process, &mut duplicate, 0, - i32::from(inheritable), + 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 ReclaimState { + Open, + Reclaimed, +} + #[derive(Debug)] pub(crate) struct RemoteHandle<'a> { process: BorrowedHandle<'a>, - value: HANDLE, + 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) -> isize { - self.value as isize + pub(crate) fn value
(&self) -> ChildHandleValue
{ + ChildHandleValue::from_raw(self.value.get()) + } + + 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) { - // 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, - ); + if matches!(self.state, ReclaimState::Open) { + let _ = close_remote(self.process, self.value); + } } } +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.get() as HANDLE, + current, + Inheritability::Private, + REMOTE_CLOSE_OPTIONS, + ) + .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 { + let result = unsafe { DuplicateHandle( - GetCurrentProcess(), + current, raw(source), raw(target_process), &mut value, 0, - i32::from(inheritable), + 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 { process: target_process, value, + state: ReclaimState::Open, + marker: PhantomData, }) } @@ -192,13 +348,17 @@ pub(crate) fn standard_handle(stream: StandardStream) -> io::Result io::Result> { if !is_valid_handle(handle) { return Ok(None); } // SAFETY: GetStdHandle returned a live borrowed handle. The borrow is used // only during DuplicateHandle and is never closed. let borrowed = unsafe { BorrowedHandle::borrow_raw(handle as RawHandle) }; - duplicate_local(borrowed, false).map(Some) + duplicate_local(borrowed, Inheritability::Private).map(Some) } pub(crate) fn null_handle(access: NullAccess) -> io::Result { @@ -234,39 +394,62 @@ pub(crate) struct Pipe { pub(crate) child: OwnedHandle, } -pub(crate) fn create_pipe(parent_reads: bool) -> io::Result { +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 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 { - Ok(Pipe { + 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 returns a distinct valid owned write handle. + let write = unsafe { OwnedHandle::from_raw_handle(write as RawHandle) }; + 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) } @@ -288,22 +471,34 @@ 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; + 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; + } } + 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(), - u32::try_from(size_of::()) - .expect("Job limit structure size fits u32"), + (limits as *const JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(), + JOB_LIMITS_SIZE, ) } == 0 { @@ -324,8 +519,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"), + JOB_LIMITS_SIZE, ptr::null_mut(), ) } == 0 @@ -336,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)) }) @@ -347,45 +551,140 @@ 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(); - if probe != 0 - || probe_error.raw_os_error() - != Some( - i32::try_from(ERROR_INSUFFICIENT_BUFFER).expect("Win32 error code fits i32"), - ) - || bytes == 0 - { - return Err(if probe != 0 { - io::Error::other("attribute-list size probe unexpectedly succeeded") - } else { - probe_error - }); - } - let words = bytes - .checked_add(size_of::() - 1) - .ok_or_else(|| io::Error::other("attribute list is too large"))? - / size_of::(); - let 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(&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 +692,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 +708,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 +716,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::(), ) } @@ -433,7 +733,7 @@ impl AttributeList { // every backing allocation stable through CreateProcessW. if unsafe { UpdateProcThreadAttribute( - self.pointer(), + self.pointer().as_ptr(), 0, attribute, value, @@ -449,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 } } @@ -459,32 +759,32 @@ 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()) }; } } #[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) creation_flags: u32, - pub(crate) suspended: bool, + pub(crate) stdio: StartupStdio
, + pub(crate) inheritability: Inheritability, + pub(crate) creation_flags: CreationFlags, + pub(crate) initial_state: InitialState, pub(crate) attributes: Option<&'a AttributeList>, } @@ -494,25 +794,29 @@ 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") + STARTUP_INFO_EX_SIZE } else { - u32::try_from(size_of::()) - .expect("startup structure size fits u32") + STARTUP_INFO_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; + .map_or(ptr::null_mut(), |attributes| attributes.pointer().as_ptr()); + + let mut flags = request + .creation_flags + .bits() + .wrapping_add(CREATE_UNICODE_ENVIRONMENT); + match request.initial_state { + 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 @@ -523,32 +827,29 @@ pub(crate) fn create_process(request: &mut ProcessRequest<'_>) -> io::Result return Err(io::Error::last_os_error()), + Win32CallOutcome::Succeeded => {} } - // SAFETY: successful CreateProcessW guarantees valid process and primary - // thread handles. Adopt both in one step so no success-only handle can leak. - let (process, thread) = unsafe { - ( - OwnedHandle::from_raw_handle(information.hProcess as RawHandle), - OwnedHandle::from_raw_handle(information.hThread as RawHandle), - ) - }; + // 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 thread handle. + let thread = unsafe { OwnedHandle::from_raw_handle(information.hThread as RawHandle) }; Ok(CreatedProcess { process, thread, @@ -556,18 +857,27 @@ pub(crate) fn create_process(request: &mut ProcessRequest<'_>) -> 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) 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()), } @@ -575,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()), @@ -583,6 +898,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 +912,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. @@ -605,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)) @@ -621,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; @@ -650,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 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") - ) { - 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; @@ -685,48 +1018,49 @@ 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; - // SAFETY: the OS-provided current entry is NUL-terminated. - while unsafe { *cursor.add(length) } != 0 { - length = length - .checked_add(1) - .ok_or_else(|| io::Error::other("environment entry is too large"))?; + 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. + match classify_environment_unit(unsafe { *unit }) { + EnvironmentUnit::Terminator => break, + EnvironmentUnit::Content => {} + } + 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) }; - if let Some(separator) = entry[1..] - .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 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) }; @@ -734,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, @@ -751,49 +1110,65 @@ pub(crate) fn program_exists(path: &[u16]) -> bool { unsafe { GetFileAttributesW(path.as_ptr()) != INVALID_FILE_ATTRIBUTES } } -pub(crate) fn system_directory() -> io::Result { - system_path(false) +#[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 windows_directory() -> io::Result { - system_path(true) +pub(crate) fn system_directory() -> io::Result { + system_path(SystemDirectory::System) } -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"), - ) +pub(crate) fn windows_directory() -> io::Result { + 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) } } - } as usize; + SystemDirectory::System => { + // SAFETY: buffer is writable for its reported length. + unsafe { GetSystemDirectoryW(buffer.as_mut_ptr(), MAXIMUM_WINDOWS_PATH_UNITS) } + } + }; + 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 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 @@ -809,23 +1184,34 @@ fn is_valid_handle(handle: HANDLE) -> bool { } fn bool_result(result: i32) -> io::Result<()> { - if result == 0 { + match classify_win32_call(result) { + Win32CallOutcome::Failed => Err(io::Error::last_os_error()), + Win32CallOutcome::Succeeded => Ok(()), + } +} + +#[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(()) + Ok(count) } } #[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] 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::*; - 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,34 +1227,45 @@ 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] 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(true)?; - assert_eq!(write_handle(parent_reads.child.as_handle(), b"a")?, 1); + let parent_reads = create_pipe(PipeDirection::ParentReads)?; + 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(false)?; - assert_eq!(write_handle(parent_writes.parent.as_handle(), b"b")?, 1); - assert_eq!(read_handle(parent_writes.child.as_handle(), &mut byte)?, 1); + let parent_writes = create_pipe(PipeDirection::ParentWrites)?; + 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(), 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 +1275,21 @@ 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); + 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); @@ -889,12 +1299,82 @@ 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); 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 +1382,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 +1391,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 +1408,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 +1421,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!( + ChildHandleValue::::INVALID.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 +1443,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); @@ -982,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); @@ -1002,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()); @@ -1013,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 new file mode 100644 index 0000000..3c49866 --- /dev/null +++ b/src/trace.rs @@ -0,0 +1,91 @@ +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, +} + +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(); + #[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(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 be7792b..23b7617 100644 --- a/src/transaction.rs +++ b/src/transaction.rs @@ -1,212 +1,501 @@ -//! 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; +#[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, CreationFlags, JobClosePolicy, SpawnOptions, TerminalMode}; use crate::plan::{ - Running, SpawnPlan, SpawnState, StandardHandles, StandardIo, StdioSpec, Suspended, + DesiredState, IoMode, 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; +pub(crate) fn spawn_running_with_backend( + command: &Command, + options: SpawnOptions<'_>, + io_mode: IoMode, +) -> Result { + spawn_running(command, options, io_mode, Backend::adapter()) +} -/// 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); - } +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) +} - 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 { +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
]>, + parent_value: Option>>, + mitigation_value: Option>, + job_values: Box<[ChildHandleValue]>, + pseudoconsole: Option>, +} + +struct ProcessAttributeList<'a, Table> { + list: Option, + _backing: Pin>>, +} + +impl<'a, Table> ProcessAttributeList<'a, Table> { + 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()) + + 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()); + 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> { + resources: PreparedResources<'options, Table>, + state: PhantomData, +} + +struct PreparedResources<'options, Table> { + application: Vec, + command_line: Vec, + environment: Environment, + current_dir: Option>, + stdio_values: sys::StartupStdio
, + stdio: StandardHandles>, + job: JobOwnership, + transfer: HandleTransfer<'options>, + attributes: ProcessAttributeList<'options, Table>, + creation_flags: CreationFlags, +} + +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> 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, 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, backend).map_err(|error| { + Error::windows(Phase::Preparation, Operation::ResolveExecutable, error) + })?; + let attributes = prepare_attributes(options, &transfer, job_values)?; Ok(Self { - created: Some(created), - kill_job, + application, + command_line, + environment, + current_dir, + stdio_values, stdio, - state: PhantomData, + job, + transfer, + attributes, + creation_flags: CreationFlags::new( + options.creation, + match options.terminal { + TerminalMode::Console(mode) => mode, + TerminalMode::PseudoConsole(_) => ConsoleMode::Inherit, + }, + ), }) } - 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(), + 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: process_inheritability(self.transfer.inherited_values()), + creation_flags: self.creation_flags, + initial_state: InitialState::Suspended, + attributes: self.attributes.as_list(), + }; + let created = trace::io( + Phase::Creation, + Operation::CreateProcess, + ResourceKind::Process, + || self.transfer.backend().create_process(&mut request), ); - (child, created.thread) + match created { + Ok(created) => Ok(CreatedResources { + process: ProcessOwner::new(created), + job: self.job, + stdio: self.stdio, + transfer: self.transfer, + attributes: self.attributes, + }), + 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 process_inheritability( + inherited_values: &[ChildHandleValue>], +) -> Inheritability { + if inherited_values.is_empty() { + Inheritability::Private + } else { + Inheritability::Inheritable } } -impl SpawnTransaction { - pub(crate) fn commit_suspended(self) -> SuspendedChild { - let (child, thread) = self.commit_parts(); - SuspendedChild::new(child, thread) +fn prepare_standard_io<'options>( + plan: &StandardIo<'_>, + 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 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 Drop for SpawnTransaction { - fn drop(&mut self) { - if let Some(created) = &self.created { - let _ = sys::terminate_process(created.process.as_handle(), 1); +fn prepare_job( + options: &SpawnOptions<'_>, + backend: BackendAdapter, +) -> 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) } - drop(self.kill_job.take()); + }; + 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)) +} + +fn prepare_attributes<'options>( + options: &SpawnOptions<'options>, + transfer: &HandleTransfer<'options>, + 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, + }, + transfer.backend(), + ) +} + +pub(crate) struct CreatedSuspended<'options, State, Table> { + resources: CreatedResources<'options, Table>, + state: PhantomData, +} + +struct CreatedResources<'options, Table> { + process: ProcessOwner, + job: JobOwnership, + stdio: StandardHandles>, + transfer: HandleTransfer<'options>, + attributes: ProcessAttributeList<'options, Table>, +} + +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, + } = self; + let backend = transfer.backend(); + drop(attributes); + transfer + .reclaim() + .map_err(Error::Cleanup) + .map(|()| ReclaimedResources { + process, + job, + stdio, + backend, + }) + } +} + +pub(crate) struct Reclaimed { + resources: ReclaimedResources, + state: PhantomData<(State, Table)>, +} + +struct ReclaimedResources { + process: ProcessOwner, + job: JobOwnership, + stdio: StandardHandles>, + backend: BackendAdapter, +} + +impl ReclaimedResources { + fn into_child(self) -> Child { + Child::new( + self.process, + self.job, + self.stdio.stdin, + self.stdio.stdout, + self.stdio.stderr, + ) + } +} + +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) } } -struct PreparedStdio { - child: isize, +impl
Reclaimed { + pub(crate) fn into_suspended(self) -> SuspendedChild { + SuspendedChild::new(self.resources.into_child()) + } +} + +struct PreparedStdio<'parent> { + child: ChildHandleValue>, parent: Option, } -fn prepare_standard_handles( +fn prepare_standard_handles<'parent>( specs: &StandardHandles>, - transfer: &mut HandleTransfer<'_>, -) -> io::Result> { + transfer: &mut HandleTransfer<'parent>, +) -> io::Result>> { Ok(StandardHandles { stdin: prepare_stdio(specs.stdin, StandardStream::Input, transfer)?, stdout: prepare_stdio(specs.stdout, StandardStream::Output, transfer)?, @@ -214,11 +503,11 @@ fn prepare_standard_handles( }) } -fn prepare_stdio( +fn prepare_stdio<'parent>( spec: StdioSpec<'_>, stream: StandardStream, - transfer: &mut HandleTransfer<'_>, -) -> io::Result { + transfer: &mut HandleTransfer<'parent>, +) -> io::Result> { match spec { StdioSpec::Inherit => prepare_inherit(stream, transfer), StdioSpec::Null => prepare_null(stream, transfer), @@ -235,42 +524,51 @@ fn prepare_stdio( } } -fn prepare_inherit( +fn prepare_inherit<'parent>( stream: StandardStream, - transfer: &mut HandleTransfer<'_>, -) -> io::Result { - match sys::standard_handle(stream)? { + transfer: &mut HandleTransfer<'parent>, +) -> io::Result> { + match transfer.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: ChildHandleValue::INVALID, parent: None, }), } } -fn prepare_null( +fn prepare_null<'parent>( stream: StandardStream, - transfer: &mut HandleTransfer<'_>, -) -> io::Result { + transfer: &mut HandleTransfer<'parent>, +) -> io::Result> { let access = match stream { StandardStream::Input => NullAccess::Read, StandardStream::Output | StandardStream::Error => NullAccess::Write, }; - let handle = sys::null_handle(access)?; + let handle = transfer.backend().null_handle(access)?; Ok(PreparedStdio { child: transfer.lower(handle.as_handle())?, parent: None, }) } -fn prepare_pipe( +fn prepare_pipe<'parent>( stream: StandardStream, - transfer: &mut HandleTransfer<'_>, -) -> io::Result { - let pipe = sys::create_pipe(!matches!(stream, StandardStream::Input))?; + transfer: &mut HandleTransfer<'parent>, +) -> 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, + || transfer.backend().create_pipe(direction), + )?; let child = transfer.lower(pipe.child.as_handle())?; Ok(PreparedStdio { child, @@ -282,34 +580,53 @@ struct HandleTransfer<'a> { parent: Option>, local: Vec, remote: Vec>, - inherited: Vec, + inherited: Vec>>, + backend: BackendAdapter, } impl<'a> HandleTransfer<'a> { - fn new(parent: Option>) -> Self { + fn new(parent: Option>, backend: BackendAdapter) -> Self { Self { parent, local: Vec::new(), remote: Vec::new(), inherited: Vec::new(), + backend, } } - 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, + || { + self.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, + || { + 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) } @@ -317,9 +634,47 @@ impl<'a> HandleTransfer<'a> { self.parent } - fn inherited_values(&self) -> &[isize] { + fn backend(&self) -> BackendAdapter { + self.backend + } + + 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, + || self.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 { @@ -364,7 +719,7 @@ fn build_environment( command: &Command, transfer: &mut HandleTransfer<'_>, ) -> 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 +727,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 transfer.backend().environment_strings()? { map.insert(EnvKey::new(key), value); } } @@ -419,57 +774,64 @@ fn build_environment( }) } -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); +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 { - 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) + Ok(result.finish()) } -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>, + 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 .as_encoded_bytes() @@ -507,20 +869,20 @@ fn resolve_executable(program: &OsStr, child_path: Option<&OsStr>) -> io::Result } } } - 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(sys::system_directory()?)) { + if let Some(found) = search(backend.system_directory()?.into_path_buf()) { return Ok(found); } - if let Some(found) = search(PathBuf::from(sys::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); } @@ -542,16 +904,353 @@ 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, configure_missing_standard_handle, fault_calls, + BackendCall, FaultBackend, + }; + 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)) } + 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 mut child = + 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<()> { + 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 mut child = + 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()?; + Ok(()) + } + + 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 +1282,102 @@ 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 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)?; + 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, 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")); } @@ -602,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]); } @@ -615,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(); @@ -639,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 @@ -668,48 +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()), + decode( + &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(OsStr::new(&empty_path_probe), Some(OsStr::new(";"))) - .unwrap_err() - .kind(), + 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(), @@ -717,26 +1514,123 @@ 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 = 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, windows_backend()) + .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.resources.process.process_handle(), + Inheritability::Private, ) .unwrap(), ); @@ -745,22 +1639,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, windows_backend()) + .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.resources.process.process_handle(), + Inheritability::Private, ) .unwrap(), ); @@ -772,21 +1661,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, windows_backend()) + .unwrap() + .create_suspended() + .unwrap(); let mut process = ProcessExitGuard::new( sys::duplicate_local( - transaction.created.as_ref().unwrap().process.as_handle(), - false, + transaction.resources.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..24ea461 100644 --- a/tests/windows_spawn.rs +++ b/tests/windows_spawn.rs @@ -4,20 +4,20 @@ //! 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, - 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, @@ -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,103 @@ fn temporary_path(label: &str) -> PathBuf { )) } -fn local_duplicate(source: &T, inheritable: bool) -> io::Result { +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, + 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 +170,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 +217,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 +226,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 +316,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 +329,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 +338,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 +437,18 @@ 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 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 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 +482,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 +502,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 +538,29 @@ 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 + ); + + 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 ); } @@ -502,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 @@ -513,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(()) @@ -528,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)", @@ -562,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)?; @@ -609,13 +718,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 +733,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 +756,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()?; @@ -718,87 +822,88 @@ 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 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. - if unsafe { - PeekNamedPipe( - output.as_raw_handle(), - std::ptr::null_mut(), - 0, - std::ptr::null_mut(), - &mut available, - 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 reader = thread::spawn(move || { + let mut received = Vec::new(); + loop { + let mut buffer = [0_u8; 4_096]; let mut read = 0_u32; - // SAFETY: buffer is writable for its length, read is writable, - // and the owned synchronous pipe handle remains valid. + 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(), - available, + length, &mut read, std::ptr::null_mut(), ) } == 0 { - return Err(io::Error::last_os_error()); + 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); } - 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 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); } - if Instant::now() >= deadline { - return Ok(false); - } - thread::sleep(Duration::from_millis(10)); - } + }); + 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) + })) } } 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. - 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) }; + }); + } } } // 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 +914,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 +994,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)?; @@ -941,30 +1046,38 @@ 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_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 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 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 +1152,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 +1194,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 +1238,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 +1257,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..aebd8c9 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -6,12 +6,17 @@ 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" sha2 = "0.10.9" +syn = { version = "2.0.119", features = ["full", "visit"] } +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/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..64f1633 --- /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 = "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"; + +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..75c1c8d --- /dev/null +++ b/xtask/src/invariant_registry.rs @@ -0,0 +1,240 @@ +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)] +#[serde(deny_unknown_fields)] +struct Invariant { + id: String, + target: String, + enforcement: Vec, + artifacts: Vec, + 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 { + 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)?; + 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> { + 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..073290c 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -1,7 +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 new file mode 100644 index 0000000..47e3f10 --- /dev/null +++ b/xtask/src/source_policy.rs @@ -0,0 +1,762 @@ +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, 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(); + 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()); + } + } + 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 { + Err(report) + } +} + +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()))?; + 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 +} + +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, + 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() + .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") + && 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_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 + .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_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(); + 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 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), + 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) + } + + 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) }"; + 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; }", + "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!() } }"; + assert!(violations(source).is_empty()); + } +} diff --git a/xtask/src/tasks.rs b/xtask/src/tasks.rs index fa2f686..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), @@ -89,6 +88,9 @@ 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"; +const REUSE_PACKAGE: &str = "reuse[charset-normalizer]==6.2.0"; #[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,18 +226,79 @@ fn run_simple(root: &Path, task: SimpleTask) -> Result<()> { "x86_64-unknown-linux-gnu", ], ), - SimpleTask::SupplyChain => { - run_cargo(root, &["deny", "--all-features", "--locked", "check"]) - } - SimpleTask::Reuse => run_program(root, "python", &["-m", "reuse", "lint"]), + SimpleTask::SupplyChain => supply_chain(root), + SimpleTask::Reuse => run_reuse(root, &["lint"]), SimpleTask::Typos => run_program(root, "typos", &[]), SimpleTask::Coverage => coverage(root), SimpleTask::Ci => run_ci(root), } } +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", ], ) } @@ -326,7 +409,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_program()?); + command + .current_dir(root) + .args(["--from", REUSE_PACKAGE, "reuse"]) + .args(arguments); + run(&mut command) } fn prepare_sbom_package(root: &Path) -> Result<()> { @@ -411,10 +503,10 @@ fn generate_sboms( fs::remove_file(&generated)?; } - let mut reuse = Command::new("python"); + let mut reuse = Command::new(uvx_program()?); reuse .current_dir(root) - .args(["-m", "reuse", "spdx", "-o"]) + .args(["--from", REUSE_PACKAGE, "reuse", "spdx", "-o"]) .arg(&reuse_spdx); run(&mut reuse)?; @@ -651,15 +743,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,8 +850,9 @@ 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 _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")); @@ -772,7 +862,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)) } @@ -884,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); @@ -952,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); + } + } +}