diff --git a/.cargo/config.toml b/.cargo/config.toml index 78cd64f..715c5c5 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,3 +1,9 @@ [env] # Allocation accounting is unused; its global mutex serializes SQLite callers. LIBSQLITE3_FLAGS = "-DSQLITE_DEFAULT_MEMSTATUS=0" + +# Link the C runtime statically on Windows, so no image imports a C runtime +# DLL. The confined worker should load only the system's own DLLs, and the +# dynamic C runtime is a separately installed component. +[target.x86_64-pc-windows-msvc] +rustflags = ["-C", "target-feature=+crt-static"] diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..52e3f56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Check text out with LF on every OS. Tests read docs, fixtures and digest +# test vectors byte for byte, and a CRLF checkout on Windows would change them. +* text=auto eol=lf diff --git a/.github/workflows/check_mutation_coverage.py b/.github/workflows/check_mutation_coverage.py index db942e9..a450f05 100644 --- a/.github/workflows/check_mutation_coverage.py +++ b/.github/workflows/check_mutation_coverage.py @@ -11,9 +11,10 @@ import tomllib -PLATFORMS = ("linux", "macos") +PLATFORMS = ("linux", "macos", "windows") PORTABLE_PACKAGES = {"basal-proto", "basal-core", "basal-host"} SUCCESS = {"CAUGHT", "CAUGHT_BROADLY", "HUB", "EQUIVALENT", "UNREACHABLE"} +ARTIFACT_PATTERN = re.compile(r"mutations-(linux|macos|windows)-[1-9][0-9]*") def partition(rows): @@ -21,13 +22,18 @@ def partition(rows): selected = {platform: set() for platform in PLATFORMS} seen = set() for row in rows: - hosts = set(row.get("platforms", PLATFORMS)) & set(PLATFORMS) - if len(hosts) != 1: + platforms = row.get("platforms", PLATFORMS) + hosts = set(platforms) & set(PLATFORMS) + if len(platforms) != 1 or len(hosts) != 1: raise ValueError(f"{row['id']}: expected exactly one CI platform, got {sorted(hosts)}") host = hosts.pop() package = row.get("package") - if package in PORTABLE_PACKAGES and host != "linux": - raise ValueError(f"{row['id']}: {package} proofs belong on Linux") + file = Path(row.get("file", "")) + windows_module = "windows" in file.parts or file.name == "windows.rs" + if package in PORTABLE_PACKAGES and host != "linux" and not ( + host == "windows" and windows_module + ): + raise ValueError(f"{row['id']}: {package} proofs belong on Linux unless targeting a Windows-only module on Windows") if row["id"] in seen: raise ValueError(f"duplicate catalogue ID: {row['id']}") seen.add(row["id"]) @@ -55,7 +61,7 @@ def verify_reports(selected, reports): executed[platform][identity] += 1 for platform in PLATFORMS: # Each host shards the entire catalogue; the runner reports the other - # host's rows as skips. Checking both views also detects a missing shard. + # hosts' rows as skips. Checking every view also detects a missing shard. if reported[platform] != Counter({identity: 1 for identity in expected}): raise ValueError(f"{platform}: reports must contain every catalogue ID exactly once") if executed[platform] != Counter({identity: 1 for identity in selected[platform]}): @@ -74,12 +80,12 @@ def main(): if args.reports: reports = [] for path in sorted(args.reports.glob("mutations-*/all.json")): - match = re.fullmatch(r"mutations-(linux|macos)-[1-9][0-9]*", path.parent.name) + match = ARTIFACT_PATTERN.fullmatch(path.parent.name) if not match: raise ValueError(f"unrecognized mutation artifact: {path.parent.name}") reports.append((match[1], json.loads(path.read_text()))) counts = verify_reports(selected, reports) - print(f"Mutation coverage: linux={counts['linux']}, macos={counts['macos']}, " + print(f"Mutation coverage: linux={counts['linux']}, macos={counts['macos']}, windows={counts['windows']}, " f"total={sum(counts.values())} catalogue rows; exactly one platform per row") diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d01ad9d..4d536bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,6 +59,7 @@ jobs: rustup default stable - &install-nats name: Install pinned nats-server v2.15.0 + if: runner.os != 'Windows' shell: bash run: | case "$RUNNER_OS:$RUNNER_ARCH" in @@ -95,7 +96,7 @@ jobs: if: matrix.platform != 'linux-arm64' id: mutate-cache with: - path: ~/.cargo/bin/ckdev-mutate + path: ~/.cargo/bin/ckdev-mutate${{ runner.os == 'Windows' && '.exe' || '' }} key: ${{ runner.os }}-${{ runner.arch }}-ckdev-mutate-${{ hashFiles('Cargo.lock') }}-${{ steps.rust.outputs.version }}-${{ env.CK_MUTATE_REV }} - name: Install pinned mutation runner if: matrix.platform != 'linux-arm64' && steps.mutate-cache.outputs.cache-hit != 'true' @@ -198,9 +199,12 @@ jobs: mutations-pr: if: github.event_name == 'pull_request' needs: checks + defaults: + run: + shell: bash # Replays the mutation controls whose files this pull request changes. # Every catalogue row is marked for exactly one OS, so running the replay - # on both hosts covers every changed control without running any twice. + # on all three platforms covers every changed control without running any twice. strategy: fail-fast: false matrix: @@ -209,16 +213,40 @@ jobs: os: ubuntu-latest - platform: macos os: macos-latest + - platform: windows + os: windows-2022 runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + if: matrix.platform == 'windows' + with: + python-version: '3.12' - name: Install stable Rust run: | rustup toolchain install stable --profile minimal rustup default stable - *install-nats + - &install-nats-windows + name: Install pinned nats-server v2.15.0 + if: runner.os == 'Windows' + shell: pwsh + run: | + switch ($env:RUNNER_ARCH) { + 'X64' { $platform = 'windows-amd64'; $sha = '2d0861dc2ca3567d17f7d19611d7b346cc2ccbd7086a0a77cc957a2c71663200' } + 'ARM64' { $platform = 'windows-arm64'; $sha = 'd468fa3f0ac953bed2219e37a2b5977d87cb8d705d5bbdc70b9f960e12fb7c38' } + default { throw "No pinned NATS archive for $env:RUNNER_ARCH" } + } + $archive = "nats-server-v2.15.0-$platform.zip" + $path = Join-Path $env:RUNNER_TEMP $archive + Invoke-WebRequest "https://github.com/nats-io/nats-server/releases/download/v2.15.0/$archive" -OutFile $path + if ((Get-FileHash $path -Algorithm SHA256).Hash.ToLowerInvariant() -ne $sha) { throw 'NATS archive SHA256 mismatch' } + Expand-Archive $path -DestinationPath $env:RUNNER_TEMP + $bin = Join-Path $env:RUNNER_TEMP "nats-server-v2.15.0-$platform" + $bin | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + & (Join-Path $bin 'nats-server.exe') --version - name: Record installed Rust version id: rust run: echo "version=$(rustc --version | cut -d ' ' -f 2)" >> "$GITHUB_OUTPUT" @@ -238,7 +266,7 @@ jobs: - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 id: mutate-cache with: - path: ~/.cargo/bin/ckdev-mutate + path: ~/.cargo/bin/ckdev-mutate${{ runner.os == 'Windows' && '.exe' || '' }} key: ${{ runner.os }}-${{ runner.arch }}-ckdev-mutate-${{ hashFiles('Cargo.lock') }}-${{ steps.rust.outputs.version }}-${{ env.CK_MUTATE_REV }} - name: Install pinned mutation runner if: steps.mutate-cache.outputs.cache-hit != 'true' @@ -248,10 +276,18 @@ jobs: mkdir -p target/mutations rm -f target/mutations/diff.json env -u BASAL_WORKER_BIN -u BASAL_CUT_EXHAUSTIVE ckdev-mutate run --diff origin/main --report target/mutations/diff.json + - name: Capture exact Windows mutation application and restoration witnesses + if: matrix.platform == 'windows' + run: python script/witness-mutations.py --platform windows --output "$RUNNER_TEMP/windows-mutation-evidence" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + if: always() && matrix.platform == 'windows' + with: + name: windows-mutation-evidence-pr + path: ${{ runner.temp }}/windows-mutation-evidence - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 if: always() with: - name: mutations-pr-${{ matrix.platform }} + name: ${{ matrix.platform == 'windows' && 'mutations-windows-1' || format('mutations-pr-{0}', matrix.platform) }} path: target/mutations/diff.json # On a push to main: the full catalogue, each row against its own target. @@ -259,6 +295,9 @@ jobs: mutations-main: if: always() && ((github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.checks.result == 'success') || github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && needs.checks.result == 'success')) needs: checks + defaults: + run: + shell: bash # Scheduled breadth runs deliberately skip the redundant checks job. # A push still requires checks to succeed before replaying mutations. # always() lets the scheduled run proceed past that skipped dependency. @@ -267,11 +306,10 @@ jobs: strategy: fail-fast: false matrix: - # Each catalogue row is one mutation control. The 125 rows marked for - # Linux use two shards, about 63 each. The 431 marked for macOS use five, - # about 86 each: five is the most macOS jobs this GitHub account runs at - # once. Each host shards the sorted catalogue on its own, and rows marked - # for the other OS are reported SKIPPED_PLATFORM without being built. + # Each catalogue row is one mutation control. Linux and Windows use + # two shards each; macOS uses five, the most macOS jobs this account + # runs at once. Each host shards the sorted catalogue on its own; + # rows marked for the other platforms are skipped without being built. include: - { platform: linux, os: ubuntu-latest, shard: 1, shards: 2 } - { platform: linux, os: ubuntu-latest, shard: 2, shards: 2 } @@ -280,13 +318,20 @@ jobs: - { platform: macos, os: macos-latest, shard: 3, shards: 5 } - { platform: macos, os: macos-latest, shard: 4, shards: 5 } - { platform: macos, os: macos-latest, shard: 5, shards: 5 } + - { platform: windows, os: windows-2022, shard: 1, shards: 2 } + - { platform: windows, os: windows-2022, shard: 2, shards: 2 } steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + if: matrix.platform == 'windows' + with: + python-version: '3.12' - name: Install stable Rust run: | rustup toolchain install stable --profile minimal rustup default stable - *install-nats + - *install-nats-windows - name: Record installed Rust version id: rust run: echo "version=$(rustc --version | cut -d ' ' -f 2)" >> "$GITHUB_OUTPUT" @@ -303,7 +348,7 @@ jobs: - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 id: mutate-cache with: - path: ~/.cargo/bin/ckdev-mutate + path: ~/.cargo/bin/ckdev-mutate${{ runner.os == 'Windows' && '.exe' || '' }} key: ${{ runner.os }}-${{ runner.arch }}-ckdev-mutate-${{ hashFiles('Cargo.lock') }}-${{ steps.rust.outputs.version }}-${{ env.CK_MUTATE_REV }} - name: Install pinned mutation runner if: steps.mutate-cache.outputs.cache-hit != 'true' @@ -315,13 +360,21 @@ jobs: env -u BASAL_WORKER_BIN -u BASAL_CUT_EXHAUSTIVE ckdev-mutate run --all --shard ${{ matrix.shard }}/${{ matrix.shards }} ${BROAD:+--broad} --report target/mutations/all.json env: BROAD: ${{ github.event_name != 'push' && 'true' || '' }} + - name: Capture exact Windows mutation application and restoration witnesses + if: matrix.platform == 'windows' + run: python script/witness-mutations.py --platform windows --shard ${{ matrix.shard }}/${{ matrix.shards }} --output "$RUNNER_TEMP/windows-mutation-evidence" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + if: always() && matrix.platform == 'windows' + with: + name: windows-mutation-evidence-${{ matrix.shard }} + path: ${{ runner.temp }}/windows-mutation-evidence - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 if: always() with: name: mutations-${{ matrix.platform }}-${{ matrix.shard }} path: target/mutations/all.json - # Reads every shard's replay report (target/mutations/all.json) after both + # Reads every shard's replay report (target/mutations/all.json) after all # hosts finish, and requires exactly one successful replay of each # catalogue row across all of them. A row skipped everywhere, missing from # every report or replayed twice fails here, even if each shard passed. @@ -371,40 +424,24 @@ jobs: cargo +1.88.0 check --workspace --all-targets --locked cargo +1.88.0 check --workspace --all-targets --locked --features basal-module/rig-kill-hook - # Windows build baseline. basal does not build on Windows yet, and the causes - # are measured here rather than guessed. The job runs the workspace check and - # tests on windows-latest and records what fails without failing the - # workflow: every build and test step continues on error, the job itself - # continues on error, and no other job depends on it. When a step fails, a - # second run collects the complete list (`--keep-going` for the check, - # `--no-fail-fast` for the tests), since the plain commands stop at the - # first failure. Every log is uploaded as the windows-baseline-logs artifact. + # Each Windows image leaves a different set of loader and thread-pool handles + # in the worker, so both must exercise the startup allowlist. Native suites + # also exercise the launcher, worker probes and filesystem/Git guards before + # the workspace gate. Logs are retained even when a gate fails. windows-baseline: if: github.event_name != 'schedule' - runs-on: windows-latest - continue-on-error: true + strategy: + fail-fast: false + matrix: + os: [windows-latest, windows-2022] + runs-on: ${{ matrix.os }} timeout-minutes: 120 defaults: run: shell: bash steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - name: Install pinned nats-server v2.15.0 - shell: pwsh - run: | - switch ($env:RUNNER_ARCH) { - 'X64' { $platform = 'windows-amd64'; $sha = '2d0861dc2ca3567d17f7d19611d7b346cc2ccbd7086a0a77cc957a2c71663200' } - 'ARM64' { $platform = 'windows-arm64'; $sha = 'd468fa3f0ac953bed2219e37a2b5977d87cb8d705d5bbdc70b9f960e12fb7c38' } - default { throw "No pinned NATS archive for $env:RUNNER_ARCH" } - } - $archive = "nats-server-v2.15.0-$platform.zip" - $path = Join-Path $env:RUNNER_TEMP $archive - Invoke-WebRequest "https://github.com/nats-io/nats-server/releases/download/v2.15.0/$archive" -OutFile $path - if ((Get-FileHash $path -Algorithm SHA256).Hash.ToLowerInvariant() -ne $sha) { throw 'NATS archive SHA256 mismatch' } - Expand-Archive $path -DestinationPath $env:RUNNER_TEMP - $bin = Join-Path $env:RUNNER_TEMP "nats-server-v2.15.0-$platform" - $bin | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - & (Join-Path $bin 'nats-server.exe') --version + - *install-nats-windows - name: Install stable Rust run: | rustup toolchain install stable --profile minimal @@ -420,43 +457,118 @@ jobs: ~/.cargo/registry ~/.cargo/git target - key: ${{ runner.os }}-${{ runner.arch }}-cargo-${{ hashFiles('Cargo.lock') }}-${{ steps.rust.outputs.version }}-windows-baseline-${{ github.sha }} + key: ${{ runner.os }}-${{ runner.arch }}-cargo-${{ hashFiles('Cargo.lock') }}-${{ steps.rust.outputs.version }}-windows-baseline-${{ matrix.os }}-${{ github.sha }} restore-keys: | - ${{ runner.os }}-${{ runner.arch }}-cargo-${{ hashFiles('Cargo.lock') }}-${{ steps.rust.outputs.version }}-windows-baseline- + ${{ runner.os }}-${{ runner.arch }}-cargo-${{ hashFiles('Cargo.lock') }}-${{ steps.rust.outputs.version }}-windows-baseline-${{ matrix.os }}- - name: Prepare the log directory run: mkdir -p "$RUNNER_TEMP/windows-baseline" + - name: Verify the pinned mutation runner on Windows + if: matrix.os == 'windows-2022' + run: | + cargo install --locked --git https://github.com/cortexkit/commons --rev "$CK_MUTATE_REV" cortexkit-mutate 2>&1 | tee "$RUNNER_TEMP/windows-baseline/install-mutate.log" + ckdev-mutate --version 2>&1 | tee "$RUNNER_TEMP/windows-baseline/mutate-version.log" + ckdev-mutate check 2>&1 | tee "$RUNNER_TEMP/windows-baseline/mutate-check.log" + # The Windows launcher's own tests start real confined children, so + # they can only run here. The deviations feature adds the test-only + # launch variants, one per confinement check. They run first and print + # what the parent read back, so the log shows it even when the + # workspace steps below fail. + - name: Test the Windows launcher with its test variants + id: test-launch + timeout-minutes: 20 + run: | + cargo test -p basal-launch --locked -- --show-output 2>&1 | tee "$RUNNER_TEMP/windows-baseline/test-basal-launch-production.log" + cargo test -p basal-launch --features deviations --locked -- --show-output 2>&1 | tee "$RUNNER_TEMP/windows-baseline/test-basal-launch.log" + # The worker's tests on their own: its Windows startup checks, its image + # and a real activation, each worker started through the launcher. The + # plain build is the production worker, which must refuse the + # launcher's request to skip a check. The deviations build is a test + # worker that honours that request, for the three checks the parent + # cannot break from outside. + - name: Test the worker on its own + id: test-worker + timeout-minutes: 30 + run: cargo test -p basal-worker --locked --no-fail-fast -- --show-output 2>&1 | tee "$RUNNER_TEMP/windows-baseline/test-basal-worker.log" + # The creators and counts of production startup handles are measured + # once per Windows runner image. Every worker startup still compares its + # live handle table with the fixed per-image count limits; the image test + # rejects DLL imports absent from the worker's windows-imports.txt file. + - name: Witness the startup-handle and import-inventory mutants + timeout-minutes: 10 + run: python crates/basal-worker/tests/windows_mutation_witness.py "$RUNNER_TEMP/windows-baseline" + - name: Test the worker with its test variants + id: test-worker-deviations + timeout-minutes: 30 + run: cargo test -p basal-worker --features deviations --locked --no-fail-fast -- --show-output 2>&1 | tee "$RUNNER_TEMP/windows-baseline/test-basal-worker-deviations.log" + # The module's tests: workers started, supervised and killed through + # the launcher; worker deaths from a confinement fault (an invalid + # handle or access violation) counted apart from other crashes; and + # the store directory and database readable by their owner only. + # basal-core's tests run with them: the module's store and runtime + # are basal-core's. + - name: Test the module and the core + id: test-module + timeout-minutes: 40 + run: cargo test -p basal-module -p basal-core --locked --no-fail-fast -- --show-output 2>&1 | tee "$RUNNER_TEMP/windows-baseline/test-basal-module.log" + # Every Windows spawn takes one process-wide lock while its child's + # handles are inheritable. This test starts confined, plain and git + # children at once and checks that no child inherited another child's + # stdin, stdout or stderr. + - name: Test the shared spawn lock across the launcher and git + id: test-spawn-lock + timeout-minutes: 20 + run: cargo test -p basal-host --lib --locked -- spawn_lock_tests --show-output 2>&1 | tee "$RUNNER_TEMP/windows-baseline/test-spawn-lock.log" + - name: Test the shared test kit on its own + id: test-testkit + timeout-minutes: 30 + run: cargo test -p basal-testkit --locked --no-fail-fast 2>&1 | tee "$RUNNER_TEMP/windows-baseline/test-basal-testkit.log" + - name: Test the integration rig on its own + id: test-rig + timeout-minutes: 30 + run: cargo test -p basal-rig --locked --no-fail-fast 2>&1 | tee "$RUNNER_TEMP/windows-baseline/test-basal-rig.log" - name: Check every workspace target id: check - continue-on-error: true timeout-minutes: 40 run: cargo check --workspace --all-targets --locked 2>&1 | tee "$RUNNER_TEMP/windows-baseline/check.log" - name: Check every workspace target, continuing past failed crates - if: steps.check.outcome == 'failure' - continue-on-error: true + if: failure() && steps.check.outcome == 'failure' timeout-minutes: 40 run: cargo check --workspace --all-targets --locked --keep-going 2>&1 | tee "$RUNNER_TEMP/windows-baseline/check-keep-going.log" - name: Test the workspace id: test - continue-on-error: true timeout-minutes: 40 run: cargo test --workspace --locked 2>&1 | tee "$RUNNER_TEMP/windows-baseline/test.log" - name: Test the workspace, continuing past failed tests - if: steps.test.outcome == 'failure' - continue-on-error: true + if: failure() && steps.test.outcome == 'failure' timeout-minutes: 40 run: cargo test --workspace --locked --no-fail-fast 2>&1 | tee "$RUNNER_TEMP/windows-baseline/test-no-fail-fast.log" - - name: Summarize the baseline + # Separate host suites retain filesystem and Git failure logs so a failing + # built-in can be diagnosed without searching the full workspace log. + - name: Test basal-host on its own + id: test-host + timeout-minutes: 30 + run: cargo test -p basal-host --lib --locked --no-fail-fast 2>&1 | tee "$RUNNER_TEMP/windows-baseline/test-basal-host.log" + - name: Test the host integration regressions on their own + id: test-host-regressions + timeout-minutes: 20 + run: cargo test -p basal-host --test builtin_host_regressions --locked --no-fail-fast 2>&1 | tee "$RUNNER_TEMP/windows-baseline/test-basal-host-regressions.log" + - name: Summarize the Windows gates if: always() run: | { - echo "## Windows baseline ($(rustc --version))" + echo "## Windows gates ($(rustc --version))" echo echo "- cargo check --workspace --all-targets --locked: ${{ steps.check.outcome }}" echo "- cargo test --workspace --locked: ${{ steps.test.outcome }}" + echo "- cargo test -p basal-launch --features deviations --locked: ${{ steps.test-launch.outcome }}" + echo "- cargo test -p basal-module -p basal-core --locked --no-fail-fast: ${{ steps.test-module.outcome }}" + echo "- cargo test -p basal-host --lib --locked -- spawn_lock_tests: ${{ steps.test-spawn-lock.outcome }}" + echo "- cargo test -p basal-testkit --locked --no-fail-fast: ${{ steps.test-testkit.outcome }}" + echo "- cargo test -p basal-rig --locked --no-fail-fast: ${{ steps.test-rig.outcome }}" } >> "$GITHUB_STEP_SUMMARY" - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 if: always() with: - name: windows-baseline-logs + name: windows-baseline-logs${{ matrix.os == 'windows-2022' && '-windows-2022' || '' }} path: ${{ runner.temp }}/windows-baseline if-no-files-found: error diff --git a/.github/workflows/test_mutation_coverage.py b/.github/workflows/test_mutation_coverage.py index 227d96f..3623da8 100644 --- a/.github/workflows/test_mutation_coverage.py +++ b/.github/workflows/test_mutation_coverage.py @@ -1,10 +1,10 @@ """Tests for check_mutation_coverage.py: every catalogue row is assigned to -exactly one CI host (Linux or macOS), with portable packages kept on Linux, + exactly one CI host, with portable packages on Linux or Windows-only modules on Windows, and the shards' replay reports contain one successful replay of every row.""" import unittest -from check_mutation_coverage import partition, verify_reports +from check_mutation_coverage import ARTIFACT_PATTERN, PLATFORMS, partition, verify_reports class MutationCoverageChecks(unittest.TestCase): @@ -12,20 +12,28 @@ def setUp(self): self.rows = [ {"id": "portable", "package": "basal-host", "platforms": ["linux"]}, {"id": "worker", "package": "basal-worker", "platforms": ["macos"]}, + {"id": "native", "package": "basal-host", "platforms": ["windows"], + "file": "crates/basal-host/src/builtins/fs/windows.rs"}, ] self.reports = [ ("linux", [{"id": "portable", "outcome": "CAUGHT"}, - {"id": "worker", "outcome": "SKIPPED_PLATFORM"}]), + {"id": "worker", "outcome": "SKIPPED_PLATFORM"}, + {"id": "native", "outcome": "SKIPPED_PLATFORM"}]), ("macos", [{"id": "portable", "outcome": "SKIPPED_PLATFORM"}, - {"id": "worker", "outcome": "CAUGHT"}]), + {"id": "worker", "outcome": "CAUGHT"}, + {"id": "native", "outcome": "SKIPPED_PLATFORM"}]), + ("windows", [{"id": "portable", "outcome": "SKIPPED_PLATFORM"}, + {"id": "worker", "outcome": "SKIPPED_PLATFORM"}, + {"id": "native", "outcome": "CAUGHT"}]), ] def test_partition_accepts_one_host_per_row(self): - self.assertEqual(partition(self.rows), {"linux": {"portable"}, "macos": {"worker"}}) + self.assertEqual(partition(self.rows), {"linux": {"portable"}, "macos": {"worker"}, "windows": {"native"}}) def test_partition_rejects_rows_selected_by_no_job_or_both(self): for package in (None, "basal-worker", "basal-module", "basal-testkit", "basal-rig"): - for platforms in ([], ["windows"], ["linux", "macos"], None): + for platforms in ([], ["unknown"], ["linux", "macos"], ["linux", "windows"], + ["macos", "windows"], ["windows", "unknown"], ["windows", "windows"], None): with self.subTest(package=package, platforms=platforms): row = {"id": "unassigned", "runner": "command", "package": package} if platforms is not None: @@ -35,10 +43,10 @@ def test_partition_rejects_rows_selected_by_no_job_or_both(self): def test_partition_accepts_worker_packages_on_either_host(self): for package in ("basal-worker", "basal-module", "basal-testkit", "basal-rig"): - for host in ("linux", "macos"): + for host in PLATFORMS: with self.subTest(package=package, host=host): row = {"id": "worker", "package": package, "platforms": [host]} - expected = {"linux": set(), "macos": set()} + expected = {platform: set() for platform in PLATFORMS} expected[host] = {"worker"} self.assertEqual(partition([row]), expected) @@ -46,11 +54,29 @@ def test_partition_keeps_portable_packages_on_linux(self): for package in ("basal-proto", "basal-core", "basal-host"): with self.subTest(package=package): row = {"id": "portable", "package": package, "platforms": ["linux"]} - self.assertEqual(partition([row]), {"linux": {"portable"}, "macos": set()}) + self.assertEqual(partition([row]), {"linux": {"portable"}, "macos": set(), "windows": set()}) row["platforms"] = ["macos"] with self.assertRaisesRegex(ValueError, "belong on Linux"): partition([row]) + def test_windows_portable_rows_require_a_windows_only_module(self): + for package in ("basal-proto", "basal-core", "basal-host"): + for file in ("src/windows.rs", "src/windows/guard.rs", "src/nested/windows/check.rs"): + with self.subTest(package=package, file=file): + row = {"id": "native", "package": package, "file": file, "platforms": ["windows"]} + self.assertEqual(partition([row]), {"linux": set(), "macos": set(), "windows": {"native"}}) + for file in ("", "src/lib.rs", "src/notwindows/guard.rs", "src/windows_guard.rs", "src/windows.rs.bak"): + with self.subTest(package=package, file=file): + row = {"id": "shared", "package": package, "file": file, "platforms": ["windows"]} + with self.assertRaisesRegex(ValueError, "Windows-only module"): + partition([row]) + + def test_artifact_names_accept_windows_shards_but_not_pr_or_unknown_hosts(self): + for name in ("mutations-linux-1", "mutations-macos-5", "mutations-windows-1", "mutations-windows-12"): + self.assertIsNotNone(ARTIFACT_PATTERN.fullmatch(name), name) + for name in ("mutations-pr-windows", "mutations-windows-0", "mutations-other-1", "mutations-windows-one"): + self.assertIsNone(ARTIFACT_PATTERN.fullmatch(name), name) + def test_partition_rejects_duplicate_ids(self): for duplicate in (self.rows[0], {"id": "portable", "platforms": ["macos"]}): with self.subTest(duplicate=duplicate): @@ -59,7 +85,7 @@ def test_partition_rejects_duplicate_ids(self): def test_reports_count_replayed_rows_not_platform_skips(self): self.assertEqual(verify_reports(partition(self.rows), self.reports), - {"linux": 1, "macos": 1}) + {"linux": 1, "macos": 1, "windows": 1}) def test_reports_reject_missing_shard(self): with self.assertRaisesRegex(ValueError, "every catalogue ID exactly once"): diff --git a/Cargo.lock b/Cargo.lock index 6e140cd..c150497 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -114,6 +114,7 @@ dependencies = [ name = "basal-host" version = "0.1.0" dependencies = [ + "basal-launch", "basal-proto", "cortexkit-resource-busy", "httparse", @@ -127,6 +128,14 @@ dependencies = [ "tokio", "tracing", "webpki-roots 1.0.9", + "windows-sys 0.61.2", +] + +[[package]] +name = "basal-launch" +version = "0.1.0" +dependencies = [ + "windows-sys 0.61.2", ] [[package]] @@ -137,6 +146,7 @@ dependencies = [ "async-trait", "basal-core", "basal-host", + "basal-launch", "basal-proto", "basal-testkit", "base64", @@ -163,6 +173,7 @@ dependencies = [ "subc-transport", "tokio", "tracing", + "windows-sys 0.61.2", ] [[package]] @@ -178,6 +189,7 @@ version = "0.1.0" dependencies = [ "async-trait", "basal-host", + "basal-launch", "basal-proto", "blake3", "rusqlite", @@ -186,6 +198,7 @@ dependencies = [ "subc-client-rs", "subc-protocol", "tokio", + "windows-sys 0.61.2", ] [[package]] @@ -194,6 +207,7 @@ version = "0.1.0" dependencies = [ "basal-core", "basal-host", + "basal-launch", "basal-proto", "blake3", "jiff", @@ -203,12 +217,14 @@ dependencies = [ "rustls", "serde_json", "subc-protocol", + "windows-sys 0.61.2", ] [[package]] name = "basal-worker" version = "0.1.0" dependencies = [ + "basal-launch", "basal-proto", "basal-testkit", "landlock", @@ -216,6 +232,7 @@ dependencies = [ "rquickjs", "seccompiler", "serde_json", + "windows-sys 0.61.2", ] [[package]] @@ -400,20 +417,22 @@ dependencies = [ [[package]] name = "cortexkit-lease" -version = "0.1.0" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9a8fc97760adc623eddc8e092b6361b91f4449dd6c93443fde9e221fa727161" +checksum = "d02256247fecd266c7d853cf63d85270367abce9925e52c9ee01ac4994ed2293" dependencies = [ "fs2", + "windows-sys 0.61.2", ] [[package]] name = "cortexkit-log" -version = "0.3.3" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ceae091889f52b64ef218ca24346fd18ed14d24053396d7a8cad031a78c430e7" +checksum = "0c237e3dbd3b9bc5b7bd77e4899c660d8f5773192a0b98a35bab9f68307feffe" dependencies = [ "chrono", + "cortexkit-lease", "cortexkit-store-types", "regex", "tracing", @@ -466,9 +485,9 @@ dependencies = [ [[package]] name = "cortexkit-store" -version = "0.2.1" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a6cc64ebbed57cb7f006b2b5fec9983c634d69334e4c966072b39b9433a907c" +checksum = "df7beeeeaf1f244870ff160eff79a6f6b2eb8f2d234da9a1f6aa2c60a8a93c93" dependencies = [ "cortexkit-lease", "cortexkit-store-types", diff --git a/Cargo.toml b/Cargo.toml index 8a4b34e..cb79340 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "crates/basal-core", "crates/basal-testkit", "crates/basal-module", + "crates/basal-launch", "crates/basal-rig", ] @@ -20,6 +21,7 @@ rust-version = "1.88" # Dependencies do not require sibling checkouts. basal-core = { path = "crates/basal-core" } basal-host = { path = "crates/basal-host" } +basal-launch = { path = "crates/basal-launch" } basal-proto = { path = "crates/basal-proto" } basal-testkit = { path = "crates/basal-testkit" } blake3 = "1.8" @@ -35,7 +37,7 @@ croner = { version = "4.0", default-features = false, features = ["jiff"] } jiff = { version = "0.2.37", default-features = false, features = ["std", "tzdb-bundle-always"] } # The fleet's storage mechanics: open with the single-writer lease, WAL and a # busy timeout, and versioned migrations. Pinned exactly, as astrocyte does. -cortexkit-store = { version = "=0.2.1", features = ["sqlite"] } +cortexkit-store = { version = "=0.2.4", features = ["sqlite"] } cortexkit-store-types = "0.2.2" cortexkit-role-tool-provider = "=0.6.0" cortexkit-role-tool-provider-conformance = "=0.6.0" @@ -74,7 +76,7 @@ futures-util = "0.3" tokio = { version = "1", features = ["rt-multi-thread", "macros"] } # The fleet's log format and retention; the module logs through it as # entorhinal does. -cortexkit-log = "0.3.3" +cortexkit-log = "0.3.5" tracing = "0.1" # net.fetch, the network built-in. basal writes its own small HTTP/1.1 # client over rustls so it controls every step the safety rules name: diff --git a/README.md b/README.md index 3724c4d..b9aaad9 100644 --- a/README.md +++ b/README.md @@ -10,23 +10,24 @@ basal runs as a module supervised by the subc daemon, with the binary `ck-basal` Basal provides one agent-facing tool, `codemode({code, description?, limits?})`, on its tool route, where the daemon verifies the caller's principal and stamps its agent and scope identities. A short JavaScript program can await the agent's own tools, capture console output and return one JSON result. prefrontal-core, which manages agents and their tool grants, grants access to codemode and opens the run scope; basal owns the description, run records, budgets and rendered result. Tool schemas come from the daemon catalog, and each provider decides allow, ask and deny using the daemon-recorded agent identity and run scope, just as for a direct call. Model tools are a later extension. -A keyed call derives its run ID from the stamped agent and call key; retries attach without replay. A keyless call executes once under an OS-random ID and warns that its reply cannot be recovered. Basal's parent process (outside the confined worker) caps wall time at 30 minutes plus a separate cumulative 10 minutes waiting only on people: the worker must be blocked and every in-flight tool call waiting for a person's approval or decision. It closes the scope before cancellation kills its worker, and interrupts leftover runs at startup. Long keyed calls can return before the program finishes; `late_results` lets the caller retrieve the finished result later, and `tool.withdraw` cancels the run. Windows refuses `unsupported_platform`. See [the tool and role operations](docs/ops.md#codemode). +A keyed call derives its run ID from the stamped agent and call key; retries attach without replay. A keyless call executes once under an OS-random ID and warns that its reply cannot be recovered. Basal's parent process (outside the confined worker) caps wall time at 30 minutes plus a separate cumulative 10 minutes waiting only on people: the worker must be blocked and every in-flight tool call waiting for a person's approval or decision. It closes the scope before cancellation kills its worker, and interrupts leftover runs at startup. Long keyed calls can return before the program finishes; `late_results` lets the caller retrieve the finished result later, and `tool.withdraw` cancels the run. Codemode runs on macOS, Linux and Windows under each OS's full worker confinement. See [the tool and role operations](docs/ops.md#codemode). ## Crates | Crate | What | |---|---| | `basal-proto` | The versioned IPC protocol between the parent and its worker processes. | -| `basal-worker` | `ck-basal-worker`: the confined QuickJS engine. It confines itself before reading anything: Seatbelt on macOS, seccomp and Landlock on Linux. See [docs/sandbox.md](docs/sandbox.md). | +| `basal-worker` | `ck-basal-worker`: the confined QuickJS engine. Before reading anything it checks its confinement: Seatbelt on macOS, seccomp and Landlock on Linux, and the launcher's restricted LPAC token, job and mitigations on Windows. See [docs/sandbox.md](docs/sandbox.md). | | `basal-core` | Runtime state: the SQLite store, admission, the run state machine, the journal and mailbox, manifests and authorization, the scheduler and the activation driver. | | `basal-host` | The `Host` boundary that flow calls go through (module ops, facts, model calls, sinks), the adapters to the fleet's modules, and a deterministic mock. | | `basal-module` | `ck-basal`: the supervised module, with its subc manifest, the codemode tool-provider role, the worker pool, the flow ops, dry runs and consent cards. | +| `basal-launch` | Windows process creation: restricted LPAC workers and job-owned child processes, with a shared spawn lock. | | `basal-testkit` | Test parents, crash and cut harnesses, and benchmarks. | | `basal-rig` | Test support for the isolated ckdev-flows rig only: a callosum stub and the live contract suite against a real prefrontal-core. Never deployed. | ## Building and testing -basal needs Rust 1.88 or newer (edition 2024) on macOS or Linux (x86_64 or aarch64). Dependencies are published on crates.io or pinned to an immutable Git revision. +basal needs Rust 1.88 or newer (edition 2024) on macOS, Linux (x86_64 or aarch64), or Windows. CI runs natively on both Windows runner images, windows-latest and windows-2022. Dependencies are published on crates.io or pinned to an immutable Git revision. ```sh cargo build --workspace diff --git a/crates/basal-core/src/codemode/admission.rs b/crates/basal-core/src/codemode/admission.rs index a0011b5..9c57818 100644 --- a/crates/basal-core/src/codemode/admission.rs +++ b/crates/basal-core/src/codemode/admission.rs @@ -25,8 +25,8 @@ pub const MAX_DESCRIPTION_BYTES: usize = 1_024; pub const MAX_AGENT_RUNS: u64 = 2; pub const MAX_BASAL_RUNS: u64 = 16; -/// Injectable so a Linux or macOS test can verify that Windows is refused -/// without requiring a Windows worker or sandbox. +/// The admission caller's operating system. Scope and budget checks are the +/// same on every supported platform; worker confinement is checked at launch. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Platform { Unix, @@ -121,7 +121,7 @@ pub fn admit( store: &Store, host: &dyn Host, clock: &Clock, - platform: Platform, + _platform: Platform, request: &Value, ) -> Result { if let Some(run_id) = request.get("run_id").and_then(Value::as_str) @@ -129,12 +129,6 @@ pub fn admit( { return Ok(known); } - if platform == Platform::Windows { - return Ok(Admission::Refused(Refusal::new( - "unsupported_platform", - "codemode is not supported on Windows", - ))); - } let checked = (|| { let scope = scope_shape(request)?; let agent_id = attest(host, &scope, request)?; @@ -468,7 +462,7 @@ pub fn admit_tool( host: &dyn Host, transport: &dyn basal_host::transport::Transport, clock: &Clock, - platform: Platform, + _platform: Platform, invocation: &ToolInvocation<'_>, arguments: &Value, ) -> Result { @@ -477,12 +471,6 @@ pub fn admit_tool( if let Some(known) = store.read(|conn| known(conn, invocation.run_id))? { return Ok(known); } - if platform == Platform::Windows { - return Ok(Admission::Refused(Refusal::new( - "unsupported_platform", - "codemode is not supported on Windows", - ))); - } let Some(fields_in) = arguments.as_object() else { return Ok(Admission::Refused(Refusal::new( "invalid_request", diff --git a/crates/basal-core/src/codemode/admission_tests.rs b/crates/basal-core/src/codemode/admission_tests.rs index c37867a..83bbd7f 100644 --- a/crates/basal-core/src/codemode/admission_tests.rs +++ b/crates/basal-core/src/codemode/admission_tests.rs @@ -329,22 +329,23 @@ fn pruned_id_is_unknown_and_never_admitted_again() { } #[test] -fn injected_windows_is_refused_before_scope_or_request_checks() { +fn windows_admission_uses_the_same_scope_and_budget_checks() { let t = temp_store(); - let h = host("agent"); - let Admission::Refused(refusal) = admit( + let h = host_for_run("agent", "windows"); + let admitted = admit( &t.store, &h, &Clock::manual(NOW), Platform::Windows, - &json!({}), + &request("windows", "agent"), ) - .unwrap() else { - panic!("Windows must refuse without a start token"); - }; - assert_eq!(refusal.code, "unsupported_platform"); - assert_eq!(row_count(&t.store), 0); - assert!(h.scope_queries().is_empty()); + .unwrap(); + assert!(matches!( + admitted, + Admission::Admitted { start: Some(_), .. } + )); + assert_eq!(row_count(&t.store), 1); + assert_eq!(h.scope_queries().len(), 1); } #[test] diff --git a/crates/basal-core/src/manifest.rs b/crates/basal-core/src/manifest.rs index 198bd7e..dc02bf1 100644 --- a/crates/basal-core/src/manifest.rs +++ b/crates/basal-core/src/manifest.rs @@ -287,6 +287,35 @@ fn check_root(field: &str, root: &str) -> Result<(), ManifestError> { if root.chars().any(char::is_control) { return Err(invalid(field, format!("{root:?} has a control character"))); } + if cfg!(windows) { + check_windows_root(field, root) + } else { + check_unix_root(field, root) + } +} + +/// A Windows root is spelt exactly as the Windows `fs` built-in accepts a +/// path: an absolute drive path (`C:\dir`), never a UNC, device, +/// extended-length or drive-relative path, an alternate data stream, a `.` +/// or `..` component, or a reserved device name. `~` and `~/...` (the +/// user's profile folder) stay accepted as on Unix, with each component +/// under `~/` held to the same rules. +fn check_windows_root(field: &str, root: &str) -> Result<(), ManifestError> { + let spelling = if root == "~" { + return Ok(()); + } else if let Some(rest) = root.strip_prefix("~/") { + // The components are checked as if under a drive root; the drive + // letter itself is never used. + format!("C:\\{}", rest.replace('/', "\\")) + } else { + root.to_owned() + }; + basal_host::builtins::fs::windows::validate_raw_spelling(&spelling) + .map_err(|denial| invalid(field, format!("{root:?}: {}", denial.message))) +} + +/// A Unix root starts with `/` or `~/` (or is `~`). +fn check_unix_root(field: &str, root: &str) -> Result<(), ManifestError> { let rest = if root == "~" { "" } else if let Some(rest) = root.strip_prefix("~/") { @@ -824,6 +853,69 @@ impl Manifest { mod tests { use super::*; + #[test] + fn windows_roots_are_the_fs_built_ins_drive_paths_or_under_the_profile() { + for root in [ + r"C:\x", + r"C:\", + r"d:\Users\me\notes", + "~", + "~/", + "~/notes", + "~/notes/2026", + ] { + assert_eq!(check_windows_root("fs.read", root), Ok(()), "{root}"); + } + for root in [ + r"\\server\share\x", + "//server/share/x", + r"\\?\C:\x", + r"\\.\C:\x", + r"\??\C:\x", + r"C:x", + "C:", + r"C:\x\file.txt:stream", + r"C:\x::$DATA", + r"C:\x\..\y", + r"C:\x\.", + r"C:\x\CON", + "C:/x", + "/x", + "x", + "~/../x", + "~/a:b", + r"~\x", + ] { + assert!( + matches!( + check_windows_root("fs.write", root), + Err(ManifestError::Invalid { ref field, .. }) if field == "fs.write" + ), + "{root} was accepted" + ); + } + } + + /// The platform's own grammar is the one a manifest is parsed with. + #[test] + fn manifest_roots_follow_this_platforms_spelling() { + let parse = |root: &str| { + Manifest::parse( + &serde_json::json!({ + "id":"roots", "version":1, "purpose":"test", + "trigger":{"events":[{"module":"echo","name":"tick","version":1}]}, + "fs": {"read": [root]} + }) + .to_string(), + ) + .map(|_| ()) + }; + assert!(parse("~/notes").is_ok()); + assert_eq!(parse(r"C:\x").is_ok(), cfg!(windows)); + assert_eq!(parse("/x").is_ok(), !cfg!(windows)); + assert!(parse(r"\\server\share").is_err()); + } + #[test] fn multibyte_durations_are_refused_without_panicking() { for text in ["é", "10é", "10中", "10🦀", "é1s", "١s", "1sé"] { diff --git a/crates/basal-core/src/retention.rs b/crates/basal-core/src/retention.rs index 6021fb6..26ccc00 100644 --- a/crates/basal-core/src/retention.rs +++ b/crates/basal-core/src/retention.rs @@ -21,8 +21,7 @@ use rusqlite::{Connection, OptionalExtension, Transaction, params}; use std::collections::HashMap; -use std::ffi::OsString; -use std::os::unix::ffi::{OsStrExt, OsStringExt}; +use std::ffi::{OsStr, OsString}; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, MutexGuard, Weak}; @@ -360,7 +359,7 @@ impl TempLedger for FsTempLedger { let hold = FsTempHold { shared: self.shared.clone(), key: lease.call_key.clone(), - dir: lease.dir.as_os_str().as_bytes().to_vec(), + dir: os_bytes(lease.dir.as_os_str()), cleared: false, }; let roots = serde_json::to_string(&lease.roots).map_err(|e| e.to_string())?; @@ -373,7 +372,7 @@ impl TempLedger for FsTempLedger { params![ lease.call_key, hold.dir, - lease.target.as_bytes(), + os_bytes(&lease.target), lease.temp.to_string_lossy(), roots ], @@ -466,18 +465,27 @@ impl Runtime { // it ends without doing so. continue; } - let removal = match serde_json::from_str::>(&roots) { - Ok(roots) => host_fs::remove_temp(&TempLease { + let removal = match ( + serde_json::from_str::>(&roots), + os_from_bytes(dir.clone()), + os_from_bytes(target), + ) { + (Ok(roots), Some(record_dir), Some(target)) => host_fs::remove_temp(&TempLease { call_key: call_key.clone(), - dir: PathBuf::from(OsString::from_vec(dir.clone())), - target: OsString::from_vec(target), + dir: PathBuf::from(record_dir), + target, temp: OsString::from(temp), roots, }), // No roots to check a path against: nothing may be removed. - Err(_) => Ok(host_fs::TempRemoval::Refused( + (Err(_), _, _) => Ok(host_fs::TempRemoval::Refused( basal_host::builtins::Denial::invalid("unreadable roots"), )), + // Bytes `os_bytes` cannot have produced, so the record names no + // file a write created: nothing may be removed. + _ => Ok(host_fs::TempRemoval::Refused( + basal_host::builtins::Denial::invalid("unreadable path"), + )), }; match removal { Ok(outcome) => { @@ -556,3 +564,74 @@ impl Runtime { }) } } + +/// A path or file name as stored in an `fs_temps` record. On Unix these are +/// the raw bytes, which every record already written holds. On Windows, +/// where a name is a sequence of UTF-16 units that need not be valid +/// Unicode, they are those units in little-endian order, so any name +/// round-trips exactly. +#[cfg(unix)] +fn os_bytes(value: &OsStr) -> Vec { + use std::os::unix::ffi::OsStrExt; + value.as_bytes().to_vec() +} + +#[cfg(windows)] +fn os_bytes(value: &OsStr) -> Vec { + use std::os::windows::ffi::OsStrExt; + value.encode_wide().flat_map(u16::to_le_bytes).collect() +} + +/// The inverse of [`os_bytes`]. `None` for bytes it cannot have produced (an +/// odd length on Windows), which name nothing basal may remove. +#[cfg(unix)] +fn os_from_bytes(bytes: Vec) -> Option { + use std::os::unix::ffi::OsStringExt; + Some(OsString::from_vec(bytes)) +} + +#[cfg(windows)] +fn os_from_bytes(bytes: Vec) -> Option { + use std::os::windows::ffi::OsStringExt; + if bytes.len() % 2 != 0 { + return None; + } + let units: Vec = bytes + .chunks_exact(2) + .map(|unit| u16::from_le_bytes([unit[0], unit[1]])) + .collect(); + Some(OsString::from_wide(&units)) +} + +#[cfg(test)] +mod os_bytes_tests { + use super::*; + + #[test] + fn stored_names_round_trip_exactly() { + for name in ["", "plain", "résumé 日本語", ".basal-1-2.tmp"] { + let value = OsString::from(name); + assert_eq!(os_from_bytes(os_bytes(&value)), Some(value)); + } + } + + /// Records written before this encoding existed hold the raw bytes, so + /// on Unix the stored form must still be exactly those bytes. + #[cfg(unix)] + #[test] + fn unix_names_are_stored_as_their_raw_bytes() { + use std::os::unix::ffi::OsStringExt; + let raw = vec![b'a', 0xff, b'/', b'b']; + assert_eq!(os_bytes(&OsString::from_vec(raw.clone())), raw); + } + + #[cfg(windows)] + #[test] + fn windows_names_keep_unpaired_surrogates_and_refuse_odd_lengths() { + use std::os::windows::ffi::OsStringExt; + let value = OsString::from_wide(&[0xd800, u16::from(b'a')]); + assert_eq!(os_bytes(&value), [0x00, 0xd8, b'a', 0x00]); + assert_eq!(os_from_bytes(os_bytes(&value)), Some(value)); + assert_eq!(os_from_bytes(vec![b'a']), None); + } +} diff --git a/crates/basal-core/src/runtime_authorization_regressions.rs b/crates/basal-core/src/runtime_authorization_regressions.rs index 47f580e..7dbb555 100644 --- a/crates/basal-core/src/runtime_authorization_regressions.rs +++ b/crates/basal-core/src/runtime_authorization_regressions.rs @@ -20,7 +20,18 @@ pub(crate) fn connection() -> Connection { pub(crate) struct TestRuntime { pub rt: Runtime, - path: std::path::PathBuf, + // Declared after the runtime so it drops after it: on Windows an open + // file blocks deleting its directory, so the store must close first. + _dir: ScratchDir, +} + +/// Removes the runtime's scratch directory when dropped. +struct ScratchDir(std::path::PathBuf); + +impl Drop for ScratchDir { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.0).unwrap(); + } } impl TestRuntime { @@ -43,13 +54,10 @@ impl TestRuntime { None, Config::default(), ); - Self { rt, path } - } -} - -impl Drop for TestRuntime { - fn drop(&mut self) { - std::fs::remove_dir_all(&self.path).unwrap(); + Self { + rt, + _dir: ScratchDir(path), + } } } diff --git a/crates/basal-host/Cargo.toml b/crates/basal-host/Cargo.toml index 4cadb55..a7f7113 100644 --- a/crates/basal-host/Cargo.toml +++ b/crates/basal-host/Cargo.toml @@ -36,5 +36,11 @@ tokio.workspace = true tracing.workspace = true webpki-roots.workspace = true +[target.'cfg(windows)'.dependencies] +# The git runner takes basal-launch's process-wide spawn lock while its +# child's handles are inheritable. +basal-launch.workspace = true +windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Security_Cryptography", "Win32_Storage_FileSystem", "Win32_System_IO", "Win32_System_JobObjects", "Win32_System_Pipes", "Win32_System_Threading"] } + [dev-dependencies] subc-transport.workspace = true diff --git a/crates/basal-host/src/builtins/fs.rs b/crates/basal-host/src/builtins/fs.rs index 8598478..b8f79fd 100644 --- a/crates/basal-host/src/builtins/fs.rs +++ b/crates/basal-host/src/builtins/fs.rs @@ -27,21 +27,34 @@ #[cfg(test)] mod tests; -use std::ffi::{CString, OsStr, OsString}; +#[cfg(unix)] +use std::ffi::CString; +use std::ffi::{OsStr, OsString}; +#[cfg(unix)] use std::fs::File; -#[cfg(not(target_os = "linux"))] +#[cfg(all(unix, not(target_os = "linux")))] use std::fs::OpenOptions; +#[cfg(unix)] use std::io::{Read, Write}; +#[cfg(unix)] use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}; +#[cfg(unix)] use std::os::unix::ffi::OsStrExt; -#[cfg(not(target_os = "linux"))] +#[cfg(all(unix, not(target_os = "linux")))] use std::os::unix::fs::OpenOptionsExt; -use std::path::{Component, Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; - +#[cfg(unix)] +use std::path::Component; +use std::path::{Path, PathBuf}; +use std::sync::atomic::AtomicU64; +#[cfg(unix)] +use std::sync::atomic::Ordering; + +#[cfg(unix)] use serde_json::{Value, json}; -use super::{Denial, codes, expand_home}; +#[cfg(unix)] +use super::expand_home; +use super::{Denial, codes}; #[cfg(target_os = "linux")] pub use linux::{ @@ -49,6 +62,13 @@ pub use linux::{ stat, }; +pub mod windows; +#[cfg(windows)] +pub use windows::{ + Target, list, open_checked, read, remove_legacy_temps, remove_temp, resolve, stat, write, + write_call, +}; + /// `fs.read`'s default cap. pub const DEFAULT_READ_BYTES: u64 = super::MAX_TEXT_RESULT_BYTES as u64; /// The largest cap `fs.read` accepts. @@ -70,7 +90,7 @@ pub enum Purpose { /// Where a path resolved to. #[derive(Debug, Clone, PartialEq, Eq)] -#[cfg(not(target_os = "linux"))] +#[cfg(all(unix, not(target_os = "linux")))] pub enum Target { /// It exists; this is its real path. Existing(PathBuf), @@ -81,7 +101,7 @@ pub enum Target { /// The roots, each resolved to its real path. A root that does not exist /// now grants nothing. -#[cfg(not(target_os = "linux"))] +#[cfg(all(unix, not(target_os = "linux")))] fn real_roots(roots: &[String]) -> Vec { roots .iter() @@ -111,6 +131,7 @@ fn io_denial(path: &Path, e: &std::io::Error) -> Denial { } /// The script's path as an absolute path, with `~` expanded. +#[cfg(unix)] fn absolute(path: &str) -> Result { if path.len() > MAX_PATH_BYTES || path.contains('\0') { return Err(Denial::invalid(format!( @@ -121,13 +142,13 @@ fn absolute(path: &str) -> Result { } /// Resolves `path` and requires it to lie under one of `roots`. -#[cfg(not(target_os = "linux"))] +#[cfg(all(unix, not(target_os = "linux")))] pub fn resolve(path: &str, roots: &[String], purpose: Purpose) -> Result { let roots = real_roots(roots); resolve_real(path, &roots, purpose) } -#[cfg(not(target_os = "linux"))] +#[cfg(all(unix, not(target_os = "linux")))] fn resolve_real(path: &str, roots: &[PathBuf], purpose: Purpose) -> Result { let path = absolute(path)?; if purpose == Purpose::Read { @@ -166,7 +187,7 @@ fn resolve_real(path: &str, roots: &[PathBuf], purpose: Purpose) -> Result std::io::Result { #[cfg(target_os = "macos")] { @@ -190,7 +211,7 @@ fn fd_path(fd: RawFd) -> std::io::Result { /// Requires the file behind `fd` (or, with `name`, the entry `name` in the /// directory behind `fd`) to lie under one of `roots`. -#[cfg(not(target_os = "linux"))] +#[cfg(all(unix, not(target_os = "linux")))] fn verify(fd: RawFd, name: Option<&OsStr>, roots: &[PathBuf]) -> Result<(), Denial> { let mut real = fd_path(fd).map_err(|e| Denial::new(codes::IO, e.to_string()))?; if let Some(name) = name { @@ -210,12 +231,12 @@ fn verify(fd: RawFd, name: Option<&OsStr>, roots: &[PathBuf]) -> Result<(), Deni /// it was resolved against. A symlink swapped into the last component /// since resolution fails the open; one swapped in higher up is caught by /// the check after it. -#[cfg(not(target_os = "linux"))] +#[cfg(all(unix, not(target_os = "linux")))] pub fn open_checked(resolved: &Path, roots: &[String], directory: bool) -> Result { open_checked_real(resolved, &real_roots(roots), directory) } -#[cfg(not(target_os = "linux"))] +#[cfg(all(unix, not(target_os = "linux")))] fn open_checked_real(resolved: &Path, roots: &[PathBuf], directory: bool) -> Result { let mut flags = libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK; if directory { @@ -240,6 +261,7 @@ fn open_checked_real(resolved: &Path, roots: &[PathBuf], directory: bool) -> Res } /// `fs.read`: the file's text, refused over `max_bytes` or when not UTF-8. +#[cfg(unix)] pub fn read(path: &str, roots: &[String], max_bytes: u64) -> Result { #[cfg(not(target_os = "linux"))] let roots = real_roots(roots); @@ -294,6 +316,7 @@ pub fn read(path: &str, roots: &[String], max_bytes: u64) -> Result &'static str { match mode & libc::S_IFMT { libc::S_IFREG => "file", @@ -303,12 +326,14 @@ fn kind_of(mode: libc::mode_t) -> &'static str { } } +#[cfg(unix)] fn c_name(name: &OsStr) -> Result { CString::new(name.as_bytes()).map_err(|_| Denial::invalid("a name with a NUL byte")) } /// `lstat` of `name` in the directory behind `dir`; `None` when it does /// not exist. +#[cfg(unix)] fn stat_at(dir: RawFd, name: &OsStr) -> Result, Denial> { let c = c_name(name)?; let mut st = std::mem::MaybeUninit::::uninit(); @@ -328,7 +353,7 @@ fn stat_at(dir: RawFd, name: &OsStr) -> Result, Denial> { } /// Opens the parent of an entry and checks the entry's real path. -#[cfg(not(target_os = "linux"))] +#[cfg(all(unix, not(target_os = "linux")))] fn open_parent(parent: &Path, name: &OsStr, roots: &[PathBuf]) -> Result { let file = OpenOptions::new() .read(true) @@ -343,7 +368,7 @@ fn open_parent(parent: &Path, name: &OsStr, roots: &[PathBuf]) -> Result Result { let roots = real_roots(roots); let (parent, name) = match resolve_real(path, &roots, Purpose::Read)? { @@ -370,6 +395,7 @@ pub fn stat(path: &str, roots: &[String]) -> Result { /// `fs.list`: the directory's entries, sorted by name, refused over /// [`MAX_LIST_ENTRIES`] or when a name is not UTF-8. +#[cfg(unix)] pub fn list(path: &str, roots: &[String]) -> Result { #[cfg(not(target_os = "linux"))] let roots = real_roots(roots); @@ -431,6 +457,7 @@ pub fn list(path: &str, roots: &[String]) -> Result { /// The entries of the directory behind `fd` (not `.` or `..`) with their /// `d_type`, reading at most one more than [`MAX_LIST_ENTRIES`]. +#[cfg(unix)] fn read_dir_fd(fd: RawFd) -> std::io::Result> { let mut out = Vec::new(); scan_dir(fd, |name, d_type| { @@ -442,6 +469,7 @@ fn read_dir_fd(fd: RawFd) -> std::io::Result> { /// Hands each entry of the directory behind `fd` (not `.` or `..`) with its /// `d_type` to `visit`, until `visit` answers false. +#[cfg(unix)] fn scan_dir(fd: RawFd, mut visit: impl FnMut(OsString, u8) -> bool) -> std::io::Result<()> { // fdopendir takes ownership of the descriptor it is given, so it gets // a duplicate and the caller keeps its own. @@ -580,6 +608,7 @@ pub fn is_legacy_temp_name(name: &OsStr) -> bool { } /// What [`unlink_regular`] found under a name. +#[cfg(unix)] enum Unlinked { Removed, Absent, @@ -590,6 +619,7 @@ enum Unlinked { /// Removes `name` from the directory behind `dir` only if it is a regular /// file. The name is examined without following a symlink, and unlinkat /// removes the entry itself, so a symlink's target is never touched. +#[cfg(unix)] fn unlink_regular(dir: RawFd, name: &OsStr) -> Result { match stat_at(dir, name)? { None => return Ok(Unlinked::Absent), @@ -611,6 +641,7 @@ fn unlink_regular(dir: RawFd, name: &OsStr) -> Result { } } +#[cfg(unix)] fn replacement_mode(mode: libc::mode_t) -> libc::mode_t { mode & 0o777 } @@ -627,6 +658,7 @@ pub(super) fn check_write_size(bytes: usize) -> Result<(), Denial> { } /// `fs.write` outside a journaled call, under a key of its own. +#[cfg(unix)] pub fn write(path: &str, roots: &[String], text: &str) -> Result { let key = format!( "local-{}-{}", @@ -639,6 +671,7 @@ pub fn write(path: &str, roots: &[String], text: &str) -> Result /// Resolves `path` for a write and opens its parent directory, checked /// against `roots`: the directory's real path, the name in it, and the /// open directory. +#[cfg(unix)] fn open_write_parent(path: &str, roots: &[String]) -> Result<(PathBuf, OsString, File), Denial> { #[cfg(not(target_os = "linux"))] let real = real_roots(roots); @@ -658,6 +691,7 @@ fn open_write_parent(path: &str, roots: &[String]) -> Result<(PathBuf, OsString, /// Creates the temporary file `name` (`c` is the same name) in the /// directory behind `dir`, never following a symlink. +#[cfg(unix)] fn create_temp(dir: RawFd, name: &OsStr, c: &CString, mode: libc::mode_t) -> std::io::Result { let mut replaced = false; loop { @@ -701,6 +735,7 @@ fn create_temp(dir: RawFd, name: &OsStr, c: &CString, mode: libc::mode_t) -> std /// `ledger`, the file is recorded there before it is created and the record /// is cleared only once the file is gone and that is durable, so however /// the call ends, crash included, a file it left behind is on record. +#[cfg(unix)] pub fn write_call( path: &str, roots: &[String], @@ -819,6 +854,7 @@ pub enum TempRemoval { /// following a symlink, and nothing else is ever removed. Removing a file /// that is already gone is not an error, so this may run any number of /// times. `Err` is a failure that may pass: keep the record and try again. +#[cfg(unix)] pub fn remove_temp(lease: &TempLease) -> Result { if !is_temp_name(&lease.temp) && !is_legacy_temp_name(&lease.temp) { return Ok(TempRemoval::Refused(Denial::invalid( @@ -861,6 +897,7 @@ pub fn remove_temp(lease: &TempLease) -> Result { /// is opened and checked against `roots` as a write to `path` would be; /// symlinks are never followed and nothing else is removed. Returns how /// many files were removed. +#[cfg(unix)] pub fn remove_legacy_temps(path: &str, roots: &[String]) -> Result { let (parent, _, dir) = open_write_parent(path, roots)?; let mut names = Vec::new(); diff --git a/crates/basal-host/src/builtins/fs/tests.rs b/crates/basal-host/src/builtins/fs/tests.rs index 9a482c9..216f67e 100644 --- a/crates/basal-host/src/builtins/fs/tests.rs +++ b/crates/basal-host/src/builtins/fs/tests.rs @@ -1,5 +1,7 @@ +#[cfg(unix)] use super::*; +#[cfg(unix)] #[test] fn replacement_mode_never_carries_setuid_or_setgid() { assert_eq!(replacement_mode(0o6755), 0o755); diff --git a/crates/basal-host/src/builtins/fs/windows.rs b/crates/basal-host/src/builtins/fs/windows.rs new file mode 100644 index 0000000..c24c7cb --- /dev/null +++ b/crates/basal-host/src/builtins/fs/windows.rs @@ -0,0 +1,3280 @@ +//! Windows-specific implementation of the `fs` built-in. +//! +//! A path's spelling is checked before anything is opened +//! ([`validate_raw_spelling`]). Every root and every path is then walked one +//! component at a time from `\??\X:\`, each component opened relative to the +//! handle of the one before, so Windows never rewrites a path, and a reparse +//! point (symlink, junction, mount point or any other) is refused wherever +//! it appears. The handle finally acted on is checked once more: its +//! volume-GUID path must lie under the root's, component by component. +//! +//! `fs.write` holds every directory from the volume root down to the +//! target's parent without `FILE_SHARE_DELETE` until its rename returns, so +//! none of them can be moved out of the root while it runs. It writes a +//! temporary file, flushes it, and renames it over the target with POSIX +//! semantics, failing rather than falling back to any other rename. A +//! replaced file keeps its DACL; a new file takes what NTFS inherits. + +#![cfg_attr(not(windows), allow(unused))] + +use serde_json::{Value, json}; +use std::ffi::{OsStr, OsString}; +use std::path::{Path, PathBuf}; + +pub use super::{ + DEFAULT_READ_BYTES, MAX_LIST_ENTRIES, MAX_PATH_BYTES, MAX_READ_BYTES, MAX_WRITE_BYTES, Purpose, + TempHold, TempLease, TempLedger, TempRemoval, inside, is_legacy_temp_name, temp_name, +}; +use super::{TEMP_SEQ, check_write_size, io_denial, is_temp_name, outside}; +use crate::builtins::Denial; +pub use crate::builtins::codes; + +/// Where a path resolved to on Windows. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Target { + /// It exists; this is its real path. + Existing(PathBuf), + /// It does not exist (or, for a write, is about to be replaced): the + /// real path of its parent, and its last component. + Entry { parent: PathBuf, name: OsString }, +} + +/// Validates raw Windows path spelling before any normalization. +/// +/// Accepted: only drive-letter-rooted absolute paths (`X:\...`). +/// Refused prefixes: UNC (`\\server\share`), `C:relative`, and `\\.\`, `\\?\`, +/// `\??\`, `//./` namespaces. +/// Refused components (after the drive root): +/// - `.`, `..` and empty components +/// - `:` (alternate data streams and `::$DATA`) +/// - trailing dots or spaces +/// - reserved device names even with extension (`CON.txt`, `aux`, etc.) +pub fn validate_raw_spelling(path: &str) -> Result<(), Denial> { + if path.len() > MAX_PATH_BYTES || path.contains('\0') { + return Err(Denial::invalid(format!( + "a path is at most {MAX_PATH_BYTES} bytes with no NUL" + ))); + } + + // Refused prefixes + if path.starts_with(r"\\") || path.starts_with("//") { + return Err(Denial::invalid(format!( + "{path:?} is a UNC or device path, not an ordinary drive path" + ))); + } + if path.starts_with(r"\??\") || path.starts_with("/??/") { + return Err(Denial::invalid(format!( + "{path:?} uses the NT object namespace" + ))); + } + if path.starts_with(r"\\.\") || path.starts_with("//./") { + return Err(Denial::invalid(format!( + "{path:?} uses the Win32 device namespace" + ))); + } + if path.starts_with(r"\\?\") || path.starts_with("//?/") { + return Err(Denial::invalid(format!( + "{path:?} uses extended-length path syntax" + ))); + } + + let bytes = path.as_bytes(); + // Must start with X:\ where X is ASCII alphabetic + if bytes.len() < 3 || !bytes[0].is_ascii_alphabetic() || bytes[1] != b':' || bytes[2] != b'\\' { + if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' { + return Err(Denial::invalid(format!( + "{path:?} is drive-relative, not an absolute drive path" + ))); + } + return Err(Denial::invalid(format!( + "{path:?} is not an absolute drive path (must start with X:\\)" + ))); + } + + let remainder = &path[3..]; + if remainder.is_empty() { + return Ok(()); + } + + if remainder.contains('/') { + return Err(Denial::invalid(format!( + "{path:?} contains forward slashes" + ))); + } + + for comp in remainder.split('\\') { + if comp.is_empty() { + return Err(Denial::invalid(format!( + "{path:?} contains an empty component" + ))); + } + if comp == "." || comp == ".." { + return Err(Denial::invalid(format!( + "{path:?} contains relative component {comp:?}" + ))); + } + if comp.contains(':') { + return Err(Denial::invalid(format!( + "{path:?} contains an alternate data stream selector" + ))); + } + if comp.ends_with('.') || comp.ends_with(' ') { + return Err(Denial::invalid(format!( + "{path:?} component {comp:?} ends with a dot or space" + ))); + } + let base = comp + .split('.') + .next() + .unwrap_or(comp) + .trim_end_matches([' ', '.']); + if is_reserved_device_name(base) { + return Err(Denial::invalid(format!( + "{path:?} component {comp:?} uses reserved device name {base:?}" + ))); + } + } + + Ok(()) +} + +/// Whether `name` is a Windows reserved DOS device name, compared ASCII-case-insensitively. +pub fn is_reserved_device_name(name: &str) -> bool { + let upper = name.to_ascii_uppercase(); + matches!( + upper.as_str(), + "CON" + | "PRN" + | "AUX" + | "NUL" + | "CONIN$" + | "CONOUT$" + | "COM0" + | "COM1" + | "COM2" + | "COM3" + | "COM4" + | "COM5" + | "COM6" + | "COM7" + | "COM8" + | "COM9" + | "LPT0" + | "LPT1" + | "LPT2" + | "LPT3" + | "LPT4" + | "LPT5" + | "LPT6" + | "LPT7" + | "LPT8" + | "LPT9" + // Windows also reserves COM and LPT followed by a superscript one, two or three. + | "COM\u{b9}" + | "COM\u{b2}" + | "COM\u{b3}" + | "LPT\u{b9}" + | "LPT\u{b2}" + | "LPT\u{b3}" + ) +} + +/// Compares a target's volume-GUID path with a root's volume-GUID path +/// component-by-component without case folding or string-prefix matching. +pub fn guid_path_inside_component_wise( + target_guid: &str, + root_guid: &str, + is_directory_root: bool, +) -> bool { + let target_parts: Vec<&str> = target_guid.split('\\').filter(|s| !s.is_empty()).collect(); + let root_parts: Vec<&str> = root_guid.split('\\').filter(|s| !s.is_empty()).collect(); + + if target_parts.len() < root_parts.len() { + return false; + } + + for (r, t) in root_parts.iter().zip(target_parts.iter()) { + if r != t { + return false; + } + } + + if !is_directory_root && target_parts.len() != root_parts.len() { + return false; + } + + true +} + +#[cfg(windows)] +#[allow(non_snake_case, non_upper_case_globals, clippy::upper_case_acronyms)] +mod ffi { + use std::ffi::c_void; + + pub type NTSTATUS = i32; + pub type HANDLE = *mut c_void; + + pub const STATUS_OBJECT_NAME_NOT_FOUND: NTSTATUS = 0xC0000034_u32 as i32; + pub const STATUS_OBJECT_PATH_NOT_FOUND: NTSTATUS = 0xC000003A_u32 as i32; + pub const STATUS_OBJECT_NAME_COLLISION: NTSTATUS = 0xC0000035_u32 as i32; + pub const STATUS_ACCESS_DENIED: NTSTATUS = 0xC0000022_u32 as i32; + pub const STATUS_NOT_A_DIRECTORY: NTSTATUS = 0xC0000103_u32 as i32; + pub const STATUS_FILE_IS_A_DIRECTORY: NTSTATUS = 0xC00000BA_u32 as i32; + pub const STATUS_NOT_SUPPORTED: NTSTATUS = 0xC00000BB_u32 as i32; + pub const STATUS_INVALID_PARAMETER: NTSTATUS = 0xC000000D_u32 as i32; + pub const STATUS_NAME_TOO_LONG: NTSTATUS = 0xC0000106_u32 as i32; + pub const STATUS_FILE_CORRUPT_ERROR: NTSTATUS = 0xC0000102_u32 as i32; + pub const STATUS_NO_MORE_FILES: NTSTATUS = 0x80000006_u32 as i32; + #[cfg(test)] + pub const STATUS_IO_DEVICE_ERROR: NTSTATUS = 0xC0000185_u32 as i32; + + pub const FILE_LIST_DIRECTORY: u32 = 0x0001; + pub const FILE_WRITE_DATA: u32 = 0x0002; + pub const FILE_TRAVERSE: u32 = 0x0020; + pub const FILE_READ_ATTRIBUTES: u32 = 0x0080; + pub const DELETE: u32 = 0x00010000; + pub const READ_CONTROL: u32 = 0x00020000; + pub const SYNCHRONIZE: u32 = 0x00100000; + pub const FILE_GENERIC_READ: u32 = 0x00120089; + pub const FILE_GENERIC_WRITE: u32 = 0x00120116; + + pub const FILE_SHARE_READ: u32 = 0x00000001; + pub const FILE_SHARE_WRITE: u32 = 0x00000002; + pub const FILE_SHARE_DELETE: u32 = 0x00000004; + + pub const FILE_OPEN: u32 = 0x00000001; + pub const FILE_CREATE: u32 = 0x00000002; + + pub const FILE_DIRECTORY_FILE: u32 = 0x00000001; + pub const FILE_SYNCHRONOUS_IO_NONALERT: u32 = 0x00000020; + pub const FILE_NON_DIRECTORY_FILE: u32 = 0x00000040; + pub const FILE_OPEN_REPARSE_POINT: u32 = 0x00200000; + + pub const FILE_ATTRIBUTE_NORMAL: u32 = 0x00000080; + pub const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x00000010; + pub const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x00000400; + + pub const OBJ_CASE_INSENSITIVE: u32 = 0x00000040; + + pub const FileDirectoryInformation: u32 = 1; + pub const FileBasicInformation: u32 = 4; + pub const FileStandardInformation: u32 = 5; + pub const FileDispositionInformation: u32 = 13; + pub const FileRenameInformationEx: u32 = 65; + + pub const FILE_RENAME_REPLACE_IF_EXISTS: u32 = 0x00000001; + pub const FILE_RENAME_POSIX_SEMANTICS: u32 = 0x00000002; + pub const FILE_RENAME_IGNORE_READONLY_ATTRIBUTE: u32 = 0x00000040; + + pub const FILE_NAME_NORMALIZED: u32 = 0x0; + pub const VOLUME_NAME_GUID: u32 = 0x1; + + pub const DACL_SECURITY_INFORMATION: u32 = 0x00000004; + pub const SE_FILE_OBJECT: u32 = 1; + + #[repr(C)] + pub struct UNICODE_STRING { + pub Length: u16, + pub MaximumLength: u16, + pub Buffer: *mut u16, + } + + #[repr(C)] + pub struct OBJECT_ATTRIBUTES { + pub Length: u32, + pub RootDirectory: HANDLE, + pub ObjectName: *mut UNICODE_STRING, + pub Attributes: u32, + pub SecurityDescriptor: *mut c_void, + pub SecurityQualityOfService: *mut c_void, + } + + #[repr(C)] + pub struct IO_STATUS_BLOCK { + pub Status: NTSTATUS, + pub Information: usize, + } + + #[repr(C)] + #[derive(Default, Clone, Copy)] + pub struct FILE_BASIC_INFORMATION { + pub CreationTime: i64, + pub LastAccessTime: i64, + pub LastWriteTime: i64, + pub ChangeTime: i64, + pub FileAttributes: u32, + pub Reserved: u32, + } + + #[repr(C)] + #[derive(Default, Clone, Copy)] + pub struct FILE_STANDARD_INFORMATION { + pub AllocationSize: i64, + pub EndOfFile: i64, + pub NumberOfLinks: u32, + pub DeletePending: u8, + pub Directory: u8, + pub Reserved: [u8; 2], + } + + /// One entry of an `NtQueryDirectoryFile` reply. Entries are never read + /// through this type: the reply is parsed as bytes at the offsets + /// `offset_of!` gives for these fields, so a misaligned or truncated + /// reply cannot make a reference to it. + #[allow(dead_code)] + #[repr(C)] + pub struct FILE_DIRECTORY_INFORMATION { + pub NextEntryOffset: u32, + pub FileIndex: u32, + pub CreationTime: i64, + pub LastAccessTime: i64, + pub LastWriteTime: i64, + pub ChangeTime: i64, + pub EndOfFile: i64, + pub AllocationSize: i64, + pub FileAttributes: u32, + pub FileNameLength: u32, + pub FileName: [u16; 1], + } + + /// The header of a rename request. It is written field by field through + /// a raw pointer into an 8-byte-aligned buffer that also holds the name, + /// never built as a value. + #[allow(dead_code)] + #[repr(C)] + pub struct FILE_RENAME_INFORMATION_EX { + pub Flags: u32, + pub RootDirectory: HANDLE, + pub FileNameLength: u32, + pub FileName: [u16; 1], + } + + #[repr(C)] + pub struct FILE_DISPOSITION_INFORMATION { + pub DeleteFile: u8, + } + + #[link(name = "ntdll")] + unsafe extern "system" { + pub fn NtCreateFile( + FileHandle: *mut HANDLE, + DesiredAccess: u32, + ObjectAttributes: *mut OBJECT_ATTRIBUTES, + IoStatusBlock: *mut IO_STATUS_BLOCK, + AllocationSize: *mut i64, + FileAttributes: u32, + ShareAccess: u32, + CreateDisposition: u32, + CreateOptions: u32, + EaBuffer: *mut c_void, + EaLength: u32, + ) -> NTSTATUS; + + pub fn NtClose(Handle: HANDLE) -> NTSTATUS; + + pub fn NtQueryInformationFile( + FileHandle: HANDLE, + IoStatusBlock: *mut IO_STATUS_BLOCK, + FileInformation: *mut c_void, + Length: u32, + FileInformationClass: u32, + ) -> NTSTATUS; + + pub fn NtSetInformationFile( + FileHandle: HANDLE, + IoStatusBlock: *mut IO_STATUS_BLOCK, + FileInformation: *mut c_void, + Length: u32, + FileInformationClass: u32, + ) -> NTSTATUS; + + pub fn NtQueryDirectoryFile( + FileHandle: HANDLE, + Event: HANDLE, + ApcRoutine: *mut c_void, + ApcContext: *mut c_void, + IoStatusBlock: *mut IO_STATUS_BLOCK, + FileInformation: *mut c_void, + Length: u32, + FileInformationClass: u32, + ReturnSingleEntry: u8, + FileName: *mut UNICODE_STRING, + RestartScan: u8, + ) -> NTSTATUS; + + pub fn NtWriteFile( + FileHandle: HANDLE, + Event: HANDLE, + ApcRoutine: *mut c_void, + ApcContext: *mut c_void, + IoStatusBlock: *mut IO_STATUS_BLOCK, + Buffer: *const c_void, + Length: u32, + ByteOffset: *mut i64, + Key: *mut u32, + ) -> NTSTATUS; + + pub fn NtFlushBuffersFile( + FileHandle: HANDLE, + IoStatusBlock: *mut IO_STATUS_BLOCK, + ) -> NTSTATUS; + } + + #[link(name = "kernel32")] + unsafe extern "system" { + pub fn GetFinalPathNameByHandleW( + hFile: HANDLE, + lpszFilePath: *mut u16, + cchFilePath: u32, + dwFlags: u32, + ) -> u32; + + pub fn LocalFree(hMem: *mut c_void) -> *mut c_void; + } + + #[link(name = "advapi32")] + unsafe extern "system" { + pub fn GetSecurityInfo( + handle: HANDLE, + ObjectType: u32, + SecurityInfo: u32, + ppsidOwner: *mut *mut c_void, + ppsidGroup: *mut *mut c_void, + ppDacl: *mut *mut c_void, + ppSacl: *mut *mut c_void, + ppSecurityDescriptor: *mut *mut c_void, + ) -> u32; + } +} + +/// Every sharing mode: what reads, stats and listings open with. +#[cfg(windows)] +const SHARE_ALL: u32 = ffi::FILE_SHARE_READ | ffi::FILE_SHARE_WRITE | ffi::FILE_SHARE_DELETE; + +/// The sharing mode of the directories a write holds while it runs. Without +/// `FILE_SHARE_DELETE` nobody else can open them for `DELETE`, which a +/// rename or a delete of the directory needs, so none of them can be moved +/// out of the root before the write's rename returns. +#[cfg(windows)] +const SHARE_NO_DELETE: u32 = ffi::FILE_SHARE_READ | ffi::FILE_SHARE_WRITE; + +/// The access a directory on a walked path is opened with. `FILE_TRAVERSE` +/// is there for sharing, not for the walk itself: the kernel records a +/// handle's sharing mode only when the handle has read, write, execute +/// (`FILE_TRAVERSE` is the execute bit) or delete access. A held directory +/// opened with attribute access alone would not stop anyone renaming it. +#[cfg(windows)] +const DIR_WALK: u32 = ffi::FILE_READ_ATTRIBUTES | ffi::FILE_TRAVERSE | ffi::SYNCHRONIZE; + +/// A handle this module opened and closes. The field is private and the +/// only constructor is [`nt_open_relative`], so no code can hand an +/// arbitrary pointer to `NtClose`. +#[cfg(windows)] +struct OwnedHandle(ffi::HANDLE); + +#[cfg(windows)] +impl OwnedHandle { + fn raw(&self) -> ffi::HANDLE { + self.0 + } + + fn into_file(self) -> std::fs::File { + use std::os::windows::io::FromRawHandle; + let raw = self.0; + std::mem::forget(self); + // SAFETY: `raw` is an open handle this value owned; forgetting the + // value hands that ownership to the File exactly once. + unsafe { std::fs::File::from_raw_handle(raw) } + } +} + +#[cfg(windows)] +impl Drop for OwnedHandle { + fn drop(&mut self) { + // SAFETY: the handle came from a successful NtCreateFile and is + // closed exactly once, here. + unsafe { ffi::NtClose(self.0) }; + } +} + +/// A security descriptor allocated by `GetSecurityInfo`, freed on drop. +#[cfg(windows)] +struct FileDacl { + sd: *mut std::ffi::c_void, +} + +#[cfg(windows)] +impl Drop for FileDacl { + fn drop(&mut self) { + if !self.sd.is_null() { + // SAFETY: `sd` came from GetSecurityInfo, whose descriptors are + // documented to be released with LocalFree, and is freed once. + unsafe { ffi::LocalFree(self.sd) }; + } + } +} + +#[cfg(windows)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OpenError { + NotFound, + ReparsePoint, + AccessDenied, + NotADirectory, + IsADirectory, + Collision, + Other(ffi::NTSTATUS), +} + +#[cfg(windows)] +impl OpenError { + fn status(self) -> ffi::NTSTATUS { + match self { + Self::NotFound => ffi::STATUS_OBJECT_NAME_NOT_FOUND, + // Not an NT status of its own: the open succeeded and the entry + // was refused for what it is. + Self::ReparsePoint => ffi::STATUS_ACCESS_DENIED, + Self::AccessDenied => ffi::STATUS_ACCESS_DENIED, + Self::NotADirectory => ffi::STATUS_NOT_A_DIRECTORY, + Self::IsADirectory => ffi::STATUS_FILE_IS_A_DIRECTORY, + Self::Collision => ffi::STATUS_OBJECT_NAME_COLLISION, + Self::Other(status) => status, + } + } +} + +/// How [`nt_open_relative`] opens a name. +#[cfg(windows)] +#[derive(Clone, Copy)] +struct Open { + access: u32, + share: u32, + disposition: u32, + options: u32, + security_descriptor: *mut std::ffi::c_void, +} + +#[cfg(windows)] +impl Open { + /// Opens an existing entry of any type, sharing everything. + fn existing(access: u32) -> Self { + Self { + access, + share: SHARE_ALL, + disposition: ffi::FILE_OPEN, + options: 0, + security_descriptor: std::ptr::null_mut(), + } + } + + fn directory(mut self) -> Self { + self.options |= ffi::FILE_DIRECTORY_FILE; + self + } + + fn non_directory(mut self) -> Self { + self.options |= ffi::FILE_NON_DIRECTORY_FILE; + self + } + + fn share(mut self, share: u32) -> Self { + self.share = share; + self + } +} + +/// Opens `name`, one component, relative to the directory `root` (or, with +/// a null `root`, the NT path `name`). An empty `name` opens `root` itself +/// again, with the access `open` asks for. +/// +/// No reparse point is ever followed (`FILE_OPEN_REPARSE_POINT`), and one +/// that was opened is refused: the handle is asked for its attributes, and +/// an entry that is a reparse point, or whose attributes cannot be read, is +/// closed and refused. `FILE_READ_ATTRIBUTES` is added to every request so +/// that check always has the access it needs. +#[cfg(windows)] +fn nt_open_relative(root: ffi::HANDLE, name: &str, open: Open) -> Result { + let mut wide: Vec = name.encode_utf16().collect(); + // A UNICODE_STRING counts bytes in a u16; a longer name would be cut to + // a prefix, which can name a different entry. + let length = + u16::try_from(wide.len() * 2).map_err(|_| OpenError::Other(ffi::STATUS_NAME_TOO_LONG))?; + let maximum = length + .checked_add(2) + .ok_or(OpenError::Other(ffi::STATUS_NAME_TOO_LONG))?; + wide.push(0); + let mut unicode_name = ffi::UNICODE_STRING { + Length: length, + MaximumLength: maximum, + Buffer: wide.as_mut_ptr(), + }; + let mut obj_attr = ffi::OBJECT_ATTRIBUTES { + Length: std::mem::size_of::() as u32, + RootDirectory: root, + ObjectName: &mut unicode_name, + Attributes: ffi::OBJ_CASE_INSENSITIVE, + SecurityDescriptor: open.security_descriptor, + SecurityQualityOfService: std::ptr::null_mut(), + }; + let mut handle = std::ptr::null_mut(); + let mut io_status = ffi::IO_STATUS_BLOCK { + Status: 0, + Information: 0, + }; + // SAFETY: every pointer refers to a live local that outlives the call: + // the name buffer `wide`, the `unicode_name` describing it, the object + // attributes, the status block, and `handle`, which receives the new + // handle on success. + let status = unsafe { + ffi::NtCreateFile( + &mut handle, + open.access | ffi::FILE_READ_ATTRIBUTES, + &mut obj_attr, + &mut io_status, + std::ptr::null_mut(), + ffi::FILE_ATTRIBUTE_NORMAL, + open.share, + open.disposition, + open.options | ffi::FILE_SYNCHRONOUS_IO_NONALERT | ffi::FILE_OPEN_REPARSE_POINT, + std::ptr::null_mut(), + 0, + ) + }; + if status < 0 { + return Err(match status { + ffi::STATUS_OBJECT_NAME_NOT_FOUND | ffi::STATUS_OBJECT_PATH_NOT_FOUND => { + OpenError::NotFound + } + ffi::STATUS_ACCESS_DENIED => OpenError::AccessDenied, + ffi::STATUS_NOT_A_DIRECTORY => OpenError::NotADirectory, + ffi::STATUS_FILE_IS_A_DIRECTORY => OpenError::IsADirectory, + ffi::STATUS_OBJECT_NAME_COLLISION => OpenError::Collision, + _ => OpenError::Other(status), + }); + } + let handle = OwnedHandle(handle); + // Fail closed: an entry whose attributes cannot be read is not known + // not to be a reparse point. + let basic = query_file_basic(handle.raw()).map_err(OpenError::Other)?; + if basic.FileAttributes & ffi::FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(OpenError::ReparsePoint); + } + Ok(handle) +} + +/// Opens the file or directory behind `handle` again, with the access +/// `open` asks for. An NT open of an empty name relative to a handle opens +/// that handle's own file object (this is how `ReOpenFile` works), so the +/// new handle is for the object already checked, not for whatever a path +/// names now. +#[cfg(windows)] +fn nt_reopen(handle: &OwnedHandle, open: Open) -> Result { + nt_open_relative(handle.raw(), "", open) +} + +/// Opens each of `components` beneath `start` as a directory, in order, and +/// returns every handle (the last is the deepest). +#[cfg(windows)] +fn walk_dirs( + start: &OwnedHandle, + components: &[&str], + open: Open, +) -> Result, OpenError> { + let mut held: Vec = Vec::with_capacity(components.len()); + for comp in components { + let current = held.last().unwrap_or(start); + let next = nt_open_relative(current.raw(), comp, open.directory())?; + held.push(next); + } + Ok(held) +} + +#[cfg(windows)] +fn query_file_basic(handle: ffi::HANDLE) -> Result { + let mut io_status = ffi::IO_STATUS_BLOCK { + Status: 0, + Information: 0, + }; + let mut info = ffi::FILE_BASIC_INFORMATION::default(); + // SAFETY: the output buffer is `info`, and the length passed is its size. + let status = unsafe { + ffi::NtQueryInformationFile( + handle, + &mut io_status, + (&mut info as *mut ffi::FILE_BASIC_INFORMATION).cast(), + std::mem::size_of::() as u32, + ffi::FileBasicInformation, + ) + }; + if status < 0 { + return Err(status); + } + Ok(info) +} + +#[cfg(windows)] +fn query_file_standard( + handle: ffi::HANDLE, +) -> Result { + let mut io_status = ffi::IO_STATUS_BLOCK { + Status: 0, + Information: 0, + }; + let mut info = ffi::FILE_STANDARD_INFORMATION::default(); + // SAFETY: the output buffer is `info`, and the length passed is its size. + let status = unsafe { + ffi::NtQueryInformationFile( + handle, + &mut io_status, + (&mut info as *mut ffi::FILE_STANDARD_INFORMATION).cast(), + std::mem::size_of::() as u32, + ffi::FileStandardInformation, + ) + }; + if status < 0 { + return Err(status); + } + Ok(info) +} + +/// A denial with code `IO` for an NT call on `path` that failed with +/// `status`, naming both. +#[cfg(windows)] +fn nt_io(path: &Path, what: &str, status: ffi::NTSTATUS) -> Denial { + Denial::new( + codes::IO, + format!( + "{}: {what} failed (NT status 0x{:08x})", + path.display(), + status as u32 + ), + ) +} + +#[cfg(windows)] +fn get_volume_guid_path(handle: ffi::HANDLE) -> Result { + let mut buf = vec![0u16; 1024]; + // SAFETY: `buf` is writable for the `buf.len()` UTF-16 units passed as + // the capacity. + let len = unsafe { + ffi::GetFinalPathNameByHandleW( + handle, + buf.as_mut_ptr(), + buf.len() as u32, + ffi::VOLUME_NAME_GUID | ffi::FILE_NAME_NORMALIZED, + ) + }; + if len == 0 { + return Err(Denial::denied("failed to get volume GUID path for handle")); + } + if len as usize > buf.len() { + buf.resize(len as usize + 1, 0); + // SAFETY: `buf` was grown to the length the first call asked for and + // is writable for the `buf.len()` units passed as the capacity. + let len2 = unsafe { + ffi::GetFinalPathNameByHandleW( + handle, + buf.as_mut_ptr(), + buf.len() as u32, + ffi::VOLUME_NAME_GUID | ffi::FILE_NAME_NORMALIZED, + ) + }; + if len2 == 0 || len2 as usize > buf.len() { + return Err(Denial::denied("failed to get volume GUID path for handle")); + } + buf.truncate(len2 as usize); + } else { + buf.truncate(len as usize); + } + String::from_utf16(&buf).map_err(|_| Denial::denied("volume GUID path is not UTF-16")) +} + +/// Whether `parent_guid` is the directory that holds the file root whose +/// volume-GUID path is `file_root_guid`, compared component by component. +#[cfg(windows)] +fn guid_is_parent_of(parent_guid: &str, file_root_guid: &str) -> bool { + let parent: Vec<&str> = parent_guid.split('\\').filter(|s| !s.is_empty()).collect(); + let root: Vec<&str> = file_root_guid + .split('\\') + .filter(|s| !s.is_empty()) + .collect(); + root.split_last() + .is_some_and(|(_, head)| head == parent.as_slice()) +} + +/// A manifest root after [`walk_from_volume_root`] opened it from its +/// volume's root directory, refusing reparse points, with its volume-GUID +/// path for checking where later handles really are. +#[cfg(windows)] +struct VerifiedRoot { + manifest: String, + guid_path: String, + is_directory: bool, + /// The root itself: [`walk_from_volume_root`] opens a directory with + /// [`DIR_WALK`] and a file with attribute access only. Operations that need more open it again with + /// [`nt_reopen`]. + handle: OwnedHandle, + /// The directories above the root, from the volume root down; the last + /// is the root's parent. Empty for a volume root. + ancestors: Vec, +} + +/// A directory and every ancestor held against rename or replacement while a +/// reader such as git opens the directory by path. Omitting delete sharing +/// prevents another process from moving any of those directories. +#[cfg(windows)] +pub(crate) struct PinnedDirectory(VerifiedRoot); + +#[cfg(windows)] +impl PinnedDirectory { + pub(crate) fn path(&self) -> &Path { + Path::new(&self.0.manifest) + } + + pub(crate) fn same_identity(&self, other: &Self) -> bool { + guid_path_inside_component_wise(&self.0.guid_path, &other.0.guid_path, false) + } +} + +#[cfg(windows)] +impl std::ops::Deref for PinnedDirectory { + type Target = Path; + + fn deref(&self) -> &Path { + self.path() + } +} + +/// Uses the filesystem builtin's walk, which refuses reparse points, retaining +/// every directory handle until the returned guard is dropped. The volume-GUID identity is +/// obtained from the opened directory, never from canonicalized input text. +#[cfg(windows)] +pub(crate) fn pin_directory(path: &str) -> Result { + let root = walk_from_volume_root(path, SHARE_NO_DELETE)?; + if !root.is_directory { + return Err(Denial::denied("the pinned path is not a directory")); + } + Ok(PinnedDirectory(root)) +} + +/// The denial for a failed open of `comp` while walking to `manifest_root`. +#[cfg(windows)] +fn root_walk_denial(e: OpenError, comp: &str, manifest_root: &str) -> Denial { + match e { + OpenError::ReparsePoint => Denial::denied(format!( + "{comp} in root {manifest_root} is a reparse point; fs refuses every reparse point" + )), + OpenError::NotFound => Denial::new( + codes::NOT_FOUND, + format!("root {manifest_root} does not exist"), + ), + OpenError::AccessDenied => Denial::denied(format!( + "access to {comp} in root {manifest_root} is denied" + )), + OpenError::NotADirectory => { + Denial::denied(format!("{comp} in root {manifest_root} is not a directory")) + } + other => Denial::new( + codes::IO, + format!( + "opening {comp} in root {manifest_root} failed (NT status 0x{:08x})", + other.status() as u32 + ), + ), + } +} + +/// Walks `manifest_root` from its volume's root directory, one component at +/// a time, refusing a reparse point anywhere. Every handle is opened with +/// `share`. +#[cfg(windows)] +fn walk_from_volume_root(manifest_root: &str, share: u32) -> Result { + validate_raw_spelling(manifest_root)?; + let drive = &manifest_root[..3]; // e.g. "C:\" + let volume = nt_open_relative( + std::ptr::null_mut(), + &format!(r"\??\{drive}"), + Open::existing(DIR_WALK).directory().share(share), + ) + .map_err(|e| root_walk_denial(e, drive, manifest_root))?; + + let rest = &manifest_root[3..]; + if rest.is_empty() { + let guid_path = get_volume_guid_path(volume.raw())?; + return Ok(VerifiedRoot { + manifest: manifest_root.to_owned(), + guid_path, + is_directory: true, + handle: volume, + ancestors: Vec::new(), + }); + } + + let components: Vec<&str> = rest.split('\\').collect(); + let Some((last, middle)) = components.split_last() else { + return Err(outside(Path::new(manifest_root))); + }; + let mut ancestors = vec![volume]; + for comp in middle { + let current = ancestors.last().expect("the volume root is held"); + let next = nt_open_relative( + current.raw(), + comp, + Open::existing(DIR_WALK).directory().share(share), + ) + .map_err(|e| root_walk_denial(e, comp, manifest_root))?; + ancestors.push(next); + } + let parent = ancestors.last().expect("the volume root is held"); + // The root's type is not known before it is open, so it is opened first + // with attribute access, which any type grants. A directory is then + // opened again with FILE_TRAVERSE, which a held directory needs to take + // part in sharing checks; a file is not, because on a file that bit is + // the execute right and may not be granted. A file root keeps the + // first handle, which shares everything: it is the very file a write + // to the root replaces, so it must never stand in the way of that. + let first = nt_open_relative(parent.raw(), last, Open::existing(ffi::SYNCHRONIZE)) + .map_err(|e| root_walk_denial(e, last, manifest_root))?; + let std_info = + query_file_standard(first.raw()).map_err(|s| nt_io(Path::new(manifest_root), "stat", s))?; + let is_directory = std_info.Directory != 0; + let handle = if is_directory { + nt_reopen(&first, Open::existing(DIR_WALK).directory().share(share)) + .map_err(|e| root_walk_denial(e, last, manifest_root))? + } else { + first + }; + let guid_path = get_volume_guid_path(handle.raw())?; + Ok(VerifiedRoot { + manifest: manifest_root.to_owned(), + guid_path, + is_directory, + handle, + ancestors, + }) +} + +#[cfg(windows)] +fn filetime_to_mtime_ms(ft: i64) -> i64 { + const UNIX_EPOCH_DIFF_100NS: i64 = 116_444_736_000_000_000; + if ft < UNIX_EPOCH_DIFF_100NS { + 0 + } else { + (ft - UNIX_EPOCH_DIFF_100NS) / 10_000 + } +} + +/// Whether `path` is `root` or lies beneath it, by spelling. A volume root +/// (`X:\`, the only root spelling that ends in a separator) covers every +/// path on its drive. +fn path_under(path: &str, root: &str) -> bool { + path == root + || path + .strip_prefix(root) + .is_some_and(|rest| root.ends_with('\\') || rest.starts_with('\\')) +} + +/// The components of `path` below `root`, which it must lie under +/// ([`path_under`]); empty when it is the root. +fn components_below<'a>(path: &'a str, root: &str) -> Vec<&'a str> { + let rest = &path[root.len()..]; + let rest = rest.strip_prefix('\\').unwrap_or(rest); + if rest.is_empty() { + Vec::new() + } else { + rest.split('\\').collect() + } +} + +/// How bad a root's failure is, for choosing which one to report: a +/// failure that may pass beats a missing root, which beats a refusal. +#[cfg(windows)] +fn failure_rank(d: &Denial) -> u8 { + if d.code == codes::IO { + 2 + } else if d.code == codes::NOT_FOUND { + 1 + } else { + 0 + } +} + +/// Resolve the profile of the process token, not a caller-controlled +/// environment variable. A missing or unreadable profile grants no root. +#[cfg(windows)] +fn process_profile_directory() -> Result { + basal_launch::process_profile_directory().map_err(|error| Denial::new(codes::IO, error)) +} + +#[cfg(windows)] +fn expand_root(root: &str) -> Result, Denial> { + let suffix = if root == "~" { + "" + } else if let Some(suffix) = root.strip_prefix("~/") { + suffix + } else { + return Ok(std::borrow::Cow::Borrowed(root)); + }; + let mut profile = process_profile_directory()?; + validate_raw_spelling(&profile)?; + if !suffix.is_empty() { + if !profile.ends_with('\\') { + profile.push('\\'); + } + // Preserve components, including refused spellings, until validation. + profile.push_str(&suffix.replace('/', "\\")); + } + validate_raw_spelling(&profile)?; + Ok(std::borrow::Cow::Owned(profile)) +} + +/// Finds a manifest root that `path` lies under and walks it +/// ([`walk_from_volume_root`]). A file root grants only its own path. When +/// every root `path` lies under fails to walk, the most telling failure is +/// returned: an `IO` error (which may succeed if tried again), then +/// `NOT_FOUND` (the root is gone), then a refusal. `remove_temp` relies on +/// that order to keep a record after a transient failure and to drop one +/// whose directory no longer exists. +#[cfg(windows)] +fn select_root(path: &str, roots: &[String], share: u32) -> Result { + validate_raw_spelling(path)?; + let mut failure: Option = None; + for r in roots { + let r = expand_root(r)?; + if validate_raw_spelling(&r).is_err() || !path_under(path, &r) { + continue; + } + match walk_from_volume_root(&r, share) { + Ok(vr) if vr.is_directory || path == vr.manifest => return Ok(vr), + Ok(_) => {} + Err(d) => { + if failure + .as_ref() + .is_none_or(|f| failure_rank(&d) > failure_rank(f)) + { + failure = Some(d); + } + } + } + } + Err(failure.unwrap_or_else(|| outside(Path::new(path)))) +} + +/// The denial for a failed open of a directory between a root and the +/// entry `path` names: a reparse point is refused as one, an unexpected NT +/// failure is `IO`, and a missing, unreadable or non-directory component +/// is "outside the manifest's roots or missing", the answer the Unix +/// implementation gives when resolving such a path fails. +#[cfg(windows)] +fn intermediate_denial(e: OpenError, path: &Path) -> Denial { + match e { + OpenError::ReparsePoint => Denial::denied(format!( + "{} contains a reparse point; fs refuses every reparse point", + path.display() + )), + OpenError::Other(status) => nt_io(path, "opening a directory", status), + _ => outside(path), + } +} + +#[cfg(windows)] +pub fn resolve(path: &str, roots: &[String], purpose: Purpose) -> Result { + validate_raw_spelling(path)?; + let vr = select_root(path, roots, SHARE_ALL)?; + let p = Path::new(path); + + if purpose == Purpose::Read { + if path == vr.manifest { + return Ok(Target::Existing(PathBuf::from(path))); + } + let components = components_below(path, &vr.manifest); + let Some((last, middle)) = components.split_last() else { + return Err(outside(p)); + }; + let dirs = walk_dirs(&vr.handle, middle, Open::existing(ffi::SYNCHRONIZE)) + .map_err(|e| intermediate_denial(e, p))?; + let parent = dirs.last().unwrap_or(&vr.handle); + match nt_open_relative(parent.raw(), last, Open::existing(ffi::SYNCHRONIZE)) { + Ok(leaf) => { + let guid = get_volume_guid_path(leaf.raw())?; + return if guid_path_inside_component_wise(&guid, &vr.guid_path, vr.is_directory) { + Ok(Target::Existing(PathBuf::from(path))) + } else { + Err(outside(p)) + }; + } + // A missing entry: answered below as the name in its parent + // directory, which must lie under the root. + Err(OpenError::NotFound) => {} + Err(OpenError::ReparsePoint) => { + return Err(Denial::denied(format!( + "{path} contains a reparse point; fs refuses every reparse point" + ))); + } + Err(_) => return Err(outside(p)), + } + } + + let parent = p.parent().ok_or_else(|| outside(p))?; + let name = p.file_name().ok_or_else(|| outside(p))?; + + if vr.is_directory { + // The directory root itself is not an entry a write can replace. + let parent_str = parent.to_str().ok_or_else(|| outside(p))?; + if path == vr.manifest || !path_under(parent_str, &vr.manifest) { + return Err(outside(p)); + } + } else if path != vr.manifest { + return Err(outside(p)); + } + + Ok(Target::Entry { + parent: parent.to_path_buf(), + name: name.to_os_string(), + }) +} + +/// The denial for a failed open of the entry `path` itself. +#[cfg(windows)] +fn leaf_denial(e: OpenError, path: &Path) -> Denial { + match e { + OpenError::ReparsePoint => Denial::denied(format!( + "{} became a symlink while it was being opened", + path.display() + )), + OpenError::NotFound => io_denial(path, &std::io::Error::from(std::io::ErrorKind::NotFound)), + OpenError::NotADirectory => { + Denial::new(codes::IO, format!("{} is not a directory", path.display())) + } + OpenError::AccessDenied => { + Denial::denied(format!("access to {} is denied", path.display())) + } + other => nt_io(path, "opening", other.status()), + } +} + +#[cfg(windows)] +pub fn open_checked( + resolved: &Path, + roots: &[String], + directory: bool, +) -> Result { + let path_str = resolved.to_str().ok_or_else(|| outside(resolved))?; + validate_raw_spelling(path_str)?; + let vr = select_root(path_str, roots, SHARE_ALL)?; + let leaf_open = if directory { + Open::existing(ffi::FILE_GENERIC_READ | ffi::FILE_TRAVERSE).directory() + } else { + Open::existing(ffi::FILE_GENERIC_READ) + }; + + let opened = if path_str == vr.manifest { + if directory && !vr.is_directory { + return Err(outside(resolved)); + } + // The walk opened the root with attribute access only; reading or + // listing it needs a handle with the access for that. + nt_reopen(&vr.handle, leaf_open).map_err(|e| leaf_denial(e, resolved))? + } else { + let components = components_below(path_str, &vr.manifest); + let Some((last, middle)) = components.split_last() else { + return Err(outside(resolved)); + }; + let dirs = walk_dirs(&vr.handle, middle, Open::existing(DIR_WALK)) + .map_err(|e| intermediate_denial(e, resolved))?; + let parent = dirs.last().unwrap_or(&vr.handle); + nt_open_relative(parent.raw(), last, leaf_open).map_err(|e| leaf_denial(e, resolved))? + }; + + let guid = get_volume_guid_path(opened.raw())?; + if !guid_path_inside_component_wise(&guid, &vr.guid_path, vr.is_directory) { + return Err(outside(resolved)); + } + Ok(opened.into_file()) +} + +/// Test-only hooks. They are per thread, so a test that sets one affects +/// only the calls it makes itself, never a test running beside it. +#[cfg(all(test, windows))] +mod hooks { + use std::cell::{Cell, RefCell}; + + thread_local! { + /// Runs in `read` between resolving the path and opening it. + pub(super) static BEFORE_READ_OPEN: RefCell>> = + const { RefCell::new(None) }; + /// Makes `write_call` treat the rename as refused by the volume. + pub(super) static REFUSE_POSIX_RENAME: Cell = const { Cell::new(false) }; + /// Makes `write_call` treat flushing its temporary file as failed. + pub(super) static FAIL_TEMP_FLUSH: Cell = const { Cell::new(false) }; + } +} + +#[cfg(windows)] +pub fn read(path: &str, roots: &[String], max_bytes: u64) -> Result { + let max_bytes = max_bytes.min(MAX_READ_BYTES); + let target = resolve(path, roots, Purpose::Read)?; + let real = match target { + Target::Existing(real) => real, + Target::Entry { parent, name } => { + return Err(Denial::new( + codes::NOT_FOUND, + format!("{} does not exist", parent.join(name).display()), + )); + } + }; + + #[cfg(test)] + hooks::BEFORE_READ_OPEN.with(|hook| { + if let Some(hook) = hook.borrow().as_ref() { + hook(); + } + }); + + let file = open_checked(&real, roots, false)?; + let meta = file.metadata().map_err(|e| io_denial(&real, &e))?; + if !meta.is_file() { + return Err(Denial::invalid(format!( + "{} is not a regular file", + real.display() + ))); + } + + let too_large = || { + Denial::new( + codes::TOO_LARGE, + format!("{} is larger than {max_bytes} bytes", real.display()), + ) + }; + if meta.len() > max_bytes { + return Err(too_large()); + } + + use std::io::Read; + let mut bytes = Vec::new(); + file.take(max_bytes + 1) + .read_to_end(&mut bytes) + .map_err(|e| io_denial(&real, &e))?; + // The file can grow between the size check and the read. + if bytes.len() as u64 > max_bytes { + return Err(too_large()); + } + + let text = String::from_utf8(bytes).map_err(|_| { + Denial::new( + codes::NOT_UTF8, + format!("{} is not UTF-8 text", real.display()), + ) + })?; + + Ok(json!({ "text": text })) +} + +/// `fs.stat`'s answer for the open entry behind `handle`. +#[cfg(windows)] +fn stat_value(handle: &OwnedHandle, path: &Path) -> Result { + let basic = query_file_basic(handle.raw()).map_err(|s| nt_io(path, "stat", s))?; + let std_info = query_file_standard(handle.raw()).map_err(|s| nt_io(path, "stat", s))?; + Ok(json!({ + "exists": true, + "kind": if std_info.Directory != 0 { "dir" } else { "file" }, + "size": std_info.EndOfFile, + "mtime_ms": filetime_to_mtime_ms(basic.LastWriteTime), + })) +} + +#[cfg(windows)] +pub fn stat(path: &str, roots: &[String]) -> Result { + validate_raw_spelling(path)?; + let vr = select_root(path, roots, SHARE_ALL)?; + let p = Path::new(path); + + if path == vr.manifest { + return stat_value(&vr.handle, p); + } + + let components = components_below(path, &vr.manifest); + let Some((last, middle)) = components.split_last() else { + return Err(outside(p)); + }; + let dirs = walk_dirs(&vr.handle, middle, Open::existing(ffi::SYNCHRONIZE)) + .map_err(|e| intermediate_denial(e, p))?; + let parent = dirs.last().unwrap_or(&vr.handle); + match nt_open_relative(parent.raw(), last, Open::existing(ffi::SYNCHRONIZE)) { + Ok(leaf) => { + let guid = get_volume_guid_path(leaf.raw())?; + if !guid_path_inside_component_wise(&guid, &vr.guid_path, vr.is_directory) { + return Err(outside(p)); + } + stat_value(&leaf, p) + } + Err(OpenError::NotFound) => Ok(json!({ "exists": false })), + Err(OpenError::ReparsePoint) => Err(Denial::denied(format!( + "{path} is a reparse point; fs refuses every reparse point" + ))), + Err(OpenError::AccessDenied) => Err(Denial::denied(format!("access to {path} is denied"))), + Err(e) => Err(nt_io(p, "opening", e.status())), + } +} + +/// Byte offsets of the `FILE_DIRECTORY_INFORMATION` fields a scan reads. +#[cfg(windows)] +const DIR_NEXT_OFFSET: usize = + std::mem::offset_of!(ffi::FILE_DIRECTORY_INFORMATION, NextEntryOffset); +#[cfg(windows)] +const DIR_ATTRIBUTES: usize = std::mem::offset_of!(ffi::FILE_DIRECTORY_INFORMATION, FileAttributes); +#[cfg(windows)] +const DIR_NAME_LENGTH: usize = + std::mem::offset_of!(ffi::FILE_DIRECTORY_INFORMATION, FileNameLength); +#[cfg(windows)] +const DIR_NAME: usize = std::mem::offset_of!(ffi::FILE_DIRECTORY_INFORMATION, FileName); + +#[cfg(windows)] +fn u32_at(bytes: &[u8], at: usize) -> u32 { + u32::from_le_bytes([bytes[at], bytes[at + 1], bytes[at + 2], bytes[at + 3]]) +} + +/// Hands each entry of the directory behind `handle` (not `.` or `..`) to +/// `visit` as its UTF-16 name and attributes, until `visit` answers false. +/// The handle needs `FILE_LIST_DIRECTORY`. Any failed query is an error, +/// the first one included: an unreadable directory is never reported as +/// an empty one. The reply is parsed as bytes and every entry is checked +/// against the length the kernel reported, so a malformed reply is an +/// error rather than a read past it. +#[cfg(windows)] +fn scan_directory( + handle: ffi::HANDLE, + mut visit: impl FnMut(&[u16], u32) -> bool, +) -> Result<(), ffi::NTSTATUS> { + // u64 storage: the kernel requires an 8-byte-aligned buffer. + let mut buffer = vec![0u64; 8 * 1024]; + let buffer_bytes = buffer.len() * std::mem::size_of::(); + let mut restart = 1u8; + loop { + let mut io_status = ffi::IO_STATUS_BLOCK { + Status: 0, + Information: 0, + }; + // SAFETY: the buffer is `buffer_bytes` long and outlives the call. + let status = unsafe { + ffi::NtQueryDirectoryFile( + handle, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut io_status, + buffer.as_mut_ptr().cast(), + buffer_bytes as u32, + ffi::FileDirectoryInformation, + 0, + std::ptr::null_mut(), + restart, + ) + }; + restart = 0; + if status == ffi::STATUS_NO_MORE_FILES { + return Ok(()); + } + if status < 0 { + return Err(status); + } + let filled = io_status.Information; + if filled == 0 { + return Ok(()); + } + if filled > buffer_bytes { + return Err(ffi::STATUS_FILE_CORRUPT_ERROR); + } + // SAFETY: the first `filled` bytes of the buffer are initialised + // (it was zeroed) and lie within it. + let bytes = unsafe { std::slice::from_raw_parts(buffer.as_ptr().cast::(), filled) }; + let mut offset = 0usize; + loop { + let entry = &bytes[offset..]; + if entry.len() < DIR_NAME { + return Err(ffi::STATUS_FILE_CORRUPT_ERROR); + } + let next = u32_at(entry, DIR_NEXT_OFFSET) as usize; + let attributes = u32_at(entry, DIR_ATTRIBUTES); + let name_bytes = u32_at(entry, DIR_NAME_LENGTH) as usize; + let Some(raw_name) = entry.get(DIR_NAME..DIR_NAME + name_bytes) else { + return Err(ffi::STATUS_FILE_CORRUPT_ERROR); + }; + if !name_bytes.is_multiple_of(2) { + return Err(ffi::STATUS_FILE_CORRUPT_ERROR); + } + let name: Vec = raw_name + .as_chunks::<2>() + .0 + .iter() + .map(|unit| u16::from_le_bytes(*unit)) + .collect(); + let dot = u16::from(b'.'); + let is_dot = name == [dot] || name == [dot, dot]; + if !is_dot && !visit(&name, attributes) { + return Ok(()); + } + if next == 0 { + break; + } + offset = match offset.checked_add(next) { + Some(o) if o < filled => o, + _ => return Err(ffi::STATUS_FILE_CORRUPT_ERROR), + }; + } + } +} + +#[cfg(windows)] +pub fn list(path: &str, roots: &[String]) -> Result { + use std::os::windows::io::AsRawHandle; + let real = match resolve(path, roots, Purpose::Read)? { + Target::Existing(real) => real, + Target::Entry { parent, name } => { + return Err(Denial::new( + codes::NOT_FOUND, + format!("{} does not exist", parent.join(name).display()), + )); + } + }; + let dir = open_checked(&real, roots, true)?; + + let mut names: Vec<(Vec, u32)> = Vec::new(); + scan_directory(dir.as_raw_handle(), |name, attributes| { + names.push((name.to_vec(), attributes)); + names.len() <= MAX_LIST_ENTRIES + }) + .map_err(|s| nt_io(&real, "listing", s))?; + if names.len() > MAX_LIST_ENTRIES { + return Err(Denial::new( + codes::TOO_LARGE, + format!( + "{} has more than {MAX_LIST_ENTRIES} entries", + real.display() + ), + )); + } + + let mut entries = Vec::with_capacity(names.len()); + for (name, attributes) in names { + let kind = if attributes & ffi::FILE_ATTRIBUTE_REPARSE_POINT != 0 { + "symlink" + } else if attributes & ffi::FILE_ATTRIBUTE_DIRECTORY != 0 { + "dir" + } else { + "file" + }; + let Ok(text) = String::from_utf16(&name) else { + return Err(Denial::new( + codes::NOT_UTF8, + format!("{} holds a name that is not UTF-8", real.display()), + )); + }; + entries.push((text, kind)); + } + entries.sort(); + Ok(Value::Array( + entries + .into_iter() + .map(|(name, kind)| json!({ "name": name, "kind": kind })) + .collect(), + )) +} + +/// The DACL of the file a write is about to replace, or `None` when there +/// is no such file and the new one takes what NTFS inherits for it. +/// Refuses a reparse point and anything that is not a regular file, as the +/// Unix write refuses a symlink and a non-regular file. +#[cfg(windows)] +fn existing_target_dacl( + parent: &OwnedHandle, + leaf: &str, + target: &Path, +) -> Result, Denial> { + let file = match nt_open_relative( + parent.raw(), + leaf, + Open::existing(ffi::READ_CONTROL | ffi::SYNCHRONIZE).non_directory(), + ) { + Ok(file) => file, + Err(OpenError::NotFound) => return Ok(None), + Err(OpenError::ReparsePoint) => { + return Err(Denial::denied(format!( + "{} is a symlink; fs.write does not replace symlinks", + target.display() + ))); + } + Err(OpenError::IsADirectory) => { + return Err(Denial::invalid(format!( + "{} is not a regular file", + target.display() + ))); + } + Err(OpenError::AccessDenied) => { + return Err(Denial::denied(format!( + "the permissions of {} cannot be read", + target.display() + ))); + } + Err(e) => return Err(nt_io(target, "opening", e.status())), + }; + let mut sd = std::ptr::null_mut(); + // SAFETY: `file` is open with READ_CONTROL; on success `sd` receives a + // LocalAlloc'd descriptor that FileDacl frees. + let err = unsafe { + ffi::GetSecurityInfo( + file.raw(), + ffi::SE_FILE_OBJECT, + ffi::DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut sd, + ) + }; + if err != 0 { + return Err(Denial::new( + codes::IO, + format!( + "{}: reading its permissions failed (error {err})", + target.display() + ), + )); + } + Ok(Some(FileDacl { sd })) +} + +/// Marks the open file behind `handle` for deletion; it goes when the last +/// handle to it closes. +#[cfg(windows)] +fn delete_by_handle(handle: &OwnedHandle) -> Result<(), ffi::NTSTATUS> { + let mut disp = ffi::FILE_DISPOSITION_INFORMATION { DeleteFile: 1 }; + let mut io_status = ffi::IO_STATUS_BLOCK { + Status: 0, + Information: 0, + }; + // SAFETY: the buffer passed is `disp`, and the length passed is its + // size. + let status = unsafe { + ffi::NtSetInformationFile( + handle.raw(), + &mut io_status, + (&mut disp as *mut ffi::FILE_DISPOSITION_INFORMATION).cast(), + std::mem::size_of::() as u32, + ffi::FileDispositionInformation, + ) + }; + if status < 0 { Err(status) } else { Ok(()) } +} + +/// What [`unlink_file`] found under a name. +#[cfg(windows)] +enum Unlinked { + Removed, + Absent, + /// Something other than a regular file, left in place: what it is. + NotRegular(&'static str), +} + +/// Removes `name` from the directory behind `parent` only if it is a +/// regular file, as the Unix `unlink_regular` does. A directory or a +/// reparse point under the name is left alone, and a link is never +/// followed, so nothing but that one entry can be removed. +#[cfg(windows)] +fn unlink_file(parent: ffi::HANDLE, name: &str) -> Result { + let file = match nt_open_relative( + parent, + name, + Open::existing(ffi::DELETE | ffi::SYNCHRONIZE).non_directory(), + ) { + Ok(file) => file, + Err(OpenError::NotFound) => return Ok(Unlinked::Absent), + Err(OpenError::ReparsePoint) => return Ok(Unlinked::NotRegular("reparse point")), + Err(OpenError::IsADirectory) => return Ok(Unlinked::NotRegular("directory")), + Err(e) => return Err(e.status()), + }; + delete_by_handle(&file)?; + Ok(Unlinked::Removed) +} + +/// Flushes the file or directory behind `handle` to disk. +#[cfg(windows)] +fn flush(handle: &OwnedHandle) -> ffi::NTSTATUS { + let mut io_status = ffi::IO_STATUS_BLOCK { + Status: 0, + Information: 0, + }; + // SAFETY: `handle` is open; the status block is a live local. + unsafe { ffi::NtFlushBuffersFile(handle.raw(), &mut io_status) } +} + +#[cfg(windows)] +pub fn write(path: &str, roots: &[String], text: &str) -> Result { + let key = format!( + "local-{}-{}", + std::process::id(), + TEMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + ); + write_call(path, roots, text, &key, None) +} + +/// The directory a write to a path acts in, held open from the volume root +/// down, and the name the write replaces in it. +#[cfg(windows)] +struct WriteParent { + root: VerifiedRoot, + /// The directories between a directory root and the parent, the parent + /// last. Empty when the parent is the root, or for a file root. + below: Vec, + /// The parent directory, as spelled in the path. + dir: PathBuf, + /// The last component of the path. + leaf: String, +} + +#[cfg(windows)] +impl WriteParent { + /// The handle on the directory the write acts in. + fn parent(&self) -> &OwnedHandle { + if let Some(dir) = self.below.last() { + dir + } else if self.root.is_directory { + &self.root.handle + } else { + self.root + .ancestors + .last() + .expect("a file root has a parent directory") + } + } + + /// Checks where the parent directory really is, against the root. + fn check_inside(&self) -> Result<(), Denial> { + let guid = get_volume_guid_path(self.parent().raw())?; + let inside = if self.root.is_directory { + guid_path_inside_component_wise(&guid, &self.root.guid_path, true) + } else { + guid_is_parent_of(&guid, &self.root.guid_path) + }; + if inside { + Ok(()) + } else { + Err(outside(&self.dir.join(&self.leaf))) + } + } +} + +/// Selects the root for `path` by the whole path (so a file root grants a +/// write to itself), walks to the directory that holds it and checks that +/// directory. Every directory from the volume root down to that one is +/// opened with the sharing mode `share` and stays open as long as the +/// returned value lives, so a caller passing [`SHARE_NO_DELETE`] keeps them +/// all from being moved until it drops the value. +#[cfg(windows)] +fn open_write_parent(path: &str, roots: &[String], share: u32) -> Result { + validate_raw_spelling(path)?; + let p = Path::new(path); + let (Some(parent), Some(leaf)) = (p.parent(), p.file_name().and_then(OsStr::to_str)) else { + return Err(outside(p)); + }; + let parent_str = parent.to_str().ok_or_else(|| outside(p))?; + let root = select_root(path, roots, share)?; + let below = if root.is_directory { + // The directory root itself is not an entry a write can replace. + if path == root.manifest || !path_under(parent_str, &root.manifest) { + return Err(outside(p)); + } + let components = components_below(parent_str, &root.manifest); + walk_dirs( + &root.handle, + &components, + Open::existing(DIR_WALK).share(share), + ) + .map_err(|e| intermediate_denial(e, p))? + } else { + // select_root grants a file root only to its own path. Its parent + // is the last of the root's ancestors, which walk_from_volume_root + // opened and keeps in `root.ancestors`; WriteParent::parent uses it. + Vec::new() + }; + let wp = WriteParent { + root, + below, + dir: parent.to_path_buf(), + leaf: leaf.to_owned(), + }; + wp.check_inside()?; + Ok(wp) +} + +#[cfg(windows)] +pub fn write_call( + path: &str, + roots: &[String], + text: &str, + call_key: &str, + ledger: Option<&dyn TempLedger>, +) -> Result { + check_write_size(text.len())?; + validate_raw_spelling(path)?; + let temp_name_os = temp_name(call_key)?; + let temp_str = temp_name_os + .to_str() + .ok_or_else(|| Denial::invalid("temporary file name is not valid UTF-8"))?; + + // `wp` holds every directory from the volume root down to the parent, + // opened without FILE_SHARE_DELETE, until write_call returns. Renaming + // or deleting a directory needs it opened for DELETE, which those + // handles refuse, so none can be moved out of the root before the + // rename below lands where the check said it would. + let wp = open_write_parent(path, roots, SHARE_NO_DELETE)?; + let target_path = wp.dir.join(&wp.leaf); + let temp_path = wp.dir.join(temp_str); + + // A replaced file keeps its DACL. A new file gets no explicit + // descriptor, so NTFS gives it what the folder's inheritable entries + // grant, marked inherited, and later changes to the folder reach it. + let target_dacl = existing_target_dacl(wp.parent(), &wp.leaf, &target_path)?; + + let lease = TempLease { + call_key: call_key.to_owned(), + dir: wp.dir.clone(), + target: OsString::from(&wp.leaf), + temp: temp_name_os.clone(), + roots: roots.to_vec(), + }; + // With a ledger, the record is durable before the file exists, so no + // crash can leave a temporary file that nothing has recorded. + let hold = match ledger { + Some(l) => Some(l.record(&lease).map_err(|e| { + Denial::new( + codes::IO, + format!( + "the temporary file for {} could not be recorded: {e}", + target_path.display() + ), + ) + })?), + None => None, + }; + let clear = |hold: Option>| { + if let Some(h) = hold { + h.clear(); + } + }; + + let create = Open { + access: ffi::FILE_GENERIC_WRITE | ffi::DELETE | ffi::SYNCHRONIZE, + share: SHARE_ALL, + disposition: ffi::FILE_CREATE, + options: ffi::FILE_NON_DIRECTORY_FILE, + security_descriptor: target_dacl.as_ref().map_or(std::ptr::null_mut(), |d| d.sd), + }; + // On a failed create the record stays: a file an earlier send left + // under this name may still be there, and cleanup checks for it. + let mut replaced = false; + let temp = loop { + match nt_open_relative(wp.parent().raw(), temp_str, create) { + Ok(file) => break file, + // The name belongs to this call alone, so a regular file already + // under it is what an earlier send of the same call left. It is + // replaced once; a directory or link there is an error. + Err(OpenError::Collision) + if !replaced + && matches!( + unlink_file(wp.parent().raw(), temp_str), + Ok(Unlinked::Removed) + ) => + { + replaced = true; + } + Err(e) => return Err(nt_io(&temp_path, "creating", e.status())), + } + }; + if let Some(l) = ledger { + l.created(&lease); + } + + // On a failure from here on, the temporary file is removed; the record + // is cleared only when that worked, otherwise cleanup tries again. + let abandon = |temp: OwnedHandle, hold: Option>| { + if delete_by_handle(&temp).is_ok() { + drop(temp); + clear(hold); + } + }; + + let bytes = text.as_bytes(); + let mut written = 0usize; + while written < bytes.len() { + let chunk = &bytes[written..]; + let mut io_status = ffi::IO_STATUS_BLOCK { + Status: 0, + Information: 0, + }; + let mut offset = written as i64; + // SAFETY: `chunk` is live for the call; its length is at most + // MAX_WRITE_BYTES, so it fits in a u32. + let status = unsafe { + ffi::NtWriteFile( + temp.raw(), + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut io_status, + chunk.as_ptr().cast(), + chunk.len() as u32, + &mut offset, + std::ptr::null_mut(), + ) + }; + if status < 0 || io_status.Information == 0 { + abandon(temp, hold); + return Err(nt_io(&temp_path, "writing", status)); + } + written += io_status.Information; + } + + // The data must be on disk before the rename makes it the target: the + // rename is journaled, the data is not, so renaming unflushed data can + // leave an empty or partial file after a crash. A failed flush stops + // the write, as a failed sync does on Unix. + #[cfg(test)] + let flush_status = if hooks::FAIL_TEMP_FLUSH.with(std::cell::Cell::get) { + ffi::STATUS_IO_DEVICE_ERROR + } else { + flush(&temp) + }; + #[cfg(not(test))] + let flush_status = flush(&temp); + if flush_status < 0 { + abandon(temp, hold); + return Err(nt_io( + &target_path, + "flushing the new contents", + flush_status, + )); + } + + // The parent is held, but where it is gets checked again before the + // rename so the write does not rely on the hold alone. + if let Err(d) = wp.check_inside() { + abandon(temp, hold); + return Err(d); + } + + // FileRenameInformationEx with POSIX semantics replaces the target even + // while it is open. If the volume refuses it, the write fails: there is + // no fallback to a non-POSIX rename or a copy. + // A read-only target is replaced, as renameat ignores a target's mode. + let wide_target: Vec = wp.leaf.encode_utf16().collect(); + let name_bytes = wide_target.len() * std::mem::size_of::(); + let name_offset = std::mem::offset_of!(ffi::FILE_RENAME_INFORMATION_EX, FileName); + let struct_size = + (name_offset + name_bytes).max(std::mem::size_of::()); + // u64 storage gives the 8-byte alignment the structure needs. + let mut rename_buf = vec![0u64; struct_size.div_ceil(std::mem::size_of::())]; + let rename_info = rename_buf + .as_mut_ptr() + .cast::(); + // SAFETY: the buffer is aligned for the structure and holds its header + // plus `name_bytes` of name; fields are written through the raw pointer + // (no reference is made), and the name is copied through a pointer + // derived from it, so it may run past the declared one-element array. + unsafe { + std::ptr::addr_of_mut!((*rename_info).Flags).write( + ffi::FILE_RENAME_REPLACE_IF_EXISTS + | ffi::FILE_RENAME_POSIX_SEMANTICS + | ffi::FILE_RENAME_IGNORE_READONLY_ATTRIBUTE, + ); + std::ptr::addr_of_mut!((*rename_info).RootDirectory).write(wp.parent().raw()); + std::ptr::addr_of_mut!((*rename_info).FileNameLength).write(name_bytes as u32); + std::ptr::copy_nonoverlapping( + wide_target.as_ptr(), + std::ptr::addr_of_mut!((*rename_info).FileName).cast::(), + wide_target.len(), + ); + } + + let mut io_status = ffi::IO_STATUS_BLOCK { + Status: 0, + Information: 0, + }; + let rename = |io_status: &mut ffi::IO_STATUS_BLOCK| { + // SAFETY: `rename_info` points into `rename_buf`, which is + // `struct_size` bytes or more and outlives the call. + unsafe { + ffi::NtSetInformationFile( + temp.raw(), + io_status, + rename_info.cast(), + struct_size as u32, + ffi::FileRenameInformationEx, + ) + } + }; + #[cfg(test)] + let rename_status = if hooks::REFUSE_POSIX_RENAME.with(std::cell::Cell::get) { + ffi::STATUS_NOT_SUPPORTED + } else { + rename(&mut io_status) + }; + #[cfg(not(test))] + let rename_status = rename(&mut io_status); + + if rename_status < 0 { + abandon(temp, hold); + if rename_status == ffi::STATUS_NOT_SUPPORTED + || rename_status == ffi::STATUS_INVALID_PARAMETER + { + return Err(Denial::denied(format!( + "volume does not support POSIX replace rename (status 0x{:08x})", + rename_status as u32 + ))); + } + return Err(nt_io( + &target_path, + "renaming the new contents into place", + rename_status, + )); + } + drop(temp); + + // Make the rename itself durable. Best effort, as on Unix: a failure + // here leaves the new file in place. Flushing a directory needs write + // access, which the held handle does not carry. + if let Ok(dir) = nt_reopen( + wp.parent(), + Open::existing(ffi::FILE_WRITE_DATA | ffi::SYNCHRONIZE).directory(), + ) { + let _ = flush(&dir); + } + + clear(hold); + Ok(json!({ "bytes": text.len() })) +} + +#[cfg(windows)] +pub fn remove_temp(lease: &TempLease) -> Result { + if !is_temp_name(&lease.temp) && !is_legacy_temp_name(&lease.temp) { + return Ok(TempRemoval::Refused(Denial::invalid( + "the record does not name a temporary file", + ))); + } + let Some(temp_str) = lease.temp.to_str() else { + return Ok(TempRemoval::Refused(Denial::invalid( + "the recorded temporary file name is not UTF-8", + ))); + }; + let joined = lease.dir.join(&lease.target); + let Some(path) = joined.to_str() else { + return Ok(TempRemoval::Refused(Denial::invalid( + "the recorded path is not UTF-8", + ))); + }; + + // The lease's directory is reached exactly as write_call reached it: + // the root chosen by the whole path, then a walk down to `lease.dir`. + let wp = match open_write_parent(path, &lease.roots, SHARE_ALL) { + Ok(wp) => wp, + Err(d) if d.code == codes::NOT_FOUND => return Ok(TempRemoval::Absent), + Err(d) if d.code == codes::IO => return Err(d), + Err(d) => return Ok(TempRemoval::Refused(d)), + }; + // The walk follows the spelling of `lease.dir` and refuses every reparse + // point, so it cannot end anywhere that spelling does not name. What + // this check catches is a `lease.target` that is not one plain name: one + // holding a separator makes the parent of `dir\target` a deeper + // directory than `lease.dir`. + if wp.dir != lease.dir || OsStr::new(&wp.leaf) != lease.target { + return Ok(TempRemoval::Refused(Denial::denied(format!( + "{} now resolves to {}", + joined.display(), + wp.dir.join(&wp.leaf).display() + )))); + } + + let temp_path = wp.dir.join(temp_str); + Ok(match unlink_file(wp.parent().raw(), temp_str) { + Ok(Unlinked::Removed) => TempRemoval::Removed, + Ok(Unlinked::Absent) => TempRemoval::Absent, + Ok(Unlinked::NotRegular(kind)) => TempRemoval::Refused(Denial::denied(format!( + "{} is a {kind}, not a temporary file", + temp_path.display() + ))), + Err(status) => return Err(nt_io(&temp_path, "removing", status)), + }) +} + +#[cfg(windows)] +pub fn remove_legacy_temps(path: &str, roots: &[String]) -> Result { + let wp = open_write_parent(path, roots, SHARE_ALL)?; + let lister = nt_reopen( + wp.parent(), + Open::existing(ffi::FILE_LIST_DIRECTORY | ffi::SYNCHRONIZE).directory(), + ) + .map_err(|e| nt_io(&wp.dir, "opening the directory to list", e.status()))?; + let mut names = Vec::new(); + scan_directory(lister.raw(), |name, _| { + if let Ok(name) = String::from_utf16(name) + && is_legacy_temp_name(OsStr::new(&name)) + { + names.push(name); + } + true + }) + .map_err(|s| nt_io(&wp.dir, "listing", s))?; + let mut removed = 0; + for name in names { + // Each name is examined again as it is removed: only a regular + // file goes, whatever the listing said it was. + match unlink_file(wp.parent().raw(), &name) { + Ok(Unlinked::Removed) => removed += 1, + Ok(_) => {} + Err(status) => return Err(nt_io(&wp.dir.join(&name), "removing", status)), + } + } + Ok(removed) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn spelling_accepts_ordinary_drive_paths() { + assert!(validate_raw_spelling(r"C:\").is_ok()); + assert!(validate_raw_spelling(r"C:\dir").is_ok()); + assert!(validate_raw_spelling(r"C:\dir\file.txt").is_ok()); + assert!(validate_raw_spelling(r"d:\nested\sub\folder\file").is_ok()); + } + + #[test] + fn spelling_refuses_unc_and_device_namespaces() { + assert!(validate_raw_spelling(r"\\server\share\file").is_err()); + assert!(validate_raw_spelling(r"//server/share/file").is_err()); + assert!(validate_raw_spelling(r"\\.\COM1").is_err()); + assert!(validate_raw_spelling(r"//./COM1").is_err()); + assert!(validate_raw_spelling(r"\\?\C:\file").is_err()); + assert!(validate_raw_spelling(r"//?/C:/file").is_err()); + assert!(validate_raw_spelling(r"\??\C:\file").is_err()); + assert!(validate_raw_spelling(r"/??/C:/file").is_err()); + } + + #[test] + fn spelling_refuses_drive_relative() { + assert!(validate_raw_spelling("C:").is_err()); + assert!(validate_raw_spelling("C:file").is_err()); + assert!(validate_raw_spelling(r"C:dir\file").is_err()); + assert!(validate_raw_spelling("C:/file").is_err()); + } + + #[test] + fn spelling_refuses_alternate_data_streams() { + assert!(validate_raw_spelling(r"C:\file:stream").is_err()); + assert!(validate_raw_spelling(r"C:\file::$DATA").is_err()); + assert!(validate_raw_spelling(r"C:\dir:ads\file").is_err()); + } + + #[test] + fn spelling_refuses_trailing_dots_and_spaces() { + assert!(validate_raw_spelling(r"C:\dir\file.").is_err()); + assert!(validate_raw_spelling(r"C:\dir\file ").is_err()); + assert!(validate_raw_spelling(r"C:\dir \file").is_err()); + assert!(validate_raw_spelling(r"C:\dir.\file").is_err()); + assert!(validate_raw_spelling(r"C:\dir\file.txt.").is_err()); + } + + #[test] + fn spelling_refuses_reserved_device_names() { + assert!(validate_raw_spelling(r"C:\CON").is_err()); + assert!(validate_raw_spelling(r"C:\con.txt").is_err()); + assert!(validate_raw_spelling(r"C:\dir\PRN").is_err()); + assert!(validate_raw_spelling(r"C:\dir\aux.dat").is_err()); + assert!(validate_raw_spelling(r"C:\NUL.tar.gz").is_err()); + assert!(validate_raw_spelling(r"C:\COM1").is_err()); + assert!(validate_raw_spelling(r"C:\com9.txt").is_err()); + assert!(validate_raw_spelling(r"C:\LPT1").is_err()); + assert!(validate_raw_spelling(r"C:\lpt8.log").is_err()); + assert!(validate_raw_spelling(r"C:\conin$").is_err()); + assert!(validate_raw_spelling(r"C:\conout$").is_err()); + assert!(validate_raw_spelling("C:\\COM\u{b9}").is_err()); + assert!(validate_raw_spelling("C:\\lpt\u{b3}.txt").is_err()); + assert!(validate_raw_spelling("C:\\COM\u{b4}").is_ok()); + + // Not reserved + assert!(validate_raw_spelling(r"C:\context.txt").is_ok()); + assert!(validate_raw_spelling(r"C:\connect").is_ok()); + assert!(validate_raw_spelling(r"C:\auxiliary.dat").is_ok()); + } + + #[test] + fn spelling_refuses_relative_and_empty_components() { + assert!(validate_raw_spelling(r"C:\dir\..\file").is_err()); + assert!(validate_raw_spelling(r"C:\dir\.\file").is_err()); + assert!(validate_raw_spelling(r"C:\dir\\file").is_err()); + assert!(validate_raw_spelling(r"C:\dir\").is_err()); + } + + #[test] + fn spelling_refuses_forward_slashes() { + assert!(validate_raw_spelling("C:/dir/file").is_err()); + assert!(validate_raw_spelling(r"C:\dir/file").is_err()); + } + + #[test] + fn guid_path_inside_component_wise_matches() { + let root = r"\\?\Volume{12345678-0000-0000-0000-000000000000}\Users\admin\root"; + let child = + r"\\?\Volume{12345678-0000-0000-0000-000000000000}\Users\admin\root\sub\file.txt"; + let sibling = + r"\\?\Volume{12345678-0000-0000-0000-000000000000}\Users\admin\rootx\file.txt"; + let case_diff = + r"\\?\Volume{12345678-0000-0000-0000-000000000000}\users\admin\root\file.txt"; + let other_vol = + r"\\?\Volume{87654321-0000-0000-0000-000000000000}\Users\admin\root\sub\file.txt"; + + // Directory root + assert!(guid_path_inside_component_wise(child, root, true)); + assert!(guid_path_inside_component_wise(root, root, true)); + assert!(!guid_path_inside_component_wise(sibling, root, true)); + assert!(!guid_path_inside_component_wise(case_diff, root, true)); + assert!(!guid_path_inside_component_wise(other_vol, root, true)); + + // File root + let file_root = + r"\\?\Volume{12345678-0000-0000-0000-000000000000}\Users\admin\root\file.txt"; + let file_child = + r"\\?\Volume{12345678-0000-0000-0000-000000000000}\Users\admin\root\file.txt\sub"; + let file_sibling = + r"\\?\Volume{12345678-0000-0000-0000-000000000000}\Users\admin\root\other.txt"; + + assert!(guid_path_inside_component_wise(file_root, file_root, false)); + assert!(!guid_path_inside_component_wise( + file_child, file_root, false + )); + assert!(!guid_path_inside_component_wise( + file_sibling, + file_root, + false + )); + } + + #[test] + fn path_under_matches_by_component_and_volume_roots_cover_their_drive() { + assert!(path_under(r"C:\root", r"C:\root")); + assert!(path_under(r"C:\root\a\b.txt", r"C:\root")); + assert!(!path_under(r"C:\rootx\a.txt", r"C:\root")); + assert!(!path_under(r"C:\other", r"C:\root")); + assert!(path_under(r"C:\", r"C:\")); + assert!(path_under(r"C:\x", r"C:\")); + assert!(path_under(r"C:\x\y.txt", r"C:\")); + assert!(!path_under(r"D:\x", r"C:\")); + } + + #[test] + fn components_below_handles_directory_and_volume_roots() { + assert!(components_below(r"C:\root", r"C:\root").is_empty()); + assert_eq!(components_below(r"C:\root\a\b", r"C:\root"), ["a", "b"]); + assert!(components_below(r"C:\", r"C:\").is_empty()); + assert_eq!(components_below(r"C:\x\y", r"C:\"), ["x", "y"]); + } + + #[cfg(windows)] + mod win_tests { + use super::*; + use std::os::windows::fs::OpenOptionsExt; + use std::os::windows::io::AsRawHandle; + use std::sync::Mutex; + + /// Win32 calls only the tests make. + #[allow(non_snake_case)] + mod test_ffi { + use std::ffi::c_void; + + pub const SDDL_REVISION_1: u32 = 1; + pub const PROTECTED_DACL_SECURITY_INFORMATION: u32 = 0x8000_0000; + pub const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000; + pub const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + pub const ERROR_SHARING_VIOLATION: i32 = 32; + + #[link(name = "advapi32")] + unsafe extern "system" { + pub fn ConvertStringSecurityDescriptorToSecurityDescriptorW( + StringSecurityDescriptor: *const u16, + StringSDRevision: u32, + SecurityDescriptor: *mut *mut c_void, + SecurityDescriptorSize: *mut u32, + ) -> i32; + + pub fn ConvertSecurityDescriptorToStringSecurityDescriptorW( + SecurityDescriptor: *mut c_void, + RequestedStringSDRevision: u32, + SecurityInformation: u32, + StringSecurityDescriptor: *mut *mut u16, + StringSecurityDescriptorLen: *mut u32, + ) -> i32; + + pub fn SetFileSecurityW( + lpFileName: *const u16, + SecurityInformation: u32, + pSecurityDescriptor: *mut c_void, + ) -> i32; + } + + #[link(name = "kernel32")] + unsafe extern "system" { + pub fn GetVolumeNameForVolumeMountPointW( + lpszVolumeMountPoint: *const u16, + lpszVolumeName: *mut u16, + cchBufferLength: u32, + ) -> i32; + + pub fn SetVolumeMountPointW( + lpszVolumeMountPoint: *const u16, + lpszVolumeName: *const u16, + ) -> i32; + + pub fn DeleteVolumeMountPointW(lpszVolumeMountPoint: *const u16) -> i32; + } + } + + struct WinTree { + base: PathBuf, + root: PathBuf, + outside: PathBuf, + } + + impl WinTree { + fn new(tag: &str) -> Self { + Self::new_under(tag, &std::env::temp_dir()) + } + + fn new_under(tag: &str, parent: &Path) -> Self { + static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let base = parent.join(format!( + "basal-win-tree-{tag}-{}-{}", + std::process::id(), + SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + let root = base.join("root"); + let outside = base.join("outside"); + std::fs::create_dir_all(root.join("sub")).expect("create root"); + std::fs::create_dir_all(&outside).expect("create outside"); + std::fs::write(root.join("a.txt"), "inside").expect("write a.txt"); + std::fs::write(root.join("sub\\b.txt"), "nested").expect("write b.txt"); + std::fs::write(outside.join("secret.txt"), "secret").expect("write secret"); + Self { + base, + root, + outside, + } + } + + fn roots(&self) -> Vec { + vec![self.root.display().to_string()] + } + + fn p(&self, rel: &str) -> String { + self.root.join(rel).display().to_string() + } + + /// The names in the root directory that are temporary files. + fn temps_in(&self, rel: &str) -> Vec { + std::fs::read_dir(self.root.join(rel)) + .expect("read dir") + .map(|e| e.expect("entry").file_name().to_string_lossy().into_owned()) + .filter(|n| n.starts_with(".basal-")) + .collect() + } + } + + impl Drop for WinTree { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.base); + } + } + + #[test] + fn tilde_root_reads_below_the_process_tokens_profile() { + let profile = process_profile_directory().expect("process token profile"); + let tree = WinTree::new_under("profile", Path::new(&profile)); + let name = tree.base.file_name().unwrap().to_str().unwrap(); + let roots = vec![format!("~/{name}/root")]; + assert_eq!( + read(&tree.p("sub\\b.txt"), &roots, 100).unwrap()["text"], + "nested" + ); + for root in [ + format!("~/{name}/root/.."), + format!("~/{name}//root"), + format!("~/{name}/root:stream"), + ] { + assert!(read(&tree.p("sub\\b.txt"), &[root], 100).is_err()); + } + } + + fn wide(s: &str) -> Vec { + s.encode_utf16().chain(Some(0)).collect() + } + + /// Fails the test unless `path` is a symlink, junction or mount point. + fn assert_link(path: &Path) { + let meta = std::fs::symlink_metadata(path) + .unwrap_or_else(|e| panic!("{} was not created: {e}", path.display())); + assert!( + meta.file_type().is_symlink(), + "{} is not a link", + path.display() + ); + } + + /// Creates a file symlink. Needs SeCreateSymbolicLinkPrivilege, which + /// an elevated administrator (as on GitHub's Windows runners) holds. + fn file_symlink(link: &Path, target: &Path) { + std::os::windows::fs::symlink_file(target, link).unwrap_or_else(|e| { + panic!( + "creating symlink {} failed (needs SeCreateSymbolicLinkPrivilege): {e}", + link.display() + ) + }); + assert_link(link); + } + + /// Creates a directory junction; needs no privilege. + fn junction(link: &Path, target: &Path) { + // Subprocess text must not interrupt libtest's result lines. + // Keep it for diagnostics only when fixture creation fails. + let output = std::process::Command::new("cmd") + .args([ + "/c", + "mklink", + "/J", + link.to_str().unwrap(), + target.to_str().unwrap(), + ]) + .output() + .expect("run mklink /J"); + assert!( + output.status.success(), + "mklink /J {} failed: {} {}", + link.display(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert_link(link); + } + + /// Sets the DACL of `path` from an SDDL string, including its + /// protection flag. + fn set_dacl(path: &Path, sddl: &str) { + let wide_sddl = wide(sddl); + let mut sd = std::ptr::null_mut(); + let ok = unsafe { + test_ffi::ConvertStringSecurityDescriptorToSecurityDescriptorW( + wide_sddl.as_ptr(), + test_ffi::SDDL_REVISION_1, + &mut sd, + std::ptr::null_mut(), + ) + }; + assert_ne!(ok, 0, "convert {sddl}: {}", std::io::Error::last_os_error()); + let mut info = ffi::DACL_SECURITY_INFORMATION; + if sddl.starts_with("D:P") { + info |= test_ffi::PROTECTED_DACL_SECURITY_INFORMATION; + } + let wide_path = wide(path.to_str().unwrap()); + let ok = unsafe { test_ffi::SetFileSecurityW(wide_path.as_ptr(), info, sd) }; + let err = std::io::Error::last_os_error(); + unsafe { ffi::LocalFree(sd) }; + assert_ne!(ok, 0, "set the DACL of {}: {err}", path.display()); + } + + /// The DACL of `path` as SDDL, read without following a link. + fn dacl_of(path: &Path) -> Result { + let file = std::fs::OpenOptions::new() + .access_mode(ffi::READ_CONTROL) + .custom_flags( + test_ffi::FILE_FLAG_BACKUP_SEMANTICS | test_ffi::FILE_FLAG_OPEN_REPARSE_POINT, + ) + .open(path) + .map_err(|e| format!("open {}: {e}", path.display()))?; + let mut sd = std::ptr::null_mut(); + let err = unsafe { + ffi::GetSecurityInfo( + file.as_raw_handle(), + ffi::SE_FILE_OBJECT, + ffi::DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut sd, + ) + }; + if err != 0 { + return Err(format!("GetSecurityInfo {}: {err}", path.display())); + } + let mut text = std::ptr::null_mut(); + let ok = unsafe { + test_ffi::ConvertSecurityDescriptorToStringSecurityDescriptorW( + sd, + test_ffi::SDDL_REVISION_1, + ffi::DACL_SECURITY_INFORMATION, + &mut text, + std::ptr::null_mut(), + ) + }; + let result = if ok == 0 || text.is_null() { + Err(format!("convert the DACL of {} to SDDL", path.display())) + } else { + let mut len = 0; + while unsafe { *text.add(len) } != 0 { + len += 1; + } + let units = unsafe { std::slice::from_raw_parts(text, len) }; + let sddl = String::from_utf16_lossy(units); + unsafe { ffi::LocalFree(text.cast()) }; + Ok(sddl) + }; + unsafe { ffi::LocalFree(sd) }; + result + } + + /// Splits `D:()()...` into its flags and ACEs. + fn dacl_parts(sddl: &str) -> (String, Vec) { + let rest = sddl + .strip_prefix("D:") + .unwrap_or_else(|| panic!("not a DACL: {sddl}")); + let (flags, aces) = rest.split_at(rest.find('(').unwrap_or(rest.len())); + let aces = aces + .split(')') + .filter(|a| !a.is_empty()) + .map(|a| a.trim_start_matches('(').to_owned()) + .collect(); + (flags.to_owned(), aces) + } + + /// A ledger that records the temporary file's DACL once it exists. + #[derive(Default)] + struct DaclObserver { + temp_dacl: Mutex>>, + } + + struct NoHold; + impl TempHold for NoHold { + fn clear(self: Box) {} + } + + impl TempLedger for DaclObserver { + fn record(&self, _lease: &TempLease) -> Result, String> { + Ok(Box::new(NoHold)) + } + + fn created(&self, lease: &TempLease) { + *self.temp_dacl.lock().unwrap() = Some(dacl_of(&lease.dir.join(&lease.temp))); + } + } + + impl DaclObserver { + fn temp_dacl(&self) -> String { + self.temp_dacl + .lock() + .unwrap() + .clone() + .expect("the ledger saw the temporary file") + .expect("the temporary file's DACL was read") + } + } + + #[test] + fn windows_read_and_stat_ordinary_drive_path() { + let t = WinTree::new("read-stat"); + let roots = t.roots(); + + let val = read(&t.p("a.txt"), &roots, 1024).expect("read a.txt"); + assert_eq!(val["text"], "inside"); + + let st = stat(&t.p("a.txt"), &roots).expect("stat a.txt"); + assert_eq!(st["exists"], true); + assert_eq!(st["kind"], "file"); + assert_eq!(st["size"], 6); + assert!(st["mtime_ms"].as_i64().unwrap_or(0) > 0); + + let st_missing = stat(&t.p("missing.txt"), &roots).expect("stat missing"); + assert_eq!(st_missing["exists"], false); + + let st_root = stat(&t.root.display().to_string(), &roots).expect("stat root"); + assert_eq!(st_root["kind"], "dir"); + + let outside_path = t.outside.join("secret.txt").display().to_string(); + let denial = stat(&outside_path, &roots).expect_err("outside"); + assert_eq!(denial.code, codes::DENIED); + } + + #[test] + fn windows_raw_spelling_refusals_before_open() { + let t = WinTree::new("spelling-refusals"); + let roots = t.roots(); + + let ads = format!("{}:stream", t.p("a.txt")); + let d1 = read(&ads, &roots, 1024).expect_err("ads"); + assert_eq!(d1.code, codes::INVALID_ARGUMENTS); + + let drive_rel = "C:a.txt"; + let d2 = read(drive_rel, &roots, 1024).expect_err("drive rel"); + assert_eq!(d2.code, codes::INVALID_ARGUMENTS); + + let con_path = t.p("con.txt"); + let d3 = read(&con_path, &roots, 1024).expect_err("device name"); + assert_eq!(d3.code, codes::INVALID_ARGUMENTS); + } + + #[test] + fn windows_symlink_escape_and_in_root_refused() { + let t = WinTree::new("symlink-refused"); + let roots = t.roots(); + + let out_link = t.root.join("symlink_out.txt"); + let in_link = t.root.join("symlink_in.txt"); + file_symlink(&out_link, &t.outside.join("secret.txt")); + file_symlink(&in_link, &t.root.join("a.txt")); + + let err_out = read(&out_link.display().to_string(), &roots, 1024) + .expect_err("symlink out denied"); + assert_eq!(err_out.code, codes::DENIED); + // Every reparse point is refused, in-root ones included. + let err_in = read(&in_link.display().to_string(), &roots, 1024) + .expect_err("in-root symlink denied"); + assert_eq!(err_in.code, codes::DENIED); + assert!( + err_in.message.contains("reparse point"), + "{}", + err_in.message + ); + + let err_write = + write(&out_link.display().to_string(), &roots, "x").expect_err("write link"); + assert_eq!(err_write.code, codes::DENIED); + assert_eq!( + std::fs::read_to_string(t.outside.join("secret.txt")).unwrap(), + "secret" + ); + } + + #[test] + fn windows_reparse_points_at_root_middle_leaf() { + let t = WinTree::new("reparse-points"); + let roots = t.roots(); + + // A junction as the last component. + let leaf_junc = t.root.join("leaf_junc"); + junction(&leaf_junc, &t.outside); + let st = stat(&leaf_junc.display().to_string(), &roots) + .expect_err("leaf reparse stat denial"); + assert_eq!(st.code, codes::DENIED); + assert!(st.message.contains("reparse point"), "{}", st.message); + + // A junction in the middle of the path. + let mid_junc = t.root.join("mid_junc"); + junction(&mid_junc, &t.outside); + let target = mid_junc.join("secret.txt").display().to_string(); + let err = read(&target, &roots, 1024).expect_err("mid reparse denial"); + assert_eq!(err.code, codes::DENIED); + assert!(err.message.contains("reparse point"), "{}", err.message); + + // The root itself is a junction: refused for being one, not + // for some other failure of the walk. + let root_junc = t.base.join("root_junc"); + junction(&root_junc, &t.root); + let junc_roots = vec![root_junc.display().to_string()]; + let target = root_junc.join("a.txt").display().to_string(); + let err = read(&target, &junc_roots, 1024).expect_err("root reparse denial"); + assert_eq!(err.code, codes::DENIED); + assert!(err.message.contains("reparse point"), "{}", err.message); + } + + /// Removes a volume mount point when the test ends, however it ends. + struct MountPoint(Vec); + + impl Drop for MountPoint { + fn drop(&mut self) { + unsafe { test_ffi::DeleteVolumeMountPointW(self.0.as_ptr()) }; + } + } + + #[test] + fn windows_mount_point_refused() { + let t = WinTree::new("mount-point"); + let roots = t.roots(); + let mnt = t.root.join("mnt"); + std::fs::create_dir(&mnt).expect("create mount directory"); + + // Mount the volume the tree is on at root\mnt. Needs an + // administrator, as on GitHub's Windows runners. + let drive = &t.root.to_str().unwrap()[..3]; + let mut volume = vec![0u16; 64]; + let ok = unsafe { + test_ffi::GetVolumeNameForVolumeMountPointW( + wide(drive).as_ptr(), + volume.as_mut_ptr(), + volume.len() as u32, + ) + }; + assert_ne!( + ok, + 0, + "volume name of {drive}: {}", + std::io::Error::last_os_error() + ); + let mount_at = wide(&format!("{}\\", mnt.display())); + let ok = unsafe { test_ffi::SetVolumeMountPointW(mount_at.as_ptr(), volume.as_ptr()) }; + assert_ne!( + ok, + 0, + "mount {drive} at {} (needs an administrator): {}", + mnt.display(), + std::io::Error::last_os_error() + ); + let _mounted = MountPoint(mount_at); + assert_link(&mnt); + + let st = stat(&mnt.display().to_string(), &roots).expect_err("stat mount point"); + assert_eq!(st.code, codes::DENIED); + assert!(st.message.contains("reparse point"), "{}", st.message); + + let through = mnt.join("Windows").display().to_string(); + let err = list(&through, &roots).expect_err("list through mount point"); + assert_eq!(err.code, codes::DENIED); + assert!(err.message.contains("reparse point"), "{}", err.message); + + let mnt_roots = vec![mnt.display().to_string()]; + let err = list(&mnt.display().to_string(), &mnt_roots).expect_err("mount point root"); + assert_eq!(err.code, codes::DENIED); + assert!(err.message.contains("reparse point"), "{}", err.message); + } + + #[test] + fn windows_dotdot_and_relative_escapes() { + let t = WinTree::new("dotdot-escapes"); + let roots = t.roots(); + + let p1 = t.root.join("..\\outside\\secret.txt").display().to_string(); + assert_eq!( + read(&p1, &roots, 1024).expect_err("dotdot").code, + codes::INVALID_ARGUMENTS + ); + + let p2 = t.root.join("sub\\..\\a.txt").display().to_string(); + assert_eq!( + read(&p2, &roots, 1024).expect_err("dotdot").code, + codes::INVALID_ARGUMENTS + ); + + let p3 = t.root.join(".\\a.txt").display().to_string(); + assert_eq!( + read(&p3, &roots, 1024).expect_err("dot").code, + codes::INVALID_ARGUMENTS + ); + } + + #[test] + fn windows_ads_spellings() { + let t = WinTree::new("ads-spellings"); + let roots = t.roots(); + + let p1 = format!("{}:stream", t.p("a.txt")); + assert_eq!( + read(&p1, &roots, 1024).expect_err("ads a:b").code, + codes::INVALID_ARGUMENTS + ); + + let p2 = format!("{}::$DATA", t.p("a.txt")); + assert_eq!( + read(&p2, &roots, 1024).expect_err("ads data").code, + codes::INVALID_ARGUMENTS + ); + + let p3 = format!("{}::$INDEX_ALLOCATION", t.p("sub")); + assert_eq!( + read(&p3, &roots, 1024).expect_err("ads index").code, + codes::INVALID_ARGUMENTS + ); + } + + #[test] + fn windows_trailing_dot_and_space() { + let t = WinTree::new("trailing-dot-space"); + let roots = t.roots(); + + let p1 = format!("{}.", t.p("a.txt")); + assert_eq!( + read(&p1, &roots, 1024).expect_err("trailing dot").code, + codes::INVALID_ARGUMENTS + ); + + let p2 = format!("{} ", t.p("a.txt")); + assert_eq!( + read(&p2, &roots, 1024).expect_err("trailing space").code, + codes::INVALID_ARGUMENTS + ); + + let p3 = t.root.join("sub.\\a.txt").display().to_string(); + assert_eq!( + read(&p3, &roots, 1024).expect_err("dir trailing dot").code, + codes::INVALID_ARGUMENTS + ); + + let p4 = t.root.join("sub \\a.txt").display().to_string(); + assert_eq!( + read(&p4, &roots, 1024) + .expect_err("dir trailing space") + .code, + codes::INVALID_ARGUMENTS + ); + } + + #[test] + fn windows_each_reserved_device_name() { + let t = WinTree::new("reserved-names"); + let roots = t.roots(); + + let names = [ + "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", + "COM8", "COM9", "COM0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", + "LPT8", "LPT9", "LPT0", "CONIN$", "CONOUT$", + ]; + + for name in names { + let bare = t.root.join(name).display().to_string(); + assert_eq!( + read(&bare, &roots, 1024).expect_err(name).code, + codes::INVALID_ARGUMENTS, + "bare {name}" + ); + + let with_ext = t.root.join(format!("{name}.txt")).display().to_string(); + assert_eq!( + read(&with_ext, &roots, 1024).expect_err(name).code, + codes::INVALID_ARGUMENTS, + "ext {name}.txt" + ); + } + } + + #[test] + fn windows_refused_prefixes_and_drive_relative() { + let t = WinTree::new("refused-prefixes"); + let roots = t.roots(); + + let prefixes = [ + r"\\server\share\file", + "//server/share/file", + r"\\.\COM1", + "//./COM1", + r"\\?\C:\file", + "//?/C:/file", + r"\??\C:\file", + "/??/C:/file", + "C:file", + r"C:dir\file", + "C:", + ]; + + for prefix in prefixes { + assert_eq!( + read(prefix, &roots, 1024).expect_err(prefix).code, + codes::INVALID_ARGUMENTS, + "prefix {prefix}" + ); + } + } + + #[test] + fn windows_case_and_8_3_aliases() { + let t = WinTree::new("aliases"); + let roots = t.roots(); + + // A case alias inside the root opens the same entry. + let case_alias_inside = t.root.join("SUB\\B.TXT").display().to_string(); + let val = read(&case_alias_inside, &roots, 1024).expect("read case alias inside"); + assert_eq!(val["text"], "nested"); + + // Outside the root: refused because the path's spelling does not + // lie under the root's, before anything is opened. + let case_outside = t.outside.join("SECRET.TXT").display().to_string(); + let err_case = read(&case_outside, &roots, 1024).expect_err("case outside denied"); + assert_eq!(err_case.code, codes::DENIED); + + let outside_83 = t.base.join("OUTSI~1\\secret.txt").display().to_string(); + let err_83 = read(&outside_83, &roots, 1024).expect_err("8.3 outside denied"); + assert_eq!(err_83.code, codes::DENIED); + } + + #[test] + fn windows_target_swapped_between_check_and_open() { + let t = WinTree::new("swap-hook"); + let roots = t.roots(); + let target_path = t.root.join("to_swap.txt"); + std::fs::write(&target_path, "before swap").expect("write target"); + + let target_clone = target_path.clone(); + let outside_clone = t.outside.join("secret.txt"); + // Runs on this thread only, after resolution and before the open. + hooks::BEFORE_READ_OPEN.with(|h| { + *h.borrow_mut() = Some(Box::new(move || { + std::fs::remove_file(&target_clone).expect("remove the checked file"); + file_symlink(&target_clone, &outside_clone); + })); + }); + let res = read(&target_path.display().to_string(), &roots, 1024); + hooks::BEFORE_READ_OPEN.with(|h| *h.borrow_mut() = None); + + let denial = res.expect_err("a symlink swapped in after the check is refused"); + assert_eq!(denial.code, codes::DENIED); + assert!( + denial.message.contains("became a symlink"), + "{}", + denial.message + ); + } + + #[test] + fn windows_posix_rename_refusal_simulated() { + // NTFS and ReFS on Windows 10 1709 and later support the POSIX + // rename, so a test hook (on this thread only) makes the rename + // fail as a refusing volume would, to show the write is refused + // with no fallback. + let t = WinTree::new("posix-refusal"); + let roots = t.roots(); + let target = t.p("refused_rename.txt"); + + hooks::REFUSE_POSIX_RENAME.with(|f| f.set(true)); + let res = write(&target, &roots, "data"); + hooks::REFUSE_POSIX_RENAME.with(|f| f.set(false)); + + let err = res.expect_err("POSIX rename refusal"); + assert_eq!(err.code, codes::DENIED); + assert!( + err.message + .contains("volume does not support POSIX replace rename"), + "{}", + err.message + ); + assert!( + !Path::new(&target).exists(), + "nothing was renamed into place" + ); + assert!(t.temps_in("").is_empty(), "leftover temp files"); + } + + #[test] + fn windows_failed_flush_stops_the_replace() { + let t = WinTree::new("flush-failure"); + let roots = t.roots(); + + hooks::FAIL_TEMP_FLUSH.with(|f| f.set(true)); + let res = write(&t.p("a.txt"), &roots, "unflushed"); + hooks::FAIL_TEMP_FLUSH.with(|f| f.set(false)); + + let err = res.expect_err("a failed flush refuses the rename"); + assert_eq!(err.code, codes::IO); + assert_eq!(std::fs::read_to_string(t.p("a.txt")).unwrap(), "inside"); + assert!(t.temps_in("").is_empty(), "leftover temp files"); + } + + #[test] + fn windows_file_root_grants_no_sibling() { + let t = WinTree::new("file-root"); + let file_path = t.p("a.txt"); + let roots = vec![file_path.clone()]; + + let res = read(&file_path, &roots, 1024).expect("read file root"); + assert_eq!(res["text"], "inside"); + let st = stat(&file_path, &roots).expect("stat file root"); + assert_eq!(st["kind"], "file"); + + let sibling = t.p("sub\\b.txt"); + let err = read(&sibling, &roots, 1024).expect_err("sibling denied"); + assert_eq!(err.code, codes::DENIED); + let err = list(&file_path, &roots).expect_err("a file root is not listed"); + assert_eq!(err.code, codes::DENIED); + + // Writing to the granted file's own path replaces its contents. + resolve(&file_path, &roots, Purpose::Write).expect("authorized"); + write(&file_path, &roots, "replaced").expect("replace file root"); + assert_eq!(std::fs::read_to_string(&file_path).unwrap(), "replaced"); + + let err_write = + write(&sibling, &roots, "sibling data").expect_err("sibling write denied"); + assert_eq!(err_write.code, codes::DENIED); + let err_write = write(&t.p("new.txt"), &roots, "new").expect_err("new sibling denied"); + assert_eq!(err_write.code, codes::DENIED); + assert!(!Path::new(&t.p("new.txt")).exists()); + } + + #[test] + fn windows_file_root_is_read_without_execute_access() { + let t = WinTree::new("file-root-no-execute"); + let file_path = t.p("a.txt"); + // Read for everyone, and no execute right for anyone. + set_dacl(Path::new(&file_path), "D:P(A;;FR;;;WD)"); + let roots = vec![file_path.clone()]; + + let res = read(&file_path, &roots, 1024).expect("read file root"); + assert_eq!(res["text"], "inside"); + } + + #[test] + fn windows_write_uses_posix_replace() { + let t = WinTree::new("write-posix"); + let roots = t.roots(); + let target = t.p("out.txt"); + + write(&target, &roots, "version 1").expect("write v1"); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "version 1"); + + write(&target, &roots, "version 2").expect("write v2"); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "version 2"); + assert!(t.temps_in("").is_empty(), "leftover temp files"); + } + + #[test] + fn windows_write_creates_file_in_subdirectory() { + let t = WinTree::new("write-subdir"); + let roots = t.roots(); + let target = t.p("sub\\created.txt"); + + write(&target, &roots, "created").expect("create in subdirectory"); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "created"); + assert!(t.temps_in("sub").is_empty(), "leftover temp files"); + + let missing_parent = t.p("nowhere\\x.txt"); + let err = write(&missing_parent, &roots, "x").expect_err("missing parent"); + assert_eq!(err.code, codes::DENIED); + + let dir_target = write(&t.p("sub"), &roots, "x").expect_err("directory target"); + assert_eq!(dir_target.code, codes::INVALID_ARGUMENTS); + } + + #[test] + fn windows_write_replaces_read_only_file() { + let t = WinTree::new("write-read-only"); + let roots = t.roots(); + let target = t.root.join("a.txt"); + let mut perms = std::fs::metadata(&target).unwrap().permissions(); + perms.set_readonly(true); + std::fs::set_permissions(&target, perms).expect("set read-only"); + + write(&target.display().to_string(), &roots, "replaced").expect("replace"); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "replaced"); + } + + #[test] + fn windows_hardlink_write_replaces_link() { + let t = WinTree::new("hardlink-replace"); + let roots = t.roots(); + let orig = t.root.join("orig.txt"); + let link = t.root.join("link.txt"); + std::fs::write(&orig, "initial content").expect("write orig"); + + std::fs::hard_link(&orig, &link).expect("create hardlink"); + assert_eq!(std::fs::read_to_string(&link).unwrap(), "initial content"); + + write(&link.display().to_string(), &roots, "new link content").expect("write link"); + + // The name was replaced; the other link keeps the old contents. + assert_eq!(std::fs::read_to_string(&link).unwrap(), "new link content"); + assert_eq!(std::fs::read_to_string(&orig).unwrap(), "initial content"); + } + + #[test] + fn windows_replace_keeps_the_protected_dacl() { + let t = WinTree::new("dacl-replace"); + let roots = t.roots(); + let target = t.root.join("protected.txt"); + std::fs::write(&target, "secret").expect("write target"); + set_dacl(&target, "D:P(A;;FA;;;WD)"); + let (flags, aces) = dacl_parts(&dacl_of(&target).expect("target DACL")); + assert!(flags.contains('P'), "the DACL was applied: {flags}"); + assert_eq!(aces, ["A;;FA;;;WD"], "the DACL was applied"); + + let observer = DaclObserver::default(); + write_call( + &target.display().to_string(), + &roots, + "replacement text", + "call-dacl-1", + Some(&observer), + ) + .expect("write call with dacl"); + + for (what, sddl) in [ + ("temporary file", observer.temp_dacl()), + ("result", dacl_of(&target).expect("result DACL")), + ] { + let (flags, aces) = dacl_parts(&sddl); + assert!(flags.contains('P'), "the {what} is protected: {sddl}"); + assert_eq!(aces, ["A;;FA;;;WD"], "the {what} keeps the DACL: {sddl}"); + } + assert_eq!( + std::fs::read_to_string(&target).unwrap(), + "replacement text" + ); + } + + #[test] + fn windows_create_inherits_the_folder_dacl() { + let t = WinTree::new("dacl-create"); + let roots = t.roots(); + let sub = t.root.join("sub"); + set_dacl(&sub, "D:P(A;OICI;FA;;;WD)"); + let new_file = sub.join("new_file.txt"); + + let observer = DaclObserver::default(); + write_call( + &new_file.display().to_string(), + &roots, + "initial created text", + "call-dacl-create-1", + Some(&observer), + ) + .expect("write call create with dacl"); + + for (what, sddl) in [ + ("temporary file", observer.temp_dacl()), + ("result", dacl_of(&new_file).expect("result DACL")), + ] { + let (flags, aces) = dacl_parts(&sddl); + assert!(!flags.contains('P'), "the {what} is not protected: {sddl}"); + assert_eq!( + aces, + ["A;ID;FA;;;WD"], + "the {what} carries the folder's entry, inherited: {sddl}" + ); + } + assert_eq!( + std::fs::read_to_string(&new_file).unwrap(), + "initial created text" + ); + } + + /// A test `TempLedger` that, when the write records its temporary + /// file, tries to move a directory out of the root. + struct MoveDuringWrite { + from: PathBuf, + to: PathBuf, + result: Mutex>>, + } + + impl TempLedger for MoveDuringWrite { + fn record(&self, _lease: &TempLease) -> Result, String> { + // No file beneath `from` is open yet, so the write's own + // handle on that directory is the only thing that can stop + // the move. + *self.result.lock().unwrap() = Some(std::fs::rename(&self.from, &self.to)); + Ok(Box::new(NoHold)) + } + } + + #[test] + fn windows_write_parent_cannot_be_moved_out_during_the_write() { + let t = WinTree::new("parent-move"); + let roots = t.roots(); + let moved = t.outside.join("sub-moved"); + let ledger = MoveDuringWrite { + from: t.root.join("sub"), + to: moved.clone(), + result: Mutex::new(None), + }; + + write_call( + &t.p("sub\\new.txt"), + &roots, + "data", + "call-move-1", + Some(&ledger), + ) + .expect("write"); + + let attempt = ledger + .result + .lock() + .unwrap() + .take() + .expect("move attempted"); + let err = attempt.expect_err("the held parent cannot be moved"); + assert_eq!( + err.raw_os_error(), + Some(test_ffi::ERROR_SHARING_VIOLATION), + "{err}" + ); + assert!(!moved.exists(), "nothing was moved outside the root"); + assert_eq!( + std::fs::read_to_string(t.p("sub\\new.txt")).unwrap(), + "data" + ); + } + + #[test] + fn windows_list_directory_root_and_subdirectory() { + let t = WinTree::new("list"); + let roots = t.roots(); + + let root_list = list(&t.root.display().to_string(), &roots).expect("list root"); + assert_eq!( + root_list, + json!([ + { "name": "a.txt", "kind": "file" }, + { "name": "sub", "kind": "dir" }, + ]) + ); + let sub_list = list(&t.p("sub"), &roots).expect("list sub"); + assert_eq!(sub_list, json!([{ "name": "b.txt", "kind": "file" }])); + + let missing = list(&t.p("missing"), &roots).expect_err("missing"); + assert_eq!(missing.code, codes::NOT_FOUND); + let file = list(&t.p("a.txt"), &roots).expect_err("a file"); + assert_eq!(file.code, codes::IO); + } + + #[test] + fn windows_failed_directory_query_is_an_error() { + let t = WinTree::new("list-failure"); + // The walk's handle on a directory root has no FILE_LIST_DIRECTORY, + // so querying it fails on the first call. + let vr = + walk_from_volume_root(&t.root.display().to_string(), SHARE_ALL).expect("walk root"); + let mut seen = 0; + let res = scan_directory(vr.handle.raw(), |_, _| { + seen += 1; + true + }); + assert_eq!(res, Err(ffi::STATUS_ACCESS_DENIED)); + assert_eq!(seen, 0); + } + + fn lease(dir: &Path, target: &str, temp: &str, roots: Vec) -> TempLease { + TempLease { + call_key: "k".to_owned(), + dir: dir.to_path_buf(), + target: OsString::from(target), + temp: OsString::from(temp), + roots, + } + } + + #[test] + fn windows_remove_temp_acts_in_the_recorded_directory() { + let t = WinTree::new("remove-temp-subdir"); + let temp = ".basal-call-k1.tmp"; + std::fs::write(t.root.join("sub").join(temp), "partial").unwrap(); + // The same name in the root is not the recorded file. + std::fs::write(t.root.join(temp), "decoy").unwrap(); + let l = lease(&t.root.join("sub"), "x.txt", temp, t.roots()); + + assert_eq!(remove_temp(&l), Ok(TempRemoval::Removed)); + assert!(!t.root.join("sub").join(temp).exists()); + assert!(t.root.join(temp).exists(), "the root's file is untouched"); + assert_eq!(remove_temp(&l), Ok(TempRemoval::Absent)); + } + + #[test] + fn windows_remove_temp_under_a_file_root() { + let t = WinTree::new("remove-temp-file-root"); + let temp = ".basal-call-k2.tmp"; + std::fs::write(t.root.join(temp), "partial").unwrap(); + let l = lease(&t.root, "a.txt", temp, vec![t.p("a.txt")]); + + assert_eq!(remove_temp(&l), Ok(TempRemoval::Removed)); + assert!(!t.root.join(temp).exists()); + } + + #[test] + fn windows_remove_temp_refuses_anything_but_a_regular_file() { + let t = WinTree::new("remove-temp-dir"); + let temp = ".basal-call-k3.tmp"; + std::fs::create_dir(t.root.join(temp)).unwrap(); + let l = lease(&t.root, "x.txt", temp, t.roots()); + + let Ok(TempRemoval::Refused(why)) = remove_temp(&l) else { + panic!("a directory under the temporary name is refused"); + }; + assert_eq!(why.code, codes::DENIED); + assert!(t.root.join(temp).is_dir(), "the directory is untouched"); + } + + #[test] + fn windows_remove_temp_with_a_missing_root_is_absent() { + let t = WinTree::new("remove-temp-gone"); + let gone = t.base.join("gone"); + let l = lease( + &gone, + "x.txt", + ".basal-call-k4.tmp", + vec![gone.display().to_string()], + ); + assert_eq!(remove_temp(&l), Ok(TempRemoval::Absent)); + } + + #[test] + fn windows_remove_legacy_temps_removes_only_regular_files() { + let t = WinTree::new("legacy-temps"); + std::fs::write(t.root.join(".basal-12-3.tmp"), "old").unwrap(); + std::fs::create_dir(t.root.join(".basal-45-6.tmp")).unwrap(); + std::fs::write(t.root.join(".basal-x-1.tmp"), "not legacy").unwrap(); + + assert_eq!(remove_legacy_temps(&t.p("a.txt"), &t.roots()), Ok(1)); + assert!(!t.root.join(".basal-12-3.tmp").exists()); + assert!(t.root.join(".basal-45-6.tmp").is_dir()); + assert!(t.root.join(".basal-x-1.tmp").exists()); + + // Also beside a file root: the directory a write to that file + // root acts in. + std::fs::write(t.root.join(".basal-7-8.tmp"), "old").unwrap(); + assert_eq!(remove_legacy_temps(&t.p("a.txt"), &[t.p("a.txt")]), Ok(1)); + assert!(!t.root.join(".basal-7-8.tmp").exists()); + } + + #[test] + fn windows_temp_name_holding_a_directory_is_not_removed() { + let t = WinTree::new("temp-collision-dir"); + let roots = t.roots(); + let temp_dir = t.root.join(".basal-call-collide.tmp"); + std::fs::create_dir(&temp_dir).unwrap(); + + let err = write_call(&t.p("x.txt"), &roots, "data", "collide", None) + .expect_err("a directory under the temporary name stops the write"); + assert_eq!(err.code, codes::IO); + assert!(temp_dir.is_dir(), "the directory is untouched"); + assert!(!Path::new(&t.p("x.txt")).exists()); + } + + #[test] + fn windows_temp_name_holding_a_regular_file_is_replaced() { + let t = WinTree::new("temp-collision-file"); + let roots = t.roots(); + std::fs::write(t.root.join(".basal-call-again.tmp"), "earlier send").unwrap(); + + write_call(&t.p("x.txt"), &roots, "data", "again", None).expect("write"); + assert_eq!(std::fs::read_to_string(t.p("x.txt")).unwrap(), "data"); + assert!(t.temps_in("").is_empty(), "leftover temp files"); + } + + #[test] + fn windows_volume_root_grants_its_children() { + let t = WinTree::new("volume-root"); + let drive = t.root.to_str().unwrap()[..3].to_owned(); + let roots = vec![drive]; + + resolve(&t.p("a.txt"), &roots, Purpose::Read).expect("resolve"); + let val = read(&t.p("a.txt"), &roots, 1024).expect("read under a volume root"); + assert_eq!(val["text"], "inside"); + let st = stat(&t.p("sub\\b.txt"), &roots).expect("stat under a volume root"); + assert_eq!(st["size"], 6); + write(&t.p("v.txt"), &roots, "volume").expect("write under a volume root"); + assert_eq!(std::fs::read_to_string(t.p("v.txt")).unwrap(), "volume"); + } + } +} diff --git a/crates/basal-host/src/builtins/git.rs b/crates/basal-host/src/builtins/git.rs index c718e66..d17af8e 100644 --- a/crates/basal-host/src/builtins/git.rs +++ b/crates/basal-host/src/builtins/git.rs @@ -15,14 +15,29 @@ #[cfg(test)] mod tests; +#[cfg(windows)] +pub mod windows; + +#[cfg(not(windows))] use std::io::Read; +#[cfg(not(windows))] use std::os::fd::AsRawFd; +#[cfg(not(windows))] use std::os::unix::process::CommandExt; -use std::path::{Component, Path, PathBuf}; -use std::process::{Child, Command, ExitStatus, Stdio}; +#[cfg(not(windows))] +use std::path::PathBuf; +use std::path::{Component, Path}; +#[cfg(not(windows))] +use std::process::Command; +#[cfg(not(windows))] +use std::process::{Child, ExitStatus, Stdio}; +#[cfg(not(windows))] use std::sync::mpsc::{self, Sender}; +#[cfg(not(windows))] use std::thread::{self, JoinHandle}; -use std::time::{Duration, Instant}; +use std::time::Duration; +#[cfg(not(windows))] +use std::time::Instant; use basal_proto::Primitive; use serde_json::{Value, json}; @@ -31,7 +46,7 @@ use super::{Denial, codes, expand_home, options, string_arg}; /// How long one git command may run. pub const TIMEOUT: Duration = Duration::from_secs(20); -const STDERR_BYTES: usize = 4096; +pub(crate) const STDERR_BYTES: usize = 4096; /// The most output one git command may produce: a log, a blob or a diff. pub const MAX_OUTPUT_BYTES: usize = super::MAX_TEXT_RESULT_BYTES; /// `git.log`'s default and largest entry counts. @@ -210,6 +225,7 @@ pub fn parse(primitive: Primitive, args: &Value) -> Result { /// The repository's real path, which must be one of the approved /// repositories (each resolved the same way). A subdirectory of an approved /// repository is not itself approved. +#[cfg(not(windows))] pub fn repo(repo: &str, repos: &[String]) -> Result { let path = expand_home(repo) .ok_or_else(|| Denial::invalid(format!("{repo:?} is not an absolute path")))?; @@ -223,6 +239,29 @@ pub fn repo(repo: &str, repos: &[String]) -> Result { if approved { Ok(real) } else { Err(refused()) } } +#[cfg(windows)] +pub(crate) fn repo( + repo: &str, + repos: &[String], +) -> Result { + use super::fs::windows::pin_directory; + let path = expand_home(repo).ok_or_else(|| Denial::invalid("repository must be absolute"))?; + let path = path + .to_str() + .ok_or_else(|| Denial::invalid("repository is not Unicode"))?; + let refused = || Denial::denied("repository is outside the manifest's repositories or missing"); + let pinned = pin_directory(path).map_err(|_| refused())?; + for approved in repos.iter().filter_map(|r| expand_home(r)) { + if let Some(approved) = approved.to_str() + && let Ok(root) = pin_directory(approved) + && pinned.same_identity(&root) + { + return Ok(pinned); + } + } + Err(refused()) +} + /// The only way the built-ins run git: a `git -C ` command that can /// read the repository and run nothing else. /// @@ -246,6 +285,7 @@ pub fn repo(repo: &str, repos: &[String]) -> Result { /// /// Repositories must belong to the service uid. Global `safe.directory` /// exceptions are deliberately ignored, rather than trusting foreign config. +#[cfg(not(windows))] pub fn hardened_command(repo: &Path) -> Command { let mut command = Command::new("git"); command.env_clear(); @@ -287,6 +327,9 @@ pub fn hardened_command(repo: &Path) -> Command { command } +#[cfg(windows)] +pub use windows::hardened_command; + /// How a git command ended. pub struct Ran { pub success: bool, @@ -297,10 +340,15 @@ pub struct Ran { /// Runs a git command under [`TIMEOUT`], refusing output over /// [`MAX_OUTPUT_BYTES`]. +#[cfg(not(windows))] pub fn run_command(command: Command) -> Result { run_command_until(command, None) } +#[cfg(windows)] +pub use windows::run_command; + +#[cfg(not(windows))] fn run_command_until(mut command: Command, max_lines: Option) -> Result { command.process_group(0); let child = command @@ -383,6 +431,7 @@ fn run_command_until(mut command: Command, max_lines: Option) -> Result Denial { Denial::new( codes::TIMEOUT, @@ -390,17 +439,20 @@ fn timeout_denial() -> Denial { ) } +#[cfg(not(windows))] enum Completion { Exit(std::io::Result), Stdout(Result), Stderr(Result), } +#[cfg(not(windows))] struct ProcessGroup { pid: u32, child: Option, waiter: Option>, } +#[cfg(not(windows))] impl ProcessGroup { fn new(child: Child) -> Self { Self { @@ -425,6 +477,7 @@ impl ProcessGroup { } } } +#[cfg(not(windows))] impl Drop for ProcessGroup { fn drop(&mut self) { self.kill(); @@ -437,11 +490,13 @@ impl Drop for ProcessGroup { } } +#[cfg(not(windows))] struct PipeOutput { bytes: Vec, limit_reached: bool, } +#[cfg(not(windows))] fn drain_pipe( mut pipe: Option, cap: usize, @@ -517,15 +572,22 @@ fn drain_pipe( } fn git(repo: &Path, args: &[&str]) -> Result { + #[cfg(not(windows))] let mut command = hardened_command(repo); + #[cfg(windows)] + let mut command = hardened_command(repo)?; command.args(args); run_command(command) } +#[cfg(not(windows))] fn run_tags_command(command: Command) -> Result { run_command_until(command, Some(MAX_TAGS)) } +#[cfg(windows)] +use windows::run_tags_command; + fn failed(ran: &Ran) -> Denial { Denial::new(codes::GIT, ran.stderr.trim().to_owned()) } @@ -655,7 +717,10 @@ pub fn run(repo_arg: &str, repos: &[String], op: &Op) -> Result { if let Some(p) = pattern { list.push(p); } + #[cfg(not(windows))] let mut command = hardened_command(&dir); + #[cfg(windows)] + let mut command = hardened_command(&dir)?; command.args(&list); let ran = run_tags_command(command)?; if !ran.success { diff --git a/crates/basal-host/src/builtins/git/fixtures/windows_child.rs b/crates/basal-host/src/builtins/git/fixtures/windows_child.rs new file mode 100644 index 0000000..5f3a830 --- /dev/null +++ b/crates/basal-host/src/builtins/git/fixtures/windows_child.rs @@ -0,0 +1,80 @@ +// Compiled natively by the Windows tests. This child uses Rust's real Windows +// argv parser, independently of the encoder in the parent. +use std::io::{Read, Write}; +use std::os::windows::{ffi::OsStrExt, process::CommandExt}; +use std::process::{Command, Stdio}; +use std::time::Duration; + +fn main() { + let mut arguments = std::env::args_os().skip(1); + let mode = arguments.next().unwrap(); + match mode.to_str().unwrap() { + "argv" => { + for argument in arguments { + for unit in argument.encode_wide() { + print!("{unit:04x}"); + } + println!(); + } + } + "mark" => std::fs::write(arguments.next().unwrap(), "worked").unwrap(), + "stdin" => { + let mut bytes = Vec::new(); + std::io::stdin().read_to_end(&mut bytes).unwrap(); + assert!(bytes.is_empty()); + println!("inert"); + } + "hold" => { + std::fs::write(arguments.next().unwrap(), std::process::id().to_string()).unwrap(); + std::thread::sleep(Duration::from_secs(300)); + } + "spawn" | "post-exit" => { + let pid = arguments.next().unwrap(); + let ready = arguments.next().unwrap(); + let mut child = Command::new(std::env::current_exe().unwrap()) + .arg("hold") + .arg(&pid) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + .unwrap(); + while !std::path::Path::new(&pid).exists() { + std::thread::sleep(Duration::from_millis(2)); + } + std::fs::write(&ready, "ready").unwrap(); + if mode == "post-exit" { + while !std::path::Path::new(&ready) + .with_extension("release") + .exists() + { + std::thread::sleep(Duration::from_millis(2)); + } + } else { + let _ = child.wait(); + } + } + "lines" => { + let width: usize = arguments.next().unwrap().to_str().unwrap().parse().unwrap(); + let mut stdout = std::io::stdout().lock(); + // More bytes than the cap even with only 199 completed lines when + // width is large. An unbounded collector never reaches process exit. + for _ in 0..4000 { + writeln!(stdout, "{}", "x".repeat(width)).unwrap(); + } + stdout.flush().unwrap(); + std::thread::sleep(Duration::from_secs(300)); + } + "breakaway" => { + let marker = arguments.next().unwrap(); + let flags: u32 = arguments.next().unwrap().to_str().unwrap().parse().unwrap(); + let status = Command::new(std::env::current_exe().unwrap()) + .arg("mark") + .arg(marker) + .creation_flags(flags) + .status(); + assert!(status.is_err(), "breakaway child was allowed"); + println!("breakaway refused"); + } + other => panic!("unknown mode {other}"), + } +} diff --git a/crates/basal-host/src/builtins/git/tests.rs b/crates/basal-host/src/builtins/git/tests.rs index 53862f9..08d1147 100644 --- a/crates/basal-host/src/builtins/git/tests.rs +++ b/crates/basal-host/src/builtins/git/tests.rs @@ -1,4 +1,6 @@ use super::*; +#[cfg(unix)] +use std::process::Stdio; #[test] fn overflowing_or_malformed_log_timestamps_are_refused_not_dropped() { @@ -22,6 +24,7 @@ fn show_distinguishes_missing_blob_from_broken_repository() { } #[test] +#[cfg(unix)] fn tag_output_is_bounded_while_reading_not_after_completion() { let mut command = Command::new("/bin/sh"); command @@ -41,6 +44,7 @@ fn tag_output_is_bounded_while_reading_not_after_completion() { } #[test] +#[cfg(unix)] fn git_waiter_timeout_kills_the_process_group_and_reaps_the_child() { let directory = std::env::temp_dir().join(format!("basal-git-timeout-{}", std::process::id())); std::fs::create_dir_all(&directory).unwrap(); diff --git a/crates/basal-host/src/builtins/git/windows.rs b/crates/basal-host/src/builtins/git/windows.rs new file mode 100644 index 0000000..8136037 --- /dev/null +++ b/crates/basal-host/src/builtins/git/windows.rs @@ -0,0 +1,1242 @@ +//! Windows git reads use a private empty global config, explicit executable +//! paths, and a non-breakaway kill-on-close job. The child remains suspended +//! until membership in that exact job is verified. + +use std::collections::BTreeMap; +use std::ffi::{OsStr, OsString}; +use std::os::windows::ffi::{OsStrExt, OsStringExt}; +use std::path::{Component, Path, PathBuf, Prefix}; +use std::process::Command; +use std::sync::{ + OnceLock, + atomic::{AtomicBool, Ordering}, + mpsc, +}; +use std::thread; +use std::time::{Duration, Instant}; + +use windows_sys::Win32::{ + Foundation::{ + CloseHandle, ERROR_BROKEN_PIPE, HANDLE, HANDLE_FLAG_INHERIT, INVALID_HANDLE_VALUE, + LocalFree, SetHandleInformation, WAIT_OBJECT_0, WAIT_TIMEOUT, + }, + Security::{ + Authorization::ConvertStringSecurityDescriptorToSecurityDescriptorW, + Cryptography::{BCRYPT_USE_SYSTEM_PREFERRED_RNG, BCryptGenRandom}, + SECURITY_ATTRIBUTES, + }, + Storage::FileSystem::{ + CREATE_NEW, CreateDirectoryW, CreateFileW, FILE_ATTRIBUTE_NORMAL, + FILE_FLAG_OPEN_REPARSE_POINT, FILE_GENERIC_READ, FILE_SHARE_READ, FILE_SHARE_WRITE, + OPEN_EXISTING, ReadFile, + }, + System::{ + JobObjects::{ + CreateJobObjectW, IsProcessInJob, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, + SetInformationJobObject, TerminateJobObject, + }, + Pipes::{CreatePipe, PeekNamedPipe}, + Threading::{ + CREATE_NO_WINDOW, CREATE_SUSPENDED, CREATE_UNICODE_ENVIRONMENT, CreateProcessW, + DeleteProcThreadAttributeList, EXTENDED_STARTUPINFO_PRESENT, GetExitCodeProcess, + InitializeProcThreadAttributeList, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, + PROC_THREAD_ATTRIBUTE_JOB_LIST, PROCESS_INFORMATION, ResumeThread, + STARTF_USESTDHANDLES, STARTUPINFOEXW, TerminateProcess, UpdateProcThreadAttribute, + WaitForSingleObject, + }, + }, +}; + +use super::{MAX_OUTPUT_BYTES, MAX_TAGS, Ran, STDERR_BYTES, TIMEOUT, codes}; +use crate::builtins::{ + Denial, + fs::windows::{PinnedDirectory, pin_directory}, +}; + +fn native_error(what: &str) -> Denial { + Denial::new( + codes::GIT, + format!("{what}: {}", std::io::Error::last_os_error()), + ) +} + +fn wide(s: impl AsRef) -> Result, Denial> { + let mut value: Vec = s.as_ref().encode_wide().collect(); + if value.contains(&0) { + return Err(Denial::invalid("NUL in Windows process input")); + } + value.push(0); + Ok(value) +} + +/// Only a verbatim drive path can be converted to an ordinary drive path. +/// UNC, device, volume and drive-relative names are not supported by the fs policy. +fn drive_path(path: &Path) -> Result { + let mut components = path.components(); + match (components.next(), components.next()) { + (Some(Component::Prefix(prefix)), Some(Component::RootDir)) => match prefix.kind() { + Prefix::Disk(_) => Ok(path.to_path_buf()), + Prefix::VerbatimDisk(_) => { + let units: Vec = path.as_os_str().encode_wide().skip(4).collect(); + Ok(PathBuf::from(OsString::from_wide(&units))) + } + _ => Err(Denial::invalid("only absolute drive paths are supported")), + }, + _ => Err(Denial::invalid("only absolute drive paths are supported")), + } +} + +fn canonical_image(path: &Path) -> Result { + let path = drive_path(path)?; + if !path + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("exe")) + || !path.is_file() + { + return Err(Denial::new( + codes::GIT, + "the image must be an existing .exe", + )); + } + drive_path(&std::fs::canonicalize(path).map_err(|e| Denial::new(codes::GIT, e.to_string()))?) +} + +/// PATH is operator-controlled; only absolute directories participate. This +/// establishes a stable image path, not a signature/trust check of the binary. +fn resolve_git_binary(program: &OsStr, path_env: Option<&OsStr>) -> Result { + let p = Path::new(program); + if p.extension() + .is_some_and(|e| e.eq_ignore_ascii_case("bat") || e.eq_ignore_ascii_case("cmd")) + { + return Err(Denial::new(codes::GIT, "git scripts are refused")); + } + if program != "git" && program != "git.exe" { + return canonical_image(p); + } + if let Some(path_env) = path_env { + for directory in std::env::split_paths(path_env) { + let Ok(directory) = drive_path(&directory) else { + continue; + }; + let image = directory.join("git.exe"); + if image.is_file() { + return canonical_image(&image); + } + if directory.join("git.cmd").is_file() || directory.join("git.bat").is_file() { + return Err(Denial::new(codes::GIT, "git PATH entry is a script")); + } + } + } + Err(Denial::new( + codes::GIT, + "git.exe was not found in absolute PATH directories", + )) +} + +fn resolve_git() -> Result<&'static Path, Denial> { + static IMAGE: OnceLock> = OnceLock::new(); + match IMAGE + .get_or_init(|| resolve_git_binary(OsStr::new("git"), std::env::var_os("PATH").as_deref())) + { + Ok(image) => Ok(image), + Err(error) => Err(error.clone()), + } +} + +struct OwnedHandle(HANDLE); +// SAFETY: kernel handles may move across threads. Each owner closes once. +unsafe impl Send for OwnedHandle {} +impl Drop for OwnedHandle { + fn drop(&mut self) { + // SAFETY: all constructors check for invalid handles before taking ownership. + unsafe { + CloseHandle(self.0); + } + } +} + +struct SecurityDescriptor(*mut std::ffi::c_void); +impl SecurityDescriptor { + fn private() -> Result { + let sddl = wide("D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;OW)")?; + let mut descriptor = std::ptr::null_mut(); + // SAFETY: the null-terminated SDDL and output pointer live through the call. + if unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + sddl.as_ptr(), + 1, + &mut descriptor, + std::ptr::null_mut(), + ) + } == 0 + { + return Err(native_error("private DACL")); + } + Ok(Self(descriptor)) + } + fn attributes(&self) -> SECURITY_ATTRIBUTES { + SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + lpSecurityDescriptor: self.0, + bInheritHandle: 0, + } + } +} +impl Drop for SecurityDescriptor { + fn drop(&mut self) { + // SAFETY: the conversion API allocated this descriptor with LocalAlloc. + unsafe { + LocalFree(self.0); + } + } +} + +/// Guards precede cleanup: the directory cannot be replaced and the zero-byte +/// file cannot be written/deleted while git reads it. Each call gets a new DACL +/// protected directory, created exclusively using a cryptographically random name. +struct Isolation { + directory: PathBuf, + pins: Vec, + config: Option, +} +impl Isolation { + fn new() -> Result { + Self::in_base(&std::env::temp_dir()) + } + + fn in_base(base: &Path) -> Result { + let base = drive_path(base)?; + // GetTempPathW normally includes a trailing separator. Remove only that + // separator from this host-selected base, not from repository inputs; + // the fs walk still rejects ambiguous components and reparse points. + let mut units: Vec = base.as_os_str().encode_wide().collect(); + while units.len() > 3 && units.last() == Some(&(b'\\' as u16)) { + units.pop(); + } + let base = PathBuf::from(OsString::from_wide(&units)); + let base = base + .to_str() + .ok_or_else(|| Denial::invalid("temp directory is not Unicode"))?; + let base_pin = pin_directory(base)?; + let descriptor = SecurityDescriptor::private()?; + let attributes = descriptor.attributes(); + let mut random = [0u8; 16]; + // SAFETY: random is writable for exactly the requested byte count. + if unsafe { + BCryptGenRandom( + std::ptr::null_mut(), + random.as_mut_ptr(), + random.len() as u32, + BCRYPT_USE_SYSTEM_PREFERRED_RNG, + ) + } < 0 + { + return Err(Denial::new( + codes::GIT, + "private directory randomness failed", + )); + } + let name: String = random.iter().map(|b| format!("{b:02x}")).collect(); + let directory = base_pin.path().join(format!("basal-git-{name}")); + let path = wide(&directory)?; + // SAFETY: path and private security descriptor live through the call. + // CreateDirectory refuses an existing name; no existing contents are trusted. + if unsafe { CreateDirectoryW(path.as_ptr(), &attributes) } == 0 { + return Err(native_error("create private git directory")); + } + let mut isolation = Self { + directory, + pins: vec![base_pin], + config: None, + }; + isolation.pins.push(pin_directory( + isolation + .directory + .to_str() + .ok_or_else(|| Denial::invalid("private directory is not Unicode"))?, + )?); + let hooks = isolation.directory.join("hooks"); + if unsafe { CreateDirectoryW(wide(&hooks)?.as_ptr(), &attributes) } == 0 { + return Err(native_error("create private hooks directory")); + } + isolation.pins.push(pin_directory( + hooks + .to_str() + .ok_or_else(|| Denial::invalid("hooks directory is not Unicode"))?, + )?); + let config = wide(isolation.directory.join("global.config"))?; + // SAFETY: CREATE_NEW is exclusive, OPEN_REPARSE_POINT never follows a link, + // and read sharing only prevents both writes and deletion for the call. + let handle = unsafe { + CreateFileW( + config.as_ptr(), + FILE_GENERIC_READ, + FILE_SHARE_READ, + &attributes, + CREATE_NEW, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, + std::ptr::null_mut(), + ) + }; + if handle == INVALID_HANDLE_VALUE { + return Err(native_error("create empty global config")); + } + isolation.config = Some(OwnedHandle(handle)); + Ok(isolation) + } +} +impl Drop for Isolation { + fn drop(&mut self) { + self.config.take(); + self.pins.clear(); + let _ = std::fs::remove_dir_all(&self.directory); + } +} + +pub struct HardenedCommand { + command: Command, + _isolation: Isolation, +} +impl std::ops::Deref for HardenedCommand { + type Target = Command; + fn deref(&self) -> &Command { + &self.command + } +} +impl std::ops::DerefMut for HardenedCommand { + fn deref_mut(&mut self) -> &mut Command { + &mut self.command + } +} + +/// The resources selected by this recipe are owned until the child tree exits. +pub fn hardened_command(repo: &Path) -> Result { + let isolation = Isolation::new()?; + let repo = drive_path(repo)?; + let mut command = Command::new(resolve_git()?); + command.env_clear(); + for key in ["PATH", "SystemRoot", "USERPROFILE"] { + if let Some(value) = std::env::var_os(key) { + command.env(key, value); + } + } + command + .env("GIT_CONFIG_NOSYSTEM", "1") + .env( + "GIT_CONFIG_GLOBAL", + isolation.directory.join("global.config"), + ) + .env("LC_ALL", "C") + .env("GIT_TERMINAL_PROMPT", "0") + .env("GIT_NO_LAZY_FETCH", "1"); + if let Some(parent) = repo.parent() { + command.env("GIT_CEILING_DIRECTORIES", parent); + } + let mut hooks = OsString::from("core.hooksPath="); + hooks.push(isolation.directory.join("hooks")); + command + .arg("-C") + .arg(repo) + .args([ + "--no-optional-locks", + "--no-pager", + "--literal-pathspecs", + "-c", + "core.fsmonitor=false", + "-c", + ]) + .arg(hooks) + .args([ + "-c", + "core.pager=cat", + "-c", + "log.showSignature=false", + "-c", + "protocol.allow=never", + ]); + Ok(HardenedCommand { + command, + _isolation: isolation, + }) +} + +/// Microsoft CRT encoding over UTF-16 units (including unpaired surrogates). +/// Backslashes before quotes and before the closing quote must be doubled. +fn append_windows_arg(command: &mut Vec, argument: &OsStr) -> Result<(), Denial> { + let units: Vec = argument.encode_wide().collect(); + if units.contains(&0) { + return Err(Denial::invalid("NUL in process argument")); + } + command.push(b'"' as u16); + let mut slashes = 0; + for unit in units { + if unit == b'\\' as u16 { + slashes += 1; + continue; + } + let count = if unit == b'"' as u16 { + 2 * slashes + 1 + } else { + slashes + }; + command.extend(std::iter::repeat_n(b'\\' as u16, count)); + command.push(unit); + slashes = 0; + } + command.extend(std::iter::repeat_n(b'\\' as u16, 2 * slashes)); + command.push(b'"' as u16); + Ok(()) +} + +struct AttributeList { + buffer: Vec, + jobs: Box<[HANDLE; 1]>, + handles: Box<[HANDLE; 3]>, +} +impl AttributeList { + fn new(job: HANDLE, handles: [HANDLE; 3]) -> Result { + let mut size = 0; + // SAFETY: the first call queries storage size; no attribute list exists yet. + unsafe { + InitializeProcThreadAttributeList(std::ptr::null_mut(), 2, 0, &mut size); + } + if size == 0 { + return Err(native_error("attribute list size")); + } + let mut buffer = vec![0; size.div_ceil(std::mem::size_of::())]; + if unsafe { InitializeProcThreadAttributeList(buffer.as_mut_ptr().cast(), 2, 0, &mut size) } + == 0 + { + return Err(native_error("initialize attribute list")); + } + let mut list = Self { + buffer, + jobs: Box::new([job]), + handles: Box::new(handles), + }; + // SAFETY: both payloads have stable boxed addresses. Drop deletes the + // attribute list before Rust drops either payload or its backing storage. + for (attribute, payload, length) in [ + ( + PROC_THREAD_ATTRIBUTE_JOB_LIST as usize, + list.jobs.as_mut_ptr(), + std::mem::size_of_val(list.jobs.as_ref()), + ), + ( + PROC_THREAD_ATTRIBUTE_HANDLE_LIST as usize, + list.handles.as_mut_ptr(), + std::mem::size_of_val(list.handles.as_ref()), + ), + ] { + if unsafe { + UpdateProcThreadAttribute( + list.pointer(), + 0, + attribute, + payload.cast(), + length, + std::ptr::null_mut(), + std::ptr::null(), + ) + } == 0 + { + return Err(native_error("set process attribute")); + } + } + Ok(list) + } + fn pointer(&mut self) -> *mut std::ffi::c_void { + self.buffer.as_mut_ptr().cast() + } +} +impl Drop for AttributeList { + fn drop(&mut self) { + // SAFETY: initialization succeeded and backing/payload storage is still live. + unsafe { + DeleteProcThreadAttributeList(self.pointer()); + } + } +} + +struct ProcessJob { + process: OwnedHandle, + job: Option, + terminated: bool, + #[cfg(test)] + fail_termination_once: bool, +} +impl ProcessJob { + fn terminate(&mut self) -> Result<(), Denial> { + if self.terminated { + return Ok(()); + } + #[cfg(test)] + if std::mem::take(&mut self.fail_termination_once) { + return Err(Denial::new(codes::GIT, "injected job termination failure")); + } + if let Some(job) = &self.job { + // SAFETY: the job is exclusively owned and cannot close during this call. + if unsafe { TerminateJobObject(job.0, 1) } == 0 { + return Err(native_error("terminate git job")); + } + } + self.terminated = true; + Ok(()) + } + fn stop_tree(&mut self) { + if self.terminate().is_err() { + let _ = self.terminate(); + } + // Last-handle close is the kill-on-close fallback, before any joins. + self.job.take(); + } +} +impl Drop for ProcessJob { + fn drop(&mut self) { + self.stop_tree(); + } +} + +fn pipe(attributes: &SECURITY_ATTRIBUTES) -> Result<(OwnedHandle, OwnedHandle), Denial> { + let mut read = std::ptr::null_mut(); + let mut write = std::ptr::null_mut(); + // SAFETY: output pointers and security attributes are valid for this call. + if unsafe { CreatePipe(&mut read, &mut write, attributes, 0) } == 0 { + return Err(native_error("create git pipe")); + } + let read = OwnedHandle(read); + let write = OwnedHandle(write); + if unsafe { SetHandleInformation(read.0, HANDLE_FLAG_INHERIT, 0) } == 0 { + return Err(native_error("clear pipe inheritance")); + } + Ok((read, write)) +} + +pub struct CommandInput { + command: Command, + _isolation: Option, +} +impl From for CommandInput { + fn from(value: HardenedCommand) -> Self { + Self { + command: value.command, + _isolation: Some(value._isolation), + } + } +} +impl From for CommandInput { + fn from(command: Command) -> Self { + Self { + command, + _isolation: None, + } + } +} + +fn environment(command: &Command, hardened: bool) -> Result, Denial> { + fn key(value: &OsStr) -> Vec { + value + .encode_wide() + .map(|c| { + if (b'a' as u16..=b'z' as u16).contains(&c) { + c - 32 + } else { + c + } + }) + .collect() + } + let mut entries = BTreeMap::new(); + if !hardened { + for (name, value) in std::env::vars_os() { + entries.insert(key(&name), (name, value)); + } + } + for (name, value) in command.get_envs() { + if let Some(value) = value { + entries.insert(key(name), (name.to_owned(), value.to_owned())); + } else { + entries.remove(&key(name)); + } + } + let mut block = Vec::new(); + for (_, (name, value)) in entries { + let name = wide(name)?; + block.extend_from_slice(&name[..name.len() - 1]); + block.push(b'=' as u16); + block.extend(wide(value)?); + } + if block.is_empty() { + block.push(0); + } + block.push(0); + Ok(block) +} + +/// The optional observer runs while the primary thread is still suspended. It +/// is used by native tests to inspect the exact job, not just any inherited job. +fn spawn_job_command( + input: &CommandInput, + observer: impl FnOnce(&ProcessJob) -> Result<(), Denial>, +) -> Result<(ProcessJob, OwnedHandle, OwnedHandle), Denial> { + spawn_with_argv0(input, observer, None) +} + +fn spawn_with_argv0( + input: &CommandInput, + observer: impl FnOnce(&ProcessJob) -> Result<(), Denial>, + argv0: Option<&OsStr>, +) -> Result<(ProcessJob, OwnedHandle, OwnedHandle), Denial> { + let command = &input.command; + let program = command.get_program(); + let image = if program == "git" || program == "git.exe" { + resolve_git()?.to_path_buf() + } else { + canonical_image(Path::new(program))? + }; + let application = wide(&image)?; + let mut command_line = Vec::new(); + append_windows_arg(&mut command_line, argv0.unwrap_or(image.as_os_str()))?; + for argument in command.get_args() { + command_line.push(b' ' as u16); + append_windows_arg(&mut command_line, argument)?; + } + command_line.push(0); + if command_line.len() > 32767 { + return Err(Denial::invalid("Windows command line is too long")); + } + let env = environment(command, input._isolation.is_some())?; + let cwd = command + .get_current_dir() + .map(|p| drive_path(p).and_then(wide)) + .transpose()?; + let cwd_pointer = cwd.as_ref().map_or(std::ptr::null(), |p| p.as_ptr()); + let job = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) }; + if job.is_null() { + return Err(native_error("create git job")); + } + let job = OwnedHandle(job); + let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { std::mem::zeroed() }; + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + if unsafe { + SetInformationJobObject( + job.0, + JobObjectExtendedLimitInformation, + (&limits as *const JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(), + std::mem::size_of_val(&limits) as u32, + ) + } == 0 + { + return Err(native_error("git job limits")); + } + // Every Windows spawn in basal makes its child's handles inheritable only + // under this process-wide lock, and closes its copies before releasing + // it, so no concurrent spawn can copy them. Taken before the first + // inheritable handle exists; on an early return those handles, declared + // later, are closed before the lock is released. + let spawn_lock = basal_launch::spawn_lock(); + let inherit = SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + lpSecurityDescriptor: std::ptr::null_mut(), + bInheritHandle: 1, + }; + // NUL is a device, never a reopen of the trusted global configuration file. + let nul = wide("NUL")?; + let stdin = unsafe { + CreateFileW( + nul.as_ptr(), + FILE_GENERIC_READ, + FILE_SHARE_READ | FILE_SHARE_WRITE, + &inherit, + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, + std::ptr::null_mut(), + ) + }; + if stdin == INVALID_HANDLE_VALUE { + return Err(native_error("open inert stdin")); + } + let stdin = OwnedHandle(stdin); + let (stdout, stdout_writer) = pipe(&inherit)?; + let (stderr, stderr_writer) = pipe(&inherit)?; + let mut attributes = AttributeList::new(job.0, [stdin.0, stdout_writer.0, stderr_writer.0])?; + let mut startup: STARTUPINFOEXW = unsafe { std::mem::zeroed() }; + startup.StartupInfo.cb = std::mem::size_of::() as u32; + startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + startup.StartupInfo.hStdInput = stdin.0; + startup.StartupInfo.hStdOutput = stdout_writer.0; + startup.StartupInfo.hStdError = stderr_writer.0; + startup.lpAttributeList = attributes.pointer(); + let mut process: PROCESS_INFORMATION = unsafe { std::mem::zeroed() }; + // SAFETY: buffers, owned attribute payloads, handles and startup structure + // remain live through creation; lpApplicationName is the canonical image. + if unsafe { + CreateProcessW( + application.as_ptr(), + command_line.as_mut_ptr(), + std::ptr::null(), + std::ptr::null(), + 1, + EXTENDED_STARTUPINFO_PRESENT + | CREATE_NO_WINDOW + | CREATE_UNICODE_ENVIRONMENT + | CREATE_SUSPENDED, + env.as_ptr().cast(), + cwd_pointer, + &startup.StartupInfo, + &mut process, + ) + } == 0 + { + return Err(native_error("start git")); + } + #[cfg(test)] + spawn_lock_tests::created(); + // The child now holds its own copies of its three standard handles, so + // the parent closes its copies before releasing the spawn lock. That + // also lets the pipes report end of file once git exits. + drop(attributes); + drop((stdin, stdout_writer, stderr_writer)); + drop(spawn_lock); + let primary_thread = OwnedHandle(process.hThread); + let guard = ProcessJob { + process: OwnedHandle(process.hProcess), + job: Some(job), + terminated: false, + #[cfg(test)] + fail_termination_once: false, + }; + let mut member = 0; + // Verify the exact job before the child can perform any work. A failed + // verification drops the job and kills the still-suspended process. + if unsafe { IsProcessInJob(guard.process.0, guard.job.as_ref().unwrap().0, &mut member) } == 0 + || member == 0 + { + // If assignment ever fails, job closure cannot kill an unassigned child. + // It is still suspended and its primary thread has never done work. + unsafe { + TerminateProcess(guard.process.0, 1); + } + return Err(Denial::new( + codes::GIT, + "git did not join its containment job", + )); + } + observer(&guard)?; + if unsafe { ResumeThread(primary_thread.0) } != 1 { + return Err(Denial::new( + codes::GIT, + "git primary thread was not suspended exactly once", + )); + } + Ok((guard, stdout, stderr)) +} + +struct PipeOutput { + bytes: Vec, + limit_reached: bool, +} + +fn drain_pipe( + handle: HANDLE, + cap: usize, + lines: Option, + cancelled: &AtomicBool, +) -> Result { + let mut output = PipeOutput { + bytes: Vec::new(), + limit_reached: false, + }; + let mut count = 0; + loop { + if cancelled.load(Ordering::Acquire) { + return Ok(output); + } + let mut available = 0; + // Peek avoids an uncancellable synchronous read when exceptional cleanup + // cannot terminate a process. Only this thread reads this pipe. + if unsafe { + PeekNamedPipe( + handle, + std::ptr::null_mut(), + 0, + std::ptr::null_mut(), + &mut available, + std::ptr::null_mut(), + ) + } == 0 + { + if std::io::Error::last_os_error().raw_os_error() == Some(ERROR_BROKEN_PIPE as i32) { + return Ok(output); + } + return Err(native_error("peek git pipe")); + } + if available == 0 { + thread::sleep(Duration::from_millis(2)); + continue; + } + let mut chunk = [0u8; STDERR_BYTES]; + let mut read = 0; + if unsafe { + ReadFile( + handle, + chunk.as_mut_ptr(), + available.min(chunk.len() as u32), + &mut read, + std::ptr::null_mut(), + ) + } == 0 + { + return Err(native_error("read git pipe")); + } + for byte in &chunk[..read as usize] { + if output.bytes.len() < cap { + output.bytes.push(*byte); + } + if cap > STDERR_BYTES && output.bytes.len() > MAX_OUTPUT_BYTES { + return Err(Denial::new( + codes::TOO_LARGE, + "git output exceeds the byte cap", + )); + } + if *byte == b'\n' { + count += 1; + if lines.is_some_and(|limit| count >= limit) { + output.limit_reached = true; + return Ok(output); + } + } + } + } +} + +struct ScopeCleanup<'a> { + guard: &'a mut ProcessJob, + cancelled: &'a AtomicBool, +} +impl Drop for ScopeCleanup<'_> { + fn drop(&mut self) { + self.guard.stop_tree(); + self.cancelled.store(true, Ordering::Release); + } +} + +enum Completion { + Exit(Result), + Stdout(Result), + Stderr(Result), +} +#[derive(Default)] +struct Faults { + #[cfg(test)] + fail_termination_once: bool, + #[cfg(test)] + panic_after_waiter: bool, +} + +fn run_until( + input: CommandInput, + lines: Option, + timeout: Duration, + _faults: Faults, +) -> Result { + let (mut guard, stdout, stderr) = spawn_job_command(&input, |_| Ok(()))?; + #[cfg(test)] + { + guard.fail_termination_once = _faults.fail_termination_once; + } + let out = stdout.0 as usize; + let err = stderr.0 as usize; + let process = guard.process.0 as usize; + let deadline = Instant::now() + timeout; + let cancelled = AtomicBool::new(false); + let (sender, receiver) = mpsc::channel(); + thread::scope(|scope| { + // This guard drops on an early return or a panic during thread startup, + // BEFORE scope joins workers. The outer process/pipe owners outlive joins. + let cleanup = ScopeCleanup { + guard: &mut guard, + cancelled: &cancelled, + }; + let exit_sender = sender.clone(); + let cancel = &cancelled; + scope.spawn(move || { + let result = loop { + let wait = unsafe { WaitForSingleObject(process as HANDLE, 10) }; + if wait == WAIT_OBJECT_0 { + let mut code = 0; + break if unsafe { GetExitCodeProcess(process as HANDLE, &mut code) } == 0 { + Err(native_error("git exit code")) + } else { + Ok(code) + }; + } + if wait != WAIT_TIMEOUT { + break Err(native_error("wait for git")); + } + if cancel.load(Ordering::Acquire) { + break Err(Denial::new(codes::GIT, "git wait cancelled")); + } + }; + let _ = exit_sender.send(Completion::Exit(result)); + }); + #[cfg(test)] + if _faults.panic_after_waiter { + panic!("injected thread-start failure"); + } + let out_sender = sender.clone(); + scope.spawn(move || { + let _ = out_sender.send(Completion::Stdout(drain_pipe( + out as HANDLE, + MAX_OUTPUT_BYTES + 1, + lines, + cancel, + ))); + }); + scope.spawn(move || { + let _ = sender.send(Completion::Stderr(drain_pipe( + err as HANDLE, + STDERR_BYTES, + None, + cancel, + ))); + }); + let mut code = None; + let mut output = None; + let mut errors = None; + let mut limited = false; + while code.is_none() || output.is_none() || errors.is_none() { + let event = receiver + .recv_timeout(deadline.saturating_duration_since(Instant::now())) + .map_err(|_| { + Denial::new( + codes::TIMEOUT, + format!("git ran longer than {} s", timeout.as_secs()), + ) + })?; + match event { + Completion::Exit(result) => { + code = Some(result?); + cleanup.guard.stop_tree(); + } + Completion::Stdout(result) => { + let result = result?; + limited = result.limit_reached; + if limited { + cleanup.guard.stop_tree(); + } + output = Some(result.bytes); + } + Completion::Stderr(result) => { + errors = Some(result?.bytes); + } + } + } + Ok(Ran { + success: limited || code == Some(0), + code: Some(if limited { 0 } else { code.unwrap() as i32 }), + stdout: output.unwrap(), + stderr: String::from_utf8_lossy(&errors.unwrap()).into_owned(), + }) + }) +} + +pub fn run_command(command: impl Into) -> Result { + run_until(command.into(), None, TIMEOUT, Faults::default()) +} +pub fn run_tags_command(command: impl Into) -> Result { + run_until(command.into(), Some(MAX_TAGS), TIMEOUT, Faults::default()) +} + +#[cfg(test)] +mod tests; + +/// Tests that this runner creates its child under the process-wide spawn +/// lock, and that children started at once through the launcher, +/// `spawn_plain` and this runner never inherit each other's handles. +#[cfg(test)] +mod spawn_lock_tests { + use super::*; + use std::cell::Cell; + use std::os::windows::io::AsRawHandle; + use std::sync::Barrier; + use windows_sys::Win32::{ + Foundation::{ + CompareObjectHandles, DUPLICATE_SAME_ACCESS, DuplicateHandle, ERROR_INVALID_HANDLE, + }, + System::Threading::GetCurrentProcess, + }; + + thread_local! { + /// Whether the spawn lock was held when this thread's last git child + /// was created. + static HELD_AT_CREATION: Cell> = const { Cell::new(None) }; + } + + /// Called by the runner right after `CreateProcessW`, while the parent + /// still holds its copies of the child's handles. + pub(super) fn created() { + HELD_AT_CREATION.with(|held| held.set(Some(basal_launch::spawn_lock_held()))); + } + + /// The git child is created under the spawn lock, and the lock is free + /// again by the time it can run. + #[test] + fn windows_git_children_are_created_under_the_spawn_lock() { + HELD_AT_CREATION.with(|held| held.set(None)); + let (mut guard, _, _) = spawn_job_command(&Command::new(child_image()).into(), |_| { + assert!( + !basal_launch::spawn_lock_held(), + "the lock is released before the child is resumed" + ); + Ok(()) + }) + .unwrap(); + guard.stop_tree(); + assert_eq!(HELD_AT_CREATION.with(Cell::get), Some(true)); + } + + /// Prints its standard handle values on one line, then waits until it + /// is killed. Built like `ck-basal-worker` (GUI subsystem, C runtime + /// linked statically), so the launcher can also start it under the + /// worker's full confinement, where no console host and no C runtime DLL + /// can be loaded. + const CHILD_SOURCE: &str = r#" +#![windows_subsystem = "windows"] +use std::io::Write; +use std::os::windows::io::AsRawHandle; +fn main() { + let line = format!( + "stdin={} stdout={} stderr={}\n", + std::io::stdin().as_raw_handle() as usize, + std::io::stdout().as_raw_handle() as usize, + std::io::stderr().as_raw_handle() as usize, + ); + let mut out = std::io::stdout(); + let _ = out.write_all(line.as_bytes()); + let _ = out.flush(); + loop { + std::thread::park(); + } +} +"#; + + /// The child image, built once in a directory next to this test binary. + /// The directory is granted read and execute for the worker's + /// AppContainer package SID, without which a confined child cannot + /// start from it. + fn child_image() -> &'static Path { + static IMAGE: OnceLock = OnceLock::new(); + IMAGE.get_or_init(|| { + let directory = std::env::current_exe() + .unwrap() + .parent() + .unwrap() + .join("basal-spawn-lock-child"); + std::fs::create_dir_all(&directory).unwrap(); + let source = directory.join("child.rs"); + std::fs::write(&source, CHILD_SOURCE).unwrap(); + let image = directory.join("spawn-lock-child.exe"); + let output = Command::new("rustc") + .args([ + "--edition=2021", + "-Dwarnings", + "-C", + "target-feature=+crt-static", + ]) + .arg(&source) + .arg("-o") + .arg(&image) + .output() + .expect("native rustc is required"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let package = basal_launch::create_or_open_profile().expect("worker profile"); + basal_launch::grant_test_binary_directory(&image, &package) + .expect("grant the package read and execute"); + image + }) + } + + enum Child { + Launched(basal_launch::ConfinedProcess), + Plain(basal_launch::OwnedProcess), + Git(ProcessJob, OwnedHandle), + } + + impl Child { + fn process(&self) -> HANDLE { + match self { + Self::Launched(child) => child.as_raw_handle(), + Self::Plain(child) => child.as_raw_handle(), + Self::Git(guard, _) => guard.process.0, + } + } + + fn kind(&self) -> &'static str { + match self { + Self::Launched(_) => "launched", + Self::Plain(_) => "plain", + Self::Git(..) => "git", + } + } + + /// The values of the child's standard handles, as it reports them. + fn stdio(&mut self) -> [usize; 3] { + let stdout = match self { + Self::Launched(child) => child.stdout.as_ref().unwrap().as_raw_handle(), + Self::Plain(child) => child.stdout.as_ref().unwrap().as_raw_handle(), + Self::Git(_, stdout) => stdout.0, + }; + let mut line = Vec::new(); + loop { + let mut byte = 0u8; + let mut read = 0; + let ok = unsafe { ReadFile(stdout, &mut byte, 1, &mut read, std::ptr::null_mut()) }; + assert!( + ok != 0 && read == 1, + "the {} child ended before reporting: {}", + self.kind(), + std::io::Error::last_os_error() + ); + if byte == b'\n' { + break; + } + line.push(byte); + } + let line = String::from_utf8(line).unwrap(); + let values: Vec = line + .split(' ') + .map(|field| field.split_once('=').unwrap().1.parse().unwrap()) + .collect(); + values + .try_into() + .unwrap_or_else(|_| panic!("bad report {line}")) + } + } + + /// The object `process` holds at `value`, duplicated into this process. + fn object_at(process: HANDLE, value: usize) -> Option { + let mut duplicate = std::ptr::null_mut(); + if unsafe { + DuplicateHandle( + process, + value as HANDLE, + GetCurrentProcess(), + &mut duplicate, + 0, + 0, + DUPLICATE_SAME_ACCESS, + ) + } == 0 + { + assert_eq!( + std::io::Error::last_os_error().raw_os_error(), + Some(ERROR_INVALID_HANDLE as i32), + "could not inspect a child's handle table" + ); + return None; + } + Some(OwnedHandle(duplicate)) + } + + /// Whether `process` holds `object` at `value`. A handle a child + /// inherited keeps its value there, so another child's pipe end would + /// sit at the value it has in its owner; comparing objects, not values, + /// tells it apart from an unrelated handle that has the same value. + fn holds(process: HANDLE, value: usize, object: &OwnedHandle) -> bool { + object_at(process, value) + .is_some_and(|found| unsafe { CompareObjectHandles(found.0, object.0) } != 0) + } + + /// Many confined launches, plain spawns and git children started at + /// once: none of them holds another child's standard handle. A child + /// that held one could read or write that child's pipe, and would keep + /// it open after its owner exits. + #[test] + fn windows_concurrent_spawns_inherit_no_other_childs_handles() { + const EACH: usize = 4; + let image = child_image(); + let start = Barrier::new(3 * EACH); + let mut children: Vec = thread::scope(|scope| { + let mut threads = Vec::new(); + for index in 0..3 * EACH { + let start = &start; + threads.push(scope.spawn(move || { + start.wait(); + match index % 3 { + 0 => Child::Launched( + basal_launch::launch(&basal_launch::LaunchOptions::new( + image, + 512 * 1024 * 1024, + )) + .expect("launch the child confined"), + ), + 1 => { + let mut command = basal_launch::PlainCommand::new(image); + command.stdin = basal_launch::PlainStdio::Piped; + command.stdout = basal_launch::PlainStdio::Piped; + command.stderr = basal_launch::PlainStdio::Piped; + Child::Plain(basal_launch::spawn_plain(&command).expect("spawn plain")) + } + _ => { + let (guard, stdout, _stderr) = + spawn_job_command(&Command::new(image).into(), |_| Ok(())) + .expect("spawn through the git runner"); + Child::Git(guard, stdout) + } + } + })); + } + threads.into_iter().map(|t| t.join().unwrap()).collect() + }); + let reports: Vec<[usize; 3]> = children.iter_mut().map(Child::stdio).collect(); + // Each child's own standard handles, as objects. + let objects: Vec> = children + .iter() + .zip(&reports) + .map(|(child, values)| { + values + .iter() + .map(|&value| { + object_at(child.process(), value) + .unwrap_or_else(|| panic!("the {} child's stdio", child.kind())) + }) + .collect() + }) + .collect(); + let mut checked = 0; + for (owner, (values, owned)) in reports.iter().zip(&objects).enumerate() { + for (value, object) in values.iter().zip(owned) { + // Positive control: `holds` does report a child's own handle. + assert!(holds(children[owner].process(), *value, object)); + for (other, child) in children.iter().enumerate() { + if other == owner { + continue; + } + assert!( + !holds(child.process(), *value, object), + "the {} child {other} holds handle {value} of the {} child {owner}", + child.kind(), + children[owner].kind() + ); + checked += 1; + } + } + } + assert_eq!(checked, 3 * EACH * 3 * (3 * EACH - 1)); + println!( + "checked {checked} handle placements across {} children", + children.len() + ); + for child in &mut children { + if let Child::Git(guard, _) = child { + guard.stop_tree(); + } + } + } +} diff --git a/crates/basal-host/src/builtins/git/windows/tests.rs b/crates/basal-host/src/builtins/git/windows/tests.rs new file mode 100644 index 0000000..90009b9 --- /dev/null +++ b/crates/basal-host/src/builtins/git/windows/tests.rs @@ -0,0 +1,1022 @@ +use super::*; +use std::sync::Arc; +use windows_sys::Win32::{ + Foundation::{ + CompareObjectHandles, DUPLICATE_SAME_ACCESS, DuplicateHandle, ERROR_INVALID_HANDLE, + STILL_ACTIVE, + }, + System::{ + JobObjects::QueryInformationJobObject, + Threading::{ + CREATE_BREAKAWAY_FROM_JOB, GetCurrentProcess, OpenProcess, + PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, + }, + }, +}; + +struct Tree(PathBuf); +impl Tree { + fn new() -> Self { + let mut random = [0u8; 16]; + assert!( + unsafe { + BCryptGenRandom( + std::ptr::null_mut(), + random.as_mut_ptr(), + random.len() as u32, + BCRYPT_USE_SYSTEM_PREFERRED_RNG, + ) + } >= 0 + ); + let name: String = random.iter().map(|b| format!("{b:02x}")).collect(); + let path = std::env::temp_dir().join(format!("basal-git-test-{name}")); + std::fs::create_dir(&path).unwrap(); + Self(path) + } + fn p(&self, name: &str) -> PathBuf { + self.0.join(name) + } +} +impl Drop for Tree { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn child_image() -> &'static Path { + static IMAGE: OnceLock = OnceLock::new(); + IMAGE.get_or_init(|| { + let tree = Tree::new(); + let source = tree.p("child.rs"); + std::fs::write(&source, include_str!("../fixtures/windows_child.rs")).unwrap(); + let image = tree.p("argv-echo.exe"); + let output = Command::new("rustc") + .args(["--edition=2024", "-Dwarnings"]) + .arg(&source) + .arg("-o") + .arg(&image) + .output() + .expect("native rustc is required"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + // Keep the compiled child executable available to concurrent tests. + std::mem::forget(tree); + image + }) +} +fn child(mode: &str) -> Command { + let mut command = Command::new(child_image()); + command.arg(mode); + command +} +fn succeeded(result: Ran) -> Ran { + assert!(result.success, "{}", result.stderr); + result +} +fn runner(command: Command, lines: Option, timeout: Duration) -> Result { + run_until(command.into(), lines, timeout, Faults::default()) +} + +#[test] +fn windows_crt_argv_round_trip_preserves_quotes_and_utf16() { + let arguments = [ + OsString::from(""), + OsString::from("HEAD:a\"b"), + OsString::from("a\\\"b"), + OsString::from("a\\\\\"b"), + OsString::from("with space\\"), + OsString::from("\t\n"), + OsString::from("C:\\résumé\\日本語"), + OsString::from_wide(&[0xd800, b'"' as u16, b'\\' as u16]), + ]; + let mut command = child("argv"); + command.args(&arguments); + let result = succeeded(run_command(command).unwrap()); + let actual: Vec> = String::from_utf8(result.stdout) + .unwrap() + .lines() + .map(|line| { + line.as_bytes() + .chunks(4) + .map(|unit| u16::from_str_radix(std::str::from_utf8(unit).unwrap(), 16).unwrap()) + .collect() + }) + .collect(); + let expected: Vec> = arguments + .iter() + .map(|arg| arg.encode_wide().collect()) + .collect(); + assert_eq!(actual, expected); +} + +#[test] +fn windows_drive_paths_refuse_unc_and_preserve_utf16() { + for path in [ + r"\\?\UNC\server\share\repo", + r"\\?\Volume{abc}\repo", + r"\\.\C:\repo", + r"\\server\share\repo", + r"C:repo", + r"\repo", + ] { + assert!(drive_path(Path::new(path)).is_err(), "accepted {path}"); + } + let input = OsString::from_wide(&[92, 92, 63, 92, 67, 58, 92, 0xd800]); + assert_eq!( + drive_path(Path::new(&input)) + .unwrap() + .as_os_str() + .encode_wide() + .collect::>(), + [67, 58, 92, 0xd800] + ); +} + +#[test] +fn windows_git_resolution_ignores_relative_path_and_cwd_images() { + let tree = Tree::new(); + std::fs::write(tree.p("git.exe"), b"MZ fixture, never executed").unwrap(); + let paths = std::env::join_paths([ + Path::new(""), + Path::new("relative-bin"), + Path::new(r"C:bin"), + Path::new(r"\bin"), + &tree.0, + ]) + .unwrap(); + let resolved = resolve_git_binary(OsStr::new("git"), Some(&paths)).unwrap(); + assert!(resolved.is_absolute()); + assert_eq!( + resolved, + drive_path(&std::fs::canonicalize(tree.p("git.exe")).unwrap()).unwrap() + ); + let only_relative = OsStr::new(";relative-bin;C:bin;\\bin"); + assert!(resolve_git_binary(OsStr::new("git"), Some(only_relative)).is_err()); + assert!(resolve_git_binary(OsStr::new("relative-bin\\git.exe"), None).is_err()); + for extension in ["bat", "CMD"] { + assert!(resolve_git_binary(tree.p(&format!("git.{extension}")).as_os_str(), None).is_err()); + } + let image = tree.p("git.exe"); + std::fs::remove_file(&image).unwrap(); + std::fs::write(tree.p("git.cmd"), "exit /b 0").unwrap(); + assert!(resolve_git_binary(OsStr::new("git"), Some(&paths)).is_err()); +} + +#[test] +fn windows_explicit_application_is_independent_of_current_directory() { + let tree = Tree::new(); + // A script and a fake executable in the CWD must not shadow the resolved image. + std::fs::write(tree.p("argv-echo.exe"), "not an executable").unwrap(); + std::fs::write(tree.p("git.exe"), "not an executable").unwrap(); + let mut command = child("argv"); + command.current_dir(&tree.0).arg("stable image"); + assert_eq!( + succeeded(run_command(command).unwrap()).stdout, + b"0073007400610062006c006500200069006d006100670065\n" + ); + let marker = tree.p("application-marker"); + let mut command = child("mark"); + command.arg(&marker).current_dir(&tree.0); + let input = command.into(); + // Deliberately misleading argv[0] distinguishes explicit application selection + // from CreateProcess's fallback command-line executable search. + let wrong_image = tree.p("git.exe"); + let (guard, _, _) = + spawn_with_argv0(&input, |_| Ok(()), Some(wrong_image.as_os_str())).unwrap(); + assert_eq!( + unsafe { WaitForSingleObject(guard.process.0, 5000) }, + WAIT_OBJECT_0 + ); + assert!(marker.exists()); +} + +#[test] +fn windows_private_isolation_is_fresh_exclusive_and_pinned() { + let first = Isolation::new().unwrap(); + let second = Isolation::new().unwrap(); + assert_ne!(first.directory, second.directory); + let config = first.directory.join("global.config"); + assert_eq!(std::fs::metadata(&config).unwrap().len(), 0); + assert!(std::fs::write(&config, "[alias]\npwn = !whoami\n").is_err()); + assert!(std::fs::rename(&config, first.directory.join("swapped.config")).is_err()); + assert!(std::fs::rename(&first.directory, first.directory.with_extension("swap")).is_err()); + let hooks = first.directory.join("hooks"); + let config_path = config.clone(); + let hooks_path = hooks.clone(); + thread::spawn(move || { + for _ in 0..20 { + assert!(std::fs::write(&config_path, "[alias]\npwn = !whoami\n").is_err()); + assert!(std::fs::rename(&hooks_path, hooks_path.with_extension("swap")).is_err()); + } + }) + .join() + .unwrap(); + let old_predictable = + std::env::temp_dir().join(format!("basal-git-isolation-{}", std::process::id())); + // The obsolete name is untrusted; no production helper consults it. + std::fs::create_dir_all(&old_predictable).unwrap(); + std::fs::write( + old_predictable.join("empty.config"), + "[alias]\npwn = !whoami\n", + ) + .unwrap(); + let third = Isolation::new().unwrap(); + assert_ne!(third.directory, old_predictable); + assert_eq!( + std::fs::metadata(third.directory.join("global.config")) + .unwrap() + .len(), + 0 + ); + std::fs::remove_dir_all(old_predictable).unwrap(); + let directory = first.directory.clone(); + drop(first); + assert!(!directory.exists()); + assert_eq!( + succeeded(run_command(child("stdin")).unwrap()).stdout, + b"inert\n" + ); +} + +fn private_dacl(path: &Path) -> String { + use windows_sys::Win32::Security::{ + Authorization::{ + ConvertSecurityDescriptorToStringSecurityDescriptorW, GetNamedSecurityInfoW, + SE_FILE_OBJECT, + }, + DACL_SECURITY_INFORMATION, + }; + let mut descriptor = std::ptr::null_mut(); + assert_eq!( + unsafe { + GetNamedSecurityInfoW( + wide(path).unwrap().as_ptr(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut descriptor, + ) + }, + 0 + ); + let descriptor = SecurityDescriptor(descriptor); + let mut sddl = std::ptr::null_mut(); + let mut length = 0; + assert_ne!( + unsafe { + ConvertSecurityDescriptorToStringSecurityDescriptorW( + descriptor.0, + 1, + DACL_SECURITY_INFORMATION, + &mut sddl, + &mut length, + ) + }, + 0 + ); + let text = unsafe { + String::from_utf16(std::slice::from_raw_parts(sddl, length as usize - 1)).unwrap() + }; + unsafe { + LocalFree(sddl.cast()); + } + text +} + +#[test] +fn windows_isolation_trims_host_temp_separator_and_has_private_dacl() { + let tree = Tree::new(); + let mut base = tree.0.as_os_str().to_owned(); + base.push("\\"); + let isolation = Isolation::in_base(Path::new(&base)).unwrap(); + assert_eq!(isolation.directory.parent(), Some(tree.0.as_path())); + for path in [ + &isolation.directory, + &isolation.directory.join("hooks"), + &isolation.directory.join("global.config"), + ] { + let sddl = private_dacl(path); + // Both explicit and inherited ACLs must grant only SYSTEM and the owner. + assert!(sddl.starts_with("D:P"), "DACL is not protected: {sddl}"); + let trustees: Vec<&str> = sddl + .split(";;;") + .skip(1) + .map(|ace| ace.split(')').next().unwrap()) + .collect(); + assert_eq!(trustees.len(), 2, "unexpected ACL entries: {sddl}"); + assert!( + trustees.contains(&"SY") && trustees.contains(&"OW"), + "not private: {sddl}" + ); + } + let link = tree.p("temp-junction"); + junction(&link, &tree.0); + assert!( + Isolation::in_base(&link).is_err(), + "temp junction was accepted" + ); + std::fs::remove_dir(link).unwrap(); +} + +#[test] +fn windows_attribute_payloads_and_job_membership_before_work() { + let tree = Tree::new(); + let marker = tree.p("work"); + let mut command = child("mark"); + command.arg(&marker); + let input: CommandInput = command.into(); + let (mut guard, _, _) = spawn_job_command(&input, |guard| { + assert!(!marker.exists(), "child ran before membership verification"); + let job = guard.job.as_ref().unwrap().0; + let mut in_job = 0; + assert_ne!( + unsafe { IsProcessInJob(guard.process.0, job, &mut in_job) }, + 0 + ); + assert_ne!(in_job, 0); + let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { std::mem::zeroed() }; + assert_ne!( + unsafe { + QueryInformationJobObject( + job, + JobObjectExtendedLimitInformation, + (&mut limits as *mut JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(), + std::mem::size_of_val(&limits) as u32, + std::ptr::null_mut(), + ) + }, + 0 + ); + assert_eq!( + limits.BasicLimitInformation.LimitFlags, + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + ); + // Moving an attribute owner must not move either retained payload. + let inherit = SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + lpSecurityDescriptor: std::ptr::null_mut(), + bInheritHandle: 1, + }; + let (_read_a, write_a) = pipe(&inherit).unwrap(); + let (_read_b, write_b) = pipe(&inherit).unwrap(); + let (_read_c, write_c) = pipe(&inherit).unwrap(); + let attributes = AttributeList::new(job, [write_a.0, write_b.0, write_c.0]).unwrap(); + let jobs = attributes.jobs.as_ptr(); + let handles = attributes.handles.as_ptr(); + let moved = Box::new(attributes); + assert_eq!(moved.jobs.as_ptr(), jobs); + assert_eq!(moved.handles.as_ptr(), handles); + Ok(()) + }) + .unwrap(); + assert_eq!( + unsafe { WaitForSingleObject(guard.process.0, 5000) }, + WAIT_OBJECT_0 + ); + assert!(marker.exists()); + guard.stop_tree(); + let mut command = child("breakaway"); + command + .arg(tree.p("escaped")) + .arg(CREATE_BREAKAWAY_FROM_JOB.to_string()); + succeeded(run_command(command).unwrap()); + assert!(!tree.p("escaped").exists()); +} + +fn await_file(path: &Path) { + let deadline = Instant::now() + Duration::from_secs(10); + while !path.exists() { + assert!( + Instant::now() < deadline, + "child did not create {}", + path.display() + ); + thread::sleep(Duration::from_millis(5)); + } +} +fn descendant(pid_path: &Path) -> OwnedHandle { + await_file(pid_path); + let pid: u32 = std::fs::read_to_string(pid_path).unwrap().parse().unwrap(); + let handle = unsafe { + OpenProcess( + PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_SYNCHRONIZE, + 0, + pid, + ) + }; + assert!( + !handle.is_null(), + "cannot retain live descendant: {}", + std::io::Error::last_os_error() + ); + let handle = OwnedHandle(handle); + assert_eq!(unsafe { WaitForSingleObject(handle.0, 0) }, WAIT_TIMEOUT); + let mut code = 0; + assert_ne!(unsafe { GetExitCodeProcess(handle.0, &mut code) }, 0); + assert_eq!(code, STILL_ACTIVE as u32); + handle +} +fn require_dead(handle: &OwnedHandle) { + assert_eq!( + unsafe { WaitForSingleObject(handle.0, 5000) }, + WAIT_OBJECT_0, + "descendant survived cleanup" + ); + let mut code = 0; + assert_ne!(unsafe { GetExitCodeProcess(handle.0, &mut code) }, 0); + assert_ne!(code, STILL_ACTIVE as u32); +} + +#[test] +fn windows_timeout_proves_live_descendant_died() { + let tree = Arc::new(Tree::new()); + let worker_tree = tree.clone(); + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let mut command = child("spawn"); + command + .arg(worker_tree.p("pid")) + .arg(worker_tree.p("ready")); + tx.send(runner(command, None, Duration::from_secs(3)).map(|_| ())) + .unwrap(); + }); + let handle = descendant(&tree.p("pid")); + let error = rx + .recv_timeout(Duration::from_secs(10)) + .unwrap() + .unwrap_err(); + assert_eq!(error.code, codes::TIMEOUT); + require_dead(&handle); +} + +#[test] +fn windows_post_exit_kills_descendant_holding_both_pipes() { + let tree = Arc::new(Tree::new()); + let worker_tree = tree.clone(); + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let mut command = child("post-exit"); + command + .arg(worker_tree.p("pid")) + .arg(worker_tree.p("ready")); + tx.send(run_command(command).map(|_| ())).unwrap(); + }); + let handle = descendant(&tree.p("pid")); + await_file(&tree.p("ready")); + std::fs::write(tree.p("ready.release"), "release").unwrap(); + rx.recv_timeout(Duration::from_secs(10)).unwrap().unwrap(); + require_dead(&handle); +} + +#[test] +fn windows_tags_stop_while_reading_and_enforce_byte_cap() { + let mut command = child("lines"); + command.arg("80"); + let result = succeeded(runner(command, Some(MAX_TAGS), Duration::from_secs(3)).unwrap()); + assert_eq!( + result.stdout.iter().filter(|b| **b == b'\n').count(), + MAX_TAGS + ); + let mut command = child("lines"); + command.arg((MAX_OUTPUT_BYTES / (MAX_TAGS - 1) + 1).to_string()); + assert_eq!( + runner(command, Some(MAX_TAGS), Duration::from_secs(3)) + .err() + .unwrap() + .code, + codes::TOO_LARGE + ); +} + +#[test] +fn windows_failed_termination_is_retryable_and_scope_unwind_is_bounded() { + let tree = Arc::new(Tree::new()); + let worker_tree = tree.clone(); + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let mut command = child("hold"); + command.arg(worker_tree.p("pid")); + let input = command.into(); + let (mut guard, _, _) = spawn_job_command(&input, |_| Ok(())).unwrap(); + guard.fail_termination_once = true; + assert!(guard.terminate().is_err()); + assert!(!guard.terminated); + guard.terminate().unwrap(); + assert!(guard.terminated); + assert_eq!( + unsafe { WaitForSingleObject(guard.process.0, 5000) }, + WAIT_OBJECT_0 + ); + let mut command = child("hold"); + command.arg(worker_tree.p("panic-pid")); + let panicked = std::panic::catch_unwind(|| { + run_until( + command.into(), + None, + Duration::from_secs(2), + Faults { + fail_termination_once: true, + panic_after_waiter: true, + }, + ) + }); + assert!(panicked.is_err()); + tx.send(()).unwrap(); + }); + rx.recv_timeout(Duration::from_secs(10)) + .expect("scope cleanup hung after startup failure"); +} + +#[test] +fn windows_only_stdio_handles_are_inherited() { + // Compile before publishing any inheritable test handle to other processes. + let image = wide(child_image()).unwrap(); + let inherit = SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + lpSecurityDescriptor: std::ptr::null_mut(), + bInheritHandle: 1, + }; + let (_read, write) = pipe(&inherit).unwrap(); + // Positive control: CreateProcess(TRUE) without a handle list inherits this + // exact pipe writer. The child remains suspended and is killed by the guard. + let mut startup: windows_sys::Win32::System::Threading::STARTUPINFOW = + unsafe { std::mem::zeroed() }; + startup.cb = std::mem::size_of_val(&startup) as u32; + let mut process: PROCESS_INFORMATION = unsafe { std::mem::zeroed() }; + let mut argv = Vec::new(); + append_windows_arg(&mut argv, child_image().as_os_str()).unwrap(); + argv.push(0); + assert_ne!( + unsafe { + CreateProcessW( + image.as_ptr(), + argv.as_mut_ptr(), + std::ptr::null(), + std::ptr::null(), + 1, + CREATE_SUSPENDED | CREATE_NO_WINDOW, + std::ptr::null(), + std::ptr::null(), + &startup, + &mut process, + ) + }, + 0 + ); + let control = SuspendedControl(OwnedHandle(process.hProcess)); + let _primary_thread = OwnedHandle(process.hThread); + assert!( + process_has_handle(control.0.0, write.0), + "positive control did not inherit the writer" + ); + drop(control); + let tree = Tree::new(); + let mut command = child("hold"); + command.arg(tree.p("pid")); + let input = command.into(); + let (mut guard, _, _) = spawn_job_command(&input, |guard| { + assert!( + !process_has_handle(guard.process.0, write.0), + "contained child inherited an unrelated writer before resume" + ); + Ok(()) + }) + .unwrap(); + await_file(&tree.p("pid")); + // Pipe EOF is not process-specific: another concurrent, unrestricted spawn + // can inherit the writer. Duplicate from the tested child's handle table + // instead; object comparison also distinguishes reuse of the numeric slot. + assert!( + !process_has_handle(guard.process.0, write.0), + "contained child owns an unrelated writer after resume" + ); + guard.stop_tree(); +} + +struct SuspendedControl(OwnedHandle); +impl Drop for SuspendedControl { + fn drop(&mut self) { + unsafe { + TerminateProcess(self.0.0, 1); + WaitForSingleObject(self.0.0, 5000); + } + } +} + +fn process_has_handle(process: HANDLE, original: HANDLE) -> bool { + let mut duplicate = std::ptr::null_mut(); + // Inherited handles have the same numeric value in both processes. Keep + // the parent's original live and compare objects, not just handle numbers. + if unsafe { + DuplicateHandle( + process, + original, + GetCurrentProcess(), + &mut duplicate, + 0, + 0, + DUPLICATE_SAME_ACCESS, + ) + } == 0 + { + assert_eq!( + std::io::Error::last_os_error().raw_os_error(), + Some(ERROR_INVALID_HANDLE as i32), + "could not inspect the child's handle table" + ); + return false; + } + let duplicate = OwnedHandle(duplicate); + unsafe { CompareObjectHandles(original, duplicate.0) != 0 } +} + +fn repository(tree: &Tree) -> PathBuf { + let repo = tree.p("repo"); + std::fs::create_dir(&repo).unwrap(); + setup_git(&repo, &["init", "-q"]); + std::fs::write(repo.join("file.txt"), "one\n").unwrap(); + setup_git(&repo, &["add", "."]); + setup_git( + &repo, + &[ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.test", + "commit", + "-qm", + "first", + ], + ); + std::fs::write(repo.join("file.txt"), "two\n").unwrap(); + setup_git(&repo, &["add", "."]); + setup_git( + &repo, + &[ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.test", + "commit", + "-qm", + "second", + ], + ); + repo +} +fn plain_git(repo: &Path) -> HardenedCommand { + let isolation = Isolation::new().expect("owned empty global configuration"); + let mut command = Command::new( + resolve_git().expect("native git.exe is required for Windows confinement tests"), + ); + // Positive controls omit the hardened arguments, not fixture isolation. + // A real empty file works across Git builds; NUL is a device, not a config. + command.env_clear(); + for key in ["PATH", "HOME", "SystemRoot", "USERPROFILE"] { + if let Some(value) = std::env::var_os(key) { + command.env(key, value); + } + } + command + .arg("-C") + .arg(repo) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("TMP", &isolation.directory) + .env("TEMP", &isolation.directory) + .env("TMPDIR", &isolation.directory) + .env( + "GIT_CONFIG_GLOBAL", + isolation.directory.join("global.config"), + ); + HardenedCommand { + command, + _isolation: isolation, + } +} +fn setup_git(repo: &Path, args: &[&str]) { + let output = plain_git(repo).args(args).output().unwrap(); + assert!( + output.status.success(), + "setup git {args:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); +} +fn junction(link: &Path, target: &Path) { + let output = Command::new("cmd.exe") + .args(["/d", "/c", "mklink", "/J"]) + .arg(link) + .arg(target) + .output() + .unwrap(); + assert!( + output.status.success(), + "junction creation failed: {}", + String::from_utf8_lossy(&output.stdout) + ); +} + +#[test] +fn windows_repository_walk_refuses_junctions_and_pins_all_ancestors() { + let tree = Tree::new(); + let parent = tree.p("parent"); + std::fs::create_dir(&parent).unwrap(); + let repo = parent.join("repo"); + std::fs::create_dir(&repo).unwrap(); + let approved = [repo.to_str().unwrap().to_owned()]; + // Positive control: the same names really can be replaced without a pin. + let moved = tree.p("moved"); + std::fs::rename(&parent, &moved).unwrap(); + std::fs::rename(&moved, &parent).unwrap(); + let pin = super::super::repo(repo.to_str().unwrap(), &approved).unwrap(); + assert!( + std::fs::rename(&parent, &moved).is_err(), + "ancestor replaced while git is authorized" + ); + assert!( + std::fs::rename(&repo, parent.join("old-repo")).is_err(), + "repo replaced while git is authorized" + ); + let mut command = child("argv"); + command.current_dir(pin.path()).arg("pinned"); + succeeded(run_command(command).unwrap()); + drop(pin); + std::fs::rename(&parent, &moved).unwrap(); + junction(&parent, &moved); + assert!(super::super::repo(repo.to_str().unwrap(), &approved).is_err()); + std::fs::remove_dir(&parent).unwrap(); + junction(&tree.p("root-link"), &moved.join("repo")); + let link = tree.p("root-link").to_str().unwrap().to_owned(); + assert!(super::super::repo(&link, std::slice::from_ref(&link)).is_err()); + std::fs::remove_dir(tree.p("root-link")).unwrap(); + let sub = moved.join("repo").join("sub"); + std::fs::create_dir(&sub).unwrap(); + assert!( + super::super::repo( + sub.to_str().unwrap(), + &[moved.join("repo").to_str().unwrap().to_owned()] + ) + .is_err() + ); +} + +#[test] +fn windows_repository_refusals_do_not_reveal_unapproved_existence() { + let tree = Tree::new(); + let approved = tree.p("approved"); + std::fs::create_dir(&approved).unwrap(); + let roots = [approved.to_str().unwrap().to_owned()]; + let outside = tree.p("outside"); + let missing = tree.p("missing"); + std::fs::create_dir(&outside).unwrap(); + for path in [&outside, &missing] { + assert_eq!( + super::super::repo(path.to_str().unwrap(), &roots) + .err() + .unwrap() + .code, + codes::DENIED + ); + } +} + +fn marker_script(tree: &Tree, name: &str) -> (PathBuf, PathBuf) { + let marker = tree.p(&format!("{name}.marker")); + let script = tree.p(&format!("{name}.sh")); + let marker_shell = marker + .to_str() + .unwrap() + .replace('\\', "/") + .replace('\'', "'\\''"); + std::fs::write( + &script, + format!("#!/bin/sh\nprintf ran >> '{marker_shell}'\nprintf 'converted\\n'\n"), + ) + .unwrap(); + (script, marker) +} +fn helper_value(script: &Path) -> String { + format!( + "sh '{}'", + script + .to_str() + .unwrap() + .replace('\\', "/") + .replace('\'', "'\\''") + ) +} +fn hard_git(repo: &Path, args: &[&str]) -> Ran { + let mut command = hardened_command(repo).unwrap(); + command.args(args); + run_command(command).unwrap() +} +fn positive_marker(repo: &Path, args: &[&str], marker: &Path) { + let mut command = plain_git(repo); + command.args(args); + succeeded(run_command(command).unwrap()); + assert!( + marker.exists(), + "positive control did not execute the planted helper: {args:?}" + ); + std::fs::remove_file(marker).unwrap(); +} + +#[test] +fn windows_hook_positive_control_and_hardened_suppression() { + let tree = Tree::new(); + let repo = repository(&tree); + let (script, marker) = marker_script(&tree, "hook"); + // Hooks have no .bat suffix: Git for Windows executes the shebang file. + std::fs::copy( + script, + repo.join(".git").join("hooks").join("post-checkout"), + ) + .unwrap(); + positive_marker(&repo, &["checkout", "--detach", "HEAD~"], &marker); + succeeded(hard_git(&repo, &["checkout", "--detach", "HEAD"])); + assert!(!marker.exists(), "hook ran through hardened hooksPath"); +} + +#[test] +fn windows_fsmonitor_positive_control_and_hardened_suppression() { + let tree = Tree::new(); + let repo = repository(&tree); + let (script, marker) = marker_script(&tree, "fsmonitor"); + setup_git(&repo, &["config", "core.fsmonitor", &helper_value(&script)]); + positive_marker(&repo, &["status", "--porcelain"], &marker); + succeeded(hard_git(&repo, &["status", "--porcelain"])); + assert!(!marker.exists(), "fsmonitor ran through hardened command"); +} + +#[test] +fn windows_external_diff_positive_control_and_builtin_suppression() { + let tree = Tree::new(); + let repo = repository(&tree); + let (script, marker) = marker_script(&tree, "external-diff"); + setup_git(&repo, &["config", "diff.external", &helper_value(&script)]); + positive_marker(&repo, &["diff", "HEAD~", "HEAD", "--", "file.txt"], &marker); + let path = repo.to_str().unwrap().to_owned(); + let op = super::super::Op::Diff { + from: "HEAD~".into(), + to: "HEAD".into(), + path: Some("file.txt".into()), + }; + super::super::run(&path, std::slice::from_ref(&path), &op).unwrap(); + assert!(!marker.exists(), "external diff ran through builtin diff"); +} + +#[test] +fn windows_textconv_positive_control_and_builtin_suppression() { + let tree = Tree::new(); + let repo = repository(&tree); + let (script, marker) = marker_script(&tree, "textconv"); + std::fs::write(repo.join(".gitattributes"), "file.txt diff=marker\n").unwrap(); + setup_git( + &repo, + &["config", "diff.marker.textconv", &helper_value(&script)], + ); + positive_marker(&repo, &["diff", "HEAD~", "HEAD", "--", "file.txt"], &marker); + let path = repo.to_str().unwrap().to_owned(); + let op = super::super::Op::Diff { + from: "HEAD~".into(), + to: "HEAD".into(), + path: Some("file.txt".into()), + }; + super::super::run(&path, std::slice::from_ref(&path), &op).unwrap(); + assert!(!marker.exists(), "textconv ran through builtin diff"); +} + +#[test] +fn windows_contaminated_global_config_positive_control_and_isolation() { + let tree = Tree::new(); + let repo = repository(&tree); + let (script, marker) = marker_script(&tree, "global"); + let config = tree.p("hostile-global.config"); + let alias = format!("!{}", helper_value(&script)); + let output = Command::new(resolve_git().unwrap()) + .args(["config", "--file"]) + .arg(&config) + .args(["alias.basal-poison", &alias]) + .output() + .unwrap(); + assert!(output.status.success()); + let mut command = plain_git(&repo); + command + .env("GIT_CONFIG_GLOBAL", &config) + .arg("basal-poison"); + succeeded(run_command(command).unwrap()); + assert!( + marker.exists(), + "contaminated global alias positive control did not run" + ); + std::fs::remove_file(&marker).unwrap(); + let profile = tree.p("profile"); + std::fs::create_dir(&profile).unwrap(); + std::fs::copy(&config, profile.join(".gitconfig")).unwrap(); + let mut default_global = plain_git(&repo); + default_global + .env_remove("GIT_CONFIG_GLOBAL") + .env("HOME", &profile) + .env("USERPROFILE", &profile) + .arg("basal-poison"); + succeeded(run_command(default_global).unwrap()); + assert!( + marker.exists(), + "default user-global config positive control did not run" + ); + std::fs::remove_file(&marker).unwrap(); + let mut command = hardened_command(&repo).unwrap(); + command + .env("HOME", &profile) + .env("USERPROFILE", &profile) + .arg("basal-poison"); + let block = environment(&command.command, true).unwrap(); + assert!(!String::from_utf16_lossy(&block).contains(config.to_str().unwrap())); + let result = run_command(command).unwrap(); + assert!( + !result.success, + "global alias survived isolated global config" + ); + assert!(!marker.exists()); +} + +#[test] +fn windows_hostile_environment_child() { + let Some(repo) = std::env::var_os("BASAL_GIT_ENV_REPO") else { + return; + }; + let config = + std::env::var_os("GIT_CONFIG_GLOBAL").expect("parent supplied contaminated global config"); + let mut positive = plain_git(Path::new(&repo)); + positive + .env_remove("GIT_DIR") + .env_remove("GIT_CONFIG_COUNT") + .env("GIT_CONFIG_GLOBAL", &config) + .arg("basal-poison"); + succeeded(run_command(positive).unwrap()); + let marker = PathBuf::from(std::env::var_os("BASAL_GIT_ENV_MARKER").unwrap()); + assert!(marker.exists()); + std::fs::remove_file(&marker).unwrap(); + let result = hard_git(Path::new(&repo), &["basal-poison"]); + assert!(!result.success); + assert!( + !marker.exists(), + "inherited global config executed a helper" + ); + let mut command = hardened_command(Path::new(&repo)).unwrap(); + command.args(["rev-parse", "--show-toplevel"]); + let result = succeeded(run_command(command).unwrap()); + assert!( + String::from_utf8(result.stdout).unwrap().contains("repo"), + "inherited GIT_DIR redirected the repository" + ); +} + +#[test] +fn windows_inherited_git_environment_is_not_imported() { + let tree = Tree::new(); + let repo = repository(&tree); + let (script, marker) = marker_script(&tree, "inherited-global"); + let config = tree.p("global.config"); + let alias = format!("!{}", helper_value(&script)); + let output = Command::new(resolve_git().unwrap()) + .args(["config", "--file"]) + .arg(&config) + .args(["alias.basal-poison", &alias]) + .output() + .unwrap(); + assert!(output.status.success()); + // A subprocess is the real host with hostile inherited settings. No unsafe + // process-wide environment mutation races this suite's concurrent tests. + let output = Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "builtins::git::windows::tests::windows_hostile_environment_child", + "--nocapture", + ]) + .env("BASAL_GIT_ENV_REPO", &repo) + .env("BASAL_GIT_ENV_MARKER", &marker) + .env("GIT_CONFIG_GLOBAL", &config) + .env("GIT_DIR", tree.p("not-a-repository")) + .env("GIT_CONFIG_COUNT", "1") + .env("GIT_CONFIG_KEY_0", "alias.basal-poison") + .env("GIT_CONFIG_VALUE_0", &alias) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + String::from_utf8_lossy(&output.stdout).contains("1 passed"), + "child control never executed" + ); + assert!(!marker.exists()); +} diff --git a/crates/basal-host/src/core_host.rs b/crates/basal-host/src/core_host.rs index 418623e..6b46fc2 100644 --- a/crates/basal-host/src/core_host.rs +++ b/crates/basal-host/src/core_host.rs @@ -241,14 +241,44 @@ pub(crate) fn sample() -> f64 { // Live draws use OS entropy, not shared predictable process state. The // runtime journals each draw and replay serves that saved value instead. // There is deliberately no fallback when the OS cannot supply entropy. - let rc = unsafe { libc::getentropy((&mut x as *mut u64).cast(), std::mem::size_of::()) }; + fill_entropy(&mut x); + (x >> 11) as f64 / ((1u64 << 53) as f64) +} + +#[cfg(unix)] +fn fill_entropy(x: &mut u64) { + let rc = unsafe { libc::getentropy((x as *mut u64).cast(), std::mem::size_of::()) }; assert_eq!( rc, 0, "OS entropy unavailable: {}", std::io::Error::last_os_error() ); - (x >> 11) as f64 / ((1u64 << 53) as f64) +} + +#[cfg(windows)] +fn fill_entropy(x: &mut u64) { + // The system-preferred RNG needs no algorithm handle; flag value 2 is + // BCRYPT_USE_SYSTEM_PREFERRED_RNG. + #[link(name = "bcrypt")] + unsafe extern "system" { + fn BCryptGenRandom( + algorithm: *mut core::ffi::c_void, + buffer: *mut u8, + len: u32, + flags: u32, + ) -> i32; + } + const BCRYPT_USE_SYSTEM_PREFERRED_RNG: u32 = 2; + let status = unsafe { + BCryptGenRandom( + std::ptr::null_mut(), + (x as *mut u64).cast(), + std::mem::size_of::() as u32, + BCRYPT_USE_SYSTEM_PREFERRED_RNG, + ) + }; + assert_eq!(status, 0, "OS entropy unavailable: NTSTATUS {status:#010x}"); } /// A published status lasts half an hour unless core supersedes its revision. diff --git a/crates/basal-host/tests/builtin_host_regressions.rs b/crates/basal-host/tests/builtin_host_regressions.rs index 1108d1b..eb04fc0 100644 --- a/crates/basal-host/tests/builtin_host_regressions.rs +++ b/crates/basal-host/tests/builtin_host_regressions.rs @@ -2,6 +2,7 @@ use basal_host::builtins::{BuiltinHost, Grant, codes, envelope, fs, git, net}; use basal_host::{CallClass, CallRequest, Dispatched, Host}; use basal_proto::{CallKind, JsonText, Primitive, Settlement}; use serde_json::json; +#[cfg(unix)] use std::os::unix::fs::{PermissionsExt, symlink}; use std::path::PathBuf; use std::sync::atomic::{AtomicU64, Ordering}; @@ -31,7 +32,7 @@ impl Drop for Tree { #[test] fn escaped_800kb_file_is_a_typed_refusal_not_successful_null() { let tree = Tree::new(); - let path = tree.0.join("root/escaped"); + let path = tree.0.join("root").join("escaped"); std::fs::write(&path, vec![0; 800 * 1024]).unwrap(); let request = CallRequest { flow_id: "f".into(), @@ -63,6 +64,7 @@ fn escaped_800kb_file_is_a_typed_refusal_not_successful_null() { } #[test] +#[cfg(unix)] fn escaped_listing_exceeds_encoded_cap_as_a_typed_refusal() { let tree = Tree::new(); let dir = tree.0.join("root"); @@ -106,13 +108,16 @@ fn outside_missing_and_symlink_targets_have_identical_refusals() { let tree = Tree::new(); let existing = tree.0.join("secret"); std::fs::write(&existing, "secret").unwrap(); + #[cfg(unix)] let link = tree.0.join("root/link"); + #[cfg(unix)] symlink(&existing, &link).unwrap(); - let missing = tree.0.join("absent/child"); + let missing = tree.0.join("absent").join("child"); for purpose in [fs::Purpose::Read, fs::Purpose::Write] { let a = fs::resolve(existing.to_str().unwrap(), &tree.roots(), purpose).unwrap_err(); let b = fs::resolve(missing.to_str().unwrap(), &tree.roots(), purpose).unwrap_err(); assert_eq!(a, b); + #[cfg(unix)] if purpose == fs::Purpose::Read { assert_eq!( a, @@ -122,16 +127,20 @@ fn outside_missing_and_symlink_targets_have_identical_refusals() { assert_eq!(a.code, codes::DENIED); assert!(!a.message.contains(tree.0.to_str().unwrap())); } + let op = git::Op::RevParse { + reference: "HEAD".into(), + }; assert_eq!( - git::repo(existing.to_str().unwrap(), &tree.roots()).unwrap_err(), - git::repo(missing.to_str().unwrap(), &tree.roots()).unwrap_err() + git::run(existing.to_str().unwrap(), &tree.roots(), &op).unwrap_err(), + git::run(missing.to_str().unwrap(), &tree.roots(), &op).unwrap_err() ); } #[test] +#[cfg(unix)] fn atomic_write_drops_special_permission_bits() { let tree = Tree::new(); - let path = tree.0.join("root/file"); + let path = tree.0.join("root").join("file"); std::fs::write(&path, "old").unwrap(); std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o6755)).unwrap(); fs::write(path.to_str().unwrap(), &tree.roots(), "").unwrap(); @@ -179,6 +188,7 @@ fn method_override_and_compression_headers_are_denied() { } #[test] +#[cfg(unix)] fn git_reaps_descendants_that_keep_its_output_pipe_open() { use std::process::{Command, Stdio}; use std::time::Duration; @@ -219,13 +229,21 @@ fn git_reaps_descendants_that_keep_its_output_pipe_open() { fn tag_patterns_use_git_globs_including_hierarchical_names() { let tree = Tree::new(); let repo = tree.0.join("root"); + let global_config = tree.0.join("empty.gitconfig"); + std::fs::write(&global_config, []).unwrap(); let git_command = |args: &[&str]| { let result = std::process::Command::new("git") + .env_clear() + .envs(std::env::vars_os().filter(|(key, _)| { + ["PATH", "HOME", "SystemRoot", "USERPROFILE"] + .iter() + .any(|kept| key.to_string_lossy().eq_ignore_ascii_case(kept)) + })) .arg("-C") .arg(&repo) .args(args) .env("GIT_CONFIG_NOSYSTEM", "1") - .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_GLOBAL", &global_config) .env("GIT_AUTHOR_NAME", "Fixture") .env("GIT_AUTHOR_EMAIL", "fixture@example.invalid") .env("GIT_COMMITTER_NAME", "Fixture") diff --git a/crates/basal-launch/Cargo.toml b/crates/basal-launch/Cargo.toml new file mode 100644 index 0000000..b25e73f --- /dev/null +++ b/crates/basal-launch/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "basal-launch" +version = "0.1.0" +description = "Starts basal's worker process under the Windows confinement: AppContainer profile, restricted tokens, job, mitigations and the checks made before the worker runs." +edition.workspace = true +license.workspace = true +publish.workspace = true +rust-version.workspace = true + +# A tiny GUI-subsystem child image used only by this crate's tests. It is a +# binary of this package so that `cargo test` builds it next to the tests. +[[bin]] +name = "basal-launch-test-helper" +path = "src/bin/test-helper.rs" +test = false +bench = false + +[features] +default = [] +# Test-only launch variants: the weaker positive controls and one variant per +# confinement check, each built so that exactly that check must catch it. +# A build without this feature contains only the full confinement. +deviations = [] + +# User32 (window stations and desktops) and Userenv (AppContainer profiles) +# are loaded at run time from System32 instead of being linked, so that no +# image linking this crate imports them. +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Security_Authorization", + "Win32_Security_Cryptography", + "Win32_Storage_FileSystem", + "Win32_System_Console", + "Win32_System_JobObjects", + "Win32_System_LibraryLoader", + "Win32_System_Pipes", + "Win32_System_SystemInformation", + "Win32_System_SystemServices", + "Win32_System_Threading", + "Win32_System_WindowsProgramming", +] } diff --git a/crates/basal-launch/src/bin/test-helper.rs b/crates/basal-launch/src/bin/test-helper.rs new file mode 100644 index 0000000..7ee6f05 --- /dev/null +++ b/crates/basal-launch/src/bin/test-helper.rs @@ -0,0 +1,46 @@ +//! A minimal child image for basal-launch's own tests. +//! +//! It is a GUI-subsystem image, like the worker, so no console host starts +//! for it. Started by the launcher, it drops the start-up thread token the +//! launcher set (as the worker does before reading input), reports its +//! standard handles on stdout, optionally tries to create a file in its +//! TEMP directory and reports the outcome, then waits for one byte or the end +//! of stdin and exits. +#![cfg_attr(windows, windows_subsystem = "windows")] + +#[cfg(windows)] +fn main() { + use std::io::{Read, Write}; + use windows_sys::Win32::Security::RevertToSelf; + use windows_sys::Win32::System::Console::{ + GetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, + }; + + let reverted = unsafe { RevertToSelf() } != 0; + let [stdin, stdout, stderr] = [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE] + .map(|which| unsafe { GetStdHandle(which) } as usize); + let mut out = std::io::stdout().lock(); + let _ = writeln!( + out, + "ready stdin={stdin} stdout={stdout} stderr={stderr} reverted={reverted}" + ); + if std::env::args().any(|arg| arg == "--try-temp-write") { + let outcome = match std::env::var_os("TEMP") { + None => "no-temp".to_owned(), + Some(directory) => { + let path = std::path::Path::new(&directory).join("basal-launch-probe"); + match std::fs::File::create_new(&path) { + Ok(_) => "created".to_owned(), + Err(error) => format!("denied {}", error.raw_os_error().unwrap_or(-1)), + } + } + }; + let _ = writeln!(out, "temp-write {outcome}"); + } + let _ = out.flush(); + drop(out); + let _ = std::io::stdin().read(&mut [0u8; 1]); +} + +#[cfg(not(windows))] +fn main() {} diff --git a/crates/basal-launch/src/lib.rs b/crates/basal-launch/src/lib.rs new file mode 100644 index 0000000..66469a7 --- /dev/null +++ b/crates/basal-launch/src/lib.rs @@ -0,0 +1,28 @@ +//! Starts basal's worker process under the Windows confinement. +//! +//! On Windows the worker cannot confine itself the way it does on macOS and +//! Linux: most of the confinement has to be fixed by the parent when the +//! process is created. This crate owns that part: +//! +//! - the one AppContainer profile all workers share; +//! - the restricted primary token and the matching start-up thread token; +//! - the job the worker is born in, and its limits; +//! - the creation attributes: Less Privileged AppContainer, mitigation +//! policies, child-process ban and an explicit list of inherited handles; +//! - a minimal environment, working directory, window station and desktop; +//! - the checks the parent makes on the suspended process before it lets the +//! first instruction run, and the owned process wrapper afterwards; +//! - the one way to start an unconfined child (`spawn_plain`), and the +//! process-wide spawn lock every Windows spawn in basal takes while it +//! holds inheritable handles (`spawn_lock`). +//! +//! The worker's own checks, made after it starts and before it reads any +//! input, live in the worker. +//! +//! Off Windows this crate contains no code. + +#[cfg(windows)] +mod windows; + +#[cfg(windows)] +pub use windows::*; diff --git a/crates/basal-launch/src/windows/context.rs b/crates/basal-launch/src/windows/context.rs new file mode 100644 index 0000000..de9d525 --- /dev/null +++ b/crates/basal-launch/src/windows/context.rs @@ -0,0 +1,334 @@ +//! The start-up context a worker gets: a private window station and desktop, +//! a private TEMP directory, a minimal environment and a working directory. + +use super::native::{Result, check, groups, last, sid_string, token_buffer, wide}; +use super::profile::{Library, PackageSid}; +use std::ffi::c_void; +use std::mem::size_of; +use std::path::{Path, PathBuf}; +use std::ptr::{null, null_mut}; +use std::sync::Mutex; +use windows_sys::Win32::Foundation::{HANDLE, LocalFree}; +use windows_sys::Win32::Security::Authorization::ConvertStringSecurityDescriptorToSecurityDescriptorW; +use windows_sys::Win32::Security::Cryptography::{ + BCRYPT_USE_SYSTEM_PREFERRED_RNG, BCryptGenRandom, +}; +use windows_sys::Win32::Security::{ + DACL_SECURITY_INFORMATION, LABEL_SECURITY_INFORMATION, PROTECTED_DACL_SECURITY_INFORMATION, + PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES, SetFileSecurityW, TOKEN_USER, TokenGroups, + TokenUser, +}; +use windows_sys::Win32::System::SystemInformation::GetSystemWindowsDirectoryW; + +use super::native::SE_GROUP_LOGON_ID; + +/// `WINSTA_ALL_ACCESS | STANDARD_RIGHTS_REQUIRED`. +const STATION_ACCESS: u32 = 0x000f_037f; +/// Every desktop right plus the standard rights, requested by the creator. +const DESKTOP_ACCESS: u32 = 0x000f_01ff; + +type CreateStation = + unsafe extern "system" fn(*const u16, u32, u32, *const SECURITY_ATTRIBUTES) -> HANDLE; +type CreateDesktop = unsafe extern "system" fn( + *const u16, + *const u16, + *const c_void, + u32, + u32, + *const SECURITY_ATTRIBUTES, +) -> HANDLE; +type GetStation = unsafe extern "system" fn() -> HANDLE; +type SetStation = unsafe extern "system" fn(HANDLE) -> i32; +type CloseObject = unsafe extern "system" fn(HANDLE) -> i32; + +/// Creating a desktop uses the process's current window station, which is +/// process-wide state; launches that switch it must not interleave. +static STATION_SWITCH: Mutex<()> = Mutex::new(()); + +/// A worker's start-up context. Dropping it closes the station and desktop +/// and removes the TEMP directory, so it must outlive the worker. +pub(crate) struct Context { + /// Keeps User32 loaded until the close functions below have run. + _user32: Library, + station: HANDLE, + desktop: HANDLE, + close_station: CloseObject, + close_desktop: CloseObject, + /// `station\desktop`, NUL-terminated, for `STARTUPINFO.lpDesktop`. + pub(crate) desktop_name: Vec, + /// The working directory, NUL-terminated. + pub(crate) cwd: Vec, + /// The environment block: sorted `NAME=value` strings, each + /// NUL-terminated, then one more NUL. + pub(crate) environment: Vec, + /// The private TEMP directory. + pub(crate) temp: PathBuf, +} + +// The station and desktop handles and the module handle are process-wide +// and may be closed from any thread. +unsafe impl Send for Context {} +unsafe impl Sync for Context {} + +impl Drop for Context { + fn drop(&mut self) { + unsafe { + if !self.desktop.is_null() { + (self.close_desktop)(self.desktop); + } + (self.close_station)(self.station); + } + let _ = std::fs::remove_dir_all(&self.temp); + } +} + +/// A security descriptor parsed from SDDL, freed on drop. +struct Descriptor(PSECURITY_DESCRIPTOR); + +impl Descriptor { + fn parse(sddl: &str) -> Result { + let mut descriptor = null_mut(); + check( + unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + wide(sddl).as_ptr(), + 1, + &mut descriptor, + null_mut(), + ) + }, + "ConvertStringSecurityDescriptorToSecurityDescriptorW", + )?; + Ok(Self(descriptor)) + } + + fn attributes(&self) -> SECURITY_ATTRIBUTES { + SECURITY_ATTRIBUTES { + nLength: size_of::() as u32, + lpSecurityDescriptor: self.0, + bInheritHandle: 0, + } + } +} + +impl Drop for Descriptor { + fn drop(&mut self) { + unsafe { + LocalFree(self.0); + } + } +} + +/// The Windows directory, from the system rather than from this process's +/// own environment. +pub(crate) fn system_root() -> Result { + let mut buffer = [0u16; 260]; + let length = unsafe { GetSystemWindowsDirectoryW(buffer.as_mut_ptr(), buffer.len() as u32) }; + if length == 0 || length as usize >= buffer.len() { + return Err(last("GetSystemWindowsDirectoryW")); + } + Ok(String::from_utf16_lossy(&buffer[..length as usize])) +} + +/// The environment block a worker gets, and nothing from the parent's own +/// environment: only the Windows directory variables the system DLLs read, +/// `PATH` limited to System32, and TEMP-style variables naming the private +/// directory. Names are sorted case-insensitively, as Windows requires of an +/// environment block. +pub(crate) fn environment_block(system_root: &str, temp: &str) -> Vec { + let drive = system_root.get(..2).unwrap_or(system_root); + let mut variables = [ + ("LOCALAPPDATA", temp.to_owned()), + ("PATH", format!("{system_root}\\System32")), + ("SYSTEMDRIVE", drive.to_owned()), + ("SYSTEMROOT", system_root.to_owned()), + ("TEMP", temp.to_owned()), + ("TMP", temp.to_owned()), + ("windir", system_root.to_owned()), + ]; + variables.sort_by_key(|(name, _)| name.to_ascii_uppercase()); + variables + .iter() + .flat_map(|(name, value)| { + format!("{name}={value}") + .encode_utf16() + .chain([0]) + .collect::>() + }) + .chain([0]) + .collect() +} + +/// Creates the context for one worker. +/// +/// - **Window station and desktop:** private to this worker. Only SYSTEM, +/// Administrators and the parent's user have full access; the logon SID, +/// the NULL SID and the package get read and execute on the station and +/// read-control, read-objects and write-objects (`0x20081`) on the +/// desktop. Both carry a Low no-write-up label. +/// - **TEMP:** a fresh directory with a protected DACL. The worker's package +/// and the NULL SID get only read and execute, so the variables resolve to +/// an existing directory in which the worker can create nothing. +/// - **Working directory:** the image's own directory, which the package +/// must already be allowed to read for the image to load at all. +pub(crate) fn create(image: &Path, parent_token: HANDLE, package: &PackageSid) -> Result { + unsafe { + let user32 = Library::system32("user32.dll")?; + let create_station: CreateStation = user32.symbol(b"CreateWindowStationW\0")?; + let create_desktop: CreateDesktop = user32.symbol(b"CreateDesktopW\0")?; + let get_station: GetStation = user32.symbol(b"GetProcessWindowStation\0")?; + let set_station: SetStation = user32.symbol(b"SetProcessWindowStation\0")?; + let close_station: CloseObject = user32.symbol(b"CloseWindowStation\0")?; + let close_desktop: CloseObject = user32.symbol(b"CloseDesktop\0")?; + + let user = token_buffer(parent_token, TokenUser)?; + let user = sid_string((*user.as_ptr().cast::()).User.Sid)?; + let group_data = token_buffer(parent_token, TokenGroups)?; + let logon = match groups(&group_data) + .iter() + .find(|group| group.Attributes & SE_GROUP_LOGON_ID == SE_GROUP_LOGON_ID) + { + Some(group) => Some(sid_string(group.Sid)?), + None => None, + }; + let package = package.as_str(); + let logon_station = logon + .as_deref() + .map(|sid| format!("(A;;GRGX;;;{sid})")) + .unwrap_or_default(); + let logon_desktop = logon + .as_deref() + .map(|sid| format!("(A;;0x20081;;;{sid})")) + .unwrap_or_default(); + let station_sd = Descriptor::parse(&format!( + "D:(A;;GA;;;SY)(A;;GA;;;BA)(A;;GA;;;{user}){logon_station}(A;;GRGX;;;S-1-0-0)(A;;GRGX;;;{package})S:(ML;;NW;;;LW)" + ))?; + let desktop_sd = Descriptor::parse(&format!( + "D:(A;;GA;;;SY)(A;;GA;;;BA)(A;;GA;;;{user}){logon_desktop}(A;;0x20081;;;S-1-0-0)(A;;0x20081;;;{package})S:(ML;;NW;;;LW)" + ))?; + + let name = context_name()?; + let station = create_station( + wide(&name).as_ptr(), + 0, + STATION_ACCESS, + &station_sd.attributes(), + ); + if station.is_null() { + return Err(last("CreateWindowStationW")); + } + let mut context = Context { + _user32: user32, + station, + desktop: null_mut(), + close_station, + close_desktop, + desktop_name: wide(format!("{name}\\worker")), + cwd: Vec::new(), + environment: Vec::new(), + temp: PathBuf::new(), + }; + { + let _switch = STATION_SWITCH + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + let original = get_station(); + check(set_station(station), "SetProcessWindowStation(worker)")?; + context.desktop = create_desktop( + wide("worker").as_ptr(), + null(), + null(), + 0, + DESKTOP_ACCESS, + &desktop_sd.attributes(), + ); + let desktop_error = context.desktop.is_null().then(|| last("CreateDesktopW")); + // Restore the parent's station before anything else can run on it. + check(set_station(original), "SetProcessWindowStation(restore)")?; + if let Some(error) = desktop_error { + return Err(error); + } + } + + let directory = image + .parent() + .ok_or_else(|| format!("{} has no parent directory", image.display()))?; + context.cwd = wide(directory); + // `create_dir` fails on an existing name, so a directory that is + // already there (left by a killed process, or planted) is never + // reused; with an unpredictable name nobody can plant it in advance. + let temp = std::env::temp_dir().join(&name); + std::fs::create_dir(&temp) + .map_err(|error| format!("create {}: {error}", temp.display()))?; + // The system TEMP path often holds 8.3 short names (`RUNNER~1`); + // give the worker the long form. + context.temp = long_path(&temp); + let temp_text = context.temp.to_string_lossy().into_owned(); + let temp_sd = Descriptor::parse(&format!( + "D:P(A;OICI;GA;;;SY)(A;OICI;GA;;;BA)(A;OICI;GA;;;{user})(A;OICI;GRGX;;;S-1-0-0)(A;OICI;GRGX;;;{package})S:(ML;OICI;NW;;;LW)" + ))?; + check( + SetFileSecurityW( + wide(&context.temp).as_ptr(), + DACL_SECURITY_INFORMATION + | LABEL_SECURITY_INFORMATION + | PROTECTED_DACL_SECURITY_INFORMATION, + temp_sd.0, + ), + "SetFileSecurityW(worker TEMP)", + )?; + context.environment = environment_block(&system_root()?, &temp_text); + Ok(context) + } +} + +/// A fresh name for one launch's window station and TEMP directory: 128 +/// bits from the system's random number generator. A name made from the +/// process id and a counter repeats when a later process gets the same id, +/// and can be guessed and created by someone else first. +pub(crate) fn context_name() -> Result { + let mut random = [0u8; 16]; + let status = unsafe { + BCryptGenRandom( + null_mut(), + random.as_mut_ptr(), + random.len() as u32, + BCRYPT_USE_SYSTEM_PREFERRED_RNG, + ) + }; + if status < 0 { + return Err(format!("BCryptGenRandom: NTSTATUS {status:#x}")); + } + let hex: String = random.iter().map(|byte| format!("{byte:02x}")).collect(); + Ok(format!("cortexkit_basal_{hex}")) +} + +/// The long form of an existing path, without the `\\?\` prefix that +/// canonicalization adds, or the path unchanged if it has no such form. +fn long_path(path: &Path) -> PathBuf { + let Ok(canonical) = std::fs::canonicalize(path) else { + return path.to_owned(); + }; + let text = canonical.to_string_lossy(); + match text.strip_prefix(r"\\?\") { + Some(rest) if rest.as_bytes().get(1) == Some(&b':') => PathBuf::from(rest), + _ => path.to_owned(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn context_names_are_random_and_never_repeat() { + let names: std::collections::BTreeSet = + (0..64).map(|_| context_name().unwrap()).collect(); + assert_eq!(names.len(), 64); + for name in &names { + let hex = name.strip_prefix("cortexkit_basal_").unwrap(); + assert_eq!(hex.len(), 32, "{name}"); + assert!(hex.bytes().all(|b| b.is_ascii_hexdigit()), "{name}"); + } + } +} diff --git a/crates/basal-launch/src/windows/deviation.rs b/crates/basal-launch/src/windows/deviation.rs new file mode 100644 index 0000000..ff712f0 --- /dev/null +++ b/crates/basal-launch/src/windows/deviation.rs @@ -0,0 +1,462 @@ +//! The launch variants: the full confinement, and the test-only variants. + +use super::error::Refusal; +use super::launch::MITIGATION_POLICY; + +/// Which launch recipe to build. +/// +/// A build without the `deviations` feature has only [`Deviation::Full`]. +/// With the feature, every other variant is a test control: +/// +/// - two weaker positive controls, which show that a denial seen under the +/// full confinement comes from the confinement and not from a missing +/// target or a broken probe; +/// - one variant per confinement check, each building the full recipe with +/// exactly one property broken, so that exactly that check must refuse it. +/// A check's refusal test and its mutation control share this one +/// definition of what the check is meant to catch. +/// +/// For a worker check, the parent's own checks expect the broken property, so +/// the worker gets to start and must refuse by itself. For a parent check, +/// the parent still expects the full recipe and must refuse before resume. +#[cfg_attr( + not(feature = "deviations"), + doc = "```compile_fail\nuse basal_launch::Deviation;\nlet _ = Deviation::PlainWithoutHandleList;\n```" +)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum Deviation { + /// The full confinement. The only variant in production builds. + #[default] + Full, + + /// Positive control: a Less Privileged AppContainer with no capabilities, + /// started with `CreateProcessW` and the parent's unrestricted token, no + /// mitigations and no initial thread token. + #[cfg(feature = "deviations")] + LpacOnly, + /// Positive control: no AppContainer at all. + #[cfg(feature = "deviations")] + Plain, + /// Plain positive control with all inheritable parent handles passed through. + #[cfg(feature = "deviations")] + PlainWithoutHandleList, + + /// Worker check `thread-token-present`. The parent cannot leave a thread + /// token behind in a correctly built worker, so it asks the worker to + /// keep one (see [`Deviation::child_argument`]). + #[cfg(feature = "deviations")] + ThreadTokenPresent, + /// Worker check `integrity-lower-failed`. The parent cannot make lowering + /// fail in a correctly built worker, so it asks the worker to simulate the + /// failure (see [`Deviation::child_argument`]). + #[cfg(feature = "deviations")] + IntegrityLowerFailed, + /// Worker check `not-lpac`: the opt-out from the `ALL_APPLICATION_PACKAGES` + /// group is left out, so objects that grant every AppContainer package + /// grant this worker too, as for an ordinary AppContainer. + #[cfg(feature = "deviations")] + NotLpac, + /// Worker check `capabilities-present`: one capability is granted. + #[cfg(feature = "deviations")] + CapabilitiesPresent, + /// Worker check `restricting-sid-mismatch`: Everyone is added to the + /// restricting SIDs, next to the NULL SID. + #[cfg(feature = "deviations")] + RestrictingSidMismatch, + /// Worker check `group-not-deny-only`: the logon SID stays enabled. + #[cfg(feature = "deviations")] + GroupNotDenyOnly, + /// Worker check `privileges-present`: the privileges that survive + /// filtering are not removed. + #[cfg(feature = "deviations")] + PrivilegesPresent, + /// Worker check `integrity-not-untrusted`. The worker lowers its own + /// integrity, so the parent asks it to skip that step (see + /// [`Deviation::child_argument`]). + #[cfg(feature = "deviations")] + IntegrityNotUntrusted, + /// Worker check `mitigation-mismatch`: the dynamic-code prohibition is + /// left out of the mitigation policy. + #[cfg(feature = "deviations")] + MitigationMismatch, + /// Worker check `handle-not-allowed`: the explicit inherited-handle list + /// is left out, so every inheritable handle of the parent is inherited. + #[cfg(feature = "deviations")] + HandleNotAllowed, + + /// Parent check `job-limits-mismatch`: the job is created with an + /// active-process limit of 2. + #[cfg(feature = "deviations")] + JobLimitsMismatch, + /// Parent check `not-in-owned-job`: the child is not created in the job. + #[cfg(feature = "deviations")] + NotInOwnedJob, + /// Parent check `birth-token-mismatch`: the privileges that survive + /// filtering are not removed, while the check still expects none. + #[cfg(feature = "deviations")] + BirthTokenMismatch, + /// Parent check `initial-token-open`: the start-up thread token is never + /// set on the suspended thread. + #[cfg(feature = "deviations")] + InitialTokenOpen, +} + +/// The three basic shapes a launch can take. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Shape { + /// The full confinement, possibly with one property broken. + Confined, + /// The LPAC-only positive control. + #[cfg(feature = "deviations")] + LpacOnly, + /// The plain positive control. + #[cfg(feature = "deviations")] + Plain, +} + +impl Deviation { + /// Every variant, for tests that cover them all. + #[cfg(feature = "deviations")] + pub const ALL: [Self; 18] = [ + Self::Full, + Self::LpacOnly, + Self::Plain, + Self::PlainWithoutHandleList, + Self::ThreadTokenPresent, + Self::IntegrityLowerFailed, + Self::NotLpac, + Self::CapabilitiesPresent, + Self::RestrictingSidMismatch, + Self::GroupNotDenyOnly, + Self::PrivilegesPresent, + Self::IntegrityNotUntrusted, + Self::MitigationMismatch, + Self::HandleNotAllowed, + Self::JobLimitsMismatch, + Self::NotInOwnedJob, + Self::BirthTokenMismatch, + Self::InitialTokenOpen, + ]; + + /// The variant's stable name. For a check's variant it is the check's + /// reason token. + pub const fn name(self) -> &'static str { + match self { + Self::Full => "full", + #[cfg(feature = "deviations")] + Self::LpacOnly => "lpac-only", + #[cfg(feature = "deviations")] + Self::Plain => "plain", + #[cfg(feature = "deviations")] + Self::PlainWithoutHandleList => "plain-without-handle-list", + #[cfg(feature = "deviations")] + Self::ThreadTokenPresent => "thread-token-present", + #[cfg(feature = "deviations")] + Self::IntegrityLowerFailed => "integrity-lower-failed", + #[cfg(feature = "deviations")] + Self::NotLpac => "not-lpac", + #[cfg(feature = "deviations")] + Self::CapabilitiesPresent => "capabilities-present", + #[cfg(feature = "deviations")] + Self::RestrictingSidMismatch => "restricting-sid-mismatch", + #[cfg(feature = "deviations")] + Self::GroupNotDenyOnly => "group-not-deny-only", + #[cfg(feature = "deviations")] + Self::PrivilegesPresent => "privileges-present", + #[cfg(feature = "deviations")] + Self::IntegrityNotUntrusted => "integrity-not-untrusted", + #[cfg(feature = "deviations")] + Self::MitigationMismatch => "mitigation-mismatch", + #[cfg(feature = "deviations")] + Self::HandleNotAllowed => "handle-not-allowed", + #[cfg(feature = "deviations")] + Self::JobLimitsMismatch => "job-limits-mismatch", + #[cfg(feature = "deviations")] + Self::NotInOwnedJob => "not-in-owned-job", + #[cfg(feature = "deviations")] + Self::BirthTokenMismatch => "birth-token-mismatch", + #[cfg(feature = "deviations")] + Self::InitialTokenOpen => "initial-token-open", + } + } + + /// The reason the worker must exit with, for a worker check's variant. + pub const fn worker_reason(self) -> Option<&'static str> { + #[cfg(feature = "deviations")] + if matches!( + self, + Self::ThreadTokenPresent + | Self::IntegrityLowerFailed + | Self::NotLpac + | Self::CapabilitiesPresent + | Self::RestrictingSidMismatch + | Self::GroupNotDenyOnly + | Self::PrivilegesPresent + | Self::IntegrityNotUntrusted + | Self::MitigationMismatch + | Self::HandleNotAllowed + ) { + return Some(self.name()); + } + None + } + + /// The refusal the parent must return, for a parent check's variant. + pub const fn parent_refusal(self) -> Option { + #[cfg(feature = "deviations")] + match self { + Self::JobLimitsMismatch => return Some(Refusal::JobLimitsMismatch), + Self::NotInOwnedJob => return Some(Refusal::NotInOwnedJob), + Self::BirthTokenMismatch => return Some(Refusal::BirthTokenMismatch), + Self::InitialTokenOpen => return Some(Refusal::InitialTokenOpen), + _ => {} + } + None + } + + /// The argument added to the child's command line for a worker check the + /// parent cannot set up itself. Only a worker built with its own test + /// support acts on it. + pub fn child_argument(self) -> Option { + #[cfg(feature = "deviations")] + if matches!( + self, + Self::ThreadTokenPresent | Self::IntegrityLowerFailed | Self::IntegrityNotUntrusted + ) { + return Some(format!("--confinement-deviation={}", self.name())); + } + None + } + + pub(crate) const fn shape(self) -> Shape { + #[cfg(feature = "deviations")] + match self { + Self::LpacOnly => return Shape::LpacOnly, + Self::Plain | Self::PlainWithoutHandleList => return Shape::Plain, + _ => {} + } + Shape::Confined + } + + /// Whether the `ALL_APPLICATION_PACKAGES` opt-out, which makes the + /// AppContainer a Less Privileged one, is applied. + pub(crate) const fn less_privileged(self) -> bool { + #[cfg(feature = "deviations")] + if matches!(self, Self::NotLpac) { + return false; + } + true + } + + /// Whether one capability is granted to the AppContainer. Production + /// builds grant none, so they do not have this question at all. + #[cfg(feature = "deviations")] + pub(crate) const fn grants_capability(self) -> bool { + matches!(self, Self::CapabilitiesPresent) + } + + /// Whether the logon SID is left enabled in the primary token. + pub(crate) const fn keeps_logon_group(self) -> bool { + #[cfg(feature = "deviations")] + if matches!(self, Self::GroupNotDenyOnly) { + return true; + } + false + } + + /// Whether Everyone joins the NULL SID among the restricting SIDs. + pub(crate) const fn widens_restricting_sids(self) -> bool { + #[cfg(feature = "deviations")] + if matches!(self, Self::RestrictingSidMismatch) { + return true; + } + false + } + + /// Whether the privileges that survive filtering stay in the primary. + pub(crate) const fn keeps_privileges(self) -> bool { + #[cfg(feature = "deviations")] + if matches!(self, Self::PrivilegesPresent | Self::BirthTokenMismatch) { + return true; + } + false + } + + /// Whether the parent's birth check expects privileges in the primary. + /// It differs from `keeps_privileges` only for the variant that tests the + /// birth check itself. + pub(crate) const fn expects_privileges(self) -> bool { + #[cfg(feature = "deviations")] + if matches!(self, Self::PrivilegesPresent) { + return true; + } + false + } + + /// The mitigation policy passed at creation. + pub(crate) const fn mitigation_policy(self) -> u64 { + #[cfg(feature = "deviations")] + if matches!(self, Self::MitigationMismatch) { + return MITIGATION_POLICY & !super::launch::MITIGATION_PROHIBIT_DYNAMIC_CODE; + } + MITIGATION_POLICY + } + + /// Whether the explicit inherited-handle list is passed. + pub(crate) const fn restricts_inherited_handles(self) -> bool { + #[cfg(feature = "deviations")] + if matches!(self, Self::HandleNotAllowed | Self::PlainWithoutHandleList) { + return false; + } + true + } + + /// The active-process limit the job is created with. + pub(crate) const fn job_active_process_limit(self) -> u32 { + #[cfg(feature = "deviations")] + if matches!(self, Self::JobLimitsMismatch) { + return 2; + } + 1 + } + + /// Whether the child is created inside the owned job. + pub(crate) const fn joins_job(self) -> bool { + #[cfg(feature = "deviations")] + if matches!(self, Self::NotInOwnedJob) { + return false; + } + true + } + + /// Whether the start-up thread token is set on the suspended thread. + pub(crate) const fn sets_initial_token(self) -> bool { + #[cfg(feature = "deviations")] + if matches!(self, Self::InitialTokenOpen) { + return false; + } + true + } +} + +impl std::fmt::Display for Deviation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.name()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn full_is_the_default_and_names_no_check() { + assert_eq!(Deviation::default(), Deviation::Full); + assert_eq!(Deviation::Full.name(), "full"); + assert_eq!(Deviation::Full.worker_reason(), None); + assert_eq!(Deviation::Full.parent_refusal(), None); + assert_eq!(Deviation::Full.child_argument(), None); + assert_eq!(Deviation::Full.shape(), Shape::Confined); + assert_eq!(Deviation::Full.mitigation_policy(), MITIGATION_POLICY); + assert!(Deviation::Full.less_privileged()); + #[cfg(feature = "deviations")] + assert!(!Deviation::Full.grants_capability()); + assert!(!Deviation::Full.keeps_logon_group()); + assert!(!Deviation::Full.widens_restricting_sids()); + assert!(!Deviation::Full.keeps_privileges()); + assert!(!Deviation::Full.expects_privileges()); + assert!(Deviation::Full.restricts_inherited_handles()); + assert_eq!(Deviation::Full.job_active_process_limit(), 1); + assert!(Deviation::Full.joins_job()); + assert!(Deviation::Full.sets_initial_token()); + } + + /// Without the `deviations` feature the launcher can build only the full + /// recipe. The match has no wildcard arm, so this test stops compiling + /// if any other variant exists in such a build. + #[cfg(not(feature = "deviations"))] + #[test] + fn a_build_without_the_feature_has_only_the_full_recipe() { + match Deviation::default() { + Deviation::Full => {} + } + } + + /// Every check's variant names exactly one check, and changes exactly + /// one property of the full recipe (or, for the three worker checks the + /// parent cannot set up, only adds the request to the worker). + #[cfg(feature = "deviations")] + #[test] + fn every_check_variant_breaks_exactly_one_property() { + let full = knobs(Deviation::Full); + for deviation in Deviation::ALL { + let checks = usize::from(deviation.worker_reason().is_some()) + + usize::from(deviation.parent_refusal().is_some()); + let control = matches!( + deviation, + Deviation::Full + | Deviation::LpacOnly + | Deviation::Plain + | Deviation::PlainWithoutHandleList + ); + assert_eq!(checks, usize::from(!control), "{deviation}"); + if let Some(reason) = deviation.worker_reason() { + assert_eq!(reason, deviation.name()); + } + if let Some(refusal) = deviation.parent_refusal() { + assert_eq!(refusal.reason(), deviation.name()); + } + if control { + continue; + } + let changed = knobs(deviation) + .iter() + .zip(full.iter()) + .filter(|(a, b)| a != b) + .count(); + let expected = match deviation { + // `BirthTokenMismatch` builds the same token as + // `PrivilegesPresent` but leaves the parent expecting no + // privileges, so only `keeps_privileges` differs from the full + // recipe. `PrivilegesPresent` changes both `keeps_privileges` + // and `expects_privileges`. + Deviation::PrivilegesPresent => 2, + _ => 1, + }; + assert_eq!(changed, expected, "{deviation}"); + } + } + + #[cfg(feature = "deviations")] + #[test] + fn dropped_handle_list_controls_change_only_the_handle_list() { + for (normal, dropped) in [ + (Deviation::Full, Deviation::HandleNotAllowed), + (Deviation::Plain, Deviation::PlainWithoutHandleList), + ] { + assert_eq!(normal.shape(), dropped.shape()); + let mut expected = knobs(normal); + assert_eq!(expected[7], 1); + expected[7] = 0; + assert_eq!(knobs(dropped), expected); + } + } + + #[cfg(feature = "deviations")] + fn knobs(deviation: Deviation) -> Vec { + vec![ + u64::from(deviation.less_privileged()), + u64::from(deviation.grants_capability()), + u64::from(deviation.keeps_logon_group()), + u64::from(deviation.widens_restricting_sids()), + u64::from(deviation.keeps_privileges()), + u64::from(deviation.expects_privileges()), + deviation.mitigation_policy(), + u64::from(deviation.restricts_inherited_handles()), + u64::from(deviation.job_active_process_limit()), + u64::from(deviation.joins_job()), + u64::from(deviation.sets_initial_token()), + u64::from(deviation.child_argument().is_some()), + ] + } +} diff --git a/crates/basal-launch/src/windows/error.rs b/crates/basal-launch/src/windows/error.rs new file mode 100644 index 0000000..e1285db --- /dev/null +++ b/crates/basal-launch/src/windows/error.rs @@ -0,0 +1,99 @@ +//! Launch failures, and the named refusals the parent's checks produce. + +use std::fmt; + +/// A named reason the launcher refused to start a worker. +/// +/// Every refusal leaves nothing running: a suspended child that fails a check +/// is killed before its first instruction executes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Refusal { + /// The shared AppContainer profile could not be created or opened, so + /// there is no package identity to confine the worker with. + AppContainerProfileUnavailable, + /// The owned job's flags or limits, read back before resume, are not the + /// ones the confinement requires. + JobLimitsMismatch, + /// The suspended child is not a member of the job the parent created. + NotInOwnedJob, + /// The suspended child's primary token differs from the token the parent + /// built. + BirthTokenMismatch, + /// The start-up thread token could not be read back from the suspended + /// thread as required, or the parent's handle to it did not close before + /// resume. + InitialTokenOpen, +} + +impl Refusal { + /// The stable reason token, as written in logs and matched by tests. + pub const fn reason(self) -> &'static str { + match self { + Self::AppContainerProfileUnavailable => "appcontainer-profile-unavailable", + Self::JobLimitsMismatch => "job-limits-mismatch", + Self::NotInOwnedJob => "not-in-owned-job", + Self::BirthTokenMismatch => "birth-token-mismatch", + Self::InitialTokenOpen => "initial-token-open", + } + } +} + +impl fmt::Display for Refusal { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.reason()) + } +} + +/// Why a launch did not produce a running worker. +#[derive(Debug)] +pub enum LaunchError { + /// A named check refused the launch. + Refused { + /// Which check refused. + refusal: Refusal, + /// What was observed, for diagnosis. + detail: String, + }, + /// A system call needed to build the launch failed. Nothing was started, + /// or what was started has been killed. + Failed(String), +} + +impl LaunchError { + pub(crate) fn refused(refusal: Refusal, detail: impl Into) -> Self { + Self::Refused { + refusal, + detail: detail.into(), + } + } + + /// The named refusal, when a check refused the launch. + pub fn refusal(&self) -> Option { + match self { + Self::Refused { refusal, .. } => Some(*refusal), + Self::Failed(_) => None, + } + } + + /// The reason token of the refusal, when a check refused the launch. + pub fn reason(&self) -> Option<&'static str> { + self.refusal().map(Refusal::reason) + } +} + +impl fmt::Display for LaunchError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Refused { refusal, detail } => write!(f, "{refusal}: {detail}"), + Self::Failed(detail) => write!(f, "worker launch failed: {detail}"), + } + } +} + +impl std::error::Error for LaunchError {} + +impl From for LaunchError { + fn from(detail: String) -> Self { + Self::Failed(detail) + } +} diff --git a/crates/basal-launch/src/windows/job.rs b/crates/basal-launch/src/windows/job.rs new file mode 100644 index 0000000..e61fd1a --- /dev/null +++ b/crates/basal-launch/src/windows/job.rs @@ -0,0 +1,172 @@ +//! The job a confined worker is born in, and its read-back. + +use super::native::{Result, check, owned}; +use std::mem::{size_of, zeroed}; +use std::os::windows::io::{AsRawHandle, OwnedHandle}; +use std::ptr::{null, null_mut}; +use windows_sys::Win32::Foundation::HANDLE; +use windows_sys::Win32::System::JobObjects::{ + CreateJobObjectW, IsProcessInJob, JOB_OBJECT_LIMIT_ACTIVE_PROCESS, + JOB_OBJECT_LIMIT_DIE_ON_UNHANDLED_EXCEPTION, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + JOB_OBJECT_LIMIT_PROCESS_MEMORY, JOBOBJECT_BASIC_UI_RESTRICTIONS, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectBasicUIRestrictions, + JobObjectExtendedLimitInformation, QueryInformationJobObject, SetInformationJobObject, +}; + +/// The job's limit flags, `0x2508`: kill every member when the last job +/// handle closes, end a member on an unhandled exception instead of showing +/// an error dialog, bound each member's committed memory, and bound the +/// number of live members. No breakaway flag is set, so a member can never +/// leave the job. +pub const JOB_LIMIT_FLAGS: u32 = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + | JOB_OBJECT_LIMIT_DIE_ON_UNHANDLED_EXCEPTION + | JOB_OBJECT_LIMIT_PROCESS_MEMORY + | JOB_OBJECT_LIMIT_ACTIVE_PROCESS; + +/// The UI restrictions, `0xff`: no USER handles from outside the job, no +/// clipboard reads or writes, no system-parameter or display-settings +/// changes, no global atoms, no desktop switching and no exit-Windows. +/// Newer SDKs also define an input-method restriction, `0x100`, and fold it +/// into `JOB_OBJECT_UILIMIT_ALL`. The confinement was validated with `0xff`, +/// so that is the value set and required on read-back. +pub const JOB_UI_RESTRICTIONS: u32 = 0xff; + +/// The limits of a job, as read back from the system. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct JobLimits { + /// `LimitFlags` of the extended limit information. + pub flags: u32, + /// The most processes the job may hold at once. + pub active_process_limit: u32, + /// The commit limit of each process, in bytes. + pub process_memory_limit: usize, + /// The commit limit of the whole job, in bytes; unset is 0. + pub job_memory_limit: usize, + /// The UI restriction flags (see [`JOB_UI_RESTRICTIONS`]). + pub ui_restrictions: u32, +} + +impl JobLimits { + /// The limits a confined worker's job must have, for the given commit + /// limit: one live process, no breakaway, no whole-job memory limit, and + /// every UI restriction. + pub fn confined(commit_bytes: usize) -> Self { + Self { + flags: JOB_LIMIT_FLAGS, + active_process_limit: 1, + process_memory_limit: commit_bytes, + job_memory_limit: 0, + ui_restrictions: JOB_UI_RESTRICTIONS, + } + } +} + +/// Creates the confined worker's job. Its handle is not inheritable, so it +/// never reaches the worker. +pub(crate) fn create_confined( + commit_bytes: usize, + active_process_limit: u32, +) -> Result { + let job = owned( + unsafe { CreateJobObjectW(null(), null()) }, + "CreateJobObjectW", + )?; + let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { zeroed() }; + limits.BasicLimitInformation.LimitFlags = JOB_LIMIT_FLAGS; + limits.BasicLimitInformation.ActiveProcessLimit = active_process_limit; + limits.ProcessMemoryLimit = commit_bytes; + set_limits(job.as_raw_handle(), &limits)?; + let ui = JOBOBJECT_BASIC_UI_RESTRICTIONS { + UIRestrictionsClass: JOB_UI_RESTRICTIONS, + }; + check( + unsafe { + SetInformationJobObject( + job.as_raw_handle(), + JobObjectBasicUIRestrictions, + (&ui as *const JOBOBJECT_BASIC_UI_RESTRICTIONS).cast(), + size_of::() as u32, + ) + }, + "SetInformationJobObject(UI restrictions)", + )?; + Ok(job) +} + +/// Creates a job whose only limit is killing its members when the last +/// handle closes. The positive controls and the token source process are +/// put in one, so a parent that dies never leaves them behind. +pub(crate) fn create_kill_on_close() -> Result { + let job = owned( + unsafe { CreateJobObjectW(null(), null()) }, + "CreateJobObjectW", + )?; + let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { zeroed() }; + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + set_limits(job.as_raw_handle(), &limits)?; + Ok(job) +} + +fn set_limits(job: HANDLE, limits: &JOBOBJECT_EXTENDED_LIMIT_INFORMATION) -> Result<()> { + check( + unsafe { + SetInformationJobObject( + job, + JobObjectExtendedLimitInformation, + (limits as *const JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(), + size_of::() as u32, + ) + }, + "SetInformationJobObject(limits)", + ) +} + +/// Reads a job's limits back through the given job handle. +/// +/// The handle matters: a query with a NULL handle describes whichever job +/// the caller itself is in, such as a CI runner's, not the worker's. +pub(crate) fn read_limits(job: HANDLE) -> Result { + let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { zeroed() }; + let mut ui: JOBOBJECT_BASIC_UI_RESTRICTIONS = unsafe { zeroed() }; + check( + unsafe { + QueryInformationJobObject( + job, + JobObjectExtendedLimitInformation, + (&mut limits as *mut JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(), + size_of::() as u32, + null_mut(), + ) + }, + "QueryInformationJobObject(limits)", + )?; + check( + unsafe { + QueryInformationJobObject( + job, + JobObjectBasicUIRestrictions, + (&mut ui as *mut JOBOBJECT_BASIC_UI_RESTRICTIONS).cast(), + size_of::() as u32, + null_mut(), + ) + }, + "QueryInformationJobObject(UI restrictions)", + )?; + Ok(JobLimits { + flags: limits.BasicLimitInformation.LimitFlags, + active_process_limit: limits.BasicLimitInformation.ActiveProcessLimit, + process_memory_limit: limits.ProcessMemoryLimit, + job_memory_limit: limits.JobMemoryLimit, + ui_restrictions: ui.UIRestrictionsClass, + }) +} + +/// Whether `process` is a member of `job`. +pub(crate) fn contains(job: HANDLE, process: HANDLE) -> Result { + let mut member = 0; + check( + unsafe { IsProcessInJob(process, job, &mut member) }, + "IsProcessInJob", + )?; + Ok(member != 0) +} diff --git a/crates/basal-launch/src/windows/launch.rs b/crates/basal-launch/src/windows/launch.rs new file mode 100644 index 0000000..058daf2 --- /dev/null +++ b/crates/basal-launch/src/windows/launch.rs @@ -0,0 +1,799 @@ +//! Building the launch: creation attributes, the suspended child, the +//! parent's checks before resume, and resume. + +use super::context; +use super::deviation::{Deviation, Shape}; +use super::error::{LaunchError, Refusal}; +use super::job::{self, JobLimits}; +use super::native::{Result, SidBuf, check, last, owned, wide}; +use super::process::{ConfinedProcess, KILL_EXIT_CODE}; +use super::profile::{PackageSid, create_or_open_profile}; +use super::spawn_lock::{make_inheritable, spawn_lock}; +use super::token::{self, BirthExpectation}; +use std::ffi::{OsStr, OsString, c_void}; +use std::fs::File; +use std::mem::{size_of, zeroed}; +use std::os::windows::ffi::OsStrExt; +use std::os::windows::io::{AsRawHandle, FromRawHandle, IntoRawHandle, OwnedHandle}; +use std::path::{Path, PathBuf}; +use std::ptr::{null, null_mut}; +use windows_sys::Win32::Foundation::{CloseHandle, HANDLE}; +use windows_sys::Win32::Security::{ + SECURITY_CAPABILITIES, SID_AND_ATTRIBUTES, TOKEN_ALL_ACCESS, TOKEN_QUERY, +}; +use windows_sys::Win32::System::Pipes::CreatePipe; +use windows_sys::Win32::System::SystemServices::SE_GROUP_ENABLED; +use windows_sys::Win32::System::Threading::{ + CREATE_NO_WINDOW, CREATE_SUSPENDED, CREATE_UNICODE_ENVIRONMENT, CreateProcessAsUserW, + CreateProcessW, DeleteProcThreadAttributeList, EXTENDED_STARTUPINFO_PRESENT, INFINITE, + InitializeProcThreadAttributeList, LPPROC_THREAD_ATTRIBUTE_LIST, OpenProcessToken, + OpenThreadToken, PROC_THREAD_ATTRIBUTE_ALL_APPLICATION_PACKAGES_POLICY, + PROC_THREAD_ATTRIBUTE_CHILD_PROCESS_POLICY, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, + PROC_THREAD_ATTRIBUTE_JOB_LIST, PROC_THREAD_ATTRIBUTE_MITIGATION_POLICY, + PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, PROCESS_INFORMATION, ResumeThread, + STARTF_USESTDHANDLES, STARTUPINFOEXW, SetThreadToken, TerminateProcess, + UpdateProcThreadAttribute, WaitForSingleObject, +}; +use windows_sys::Win32::System::WindowsProgramming::{ + PROCESS_CREATION_ALL_APPLICATION_PACKAGES_OPT_OUT, PROCESS_CREATION_CHILD_PROCESS_RESTRICTED, +}; + +// Process-creation mitigation policy bits, the `..._ALWAYS_ON` values of +// `PROCESS_CREATION_MITIGATION_POLICY_*` in winnt.h. + +/// Using an invalid handle raises an exception instead of returning an +/// error, and the setting cannot be turned off. +pub const MITIGATION_STRICT_HANDLE_CHECKS: u64 = 1 << 24; +/// No system calls into the Win32k (GUI) kernel component. +pub const MITIGATION_WIN32K_SYSTEM_CALL_DISABLE: u64 = 1 << 28; +/// No legacy extension points (AppInit DLLs, IMEs, window hooks and the +/// like) load into the process. +pub const MITIGATION_EXTENSION_POINT_DISABLE: u64 = 1 << 32; +/// No executable memory can be created or made executable after load. +pub const MITIGATION_PROHIBIT_DYNAMIC_CODE: u64 = 1 << 36; +/// Only Microsoft-signed DLLs load. +pub const MITIGATION_MICROSOFT_SIGNED_ONLY: u64 = 1 << 44; +/// No images load from remote (network) locations. +pub const MITIGATION_NO_REMOTE_IMAGES: u64 = 1 << 52; +/// No images with a Low mandatory label load. +pub const MITIGATION_NO_LOW_LABEL_IMAGES: u64 = 1 << 56; +/// DLLs are looked up in System32 before the application directory. +pub const MITIGATION_PREFER_SYSTEM32_IMAGES: u64 = 1 << 60; + +/// The mitigation policy every confined worker is created with. +pub const MITIGATION_POLICY: u64 = MITIGATION_STRICT_HANDLE_CHECKS + | MITIGATION_WIN32K_SYSTEM_CALL_DISABLE + | MITIGATION_EXTENSION_POINT_DISABLE + | MITIGATION_PROHIBIT_DYNAMIC_CODE + | MITIGATION_MICROSOFT_SIGNED_ONLY + | MITIGATION_NO_REMOTE_IMAGES + | MITIGATION_NO_LOW_LABEL_IMAGES + | MITIGATION_PREFER_SYSTEM32_IMAGES; + +/// The capability granted only by the `capabilities-present` test variant: +/// `internetClient`. +#[cfg(feature = "deviations")] +const TEST_CAPABILITY: &str = "S-1-15-3-1"; + +/// What to start, and how. +#[cfg_attr( + not(feature = "deviations"), + doc = "```compile_fail\nuse basal_launch::LaunchOptions;\nlet mut options = LaunchOptions::new(\"C:\\\\ckdev-worker.exe\", 512 << 20);\noptions.before_resume = None;\n```" +)] +#[derive(Debug, Clone)] +pub struct LaunchOptions { + /// The absolute path of the image. + pub program: PathBuf, + /// Arguments after the image name. The launcher appends + /// `--package-sid=` after them on every launch. + pub args: Vec, + /// The commit limit of the worker's job, in bytes. Callers pass the + /// limit of the worker's profile from `basal_proto::limits`. + pub job_commit_bytes: u64, + /// The recipe. Production builds have only the full confinement. + pub deviation: Deviation, + /// A test-only observer called while the child is still suspended. It can + /// enable handle tracing before loader initialization creates any objects. + #[cfg(feature = "deviations")] + pub before_resume: Option Result<()>>, +} + +impl LaunchOptions { + /// The full confinement for `program`, with no arguments. + pub fn new(program: impl Into, job_commit_bytes: u64) -> Self { + Self { + program: program.into(), + args: Vec::new(), + job_commit_bytes, + deviation: Deviation::Full, + #[cfg(feature = "deviations")] + before_resume: None, + } + } + + /// Adds one argument. + pub fn arg(mut self, arg: impl Into) -> Self { + self.args.push(arg.into()); + self + } + + /// Selects the recipe. + pub fn deviation(mut self, deviation: Deviation) -> Self { + self.deviation = deviation; + self + } +} + +/// Starts a worker. On return it is running with stdin, stdout and stderr +/// connected to the parent through three pipes, and nothing else inherited. +/// +/// Under the full confinement the child is created suspended and is only +/// resumed after the parent has read back, through its own handles, the +/// job's limits and membership, the child's primary token and its start-up +/// thread token. Any difference kills the child before its first +/// instruction and returns the named refusal. +pub fn launch(options: &LaunchOptions) -> std::result::Result { + if !options.program.is_absolute() { + return Err(LaunchError::Failed(format!( + "the worker image path must be absolute: {}", + options.program.display() + ))); + } + let commit_bytes = usize::try_from(options.job_commit_bytes) + .map_err(|_| format!("commit limit {} does not fit", options.job_commit_bytes))?; + let package = create_or_open_profile()?; + let parent_token = token::own_token()?; + let context = context::create(&options.program, parent_token.as_raw_handle(), &package)?; + + // The child's ends are made inheritable only under the spawn lock, which + // is released below once they are closed. It is taken before the pipes + // exist so that on an early return the ends, dropped in reverse order of + // declaration, are closed before the lock is released. + let spawn_lock = spawn_lock(); + let (child_stdin, parent_stdin) = pipe()?; + let (parent_stdout, child_stdout) = pipe()?; + let (parent_stderr, child_stderr) = pipe()?; + // Only the child's three ends are inheritable, and only they are named + // in the inherited-handle list. + make_inheritable( + &spawn_lock, + &[ + child_stdin.as_raw_handle(), + child_stdout.as_raw_handle(), + child_stderr.as_raw_handle(), + ], + ) + .map_err(|error| format!("SetHandleInformation(child pipe end): {error}"))?; + let inherited: [HANDLE; 3] = [ + child_stdin.as_raw_handle(), + child_stdout.as_raw_handle(), + child_stderr.as_raw_handle(), + ]; + let mut command = command_line(&options.program, &options.args, &package, options.deviation); + let deviation = options.deviation; + + let spawned = match deviation.shape() { + Shape::Confined => spawn_confined( + options, + commit_bytes, + &package, + parent_token.as_raw_handle(), + &context, + &inherited, + &mut command, + )?, + #[cfg(feature = "deviations")] + Shape::LpacOnly | Shape::Plain => spawn_control( + options, + &package, + &context, + &inherited, + &mut command, + deviation.shape() == Shape::LpacOnly, + )?, + }; + + // The child has inherited its own copies of its three pipe ends; the + // parent's copies would keep the pipes open after the child exits. + drop((child_stdin, child_stdout, child_stderr)); + drop(spawn_lock); + Ok(ConfinedProcess::new( + spawned.process, + spawned.pid, + spawned.job, + package, + inherited.map(|handle| handle as usize), + File::from(parent_stdin), + File::from(parent_stdout), + File::from(parent_stderr), + context, + )) +} + +struct Spawned { + process: OwnedHandle, + pid: u32, + job: OwnedHandle, +} + +/// The full confinement, or one of the check variants built from it. +fn spawn_confined( + options: &LaunchOptions, + commit_bytes: usize, + package: &PackageSid, + parent_token: HANDLE, + context: &context::Context, + inherited: &[HANDLE; 3], + command: &mut [u16], +) -> std::result::Result { + let deviation = options.deviation; + let source = TokenSource::start( + &options.program, + package, + deviation.less_privileged(), + context, + )?; + let (primary, mut expected) = token::build_primary(parent_token, package.as_str(), deviation)?; + let initial = token::build_initial(source.token.as_raw_handle())?; + let job = job::create_confined(commit_bytes, deviation.job_active_process_limit())?; + + // Every value an attribute points at must live until the process exists. + let capability_sids = capability_sids(deviation)?; + let capabilities: Vec = capability_sids + .iter() + .map(|sid| SID_AND_ATTRIBUTES { + Sid: sid.as_psid(), + Attributes: SE_GROUP_ENABLED as u32, + }) + .collect(); + for sid in &capability_sids { + expected.capabilities.push(sid.to_text()?); + } + let security = security_capabilities(package, &capabilities); + let opt_out = PROCESS_CREATION_ALL_APPLICATION_PACKAGES_OPT_OUT; + let jobs: [HANDLE; 1] = [job.as_raw_handle()]; + let mitigation = deviation.mitigation_policy(); + let child_policy = PROCESS_CREATION_CHILD_PROCESS_RESTRICTED; + + let mut attributes = AttributeList::new(6)?; + if deviation.restricts_inherited_handles() { + attributes.add(PROC_THREAD_ATTRIBUTE_HANDLE_LIST, inherited)?; + } + attributes.add(PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, &security)?; + if deviation.less_privileged() { + attributes.add( + PROC_THREAD_ATTRIBUTE_ALL_APPLICATION_PACKAGES_POLICY, + &opt_out, + )?; + } + if deviation.joins_job() { + attributes.add(PROC_THREAD_ATTRIBUTE_JOB_LIST, &jobs)?; + } + attributes.add(PROC_THREAD_ATTRIBUTE_MITIGATION_POLICY, &mitigation)?; + attributes.add(PROC_THREAD_ATTRIBUTE_CHILD_PROCESS_POLICY, &child_policy)?; + + let startup = startup_info(context, inherited, &mut attributes); + let mut info: PROCESS_INFORMATION = unsafe { zeroed() }; + check( + unsafe { + CreateProcessAsUserW( + primary.as_raw_handle(), + wide(&options.program).as_ptr(), + command.as_mut_ptr(), + null(), + null(), + 1, + CREATION_FLAGS, + context.environment.as_ptr().cast::(), + context.cwd.as_ptr(), + &startup.StartupInfo, + &mut info, + ) + }, + "CreateProcessAsUserW", + )?; + let suspended = Suspended::adopt(&info)?; + drop(attributes); + + if let Err(refusal) = check_before_resume( + &suspended, + &job, + commit_bytes, + &expected, + initial, + deviation, + ) { + suspended.kill(); + return Err(refusal); + } + #[cfg(feature = "deviations")] + if let Some(observer) = options.before_resume { + observer(suspended.process.as_raw_handle(), info.dwProcessId)?; + } + suspended.resume()?; + drop(source); + Ok(Spawned { + process: suspended.process, + pid: info.dwProcessId, + job, + }) +} + +/// The parent's checks on the suspended child. On success the start-up +/// thread token is set on the main thread and the parent's handle to it is +/// closed. +fn check_before_resume( + child: &Suspended, + job: &OwnedHandle, + commit_bytes: usize, + expected: &BirthExpectation, + initial: OwnedHandle, + deviation: Deviation, +) -> std::result::Result<(), LaunchError> { + let limits = job::read_limits(job.as_raw_handle()) + .map_err(|error| LaunchError::refused(Refusal::JobLimitsMismatch, error))?; + let required = JobLimits::confined(commit_bytes); + if limits != required { + return Err(LaunchError::refused( + Refusal::JobLimitsMismatch, + format!("read back {limits:?}, required {required:?}"), + )); + } + let member = job::contains(job.as_raw_handle(), child.process.as_raw_handle()) + .map_err(|error| LaunchError::refused(Refusal::NotInOwnedJob, error))?; + if !member { + return Err(LaunchError::refused( + Refusal::NotInOwnedJob, + "the suspended child is not in the job the parent created", + )); + } + + let birth = process_token(child.process.as_raw_handle()) + .and_then(|token| token::read_facts(token.as_raw_handle())) + .map_err(|error| LaunchError::refused(Refusal::BirthTokenMismatch, error))?; + token::check_birth(&birth, expected) + .map_err(|error| LaunchError::refused(Refusal::BirthTokenMismatch, error))?; + + if deviation.sets_initial_token() { + let thread = child.thread.as_raw_handle(); + check( + unsafe { SetThreadToken(&thread, initial.as_raw_handle()) }, + "SetThreadToken(suspended main thread)", + ) + .map_err(|error| LaunchError::refused(Refusal::InitialTokenOpen, error))?; + } + let assigned = thread_token(child.thread.as_raw_handle()) + .and_then(|token| token::read_facts(token.as_raw_handle())) + .map_err(|error| LaunchError::refused(Refusal::InitialTokenOpen, error))?; + token::check_initial(&assigned, &expected.package) + .map_err(|error| LaunchError::refused(Refusal::InitialTokenOpen, error))?; + // The handle was never inheritable and is not in the handle list; close + // it now so the parent holds no reference to a token the child uses. + if unsafe { CloseHandle(initial.into_raw_handle()) } == 0 { + return Err(LaunchError::refused( + Refusal::InitialTokenOpen, + last("CloseHandle(initial token)"), + )); + } + Ok(()) +} + +/// The two positive controls: no restricted primary, no start-up token, no +/// mitigations, created with `CreateProcessW` in a job that only kills on +/// close. +#[cfg(feature = "deviations")] +fn spawn_control( + options: &LaunchOptions, + package: &PackageSid, + context: &context::Context, + inherited: &[HANDLE; 3], + command: &mut [u16], + appcontainer: bool, +) -> std::result::Result { + let job = job::create_kill_on_close()?; + let no_capabilities: [SID_AND_ATTRIBUTES; 0] = []; + let security = security_capabilities(package, &no_capabilities); + let opt_out = PROCESS_CREATION_ALL_APPLICATION_PACKAGES_OPT_OUT; + let jobs: [HANDLE; 1] = [job.as_raw_handle()]; + let mut attributes = AttributeList::new(4)?; + if options.deviation.restricts_inherited_handles() { + attributes.add(PROC_THREAD_ATTRIBUTE_HANDLE_LIST, inherited)?; + } + if appcontainer { + attributes.add(PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, &security)?; + attributes.add( + PROC_THREAD_ATTRIBUTE_ALL_APPLICATION_PACKAGES_POLICY, + &opt_out, + )?; + } + attributes.add(PROC_THREAD_ATTRIBUTE_JOB_LIST, &jobs)?; + let startup = startup_info(context, inherited, &mut attributes); + let mut info: PROCESS_INFORMATION = unsafe { zeroed() }; + check( + unsafe { + CreateProcessW( + wide(&options.program).as_ptr(), + command.as_mut_ptr(), + null(), + null(), + 1, + CREATION_FLAGS, + context.environment.as_ptr().cast::(), + context.cwd.as_ptr(), + &startup.StartupInfo, + &mut info, + ) + }, + "CreateProcessW(control)", + )?; + let suspended = Suspended::adopt(&info)?; + #[cfg(feature = "deviations")] + if let Some(observer) = options.before_resume { + observer(suspended.process.as_raw_handle(), info.dwProcessId)?; + } + suspended.resume()?; + Ok(Spawned { + process: suspended.process, + pid: info.dwProcessId, + job, + }) +} + +/// Suspended, so the parent can check the child before it runs; extended +/// startup information, for the attribute list; no console window; and a +/// UTF-16 environment block. +const CREATION_FLAGS: u32 = + EXTENDED_STARTUPINFO_PRESENT | CREATE_SUSPENDED | CREATE_NO_WINDOW | CREATE_UNICODE_ENVIRONMENT; + +fn capability_sids(deviation: Deviation) -> Result> { + #[cfg(feature = "deviations")] + if deviation.grants_capability() { + return Ok(vec![SidBuf::parse(TEST_CAPABILITY)?]); + } + let _ = deviation; + Ok(Vec::new()) +} + +/// The AppContainer creation attribute. The capability list pointer is +/// never NULL, even when the list is empty. +fn security_capabilities( + package: &PackageSid, + capabilities: &[SID_AND_ATTRIBUTES], +) -> SECURITY_CAPABILITIES { + SECURITY_CAPABILITIES { + AppContainerSid: package.as_psid(), + Capabilities: capabilities.as_ptr().cast_mut(), + CapabilityCount: capabilities.len() as u32, + Reserved: 0, + } +} + +fn startup_info( + context: &context::Context, + inherited: &[HANDLE; 3], + attributes: &mut AttributeList, +) -> STARTUPINFOEXW { + let mut startup: STARTUPINFOEXW = unsafe { zeroed() }; + startup.StartupInfo.cb = size_of::() as u32; + startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + startup.StartupInfo.hStdInput = inherited[0]; + startup.StartupInfo.hStdOutput = inherited[1]; + startup.StartupInfo.hStdError = inherited[2]; + // The system only reads the desktop name. + startup.StartupInfo.lpDesktop = context.desktop_name.as_ptr().cast_mut(); + startup.lpAttributeList = attributes.as_ptr(); + startup +} + +/// The command line: the quoted image path, the caller's arguments, the +/// package SID and, for a worker check the parent cannot set up itself, the +/// request to the worker. +fn command_line( + program: &Path, + args: &[OsString], + package: &PackageSid, + deviation: Deviation, +) -> Vec { + let mut command: Vec = Vec::new(); + command.push(u16::from(b'"')); + command.extend(program.as_os_str().encode_wide()); + command.push(u16::from(b'"')); + let mut all: Vec = args.to_vec(); + all.push(format!("--package-sid={}", package.as_str()).into()); + if let Some(argument) = deviation.child_argument() { + all.push(argument.into()); + } + for arg in &all { + command.push(u16::from(b' ')); + push_argument(&mut command, arg); + } + command.push(0); + command +} + +/// Appends one argument quoted so the C runtime's parser reads it back +/// unchanged: backslashes are literal except before a double quote, where +/// they and the quote are escaped. +pub(crate) fn push_argument(command: &mut Vec, arg: &OsStr) { + const QUOTE: u16 = b'"' as u16; + const BACKSLASH: u16 = b'\\' as u16; + let units: Vec = arg.encode_wide().collect(); + let needs_quotes = units.is_empty() + || units + .iter() + .any(|&unit| unit == u16::from(b' ') || unit == u16::from(b'\t') || unit == QUOTE); + if !needs_quotes { + command.extend(units); + return; + } + command.push(QUOTE); + let mut backslashes = 0; + for unit in units { + if unit == BACKSLASH { + backslashes += 1; + } else { + if unit == QUOTE { + command.extend(std::iter::repeat_n(BACKSLASH, backslashes + 1)); + } + backslashes = 0; + } + command.push(unit); + } + command.extend(std::iter::repeat_n(BACKSLASH, backslashes)); + command.push(QUOTE); +} + +/// An anonymous pipe as (read end, write end). Neither end is inheritable. +pub(crate) fn pipe() -> Result<(OwnedHandle, OwnedHandle)> { + let mut read = null_mut(); + let mut write = null_mut(); + check( + unsafe { CreatePipe(&mut read, &mut write, null(), 0) }, + "CreatePipe", + )?; + Ok((owned(read, "CreatePipe")?, owned(write, "CreatePipe")?)) +} + +fn process_token(process: HANDLE) -> Result { + let mut token = null_mut(); + check( + unsafe { OpenProcessToken(process, TOKEN_QUERY, &mut token) }, + "OpenProcessToken(child)", + )?; + owned(token, "OpenProcessToken(child)") +} + +/// The thread's impersonation token, opened with the parent's own identity +/// rather than the thread's. +fn thread_token(thread: HANDLE) -> Result { + let mut token = null_mut(); + check( + unsafe { OpenThreadToken(thread, TOKEN_QUERY, 1, &mut token) }, + "OpenThreadToken(suspended main thread)", + )?; + owned(token, "OpenThreadToken(suspended main thread)") +} + +/// A created, not yet resumed child. Dropping it without resuming kills it. +struct Suspended { + process: OwnedHandle, + thread: OwnedHandle, +} + +impl Suspended { + fn adopt(info: &PROCESS_INFORMATION) -> Result { + Ok(Self { + process: unsafe { OwnedHandle::from_raw_handle(info.hProcess) }, + thread: unsafe { OwnedHandle::from_raw_handle(info.hThread) }, + }) + } + + fn kill(&self) { + unsafe { + TerminateProcess(self.process.as_raw_handle(), KILL_EXIT_CODE); + WaitForSingleObject(self.process.as_raw_handle(), INFINITE); + } + } + + fn resume(&self) -> Result<()> { + if unsafe { ResumeThread(self.thread.as_raw_handle()) } == u32::MAX { + let error = last("ResumeThread"); + self.kill(); + return Err(error); + } + Ok(()) + } +} + +/// A process born into the worker's AppContainer and never resumed, whose +/// token is the source of the start-up thread token. Dropping it kills it. +struct TokenSource { + process: OwnedHandle, + token: OwnedHandle, + _job: OwnedHandle, +} + +impl TokenSource { + fn start( + program: &Path, + package: &PackageSid, + less_privileged: bool, + context: &context::Context, + ) -> Result { + // The source runs no code, but it is still put in a kill-on-close + // job so a parent that dies cannot leave it suspended forever. + let job = job::create_kill_on_close()?; + let no_capabilities: [SID_AND_ATTRIBUTES; 0] = []; + let security = security_capabilities(package, &no_capabilities); + let opt_out = PROCESS_CREATION_ALL_APPLICATION_PACKAGES_OPT_OUT; + let jobs: [HANDLE; 1] = [job.as_raw_handle()]; + let mut attributes = AttributeList::new(3)?; + attributes.add(PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, &security)?; + if less_privileged { + attributes.add( + PROC_THREAD_ATTRIBUTE_ALL_APPLICATION_PACKAGES_POLICY, + &opt_out, + )?; + } + attributes.add(PROC_THREAD_ATTRIBUTE_JOB_LIST, &jobs)?; + let mut startup: STARTUPINFOEXW = unsafe { zeroed() }; + startup.StartupInfo.cb = size_of::() as u32; + startup.lpAttributeList = attributes.as_ptr(); + let mut command = wide(format!("\"{}\"", program.display())); + let mut info: PROCESS_INFORMATION = unsafe { zeroed() }; + check( + unsafe { + CreateProcessW( + wide(program).as_ptr(), + command.as_mut_ptr(), + null(), + null(), + 0, + CREATION_FLAGS, + context.environment.as_ptr().cast::(), + context.cwd.as_ptr(), + &startup.StartupInfo, + &mut info, + ) + }, + "CreateProcessW(token source)", + )?; + let suspended = Suspended::adopt(&info)?; + let mut token = null_mut(); + let opened = check( + unsafe { + OpenProcessToken( + suspended.process.as_raw_handle(), + TOKEN_ALL_ACCESS, + &mut token, + ) + }, + "OpenProcessToken(token source)", + ); + if let Err(error) = opened { + suspended.kill(); + return Err(error); + } + let token = match owned(token, "OpenProcessToken(token source)") { + Ok(token) => token, + Err(error) => { + suspended.kill(); + return Err(error); + } + }; + Ok(Self { + process: suspended.process, + token, + _job: job, + }) + } +} + +impl Drop for TokenSource { + fn drop(&mut self) { + unsafe { + TerminateProcess(self.process.as_raw_handle(), KILL_EXIT_CODE); + WaitForSingleObject(self.process.as_raw_handle(), INFINITE); + } + } +} + +/// A process/thread attribute list. The values added must outlive every +/// use of the list, since the list stores pointers to them. +pub(crate) struct AttributeList { + buffer: Vec, +} + +impl AttributeList { + pub(crate) fn new(capacity: u32) -> Result { + let mut bytes = 0; + unsafe { InitializeProcThreadAttributeList(null_mut(), capacity, 0, &mut bytes) }; + if bytes == 0 { + return Err(last("InitializeProcThreadAttributeList(size)")); + } + let mut list = Self { + buffer: vec![0; bytes.div_ceil(size_of::())], + }; + check( + unsafe { InitializeProcThreadAttributeList(list.as_ptr(), capacity, 0, &mut bytes) }, + "InitializeProcThreadAttributeList", + )?; + Ok(list) + } + + pub(crate) fn as_ptr(&mut self) -> LPPROC_THREAD_ATTRIBUTE_LIST { + self.buffer.as_mut_ptr().cast() + } + + pub(crate) fn add(&mut self, attribute: u32, value: &T) -> Result<()> { + check( + unsafe { + UpdateProcThreadAttribute( + self.as_ptr(), + 0, + attribute as usize, + (value as *const T).cast(), + size_of_val(value), + null_mut(), + null(), + ) + }, + &format!("UpdateProcThreadAttribute({attribute:#x})"), + ) + } +} + +impl Drop for AttributeList { + fn drop(&mut self) { + unsafe { DeleteProcThreadAttributeList(self.as_ptr()) }; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn quoted(arg: &str) -> String { + let mut command = Vec::new(); + push_argument(&mut command, OsStr::new(arg)); + String::from_utf16(&command).unwrap() + } + + #[test] + fn arguments_are_quoted_for_the_c_runtime_parser() { + assert_eq!( + quoted("--package-sid=S-1-15-2-1"), + "--package-sid=S-1-15-2-1" + ); + assert_eq!(quoted(""), "\"\""); + assert_eq!(quoted("a b"), "\"a b\""); + assert_eq!(quoted("a\"b"), "\"a\\\"b\""); + assert_eq!(quoted("C:\\dir name\\"), "\"C:\\dir name\\\\\""); + assert_eq!(quoted("C:\\dir\\x"), "C:\\dir\\x"); + } + + #[test] + fn the_environment_holds_only_the_named_variables_sorted() { + let block = context::environment_block("C:\\Windows", "C:\\T\\w"); + let text = String::from_utf16(&block).unwrap(); + let variables: Vec<&str> = text.trim_end_matches('\0').split('\0').collect(); + assert_eq!( + variables, + [ + "LOCALAPPDATA=C:\\T\\w", + "PATH=C:\\Windows\\System32", + "SYSTEMDRIVE=C:", + "SYSTEMROOT=C:\\Windows", + "TEMP=C:\\T\\w", + "TMP=C:\\T\\w", + "windir=C:\\Windows", + ] + ); + assert!(block.ends_with(&[0, 0])); + } + + #[test] + fn the_mitigation_policy_is_the_eight_always_on_bits() { + assert_eq!(MITIGATION_POLICY, 0x1110_1011_1100_0000); + } +} diff --git a/crates/basal-launch/src/windows/mod.rs b/crates/basal-launch/src/windows/mod.rs new file mode 100644 index 0000000..5ff915e --- /dev/null +++ b/crates/basal-launch/src/windows/mod.rs @@ -0,0 +1,82 @@ +//! The Windows launcher. + +mod context; +mod deviation; +mod error; +mod job; +mod launch; +mod native; +mod plain; +mod process; +mod profile; +mod spawn_lock; +mod token; + +pub use deviation::Deviation; +pub use error::{LaunchError, Refusal}; +pub use job::{JOB_LIMIT_FLAGS, JOB_UI_RESTRICTIONS, JobLimits}; +pub use launch::{ + LaunchOptions, MITIGATION_EXTENSION_POINT_DISABLE, MITIGATION_MICROSOFT_SIGNED_ONLY, + MITIGATION_NO_LOW_LABEL_IMAGES, MITIGATION_NO_REMOTE_IMAGES, MITIGATION_POLICY, + MITIGATION_PREFER_SYSTEM32_IMAGES, MITIGATION_PROHIBIT_DYNAMIC_CODE, + MITIGATION_STRICT_HANDLE_CHECKS, MITIGATION_WIN32K_SYSTEM_CALL_DISABLE, launch, +}; +pub use plain::{PlainCommand, PlainStdio, spawn_plain}; +pub use process::{ConfinedProcess, KILL_EXIT_CODE, OwnedProcess}; +pub use profile::{ + PROFILE_NAME, PackageSid, create_or_open_profile, grant_test_binary_directory, + process_profile_directory, +}; +pub use spawn_lock::{SpawnLock, make_inheritable, spawn_lock, spawn_lock_held}; +pub use token::{ + IMPERSONATION_LEVEL, LOW_INTEGRITY, NULL_SID, TOKEN_IMPERSONATION, TOKEN_PRIMARY, TokenFacts, +}; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn refusal_reasons_are_the_named_tokens() { + let all = [ + ( + Refusal::AppContainerProfileUnavailable, + "appcontainer-profile-unavailable", + ), + (Refusal::JobLimitsMismatch, "job-limits-mismatch"), + (Refusal::NotInOwnedJob, "not-in-owned-job"), + (Refusal::BirthTokenMismatch, "birth-token-mismatch"), + (Refusal::InitialTokenOpen, "initial-token-open"), + ]; + for (refusal, reason) in all { + assert_eq!(refusal.reason(), reason); + let error = LaunchError::refused(refusal, "detail"); + assert_eq!(error.reason(), Some(reason)); + assert_eq!(error.to_string(), format!("{reason}: detail")); + } + assert_eq!(LaunchError::Failed("x".into()).reason(), None); + } + + #[test] + fn the_job_flags_are_0x2508() { + assert_eq!(JOB_LIMIT_FLAGS, 0x2508); + let limits = JobLimits::confined(512 << 20); + assert_eq!(limits.active_process_limit, 1); + assert_eq!(limits.job_memory_limit, 0); + assert_eq!(limits.ui_restrictions, 0xff); + } + + /// A profile name the system rejects (longer than 64 characters) is + /// refused as `appcontainer-profile-unavailable`, never launched without. + #[test] + fn an_unusable_profile_is_refused_by_name() { + let name = "x".repeat(65); + let error = profile::create_or_open_named(&name) + .map_err(profile::unavailable) + .expect_err("a 65-character profile name must be rejected"); + assert_eq!( + error.refusal(), + Some(Refusal::AppContainerProfileUnavailable) + ); + } +} diff --git a/crates/basal-launch/src/windows/native.rs b/crates/basal-launch/src/windows/native.rs new file mode 100644 index 0000000..612cb84 --- /dev/null +++ b/crates/basal-launch/src/windows/native.rs @@ -0,0 +1,182 @@ +//! Small helpers over the Win32 and native calls the launcher makes. + +use std::ffi::{OsStr, c_void}; +use std::mem::size_of; +use std::os::windows::ffi::OsStrExt; +use std::os::windows::io::{FromRawHandle, OwnedHandle}; +use std::ptr::null_mut; +use windows_sys::Win32::Foundation::{GetLastError, HANDLE, INVALID_HANDLE_VALUE, LocalFree}; +use windows_sys::Win32::Security::Authorization::{ConvertSidToStringSidW, ConvertStringSidToSidW}; +use windows_sys::Win32::Security::{ + CopySid, CreateWellKnownSid, GetLengthSid, GetTokenInformation, PSID, SID_AND_ATTRIBUTES, + TOKEN_GROUPS, TOKEN_INFORMATION_CLASS, TOKEN_PRIVILEGES, WELL_KNOWN_SID_TYPE, +}; + +pub(crate) type Result = std::result::Result; + +/// `SE_GROUP_INTEGRITY`: marks the token's integrity label, which is not an +/// access group. +pub(crate) const SE_GROUP_INTEGRITY: u32 = 0x20; +/// `SE_GROUP_USE_FOR_DENY_ONLY`: the group matches only deny entries. +pub(crate) const SE_GROUP_USE_FOR_DENY_ONLY: u32 = 0x10; +/// `SE_GROUP_LOGON_ID`: the group is the session's logon SID. +pub(crate) const SE_GROUP_LOGON_ID: u32 = 0xc000_0000; + +/// A NUL-terminated UTF-16 copy of `text`. +pub(crate) fn wide(text: impl AsRef) -> Vec { + text.as_ref().encode_wide().chain(Some(0)).collect() +} + +/// The calling thread's last Win32 error, named after the call that failed. +pub(crate) fn last(api: &str) -> String { + let code = unsafe { GetLastError() }; + format!( + "{api}: Win32 {code} ({})", + std::io::Error::from_raw_os_error(code as i32) + ) +} + +/// Turns a Win32 `BOOL` result into a `Result`, naming the call. +pub(crate) fn check(ok: i32, api: &str) -> Result<()> { + if ok == 0 { Err(last(api)) } else { Ok(()) } +} + +/// Takes ownership of a handle a call returned, refusing the two failure +/// values. +pub(crate) fn owned(handle: HANDLE, api: &str) -> Result { + if handle.is_null() || handle == INVALID_HANDLE_VALUE { + Err(last(api)) + } else { + Ok(unsafe { OwnedHandle::from_raw_handle(handle) }) + } +} + +/// Reads one variable-length token information class into an aligned buffer. +/// +/// # Safety +/// +/// `token` must be a valid token handle opened with `TOKEN_QUERY`. +pub(crate) unsafe fn token_buffer( + token: HANDLE, + class: TOKEN_INFORMATION_CLASS, +) -> Result> { + unsafe { + let mut bytes = 0; + GetTokenInformation(token, class, null_mut(), 0, &mut bytes); + if bytes == 0 { + return Err(last(&format!("GetTokenInformation(class {class}, size)"))); + } + // A list with zero entries can be shorter than the C declaration, + // which reserves one entry. Keep room for that declaration so a + // reference to it never reaches past the allocation. + let allocation = (bytes as usize) + .max(size_of::()) + .max(size_of::()); + let mut data = vec![0usize; allocation.div_ceil(size_of::())]; + check( + GetTokenInformation(token, class, data.as_mut_ptr().cast(), bytes, &mut bytes), + &format!("GetTokenInformation(class {class})"), + )?; + Ok(data) + } +} + +/// The entries of a `TOKEN_GROUPS` buffer. +/// +/// # Safety +/// +/// `data` must hold a `TOKEN_GROUPS` structure returned by the system. +pub(crate) unsafe fn groups(data: &[usize]) -> &[SID_AND_ATTRIBUTES] { + unsafe { + let list = &*data.as_ptr().cast::(); + std::slice::from_raw_parts(list.Groups.as_ptr(), list.GroupCount as usize) + } +} + +/// The `S-1-...` form of a SID. +/// +/// # Safety +/// +/// `sid` must point to a valid SID. +pub(crate) unsafe fn sid_string(sid: PSID) -> Result { + unsafe { + let mut text = null_mut(); + check( + ConvertSidToStringSidW(sid, &mut text), + "ConvertSidToStringSidW", + )?; + let result = utf16_until_nul(text); + LocalFree(text.cast()); + Ok(result) + } +} + +/// Reads a NUL-terminated UTF-16 string. +/// +/// # Safety +/// +/// `text` must point to a NUL-terminated UTF-16 string. +pub(crate) unsafe fn utf16_until_nul(text: *const u16) -> String { + unsafe { + let mut len = 0; + while *text.add(len) != 0 { + len += 1; + } + String::from_utf16_lossy(std::slice::from_raw_parts(text, len)) + } +} + +/// A SID held in memory this process owns. +#[derive(Clone, PartialEq, Eq)] +pub(crate) struct SidBuf(Vec); + +impl SidBuf { + /// The SID of a well-known kind, such as the NULL SID or an integrity label. + pub(crate) fn well_known(kind: WELL_KNOWN_SID_TYPE) -> Result { + // SECURITY_MAX_SID_SIZE is 68 bytes. + let mut sid = vec![0u32; 17]; + let mut bytes = (sid.len() * 4) as u32; + check( + unsafe { CreateWellKnownSid(kind, null_mut(), sid.as_mut_ptr().cast(), &mut bytes) }, + "CreateWellKnownSid", + )?; + Ok(Self(sid)) + } + + /// Parses the `S-1-...` form. + #[cfg_attr(not(feature = "deviations"), allow(dead_code))] + pub(crate) fn parse(text: &str) -> Result { + let mut sid = null_mut(); + check( + unsafe { ConvertStringSidToSidW(wide(text).as_ptr(), &mut sid) }, + "ConvertStringSidToSidW", + )?; + let copy = unsafe { Self::copy(sid) }; + unsafe { LocalFree(sid) }; + copy + } + + /// Copies a SID the system returned. + /// + /// # Safety + /// + /// `sid` must point to a valid SID. + pub(crate) unsafe fn copy(sid: PSID) -> Result { + unsafe { + let bytes = GetLengthSid(sid); + let mut buffer = vec![0u32; (bytes as usize).div_ceil(4)]; + check(CopySid(bytes, buffer.as_mut_ptr().cast(), sid), "CopySid")?; + Ok(Self(buffer)) + } + } + + /// A pointer for calls that read the SID. Callers that write through it + /// must not exist; the pointer is mutable only because the API types are. + pub(crate) fn as_psid(&self) -> PSID { + self.0.as_ptr().cast_mut().cast::() + } + + pub(crate) fn to_text(&self) -> Result { + unsafe { sid_string(self.as_psid()) } + } +} diff --git a/crates/basal-launch/src/windows/plain.rs b/crates/basal-launch/src/windows/plain.rs new file mode 100644 index 0000000..c2edeb7 --- /dev/null +++ b/crates/basal-launch/src/windows/plain.rs @@ -0,0 +1,355 @@ +//! Unconfined children: every Windows process basal starts that is not a +//! confined worker (test fixtures, helper tools) goes through here, so that +//! it is created under the same spawn lock, inherits only its own standard +//! handles, and is born in a job that ends it with its owner. + +use super::error::{LaunchError, Refusal}; +use super::job; +use super::launch::{AttributeList, pipe, push_argument}; +use super::native::{Result, check, owned, wide}; +use super::process::{KILL_EXIT_CODE, OwnedProcess}; +use super::spawn_lock::{make_inheritable, spawn_lock}; +use std::collections::BTreeMap; +use std::ffi::{OsStr, OsString, c_void}; +use std::fs::File; +use std::mem::{size_of, zeroed}; +use std::os::windows::ffi::OsStrExt; +use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; +use std::path::{Path, PathBuf}; +use std::ptr::{null, null_mut}; +use windows_sys::Win32::Foundation::{GENERIC_READ, GENERIC_WRITE, HANDLE}; +use windows_sys::Win32::Storage::FileSystem::{ + CreateFileW, FILE_ATTRIBUTE_NORMAL, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, +}; +use windows_sys::Win32::System::Threading::{ + CREATE_NO_WINDOW, CREATE_SUSPENDED, CREATE_UNICODE_ENVIRONMENT, CreateProcessW, + EXTENDED_STARTUPINFO_PRESENT, INFINITE, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, + PROC_THREAD_ATTRIBUTE_JOB_LIST, PROCESS_INFORMATION, ResumeThread, STARTF_USESTDHANDLES, + STARTUPINFOEXW, TerminateProcess, WaitForSingleObject, +}; + +/// The longest command line `CreateProcessW` accepts, in UTF-16 units, +/// including the terminating NUL. +const MAX_COMMAND_LINE: usize = 32_767; + +/// Where one of the child's standard handles goes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PlainStdio { + /// A new anonymous pipe; the parent's end is on the [`OwnedProcess`]. + Piped, + /// The `NUL` device: reads see end of file, writes are discarded. + Null, +} + +/// An unconfined child to start with [`spawn_plain`]. +#[derive(Debug, Clone)] +pub struct PlainCommand { + /// The absolute path of the image; a relative path is refused. + pub program: PathBuf, + /// Arguments after the image name, encoded so the C runtime's parser + /// reads each back unchanged. + pub args: Vec, + /// The child's whole environment. Nothing of the parent's is added. + pub env: Vec<(OsString, OsString)>, + /// The working directory; the parent's when unset. + pub cwd: Option, + /// The child's stdin. + pub stdin: PlainStdio, + /// The child's stdout. + pub stdout: PlainStdio, + /// The child's stderr. + pub stderr: PlainStdio, +} + +impl PlainCommand { + /// `program` with no arguments, an empty environment, the parent's + /// working directory and every standard handle on `NUL`. + pub fn new(program: impl Into) -> Self { + Self { + program: program.into(), + args: Vec::new(), + env: Vec::new(), + cwd: None, + stdin: PlainStdio::Null, + stdout: PlainStdio::Null, + stderr: PlainStdio::Null, + } + } +} + +/// Starts an unconfined child: no restricted token and no mitigations, but +/// the same handling of handles and lifetime as a confined worker. +/// +/// - Its standard handles are made inheritable only under the spawn lock, +/// and they are the only entries of its inherited-handle list, so it +/// inherits nothing else the parent holds. +/// - It is created suspended in a fresh job whose only limit is +/// kill-on-close (so no breakaway), its membership of exactly that job is +/// verified, and only then is it resumed. A child that is not in the job +/// is killed before its first instruction. +/// - The returned [`OwnedProcess`] owns the process and the job: `kill` +/// ends the whole job, and dropping it kills the child. +pub fn spawn_plain(command: &PlainCommand) -> std::result::Result { + if !command.program.is_absolute() { + return Err(LaunchError::Failed(format!( + "the image path must be absolute: {}", + command.program.display() + ))); + } + let application = wide_checked(&command.program, "image path")?; + let mut command_line = command_line(&command.program, &command.args)?; + let environment = environment_block(&command.env)?; + let cwd = command + .cwd + .as_ref() + .map(|cwd| wide_checked(cwd, "working directory")) + .transpose()?; + let job = job::create_kill_on_close()?; + + // Declared before the handles so that on an early return they close + // before the lock is released. + let spawn_lock = spawn_lock(); + let stdin = Stdio::open(command.stdin, Direction::ToChild)?; + let stdout = Stdio::open(command.stdout, Direction::FromChild)?; + let stderr = Stdio::open(command.stderr, Direction::FromChild)?; + let inherited: [HANDLE; 3] = [ + stdin.child.as_raw_handle(), + stdout.child.as_raw_handle(), + stderr.child.as_raw_handle(), + ]; + make_inheritable(&spawn_lock, &inherited) + .map_err(|error| format!("SetHandleInformation(child stdio): {error}"))?; + let jobs: [HANDLE; 1] = [job.as_raw_handle()]; + let mut attributes = AttributeList::new(2)?; + attributes.add(PROC_THREAD_ATTRIBUTE_HANDLE_LIST, &inherited)?; + attributes.add(PROC_THREAD_ATTRIBUTE_JOB_LIST, &jobs)?; + let mut startup: STARTUPINFOEXW = unsafe { zeroed() }; + startup.StartupInfo.cb = size_of::() as u32; + startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + startup.StartupInfo.hStdInput = inherited[0]; + startup.StartupInfo.hStdOutput = inherited[1]; + startup.StartupInfo.hStdError = inherited[2]; + startup.lpAttributeList = attributes.as_ptr(); + let mut info: PROCESS_INFORMATION = unsafe { zeroed() }; + check( + unsafe { + CreateProcessW( + application.as_ptr(), + command_line.as_mut_ptr(), + null(), + null(), + 1, + CREATION_FLAGS, + environment.as_ptr().cast::(), + cwd.as_ref().map_or(null(), |cwd| cwd.as_ptr()), + &startup.StartupInfo, + &mut info, + ) + }, + "CreateProcessW", + )?; + let created = Created { + process: unsafe { OwnedHandle::from_raw_handle(info.hProcess) }, + thread: unsafe { OwnedHandle::from_raw_handle(info.hThread) }, + }; + #[cfg(test)] + tests::observe(&created, &inherited); + // The child holds its own copies now. Closing the parent's before the + // lock is released ends the window in which another spawn could copy + // them, and lets the pipes report end of file when the child exits. + drop(attributes); + let [stdin, stdout, stderr] = [stdin, stdout, stderr].map(Stdio::close_child); + drop(spawn_lock); + + match job::contains(job.as_raw_handle(), created.process.as_raw_handle()) { + Ok(true) => {} + Ok(false) => { + created.kill(); + return Err(LaunchError::refused( + Refusal::NotInOwnedJob, + "the suspended child is not in the job the parent created", + )); + } + Err(error) => { + created.kill(); + return Err(LaunchError::refused(Refusal::NotInOwnedJob, error)); + } + } + // A previous suspend count other than 1 means the thread was not + // suspended exactly once by the creation, and may already have run or + // may stay suspended. + let previous = unsafe { ResumeThread(created.thread.as_raw_handle()) }; + if previous != 1 { + let error = super::native::last("ResumeThread"); + created.kill(); + return Err(LaunchError::Failed(format!( + "{error}; previous suspend count {previous}" + ))); + } + Ok(OwnedProcess::new( + created.process, + info.dwProcessId, + job, + stdin, + stdout, + stderr, + )) +} + +/// Suspended, with extended startup information for the attribute list, no +/// console window, and a UTF-16 environment block. +const CREATION_FLAGS: u32 = + EXTENDED_STARTUPINFO_PRESENT | CREATE_SUSPENDED | CREATE_NO_WINDOW | CREATE_UNICODE_ENVIRONMENT; + +/// A created child before it is handed out. +struct Created { + process: OwnedHandle, + thread: OwnedHandle, +} + +impl Created { + fn kill(&self) { + unsafe { + TerminateProcess(self.process.as_raw_handle(), KILL_EXIT_CODE); + WaitForSingleObject(self.process.as_raw_handle(), INFINITE); + } + } +} + +#[derive(Clone, Copy)] +enum Direction { + ToChild, + FromChild, +} + +/// One standard handle: the child's end, and the parent's end of a pipe. +struct Stdio { + child: OwnedHandle, + parent: Option, +} + +impl Stdio { + /// Closes the parent's copy of the child's end, keeping the parent's end. + fn close_child(self) -> Option { + drop(self.child); + self.parent + } + + /// Neither end is inheritable when opened. + fn open(kind: PlainStdio, direction: Direction) -> Result { + match kind { + PlainStdio::Piped => { + let (read, write) = pipe()?; + let (child, parent) = match direction { + Direction::ToChild => (read, write), + Direction::FromChild => (write, read), + }; + Ok(Self { + child, + parent: Some(File::from(parent)), + }) + } + PlainStdio::Null => { + let device = wide("NUL"); + let handle = unsafe { + CreateFileW( + device.as_ptr(), + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + null(), + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, + null_mut(), + ) + }; + Ok(Self { + child: owned(handle, "CreateFileW(NUL)")?, + parent: None, + }) + } + } + } +} + +/// A NUL-terminated UTF-16 copy of a value that must not contain NUL. +fn wide_checked(value: impl AsRef, what: &str) -> Result> { + let value = value.as_ref(); + if value.encode_wide().any(|unit| unit == 0) { + return Err(format!("the {what} contains NUL")); + } + Ok(wide(value)) +} + +/// The quoted image path, then each argument encoded for the C runtime's +/// parser. The image path is quoted as is: the parser takes the first +/// argument up to the closing quote, and a path cannot contain a quote. +fn command_line(program: &Path, args: &[OsString]) -> Result> { + let mut line: Vec = Vec::new(); + line.push(u16::from(b'"')); + line.extend(program.as_os_str().encode_wide()); + line.push(u16::from(b'"')); + for arg in args { + if arg.encode_wide().any(|unit| unit == 0) { + return Err("an argument contains NUL".to_owned()); + } + line.push(u16::from(b' ')); + push_argument(&mut line, arg); + } + line.push(0); + if line.len() > MAX_COMMAND_LINE { + return Err(format!( + "the command line is {} UTF-16 units, over the limit of {MAX_COMMAND_LINE}", + line.len() + )); + } + Ok(line) +} + +/// The environment block: `NAME=value` strings sorted by upper-cased name, +/// as Windows keeps them, each NUL-terminated, then one more NUL. Names +/// compare case-insensitively, and a later entry replaces an earlier one +/// with the same name. +fn environment_block(env: &[(OsString, OsString)]) -> Result> { + let mut sorted: BTreeMap, (Vec, Vec)> = BTreeMap::new(); + for (name, value) in env { + let name: Vec = name.encode_wide().collect(); + let value: Vec = value.encode_wide().collect(); + // Windows itself keeps a few names that start with `=` (the + // per-drive working directories), so only a later `=` is refused. + if name.is_empty() + || name.iter().skip(1).any(|&unit| unit == u16::from(b'=')) + || name.contains(&0) + || value.contains(&0) + { + return Err(format!( + "invalid environment entry {:?}", + String::from_utf16_lossy(&name) + )); + } + let key = name + .iter() + .map(|&unit| match u8::try_from(unit) { + Ok(byte) => u16::from(byte.to_ascii_uppercase()), + Err(_) => unit, + }) + .collect(); + sorted.insert(key, (name, value)); + } + let mut block = Vec::new(); + for (name, value) in sorted.into_values() { + block.extend(name); + block.push(u16::from(b'=')); + block.extend(value); + block.push(0); + } + // An empty block is two NULs: one for the absent first string, one to + // end the block. + if block.is_empty() { + block.push(0); + } + block.push(0); + Ok(block) +} + +#[cfg(test)] +mod tests; diff --git a/crates/basal-launch/src/windows/plain/tests.rs b/crates/basal-launch/src/windows/plain/tests.rs new file mode 100644 index 0000000..5373195 --- /dev/null +++ b/crates/basal-launch/src/windows/plain/tests.rs @@ -0,0 +1,317 @@ +use super::*; +use crate::windows::spawn_lock::spawn_lock_held; +use std::cell::RefCell; +use std::io::Read; +use std::sync::mpsc; +use windows_sys::Win32::Foundation::{ + CompareObjectHandles, DUPLICATE_SAME_ACCESS, DuplicateHandle, ERROR_INVALID_HANDLE, + WAIT_OBJECT_0, +}; +use windows_sys::Win32::System::JobObjects::JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; +use windows_sys::Win32::System::Threading::{GetCurrentProcess, STARTUPINFOW, SuspendThread}; + +type Observer = Box; + +thread_local! { + /// Called by `spawn_plain` on this thread while the child is suspended + /// and the parent still holds its copies of the child's handles. + static OBSERVER: RefCell> = const { RefCell::new(None) }; +} + +pub(super) fn observe(created: &Created, inherited: &[HANDLE; 3]) { + let observer = OBSERVER.with(|slot| slot.borrow_mut().take()); + if let Some(mut observer) = observer { + observer(created, inherited); + } +} + +fn cmd() -> PathBuf { + let root = std::env::var_os("SystemRoot").expect("SystemRoot is set"); + Path::new(&root).join("System32").join("cmd.exe") +} + +fn cmd_command(args: &[&str]) -> PlainCommand { + let mut command = PlainCommand::new(cmd()); + command.args = args.iter().map(OsString::from).collect(); + command.env = vec![( + "SystemRoot".into(), + std::env::var_os("SystemRoot").expect("SystemRoot is set"), + )]; + command +} + +/// A `cmd.exe` that waits for a line on its piped stdin, so it stays alive +/// until it is killed. +fn waiting_cmd() -> PlainCommand { + let mut command = cmd_command(&["/d", "/q", "/k"]); + command.stdin = PlainStdio::Piped; + command +} + +/// Whether `process` holds the object the parent's `original` handle names. +/// An inherited handle keeps its value in the child, so the value is +/// duplicated out of the child and compared as an object, which also tells +/// apart an unrelated handle that happens to have the same value. +fn process_has_handle(process: HANDLE, original: HANDLE) -> bool { + let mut duplicate = null_mut(); + if unsafe { + DuplicateHandle( + process, + original, + GetCurrentProcess(), + &mut duplicate, + 0, + 0, + DUPLICATE_SAME_ACCESS, + ) + } == 0 + { + assert_eq!( + std::io::Error::last_os_error().raw_os_error(), + Some(ERROR_INVALID_HANDLE as i32), + "could not inspect the child's handle table" + ); + return false; + } + let duplicate = unsafe { OwnedHandle::from_raw_handle(duplicate) }; + unsafe { CompareObjectHandles(original, duplicate.as_raw_handle()) != 0 } +} + +/// Starts `cmd.exe` suspended the broad way: every inheritable handle, no +/// list. The caller kills it. +fn unlisted_suspended_cmd() -> Created { + let application = wide(cmd()); + let mut line = wide(format!("\"{}\"", cmd().display())); + let mut startup: STARTUPINFOW = unsafe { zeroed() }; + startup.cb = size_of::() as u32; + let mut info: PROCESS_INFORMATION = unsafe { zeroed() }; + check( + unsafe { + CreateProcessW( + application.as_ptr(), + line.as_mut_ptr(), + null(), + null(), + 1, + CREATE_SUSPENDED | CREATE_NO_WINDOW, + null(), + null(), + &startup, + &mut info, + ) + }, + "CreateProcessW(unlisted control)", + ) + .unwrap(); + Created { + process: unsafe { OwnedHandle::from_raw_handle(info.hProcess) }, + thread: unsafe { OwnedHandle::from_raw_handle(info.hThread) }, + } +} + +#[derive(Debug, PartialEq, Eq)] +struct Observed { + lock_held: bool, + previous_suspend_count: u32, + holds_listed: [bool; 3], + holds_stray: bool, +} + +/// While the child exists but has not run, the spawn lock is held, the main +/// thread is suspended exactly once, and the child holds its three listed +/// standard handles but not an unrelated inheritable handle of the parent. +/// The unlisted control shows that same handle really is inheritable. +#[test] +fn the_child_is_created_suspended_under_the_lock_inheriting_only_its_stdio() { + let (_stray_read, stray_write) = pipe().unwrap(); + { + // The control is a broad spawn, so it runs under the lock like any + // other: it must copy only the handle it is testing. + let lock = spawn_lock(); + make_inheritable(&lock, &[stray_write.as_raw_handle()]).unwrap(); + let control = unlisted_suspended_cmd(); + let inherited = + process_has_handle(control.process.as_raw_handle(), stray_write.as_raw_handle()); + control.kill(); + assert!(inherited, "the unlisted control did not inherit the handle"); + } + // The stray handle stays inheritable, as one opened by code that does + // not take the lock would be. + let stray = stray_write.as_raw_handle() as usize; + let (sender, receiver) = mpsc::channel(); + OBSERVER.with(|slot| { + *slot.borrow_mut() = Some(Box::new(move |created: &Created, inherited| { + let thread = created.thread.as_raw_handle(); + let previous_suspend_count = unsafe { SuspendThread(thread) }; + unsafe { ResumeThread(thread) }; + let process = created.process.as_raw_handle(); + let _ = sender.send(Observed { + lock_held: spawn_lock_held(), + previous_suspend_count, + holds_listed: inherited.map(|handle| process_has_handle(process, handle)), + holds_stray: process_has_handle(process, stray as HANDLE), + }); + })); + }); + let child = spawn_plain(&waiting_cmd()).expect("spawn cmd.exe"); + let observed = receiver.try_recv().expect("the observer ran"); + assert_eq!( + observed, + Observed { + lock_held: true, + previous_suspend_count: 1, + holds_listed: [true; 3], + holds_stray: false, + } + ); + assert!(!spawn_lock_held(), "the lock is released on return"); + assert!( + !process_has_handle(child.as_raw_handle(), stray_write.as_raw_handle()), + "the running child holds the unrelated handle" + ); + child.kill().unwrap(); +} + +/// The child is in its own job, whose only limit is kill-on-close: no +/// breakaway flag, so the child cannot leave it. +#[test] +fn the_child_is_in_a_fresh_kill_on_close_job_without_breakaway() { + let first = spawn_plain(&waiting_cmd()).expect("spawn cmd.exe"); + let second = spawn_plain(&waiting_cmd()).expect("spawn cmd.exe"); + assert!(first.in_owned_job().unwrap()); + let limits = first.job_limits().unwrap(); + assert_eq!(limits.flags, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE); + assert_eq!(limits.active_process_limit, 0); + // Each spawn has a job of its own: killing the first one's job leaves + // the second running. + first.kill().unwrap(); + assert_eq!(first.wait().unwrap(), KILL_EXIT_CODE); + assert_eq!(second.try_wait().unwrap(), None); + assert!(second.in_owned_job().unwrap()); +} + +#[test] +fn kill_wait_and_try_wait_report_the_kill_code() { + let child = spawn_plain(&waiting_cmd()).expect("spawn cmd.exe"); + assert_eq!(child.try_wait().unwrap(), None, "cmd.exe waits on stdin"); + child.kill().unwrap(); + assert_eq!(child.wait().unwrap(), KILL_EXIT_CODE); + assert_eq!(child.try_wait().unwrap(), Some(KILL_EXIT_CODE)); + child.kill().expect("killing an ended child succeeds"); +} + +#[test] +fn dropping_the_process_kills_the_child() { + let child = spawn_plain(&waiting_cmd()).expect("spawn cmd.exe"); + let mut process = null_mut(); + check( + unsafe { + DuplicateHandle( + GetCurrentProcess(), + child.as_raw_handle(), + GetCurrentProcess(), + &mut process, + 0, + 0, + DUPLICATE_SAME_ACCESS, + ) + }, + "DuplicateHandle(child)", + ) + .unwrap(); + let process = unsafe { OwnedHandle::from_raw_handle(process) }; + assert_eq!(child.try_wait().unwrap(), None); + drop(child); + assert_eq!( + unsafe { WaitForSingleObject(process.as_raw_handle(), 0) }, + WAIT_OBJECT_0, + "the child was still running after its owner was dropped" + ); +} + +#[test] +fn piped_output_and_the_exit_code_reach_the_parent() { + let mut command = cmd_command(&["/d", "/c", "echo", "hello&exit", "7"]); + command.stdout = PlainStdio::Piped; + let mut child = spawn_plain(&command).expect("spawn cmd.exe"); + let mut output = String::new(); + child + .stdout + .take() + .expect("stdout is piped") + .read_to_string(&mut output) + .unwrap(); + assert_eq!(output.trim_end(), "hello"); + assert_eq!(child.wait().unwrap(), 7); + assert!(child.stdin.is_none() && child.stderr.is_none()); +} + +/// The child sees the given variables and none of the parent's. +#[test] +fn the_environment_replaces_the_parents() { + assert!( + std::env::var_os("COMPUTERNAME").is_some(), + "the parent's own variable this test looks for is set" + ); + let mut command = cmd_command(&["/d", "/c", "set"]); + command.env.push(("BASAL_PLAIN_PROBE".into(), "x y".into())); + command.stdout = PlainStdio::Piped; + let mut child = spawn_plain(&command).expect("spawn cmd.exe"); + let mut output = String::new(); + child + .stdout + .take() + .unwrap() + .read_to_string(&mut output) + .unwrap(); + assert_eq!(child.wait().unwrap(), 0); + let lines: Vec<&str> = output.lines().collect(); + assert!(lines.contains(&"BASAL_PLAIN_PROBE=x y"), "{output}"); + assert!( + !lines.iter().any(|line| line.starts_with("COMPUTERNAME=")), + "{output}" + ); +} + +#[test] +fn a_relative_image_path_or_a_nul_is_refused_before_anything_starts() { + let error = spawn_plain(&PlainCommand::new("cmd.exe")).unwrap_err(); + assert!(error.to_string().contains("must be absolute"), "{error}"); + let mut command = cmd_command(&["/c", "exit"]); + command.args.push(OsString::from("a\0b")); + assert!(spawn_plain(&command).is_err()); + let mut command = cmd_command(&["/c", "exit"]); + command.env.push(("A=B".into(), "c".into())); + assert!(spawn_plain(&command).is_err()); +} + +#[test] +fn the_environment_block_is_sorted_and_later_names_replace_earlier_ones() { + let block = environment_block(&[ + ("b".into(), "2".into()), + ("A".into(), "1".into()), + ("a".into(), "3".into()), + ("=C:".into(), "C:\\".into()), + ]) + .unwrap(); + assert_eq!( + String::from_utf16(&block).unwrap(), + "=C:=C:\\\0a=3\0b=2\0\0" + ); + assert_eq!(environment_block(&[]).unwrap(), [0, 0]); +} + +#[test] +fn the_command_line_quotes_the_image_and_encodes_each_argument() { + let line = command_line( + Path::new("C:\\Program Files\\x.exe"), + &["a b".into(), "c".into(), "".into()], + ) + .unwrap(); + assert_eq!( + String::from_utf16(&line).unwrap(), + "\"C:\\Program Files\\x.exe\" \"a b\" c \"\"\0" + ); + let long = vec![OsString::from("x".repeat(MAX_COMMAND_LINE))]; + assert!(command_line(Path::new("C:\\x.exe"), &long).is_err()); +} diff --git a/crates/basal-launch/src/windows/process.rs b/crates/basal-launch/src/windows/process.rs new file mode 100644 index 0000000..c8519ef --- /dev/null +++ b/crates/basal-launch/src/windows/process.rs @@ -0,0 +1,238 @@ +//! The running child, owned together with its job. + +use super::context::Context; +use super::job::{self, JobLimits}; +use super::profile::PackageSid; +use super::token::{self, TokenFacts}; +use std::fs::File; +use std::io; +use std::ops::{Deref, DerefMut}; +use std::os::windows::io::{AsRawHandle, OwnedHandle, RawHandle}; +use std::path::Path; +use std::ptr::null_mut; +use windows_sys::Win32::Foundation::{WAIT_OBJECT_0, WAIT_TIMEOUT}; +use windows_sys::Win32::Security::TOKEN_QUERY; +use windows_sys::Win32::System::JobObjects::TerminateJobObject; +use windows_sys::Win32::System::Threading::{ + GetExitCodeProcess, INFINITE, OpenProcessToken, TerminateProcess, WaitForSingleObject, +}; + +/// The exit code a killed child reports, by analogy with a shell's 128 + 9 +/// for SIGKILL. It is distinct from the worker's own refusal exit (70) and +/// from the NTSTATUS codes a confinement fault ends a process with. +pub const KILL_EXIT_CODE: u32 = 137; + +/// A child process started by this crate, owned together with the job it +/// was born in and the parent's ends of its standard pipes. +/// +/// Dropping it kills the child and waits for it to end, then closes the job, +/// whose kill-on-close limit ends anything else still in it. +pub struct OwnedProcess { + process: OwnedHandle, + pid: u32, + job: OwnedHandle, + /// Writes to the child's stdin, when it was piped. + pub stdin: Option, + /// Reads the child's stdout, when it was piped. + pub stdout: Option, + /// Reads the child's stderr, when it was piped. + pub stderr: Option, +} + +impl OwnedProcess { + pub(crate) fn new( + process: OwnedHandle, + pid: u32, + job: OwnedHandle, + stdin: Option, + stdout: Option, + stderr: Option, + ) -> Self { + Self { + process, + pid, + job, + stdin, + stdout, + stderr, + } + } + + /// The child's process id. + pub fn id(&self) -> u32 { + self.pid + } + + /// Kills every process in the child's job, then the child itself, with + /// [`KILL_EXIT_CODE`]. Killing a child that has already ended succeeds. + pub fn kill(&self) -> io::Result<()> { + let job_killed = + unsafe { TerminateJobObject(self.job.as_raw_handle(), KILL_EXIT_CODE) } != 0; + if unsafe { TerminateProcess(self.process.as_raw_handle(), KILL_EXIT_CODE) } != 0 { + return Ok(()); + } + let error = io::Error::last_os_error(); + // Terminating a process that is already ending fails with access + // denied. That happens right after the job kill above, which ends the + // child (a member of the job) whether or not it has finished exiting + // yet, and for a child that had already exited. + if job_killed && self.in_owned_job().unwrap_or(false) { + return Ok(()); + } + match self.try_wait()? { + Some(_) => Ok(()), + None => Err(error), + } + } + + /// The exit code, if the child has ended. Never blocks. + pub fn try_wait(&self) -> io::Result> { + match unsafe { WaitForSingleObject(self.process.as_raw_handle(), 0) } { + WAIT_OBJECT_0 => self.exit_code().map(Some), + WAIT_TIMEOUT => Ok(None), + _ => Err(io::Error::last_os_error()), + } + } + + /// Blocks until the child ends, and returns its exit code. + pub fn wait(&self) -> io::Result { + match unsafe { WaitForSingleObject(self.process.as_raw_handle(), INFINITE) } { + WAIT_OBJECT_0 => self.exit_code(), + _ => Err(io::Error::last_os_error()), + } + } + + fn exit_code(&self) -> io::Result { + let mut code = 0; + if unsafe { GetExitCodeProcess(self.process.as_raw_handle(), &mut code) } == 0 { + return Err(io::Error::last_os_error()); + } + Ok(code) + } + + /// Reads the limits of the child's job through the parent's own handle. + pub fn job_limits(&self) -> Result { + job::read_limits(self.job.as_raw_handle()) + } + + /// Whether the child is in the job the parent created for it. + pub fn in_owned_job(&self) -> Result { + job::contains(self.job.as_raw_handle(), self.process.as_raw_handle()) + } +} + +impl AsRawHandle for OwnedProcess { + /// The process handle. + fn as_raw_handle(&self) -> RawHandle { + self.process.as_raw_handle() + } +} + +impl std::fmt::Debug for OwnedProcess { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OwnedProcess") + .field("pid", &self.pid) + .finish_non_exhaustive() + } +} + +impl Drop for OwnedProcess { + fn drop(&mut self) { + if self.kill().is_ok() { + let _ = self.wait(); + } + } +} + +/// A worker started by [`launch`](crate::launch): the [`OwnedProcess`] +/// (reached through `Deref`, so `kill`, `wait`, `try_wait` and the pipes are +/// the same as for any child) plus what only a confined launch has. +/// +/// Dropping it kills the worker and waits for it to end, then closes the job +/// and removes the worker's window station, desktop and TEMP directory. +pub struct ConfinedProcess { + // Fields drop in order: the process (killed and waited for) first, the + // start-up context last, after the worker has ended. + process: OwnedProcess, + package: PackageSid, + inherited_stdio: [usize; 3], + context: Context, +} + +impl ConfinedProcess { + #[allow(clippy::too_many_arguments)] + pub(crate) fn new( + process: OwnedHandle, + pid: u32, + job: OwnedHandle, + package: PackageSid, + inherited_stdio: [usize; 3], + stdin: File, + stdout: File, + stderr: File, + context: Context, + ) -> Self { + Self { + process: OwnedProcess::new(process, pid, job, Some(stdin), Some(stdout), Some(stderr)), + package, + inherited_stdio, + context, + } + } + + /// The package SID the worker runs under. + pub fn package_sid(&self) -> &PackageSid { + &self.package + } + + /// The handle values the worker was given as stdin, stdout and stderr. + /// An inherited handle keeps its value in the child, so these are also + /// the values the worker sees. + pub fn inherited_stdio(&self) -> [usize; 3] { + self.inherited_stdio + } + + /// The worker's private TEMP directory. + pub fn temp_dir(&self) -> &Path { + &self.context.temp + } + + /// Reads the worker's current primary token. + pub fn primary_token(&self) -> Result { + let mut token = null_mut(); + if unsafe { OpenProcessToken(self.process.as_raw_handle(), TOKEN_QUERY, &mut token) } == 0 { + return Err(super::native::last("OpenProcessToken(worker)")); + } + let token = super::native::owned(token, "OpenProcessToken(worker)")?; + token::read_facts(token.as_raw_handle()) + } +} + +impl Deref for ConfinedProcess { + type Target = OwnedProcess; + fn deref(&self) -> &OwnedProcess { + &self.process + } +} + +impl DerefMut for ConfinedProcess { + fn deref_mut(&mut self) -> &mut OwnedProcess { + &mut self.process + } +} + +impl AsRawHandle for ConfinedProcess { + /// The process handle. + fn as_raw_handle(&self) -> RawHandle { + self.process.as_raw_handle() + } +} + +impl std::fmt::Debug for ConfinedProcess { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ConfinedProcess") + .field("pid", &self.process.pid) + .field("package", &self.package) + .finish_non_exhaustive() + } +} diff --git a/crates/basal-launch/src/windows/profile.rs b/crates/basal-launch/src/windows/profile.rs new file mode 100644 index 0000000..fbbf98b --- /dev/null +++ b/crates/basal-launch/src/windows/profile.rs @@ -0,0 +1,465 @@ +//! The AppContainer profile every worker shares, the parent's loaded user +//! profile, and the test-only grant on the directory of a worker binary. + +use super::error::{LaunchError, Refusal}; +use super::native::{Result, SidBuf, check, last, owned, wide}; +use std::ffi::c_void; +use std::os::windows::io::AsRawHandle; +use std::path::Path; +use std::ptr::{null, null_mut}; +use std::sync::Mutex; +use windows_sys::Win32::Foundation::{ + FreeLibrary, HANDLE, HMODULE, LocalFree, WAIT_ABANDONED, WAIT_OBJECT_0, +}; +use windows_sys::Win32::Security::Authorization::{ + EXPLICIT_ACCESS_W, GRANT_ACCESS, GetNamedSecurityInfoW, NO_MULTIPLE_TRUSTEE, SE_FILE_OBJECT, + SetEntriesInAclW, SetNamedSecurityInfoW, TRUSTEE_IS_SID, TRUSTEE_IS_UNKNOWN, TRUSTEE_W, +}; +use windows_sys::Win32::Security::{ + DACL_SECURITY_INFORMATION, FreeSid, PSID, SID_AND_ATTRIBUTES, + SUB_CONTAINERS_AND_OBJECTS_INHERIT, TOKEN_QUERY, +}; +use windows_sys::Win32::Storage::FileSystem::{FILE_GENERIC_EXECUTE, FILE_GENERIC_READ}; +use windows_sys::Win32::System::LibraryLoader::{ + GetProcAddress, LOAD_LIBRARY_SEARCH_SYSTEM32, LoadLibraryExW, +}; +use windows_sys::Win32::System::Threading::{ + CreateMutexW, GetCurrentProcess, INFINITE, OpenProcessToken, ReleaseMutex, WaitForSingleObject, +}; + +/// The name of the one AppContainer profile all basal workers run under. +pub const PROFILE_NAME: &str = "cortexkit.basal.worker"; + +/// Serializes profile creation across every process of this user's session, +/// so concurrent first launches see one creation and later ones an existing +/// profile. +const PROFILE_MUTEX_NAME: &str = "Local\\cortexkit.basal.worker.profile"; + +/// `HRESULT_FROM_WIN32(ERROR_ALREADY_EXISTS)`. +const HRESULT_ALREADY_EXISTS: i32 = 0x8007_00b7_u32 as i32; + +type CreateProfile = unsafe extern "system" fn( + *const u16, + *const u16, + *const u16, + *const SID_AND_ATTRIBUTES, + u32, + *mut PSID, +) -> i32; +type DeriveSid = unsafe extern "system" fn(*const u16, *mut PSID) -> i32; +type UserProfileDirectory = unsafe extern "system" fn(HANDLE, *mut u16, *mut u32) -> i32; + +/// Reads the loaded profile folder from the process's primary token, never +/// from environment variables or a thread's impersonation token. Userenv is +/// resolved from System32 only in the parent, not imported into worker images. +pub fn process_profile_directory() -> Result { + let mut token = null_mut(); + // SAFETY: the process pseudo-handle is valid and token is writable. + check( + unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) }, + "OpenProcessToken(profile)", + )?; + let token = owned(token, "OpenProcessToken(profile)")?; + let userenv = Library::system32("userenv.dll")?; + // SAFETY: the function type matches the documented Userenv export. + let directory: UserProfileDirectory = unsafe { userenv.symbol(b"GetUserProfileDirectoryW\0")? }; + let mut size = 0; + // SAFETY: a null buffer asks for the size; the owned token stays open. + unsafe { directory(token.as_raw_handle(), null_mut(), &mut size) }; + if std::io::Error::last_os_error().raw_os_error() != Some(122) || size <= 1 || size > 32768 { + return Err(last("GetUserProfileDirectoryW(size)")); + } + let mut buffer = vec![0u16; size as usize]; + // SAFETY: the buffer has the requested capacity and the token is live. + check( + unsafe { directory(token.as_raw_handle(), buffer.as_mut_ptr(), &mut size) }, + "GetUserProfileDirectoryW", + )?; + let end = buffer + .iter() + .position(|unit| *unit == 0) + .ok_or_else(|| "user profile path is not terminated".to_owned())?; + String::from_utf16(&buffer[..end]).map_err(|_| "user profile path is not Unicode".to_owned()) +} + +/// The package SID of an AppContainer profile. +#[derive(Clone, PartialEq, Eq)] +pub struct PackageSid { + sid: SidBuf, + text: String, +} + +impl PackageSid { + /// The `S-1-15-2-...` form, as passed to the worker in `--package-sid`. + pub fn as_str(&self) -> &str { + &self.text + } + + pub(crate) fn as_psid(&self) -> PSID { + self.sid.as_psid() + } + + /// Takes a SID the profile API returned and frees the original. + /// + /// # Safety + /// + /// `sid` must be a valid SID allocated with `AllocateAndInitializeSid`, + /// as the profile API documents. + unsafe fn adopt(sid: PSID) -> Result { + let copy = unsafe { SidBuf::copy(sid) }; + unsafe { FreeSid(sid) }; + let sid = copy?; + let text = sid.to_text()?; + Ok(Self { sid, text }) + } +} + +impl std::fmt::Debug for PackageSid { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "PackageSid({})", self.text) + } +} + +impl std::fmt::Display for PackageSid { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.text) + } +} + +/// Creates the shared worker profile, or opens it if it exists, with no +/// capabilities. Either way the result is the profile's package SID. +/// +/// A failure is the named refusal `appcontainer-profile-unavailable`; no +/// worker is ever started without a profile. +pub fn create_or_open_profile() -> std::result::Result { + create_or_open_named(PROFILE_NAME).map_err(unavailable) +} + +pub(crate) fn unavailable(detail: String) -> LaunchError { + LaunchError::refused(Refusal::AppContainerProfileUnavailable, detail) +} + +pub(crate) fn create_or_open_named(name: &str) -> Result { + // Userenv pulls User32 and other GUI libraries in when it loads. It is + // loaded here, at run time, only in the parent, so that no image linking + // this crate imports it. + let userenv = Library::system32("userenv.dll")?; + let create: CreateProfile = unsafe { userenv.symbol(b"CreateAppContainerProfile\0")? }; + let derive: DeriveSid = + unsafe { userenv.symbol(b"DeriveAppContainerSidFromAppContainerName\0")? }; + let _guard = SessionMutex::acquire(PROFILE_MUTEX_NAME)?; + let name_w = wide(name); + let description = wide("CortexKit basal flow worker"); + let mut sid = null_mut(); + let hr = unsafe { + create( + name_w.as_ptr(), + name_w.as_ptr(), + description.as_ptr(), + null(), + 0, + &mut sid, + ) + }; + if hr >= 0 { + return unsafe { PackageSid::adopt(sid) }; + } + if hr != HRESULT_ALREADY_EXISTS { + return Err(format!( + "CreateAppContainerProfile({name}): HRESULT {:#010x}", + hr as u32 + )); + } + // The profile exists. Its package SID is a fixed function of its name. + let mut sid = null_mut(); + let hr = unsafe { derive(name_w.as_ptr(), &mut sid) }; + if hr < 0 { + return Err(format!( + "DeriveAppContainerSidFromAppContainerName({name}): HRESULT {:#010x}", + hr as u32 + )); + } + unsafe { PackageSid::adopt(sid) } +} + +/// Grants the package SID read and execute on the directory holding +/// `binary`, inherited by its contents, and changes no other entry of any ACL. +/// +/// An AppContainer process cannot load an image, or open its working +/// directory, from a directory that grants its package nothing. In production +/// that grant is part of installation. Test harnesses, which run binaries +/// from a build directory, call this instead; the production launch path +/// never does. +pub fn grant_test_binary_directory( + binary: &Path, + package: &PackageSid, +) -> std::result::Result<(), LaunchError> { + static GRANTS: Mutex<()> = Mutex::new(()); + let directory = binary + .parent() + .ok_or_else(|| format!("{} has no parent directory", binary.display()))?; + let path = wide(directory); + // Reading and rewriting a DACL is not atomic; serialize this process's + // grants so two concurrent ones cannot drop each other's entry. + let _guard = GRANTS.lock().unwrap_or_else(|poison| poison.into_inner()); + unsafe { + let mut old = null_mut(); + let mut descriptor = null_mut(); + let error = GetNamedSecurityInfoW( + path.as_ptr(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + null_mut(), + null_mut(), + &mut old, + null_mut(), + &mut descriptor, + ); + if error != 0 { + return Err(format!( + "GetNamedSecurityInfoW({}): Win32 {error}", + directory.display() + ) + .into()); + } + let entry = EXPLICIT_ACCESS_W { + grfAccessPermissions: FILE_GENERIC_READ | FILE_GENERIC_EXECUTE, + grfAccessMode: GRANT_ACCESS, + grfInheritance: SUB_CONTAINERS_AND_OBJECTS_INHERIT, + Trustee: TRUSTEE_W { + pMultipleTrustee: null_mut(), + MultipleTrusteeOperation: NO_MULTIPLE_TRUSTEE, + TrusteeForm: TRUSTEE_IS_SID, + TrusteeType: TRUSTEE_IS_UNKNOWN, + ptstrName: package.as_psid().cast(), + }, + }; + let mut new = null_mut(); + let error = SetEntriesInAclW(1, &entry, old, &mut new); + if error != 0 { + LocalFree(descriptor); + return Err(format!("SetEntriesInAclW: Win32 {error}").into()); + } + let error = SetNamedSecurityInfoW( + path.as_ptr(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + null_mut(), + null_mut(), + new, + null(), + ); + LocalFree(new.cast()); + LocalFree(descriptor); + if error != 0 { + return Err(format!( + "SetNamedSecurityInfoW({}): Win32 {error}", + directory.display() + ) + .into()); + } + } + Ok(()) +} + +/// A DLL loaded from System32 only, never from the search path. +pub(crate) struct Library(HMODULE); + +impl Library { + pub(crate) fn system32(name: &str) -> Result { + let module = unsafe { + LoadLibraryExW( + wide(name).as_ptr(), + null_mut(), + LOAD_LIBRARY_SEARCH_SYSTEM32, + ) + }; + if module.is_null() { + Err(last(&format!("LoadLibraryExW({name})"))) + } else { + Ok(Self(module)) + } + } + + /// Looks up an export. + /// + /// # Safety + /// + /// `T` must be the function pointer type of the export, and `name` must + /// end with a NUL byte. + pub(crate) unsafe fn symbol(&self, name: &[u8]) -> Result { + debug_assert_eq!(size_of::(), size_of::<*const c_void>()); + let function = unsafe { GetProcAddress(self.0, name.as_ptr()) }.ok_or_else(|| { + last(&format!( + "GetProcAddress({})", + String::from_utf8_lossy(&name[..name.len().saturating_sub(1)]) + )) + })?; + Ok(unsafe { std::mem::transmute_copy(&function) }) + } +} + +impl Drop for Library { + fn drop(&mut self) { + unsafe { + FreeLibrary(self.0); + } + } +} + +// The module handle is process-wide and FreeLibrary may run on any thread. +unsafe impl Send for Library {} + +/// A named mutex held for the session, released on drop. +struct SessionMutex(std::os::windows::io::OwnedHandle); + +impl SessionMutex { + fn acquire(name: &str) -> Result { + let handle = owned( + unsafe { CreateMutexW(null(), 0, wide(name).as_ptr()) }, + "CreateMutexW(profile)", + )?; + // An abandoned mutex is still acquired: its previous holder died, and + // profile creation is safe to repeat. + match unsafe { WaitForSingleObject(handle.as_raw_handle(), INFINITE) } { + WAIT_OBJECT_0 | WAIT_ABANDONED => Ok(Self(handle)), + _ => Err(last("WaitForSingleObject(profile mutex)")), + } + } +} + +impl Drop for SessionMutex { + fn drop(&mut self) { + let _ = check( + unsafe { ReleaseMutex(self.0.as_raw_handle()) }, + "ReleaseMutex(profile)", + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_process_token_profile_ignores_userprofile_environment() { + const MARKER: &str = "BASAL_PROFILE_ENV_CONTROL"; + if let Some(fake) = std::env::var_os(MARKER) { + let fake = fake.to_str().expect("fixture profile is Unicode"); + let actual = process_profile_directory().expect("loaded process token profile"); + assert_ne!(actual.to_lowercase(), fake.to_lowercase()); + return; + } + // A subprocess changes only its own environment, so concurrent profile + // and launcher tests never see the hostile USERPROFILE value. + let fake = std::env::temp_dir().join(format!("basal-fake-profile-{}", std::process::id())); + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "windows::profile::tests::the_process_token_profile_ignores_userprofile_environment", "--nocapture"]) + .env(MARKER, &fake) + .env("USERPROFILE", &fake) + .output().expect("run profile environment control"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stdout) + ); + assert!( + String::from_utf8_lossy(&output.stdout).contains("1 passed"), + "the profile control did not execute" + ); + } + use std::sync::{Arc, Barrier}; + + /// A profile used only by the race test below, so deleting it cannot + /// disturb launches other tests make under the shared profile. + const RACE_PROFILE: &str = "cortexkit.basal.launch-test.race"; + + fn delete_profile(name: &str) { + type DeleteProfile = unsafe extern "system" fn(*const u16) -> i32; + let userenv = Library::system32("userenv.dll").unwrap(); + let delete: DeleteProfile = + unsafe { userenv.symbol(b"DeleteAppContainerProfile\0").unwrap() }; + // Deleting a profile that does not exist also succeeds. + let hr = unsafe { delete(wide(name).as_ptr()) }; + assert!( + hr >= 0, + "DeleteAppContainerProfile({name}): {:#010x}", + hr as u32 + ); + } + + fn derived(name: &str) -> String { + let userenv = Library::system32("userenv.dll").unwrap(); + let derive: DeriveSid = unsafe { + userenv + .symbol(b"DeriveAppContainerSidFromAppContainerName\0") + .unwrap() + }; + let mut sid = null_mut(); + let hr = unsafe { derive(wide(name).as_ptr(), &mut sid) }; + assert!( + hr >= 0, + "DeriveAppContainerSidFromAppContainerName: {:#010x}", + hr as u32 + ); + unsafe { PackageSid::adopt(sid) } + .unwrap() + .as_str() + .to_owned() + } + + fn race(name: &'static str, threads: usize) -> Vec { + let barrier = Arc::new(Barrier::new(threads)); + let handles: Vec<_> = (0..threads) + .map(|_| { + let barrier = Arc::clone(&barrier); + std::thread::spawn(move || { + barrier.wait(); + create_or_open_named(name).map(|sid| sid.as_str().to_owned()) + }) + }) + .collect(); + handles + .into_iter() + .map(|handle| { + handle + .join() + .expect("thread panicked") + .expect("create or open") + }) + .collect() + } + + /// Eight threads create a profile that does not exist yet, all at once. + /// Exactly the race a first launch from concurrent tests meets: every + /// one of them gets the profile's one package SID. + #[test] + fn concurrent_first_creation_yields_one_package_sid() { + delete_profile(RACE_PROFILE); + let sids = race(RACE_PROFILE, 8); + let expected = derived(RACE_PROFILE); + println!( + "race profile {RACE_PROFILE}: {} creations, package SID {expected}", + sids.len() + ); + assert!(expected.starts_with("S-1-15-2-"), "{expected}"); + assert!( + sids.iter().all(|sid| *sid == expected), + "{sids:?} != {expected}" + ); + delete_profile(RACE_PROFILE); + } + + /// The shared worker profile, opened concurrently while it exists, + /// always yields the SID derived from its name. + #[test] + fn concurrent_opens_of_the_worker_profile_agree() { + let sids = race(PROFILE_NAME, 8); + let expected = derived(PROFILE_NAME); + println!("worker profile {PROFILE_NAME}: package SID {expected}"); + assert!( + sids.iter().all(|sid| *sid == expected), + "{sids:?} != {expected}" + ); + } +} diff --git a/crates/basal-launch/src/windows/spawn_lock.rs b/crates/basal-launch/src/windows/spawn_lock.rs new file mode 100644 index 0000000..66eb77e --- /dev/null +++ b/crates/basal-launch/src/windows/spawn_lock.rs @@ -0,0 +1,94 @@ +//! The one lock every Windows child spawn in basal takes. +//! +//! A child created with `bInheritHandles = TRUE` and no explicit handle list +//! inherits every inheritable handle in the parent at that instant. basal's +//! own spawns pass an explicit list, so they inherit only their own three +//! standard handles; but a handle is only listable once it is inheritable, +//! so between "made inheritable" and "parent's copy closed" any concurrent +//! spawn without a list (a test variant, a library, the standard library's +//! `Command`) would copy it. A child holding another child's pipe end keeps +//! that pipe open after its real owner exits, and can read or write it. +//! +//! The rule that closes this window: a spawn holds [`SpawnLock`] from the +//! moment it makes its child's handles inheritable, through process +//! creation, until it has closed its copies of them. So at any instant at +//! most one spawn's handles are inheritable, and any spawn under the lock, +//! with or without a handle list, can only inherit its own. + +use std::cell::Cell; +use std::os::windows::io::RawHandle; +use std::sync::{Mutex, MutexGuard}; +use windows_sys::Win32::Foundation::{HANDLE_FLAG_INHERIT, SetHandleInformation}; + +static SPAWN: Mutex<()> = Mutex::new(()); + +thread_local! { + static HELD: Cell = const { Cell::new(false) }; +} + +/// Proof that the calling thread holds the process-wide spawn lock. +/// Dropping it releases the lock. +#[must_use = "the lock is released as soon as the guard is dropped"] +pub struct SpawnLock { + _guard: MutexGuard<'static, ()>, +} + +impl Drop for SpawnLock { + fn drop(&mut self) { + HELD.with(|held| held.set(false)); + } +} + +impl std::fmt::Debug for SpawnLock { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("SpawnLock") + } +} + +/// Takes the process-wide spawn lock, blocking until it is free. The lock is +/// not reentrant: a thread that already holds it must not call this again. +/// +/// A panic while the lock was held leaves no state behind it to repair, so +/// a poisoned lock is simply taken. +pub fn spawn_lock() -> SpawnLock { + let guard = SPAWN + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + HELD.with(|held| held.set(true)); + SpawnLock { _guard: guard } +} + +/// Whether the calling thread holds the spawn lock. +pub fn spawn_lock_held() -> bool { + HELD.with(Cell::get) +} + +/// Marks handles inheritable for a child about to be created. It takes the +/// lock guard so that no caller can make a handle inheritable outside it. +/// The caller must close its copies before dropping the guard. +pub fn make_inheritable(_held: &SpawnLock, handles: &[RawHandle]) -> std::io::Result<()> { + for &handle in handles { + if unsafe { SetHandleInformation(handle, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT) } == 0 { + return Err(std::io::Error::last_os_error()); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_guard_marks_the_thread_and_excludes_other_threads() { + assert!(!spawn_lock_held()); + let guard = spawn_lock(); + assert!(spawn_lock_held()); + let elsewhere = std::thread::spawn(|| (spawn_lock_held(), SPAWN.try_lock().is_err())) + .join() + .unwrap(); + assert_eq!(elsewhere, (false, true)); + drop(guard); + assert!(!spawn_lock_held()); + } +} diff --git a/crates/basal-launch/src/windows/token.rs b/crates/basal-launch/src/windows/token.rs new file mode 100644 index 0000000..3a4816f --- /dev/null +++ b/crates/basal-launch/src/windows/token.rs @@ -0,0 +1,628 @@ +//! The worker's tokens: how they are built, and how the parent reads a +//! token back and compares it with what it built. + +use super::deviation::Deviation; +use super::native::{ + Result, SE_GROUP_INTEGRITY, SE_GROUP_LOGON_ID, SE_GROUP_USE_FOR_DENY_ONLY, SidBuf, check, + groups, last, owned, sid_string, token_buffer, +}; +use std::ffi::c_void; +use std::mem::size_of; +use std::os::windows::io::{AsRawHandle, OwnedHandle}; +use std::ptr::{null, null_mut}; +use windows_sys::Win32::Foundation::{ + ERROR_NOT_ALL_ASSIGNED, GetHandleInformation, GetLastError, HANDLE, HANDLE_FLAG_INHERIT, + SetHandleInformation, +}; +use windows_sys::Win32::Security::{ + AdjustTokenPrivileges, CreateRestrictedToken, DACL_SECURITY_INFORMATION, DISABLE_MAX_PRIVILEGE, + DuplicateTokenEx, EqualSid, GetKernelObjectSecurity, LUID_AND_ATTRIBUTES, SE_PRIVILEGE_REMOVED, + SID_AND_ATTRIBUTES, SecurityImpersonation, SetKernelObjectSecurity, SetTokenInformation, + TOKEN_ALL_ACCESS, TOKEN_APPCONTAINER_INFORMATION, TOKEN_INFORMATION_CLASS, + TOKEN_MANDATORY_LABEL, TOKEN_PRIVILEGES, TOKEN_USER, TokenAppContainerSid, TokenCapabilities, + TokenGroups, TokenImpersonation, TokenImpersonationLevel, TokenIntegrityLevel, + TokenIsAppContainer, TokenPrivileges, TokenRestrictedSids, TokenSecurityAttributes, TokenType, + TokenUser, WinLowLabelSid, WinNullSid, WinWorldSid, +}; +use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + +/// The Low integrity label, `S-1-16-4096`. Process creation turns the +/// primary of an AppContainer process Low whatever the supplied token says, +/// and an Untrusted-at-birth worker fails before entry, so the worker is born +/// Low and lowers itself to Untrusted before reading input. +pub const LOW_INTEGRITY: &str = "S-1-16-4096"; + +/// The NULL SID, `S-1-0-0`: the worker primary's only restricting SID. No +/// object grants it anything unless a grant names it explicitly, so every +/// access check the restricted half of the token must also pass fails. +pub const NULL_SID: &str = "S-1-0-0"; + +/// The claim that marks a Less Privileged AppContainer token. +const LPAC_CLAIM: &str = "WIN://NOALLAPPPKG"; + +/// `TOKEN_TYPE::TokenPrimary`. +pub const TOKEN_PRIMARY: i32 = 1; +/// `TOKEN_TYPE::TokenImpersonation`. +pub const TOKEN_IMPERSONATION: i32 = 2; +/// `SECURITY_IMPERSONATION_LEVEL::SecurityImpersonation`. +pub const IMPERSONATION_LEVEL: i32 = 2; + +/// What the parent reads back from a token. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TokenFacts { + /// `TOKEN_TYPE`: 1 primary, 2 impersonation. + pub token_type: i32, + /// The impersonation level of an impersonation token. + pub impersonation_level: Option, + /// The integrity label SID. + pub integrity: String, + /// The AppContainer (package) SID, if the token is an AppContainer token. + pub appcontainer: Option, + /// The capability SIDs. + pub capabilities: Vec, + /// Whether the token carries the Less Privileged AppContainer claim, + /// `WIN://NOALLAPPPKG` as the single unsigned value 1. + pub less_privileged: bool, + /// The restricting SIDs. + pub restricting_sids: Vec, + /// Access groups (every group but the integrity label) that are not + /// deny-only. + pub enabled_groups: Vec, + /// How many access groups are deny-only. + pub deny_only_groups: usize, + /// How many privileges the token holds, enabled or not. + pub privileges: usize, +} + +/// Reads the facts of a token opened with `TOKEN_QUERY`. +pub(crate) fn read_facts(token: HANDLE) -> Result { + unsafe { + let token_type = token_buffer(token, TokenType)?; + let token_type = *token_type.as_ptr().cast::(); + let impersonation_level = if token_type == TOKEN_IMPERSONATION { + let level = token_buffer(token, TokenImpersonationLevel)?; + Some(*level.as_ptr().cast::()) + } else { + None + }; + let label = token_buffer(token, TokenIntegrityLevel)?; + let integrity = sid_string((*label.as_ptr().cast::()).Label.Sid)?; + let is_app = token_buffer(token, TokenIsAppContainer)?; + let is_app = *is_app.as_ptr().cast::() != 0; + let (appcontainer, capabilities) = if is_app { + let info = token_buffer(token, TokenAppContainerSid)?; + let sid = (*info.as_ptr().cast::()).TokenAppContainer; + let capabilities = token_buffer(token, TokenCapabilities)?; + (Some(sid_string(sid)?), sid_list(groups(&capabilities))?) + } else { + (None, Vec::new()) + }; + let restricting = token_buffer(token, TokenRestrictedSids)?; + let group_data = token_buffer(token, TokenGroups)?; + let mut enabled_groups = Vec::new(); + let mut deny_only_groups = 0; + for group in groups(&group_data) { + if group.Attributes & SE_GROUP_INTEGRITY != 0 { + continue; + } + if group.Attributes & SE_GROUP_USE_FOR_DENY_ONLY != 0 { + deny_only_groups += 1; + } else { + enabled_groups.push(sid_string(group.Sid)?); + } + } + let privileges = token_buffer(token, TokenPrivileges)?; + let privileges = (*privileges.as_ptr().cast::()).PrivilegeCount as usize; + Ok(TokenFacts { + token_type, + impersonation_level, + integrity, + appcontainer, + capabilities, + // Only an AppContainer token can be a Less Privileged one. + less_privileged: is_app && less_privileged_claim(token)?, + restricting_sids: sid_list(groups(&restricting))?, + enabled_groups, + deny_only_groups, + privileges, + }) + } +} + +fn sid_list(list: &[SID_AND_ATTRIBUTES]) -> Result> { + list.iter() + .map(|entry| unsafe { sid_string(entry.Sid) }) + .collect() +} + +/// What the parent built into the worker's primary, and so expects to read +/// back from the suspended child. +#[derive(Debug, Clone)] +pub(crate) struct BirthExpectation { + pub(crate) package: String, + pub(crate) less_privileged: bool, + pub(crate) capabilities: Vec, + pub(crate) restricting_sids: Vec, + pub(crate) enabled_groups: Vec, + pub(crate) privileges: bool, +} + +/// Compares the suspended child's primary with what was built. Returns every +/// difference found. +pub(crate) fn check_birth( + facts: &TokenFacts, + expected: &BirthExpectation, +) -> std::result::Result<(), String> { + let mut wrong = Vec::new(); + if facts.token_type != TOKEN_PRIMARY { + wrong.push(format!("token type {} is not primary", facts.token_type)); + } + if facts.integrity != LOW_INTEGRITY { + wrong.push(format!("integrity {} is not Low", facts.integrity)); + } + if facts.appcontainer.as_deref() != Some(expected.package.as_str()) { + wrong.push(format!( + "AppContainer SID {:?} is not the package {}", + facts.appcontainer, expected.package + )); + } + if facts.capabilities != expected.capabilities { + wrong.push(format!( + "capabilities {:?}, expected {:?}", + facts.capabilities, expected.capabilities + )); + } + if facts.less_privileged != expected.less_privileged { + wrong.push(format!( + "{LPAC_CLAIM} claim present {}, expected {}", + facts.less_privileged, expected.less_privileged + )); + } + if sorted(&facts.restricting_sids) != sorted(&expected.restricting_sids) { + wrong.push(format!( + "restricting SIDs {:?}, expected {:?}", + facts.restricting_sids, expected.restricting_sids + )); + } + if sorted(&facts.enabled_groups) != sorted(&expected.enabled_groups) { + wrong.push(format!( + "groups not deny-only {:?}, expected {:?}", + facts.enabled_groups, expected.enabled_groups + )); + } + if (facts.privileges != 0) != expected.privileges { + wrong.push(format!( + "{} privileges, expected {}", + facts.privileges, + if expected.privileges { "some" } else { "none" } + )); + } + if wrong.is_empty() { + Ok(()) + } else { + Err(wrong.join("; ")) + } +} + +/// Compares the start-up thread token, read back from the suspended thread, +/// with the one the parent set: a Low impersonation token at +/// SecurityImpersonation for the same package. A token the system judges +/// stronger than the process's primary is silently downgraded to +/// SecurityIdentification, and the child then fails before entry, so the +/// level must be read back rather than assumed. +pub(crate) fn check_initial(facts: &TokenFacts, package: &str) -> std::result::Result<(), String> { + let mut wrong = Vec::new(); + if facts.token_type != TOKEN_IMPERSONATION { + wrong.push(format!( + "token type {} is not impersonation", + facts.token_type + )); + } + if facts.impersonation_level != Some(IMPERSONATION_LEVEL) { + wrong.push(format!( + "impersonation level {:?} is not SecurityImpersonation", + facts.impersonation_level + )); + } + if facts.integrity != LOW_INTEGRITY { + wrong.push(format!("integrity {} is not Low", facts.integrity)); + } + if facts.appcontainer.as_deref() != Some(package) { + wrong.push(format!( + "AppContainer SID {:?} is not the package {package}", + facts.appcontainer + )); + } + if wrong.is_empty() { + Ok(()) + } else { + Err(wrong.join("; ")) + } +} + +fn sorted(list: &[String]) -> Vec<&str> { + let mut list: Vec<&str> = list.iter().map(String::as_str).collect(); + list.sort_unstable(); + list +} + +/// Opens this process's own token with full access, the source of the +/// worker's primary. +pub(crate) fn own_token() -> Result { + let mut token = null_mut(); + check( + unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_ALL_ACCESS, &mut token) }, + "OpenProcessToken(parent)", + )?; + owned(token, "OpenProcessToken(parent)") +} + +/// Builds the worker's primary token from the parent's own token: every +/// access group deny-only (logon included), no privileges, the NULL SID as +/// the only restricting SID, Low integrity. +/// +/// The AppContainer part is not added here. Process creation lowboxes this +/// token to the package with the security capabilities passed as a creation +/// attribute. Because the token is a restricted copy of the caller's own, +/// creating a process with it needs no SeAssignPrimaryTokenPrivilege. +pub(crate) fn build_primary( + parent: HANDLE, + package: &str, + deviation: Deviation, +) -> Result<(OwnedHandle, BirthExpectation)> { + unsafe { + let data = token_buffer(parent, TokenGroups)?; + let mut kept_enabled = Vec::new(); + let mut disabled = Vec::new(); + for group in groups(&data) { + // The integrity label is not an access group. + if group.Attributes & SE_GROUP_INTEGRITY != 0 { + continue; + } + if deviation.keeps_logon_group() + && group.Attributes & SE_GROUP_LOGON_ID == SE_GROUP_LOGON_ID + { + kept_enabled.push(sid_string(group.Sid)?); + continue; + } + disabled.push(SID_AND_ATTRIBUTES { + Sid: group.Sid, + Attributes: 0, + }); + } + if deviation.keeps_logon_group() && kept_enabled.is_empty() { + return Err("the parent token has no logon SID to keep enabled".into()); + } + let null_sid = SidBuf::well_known(WinNullSid)?; + let world = SidBuf::well_known(WinWorldSid)?; + let mut restrict = vec![SID_AND_ATTRIBUTES { + Sid: null_sid.as_psid(), + Attributes: 0, + }]; + if deviation.widens_restricting_sids() { + restrict.push(SID_AND_ATTRIBUTES { + Sid: world.as_psid(), + Attributes: 0, + }); + } + let mut token = null_mut(); + check( + CreateRestrictedToken( + parent, + DISABLE_MAX_PRIVILEGE, + disabled.len() as u32, + disabled.as_ptr(), + 0, + null(), + restrict.len() as u32, + restrict.as_ptr(), + &mut token, + ), + "CreateRestrictedToken(primary)", + )?; + let token = owned(token, "CreateRestrictedToken(primary)")?; + // DISABLE_MAX_PRIVILEGE keeps SeChangeNotifyPrivilege. Remove it and + // anything else left, so the token holds no privilege at all. + if !deviation.keeps_privileges() { + remove_privileges(token.as_raw_handle())?; + } + ensure_low(token.as_raw_handle())?; + let mut restricting_sids = vec![null_sid.to_text()?]; + if deviation.widens_restricting_sids() { + restricting_sids.push(world.to_text()?); + } + Ok(( + token, + BirthExpectation { + package: package.to_owned(), + less_privileged: deviation.less_privileged(), + capabilities: Vec::new(), + restricting_sids, + enabled_groups: kept_enabled, + privileges: deviation.expects_privileges(), + }, + )) + } +} + +/// Builds the start-up thread token from the token of a never-resumed +/// process born into the same AppContainer: a Low, same-package impersonation +/// token whose restricting SIDs are its own user and groups. +/// +/// The worker's primary allows almost nothing, which is too little for the +/// loader to map system DLLs and initialize the process. The loader runs on +/// the main thread, which impersonates this token until the worker reverts +/// to its primary before reading any input. Restricting the token to the +/// same SIDs it already has keeps it from counting as stronger than the +/// restricted primary, which would get it downgraded to identification +/// level and fail the child before entry. +pub(crate) fn build_initial(source: HANDLE) -> Result { + unsafe { + let source_groups = token_buffer(source, TokenGroups)?; + let source_user = token_buffer(source, TokenUser)?; + let source_dacl = token_dacl(source)?; + let mut same_access = groups(&source_groups) + .iter() + .filter(|group| group.Attributes & SE_GROUP_INTEGRITY == 0) + .map(|group| SID_AND_ATTRIBUTES { + Sid: group.Sid, + Attributes: 0, + }) + .collect::>(); + same_access.push(SID_AND_ATTRIBUTES { + Sid: (*source_user.as_ptr().cast::()).User.Sid, + Attributes: 0, + }); + let mut loader = null_mut(); + check( + CreateRestrictedToken( + source, + DISABLE_MAX_PRIVILEGE, + 0, + null(), + 0, + null(), + same_access.len() as u32, + same_access.as_ptr(), + &mut loader, + ), + "CreateRestrictedToken(initial)", + )?; + let loader = owned(loader, "CreateRestrictedToken(initial)")?; + // Filtering and duplicating otherwise give the new token object the + // parent's default DACL, which does not grant the package SID: the + // child could not query its own start-up token. + set_token_dacl(loader.as_raw_handle(), &source_dacl)?; + ensure_low(loader.as_raw_handle())?; + let loader_dacl = token_dacl(loader.as_raw_handle())?; + let mut initial = null_mut(); + check( + DuplicateTokenEx( + loader.as_raw_handle(), + TOKEN_ALL_ACCESS, + null(), + SecurityImpersonation, + TokenImpersonation, + &mut initial, + ), + "DuplicateTokenEx(initial)", + )?; + let initial = owned(initial, "DuplicateTokenEx(initial)")?; + set_token_dacl(initial.as_raw_handle(), &loader_dacl)?; + check( + SetHandleInformation(initial.as_raw_handle(), HANDLE_FLAG_INHERIT, 0), + "SetHandleInformation(initial token)", + )?; + let mut flags = 0; + check( + GetHandleInformation(initial.as_raw_handle(), &mut flags), + "GetHandleInformation(initial token)", + )?; + if flags & HANDLE_FLAG_INHERIT != 0 { + return Err("the initial token handle stayed inheritable".into()); + } + Ok(initial) + } +} + +/// Removes every privilege the token still holds. +unsafe fn remove_privileges(token: HANDLE) -> Result<()> { + unsafe { + let data = token_buffer(token, TokenPrivileges)?; + let list = &*data.as_ptr().cast::(); + let entries = + std::slice::from_raw_parts(list.Privileges.as_ptr(), list.PrivilegeCount as usize); + for entry in entries { + let remove = TOKEN_PRIVILEGES { + PrivilegeCount: 1, + Privileges: [LUID_AND_ATTRIBUTES { + Luid: entry.Luid, + Attributes: SE_PRIVILEGE_REMOVED, + }], + }; + check( + AdjustTokenPrivileges(token, 0, &remove, 0, null_mut(), null_mut()), + "AdjustTokenPrivileges(remove)", + )?; + // The call succeeds even when it changed nothing; that case is + // reported only through the last error. + if GetLastError() == ERROR_NOT_ALL_ASSIGNED { + return Err(last("AdjustTokenPrivileges(remove)")); + } + } + Ok(()) + } +} + +/// Sets the token's integrity to Low unless it is Low already. Lowering +/// needs only TOKEN_ADJUST_DEFAULT on the handle. +unsafe fn ensure_low(token: HANDLE) -> Result<()> { + unsafe { + let low = SidBuf::well_known(WinLowLabelSid)?; + let current = token_buffer(token, TokenIntegrityLevel)?; + let current = (*current.as_ptr().cast::()) + .Label + .Sid; + if EqualSid(current, low.as_psid()) != 0 { + return Ok(()); + } + let label = TOKEN_MANDATORY_LABEL { + Label: SID_AND_ATTRIBUTES { + Sid: low.as_psid(), + Attributes: SE_GROUP_INTEGRITY, + }, + }; + let length = size_of::() + + windows_sys::Win32::Security::GetLengthSid(low.as_psid()) as usize; + check( + SetTokenInformation( + token, + TokenIntegrityLevel, + (&label as *const TOKEN_MANDATORY_LABEL).cast(), + length as u32, + ), + "SetTokenInformation(TokenIntegrityLevel)", + ) + } +} + +unsafe fn token_dacl(token: HANDLE) -> Result> { + unsafe { + let mut bytes = 0; + GetKernelObjectSecurity(token, DACL_SECURITY_INFORMATION, null_mut(), 0, &mut bytes); + if bytes == 0 { + return Err(last("GetKernelObjectSecurity(token DACL size)")); + } + let mut data = vec![0usize; (bytes as usize).div_ceil(size_of::())]; + check( + GetKernelObjectSecurity( + token, + DACL_SECURITY_INFORMATION, + data.as_mut_ptr().cast(), + bytes, + &mut bytes, + ), + "GetKernelObjectSecurity(token DACL)", + )?; + Ok(data) + } +} + +unsafe fn set_token_dacl(token: HANDLE, descriptor: &[usize]) -> Result<()> { + check( + unsafe { + SetKernelObjectSecurity( + token, + DACL_SECURITY_INFORMATION, + descriptor.as_ptr().cast_mut().cast(), + ) + }, + "SetKernelObjectSecurity(token DACL)", + ) +} + +// Token security attributes have no Win32 reader, so the claim is read with +// the native call. The layouts are those of +// TOKEN_SECURITY_ATTRIBUTES_INFORMATION and TOKEN_SECURITY_ATTRIBUTE_V1 on +// 64-bit Windows. +#[repr(C)] +struct UnicodeString { + length: u16, + maximum_length: u16, + buffer: *mut u16, +} + +#[repr(C)] +struct SecurityAttribute { + name: UnicodeString, + value_type: u16, + reserved: u16, + flags: u32, + value_count: u32, + values: *mut c_void, +} + +#[repr(C)] +struct SecurityAttributes { + version: u16, + reserved: u16, + count: u32, + attributes: *mut SecurityAttribute, +} + +/// `TOKEN_SECURITY_ATTRIBUTE_TYPE_UINT64`. +const ATTRIBUTE_TYPE_UINT64: u16 = 2; + +#[link(name = "ntdll", kind = "raw-dylib")] +unsafe extern "system" { + fn NtQueryInformationToken( + token: HANDLE, + class: TOKEN_INFORMATION_CLASS, + buffer: *mut c_void, + length: u32, + returned: *mut u32, + ) -> i32; +} + +/// Whether the token carries `WIN://NOALLAPPPKG` as the single unsigned +/// value 1, the mark of a Less Privileged AppContainer. The dedicated +/// information class, `TokenIsLessPrivilegedAppContainer`, fails with +/// "invalid information class" on Windows Server 2022 and Windows 11, so the +/// claim is read instead. +fn less_privileged_claim(token: HANDLE) -> Result { + unsafe { + let mut bytes = 0; + let status = + NtQueryInformationToken(token, TokenSecurityAttributes, null_mut(), 0, &mut bytes); + if bytes == 0 { + return Err(format!( + "NtQueryInformationToken(TokenSecurityAttributes, size): {:#010x}", + status as u32 + )); + } + let mut data = vec![0usize; (bytes as usize).div_ceil(size_of::())]; + let status = NtQueryInformationToken( + token, + TokenSecurityAttributes, + data.as_mut_ptr().cast(), + bytes, + &mut bytes, + ); + if status != 0 { + return Err(format!( + "NtQueryInformationToken(TokenSecurityAttributes): {:#010x}", + status as u32 + )); + } + let header = &*data.as_ptr().cast::(); + if header.version != 1 { + return Err(format!( + "unsupported token security attributes version {}", + header.version + )); + } + if header.count == 0 { + return Ok(false); + } + let entries = std::slice::from_raw_parts(header.attributes, header.count as usize); + for entry in entries { + let name = &entry.name; + let name = if name.buffer.is_null() { + String::new() + } else { + String::from_utf16_lossy(std::slice::from_raw_parts( + name.buffer, + usize::from(name.length) / 2, + )) + }; + if name != LPAC_CLAIM { + continue; + } + if entry.value_type != ATTRIBUTE_TYPE_UINT64 || entry.value_count != 1 { + return Ok(false); + } + return Ok(*entry.values.cast::() == 1); + } + Ok(false) + } +} diff --git a/crates/basal-launch/tests/windows_launch.rs b/crates/basal-launch/tests/windows_launch.rs new file mode 100644 index 0000000..640ac4f --- /dev/null +++ b/crates/basal-launch/tests/windows_launch.rs @@ -0,0 +1,311 @@ +//! Real launches of a GUI-subsystem test child under the Windows confinement, +//! read back by the parent. +#![cfg(windows)] + +use basal_launch::{ + ConfinedProcess, Deviation, JobLimits, KILL_EXIT_CODE, LOW_INTEGRITY, LaunchOptions, NULL_SID, + TOKEN_PRIMARY, create_or_open_profile, grant_test_binary_directory, launch, +}; +use std::io::{BufRead, BufReader, Read}; +use std::os::windows::io::AsRawHandle; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; +use windows_sys::Win32::System::Threading::{GetProcessMitigationPolicy, ProcessDynamicCodePolicy}; + +/// The job commit limit for these launches. Production passes the limit of +/// the worker's profile from `basal_proto::limits`; the test child needs far +/// less than this. +const COMMIT_BYTES: u64 = 512 * 1024 * 1024; + +/// The test child, copied into a directory of its own so that the package +/// grant changes no ACL but that directory's. +fn placed_child() -> &'static Path { + static PLACED: OnceLock = OnceLock::new(); + PLACED.get_or_init(|| { + let built = Path::new(env!("CARGO_BIN_EXE_basal-launch-test-helper")); + let directory = built + .parent() + .expect("the test child has a directory") + .join("basal-launch-placed"); + std::fs::create_dir_all(&directory).expect("create the placement directory"); + let placed = directory.join("basal-launch-test-helper.exe"); + std::fs::copy(built, &placed).expect("place the test child"); + let package = create_or_open_profile().expect("worker profile"); + grant_test_binary_directory(&placed, &package).expect("grant the package read and execute"); + placed + }) +} + +fn start(deviation: Deviation, args: &[&str]) -> ConfinedProcess { + let mut options = LaunchOptions::new(placed_child(), COMMIT_BYTES).deviation(deviation); + for arg in args { + options = options.arg(arg); + } + launch(&options).unwrap_or_else(|error| panic!("launch {deviation}: {error}")) +} + +/// Reads `count` lines from the child's stdout. If the child ends first, +/// fails with its exit code and stderr. +fn lines(child: &mut ConfinedProcess, count: usize) -> Vec { + let mut stdout = BufReader::new(child.stdout.take().expect("stdout")); + let mut lines = Vec::new(); + for _ in 0..count { + let mut line = String::new(); + let read = stdout + .read_line(&mut line) + .expect("read the child's stdout"); + if read == 0 { + let code = child.wait().expect("wait for the child"); + let mut stderr = String::new(); + let _ = child + .stderr + .take() + .expect("stderr") + .read_to_string(&mut stderr); + panic!("the child ended with {code:#010x} after {lines:?}; stderr: {stderr}"); + } + lines.push(line.trim_end().to_owned()); + } + child.stdout = Some(stdout.into_inner()); + lines +} + +/// The child's dynamic-code policy word, read through the parent's handle. +fn dynamic_code_policy(child: &ConfinedProcess) -> u32 { + let mut policy = 0u32; + let ok = unsafe { + GetProcessMitigationPolicy( + child.as_raw_handle(), + ProcessDynamicCodePolicy, + (&mut policy as *mut u32).cast(), + 4, + ) + }; + assert_ne!( + ok, + 0, + "GetProcessMitigationPolicy: {}", + std::io::Error::last_os_error() + ); + policy +} + +fn kill_and_reap(child: &ConfinedProcess) { + child.kill().expect("kill"); + assert_eq!(child.wait().expect("wait"), KILL_EXIT_CODE); + assert_eq!(child.try_wait().expect("try_wait"), Some(KILL_EXIT_CODE)); +} + +/// The full confinement starts the GUI-subsystem child, which runs to its +/// first output with exactly the three pipes as its standard handles. The +/// parent then reads back the child's primary token and job through its own +/// handles, and kills it. +#[test] +fn full_confinement_starts_the_child_reads_back_its_token_and_job_and_kills_it() { + let mut child = start(Deviation::Full, &[]); + let ready = lines(&mut child, 1).remove(0); + let [stdin, stdout, stderr] = child.inherited_stdio(); + assert_eq!( + ready, + format!("ready stdin={stdin} stdout={stdout} stderr={stderr} reverted=true") + ); + assert_eq!( + child.try_wait().expect("try_wait"), + None, + "the child waits on stdin" + ); + + let token = child.primary_token().expect("read the primary token"); + let package = child.package_sid().as_str().to_owned(); + println!("full: pid {} package {package}", child.id()); + println!("full: primary token {token:?}"); + assert_eq!(token.token_type, TOKEN_PRIMARY); + assert_eq!(token.appcontainer.as_deref(), Some(package.as_str())); + assert!( + token.less_privileged, + "the WIN://NOALLAPPPKG claim is present" + ); + assert_eq!(token.capabilities, Vec::::new()); + assert_eq!(token.integrity, LOW_INTEGRITY); + assert_eq!(token.restricting_sids, [NULL_SID]); + assert_eq!(token.enabled_groups, Vec::::new()); + assert!(token.deny_only_groups > 0); + assert_eq!(token.privileges, 0); + + let limits = child.job_limits().expect("read the job limits"); + println!("full: job limits {limits:?}"); + assert_eq!(limits, JobLimits::confined(COMMIT_BYTES as usize)); + assert!(child.in_owned_job().expect("job membership")); + let dynamic_code = dynamic_code_policy(&child); + println!("full: dynamic-code policy {dynamic_code:#x}"); + assert_eq!(dynamic_code & 1, 1, "dynamic code is prohibited"); + + kill_and_reap(&child); + println!("full: killed, exit code {KILL_EXIT_CODE}"); +} + +/// The fully confined child cannot create a file in its own TEMP directory: +/// the directory exists and resolves, but grants the worker no write. +#[test] +fn full_confinement_denies_a_file_in_the_childs_temp_directory() { + let mut child = start(Deviation::Full, &["--try-temp-write"]); + let report = lines(&mut child, 2); + println!("temp: {report:?} in {}", child.temp_dir().display()); + assert!(child.temp_dir().is_dir(), "the TEMP directory exists"); + // Win32 5 is ERROR_ACCESS_DENIED. + assert_eq!(report[1], "temp-write denied 5"); + assert!(!child.temp_dir().join("basal-launch-probe").exists()); + kill_and_reap(&child); +} + +/// A launch's TEMP directory used to be named from the process id and a +/// counter, so a process killed before cleaning up left a directory a later +/// process with the same id collided with. Directories with every such name +/// this process could have used do not block a launch. +#[test] +fn leftover_directories_with_pid_based_names_do_not_block_a_launch() { + let leftovers: Vec = (0..64) + .map(|counter| { + std::env::temp_dir().join(format!("cortexkit_basal_{}_{counter}", std::process::id())) + }) + .collect(); + for leftover in &leftovers { + let _ = std::fs::create_dir(leftover); + assert!(leftover.is_dir()); + } + let mut child = start(Deviation::Full, &[]); + let ready = lines(&mut child, 1).remove(0); + assert!(ready.starts_with("ready "), "{ready}"); + assert!( + !leftovers + .iter() + .any(|leftover| child.temp_dir().file_name() == leftover.file_name()), + "the launch reused a leftover directory: {}", + child.temp_dir().display() + ); + kill_and_reap(&child); + drop(child); + for leftover in &leftovers { + let _ = std::fs::remove_dir(leftover); + } +} + +#[cfg(feature = "deviations")] +mod deviations { + use super::*; + use basal_launch::Refusal; + + #[test] + fn a_confined_child_is_created_under_the_shared_spawn_lock() { + let mut options = LaunchOptions::new(placed_child(), COMMIT_BYTES); + options.before_resume = Some(|_, _| { + assert!( + basal_launch::spawn_lock_held(), + "the child's pipe ends are still inheritable before resume" + ); + Ok(()) + }); + let child = launch(&options).expect("launch under the shared spawn lock"); + assert!(!basal_launch::spawn_lock_held()); + kill_and_reap(&child); + } + + fn assert_refused(deviation: Deviation, refusal: Refusal) { + assert_eq!(deviation.parent_refusal(), Some(refusal)); + let options = LaunchOptions::new(placed_child(), COMMIT_BYTES).deviation(deviation); + let error = launch(&options).expect_err("the parent must refuse before resume"); + println!("{deviation}: {error}"); + assert_eq!(error.refusal(), Some(refusal), "{error}"); + } + + #[test] + fn a_job_with_other_limits_is_refused_as_job_limits_mismatch() { + assert_refused(Deviation::JobLimitsMismatch, Refusal::JobLimitsMismatch); + } + + #[test] + fn a_child_outside_the_owned_job_is_refused_as_not_in_owned_job() { + assert_refused(Deviation::NotInOwnedJob, Refusal::NotInOwnedJob); + } + + #[test] + fn a_primary_with_privileges_is_refused_as_birth_token_mismatch() { + assert_refused(Deviation::BirthTokenMismatch, Refusal::BirthTokenMismatch); + } + + #[test] + fn a_missing_start_up_token_is_refused_as_initial_token_open() { + assert_refused(Deviation::InitialTokenOpen, Refusal::InitialTokenOpen); + } + + /// Each worker check's variant passes the parent's checks, because the + /// parent expects the broken property, and the child starts with that + /// property visibly broken. + #[test] + fn every_worker_check_variant_starts_with_its_property_broken() { + for deviation in Deviation::ALL { + if deviation.worker_reason().is_none() { + continue; + } + let mut child = start(deviation, &[]); + let ready = lines(&mut child, 1).remove(0); + assert!(ready.starts_with("ready "), "{deviation}: {ready}"); + let token = child.primary_token().expect("read the primary token"); + println!("{deviation}: started; primary token {token:?}"); + match deviation { + Deviation::NotLpac => assert!(!token.less_privileged), + Deviation::CapabilitiesPresent => assert_eq!(token.capabilities, ["S-1-15-3-1"]), + Deviation::RestrictingSidMismatch => { + let mut sids = token.restricting_sids.clone(); + sids.sort(); + assert_eq!(sids, [NULL_SID, "S-1-1-0"]); + } + Deviation::GroupNotDenyOnly => assert_eq!(token.enabled_groups.len(), 1), + Deviation::PrivilegesPresent => assert!(token.privileges > 0), + Deviation::MitigationMismatch => assert_eq!(dynamic_code_policy(&child) & 1, 0), + _ => { + assert!(token.less_privileged); + assert_eq!(token.restricting_sids, [NULL_SID]); + } + } + if deviation != Deviation::NotLpac { + assert!(token.less_privileged, "{deviation}"); + } + kill_and_reap(&child); + } + } + + /// The positive controls start, with the weaker tokens they are meant + /// to have. The plain child can create a file in the TEMP directory, + /// which shows the path the confined child is denied is a real, writable + /// one. The LPAC-only child is denied too. + #[test] + fn the_positive_controls_start_with_their_weaker_tokens() { + let mut child = start(Deviation::LpacOnly, &["--try-temp-write"]); + let report = lines(&mut child, 2); + println!("lpac-only: {report:?}"); + assert!(report[1].starts_with("temp-write denied "), "{report:?}"); + let token = child.primary_token().expect("read the primary token"); + println!("lpac-only: primary token {token:?}"); + assert_eq!( + token.appcontainer.as_deref(), + Some(child.package_sid().as_str()) + ); + assert!(token.less_privileged); + assert_eq!(token.capabilities, Vec::::new()); + assert_eq!(token.restricting_sids, Vec::::new()); + assert!(!token.enabled_groups.is_empty()); + kill_and_reap(&child); + + let mut child = start(Deviation::Plain, &["--try-temp-write"]); + let report = lines(&mut child, 2); + println!("plain: {report:?}"); + assert_eq!(report[1], "temp-write created"); + assert!(child.temp_dir().join("basal-launch-probe").is_file()); + let token = child.primary_token().expect("read the primary token"); + println!("plain: primary token {token:?}"); + assert_eq!(token.appcontainer, None); + assert!(!token.less_privileged); + kill_and_reap(&child); + } +} diff --git a/crates/basal-module/Cargo.toml b/crates/basal-module/Cargo.toml index 5186dd2..c036ef6 100644 --- a/crates/basal-module/Cargo.toml +++ b/crates/basal-module/Cargo.toml @@ -47,11 +47,21 @@ rusqlite.workspace = true serde.workspace = true serde_json.workspace = true subc-client-rs.workspace = true -subc-os.workspace = true subc-protocol.workspace = true tokio.workspace = true tracing.workspace = true +# Only the macOS worker launch uses it: the module re-executes itself so +# the worker is its own "responsible process" and holds none of the +# module's macOS privacy grants (see src/process.rs). +[target.'cfg(target_os = "macos")'.dependencies] +subc-os.workspace = true + +# Windows workers are started and owned by the launcher. +[target.'cfg(windows)'.dependencies] +basal-launch.workspace = true +windows-sys = { version = "0.61", features = ["Win32_System_Threading"] } + [build-dependencies] sha2.workspace = true @@ -62,3 +72,15 @@ cortexkit-role-harness = "=0.1.0" subc-client-rs = { workspace = true, features = ["test-support"] } sha2.workspace = true subc-transport.workspace = true + +# tests/store_dacl.rs reads the store directory's and database's security +# descriptors back to check they are owner-only. +[target.'cfg(windows)'.dev-dependencies] +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Security_Authorization", + "Win32_Storage_FileSystem", + "Win32_System_SystemServices", + "Win32_System_Threading", +] } diff --git a/crates/basal-module/src/acceptance_tests.rs b/crates/basal-module/src/acceptance_tests.rs index f002238..398d7f7 100644 --- a/crates/basal-module/src/acceptance_tests.rs +++ b/crates/basal-module/src/acceptance_tests.rs @@ -37,7 +37,7 @@ fn health_reports_the_explicit_policy_only_on_linux() { serde_json::json!({ "os": std::env::consts::OS, "landlock": if cfg!(target_os = "linux") { serde_json::json!(mode.as_str()) } else { serde_json::Value::Null }, - "sigsys_deaths": 0, + CONFINEMENT_FAULT_DEATHS: 0, }) ); pool.stop(); diff --git a/crates/basal-module/src/engine_tests.rs b/crates/basal-module/src/engine_tests.rs index 1f7e603..26108b8 100644 --- a/crates/basal-module/src/engine_tests.rs +++ b/crates/basal-module/src/engine_tests.rs @@ -609,10 +609,71 @@ fn a_pool_spawn_retry_wakes_before_the_fallback() { fn cleanup(engine: Engine, path: std::path::PathBuf) { engine.inner.pool.stop(); + // An activation releases its slot before its thread drops the runtime and + // scheduler store handles. Empty slots are not enough to permit deletion. + engine.join_threads(); + engine.inner.rt.quiesce(); drop(engine); std::fs::remove_dir_all(path.parent().unwrap()).unwrap(); } +#[test] +fn engine_cleanup_joins_activation_before_removing_its_store() { + use std::sync::mpsc; + + let (engine, path) = engine(false); + install_flow(&engine.inner.rt, "cleanup"); + engine + .inner + .rt + .admit_trigger("cleanup", "event", basal_proto::JsonText::null()) + .unwrap(); + + let (release, released) = mpsc::channel(); + let released = Mutex::new(released); + let (parked_tx, parked) = mpsc::channel(); + *engine.inner.activation_exit.lock().unwrap() = Some(Arc::new(move || { + parked_tx.send(()).unwrap(); + released.lock().unwrap().recv().unwrap(); + })); + engine.pass().unwrap(); + parked.recv_timeout(Duration::from_secs(60)).unwrap(); + engine.wait_quiet(); + assert_eq!(engine.active_count(), 0); + + #[derive(Debug)] + enum Event { + Joining, + CleanupFinished, + } + let (event_tx, events) = mpsc::channel(); + let joining_tx = event_tx.clone(); + *engine.inner.joining.lock().unwrap() = Some(Arc::new(move || { + joining_tx.send(Event::Joining).unwrap(); + })); + let retained = Arc::downgrade(&engine.inner); + let directory = path.parent().unwrap().to_path_buf(); + let cleaning = thread::spawn(move || { + let result = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| cleanup(engine, path))); + event_tx.send(Event::CleanupFinished).unwrap(); + result + }); + let first = events.recv_timeout(Duration::from_secs(60)); + let directory_present_before_release = directory.exists(); + let store_owner_present_before_release = retained.upgrade().is_some(); + // Release the activation even when cleanup failed, so a failed ordering + // assertion cannot strand the thread that still owns the store. + release.send(()).unwrap(); + let cleaned = cleaning.join().unwrap(); + assert!(matches!(first, Ok(Event::Joining)), "{first:?}"); + assert!(directory_present_before_release); + assert!(store_owner_present_before_release); + assert!(cleaned.is_ok(), "store removal failed after joining"); + assert!(retained.upgrade().is_none()); + assert!(!directory.exists()); +} + #[test] fn panicking_activation_releases_run_and_flow_slots() { let (engine, path) = engine(true); @@ -740,6 +801,8 @@ fn module_drop_joins_activation_after_slot_release() { ); // The consent adapter is deliberately still alive and attached here. drop(consent); + // On Windows an open database blocks deleting its directory. + drop(reopened); std::fs::remove_dir_all(path.parent().unwrap()).unwrap(); } diff --git a/crates/basal-module/src/fatal.rs b/crates/basal-module/src/fatal.rs index 7d18c99..9170608 100644 --- a/crates/basal-module/src/fatal.rs +++ b/crates/basal-module/src/fatal.rs @@ -20,6 +20,25 @@ pub const EXIT_STORE_FAILURE: i32 = 75; /// The supervisor must report this refusal and restart, never launch plainly. pub const EXIT_PRIVACY_IDENTITY_FAILURE: u8 = 76; +/// Ends this process at once, as an outside kill would: no destructors, no +/// flushing, no exit handlers. The test hooks that simulate a module crash +/// use it. On Unix it is SIGKILL to the process itself; on Windows, +/// `TerminateProcess` on the current process. +pub fn kill_self() -> ! { + #[cfg(unix)] + // SAFETY: kill(2) on our own pid has no memory-safety preconditions. + unsafe { + libc::kill(libc::getpid(), libc::SIGKILL); + } + #[cfg(windows)] + crate::windows::terminate_self(); + // The kill can return before it takes the process down; the calling + // thread must not go on to its next step meanwhile. + loop { + std::thread::park(); + } +} + /// Whether a core error is a failure of the store itself, as opposed to a /// refusal or a request the core found invalid. pub fn is_storage(e: &CoreError) -> bool { diff --git a/crates/basal-module/src/harness.rs b/crates/basal-module/src/harness.rs index cb2a813..6e3b4e9 100644 --- a/crates/basal-module/src/harness.rs +++ b/crates/basal-module/src/harness.rs @@ -161,16 +161,7 @@ impl Hooks for HarnessHooks { } if self.kill_self_at.as_deref() == Some(point.as_str()) { eprintln!("ck-basal-harness: killing itself at {point}"); - // SAFETY: kill(2) on our own pid has no memory-safety - // preconditions. - unsafe { - libc::kill(libc::getpid(), libc::SIGKILL); - } - // kill(2) can return before the signal takes the process down; - // this thread must not go on to the next commit meanwhile. - loop { - std::thread::park(); - } + crate::fatal::kill_self(); } if self.cut_store_at.as_deref() == Some(point.as_str()) { eprintln!("ck-basal-harness: cutting the store at {point}"); diff --git a/crates/basal-module/src/lib.rs b/crates/basal-module/src/lib.rs index c209136..d416004 100644 --- a/crates/basal-module/src/lib.rs +++ b/crates/basal-module/src/lib.rs @@ -16,6 +16,8 @@ //! - [`unconfigured`]: the hosts the production binary runs with until real //! adapters exist; [`harness`]: the same module against the mocks, driven //! over stdio for tests. +//! - `windows` (Windows only): the worker started through `basal-launch`, +//! its kill, and the exit codes that are confinement faults. //! - `rig_kill` (unit tests or the `rig-kill-hook` feature): the one-shot kill //! switch the ckdev-flows rig's contract suite uses for its crash case. @@ -41,3 +43,5 @@ pub mod scope_connection; pub mod serve; pub mod tool; pub mod unconfigured; +#[cfg(windows)] +mod windows; diff --git a/crates/basal-module/src/metrics.rs b/crates/basal-module/src/metrics.rs index d6b3c91..54fdb06 100644 --- a/crates/basal-module/src/metrics.rs +++ b/crates/basal-module/src/metrics.rs @@ -17,7 +17,9 @@ pub struct Metrics { pub workers_killed: AtomicU64, /// Workers found dead that nobody killed. pub workers_crashed: AtomicU64, - /// Deaths with exit signal SIGSYS, counted apart from other worker crashes. + /// Deaths from a confinement fault, counted apart from other worker + /// crashes: exit signal SIGSYS on Unix, a fatal access-violation or + /// invalid-handle status on Windows (see `pool::is_confinement_fault`). pub sigsys_deaths: AtomicU64, /// Workers ended on purpose after their idle period or activation count. pub workers_retired: AtomicU64, diff --git a/crates/basal-module/src/pool.rs b/crates/basal-module/src/pool.rs index 6fd03d0..b948880 100644 --- a/crates/basal-module/src/pool.rs +++ b/crates/basal-module/src/pool.rs @@ -112,15 +112,38 @@ impl PoolConfig { // The file name, not the signing identifier, distinguishes development copies // from the placed fleet. Deriving both names keeps their lookup layouts alike. +// A trailing `.exe` (any case) is kept at the end, so `ck-basal.exe` finds +// `ck-basal-worker.exe`. fn sibling_worker(exe: &Path) -> std::io::Result { - let mut name = exe + let file_name = exe .file_name() - .ok_or_else(|| std::io::Error::other("the executable has no file name"))? - .to_os_string(); + .ok_or_else(|| std::io::Error::other("the executable has no file name"))?; + let (stem, exe_suffix) = split_exe_suffix(file_name); + let mut name = stem.to_os_string(); name.push("-worker"); + if exe_suffix { + name.push(".exe"); + } Ok(exe.with_file_name(name)) } +/// Splits a trailing `.exe`, compared case-insensitively, off a file name. +fn split_exe_suffix(name: &std::ffi::OsStr) -> (&std::ffi::OsStr, bool) { + let bytes = name.as_encoded_bytes(); + let Some(split) = bytes.len().checked_sub(4) else { + return (name, false); + }; + if !bytes[split..].eq_ignore_ascii_case(b".exe") { + return (name, false); + } + // SAFETY: the split falls immediately before an ASCII `.`, which the + // standard library documents as a valid boundary of the encoding. + ( + unsafe { std::ffi::OsStr::from_encoded_bytes_unchecked(&bytes[..split]) }, + true, + ) +} + #[cfg(test)] mod names { use super::*; @@ -135,6 +158,23 @@ mod names { sibling_worker(Path::new("/scratch/ckdev-basal")).unwrap(), Path::new("/scratch/ckdev-basal-worker") ); + assert_eq!( + sibling_worker(Path::new("/placed/ck-basal.exe")).unwrap(), + Path::new("/placed/ck-basal-worker.exe") + ); + assert_eq!( + sibling_worker(Path::new("/scratch/ckdev-basal.EXE")).unwrap(), + Path::new("/scratch/ckdev-basal-worker.exe") + ); + // Only a whole trailing `.exe` is an extension to keep at the end. + assert_eq!( + sibling_worker(Path::new("/placed/ck-basal.exe.old")).unwrap(), + Path::new("/placed/ck-basal.exe.old-worker") + ); + assert_eq!( + sibling_worker(Path::new("/placed/exe")).unwrap(), + Path::new("/placed/exe-worker") + ); } } @@ -219,7 +259,23 @@ fn accepts_welcome(welcome: &Welcome, landlock: LandlockPolicy) -> bool { no_thread_token, mitigations, handle_table, - } => cfg!(windows) && lpac && untrusted && no_thread_token && mitigations && handle_table, + } => { + #[cfg(windows)] + { + crate::windows::accepts_report( + lpac, + untrusted, + no_thread_token, + mitigations, + handle_table, + ) + } + #[cfg(not(windows))] + { + let _ = (lpac, untrusted, no_thread_token, mitigations, handle_table); + false + } + } Confinement::None => false, } } @@ -647,17 +703,25 @@ impl Pool { } } - /// The operator's confinement policy and deaths attributed to SIGSYS. + /// The operator's confinement policy and deaths attributed to a + /// confinement fault, published under the name of this OS's fault: + /// `sigsys_deaths` on Unix, `fatal_status_deaths` on Windows. pub fn worker_confinement(&self) -> serde_json::Value { - serde_json::json!({ + let mut confinement = serde_json::json!({ "os": std::env::consts::OS, "landlock": if cfg!(target_os = "linux") { Some(self.shared.config.landlock.as_str()) } else { None }, - "sigsys_deaths": self.shared.metrics.sigsys_deaths.load(Ordering::Relaxed), - }) + }); + confinement[CONFINEMENT_FAULT_DEATHS] = self + .shared + .metrics + .sigsys_deaths + .load(Ordering::Relaxed) + .into(); + confinement } /// Stops handing out workers and kills every idle one. @@ -689,9 +753,9 @@ impl Pool { state.busy.remove(&lease.id); let activations = lease.activations.saturating_add(1); let status = process.exit_status(); - // A channel failure can make drive request a kill after SIGSYS has - // already ended the worker. Retain that death as a confinement crash. - let crash_status = status.filter(|status| !lease.killed || is_sigsys(status)); + // A channel failure can make drive request a kill after a confinement + // fault has already ended the worker. Retain that death as a confinement crash. + let crash_status = status.filter(|status| !lease.killed || is_confinement_fault(status)); let crashed = crash_status.is_some(); let keep = match (&lease.binding, lease.killed || crashed) { (_, true) => None, @@ -770,7 +834,7 @@ impl Shared { /// Accounts for a worker found dead that nobody killed. fn crashed(&self, state: &mut State, status: std::io::Result) { Metrics::bump(&self.metrics.workers_crashed); - if is_sigsys(&status) { + if is_confinement_fault(&status) { Metrics::bump(&self.metrics.sigsys_deaths); } self.lost(state, true); @@ -862,12 +926,26 @@ impl Shared { } } -fn is_sigsys(status: &std::io::Result) -> bool { +/// The health key the confinement-fault count is published under. +pub const CONFINEMENT_FAULT_DEATHS: &str = if cfg!(windows) { + "fatal_status_deaths" +} else { + "sigsys_deaths" +}; + +/// Whether a worker died of its confinement: SIGSYS on Unix (seccomp or the +/// macOS sandbox), a fatal access-violation or invalid-handle status on +/// Windows. A worker's own startup refusal (exit 70) and a kill are not. +fn is_confinement_fault(status: &std::io::Result) -> bool { #[cfg(unix)] { matches!(status, Ok(status) if status.signal() == Some(libc::SIGSYS)) } - #[cfg(not(unix))] + #[cfg(windows)] + { + matches!(status, Ok(status) if status.code().is_some_and(|code| crate::windows::is_fault_exit(code as u32))) + } + #[cfg(not(any(unix, windows)))] { let _ = status; false diff --git a/crates/basal-module/src/pool_tests.rs b/crates/basal-module/src/pool_tests.rs index e0ffe23..cc44cee 100644 --- a/crates/basal-module/src/pool_tests.rs +++ b/crates/basal-module/src/pool_tests.rs @@ -78,3 +78,35 @@ fn spawn_failure_notifies_the_engine_and_arms_the_pool_retry() { "an unused expired retry cannot spin the loop" ); } + +/// On Windows a fatal invalid-handle or access-violation status is a +/// confinement fault and is published as `fatal_status_deaths`; the +/// worker's own startup refusal (exit 70) and a kill are other crashes. +#[cfg(windows)] +#[test] +fn a_fatal_status_exit_counts_as_a_confinement_fault_and_exit_70_does_not() { + use std::os::windows::process::ExitStatusExt; + let pool = pool(); + let deaths = |pool: &Pool| pool.worker_confinement()["fatal_status_deaths"].clone(); + assert_eq!(deaths(&pool), 0); + let mut state = pool.lock(); + state.live = 4; + pool.shared + .crashed(&mut state, Ok(ExitStatus::from_raw(0xc000_0008))); + drop(state); + assert_eq!(deaths(&pool), 1); + let mut state = pool.lock(); + for code in [70, basal_launch::KILL_EXIT_CODE, 0] { + pool.shared + .crashed(&mut state, Ok(ExitStatus::from_raw(code))); + } + pool.shared + .crashed(&mut state, Ok(ExitStatus::from_raw(0xc000_0005))); + drop(state); + assert_eq!(deaths(&pool), 2); + assert!(pool.worker_confinement().get("sigsys_deaths").is_none()); + assert_eq!( + pool.shared.metrics.workers_crashed.load(Ordering::Relaxed), + 5 + ); +} diff --git a/crates/basal-module/src/process.rs b/crates/basal-module/src/process.rs index 561a594..5c7dbbd 100644 --- a/crates/basal-module/src/process.rs +++ b/crates/basal-module/src/process.rs @@ -5,6 +5,11 @@ //! Rust's descriptors are close-on-exec; the worker also closes every inherited //! descriptor above stdio and confines itself before reading its first frame. //! +//! On Windows the worker is started by `basal-launch` under the confinement +//! the parent has to fix at creation (restricted token, AppContainer, job, +//! mitigations), and the process is the launcher's owned wrapper rather than +//! a `std::process::Child`; see `crate::windows`. +//! //! **Disclaimed launch.** On macOS, a child normally shares its parent's //! "responsible process", the identity privacy (TCC) grants such as Files & //! Folders are checked against. So a worker launched plainly could use any @@ -21,7 +26,9 @@ use std::io::{Read, Write}; use std::path::Path; #[cfg(target_os = "macos")] use std::path::PathBuf; -use std::process::{Child, ChildStdin, Command, ExitStatus, Stdio}; +use std::process::ExitStatus; +#[cfg(unix)] +use std::process::{Child, ChildStdin, Command, Stdio}; use std::sync::mpsc::{self, Receiver, RecvTimeoutError}; use std::sync::{Arc, Mutex}; use std::thread; @@ -35,6 +42,14 @@ use basal_proto::{ #[cfg(target_os = "macos")] use subc_os::privacy_identity::DisclaimedCommand; +/// The owned worker process: a `std::process::Child` on Unix, the +/// launcher's wrapper (process, job and pipes) on Windows. +#[cfg(windows)] +type Child = basal_launch::ConfinedProcess; +/// The parent's end of the worker's stdin. +#[cfg(windows)] +type ChildStdin = std::fs::File; + /// Linux workers require Landlock unless the caller explicitly permits a /// seccomp-only worker on kernels where Landlock is unavailable. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] @@ -185,68 +200,21 @@ impl WorkerProcess { landlock: LandlockPolicy, codemode: bool, ) -> Result { - #[cfg(target_os = "macos")] - let (mut command, confirmation) = match launch { - WorkerLaunch::Disclaimed { trampoline } => { - let mut builder = DisclaimedCommand::new(trampoline, binary); - builder - .env_clear() - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - let (command, confirmation) = builder - .into_command() - .map_err(|e| SpawnError::Disclaim(e.to_string()))?; - (command, Some(confirmation)) - } - WorkerLaunch::Plain => { - let mut command = Command::new(binary); - command - .env_clear() - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - (command, None) - } - }; - #[cfg(not(target_os = "macos"))] - let mut command = match launch { - WorkerLaunch::Plain => { - let mut command = Command::new(binary); - command - .env_clear() - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - command - } - }; - #[cfg(target_os = "linux")] - command.arg(format!("--landlock={}", landlock.as_str())); - #[cfg(not(target_os = "linux"))] - let _ = landlock; - codemode_address_space(&mut command, codemode); let deadline = Instant::now() + timeout; - let mut child = command - .spawn() - .map_err(|e| SpawnError::Exec(format!("{}: {e}", binary.display())))?; - // The command holds this process's copy of the trampoline's confirmation - // pipe. The confirmation reads that pipe to end-of-file, which never comes - // while a copy stays open here, so drop it before confirming. - drop(command); - #[cfg(target_os = "macos")] - if let Some(confirmation) = confirmation - && let Err(error) = confirmation.confirm(deadline) - { - let _ = child.kill(); - let _ = child.wait(); - return Err(SpawnError::Disclaim(error.to_string())); - } + #[cfg(unix)] + let mut child = spawn_unix(binary, launch, landlock, codemode, deadline)?; + #[cfg(windows)] + let mut child = { + // Disclaimed launch is macOS-only; the Linux Landlock argument + // has no Windows counterpart. + let WorkerLaunch::Plain = launch; + let _ = landlock; + crate::windows::start(binary, codemode)? + }; let stdin = child.stdin.take(); let (Some(mut stdout), Some(mut stderr)) = (child.stdout.take(), child.stderr.take()) else { - let _ = child.kill(); - let _ = child.wait(); + kill_and_reap(&mut child); return Err(SpawnError::Exec( "the worker's pipes were not created".into(), )); @@ -397,19 +365,111 @@ impl WorkerProcess { /// confinement violation from other deaths. An observation error also /// retires the worker: its liveness can no longer be established. pub fn exit_status(&mut self) -> Option> { - self.child - .lock() - .unwrap_or_else(|p| p.into_inner()) - .try_wait() - .transpose() + try_wait(&mut self.child.lock().unwrap_or_else(|p| p.into_inner())).transpose() } pub fn kill(&mut self) { self.stdin = None; let mut child = self.child.lock().unwrap_or_else(|p| p.into_inner()); + kill_and_reap(&mut child); + } +} + +/// The exit status, if the process has ended. Never blocks. +#[cfg(unix)] +fn try_wait(child: &mut Child) -> std::io::Result> { + child.try_wait() +} + +#[cfg(windows)] +fn try_wait(child: &mut Child) -> std::io::Result> { + crate::windows::try_wait(child) +} + +/// Kills the process and waits for it to end. Errors are ignored: a process +/// that has already ended cannot be killed again, and is reaped either way. +#[cfg(unix)] +fn kill_and_reap(child: &mut Child) { + let _ = child.kill(); + let _ = child.wait(); +} + +#[cfg(windows)] +fn kill_and_reap(child: &mut Child) { + crate::windows::kill_and_reap(child); +} + +/// Starts the worker with `Command`: through the disclaiming trampoline on +/// macOS when asked, directly otherwise. A failed disclaim confirmation +/// kills the child rather than falling back to a plain launch. +#[cfg(unix)] +fn spawn_unix( + binary: &Path, + launch: &WorkerLaunch, + landlock: LandlockPolicy, + codemode: bool, + deadline: Instant, +) -> Result { + #[cfg(target_os = "macos")] + let (mut command, confirmation) = match launch { + WorkerLaunch::Disclaimed { trampoline } => { + let mut builder = DisclaimedCommand::new(trampoline, binary); + builder + .env_clear() + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let (command, confirmation) = builder + .into_command() + .map_err(|e| SpawnError::Disclaim(e.to_string()))?; + (command, Some(confirmation)) + } + WorkerLaunch::Plain => { + let mut command = Command::new(binary); + command + .env_clear() + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + (command, None) + } + }; + #[cfg(not(target_os = "macos"))] + let mut command = match launch { + WorkerLaunch::Plain => { + let mut command = Command::new(binary); + command + .env_clear() + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + command + } + }; + #[cfg(target_os = "linux")] + command.arg(format!("--landlock={}", landlock.as_str())); + #[cfg(not(target_os = "linux"))] + let _ = landlock; + codemode_address_space(&mut command, codemode); + let child = command + .spawn() + .map_err(|e| SpawnError::Exec(format!("{}: {e}", binary.display())))?; + // The command holds this process's copy of the trampoline's confirmation + // pipe. The confirmation reads that pipe to end-of-file, which never comes + // while a copy stays open here, so drop it before confirming. + drop(command); + #[cfg(target_os = "macos")] + if let Some(confirmation) = confirmation + && let Err(error) = confirmation.confirm(deadline) + { + let mut child = child; let _ = child.kill(); let _ = child.wait(); + return Err(SpawnError::Disclaim(error.to_string())); } + #[cfg(not(target_os = "macos"))] + let _ = deadline; + Ok(child) } struct SharedIncoming(Arc>>); @@ -430,6 +490,7 @@ impl WorkerReceiver for Ended { } } +#[cfg(unix)] fn codemode_address_space(command: &mut Command, codemode: bool) { #[cfg(target_os = "linux")] if codemode { @@ -477,6 +538,7 @@ mod codemode_limits { pub(crate) struct WorkerKiller(Arc>); impl WorkerKiller { + #[cfg(unix)] fn signal_with(&self, signal: impl FnOnce(u32) -> bool) -> bool { let mut child = self.0.lock().unwrap_or_else(|p| p.into_inner()); // A cached exit status means the pid may already belong to somebody @@ -487,6 +549,7 @@ impl WorkerKiller { signal(child.id()) } + #[cfg(unix)] pub(crate) fn kill(&self) -> bool { self.signal_with(|pid| { let Ok(pid) = libc::pid_t::try_from(pid) else { @@ -496,6 +559,14 @@ impl WorkerKiller { unsafe { libc::kill(pid, libc::SIGKILL) == 0 } }) } + + /// Kills the worker's job and process through the handles the wrapper + /// owns. A handle names one process for as long as it is open, so no + /// recycled id can be hit. + #[cfg(windows)] + pub(crate) fn kill(&self) -> bool { + crate::windows::kill_if_running(&self.0.lock().unwrap_or_else(|p| p.into_inner())) + } } /// Kills a child that never became a [`WorkerProcess`]. @@ -504,8 +575,7 @@ struct KillOnDrop(Option); impl Drop for KillOnDrop { fn drop(&mut self) { if let Some(child) = self.0.as_mut() { - let _ = child.kill(); - let _ = child.wait(); + kill_and_reap(child); } } } @@ -550,6 +620,7 @@ mod buffer_tests { assert!(tx.send(Err(ChannelError::Closed)).is_err()); } + #[cfg(unix)] #[test] fn a_reaped_worker_handle_never_signals_a_recycled_pid() { let mut child = Command::new("/usr/bin/true").spawn().unwrap(); diff --git a/crates/basal-module/src/rig_kill.rs b/crates/basal-module/src/rig_kill.rs index 3b79842..36662d3 100644 --- a/crates/basal-module/src/rig_kill.rs +++ b/crates/basal-module/src/rig_kill.rs @@ -193,15 +193,7 @@ impl Hooks for RigKillHook { let flow = &armed.flow_id; tracing::warn!("rig kill hook: killing ck-basal in run {run_id} of {flow} at {point}"); eprintln!("ck-basal: rig kill hook: killing itself in run {run_id} of {flow} at {point}"); - // SAFETY: kill(2) on our own pid has no memory-safety preconditions. - unsafe { - libc::kill(libc::getpid(), libc::SIGKILL); - } - // kill(2) can return before the signal takes the process down; this - // thread must not go on to commit the answer meanwhile. - loop { - std::thread::park(); - } + crate::fatal::kill_self(); } } diff --git a/crates/basal-module/src/windows.rs b/crates/basal-module/src/windows.rs new file mode 100644 index 0000000..060111c --- /dev/null +++ b/crates/basal-module/src/windows.rs @@ -0,0 +1,101 @@ +//! The module's Windows-only parts: starting the worker through the +//! launcher, killing and reaping it through the launcher's owned wrapper, +//! and which exit codes mean the worker hit its confinement. +//! +//! On macOS and Linux the worker confines itself after `exec`, and a +//! confinement violation kills it with SIGSYS. On Windows most of the +//! confinement is fixed by `basal-launch` when the process is created, and a +//! violation the worker cannot survive ends it with an NTSTATUS exit code. + +use std::io; +use std::os::windows::process::ExitStatusExt; +use std::path::Path; +use std::process::ExitStatus; + +use basal_launch::{ConfinedProcess, LaunchOptions, launch}; +use basal_proto::{CODEMODE_JOB_COMMIT_BYTES, FLOW_JOB_COMMIT_BYTES}; + +use crate::process::SpawnError; + +/// A Windows worker must report every startup check, not just its token kind. +pub(crate) fn accepts_report( + lpac: bool, + untrusted: bool, + no_thread_token: bool, + mitigations: bool, + handle_table: bool, +) -> bool { + lpac && untrusted && no_thread_token && mitigations && handle_table +} + +/// `STATUS_ACCESS_VIOLATION`: the worker touched memory it may not, for +/// example code the dynamic-code policy refused to make executable. +pub const STATUS_ACCESS_VIOLATION: u32 = 0xC000_0005; + +/// `STATUS_INVALID_HANDLE`: strict handle checks end the worker when it uses +/// a handle it does not hold, such as one its startup checks closed. +pub const STATUS_INVALID_HANDLE: u32 = 0xC000_0008; + +/// Whether a worker's exit code is a confinement fault. The worker's own +/// refusal (exit 70) and the launcher's kill code are not: neither is the +/// confinement stopping the worker in the act. +pub fn is_fault_exit(code: u32) -> bool { + matches!(code, STATUS_ACCESS_VIOLATION | STATUS_INVALID_HANDLE) +} + +/// Starts the worker under the full confinement, with the job commit limit +/// of its profile. +pub(crate) fn start(binary: &Path, codemode: bool) -> Result { + let commit_bytes = if codemode { + CODEMODE_JOB_COMMIT_BYTES + } else { + FLOW_JOB_COMMIT_BYTES + }; + launch(&LaunchOptions::new(binary, commit_bytes)) + .map_err(|error| SpawnError::Exec(format!("{}: {error}", binary.display()))) +} + +/// The exit status, if the worker has ended. Never blocks. +pub(crate) fn try_wait(child: &ConfinedProcess) -> io::Result> { + Ok(child.try_wait()?.map(ExitStatus::from_raw)) +} + +/// Kills the worker's job and process and waits for the process to end. +/// Errors are ignored: an ended worker is reaped either way. +pub(crate) fn kill_and_reap(child: &ConfinedProcess) { + let _ = child.kill(); + let _ = child.wait(); +} + +/// Kills a worker that is still running; returns whether a kill was issued. +/// One that has already ended, or whose state cannot be read, is left alone. +pub(crate) fn kill_if_running(child: &ConfinedProcess) -> bool { + if !matches!(child.try_wait(), Ok(None)) { + return false; + } + child.kill().is_ok() +} + +/// Ends this process at once with the launcher's kill code, as an outside +/// kill would (see `crate::fatal::kill_self`). +pub(crate) fn terminate_self() { + use windows_sys::Win32::System::Threading::{GetCurrentProcess, TerminateProcess}; + // SAFETY: the pseudo-handle of the current process is always valid. + unsafe { + TerminateProcess(GetCurrentProcess(), basal_launch::KILL_EXIT_CODE); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fault_statuses_count_and_the_refusal_and_kill_codes_do_not() { + assert!(is_fault_exit(0xc000_0008)); + assert!(is_fault_exit(0xc000_0005)); + assert!(!is_fault_exit(70)); + assert!(!is_fault_exit(basal_launch::KILL_EXIT_CODE)); + assert!(!is_fault_exit(0)); + } +} diff --git a/crates/basal-module/tests/codemode.rs b/crates/basal-module/tests/codemode.rs index e0f0228..380a0ad 100644 --- a/crates/basal-module/tests/codemode.rs +++ b/crates/basal-module/tests/codemode.rs @@ -150,6 +150,9 @@ struct Rig { impl Drop for Rig { fn drop(&mut self) { self.core.release(); + // This caller-owned store clone is only for assertions during the run; + // it must not outlive the fixture's module shutdown and cleanup. + self.core.store.lock().unwrap().take(); } } fn rig(tag: &str, missing_worker: bool) -> Rig { @@ -515,7 +518,7 @@ fn codemode_engine_budgets_still_apply_end_to_end() { ), ("while (true) {}", "budget_exhausted:js_cpu"), ] { - let rig = rig(status, false); + let rig = rig("codemode-budgets", false); let result = call(&rig, "budget", code, Duration::from_secs(180)); assert_eq!(result["status"], status, "{result}"); } diff --git a/crates/basal-module/tests/common/mod.rs b/crates/basal-module/tests/common/mod.rs index c63a31c..7e78722 100644 --- a/crates/basal-module/tests/common/mod.rs +++ b/crates/basal-module/tests/common/mod.rs @@ -31,12 +31,34 @@ pub struct Fixture { pub catalog: MockCatalog, pub clock: Clock, pub dir: PathBuf, + // Fields drop in declaration order, so Module shutdown must precede this + // guard's directory removal. + _cleanup: DirectoryCleanup, +} + +struct DirectoryCleanup { + dir: PathBuf, + store: std::sync::Weak, +} + +impl Drop for DirectoryCleanup { + fn drop(&mut self) { + if std::thread::panicking() { + // Do not turn the test's original failure into a double-panic abort. + let _ = std::fs::remove_dir_all(&self.dir); + return; + } + assert!( + self.store.upgrade().is_none(), + "fixture cleanup still has an open store owner" + ); + std::fs::remove_dir_all(&self.dir).expect("remove fixture after module shutdown"); + } } impl Drop for Fixture { fn drop(&mut self) { self.module.pool.stop(); - let _ = std::fs::remove_dir_all(&self.dir); } } @@ -55,6 +77,7 @@ pub struct Options { pub selector: Arc, pub warm_spares: usize, pub max_concurrent: usize, + pub engine_pass_interval: Duration, pub spawner: Option>, pub hooks: Arc, pub default_deadline: Duration, @@ -76,6 +99,7 @@ impl Default for Options { selector: Arc::new(basal_host::selector::FakeSelector::default()), warm_spares: 0, max_concurrent: 4, + engine_pass_interval: EngineConfig::default().pass_interval, spawner: None, hooks: Arc::new(NoHooks), default_deadline: Duration::from_secs(600), @@ -151,10 +175,11 @@ pub fn fixture_with_store( pool, engine: EngineConfig { max_concurrent_activations: o.max_concurrent, - ..EngineConfig::default() + pass_interval: o.engine_pass_interval, }, dry_run: DryRunConfig::new(dir.join("dry-run")), }; + let mut store = None; let module = Module::start_with_store( config, o.hosts.unwrap_or_else(|| Hosts { @@ -165,7 +190,10 @@ pub fn fixture_with_store( hooks: o.hooks.clone(), }), spawner, - initialize, + |opened| { + store = Some(Arc::downgrade(&opened)); + initialize(opened) + }, ) .expect("module starts"); Fixture { @@ -174,6 +202,10 @@ pub fn fixture_with_store( consent, catalog, clock, + _cleanup: DirectoryCleanup { + dir: dir.clone(), + store: store.expect("module initialized its store"), + }, dir, } } diff --git a/crates/basal-module/tests/dry_run.rs b/crates/basal-module/tests/dry_run.rs index f08060b..0c4ecac 100644 --- a/crates/basal-module/tests/dry_run.rs +++ b/crates/basal-module/tests/dry_run.rs @@ -192,6 +192,24 @@ fn kinds_and_actions(run: &Value) -> Vec<(String, String)> { .collect() } +/// On Windows, the directory canonicalized to its long name, with the +/// `\\?\` prefix removed: the `fs` built-in refuses that prefix, and the +/// temp directory is often spelt with 8.3 short names (`RUNNER~1`), which +/// the built-in's alias checks are not meant to be exercised by here. On +/// other systems, unchanged. +fn spelled(dir: &std::path::Path) -> std::path::PathBuf { + #[cfg(windows)] + { + let canonical = std::fs::canonicalize(dir).expect("canonical scratch directory"); + let text = canonical.display().to_string(); + std::path::PathBuf::from(text.strip_prefix(r"\\?\").unwrap_or(&text)) + } + #[cfg(not(windows))] + { + dir.to_path_buf() + } +} + /// Capture mode captures every built-in, reads included. Live mode runs /// only the built-ins that read (here `fs.read`), and only inside the /// manifest's scope: a read outside it is refused in the parent, and a @@ -199,8 +217,9 @@ fn kinds_and_actions(run: &Value) -> Vec<(String, String)> { #[test] fn dry_runs_capture_every_built_in_and_live_runs_only_reads_in_scope() { let f = fixture("dry-builtins", Options::default()); - let root = f.dir.join("files"); - let outside = f.dir.join("outside"); + let base = spelled(&f.dir); + let root = base.join("files"); + let outside = base.join("outside"); std::fs::create_dir_all(&root).expect("root"); std::fs::create_dir_all(&outside).expect("outside"); std::fs::write(root.join("notes.txt"), "notes").expect("notes"); diff --git a/crates/basal-module/tests/e2e.rs b/crates/basal-module/tests/e2e.rs index 1921add..396e3e5 100644 --- a/crates/basal-module/tests/e2e.rs +++ b/crates/basal-module/tests/e2e.rs @@ -13,6 +13,7 @@ //! a wire daemon and measures a real worker's responsible-process identity. use std::io::{BufRead, BufReader, Read, Write}; +#[cfg(unix)] use std::os::unix::process::ExitStatusExt; use std::path::{Path, PathBuf}; use std::process::{Child, ChildStdin, Command, ExitStatus, Stdio}; @@ -388,6 +389,31 @@ fn scratch(tag: &str) -> PathBuf { basal_testkit::harness::scratch(&format!("e2e-{tag}")) } +/// Whether the module ended the way an outside kill ends it. On Unix that is +/// SIGKILL either way. On Windows a module that killed itself +/// (`fatal::kill_self`) exits with the launcher's kill code, and one killed +/// with `Child::kill` exits with 1, the code the standard library +/// terminates with. +fn assert_killed(status: ExitStatus, killed_itself: bool) { + #[cfg(unix)] + { + let _ = killed_itself; + assert_eq!(status.signal(), Some(libc::SIGKILL), "{status:?}"); + } + #[cfg(windows)] + { + let code = if killed_itself { + basal_launch::KILL_EXIT_CODE as i32 + } else { + 1 + }; + assert_eq!(status.code(), Some(code), "{status:?}"); + } +} + +// Reaping is a POSIX notion: a dropped child that was not waited for stays +// a zombie. A Windows process handle is simply closed. +#[cfg(unix)] #[test] fn e2e_fixture_drop_reaps_its_child() { let dir = scratch("drop-reaps"); @@ -621,8 +647,7 @@ fn a_schedule_flow_survives_a_catch_up_a_worker_kill_and_a_module_kill_with_each h.send(json!({ "cmd": "pump" })).is_none(), "the module died mid-run" ); - let status = h.wait(); - assert_eq!(status.signal(), Some(libc::SIGKILL), "{status:?}"); + assert_killed(h.wait(), true); // The restart recovers the run and finishes both fires. let mut h = Harness::start(&dir, &["--routing-fake"]); @@ -740,8 +765,7 @@ fn a_broca_llm_suspends_survives_module_kill_and_settles_after_restart() { assert_eq!(sends[0]["params"]["tools"], json!([])); assert_eq!(sends[0]["params"]["generation"]["max_output_tokens"], 32); h.child.kill().expect("kill the module"); - let status = h.wait(); - assert_eq!(status.signal(), Some(libc::SIGKILL)); + assert_killed(h.wait(), false); let mut h = Harness::start(&dir, &["--broca"]); assert_eq!(h.ok(json!({"cmd":"runs"}))[0]["state"], "suspended"); diff --git a/crates/basal-module/tests/engine.rs b/crates/basal-module/tests/engine.rs index 10436c3..cfe9fd6 100644 --- a/crates/basal-module/tests/engine.rs +++ b/crates/basal-module/tests/engine.rs @@ -169,5 +169,6 @@ fn the_unconfigured_host_refuses_every_dispatch_as_never_sent() { assert_eq!(calls.len(), 1); assert_eq!(calls[0].dispatch, basal_core::DispatchState::Sent); module.pool.stop(); - let _ = std::fs::remove_dir_all(&dir); + drop(module); + std::fs::remove_dir_all(&dir).expect("remove store after module shutdown"); } diff --git a/crates/basal-module/tests/fixture_teardown.rs b/crates/basal-module/tests/fixture_teardown.rs new file mode 100644 index 0000000..c3f86f7 --- /dev/null +++ b/crates/basal-module/tests/fixture_teardown.rs @@ -0,0 +1,19 @@ +mod common; + +use std::sync::Arc; + +#[test] +fn fixture_releases_its_store_before_removing_the_directory() { + let mut store = None; + let fixture = + common::fixture_with_store("fixture-teardown", common::Options::default(), |opened| { + store = Some(Arc::downgrade(&opened)); + Ok(()) + }); + let store = store.unwrap(); + let directory = fixture.dir.clone(); + assert!(store.upgrade().is_some()); + drop(fixture); + assert!(store.upgrade().is_none()); + assert!(!directory.exists()); +} diff --git a/crates/basal-module/tests/health_contract.rs b/crates/basal-module/tests/health_contract.rs index 89bb687..e1ff305 100644 --- a/crates/basal-module/tests/health_contract.rs +++ b/crates/basal-module/tests/health_contract.rs @@ -149,7 +149,7 @@ fn flow_health_decodes_as_core_decodes_it() { json!({ "os": std::env::consts::OS, "landlock": if cfg!(target_os = "linux") { json!("required") } else { Value::Null }, - "sigsys_deaths": 0, + basal_module::pool::CONFINEMENT_FAULT_DEATHS: 0, }) ); let reply = decode(&raw); diff --git a/crates/basal-module/tests/idle_wake.rs b/crates/basal-module/tests/idle_wake.rs index 0da049d..85154da 100644 --- a/crates/basal-module/tests/idle_wake.rs +++ b/crates/basal-module/tests/idle_wake.rs @@ -2,32 +2,17 @@ mod common; use basal_core::RunState; -use basal_module::engine::{Engine, EngineConfig}; use common::{Options, admit, agent, events_manifest, fixture, install_approved}; use serde_json::json; use std::time::Duration; -fn loop_engine(f: &common::Fixture, max: usize) -> Engine { - Engine::new( - f.module.rt.clone(), - f.module.pool.clone(), - f.module.consent.clone(), - EngineConfig { - max_concurrent_activations: max, - pass_interval: Duration::from_secs(600), - }, - f.module.metrics.clone(), - f.module.fatal.clone(), - ) - .unwrap() -} - #[test] fn completing_a_run_and_returning_pool_capacity_start_the_next_flow() { let f = fixture( "idle-completion", Options { max_concurrent: 1, + engine_pass_interval: Duration::from_secs(600), ..Options::default() }, ); @@ -40,7 +25,7 @@ fn completing_a_run_and_returning_pool_capacity_start_the_next_flow() { let next = install_approved(&f, &agent("SYNAPSE"), "return 2;", &events_manifest("next")); let first = admit(&f, &held, "first"); let second = admit(&f, &next, "second"); - let engine = loop_engine(&f, 1); + let engine = f.module.engine.clone(); let loop_thread = engine.spawn_loop(); assert!(engine.wait_active(&first, Duration::from_secs(60))); assert_eq!(f.module.rt.run(&second).unwrap().state, RunState::Pending); @@ -57,7 +42,14 @@ fn completing_a_run_and_returning_pool_capacity_start_the_next_flow() { #[test] fn a_host_completion_resumes_a_suspended_run_without_the_fallback() { - let f = fixture("idle-host-completion", Options::default()); + let f = fixture( + "idle-host-completion", + Options { + max_concurrent: 1, + engine_pass_interval: Duration::from_secs(600), + ..Options::default() + }, + ); let mut manifest = events_manifest("long"); manifest["ops"] .as_array_mut() @@ -70,7 +62,7 @@ fn a_host_completion_resumes_a_suspended_run_without_the_fallback() { &manifest, ); let run = admit(&f, &flow, "first"); - let engine = loop_engine(&f, 1); + let engine = f.module.engine.clone(); let loop_thread = engine.spawn_loop(); f.module .rt diff --git a/crates/basal-module/tests/module_events.rs b/crates/basal-module/tests/module_events.rs index d902315..adb54fd 100644 --- a/crates/basal-module/tests/module_events.rs +++ b/crates/basal-module/tests/module_events.rs @@ -17,8 +17,29 @@ static NEXT: AtomicUsize = AtomicUsize::new(0); const STREAM: &str = "CK_ACCOUNT_EVENT"; const SUBJECT: &str = "ck.account.event.plexus.pull_request_review.v1"; +#[cfg(windows)] +#[path = "module_events/windows.rs"] +mod windows; +#[cfg(unix)] +use std::process::Child; +#[cfg(windows)] +use windows::{Child, start_server}; + +#[cfg(unix)] +fn start_server(dir: &std::path::Path, port: u16, log: std::fs::File) -> Child { + std::process::Command::new("nats-server") + .args(["-js", "-a", "127.0.0.1", "-p", &port.to_string(), "-sd"]) + .arg(dir.join("jetstream")) + .stdout(log.try_clone().unwrap()) + .stderr(log) + .spawn() + .expect( + "REAL NATS TEST REQUIRES nats-server ON PATH; install pinned v2.15.0 (no silent skip)", + ) +} + struct Server { - child: std::process::Child, + child: Child, dir: std::path::PathBuf, bus: NatsConnection, } @@ -38,10 +59,7 @@ impl Server { let port = listener.local_addr().unwrap().port(); drop(listener); let log = std::fs::File::create(dir.join("server.log")).unwrap(); - let mut child = std::process::Command::new("nats-server") - .args(["-js","-a","127.0.0.1","-p", &port.to_string(),"-sd"]) - .arg(dir.join("jetstream")).stdout(log.try_clone().unwrap()).stderr(log) - .spawn().expect("REAL NATS TEST REQUIRES nats-server ON PATH; install pinned v2.15.0 (no silent skip)"); + let mut child = start_server(&dir, port, log); let until = Instant::now() + Duration::from_secs(10); let bus = loop { match NatsConnection::connect( diff --git a/crates/basal-module/tests/module_events/windows.rs b/crates/basal-module/tests/module_events/windows.rs new file mode 100644 index 0000000..86638e4 --- /dev/null +++ b/crates/basal-module/tests/module_events/windows.rs @@ -0,0 +1,54 @@ +//! The NATS fixture shares the launcher's spawn lock with workers. Its job +//! also ends the server if a test panics before normal fixture cleanup. + +use basal_launch::{OwnedProcess, PlainCommand, PlainStdio, spawn_plain}; +use std::fs::File; +use std::io; +use std::path::Path; + +pub struct Child(OwnedProcess); + +impl Child { + pub fn try_wait(&mut self) -> io::Result> { + self.0.try_wait() + } + + pub fn kill(&mut self) -> io::Result<()> { + self.0.kill() + } + + pub fn wait(&mut self) -> io::Result { + self.0.wait() + } +} + +pub fn start_server(dir: &Path, port: u16, log: File) -> Child { + let program = std::env::var_os("PATH") + .and_then(|path| { + std::env::split_paths(&path) + .map(|entry| entry.join("nats-server.exe")) + .find(|candidate| candidate.is_file()) + }) + .expect( + "REAL NATS TEST REQUIRES nats-server ON PATH; install pinned v2.15.0 (no silent skip)", + ) + .canonicalize() + .unwrap(); + let mut command = PlainCommand::new(program); + command.args = ["-js", "-a", "127.0.0.1", "-p", &port.to_string(), "-sd"] + .map(Into::into) + .into(); + command.args.push(dir.join("jetstream").into()); + command.env = std::env::vars_os().collect(); + command.stdout = PlainStdio::Piped; + command.stderr = PlainStdio::Piped; + let mut child = spawn_plain(&command).expect("start real NATS server through the spawn lock"); + let stdout = child.stdout.take().unwrap(); + let stderr = child.stderr.take().unwrap(); + for (mut pipe, mut log) in [(stdout, log.try_clone().unwrap()), (stderr, log)] { + std::thread::spawn(move || { + let _ = io::copy(&mut pipe, &mut log); + }); + } + Child(child) +} diff --git a/crates/basal-module/tests/pool.rs b/crates/basal-module/tests/pool.rs index eec99b4..6bfda55 100644 --- a/crates/basal-module/tests/pool.rs +++ b/crates/basal-module/tests/pool.rs @@ -5,8 +5,10 @@ mod common; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex, OnceLock, mpsc}; +use std::sync::atomic::Ordering; +use std::sync::{Arc, Mutex, OnceLock}; +#[cfg(unix)] +use std::sync::{atomic::AtomicBool, mpsc}; use std::time::{Duration, Instant}; use basal_core::{Boundary, Hooks, RunState, Step}; @@ -22,6 +24,7 @@ use serde_json::{Value, json}; const SCRIPT: &str = "const r = await ops.call('mock', 'echo', { n: 1 }); return r.n;"; const WAIT: Duration = Duration::from_secs(60); +#[cfg(unix)] const STALLED_WAIT: Duration = Duration::from_secs(3); fn pool_config(o: &Options) -> PoolConfig { @@ -241,6 +244,9 @@ fn a_caught_memory_failure_completes_and_reuses_the_worker() { assert_eq!(f.module.metrics.workers_killed.load(Ordering::Relaxed), 0); } +// Signals and `waitid` are POSIX; the Windows fault accounting is tested in +// the pool's unit tests. +#[cfg(unix)] fn signal_crash_is_reported(signal: i32, expected_deaths: u64) { use basal_core::channel::WorkerChannel; @@ -306,11 +312,13 @@ fn signal_crash_is_reported(signal: i32, expected_deaths: u64) { } } +#[cfg(unix)] #[test] fn sigsys_deaths_are_counted_in_flow_health() { signal_crash_is_reported(libc::SIGSYS, 1); } +#[cfg(unix)] #[test] fn sigkill_deaths_are_not_confinement_violations() { signal_crash_is_reported(libc::SIGKILL, 0); @@ -516,12 +524,19 @@ fn the_run_deadline_starts_after_the_worker_answers_its_handshake() { struct RecordedSpawner { inner: Arc, - pids: Mutex>, + pids: Mutex>, } impl RecordedSpawner { + /// On Windows there is no zombie to reap: the pool's owned process + /// wrapper kills its worker's job and waits for it when dropped. + #[cfg(windows)] + fn reap_workers(&self) {} + + #[cfg(unix)] fn reap_workers(&self) { for pid in self.pids.lock().unwrap().iter().copied() { + let pid = libc::pid_t::try_from(pid).expect("worker pid"); let deadline = Instant::now() + WAIT; loop { let mut status = 0; @@ -551,14 +566,13 @@ impl RecordedSpawner { impl Spawn for RecordedSpawner { fn spawn(&self) -> Result { let process = self.inner.spawn()?; - self.pids - .lock() - .unwrap() - .push(process.pid().try_into().expect("worker pid")); + self.pids.lock().unwrap().push(process.pid()); Ok(process) } } +// Reaping is checked with `waitpid`, a POSIX notion. +#[cfg(unix)] #[test] fn a_wait_deadline_stops_and_reaps_its_workers() { let spawner = Arc::new(RecordedSpawner { @@ -610,6 +624,7 @@ fn a_wait_deadline_stops_and_reaps_its_workers() { f.module.pool.stop(); } for pid in pids { + let pid = libc::pid_t::try_from(pid).expect("worker pid"); let mut status = 0; // SAFETY: this nonblocking wait probes a child started by this test. let waited = unsafe { libc::waitpid(pid, &mut status, libc::WNOHANG) }; diff --git a/crates/basal-module/tests/store_dacl.rs b/crates/basal-module/tests/store_dacl.rs new file mode 100644 index 0000000..b0970df --- /dev/null +++ b/crates/basal-module/tests/store_dacl.rs @@ -0,0 +1,202 @@ +//! basal's store holds flow inputs and outputs, so no other account may read +//! it. On Unix the shared store crate makes the directory 0700 and the files +//! 0600 (pinned by basal-core's `store_dir_mode` tests). On Windows it gives +//! the directory and the database files a protected DACL (nothing inherited +//! from the parent) with a single entry: full access for the user the +//! process runs as. These tests read that DACL back after basal opens its +//! store, including over a directory that already existed with the +//! permissive DACL it inherited. +#![cfg(windows)] + +use basal_core::{Durability, Store}; +use std::os::windows::ffi::OsStrExt; +use std::path::{Path, PathBuf}; +use std::ptr::null_mut; +use std::time::{SystemTime, UNIX_EPOCH}; +use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, LocalFree}; +use windows_sys::Win32::Security::Authorization::{ + ConvertSidToStringSidW, GetNamedSecurityInfoW, SE_FILE_OBJECT, +}; +use windows_sys::Win32::Security::{ + ACCESS_ALLOWED_ACE, ACE_HEADER, ACL, DACL_SECURITY_INFORMATION, GetAce, + GetSecurityDescriptorControl, GetTokenInformation, PSECURITY_DESCRIPTOR, PSID, + SE_DACL_PROTECTED, TOKEN_QUERY, TOKEN_USER, TokenUser, +}; +use windows_sys::Win32::Storage::FileSystem::FILE_ALL_ACCESS; +use windows_sys::Win32::System::SystemServices::ACCESS_ALLOWED_ACE_TYPE; +use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + +fn scratch(name: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!( + "basal-store-dacl-{name}-{}-{nanos}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +struct Cleanup(PathBuf); +impl Drop for Cleanup { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn wide(path: &Path) -> Vec { + path.as_os_str().encode_wide().chain(Some(0)).collect() +} + +/// The `S-1-...` form of a SID. +fn sid_text(sid: PSID) -> String { + let mut text = null_mut(); + assert_ne!(unsafe { ConvertSidToStringSidW(sid, &mut text) }, 0); + let mut len = 0; + while unsafe { *text.add(len) } != 0 { + len += 1; + } + let value = String::from_utf16_lossy(unsafe { std::slice::from_raw_parts(text, len) }); + unsafe { LocalFree(text.cast()) }; + value +} + +/// The SID of the user this test runs as. +fn current_user() -> String { + let mut token: HANDLE = null_mut(); + assert_ne!( + unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) }, + 0 + ); + let mut bytes = 0; + unsafe { GetTokenInformation(token, TokenUser, null_mut(), 0, &mut bytes) }; + let mut buffer = vec![0usize; (bytes as usize).div_ceil(size_of::())]; + assert_ne!( + unsafe { + GetTokenInformation( + token, + TokenUser, + buffer.as_mut_ptr().cast(), + bytes, + &mut bytes, + ) + }, + 0 + ); + unsafe { CloseHandle(token) }; + sid_text(unsafe { (*buffer.as_ptr().cast::()).User.Sid }) +} + +/// One entry of a DACL. +#[derive(Debug, PartialEq, Eq)] +struct Entry { + ace_type: u8, + flags: u8, + mask: u32, + sid: String, +} + +/// Whether the DACL is protected from inheritance, and its entries. +fn dacl(path: &Path) -> (bool, Vec) { + let mut acl: *mut ACL = null_mut(); + let mut descriptor: PSECURITY_DESCRIPTOR = null_mut(); + let error = unsafe { + GetNamedSecurityInfoW( + wide(path).as_ptr(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + null_mut(), + null_mut(), + &mut acl, + null_mut(), + &mut descriptor, + ) + }; + assert_eq!(error, 0, "GetNamedSecurityInfoW({})", path.display()); + let mut control = 0; + let mut revision = 0; + assert_ne!( + unsafe { GetSecurityDescriptorControl(descriptor, &mut control, &mut revision) }, + 0 + ); + assert!(!acl.is_null(), "{} has no DACL at all", path.display()); + let count = unsafe { (*acl).AceCount }; + let mut entries = Vec::new(); + for index in 0..u32::from(count) { + let mut ace = null_mut(); + assert_ne!(unsafe { GetAce(acl, index, &mut ace) }, 0); + let header = unsafe { &*ace.cast::() }; + let (mask, sid) = if u32::from(header.AceType) == ACCESS_ALLOWED_ACE_TYPE { + let allowed = ace.cast::(); + let sid = unsafe { std::ptr::addr_of!((*allowed).SidStart) } as PSID; + (unsafe { (*allowed).Mask }, sid_text(sid)) + } else { + (0, String::new()) + }; + entries.push(Entry { + ace_type: header.AceType, + flags: header.AceFlags, + mask, + sid, + }); + } + unsafe { LocalFree(descriptor) }; + (control & SE_DACL_PROTECTED != 0, entries) +} + +/// Owner-only: protected, one allow entry for the current user with full +/// access. A directory's entry is inherited by what is created in it. +fn assert_owner_only(path: &Path, directory: bool) { + let (protected, entries) = dacl(path); + println!("{}: protected {protected}, {entries:?}", path.display()); + assert!(protected, "{} inherits its parent's DACL", path.display()); + let inherit = if directory { + // OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE + 0x1 | 0x2 + } else { + 0 + }; + assert_eq!( + entries, + [Entry { + ace_type: ACCESS_ALLOWED_ACE_TYPE as u8, + flags: inherit, + mask: FILE_ALL_ACCESS, + sid: current_user(), + }], + "{}", + path.display() + ); +} + +#[test] +fn opening_the_store_makes_its_directory_and_database_owner_only() { + let root = scratch("fresh"); + let _cleanup = Cleanup(root.clone()); + let dir = root.join("basal"); + let store = Store::open(dir.join("store.db"), Durability::default()).expect("open"); + assert_owner_only(&dir, true); + assert_owner_only(&dir.join("store.db"), false); + drop(store); +} + +/// The scratch root inherits the temp directory's DACL, which grants more +/// than the current user, so the positive control shows the check can fail. +#[test] +fn opening_the_store_narrows_an_existing_inherited_directory() { + let root = scratch("existing"); + let _cleanup = Cleanup(root.clone()); + let dir = root.join("basal"); + std::fs::create_dir(&dir).unwrap(); + let (protected, entries) = dacl(&dir); + assert!( + !protected && entries.len() > 1, + "the control directory should inherit a broader DACL: {entries:?}" + ); + let store = Store::open(dir.join("store.db"), Durability::default()).expect("open"); + assert_owner_only(&dir, true); + assert_owner_only(&dir.join("store.db"), false); + drop(store); +} diff --git a/crates/basal-module/tests/tool_provider_conformance.rs b/crates/basal-module/tests/tool_provider_conformance.rs index f34afc1..2409aa5 100644 --- a/crates/basal-module/tests/tool_provider_conformance.rs +++ b/crates/basal-module/tests/tool_provider_conformance.rs @@ -134,14 +134,20 @@ impl Wire { struct Live { wire: Arc, core: Arc, - provider: tokio::task::JoinHandle<()>, - reader: tokio::task::JoinHandle<()>, + provider: Option>, + reader: Option>, + stopped: Arc>>>, } impl Drop for Live { fn drop(&mut self) { self.core.scopes.release(); - self.provider.abort(); - self.reader.abort(); + for task in [self.provider.take(), self.reader.take()] + .into_iter() + .flatten() + { + task.abort(); + self.stopped.lock().unwrap().push(task); + } } } #[derive(Clone)] @@ -218,6 +224,23 @@ impl ToolRoute for Route { #[derive(Default)] struct Subject { core: Mutex>>, + stopped: Arc>>>, +} +impl Subject { + // An abort only requests cancellation. Join every dropped provider, + // including the conformance suite's copies, before reusing or deleting its + // store: Windows refuses deletion while any database handle is still open. + async fn join_stopped(&self) { + let tasks = std::mem::take(&mut *self.stopped.lock().unwrap()); + for task in tasks { + if let Err(error) = tokio::time::timeout(Duration::from_secs(180), task) + .await + .expect("provider teardown timed out") + { + assert!(error.is_cancelled(), "provider teardown panicked: {error}"); + } + } + } } fn principals() -> ScopedPrincipals { ScopedPrincipals { @@ -254,6 +277,7 @@ impl Harness for Subject { vec![] } async fn spawn(&self, state_root: &Path) -> Result { + self.join_stopped().await; std::fs::create_dir_all(state_root).unwrap(); let root = state_root.to_path_buf(); let core = Arc::new(MockCore { @@ -372,8 +396,9 @@ impl Harness for Subject { channel: AtomicU16::new(1), }), core, - provider, - reader, + provider: Some(provider), + reader: Some(reader), + stopped: self.stopped.clone(), }; tokio::time::timeout(Duration::from_secs(180), async { prepared.await.unwrap(); @@ -507,12 +532,10 @@ impl ToolProviderSubject for Subject { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn basal_tool_provider_conforms_for_declared_capabilities() { let root = common::scratch("tool-conformance"); - let report = cortexkit_role_tool_provider_conformance::run_suite( - &Subject::default(), - &root.join("suite"), - ) - .await - .unwrap(); + let subject = Subject::default(); + let report = cortexkit_role_tool_provider_conformance::run_suite(&subject, &root.join("suite")) + .await + .unwrap(); use cortexkit_role_tool_provider_conformance::{CaseOutcome, SuiteVerdict}; println!("{}", report.render()); assert!( @@ -544,6 +567,7 @@ async fn basal_tool_provider_conforms_for_declared_capabilities() { .into_iter() .collect() ); + subject.join_stopped().await; std::fs::remove_dir_all(root).unwrap(); } @@ -615,6 +639,7 @@ async fn real_route_late_cursor_ack_and_custodian_only_serving() { "an ack must not republish its entry" ); drop((carrier, other, owner, live)); + subject.join_stopped().await; std::fs::remove_dir_all(root).unwrap(); } @@ -662,6 +687,7 @@ async fn real_route_late_retention_and_direct_call_owner_custody() { assert_eq!(expired["entries"][0][key], page["entries"][0][key]); } drop((direct, owner, live)); + subject.join_stopped().await; std::fs::remove_dir_all(root).unwrap(); } @@ -704,6 +730,7 @@ async fn real_route_late_incarnation_changes_and_event_identity_survives_restart ); assert_eq!(page["entries"], reread["entries"]); drop((route, live)); + subject.join_stopped().await; std::fs::remove_dir_all(root).unwrap(); } @@ -729,6 +756,7 @@ async fn real_route_held_action_reaches_marker_after_core_releases_admission() { complete(&route, "marker-control").await; assert_eq!(std::fs::read(&marker).unwrap(), b"executed"); drop((route, live)); + subject.join_stopped().await; std::fs::remove_dir_all(root).unwrap(); } @@ -761,5 +789,6 @@ async fn real_route_malformed_key_types_and_withdraw_null_key_name_the_field() { ); } drop((route, live)); + subject.join_stopped().await; std::fs::remove_dir_all(root).unwrap(); } diff --git a/crates/basal-proto/src/lib.rs b/crates/basal-proto/src/lib.rs index 2c7c279..a24144b 100644 --- a/crates/basal-proto/src/lib.rs +++ b/crates/basal-proto/src/lib.rs @@ -82,7 +82,7 @@ mod tests { #[test] fn codemode_wire_codes_preserve_shell_and_round_trip_tool_json() { - assert_eq!(PROTOCOL_VERSION, 7); + assert_eq!(PROTOCOL_VERSION, 8); // Checked at run time, not in a const block, so lowering the ceiling // fails this test instead of the build, and the mutation control for // the ceiling sees a red test. @@ -316,8 +316,8 @@ mod tests { } fn confinement_payload(suffix: &[u8]) -> Vec { - // A version-6 Welcome with an empty engine name and two zero hashes. - let mut payload = vec![101, 0, 0, 0, 7, 0, 0, 0, 0]; + // A version-8 Welcome with an empty engine name and two zero hashes. + let mut payload = vec![101, 0, 0, 0, 8, 0, 0, 0, 0]; payload.extend_from_slice(&[0; 64]); payload.extend_from_slice(suffix); payload diff --git a/crates/basal-proto/src/limits.rs b/crates/basal-proto/src/limits.rs index daf1285..748c14b 100644 --- a/crates/basal-proto/src/limits.rs +++ b/crates/basal-proto/src/limits.rs @@ -2,7 +2,7 @@ //! side will read before it allocates, independent of any activation budget. /// The current protocol version, exchanged in the handshake. -pub const PROTOCOL_VERSION: u32 = 7; +pub const PROTOCOL_VERSION: u32 = 8; /// Linux codemode process ceiling, including the 64 MiB JS heap, native /// stacks, executable mappings and allocator overhead. diff --git a/crates/basal-proto/src/wire.rs b/crates/basal-proto/src/wire.rs index 4dcbfa2..5b210ed 100644 --- a/crates/basal-proto/src/wire.rs +++ b/crates/basal-proto/src/wire.rs @@ -6,6 +6,8 @@ use crate::limits::{ }; use crate::types::*; +mod windows; + // Parent and worker tags live in disjoint ranges, so a frame sent in the // wrong direction is refused as an unknown tag instead of being misread. const TAG_HELLO: u8 = 1; @@ -675,12 +677,14 @@ pub(crate) fn encode_worker(enc: &mut Encoder, m: &WorkerMessage) { mitigations, handle_table, } => { - enc.u8(3); - enc.u8(u8::from(lpac)); - enc.u8(u8::from(untrusted)); - enc.u8(u8::from(no_thread_token)); - enc.u8(u8::from(mitigations)); - enc.u8(u8::from(handle_table)); + windows::encode( + enc, + lpac, + untrusted, + no_thread_token, + mitigations, + handle_table, + ); } } } @@ -740,13 +744,7 @@ pub(crate) fn decode_worker(dec: &mut Decoder<'_>) -> Result Confinement::Windows { - lpac: dec.bool("lpac")?, - untrusted: dec.bool("untrusted")?, - no_thread_token: dec.bool("no_thread_token")?, - mitigations: dec.bool("mitigations")?, - handle_table: dec.bool("handle_table")?, - }, + windows::TAG => windows::decode(dec)?, tag => { return Err(DecodeError::UnknownTag { field: "confinement", diff --git a/crates/basal-proto/src/wire/windows.rs b/crates/basal-proto/src/wire/windows.rs new file mode 100644 index 0000000..050b196 --- /dev/null +++ b/crates/basal-proto/src/wire/windows.rs @@ -0,0 +1,33 @@ +//! The Windows confinement report has its own tag and five ordered booleans. +//! It is encoded on every platform so journals and protocol fixtures stay portable. + +use crate::codec::{DecodeError, Decoder, Encoder}; +use crate::types::Confinement; + +pub(super) const TAG: u8 = 3; + +pub(super) fn encode( + enc: &mut Encoder, + lpac: bool, + untrusted: bool, + no_thread_token: bool, + mitigations: bool, + handle_table: bool, +) { + enc.u8(TAG); + enc.u8(u8::from(lpac)); + enc.u8(u8::from(untrusted)); + enc.u8(u8::from(no_thread_token)); + enc.u8(u8::from(mitigations)); + enc.u8(u8::from(handle_table)); +} + +pub(super) fn decode(dec: &mut Decoder<'_>) -> Result { + Ok(Confinement::Windows { + lpac: dec.bool("lpac")?, + untrusted: dec.bool("untrusted")?, + no_thread_token: dec.bool("no_thread_token")?, + mitigations: dec.bool("mitigations")?, + handle_table: dec.bool("handle_table")?, + }) +} diff --git a/crates/basal-rig/Cargo.toml b/crates/basal-rig/Cargo.toml index 104078d..809aac5 100644 --- a/crates/basal-rig/Cargo.toml +++ b/crates/basal-rig/Cargo.toml @@ -38,6 +38,15 @@ subc-client-rs.workspace = true subc-protocol.workspace = true tokio = { workspace = true, features = ["sync", "time"] } +[target.'cfg(windows)'.dependencies] +basal-launch.workspace = true +rusqlite = { workspace = true, features = ["bundled"] } +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_System_Diagnostics_ToolHelp", + "Win32_System_Threading", +] } + [dev-dependencies] basal-host.workspace = true basal-proto.workspace = true diff --git a/crates/basal-rig/src/bin/basal-rig-contract.rs b/crates/basal-rig/src/bin/basal-rig-contract.rs index 922332b..4cd3332 100644 --- a/crates/basal-rig/src/bin/basal-rig-contract.rs +++ b/crates/basal-rig/src/bin/basal-rig-contract.rs @@ -54,6 +54,9 @@ mod ownership; mod package_cases; #[path = "basal-rig-contract/seeded.rs"] mod seeded; +#[cfg(windows)] +#[path = "basal-rig-contract/windows.rs"] +mod windows; const CORE: &str = "prefrontal-core"; const BASAL: &str = "basal"; @@ -1408,6 +1411,7 @@ async fn relay_refusals(rig: &Rig, flow: &Flow, agent: &Agent) -> Case { case } +#[cfg(unix)] fn basal_pid() -> Option { let out = std::process::Command::new("ps") .args(["-axo", "pid=,command="]) @@ -1425,6 +1429,11 @@ fn basal_pid() -> Option { }) } +#[cfg(windows)] +fn basal_pid() -> Option { + windows::basal_pid() +} + async fn crash(rig: &Rig, flow: &Flow, agent: &Agent, since: i64) -> Case { let mut case = Case::new(CRASH_CASE); // Arm the kill switch before approving, so the flow's first run cannot diff --git a/crates/basal-rig/src/bin/basal-rig-contract/seeded.rs b/crates/basal-rig/src/bin/basal-rig-contract/seeded.rs index 16d1f2c..0e9b414 100644 --- a/crates/basal-rig/src/bin/basal-rig-contract/seeded.rs +++ b/crates/basal-rig/src/bin/basal-rig-contract/seeded.rs @@ -9,13 +9,21 @@ pub(super) fn module_cli_path(home: &std::path::Path) -> Result { return Err("module control requires the isolated ckdev-flows home".into()); } - Ok(root.join("bin/ckdev-ck")) + #[cfg(unix)] + { + Ok(root.join("bin/ckdev-ck")) + } + #[cfg(windows)] + { + Ok(root.join("bin").join("ckdev-ck.exe")) + } } pub(super) fn ck(args: &[&str]) -> Result { let connection = std::env::var_os("SUBC_CONNECTION_FILE").ok_or("no isolated connection file")?; let home = PathBuf::from(std::env::var_os("HOME").ok_or("missing isolated HOME")?); + #[cfg(unix)] let output = std::process::Command::new(module_cli_path(&home)?) .arg("--subc") .arg(connection) @@ -23,6 +31,8 @@ pub(super) fn ck(args: &[&str]) -> Result { .args(args) .output() .map_err(|e| e.to_string())?; + #[cfg(windows)] + let output = super::windows::ck_output(module_cli_path(&home)?, connection, args)?; if !output.status.success() { return Err(format!( "ckdev-ck {args:?}: {} {}", @@ -215,6 +225,7 @@ fn confirmed_recovery(instance: &Value, seeded: i64) -> bool { && instance.get("marker") == Some(&Value::Null) } +#[cfg(unix)] pub(super) fn no_store_handles(path: &std::path::Path) -> Result<(), String> { let mut command = std::process::Command::new("lsof"); command.arg("-F").arg("p").arg(path); @@ -233,6 +244,36 @@ pub(super) fn no_store_handles(path: &std::path::Path) -> Result<(), String> { Ok(()) } +#[cfg(windows)] +pub(super) fn no_store_handles(path: &std::path::Path) -> Result<(), String> { + use std::os::windows::fs::OpenOptionsExt; + let mut files = vec![path.to_path_buf()]; + for suffix in ["-wal", "-shm"] { + let sidecar = PathBuf::from(format!("{}{suffix}", path.display())); + if sidecar.exists() { + files.push(sidecar); + } + } + // No-sharing opens refuse while any other reader or writer owns the file. + // Hold all three together so the observation covers the store and sidecars. + let _handles = files + .iter() + .map(|file| { + std::fs::OpenOptions::new() + .read(true) + .share_mode(0) + .open(file) + .map_err(|e| { + format!( + "cannot prove {} has no open store handles: {e}", + file.display() + ) + }) + }) + .collect::, _>>()?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/basal-rig/src/bin/basal-rig-contract/tests.rs b/crates/basal-rig/src/bin/basal-rig-contract/tests.rs index c71dd23..f4869a9 100644 --- a/crates/basal-rig/src/bin/basal-rig-contract/tests.rs +++ b/crates/basal-rig/src/bin/basal-rig-contract/tests.rs @@ -129,15 +129,19 @@ fn seeded_fixture_requires_a_terminal_disabled_module_and_no_pid() { #[test] fn seeded_fixture_rejects_foreign_paths_and_symlinked_stores() { - let output = std::process::Command::new("mktemp") - .arg("-d") - .arg(std::env::temp_dir().join("basal-rig-fixture.XXXXXXXX")) - .output() - .unwrap(); - assert!(output.status.success()); - let root = PathBuf::from(String::from_utf8(output.stdout).unwrap().trim()) - .canonicalize() - .unwrap(); + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let root = loop { + let root = std::env::temp_dir().join(format!( + "basal-rig-fixture-{}-{}", + std::process::id(), + NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + match std::fs::create_dir(&root) { + Ok(()) => break root.canonicalize().unwrap(), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => panic!("create isolated fixture: {error}"), + } + }; let rig = root.join("ckdev-flows"); let home = rig.join("home"); let store = rig.join("data/cortexkit/basal/store.db"); @@ -151,9 +155,12 @@ fn seeded_fixture_rejects_foreign_paths_and_symlinked_stores() { let foreign = root.join("production.db"); std::fs::write(&foreign, []).unwrap(); assert!(seeded::fixture_path_for(&home, &foreign, "basal").is_err()); - std::fs::remove_file(&store).unwrap(); - std::os::unix::fs::symlink(&foreign, &store).unwrap(); - assert!(seeded::fixture_path_for(&home, &store, "basal").is_err()); + #[cfg(unix)] + { + std::fs::remove_file(&store).unwrap(); + std::os::unix::fs::symlink(&foreign, &store).unwrap(); + assert!(seeded::fixture_path_for(&home, &store, "basal").is_err()); + } assert!(seeded::fixture_path_for(&home, &store, "other").is_err()); std::fs::remove_dir_all(&root).unwrap(); } @@ -191,10 +198,38 @@ fn authorship_fingerprint_allows_only_the_one_author_cell() { #[test] fn fixture_module_control_uses_only_the_absolute_isolated_cli() { + let root = std::env::temp_dir().join("rig").join("ckdev-flows"); + let home = root.join("home"); assert_eq!( - seeded::module_cli_path(std::path::Path::new("/rig/ckdev-flows/home")).unwrap(), - PathBuf::from("/rig/ckdev-flows/bin/ckdev-ck") + seeded::module_cli_path(&home).unwrap(), + root.join("bin").join(if cfg!(windows) { + "ckdev-ck.exe" + } else { + "ckdev-ck" + }) ); - assert!(seeded::module_cli_path(std::path::Path::new("/rig/home")).is_err()); + assert!(seeded::module_cli_path(&std::env::temp_dir().join("rig").join("home")).is_err()); assert!(seeded::module_cli_path(std::path::Path::new("ckdev-flows/home")).is_err()); } + +#[cfg(windows)] +#[test] +fn store_handle_probe_refuses_an_open_database_or_sidecar() { + let directory = + std::env::temp_dir().join(format!("basal-rig-handle-probe-{}", std::process::id())); + std::fs::create_dir(&directory).unwrap(); + let store = directory.join("store.db"); + std::fs::write(&store, []).unwrap(); + for suffix in ["", "-wal", "-shm"] { + let path = PathBuf::from(format!("{}{suffix}", store.display())); + std::fs::write(&path, []).unwrap(); + let held = std::fs::File::open(&path).unwrap(); + assert!( + seeded::no_store_handles(&store).is_err(), + "open {suffix:?} was not refused" + ); + drop(held); + assert!(seeded::no_store_handles(&store).is_ok()); + } + std::fs::remove_dir_all(directory).unwrap(); +} diff --git a/crates/basal-rig/src/bin/basal-rig-contract/windows.rs b/crates/basal-rig/src/bin/basal-rig-contract/windows.rs new file mode 100644 index 0000000..473c720 --- /dev/null +++ b/crates/basal-rig/src/bin/basal-rig-contract/windows.rs @@ -0,0 +1,130 @@ +//! Native process observations and CLI capture for the isolated Windows rig. + +use std::ffi::OsString; +use std::io::Read; +use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; +use std::path::PathBuf; +use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; +use windows_sys::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, TH32CS_SNAPPROCESS, +}; +use windows_sys::Win32::System::Threading::{ + OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, QueryFullProcessImageNameW, +}; + +pub(super) fn basal_pid() -> Option { + let home = PathBuf::from(std::env::var_os("HOME")?); + let expected = module_image(&home)?; + pid_of_image(&expected) +} + +fn module_image(home: &std::path::Path) -> Option { + Some(home.parent()?.join("bin").join("ckdev-basal.exe")) +} + +fn pid_of_image(expected: &std::path::Path) -> Option { + // SAFETY: snapshot handles are newly owned unless the call returns INVALID_HANDLE_VALUE. + let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }; + if snapshot == INVALID_HANDLE_VALUE { + return None; + } + let snapshot = unsafe { OwnedHandle::from_raw_handle(snapshot) }; + let mut row: PROCESSENTRY32W = unsafe { std::mem::zeroed() }; + row.dwSize = std::mem::size_of_val(&row) as u32; + let mut more = unsafe { Process32FirstW(snapshot.as_raw_handle(), &mut row) }; + while more != 0 { + let process = + unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, row.th32ProcessID) }; + if !process.is_null() { + let process = unsafe { OwnedHandle::from_raw_handle(process) }; + let mut path = vec![0u16; 32768]; + let mut size = path.len() as u32; + // SAFETY: size describes the writable UTF-16 buffer; the process is owned. + if unsafe { + QueryFullProcessImageNameW(process.as_raw_handle(), 0, path.as_mut_ptr(), &mut size) + } != 0 + { + use std::os::windows::ffi::OsStringExt; + let actual = PathBuf::from(OsString::from_wide(&path[..size as usize])); + if actual + .to_string_lossy() + .eq_ignore_ascii_case(&expected.to_string_lossy()) + { + return Some(row.th32ProcessID.to_string()); + } + } + } + more = unsafe { Process32NextW(snapshot.as_raw_handle(), &mut row) }; + } + None +} + +pub(super) fn ck_output( + program: PathBuf, + connection: OsString, + args: &[&str], +) -> Result { + use basal_launch::{PlainCommand, PlainStdio}; + use std::os::windows::process::ExitStatusExt; + let mut argv = vec!["--subc".into(), connection, "--json".into()]; + argv.extend(args.iter().map(|arg| OsString::from(*arg))); + let command = PlainCommand { + program, + args: argv, + env: std::env::vars_os().collect(), + cwd: None, + stdin: PlainStdio::Null, + stdout: PlainStdio::Piped, + stderr: PlainStdio::Piped, + }; + let mut child = basal_launch::spawn_plain(&command).map_err(|e| e.to_string())?; + let mut stdout = child.stdout.take().ok_or("no stdout pipe")?; + let mut stderr = child.stderr.take().ok_or("no stderr pipe")?; + let out = std::thread::spawn(move || { + let mut bytes = vec![]; + stdout.read_to_end(&mut bytes).map(|_| bytes) + }); + let err = std::thread::spawn(move || { + let mut bytes = vec![]; + stderr.read_to_end(&mut bytes).map(|_| bytes) + }); + let status = child.wait().map_err(|e| e.to_string())?; + // End descendants before joining pipe readers, even when the CLI exited normally. + child.kill().map_err(|e| e.to_string())?; + drop(child); + let stdout = out + .join() + .map_err(|_| "stdout reader panicked")? + .map_err(|e| e.to_string())?; + let stderr = err + .join() + .map_err(|_| "stderr reader panicked")? + .map_err(|e| e.to_string())?; + Ok(std::process::Output { + status: std::process::ExitStatus::from_raw(status), + stdout, + stderr, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn process_snapshot_finds_the_current_image_and_refuses_an_absent_image() { + let module = module_image(std::path::Path::new(r"C:\rig\home")).unwrap(); + // Path equality normalizes separators, but the native image-name + // comparison uses raw spelling, so assert the spelling itself here. + assert_eq!( + module.as_os_str(), + std::ffi::OsStr::new(r"C:\rig\bin\ckdev-basal.exe") + ); + let image = std::env::current_exe().unwrap(); + assert_eq!(pid_of_image(&image), Some(std::process::id().to_string())); + assert_eq!( + pid_of_image(&image.with_file_name("absent-rig-image.exe")), + None + ); + } +} diff --git a/crates/basal-rig/tests/stores.rs b/crates/basal-rig/tests/stores.rs index a5f1cfa..882cf8d 100644 --- a/crates/basal-rig/tests/stores.rs +++ b/crates/basal-rig/tests/stores.rs @@ -7,13 +7,19 @@ use serde_json::{Value, json}; struct Scratch(PathBuf); impl Scratch { fn new() -> Self { - let output = std::process::Command::new("mktemp") - .arg("-d") - .arg(std::env::temp_dir().join("basal-rig-store.XXXXXXXX")) - .output() - .expect("create isolated store directory"); - assert!(output.status.success(), "{output:?}"); - let dir = PathBuf::from(String::from_utf8(output.stdout).unwrap().trim()); + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let dir = loop { + let dir = std::env::temp_dir().join(format!( + "basal-rig-store-{}-{}", + std::process::id(), + NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + match std::fs::create_dir(&dir) { + Ok(()) => break dir, + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => panic!("create isolated store directory: {error}"), + } + }; Self(dir.join("store.db")) } } diff --git a/crates/basal-testkit/Cargo.toml b/crates/basal-testkit/Cargo.toml index 355eb50..f8112a2 100644 --- a/crates/basal-testkit/Cargo.toml +++ b/crates/basal-testkit/Cargo.toml @@ -19,6 +19,15 @@ libc.workspace = true rcgen.workspace = true rustls.workspace = true +[target.'cfg(windows)'.dependencies] +basal-launch.workspace = true +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Storage_FileSystem", + "Win32_System_ProcessStatus", + "Win32_System_Threading", +] } + [build-dependencies] serde_json.workspace = true diff --git a/crates/basal-testkit/src/bin/basal-test-parent.rs b/crates/basal-testkit/src/bin/basal-test-parent.rs index 44826df..fb5371f 100644 --- a/crates/basal-testkit/src/bin/basal-test-parent.rs +++ b/crates/basal-testkit/src/bin/basal-test-parent.rs @@ -1,5 +1,5 @@ //! A test parent process: basal-core's store and driver, real workers, and -//! the mock host, in one process the kill harness can `kill -9`. +//! the mock host, in one process the kill harness can terminate abruptly. //! //! ```text //! basal-test-parent --dir --worker @@ -13,7 +13,7 @@ //! `/mock.json`, synced before every reply, so effects survive the //! kill like a remote system's would. //! -//! With `--kill-at`, the process sends itself SIGKILL the moment it passes +//! With `--kill-at`, the process terminates without cleanup the moment it passes //! that boundary. Otherwise it prints one JSON line: the run's summary and //! every boundary it passed. @@ -69,6 +69,14 @@ fn run(args: Args) -> Result { let probe = Arc::new(match args.kill_at { Some(point) => Probe::act_at(point, |_, boundary| { eprintln!("basal-test-parent: killed at {boundary:?}"); + #[cfg(windows)] + unsafe { + // SAFETY: the pseudo-handle names this process; termination + // deliberately bypasses destructors to simulate a crash. + use windows_sys::Win32::System::Threading::{GetCurrentProcess, TerminateProcess}; + TerminateProcess(GetCurrentProcess(), basal_launch::KILL_EXIT_CODE); + } + #[cfg(unix)] // SAFETY: kill(2) on our own pid has no memory-safety // preconditions. unsafe { @@ -123,6 +131,21 @@ fn run(args: Args) -> Result { } fn main() -> ExitCode { + if let Some(calls) = std::env::var_os("BASAL_DISCOVERY_CALLS") { + // A native sentinel for the discovery test: if runtime discovery ever + // invokes its CARGO override, record the invocation before refusing it. + use std::io::Write; + writeln!( + std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(calls) + .expect("sentinel log"), + "build" + ) + .expect("record invocation"); + return ExitCode::FAILURE; + } let args = match parse() { Ok(a) => a, Err(e) => { diff --git a/crates/basal-testkit/src/bin/worker-bench.rs b/crates/basal-testkit/src/bin/worker-bench.rs index 195f980..22b6e51 100644 --- a/crates/basal-testkit/src/bin/worker-bench.rs +++ b/crates/basal-testkit/src/bin/worker-bench.rs @@ -16,8 +16,8 @@ //! Every sample is kept in the output; summaries are the median and the //! nearest-rank 95th percentile. +use basal_testkit::command::Command; use std::path::{Path, PathBuf}; -use std::process::Command; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::thread; diff --git a/crates/basal-testkit/src/binaries.rs b/crates/basal-testkit/src/binaries.rs index b0c0749..08d1d98 100644 --- a/crates/basal-testkit/src/binaries.rs +++ b/crates/basal-testkit/src/binaries.rs @@ -208,14 +208,21 @@ pub fn dev_binary(binary: impl AsRef) -> PathBuf { static COPIES: OnceLock>> = OnceLock::new(); let mut copies = COPIES.get_or_init(Mutex::default).lock().unwrap(); let placed = copies.entry(binary.to_path_buf()).or_insert_with(|| { - let worker = binary.with_file_name("ck-basal-worker"); - let module = binary.with_file_name("ck-basal"); + let suffix = if cfg!(windows) { ".exe" } else { "" }; + let worker_name = format!("ck-basal-worker{suffix}"); + let module_name = format!("ck-basal{suffix}"); + let worker = binary.with_file_name(&worker_name); + let module = binary.with_file_name(&module_name); let mut sources = vec![binary]; - if binary.file_name().is_some_and(|name| name == "ck-basal") && worker.is_file() { + if binary + .file_name() + .is_some_and(|name| name == module_name.as_str()) + && worker.is_file() + { sources.push(&worker); } else if binary .file_name() - .is_some_and(|name| name == "ck-basal-worker") + .is_some_and(|name| name == worker_name.as_str()) && module.is_file() { sources.insert(0, &module); @@ -225,6 +232,26 @@ pub fn dev_binary(binary: impl AsRef) -> PathBuf { placed.path(binary).expect("development executable path") } +/// Places a test worker and makes that directory launchable by the Windows +/// AppContainer. Production installation never uses this test-only ACL grant. +pub(crate) fn dev_worker_binary(binary: impl AsRef) -> PathBuf { + let binary = dev_binary(binary); + #[cfg(windows)] + { + use std::collections::HashSet; + static GRANTED: OnceLock>> = OnceLock::new(); + let directory = binary.parent().expect("worker directory").to_path_buf(); + let mut granted = GRANTED.get_or_init(Mutex::default).lock().unwrap(); + if !granted.contains(&directory) { + let package = basal_launch::create_or_open_profile().expect("test worker package SID"); + basal_launch::grant_test_binary_directory(&binary, &package) + .expect("test worker directory ACL"); + granted.insert(directory); + } + } + binary +} + #[cfg(test)] mod tests { use super::*; @@ -258,6 +285,7 @@ mod tests { #[test] fn module_and_worker_share_a_content_addressed_development_directory() { + #[cfg(unix)] use std::os::unix::fs::PermissionsExt; let source = SourceFiles::new(); @@ -270,6 +298,7 @@ mod tests { assert_eq!(path.file_name().unwrap(), name); assert_eq!(fs::read(&path).unwrap(), fs::read(original).unwrap()); assert_eq!(path.parent(), Some(placed.directory.as_path())); + #[cfg(unix)] assert_eq!( fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o755 @@ -279,6 +308,7 @@ mod tests { #[test] fn repeated_placement_reuses_the_same_file_and_inode() { + #[cfg(unix)] use std::os::unix::fs::MetadataExt; let source = SourceFiles::new(); @@ -286,10 +316,33 @@ mod tests { let first = DevBinaries::new(&[&binary]).unwrap().path(&binary).unwrap(); let second = DevBinaries::new(&[&binary]).unwrap().path(&binary).unwrap(); assert_eq!(first, second); + #[cfg(unix)] assert_eq!( fs::metadata(first).unwrap().ino(), fs::metadata(second).unwrap().ino() ); + #[cfg(windows)] + assert_eq!(file_identity(&first), file_identity(&second)); + } + + #[cfg(windows)] + fn file_identity(path: &Path) -> (u32, u32, u32) { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{ + BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle, + }; + let file = File::open(path).unwrap(); + let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() }; + // SAFETY: file owns the handle and info is writable for the call. + assert_ne!( + unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut info) }, + 0 + ); + ( + info.dwVolumeSerialNumber, + info.nFileIndexHigh, + info.nFileIndexLow, + ) } #[test] diff --git a/crates/basal-testkit/src/channel.rs b/crates/basal-testkit/src/channel.rs index e182760..e03a3c7 100644 --- a/crates/basal-testkit/src/channel.rs +++ b/crates/basal-testkit/src/channel.rs @@ -155,9 +155,9 @@ pub fn worker_binary() -> PathBuf { static PATH: OnceLock = OnceLock::new(); PATH.get_or_init(|| { if let Some(p) = std::env::var_os("BASAL_WORKER_BIN") { - return crate::dev_binary(PathBuf::from(p)); + return crate::binaries::dev_worker_binary(PathBuf::from(p)); } - crate::dev_binary(env!("BASAL_TEST_WORKER_BIN")) + crate::binaries::dev_worker_binary(env!("BASAL_TEST_WORKER_BIN")) }) .clone() } diff --git a/crates/basal-testkit/src/command.rs b/crates/basal-testkit/src/command.rs new file mode 100644 index 0000000..6f44f42 --- /dev/null +++ b/crates/basal-testkit/src/command.rs @@ -0,0 +1,153 @@ +//! Commands used by test parents, with an explicit environment on Windows. +//! +//! Keeping the environment here avoids trying to recover `env_clear` from a +//! standard Command, which does not expose whether inheritance was disabled. + +#[cfg(unix)] +pub use std::process::Command; + +#[cfg(windows)] +mod windows { + use std::collections::BTreeMap; + use std::ffi::{OsStr, OsString}; + use std::io; + use std::path::{Path, PathBuf}; + use std::process::{ExitStatus, Output}; + use std::time::Duration; + + pub struct Command { + program: OsString, + args: Vec, + env: BTreeMap, + cwd: Option, + } + + impl Command { + pub fn new(program: impl AsRef) -> Self { + Self { + program: program.as_ref().into(), + args: vec![], + env: std::env::vars_os().collect(), + cwd: None, + } + } + + pub fn arg(&mut self, arg: impl AsRef) -> &mut Self { + self.args.push(arg.as_ref().into()); + self + } + + pub fn args(&mut self, args: I) -> &mut Self + where + I: IntoIterator, + S: AsRef, + { + for arg in args { + self.arg(arg); + } + self + } + + pub fn env(&mut self, key: impl AsRef, value: impl AsRef) -> &mut Self { + self.env_remove(key.as_ref()); + self.env.insert(key.as_ref().into(), value.as_ref().into()); + self + } + + pub fn envs(&mut self, env: I) -> &mut Self + where + I: IntoIterator, + K: AsRef, + V: AsRef, + { + for (key, value) in env { + self.env(key, value); + } + self + } + + pub fn env_clear(&mut self) -> &mut Self { + self.env.clear(); + self + } + + pub fn env_remove(&mut self, key: impl AsRef) -> &mut Self { + // Windows environment keys are case-insensitive, unlike OsString's ordering. + let key = key.as_ref().to_string_lossy(); + self.env + .retain(|name, _| !name.to_string_lossy().eq_ignore_ascii_case(&key)); + self + } + + pub fn current_dir(&mut self, path: impl AsRef) -> &mut Self { + self.cwd = Some(path.as_ref().into()); + self + } + + pub fn output(&mut self) -> io::Result { + self.output_until(Duration::from_secs(950)) + } + pub fn status(&mut self) -> io::Result { + self.output().map(|output| output.status) + } + + pub fn output_until(&self, timeout: Duration) -> io::Result { + use basal_launch::{PlainCommand, PlainStdio}; + let program = resolve_program(&self.program, &self.env, self.cwd.as_deref())?; + let command = PlainCommand { + program, + args: self.args.clone(), + env: self + .env + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + cwd: self.cwd.clone(), + stdin: PlainStdio::Null, + stdout: PlainStdio::Piped, + stderr: PlainStdio::Piped, + }; + super::super::process::windows::output_until(&command, timeout) + } + } + + fn resolve_program( + program: &OsStr, + env: &BTreeMap, + cwd: Option<&Path>, + ) -> io::Result { + let path = Path::new(program); + if path.is_absolute() { + return Ok(path.into()); + } + if path.components().count() > 1 { + return Ok(cwd + .map(PathBuf::from) + .unwrap_or(std::env::current_dir()?) + .join(path)); + } + let search = env + .iter() + .find(|(key, _)| key.to_string_lossy().eq_ignore_ascii_case("PATH")) + .map(|(_, value)| value) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "command PATH is absent"))?; + for directory in std::env::split_paths(search) { + let candidate = directory.join(path); + let candidate = if candidate.extension().is_none() { + candidate.with_extension("exe") + } else { + candidate + }; + if candidate.is_file() { + return Ok(candidate); + } + } + Err(io::Error::new( + io::ErrorKind::NotFound, + format!("executable {} not found", path.display()), + )) + } +} + +#[cfg(windows)] +pub use windows::Command; diff --git a/crates/basal-testkit/src/git.rs b/crates/basal-testkit/src/git.rs index c668985..2f726e0 100644 --- a/crates/basal-testkit/src/git.rs +++ b/crates/basal-testkit/src/git.rs @@ -1,5 +1,5 @@ +use crate::command::Command; use std::ffi::{OsStr, OsString}; -use std::process::Command; /// Starts git without inheriting caller configuration or environment. /// @@ -19,14 +19,19 @@ fn git_command_with_env(base_env: impl IntoIterator // would reach git, and the isolation test would see the difference. command.envs(base_env.iter().cloned()); command.env_clear(); - for key in [OsStr::new("PATH"), OsStr::new("HOME")] { + for key in [ + OsStr::new("PATH"), + OsStr::new("HOME"), + OsStr::new("SystemRoot"), + OsStr::new("USERPROFILE"), + ] { if let Some((_, value)) = base_env.iter().rev().find(|(name, _)| name == key) { command.env(key, value); } } command .env("GIT_CONFIG_NOSYSTEM", "1") - .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_GLOBAL", empty_global_config()) .env("GIT_TERMINAL_PROMPT", "0") .env("GIT_AUTHOR_NAME", "Basal Test") .env("GIT_AUTHOR_EMAIL", "basal-test@example.com") @@ -35,7 +40,17 @@ fn git_command_with_env(base_env: impl IntoIterator command } -#[cfg(test)] +fn empty_global_config() -> &'static std::path::Path { + static CONFIG: std::sync::OnceLock = std::sync::OnceLock::new(); + CONFIG.get_or_init(|| { + let dir = crate::harness::scratch("empty-git-config"); + let path = dir.join("global.gitconfig"); + std::fs::write(&path, []).expect("empty git global config"); + path + }) +} + +#[cfg(all(test, unix))] mod tests { use std::os::unix::fs::PermissionsExt; diff --git a/crates/basal-testkit/src/https.rs b/crates/basal-testkit/src/https.rs index e018f2b..ac4c328 100644 --- a/crates/basal-testkit/src/https.rs +++ b/crates/basal-testkit/src/https.rs @@ -99,6 +99,7 @@ pub struct TestServer { stop: Arc, thread: Option>, #[cfg(test)] + #[cfg(unix)] blocking_accept: bool, } @@ -121,7 +122,7 @@ impl TestServer { .expect("server certificate"); let config = Arc::new(config); let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); - #[cfg(test)] + #[cfg(all(test, unix))] let blocking_accept = { use std::os::fd::AsRawFd; // SAFETY: listener owns this live socket; F_GETFL only reads flags. @@ -166,6 +167,7 @@ impl TestServer { stop, thread: Some(thread), #[cfg(test)] + #[cfg(unix)] blocking_accept, } } @@ -299,6 +301,7 @@ mod tests { let server = TestServer::start(|_| BTreeMap::new()); let connections = server.connections.clone(); let seen = server.seen.clone(); + #[cfg(unix)] assert!( server.blocking_accept, "the accept loop must block instead of polling" diff --git a/crates/basal-testkit/src/lib.rs b/crates/basal-testkit/src/lib.rs index 6561ec4..051179f 100644 --- a/crates/basal-testkit/src/lib.rs +++ b/crates/basal-testkit/src/lib.rs @@ -10,6 +10,7 @@ mod backoff; pub mod binaries; pub mod channel; +pub mod command; pub mod fuzz; pub mod git; pub mod harness; diff --git a/crates/basal-testkit/src/process.rs b/crates/basal-testkit/src/process.rs index 0ee4dff..8b6bb3a 100644 --- a/crates/basal-testkit/src/process.rs +++ b/crates/basal-testkit/src/process.rs @@ -1,9 +1,12 @@ //! A real `ck-basal-worker` child process, driven over its stdio frames. +use crate::command::Command; use std::fmt; use std::io::{self, Read, Write}; use std::path::Path; -use std::process::{Child, ChildStdin, Command, ExitStatus, Stdio}; +use std::process::ExitStatus; +#[cfg(unix)] +use std::process::{Child, ChildStdin, Stdio}; use std::sync::mpsc::{self, Receiver, RecvTimeoutError}; use std::sync::{Arc, Mutex}; use std::thread; @@ -44,6 +47,15 @@ impl std::error::Error for ParentError {} type Incoming = Result; +#[cfg(windows)] +pub(crate) mod windows; +#[cfg(windows)] +use windows::Child; +#[cfg(windows)] +pub use windows::terminate_process; +#[cfg(windows)] +type ChildStdin = std::fs::File; + /// A spawned worker. Killed on drop. pub struct WorkerProcess { child: Child, @@ -62,19 +74,24 @@ impl WorkerProcess { } pub fn spawn_with_args(binary: &Path, args: &[&str]) -> io::Result { - let binary = crate::dev_binary(binary); - let mut command = Command::new(binary); - command.args(args); - #[cfg(target_os = "linux")] - if !args.iter().any(|arg| arg.starts_with("--landlock=")) { - command.arg("--landlock=required"); - } - let mut child = command - .env_clear() - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn()?; + let binary = crate::binaries::dev_worker_binary(binary); + #[cfg(windows)] + let mut child = windows::spawn_worker(&binary, args)?; + #[cfg(unix)] + let mut child = { + let mut command = Command::new(binary); + command.args(args); + #[cfg(target_os = "linux")] + if !args.iter().any(|arg| arg.starts_with("--landlock=")) { + command.arg("--landlock=required"); + } + command + .env_clear() + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()? + }; let stdin = child.stdin.take(); let mut stdout = child .stdout @@ -251,7 +268,11 @@ pub fn rss_kib(pid: u32) -> Option { } Some(unsafe { info.assume_init() }.pti_resident_size / 1024) } - #[cfg(not(target_os = "macos"))] + #[cfg(windows)] + { + windows::rss_kib(pid) + } + #[cfg(all(unix, not(target_os = "macos")))] { let out = Command::new("ps") .args(["-o", "rss=", "-p", &pid.to_string()]) @@ -270,25 +291,31 @@ impl Drop for WorkerProcess { /// Probe the operating system without waiting for an exit. A successful /// process-exit wait can hide a missing reap in the cleanup path. pub fn assert_reaped(pid: u32) { - let mut status = 0; - // SAFETY: waitpid writes only to this status and the caller's child pid. - let result = unsafe { - libc::waitpid( - pid.try_into().expect("child pid"), - &mut status, - libc::WNOHANG, - ) - }; - assert_eq!(result, -1, "worker {pid} was not reaped"); - assert_eq!( - std::io::Error::last_os_error().raw_os_error(), - Some(libc::ECHILD) - ); + #[cfg(windows)] + windows::assert_reaped(pid); + #[cfg(unix)] + { + let mut status = 0; + // SAFETY: waitpid writes only to this status and the caller's child pid. + let result = unsafe { + libc::waitpid( + pid.try_into().expect("child pid"), + &mut status, + libc::WNOHANG, + ) + }; + assert_eq!(result, -1, "worker {pid} was not reaped"); + assert_eq!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::ECHILD) + ); + } } /// Capture a subprocess with a deadline, draining both pipes concurrently. /// Timeout kills the process group, so inherited pipes cannot keep a reader /// alive after the parent has been reaped. +#[cfg(unix)] pub fn output_until(command: &mut Command, timeout: Duration) -> io::Result { use std::os::unix::process::CommandExt; // SAFETY: setpgid is async-signal-safe and touches no Rust-owned memory. @@ -351,9 +378,15 @@ pub fn output_until(command: &mut Command, timeout: Duration) -> io::Result io::Result { + command.output_until(timeout) +} + #[cfg(test)] mod tests { use super::*; + #[cfg(unix)] #[test] fn subprocess_deadline_reaps_descendants_holding_output_pipes() { let error = output_until( diff --git a/crates/basal-testkit/src/process/windows.rs b/crates/basal-testkit/src/process/windows.rs new file mode 100644 index 0000000..01ee520 --- /dev/null +++ b/crates/basal-testkit/src/process/windows.rs @@ -0,0 +1,204 @@ +//! Native worker ownership and process observations for the Windows test parent. + +use basal_launch::{ConfinedProcess, LaunchOptions}; +use std::collections::HashMap; +use std::fs::File; +use std::io; +use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; +use std::os::windows::process::ExitStatusExt; +use std::path::Path; +use std::process::ExitStatus; +use std::sync::{Arc, Mutex, OnceLock, Weak}; +use windows_sys::Win32::Foundation::WAIT_OBJECT_0; +use windows_sys::Win32::System::ProcessStatus::{K32GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS}; +use windows_sys::Win32::System::Threading::{ + OpenProcess, PROCESS_QUERY_INFORMATION, PROCESS_TERMINATE, PROCESS_VM_READ, TerminateProcess, + WaitForSingleObject, +}; + +struct ReapState { + process: Weak, + signaled: bool, +} + +fn processes() -> &'static Mutex> { + static PROCESSES: OnceLock>> = OnceLock::new(); + PROCESSES.get_or_init(Mutex::default) +} + +pub(super) struct Child { + process: Option>, + pid: u32, + status: Option, + pub stdin: Option, + pub stdout: Option, + pub stderr: Option, +} + +pub(super) fn spawn_worker(binary: &Path, args: &[&str]) -> io::Result { + let mut options = LaunchOptions::new(binary, basal_proto::FLOW_JOB_COMMIT_BYTES); + options.args = args.iter().map(|arg| (*arg).into()).collect(); + let mut process = basal_launch::launch(&options).map_err(io::Error::other)?; + let stdin = process.stdin.take(); + let stdout = process.stdout.take(); + let stderr = process.stderr.take(); + let process = Arc::new(process); + let pid = process.id(); + processes().lock().unwrap().insert( + pid, + ReapState { + process: Arc::downgrade(&process), + signaled: false, + }, + ); + Ok(Child { + process: Some(process), + pid, + status: None, + stdin, + stdout, + stderr, + }) +} + +impl Child { + pub fn id(&self) -> u32 { + self.pid + } + + pub fn kill(&self) -> io::Result<()> { + self.process + .as_ref() + .map_or(Ok(()), |process| process.kill()) + } + + fn release(&mut self, code: u32) -> ExitStatus { + let process = self.process.take().expect("owned process before release"); + // Observe the real handle before releasing it, rather than treating a + // cached exit code or the disappearance of a pid as evidence of exit. + let signaled = unsafe { WaitForSingleObject(process.as_raw_handle(), 0) } == WAIT_OBJECT_0; + processes() + .lock() + .unwrap() + .get_mut(&self.pid) + .unwrap() + .signaled = signaled; + drop(process); + let status = ExitStatus::from_raw(code); + self.status = Some(status); + status + } + + pub fn try_wait(&mut self) -> io::Result> { + if let Some(status) = self.status { + return Ok(Some(status)); + } + let code = self.process.as_ref().unwrap().try_wait()?; + Ok(code.map(|code| self.release(code))) + } + + pub fn wait(&mut self) -> io::Result { + if let Some(status) = self.status { + return Ok(status); + } + let code = self.process.as_ref().unwrap().wait()?; + Ok(self.release(code)) + } +} + +pub(super) fn assert_reaped(pid: u32) { + let state = processes() + .lock() + .unwrap() + .get(&pid) + .map(|state| (state.process.clone(), state.signaled)); + let (process, signaled) = state.expect("process was launched by the test kit"); + assert!(signaled, "worker {pid}'s process handle was not signaled"); + assert!( + process.upgrade().is_none(), + "worker {pid}'s process handle is still owned" + ); +} + +pub(super) fn rss_kib(pid: u32) -> Option { + // SAFETY: OpenProcess returns a new owned handle, or NULL on failure. + let handle = unsafe { OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, 0, pid) }; + if handle.is_null() { + return None; + } + let handle = unsafe { OwnedHandle::from_raw_handle(handle) }; + let mut counters: PROCESS_MEMORY_COUNTERS = unsafe { std::mem::zeroed() }; + let size = std::mem::size_of_val(&counters) as u32; + counters.cb = size; + // SAFETY: counters is writable for exactly the declared structure size. + if unsafe { K32GetProcessMemoryInfo(handle.as_raw_handle(), &mut counters, size) } == 0 { + return None; + } + Some(counters.WorkingSetSize as u64 / 1024) +} + +pub fn terminate_process(pid: u32) -> io::Result<()> { + // SAFETY: OpenProcess returns a fresh owned handle, or NULL on failure. + let handle = unsafe { OpenProcess(PROCESS_TERMINATE, 0, pid) }; + if handle.is_null() { + return Err(io::Error::last_os_error()); + } + let handle = unsafe { OwnedHandle::from_raw_handle(handle) }; + if unsafe { TerminateProcess(handle.as_raw_handle(), basal_launch::KILL_EXIT_CODE) } == 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +pub(crate) fn output_until( + command: &basal_launch::PlainCommand, + timeout: std::time::Duration, +) -> io::Result { + use std::io::Read; + use std::time::{Duration, Instant}; + let mut child = basal_launch::spawn_plain(command).map_err(io::Error::other)?; + let mut stdout = child.stdout.take().expect("stdout pipe"); + let mut stderr = child.stderr.take().expect("stderr pipe"); + let out = std::thread::spawn(move || { + let mut bytes = Vec::new(); + stdout.read_to_end(&mut bytes).map(|_| bytes) + }); + let err = std::thread::spawn(move || { + let mut bytes = Vec::new(); + stderr.read_to_end(&mut bytes).map(|_| bytes) + }); + let deadline = Instant::now() + timeout; + let mut timed_out = false; + let status = loop { + match child.try_wait() { + Ok(Some(code)) => break Ok(ExitStatus::from_raw(code)), + Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(10)), + result => { + timed_out = result.is_ok(); + child.kill()?; + break child.wait().map(ExitStatus::from_raw); + } + } + }; + // Even a normally exited parent can leave a descendant holding a pipe. + // End the entire job before joining readers, never only the root process. + child.kill()?; + drop(child); + let stdout = out + .join() + .map_err(|_| io::Error::other("stdout reader panicked"))??; + let stderr = err + .join() + .map_err(|_| io::Error::other("stderr reader panicked"))??; + if timed_out { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "subprocess deadline expired; job killed and process released", + )); + } + Ok(std::process::Output { + status: status?, + stdout, + stderr, + }) +} diff --git a/crates/basal-testkit/tests/bench_args.rs b/crates/basal-testkit/tests/bench_args.rs index b6657d6..70d214c 100644 --- a/crates/basal-testkit/tests/bench_args.rs +++ b/crates/basal-testkit/tests/bench_args.rs @@ -1,4 +1,4 @@ -use std::process::Command; +use basal_testkit::command::Command; #[test] fn worker_benchmark_refuses_invalid_arguments_before_spawning() { diff --git a/crates/basal-testkit/tests/builtins_dispatch.rs b/crates/basal-testkit/tests/builtins_dispatch.rs index f227316..d01f5e1 100644 --- a/crates/basal-testkit/tests/builtins_dispatch.rs +++ b/crates/basal-testkit/tests/builtins_dispatch.rs @@ -12,7 +12,7 @@ use std::time::Duration; use basal_core::{ Config, InstallError, InstallRequest, NoHooks, RunState, Runtime, Store, StoredClass, Warning, }; -use basal_host::builtins::{self, BuiltinHost}; +use basal_host::builtins::{self, BuiltinHost, codes}; use basal_host::{ CallClass, CallRequest, CompletionSink, Dispatched, Host, HostOutcome, InstallStatus, TransportError, @@ -217,7 +217,9 @@ fn calls_outside_the_manifest_are_refused_in_the_parent_and_journaled() { outside = files.outside.join("secret.txt").display().to_string(), escape = files .root - .join("../outside/secret.txt") + .join("..") + .join("outside") + .join("secret.txt") .display() .to_string(), root = files.root.display().to_string(), @@ -225,12 +227,19 @@ fn calls_outside_the_manifest_are_refused_in_the_parent_and_journaled() { let run_id = admit_with(&rt, &world, &script, &files.manifest()); let run = finish(&rt, &world, &run_id); assert_eq!(run.state, RunState::Succeeded, "{run:#?}"); + // Windows rejects raw relative components as malformed arguments; Unix + // resolves the spelling and refuses the resulting outside-root target. + let escape_code = if cfg!(windows) { + codes::INVALID_ARGUMENTS + } else { + codes::DENIED + }; assert_eq!( result(&run), json!({ "inside": "PRIVATE-FILE-TEXT", "outside": "denied", - "escape": "denied", + "escape": escape_code, "git": "denied", "host": "denied", "method": "denied", @@ -245,7 +254,11 @@ fn calls_outside_the_manifest_are_refused_in_the_parent_and_journaled() { assert_eq!(outcome.settlement, Settlement::Rejected, "{row:?}"); assert_eq!( code_of(&outcome.value).as_deref(), - Some("denied"), + Some(if row.position == 2 { + escape_code + } else { + codes::DENIED + }), "{row:?}" ); } @@ -265,7 +278,7 @@ fn calls_outside_the_manifest_are_refused_in_the_parent_and_journaled() { [ ("fs.read", "allowed"), ("fs.read", "denied"), - ("fs.read", "denied"), + ("fs.read", escape_code), ("git.log", "denied"), ("net.fetch", "denied"), ("net.fetch", "denied"), @@ -453,7 +466,7 @@ fn install_refuses_bad_roots_hosts_and_methods() { ), ( "dotdot root", - json!({ "fs": { "read": [format!("{root}/../outside")] } }), + json!({ "fs": { "read": [files.root.join("..").join("outside").display().to_string()] } }), ), ( "missing read root", diff --git a/crates/basal-testkit/tests/builtins_fs.rs b/crates/basal-testkit/tests/builtins_fs.rs index 7755d74..15ceec8 100644 --- a/crates/basal-testkit/tests/builtins_fs.rs +++ b/crates/basal-testkit/tests/builtins_fs.rs @@ -5,10 +5,13 @@ //! reads refuse text that is not UTF-8 or is over the cap; writes replace //! the whole file and never through a symlink. +#[cfg(unix)] use std::os::unix::fs::symlink; use std::path::PathBuf; -use basal_host::builtins::fs::{self, Purpose, Target}; +use basal_host::builtins::fs; +#[cfg(unix)] +use basal_host::builtins::fs::{Purpose, Target}; use basal_host::builtins::{BuiltinHost, Denial, Failure, Grant, codes, envelope}; use basal_proto::Primitive; use basal_testkit::harness::scratch; @@ -44,7 +47,16 @@ impl Tree { } fn p(&self, rel: &str) -> String { - self.root.join(rel).display().to_string() + let path = if rel.is_empty() { + self.root.clone() + } else { + self.root.join(rel) + }; + if cfg!(windows) { + path.display().to_string().replace('/', "\\") + } else { + path.display().to_string() + } } } @@ -69,14 +81,25 @@ fn reads_inside_a_root_and_refuses_a_dotdot_escape() { fs::read(&t.p("a.txt"), &roots, 1024).expect("read"), json!({ "text": "inside" }) ); + #[cfg(unix)] assert_eq!( fs::read(&t.p("sub/../a.txt"), &roots, 1024).expect("read"), json!({ "text": "inside" }) ); let escape = t.p("../outside/secret.txt"); - assert_eq!(code(fs::read(&escape, &roots, 1024)), codes::DENIED); - assert_eq!(code(fs::stat(&escape, &roots)), codes::DENIED); - assert_eq!(code(fs::list(&t.p(".."), &roots)), codes::DENIED); + let escape_code = if cfg!(windows) { + codes::INVALID_ARGUMENTS + } else { + codes::DENIED + }; + #[cfg(windows)] + assert_eq!( + code(fs::read(&t.p("sub/../a.txt"), &roots, 1024)), + codes::INVALID_ARGUMENTS + ); + assert_eq!(code(fs::read(&escape, &roots, 1024)), escape_code); + assert_eq!(code(fs::stat(&escape, &roots)), escape_code); + assert_eq!(code(fs::list(&t.p(".."), &roots)), escape_code); // A sibling whose name only starts with the root's is not under it. let sibling = t.base.join("rootx"); std::fs::create_dir_all(&sibling).expect("sibling"); @@ -97,6 +120,7 @@ fn reads_inside_a_root_and_refuses_a_dotdot_escape() { } #[test] +#[cfg(unix)] fn a_symlink_resolving_outside_the_root_is_refused() { let t = Tree::new("fs-symlink"); let roots = t.roots(); @@ -154,7 +178,9 @@ fn stat_of_a_missing_path_is_exists_false_only_when_its_parent_is_in_scope() { fn list_names_entries_and_their_kinds() { let t = Tree::new("fs-list"); let roots = t.roots(); + #[cfg(unix)] symlink(t.root.join("a.txt"), t.root.join("link")).expect("link"); + #[cfg(unix)] assert_eq!( fs::list(&t.p(""), &roots).expect("list"), json!([ @@ -163,6 +189,13 @@ fn list_names_entries_and_their_kinds() { { "name": "sub", "kind": "dir" }, ]) ); + #[cfg(windows)] + assert_eq!( + fs::list(&t.p(""), &roots).expect("list"), + json!([ + { "name": "a.txt", "kind": "file" }, { "name": "sub", "kind": "dir" } + ]) + ); for i in 0..=fs::MAX_LIST_ENTRIES { std::fs::write(t.root.join(format!("sub/f{i}")), "").expect("file"); } @@ -225,21 +258,37 @@ fn write_replaces_the_whole_file_inside_a_write_root_only() { .collect(); assert!(names.iter().all(|n| !n.contains(".basal-")), "{names:?}"); - // Outside the root, through `..`, through a directory symlink, or onto - // a symlink: refused, and nothing outside changes. - symlink(&t.outside, t.root.join("out-dir")).expect("dir link"); - symlink(t.outside.join("secret.txt"), t.root.join("out-link")).expect("link"); - for path in [ - t.p("../outside/new.txt"), - t.p("out-dir/new.txt"), - t.p("out-link"), - ] { - assert_eq!( - code(fs::write(&path, &roots, "pwned")), - codes::DENIED, - "{path}" - ); + // Raw relative components are refused on Windows before resolution. + #[cfg(windows)] + assert_eq!( + code(fs::write(&t.p("../outside/new.txt"), &roots, "pwned")), + codes::INVALID_ARGUMENTS + ); + // Unix permits symlink creation without an elevated test account. + #[cfg(unix)] + { + symlink(&t.outside, t.root.join("out-dir")).expect("dir link"); + symlink(t.outside.join("secret.txt"), t.root.join("out-link")).expect("link"); + for path in [ + t.p("../outside/new.txt"), + t.p("out-dir/new.txt"), + t.p("out-link"), + ] { + assert_eq!( + code(fs::write(&path, &roots, "pwned")), + codes::DENIED, + "{path}" + ); + } } + assert_eq!( + code(fs::write( + t.outside.join("new.txt").to_str().unwrap(), + &roots, + "pwned" + )), + codes::DENIED + ); assert_eq!( std::fs::read_to_string(t.outside.join("secret.txt")).expect("secret"), "secret" @@ -259,6 +308,7 @@ fn write_replaces_the_whole_file_inside_a_write_root_only() { /// component is then swapped for a symlink to a file outside. The open /// refuses to follow it. #[test] +#[cfg(unix)] fn a_symlink_swapped_into_the_last_component_after_the_check_is_not_followed() { let t = Tree::new("fs-swap-last"); let roots = t.roots(); @@ -278,6 +328,7 @@ fn a_symlink_swapped_into_the_last_component_after_the_check_is_not_followed() { /// directory outside after the check. The open succeeds, but the opened /// file's real path lies outside the root, so it is refused. #[test] +#[cfg(unix)] fn a_directory_swapped_for_a_symlink_after_the_check_is_caught_after_the_open() { let t = Tree::new("fs-swap-dir"); let roots = t.roots(); diff --git a/crates/basal-testkit/tests/builtins_git.rs b/crates/basal-testkit/tests/builtins_git.rs index 712cd8e..dc1787c 100644 --- a/crates/basal-testkit/tests/builtins_git.rs +++ b/crates/basal-testkit/tests/builtins_git.rs @@ -2,24 +2,34 @@ //! the answers, the approved-repository check, argument checks, and that a //! repository's own configuration cannot make a built-in run a program. -use basal_host::builtins::git::{self, Op, hardened_command, run_command}; +use basal_host::builtins::git::{self, Op}; +#[cfg(unix)] +use basal_host::builtins::git::{hardened_command, run_command}; use basal_host::builtins::{Denial, codes}; use basal_proto::Primitive; use basal_testkit::git::git_command; use basal_testkit::harness::scratch; use serde_json::{Value, json}; +#[cfg(unix)] use std::os::unix::process::ExitStatusExt; use std::path::{Path, PathBuf}; fn child_exit(status: &std::process::ExitStatus) -> String { - match status.code() { - Some(code) => format!("exit code {code}"), - None => format!( - "terminated by signal {}", - status - .signal() - .map_or_else(|| "unknown".to_owned(), |signal| signal.to_string()) - ), + #[cfg(windows)] + { + status.to_string() + } + #[cfg(unix)] + { + match status.code() { + Some(code) => format!("exit code {code}"), + None => format!( + "terminated by signal {}", + status + .signal() + .map_or_else(|| "unknown".to_owned(), |signal| signal.to_string()) + ), + } } } @@ -160,6 +170,7 @@ fn git_reads_only_approved_repositories() { }; let refused = git::run(&other.path(), &approved.repos(), &op).expect_err("refused"); assert_eq!(refused.code, codes::DENIED); + assert!(git::run(&approved.path(), &approved.repos(), &op).is_ok()); // A directory inside an approved repository is not itself approved. std::fs::create_dir_all(approved.dir.join("sub")).expect("sub"); let sub = approved.dir.join("sub").display().to_string(); @@ -169,10 +180,16 @@ fn git_reads_only_approved_repositories() { .code, codes::DENIED ); - // The same repository reached through `..` resolves to the approved - // path and is allowed. - let around = approved.dir.join("sub/..").display().to_string(); + // Unix canonicalizes relative components; Windows refuses them before + // opening a root, just as the Windows filesystem built-in does. + let around = approved.dir.join("sub").join("..").display().to_string(); + #[cfg(unix)] assert!(git::run(&around, &approved.repos(), &op).is_ok()); + #[cfg(windows)] + assert_eq!( + git::run(&around, &approved.repos(), &op).unwrap_err().code, + codes::DENIED + ); } #[test] @@ -201,6 +218,7 @@ fn arguments_that_git_could_read_as_options_are_refused() { } /// A script `name` in `dir` that appends its name to `marker` when run. +#[cfg(unix)] fn plant(dir: &Path, name: &str, marker: &Path) -> PathBuf { let path = dir.join(name); std::fs::write( @@ -220,6 +238,7 @@ fn plant(dir: &Path, name: &str, marker: &Path) -> PathBuf { /// while plain git in the same repository does run the fsmonitor program, /// which shows the plant works. #[test] +#[cfg(unix)] fn a_repository_config_cannot_make_a_built_in_run_a_program() { let repo = Repo::new("git-fsmonitor"); let marker = repo.base.join("marker"); diff --git a/crates/basal-testkit/tests/fs_write_temps.rs b/crates/basal-testkit/tests/fs_write_temps.rs index 8b0d3d9..12718ca 100644 --- a/crates/basal-testkit/tests/fs_write_temps.rs +++ b/crates/basal-testkit/tests/fs_write_temps.rs @@ -11,6 +11,7 @@ mod common; +#[cfg(unix)] use std::os::unix::fs::symlink; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, mpsc}; @@ -170,7 +171,7 @@ impl Files { } fn out(&self) -> PathBuf { - self.root.join("sub/out.txt") + self.root.join("sub").join("out.txt") } fn secret(&self) -> PathBuf { @@ -323,11 +324,14 @@ fn a_cancelled_runs_write_that_dies_mid_flight_is_cleaned_up() { enum Tamper { /// Nothing: recovery removes it. None, + #[cfg(unix)] /// The file was replaced by a symlink to a file outside the root. SymlinkedTemp, + #[cfg(unix)] /// The directory was moved outside the root and a symlink to it put in /// its place. DirectoryMovedOutside, + #[cfg(unix)] /// The directory was moved elsewhere inside the root and a symlink to it /// put in its place. DirectoryMovedWithin, @@ -369,8 +373,11 @@ fn crash_mid_write(world: &World, files: &Files, manifest: &Value) -> (String, T fn a_crash_mid_write_is_cleaned_up_by_recovery_without_following_a_swapped_path() { for tamper in [ Tamper::None, + #[cfg(unix)] Tamper::SymlinkedTemp, + #[cfg(unix)] Tamper::DirectoryMovedOutside, + #[cfg(unix)] Tamper::DirectoryMovedWithin, ] { let world = World::new("fs-temps-crash"); @@ -380,19 +387,22 @@ fn a_crash_mid_write_is_cleaned_up_by_recovery_without_following_a_swapped_path( assert!(temp.is_file(), "{tamper:?}: {temp:?}"); let sub = files.root.join("sub"); // Where the recorded file is after tampering, if it is anywhere. - let left = match tamper { + let left: Option = match tamper { Tamper::None => None, + #[cfg(unix)] Tamper::SymlinkedTemp => { std::fs::remove_file(&temp).expect("remove"); symlink(files.secret(), &temp).expect("symlink"); Some(temp.clone()) } + #[cfg(unix)] Tamper::DirectoryMovedOutside => { let moved = files.outside.join("sub"); std::fs::rename(&sub, &moved).expect("move out"); symlink(&moved, &sub).expect("symlink"); Some(moved.join(&lease.temp)) } + #[cfg(unix)] Tamper::DirectoryMovedWithin => { let moved = files.root.join("moved"); std::fs::rename(&sub, &moved).expect("move within"); @@ -411,7 +421,10 @@ fn a_crash_mid_write_is_cleaned_up_by_recovery_without_following_a_swapped_path( Some(left) => { let meta = std::fs::symlink_metadata(&left) .unwrap_or_else(|e| panic!("{tamper:?}: {left:?} was removed: {e}")); + #[cfg(unix)] assert_eq!(meta.is_symlink(), tamper == Tamper::SymlinkedTemp); + #[cfg(windows)] + assert!(!meta.is_symlink()); } } assert_eq!( @@ -437,6 +450,7 @@ fn a_crash_mid_write_is_cleaned_up_by_recovery_without_following_a_swapped_path( /// directory still exists under its recorded path, but no longer lies in /// the root the write was granted, so its file is not removed. #[test] +#[cfg(unix)] fn a_crash_record_whose_directory_left_its_root_is_not_acted_on() { let world = World::new("fs-temps-retarget"); let files = Files::new("fs-temps-retarget-files"); @@ -495,6 +509,7 @@ fn legacy_temporary_files_are_removed_once_from_written_directories() { for name in keep { std::fs::write(sub.join(name), "keep").expect("decoy"); } + #[cfg(unix)] symlink(files.secret(), sub.join(".basal-55-6.tmp")).expect("symlink"); std::fs::write(files.root.join(".basal-7-8.tmp"), "unwritten dir").expect("root stray"); std::fs::write(files.outside.join(".basal-9-9.tmp"), "outside").expect("outside"); @@ -507,9 +522,11 @@ fn legacy_temporary_files_are_removed_once_from_written_directories() { assert!(!sub.join(".basal-123-4.tmp").exists()); let mut expected: Vec = keep.iter().map(|n| (*n).to_owned()).collect(); + #[cfg(unix)] expected.push(".basal-55-6.tmp".to_owned()); expected.sort(); assert_eq!(temps_in(&sub), expected); + #[cfg(unix)] assert!( std::fs::symlink_metadata(sub.join(".basal-55-6.tmp")) .expect("symlink kept") @@ -539,7 +556,12 @@ fn legacy_temporary_files_are_removed_once_from_written_directories() { fn remove_temp_removes_only_a_recorded_regular_file() { let files = Files::new("fs-temps-remove"); let sub = files.root.join("sub"); + #[cfg(unix)] let dir = std::fs::canonicalize(&sub).expect("real dir"); + #[cfg(windows)] + // Windows leases retain the write's drive-path spelling. Canonicalization + // could expand a short-name alias while the grant retains its original spelling. + let dir = sub.clone(); let roots = vec![files.root.display().to_string()]; let lease = |temp: &str| TempLease { call_key: "k".into(), diff --git a/crates/basal-testkit/tests/journal_kill.rs b/crates/basal-testkit/tests/journal_kill.rs index 310449a..819af56 100644 --- a/crates/basal-testkit/tests/journal_kill.rs +++ b/crates/basal-testkit/tests/journal_kill.rs @@ -2,9 +2,10 @@ //! representative run, and separately of the worker. Every killed run must //! recover to the uncut run's final state and effect counts. +use basal_testkit::command::Command; +#[cfg(unix)] use std::os::unix::process::ExitStatusExt; use std::path::Path; -use std::process::Command; use std::sync::atomic::Ordering; use std::sync::{Arc, Mutex}; @@ -27,7 +28,10 @@ fn parent(dir: &Path, kill_at: Option<&Point>) -> (bool, Option, String) } let out = basal_testkit::process::output_until(&mut cmd, std::time::Duration::from_secs(950)) .expect("test parent must finish within its cleanup deadline"); + #[cfg(unix)] let killed = out.status.signal() == Some(libc::SIGKILL); + #[cfg(windows)] + let killed = out.status.code() == Some(basal_launch::KILL_EXIT_CODE as i32); let json = String::from_utf8_lossy(&out.stdout) .lines() .last() @@ -133,6 +137,10 @@ fn killing_the_worker_at_every_boundary_recovers_to_the_uncut_state() { let probe = Arc::new(Probe::act_at(point.clone(), move |_, _| { let pid = source.counts.live_pid.load(Ordering::SeqCst); if pid != 0 { + #[cfg(windows)] + basal_testkit::process::terminate_process(pid) + .expect("terminate live worker"); + #[cfg(unix)] // SAFETY: kill(2) has no memory-safety // preconditions; the pid is a worker this test // spawned and has not reaped. diff --git a/crates/basal-testkit/tests/windows_process.rs b/crates/basal-testkit/tests/windows_process.rs new file mode 100644 index 0000000..37e4a03 --- /dev/null +++ b/crates/basal-testkit/tests/windows_process.rs @@ -0,0 +1,161 @@ +#![cfg(windows)] + +use basal_testkit::command::Command; +use basal_testkit::harness::scratch; +use basal_testkit::process::{assert_reaped, output_until}; +use basal_testkit::{WorkerProcess, worker_binary}; +use std::path::Path; +use std::time::Duration; + +fn pipe_tree(dir: &Path, hold_parent: bool) -> Command { + let powershell = std::path::PathBuf::from(std::env::var_os("SystemRoot").unwrap()) + .join("System32") + .join("WindowsPowerShell") + .join("v1.0") + .join("powershell.exe"); + let pid_file = dir.join("descendant.pid"); + let quoted_path = pid_file.display().to_string().replace('\'', "''"); + // Start-Process without redirection inherits the parent's standard pipes. + // The child publishes its pid before waiting, so a missing job kill would + // leave both a live process and a pipe reader that cannot finish. + let script = format!( + "$p = Start-Process -PassThru -NoNewWindow -FilePath '{}' -ArgumentList '-NoProfile','-Command','\"[IO.File]::WriteAllText(''{}'', [string]$PID); Start-Sleep -Seconds 600\"'; while (!(Test-Path '{}')) {{ Start-Sleep -Milliseconds 10 }}; {}", + powershell.display(), + quoted_path, + quoted_path, + if hold_parent { + "Start-Sleep -Seconds 600" + } else { + "exit 0" + }, + ); + let mut command = Command::new(powershell); + command + .args(["-NoProfile", "-NonInteractive", "-Command"]) + .arg(script); + command +} + +fn assert_descendant_dead(dir: &Path) { + use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; + use windows_sys::Win32::Foundation::WAIT_OBJECT_0; + use windows_sys::Win32::System::Threading::{ + OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, WaitForSingleObject, + }; + let pid: u32 = std::fs::read_to_string(dir.join("descendant.pid")) + .expect("descendant readiness") + .trim() + .parse() + .unwrap(); + // SYNCHRONIZE is a standard access right, not a process-specific flag. + let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION | 0x0010_0000, 0, pid) }; + if handle.is_null() { + assert_eq!( + std::io::Error::last_os_error().raw_os_error(), + Some(87), + "only an exited, released process may disappear" + ); + return; + } + let handle = unsafe { OwnedHandle::from_raw_handle(handle) }; + assert_eq!( + unsafe { WaitForSingleObject(handle.as_raw_handle(), 30_000) }, + WAIT_OBJECT_0, + "descendant survived job cleanup" + ); +} + +#[test] +fn subprocess_deadline_reaps_descendants_holding_output_pipes() { + let dir = scratch("windows-pipe-deadline"); + let mut command = pipe_tree(&dir, true); + let error = output_until(&mut command, Duration::from_secs(10)).unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::TimedOut); + assert_descendant_dead(&dir); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn subprocess_exit_reaps_descendants_holding_output_pipes() { + let dir = scratch("windows-pipe-exit"); + let output = output_until(&mut pipe_tree(&dir, false), Duration::from_secs(30)).unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_descendant_dead(&dir); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn worker_memory_sampling_and_reaping_observe_native_process_state() { + let (mut worker, _) = WorkerProcess::start(&worker_binary(), Duration::from_secs(60)).unwrap(); + let pid = worker.pid(); + assert!(worker.rss_kib().is_some_and(|kib| kib > 0)); + assert!( + std::panic::catch_unwind(|| assert_reaped(pid)).is_err(), + "a running worker is not reaped" + ); + worker.close_stdin(); + assert!(worker.wait_exit(Duration::from_secs(60)).unwrap().success()); + assert_reaped(pid); + drop(worker); + assert_reaped(pid); +} + +#[test] +fn discovered_worker_launches_with_full_confinement_without_an_extra_acl_grant() { + use basal_proto::{ + Confinement, PROTOCOL_VERSION, ParentMessage, WorkerMessage, encode_parent_frame, + read_worker_message, + }; + use std::io::Write; + let binary = worker_binary(); + assert_eq!(binary, basal_testkit::channel::worker_binary()); + assert!( + binary + .file_name() + .unwrap() + .to_string_lossy() + .starts_with("ckdev-") + ); + let options = basal_launch::LaunchOptions::new(&binary, basal_proto::FLOW_JOB_COMMIT_BYTES); + let mut child = basal_launch::launch(&options).unwrap(); + child + .stdin + .as_mut() + .unwrap() + .write_all( + &encode_parent_frame(&ParentMessage::Hello { + protocol_version: PROTOCOL_VERSION, + }) + .unwrap(), + ) + .unwrap(); + let mut stdout = child.stdout.take().unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + let reader = std::thread::spawn(move || { + let _ = tx.send(read_worker_message(&mut stdout)); + }); + let frame = rx + .recv_timeout(Duration::from_secs(60)) + .expect("handshake before hang deadline") + .unwrap(); + let WorkerMessage::Welcome(welcome) = frame else { + panic!("expected welcome, got {frame:?}"); + }; + assert_eq!( + welcome.confinement, + Confinement::Windows { + lpac: true, + untrusted: true, + no_thread_token: true, + mitigations: true, + handle_table: true + } + ); + child.stdin.take(); + assert_eq!(child.wait().unwrap(), 0); + reader.join().unwrap(); +} diff --git a/crates/basal-testkit/tests/worker_discovery.rs b/crates/basal-testkit/tests/worker_discovery.rs index 53c3776..2175dc3 100644 --- a/crates/basal-testkit/tests/worker_discovery.rs +++ b/crates/basal-testkit/tests/worker_discovery.rs @@ -1,18 +1,27 @@ //! Cargo owns the build-time worker path; runtime discovery never invokes it. +use basal_testkit::command::Command; +#[cfg(unix)] use std::os::unix::fs::PermissionsExt; +#[cfg(unix)] use std::os::unix::process::ExitStatusExt; -use std::process::Command; fn child_exit(status: &std::process::ExitStatus) -> String { - match status.code() { - Some(code) => format!("exit code {code}"), - None => format!( - "terminated by signal {}", - status - .signal() - .map_or_else(|| "unknown".to_owned(), |signal| signal.to_string()) - ), + #[cfg(windows)] + { + status.to_string() + } + #[cfg(unix)] + { + match status.code() { + Some(code) => format!("exit code {code}"), + None => format!( + "terminated by signal {}", + status + .signal() + .map_or_else(|| "unknown".to_owned(), |signal| signal.to_string()) + ), + } } } @@ -49,13 +58,18 @@ fn every_test_process_uses_the_built_worker_without_invoking_cargo() { return; } let dir = basal_testkit::harness::scratch("worker-discovery"); + #[cfg(unix)] let cargo = dir.join("cargo-fixture"); + #[cfg(windows)] + let cargo = std::path::PathBuf::from(env!("CARGO_BIN_EXE_basal-test-parent")); let calls = dir.join("calls"); + #[cfg(unix)] std::fs::write( &cargo, "#!/bin/sh\necho build >> \"$BASAL_DISCOVERY_CALLS\"\nexit 1\n", ) .unwrap(); + #[cfg(unix)] std::fs::set_permissions(&cargo, std::fs::Permissions::from_mode(0o755)).unwrap(); for _ in 0..2 { let result = Command::new(std::env::current_exe().unwrap()) diff --git a/crates/basal-worker/Cargo.toml b/crates/basal-worker/Cargo.toml index 58349ed..3ed997c 100644 --- a/crates/basal-worker/Cargo.toml +++ b/crates/basal-worker/Cargo.toml @@ -19,9 +19,46 @@ rquickjs.workspace = true landlock = "=0.4.7" seccompiler = "=0.5.0" +# The worker's own startup checks on Windows: its tokens, mitigation +# policies, handle table and thread CPU time. Every function used here is +# exported by kernel32, advapi32 or ntdll, so the image imports no DLL that +# would pull User32, GDI or COM into the confined process. +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Security_Authorization", + "Win32_System_Console", + "Win32_System_Threading", + "Win32_Storage_FileSystem", + "Win32_System_IO", + "Win32_System_Memory", + "Win32_System_LibraryLoader", + "Win32_Networking_WinSock", + "Win32_System_Diagnostics_ToolHelp", + "Win32_System_Diagnostics_Debug", + "Win32_System_Registry", + "Win32_System_Pipes", +] } + +[features] +default = [] +# Test-only: lets the worker act on `--confinement-deviation=`, which +# basal-launch passes for the three startup checks the parent cannot break +# from outside (a thread token left in place, a failed or skipped integrity +# lowering). A worker built without this feature refuses that argument as +# unknown, so a production worker has no way to skip a check. +deviations = [] + [dev-dependencies] -basal-testkit.workspace = true serde_json.workspace = true +basal-testkit.workspace = true + +# On Windows every test spawn of the worker goes through the launcher, under +# the real confinement. Its test variants break one confinement property at a +# time, for the worker's refusal tests. +[target.'cfg(windows)'.dev-dependencies] +basal-launch = { workspace = true, features = ["deviations"] } [[bin]] name = "ck-basal-worker" diff --git a/crates/basal-worker/build.rs b/crates/basal-worker/build.rs index f468903..5f3d693 100644 --- a/crates/basal-worker/build.rs +++ b/crates/basal-worker/build.rs @@ -7,6 +7,19 @@ //! `CK_BUILD_GIT_DIRTY=false` certifies a clean tree. The same rule is in //! basal-module's build script; the worker keeps its own copy because it //! must not depend on the module's crates (`tests/dependency_fence.rs`). +//! +//! On Windows with the MSVC linker it also sets the worker's main-thread +//! stack reserve to 8 MiB, the size of the main thread's stack on macOS. +//! The worker runs JavaScript on its main thread with a QuickJS stack budget +//! of up to 4 MiB (`MAX_STACK_BYTES` in `src/engine.rs`); under the linker's +//! default 1 MiB reserve a deep recursion overflows the native stack and +//! kills the process before QuickJS can report the exhausted stack budget. +//! A reserve is address space, not committed memory, so the Windows job's +//! commit limit does not count it. `tests/windows_image.rs` checks the +//! reserve in the built image. + +/// The worker's main-thread stack reserve on Windows, in bytes. +const WINDOWS_STACK_RESERVE: u64 = 8 * 1024 * 1024; fn main() { println!("cargo:rerun-if-env-changed=CK_BUILD_GIT_SHA"); @@ -20,4 +33,12 @@ fn main() { revision.as_deref().unwrap_or("unknown") ); println!("cargo:rustc-env=BASAL_BUILD_GIT_DIRTY={dirty}"); + + // The build script runs on the host, so the target is read from Cargo's + // environment rather than from `cfg!`. + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + let target_env = std::env::var("CARGO_CFG_TARGET_ENV").unwrap_or_default(); + if target_os == "windows" && target_env == "msvc" { + println!("cargo:rustc-link-arg-bin=ck-basal-worker=/STACK:{WINDOWS_STACK_RESERVE}"); + } } diff --git a/crates/basal-worker/examples/windows_provenance.rs b/crates/basal-worker/examples/windows_provenance.rs new file mode 100644 index 0000000..38d61f7 --- /dev/null +++ b/crates/basal-worker/examples/windows_provenance.rs @@ -0,0 +1,12 @@ +//! One-off parent-side measurement of a production worker's startup handles. +//! This tool is not linked into the worker and is never run by ordinary tests. +#[cfg(windows)] +#[path = "windows_provenance/native.rs"] +mod native; + +#[cfg(windows)] +fn main() -> Result<(), String> { + native::run() +} +#[cfg(not(windows))] +fn main() {} diff --git a/crates/basal-worker/examples/windows_provenance/native.rs b/crates/basal-worker/examples/windows_provenance/native.rs new file mode 100644 index 0000000..edbfd79 --- /dev/null +++ b/crates/basal-worker/examples/windows_provenance/native.rs @@ -0,0 +1,496 @@ +#![allow(unsafe_op_in_unsafe_fn)] +use basal_launch::{LaunchOptions, create_or_open_profile, grant_test_binary_directory, launch}; +use basal_proto::FLOW_JOB_COMMIT_BYTES; +use basal_worker::confinement::windows::allowlist::{self, HandleEntry, Stdio}; +use serde_json::{Value, json}; +use std::collections::BTreeMap; +use std::ffi::c_void; +use std::io::{BufRead, BufReader, Write}; +use std::mem::{size_of, zeroed}; +use std::os::windows::io::AsRawHandle; +use std::path::PathBuf; +use std::ptr::null_mut; +use std::sync::mpsc; +use std::time::Duration; +use windows_sys::Win32::Foundation::*; +use windows_sys::Win32::System::LibraryLoader::*; +use windows_sys::Win32::System::Threading::*; + +struct Handle(HANDLE); +impl Drop for Handle { + fn drop(&mut self) { + unsafe { CloseHandle(self.0) }; + } +} +fn wide(text: &str) -> Vec { + text.encode_utf16().chain(Some(0)).collect() +} +#[repr(C)] +struct UnicodeString { + length: u16, + maximum_length: u16, + buffer: *mut u16, +} +#[repr(C)] +#[derive(Clone, Copy)] +struct Entry { + handle: usize, + handles: usize, + pointers: usize, + access: u32, + ty: u32, + attrs: u32, + reserved: u32, +} +#[repr(C)] +struct ObjectType { + name: UnicodeString, + counters: [u32; 13], + mapping: [u32; 4], + access: u32, + security: u8, + maintain: u8, + index: u8, + reserved: u8, + pool: [u32; 3], +} +#[repr(C)] +#[derive(Clone, Copy)] +struct Trace { + handle: usize, + pid: usize, + tid: usize, + kind: u32, + stack: [usize; 16], +} +#[link(name = "ntdll")] +unsafe extern "system" { + fn NtSetInformationProcess( + process: HANDLE, + class: u32, + buffer: *const c_void, + bytes: u32, + ) -> i32; + fn NtQueryInformationProcess( + process: HANDLE, + class: u32, + buffer: *mut c_void, + bytes: u32, + returned: *mut u32, + ) -> i32; + fn NtQueryObject( + handle: HANDLE, + class: u32, + buffer: *mut c_void, + bytes: u32, + returned: *mut u32, + ) -> i32; +} +fn enable(process: HANDLE, _: u32) -> Result<(), String> { + let settings = [0u32, 16384]; + let status = unsafe { NtSetInformationProcess(process, 32, settings.as_ptr().cast(), 8) }; + if status != 0 { + return Err(format!("ProcessHandleTracing enable: {:#x}", status as u32)); + } + Ok(()) +} +unsafe fn query(process: HANDLE, class: u32, object: bool) -> Result<(Vec, usize), String> { + let mut bytes = 64 * 1024; + for _ in 0..10 { + let mut buffer = vec![0usize; bytes / 8]; + let mut returned = 0; + let status = if object { + NtQueryObject( + process, + class, + buffer.as_mut_ptr().cast(), + bytes as u32, + &mut returned, + ) + } else { + NtQueryInformationProcess( + process, + class, + buffer.as_mut_ptr().cast(), + bytes as u32, + &mut returned, + ) + }; + if matches!(status as u32, 0xc0000004 | 0x80000005 | 0xc0000023) { + bytes = (bytes * 2) + .max(returned as usize + 4096) + .next_multiple_of(8); + continue; + } + if status < 0 { + return Err(format!("query class {class}: {:#x}", status as u32)); + } + if returned as usize > bytes { + return Err("query returned oversized buffer".into()); + } + return Ok((buffer, returned as usize)); + } + Err("query kept growing".into()) +} +unsafe fn text(name: &UnicodeString) -> String { + String::from_utf16_lossy(std::slice::from_raw_parts( + name.buffer, + name.length as usize / 2, + )) +} +unsafe fn inventory(process: HANDLE, stdio: [usize; 3]) -> Result, String> { + let (types_buffer, end) = query(null_mut(), 3, true)?; + let base = types_buffer.as_ptr().cast::(); + let mut offset = 8; + let mut types = BTreeMap::new(); + for _ in 0..*base.cast::() { + if offset + size_of::() > end { + return Err("truncated object types".into()); + } + let ty = &*base.add(offset).cast::(); + types.insert(ty.index as u32, text(&ty.name)); + offset = (offset + size_of::() + ty.name.maximum_length as usize) + .next_multiple_of(8); + } + let (buffer, returned) = query(process, 51, false)?; + let count = buffer[0]; + if count > (returned.saturating_sub(16)) / size_of::() { + return Err("truncated handle table".into()); + } + let entries = std::slice::from_raw_parts(buffer.as_ptr().add(2).cast::(), count); + let mut result = Vec::new(); + for entry in entries { + let ty = types.get(&entry.ty).ok_or("unknown object type")?.clone(); + let name = if ty == "Directory" { + let mut duplicate = null_mut(); + if DuplicateHandle( + process, + entry.handle as HANDLE, + GetCurrentProcess(), + &mut duplicate, + 0, + 0, + DUPLICATE_SAME_ACCESS, + ) == 0 + { + return Err(format!("duplicate Directory: {}", GetLastError())); + } + let duplicate = Handle(duplicate); + let (buffer, _) = query(duplicate.0, 1, true)?; + Some(text(&*buffer.as_ptr().cast::())) + } else { + None + }; + result.push(HandleEntry { + value: entry.handle, + type_name: ty, + access: entry.access, + inheritable: entry.attrs & 2 != 0, + stdio: stdio + .iter() + .position(|value| *value == entry.handle) + .map(|index| [Stdio::Input, Stdio::Output, Stdio::Error][index]), + name, + }); + } + Ok(result) +} + +#[repr(C)] +struct SymbolInfo { + size: u32, + type_index: u32, + reserved: [u64; 2], + index: u32, + symbol_size: u32, + module: u64, + flags: u32, + value: u64, + address: u64, + register: u32, + scope: u32, + tag: u32, + name_len: u32, + max_name_len: u32, + name: [u8; 1024], +} +type Initialize = unsafe extern "system" fn(HANDLE, *const u16, i32) -> i32; +type FromAddress = unsafe extern "system" fn(HANDLE, u64, *mut u64, *mut SymbolInfo) -> i32; +type ModuleInfo = unsafe extern "system" fn(HANDLE, u64, *mut c_void) -> i32; +type Cleanup = unsafe extern "system" fn(HANDLE) -> i32; +struct Symbols { + library: HMODULE, + symbols_server: HMODULE, + process: HANDLE, + address: FromAddress, + module: ModuleInfo, + cleanup: Cleanup, +} +impl Symbols { + unsafe fn new(process: HANDLE) -> Result { + let symbols_server = if let Ok(server) = std::env::var("BASAL_SYMSRV") { + let module = LoadLibraryExW( + wide(&server).as_ptr(), + null_mut(), + LOAD_LIBRARY_SEARCH_SYSTEM32 | LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR, + ); + if module.is_null() { + return Err(format!("SymSrv: {}", GetLastError())); + } + module + } else { + null_mut() + }; + let image = std::env::var("BASAL_DBGHELP").unwrap_or_else(|_| "dbghelp.dll".into()); + let library = LoadLibraryExW( + wide(&image).as_ptr(), + null_mut(), + LOAD_LIBRARY_SEARCH_SYSTEM32 + | if image.contains('\\') { + LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR + } else { + 0 + }, + ); + if library.is_null() { + return Err(format!("DbgHelp: {}", GetLastError())); + } + let exports = ( + GetProcAddress(library, c"SymInitializeW".as_ptr().cast()), + GetProcAddress(library, c"SymFromAddr".as_ptr().cast()), + GetProcAddress(library, c"SymGetModuleInfo64".as_ptr().cast()), + GetProcAddress(library, c"SymCleanup".as_ptr().cast()), + ); + let (Some(init), Some(address), Some(module), Some(cleanup)) = exports else { + FreeLibrary(library); + return Err("DbgHelp exports unavailable".into()); + }; + let init: Initialize = std::mem::transmute(init); + let cache = std::env::temp_dir().join("ckdev-provenance-symbols"); + std::fs::create_dir_all(&cache).map_err(|e| e.to_string())?; + if init( + process, + wide(&format!( + "srv*{}*https://msdl.microsoft.com/download/symbols", + cache.display() + )) + .as_ptr(), + 1, + ) == 0 + { + let error = GetLastError(); + FreeLibrary(library); + return Err(format!("SymInitialize: {error}")); + } + let address: FromAddress = std::mem::transmute(address); + let module: ModuleInfo = std::mem::transmute(module); + let cleanup: Cleanup = std::mem::transmute(cleanup); + Ok(Self { + library, + symbols_server, + process, + address, + module, + cleanup, + }) + } + unsafe fn resolve(&self, address: usize) -> Result { + let mut symbol: SymbolInfo = zeroed(); + symbol.size = 88; + symbol.max_name_len = 1024; + let mut displacement = 0; + if (self.address)(self.process, address as u64, &mut displacement, &mut symbol) == 0 { + return Err(format!("unresolved {address:#x}: {}", GetLastError())); + } + let mut buffer = [0u64; 210]; + buffer[0] = 1680; + if (self.module)(self.process, address as u64, buffer.as_mut_ptr().cast()) == 0 { + return Err(format!("module at {address:#x}: {}", GetLastError())); + } + let bytes = std::slice::from_raw_parts(buffer.as_ptr().cast::(), 1680); + let field = &bytes[36..68]; + let module = String::from_utf8_lossy( + &field[..field + .iter() + .position(|byte| *byte == 0) + .unwrap_or(field.len())], + ) + .to_ascii_lowercase(); + let symbol_type = u32::from_le_bytes(bytes[32..36].try_into().unwrap()); + let name = String::from_utf8_lossy(&symbol.name[..(symbol.name_len as usize).min(1024)]) + .into_owned(); + Ok( + json!({"address":format!("{address:#x}"),"module":module,"symbol":name,"displacement":displacement,"symbol_type":symbol_type}), + ) + } +} +impl Drop for Symbols { + fn drop(&mut self) { + unsafe { + (self.cleanup)(self.process); + FreeLibrary(self.library); + if !self.symbols_server.is_null() { + FreeLibrary(self.symbols_server); + } + } + } +} + +pub(super) fn run() -> Result<(), String> { + if cfg!(feature = "deviations") { + return Err("measurement requires the production worker build".into()); + } + let args: Vec<_> = std::env::args_os().skip(1).collect(); + if args.len() != 2 { + return Err("usage: windows_provenance ".into()); + } + let image = std::fs::canonicalize(&args[0]).map_err(|e| e.to_string())?; + let output = PathBuf::from(&args[1]); + let directory = std::env::temp_dir().join(format!("ckdev-provenance-{}", std::process::id())); + std::fs::create_dir_all(&directory).map_err(|e| e.to_string())?; + let copy = directory.join("ckdev-basal-worker.exe"); + std::fs::copy(&image, ©).map_err(|e| e.to_string())?; + let package = create_or_open_profile().map_err(|e| e.to_string())?; + grant_test_binary_directory(©, &package).map_err(|e| e.to_string())?; + let mut options = LaunchOptions::new(©, FLOW_JOB_COMMIT_BYTES) + .arg("--confinement-probe") + .arg("--probe=checkpoint"); + options.before_resume = Some(enable); + let mut child = launch(&options).map_err(|e| e.to_string())?; + let stderr = child.stderr.take().unwrap(); + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + for line in BufReader::new(stderr).lines() { + if tx.send(line.unwrap()).is_err() { + break; + } + } + }); + let mut diagnostics = String::new(); + loop { + let line = rx + .recv_timeout(Duration::from_secs(60)) + .map_err(|e| format!("startup checkpoint missing: {e}; {diagnostics}"))?; + diagnostics.push_str(&line); + diagnostics.push('\n'); + if line == "windows-probe-ready:checkpoint" { + break; + } + } + let process = child.as_raw_handle(); + let measured = unsafe { + assert_eq!(size_of::(), 160); + assert_eq!(std::mem::offset_of!(Trace, stack), 32); + let handles = inventory(process, child.inherited_stdio())?; + let ceiling = allowlist::check(&handles) + .map_err(|e| format!("STOP: production inventory outside approved ceilings: {e}"))?; + let symbols = Symbols::new(process)?; + let (buffer, returned) = query(process, 32, false)?; + let count = *buffer.as_ptr().add(1).cast::() as usize; + if count > (returned.saturating_sub(16)) / size_of::() { + return Err("truncated handle traces".into()); + } + let traces = std::slice::from_raw_parts(buffer.as_ptr().add(2).cast::(), count); + let mut rows = Vec::new(); + let mut unresolved = Vec::new(); + for handle in &handles { + if handle.stdio.is_some() { + rows.push(json!({"handle":handle.value,"type":handle.type_name,"access":format!("{:#x}",handle.access),"creator":"parent CreatePipe / explicit HANDLE_LIST", "stack":[]})); + continue; + } + let trace = traces + .iter() + .rev() + .find(|trace| trace.handle == handle.value && trace.kind == 1); + let mut stack = Vec::new(); + if let Some(trace) = trace { + for address in trace.stack.iter().copied().filter(|address| *address != 0) { + if address >= 0xffff_8000_0000_0000 { + stack.push(json!({"address":format!("{address:#x}"), "module":"kernel (unsymbolized)"})); + continue; + } + match symbols.resolve(address) { + Ok(frame) => stack.push(frame), + Err(error) => { + unresolved.push(error.clone()); + stack.push(json!({"address":format!("{address:#x}"),"error":error})); + } + } + } + } else { + unresolved.push(format!( + "no open trace for {} {:#x}", + handle.type_name, handle.value + )); + } + // Export-only labels can be arbitrarily far from the actual creator. + // Require PDB symbols and an initialization chain, not a nearest export. + let anchored = stack.iter().any(|frame| { + let module = frame["module"].as_str().unwrap_or(""); + let symbol = frame["symbol"].as_str().unwrap_or(""); + frame["symbol_type"] == 3 + && ((module == "ntdll" + && (symbol.starts_with("Ldr") + || symbol.starts_with("Tpp") + || symbol.starts_with("Tp"))) + || (module == "kernelbase" + && (symbol.contains("Initialize") || symbol.contains("Init")))) + }); + let first_user = stack + .iter() + .find(|frame| frame["module"] != "kernel (unsymbolized)"); + let creator_resolved = first_user.is_some_and(|frame| { + frame["symbol_type"] == 3 + && matches!(frame["module"].as_str(), Some("ntdll" | "kernelbase")) + }); + if !anchored || !creator_resolved { + unresolved.push(format!( + "{} {:#x}: creator not resolved to ntdll/KernelBase initialization", + handle.type_name, handle.value + )); + } + rows.push(json!({"handle":handle.value,"type":handle.type_name,"access":format!("{:#x}",handle.access),"name":handle.name,"stack":stack})); + } + json!({"image":image,"runner_image":std::env::var("ImageOS").ok(),"runner_version":std::env::var("ImageVersion").ok(),"profile":ceiling.name,"total":handles.len(),"handles":rows,"unresolved":unresolved,"tracing_class":32,"tracing_slots":16384}) + }; + // Save raw evidence even when creator resolution needs a human decision. + std::fs::write( + &output, + serde_json::to_vec_pretty(&measured).map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string())?; + // The snapshot is complete. Remove only the diagnostic trace database + // before releasing the worker; permanent strict-handle checks are unchanged. + let disabled = unsafe { NtSetInformationProcess(process, 32, std::ptr::null(), 0) }; + if disabled != 0 { + return Err(format!( + "disable ProcessHandleTracing: {:#x}", + disabled as u32 + )); + } + child + .stdin + .as_mut() + .unwrap() + .write_all(b"x") + .map_err(|e| e.to_string())?; + let code = child.wait().map_err(|e| e.to_string())?; + if code != 0 { + return Err(format!("checkpoint worker exit {code:#x}")); + } + drop(child); + std::fs::remove_file(copy).map_err(|e| e.to_string())?; + println!( + "production inventory {}: {} handles; saved {}", + measured["profile"], + measured["total"], + output.display() + ); + if !measured["unresolved"].as_array().unwrap().is_empty() { + return Err(format!( + "STOP: creators need resolution: {}", + measured["unresolved"] + )); + } + Ok(()) +} diff --git a/crates/basal-worker/examples/wrapper_stack.rs b/crates/basal-worker/examples/wrapper_stack.rs index 7f57759..9efbbe0 100644 --- a/crates/basal-worker/examples/wrapper_stack.rs +++ b/crates/basal-worker/examples/wrapper_stack.rs @@ -2,6 +2,7 @@ //! the engine freezes its global object, both directly and through the worker. //! Frozen globals reject new names before a call can run; scoped functions can //! recurse. Direct versus worker entry separates those engine costs from OS confinement. + use basal_proto::{ActivationRequest, ActivationResult, Budgets, JsonText, Profile}; use basal_worker::{engine, link::Channel}; use std::cell::RefCell; diff --git a/crates/basal-worker/src/clock.rs b/crates/basal-worker/src/clock.rs index 5dde9ad..775454d 100644 --- a/crates/basal-worker/src/clock.rs +++ b/crates/basal-worker/src/clock.rs @@ -10,12 +10,25 @@ use std::cell::Cell; use std::time::Duration; +#[cfg(any(windows, test))] +pub mod windows; + +/// The calling thread's CPU time. +/// +/// On Windows it is the thread's kernel plus user time from `GetThreadTimes` +/// (see [`windows`]), which advances in scheduler ticks. +#[cfg(windows)] +pub fn thread_cpu_time() -> Duration { + windows::thread_cpu_time() +} + /// The calling thread's CPU time. /// /// `CLOCK_THREAD_CPUTIME_ID` is supported on every macOS release this worker /// targets and on Linux; if the call ever fails, the budget falls back to /// treating the clock as stopped, and the parent's wall-clock deadline (which /// kills the worker) remains the backstop. +#[cfg(not(windows))] pub fn thread_cpu_time() -> Duration { let mut ts = libc::timespec { tv_sec: 0, @@ -39,22 +52,31 @@ pub struct JsClock { spent: Cell, entered_at: Cell>, exhausted: Cell, + /// Where CPU time samples come from: [`thread_cpu_time`], or a scripted + /// source in tests. + sample: fn() -> Duration, } impl JsClock { pub fn new(budget: Duration) -> Self { + Self::with_source(budget, thread_cpu_time) + } + + /// A clock that reads its CPU time samples from `sample`. + pub fn with_source(budget: Duration, sample: fn() -> Duration) -> Self { Self { budget, spent: Cell::new(Duration::ZERO), entered_at: Cell::new(None), exhausted: Cell::new(false), + sample, } } /// Starts counting: the worker is about to run JavaScript. pub fn enter(&self) { if self.entered_at.get().is_none() { - self.entered_at.set(Some(thread_cpu_time())); + self.entered_at.set(Some((self.sample)())); } } @@ -62,7 +84,7 @@ impl JsClock { /// about to wait on the parent. pub fn leave(&self) { if let Some(start) = self.entered_at.take() { - let now = thread_cpu_time(); + let now = (self.sample)(); self.spent.set(self.spent.get() + now.saturating_sub(start)); } } @@ -71,7 +93,7 @@ impl JsClock { let running = self .entered_at .get() - .map(|start| thread_cpu_time().saturating_sub(start)) + .map(|start| (self.sample)().saturating_sub(start)) .unwrap_or_default(); self.spent.get() + running } @@ -103,18 +125,76 @@ mod tests { } } + /// Against the real clock. The budget and the burns span several + /// scheduler ticks (about 15.6 ms on Windows, where the thread CPU clock + /// advances a tick at a time), so tick rounding cannot flip the outcome. #[test] fn time_outside_the_engine_is_not_counted() { - let clock = JsClock::new(Duration::from_millis(30)); + let clock = JsClock::new(Duration::from_millis(150)); clock.enter(); - burn(Duration::from_millis(5)); + burn(Duration::from_millis(30)); clock.leave(); // Sleeping and burning CPU while left must not count. std::thread::sleep(Duration::from_millis(50)); - burn(Duration::from_millis(40)); + burn(Duration::from_millis(200)); assert!(!clock.over_budget(), "spent {:?}", clock.spent()); clock.enter(); - burn(Duration::from_millis(40)); + burn(Duration::from_millis(200)); + assert!(clock.over_budget()); + } + + thread_local! { + static SAMPLE: Cell = const { Cell::new(Duration::ZERO) }; + } + + fn scripted() -> Duration { + SAMPLE.with(Cell::get) + } + + fn set(millis: u64) { + SAMPLE.with(|sample| sample.set(Duration::from_millis(millis))); + } + + /// With injected samples: only the intervals between `enter` and + /// `leave` count, a running interval counts up to the latest sample, and + /// exhaustion stays latched. + #[test] + fn only_samples_taken_inside_the_engine_are_counted() { + set(1_000); + let clock = JsClock::with_source(Duration::from_millis(100), scripted); + clock.enter(); + set(1_060); + clock.leave(); + assert_eq!(clock.spent(), Duration::from_millis(60)); + // A long gap outside the engine is not counted. + set(9_000); + assert_eq!(clock.spent(), Duration::from_millis(60)); + assert!(!clock.over_budget()); + clock.enter(); + // Entering twice keeps the first sample. + set(9_020); + clock.enter(); + set(9_040); + assert_eq!(clock.spent(), Duration::from_millis(100)); + assert!(!clock.over_budget(), "exactly the budget is not over it"); + set(9_041); assert!(clock.over_budget()); + clock.leave(); + assert!(clock.exhausted()); + assert!(clock.over_budget(), "exhaustion is latched"); + } + + /// A clock that failed returns zero, which can be below the sample taken + /// on entry. The interval then counts as nothing rather than wrapping. + #[test] + fn a_sample_below_the_entry_sample_counts_as_nothing() { + set(500); + let clock = JsClock::with_source(Duration::from_millis(10), scripted); + clock.enter(); + set(0); + assert_eq!(clock.spent(), Duration::ZERO); + clock.leave(); + assert_eq!(clock.spent(), Duration::ZERO); + assert!(!clock.over_budget()); } } diff --git a/crates/basal-worker/src/clock/windows.rs b/crates/basal-worker/src/clock/windows.rs new file mode 100644 index 0000000..ee9dd76 --- /dev/null +++ b/crates/basal-worker/src/clock/windows.rs @@ -0,0 +1,99 @@ +//! The thread CPU clock on Windows. +//! +//! `GetThreadTimes` reports the calling thread's kernel and user time in +//! 100-nanosecond units. The kernel charges that time a scheduler tick at a +//! time (about 15.6 ms by default), so the clock advances in ticks: a budget +//! smaller than a tick is imprecise, and the activation's wall deadline, +//! enforced by the parent, stays the hard bound. A failed read counts as +//! zero, the same fallback the Unix clock uses. + +use std::time::Duration; + +/// The calling thread's kernel plus user time. +#[cfg(windows)] +pub fn thread_cpu_time() -> Duration { + use windows_sys::Win32::Foundation::FILETIME; + use windows_sys::Win32::System::Threading::{GetCurrentThread, GetThreadTimes}; + + let zero = FILETIME { + dwLowDateTime: 0, + dwHighDateTime: 0, + }; + let (mut creation, mut exit, mut kernel, mut user) = (zero, zero, zero, zero); + // SAFETY: the pseudo-handle names the calling thread, and the four + // out-pointers are valid FILETIMEs for the duration of the call. + let ok = unsafe { + GetThreadTimes( + GetCurrentThread(), + &mut creation, + &mut exit, + &mut kernel, + &mut user, + ) + } != 0; + let ticks = + |time: FILETIME| u64::from(time.dwHighDateTime) << 32 | u64::from(time.dwLowDateTime); + cpu_time(ok.then(|| (ticks(kernel), ticks(user)))) +} + +/// CPU time from one `GetThreadTimes` sample of (kernel, user) time in +/// 100-nanosecond units, or zero when the read failed. +pub fn cpu_time(sample: Option<(u64, u64)>) -> Duration { + match sample { + None => Duration::ZERO, + Some((kernel, user)) => { + let units = kernel.saturating_add(user); + Duration::new(units / 10_000_000, (units % 10_000_000) as u32 * 100) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn kernel_and_user_time_add_up_in_100_nanosecond_units() { + // One second of kernel time and one scheduler tick (156,250 units) of + // user time. + assert_eq!( + cpu_time(Some((10_000_000, 156_250))), + Duration::from_secs(1) + Duration::from_micros(15_625) + ); + assert_eq!(cpu_time(Some((0, 1))), Duration::from_nanos(100)); + assert_eq!(cpu_time(Some((0, 0))), Duration::ZERO); + } + + #[test] + fn a_failed_read_is_zero() { + assert_eq!(cpu_time(None), Duration::ZERO); + } + + #[test] + fn the_largest_sample_saturates_instead_of_wrapping() { + let largest = cpu_time(Some((u64::MAX, u64::MAX))); + assert_eq!(largest, cpu_time(Some((u64::MAX, 0)))); + assert!(largest > cpu_time(Some((u64::MAX - 1, 0)))); + } + + /// The real clock advances while the thread burns CPU. Several ticks are + /// burned so the reading moves even at scheduler-tick resolution. The + /// loop is bounded by work done, not by the clock under test, so a clock + /// stuck at zero fails instead of hanging. + #[cfg(windows)] + #[test] + fn the_real_clock_advances_with_work() { + let start = thread_cpu_time(); + let mut x = 0u64; + for round in 0..10_000u32 { + if thread_cpu_time().saturating_sub(start) >= Duration::from_millis(100) { + break; + } + for _ in 0..100_000u32 { + x = x.wrapping_mul(31).wrapping_add(u64::from(round)); + std::hint::black_box(x); + } + } + assert!(thread_cpu_time() >= start + Duration::from_millis(100)); + } +} diff --git a/crates/basal-worker/src/confinement.rs b/crates/basal-worker/src/confinement.rs index 5e39765..77d4e19 100644 --- a/crates/basal-worker/src/confinement.rs +++ b/crates/basal-worker/src/confinement.rs @@ -16,6 +16,11 @@ use basal_proto::Confinement; #[cfg(target_os = "linux")] pub mod linux; +// The pure checks under `windows` are also built for unit tests elsewhere, +// so their accept and reject cases run on every system. +#[cfg(any(windows, test))] +pub mod windows; + /// The Seatbelt profile, checked in beside the crate and embedded at build /// time so the binary cannot be pointed at a different one. pub const SEATBELT_PROFILE: &str = include_str!("../sandbox/worker.sb"); @@ -33,6 +38,9 @@ pub enum ConfinementError { /// A Linux startup precondition or confinement layer failed. #[cfg(target_os = "linux")] Linux(&'static str), + /// A Windows startup check failed, with its reason token. + #[cfg(windows)] + Windows(windows::Refusal), } impl fmt::Display for ConfinementError { @@ -43,6 +51,8 @@ impl fmt::Display for ConfinementError { Self::Unsupported => write!(f, "no supported OS sandbox on this platform"), #[cfg(target_os = "linux")] Self::Linux(token) => f.write_str(token), + #[cfg(windows)] + Self::Windows(refusal) => write!(f, "{refusal}"), } } } diff --git a/crates/basal-worker/src/confinement/windows.rs b/crates/basal-worker/src/confinement/windows.rs new file mode 100644 index 0000000..a2e79d4 --- /dev/null +++ b/crates/basal-worker/src/confinement/windows.rs @@ -0,0 +1,290 @@ +//! The worker's own confinement checks on Windows. +//! +//! On Windows the parent fixes most of the confinement when it creates the +//! worker (see the `basal-launch` crate): a Less Privileged AppContainer +//! token with no capabilities, every group deny-only, no privileges and the +//! NULL SID as the only restricting SID; a job; mitigation policies; and an +//! explicit list of the three inherited pipes. The loader then runs on the +//! main thread under a start-up impersonation token, because the restricted +//! primary allows too little for it to map system DLLs. +//! +//! Before reading its first frame the worker finishes the job, in this order, +//! and refuses to run (exit 70, the reason on stderr) at the first failure: +//! +//! 1. it drops the start-up token and checks the thread has none left +//! (`thread-token-present`); +//! 2. it lowers its primary token from Low to Untrusted integrity and closes +//! the handle it used (`integrity-lower-failed`); +//! 3. it closes the connection to the session's client/server runtime port +//! and any device handle the loader left that it did not inherit; +//! 4. it reads its actual primary token back and checks every property +//! (`not-lpac`, `capabilities-present`, `restricting-sid-mismatch`, +//! `group-not-deny-only`, `privileges-present`, `integrity-not-untrusted`); +//! 5. it reads its mitigation policies back (`mitigation-mismatch`); +//! 6. it checks its whole handle table against an allowlist +//! (`handle-not-allowed`). +//! +//! The checks themselves ([`attest`], [`mitigation`], [`allowlist`]) are pure +//! functions over what was read, so their accept and reject cases are tested +//! on every system. The reads and the startup sequence are Windows-only. + +pub mod allowlist; +pub mod attest; +pub mod mitigation; + +#[cfg(windows)] +mod native; +#[cfg(windows)] +mod startup; + +#[cfg(windows)] +pub use native::PackageSid; +#[cfg(windows)] +pub use startup::enter; + +/// The reason tokens the worker exits with. Each names the check that +/// failed; tests and the parent match on them. +pub mod reason { + /// No `--package-sid=` argument was given. + pub const PACKAGE_SID_ARGUMENT_MISSING: &str = "package-sid-argument-missing"; + /// The main thread still has an impersonation token after reverting. + pub const THREAD_TOKEN_PRESENT: &str = "thread-token-present"; + /// The primary token could not be lowered to Untrusted integrity, or the + /// handle used for it could not be closed. + pub const INTEGRITY_LOWER_FAILED: &str = "integrity-lower-failed"; + /// The primary is not a Less Privileged AppContainer token of the + /// expected package. + pub const NOT_LPAC: &str = "not-lpac"; + /// The primary holds a capability. + pub const CAPABILITIES_PRESENT: &str = "capabilities-present"; + /// The primary's restricting SIDs are not exactly the NULL SID. + pub const RESTRICTING_SID_MISMATCH: &str = "restricting-sid-mismatch"; + /// An access group of the primary is not deny-only. + pub const GROUP_NOT_DENY_ONLY: &str = "group-not-deny-only"; + /// The primary holds a privilege. + pub const PRIVILEGES_PRESENT: &str = "privileges-present"; + /// The primary's integrity is not Untrusted. + pub const INTEGRITY_NOT_UNTRUSTED: &str = "integrity-not-untrusted"; + /// A required mitigation policy is off, or a forbidden opt-out is on. + pub const MITIGATION_MISMATCH: &str = "mitigation-mismatch"; + /// The handle table holds a handle outside the allowlist, or the table + /// could not be read or cleaned. + pub const HANDLE_NOT_ALLOWED: &str = "handle-not-allowed"; +} + +/// A failed check: its reason token and what was found. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Refusal { + pub reason: &'static str, + pub detail: String, +} + +impl Refusal { + pub fn new(reason: &'static str, detail: impl Into) -> Self { + Self { + reason, + detail: detail.into(), + } + } +} + +impl std::fmt::Display for Refusal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}: {}", self.reason, self.detail) + } +} + +/// The package SID argument the launcher passes on every Windows spawn. +pub const PACKAGE_SID_ARGUMENT: &str = "--package-sid="; + +/// The argument basal-launch passes for a worker check it cannot break from +/// the parent. Only a worker built with the `deviations` feature accepts it. +pub const DEVIATION_ARGUMENT: &str = "--confinement-deviation="; + +/// A startup check the parent asks a test worker to break. +#[cfg(feature = "deviations")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Deviation { + /// Keep the start-up thread token: step 1 must refuse. + ThreadTokenPresent, + /// Open the primary without the right to adjust it, so lowering fails: + /// step 2 must refuse. + IntegrityLowerFailed, + /// Skip lowering the primary: step 4 must refuse it as still Low. + IntegrityNotUntrusted, +} + +#[cfg(feature = "deviations")] +impl Deviation { + fn parse(name: &str) -> Option { + match name { + "thread-token-present" => Some(Self::ThreadTokenPresent), + "integrity-lower-failed" => Some(Self::IntegrityLowerFailed), + "integrity-not-untrusted" => Some(Self::IntegrityNotUntrusted), + _ => None, + } + } +} + +/// The engine-mode command line on Windows. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Arguments { + /// The package SID, as text. It is parsed with the system's SID parser + /// before confinement starts. + pub package_sid: String, + /// The startup check to break, in a test worker. + #[cfg(feature = "deviations")] + pub deviation: Option, +} + +impl Arguments { + /// Whether step 1 leaves the start-up token in place. + #[cfg(windows)] + fn keeps_thread_token(&self) -> bool { + #[cfg(feature = "deviations")] + if self.deviation == Some(Deviation::ThreadTokenPresent) { + return true; + } + false + } + + /// Whether step 2 opens the primary without the right to adjust it. + #[cfg(windows)] + fn opens_primary_read_only(&self) -> bool { + #[cfg(feature = "deviations")] + if self.deviation == Some(Deviation::IntegrityLowerFailed) { + return true; + } + false + } + + /// Whether step 2 leaves the primary at Low, the integrity the process + /// was created with. + #[cfg(windows)] + fn skips_lowering(&self) -> bool { + #[cfg(feature = "deviations")] + if self.deviation == Some(Deviation::IntegrityNotUntrusted) { + return true; + } + false + } +} + +/// Why the engine-mode command line was not accepted. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ArgumentError { + /// No `--package-sid=` argument: exit 70 `package-sid-argument-missing`. + Missing, + /// An unknown, repeated or malformed argument: exit 64. + Usage(String), +} + +/// Parses the engine-mode arguments: exactly one `--package-sid=`, and +/// in a test worker at most one `--confinement-deviation=`. Anything +/// else is a usage error, checked before a missing SID is reported. +pub fn engine_arguments(args: &[String]) -> Result { + let mut package_sid: Option<&str> = None; + #[cfg(feature = "deviations")] + let mut deviation: Option = None; + for arg in args { + if let Some(sid) = arg.strip_prefix(PACKAGE_SID_ARGUMENT) { + if package_sid.replace(sid).is_some() { + return Err(ArgumentError::Usage(format!("repeated argument {arg}"))); + } + continue; + } + #[cfg(feature = "deviations")] + if let Some(name) = arg.strip_prefix(DEVIATION_ARGUMENT) { + let parsed = Deviation::parse(name) + .ok_or_else(|| ArgumentError::Usage(format!("unknown deviation {name}")))?; + if deviation.replace(parsed).is_some() { + return Err(ArgumentError::Usage(format!("repeated argument {arg}"))); + } + continue; + } + return Err(ArgumentError::Usage(format!("unknown argument {arg}"))); + } + let package_sid = package_sid.ok_or(ArgumentError::Missing)?; + if package_sid.is_empty() { + return Err(ArgumentError::Usage("empty --package-sid".into())); + } + Ok(Arguments { + package_sid: package_sid.to_owned(), + #[cfg(feature = "deviations")] + deviation, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(list: &[&str]) -> Vec { + list.iter().map(|arg| (*arg).to_owned()).collect() + } + + const SID: &str = "--package-sid=S-1-15-2-1-2-3-4-5-6-7"; + + #[test] + fn exactly_one_package_sid_is_accepted() { + let parsed = engine_arguments(&args(&[SID])).expect("one package SID"); + assert_eq!(parsed.package_sid, "S-1-15-2-1-2-3-4-5-6-7"); + } + + #[test] + fn a_missing_package_sid_is_its_own_error() { + assert_eq!(engine_arguments(&[]), Err(ArgumentError::Missing)); + } + + #[test] + fn other_command_lines_are_usage_errors() { + for line in [ + &[SID, SID][..], + &["--package-sid="], + &[SID, "--landlock=required"], + &["--bogus"], + &["--bogus", SID], + &["--package-sid"], + ] { + assert!( + matches!(engine_arguments(&args(line)), Err(ArgumentError::Usage(_))), + "{line:?}" + ); + } + } + + /// A worker built without the `deviations` feature has no way to skip a + /// startup check: the request is an unknown argument like any other. + #[cfg(not(feature = "deviations"))] + #[test] + fn a_production_worker_refuses_every_deviation_request() { + for name in [ + "thread-token-present", + "integrity-lower-failed", + "integrity-not-untrusted", + ] { + let request = format!("{DEVIATION_ARGUMENT}{name}"); + assert_eq!( + engine_arguments(&args(&[SID, &request])), + Err(ArgumentError::Usage(format!("unknown argument {request}"))) + ); + } + } + + #[cfg(feature = "deviations")] + #[test] + fn a_test_worker_accepts_the_three_deviations_once() { + for (name, deviation) in [ + ("thread-token-present", Deviation::ThreadTokenPresent), + ("integrity-lower-failed", Deviation::IntegrityLowerFailed), + ("integrity-not-untrusted", Deviation::IntegrityNotUntrusted), + ] { + let request = format!("{DEVIATION_ARGUMENT}{name}"); + let parsed = engine_arguments(&args(&[SID, &request])).expect("accepted"); + assert_eq!(parsed.deviation, Some(deviation)); + assert!(engine_arguments(&args(&[SID, &request, &request])).is_err()); + } + let other = format!("{DEVIATION_ARGUMENT}not-lpac"); + assert!(engine_arguments(&args(&[SID, &other])).is_err()); + } +} diff --git a/crates/basal-worker/src/confinement/windows/allowlist.rs b/crates/basal-worker/src/confinement/windows/allowlist.rs new file mode 100644 index 0000000..e395810 --- /dev/null +++ b/crates/basal-worker/src/confinement/windows/allowlist.rs @@ -0,0 +1,472 @@ +//! The handle allowlist (startup step 6). +//! +//! Before it reads input the worker lists every handle in its own table and +//! accepts the table only if it fits one of two measured profiles, one per +//! supported Windows image. Some rows are exact: the three standard pipes and +//! the loader's read-only `\KnownDlls` directory must each be there. Every +//! other row is a ceiling on handles the system's own start-up code creates +//! for the process's thread pool, loader and crypto initialisation: unnamed, +//! private objects that give no file, network, process or persistent reach. +//! Anything else (a file that is not a standard pipe, a section, process, +//! thread, token, job, key, port, another directory, any inheritable handle +//! that is not a standard pipe) is refused. +//! +//! The table must fit a single profile on its own. The two profiles are never +//! combined, so a table holding Windows 11's SchedulerSharedData handle and +//! Server 2022's two CNG semaphores fits neither and is refused. + +use std::collections::BTreeMap; + +/// Which standard handle an entry is, by its handle value. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Stdio { + Input, + Output, + Error, +} + +/// One handle in the worker's table, as identified without dereferencing it: +/// its type comes from the table's type index and the system's type list, +/// and only the single Directory handle has its name queried. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HandleEntry { + /// The handle value. + pub value: usize, + /// The object type name, or `unknown type `. + pub type_name: String, + /// The granted access mask. + pub access: u32, + /// Whether the handle is marked inheritable (`OBJ_INHERIT`). + pub inheritable: bool, + /// Which standard handle it is, if its value is one of them. + pub stdio: Option, + /// The object name, read for the Directory handle only. + pub name: Option, +} + +/// Read access to the stdin pipe, as `CreatePipe` grants it. +pub const STDIN_ACCESS: u32 = 0x0012_0189; +/// Write access to the stdout and stderr pipes, as `CreatePipe` grants it. +pub const STDOUT_ACCESS: u32 = 0x0012_0196; +/// Query and traverse on the `\KnownDlls` object directory. +pub const KNOWN_DLLS_ACCESS: u32 = 0x3; +/// The one object directory the loader keeps open. +pub const KNOWN_DLLS: &str = "\\KnownDlls"; + +/// At most `count` handles of this type with exactly this access. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Ceiling { + pub type_name: &'static str, + pub access: u32, + pub count: usize, +} + +/// The measured handle table of one Windows image. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Profile { + pub name: &'static str, + /// The ceiling rows. A row with count 0 is a type this image never has. + pub ceilings: [Ceiling; 7], + /// The largest number of handles in the whole table, the exact rows + /// included. + pub total: usize, +} + +const fn ceiling(type_name: &'static str, access: u32, count: usize) -> Ceiling { + Ceiling { + type_name, + access, + count, + } +} + +/// Windows 11 (the `windows-latest` runner image). +pub const WINDOWS_LATEST: Profile = Profile { + name: "windows-latest", + ceilings: [ + ceiling("Event", 0x001f_0003, 7), + ceiling("IoCompletion", 0x001f_0003, 2), + ceiling("TpWorkerFactory", 0x000f_00ff, 2), + ceiling("IRTimer", 0x0010_0002, 4), + ceiling("WaitCompletionPacket", 0x1, 6), + ceiling("Semaphore", 0x0010_0003, 0), + ceiling("SchedulerSharedData", 0x1, 1), + ], + total: 26, +}; + +/// Windows Server 2022 (the `windows-2022` runner image). +pub const WINDOWS_2022: Profile = Profile { + name: "windows-2022", + ceilings: [ + ceiling("Event", 0x001f_0003, 7), + ceiling("IoCompletion", 0x001f_0003, 2), + ceiling("TpWorkerFactory", 0x000f_00ff, 2), + ceiling("IRTimer", 0x0010_0002, 4), + ceiling("WaitCompletionPacket", 0x1, 5), + ceiling("Semaphore", 0x0010_0003, 2), + ceiling("SchedulerSharedData", 0x1, 0), + ], + total: 26, +}; + +/// Every profile a table may fit. +pub const PROFILES: [&Profile; 2] = [&WINDOWS_LATEST, &WINDOWS_2022]; + +/// Whether startup step 3 closes this handle before the allowlist runs. +/// +/// It closes the loader's connection to the session's client/server runtime +/// port (every ALPC Port) and every File handle that is neither a standard +/// handle nor inheritable, which on Server 2022 is the crypto driver device +/// the CNG start-up code opens. Only the three pipes are inherited; any other +/// inheritable handle came from the parent by mistake and is left open, so +/// that the allowlist refuses it instead of the worker hiding it. +pub fn closed_at_startup(type_name: &str, stdio: bool, inheritable: bool) -> bool { + match type_name { + "ALPC Port" => true, + "File" => !stdio && !inheritable, + _ => false, + } +} + +fn describe(entry: &HandleEntry) -> String { + format!( + "handle {:#x} {} access {:#x}{}{}", + entry.value, + entry.type_name, + entry.access, + if entry.inheritable { + " inheritable" + } else { + "" + }, + entry + .name + .as_deref() + .map(|name| format!(" named {name}")) + .unwrap_or_default() + ) +} + +/// Checks a handle table. Returns the profile it fits, or every reason it +/// fits none. +pub fn check(entries: &[HandleEntry]) -> Result<&'static Profile, String> { + let mut refused: Vec = Vec::new(); + let mut stdio = [0usize; 3]; + let mut known_dlls = 0usize; + // Handles counted against a ceiling row, by (type, access). + let mut counted: BTreeMap<(&str, u32), usize> = BTreeMap::new(); + for entry in entries { + if let Some(slot) = entry.stdio { + let (index, access) = match slot { + Stdio::Input => (0, STDIN_ACCESS), + Stdio::Output => (1, STDOUT_ACCESS), + Stdio::Error => (2, STDOUT_ACCESS), + }; + if entry.type_name == "File" && entry.access == access { + stdio[index] += 1; + } else { + refused.push(format!("standard {slot:?} is {}", describe(entry))); + } + continue; + } + if entry.inheritable { + refused.push(format!("inheritable {}", describe(entry))); + continue; + } + if entry.type_name == "Directory" { + if entry.name.as_deref() == Some(KNOWN_DLLS) && entry.access == KNOWN_DLLS_ACCESS { + known_dlls += 1; + } else { + refused.push(describe(entry)); + } + continue; + } + let row = PROFILES + .iter() + .flat_map(|profile| profile.ceilings.iter()) + .find(|row| row.type_name == entry.type_name.as_str() && row.access == entry.access); + match row { + Some(row) => *counted.entry((row.type_name, row.access)).or_default() += 1, + None => refused.push(describe(entry)), + } + } + for (index, name) in ["stdin", "stdout", "stderr"].into_iter().enumerate() { + if stdio[index] != 1 { + refused.push(format!("{} {name} pipe handles, expected 1", stdio[index])); + } + } + if known_dlls != 1 { + refused.push(format!( + "{known_dlls} {KNOWN_DLLS} directory handles, expected 1" + )); + } + if !refused.is_empty() { + return Err(refused.join("; ")); + } + + let mut misfits: Vec = Vec::new(); + for profile in PROFILES { + let mut over: Vec = Vec::new(); + for row in &profile.ceilings { + let count = counted + .get(&(row.type_name, row.access)) + .copied() + .unwrap_or(0); + if count > row.count { + over.push(format!( + "{count} {} {:#x}, at most {}", + row.type_name, row.access, row.count + )); + } + } + if entries.len() > profile.total { + over.push(format!( + "{} handles, at most {}", + entries.len(), + profile.total + )); + } + if over.is_empty() { + return Ok(profile); + } + misfits.push(format!("not {}: {}", profile.name, over.join(", "))); + } + Err(misfits.join("; ")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(type_name: &str, access: u32) -> HandleEntry { + HandleEntry { + value: 0, + type_name: type_name.into(), + access, + inheritable: false, + stdio: None, + name: None, + } + } + + fn rows(type_name: &str, access: u32, count: usize) -> Vec { + (0..count).map(|_| entry(type_name, access)).collect() + } + + /// The exact rows: the three inherited pipes and `\KnownDlls`. + fn exact() -> Vec { + let pipe = |stdio, access| HandleEntry { + inheritable: true, + stdio: Some(stdio), + ..entry("File", access) + }; + vec![ + pipe(Stdio::Input, STDIN_ACCESS), + pipe(Stdio::Output, STDOUT_ACCESS), + pipe(Stdio::Error, STDOUT_ACCESS), + HandleEntry { + name: Some(KNOWN_DLLS.into()), + ..entry("Directory", KNOWN_DLLS_ACCESS) + }, + ] + } + + /// The 26-handle table measured on Windows 11. + fn latest() -> Vec { + let mut table = exact(); + table.extend(rows("Event", 0x1f0003, 7)); + table.extend(rows("IoCompletion", 0x1f0003, 2)); + table.extend(rows("TpWorkerFactory", 0xf00ff, 2)); + table.extend(rows("IRTimer", 0x100002, 4)); + table.extend(rows("WaitCompletionPacket", 0x1, 6)); + table.extend(rows("SchedulerSharedData", 0x1, 1)); + table + } + + /// The 26-handle table measured on Windows Server 2022. + fn server_2022() -> Vec { + let mut table = exact(); + table.extend(rows("Event", 0x1f0003, 7)); + table.extend(rows("IoCompletion", 0x1f0003, 2)); + table.extend(rows("TpWorkerFactory", 0xf00ff, 2)); + table.extend(rows("IRTimer", 0x100002, 4)); + table.extend(rows("WaitCompletionPacket", 0x1, 5)); + table.extend(rows("Semaphore", 0x100003, 2)); + table + } + + #[test] + fn each_images_measured_table_fits_its_own_profile() { + assert_eq!(latest().len(), 26); + assert_eq!(check(&latest()), Ok(&WINDOWS_LATEST)); + assert_eq!(server_2022().len(), 26); + assert_eq!(check(&server_2022()), Ok(&WINDOWS_2022)); + } + + #[test] + fn a_smaller_table_fits() { + assert_eq!(check(&exact()), Ok(&WINDOWS_LATEST)); + let mut table = exact(); + table.extend(rows("Semaphore", 0x100003, 1)); + assert_eq!(check(&table), Ok(&WINDOWS_2022)); + } + + /// Windows 11's SchedulerSharedData handle together with Server 2022's + /// semaphores: 28 handles that fit neither profile alone, and would pass + /// only if the two profiles were combined. + #[test] + fn the_mixed_table_fits_no_profile() { + let mut mixed = latest(); + mixed.extend(rows("Semaphore", 0x100003, 2)); + assert_eq!(mixed.len(), 28); + let error = check(&mixed).expect_err("the mixed table must be refused"); + assert!(error.contains("not windows-latest: 2 Semaphore"), "{error}"); + assert!( + error.contains("1 SchedulerSharedData 0x1, at most 0"), + "{error}" + ); + // Even within the total, one row of each image is refused. + let mut small = exact(); + small.extend(rows("Semaphore", 0x100003, 1)); + small.extend(rows("SchedulerSharedData", 0x1, 1)); + assert!(check(&small).is_err()); + } + + #[test] + fn an_excess_count_is_refused() { + let mut table = latest(); + table.pop(); + table.push(entry("Event", 0x1f0003)); + assert_eq!(table.len(), 26); + let error = check(&table).expect_err("eight events"); + assert!(error.contains("8 Event 0x1f0003, at most 7"), "{error}"); + + let mut table = server_2022(); + table.push(entry("WaitCompletionPacket", 0x1)); + assert!( + check(&table).is_err(), + "six wait packets on 2022 and 27 in all" + ); + } + + #[test] + fn a_table_over_the_total_is_refused() { + // Every ceiling of windows-latest filled, plus one Semaphore that only + // windows-2022 allows: 27 handles. + let mut table = latest(); + table.extend(rows("Semaphore", 0x100003, 1)); + let error = check(&table).expect_err("27 handles"); + assert!(error.contains("27 handles, at most 26"), "{error}"); + } + + #[test] + fn startup_closes_ports_and_private_files_only() { + assert!(closed_at_startup("ALPC Port", false, false)); + assert!(closed_at_startup("File", false, false)); + // The standard pipes stay, and so does an inheritable file, which the + // allowlist must see and refuse. + assert!(!closed_at_startup("File", true, true)); + assert!(!closed_at_startup("File", false, true)); + for kept in [ + "Directory", + "Event", + "IoCompletion", + "TpWorkerFactory", + "IRTimer", + "WaitCompletionPacket", + "Semaphore", + "SchedulerSharedData", + "Key", + ] { + assert!(!closed_at_startup(kept, false, false), "{kept}"); + } + } + + #[test] + fn an_unknown_row_is_refused() { + for (type_name, access) in [ + ("Key", 0x20019), + ("Section", 0x4), + ("Process", 0x1000), + ("Thread", 0x1fffff), + ("Token", 0x8), + ("Job", 0x1f001f), + ("ALPC Port", 0x1f0001), + ("File", 0x100003), + ("Mutant", 0x1f0001), + ("unknown type 99", 0x1), + // A known type with an access mask the table does not list. + ("Event", 0x1f0001), + ("TpWorkerFactory", 0xf003f), + ] { + let mut table = exact(); + table.push(entry(type_name, access)); + let error = check(&table).expect_err(type_name); + assert!(error.contains(type_name), "{error}"); + } + } + + #[test] + fn a_missing_pipe_or_known_dlls_is_refused() { + for missing in 0..4 { + let mut table = latest(); + table.remove(missing); + assert!(check(&table).is_err(), "row {missing} missing"); + } + // A duplicate stdin pipe is not a second accepted pipe. + let mut table = latest(); + table.push(exact().remove(0)); + assert!(check(&table).is_err()); + } + + #[test] + fn a_standard_handle_must_be_its_own_pipe() { + // stdout with read access, stdin with write access. + let mut table = exact(); + table[0].access = STDOUT_ACCESS; + assert!(check(&table).is_err()); + let mut table = exact(); + table[1].access = STDIN_ACCESS; + assert!(check(&table).is_err()); + // A standard handle that is not a File. + let mut table = exact(); + table[2].type_name = "Event".into(); + assert!(check(&table).is_err()); + } + + #[test] + fn an_inheritable_handle_that_is_not_stdio_is_refused() { + let mut table = exact(); + table.push(HandleEntry { + inheritable: true, + ..entry("Event", 0x1f0003) + }); + let error = check(&table).expect_err("inheritable event"); + assert!(error.contains("inheritable"), "{error}"); + // A file handle the parent leaked, with pipe access but not one of + // the standard handles. + let mut table = exact(); + table.push(HandleEntry { + inheritable: true, + ..entry("File", STDOUT_ACCESS) + }); + assert!(check(&table).is_err()); + } + + #[test] + fn only_the_known_dlls_directory_is_allowed() { + let mut table = exact(); + table[3].name = Some("\\Sessions\\1\\BaseNamedObjects".into()); + assert!(check(&table).is_err()); + let mut table = exact(); + table[3].name = None; + assert!(check(&table).is_err()); + let mut table = exact(); + table[3].access = 0xf; + assert!(check(&table).is_err()); + let mut table = exact(); + table.push(table[3].clone()); + assert!(check(&table).is_err(), "two directory handles"); + } +} diff --git a/crates/basal-worker/src/confinement/windows/attest.rs b/crates/basal-worker/src/confinement/windows/attest.rs new file mode 100644 index 0000000..c5ff246 --- /dev/null +++ b/crates/basal-worker/src/confinement/windows/attest.rs @@ -0,0 +1,321 @@ +//! The check of the worker's actual primary token (startup step 4). +//! +//! The parent builds the primary and checks it on the suspended process, but +//! the system changes a token at creation (process creation makes an +//! AppContainer primary Low whatever integrity was supplied), and the worker +//! lowers its own integrity after load. So the worker reads its token back +//! once more, after every change, and judges what it reads rather than what +//! anyone meant to build. +//! +//! The readers report each property separately, and a property that could +//! not be read fails the check that needed it. The checks run in a fixed +//! order and the first failure is the reason. + +use super::{Refusal, reason}; + +/// The NULL SID, the only restricting SID the primary may have. +pub const NULL_SID: &str = "S-1-0-0"; +/// The Untrusted mandatory label the worker lowers itself to. +pub const UNTRUSTED_INTEGRITY: &str = "S-1-16-0"; +/// The claim that marks a Less Privileged AppContainer token. +pub const LPAC_CLAIM: &str = "WIN://NOALLAPPPKG"; +/// `SE_GROUP_INTEGRITY`: marks the integrity label, which is not an access +/// group. +pub const SE_GROUP_INTEGRITY: u32 = 0x20; +/// `SE_GROUP_USE_FOR_DENY_ONLY`: the group matches only deny entries. +pub const SE_GROUP_USE_FOR_DENY_ONLY: u32 = 0x10; + +/// The `WIN://NOALLAPPPKG` security attribute as read from the token. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Claim { + /// The token has no attribute of that name. + Absent, + /// The attribute holds unsigned 64-bit values. + Unsigned(Vec), + /// The attribute holds values of another type, by its type number. + OtherType(u16), +} + +/// One access group: its SID and attribute flags. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Group { + pub sid: String, + pub attributes: u32, +} + +/// What was read from the primary. Each field is the property, or why it +/// could not be read. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PrimaryFacts { + pub lpac_claim: Result, + /// The token's AppContainer SID, if it is an AppContainer token, and + /// whether it is equal (`EqualSid`) to the `--package-sid` argument. + pub package: Result, String>, + pub capabilities: Result, String>, + pub restricting_sids: Result, String>, + pub groups: Result, String>, + pub privileges: Result, + pub integrity: Result, +} + +/// The property, or the named refusal of the check that could not read it. +fn read<'a, T>(reason: &'static str, result: &'a Result) -> Result<&'a T, Refusal> { + result + .as_ref() + .map_err(|error| Refusal::new(reason, format!("could not read: {error}"))) +} + +/// Runs the six token checks in order and returns the first failure. +pub fn check(facts: &PrimaryFacts) -> Result<(), Refusal> { + let claim = read(reason::NOT_LPAC, &facts.lpac_claim)?; + if !matches!(claim, Claim::Unsigned(values) if values.as_slice() == [1]) { + return Err(Refusal::new( + reason::NOT_LPAC, + format!("{LPAC_CLAIM} is {claim:?}, not [1]"), + )); + } + match read(reason::NOT_LPAC, &facts.package)? { + Some((_, true)) => {} + Some((sid, false)) => { + return Err(Refusal::new( + reason::NOT_LPAC, + format!("AppContainer SID {sid} is not the --package-sid"), + )); + } + None => return Err(Refusal::new(reason::NOT_LPAC, "not an AppContainer token")), + } + + let capabilities = read(reason::CAPABILITIES_PRESENT, &facts.capabilities)?; + if !capabilities.is_empty() { + return Err(Refusal::new( + reason::CAPABILITIES_PRESENT, + format!("capabilities {capabilities:?}"), + )); + } + + let sids = read(reason::RESTRICTING_SID_MISMATCH, &facts.restricting_sids)?; + if sids.as_slice() != [NULL_SID] { + return Err(Refusal::new( + reason::RESTRICTING_SID_MISMATCH, + format!("restricting SIDs {sids:?}, not [{NULL_SID}]"), + )); + } + + let groups = read(reason::GROUP_NOT_DENY_ONLY, &facts.groups)?; + let enabled: Vec<&str> = groups + .iter() + .filter(|group| group.attributes & SE_GROUP_INTEGRITY == 0) + .filter(|group| group.attributes & SE_GROUP_USE_FOR_DENY_ONLY == 0) + .map(|group| group.sid.as_str()) + .collect(); + if !enabled.is_empty() { + return Err(Refusal::new( + reason::GROUP_NOT_DENY_ONLY, + format!("groups not deny-only {enabled:?}"), + )); + } + + let privileges = *read(reason::PRIVILEGES_PRESENT, &facts.privileges)?; + if privileges != 0 { + return Err(Refusal::new( + reason::PRIVILEGES_PRESENT, + format!("{privileges} privileges"), + )); + } + + let integrity = read(reason::INTEGRITY_NOT_UNTRUSTED, &facts.integrity)?; + if integrity != UNTRUSTED_INTEGRITY { + return Err(Refusal::new( + reason::INTEGRITY_NOT_UNTRUSTED, + format!("integrity {integrity}, not {UNTRUSTED_INTEGRITY}"), + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const PACKAGE: &str = "S-1-15-2-1-2-3-4-5-6-7"; + + /// The primary measured in a fully confined worker after it lowered + /// itself: deny-only groups (logon included) plus the integrity label. + fn confined() -> PrimaryFacts { + PrimaryFacts { + lpac_claim: Ok(Claim::Unsigned(vec![1])), + package: Ok(Some((PACKAGE.into(), true))), + capabilities: Ok(vec![]), + restricting_sids: Ok(vec![NULL_SID.into()]), + groups: Ok(vec![ + Group { + sid: "S-1-1-0".into(), + attributes: SE_GROUP_USE_FOR_DENY_ONLY, + }, + Group { + sid: "S-1-5-5-0-1".into(), + attributes: SE_GROUP_USE_FOR_DENY_ONLY | 0xc000_0000, + }, + Group { + sid: UNTRUSTED_INTEGRITY.into(), + attributes: SE_GROUP_INTEGRITY | 0x40, + }, + ]), + privileges: Ok(0), + integrity: Ok(UNTRUSTED_INTEGRITY.into()), + } + } + + fn reason_of(facts: &PrimaryFacts) -> &'static str { + check(facts).expect_err("must be refused").reason + } + + #[test] + fn the_confined_primary_passes() { + assert_eq!(check(&confined()), Ok(())); + // No group count is pinned: a token with fewer groups passes too. + let mut fewer = confined(); + fewer.groups = Ok(vec![]); + assert_eq!(check(&fewer), Ok(())); + } + + /// One way to break the confined primary, and the reason it must get. + type Case = (&'static str, Box); + + #[test] + fn each_broken_property_is_refused_with_its_reason() { + let cases: Vec = vec![ + ( + reason::NOT_LPAC, + Box::new(|f| f.lpac_claim = Ok(Claim::Absent)), + ), + ( + reason::NOT_LPAC, + Box::new(|f| f.lpac_claim = Ok(Claim::Unsigned(vec![0]))), + ), + ( + reason::NOT_LPAC, + Box::new(|f| f.lpac_claim = Ok(Claim::Unsigned(vec![1, 1]))), + ), + ( + reason::NOT_LPAC, + Box::new(|f| f.lpac_claim = Ok(Claim::OtherType(1))), + ), + ( + reason::NOT_LPAC, + Box::new(|f| f.package = Ok(Some(("S-1-15-2-9".into(), false)))), + ), + (reason::NOT_LPAC, Box::new(|f| f.package = Ok(None))), + ( + reason::CAPABILITIES_PRESENT, + Box::new(|f| f.capabilities = Ok(vec!["S-1-15-3-1".into()])), + ), + ( + reason::RESTRICTING_SID_MISMATCH, + Box::new(|f| f.restricting_sids = Ok(vec![])), + ), + ( + reason::RESTRICTING_SID_MISMATCH, + Box::new(|f| f.restricting_sids = Ok(vec![NULL_SID.into(), "S-1-1-0".into()])), + ), + ( + reason::RESTRICTING_SID_MISMATCH, + Box::new(|f| f.restricting_sids = Ok(vec!["S-1-1-0".into()])), + ), + ( + reason::GROUP_NOT_DENY_ONLY, + Box::new(|f| { + f.groups.as_mut().unwrap().push(Group { + sid: "S-1-5-5-0-1".into(), + attributes: 0xc000_0007, + }) + }), + ), + ( + reason::PRIVILEGES_PRESENT, + Box::new(|f| f.privileges = Ok(1)), + ), + ( + reason::INTEGRITY_NOT_UNTRUSTED, + Box::new(|f| f.integrity = Ok("S-1-16-4096".into())), + ), + ]; + for (expected, breaks) in cases { + let mut facts = confined(); + breaks(&mut facts); + assert_eq!(reason_of(&facts), expected, "{facts:?}"); + } + } + + #[test] + fn an_unreadable_property_fails_the_check_that_needs_it() { + fn error() -> Result { + Err("Win32 5".to_owned()) + } + let mut facts = confined(); + facts.lpac_claim = error(); + assert_eq!(reason_of(&facts), reason::NOT_LPAC); + let mut facts = confined(); + facts.package = error(); + assert_eq!(reason_of(&facts), reason::NOT_LPAC); + let mut facts = confined(); + facts.capabilities = error(); + assert_eq!(reason_of(&facts), reason::CAPABILITIES_PRESENT); + let mut facts = confined(); + facts.restricting_sids = error(); + assert_eq!(reason_of(&facts), reason::RESTRICTING_SID_MISMATCH); + let mut facts = confined(); + facts.groups = error(); + assert_eq!(reason_of(&facts), reason::GROUP_NOT_DENY_ONLY); + let mut facts = confined(); + facts.privileges = error(); + assert_eq!(reason_of(&facts), reason::PRIVILEGES_PRESENT); + let mut facts = confined(); + facts.integrity = error(); + assert_eq!(reason_of(&facts), reason::INTEGRITY_NOT_UNTRUSTED); + } + + /// The checks run in the spec's order: with every property broken, the + /// first one is reported, and fixing it reveals the next. + #[test] + fn the_first_failing_check_in_order_is_reported() { + let mut facts = PrimaryFacts { + lpac_claim: Ok(Claim::Absent), + package: Ok(None), + capabilities: Ok(vec!["S-1-15-3-1".into()]), + restricting_sids: Ok(vec![]), + groups: Ok(vec![Group { + sid: "S-1-1-0".into(), + attributes: 7, + }]), + privileges: Ok(3), + integrity: Ok("S-1-16-4096".into()), + }; + let fixed = confined(); + let order = [ + reason::NOT_LPAC, + reason::CAPABILITIES_PRESENT, + reason::RESTRICTING_SID_MISMATCH, + reason::GROUP_NOT_DENY_ONLY, + reason::PRIVILEGES_PRESENT, + reason::INTEGRITY_NOT_UNTRUSTED, + ]; + for expected in order { + assert_eq!(reason_of(&facts), expected); + match expected { + reason::NOT_LPAC => { + facts.lpac_claim = fixed.lpac_claim.clone(); + facts.package = fixed.package.clone(); + } + reason::CAPABILITIES_PRESENT => facts.capabilities = fixed.capabilities.clone(), + reason::RESTRICTING_SID_MISMATCH => { + facts.restricting_sids = fixed.restricting_sids.clone() + } + reason::GROUP_NOT_DENY_ONLY => facts.groups = fixed.groups.clone(), + reason::PRIVILEGES_PRESENT => facts.privileges = fixed.privileges.clone(), + _ => facts.integrity = fixed.integrity.clone(), + } + } + assert_eq!(check(&facts), Ok(())); + } +} diff --git a/crates/basal-worker/src/confinement/windows/mitigation.rs b/crates/basal-worker/src/confinement/windows/mitigation.rs new file mode 100644 index 0000000..ca1d974 --- /dev/null +++ b/crates/basal-worker/src/confinement/windows/mitigation.rs @@ -0,0 +1,226 @@ +//! The mitigation policies the worker requires of its own process. +//! +//! The parent sets them as a creation attribute; the worker reads each policy +//! word back with `GetProcessMitigationPolicy` and checks single bits. Only +//! the listed bits are judged. Whole words are never compared: they differ +//! between Windows releases (Windows 11 reports an extra Win32k bit that +//! Server 2022 does not), and a bit added by a later release must not make a +//! correctly confined worker refuse to start. + +/// One policy word per mitigation policy the worker reads. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct MitigationWords { + /// `PROCESS_MITIGATION_DYNAMIC_CODE_POLICY`. + pub dynamic_code: u32, + /// `PROCESS_MITIGATION_BINARY_SIGNATURE_POLICY`. + pub signature: u32, + /// `PROCESS_MITIGATION_IMAGE_LOAD_POLICY`. + pub image_load: u32, + /// `PROCESS_MITIGATION_SYSTEM_CALL_DISABLE_POLICY`. + pub system_call: u32, + /// `PROCESS_MITIGATION_STRICT_HANDLE_CHECK_POLICY`. + pub strict_handle: u32, + /// `PROCESS_MITIGATION_EXTENSION_POINT_DISABLE_POLICY`. + pub extension_point: u32, + /// `PROCESS_MITIGATION_CHILD_PROCESS_POLICY`. + pub child_process: u32, +} + +/// Which policy word a bit lives in. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Policy { + DynamicCode, + Signature, + ImageLoad, + SystemCall, + StrictHandle, + ExtensionPoint, + ChildProcess, +} + +impl Policy { + fn word(self, words: &MitigationWords) -> u32 { + match self { + Self::DynamicCode => words.dynamic_code, + Self::Signature => words.signature, + Self::ImageLoad => words.image_load, + Self::SystemCall => words.system_call, + Self::StrictHandle => words.strict_handle, + Self::ExtensionPoint => words.extension_point, + Self::ChildProcess => words.child_process, + } + } +} + +/// One named bit of a policy word. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Bit { + pub policy: Policy, + pub mask: u32, + pub name: &'static str, +} + +const fn bit(policy: Policy, index: u32, name: &'static str) -> Bit { + Bit { + policy, + mask: 1 << index, + name, + } +} + +/// The bits that must be set. The positions are those of the bit fields in +/// the `PROCESS_MITIGATION_*_POLICY` structures. +pub const REQUIRED: [Bit; 10] = [ + // No executable memory can be created, or made executable, after load. + bit(Policy::DynamicCode, 0, "ProhibitDynamicCode"), + // Only Microsoft-signed DLLs load. + bit(Policy::Signature, 0, "MicrosoftSignedOnly"), + bit(Policy::ImageLoad, 0, "NoRemoteImages"), + bit(Policy::ImageLoad, 1, "NoLowMandatoryLabelImages"), + bit(Policy::ImageLoad, 2, "PreferSystem32Images"), + // No system calls into the GUI kernel component. + bit(Policy::SystemCall, 0, "DisallowWin32kSystemCalls"), + // Using an invalid handle raises an exception, and that cannot be undone. + bit( + Policy::StrictHandle, + 0, + "RaiseExceptionOnInvalidHandleReference", + ), + bit( + Policy::StrictHandle, + 1, + "HandleExceptionsPermanentlyEnabled", + ), + // No legacy extension points (AppInit DLLs, hooks and the like). + bit(Policy::ExtensionPoint, 0, "DisableExtensionPoints"), + bit(Policy::ChildProcess, 0, "NoChildProcessCreation"), +]; + +/// The bits that must be clear: each lets code in the process turn the +/// dynamic-code prohibition off again, for one thread or from outside. +pub const FORBIDDEN: [Bit; 2] = [ + bit(Policy::DynamicCode, 1, "AllowThreadOptOut"), + bit(Policy::DynamicCode, 2, "AllowRemoteDowngrade"), +]; + +/// Checks the words read back: every required bit set and every forbidden +/// bit clear. Any other bit is ignored. The error names every bit that is +/// wrong. +pub fn validate(words: &MitigationWords) -> Result<(), String> { + let mut wrong: Vec = Vec::new(); + for required in REQUIRED { + if required.policy.word(words) & required.mask == 0 { + wrong.push(format!("{} is off", required.name)); + } + } + for forbidden in FORBIDDEN { + if forbidden.policy.word(words) & forbidden.mask != 0 { + wrong.push(format!("{} is on", forbidden.name)); + } + } + if wrong.is_empty() { + Ok(()) + } else { + Err(format!("{} (read back {words:?})", wrong.join(", "))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The words measured in a fully confined child. Windows 11 reports + /// Win32k word 5 and Server 2022 word 1; both must pass. + fn measured(system_call: u32) -> MitigationWords { + MitigationWords { + dynamic_code: 1, + signature: 5, + image_load: 7, + system_call, + strict_handle: 3, + extension_point: 1, + child_process: 1, + } + } + + fn set(words: &mut MitigationWords, bit: Bit, on: bool) { + let word = match bit.policy { + Policy::DynamicCode => &mut words.dynamic_code, + Policy::Signature => &mut words.signature, + Policy::ImageLoad => &mut words.image_load, + Policy::SystemCall => &mut words.system_call, + Policy::StrictHandle => &mut words.strict_handle, + Policy::ExtensionPoint => &mut words.extension_point, + Policy::ChildProcess => &mut words.child_process, + }; + if on { + *word |= bit.mask; + } else { + *word &= !bit.mask; + } + } + + #[test] + fn the_measured_words_of_both_images_pass() { + assert_eq!(validate(&measured(5)), Ok(())); + assert_eq!(validate(&measured(1)), Ok(())); + } + + #[test] + fn exactly_the_required_bits_pass_and_other_bits_are_ignored() { + let mut words = MitigationWords::default(); + for required in REQUIRED { + set(&mut words, required, true); + } + assert_eq!(validate(&words), Ok(())); + // Bits nobody listed (audit modes, store signing, later additions) + // change nothing, in any word but the dynamic-code one, whose other + // bits include the forbidden opt-outs. + let mut noisy = words; + noisy.signature |= !1; + noisy.image_load |= !7; + noisy.system_call |= !1; + noisy.strict_handle |= !3; + noisy.extension_point |= !1; + noisy.child_process |= !1; + noisy.dynamic_code |= 1 << 3; + assert_eq!(validate(&noisy), Ok(())); + } + + #[test] + fn each_missing_required_bit_is_refused_by_name() { + for required in REQUIRED { + let mut words = measured(5); + set(&mut words, required, false); + let error = validate(&words).expect_err(required.name); + assert!( + error.starts_with(&format!("{} is off", required.name)), + "{error}" + ); + } + assert!(validate(&MitigationWords::default()).is_err()); + } + + /// Every required bit set does not excuse an opt-out: each forbidden bit + /// alone, together with all the required ones, is refused. + #[test] + fn every_required_bit_with_a_forbidden_opt_out_is_refused() { + let mut all_required = MitigationWords::default(); + for required in REQUIRED { + set(&mut all_required, required, true); + } + for forbidden in FORBIDDEN { + let mut words = all_required; + set(&mut words, forbidden, true); + let error = validate(&words).expect_err(forbidden.name); + assert!( + error.starts_with(&format!("{} is on", forbidden.name)), + "{error}" + ); + } + assert_eq!(FORBIDDEN[0].name, "AllowThreadOptOut"); + assert_eq!(FORBIDDEN[0].mask, 0x2); + assert_eq!(FORBIDDEN[1].name, "AllowRemoteDowngrade"); + assert_eq!(FORBIDDEN[1].mask, 0x4); + } +} diff --git a/crates/basal-worker/src/confinement/windows/native.rs b/crates/basal-worker/src/confinement/windows/native.rs new file mode 100644 index 0000000..1f75506 --- /dev/null +++ b/crates/basal-worker/src/confinement/windows/native.rs @@ -0,0 +1,623 @@ +//! The Win32 and native calls behind the worker's startup checks. +//! +//! Each reader reports what it found and leaves judging it to the pure +//! checks in [`super::attest`], [`super::mitigation`] and +//! [`super::allowlist`]. Native structure layouts are those of 64-bit +//! Windows, the only Windows target basal builds for. + +use super::allowlist::{HandleEntry, Stdio}; +use super::attest::{Claim, Group, LPAC_CLAIM, PrimaryFacts}; +use super::mitigation::MitigationWords; +use std::collections::BTreeMap; +use std::ffi::c_void; +use std::mem::size_of; +use std::ptr::null_mut; +use windows_sys::Win32::Foundation::{ + CloseHandle, DUPLICATE_SAME_ACCESS, DuplicateHandle, GetLastError, HANDLE, LocalFree, +}; +use windows_sys::Win32::Security::Authorization::{ConvertSidToStringSidW, ConvertStringSidToSidW}; +use windows_sys::Win32::Security::{ + CreateWellKnownSid, EqualSid, GetLengthSid, GetTokenInformation, PSID, SID_AND_ATTRIBUTES, + SetTokenInformation, TOKEN_APPCONTAINER_INFORMATION, TOKEN_GROUPS, TOKEN_INFORMATION_CLASS, + TOKEN_MANDATORY_LABEL, TOKEN_PRIVILEGES, TokenAppContainerSid, TokenCapabilities, TokenGroups, + TokenIntegrityLevel, TokenIsAppContainer, TokenPrivileges, TokenRestrictedSids, + TokenSecurityAttributes, WinUntrustedLabelSid, +}; +use windows_sys::Win32::System::Console::{ + GetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, +}; +use windows_sys::Win32::System::Threading::{ + GetCurrentProcess, GetProcessMitigationPolicy, PROCESS_MITIGATION_POLICY, + ProcessChildProcessPolicy, ProcessDynamicCodePolicy, ProcessExtensionPointDisablePolicy, + ProcessImageLoadPolicy, ProcessSignaturePolicy, ProcessStrictHandleCheckPolicy, + ProcessSystemCallDisablePolicy, +}; + +pub(super) type Result = std::result::Result; + +/// The pseudo-handle for this process's primary token +/// (`GetCurrentProcessToken`). Querying through it opens no handle, so the +/// token check leaves nothing behind in the handle table. +pub(super) const CURRENT_PROCESS_TOKEN: HANDLE = -4isize as HANDLE; + +/// `SE_GROUP_INTEGRITY`, the attribute of the integrity label in a token's +/// group list. +const SE_GROUP_INTEGRITY: u32 = 0x20; +/// `OBJ_INHERIT` in a handle-table entry's attributes. +pub(super) const OBJ_INHERIT: u32 = 0x2; +/// `STATUS_INFO_LENGTH_MISMATCH`: the buffer was too small. +const STATUS_INFO_LENGTH_MISMATCH: i32 = 0xc000_0004_u32 as i32; +/// `STATUS_BUFFER_OVERFLOW`: the buffer was too small for the whole result. +const STATUS_BUFFER_OVERFLOW: i32 = 0x8000_0005_u32 as i32; +/// `ProcessHandleInformation`, the process's own handle table. +const PROCESS_HANDLE_INFORMATION: u32 = 51; +/// `ObjectNameInformation`. +const OBJECT_NAME_INFORMATION: u32 = 1; +/// `ObjectTypesInformation`: every object type and its index. +const OBJECT_TYPES_INFORMATION: u32 = 3; +/// `TOKEN_SECURITY_ATTRIBUTE_TYPE_UINT64`. +const ATTRIBUTE_TYPE_UINT64: u16 = 2; + +#[link(name = "ntdll", kind = "raw-dylib")] +unsafe extern "system" { + fn NtQueryInformationProcess( + process: HANDLE, + class: u32, + buffer: *mut c_void, + length: u32, + returned: *mut u32, + ) -> i32; + fn NtQueryObject( + handle: HANDLE, + class: u32, + buffer: *mut c_void, + length: u32, + returned: *mut u32, + ) -> i32; + fn NtQueryInformationToken( + token: HANDLE, + class: TOKEN_INFORMATION_CLASS, + buffer: *mut c_void, + length: u32, + returned: *mut u32, + ) -> i32; +} + +/// The calling thread's last Win32 error, named after the call that failed. +pub(super) fn last(api: &str) -> String { + let code = unsafe { GetLastError() }; + format!( + "{api}: Win32 {code} ({})", + std::io::Error::from_raw_os_error(code as i32) + ) +} + +fn status(api: &str, status: i32) -> String { + format!("{api}: NTSTATUS {:#010x}", status as u32) +} + +/// Closes a handle this process owns and reports a failure. +pub(super) fn close(handle: HANDLE, what: &str) -> Result<()> { + if unsafe { CloseHandle(handle) } == 0 { + Err(last(&format!("CloseHandle({what})"))) + } else { + Ok(()) + } +} + +/// The package SID from `--package-sid=`, parsed by the system's own SID +/// parser. +pub struct PackageSid(PSID); + +impl PackageSid { + /// Parses SID text. Fails for text the system does not accept as a SID. + pub fn parse(text: &str) -> Result { + let wide: Vec = text.encode_utf16().chain(Some(0)).collect(); + let mut sid: PSID = null_mut(); + if unsafe { ConvertStringSidToSidW(wide.as_ptr(), &mut sid) } == 0 { + return Err(last(&format!("ConvertStringSidToSidW({text})"))); + } + Ok(Self(sid)) + } +} + +impl Drop for PackageSid { + fn drop(&mut self) { + // The SID was allocated by ConvertStringSidToSidW with LocalAlloc. + unsafe { LocalFree(self.0) }; + } +} + +/// A SID as text. +/// +/// # Safety +/// +/// `sid` must point to a valid SID. +unsafe fn sid_string(sid: PSID) -> Result { + unsafe { + let mut text = null_mut(); + if ConvertSidToStringSidW(sid, &mut text) == 0 { + return Err(last("ConvertSidToStringSidW")); + } + let mut length = 0; + while *text.add(length) != 0 { + length += 1; + } + let result = String::from_utf16_lossy(std::slice::from_raw_parts(text, length)); + LocalFree(text.cast()); + Ok(result) + } +} + +/// Reads one variable-length token information class into an aligned +/// buffer. +fn token_buffer(token: HANDLE, class: TOKEN_INFORMATION_CLASS) -> Result> { + unsafe { + let mut bytes = 0; + GetTokenInformation(token, class, null_mut(), 0, &mut bytes); + if bytes == 0 { + return Err(last(&format!("GetTokenInformation(class {class}, size)"))); + } + // A list with no entries can be shorter than the C declaration, which + // reserves one entry. Keep room for it so a reference to the + // declaration never reaches past the allocation. + let allocation = (bytes as usize) + .max(size_of::()) + .max(size_of::()); + let mut data = vec![0usize; allocation.div_ceil(size_of::())]; + if GetTokenInformation(token, class, data.as_mut_ptr().cast(), bytes, &mut bytes) == 0 { + return Err(last(&format!("GetTokenInformation(class {class})"))); + } + Ok(data) + } +} + +/// The entries of a `TOKEN_GROUPS` buffer. +/// +/// # Safety +/// +/// `data` must hold a `TOKEN_GROUPS` structure returned by the system. +unsafe fn groups(data: &[usize]) -> &[SID_AND_ATTRIBUTES] { + unsafe { + let list = &*data.as_ptr().cast::(); + std::slice::from_raw_parts(list.Groups.as_ptr(), list.GroupCount as usize) + } +} + +fn sid_list(token: HANDLE, class: TOKEN_INFORMATION_CLASS) -> Result> { + let data = token_buffer(token, class)?; + unsafe { groups(&data) } + .iter() + .map(|entry| unsafe { sid_string(entry.Sid) }) + .collect() +} + +/// Reads every property the token check judges, each on its own. +pub(super) fn primary_facts(token: HANDLE, package: &PackageSid) -> PrimaryFacts { + PrimaryFacts { + lpac_claim: lpac_claim(token), + package: appcontainer(token, package), + capabilities: sid_list(token, TokenCapabilities), + restricting_sids: sid_list(token, TokenRestrictedSids), + groups: token_buffer(token, TokenGroups).and_then(|data| { + unsafe { groups(&data) } + .iter() + .map(|group| { + Ok(Group { + sid: unsafe { sid_string(group.Sid) }?, + attributes: group.Attributes, + }) + }) + .collect() + }), + privileges: token_buffer(token, TokenPrivileges).map(|data| unsafe { + (*data.as_ptr().cast::()).PrivilegeCount as usize + }), + integrity: token_buffer(token, TokenIntegrityLevel).and_then(|data| unsafe { + sid_string((*data.as_ptr().cast::()).Label.Sid) + }), + } +} + +/// The token's AppContainer SID, and whether it equals the package SID. +fn appcontainer(token: HANDLE, package: &PackageSid) -> Result> { + let is_app = token_buffer(token, TokenIsAppContainer)?; + if unsafe { *is_app.as_ptr().cast::() } == 0 { + return Ok(None); + } + let info = token_buffer(token, TokenAppContainerSid)?; + let sid = + unsafe { (*info.as_ptr().cast::()).TokenAppContainer }; + if sid.is_null() { + return Ok(None); + } + let equal = unsafe { EqualSid(sid, package.0) } != 0; + Ok(Some((unsafe { sid_string(sid) }?, equal))) +} + +// Token security attributes have no Win32 reader, so the claim is read with +// the native call. The layouts are those of +// TOKEN_SECURITY_ATTRIBUTES_INFORMATION and TOKEN_SECURITY_ATTRIBUTE_V1. +#[repr(C)] +struct UnicodeString { + length: u16, + maximum_length: u16, + buffer: *mut u16, +} + +impl UnicodeString { + /// # Safety + /// + /// `buffer` must hold `length` bytes of UTF-16, or be null. + unsafe fn text(&self) -> String { + if self.buffer.is_null() { + return String::new(); + } + String::from_utf16_lossy(unsafe { + std::slice::from_raw_parts(self.buffer, usize::from(self.length) / 2) + }) + } +} + +#[repr(C)] +struct SecurityAttribute { + name: UnicodeString, + value_type: u16, + reserved: u16, + flags: u32, + value_count: u32, + values: *mut c_void, +} + +#[repr(C)] +struct SecurityAttributes { + version: u16, + reserved: u16, + count: u32, + attributes: *mut SecurityAttribute, +} + +/// The `WIN://NOALLAPPPKG` attribute. The dedicated information class, +/// `TokenIsLessPrivilegedAppContainer`, fails with "invalid information +/// class" on Windows 11 and Server 2022, so the claim is read instead. +fn lpac_claim(token: HANDLE) -> Result { + unsafe { + let mut bytes = 0; + let size_status = + NtQueryInformationToken(token, TokenSecurityAttributes, null_mut(), 0, &mut bytes); + if bytes == 0 { + return Err(status( + "NtQueryInformationToken(TokenSecurityAttributes, size)", + size_status, + )); + } + let mut data = vec![0usize; (bytes as usize).div_ceil(size_of::())]; + let read = NtQueryInformationToken( + token, + TokenSecurityAttributes, + data.as_mut_ptr().cast(), + bytes, + &mut bytes, + ); + if read != 0 { + return Err(status( + "NtQueryInformationToken(TokenSecurityAttributes)", + read, + )); + } + let header = &*data.as_ptr().cast::(); + if header.version != 1 { + return Err(format!( + "unsupported token security attributes version {}", + header.version + )); + } + if header.count == 0 { + return Ok(Claim::Absent); + } + let entries = std::slice::from_raw_parts(header.attributes, header.count as usize); + for entry in entries { + if entry.name.text() != LPAC_CLAIM { + continue; + } + if entry.value_type != ATTRIBUTE_TYPE_UINT64 { + return Ok(Claim::OtherType(entry.value_type)); + } + let values = if entry.value_count == 0 { + Vec::new() + } else { + std::slice::from_raw_parts(entry.values.cast::(), entry.value_count as usize) + .to_vec() + }; + return Ok(Claim::Unsigned(values)); + } + Ok(Claim::Absent) + } +} + +/// Sets the token's integrity label to Untrusted, `S-1-16-0`. The handle +/// needs `TOKEN_ADJUST_DEFAULT`. +pub(super) fn set_untrusted(token: HANDLE) -> Result<()> { + unsafe { + // SECURITY_MAX_SID_SIZE is 68 bytes. + let mut sid = [0usize; 68usize.div_ceil(size_of::())]; + let mut bytes = size_of_val(&sid) as u32; + if CreateWellKnownSid( + WinUntrustedLabelSid, + null_mut(), + sid.as_mut_ptr().cast(), + &mut bytes, + ) == 0 + { + return Err(last("CreateWellKnownSid(Untrusted)")); + } + let label = TOKEN_MANDATORY_LABEL { + Label: SID_AND_ATTRIBUTES { + Sid: sid.as_mut_ptr().cast(), + Attributes: SE_GROUP_INTEGRITY, + }, + }; + let length = size_of::() + GetLengthSid(label.Label.Sid) as usize; + if SetTokenInformation( + token, + TokenIntegrityLevel, + (&label as *const TOKEN_MANDATORY_LABEL).cast(), + length as u32, + ) == 0 + { + return Err(last("SetTokenInformation(TokenIntegrityLevel)")); + } + Ok(()) + } +} + +/// Reads this process's seven mitigation policy words. +pub(super) fn mitigation_words() -> Result { + let read = |policy: PROCESS_MITIGATION_POLICY, name: &str| -> Result { + let mut word = 0u32; + let ok = unsafe { + GetProcessMitigationPolicy( + GetCurrentProcess(), + policy, + (&mut word as *mut u32).cast(), + size_of::(), + ) + }; + if ok == 0 { + return Err(last(&format!("GetProcessMitigationPolicy({name})"))); + } + Ok(word) + }; + Ok(MitigationWords { + dynamic_code: read(ProcessDynamicCodePolicy, "dynamic code")?, + signature: read(ProcessSignaturePolicy, "signature")?, + image_load: read(ProcessImageLoadPolicy, "image load")?, + system_call: read(ProcessSystemCallDisablePolicy, "system call disable")?, + strict_handle: read(ProcessStrictHandleCheckPolicy, "strict handle check")?, + extension_point: read(ProcessExtensionPointDisablePolicy, "extension point")?, + child_process: read(ProcessChildProcessPolicy, "child process")?, + }) +} + +/// The values of this process's standard input, output and error handles. +pub(super) fn standard_handles() -> [usize; 3] { + [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE] + .map(|which| unsafe { GetStdHandle(which) } as usize) +} + +/// One entry of `PROCESS_HANDLE_SNAPSHOT_INFORMATION`. +#[repr(C)] +#[derive(Clone, Copy)] +pub(super) struct SnapshotEntry { + pub(super) handle: HANDLE, + handle_count: usize, + pointer_count: usize, + pub(super) access: u32, + pub(super) type_index: u32, + pub(super) attributes: u32, + reserved: u32, +} + +/// Lists this process's handle table. Only the values, types and flags are +/// read: no snapshot handle is used as a query target, because another +/// thread can close it meanwhile, and strict handle checks make a call on a +/// closed handle end the process. +pub(super) fn handle_snapshot() -> Result> { + let mut bytes = 64 * 1024u32; + for _ in 0..8 { + let mut buffer = vec![0usize; bytes as usize / size_of::()]; + let mut returned = 0; + let result = unsafe { + NtQueryInformationProcess( + GetCurrentProcess(), + PROCESS_HANDLE_INFORMATION, + buffer.as_mut_ptr().cast(), + bytes, + &mut returned, + ) + }; + if result == STATUS_INFO_LENGTH_MISMATCH { + // Handles can be created between the two calls; leave room. + bytes = returned.max(bytes).saturating_mul(2); + continue; + } + if result < 0 { + return Err(status( + "NtQueryInformationProcess(ProcessHandleInformation)", + result, + )); + } + // The header is two pointer-sized fields: the count and a reserved + // field. The entries follow. + let count = buffer[0]; + let capacity = (bytes as usize - 2 * size_of::()) / size_of::(); + if count > capacity { + return Err(format!("handle snapshot count {count} exceeds its buffer")); + } + let entries = unsafe { + std::slice::from_raw_parts(buffer.as_ptr().add(2).cast::(), count) + }; + return Ok(entries.to_vec()); + } + Err("the handle snapshot kept growing".into()) +} + +/// One entry of `OBJECT_TYPES_INFORMATION`, the fixed part of +/// `OBJECT_TYPE_INFORMATION`. The type's name follows it. +#[repr(C)] +struct ObjectTypeInformation { + name: UnicodeString, + counters: [u32; 13], + generic_mapping: [u32; 4], + valid_access_mask: u32, + security_required: u8, + maintain_handle_count: u8, + type_index: u8, + reserved: u8, + pool: [u32; 3], +} + +/// Every object type's name by its index, the index handle tables use. +pub(super) fn object_types() -> Result> { + let mut bytes = 64 * 1024u32; + for _ in 0..8 { + let mut buffer = vec![0usize; bytes as usize / size_of::()]; + let mut returned = 0; + let result = unsafe { + NtQueryObject( + null_mut(), + OBJECT_TYPES_INFORMATION, + buffer.as_mut_ptr().cast(), + bytes, + &mut returned, + ) + }; + if result == STATUS_INFO_LENGTH_MISMATCH { + bytes = returned.max(bytes).saturating_mul(2); + continue; + } + if result < 0 { + return Err(status("NtQueryObject(ObjectTypesInformation)", result)); + } + let base = buffer.as_ptr().cast::(); + let end = (returned as usize).min(bytes as usize); + let count = unsafe { *base.cast::() }; + // The first entry is aligned to a pointer after the 4-byte count; each + // later one follows the previous entry's name, aligned the same way. + let mut offset = size_of::(); + let mut types = BTreeMap::new(); + for _ in 0..count { + if offset + size_of::() > end { + return Err("truncated ObjectTypesInformation".into()); + } + let info = unsafe { &*base.add(offset).cast::() }; + types.insert(u32::from(info.type_index), unsafe { info.name.text() }); + offset = (offset + + size_of::() + + usize::from(info.name.maximum_length)) + .next_multiple_of(size_of::()); + } + return Ok(types); + } + Err("the object type list kept growing".into()) +} + +/// The object name behind a handle, read through a duplicate so that the +/// table's own handle is never a query target. +pub(super) fn object_name_via_duplicate(handle: HANDLE) -> Result { + unsafe { + let process = GetCurrentProcess(); + let mut duplicate = null_mut(); + if DuplicateHandle( + process, + handle, + process, + &mut duplicate, + 0, + 0, + DUPLICATE_SAME_ACCESS, + ) == 0 + { + return Err(last("DuplicateHandle(directory)")); + } + let name = object_name(duplicate); + close(duplicate, "directory duplicate")?; + name + } +} + +fn object_name(handle: HANDLE) -> Result { + let mut bytes = 1024u32; + for _ in 0..2 { + let mut buffer = vec![0usize; bytes as usize / size_of::()]; + let mut returned = 0; + let result = unsafe { + NtQueryObject( + handle, + OBJECT_NAME_INFORMATION, + buffer.as_mut_ptr().cast(), + bytes, + &mut returned, + ) + }; + if (result == STATUS_INFO_LENGTH_MISMATCH || result == STATUS_BUFFER_OVERFLOW) + && returned > bytes + { + bytes = returned.next_multiple_of(size_of::() as u32); + continue; + } + if result < 0 { + return Err(status("NtQueryObject(ObjectNameInformation)", result)); + } + // The name's characters are in the same buffer, after the header. + let name = unsafe { &*buffer.as_ptr().cast::() }; + let start = name.buffer as usize; + let base = buffer.as_ptr() as usize; + if !name.buffer.is_null() + && (start < base || start + usize::from(name.length) > base + bytes as usize) + { + return Err("object name outside its buffer".into()); + } + return Ok(unsafe { name.text() }); + } + Err("the object name kept growing".into()) +} + +/// Which standard handle `value` is. +pub(super) fn stdio_slot(value: usize, standard: &[usize; 3]) -> Option { + [Stdio::Input, Stdio::Output, Stdio::Error] + .into_iter() + .zip(standard) + .find(|(_, handle)| **handle == value) + .map(|(slot, _)| slot) +} + +/// The handle table as the allowlist judges it. +pub(super) fn handle_table() -> Result> { + let types = object_types()?; + let standard = standard_handles(); + let snapshot = handle_snapshot()?; + let mut table: Vec = snapshot + .iter() + .map(|entry| HandleEntry { + value: entry.handle as usize, + type_name: types + .get(&entry.type_index) + .cloned() + .unwrap_or_else(|| format!("unknown type {}", entry.type_index)), + access: entry.access, + inheritable: entry.attributes & OBJ_INHERIT != 0, + stdio: stdio_slot(entry.handle as usize, &standard), + name: None, + }) + .collect(); + // Only a single Directory handle is named. With two or more the table is + // refused anyway, and nothing is queried. + let directories: Vec = (0..table.len()) + .filter(|&index| table[index].type_name == "Directory") + .collect(); + if let [index] = directories[..] { + table[index].name = Some(object_name_via_duplicate(snapshot[index].handle)?); + } + Ok(table) +} diff --git a/crates/basal-worker/src/confinement/windows/startup.rs b/crates/basal-worker/src/confinement/windows/startup.rs new file mode 100644 index 0000000..a71f228 --- /dev/null +++ b/crates/basal-worker/src/confinement/windows/startup.rs @@ -0,0 +1,133 @@ +//! The startup sequence: the six checks in order, before the first frame +//! read. See the parent module for what each step guards. + +use super::allowlist::{self, closed_at_startup}; +use super::native::{self, CURRENT_PROCESS_TOKEN, OBJ_INHERIT, PackageSid, close, last}; +use super::{Arguments, Refusal, attest, mitigation, reason}; +use crate::confinement::{ConfinementError, Entered}; +use basal_proto::Confinement; +use std::ptr::null_mut; +use windows_sys::Win32::Foundation::{ERROR_NO_TOKEN, GetLastError, HANDLE}; +use windows_sys::Win32::Security::{RevertToSelf, TOKEN_ADJUST_DEFAULT, TOKEN_QUERY}; +use windows_sys::Win32::System::Threading::{ + GetCurrentProcess, GetCurrentThread, OpenProcessToken, OpenThreadToken, +}; + +/// Runs the six startup checks. On success the worker is fully confined and +/// may read input; the report it sends in its welcome is all true, because a +/// worker that failed any check has already exited. +pub fn enter(package: &PackageSid, arguments: &Arguments) -> Result { + run(package, arguments).map_err(ConfinementError::Windows)?; + Ok(Entered { + confinement: Confinement::Windows { + lpac: true, + untrusted: true, + no_thread_token: true, + mitigations: true, + handle_table: true, + }, + descriptors: Vec::new(), + }) +} + +fn run(package: &PackageSid, arguments: &Arguments) -> Result<(), Refusal> { + // The handle used to lower the primary token is opened first, while the + // main thread still impersonates the start-up token. Once the thread has + // reverted, access checks use the restricted primary, which is not + // allowed to open its own token for adjustment. + // A failure here is reported by step 2, after step 1 has run. + let adjustment = open_primary(if arguments.opens_primary_read_only() { + TOKEN_QUERY + } else { + TOKEN_QUERY | TOKEN_ADJUST_DEFAULT + }); + drop_thread_token(arguments.keeps_thread_token())?; + lower_integrity(adjustment, arguments.skips_lowering())?; + close_startup_leftovers()?; + attest::check(&native::primary_facts(CURRENT_PROCESS_TOKEN, package))?; + let words = native::mitigation_words() + .map_err(|error| Refusal::new(reason::MITIGATION_MISMATCH, error))?; + mitigation::validate(&words) + .map_err(|error| Refusal::new(reason::MITIGATION_MISMATCH, error))?; + let table = + native::handle_table().map_err(|error| Refusal::new(reason::HANDLE_NOT_ALLOWED, error))?; + allowlist::check(&table).map_err(|error| Refusal::new(reason::HANDLE_NOT_ALLOWED, error))?; + Ok(()) +} + +/// A token handle closed when dropped, unless closed explicitly first. +struct Token(HANDLE); + +impl Drop for Token { + fn drop(&mut self) { + if !self.0.is_null() { + let _ = close(self.0, "token"); + } + } +} + +fn open_primary(access: u32) -> Result { + let mut token = null_mut(); + if unsafe { OpenProcessToken(GetCurrentProcess(), access, &mut token) } == 0 { + return Err(last("OpenProcessToken(own primary)")); + } + Ok(Token(token)) +} + +/// Step 1: stop impersonating the start-up token, then require that the +/// thread has no token at all (`ERROR_NO_TOKEN`). +fn drop_thread_token(keep: bool) -> Result<(), Refusal> { + let refuse = |detail: String| Refusal::new(reason::THREAD_TOKEN_PRESENT, detail); + if !keep && unsafe { RevertToSelf() } == 0 { + return Err(refuse(last("RevertToSelf"))); + } + let mut token = null_mut(); + // Open-as-self checks access against the process's primary token rather + // than against whatever token the thread may still carry. + if unsafe { OpenThreadToken(GetCurrentThread(), TOKEN_QUERY, 1, &mut token) } != 0 { + let _ = close(token, "thread token"); + return Err(refuse("the main thread still has a token".into())); + } + let error = unsafe { GetLastError() }; + if error != ERROR_NO_TOKEN { + return Err(refuse(format!( + "OpenThreadToken: Win32 {error}, not ERROR_NO_TOKEN" + ))); + } + Ok(()) +} + +/// Step 2: lower the actual primary from Low to Untrusted, then close the +/// handle that could adjust it, so none survives into the input phase. +fn lower_integrity(adjustment: Result, skip: bool) -> Result<(), Refusal> { + let refuse = |detail: String| Refusal::new(reason::INTEGRITY_LOWER_FAILED, detail); + let mut token = adjustment.map_err(refuse)?; + if !skip { + native::set_untrusted(token.0).map_err(refuse)?; + } + let handle = std::mem::replace(&mut token.0, null_mut()); + close(handle, "primary adjustment handle").map_err(refuse) +} + +/// Step 3: close the ALPC port and private File handles the loader left (see +/// [`closed_at_startup`]). A handle that cannot be listed or closed refuses +/// startup as `handle-not-allowed`, the check that would otherwise see it. +fn close_startup_leftovers() -> Result<(), Refusal> { + let refuse = |detail: String| Refusal::new(reason::HANDLE_NOT_ALLOWED, detail); + let types = native::object_types().map_err(refuse)?; + let standard = native::standard_handles(); + for entry in native::handle_snapshot().map_err(refuse)? { + let Some(type_name) = types.get(&entry.type_index) else { + continue; + }; + let value = entry.handle as usize; + if closed_at_startup( + type_name, + standard.contains(&value), + entry.attributes & OBJ_INHERIT != 0, + ) { + close(entry.handle, &format!("{type_name} {value:#x}")).map_err(refuse)?; + } + } + Ok(()) +} diff --git a/crates/basal-worker/src/main.rs b/crates/basal-worker/src/main.rs index 0382b3b..cb86981 100644 --- a/crates/basal-worker/src/main.rs +++ b/crates/basal-worker/src/main.rs @@ -1,9 +1,16 @@ //! Entry point for `ck-basal-worker`. //! -//! Started by basal with an explicit Landlock policy on Linux, the worker confines itself and then -//! serves frames on stdin and stdout. Logs go to stderr. The other modes are -//! `--confinement-probe`, which reports what the sandbox denies, and -//! `--version`, which prints the version and the build revision. +//! Started by basal with an explicit Landlock policy on Linux, and through +//! basal-launch with `--package-sid=` on Windows, the worker confines +//! itself and then serves frames on stdin and stdout. Logs go to stderr. The +//! other modes are `--confinement-probe`, which reports what the sandbox +//! denies, and `--version`, which prints the version and the build revision. +//! +//! On Windows the image is a GUI-subsystem one, so that no console host is +//! started for it: under the confined token, console initialisation fails in +//! the loader (`STATUS_DLL_INIT_FAILED`) before the worker's first +//! instruction. The worker talks only through its three inherited pipes. +#![cfg_attr(windows, windows_subsystem = "windows")] use std::io::{self, BufWriter}; use std::process::ExitCode; @@ -30,6 +37,10 @@ fn main() -> ExitCode { } #[cfg(target_os = "linux")] Some("--landlock=required" | "--landlock=optional") if args.len() == 1 => {} + // The Windows engine arguments are checked in full below. + #[cfg(windows)] + Some(_) => {} + #[cfg(not(windows))] Some(other) => { eprintln!("ck-basal-worker: unknown argument {other}"); return ExitCode::from(64); @@ -49,7 +60,30 @@ fn main() -> ExitCode { ), Err(confinement::linux::ArgumentError::Usage) => return ExitCode::from(64), }; - #[cfg(not(target_os = "linux"))] + #[cfg(windows)] + let result = { + use confinement::windows::{self, ArgumentError, PackageSid, Refusal, reason}; + match windows::engine_arguments(&args) { + Ok(arguments) => match PackageSid::parse(&arguments.package_sid) { + Ok(package) => windows::enter(&package, &arguments), + Err(error) => { + eprintln!("ck-basal-worker: unparsable --package-sid: {error}"); + return ExitCode::from(64); + } + }, + Err(ArgumentError::Missing) => { + Err(confinement::ConfinementError::Windows(Refusal::new( + reason::PACKAGE_SID_ARGUMENT_MISSING, + "no --package-sid= argument", + ))) + } + Err(ArgumentError::Usage(detail)) => { + eprintln!("ck-basal-worker: {detail}"); + return ExitCode::from(64); + } + } + }; + #[cfg(not(any(target_os = "linux", windows)))] let result = confinement::enter(); let entered = match result { Ok(entered) => entered, diff --git a/crates/basal-worker/src/probe.rs b/crates/basal-worker/src/probe.rs index 1738942..455d92a 100644 --- a/crates/basal-worker/src/probe.rs +++ b/crates/basal-worker/src/probe.rs @@ -5,7 +5,9 @@ //! raw call in a fresh child, with readiness before the attempt; seccomp denial //! kills that child with SIGSYS. On macOS it tries to read a file, create a file, //! connect a TCP socket and execute a program, and prints one JSON line saying -//! how each attempt went. Tests and the signing gate run the real binary. +//! how each attempt went. On Windows one native operation runs per child, with +//! a readiness marker before the attempt. Tests and the signing gate run the +//! real binary. //! //! `--no-sandbox` skips confinement so the same attempts can be shown to //! succeed without it. It is accepted only in probe mode: the engine itself @@ -16,23 +18,28 @@ mod linux; #[cfg(target_os = "linux")] pub use linux::run; -#[cfg(not(target_os = "linux"))] +#[cfg(windows)] +mod windows; +#[cfg(windows)] +pub use windows::run; + +#[cfg(not(any(target_os = "linux", windows)))] use std::io::Write; -#[cfg(not(target_os = "linux"))] +#[cfg(not(any(target_os = "linux", windows)))] use std::net::{SocketAddr, TcpStream}; -#[cfg(not(target_os = "linux"))] +#[cfg(not(any(target_os = "linux", windows)))] use std::time::Duration; -#[cfg(not(target_os = "linux"))] +#[cfg(not(any(target_os = "linux", windows)))] use crate::confinement; -#[cfg(not(target_os = "linux"))] +#[cfg(not(any(target_os = "linux", windows)))] struct Attempt { name: &'static str, result: Result<(), String>, } -#[cfg(not(target_os = "linux"))] +#[cfg(not(any(target_os = "linux", windows)))] fn json_string(text: &str) -> String { let mut out = String::with_capacity(text.len() + 2); out.push('"'); @@ -50,7 +57,7 @@ fn json_string(text: &str) -> String { /// Runs the probe with the arguments after `--confinement-probe` and returns /// the process exit code. -#[cfg(not(target_os = "linux"))] +#[cfg(not(any(target_os = "linux", windows)))] pub fn run(args: &[String]) -> u8 { let mut sandbox = true; let mut read = None; diff --git a/crates/basal-worker/src/probe/windows.rs b/crates/basal-worker/src/probe/windows.rs new file mode 100644 index 0000000..b52d4a5 --- /dev/null +++ b/crates/basal-worker/src/probe/windows.rs @@ -0,0 +1,335 @@ +//! Native Windows probes. Each child attempts one operation after its own +//! readiness marker, so a startup refusal cannot be mistaken for a denial. + +#![allow(unsafe_op_in_unsafe_fn)] + +mod objects; +mod pool; +mod winsock; + +use crate::confinement::windows::{self, ArgumentError, PackageSid}; +use std::ffi::c_void; +use std::io::{Read, Write}; +use std::mem::{size_of, zeroed}; +use std::ptr::{null, null_mut}; +use windows_sys::Win32::Foundation::*; +use windows_sys::Win32::Storage::FileSystem::*; +use windows_sys::Win32::System::Memory::*; +use windows_sys::Win32::System::Threading::*; + +struct Handle(HANDLE); +impl Drop for Handle { + fn drop(&mut self) { + if !self.0.is_null() && self.0 != INVALID_HANDLE_VALUE { + unsafe { CloseHandle(self.0) }; + } + } +} +fn wide(text: &str) -> Vec { + text.encode_utf16().chain(Some(0)).collect() +} +fn last() -> u32 { + unsafe { GetLastError() } +} +fn win_result(ok: bool) -> u32 { + if ok { 0 } else { last() } +} +pub(super) fn marker(name: &str) -> Result<(), String> { + let mut stderr = std::io::stderr().lock(); + writeln!(stderr, "windows-probe-ready:{name}").map_err(|e| e.to_string())?; + stderr.flush().map_err(|e| e.to_string()) +} +pub(super) fn byte() -> Result<(), String> { + std::io::stdin() + .read_exact(&mut [0]) + .map_err(|e| e.to_string()) +} + +/// Windows-only options are deliberately separate from the Unix probe grammar. +pub fn run(args: &[String]) -> u8 { + let mut startup = Vec::new(); + let mut sandbox = true; + let mut probe = None; + let mut target = String::new(); + let mut number = 0usize; + for arg in args { + if arg == "--no-sandbox" { + sandbox = false; + } else if arg == "--thread-barrier" { + if probe.replace("thread-barrier".to_owned()).is_some() { + return 64; + } + } else if let Some(value) = arg.strip_prefix("--probe=") { + if probe.replace(value.to_owned()).is_some() { + return 64; + } + } else if let Some(value) = arg.strip_prefix("--target=") { + target = value.to_owned(); + } else if let Some(value) = arg.strip_prefix("--number=") { + let Ok(value) = value.parse() else { return 64 }; + number = value; + } else { + startup.push(arg.clone()); + } + } + let Some(probe) = probe else { return 64 }; + if !matches!( + probe.as_str(), + "read" + | "stat" + | "create" + | "registry" + | "tcp" + | "udp" + | "loopback" + | "process" + | "parent-query" + | "parent-read" + | "pipe" + | "alpc" + | "section" + | "allocate-executable" + | "protect-executable" + | "thread" + | "excluded-handle" + | "crash-before-ready" + | "thread-barrier" + | "pool" + | "checkpoint" + ) { + return 64; + } + let arguments = match windows::engine_arguments(&startup) { + Ok(arguments) => arguments, + Err(ArgumentError::Missing) => { + eprintln!("ck-basal-worker: refusing to run unconfined: package-sid-argument-missing"); + return 70; + } + Err(error) => { + eprintln!("confinement probe: {error:?}"); + return 64; + } + }; + let package = match PackageSid::parse(&arguments.package_sid) { + Ok(package) => package, + Err(error) => { + eprintln!("confinement probe: unparsable --package-sid: {error}"); + return 64; + } + }; + let winsock = if matches!(probe.as_str(), "tcp" | "udp" | "loopback") { + match unsafe { winsock::preload() } { + Ok(backend) => Some(backend), + Err(error) => { + eprintln!("confinement probe: {error}"); + return 71; + } + } + } else { + None + }; + if sandbox { + if let Err(error) = windows::enter(&package, &arguments) { + eprintln!("ck-basal-worker: refusing to run unconfined: {error}"); + return 70; + } + } else if unsafe { windows_sys::Win32::Security::RevertToSelf() } == 0 { + eprintln!("confinement probe: RevertToSelf: {}", last()); + return 70; + } + if probe == "crash-before-ready" { + unsafe { TerminateProcess(GetCurrentProcess(), 0xc0000008) }; + return 71; + } + let result = (|| { + marker(&probe)?; + let codes = unsafe { attempt(&probe, &target, number, winsock.as_ref())? }; + println!( + "windows-probe-result:{probe}:{}", + codes + .iter() + .map(u32::to_string) + .collect::>() + .join(",") + ); + Ok::<_, String>(()) + })(); + if let Err(error) = result { + eprintln!("confinement probe: {error}"); + 71 + } else { + 0 + } +} + +unsafe extern "system" fn thread_entry(_: *mut c_void) -> u32 { + 0 +} + +unsafe fn attempt( + probe: &str, + target: &str, + number: usize, + winsock: Option<&winsock::Backend>, +) -> Result, String> { + let code = match probe { + "read" | "stat" | "create" | "pipe" => { + let handle = CreateFileW( + wide(target).as_ptr(), + match probe { + "stat" => FILE_READ_ATTRIBUTES, + "create" => FILE_WRITE_DATA, + _ => FILE_READ_DATA, + }, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + null(), + if probe == "create" { + CREATE_NEW + } else { + OPEN_EXISTING + }, + 0, + null_mut(), + ); + if handle == INVALID_HANDLE_VALUE { + last() + } else { + let handle = Handle(handle); + match probe { + "read" => { + let mut read = 0; + win_result( + ReadFile(handle.0, [0u8; 1].as_mut_ptr(), 1, &mut read, null_mut()) + != 0, + ) + } + "stat" => { + let mut info = zeroed(); + win_result(GetFileInformationByHandle(handle.0, &mut info) != 0) + } + _ => 0, + } + } + } + "registry" => objects::registry_write(target), + "alpc" => objects::alpc(target), + "section" => objects::section(target), + "tcp" | "udp" | "loopback" => { + return Ok(winsock + .expect("socket mode preloaded Winsock") + .attempt(probe, number as u16)); + } + "process" => { + let mut startup: STARTUPINFOW = zeroed(); + startup.cb = size_of::() as u32; + let mut info: PROCESS_INFORMATION = zeroed(); + let mut command = wide(&format!("\"{target}\" /c exit 0")); + let ok = CreateProcessW( + wide(target).as_ptr(), + command.as_mut_ptr(), + null(), + null(), + 0, + CREATE_NO_WINDOW, + null(), + null(), + &startup, + &mut info, + ) != 0; + let code = win_result(ok); + if ok { + let process = Handle(info.hProcess); + let _thread = Handle(info.hThread); + if WaitForSingleObject(process.0, 10_000) != WAIT_OBJECT_0 { + TerminateProcess(process.0, 71); + return Err("control descendant did not exit".into()); + } + } + code + } + "parent-query" | "parent-read" => { + let handle = OpenProcess( + if probe == "parent-query" { + PROCESS_QUERY_LIMITED_INFORMATION + } else { + PROCESS_VM_READ + }, + 0, + number as u32, + ); + if handle.is_null() { + last() + } else { + drop(Handle(handle)); + 0 + } + } + "allocate-executable" => { + let memory = VirtualAlloc( + null(), + 4096, + MEM_RESERVE | MEM_COMMIT, + PAGE_EXECUTE_READWRITE, + ); + if memory.is_null() { + last() + } else { + VirtualFree(memory, 0, MEM_RELEASE); + 0 + } + } + "protect-executable" => { + let memory = VirtualAlloc(null(), 4096, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE); + if memory.is_null() { + return Err(format!("RW allocation: {}", last())); + } + let mut old = 0; + let code = win_result(VirtualProtect(memory, 4096, PAGE_EXECUTE_READ, &mut old) != 0); + VirtualFree(memory, 0, MEM_RELEASE); + code + } + "thread" => { + let thread = CreateThread(null(), 0, Some(thread_entry), null(), 0, null_mut()); + if thread.is_null() { + last() + } else { + let thread = Handle(thread); + if WaitForSingleObject(thread.0, 10_000) != WAIT_OBJECT_0 { + return Err("thread did not complete".into()); + } + 0 + } + } + "excluded-handle" => { + let mut written = 0; + let code = win_result( + WriteFile( + number as HANDLE, + b"escaped".as_ptr(), + 7, + &mut written, + null_mut(), + ) != 0, + ); + if code == 0 && written != 7 { + return Err("short planted-handle write".into()); + } + code + } + "thread-barrier" => { + byte()?; + pool::run(true)?; + 0 + } + "pool" => { + pool::run(false)?; + 0 + } + "checkpoint" => { + byte()?; + 0 + } + _ => unreachable!(), + }; + Ok(vec![code]) +} diff --git a/crates/basal-worker/src/probe/windows/objects.rs b/crates/basal-worker/src/probe/windows/objects.rs new file mode 100644 index 0000000..2d2c336 --- /dev/null +++ b/crates/basal-worker/src/probe/windows/objects.rs @@ -0,0 +1,139 @@ +//! The test harness supplies native registry and object-manager paths. In +//! particular, it resolves the AppContainer's real storage key under its token; +//! a guessed HKCU spelling can name a missing key rather than test a denial. +#![allow(unsafe_op_in_unsafe_fn)] +use super::{Handle, wide}; +use std::ffi::c_void; +use std::mem::{size_of, zeroed}; +use std::ptr::null_mut; +use windows_sys::Win32::Foundation::HANDLE; +use windows_sys::Win32::Security::{PSID, SECURITY_QUALITY_OF_SERVICE, SecurityIdentification}; + +#[repr(C)] +pub(crate) struct UnicodeString { + pub length: u16, + pub maximum_length: u16, + pub buffer: *mut u16, +} +#[repr(C)] +pub(crate) struct ObjectAttributes { + pub length: u32, + pub root: HANDLE, + pub name: *mut UnicodeString, + pub attributes: u32, + pub descriptor: *mut c_void, + pub qos: *mut c_void, +} +#[repr(C)] +struct AlpcAttributes { + flags: u32, + qos: SECURITY_QUALITY_OF_SERVICE, + max_message: usize, + bandwidth: usize, + max_pool: usize, + max_section: usize, + max_view: usize, + max_total: usize, + dup_types: u32, + reserved: u32, +} +#[link(name = "ntdll")] +unsafe extern "system" { + fn NtOpenSection(out: *mut HANDLE, access: u32, attrs: *mut ObjectAttributes) -> i32; + fn NtOpenKey(out: *mut HANDLE, access: u32, attrs: *mut ObjectAttributes) -> i32; + fn NtSetValueKey( + key: HANDLE, + name: *mut UnicodeString, + title: u32, + ty: u32, + data: *const c_void, + size: u32, + ) -> i32; + fn NtAlpcConnectPort( + out: *mut HANDLE, + name: *mut UnicodeString, + attrs: *mut ObjectAttributes, + port_attrs: *mut AlpcAttributes, + flags: u32, + sid: PSID, + message: *mut c_void, + size: *mut usize, + out_message: *mut c_void, + in_message: *mut c_void, + timeout: *mut i64, + ) -> i32; +} +pub(crate) fn us(text: &mut [u16]) -> UnicodeString { + assert!(text.len() * 2 <= u16::MAX as usize); + UnicodeString { + length: ((text.len() - 1) * 2) as u16, + maximum_length: (text.len() * 2) as u16, + buffer: text.as_mut_ptr(), + } +} +pub(crate) fn oa(name: &mut UnicodeString) -> ObjectAttributes { + ObjectAttributes { + length: size_of::() as u32, + root: null_mut(), + name, + attributes: 0x40, + descriptor: null_mut(), + qos: null_mut(), + } +} +pub(super) unsafe fn section(path: &str) -> u32 { + let mut text = wide(path); + let mut name = us(&mut text); + let mut attrs = oa(&mut name); + let mut handle = null_mut(); + let status = NtOpenSection(&mut handle, 1, &mut attrs); + if status == 0 { + drop(Handle(handle)); + } + status as u32 +} +pub(super) unsafe fn alpc(path: &str) -> u32 { + let mut text = wide(path); + let mut name = us(&mut text); + let mut attrs: AlpcAttributes = zeroed(); + attrs.max_message = 256; + attrs.qos = SECURITY_QUALITY_OF_SERVICE { + Length: size_of::() as u32, + ImpersonationLevel: SecurityIdentification, + ContextTrackingMode: 0, + EffectiveOnly: true, + }; + let mut handle = null_mut(); + let mut timeout = -1_000_000i64; + let status = NtAlpcConnectPort( + &mut handle, + &mut name, + null_mut(), + &mut attrs, + 0, + null_mut(), + null_mut(), + null_mut(), + null_mut(), + null_mut(), + &mut timeout, + ); + if status == 0 { + drop(Handle(handle)); + } + status as u32 +} +pub(super) unsafe fn registry_write(path: &str) -> u32 { + let mut text = wide(path); + let mut name = us(&mut text); + let mut attrs = oa(&mut name); + let mut key = null_mut(); + let status = NtOpenKey(&mut key, 2, &mut attrs); + if status != 0 { + return status as u32; + } + let key = Handle(key); + let mut text = wide("ckdev-confinement-fixture"); + let mut value = us(&mut text); + NtSetValueKey(key.0, &mut value, 0, 3, [1u8].as_ptr().cast(), 1) as u32 +} diff --git a/crates/basal-worker/src/probe/windows/pool.rs b/crates/basal-worker/src/probe/windows/pool.rs new file mode 100644 index 0000000..340087c --- /dev/null +++ b/crates/basal-worker/src/probe/windows/pool.rs @@ -0,0 +1,141 @@ +//! Work, wait, timer and legacy callbacks block on a release event while the +//! test harness enumerates the worker's threads and queries their tokens. Holding +//! callbacks alive makes their TIDs observable in both thread snapshots. +#![allow(unsafe_op_in_unsafe_fn)] +use super::{Handle, byte, last, marker}; +use std::ffi::c_void; +use std::ptr::{null, null_mut}; +use std::sync::Mutex; +use std::sync::atomic::{AtomicUsize, Ordering}; +use windows_sys::Win32::Foundation::*; +use windows_sys::Win32::System::Threading::*; + +struct Context { + entered: AtomicUsize, + departed: AtomicUsize, + ready: HANDLE, + release: HANDLE, + done: HANDLE, + records: Mutex>, +} +unsafe fn record(param: *mut c_void, kind: &'static str) { + let context = &*param.cast::(); + context + .records + .lock() + .unwrap() + .push((kind, GetCurrentThreadId())); + if context.entered.fetch_add(1, Ordering::SeqCst) + 1 == 4 { + SetEvent(context.ready); + } + WaitForSingleObject(context.release, INFINITE); + if context.departed.fetch_add(1, Ordering::SeqCst) + 1 == 4 { + SetEvent(context.done); + } +} +unsafe extern "system" fn work(_: PTP_CALLBACK_INSTANCE, param: *mut c_void, _: PTP_WORK) { + record(param, "work"); +} +unsafe extern "system" fn wait(_: PTP_CALLBACK_INSTANCE, param: *mut c_void, _: PTP_WAIT, _: u32) { + record(param, "wait"); +} +unsafe extern "system" fn timer(_: PTP_CALLBACK_INSTANCE, param: *mut c_void, _: PTP_TIMER) { + record(param, "timer"); +} +unsafe extern "system" fn legacy(param: *mut c_void) -> u32 { + record(param, "legacy"); + 0 +} + +pub(super) unsafe fn run(barrier: bool) -> Result<(), String> { + let ready = Handle(CreateEventW(null(), 1, 0, null())); + let release = Handle(CreateEventW(null(), 1, 0, null())); + let done = Handle(CreateEventW(null(), 1, 0, null())); + let trigger = Handle(CreateEventW(null(), 1, 1, null())); + if [ready.0, release.0, done.0, trigger.0] + .iter() + .any(|h| h.is_null()) + { + return Err(format!("CreateEventW: {}", last())); + } + let context = Box::new(Context { + entered: AtomicUsize::new(0), + departed: AtomicUsize::new(0), + ready: ready.0, + release: release.0, + done: done.0, + records: Mutex::new(Vec::new()), + }); + let param = (&*context as *const Context).cast_mut().cast(); + let work = CreateThreadpoolWork(Some(work), param, null()); + let wait = CreateThreadpoolWait(Some(wait), param, null()); + let timer = CreateThreadpoolTimer(Some(timer), param, null()); + if work == 0 || wait == 0 || timer == 0 { + let error = last(); + if work != 0 { + CloseThreadpoolWork(work); + } + if wait != 0 { + CloseThreadpoolWait(wait); + } + if timer != 0 { + CloseThreadpoolTimer(timer); + } + return Err(format!("CreateThreadpool work/wait/timer: {error}")); + } + SubmitThreadpoolWork(work); + SetThreadpoolWait(wait, trigger.0, null()); + let relative = -10_000i64 as u64; + let due = FILETIME { + dwLowDateTime: relative as u32, + dwHighDateTime: (relative >> 32) as u32, + }; + SetThreadpoolTimer(timer, &due, 0, 0); + let queued = QueueUserWorkItem(Some(legacy), param, 0) != 0; + let queued_error = last(); + let held = queued && WaitForSingleObject(ready.0, 30_000) == WAIT_OBJECT_0; + let checkpoint = (|| { + if !held { + return Err(format!( + "four callbacks not ready (legacy queued={queued}, error={queued_error})" + )); + } + if context.entered.load(Ordering::SeqCst) != 4 + || context.departed.load(Ordering::SeqCst) != 0 + { + return Err("callbacks were not held".into()); + } + for (kind, tid) in context.records.lock().unwrap().iter() { + eprintln!("windows-pool-callback:{kind}:{tid}"); + } + if barrier { + marker("callbacks-held")?; + byte()?; + } + Ok(()) + })(); + SetEvent(release.0); + SetThreadpoolWait(wait, null_mut(), null()); + SetThreadpoolTimer(timer, null(), 0, 0); + WaitForThreadpoolWorkCallbacks(work, 1); + WaitForThreadpoolWaitCallbacks(wait, 1); + WaitForThreadpoolTimerCallbacks(timer, 1); + CloseThreadpoolWork(work); + CloseThreadpoolWait(wait); + CloseThreadpoolTimer(timer); + if queued && WaitForSingleObject(done.0, 30_000) != WAIT_OBJECT_0 { + // Legacy work has no cancellation API. Its storage and signaling handles + // must outlive a late callback; the process exits on this error. + std::mem::forget(context); + std::mem::forget(ready); + std::mem::forget(release); + std::mem::forget(done); + return Err("pool callbacks did not drain".into()); + } + checkpoint?; + if context.departed.load(Ordering::SeqCst) != 4 { + return Err("pool did not complete all four callbacks".into()); + } + eprintln!("windows-pool-complete:work,wait,timer,legacy"); + Ok(()) +} diff --git a/crates/basal-worker/src/probe/windows/winsock.rs b/crates/basal-worker/src/probe/windows/winsock.rs new file mode 100644 index 0000000..49b085d --- /dev/null +++ b/crates/basal-worker/src/probe/windows/winsock.rs @@ -0,0 +1,110 @@ +//! Only socket probes preload System32 Winsock while the startup token still +//! permits DLL loading. All startup checks then run unchanged. WSAStartup and +//! socket calls occur only after attestation and readiness. The engine and every +//! other probe keep Winsock out of their loader inventory and static imports. +#![allow(unsafe_op_in_unsafe_fn)] +use super::{last, wide}; +use std::mem::{transmute, zeroed}; +use std::ptr::null_mut; +use windows_sys::Win32::Foundation::HMODULE; +use windows_sys::Win32::Networking::WinSock::{SOCKADDR, SOCKET, WSADATA}; +use windows_sys::Win32::System::LibraryLoader::{ + GetProcAddress, LOAD_LIBRARY_SEARCH_SYSTEM32, LoadLibraryExW, +}; + +type Startup = unsafe extern "system" fn(u16, *mut WSADATA) -> i32; +type Socket = unsafe extern "system" fn(i32, i32, i32) -> SOCKET; +type Connect = unsafe extern "system" fn(SOCKET, *const SOCKADDR, i32) -> i32; +type SendTo = unsafe extern "system" fn(SOCKET, *const u8, i32, i32, *const SOCKADDR, i32) -> i32; +type Error = unsafe extern "system" fn() -> i32; +type Close = unsafe extern "system" fn(SOCKET) -> i32; +type Cleanup = unsafe extern "system" fn() -> i32; +pub(super) struct Backend { + // The mapping remains resident until process exit, just like a static DLL + // import, so its callback and function addresses cannot outlive the module. + _module: HMODULE, + startup: Startup, + socket: Socket, + error: Error, + close: Close, + cleanup: Cleanup, + connect: Connect, + send_to: SendTo, +} + +pub(super) unsafe fn preload() -> Result { + let module = LoadLibraryExW( + wide("ws2_32.dll").as_ptr(), + null_mut(), + LOAD_LIBRARY_SEARCH_SYSTEM32, + ); + if module.is_null() { + return Err(format!("LoadLibraryExW(ws2_32): {}", last())); + } + macro_rules! export { + ($name:literal, $ty:ty) => { + transmute:: isize, $ty>( + GetProcAddress(module, concat!($name, "\0").as_ptr()) + .ok_or(concat!("missing ", $name))?, + ) + }; + } + let startup = export!("WSAStartup", Startup); + let socket = export!("socket", Socket); + let error = export!("WSAGetLastError", Error); + let close = export!("closesocket", Close); + let cleanup = export!("WSACleanup", Cleanup); + let connect = export!("connect", Connect); + let send_to = export!("sendto", SendTo); + Ok(Backend { + _module: module, + startup, + socket, + error, + close, + cleanup, + connect, + send_to, + }) +} + +impl Backend { + pub(super) unsafe fn attempt(&self, probe: &str, port: u16) -> Vec { + let Self { + startup, + socket, + error, + close, + cleanup, + connect, + send_to, + .. + } = self; + let mut data = zeroed(); + let started = startup(0x202, &mut data); + let udp = probe == "udp"; + let handle = socket(2, if udp { 2 } else { 1 }, if udp { 17 } else { 6 }); + if handle == usize::MAX { + let code = error() as u32; + if started == 0 { + cleanup(); + } + return vec![started as u32, code]; + } + let mut address = [0u8; 16]; + address[..2].copy_from_slice(&2u16.to_ne_bytes()); + address[2..4].copy_from_slice(&port.to_be_bytes()); + address[4..8].copy_from_slice(&[127, 0, 0, 1]); + let result = if udp { + send_to(handle, b"probe".as_ptr(), 5, 0, address.as_ptr().cast(), 16) + } else { + connect(handle, address.as_ptr().cast(), 16) + }; + let code = if result < 0 { error() as u32 } else { 0 }; + close(handle); + if started == 0 { + cleanup(); + } + vec![started as u32, 0, code] + } +} diff --git a/crates/basal-worker/tests/codemode.rs b/crates/basal-worker/tests/codemode.rs index c748e61..b9d4cf2 100644 --- a/crates/basal-worker/tests/codemode.rs +++ b/crates/basal-worker/tests/codemode.rs @@ -1,4 +1,5 @@ //! Codemode's confined wire contract, without the flow parent's journal or hosts. + mod common; use std::time::Duration; diff --git a/crates/basal-worker/tests/data/windows-handle-provenance-windows-2022.json b/crates/basal-worker/tests/data/windows-handle-provenance-windows-2022.json new file mode 100644 index 0000000..7678cc0 --- /dev/null +++ b/crates/basal-worker/tests/data/windows-handle-provenance-windows-2022.json @@ -0,0 +1,1932 @@ +{ + "handles": [ + { + "access": "0x1f0003", + "handle": 4, + "name": null, + "stack": [ + { + "address": "0xfffff80081109db1", + "module": "kernel (unsymbolized)" + }, + { + "address": "0xfffff80080e451e5", + "module": "kernel (unsymbolized)" + }, + { + "address": "0x7ff8cc880324", + "displacement": 20, + "module": "ntdll", + "symbol": "NtCreateEvent", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc87e339", + "displacement": 89, + "module": "ntdll", + "symbol": "LdrpInitializeInternal", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc85882e", + "displacement": 14, + "module": "ntdll", + "symbol": "LdrInitializeThunk", + "symbol_type": 3 + } + ], + "type": "Event" + }, + { + "access": "0x1f0003", + "handle": 8, + "name": null, + "stack": [ + { + "address": "0xfffff80081109db1", + "module": "kernel (unsymbolized)" + }, + { + "address": "0xfffff80080e451e5", + "module": "kernel (unsymbolized)" + }, + { + "address": "0x7ff8cc880324", + "displacement": 20, + "module": "ntdll", + "symbol": "NtCreateEvent", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc858936", + "displacement": 122, + "module": "ntdll", + "symbol": "LdrpInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc87e3cd", + "displacement": 237, + "module": "ntdll", + "symbol": "LdrpInitializeInternal", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc85882e", + "displacement": 14, + "module": "ntdll", + "symbol": "LdrInitializeThunk", + "symbol_type": 3 + } + ], + "type": "Event" + }, + { + "access": "0x1f0003", + "handle": 12, + "name": null, + "stack": [ + { + "address": "0xfffff80081109db1", + "module": "kernel (unsymbolized)" + }, + { + "address": "0xfffff80080e451e5", + "module": "kernel (unsymbolized)" + }, + { + "address": "0x7ff8cc880324", + "displacement": 20, + "module": "ntdll", + "symbol": "NtCreateEvent", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e312f", + "displacement": 47, + "module": "ntdll", + "symbol": "EtwpRegisterTpNotificationOnce", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7ea47a", + "displacement": 154, + "module": "ntdll", + "symbol": "RtlRunOnceExecuteOnce", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7ea668", + "displacement": 120, + "module": "ntdll", + "symbol": "EtwpRegisterProvider", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7ea847", + "displacement": 215, + "module": "ntdll", + "symbol": "EtwNotificationRegister", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e9e10", + "displacement": 32, + "module": "ntdll", + "symbol": "EtwEventRegister", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc8b5cdd", + "displacement": 2653, + "module": "ntdll", + "symbol": "LdrpInitializeProcess", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc858a28", + "displacement": 364, + "module": "ntdll", + "symbol": "LdrpInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc87e3cd", + "displacement": 237, + "module": "ntdll", + "symbol": "LdrpInitializeInternal", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc85882e", + "displacement": 14, + "module": "ntdll", + "symbol": "LdrInitializeThunk", + "symbol_type": 3 + } + ], + "type": "Event" + }, + { + "access": "0x1", + "handle": 16, + "name": null, + "stack": [ + { + "address": "0xfffff800811664d8", + "module": "kernel (unsymbolized)" + }, + { + "address": "0xfffff80080e451e5", + "module": "kernel (unsymbolized)" + }, + { + "address": "0x7ff8cc8813d4", + "displacement": 20, + "module": "ntdll", + "symbol": "NtCreateWaitCompletionPacket", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e664a", + "displacement": 186, + "module": "ntdll", + "symbol": "TpAllocWait", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e314e", + "displacement": 78, + "module": "ntdll", + "symbol": "EtwpRegisterTpNotificationOnce", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7ea47a", + "displacement": 154, + "module": "ntdll", + "symbol": "RtlRunOnceExecuteOnce", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7ea668", + "displacement": 120, + "module": "ntdll", + "symbol": "EtwpRegisterProvider", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7ea847", + "displacement": 215, + "module": "ntdll", + "symbol": "EtwNotificationRegister", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e9e10", + "displacement": 32, + "module": "ntdll", + "symbol": "EtwEventRegister", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc8b5cdd", + "displacement": 2653, + "module": "ntdll", + "symbol": "LdrpInitializeProcess", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc858a28", + "displacement": 364, + "module": "ntdll", + "symbol": "LdrpInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc87e3cd", + "displacement": 237, + "module": "ntdll", + "symbol": "LdrpInitializeInternal", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc85882e", + "displacement": 14, + "module": "ntdll", + "symbol": "LdrInitializeThunk", + "symbol_type": 3 + } + ], + "type": "WaitCompletionPacket" + }, + { + "access": "0x1f0003", + "handle": 20, + "name": null, + "stack": [ + { + "address": "0xfffff80081174055", + "module": "kernel (unsymbolized)" + }, + { + "address": "0xfffff80080e451e5", + "module": "kernel (unsymbolized)" + }, + { + "address": "0x7ff8cc880ff4", + "displacement": 20, + "module": "ntdll", + "symbol": "NtCreateIoCompletion", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e4235", + "displacement": 629, + "module": "ntdll", + "symbol": "TpAllocPoolInternal", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e80b4", + "displacement": 168, + "module": "ntdll", + "symbol": "TppPoolpReferenceGlobalPool", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7f06c0", + "displacement": 536, + "module": "ntdll", + "symbol": "TppCleanupGroupMemberInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7f0e61", + "displacement": 33, + "module": "ntdll", + "symbol": "TppWorkInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e6bf3", + "displacement": 67, + "module": "ntdll", + "symbol": "TppInitializeTimer", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e667c", + "displacement": 236, + "module": "ntdll", + "symbol": "TpAllocWait", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e314e", + "displacement": 78, + "module": "ntdll", + "symbol": "EtwpRegisterTpNotificationOnce", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7ea47a", + "displacement": 154, + "module": "ntdll", + "symbol": "RtlRunOnceExecuteOnce", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7ea668", + "displacement": 120, + "module": "ntdll", + "symbol": "EtwpRegisterProvider", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7ea847", + "displacement": 215, + "module": "ntdll", + "symbol": "EtwNotificationRegister", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e9e10", + "displacement": 32, + "module": "ntdll", + "symbol": "EtwEventRegister", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc8b5cdd", + "displacement": 2653, + "module": "ntdll", + "symbol": "LdrpInitializeProcess", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc858a28", + "displacement": 364, + "module": "ntdll", + "symbol": "LdrpInitialize", + "symbol_type": 3 + } + ], + "type": "IoCompletion" + }, + { + "access": "0xf00ff", + "handle": 24, + "name": null, + "stack": [ + { + "address": "0xfffff8008110216c", + "module": "kernel (unsymbolized)" + }, + { + "address": "0xfffff80080e451e5", + "module": "kernel (unsymbolized)" + }, + { + "address": "0x7ff8cc881434", + "displacement": 20, + "module": "ntdll", + "symbol": "NtCreateWorkerFactory", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e42a6", + "displacement": 742, + "module": "ntdll", + "symbol": "TpAllocPoolInternal", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e80b4", + "displacement": 168, + "module": "ntdll", + "symbol": "TppPoolpReferenceGlobalPool", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7f06c0", + "displacement": 536, + "module": "ntdll", + "symbol": "TppCleanupGroupMemberInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7f0e61", + "displacement": 33, + "module": "ntdll", + "symbol": "TppWorkInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e6bf3", + "displacement": 67, + "module": "ntdll", + "symbol": "TppInitializeTimer", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e667c", + "displacement": 236, + "module": "ntdll", + "symbol": "TpAllocWait", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e314e", + "displacement": 78, + "module": "ntdll", + "symbol": "EtwpRegisterTpNotificationOnce", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7ea47a", + "displacement": 154, + "module": "ntdll", + "symbol": "RtlRunOnceExecuteOnce", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7ea668", + "displacement": 120, + "module": "ntdll", + "symbol": "EtwpRegisterProvider", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7ea847", + "displacement": 215, + "module": "ntdll", + "symbol": "EtwNotificationRegister", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e9e10", + "displacement": 32, + "module": "ntdll", + "symbol": "EtwEventRegister", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc8b5cdd", + "displacement": 2653, + "module": "ntdll", + "symbol": "LdrpInitializeProcess", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc858a28", + "displacement": 364, + "module": "ntdll", + "symbol": "LdrpInitialize", + "symbol_type": 3 + } + ], + "type": "TpWorkerFactory" + }, + { + "access": "0x100002", + "handle": 28, + "name": null, + "stack": [ + { + "address": "0xfffff8008110232d", + "module": "kernel (unsymbolized)" + }, + { + "address": "0xfffff80080e451e5", + "module": "kernel (unsymbolized)" + }, + { + "address": "0x7ff8cc881314", + "displacement": 20, + "module": "ntdll", + "symbol": "NtCreateTimer2", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e56c6", + "displacement": 78, + "module": "ntdll", + "symbol": "TppInitializeTimerSubQueue", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e5635", + "displacement": 49, + "module": "ntdll", + "symbol": "TppInitializeTimerQueue", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e432e", + "displacement": 878, + "module": "ntdll", + "symbol": "TpAllocPoolInternal", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e80b4", + "displacement": 168, + "module": "ntdll", + "symbol": "TppPoolpReferenceGlobalPool", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7f06c0", + "displacement": 536, + "module": "ntdll", + "symbol": "TppCleanupGroupMemberInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7f0e61", + "displacement": 33, + "module": "ntdll", + "symbol": "TppWorkInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e6bf3", + "displacement": 67, + "module": "ntdll", + "symbol": "TppInitializeTimer", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e667c", + "displacement": 236, + "module": "ntdll", + "symbol": "TpAllocWait", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e314e", + "displacement": 78, + "module": "ntdll", + "symbol": "EtwpRegisterTpNotificationOnce", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7ea47a", + "displacement": 154, + "module": "ntdll", + "symbol": "RtlRunOnceExecuteOnce", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7ea668", + "displacement": 120, + "module": "ntdll", + "symbol": "EtwpRegisterProvider", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7ea847", + "displacement": 215, + "module": "ntdll", + "symbol": "EtwNotificationRegister", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e9e10", + "displacement": 32, + "module": "ntdll", + "symbol": "EtwEventRegister", + "symbol_type": 3 + } + ], + "type": "IRTimer" + }, + { + "access": "0x1", + "handle": 32, + "name": null, + "stack": [ + { + "address": "0xfffff800811664d8", + "module": "kernel (unsymbolized)" + }, + { + "address": "0xfffff80080e451e5", + "module": "kernel (unsymbolized)" + }, + { + "address": "0x7ff8cc8813d4", + "displacement": 20, + "module": "ntdll", + "symbol": "NtCreateWaitCompletionPacket", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e56de", + "displacement": 102, + "module": "ntdll", + "symbol": "TppInitializeTimerSubQueue", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e5635", + "displacement": 49, + "module": "ntdll", + "symbol": "TppInitializeTimerQueue", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e432e", + "displacement": 878, + "module": "ntdll", + "symbol": "TpAllocPoolInternal", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e80b4", + "displacement": 168, + "module": "ntdll", + "symbol": "TppPoolpReferenceGlobalPool", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7f06c0", + "displacement": 536, + "module": "ntdll", + "symbol": "TppCleanupGroupMemberInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7f0e61", + "displacement": 33, + "module": "ntdll", + "symbol": "TppWorkInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e6bf3", + "displacement": 67, + "module": "ntdll", + "symbol": "TppInitializeTimer", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e667c", + "displacement": 236, + "module": "ntdll", + "symbol": "TpAllocWait", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e314e", + "displacement": 78, + "module": "ntdll", + "symbol": "EtwpRegisterTpNotificationOnce", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7ea47a", + "displacement": 154, + "module": "ntdll", + "symbol": "RtlRunOnceExecuteOnce", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7ea668", + "displacement": 120, + "module": "ntdll", + "symbol": "EtwpRegisterProvider", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7ea847", + "displacement": 215, + "module": "ntdll", + "symbol": "EtwNotificationRegister", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e9e10", + "displacement": 32, + "module": "ntdll", + "symbol": "EtwEventRegister", + "symbol_type": 3 + } + ], + "type": "WaitCompletionPacket" + }, + { + "access": "0x100002", + "handle": 36, + "name": null, + "stack": [ + { + "address": "0xfffff8008110232d", + "module": "kernel (unsymbolized)" + }, + { + "address": "0xfffff80080e451e5", + "module": "kernel (unsymbolized)" + }, + { + "address": "0x7ff8cc881314", + "displacement": 20, + "module": "ntdll", + "symbol": "NtCreateTimer2", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e56c6", + "displacement": 78, + "module": "ntdll", + "symbol": "TppInitializeTimerSubQueue", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e564d", + "displacement": 73, + "module": "ntdll", + "symbol": "TppInitializeTimerQueue", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e432e", + "displacement": 878, + "module": "ntdll", + "symbol": "TpAllocPoolInternal", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e80b4", + "displacement": 168, + "module": "ntdll", + "symbol": "TppPoolpReferenceGlobalPool", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7f06c0", + "displacement": 536, + "module": "ntdll", + "symbol": "TppCleanupGroupMemberInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7f0e61", + "displacement": 33, + "module": "ntdll", + "symbol": "TppWorkInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e6bf3", + "displacement": 67, + "module": "ntdll", + "symbol": "TppInitializeTimer", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e667c", + "displacement": 236, + "module": "ntdll", + "symbol": "TpAllocWait", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e314e", + "displacement": 78, + "module": "ntdll", + "symbol": "EtwpRegisterTpNotificationOnce", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7ea47a", + "displacement": 154, + "module": "ntdll", + "symbol": "RtlRunOnceExecuteOnce", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7ea668", + "displacement": 120, + "module": "ntdll", + "symbol": "EtwpRegisterProvider", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7ea847", + "displacement": 215, + "module": "ntdll", + "symbol": "EtwNotificationRegister", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e9e10", + "displacement": 32, + "module": "ntdll", + "symbol": "EtwEventRegister", + "symbol_type": 3 + } + ], + "type": "IRTimer" + }, + { + "access": "0x1", + "handle": 40, + "name": null, + "stack": [ + { + "address": "0xfffff800811664d8", + "module": "kernel (unsymbolized)" + }, + { + "address": "0xfffff80080e451e5", + "module": "kernel (unsymbolized)" + }, + { + "address": "0x7ff8cc8813d4", + "displacement": 20, + "module": "ntdll", + "symbol": "NtCreateWaitCompletionPacket", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e56de", + "displacement": 102, + "module": "ntdll", + "symbol": "TppInitializeTimerSubQueue", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e564d", + "displacement": 73, + "module": "ntdll", + "symbol": "TppInitializeTimerQueue", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e432e", + "displacement": 878, + "module": "ntdll", + "symbol": "TpAllocPoolInternal", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e80b4", + "displacement": 168, + "module": "ntdll", + "symbol": "TppPoolpReferenceGlobalPool", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7f06c0", + "displacement": 536, + "module": "ntdll", + "symbol": "TppCleanupGroupMemberInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7f0e61", + "displacement": 33, + "module": "ntdll", + "symbol": "TppWorkInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e6bf3", + "displacement": 67, + "module": "ntdll", + "symbol": "TppInitializeTimer", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e667c", + "displacement": 236, + "module": "ntdll", + "symbol": "TpAllocWait", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e314e", + "displacement": 78, + "module": "ntdll", + "symbol": "EtwpRegisterTpNotificationOnce", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7ea47a", + "displacement": 154, + "module": "ntdll", + "symbol": "RtlRunOnceExecuteOnce", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7ea668", + "displacement": 120, + "module": "ntdll", + "symbol": "EtwpRegisterProvider", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7ea847", + "displacement": 215, + "module": "ntdll", + "symbol": "EtwNotificationRegister", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e9e10", + "displacement": 32, + "module": "ntdll", + "symbol": "EtwEventRegister", + "symbol_type": 3 + } + ], + "type": "WaitCompletionPacket" + }, + { + "access": "0x3", + "handle": 44, + "name": "\\KnownDlls", + "stack": [ + { + "address": "0xfffff8008115fca2", + "module": "kernel (unsymbolized)" + }, + { + "address": "0xfffff80080e451e5", + "module": "kernel (unsymbolized)" + }, + { + "address": "0x7ff8cc880524", + "displacement": 20, + "module": "ntdll", + "symbol": "NtOpenDirectoryObject", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc8b5ee6", + "displacement": 3174, + "module": "ntdll", + "symbol": "LdrpInitializeProcess", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc858a28", + "displacement": 364, + "module": "ntdll", + "symbol": "LdrpInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc87e3cd", + "displacement": 237, + "module": "ntdll", + "symbol": "LdrpInitializeInternal", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc85882e", + "displacement": 14, + "module": "ntdll", + "symbol": "LdrInitializeThunk", + "symbol_type": 3 + } + ], + "type": "Directory" + }, + { + "access": "0x1f0003", + "handle": 52, + "name": null, + "stack": [ + { + "address": "0xfffff80081109db1", + "module": "kernel (unsymbolized)" + }, + { + "address": "0xfffff80080e451e5", + "module": "kernel (unsymbolized)" + }, + { + "address": "0x7ff8cc880324", + "displacement": 20, + "module": "ntdll", + "symbol": "NtCreateEvent", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc8625c7", + "displacement": 35, + "module": "ntdll", + "symbol": "LdrpCreateLoaderEvents", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc8b646c", + "displacement": 4588, + "module": "ntdll", + "symbol": "LdrpInitializeProcess", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc858a28", + "displacement": 364, + "module": "ntdll", + "symbol": "LdrpInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc87e3cd", + "displacement": 237, + "module": "ntdll", + "symbol": "LdrpInitializeInternal", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc85882e", + "displacement": 14, + "module": "ntdll", + "symbol": "LdrInitializeThunk", + "symbol_type": 3 + } + ], + "type": "Event" + }, + { + "access": "0x1f0003", + "handle": 56, + "name": null, + "stack": [ + { + "address": "0xfffff80081109db1", + "module": "kernel (unsymbolized)" + }, + { + "address": "0xfffff80080e451e5", + "module": "kernel (unsymbolized)" + }, + { + "address": "0x7ff8cc880324", + "displacement": 20, + "module": "ntdll", + "symbol": "NtCreateEvent", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc8625ea", + "displacement": 70, + "module": "ntdll", + "symbol": "LdrpCreateLoaderEvents", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc8b646c", + "displacement": 4588, + "module": "ntdll", + "symbol": "LdrpInitializeProcess", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc858a28", + "displacement": 364, + "module": "ntdll", + "symbol": "LdrpInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc87e3cd", + "displacement": 237, + "module": "ntdll", + "symbol": "LdrpInitializeInternal", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc85882e", + "displacement": 14, + "module": "ntdll", + "symbol": "LdrInitializeThunk", + "symbol_type": 3 + } + ], + "type": "Event" + }, + { + "access": "0x1f0003", + "handle": 88, + "name": null, + "stack": [ + { + "address": "0xfffff80081174055", + "module": "kernel (unsymbolized)" + }, + { + "address": "0xfffff80080e451e5", + "module": "kernel (unsymbolized)" + }, + { + "address": "0x7ff8cc880ff4", + "displacement": 20, + "module": "ntdll", + "symbol": "NtCreateIoCompletion", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e4235", + "displacement": 629, + "module": "ntdll", + "symbol": "TpAllocPoolInternal", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e2e95", + "displacement": 57, + "module": "ntdll", + "symbol": "LdrpEnableParallelLoading", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc8b6d86", + "displacement": 6918, + "module": "ntdll", + "symbol": "LdrpInitializeProcess", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc858a28", + "displacement": 364, + "module": "ntdll", + "symbol": "LdrpInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc87e3cd", + "displacement": 237, + "module": "ntdll", + "symbol": "LdrpInitializeInternal", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc85882e", + "displacement": 14, + "module": "ntdll", + "symbol": "LdrInitializeThunk", + "symbol_type": 3 + } + ], + "type": "IoCompletion" + }, + { + "access": "0xf00ff", + "handle": 92, + "name": null, + "stack": [ + { + "address": "0xfffff8008110216c", + "module": "kernel (unsymbolized)" + }, + { + "address": "0xfffff80080e451e5", + "module": "kernel (unsymbolized)" + }, + { + "address": "0x7ff8cc881434", + "displacement": 20, + "module": "ntdll", + "symbol": "NtCreateWorkerFactory", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e42a6", + "displacement": 742, + "module": "ntdll", + "symbol": "TpAllocPoolInternal", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e2e95", + "displacement": 57, + "module": "ntdll", + "symbol": "LdrpEnableParallelLoading", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc8b6d86", + "displacement": 6918, + "module": "ntdll", + "symbol": "LdrpInitializeProcess", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc858a28", + "displacement": 364, + "module": "ntdll", + "symbol": "LdrpInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc87e3cd", + "displacement": 237, + "module": "ntdll", + "symbol": "LdrpInitializeInternal", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc85882e", + "displacement": 14, + "module": "ntdll", + "symbol": "LdrInitializeThunk", + "symbol_type": 3 + } + ], + "type": "TpWorkerFactory" + }, + { + "access": "0x100002", + "handle": 96, + "name": null, + "stack": [ + { + "address": "0xfffff8008110232d", + "module": "kernel (unsymbolized)" + }, + { + "address": "0xfffff80080e451e5", + "module": "kernel (unsymbolized)" + }, + { + "address": "0x7ff8cc881314", + "displacement": 20, + "module": "ntdll", + "symbol": "NtCreateTimer2", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e56c6", + "displacement": 78, + "module": "ntdll", + "symbol": "TppInitializeTimerSubQueue", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e5635", + "displacement": 49, + "module": "ntdll", + "symbol": "TppInitializeTimerQueue", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e432e", + "displacement": 878, + "module": "ntdll", + "symbol": "TpAllocPoolInternal", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e2e95", + "displacement": 57, + "module": "ntdll", + "symbol": "LdrpEnableParallelLoading", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc8b6d86", + "displacement": 6918, + "module": "ntdll", + "symbol": "LdrpInitializeProcess", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc858a28", + "displacement": 364, + "module": "ntdll", + "symbol": "LdrpInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc87e3cd", + "displacement": 237, + "module": "ntdll", + "symbol": "LdrpInitializeInternal", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc85882e", + "displacement": 14, + "module": "ntdll", + "symbol": "LdrInitializeThunk", + "symbol_type": 3 + } + ], + "type": "IRTimer" + }, + { + "access": "0x1", + "handle": 100, + "name": null, + "stack": [ + { + "address": "0xfffff800811664d8", + "module": "kernel (unsymbolized)" + }, + { + "address": "0xfffff80080e451e5", + "module": "kernel (unsymbolized)" + }, + { + "address": "0x7ff8cc8813d4", + "displacement": 20, + "module": "ntdll", + "symbol": "NtCreateWaitCompletionPacket", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e56de", + "displacement": 102, + "module": "ntdll", + "symbol": "TppInitializeTimerSubQueue", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e5635", + "displacement": 49, + "module": "ntdll", + "symbol": "TppInitializeTimerQueue", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e432e", + "displacement": 878, + "module": "ntdll", + "symbol": "TpAllocPoolInternal", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e2e95", + "displacement": 57, + "module": "ntdll", + "symbol": "LdrpEnableParallelLoading", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc8b6d86", + "displacement": 6918, + "module": "ntdll", + "symbol": "LdrpInitializeProcess", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc858a28", + "displacement": 364, + "module": "ntdll", + "symbol": "LdrpInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc87e3cd", + "displacement": 237, + "module": "ntdll", + "symbol": "LdrpInitializeInternal", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc85882e", + "displacement": 14, + "module": "ntdll", + "symbol": "LdrInitializeThunk", + "symbol_type": 3 + } + ], + "type": "WaitCompletionPacket" + }, + { + "access": "0x100002", + "handle": 104, + "name": null, + "stack": [ + { + "address": "0xfffff8008110232d", + "module": "kernel (unsymbolized)" + }, + { + "address": "0xfffff80080e451e5", + "module": "kernel (unsymbolized)" + }, + { + "address": "0x7ff8cc881314", + "displacement": 20, + "module": "ntdll", + "symbol": "NtCreateTimer2", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e56c6", + "displacement": 78, + "module": "ntdll", + "symbol": "TppInitializeTimerSubQueue", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e564d", + "displacement": 73, + "module": "ntdll", + "symbol": "TppInitializeTimerQueue", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e432e", + "displacement": 878, + "module": "ntdll", + "symbol": "TpAllocPoolInternal", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e2e95", + "displacement": 57, + "module": "ntdll", + "symbol": "LdrpEnableParallelLoading", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc8b6d86", + "displacement": 6918, + "module": "ntdll", + "symbol": "LdrpInitializeProcess", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc858a28", + "displacement": 364, + "module": "ntdll", + "symbol": "LdrpInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc87e3cd", + "displacement": 237, + "module": "ntdll", + "symbol": "LdrpInitializeInternal", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc85882e", + "displacement": 14, + "module": "ntdll", + "symbol": "LdrInitializeThunk", + "symbol_type": 3 + } + ], + "type": "IRTimer" + }, + { + "access": "0x1", + "handle": 108, + "name": null, + "stack": [ + { + "address": "0xfffff800811664d8", + "module": "kernel (unsymbolized)" + }, + { + "address": "0xfffff80080e451e5", + "module": "kernel (unsymbolized)" + }, + { + "address": "0x7ff8cc8813d4", + "displacement": 20, + "module": "ntdll", + "symbol": "NtCreateWaitCompletionPacket", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e56de", + "displacement": 102, + "module": "ntdll", + "symbol": "TppInitializeTimerSubQueue", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e564d", + "displacement": 73, + "module": "ntdll", + "symbol": "TppInitializeTimerQueue", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e432e", + "displacement": 878, + "module": "ntdll", + "symbol": "TpAllocPoolInternal", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7e2e95", + "displacement": 57, + "module": "ntdll", + "symbol": "LdrpEnableParallelLoading", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc8b6d86", + "displacement": 6918, + "module": "ntdll", + "symbol": "LdrpInitializeProcess", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc858a28", + "displacement": 364, + "module": "ntdll", + "symbol": "LdrpInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc87e3cd", + "displacement": 237, + "module": "ntdll", + "symbol": "LdrpInitializeInternal", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc85882e", + "displacement": 14, + "module": "ntdll", + "symbol": "LdrInitializeThunk", + "symbol_type": 3 + } + ], + "type": "WaitCompletionPacket" + }, + { + "access": "0x100003", + "handle": 140, + "name": null, + "stack": [ + { + "address": "0xfffff800810a7875", + "module": "kernel (unsymbolized)" + }, + { + "address": "0xfffff80080e451e5", + "module": "kernel (unsymbolized)" + }, + { + "address": "0x7ff8cc881274", + "displacement": 20, + "module": "ntdll", + "symbol": "NtCreateSemaphore", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7eaece", + "displacement": 190, + "module": "ntdll", + "symbol": "RtlInitializeResource", + "symbol_type": 3 + }, + { + "address": "0x7ff8ca05593b", + "displacement": 63, + "module": "bcrypt", + "symbol": "InitializeSystemPreferredCache", + "symbol_type": 3 + }, + { + "address": "0x7ff8ca0558c6", + "displacement": 122, + "module": "bcrypt", + "symbol": "InitializeCNG", + "symbol_type": 3 + }, + { + "address": "0x7ff8ca05a617", + "displacement": 135, + "module": "bcrypt", + "symbol": "DllMain", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc8073b7", + "displacement": 107, + "module": "ntdll", + "symbol": "LdrpCallInitRoutine", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc84fc8a", + "displacement": 458, + "module": "ntdll", + "symbol": "LdrpInitializeNode", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc84fa36", + "displacement": 66, + "module": "ntdll", + "symbol": "LdrpInitializeGraphRecurse", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc84faa0", + "displacement": 172, + "module": "ntdll", + "symbol": "LdrpInitializeGraphRecurse", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc84faa0", + "displacement": 172, + "module": "ntdll", + "symbol": "LdrpInitializeGraphRecurse", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc84faa0", + "displacement": 172, + "module": "ntdll", + "symbol": "LdrpInitializeGraphRecurse", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc8b6f2a", + "displacement": 7338, + "module": "ntdll", + "symbol": "LdrpInitializeProcess", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc858a28", + "displacement": 364, + "module": "ntdll", + "symbol": "LdrpInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc87e3cd", + "displacement": 237, + "module": "ntdll", + "symbol": "LdrpInitializeInternal", + "symbol_type": 3 + } + ], + "type": "Semaphore" + }, + { + "access": "0x100003", + "handle": 144, + "name": null, + "stack": [ + { + "address": "0xfffff800810a7875", + "module": "kernel (unsymbolized)" + }, + { + "address": "0xfffff80080e451e5", + "module": "kernel (unsymbolized)" + }, + { + "address": "0x7ff8cc881274", + "displacement": 20, + "module": "ntdll", + "symbol": "NtCreateSemaphore", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc7eaefd", + "displacement": 237, + "module": "ntdll", + "symbol": "RtlInitializeResource", + "symbol_type": 3 + }, + { + "address": "0x7ff8ca05593b", + "displacement": 63, + "module": "bcrypt", + "symbol": "InitializeSystemPreferredCache", + "symbol_type": 3 + }, + { + "address": "0x7ff8ca0558c6", + "displacement": 122, + "module": "bcrypt", + "symbol": "InitializeCNG", + "symbol_type": 3 + }, + { + "address": "0x7ff8ca05a617", + "displacement": 135, + "module": "bcrypt", + "symbol": "DllMain", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc8073b7", + "displacement": 107, + "module": "ntdll", + "symbol": "LdrpCallInitRoutine", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc84fc8a", + "displacement": 458, + "module": "ntdll", + "symbol": "LdrpInitializeNode", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc84fa36", + "displacement": 66, + "module": "ntdll", + "symbol": "LdrpInitializeGraphRecurse", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc84faa0", + "displacement": 172, + "module": "ntdll", + "symbol": "LdrpInitializeGraphRecurse", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc84faa0", + "displacement": 172, + "module": "ntdll", + "symbol": "LdrpInitializeGraphRecurse", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc84faa0", + "displacement": 172, + "module": "ntdll", + "symbol": "LdrpInitializeGraphRecurse", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc8b6f2a", + "displacement": 7338, + "module": "ntdll", + "symbol": "LdrpInitializeProcess", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc858a28", + "displacement": 364, + "module": "ntdll", + "symbol": "LdrpInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc87e3cd", + "displacement": 237, + "module": "ntdll", + "symbol": "LdrpInitializeInternal", + "symbol_type": 3 + } + ], + "type": "Semaphore" + }, + { + "access": "0x1f0003", + "handle": 148, + "name": null, + "stack": [ + { + "address": "0xfffff80081109db1", + "module": "kernel (unsymbolized)" + }, + { + "address": "0xfffff80080e451e5", + "module": "kernel (unsymbolized)" + }, + { + "address": "0x7ff8cc880324", + "displacement": 20, + "module": "ntdll", + "symbol": "NtCreateEvent", + "symbol_type": 3 + }, + { + "address": "0x7ff8ca0e9f82", + "displacement": 146, + "module": "kernelbase", + "symbol": "CreateEventW", + "symbol_type": 3 + }, + { + "address": "0x7ff8ca055b1c", + "displacement": 88, + "module": "bcrypt", + "symbol": "BcpRegisterConfigChangeNotifyNoLogging", + "symbol_type": 3 + }, + { + "address": "0x7ff8ca0559e5", + "displacement": 233, + "module": "bcrypt", + "symbol": "InitializeSystemPreferredCache", + "symbol_type": 3 + }, + { + "address": "0x7ff8ca0558c6", + "displacement": 122, + "module": "bcrypt", + "symbol": "InitializeCNG", + "symbol_type": 3 + }, + { + "address": "0x7ff8ca05a617", + "displacement": 135, + "module": "bcrypt", + "symbol": "DllMain", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc8073b7", + "displacement": 107, + "module": "ntdll", + "symbol": "LdrpCallInitRoutine", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc84fc8a", + "displacement": 458, + "module": "ntdll", + "symbol": "LdrpInitializeNode", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc84fa36", + "displacement": 66, + "module": "ntdll", + "symbol": "LdrpInitializeGraphRecurse", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc84faa0", + "displacement": 172, + "module": "ntdll", + "symbol": "LdrpInitializeGraphRecurse", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc84faa0", + "displacement": 172, + "module": "ntdll", + "symbol": "LdrpInitializeGraphRecurse", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc84faa0", + "displacement": 172, + "module": "ntdll", + "symbol": "LdrpInitializeGraphRecurse", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc8b6f2a", + "displacement": 7338, + "module": "ntdll", + "symbol": "LdrpInitializeProcess", + "symbol_type": 3 + }, + { + "address": "0x7ff8cc858a28", + "displacement": 364, + "module": "ntdll", + "symbol": "LdrpInitialize", + "symbol_type": 3 + } + ], + "type": "Event" + }, + { + "access": "0x120196", + "creator": "parent CreatePipe / explicit HANDLE_LIST", + "handle": 336, + "stack": [], + "type": "File" + }, + { + "access": "0x120189", + "creator": "parent CreatePipe / explicit HANDLE_LIST", + "handle": 348, + "stack": [], + "type": "File" + }, + { + "access": "0x120196", + "creator": "parent CreatePipe / explicit HANDLE_LIST", + "handle": 360, + "stack": [], + "type": "File" + } + ], + "image": "\\\\?\\D:\\a\\basal\\basal\\target\\release\\ck-basal-worker.exe", + "profile": "windows-2022", + "runner_image": "win22", + "runner_version": "20261004.326.1", + "total": 25, + "tracing_class": 32, + "tracing_slots": 16384, + "unresolved": [] +} \ No newline at end of file diff --git a/crates/basal-worker/tests/data/windows-handle-provenance-windows-latest.json b/crates/basal-worker/tests/data/windows-handle-provenance-windows-latest.json new file mode 100644 index 0000000..77f1246 --- /dev/null +++ b/crates/basal-worker/tests/data/windows-handle-provenance-windows-latest.json @@ -0,0 +1,1803 @@ +{ + "handles": [ + { + "access": "0x1f0003", + "handle": 4, + "name": null, + "stack": [ + { + "address": "0xfffff804c4e4bb2b", + "module": "kernel (unsymbolized)" + }, + { + "address": "0xfffff804c4cb5c55", + "module": "kernel (unsymbolized)" + }, + { + "address": "0x7ffd75a430f4", + "displacement": 20, + "module": "ntdll", + "symbol": "NtCreateEvent", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599b7b5", + "displacement": 149, + "module": "ntdll", + "symbol": "LdrpInitializeInternal", + "symbol_type": 3 + }, + { + "address": "0x7ffd759cb3ce", + "displacement": 14, + "module": "ntdll", + "symbol": "LdrInitializeThunk", + "symbol_type": 3 + } + ], + "type": "Event" + }, + { + "access": "0x1f0003", + "handle": 8, + "name": null, + "stack": [ + { + "address": "0xfffff804c4e4bb2b", + "module": "kernel (unsymbolized)" + }, + { + "address": "0xfffff804c4cb5c55", + "module": "kernel (unsymbolized)" + }, + { + "address": "0x7ffd75a430f4", + "displacement": 20, + "module": "ntdll", + "symbol": "NtCreateEvent", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599b90e", + "displacement": 234, + "module": "ntdll", + "symbol": "LdrpInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599b77a", + "displacement": 90, + "module": "ntdll", + "symbol": "LdrpInitializeInternal", + "symbol_type": 3 + }, + { + "address": "0x7ffd759cb3ce", + "displacement": 14, + "module": "ntdll", + "symbol": "LdrInitializeThunk", + "symbol_type": 3 + } + ], + "type": "Event" + }, + { + "access": "0x1f0003", + "handle": 12, + "name": null, + "stack": [ + { + "address": "0xfffff804c500c28b", + "module": "kernel (unsymbolized)" + }, + { + "address": "0xfffff804c4cb5c55", + "module": "kernel (unsymbolized)" + }, + { + "address": "0x7ffd75a43e24", + "displacement": 20, + "module": "ntdll", + "symbol": "NtCreateIoCompletion", + "symbol_type": 3 + }, + { + "address": "0x7ffd7594b40f", + "displacement": 663, + "module": "ntdll", + "symbol": "TpAllocPoolInternal", + "symbol_type": 3 + }, + { + "address": "0x7ffd758f43b7", + "displacement": 199, + "module": "ntdll", + "symbol": "TppPoolpReferenceGlobalPool", + "symbol_type": 3 + }, + { + "address": "0x7ffd758f381f", + "displacement": 351, + "module": "ntdll", + "symbol": "TppCleanupGroupMemberInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ffd758f3571", + "displacement": 49, + "module": "ntdll", + "symbol": "TppWorkInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ffd7594d882", + "displacement": 194, + "module": "ntdll", + "symbol": "TpAllocTimer", + "symbol_type": 3 + }, + { + "address": "0x7ffd7597a731", + "displacement": 89, + "module": "ntdll", + "symbol": "RtlpInitializeWnf", + "symbol_type": 3 + }, + { + "address": "0x7ffd7597af7a", + "displacement": 398, + "module": "ntdll", + "symbol": "RtlpSubscribeWnfStateChangeNotificationInternal", + "symbol_type": 3 + }, + { + "address": "0x7ffd7597ab73", + "displacement": 127, + "module": "ntdll", + "symbol": "LdrpEnableUMGLTracingStateSync", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599c805", + "displacement": 2929, + "module": "ntdll", + "symbol": "LdrpInitializeProcess", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599b990", + "displacement": 364, + "module": "ntdll", + "symbol": "LdrpInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599b77a", + "displacement": 90, + "module": "ntdll", + "symbol": "LdrpInitializeInternal", + "symbol_type": 3 + }, + { + "address": "0x7ffd759cb3ce", + "displacement": 14, + "module": "ntdll", + "symbol": "LdrInitializeThunk", + "symbol_type": 3 + } + ], + "type": "IoCompletion" + }, + { + "access": "0xf00ff", + "handle": 16, + "name": null, + "stack": [ + { + "address": "0xfffff804c4fa26fd", + "module": "kernel (unsymbolized)" + }, + { + "address": "0xfffff804c4cb5c55", + "module": "kernel (unsymbolized)" + }, + { + "address": "0x7ffd75a44284", + "displacement": 20, + "module": "ntdll", + "symbol": "NtCreateWorkerFactory", + "symbol_type": 3 + }, + { + "address": "0x7ffd7594b480", + "displacement": 776, + "module": "ntdll", + "symbol": "TpAllocPoolInternal", + "symbol_type": 3 + }, + { + "address": "0x7ffd758f43b7", + "displacement": 199, + "module": "ntdll", + "symbol": "TppPoolpReferenceGlobalPool", + "symbol_type": 3 + }, + { + "address": "0x7ffd758f381f", + "displacement": 351, + "module": "ntdll", + "symbol": "TppCleanupGroupMemberInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ffd758f3571", + "displacement": 49, + "module": "ntdll", + "symbol": "TppWorkInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ffd7594d882", + "displacement": 194, + "module": "ntdll", + "symbol": "TpAllocTimer", + "symbol_type": 3 + }, + { + "address": "0x7ffd7597a731", + "displacement": 89, + "module": "ntdll", + "symbol": "RtlpInitializeWnf", + "symbol_type": 3 + }, + { + "address": "0x7ffd7597af7a", + "displacement": 398, + "module": "ntdll", + "symbol": "RtlpSubscribeWnfStateChangeNotificationInternal", + "symbol_type": 3 + }, + { + "address": "0x7ffd7597ab73", + "displacement": 127, + "module": "ntdll", + "symbol": "LdrpEnableUMGLTracingStateSync", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599c805", + "displacement": 2929, + "module": "ntdll", + "symbol": "LdrpInitializeProcess", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599b990", + "displacement": 364, + "module": "ntdll", + "symbol": "LdrpInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599b77a", + "displacement": 90, + "module": "ntdll", + "symbol": "LdrpInitializeInternal", + "symbol_type": 3 + }, + { + "address": "0x7ffd759cb3ce", + "displacement": 14, + "module": "ntdll", + "symbol": "LdrInitializeThunk", + "symbol_type": 3 + } + ], + "type": "TpWorkerFactory" + }, + { + "access": "0x100002", + "handle": 20, + "name": null, + "stack": [ + { + "address": "0xfffff804c500b047", + "module": "kernel (unsymbolized)" + }, + { + "address": "0xfffff804c4cb5c55", + "module": "kernel (unsymbolized)" + }, + { + "address": "0x7ffd75a44164", + "displacement": 20, + "module": "ntdll", + "symbol": "NtCreateTimer2", + "symbol_type": 3 + }, + { + "address": "0x7ffd7594c11a", + "displacement": 82, + "module": "ntdll", + "symbol": "TppInitializeTimerSubQueue", + "symbol_type": 3 + }, + { + "address": "0x7ffd7594b955", + "displacement": 49, + "module": "ntdll", + "symbol": "TppInitializeTimerQueue", + "symbol_type": 3 + }, + { + "address": "0x7ffd7594b50b", + "displacement": 915, + "module": "ntdll", + "symbol": "TpAllocPoolInternal", + "symbol_type": 3 + }, + { + "address": "0x7ffd758f43b7", + "displacement": 199, + "module": "ntdll", + "symbol": "TppPoolpReferenceGlobalPool", + "symbol_type": 3 + }, + { + "address": "0x7ffd758f381f", + "displacement": 351, + "module": "ntdll", + "symbol": "TppCleanupGroupMemberInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ffd758f3571", + "displacement": 49, + "module": "ntdll", + "symbol": "TppWorkInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ffd7594d882", + "displacement": 194, + "module": "ntdll", + "symbol": "TpAllocTimer", + "symbol_type": 3 + }, + { + "address": "0x7ffd7597a731", + "displacement": 89, + "module": "ntdll", + "symbol": "RtlpInitializeWnf", + "symbol_type": 3 + }, + { + "address": "0x7ffd7597af7a", + "displacement": 398, + "module": "ntdll", + "symbol": "RtlpSubscribeWnfStateChangeNotificationInternal", + "symbol_type": 3 + }, + { + "address": "0x7ffd7597ab73", + "displacement": 127, + "module": "ntdll", + "symbol": "LdrpEnableUMGLTracingStateSync", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599c805", + "displacement": 2929, + "module": "ntdll", + "symbol": "LdrpInitializeProcess", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599b990", + "displacement": 364, + "module": "ntdll", + "symbol": "LdrpInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599b77a", + "displacement": 90, + "module": "ntdll", + "symbol": "LdrpInitializeInternal", + "symbol_type": 3 + } + ], + "type": "IRTimer" + }, + { + "access": "0x1", + "handle": 24, + "name": null, + "stack": [ + { + "address": "0xfffff804c4e3361e", + "module": "kernel (unsymbolized)" + }, + { + "address": "0xfffff804c4cb5c55", + "module": "kernel (unsymbolized)" + }, + { + "address": "0x7ffd75a44224", + "displacement": 20, + "module": "ntdll", + "symbol": "NtCreateWaitCompletionPacket", + "symbol_type": 3 + }, + { + "address": "0x7ffd7594c132", + "displacement": 106, + "module": "ntdll", + "symbol": "TppInitializeTimerSubQueue", + "symbol_type": 3 + }, + { + "address": "0x7ffd7594b955", + "displacement": 49, + "module": "ntdll", + "symbol": "TppInitializeTimerQueue", + "symbol_type": 3 + }, + { + "address": "0x7ffd7594b50b", + "displacement": 915, + "module": "ntdll", + "symbol": "TpAllocPoolInternal", + "symbol_type": 3 + }, + { + "address": "0x7ffd758f43b7", + "displacement": 199, + "module": "ntdll", + "symbol": "TppPoolpReferenceGlobalPool", + "symbol_type": 3 + }, + { + "address": "0x7ffd758f381f", + "displacement": 351, + "module": "ntdll", + "symbol": "TppCleanupGroupMemberInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ffd758f3571", + "displacement": 49, + "module": "ntdll", + "symbol": "TppWorkInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ffd7594d882", + "displacement": 194, + "module": "ntdll", + "symbol": "TpAllocTimer", + "symbol_type": 3 + }, + { + "address": "0x7ffd7597a731", + "displacement": 89, + "module": "ntdll", + "symbol": "RtlpInitializeWnf", + "symbol_type": 3 + }, + { + "address": "0x7ffd7597af7a", + "displacement": 398, + "module": "ntdll", + "symbol": "RtlpSubscribeWnfStateChangeNotificationInternal", + "symbol_type": 3 + }, + { + "address": "0x7ffd7597ab73", + "displacement": 127, + "module": "ntdll", + "symbol": "LdrpEnableUMGLTracingStateSync", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599c805", + "displacement": 2929, + "module": "ntdll", + "symbol": "LdrpInitializeProcess", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599b990", + "displacement": 364, + "module": "ntdll", + "symbol": "LdrpInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599b77a", + "displacement": 90, + "module": "ntdll", + "symbol": "LdrpInitializeInternal", + "symbol_type": 3 + } + ], + "type": "WaitCompletionPacket" + }, + { + "access": "0x100002", + "handle": 28, + "name": null, + "stack": [ + { + "address": "0xfffff804c500b047", + "module": "kernel (unsymbolized)" + }, + { + "address": "0xfffff804c4cb5c55", + "module": "kernel (unsymbolized)" + }, + { + "address": "0x7ffd75a44164", + "displacement": 20, + "module": "ntdll", + "symbol": "NtCreateTimer2", + "symbol_type": 3 + }, + { + "address": "0x7ffd7594c11a", + "displacement": 82, + "module": "ntdll", + "symbol": "TppInitializeTimerSubQueue", + "symbol_type": 3 + }, + { + "address": "0x7ffd7594b96d", + "displacement": 73, + "module": "ntdll", + "symbol": "TppInitializeTimerQueue", + "symbol_type": 3 + }, + { + "address": "0x7ffd7594b50b", + "displacement": 915, + "module": "ntdll", + "symbol": "TpAllocPoolInternal", + "symbol_type": 3 + }, + { + "address": "0x7ffd758f43b7", + "displacement": 199, + "module": "ntdll", + "symbol": "TppPoolpReferenceGlobalPool", + "symbol_type": 3 + }, + { + "address": "0x7ffd758f381f", + "displacement": 351, + "module": "ntdll", + "symbol": "TppCleanupGroupMemberInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ffd758f3571", + "displacement": 49, + "module": "ntdll", + "symbol": "TppWorkInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ffd7594d882", + "displacement": 194, + "module": "ntdll", + "symbol": "TpAllocTimer", + "symbol_type": 3 + }, + { + "address": "0x7ffd7597a731", + "displacement": 89, + "module": "ntdll", + "symbol": "RtlpInitializeWnf", + "symbol_type": 3 + }, + { + "address": "0x7ffd7597af7a", + "displacement": 398, + "module": "ntdll", + "symbol": "RtlpSubscribeWnfStateChangeNotificationInternal", + "symbol_type": 3 + }, + { + "address": "0x7ffd7597ab73", + "displacement": 127, + "module": "ntdll", + "symbol": "LdrpEnableUMGLTracingStateSync", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599c805", + "displacement": 2929, + "module": "ntdll", + "symbol": "LdrpInitializeProcess", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599b990", + "displacement": 364, + "module": "ntdll", + "symbol": "LdrpInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599b77a", + "displacement": 90, + "module": "ntdll", + "symbol": "LdrpInitializeInternal", + "symbol_type": 3 + } + ], + "type": "IRTimer" + }, + { + "access": "0x1", + "handle": 32, + "name": null, + "stack": [ + { + "address": "0xfffff804c4e3361e", + "module": "kernel (unsymbolized)" + }, + { + "address": "0xfffff804c4cb5c55", + "module": "kernel (unsymbolized)" + }, + { + "address": "0x7ffd75a44224", + "displacement": 20, + "module": "ntdll", + "symbol": "NtCreateWaitCompletionPacket", + "symbol_type": 3 + }, + { + "address": "0x7ffd7594c132", + "displacement": 106, + "module": "ntdll", + "symbol": "TppInitializeTimerSubQueue", + "symbol_type": 3 + }, + { + "address": "0x7ffd7594b96d", + "displacement": 73, + "module": "ntdll", + "symbol": "TppInitializeTimerQueue", + "symbol_type": 3 + }, + { + "address": "0x7ffd7594b50b", + "displacement": 915, + "module": "ntdll", + "symbol": "TpAllocPoolInternal", + "symbol_type": 3 + }, + { + "address": "0x7ffd758f43b7", + "displacement": 199, + "module": "ntdll", + "symbol": "TppPoolpReferenceGlobalPool", + "symbol_type": 3 + }, + { + "address": "0x7ffd758f381f", + "displacement": 351, + "module": "ntdll", + "symbol": "TppCleanupGroupMemberInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ffd758f3571", + "displacement": 49, + "module": "ntdll", + "symbol": "TppWorkInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ffd7594d882", + "displacement": 194, + "module": "ntdll", + "symbol": "TpAllocTimer", + "symbol_type": 3 + }, + { + "address": "0x7ffd7597a731", + "displacement": 89, + "module": "ntdll", + "symbol": "RtlpInitializeWnf", + "symbol_type": 3 + }, + { + "address": "0x7ffd7597af7a", + "displacement": 398, + "module": "ntdll", + "symbol": "RtlpSubscribeWnfStateChangeNotificationInternal", + "symbol_type": 3 + }, + { + "address": "0x7ffd7597ab73", + "displacement": 127, + "module": "ntdll", + "symbol": "LdrpEnableUMGLTracingStateSync", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599c805", + "displacement": 2929, + "module": "ntdll", + "symbol": "LdrpInitializeProcess", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599b990", + "displacement": 364, + "module": "ntdll", + "symbol": "LdrpInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599b77a", + "displacement": 90, + "module": "ntdll", + "symbol": "LdrpInitializeInternal", + "symbol_type": 3 + } + ], + "type": "WaitCompletionPacket" + }, + { + "access": "0x1f0003", + "handle": 36, + "name": null, + "stack": [ + { + "address": "0xfffff804c4e4bb2b", + "module": "kernel (unsymbolized)" + }, + { + "address": "0xfffff804c4cb5c55", + "module": "kernel (unsymbolized)" + }, + { + "address": "0x7ffd75a430f4", + "displacement": 20, + "module": "ntdll", + "symbol": "NtCreateEvent", + "symbol_type": 3 + }, + { + "address": "0x7ffd7597a7e9", + "displacement": 45, + "module": "ntdll", + "symbol": "RtlpWnfRegisterTpNotification", + "symbol_type": 3 + }, + { + "address": "0x7ffd7597a775", + "displacement": 157, + "module": "ntdll", + "symbol": "RtlpInitializeWnf", + "symbol_type": 3 + }, + { + "address": "0x7ffd7597af7a", + "displacement": 398, + "module": "ntdll", + "symbol": "RtlpSubscribeWnfStateChangeNotificationInternal", + "symbol_type": 3 + }, + { + "address": "0x7ffd7597ab73", + "displacement": 127, + "module": "ntdll", + "symbol": "LdrpEnableUMGLTracingStateSync", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599c805", + "displacement": 2929, + "module": "ntdll", + "symbol": "LdrpInitializeProcess", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599b990", + "displacement": 364, + "module": "ntdll", + "symbol": "LdrpInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599b77a", + "displacement": 90, + "module": "ntdll", + "symbol": "LdrpInitializeInternal", + "symbol_type": 3 + }, + { + "address": "0x7ffd759cb3ce", + "displacement": 14, + "module": "ntdll", + "symbol": "LdrInitializeThunk", + "symbol_type": 3 + } + ], + "type": "Event" + }, + { + "access": "0x1", + "handle": 40, + "name": null, + "stack": [ + { + "address": "0xfffff804c4e3361e", + "module": "kernel (unsymbolized)" + }, + { + "address": "0xfffff804c4cb5c55", + "module": "kernel (unsymbolized)" + }, + { + "address": "0x7ffd75a44224", + "displacement": 20, + "module": "ntdll", + "symbol": "NtCreateWaitCompletionPacket", + "symbol_type": 3 + }, + { + "address": "0x7ffd7594d39f", + "displacement": 191, + "module": "ntdll", + "symbol": "TpAllocWait", + "symbol_type": 3 + }, + { + "address": "0x7ffd7597a808", + "displacement": 76, + "module": "ntdll", + "symbol": "RtlpWnfRegisterTpNotification", + "symbol_type": 3 + }, + { + "address": "0x7ffd7597a775", + "displacement": 157, + "module": "ntdll", + "symbol": "RtlpInitializeWnf", + "symbol_type": 3 + }, + { + "address": "0x7ffd7597af7a", + "displacement": 398, + "module": "ntdll", + "symbol": "RtlpSubscribeWnfStateChangeNotificationInternal", + "symbol_type": 3 + }, + { + "address": "0x7ffd7597ab73", + "displacement": 127, + "module": "ntdll", + "symbol": "LdrpEnableUMGLTracingStateSync", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599c805", + "displacement": 2929, + "module": "ntdll", + "symbol": "LdrpInitializeProcess", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599b990", + "displacement": 364, + "module": "ntdll", + "symbol": "LdrpInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599b77a", + "displacement": 90, + "module": "ntdll", + "symbol": "LdrpInitializeInternal", + "symbol_type": 3 + }, + { + "address": "0x7ffd759cb3ce", + "displacement": 14, + "module": "ntdll", + "symbol": "LdrInitializeThunk", + "symbol_type": 3 + } + ], + "type": "WaitCompletionPacket" + }, + { + "access": "0x1f0003", + "handle": 44, + "name": null, + "stack": [ + { + "address": "0xfffff804c4e4bb2b", + "module": "kernel (unsymbolized)" + }, + { + "address": "0xfffff804c4cb5c55", + "module": "kernel (unsymbolized)" + }, + { + "address": "0x7ffd75a430f4", + "displacement": 20, + "module": "ntdll", + "symbol": "NtCreateEvent", + "symbol_type": 3 + }, + { + "address": "0x7ffd7597a4e3", + "displacement": 55, + "module": "ntdll", + "symbol": "EtwpRegisterTpNotificationOnce", + "symbol_type": 3 + }, + { + "address": "0x7ffd75940ae4", + "displacement": 180, + "module": "ntdll", + "symbol": "EtwpRegisterProvider", + "symbol_type": 3 + }, + { + "address": "0x7ffd7593fcd1", + "displacement": 353, + "module": "ntdll", + "symbol": "EtwNotificationRegister", + "symbol_type": 3 + }, + { + "address": "0x7ffd7593fb43", + "displacement": 35, + "module": "ntdll", + "symbol": "EtwEventRegister", + "symbol_type": 3 + }, + { + "address": "0x7ffd75a524f2", + "displacement": 90, + "module": "ntdll", + "symbol": "TraceLoggingRegisterEx_EtwEventRegister_EtwEventSetInformation", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599c811", + "displacement": 2941, + "module": "ntdll", + "symbol": "LdrpInitializeProcess", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599b990", + "displacement": 364, + "module": "ntdll", + "symbol": "LdrpInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599b77a", + "displacement": 90, + "module": "ntdll", + "symbol": "LdrpInitializeInternal", + "symbol_type": 3 + }, + { + "address": "0x7ffd759cb3ce", + "displacement": 14, + "module": "ntdll", + "symbol": "LdrInitializeThunk", + "symbol_type": 3 + } + ], + "type": "Event" + }, + { + "access": "0x1", + "handle": 48, + "name": null, + "stack": [ + { + "address": "0xfffff804c4e3361e", + "module": "kernel (unsymbolized)" + }, + { + "address": "0xfffff804c4cb5c55", + "module": "kernel (unsymbolized)" + }, + { + "address": "0x7ffd75a44224", + "displacement": 20, + "module": "ntdll", + "symbol": "NtCreateWaitCompletionPacket", + "symbol_type": 3 + }, + { + "address": "0x7ffd7594d39f", + "displacement": 191, + "module": "ntdll", + "symbol": "TpAllocWait", + "symbol_type": 3 + }, + { + "address": "0x7ffd7597a4fe", + "displacement": 82, + "module": "ntdll", + "symbol": "EtwpRegisterTpNotificationOnce", + "symbol_type": 3 + }, + { + "address": "0x7ffd75940ae4", + "displacement": 180, + "module": "ntdll", + "symbol": "EtwpRegisterProvider", + "symbol_type": 3 + }, + { + "address": "0x7ffd7593fcd1", + "displacement": 353, + "module": "ntdll", + "symbol": "EtwNotificationRegister", + "symbol_type": 3 + }, + { + "address": "0x7ffd7593fb43", + "displacement": 35, + "module": "ntdll", + "symbol": "EtwEventRegister", + "symbol_type": 3 + }, + { + "address": "0x7ffd75a524f2", + "displacement": 90, + "module": "ntdll", + "symbol": "TraceLoggingRegisterEx_EtwEventRegister_EtwEventSetInformation", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599c811", + "displacement": 2941, + "module": "ntdll", + "symbol": "LdrpInitializeProcess", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599b990", + "displacement": 364, + "module": "ntdll", + "symbol": "LdrpInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599b77a", + "displacement": 90, + "module": "ntdll", + "symbol": "LdrpInitializeInternal", + "symbol_type": 3 + }, + { + "address": "0x7ffd759cb3ce", + "displacement": 14, + "module": "ntdll", + "symbol": "LdrInitializeThunk", + "symbol_type": 3 + } + ], + "type": "WaitCompletionPacket" + }, + { + "access": "0x3", + "handle": 52, + "name": "\\KnownDlls", + "stack": [ + { + "address": "0xfffff804c4fd0359", + "module": "kernel (unsymbolized)" + }, + { + "address": "0xfffff804c4cb5c55", + "module": "kernel (unsymbolized)" + }, + { + "address": "0x7ffd75a432f4", + "displacement": 20, + "module": "ntdll", + "symbol": "NtOpenDirectoryObject", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599c9e1", + "displacement": 3405, + "module": "ntdll", + "symbol": "LdrpInitializeProcess", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599b990", + "displacement": 364, + "module": "ntdll", + "symbol": "LdrpInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599b77a", + "displacement": 90, + "module": "ntdll", + "symbol": "LdrpInitializeInternal", + "symbol_type": 3 + }, + { + "address": "0x7ffd759cb3ce", + "displacement": 14, + "module": "ntdll", + "symbol": "LdrInitializeThunk", + "symbol_type": 3 + } + ], + "type": "Directory" + }, + { + "access": "0x1f0003", + "handle": 60, + "name": null, + "stack": [ + { + "address": "0xfffff804c4e4bb2b", + "module": "kernel (unsymbolized)" + }, + { + "address": "0xfffff804c4cb5c55", + "module": "kernel (unsymbolized)" + }, + { + "address": "0x7ffd75a430f4", + "displacement": 20, + "module": "ntdll", + "symbol": "NtCreateEvent", + "symbol_type": 3 + }, + { + "address": "0x7ffd759e8f5f", + "displacement": 35, + "module": "ntdll", + "symbol": "LdrpCreateLoaderEvents", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599cf0f", + "displacement": 4731, + "module": "ntdll", + "symbol": "LdrpInitializeProcess", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599b990", + "displacement": 364, + "module": "ntdll", + "symbol": "LdrpInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599b77a", + "displacement": 90, + "module": "ntdll", + "symbol": "LdrpInitializeInternal", + "symbol_type": 3 + }, + { + "address": "0x7ffd759cb3ce", + "displacement": 14, + "module": "ntdll", + "symbol": "LdrInitializeThunk", + "symbol_type": 3 + } + ], + "type": "Event" + }, + { + "access": "0x1f0003", + "handle": 64, + "name": null, + "stack": [ + { + "address": "0xfffff804c4e4bb2b", + "module": "kernel (unsymbolized)" + }, + { + "address": "0xfffff804c4cb5c55", + "module": "kernel (unsymbolized)" + }, + { + "address": "0x7ffd75a430f4", + "displacement": 20, + "module": "ntdll", + "symbol": "NtCreateEvent", + "symbol_type": 3 + }, + { + "address": "0x7ffd759e8f82", + "displacement": 70, + "module": "ntdll", + "symbol": "LdrpCreateLoaderEvents", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599cf0f", + "displacement": 4731, + "module": "ntdll", + "symbol": "LdrpInitializeProcess", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599b990", + "displacement": 364, + "module": "ntdll", + "symbol": "LdrpInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599b77a", + "displacement": 90, + "module": "ntdll", + "symbol": "LdrpInitializeInternal", + "symbol_type": 3 + }, + { + "address": "0x7ffd759cb3ce", + "displacement": 14, + "module": "ntdll", + "symbol": "LdrInitializeThunk", + "symbol_type": 3 + } + ], + "type": "Event" + }, + { + "access": "0x1", + "handle": 72, + "name": null, + "stack": [ + { + "address": "0xfffff804c4f4f7c7", + "module": "kernel (unsymbolized)" + }, + { + "address": "0xfffff804c4f50c0a", + "module": "kernel (unsymbolized)" + }, + { + "address": "0xfffff804c4cb5c55", + "module": "kernel (unsymbolized)" + }, + { + "address": "0x7ffd75a42b74", + "displacement": 20, + "module": "ntdll", + "symbol": "NtSetInformationProcess", + "symbol_type": 3 + }, + { + "address": "0x7ffd759db691", + "displacement": 49, + "module": "ntdll", + "symbol": "LdrpAllocateSchedulerSharedData", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599d45b", + "displacement": 6087, + "module": "ntdll", + "symbol": "LdrpInitializeProcess", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599b990", + "displacement": 364, + "module": "ntdll", + "symbol": "LdrpInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599b77a", + "displacement": 90, + "module": "ntdll", + "symbol": "LdrpInitializeInternal", + "symbol_type": 3 + }, + { + "address": "0x7ffd759cb3ce", + "displacement": 14, + "module": "ntdll", + "symbol": "LdrInitializeThunk", + "symbol_type": 3 + } + ], + "type": "SchedulerSharedData" + }, + { + "access": "0x1f0003", + "handle": 100, + "name": null, + "stack": [ + { + "address": "0xfffff804c500c28b", + "module": "kernel (unsymbolized)" + }, + { + "address": "0xfffff804c4cb5c55", + "module": "kernel (unsymbolized)" + }, + { + "address": "0x7ffd75a43e24", + "displacement": 20, + "module": "ntdll", + "symbol": "NtCreateIoCompletion", + "symbol_type": 3 + }, + { + "address": "0x7ffd7594b40f", + "displacement": 663, + "module": "ntdll", + "symbol": "TpAllocPoolInternal", + "symbol_type": 3 + }, + { + "address": "0x7ffd7594acfd", + "displacement": 73, + "module": "ntdll", + "symbol": "LdrpEnableParallelLoading", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599d66f", + "displacement": 6619, + "module": "ntdll", + "symbol": "LdrpInitializeProcess", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599b990", + "displacement": 364, + "module": "ntdll", + "symbol": "LdrpInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599b77a", + "displacement": 90, + "module": "ntdll", + "symbol": "LdrpInitializeInternal", + "symbol_type": 3 + }, + { + "address": "0x7ffd759cb3ce", + "displacement": 14, + "module": "ntdll", + "symbol": "LdrInitializeThunk", + "symbol_type": 3 + } + ], + "type": "IoCompletion" + }, + { + "access": "0xf00ff", + "handle": 104, + "name": null, + "stack": [ + { + "address": "0xfffff804c4fa26fd", + "module": "kernel (unsymbolized)" + }, + { + "address": "0xfffff804c4cb5c55", + "module": "kernel (unsymbolized)" + }, + { + "address": "0x7ffd75a44284", + "displacement": 20, + "module": "ntdll", + "symbol": "NtCreateWorkerFactory", + "symbol_type": 3 + }, + { + "address": "0x7ffd7594b480", + "displacement": 776, + "module": "ntdll", + "symbol": "TpAllocPoolInternal", + "symbol_type": 3 + }, + { + "address": "0x7ffd7594acfd", + "displacement": 73, + "module": "ntdll", + "symbol": "LdrpEnableParallelLoading", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599d66f", + "displacement": 6619, + "module": "ntdll", + "symbol": "LdrpInitializeProcess", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599b990", + "displacement": 364, + "module": "ntdll", + "symbol": "LdrpInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599b77a", + "displacement": 90, + "module": "ntdll", + "symbol": "LdrpInitializeInternal", + "symbol_type": 3 + }, + { + "address": "0x7ffd759cb3ce", + "displacement": 14, + "module": "ntdll", + "symbol": "LdrInitializeThunk", + "symbol_type": 3 + } + ], + "type": "TpWorkerFactory" + }, + { + "access": "0x100002", + "handle": 108, + "name": null, + "stack": [ + { + "address": "0xfffff804c500b047", + "module": "kernel (unsymbolized)" + }, + { + "address": "0xfffff804c4cb5c55", + "module": "kernel (unsymbolized)" + }, + { + "address": "0x7ffd75a44164", + "displacement": 20, + "module": "ntdll", + "symbol": "NtCreateTimer2", + "symbol_type": 3 + }, + { + "address": "0x7ffd7594c11a", + "displacement": 82, + "module": "ntdll", + "symbol": "TppInitializeTimerSubQueue", + "symbol_type": 3 + }, + { + "address": "0x7ffd7594b955", + "displacement": 49, + "module": "ntdll", + "symbol": "TppInitializeTimerQueue", + "symbol_type": 3 + }, + { + "address": "0x7ffd7594b50b", + "displacement": 915, + "module": "ntdll", + "symbol": "TpAllocPoolInternal", + "symbol_type": 3 + }, + { + "address": "0x7ffd7594acfd", + "displacement": 73, + "module": "ntdll", + "symbol": "LdrpEnableParallelLoading", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599d66f", + "displacement": 6619, + "module": "ntdll", + "symbol": "LdrpInitializeProcess", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599b990", + "displacement": 364, + "module": "ntdll", + "symbol": "LdrpInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599b77a", + "displacement": 90, + "module": "ntdll", + "symbol": "LdrpInitializeInternal", + "symbol_type": 3 + }, + { + "address": "0x7ffd759cb3ce", + "displacement": 14, + "module": "ntdll", + "symbol": "LdrInitializeThunk", + "symbol_type": 3 + } + ], + "type": "IRTimer" + }, + { + "access": "0x1", + "handle": 112, + "name": null, + "stack": [ + { + "address": "0xfffff804c4e3361e", + "module": "kernel (unsymbolized)" + }, + { + "address": "0xfffff804c4cb5c55", + "module": "kernel (unsymbolized)" + }, + { + "address": "0x7ffd75a44224", + "displacement": 20, + "module": "ntdll", + "symbol": "NtCreateWaitCompletionPacket", + "symbol_type": 3 + }, + { + "address": "0x7ffd7594c132", + "displacement": 106, + "module": "ntdll", + "symbol": "TppInitializeTimerSubQueue", + "symbol_type": 3 + }, + { + "address": "0x7ffd7594b955", + "displacement": 49, + "module": "ntdll", + "symbol": "TppInitializeTimerQueue", + "symbol_type": 3 + }, + { + "address": "0x7ffd7594b50b", + "displacement": 915, + "module": "ntdll", + "symbol": "TpAllocPoolInternal", + "symbol_type": 3 + }, + { + "address": "0x7ffd7594acfd", + "displacement": 73, + "module": "ntdll", + "symbol": "LdrpEnableParallelLoading", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599d66f", + "displacement": 6619, + "module": "ntdll", + "symbol": "LdrpInitializeProcess", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599b990", + "displacement": 364, + "module": "ntdll", + "symbol": "LdrpInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599b77a", + "displacement": 90, + "module": "ntdll", + "symbol": "LdrpInitializeInternal", + "symbol_type": 3 + }, + { + "address": "0x7ffd759cb3ce", + "displacement": 14, + "module": "ntdll", + "symbol": "LdrInitializeThunk", + "symbol_type": 3 + } + ], + "type": "WaitCompletionPacket" + }, + { + "access": "0x100002", + "handle": 116, + "name": null, + "stack": [ + { + "address": "0xfffff804c500b047", + "module": "kernel (unsymbolized)" + }, + { + "address": "0xfffff804c4cb5c55", + "module": "kernel (unsymbolized)" + }, + { + "address": "0x7ffd75a44164", + "displacement": 20, + "module": "ntdll", + "symbol": "NtCreateTimer2", + "symbol_type": 3 + }, + { + "address": "0x7ffd7594c11a", + "displacement": 82, + "module": "ntdll", + "symbol": "TppInitializeTimerSubQueue", + "symbol_type": 3 + }, + { + "address": "0x7ffd7594b96d", + "displacement": 73, + "module": "ntdll", + "symbol": "TppInitializeTimerQueue", + "symbol_type": 3 + }, + { + "address": "0x7ffd7594b50b", + "displacement": 915, + "module": "ntdll", + "symbol": "TpAllocPoolInternal", + "symbol_type": 3 + }, + { + "address": "0x7ffd7594acfd", + "displacement": 73, + "module": "ntdll", + "symbol": "LdrpEnableParallelLoading", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599d66f", + "displacement": 6619, + "module": "ntdll", + "symbol": "LdrpInitializeProcess", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599b990", + "displacement": 364, + "module": "ntdll", + "symbol": "LdrpInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599b77a", + "displacement": 90, + "module": "ntdll", + "symbol": "LdrpInitializeInternal", + "symbol_type": 3 + }, + { + "address": "0x7ffd759cb3ce", + "displacement": 14, + "module": "ntdll", + "symbol": "LdrInitializeThunk", + "symbol_type": 3 + } + ], + "type": "IRTimer" + }, + { + "access": "0x1", + "handle": 120, + "name": null, + "stack": [ + { + "address": "0xfffff804c4e3361e", + "module": "kernel (unsymbolized)" + }, + { + "address": "0xfffff804c4cb5c55", + "module": "kernel (unsymbolized)" + }, + { + "address": "0x7ffd75a44224", + "displacement": 20, + "module": "ntdll", + "symbol": "NtCreateWaitCompletionPacket", + "symbol_type": 3 + }, + { + "address": "0x7ffd7594c132", + "displacement": 106, + "module": "ntdll", + "symbol": "TppInitializeTimerSubQueue", + "symbol_type": 3 + }, + { + "address": "0x7ffd7594b96d", + "displacement": 73, + "module": "ntdll", + "symbol": "TppInitializeTimerQueue", + "symbol_type": 3 + }, + { + "address": "0x7ffd7594b50b", + "displacement": 915, + "module": "ntdll", + "symbol": "TpAllocPoolInternal", + "symbol_type": 3 + }, + { + "address": "0x7ffd7594acfd", + "displacement": 73, + "module": "ntdll", + "symbol": "LdrpEnableParallelLoading", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599d66f", + "displacement": 6619, + "module": "ntdll", + "symbol": "LdrpInitializeProcess", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599b990", + "displacement": 364, + "module": "ntdll", + "symbol": "LdrpInitialize", + "symbol_type": 3 + }, + { + "address": "0x7ffd7599b77a", + "displacement": 90, + "module": "ntdll", + "symbol": "LdrpInitializeInternal", + "symbol_type": 3 + }, + { + "address": "0x7ffd759cb3ce", + "displacement": 14, + "module": "ntdll", + "symbol": "LdrInitializeThunk", + "symbol_type": 3 + } + ], + "type": "WaitCompletionPacket" + }, + { + "access": "0x120189", + "creator": "parent CreatePipe / explicit HANDLE_LIST", + "handle": 432, + "stack": [], + "type": "File" + }, + { + "access": "0x120196", + "creator": "parent CreatePipe / explicit HANDLE_LIST", + "handle": 444, + "stack": [], + "type": "File" + }, + { + "access": "0x120196", + "creator": "parent CreatePipe / explicit HANDLE_LIST", + "handle": 452, + "stack": [], + "type": "File" + } + ], + "image": "\\\\?\\D:\\a\\basal\\basal\\target\\release\\ck-basal-worker.exe", + "profile": "windows-latest", + "runner_image": "win25-vs2026", + "runner_version": "20260925.250.1", + "total": 25, + "tracing_class": 32, + "tracing_slots": 16384, + "unresolved": [] +} \ No newline at end of file diff --git a/crates/basal-worker/tests/data/windows-handle-provenance.md b/crates/basal-worker/tests/data/windows-handle-provenance.md new file mode 100644 index 0000000..af7783b --- /dev/null +++ b/crates/basal-worker/tests/data/windows-handle-provenance.md @@ -0,0 +1,157 @@ +# Production Windows handle provenance + +Measured by CI run [38075560568](https://github.com/cortexkit/basal/actions/runs/38075560568). + +Both images used the production release `ck-basal-worker.exe` (no worker +deviation feature). The tool copied its bytes unchanged to a `ckdev-` name, +launched the full confinement, enabled ProcessHandleTracing class 32 with +16,384 slots before resume, and queried the process at its pre-input +checkpoint after all startup checks. User-space symbols below are PDB-resolved, +not nearest-export guesses. Kernel prefixes are retained verbatim and labelled +kernel (unsymbolized). The three pipes were created by the parent and were +the only entries in its explicit inherited-handle list. + +This is a one-off measurement, not the handle allowlist. Live startup checks +continue to use the fixed per-image ceiling constants. An image change +requires remeasurement. Creator chains identify initialization authority; +they are not an exhaustive proof of every operation on the residual objects. + +## windows-latest + +Runner image: `win25-vs2026`; version: `20260925.250.1`. +[Unedited raw report](windows-handle-provenance-windows-latest.json). +Production image SHA-256: `94efcb0cd139c921dd63e12ed5a3fe3a048f149a5a49f3b71e5999615d52620e`. +Accepted ceiling profile: `windows-latest`. Total: **25**. + +| Type | Granted access | Count | Creator chains | +|---|---|---:|---| +| Directory | `0x3` | 1 | C1 | +| Event | `0x1f0003` | 6 | C2 | +| File | `0x120189` | 1 | C3 | +| File | `0x120196` | 2 | C4 | +| IRTimer | `0x100002` | 4 | C5 | +| IoCompletion | `0x1f0003` | 2 | C6 | +| SchedulerSharedData | `0x1` | 1 | C7 | +| TpWorkerFactory | `0xf00ff` | 2 | C8 | +| WaitCompletionPacket | `0x1` | 6 | C9 | + +### C1: Directory `0x3` + +- ×1: `kernel (unsymbolized) @0xfffff804c4fd0359 → kernel (unsymbolized) @0xfffff804c4cb5c55 → ntdll!NtOpenDirectoryObject+0x14 → ntdll!LdrpInitializeProcess+0xd4d → ntdll!LdrpInitialize+0x16c → ntdll!LdrpInitializeInternal+0x5a → ntdll!LdrInitializeThunk+0xe` + +### C2: Event `0x1f0003` + +- ×1: `kernel (unsymbolized) @0xfffff804c4e4bb2b → kernel (unsymbolized) @0xfffff804c4cb5c55 → ntdll!NtCreateEvent+0x14 → ntdll!EtwpRegisterTpNotificationOnce+0x37 → ntdll!EtwpRegisterProvider+0xb4 → ntdll!EtwNotificationRegister+0x161 → ntdll!EtwEventRegister+0x23 → ntdll!TraceLoggingRegisterEx_EtwEventRegister_EtwEventSetInformation+0x5a → ntdll!LdrpInitializeProcess+0xb7d → ntdll!LdrpInitialize+0x16c → ntdll!LdrpInitializeInternal+0x5a → ntdll!LdrInitializeThunk+0xe` +- ×1: `kernel (unsymbolized) @0xfffff804c4e4bb2b → kernel (unsymbolized) @0xfffff804c4cb5c55 → ntdll!NtCreateEvent+0x14 → ntdll!LdrpCreateLoaderEvents+0x23 → ntdll!LdrpInitializeProcess+0x127b → ntdll!LdrpInitialize+0x16c → ntdll!LdrpInitializeInternal+0x5a → ntdll!LdrInitializeThunk+0xe` +- ×1: `kernel (unsymbolized) @0xfffff804c4e4bb2b → kernel (unsymbolized) @0xfffff804c4cb5c55 → ntdll!NtCreateEvent+0x14 → ntdll!LdrpCreateLoaderEvents+0x46 → ntdll!LdrpInitializeProcess+0x127b → ntdll!LdrpInitialize+0x16c → ntdll!LdrpInitializeInternal+0x5a → ntdll!LdrInitializeThunk+0xe` +- ×1: `kernel (unsymbolized) @0xfffff804c4e4bb2b → kernel (unsymbolized) @0xfffff804c4cb5c55 → ntdll!NtCreateEvent+0x14 → ntdll!LdrpInitialize+0xea → ntdll!LdrpInitializeInternal+0x5a → ntdll!LdrInitializeThunk+0xe` +- ×1: `kernel (unsymbolized) @0xfffff804c4e4bb2b → kernel (unsymbolized) @0xfffff804c4cb5c55 → ntdll!NtCreateEvent+0x14 → ntdll!LdrpInitializeInternal+0x95 → ntdll!LdrInitializeThunk+0xe` +- ×1: `kernel (unsymbolized) @0xfffff804c4e4bb2b → kernel (unsymbolized) @0xfffff804c4cb5c55 → ntdll!NtCreateEvent+0x14 → ntdll!RtlpWnfRegisterTpNotification+0x2d → ntdll!RtlpInitializeWnf+0x9d → ntdll!RtlpSubscribeWnfStateChangeNotificationInternal+0x18e → ntdll!LdrpEnableUMGLTracingStateSync+0x7f → ntdll!LdrpInitializeProcess+0xb71 → ntdll!LdrpInitialize+0x16c → ntdll!LdrpInitializeInternal+0x5a → ntdll!LdrInitializeThunk+0xe` + +### C3: File `0x120189` + +- ×1: `parent CreatePipe / explicit HANDLE_LIST` + +### C4: File `0x120196` + +- ×2: `parent CreatePipe / explicit HANDLE_LIST` + +### C5: IRTimer `0x100002` + +- ×1: `kernel (unsymbolized) @0xfffff804c500b047 → kernel (unsymbolized) @0xfffff804c4cb5c55 → ntdll!NtCreateTimer2+0x14 → ntdll!TppInitializeTimerSubQueue+0x52 → ntdll!TppInitializeTimerQueue+0x31 → ntdll!TpAllocPoolInternal+0x393 → ntdll!LdrpEnableParallelLoading+0x49 → ntdll!LdrpInitializeProcess+0x19db → ntdll!LdrpInitialize+0x16c → ntdll!LdrpInitializeInternal+0x5a → ntdll!LdrInitializeThunk+0xe` +- ×1: `kernel (unsymbolized) @0xfffff804c500b047 → kernel (unsymbolized) @0xfffff804c4cb5c55 → ntdll!NtCreateTimer2+0x14 → ntdll!TppInitializeTimerSubQueue+0x52 → ntdll!TppInitializeTimerQueue+0x31 → ntdll!TpAllocPoolInternal+0x393 → ntdll!TppPoolpReferenceGlobalPool+0xc7 → ntdll!TppCleanupGroupMemberInitialize+0x15f → ntdll!TppWorkInitialize+0x31 → ntdll!TpAllocTimer+0xc2 → ntdll!RtlpInitializeWnf+0x59 → ntdll!RtlpSubscribeWnfStateChangeNotificationInternal+0x18e → ntdll!LdrpEnableUMGLTracingStateSync+0x7f → ntdll!LdrpInitializeProcess+0xb71 → ntdll!LdrpInitialize+0x16c → ntdll!LdrpInitializeInternal+0x5a` +- ×1: `kernel (unsymbolized) @0xfffff804c500b047 → kernel (unsymbolized) @0xfffff804c4cb5c55 → ntdll!NtCreateTimer2+0x14 → ntdll!TppInitializeTimerSubQueue+0x52 → ntdll!TppInitializeTimerQueue+0x49 → ntdll!TpAllocPoolInternal+0x393 → ntdll!LdrpEnableParallelLoading+0x49 → ntdll!LdrpInitializeProcess+0x19db → ntdll!LdrpInitialize+0x16c → ntdll!LdrpInitializeInternal+0x5a → ntdll!LdrInitializeThunk+0xe` +- ×1: `kernel (unsymbolized) @0xfffff804c500b047 → kernel (unsymbolized) @0xfffff804c4cb5c55 → ntdll!NtCreateTimer2+0x14 → ntdll!TppInitializeTimerSubQueue+0x52 → ntdll!TppInitializeTimerQueue+0x49 → ntdll!TpAllocPoolInternal+0x393 → ntdll!TppPoolpReferenceGlobalPool+0xc7 → ntdll!TppCleanupGroupMemberInitialize+0x15f → ntdll!TppWorkInitialize+0x31 → ntdll!TpAllocTimer+0xc2 → ntdll!RtlpInitializeWnf+0x59 → ntdll!RtlpSubscribeWnfStateChangeNotificationInternal+0x18e → ntdll!LdrpEnableUMGLTracingStateSync+0x7f → ntdll!LdrpInitializeProcess+0xb71 → ntdll!LdrpInitialize+0x16c → ntdll!LdrpInitializeInternal+0x5a` + +### C6: IoCompletion `0x1f0003` + +- ×1: `kernel (unsymbolized) @0xfffff804c500c28b → kernel (unsymbolized) @0xfffff804c4cb5c55 → ntdll!NtCreateIoCompletion+0x14 → ntdll!TpAllocPoolInternal+0x297 → ntdll!LdrpEnableParallelLoading+0x49 → ntdll!LdrpInitializeProcess+0x19db → ntdll!LdrpInitialize+0x16c → ntdll!LdrpInitializeInternal+0x5a → ntdll!LdrInitializeThunk+0xe` +- ×1: `kernel (unsymbolized) @0xfffff804c500c28b → kernel (unsymbolized) @0xfffff804c4cb5c55 → ntdll!NtCreateIoCompletion+0x14 → ntdll!TpAllocPoolInternal+0x297 → ntdll!TppPoolpReferenceGlobalPool+0xc7 → ntdll!TppCleanupGroupMemberInitialize+0x15f → ntdll!TppWorkInitialize+0x31 → ntdll!TpAllocTimer+0xc2 → ntdll!RtlpInitializeWnf+0x59 → ntdll!RtlpSubscribeWnfStateChangeNotificationInternal+0x18e → ntdll!LdrpEnableUMGLTracingStateSync+0x7f → ntdll!LdrpInitializeProcess+0xb71 → ntdll!LdrpInitialize+0x16c → ntdll!LdrpInitializeInternal+0x5a → ntdll!LdrInitializeThunk+0xe` + +### C7: SchedulerSharedData `0x1` + +- ×1: `kernel (unsymbolized) @0xfffff804c4f4f7c7 → kernel (unsymbolized) @0xfffff804c4f50c0a → kernel (unsymbolized) @0xfffff804c4cb5c55 → ntdll!NtSetInformationProcess+0x14 → ntdll!LdrpAllocateSchedulerSharedData+0x31 → ntdll!LdrpInitializeProcess+0x17c7 → ntdll!LdrpInitialize+0x16c → ntdll!LdrpInitializeInternal+0x5a → ntdll!LdrInitializeThunk+0xe` + +### C8: TpWorkerFactory `0xf00ff` + +- ×1: `kernel (unsymbolized) @0xfffff804c4fa26fd → kernel (unsymbolized) @0xfffff804c4cb5c55 → ntdll!NtCreateWorkerFactory+0x14 → ntdll!TpAllocPoolInternal+0x308 → ntdll!LdrpEnableParallelLoading+0x49 → ntdll!LdrpInitializeProcess+0x19db → ntdll!LdrpInitialize+0x16c → ntdll!LdrpInitializeInternal+0x5a → ntdll!LdrInitializeThunk+0xe` +- ×1: `kernel (unsymbolized) @0xfffff804c4fa26fd → kernel (unsymbolized) @0xfffff804c4cb5c55 → ntdll!NtCreateWorkerFactory+0x14 → ntdll!TpAllocPoolInternal+0x308 → ntdll!TppPoolpReferenceGlobalPool+0xc7 → ntdll!TppCleanupGroupMemberInitialize+0x15f → ntdll!TppWorkInitialize+0x31 → ntdll!TpAllocTimer+0xc2 → ntdll!RtlpInitializeWnf+0x59 → ntdll!RtlpSubscribeWnfStateChangeNotificationInternal+0x18e → ntdll!LdrpEnableUMGLTracingStateSync+0x7f → ntdll!LdrpInitializeProcess+0xb71 → ntdll!LdrpInitialize+0x16c → ntdll!LdrpInitializeInternal+0x5a → ntdll!LdrInitializeThunk+0xe` + +### C9: WaitCompletionPacket `0x1` + +- ×1: `kernel (unsymbolized) @0xfffff804c4e3361e → kernel (unsymbolized) @0xfffff804c4cb5c55 → ntdll!NtCreateWaitCompletionPacket+0x14 → ntdll!TpAllocWait+0xbf → ntdll!EtwpRegisterTpNotificationOnce+0x52 → ntdll!EtwpRegisterProvider+0xb4 → ntdll!EtwNotificationRegister+0x161 → ntdll!EtwEventRegister+0x23 → ntdll!TraceLoggingRegisterEx_EtwEventRegister_EtwEventSetInformation+0x5a → ntdll!LdrpInitializeProcess+0xb7d → ntdll!LdrpInitialize+0x16c → ntdll!LdrpInitializeInternal+0x5a → ntdll!LdrInitializeThunk+0xe` +- ×1: `kernel (unsymbolized) @0xfffff804c4e3361e → kernel (unsymbolized) @0xfffff804c4cb5c55 → ntdll!NtCreateWaitCompletionPacket+0x14 → ntdll!TpAllocWait+0xbf → ntdll!RtlpWnfRegisterTpNotification+0x4c → ntdll!RtlpInitializeWnf+0x9d → ntdll!RtlpSubscribeWnfStateChangeNotificationInternal+0x18e → ntdll!LdrpEnableUMGLTracingStateSync+0x7f → ntdll!LdrpInitializeProcess+0xb71 → ntdll!LdrpInitialize+0x16c → ntdll!LdrpInitializeInternal+0x5a → ntdll!LdrInitializeThunk+0xe` +- ×1: `kernel (unsymbolized) @0xfffff804c4e3361e → kernel (unsymbolized) @0xfffff804c4cb5c55 → ntdll!NtCreateWaitCompletionPacket+0x14 → ntdll!TppInitializeTimerSubQueue+0x6a → ntdll!TppInitializeTimerQueue+0x31 → ntdll!TpAllocPoolInternal+0x393 → ntdll!LdrpEnableParallelLoading+0x49 → ntdll!LdrpInitializeProcess+0x19db → ntdll!LdrpInitialize+0x16c → ntdll!LdrpInitializeInternal+0x5a → ntdll!LdrInitializeThunk+0xe` +- ×1: `kernel (unsymbolized) @0xfffff804c4e3361e → kernel (unsymbolized) @0xfffff804c4cb5c55 → ntdll!NtCreateWaitCompletionPacket+0x14 → ntdll!TppInitializeTimerSubQueue+0x6a → ntdll!TppInitializeTimerQueue+0x31 → ntdll!TpAllocPoolInternal+0x393 → ntdll!TppPoolpReferenceGlobalPool+0xc7 → ntdll!TppCleanupGroupMemberInitialize+0x15f → ntdll!TppWorkInitialize+0x31 → ntdll!TpAllocTimer+0xc2 → ntdll!RtlpInitializeWnf+0x59 → ntdll!RtlpSubscribeWnfStateChangeNotificationInternal+0x18e → ntdll!LdrpEnableUMGLTracingStateSync+0x7f → ntdll!LdrpInitializeProcess+0xb71 → ntdll!LdrpInitialize+0x16c → ntdll!LdrpInitializeInternal+0x5a` +- ×1: `kernel (unsymbolized) @0xfffff804c4e3361e → kernel (unsymbolized) @0xfffff804c4cb5c55 → ntdll!NtCreateWaitCompletionPacket+0x14 → ntdll!TppInitializeTimerSubQueue+0x6a → ntdll!TppInitializeTimerQueue+0x49 → ntdll!TpAllocPoolInternal+0x393 → ntdll!LdrpEnableParallelLoading+0x49 → ntdll!LdrpInitializeProcess+0x19db → ntdll!LdrpInitialize+0x16c → ntdll!LdrpInitializeInternal+0x5a → ntdll!LdrInitializeThunk+0xe` +- ×1: `kernel (unsymbolized) @0xfffff804c4e3361e → kernel (unsymbolized) @0xfffff804c4cb5c55 → ntdll!NtCreateWaitCompletionPacket+0x14 → ntdll!TppInitializeTimerSubQueue+0x6a → ntdll!TppInitializeTimerQueue+0x49 → ntdll!TpAllocPoolInternal+0x393 → ntdll!TppPoolpReferenceGlobalPool+0xc7 → ntdll!TppCleanupGroupMemberInitialize+0x15f → ntdll!TppWorkInitialize+0x31 → ntdll!TpAllocTimer+0xc2 → ntdll!RtlpInitializeWnf+0x59 → ntdll!RtlpSubscribeWnfStateChangeNotificationInternal+0x18e → ntdll!LdrpEnableUMGLTracingStateSync+0x7f → ntdll!LdrpInitializeProcess+0xb71 → ntdll!LdrpInitialize+0x16c → ntdll!LdrpInitializeInternal+0x5a` + +## windows-2022 + +Runner image: `win22`; version: `20261004.326.1`. +[Unedited raw report](windows-handle-provenance-windows-2022.json). +Production image SHA-256: `82661cd814af5f43db545c0a21cd98b4a7b9509058ff6459ca87611ba00d4612`. +Accepted ceiling profile: `windows-2022`. Total: **25**. + +| Type | Granted access | Count | Creator chains | +|---|---|---:|---| +| Directory | `0x3` | 1 | C1 | +| Event | `0x1f0003` | 6 | C2 | +| File | `0x120189` | 1 | C3 | +| File | `0x120196` | 2 | C4 | +| IRTimer | `0x100002` | 4 | C5 | +| IoCompletion | `0x1f0003` | 2 | C6 | +| Semaphore | `0x100003` | 2 | C7 | +| TpWorkerFactory | `0xf00ff` | 2 | C8 | +| WaitCompletionPacket | `0x1` | 5 | C9 | + +### C1: Directory `0x3` + +- ×1: `kernel (unsymbolized) @0xfffff8008115fca2 → kernel (unsymbolized) @0xfffff80080e451e5 → ntdll!NtOpenDirectoryObject+0x14 → ntdll!LdrpInitializeProcess+0xc66 → ntdll!LdrpInitialize+0x16c → ntdll!LdrpInitializeInternal+0xed → ntdll!LdrInitializeThunk+0xe` + +### C2: Event `0x1f0003` + +- ×1: `kernel (unsymbolized) @0xfffff80081109db1 → kernel (unsymbolized) @0xfffff80080e451e5 → ntdll!NtCreateEvent+0x14 → kernelbase!CreateEventW+0x92 → bcrypt!BcpRegisterConfigChangeNotifyNoLogging+0x58 → bcrypt!InitializeSystemPreferredCache+0xe9 → bcrypt!InitializeCNG+0x7a → bcrypt!DllMain+0x87 → ntdll!LdrpCallInitRoutine+0x6b → ntdll!LdrpInitializeNode+0x1ca → ntdll!LdrpInitializeGraphRecurse+0x42 → ntdll!LdrpInitializeGraphRecurse+0xac → ntdll!LdrpInitializeGraphRecurse+0xac → ntdll!LdrpInitializeGraphRecurse+0xac → ntdll!LdrpInitializeProcess+0x1caa → ntdll!LdrpInitialize+0x16c` +- ×1: `kernel (unsymbolized) @0xfffff80081109db1 → kernel (unsymbolized) @0xfffff80080e451e5 → ntdll!NtCreateEvent+0x14 → ntdll!EtwpRegisterTpNotificationOnce+0x2f → ntdll!RtlRunOnceExecuteOnce+0x9a → ntdll!EtwpRegisterProvider+0x78 → ntdll!EtwNotificationRegister+0xd7 → ntdll!EtwEventRegister+0x20 → ntdll!LdrpInitializeProcess+0xa5d → ntdll!LdrpInitialize+0x16c → ntdll!LdrpInitializeInternal+0xed → ntdll!LdrInitializeThunk+0xe` +- ×1: `kernel (unsymbolized) @0xfffff80081109db1 → kernel (unsymbolized) @0xfffff80080e451e5 → ntdll!NtCreateEvent+0x14 → ntdll!LdrpCreateLoaderEvents+0x23 → ntdll!LdrpInitializeProcess+0x11ec → ntdll!LdrpInitialize+0x16c → ntdll!LdrpInitializeInternal+0xed → ntdll!LdrInitializeThunk+0xe` +- ×1: `kernel (unsymbolized) @0xfffff80081109db1 → kernel (unsymbolized) @0xfffff80080e451e5 → ntdll!NtCreateEvent+0x14 → ntdll!LdrpCreateLoaderEvents+0x46 → ntdll!LdrpInitializeProcess+0x11ec → ntdll!LdrpInitialize+0x16c → ntdll!LdrpInitializeInternal+0xed → ntdll!LdrInitializeThunk+0xe` +- ×1: `kernel (unsymbolized) @0xfffff80081109db1 → kernel (unsymbolized) @0xfffff80080e451e5 → ntdll!NtCreateEvent+0x14 → ntdll!LdrpInitialize+0x7a → ntdll!LdrpInitializeInternal+0xed → ntdll!LdrInitializeThunk+0xe` +- ×1: `kernel (unsymbolized) @0xfffff80081109db1 → kernel (unsymbolized) @0xfffff80080e451e5 → ntdll!NtCreateEvent+0x14 → ntdll!LdrpInitializeInternal+0x59 → ntdll!LdrInitializeThunk+0xe` + +### C3: File `0x120189` + +- ×1: `parent CreatePipe / explicit HANDLE_LIST` + +### C4: File `0x120196` + +- ×2: `parent CreatePipe / explicit HANDLE_LIST` + +### C5: IRTimer `0x100002` + +- ×1: `kernel (unsymbolized) @0xfffff8008110232d → kernel (unsymbolized) @0xfffff80080e451e5 → ntdll!NtCreateTimer2+0x14 → ntdll!TppInitializeTimerSubQueue+0x4e → ntdll!TppInitializeTimerQueue+0x31 → ntdll!TpAllocPoolInternal+0x36e → ntdll!LdrpEnableParallelLoading+0x39 → ntdll!LdrpInitializeProcess+0x1b06 → ntdll!LdrpInitialize+0x16c → ntdll!LdrpInitializeInternal+0xed → ntdll!LdrInitializeThunk+0xe` +- ×1: `kernel (unsymbolized) @0xfffff8008110232d → kernel (unsymbolized) @0xfffff80080e451e5 → ntdll!NtCreateTimer2+0x14 → ntdll!TppInitializeTimerSubQueue+0x4e → ntdll!TppInitializeTimerQueue+0x31 → ntdll!TpAllocPoolInternal+0x36e → ntdll!TppPoolpReferenceGlobalPool+0xa8 → ntdll!TppCleanupGroupMemberInitialize+0x218 → ntdll!TppWorkInitialize+0x21 → ntdll!TppInitializeTimer+0x43 → ntdll!TpAllocWait+0xec → ntdll!EtwpRegisterTpNotificationOnce+0x4e → ntdll!RtlRunOnceExecuteOnce+0x9a → ntdll!EtwpRegisterProvider+0x78 → ntdll!EtwNotificationRegister+0xd7 → ntdll!EtwEventRegister+0x20` +- ×1: `kernel (unsymbolized) @0xfffff8008110232d → kernel (unsymbolized) @0xfffff80080e451e5 → ntdll!NtCreateTimer2+0x14 → ntdll!TppInitializeTimerSubQueue+0x4e → ntdll!TppInitializeTimerQueue+0x49 → ntdll!TpAllocPoolInternal+0x36e → ntdll!LdrpEnableParallelLoading+0x39 → ntdll!LdrpInitializeProcess+0x1b06 → ntdll!LdrpInitialize+0x16c → ntdll!LdrpInitializeInternal+0xed → ntdll!LdrInitializeThunk+0xe` +- ×1: `kernel (unsymbolized) @0xfffff8008110232d → kernel (unsymbolized) @0xfffff80080e451e5 → ntdll!NtCreateTimer2+0x14 → ntdll!TppInitializeTimerSubQueue+0x4e → ntdll!TppInitializeTimerQueue+0x49 → ntdll!TpAllocPoolInternal+0x36e → ntdll!TppPoolpReferenceGlobalPool+0xa8 → ntdll!TppCleanupGroupMemberInitialize+0x218 → ntdll!TppWorkInitialize+0x21 → ntdll!TppInitializeTimer+0x43 → ntdll!TpAllocWait+0xec → ntdll!EtwpRegisterTpNotificationOnce+0x4e → ntdll!RtlRunOnceExecuteOnce+0x9a → ntdll!EtwpRegisterProvider+0x78 → ntdll!EtwNotificationRegister+0xd7 → ntdll!EtwEventRegister+0x20` + +### C6: IoCompletion `0x1f0003` + +- ×1: `kernel (unsymbolized) @0xfffff80081174055 → kernel (unsymbolized) @0xfffff80080e451e5 → ntdll!NtCreateIoCompletion+0x14 → ntdll!TpAllocPoolInternal+0x275 → ntdll!LdrpEnableParallelLoading+0x39 → ntdll!LdrpInitializeProcess+0x1b06 → ntdll!LdrpInitialize+0x16c → ntdll!LdrpInitializeInternal+0xed → ntdll!LdrInitializeThunk+0xe` +- ×1: `kernel (unsymbolized) @0xfffff80081174055 → kernel (unsymbolized) @0xfffff80080e451e5 → ntdll!NtCreateIoCompletion+0x14 → ntdll!TpAllocPoolInternal+0x275 → ntdll!TppPoolpReferenceGlobalPool+0xa8 → ntdll!TppCleanupGroupMemberInitialize+0x218 → ntdll!TppWorkInitialize+0x21 → ntdll!TppInitializeTimer+0x43 → ntdll!TpAllocWait+0xec → ntdll!EtwpRegisterTpNotificationOnce+0x4e → ntdll!RtlRunOnceExecuteOnce+0x9a → ntdll!EtwpRegisterProvider+0x78 → ntdll!EtwNotificationRegister+0xd7 → ntdll!EtwEventRegister+0x20 → ntdll!LdrpInitializeProcess+0xa5d → ntdll!LdrpInitialize+0x16c` + +### C7: Semaphore `0x100003` + +- ×1: `kernel (unsymbolized) @0xfffff800810a7875 → kernel (unsymbolized) @0xfffff80080e451e5 → ntdll!NtCreateSemaphore+0x14 → ntdll!RtlInitializeResource+0xbe → bcrypt!InitializeSystemPreferredCache+0x3f → bcrypt!InitializeCNG+0x7a → bcrypt!DllMain+0x87 → ntdll!LdrpCallInitRoutine+0x6b → ntdll!LdrpInitializeNode+0x1ca → ntdll!LdrpInitializeGraphRecurse+0x42 → ntdll!LdrpInitializeGraphRecurse+0xac → ntdll!LdrpInitializeGraphRecurse+0xac → ntdll!LdrpInitializeGraphRecurse+0xac → ntdll!LdrpInitializeProcess+0x1caa → ntdll!LdrpInitialize+0x16c → ntdll!LdrpInitializeInternal+0xed` +- ×1: `kernel (unsymbolized) @0xfffff800810a7875 → kernel (unsymbolized) @0xfffff80080e451e5 → ntdll!NtCreateSemaphore+0x14 → ntdll!RtlInitializeResource+0xed → bcrypt!InitializeSystemPreferredCache+0x3f → bcrypt!InitializeCNG+0x7a → bcrypt!DllMain+0x87 → ntdll!LdrpCallInitRoutine+0x6b → ntdll!LdrpInitializeNode+0x1ca → ntdll!LdrpInitializeGraphRecurse+0x42 → ntdll!LdrpInitializeGraphRecurse+0xac → ntdll!LdrpInitializeGraphRecurse+0xac → ntdll!LdrpInitializeGraphRecurse+0xac → ntdll!LdrpInitializeProcess+0x1caa → ntdll!LdrpInitialize+0x16c → ntdll!LdrpInitializeInternal+0xed` + +### C8: TpWorkerFactory `0xf00ff` + +- ×1: `kernel (unsymbolized) @0xfffff8008110216c → kernel (unsymbolized) @0xfffff80080e451e5 → ntdll!NtCreateWorkerFactory+0x14 → ntdll!TpAllocPoolInternal+0x2e6 → ntdll!LdrpEnableParallelLoading+0x39 → ntdll!LdrpInitializeProcess+0x1b06 → ntdll!LdrpInitialize+0x16c → ntdll!LdrpInitializeInternal+0xed → ntdll!LdrInitializeThunk+0xe` +- ×1: `kernel (unsymbolized) @0xfffff8008110216c → kernel (unsymbolized) @0xfffff80080e451e5 → ntdll!NtCreateWorkerFactory+0x14 → ntdll!TpAllocPoolInternal+0x2e6 → ntdll!TppPoolpReferenceGlobalPool+0xa8 → ntdll!TppCleanupGroupMemberInitialize+0x218 → ntdll!TppWorkInitialize+0x21 → ntdll!TppInitializeTimer+0x43 → ntdll!TpAllocWait+0xec → ntdll!EtwpRegisterTpNotificationOnce+0x4e → ntdll!RtlRunOnceExecuteOnce+0x9a → ntdll!EtwpRegisterProvider+0x78 → ntdll!EtwNotificationRegister+0xd7 → ntdll!EtwEventRegister+0x20 → ntdll!LdrpInitializeProcess+0xa5d → ntdll!LdrpInitialize+0x16c` + +### C9: WaitCompletionPacket `0x1` + +- ×1: `kernel (unsymbolized) @0xfffff800811664d8 → kernel (unsymbolized) @0xfffff80080e451e5 → ntdll!NtCreateWaitCompletionPacket+0x14 → ntdll!TpAllocWait+0xba → ntdll!EtwpRegisterTpNotificationOnce+0x4e → ntdll!RtlRunOnceExecuteOnce+0x9a → ntdll!EtwpRegisterProvider+0x78 → ntdll!EtwNotificationRegister+0xd7 → ntdll!EtwEventRegister+0x20 → ntdll!LdrpInitializeProcess+0xa5d → ntdll!LdrpInitialize+0x16c → ntdll!LdrpInitializeInternal+0xed → ntdll!LdrInitializeThunk+0xe` +- ×1: `kernel (unsymbolized) @0xfffff800811664d8 → kernel (unsymbolized) @0xfffff80080e451e5 → ntdll!NtCreateWaitCompletionPacket+0x14 → ntdll!TppInitializeTimerSubQueue+0x66 → ntdll!TppInitializeTimerQueue+0x31 → ntdll!TpAllocPoolInternal+0x36e → ntdll!LdrpEnableParallelLoading+0x39 → ntdll!LdrpInitializeProcess+0x1b06 → ntdll!LdrpInitialize+0x16c → ntdll!LdrpInitializeInternal+0xed → ntdll!LdrInitializeThunk+0xe` +- ×1: `kernel (unsymbolized) @0xfffff800811664d8 → kernel (unsymbolized) @0xfffff80080e451e5 → ntdll!NtCreateWaitCompletionPacket+0x14 → ntdll!TppInitializeTimerSubQueue+0x66 → ntdll!TppInitializeTimerQueue+0x31 → ntdll!TpAllocPoolInternal+0x36e → ntdll!TppPoolpReferenceGlobalPool+0xa8 → ntdll!TppCleanupGroupMemberInitialize+0x218 → ntdll!TppWorkInitialize+0x21 → ntdll!TppInitializeTimer+0x43 → ntdll!TpAllocWait+0xec → ntdll!EtwpRegisterTpNotificationOnce+0x4e → ntdll!RtlRunOnceExecuteOnce+0x9a → ntdll!EtwpRegisterProvider+0x78 → ntdll!EtwNotificationRegister+0xd7 → ntdll!EtwEventRegister+0x20` +- ×1: `kernel (unsymbolized) @0xfffff800811664d8 → kernel (unsymbolized) @0xfffff80080e451e5 → ntdll!NtCreateWaitCompletionPacket+0x14 → ntdll!TppInitializeTimerSubQueue+0x66 → ntdll!TppInitializeTimerQueue+0x49 → ntdll!TpAllocPoolInternal+0x36e → ntdll!LdrpEnableParallelLoading+0x39 → ntdll!LdrpInitializeProcess+0x1b06 → ntdll!LdrpInitialize+0x16c → ntdll!LdrpInitializeInternal+0xed → ntdll!LdrInitializeThunk+0xe` +- ×1: `kernel (unsymbolized) @0xfffff800811664d8 → kernel (unsymbolized) @0xfffff80080e451e5 → ntdll!NtCreateWaitCompletionPacket+0x14 → ntdll!TppInitializeTimerSubQueue+0x66 → ntdll!TppInitializeTimerQueue+0x49 → ntdll!TpAllocPoolInternal+0x36e → ntdll!TppPoolpReferenceGlobalPool+0xa8 → ntdll!TppCleanupGroupMemberInitialize+0x218 → ntdll!TppWorkInitialize+0x21 → ntdll!TppInitializeTimer+0x43 → ntdll!TpAllocWait+0xec → ntdll!EtwpRegisterTpNotificationOnce+0x4e → ntdll!RtlRunOnceExecuteOnce+0x9a → ntdll!EtwpRegisterProvider+0x78 → ntdll!EtwNotificationRegister+0xd7 → ntdll!EtwEventRegister+0x20` diff --git a/crates/basal-worker/tests/data/windows-imports.txt b/crates/basal-worker/tests/data/windows-imports.txt new file mode 100644 index 0000000..7f13c2b --- /dev/null +++ b/crates/basal-worker/tests/data/windows-imports.txt @@ -0,0 +1,5 @@ +advapi32.dll +api-ms-win-core-synch-l1-2-0.dll +bcryptprimitives.dll +kernel32.dll +ntdll.dll diff --git a/crates/basal-worker/tests/data/windows-mutation-controls.md b/crates/basal-worker/tests/data/windows-mutation-controls.md new file mode 100644 index 0000000..2b57f4e --- /dev/null +++ b/crates/basal-worker/tests/data/windows-mutation-controls.md @@ -0,0 +1,38 @@ +# Windows mutation control + +The source replacement below removes only the worker's final startup handle +allowlist enforcement. The launcher keeps the full token, job and mitigations, +but `Deviation::HandleNotAllowed` omits its inherited-handle list. The parent +plants an inheritable file and asks the worker to write through that handle. + +- **file:** `crates/basal-worker/src/confinement/windows/startup.rs` +- **old:** ` allowlist::check(&table).map_err(|error| Refusal::new(reason::HANDLE_NOT_ALLOWED, error))?;` +- **new:** ` let _ = allowlist::check(&table); // NON-VACUITY BREAK` +- **test that must fail:** `planted_handle_startup_refuses_and_the_allowlist_mutant_writes_the_fixture` +- **command:** `cargo test -p basal-worker --locked --test windows_probes -- planted_handle_startup_refuses_and_the_allowlist_mutant_writes_the_fixture --exact --nocapture` +- **required witness:** the failing assertion reports worker exit 0 and + `fixture bytes [101, 115, 99, 97, 112, 101, 100]` (`escaped`). A compilation + failure, refusal from another layer or crash is not this witness. + +`tests/windows_mutation_witness.py` stages the live file, requires an empty +working diff, applies the replacement, records a non-empty diff and the named +red test, restores with Git checkout and touches the file, records an empty +diff, then requires the restored test to pass. It never changes `mutations.toml`. +The output directory is supplied by the caller and must be outside Cargo's +regenerable build directories. + +## Committed import allowlist + +This control removes a DLL name that the real production image imports from the +fixed committed inventory; it changes neither the image nor the PE parser. + +- **file:** `crates/basal-worker/tests/data/windows-imports.txt` +- **old:** `kernel32.dll\n` +- **new:** `# NON-VACUITY BREAK\n` +- **test that must fail:** `the_worker_is_a_gui_image_without_gui_com_or_c_runtime_imports` +- **command:** `cargo test -p basal-worker --locked --test windows_image -- the_worker_is_a_gui_image_without_gui_com_or_c_runtime_imports --exact --nocapture` +- **required witness:** `kernel32.dll is not in` the committed import inventory. + +The witness script runs this control after the startup allowlist control. Only +its named test runs; other tests in the target are reported as filtered, not as +passing. Both source files are restored before a successful script exit. diff --git a/crates/basal-worker/tests/data/windows-mutation-evidence-windows-2022.json b/crates/basal-worker/tests/data/windows-mutation-evidence-windows-2022.json new file mode 100644 index 0000000..a0fd561 --- /dev/null +++ b/crates/basal-worker/tests/data/windows-mutation-evidence-windows-2022.json @@ -0,0 +1,16 @@ +[ + { + "control": "crates/basal-worker/src/confinement/windows/startup.rs: remove startup allowlist enforcement", + "expected_red": "planted_handle_startup_refuses_and_the_allowlist_mutant_writes_the_fixture", + "captured_output": "test planted_handle_startup_refuses_and_the_allowlist_mutant_writes_the_fixture ... FAILED; 0 passed; 1 failed; other tests filtered; fixture bytes [101, 115, 99, 97, 112, 101, 100]", + "applied_evidence": "crates/basal-worker/src/confinement/windows/startup.rs; during: crates/basal-worker/src/confinement/windows/startup.rs | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-); after: (empty)", + "outcome": "reddened" + }, + { + "control": "crates/basal-worker/tests/data/windows-imports.txt: remove the real kernel32 import from the committed inventory", + "expected_red": "the_worker_is_a_gui_image_without_gui_com_or_c_runtime_imports", + "captured_output": "test the_worker_is_a_gui_image_without_gui_com_or_c_runtime_imports ... FAILED; 0 passed; 1 failed; other tests filtered; kernel32.dll is not in", + "applied_evidence": "crates/basal-worker/tests/data/windows-imports.txt; during: crates/basal-worker/tests/data/windows-imports.txt | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-); after: (empty)", + "outcome": "reddened" + } +] \ No newline at end of file diff --git a/crates/basal-worker/tests/data/windows-mutation-evidence-windows-latest.json b/crates/basal-worker/tests/data/windows-mutation-evidence-windows-latest.json new file mode 100644 index 0000000..a0fd561 --- /dev/null +++ b/crates/basal-worker/tests/data/windows-mutation-evidence-windows-latest.json @@ -0,0 +1,16 @@ +[ + { + "control": "crates/basal-worker/src/confinement/windows/startup.rs: remove startup allowlist enforcement", + "expected_red": "planted_handle_startup_refuses_and_the_allowlist_mutant_writes_the_fixture", + "captured_output": "test planted_handle_startup_refuses_and_the_allowlist_mutant_writes_the_fixture ... FAILED; 0 passed; 1 failed; other tests filtered; fixture bytes [101, 115, 99, 97, 112, 101, 100]", + "applied_evidence": "crates/basal-worker/src/confinement/windows/startup.rs; during: crates/basal-worker/src/confinement/windows/startup.rs | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-); after: (empty)", + "outcome": "reddened" + }, + { + "control": "crates/basal-worker/tests/data/windows-imports.txt: remove the real kernel32 import from the committed inventory", + "expected_red": "the_worker_is_a_gui_image_without_gui_com_or_c_runtime_imports", + "captured_output": "test the_worker_is_a_gui_image_without_gui_com_or_c_runtime_imports ... FAILED; 0 passed; 1 failed; other tests filtered; kernel32.dll is not in", + "applied_evidence": "crates/basal-worker/tests/data/windows-imports.txt; during: crates/basal-worker/tests/data/windows-imports.txt | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-); after: (empty)", + "outcome": "reddened" + } +] \ No newline at end of file diff --git a/crates/basal-worker/tests/data/windows-verification.md b/crates/basal-worker/tests/data/windows-verification.md new file mode 100644 index 0000000..1be0173 --- /dev/null +++ b/crates/basal-worker/tests/data/windows-verification.md @@ -0,0 +1,284 @@ +# Windows confinement verification + +CI run [38075560568](https://github.com/cortexkit/basal/actions/runs/38075560568), +source commit `794a02070702c8230559ef3c3610412cb7ec1f7f`. + +Tool versions from the REST job logs: `rustc 1.99.0 (b940084d7 2026-09-28)`; +`cargo 1.99.0 (5f94df478 2026-08-27)`. + +The excerpts below come from the uploaded Windows baseline artifacts, not +from the continue-on-error job conclusions. Default builds passed 77 worker +tests and deviations builds passed 79 per image. Each includes 14 new probe +tests, with unchanged production startup checks. + +## windows-latest + +Artifact ID: `11678725643`. + +### Production worker + +```text +test a_real_named_pipe_is_denied_with_plain_control ... ok +test create_thread_succeeds_inside_full_confinement ... ok +test excluded_inheritable_handle_is_fatal_only_after_readiness_and_plain_dropped_list_writes ... ok +test child_creation_and_parent_process_opens_are_denied_with_plain_controls ... ok +test executable_allocation_and_rw_to_x_are_denied_with_lpac_controls ... ok +test fatal_exit_before_readiness_is_not_a_denial ... ok +test forced_pool_work_wait_timer_and_legacy_work_complete ... ok +test native_package_hive_write_is_denied_with_lpac_control ... ok +test native_winsock_tcp_udp_and_loopback_are_denied_and_parent_observes_controls ... ok +test new_alpc_epmapper_and_package_section_are_denied_with_lpac_controls ... ok +test package_folder_create_is_denied_with_lpac_control ... ok +test planted_handle_startup_refuses_and_the_allowlist_mutant_writes_the_fixture ... ok +test system_image_data_read_and_stat_are_denied_with_lpac_controls ... ok +test thread_barrier_checks_every_thread_before_and_during_held_pool_callbacks ... ok +windows-pool-complete:work,wait,timer,legacy +tcp: WSAStartup 10107, socket 10093, no parent traffic; plain traffic observed +loopback: WSAStartup 10107, socket 10093, no parent traffic; plain traffic observed +udp: WSAStartup 10107, socket 10093, no parent traffic; plain traffic observed +before-pool: TID 2480 open_as_self=0 0xc000007c +before-pool: TID 2480 open_as_self=1 0xc000007c +before-pool: TID 3984 open_as_self=0 0xc000007c +before-pool: TID 3984 open_as_self=1 0xc000007c +before-pool: TID 10044 open_as_self=0 0xc000007c +before-pool: TID 10044 open_as_self=1 0xc000007c +before-pool: SystemProcessInformation == Toolhelp (3 threads) +callbacks-held: TID 976 open_as_self=0 0xc000007c +callbacks-held: TID 976 open_as_self=1 0xc000007c +callbacks-held: TID 2480 open_as_self=0 0xc000007c +callbacks-held: TID 2480 open_as_self=1 0xc000007c +callbacks-held: TID 3984 open_as_self=0 0xc000007c +callbacks-held: TID 3984 open_as_self=1 0xc000007c +callbacks-held: TID 5564 open_as_self=0 0xc000007c +callbacks-held: TID 5564 open_as_self=1 0xc000007c +callbacks-held: TID 6220 open_as_self=0 0xc000007c +callbacks-held: TID 6220 open_as_self=1 0xc000007c +callbacks-held: TID 6504 open_as_self=0 0xc000007c +callbacks-held: TID 6504 open_as_self=1 0xc000007c +callbacks-held: TID 10044 open_as_self=0 0xc000007c +callbacks-held: TID 10044 open_as_self=1 0xc000007c +callbacks-held: SystemProcessInformation == Toolhelp (7 threads) +every callback TID is in the snapshot: {"legacy": 976, "timer": 6504, "wait": 5564, "work": 6220} +test result: ok. 14 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.96s +``` + +### Worker with deviations + +```text +test a_real_named_pipe_is_denied_with_plain_control ... ok +test child_creation_and_parent_process_opens_are_denied_with_plain_controls ... ok +test excluded_inheritable_handle_is_fatal_only_after_readiness_and_plain_dropped_list_writes ... ok +test create_thread_succeeds_inside_full_confinement ... ok +test executable_allocation_and_rw_to_x_are_denied_with_lpac_controls ... ok +test fatal_exit_before_readiness_is_not_a_denial ... ok +test forced_pool_work_wait_timer_and_legacy_work_complete ... ok +test native_package_hive_write_is_denied_with_lpac_control ... ok +test native_winsock_tcp_udp_and_loopback_are_denied_and_parent_observes_controls ... ok +test new_alpc_epmapper_and_package_section_are_denied_with_lpac_controls ... ok +test package_folder_create_is_denied_with_lpac_control ... ok +test planted_handle_startup_refuses_and_the_allowlist_mutant_writes_the_fixture ... ok +test system_image_data_read_and_stat_are_denied_with_lpac_controls ... ok +test thread_barrier_checks_every_thread_before_and_during_held_pool_callbacks ... ok +windows-pool-complete:work,wait,timer,legacy +tcp: WSAStartup 10107, socket 10093, no parent traffic; plain traffic observed +loopback: WSAStartup 10107, socket 10093, no parent traffic; plain traffic observed +udp: WSAStartup 10107, socket 10093, no parent traffic; plain traffic observed +before-pool: TID 6940 open_as_self=0 0xc000007c +before-pool: TID 6940 open_as_self=1 0xc000007c +before-pool: TID 7612 open_as_self=0 0xc000007c +before-pool: TID 7612 open_as_self=1 0xc000007c +before-pool: TID 8524 open_as_self=0 0xc000007c +before-pool: TID 8524 open_as_self=1 0xc000007c +before-pool: TID 8612 open_as_self=0 0xc000007c +before-pool: TID 8612 open_as_self=1 0xc000007c +before-pool: SystemProcessInformation == Toolhelp (4 threads) +callbacks-held: TID 3116 open_as_self=0 0xc000007c +callbacks-held: TID 3116 open_as_self=1 0xc000007c +callbacks-held: TID 5208 open_as_self=0 0xc000007c +callbacks-held: TID 5208 open_as_self=1 0xc000007c +callbacks-held: TID 6940 open_as_self=0 0xc000007c +callbacks-held: TID 6940 open_as_self=1 0xc000007c +callbacks-held: TID 7612 open_as_self=0 0xc000007c +callbacks-held: TID 7612 open_as_self=1 0xc000007c +callbacks-held: TID 8264 open_as_self=0 0xc000007c +callbacks-held: TID 8264 open_as_self=1 0xc000007c +callbacks-held: TID 8524 open_as_self=0 0xc000007c +callbacks-held: TID 8524 open_as_self=1 0xc000007c +callbacks-held: TID 8612 open_as_self=0 0xc000007c +callbacks-held: TID 8612 open_as_self=1 0xc000007c +callbacks-held: TID 10108 open_as_self=0 0xc000007c +callbacks-held: TID 10108 open_as_self=1 0xc000007c +callbacks-held: SystemProcessInformation == Toolhelp (8 threads) +every callback TID is in the snapshot: {"legacy": 10108, "timer": 5208, "wait": 3116, "work": 8264} +test result: ok. 14 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.96s +``` + +### Production image and mutation witnesses + +```text +production inventory "windows-latest": 25 handles; saved D:\a\_temp/windows-baseline/windows-handle-provenance.json +subsystem: 2 +imports: advapi32.dll, api-ms-win-core-synch-l1-2-0.dll, bcryptprimitives.dll, kernel32.dll, ntdll.dll +delay imports: [] +``` + +The inventory success line is emitted only after the traced checkpoint child +exits 0. Diagnostic handle tracing was disabled after snapshot; permanent +strict-handle checks remained enabled. The production image has no static +Winsock import. Only socket probes preload Winsock before their unchanged +startup checks; they attempt startup and sockets after attestation/readiness. + +Unedited mutation records: [JSON](windows-mutation-evidence-windows-latest.json). + +- Startup allowlist removed: only + `planted_handle_startup_refuses_and_the_allowlist_mutant_writes_the_fixture` + fails (13 tests filtered). It reports exit 0 instead of 70 and the parent + fixture contains `[101, 115, 99, 97, 112, 101, 100]` (`escaped`). +- Kernel32 removed from the committed import inventory: only + `the_worker_is_a_gui_image_without_gui_com_or_c_runtime_imports` fails + (the C-runtime-name test is filtered). The assertion says + `kernel32.dll is not in` the committed inventory. +- Each mutation records a non-empty applied Git diff and an empty restored + diff; the restored named test passes. No other tests ran in the red checks. + +## windows-2022 + +Artifact ID: `11678860399`. + +### Production worker + +```text +test a_real_named_pipe_is_denied_with_plain_control ... ok +test create_thread_succeeds_inside_full_confinement ... ok +test child_creation_and_parent_process_opens_are_denied_with_plain_controls ... ok +test excluded_inheritable_handle_is_fatal_only_after_readiness_and_plain_dropped_list_writes ... ok +test executable_allocation_and_rw_to_x_are_denied_with_lpac_controls ... ok +test fatal_exit_before_readiness_is_not_a_denial ... ok +test forced_pool_work_wait_timer_and_legacy_work_complete ... ok +test native_package_hive_write_is_denied_with_lpac_control ... ok +test native_winsock_tcp_udp_and_loopback_are_denied_and_parent_observes_controls ... ok +test new_alpc_epmapper_and_package_section_are_denied_with_lpac_controls ... ok +test package_folder_create_is_denied_with_lpac_control ... ok +test planted_handle_startup_refuses_and_the_allowlist_mutant_writes_the_fixture ... ok +test system_image_data_read_and_stat_are_denied_with_lpac_controls ... ok +test thread_barrier_checks_every_thread_before_and_during_held_pool_callbacks ... ok +windows-pool-complete:work,wait,timer,legacy +tcp: WSAStartup 10107, socket 10093, no parent traffic; plain traffic observed +loopback: WSAStartup 10107, socket 10093, no parent traffic; plain traffic observed +udp: WSAStartup 10107, socket 10093, no parent traffic; plain traffic observed +before-pool: TID 3096 open_as_self=0 0xc000007c +before-pool: TID 3096 open_as_self=1 0xc000007c +before-pool: TID 3968 open_as_self=0 0xc000007c +before-pool: TID 3968 open_as_self=1 0xc000007c +before-pool: TID 5384 open_as_self=0 0xc000007c +before-pool: TID 5384 open_as_self=1 0xc000007c +before-pool: TID 7188 open_as_self=0 0xc000007c +before-pool: TID 7188 open_as_self=1 0xc000007c +before-pool: SystemProcessInformation == Toolhelp (4 threads) +callbacks-held: TID 2024 open_as_self=0 0xc000007c +callbacks-held: TID 2024 open_as_self=1 0xc000007c +callbacks-held: TID 2088 open_as_self=0 0xc000007c +callbacks-held: TID 2088 open_as_self=1 0xc000007c +callbacks-held: TID 3096 open_as_self=0 0xc000007c +callbacks-held: TID 3096 open_as_self=1 0xc000007c +callbacks-held: TID 3968 open_as_self=0 0xc000007c +callbacks-held: TID 3968 open_as_self=1 0xc000007c +callbacks-held: TID 5384 open_as_self=0 0xc000007c +callbacks-held: TID 5384 open_as_self=1 0xc000007c +callbacks-held: TID 6112 open_as_self=0 0xc000007c +callbacks-held: TID 6112 open_as_self=1 0xc000007c +callbacks-held: TID 7188 open_as_self=0 0xc000007c +callbacks-held: TID 7188 open_as_self=1 0xc000007c +callbacks-held: TID 7976 open_as_self=0 0xc000007c +callbacks-held: TID 7976 open_as_self=1 0xc000007c +callbacks-held: SystemProcessInformation == Toolhelp (8 threads) +every callback TID is in the snapshot: {"legacy": 7976, "timer": 2024, "wait": 2088, "work": 6112} +test result: ok. 14 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.78s +``` + +### Worker with deviations + +```text +test child_creation_and_parent_process_opens_are_denied_with_plain_controls ... ok +test a_real_named_pipe_is_denied_with_plain_control ... ok +test create_thread_succeeds_inside_full_confinement ... ok +test excluded_inheritable_handle_is_fatal_only_after_readiness_and_plain_dropped_list_writes ... ok +test executable_allocation_and_rw_to_x_are_denied_with_lpac_controls ... ok +test fatal_exit_before_readiness_is_not_a_denial ... ok +test forced_pool_work_wait_timer_and_legacy_work_complete ... ok +test native_package_hive_write_is_denied_with_lpac_control ... ok +test native_winsock_tcp_udp_and_loopback_are_denied_and_parent_observes_controls ... ok +test new_alpc_epmapper_and_package_section_are_denied_with_lpac_controls ... ok +test package_folder_create_is_denied_with_lpac_control ... ok +test planted_handle_startup_refuses_and_the_allowlist_mutant_writes_the_fixture ... ok +test system_image_data_read_and_stat_are_denied_with_lpac_controls ... ok +test thread_barrier_checks_every_thread_before_and_during_held_pool_callbacks ... ok +windows-pool-complete:work,wait,timer,legacy +tcp: WSAStartup 10107, socket 10093, no parent traffic; plain traffic observed +loopback: WSAStartup 10107, socket 10093, no parent traffic; plain traffic observed +udp: WSAStartup 10107, socket 10093, no parent traffic; plain traffic observed +before-pool: TID 5152 open_as_self=0 0xc000007c +before-pool: TID 5152 open_as_self=1 0xc000007c +before-pool: TID 5608 open_as_self=0 0xc000007c +before-pool: TID 5608 open_as_self=1 0xc000007c +before-pool: TID 5988 open_as_self=0 0xc000007c +before-pool: TID 5988 open_as_self=1 0xc000007c +before-pool: TID 7496 open_as_self=0 0xc000007c +before-pool: TID 7496 open_as_self=1 0xc000007c +before-pool: SystemProcessInformation == Toolhelp (4 threads) +callbacks-held: TID 2244 open_as_self=0 0xc000007c +callbacks-held: TID 2244 open_as_self=1 0xc000007c +callbacks-held: TID 2480 open_as_self=0 0xc000007c +callbacks-held: TID 2480 open_as_self=1 0xc000007c +callbacks-held: TID 5152 open_as_self=0 0xc000007c +callbacks-held: TID 5152 open_as_self=1 0xc000007c +callbacks-held: TID 5608 open_as_self=0 0xc000007c +callbacks-held: TID 5608 open_as_self=1 0xc000007c +callbacks-held: TID 5988 open_as_self=0 0xc000007c +callbacks-held: TID 5988 open_as_self=1 0xc000007c +callbacks-held: TID 6324 open_as_self=0 0xc000007c +callbacks-held: TID 6324 open_as_self=1 0xc000007c +callbacks-held: TID 7496 open_as_self=0 0xc000007c +callbacks-held: TID 7496 open_as_self=1 0xc000007c +callbacks-held: TID 7780 open_as_self=0 0xc000007c +callbacks-held: TID 7780 open_as_self=1 0xc000007c +callbacks-held: SystemProcessInformation == Toolhelp (8 threads) +every callback TID is in the snapshot: {"legacy": 6324, "timer": 7780, "wait": 2244, "work": 2480} +test result: ok. 14 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.77s +``` + +### Production image and mutation witnesses + +```text +production inventory "windows-2022": 25 handles; saved D:\a\_temp/windows-baseline/windows-handle-provenance.json +subsystem: 2 +imports: advapi32.dll, api-ms-win-core-synch-l1-2-0.dll, bcryptprimitives.dll, kernel32.dll, ntdll.dll +delay imports: [] +``` + +The inventory success line is emitted only after the traced checkpoint child +exits 0. Diagnostic handle tracing was disabled after snapshot; permanent +strict-handle checks remained enabled. The production image has no static +Winsock import. Only socket probes preload Winsock before their unchanged +startup checks; they attempt startup and sockets after attestation/readiness. + +Unedited mutation records: [JSON](windows-mutation-evidence-windows-2022.json). + +- Startup allowlist removed: only + `planted_handle_startup_refuses_and_the_allowlist_mutant_writes_the_fixture` + fails (13 tests filtered). It reports exit 0 instead of 70 and the parent + fixture contains `[101, 115, 99, 97, 112, 101, 100]` (`escaped`). +- Kernel32 removed from the committed import inventory: only + `the_worker_is_a_gui_image_without_gui_com_or_c_runtime_imports` fails + (the C-runtime-name test is filtered). The assertion says + `kernel32.dll is not in` the committed inventory. +- Each mutation records a non-empty applied Git diff and an empty restored + diff; the restored named test passes. No other tests ran in the red checks. + +## Unrelated workspace baseline failure + +Both Windows workspace check/test commands still fail in +`crates/basal-core/src/retention.rs`: Unix `OsStrExt` / `OsStringExt` APIs +(`as_bytes`, `from_vec`, and `std::os::unix`) do not compile on Windows. +The package-scoped worker and launcher gates above are green. No file in +basal-core was changed by these worker probes. diff --git a/crates/basal-worker/tests/inherited_descriptors.rs b/crates/basal-worker/tests/inherited_descriptors.rs index a34d6cf..c86f7e8 100644 --- a/crates/basal-worker/tests/inherited_descriptors.rs +++ b/crates/basal-worker/tests/inherited_descriptors.rs @@ -1,4 +1,5 @@ //! The real worker must discard inherited authority even in probe mode. +#![cfg(unix)] mod common; diff --git a/crates/basal-worker/tests/ipc.rs b/crates/basal-worker/tests/ipc.rs index 3ea67c9..ed86ef1 100644 --- a/crates/basal-worker/tests/ipc.rs +++ b/crates/basal-worker/tests/ipc.rs @@ -85,6 +85,17 @@ fn handshake_reports_version_engine_and_confinement() { assert_eq!(welcome.protocol_version, PROTOCOL_VERSION); #[cfg(target_os = "macos")] assert_eq!(welcome.confinement, Confinement::Seatbelt); + #[cfg(windows)] + assert_eq!( + welcome.confinement, + Confinement::Windows { + lpac: true, + untrusted: true, + no_thread_token: true, + mitigations: true, + handle_table: true, + } + ); #[cfg(target_os = "linux")] assert!(matches!( welcome.confinement, diff --git a/crates/basal-worker/tests/sandbox_integrity.rs b/crates/basal-worker/tests/sandbox_integrity.rs index 4870a2b..2cb6173 100644 --- a/crates/basal-worker/tests/sandbox_integrity.rs +++ b/crates/basal-worker/tests/sandbox_integrity.rs @@ -1,4 +1,5 @@ //! Adversarial scripts at the sandbox's function and exception boundaries. + mod common; use basal_proto::{ActivationResult, Failure}; diff --git a/crates/basal-worker/tests/sandbox_page.rs b/crates/basal-worker/tests/sandbox_page.rs index 8a07472..5f5540a 100644 --- a/crates/basal-worker/tests/sandbox_page.rs +++ b/crates/basal-worker/tests/sandbox_page.rs @@ -96,9 +96,61 @@ fn a_caught_memory_or_stack_failure_does_not_retire_the_worker() { } #[test] -fn windows_section_says_confinement_is_not_provided_yet() { - assert_mentions( - §ion("Windows"), - "Windows confinement is not provided yet", - ); +fn windows_section_states_the_layers_residual_and_limits() { + let windows = section("Windows"); + for topic in [ + "LPAC", + "Untrusted", + "zero capabilities", + "deny-only", + "Before resume", + "committed memory", + "handle list", + "all five confinement fields true", + "26 handles", + "windows-latest", + "windows-2022", + "**25**", + "windows-handle-provenance.md", + "one image profile", + "self-escalation", + "File stdin", + "File stdout/stderr", + "Directory", + "Event", + "IoCompletion", + "TpWorkerFactory", + "IRTimer", + "WaitCompletionPacket", + "Semaphore", + "SchedulerSharedData", + "0x000f0001", + "private, unnamed", + "AFD", + "KnownDlls-relative section opens", + "Native API probing", + "weaker token", + "profile", + "scheduler-tick resolution", + "wall deadline", + "idle retirement", + "loaded user profile", + "install-directory", + "AppContainer SID", + "SUBC", + "ck setup", + "ck upgrade", + ] { + assert_mentions(&windows, topic); + } + assert!(!windows.contains("not provided yet")); + let differences = section("Differences between the systems"); + for topic in [ + "| Windows |", + "allowed inside the worker", + "mounts and in-root links not traversed", + "fatal rather than an error", + ] { + assert_mentions(&differences, topic); + } } diff --git a/crates/basal-worker/tests/windows_common/fixtures.rs b/crates/basal-worker/tests/windows_common/fixtures.rs new file mode 100644 index 0000000..dff6c16 --- /dev/null +++ b/crates/basal-worker/tests/windows_common/fixtures.rs @@ -0,0 +1,341 @@ +//! The test harness retains the package namespace, registry key, named section +//! and pipe. Workers must open them afresh; none of those handles is inherited. +//! A separate file is deliberately made inheritable to test the handle-list +//! boundary. Weaker launcher controls prove that the same fixtures can be opened. +#![allow(unsafe_op_in_unsafe_fn)] +use super::windows_common::wide; +use super::{Handle, Probe}; +use basal_launch::{Deviation, PROFILE_NAME, create_or_open_profile}; +use std::ffi::c_void; +use std::fs::{File, OpenOptions}; +use std::mem::{size_of, transmute, zeroed}; +use std::os::windows::io::AsRawHandle; +use std::path::PathBuf; +use std::ptr::{null, null_mut}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use windows_sys::Win32::Foundation::*; +use windows_sys::Win32::Security::*; +use windows_sys::Win32::Storage::FileSystem::*; +use windows_sys::Win32::System::LibraryLoader::*; +use windows_sys::Win32::System::Pipes::*; +use windows_sys::Win32::System::Registry::*; +use windows_sys::Win32::System::Threading::*; + +#[repr(C)] +struct UnicodeString { + length: u16, + maximum_length: u16, + buffer: *mut u16, +} +#[repr(C)] +struct ObjectAttributes { + length: u32, + root: HANDLE, + name: *mut UnicodeString, + attributes: u32, + descriptor: *mut c_void, + qos: *mut c_void, +} +#[link(name = "ntdll")] +unsafe extern "system" { + fn NtQueryKey( + key: HANDLE, + class: u32, + buffer: *mut c_void, + bytes: u32, + returned: *mut u32, + ) -> i32; + fn NtCreateSection( + out: *mut HANDLE, + access: u32, + attrs: *mut ObjectAttributes, + size: *mut i64, + protection: u32, + allocation: u32, + file: HANDLE, + ) -> i32; +} +#[link(name = "kernel32")] +unsafe extern "system" { + fn ProcessIdToSessionId(pid: u32, session: *mut u32) -> i32; + fn GetAppContainerNamedObjectPath( + token: HANDLE, + sid: PSID, + bytes: u32, + path: *mut u16, + returned: *mut u32, + ) -> i32; +} +static SERIAL: AtomicUsize = AtomicUsize::new(0); +fn unique() -> String { + format!( + "ckdev-confinement-{}-{}", + std::process::id(), + SERIAL.fetch_add(1, Ordering::Relaxed) + ) +} + +struct Library(HMODULE); +impl Library { + unsafe fn load(name: &str) -> Self { + let module = LoadLibraryExW( + wide(name).as_ptr(), + null_mut(), + LOAD_LIBRARY_SEARCH_SYSTEM32, + ); + assert!(!module.is_null(), "{name}: {}", GetLastError()); + Self(module) + } +} +impl Drop for Library { + fn drop(&mut self) { + unsafe { FreeLibrary(self.0) }; + } +} +struct Revert; +impl Drop for Revert { + fn drop(&mut self) { + assert_ne!(unsafe { RevertToSelf() }, 0); + } +} + +pub struct Package { + _holder: Probe, + folder: String, + pub hive: String, + namespace: String, + key: HKEY, +} +impl Package { + pub fn new() -> Self { + let mut holder = Probe::start(Deviation::LpacOnly, "checkpoint", "", 0); + holder.marker("checkpoint"); + unsafe { + let api = Library::load("userenv.dll"); + let ole = Library::load("ole32.dll"); + let folder_api: unsafe extern "system" fn(*const u16, *mut *mut u16) -> i32 = transmute( + GetProcAddress(api.0, c"GetAppContainerFolderPath".as_ptr().cast()).unwrap(), + ); + let free: unsafe extern "system" fn(*const c_void) = + transmute(GetProcAddress(ole.0, c"CoTaskMemFree".as_ptr().cast()).unwrap()); + let registry: unsafe extern "system" fn(u32, *mut HKEY) -> i32 = transmute( + GetProcAddress(api.0, c"GetAppContainerRegistryLocation".as_ptr().cast()).unwrap(), + ); + let sid = create_or_open_profile().unwrap(); + let mut folder = null_mut(); + assert_eq!(folder_api(wide(sid.as_str()).as_ptr(), &mut folder), 0); + let mut length = 0; + while *folder.add(length) != 0 { + length += 1; + } + let folder_text = String::from_utf16_lossy(std::slice::from_raw_parts(folder, length)); + free(folder.cast()); + let mut token = null_mut(); + assert_ne!( + OpenProcessToken( + holder.child.as_raw_handle(), + TOKEN_QUERY | TOKEN_DUPLICATE, + &mut token + ), + 0 + ); + let token = Handle(token); + let mut imp = null_mut(); + assert_ne!( + DuplicateTokenEx( + token.0, + TOKEN_QUERY | TOKEN_IMPERSONATE, + null(), + SecurityImpersonation, + TokenImpersonation, + &mut imp + ), + 0 + ); + let imp = Handle(imp); + assert_ne!(SetThreadToken(null(), imp.0), 0); + let revert = Revert; + let mut key = null_mut(); + assert_eq!( + registry(KEY_QUERY_VALUE | KEY_SET_VALUE, &mut key), + 0, + "resolve the real profile storage key" + ); + let mut buffer = vec![0u32; 4096]; + let mut returned = 0; + assert_eq!( + NtQueryKey( + key, + 3, + buffer.as_mut_ptr().cast(), + (buffer.len() * 4) as u32, + &mut returned + ), + 0 + ); + let bytes = buffer[0] as usize; + assert!(bytes.is_multiple_of(2) && bytes + 4 <= returned as usize); + let hive = String::from_utf16_lossy(std::slice::from_raw_parts( + buffer.as_ptr().add(1).cast::(), + bytes / 2, + )); + assert!( + hive.ends_with(PROFILE_NAME), + "storage key is keyed by profile name: {hive}" + ); + drop(revert); + let mut path = vec![0u16; 2048]; + let mut returned = 0; + assert_ne!( + GetAppContainerNamedObjectPath( + token.0, + null_mut(), + path.len() as u32, + path.as_mut_ptr(), + &mut returned + ), + 0, + "package namespace: {}", + GetLastError() + ); + let length = path.iter().position(|c| *c == 0).unwrap(); + let relative = String::from_utf16_lossy(&path[..length]); + let mut session = 0; + assert_ne!(ProcessIdToSessionId(std::process::id(), &mut session), 0); + let namespace = if relative.starts_with('\\') { + relative + } else { + format!("\\Sessions\\{session}\\{relative}") + }; + Self { + _holder: holder, + folder: folder_text, + hive, + namespace, + key, + } + } + } + pub fn create_path(&self) -> String { + format!("{}\\{}", self.folder, unique()) + } + pub fn remove_value(&self) { + assert_eq!( + unsafe { RegDeleteValueW(self.key, wide("ckdev-confinement-fixture").as_ptr()) }, + 0 + ); + } + pub fn section(&self) -> (Handle, String) { + unsafe { + let mut sd: SECURITY_DESCRIPTOR = zeroed(); + assert_ne!( + InitializeSecurityDescriptor((&mut sd as *mut SECURITY_DESCRIPTOR).cast(), 1), + 0 + ); + // A NULL DACL makes the successful LPAC open an authority witness, not + // an accidental denial caused by a fixture's discretionary permissions. + assert_ne!( + SetSecurityDescriptorDacl( + (&mut sd as *mut SECURITY_DESCRIPTOR).cast(), + 1, + null(), + 0 + ), + 0 + ); + let path = format!("{}\\{}", self.namespace, unique()); + let mut text = wide(&path); + let mut name = UnicodeString { + length: ((text.len() - 1) * 2) as u16, + maximum_length: (text.len() * 2) as u16, + buffer: text.as_mut_ptr(), + }; + let mut attrs = ObjectAttributes { + length: size_of::() as u32, + root: null_mut(), + name: &mut name, + attributes: 0x40, + descriptor: (&mut sd as *mut SECURITY_DESCRIPTOR).cast(), + qos: null_mut(), + }; + let mut section = null_mut(); + let mut size = 4096; + assert_eq!( + NtCreateSection( + &mut section, + 0xf001f, + &mut attrs, + &mut size, + 4, + 0x8000000, + null_mut() + ), + 0 + ); + (Handle(section), path) + } + } +} +impl Drop for Package { + fn drop(&mut self) { + unsafe { + RegDeleteValueW(self.key, wide("ckdev-confinement-fixture").as_ptr()); + RegCloseKey(self.key); + } + } +} + +pub fn pipe() -> (Handle, String) { + let path = format!("\\\\.\\pipe\\{}", unique()); + let handle = unsafe { + CreateNamedPipeW( + wide(&path).as_ptr(), + PIPE_ACCESS_OUTBOUND, + PIPE_TYPE_BYTE | PIPE_WAIT, + 1, + 4096, + 4096, + 0, + null(), + ) + }; + assert_ne!(handle, INVALID_HANDLE_VALUE, "named pipe: {}", unsafe { + GetLastError() + }); + (Handle(handle), path) +} + +pub struct Planted { + pub file: File, + path: PathBuf, +} +impl Planted { + pub fn new() -> Self { + let path = std::env::temp_dir().join(unique()); + let file = OpenOptions::new() + .create_new(true) + .read(true) + .write(true) + .open(&path) + .unwrap(); + assert_ne!( + unsafe { + SetHandleInformation( + file.as_raw_handle(), + HANDLE_FLAG_INHERIT, + HANDLE_FLAG_INHERIT, + ) + }, + 0 + ); + Self { file, path } + } + pub fn bytes(&self) -> Vec { + std::fs::read(&self.path).unwrap() + } +} +impl Drop for Planted { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } +} diff --git a/crates/basal-worker/tests/windows_common/mod.rs b/crates/basal-worker/tests/windows_common/mod.rs new file mode 100644 index 0000000..ad07ff8 --- /dev/null +++ b/crates/basal-worker/tests/windows_common/mod.rs @@ -0,0 +1,62 @@ +//! Shared by the Windows worker tests. +//! +//! Test processes never run under a production executable name (`ck-*`), so +//! a test process can't be mistaken for the placed production fleet. The +//! worker is copied to a `ckdev-` name before it is run or inspected, as +//! basal-testkit's `dev_binary` does on the other systems. + +#![allow(dead_code)] + +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +/// A `ckdev-` named copy of the built worker, in a directory of its own so +/// that granting the worker's package access changes no other ACL. A build +/// with the `deviations` feature gets its own directory, so the two test runs +/// never replace each other's image. +pub fn dev_binary(built: &str) -> &'static Path { + static COPY: OnceLock = OnceLock::new(); + COPY.get_or_init(|| { + let built = Path::new(built); + let directory = built.parent().expect("the worker has a directory").join( + if cfg!(feature = "deviations") { + "ckdev-placed-deviations" + } else { + "ckdev-placed" + }, + ); + std::fs::create_dir_all(&directory).expect("create the placement directory"); + let copy = directory.join("ckdev-basal-worker.exe"); + std::fs::copy(built, ©).expect("copy the worker"); + copy + }) +} + +use std::os::windows::io::{AsRawHandle, RawHandle}; +use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE}; + +pub struct Handle(pub HANDLE); +impl Drop for Handle { + fn drop(&mut self) { + if !self.0.is_null() && self.0 != INVALID_HANDLE_VALUE { + unsafe { CloseHandle(self.0) }; + } + } +} +impl AsRawHandle for Handle { + fn as_raw_handle(&self) -> RawHandle { + self.0 + } +} +pub fn wide(text: &str) -> Vec { + text.encode_utf16().chain(Some(0)).collect() +} + +pub fn placed_worker(worker: &'static Path) -> &'static Path { + static GRANT: OnceLock<()> = OnceLock::new(); + GRANT.get_or_init(|| { + let package = basal_launch::create_or_open_profile().expect("worker profile"); + basal_launch::grant_test_binary_directory(worker, &package).expect("grant test placement"); + }); + worker +} diff --git a/crates/basal-worker/tests/windows_common/threads.rs b/crates/basal-worker/tests/windows_common/threads.rs new file mode 100644 index 0000000..cc0fa7c --- /dev/null +++ b/crates/basal-worker/tests/windows_common/threads.rs @@ -0,0 +1,150 @@ +//! At worker-requested barriers the test harness independently enumerates +//! threads through two APIs, then queries every thread's impersonation token. +//! Checking the complete live thread set rules out credentials on pool threads. +#![allow(unsafe_op_in_unsafe_fn)] +use std::collections::BTreeSet; +use std::ffi::c_void; +use std::mem::{size_of, zeroed}; +use std::ptr::null_mut; +use windows_sys::Win32::Foundation::*; +use windows_sys::Win32::Security::TOKEN_QUERY; +use windows_sys::Win32::System::Diagnostics::ToolHelp::*; +use windows_sys::Win32::System::Threading::*; + +#[link(name = "ntdll")] +unsafe extern "system" { + fn NtQuerySystemInformation( + class: u32, + buffer: *mut c_void, + bytes: u32, + returned: *mut u32, + ) -> i32; + fn NtOpenThreadTokenEx( + thread: HANDLE, + access: u32, + open_as_self: u8, + attributes: u32, + token: *mut HANDLE, + ) -> i32; +} + +unsafe fn toolhelp(pid: u32) -> BTreeSet { + let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0); + assert_ne!( + snapshot, + INVALID_HANDLE_VALUE, + "snapshot: {}", + GetLastError() + ); + let snapshot = super::Handle(snapshot); + let mut entry: THREADENTRY32 = zeroed(); + entry.dwSize = size_of::() as u32; + assert_ne!( + Thread32First(snapshot.0, &mut entry), + 0, + "first thread: {}", + GetLastError() + ); + let mut tids = BTreeSet::new(); + loop { + if entry.th32OwnerProcessID == pid { + assert!(tids.insert(entry.th32ThreadID)); + } + entry.dwSize = size_of::() as u32; + if Thread32Next(snapshot.0, &mut entry) == 0 { + assert_eq!(GetLastError(), ERROR_NO_MORE_FILES); + break; + } + } + tids +} + +/// Offsets and record sizes are the x64 SystemProcessInformation ABI. Validate +/// every record bound before reading its thread array; an error is not an empty set. +unsafe fn system(pid: u32) -> BTreeSet { + assert_eq!(size_of::(), 8); + let mut bytes = 2 * 1024 * 1024; + loop { + let mut buffer = vec![0usize; bytes / 8]; + let mut returned = 0; + let status = + NtQuerySystemInformation(5, buffer.as_mut_ptr().cast(), bytes as u32, &mut returned); + if status as u32 == 0xc0000004 && bytes < 32 * 1024 * 1024 { + bytes = (bytes * 2) + .max(returned as usize + 8192) + .next_multiple_of(8); + continue; + } + assert_eq!(status, 0, "SystemProcessInformation: {:#x}", status as u32); + let end = returned as usize; + assert!(end <= bytes); + let base = buffer.as_ptr().cast::(); + let mut offset = 0; + while offset + 256 <= end { + let next = base.add(offset).cast::().read_unaligned() as usize; + let count = base.add(offset + 4).cast::().read_unaligned() as usize; + let record_end = if next == 0 { + end + } else { + offset.checked_add(next).unwrap() + }; + assert!(record_end <= end && record_end >= offset + 256); + if base.add(offset + 80).cast::().read_unaligned() == pid as usize { + assert!(count <= (record_end - offset - 256) / 80); + let mut tids = BTreeSet::new(); + for index in 0..count { + let thread = base.add(offset + 256 + index * 80); + assert_eq!( + thread.add(40).cast::().read_unaligned(), + pid as usize + ); + let tid = thread.add(48).cast::().read_unaligned(); + assert!(tid != 0 && tid <= u32::MAX as usize); + assert!(tids.insert(tid as u32)); + } + return tids; + } + if next == 0 { + break; + } + offset = record_end; + } + panic!("worker PID {pid} missing from SystemProcessInformation"); + } +} + +pub fn assert_all_threads(pid: u32, stage: &str) -> BTreeSet { + unsafe { + let nt = system(pid); + let toolhelp = toolhelp(pid); + assert!(!nt.is_empty(), "{stage}: no threads"); + assert_eq!(nt, toolhelp, "{stage}: enumerations disagree"); + for tid in &nt { + let thread = OpenThread(THREAD_QUERY_INFORMATION, 0, *tid); + assert!( + !thread.is_null(), + "{stage}: OpenThread({tid}): {}", + GetLastError() + ); + let thread = super::Handle(thread); + for open_as_self in [0, 1] { + let mut token = null_mut(); + let status = + NtOpenThreadTokenEx(thread.0, TOKEN_QUERY, open_as_self, 0, &mut token); + if status >= 0 { + drop(super::Handle(token)); + } + assert_eq!( + status as u32, 0xc000007c, + "{stage}: TID {tid}, open_as_self={open_as_self}" + ); + println!("{stage}: TID {tid} open_as_self={open_as_self} 0xc000007c"); + } + } + println!( + "{stage}: SystemProcessInformation == Toolhelp ({} threads)", + nt.len() + ); + nt + } +} diff --git a/crates/basal-worker/tests/windows_image.rs b/crates/basal-worker/tests/windows_image.rs new file mode 100644 index 0000000..b868a97 --- /dev/null +++ b/crates/basal-worker/tests/windows_image.rs @@ -0,0 +1,248 @@ +//! The built worker image: a GUI-subsystem executable that imports no DLL +//! which would load User32, GDI or COM into the confined process, and no C +//! runtime DLL. +//! +//! A static Userenv or Ole32 import once pulled User32, GDI and Win32u into +//! the confined child and left it with over a hundred handles before input; +//! those DLLs are loaded at run time in the parent only. The C runtime is +//! linked statically (`+crt-static` for this target in `.cargo/config.toml`), +//! so the worker never depends on a separately installed runtime DLL. The +//! console subsystem would start a console host the confined token cannot +//! initialise. +//! +//! The image also reserves at least 8 MiB for the main thread's stack (set +//! by `build.rs`). The worker runs JavaScript on that thread with a QuickJS +//! stack budget of up to 4 MiB; with the linker's default 1 MiB a deep +//! recursion kills the worker instead of ending in a typed budget failure. +#![cfg(windows)] + +use std::path::Path; + +mod windows_common; + +/// `IMAGE_SUBSYSTEM_WINDOWS_GUI`. +const GUI_SUBSYSTEM: u16 = 2; + +/// The smallest main-thread stack reserve the worker image may declare. +const MIN_STACK_RESERVE: u64 = 8 * 1024 * 1024; + +/// DLLs whose import would load GUI or COM code into the worker. +const FORBIDDEN: [&str; 5] = [ + "userenv.dll", + "ole32.dll", + "user32.dll", + "gdi32.dll", + "win32u.dll", +]; + +/// What the import test reads from a PE image. +struct Image { + subsystem: u16, + stack_reserve: u64, + imports: Vec, + delay_imports: Vec, +} + +fn u16_at(bytes: &[u8], offset: usize) -> u16 { + u16::from_le_bytes(bytes[offset..offset + 2].try_into().expect("two bytes")) +} + +fn u32_at(bytes: &[u8], offset: usize) -> u32 { + u32::from_le_bytes(bytes[offset..offset + 4].try_into().expect("four bytes")) +} + +fn u64_at(bytes: &[u8], offset: usize) -> u64 { + u64::from_le_bytes(bytes[offset..offset + 8].try_into().expect("eight bytes")) +} + +/// Reads the subsystem, the main-thread stack reserve and the import and +/// delay-import DLL names of a 64-bit PE image. +fn parse(bytes: &[u8]) -> Image { + assert_eq!(&bytes[..2], b"MZ", "not a PE image"); + let pe = u32_at(bytes, 0x3c) as usize; + assert_eq!(&bytes[pe..pe + 4], b"PE\0\0", "no PE signature"); + let coff = pe + 4; + let sections = u16_at(bytes, coff + 2) as usize; + let optional_size = u16_at(bytes, coff + 16) as usize; + let optional = coff + 20; + assert_eq!(u16_at(bytes, optional), 0x20b, "not a PE32+ image"); + let subsystem = u16_at(bytes, optional + 68); + // PE32+ optional header: Subsystem (2 bytes) at 68, DllCharacteristics + // (2 bytes) at 70, then SizeOfStackReserve (8 bytes) at 72. + let stack_reserve = u64_at(bytes, optional + 72); + let image_base = u64_at(bytes, optional + 24); + let directories = u32_at(bytes, optional + 108) as usize; + let directory = |index: usize| -> (u32, u32) { + if index >= directories { + return (0, 0); + } + let entry = optional + 112 + index * 8; + (u32_at(bytes, entry), u32_at(bytes, entry + 4)) + }; + + // (virtual address, virtual size, raw size, raw offset) per section. + let table = optional + optional_size; + let sections: Vec<(u32, u32, u32, u32)> = (0..sections) + .map(|index| { + let header = table + index * 40; + ( + u32_at(bytes, header + 12), + u32_at(bytes, header + 8), + u32_at(bytes, header + 16), + u32_at(bytes, header + 20), + ) + }) + .collect(); + let offset = |rva: u32| -> usize { + let (address, _, _, raw) = sections + .iter() + .copied() + .find(|&(address, virtual_size, raw_size, _)| { + rva >= address && rva < address + virtual_size.max(raw_size) + }) + .unwrap_or_else(|| panic!("RVA {rva:#x} is in no section")); + (rva - address + raw) as usize + }; + let name = |rva: u32| -> String { + let start = offset(rva); + let end = bytes[start..] + .iter() + .position(|&byte| byte == 0) + .expect("a NUL-terminated name"); + String::from_utf8_lossy(&bytes[start..start + end]).into_owned() + }; + + let mut imports = Vec::new(); + let (import_rva, import_size) = directory(1); + if import_rva != 0 && import_size != 0 { + // IMAGE_IMPORT_DESCRIPTOR, 20 bytes, the name RVA at offset 12; the + // list ends with an all-zero descriptor. + let mut descriptor = offset(import_rva); + while bytes[descriptor..descriptor + 20] + .iter() + .any(|&byte| byte != 0) + { + imports.push(name(u32_at(bytes, descriptor + 12))); + descriptor += 20; + } + } + let mut delay_imports = Vec::new(); + let (delay_rva, delay_size) = directory(13); + if delay_rva != 0 && delay_size != 0 { + // IMAGE_DELAYLOAD_DESCRIPTOR, 32 bytes: attributes, then the name. + // Attribute bit 0 clear means the name is a virtual address. + let mut descriptor = offset(delay_rva); + while bytes[descriptor..descriptor + 32] + .iter() + .any(|&byte| byte != 0) + { + let attributes = u32_at(bytes, descriptor); + let mut rva = u32_at(bytes, descriptor + 4); + if attributes & 1 == 0 { + rva = (u64::from(rva) - image_base) as u32; + } + delay_imports.push(name(rva)); + descriptor += 32; + } + } + Image { + subsystem, + stack_reserve, + imports, + delay_imports, + } +} + +fn is_c_runtime(name: &str) -> bool { + (name.starts_with("vcruntime") && name.ends_with(".dll")) + || (name.starts_with("msvcp") && name.ends_with(".dll")) + || name == "ucrtbase.dll" + || name.starts_with("api-ms-win-crt-") +} + +#[test] +fn the_worker_is_a_gui_image_without_gui_com_or_c_runtime_imports() { + let path = match std::env::var("CKDEV_WINDOWS_IMAGE") { + Ok(source) => windows_common::dev_binary(&source), + Err(_) => windows_common::dev_binary(env!("CARGO_BIN_EXE_ck-basal-worker")), + }; + let image = parse(&std::fs::read(path).expect("read the worker image")); + println!("subsystem: {}", image.subsystem); + println!("stack reserve: {}", image.stack_reserve); + println!("imports: {:?}", image.imports); + println!("delay imports: {:?}", image.delay_imports); + assert_eq!(image.subsystem, GUI_SUBSYSTEM); + assert!( + image.stack_reserve >= MIN_STACK_RESERVE, + "main-thread stack reserve {} is below {MIN_STACK_RESERVE}", + image.stack_reserve + ); + + let all: Vec = image + .imports + .iter() + .chain(&image.delay_imports) + .map(|name| name.to_ascii_lowercase()) + .collect(); + // The parse found the import table: every Windows program imports + // kernel32, so an empty or misread table cannot pass the checks below. + assert!(all.iter().any(|name| name == "kernel32.dll"), "{all:?}"); + for name in &all { + assert!(!FORBIDDEN.contains(&name.as_str()), "imports {name}"); + assert!(!is_c_runtime(name), "imports the C runtime DLL {name}"); + } + + if let Ok(directory) = std::env::var("CKDEV_WINDOWS_EVIDENCE") { + let mut measured = all.clone(); + measured.sort(); + measured.dedup(); + std::fs::write( + Path::new(&directory).join("windows-imports.txt"), + format!("{}\n", measured.join("\n")), + ) + .expect("record measured image imports"); + } + assert!( + !all.iter().any(|name| name == "ws2_32.dll"), + "Winsock belongs only to dynamically resolved probe mode" + ); + // Every import must be on the committed production inventory, so adding a + // DLL dependency requires an explicit update. API-set names are names, not + // filesystem paths; normal and delay imports use the same fixed allowlist. + let listed = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/data/windows-imports.txt"); + let text = std::fs::read_to_string(&listed).expect("committed production import inventory"); + let allowed: Vec = text + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .map(str::to_ascii_lowercase) + .collect(); + for name in &all { + assert!( + allowed.contains(name), + "{name} is not in {}", + listed.display() + ); + } +} + +#[test] +fn the_c_runtime_names_are_recognised() { + for name in [ + "vcruntime140.dll", + "vcruntime140_1.dll", + "msvcp140.dll", + "ucrtbase.dll", + "api-ms-win-crt-runtime-l1-1-0.dll", + ] { + assert!(is_c_runtime(name), "{name}"); + } + for name in [ + "kernel32.dll", + "ntdll.dll", + "advapi32.dll", + "api-ms-win-core-synch-l1-2-0.dll", + ] { + assert!(!is_c_runtime(name), "{name}"); + } +} diff --git a/crates/basal-worker/tests/windows_mutation_witness.py b/crates/basal-worker/tests/windows_mutation_witness.py new file mode 100644 index 0000000..79f0f0b --- /dev/null +++ b/crates/basal-worker/tests/windows_mutation_witness.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Temporarily remove a Windows guard, require its named test to fail, then +restore the source from Git's index and require the test to pass again. +""" +import json +import pathlib +import subprocess +import sys + +ROOT = pathlib.Path(__file__).resolve().parents[3] +OUTPUT = pathlib.Path(sys.argv[1]) +OUTPUT.mkdir(parents=True, exist_ok=True) + +CONTROLS = [ + { + "file": "crates/basal-worker/src/confinement/windows/startup.rs", + "old": " allowlist::check(&table).map_err(|error| Refusal::new(reason::HANDLE_NOT_ALLOWED, error))?;", + "new": " let _ = allowlist::check(&table); // NON-VACUITY BREAK", + "target": "windows_probes", + "test": "planted_handle_startup_refuses_and_the_allowlist_mutant_writes_the_fixture", + "description": "remove startup allowlist enforcement", + "witness": "fixture bytes [101, 115, 99, 97, 112, 101, 100]", + }, + { + "file": "crates/basal-worker/tests/data/windows-imports.txt", + "old": "kernel32.dll\n", + "new": "# NON-VACUITY BREAK\n", + "target": "windows_image", + "test": "the_worker_is_a_gui_image_without_gui_com_or_c_runtime_imports", + "description": "remove the real kernel32 import from the committed inventory", + "witness": "kernel32.dll is not in", + }, +] + +def git(*args): + return subprocess.check_output(["git", *args], cwd=ROOT, text=True).strip() + +proofs = [] +for control in CONTROLS: + path = ROOT / control["file"] + subprocess.run(["git", "add", "--", control["file"]], cwd=ROOT, check=True) + if git("diff", "--stat"): + raise SystemExit("mutation safety: working changes remain after staging") + text = path.read_text() + if text.count(control["old"]) != 1: + raise SystemExit("mutation safety: expected exactly one guard") + try: + path.write_text(text.replace(control["old"], control["new"])) + during = git("diff", "--stat") + if not during: + raise SystemExit("mutation safety: empty applied diff") + command = ["cargo", "test", "-p", "basal-worker", "--locked", "--test", control["target"], "--", control["test"], "--exact", "--nocapture"] + result = subprocess.run(command, cwd=ROOT, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=300) + (OUTPUT / f"mutation-{control['test']}.log").write_text(result.stdout) + expected = f"test {control['test']} ... FAILED" + reached = control["witness"] in result.stdout + red = result.returncode != 0 and expected in result.stdout and "0 passed; 1 failed" in result.stdout and reached + finally: + subprocess.run(["git", "checkout", "--", control["file"]], cwd=ROOT, check=True) + path.touch() + after = git("diff", "--stat") + if after: + raise SystemExit("mutation safety: restored diff is not empty") + proof = { + "control": control["file"] + ": " + control["description"], + "expected_red": control["test"], + "captured_output": expected + "; 0 passed; 1 failed; other tests filtered; " + control["witness"], + "applied_evidence": f"{control['file']}; during: {during}; after: {after or '(empty)'}", + "outcome": "reddened" if red else "not_reached" if not reached else "undefended", + } + proofs.append(proof) + (OUTPUT / "mutation-evidence.json").write_text(json.dumps(proofs, indent=2)) + print(json.dumps(proof), flush=True) + if not red: + raise SystemExit("named mutation oracle did not redden with the fixture-write witness") + subprocess.run(command, cwd=ROOT, check=True) diff --git a/crates/basal-worker/tests/windows_probes.rs b/crates/basal-worker/tests/windows_probes.rs new file mode 100644 index 0000000..a45fddd --- /dev/null +++ b/crates/basal-worker/tests/windows_probes.rs @@ -0,0 +1,429 @@ +//! Each forbidden file, IPC, process, network or executable-memory operation +//! runs in a fresh worker. Its readiness marker immediately precedes the attempt; +//! a crash before that marker is a harness failure, not evidence of denial. +//! Weaker launcher controls prove the operations reach the same real fixtures. +#![cfg(windows)] + +#[path = "windows_common/fixtures.rs"] +mod fixtures; +#[path = "windows_common/threads.rs"] +mod threads; +mod windows_common; + +use basal_launch::{ConfinedProcess, Deviation, LaunchOptions, launch}; +use basal_proto::FLOW_JOB_COMMIT_BYTES; +use std::collections::BTreeMap; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{TcpListener, UdpSocket}; +use std::os::windows::io::AsRawHandle; +use std::sync::{Mutex, mpsc}; +use std::time::Duration; +use windows_common::Handle; + +// Inheritable fixtures must not race another spawn in this test process. +static SPAWNS: Mutex<()> = Mutex::new(()); +struct Probe { + child: ConfinedProcess, + lines: mpsc::Receiver, + stderr: String, +} +struct Ended { + code: u32, + stderr: String, + stdout: String, +} +impl Ended { + fn codes(&self, probe: &str) -> Vec { + let prefix = format!("windows-probe-result:{probe}:"); + let line = self + .stdout + .strip_prefix(&prefix) + .unwrap_or_else(|| panic!("{}", self.stdout)); + line.trim() + .split(',') + .map(|code| code.parse().expect("numeric result")) + .collect() + } + fn denial(&self, probe: &str, codes: &[u32], fatal: Option) -> bool { + if !self + .stderr + .lines() + .any(|line| line == format!("windows-probe-ready:{probe}")) + { + return false; + } + if let Some(fatal) = fatal { + return self.code == fatal && self.stdout.is_empty(); + } + self.code == 0 && self.codes(probe) == codes + } +} +impl Probe { + fn start(deviation: Deviation, probe: &str, target: &str, number: usize) -> Self { + let mut options = LaunchOptions::new( + windows_common::placed_worker(windows_common::dev_binary(env!( + "CARGO_BIN_EXE_ck-basal-worker" + ))), + FLOW_JOB_COMMIT_BYTES, + ) + .deviation(deviation) + .arg("--confinement-probe") + .arg(format!("--probe={probe}")) + .arg(format!("--target={target}")) + .arg(format!("--number={number}")); + if deviation != Deviation::Full && deviation != Deviation::HandleNotAllowed { + options = options.arg("--no-sandbox"); + } + Self::from_child( + launch(&options).unwrap_or_else(|error| panic!("launch {deviation}: {error}")), + ) + } + fn from_child(mut child: ConfinedProcess) -> Self { + let stderr = child.stderr.take().unwrap(); + let (tx, lines) = mpsc::channel(); + std::thread::spawn(move || { + for line in BufReader::new(stderr).lines() { + if tx.send(line.expect("stderr read")).is_err() { + break; + } + } + }); + Self { + child, + lines, + stderr: String::new(), + } + } + fn marker(&mut self, expected: &str) { + loop { + match self.lines.recv_timeout(Duration::from_secs(60)) { + Ok(line) => { + self.stderr.push_str(&line); + self.stderr.push('\n'); + if line == format!("windows-probe-ready:{expected}") { + return; + } + } + Err(error) => panic!( + "worker did not reach {expected}: {error}; stderr: {}", + self.stderr + ), + } + } + } + fn release(&mut self) { + self.child.stdin.as_mut().unwrap().write_all(b"x").unwrap(); + } + fn end(mut self) -> Ended { + drop(self.child.stdin.take()); + loop { + match self.lines.recv_timeout(Duration::from_secs(60)) { + Ok(line) => { + self.stderr.push_str(&line); + self.stderr.push('\n'); + } + Err(mpsc::RecvTimeoutError::Disconnected) => break, + Err(error) => panic!("worker did not exit: {error}; stderr: {}", self.stderr), + } + } + let code = self.child.wait().unwrap(); + let mut stdout = String::new(); + self.child + .stdout + .take() + .unwrap() + .read_to_string(&mut stdout) + .unwrap(); + Ended { + code, + stderr: self.stderr, + stdout, + } + } +} + +fn pair(probe: &str, target: &str, number: usize, denied: u32, control: Deviation) { + let confined = Probe::start(Deviation::Full, probe, target, number).end(); + assert!( + confined.denial(probe, &[denied], None), + "{probe}: exit {:#x}; stderr {}; stdout {}", + confined.code, + confined.stderr, + confined.stdout + ); + let positive = Probe::start(control, probe, target, number).end(); + assert!( + positive.denial(probe, &[0], None), + "{probe} {control}: exit {:#x}; stderr {}; stdout {}", + positive.code, + positive.stderr, + positive.stdout + ); + println!("{probe}: confined {denied:#x}; {control} succeeds"); +} + +#[test] +fn system_image_data_read_and_stat_are_denied_with_lpac_controls() { + let _lock = SPAWNS.lock().unwrap_or_else(|error| error.into_inner()); + let target = format!( + "{}\\System32\\kernel32.dll", + std::env::var("SystemRoot").unwrap() + ); + for probe in ["read", "stat"] { + pair(probe, &target, 0, 5, Deviation::LpacOnly); + } +} +#[test] +fn package_folder_create_is_denied_with_lpac_control() { + let _lock = SPAWNS.lock().unwrap_or_else(|error| error.into_inner()); + let fixture = fixtures::Package::new(); + let target = fixture.create_path(); + pair("create", &target, 0, 5, Deviation::LpacOnly); + std::fs::remove_file(target).unwrap(); +} +#[test] +fn native_package_hive_write_is_denied_with_lpac_control() { + let _lock = SPAWNS.lock().unwrap_or_else(|error| error.into_inner()); + let fixture = fixtures::Package::new(); + pair( + "registry", + &fixture.hive, + 0, + 0xc0000022, + Deviation::LpacOnly, + ); + fixture.remove_value(); +} +#[test] +fn new_alpc_epmapper_and_package_section_are_denied_with_lpac_controls() { + let _lock = SPAWNS.lock().unwrap_or_else(|error| error.into_inner()); + pair( + "alpc", + "\\RPC Control\\epmapper", + 0, + 0xc0000022, + Deviation::LpacOnly, + ); + let fixture = fixtures::Package::new(); + let (_section, name) = fixture.section(); + pair("section", &name, 0, 0xc0000022, Deviation::LpacOnly); +} +#[test] +fn child_creation_and_parent_process_opens_are_denied_with_plain_controls() { + let _lock = SPAWNS.lock().unwrap_or_else(|error| error.into_inner()); + let cmd = format!( + "{}\\System32\\cmd.exe", + std::env::var("SystemRoot").unwrap() + ); + pair("process", &cmd, 0, 5, Deviation::Plain); + for probe in ["parent-query", "parent-read"] { + pair(probe, "", std::process::id() as usize, 5, Deviation::Plain); + } +} +#[test] +fn a_real_named_pipe_is_denied_with_plain_control() { + let _lock = SPAWNS.lock().unwrap_or_else(|error| error.into_inner()); + let (_pipe, path) = fixtures::pipe(); + pair("pipe", &path, 0, 5, Deviation::Plain); +} +#[test] +fn executable_allocation_and_rw_to_x_are_denied_with_lpac_controls() { + let _lock = SPAWNS.lock().unwrap_or_else(|error| error.into_inner()); + for probe in ["allocate-executable", "protect-executable"] { + pair(probe, "", 0, 1655, Deviation::LpacOnly); + } +} +#[test] +fn native_winsock_tcp_udp_and_loopback_are_denied_and_parent_observes_controls() { + let _lock = SPAWNS.lock().unwrap_or_else(|error| error.into_inner()); + for probe in ["tcp", "loopback", "udp"] { + let tcp = TcpListener::bind("127.0.0.1:0").unwrap(); + let udp = UdpSocket::bind("127.0.0.1:0").unwrap(); + tcp.set_nonblocking(true).unwrap(); + udp.set_nonblocking(true).unwrap(); + let port = if probe == "udp" { + udp.local_addr().unwrap().port() + } else { + tcp.local_addr().unwrap().port() + }; + let confined = Probe::start(Deviation::Full, probe, "", port as usize).end(); + assert!( + confined.denial(probe, &[10107, 10093], None), + "{probe}: {} {}", + confined.stderr, + confined.stdout + ); + assert_eq!( + tcp.accept().unwrap_err().kind(), + std::io::ErrorKind::WouldBlock + ); + assert_eq!( + udp.recv(&mut [0; 16]).unwrap_err().kind(), + std::io::ErrorKind::WouldBlock + ); + let positive = Probe::start(Deviation::Plain, probe, "", port as usize).end(); + assert!( + positive.denial(probe, &[0, 0, 0], None), + "{probe}: {} {}", + positive.stderr, + positive.stdout + ); + if probe == "udp" { + let mut bytes = [0; 16]; + let n = udp.recv(&mut bytes).unwrap(); + assert_eq!(&bytes[..n], b"probe"); + } else { + drop( + tcp.accept() + .expect("parent accepts plain loopback connection"), + ); + } + println!( + "{probe}: WSAStartup 10107, socket 10093, no parent traffic; plain traffic observed" + ); + } +} +#[test] +fn create_thread_succeeds_inside_full_confinement() { + let _lock = SPAWNS.lock().unwrap_or_else(|error| error.into_inner()); + let ended = Probe::start(Deviation::Full, "thread", "", 0).end(); + assert!( + ended.denial("thread", &[0], None), + "{} {}", + ended.stderr, + ended.stdout + ); +} +#[test] +fn fatal_exit_before_readiness_is_not_a_denial() { + let _lock = SPAWNS.lock().unwrap_or_else(|error| error.into_inner()); + let ended = Probe::start(Deviation::Full, "crash-before-ready", "", 0).end(); + assert_eq!(ended.code, 0xc0000008); + assert!(!ended.denial("crash-before-ready", &[], Some(0xc0000008))); + let wrong_status = Ended { + code: 0xc0000005, + stderr: "windows-probe-ready:excluded-handle\n".into(), + stdout: String::new(), + }; + assert!(!wrong_status.denial("excluded-handle", &[], Some(0xc0000008))); +} +#[test] +fn excluded_inheritable_handle_is_fatal_only_after_readiness_and_plain_dropped_list_writes() { + let _lock = SPAWNS.lock().unwrap_or_else(|error| error.into_inner()); + let fixture = fixtures::Planted::new(); + let number = fixture.file.as_raw_handle() as usize; + let confined = Probe::start(Deviation::Full, "excluded-handle", "", number).end(); + assert!( + confined.denial("excluded-handle", &[], Some(0xc0000008)), + "{} {}", + confined.stderr, + confined.stdout + ); + assert!(fixture.bytes().is_empty()); + let excluded_plain = Probe::start(Deviation::Plain, "excluded-handle", "", number).end(); + assert!(excluded_plain.denial("excluded-handle", &[6], None)); + assert!(fixture.bytes().is_empty()); + let included_plain = Probe::start( + Deviation::PlainWithoutHandleList, + "excluded-handle", + "", + number, + ) + .end(); + assert!( + included_plain.denial("excluded-handle", &[0], None), + "{} {}", + included_plain.stderr, + included_plain.stdout + ); + assert_eq!(fixture.bytes(), b"escaped"); +} +#[test] +fn planted_handle_startup_refuses_and_the_allowlist_mutant_writes_the_fixture() { + let _lock = SPAWNS.lock().unwrap_or_else(|error| error.into_inner()); + let fixture = fixtures::Planted::new(); + let ended = Probe::start( + Deviation::HandleNotAllowed, + "excluded-handle", + "", + fixture.file.as_raw_handle() as usize, + ) + .end(); + assert_eq!( + ended.code, + 70, + "{} {}; fixture bytes {:?}", + ended.stderr, + ended.stdout, + fixture.bytes() + ); + assert!( + ended + .stderr + .contains("refusing to run unconfined: handle-not-allowed:"), + "{}", + ended.stderr + ); + assert!(!ended.stderr.contains("windows-probe-ready:")); + assert!(fixture.bytes().is_empty()); +} +#[test] +fn thread_barrier_checks_every_thread_before_and_during_held_pool_callbacks() { + let _lock = SPAWNS.lock().unwrap_or_else(|error| error.into_inner()); + let options = LaunchOptions::new( + windows_common::placed_worker(windows_common::dev_binary(env!( + "CARGO_BIN_EXE_ck-basal-worker" + ))), + FLOW_JOB_COMMIT_BYTES, + ) + .arg("--confinement-probe") + .arg("--thread-barrier"); + let mut probe = Probe::from_child(launch(&options).unwrap()); + probe.marker("thread-barrier"); + threads::assert_all_threads(probe.child.id(), "before-pool"); + probe.release(); + probe.marker("callbacks-held"); + let tids = threads::assert_all_threads(probe.child.id(), "callbacks-held"); + let callbacks: BTreeMap<_, _> = probe + .stderr + .lines() + .filter_map(|line| { + let rest = line.strip_prefix("windows-pool-callback:")?; + let (kind, tid) = rest.split_once(':')?; + Some((kind.to_owned(), tid.parse::().unwrap())) + }) + .collect(); + assert_eq!( + callbacks.keys().map(String::as_str).collect::>(), + ["legacy", "timer", "wait", "work"] + ); + for tid in callbacks.values() { + assert!(tids.contains(tid), "callback TID {tid} not in snapshot"); + } + println!("every callback TID is in the snapshot: {callbacks:?}"); + probe.release(); + let ended = probe.end(); + assert!(ended.denial("thread-barrier", &[0], None)); + assert!( + ended + .stderr + .contains("windows-pool-complete:work,wait,timer,legacy") + ); +} +#[test] +fn forced_pool_work_wait_timer_and_legacy_work_complete() { + let _lock = SPAWNS.lock().unwrap_or_else(|error| error.into_inner()); + let ended = Probe::start(Deviation::Full, "pool", "", 0).end(); + assert!( + ended.denial("pool", &[0], None), + "{} {}", + ended.stderr, + ended.stdout + ); + assert!( + ended + .stderr + .contains("windows-pool-complete:work,wait,timer,legacy") + ); + println!("windows-pool-complete:work,wait,timer,legacy"); +} diff --git a/crates/basal-worker/tests/windows_record_provenance.py b/crates/basal-worker/tests/windows_record_provenance.py new file mode 100644 index 0000000..c734aed --- /dev/null +++ b/crates/basal-worker/tests/windows_record_provenance.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +"""Render the two one-off production measurements without changing their data.""" +import collections +import json +import pathlib +import re +import sys + +run_id, latest_dir, server_dir, destination = sys.argv[1:] +lines = ["# Production Windows handle provenance", "", f"Measured by CI run [{run_id}](https://github.com/cortexkit/basal/actions/runs/{run_id}).", "", "Both images used the production release `ck-basal-worker.exe` (no worker", "deviation feature). The tool copied its bytes unchanged to a `ckdev-` name,", "launched the full confinement, enabled ProcessHandleTracing class 32 with", "16,384 slots before resume, and queried the process at its pre-input", "checkpoint after all startup checks. User-space symbols below are PDB-resolved,", "not nearest-export guesses. Kernel prefixes are retained verbatim and labelled", "kernel (unsymbolized). The three pipes were created by the parent and were", "the only entries in its explicit inherited-handle list.", "", "This is a one-off measurement, not the handle allowlist. Live startup checks", "continue to use the fixed per-image ceiling constants. An image change", "requires remeasurement. Creator chains identify initialization authority;", "they are not an exhaustive proof of every operation on the residual objects."] +for image, directory in [("windows-latest", pathlib.Path(latest_dir)), ("windows-2022", pathlib.Path(server_dir))]: + report = json.loads((directory / "windows-handle-provenance.json").read_text(encoding="utf-8-sig")) + if report["unresolved"]: + raise SystemExit(f"STOP: {image} has unresolved creators: {report['unresolved']}") + if report["total"] > 26 or report["total"] != len(report["handles"]): + raise SystemExit(f"STOP: {image} has an invalid total") + # Ceiling acceptance comes from the compiled live validator, not a second + # table generated by this renderer from the same measurement. + hash_text = (directory / "production-image-hash.txt").read_text(encoding="utf-8-sig") + hashes = re.findall(r"\b[0-9A-Fa-f]{64}\b", hash_text) + if len(hashes) != 1: + raise SystemExit(f"expected one measured SHA-256 for {image}") + groups = collections.defaultdict(list) + for handle in report["handles"]: + groups[(handle["type"], handle["access"])].append(handle) + lines += ["", f"## {image}", "", f"Runner image: `{report.get('runner_image')}`; version: `{report.get('runner_version')}`.", f"[Unedited raw report](windows-handle-provenance-{image}.json).", f"Production image SHA-256: `{hashes[0].lower()}`.", f"Accepted ceiling profile: `{report['profile']}`. Total: **{report['total']}**.", "", "| Type | Granted access | Count | Creator chains |", "|---|---|---:|---|"] + details = [] + for index, ((kind, access), handles) in enumerate(sorted(groups.items()), 1): + chains = collections.Counter() + for handle in handles: + chain = handle.get("creator") or " → ".join(f"kernel (unsymbolized) @{frame['address']}" if frame["module"] == "kernel (unsymbolized)" else f"{frame['module']}!{frame['symbol']}+0x{frame['displacement']:x}" for frame in handle["stack"]) + if not chain: + raise SystemExit(f"STOP: missing creator for {image} {kind}") + chains[chain] += 1 + lines.append(f"| {kind} | `{access}` | {len(handles)} | C{index} |") + details += ["", f"### C{index}: {kind} `{access}`", ""] + for chain, count in sorted(chains.items()): + details.append(f"- ×{count}: `{chain}`") + lines += details +pathlib.Path(destination).write_text("\n".join(lines) + "\n") +print(f"Rendered two production inventories at {destination}") diff --git a/crates/basal-worker/tests/windows_startup.rs b/crates/basal-worker/tests/windows_startup.rs new file mode 100644 index 0000000..f0ad376 --- /dev/null +++ b/crates/basal-worker/tests/windows_startup.rs @@ -0,0 +1,373 @@ +//! The worker's Windows startup, run for real: every spawn but two goes +//! through basal-launch under the full confinement, or under one of its test +//! variants that breaks exactly one property a startup check guards. +//! +//! The two exceptions start the worker directly, without a package SID or +//! with an unparsable one. The worker refuses those before it confines +//! itself or reads anything, and the launcher always passes a valid SID, so +//! it cannot produce them. +#![cfg(windows)] + +use basal_launch::{ + ConfinedProcess, Deviation, LaunchOptions, create_or_open_profile, grant_test_binary_directory, + launch, +}; +use basal_proto::{ + ActivationRequest, ActivationResult, Budgets, CallKind, Confinement, FLOW_JOB_COMMIT_BYTES, + JsonText, Outcome, PROTOCOL_VERSION, ParentMessage, Primitive, Profile, Settlement, + WorkerMessage, read_worker_message, write_parent_message, +}; +use std::fs::File; +use std::io::Read; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::sync::OnceLock; + +mod windows_common; + +/// The worker cargo built for these tests, under a development name. +fn worker() -> &'static Path { + windows_common::dev_binary(env!("CARGO_BIN_EXE_ck-basal-worker")) +} + +/// The worker, with its directory readable and executable by the worker's +/// package, so that the confined process can load its own image. +fn placed_worker() -> &'static Path { + static GRANTED: OnceLock<()> = OnceLock::new(); + GRANTED.get_or_init(|| { + let package = create_or_open_profile().expect("worker profile"); + grant_test_binary_directory(worker(), &package) + .expect("grant the package read and execute"); + }); + worker() +} + +fn start(deviation: Deviation) -> ConfinedProcess { + let options = LaunchOptions::new(placed_worker(), FLOW_JOB_COMMIT_BYTES).deviation(deviation); + launch(&options).unwrap_or_else(|error| panic!("launch {deviation}: {error}")) +} + +/// How a worker ended: its exit code and everything it wrote to stderr. +struct Ended { + code: u32, + stderr: String, +} + +impl Ended { + /// The reason token in a startup refusal line, + /// `ck-basal-worker: refusing to run unconfined: : `. + fn reason(&self) -> Option<&str> { + let rest = self.stderr.split("refusing to run unconfined: ").nth(1)?; + rest.split(':').next() + } +} + +/// Closes the worker's stdin and waits for it to end. A worker that refused +/// to start has already exited; one that started anyway reads end of input +/// and exits 0, so a missing refusal shows up as a wrong exit code rather +/// than a test that never finishes. +fn end(mut child: ConfinedProcess) -> Ended { + drop(child.stdin.take()); + let mut stderr = String::new(); + child + .stderr + .take() + .expect("stderr") + .read_to_string(&mut stderr) + .expect("read the worker's stderr"); + let code = child.wait().expect("wait for the worker"); + Ended { code, stderr } +} + +/// Ends the worker and fails the test with what it wrote to stderr. +fn fail(child: ConfinedProcess, what: String) -> ! { + let ended = end(child); + panic!( + "{what}; worker exit {:#x}, stderr: {}", + ended.code, ended.stderr + ) +} + +/// Launches the worker under `deviation` and requires it to refuse at the +/// startup check that variant breaks: exit 70, that reason on stderr. +fn assert_refused(deviation: Deviation) { + let expected = deviation + .worker_reason() + .unwrap_or_else(|| panic!("{deviation} is not a worker check")); + let ended = end(start(deviation)); + println!( + "{deviation}: exit {} stderr {}", + ended.code, + ended.stderr.trim() + ); + assert_eq!(ended.code, 70, "{deviation}: {}", ended.stderr); + assert_eq!(ended.reason(), Some(expected), "{}", ended.stderr); +} + +/// A Windows report with every property true: the only report a worker that +/// passed its startup checks sends. +const CONFINED: Confinement = Confinement::Windows { + lpac: true, + untrusted: true, + no_thread_token: true, + mitigations: true, + handle_table: true, +}; + +/// The time the test parent answers a clock read with: 2026-01-01T00:00:00Z. +const NOW_MS: &str = "1767225600000"; + +/// A real activation under the full confinement: the worker passes all six +/// startup checks with the launcher's environment, reports the Windows +/// confinement, runs a script that makes a synchronous host call, returns its +/// result and shuts down cleanly. The parent also reads the worker's primary +/// back and finds it Untrusted, which only the worker's own lowering makes it. +#[test] +fn an_activation_completes_under_the_full_confinement() { + let mut child = start(Deviation::Full); + let mut stdin = child.stdin.take().expect("stdin"); + let mut stdout = child.stdout.take().expect("stdout"); + + write_parent_message( + &mut stdin, + &ParentMessage::Hello { + protocol_version: PROTOCOL_VERSION, + }, + ) + .expect("send hello"); + let welcome = match read_worker_message(&mut stdout) { + Ok(WorkerMessage::Welcome(welcome)) => welcome, + other => fail(child, format!("no welcome: {other:?}")), + }; + println!("full: welcome {welcome:?}"); + assert_eq!(welcome.confinement, CONFINED); + + let token = child.primary_token().expect("read the worker's primary"); + println!("full: primary after startup {token:?}"); + assert_eq!(token.integrity, "S-1-16-0", "the worker lowered itself"); + + let script = r#" + const now = Date.now(); + const total = [1, 2, 3, 4].map((n) => n * n).reduce((a, b) => a + b, 0); + const text = JSON.stringify({ nested: [total, "x"] }); + return { now, total, text, parsed: JSON.parse(text).nested[1] }; + "#; + let request = ActivationRequest { + activation_id: 7, + profile: Profile::Flow, + tools: vec![], + prelude_hash: welcome.prelude_hash, + script: script.into(), + trigger: JsonText::null(), + self_input: JsonText::null(), + budgets: Budgets::default(), + prefix: vec![], + }; + write_parent_message(&mut stdin, &ParentMessage::Activate(Box::new(request))) + .expect("send the activation"); + let mut host_calls = Vec::new(); + let result = loop { + match read_worker_message(&mut stdout) { + Ok(WorkerMessage::HostCall(call)) => { + assert_eq!(call.kind, CallKind::Primitive(Primitive::Now), "{call:?}"); + let reply = ParentMessage::Deliver(Outcome { + position: call.position, + settlement: Settlement::Fulfilled, + value: JsonText::new(NOW_MS).expect("small"), + delivery_order: host_calls.len() as u64, + }); + host_calls.push(call); + write_parent_message(&mut stdin, &reply).expect("deliver the clock read"); + } + Ok(WorkerMessage::Finished { + activation_id, + result, + }) => { + assert_eq!(activation_id, 7); + break result; + } + other => fail(child, format!("unexpected reply: {other:?}")), + } + }; + println!("full: {} host call(s), result {result:?}", host_calls.len()); + assert_eq!(host_calls.len(), 1); + let value = match result { + ActivationResult::Completed { value } => value, + other => fail(child, format!("the activation did not complete: {other:?}")), + }; + let value: serde_json::Value = serde_json::from_str(value.as_str()).expect("JSON result"); + assert_eq!( + value, + serde_json::json!({ + "now": 1767225600000u64, + "total": 30, + "text": "{\"nested\":[30,\"x\"]}", + "parsed": "x", + }) + ); + + write_parent_message(&mut stdin, &ParentMessage::Shutdown).expect("send shutdown"); + drop(stdin); + drop(stdout); + let ended = end(child); + println!("full: shut down with exit {}", ended.code); + assert_eq!(ended.code, 0, "{}", ended.stderr); +} + +// Worker checks of token, mitigation and handle properties the launcher can +// break when it creates the process. In these test variants the parent's own +// pre-resume checks expect the broken property, so the worker is resumed and +// must notice and refuse by itself. + +#[test] +fn a_primary_without_the_lpac_claim_is_refused_as_not_lpac() { + assert_refused(Deviation::NotLpac); +} + +#[test] +fn a_primary_with_a_capability_is_refused_as_capabilities_present() { + assert_refused(Deviation::CapabilitiesPresent); +} + +#[test] +fn a_second_restricting_sid_is_refused_as_restricting_sid_mismatch() { + assert_refused(Deviation::RestrictingSidMismatch); +} + +#[test] +fn an_enabled_logon_group_is_refused_as_group_not_deny_only() { + assert_refused(Deviation::GroupNotDenyOnly); +} + +#[test] +fn a_primary_with_privileges_is_refused_as_privileges_present() { + assert_refused(Deviation::PrivilegesPresent); +} + +#[test] +fn allowed_dynamic_code_is_refused_as_mitigation_mismatch() { + assert_refused(Deviation::MitigationMismatch); +} + +/// Without the explicit inherited-handle list the worker inherits every +/// inheritable handle of this process. One is planted here, so the worker's +/// table holds an inheritable file that is not one of its pipes. +#[test] +fn an_inherited_handle_beyond_the_pipes_is_refused_as_handle_not_allowed() { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Foundation::{HANDLE_FLAG_INHERIT, SetHandleInformation}; + + let fixture = worker() + .parent() + .expect("the worker has a directory") + .join(format!("basal-worker-planted-{}", std::process::id())); + let planted = File::create(&fixture).expect("create the planted file"); + let ok = unsafe { + SetHandleInformation( + planted.as_raw_handle(), + HANDLE_FLAG_INHERIT, + HANDLE_FLAG_INHERIT, + ) + }; + assert_ne!( + ok, + 0, + "SetHandleInformation: {}", + std::io::Error::last_os_error() + ); + assert_refused(Deviation::HandleNotAllowed); + drop(planted); + let _ = std::fs::remove_file(&fixture); +} + +/// A production worker has no way to skip a startup check: it refuses the +/// request the launcher's test variants add as an unknown argument, exit 64, +/// before any check runs. +#[cfg(not(feature = "deviations"))] +#[test] +fn a_production_worker_refuses_the_deviation_argument() { + for deviation in [ + Deviation::ThreadTokenPresent, + Deviation::IntegrityLowerFailed, + Deviation::IntegrityNotUntrusted, + ] { + let argument = deviation.child_argument().expect("a worker argument"); + let ended = end(start(deviation)); + println!( + "{deviation}: exit {} stderr {}", + ended.code, + ended.stderr.trim() + ); + assert_eq!(ended.code, 64, "{deviation}: {}", ended.stderr); + assert!( + ended + .stderr + .contains(&format!("unknown argument {argument}")), + "{}", + ended.stderr + ); + } +} + +// Worker checks of steps the worker performs itself (dropping the start-up +// thread token, lowering its integrity), which the parent cannot break when +// it creates the process. A worker built with the `deviations` feature skips +// or breaks that step when the launcher's command line asks it to. + +#[cfg(feature = "deviations")] +#[test] +fn a_kept_start_up_token_is_refused_as_thread_token_present() { + assert_refused(Deviation::ThreadTokenPresent); +} + +#[cfg(feature = "deviations")] +#[test] +fn a_failed_lowering_is_refused_as_integrity_lower_failed() { + assert_refused(Deviation::IntegrityLowerFailed); +} + +#[cfg(feature = "deviations")] +#[test] +fn a_skipped_lowering_is_refused_as_integrity_not_untrusted() { + assert_refused(Deviation::IntegrityNotUntrusted); +} + +/// Started without a package SID, the worker refuses before confining +/// itself, as it does without its Landlock argument on Linux. +#[test] +fn a_missing_package_sid_exits_70_package_sid_argument_missing() { + let output = Command::new(worker()) + .stdin(Stdio::null()) + .output() + .expect("run the worker"); + let ended = Ended { + code: output.status.code().expect("an exit code") as u32, + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }; + println!( + "missing: exit {} stderr {}", + ended.code, + ended.stderr.trim() + ); + assert_eq!(ended.code, 70, "{}", ended.stderr); + assert_eq!( + ended.reason(), + Some("package-sid-argument-missing"), + "{}", + ended.stderr + ); +} + +/// A package SID the system's SID parser rejects is a usage error. +#[test] +fn an_unparsable_package_sid_exits_64() { + let output = Command::new(worker()) + .arg("--package-sid=S-1-15-2-not-a-sid") + .stdin(Stdio::null()) + .output() + .expect("run the worker"); + let stderr = String::from_utf8_lossy(&output.stderr); + println!("unparsable: {:?} stderr {}", output.status, stderr.trim()); + assert_eq!(output.status.code(), Some(64), "{stderr}"); + assert!(stderr.contains("unparsable --package-sid"), "{stderr}"); +} diff --git a/docs/ops.md b/docs/ops.md index b76106c..bf2eaaf 100644 --- a/docs/ops.md +++ b/docs/ops.md @@ -176,7 +176,7 @@ The JavaScript function body sees only `tools.(input)`, `console.log(...)` - `description` is optional, at most 1,024 UTF-8 bytes, and has no line breaks. - `limits` may only lower wall time (1–1,800,000 ms), tool calls (1–200), and captured output (1–65,536 bytes). - The parent enforces 30 minutes of wall time, excluding intervals when the worker is blocked and every in-flight call is held on a person. Person wait has one cumulative 10-minute cap per run. The confined worker enforces 10 seconds of JavaScript CPU, 64 MiB heap and 1 MiB stack. -- Windows refuses `unsupported_platform` until worker confinement is available there. +- Windows runs codemode under the launcher's full worker confinement, with the codemode job commit limit rather than the larger flow limit. Basal records the run before calling core's `codemode.run_scope.open` over its own route stamped `reserved:basal` (the daemon-verified basal module principal), never by borrowing a program's agent authority: ```json diff --git a/docs/sandbox.md b/docs/sandbox.md index 1745143..edc42ed 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -1,14 +1,16 @@ # The worker sandbox -basal runs every flow script in `ck-basal-worker`, a separate process. The -script is approved by the operator, but it is still untrusted input to a C +basal runs every flow and codemode script in `ck-basal-worker`, a separate process. A flow +script is approved by the operator, but every script is still untrusted input to a C JavaScript engine (QuickJS), so basal assumes the engine can be subverted and confines the worker so that a subverted worker can do little more than talk to its parent over stdin and stdout. Every effect a flow has on the world is a host call that the parent checks against the approved manifest and journals. The worker holds no durable state. -The worker confines itself before it reads its first message from the parent. +On Linux and macOS the worker confines itself before it reads its first message +from the parent. On Windows the launcher fixes the process confinement at +creation and the worker finishes and attests its startup checks before that read. The parent then accepts a worker only if its first message, `Welcome`, reports the confinement this operating system requires. A worker that reports anything else is killed before it sees any flow code. @@ -106,17 +108,20 @@ published yet. ## Differences between the systems -| | Linux | macOS | -| --- | --- | --- | -| Boundary | seccomp allowlist, fatal SIGSYS | Seatbelt profile, denied calls fail | -| Second layer | Landlock | none | -| Threads | blocked by seccomp | not blocked | -| Ptrace and memory reads | blocked by non-dumpable | blocked by the hardened runtime | -| Privacy grants | not applicable | launched detached from the module's privacy grants | -| Health counter | SIGSYS deaths | none | +| | Linux | macOS | Windows | +| --- | --- | --- | --- | +| Boundary | seccomp allowlist, fatal SIGSYS | Seatbelt profile, denied calls fail | Untrusted LPAC restricted token, job and process mitigations | +| Second layer | Landlock | none | startup token, mitigation and handle attestation | +| Threads | blocked by seccomp | not blocked | allowed inside the worker | +| Ptrace and memory reads | blocked by non-dumpable | blocked by the hardened runtime | worker cannot open another process for query or memory reads | +| Privacy grants | not applicable | launched detached from the module's privacy grants | no capabilities; environment cleared except SystemRoot | +| Health counter | SIGSYS deaths | none | `fatal_status_deaths` for invalid-handle and access-violation exits | +| Host fs mounts and links | mounts traversed; in-root links allowed | mounts traversed; in-root links allowed | mounts and in-root links not traversed; every reparse point refused, including stat | +| Strict-handle misuse | not applicable | not applicable | fatal `0xc0000008`, not a returned error | A denied operation on Linux kills the worker; on macOS the call fails and the -engine keeps running. +engine keeps running. On Windows most denied operations return an error, but +strict-handle misuse is fatal rather than an error the engine can catch. ### CPU bound of a pooled worker @@ -127,7 +132,7 @@ flow and reused for that flow's later activations until it is retired after 256 activations or after sitting idle for 10 minutes. On Linux the engine thread is the worker's only thread, so the CPU budget -covers all of the worker's CPU use. On macOS a subverted engine could start +covers all of the worker's CPU use. On macOS and Windows a subverted engine could start another thread whose CPU the engine thread's clock does not count, and that thread could keep running after its activation returns. The CPU such a pooled worker can use is therefore bounded only by time: the wall-clock deadline @@ -146,6 +151,126 @@ normally, the worker is reused for that flow. ## Windows -Windows confinement is not provided yet. The worker has no Windows sandbox, -and the module refuses any worker whose `Welcome` reports no confinement, so -no flow runs there. +### Layers and guarantees + +The native launcher creates a GUI-subsystem worker suspended, with a +Less Privileged AppContainer (LPAC) primary token for the fixed profile +`cortexkit.basal.worker`, zero capabilities, no privileges, NULL-only +restricting SIDs, and every access group deny-only. The primary is Low at birth +so the loader can initialize. A matching Low impersonation token on the initial +thread is used only during startup. The environment is cleared except +`SystemRoot`; an explicit handle list passes only the three stdio pipes. + +Before resume, the parent checks the actual suspended primary and initial +thread tokens, the owned job's limits and the child's membership in that job. +The job has kill-on-close, no breakaway, an active-process limit of one, and all +UI restrictions. Its process memory limit bounds **committed memory**, not +address space as Linux's `RLIMIT_AS` does. The flow and codemode profiles have +separate commit limits with headroom above their JavaScript heap budgets. +Dropping or killing a worker terminates its job and every thread in the process. + +Before reading its first frame, the worker reverts the thread token, checks that +none remains, lowers its actual primary to Untrusted, and closes all Advanced +Local Procedure Call (ALPC) ports +and non-inheritable non-stdio File handles (including KsecDD on Server 2022). +It attests the actual token, required process mitigations and the handle table. +A failed check exits 70 with a named reason, never a weaker sandbox. The parent +accepts a Windows `Welcome` only on Windows, with all five confinement fields true. Its +`fatal_status_deaths` health counter counts worker exits `0xc0000008` (invalid +handle) and `0xc0000005` (access violation), not refusal exit 70 or an intentional +parent kill. + +The mitigations prohibit dynamic code, child-process creation, Win32k system +calls and extension points; require Microsoft-signed images; reject remote and +low-label images; prefer System32 when loading images; and permanently enable +strict handle checks. +Thread opt-out and remote downgrade of dynamic-code policy are forbidden. + +A confined worker can read stdin, write stdout and stderr, allocate private +memory, read clocks, create threads inside itself and exit. The measured probes +deny file data and metadata opens, package-folder creates and native package-hive +writes, new IPC, process creation, access to another process, and network access +through Winsock, including loopback. Host calls remain the only authorized way +for a flow to affect the outside world: the parent checks their manifest grants. + +### Residual handles and production measurements + +Windows needs runtime handles beyond stdio. The startup allowlist fits the +entire inventory to **one image profile**: either the windows-latest ceilings +or the windows-2022 ceilings in the table below, never their union. +Stdio and `\KnownDlls` must be present at exactly their counts; all other rows +are ceilings. Unknown types, access masks, extra inheritable handles and excess +counts are refused. Each profile permits at most **26 handles**: + +| Type | Access | windows-latest ceiling | windows-2022 ceiling | windows-latest production | windows-2022 production | +| --- | --- | ---: | ---: | ---: | ---: | +| File stdin | `0x120189` | 1 (exact) | 1 (exact) | 1 | 1 | +| File stdout/stderr | `0x120196` | 2 (exact) | 2 (exact) | 2 | 2 | +| Directory `\KnownDlls` | `0x3` | 1 (exact) | 1 (exact) | 1 | 1 | +| Event | `0x1f0003` | ≤7 | ≤7 | 6 | 6 | +| IoCompletion | `0x1f0003` | ≤2 | ≤2 | 2 | 2 | +| TpWorkerFactory | `0xf00ff` | ≤2 | ≤2 | 2 | 2 | +| IRTimer | `0x100002` | ≤4 | ≤4 | 4 | 4 | +| WaitCompletionPacket | `0x1` | ≤6 | ≤5 | 6 | 5 | +| Semaphore | `0x100003` | 0 | ≤2 | 0 | 2 | +| SchedulerSharedData | `0x1` | ≤1 | 0 | 1 | 0 | +| **Total** | | **≤26** | **≤26** | **25** | **25** | + +The production counts and creator chains are committed in +[`windows-handle-provenance.md`](../crates/basal-worker/tests/data/windows-handle-provenance.md). +CI measurements counted 25 pre-input handles per production release worker +and traced their creators in a production build without test-only options that +can weaken the worker's startup checks. The images were `win25-vs2026` version +`20260925.250.1` and `win22` version `20261004.326.1`. An image change requires +remeasurement, not an automatic widening of the allowlist. Production used six +Event handles per image, one below each seven-Event ceiling. + +`\KnownDlls` is shared and read-only (query and traverse). Every other residual +object beyond stdio is unnamed and was private to the worker in the measured +snapshots. Their access masks are **not all read-only**: events, queues, timers, +wait packets and semaphores carry process-local runtime authority. Closing the +pool's completion and synchronization handles crashes real pool work, so they +must remain open. + +On images with SchedulerSharedData, the worker can duplicate its `0x1` handle +with `GENERIC_ALL` and obtain `0x000f0001`, the type's full rights. This +**self-escalation** affects its own private, unnamed scheduler object; no other +process held that object in the measurement. It is not a file, network, peer or +persistent-state capability. Closing it crashes the worker when the pool runs. + +### Non-claims and profile reuse + +These measurements do not establish denial of direct native access to the AFD +network device bypassing Winsock, or of KnownDlls-relative section opens. +Native API probing covered a finite set of operations, not every operation +against every residual handle type. +The CSR ALPC port is closed before input; whether it would accept messages is +not part of the residual claim. An unnamed handle is not, by itself, proof of +harmlessness; the inventory, creator chains and finite probes delimit the claim. + +All workers reuse the `cortexkit.basal.worker` AppContainer profile. Profile +reuse is a state channel for a **weaker token** that can create package files or +write the package hive. The full token denies both operations; there is no +per-worker profile and no claim that LPAC alone gives the full boundary. + +### Clock and thread bounds + +The engine thread's CPU clock uses `GetThreadTimes`, kernel plus user time, with +scheduler-tick resolution. A sub-tick CPU budget is imprecise; the wall deadline +is the hard bound. Small budgets are not refused. A subverted engine can use +additional threads whose CPU is not counted by that clock. For a pooled worker, +those threads are bounded by the activation wall deadline plus idle retirement, +not by the engine-thread CPU budget. Killing or retiring the worker ends every +thread. Pooled workers are retired after 256 activations or 10 minutes idle; +extra threads cannot outlive that retirement or a kill at the wall deadline. + +### Production placement requirements + +Windows confinement is implemented, but production placement also needs SUBC, +the module-hosting daemon, to support Windows placement, an install-directory +read/execute ACE for basal's +AppContainer SID, and a deployment account with a loaded user profile. The +launcher does not add that install ACE in production; test harnesses grant it +only on their worker binary's directory. Store and log files must retain their +owner-only Windows DACLs. Users install with `ck setup` and update with +`ck upgrade`; `script/stage.sh` is not a Windows or Linux installer. diff --git a/evidence/windows-build-failures.md b/evidence/windows-build-failures.md index 6da2b7b..a216c16 100644 --- a/evidence/windows-build-failures.md +++ b/evidence/windows-build-failures.md @@ -154,3 +154,56 @@ the failures above are fixed. dev-depend on `basal-host`. - **Tests:** every test in the workspace. Both test commands stopped while compiling `basal-host`. + +## Test-kit and rig portability + +The original measured baseline above is retained. The following tests, or the +explicitly named fixture subcases of mixed tests, require POSIX semantics. The +remaining assertions in mixed tests still run on Windows. + +- `crates/basal-worker/tests/inherited_descriptors.rs::worker_closes_extra_inherited_descriptors_at_startup` — POSIX descriptor numbers, `dup2`, and close-on-exec flags; Windows inherited-handle checks run in the launcher and Windows confinement suites. +- `crates/basal-testkit/src/git.rs::tests::fixture_git_commands_ignore_injected_global_hooks` — executable `/bin/sh` hook fixture; Windows built-in config/hook isolation has native coverage in basal-host. +- `crates/basal-testkit/tests/builtins_git.rs::a_repository_config_cannot_make_a_built_in_run_a_program` — executable `/bin/sh` fsmonitor, pager, diff and hook fixtures; Windows built-in isolation has native coverage in basal-host. +- `crates/basal-testkit/tests/builtins_fs.rs::a_symlink_resolving_outside_the_root_is_refused` — symlink creation requiring Windows privilege, and Unix in-root-link traversal semantics. +- `crates/basal-testkit/tests/builtins_fs.rs::a_symlink_swapped_into_the_last_component_after_the_check_is_not_followed` — privilege-dependent symlink fixture. +- `crates/basal-testkit/tests/builtins_fs.rs::a_directory_swapped_for_a_symlink_after_the_check_is_caught_after_the_open` — privilege-dependent symlink fixture. +- `crates/basal-testkit/tests/builtins_fs.rs::list_names_entries_and_their_kinds` (symlink entry only) — privilege-dependent symlink creation; ordinary entries and the listing cap remain tested on Windows. +- `crates/basal-testkit/tests/builtins_fs.rs::write_replaces_the_whole_file_inside_a_write_root_only` (symlink paths only) — privilege-dependent symlink creation; writes, outside-root refusal, relative-component refusal, size cap and temp cleanup remain tested on Windows. +- `crates/basal-testkit/tests/fs_write_temps.rs::a_crash_mid_write_is_cleaned_up_by_recovery_without_following_a_swapped_path` (three symlink tamper variants only) — privilege-dependent symlink creation; untampered crash recovery runs on Windows. +- `crates/basal-testkit/tests/fs_write_temps.rs::a_crash_record_whose_directory_left_its_root_is_not_acted_on` — privilege-dependent symlink creation and retargeting a granted Unix symlink root. +- `crates/basal-testkit/tests/fs_write_temps.rs::legacy_temporary_files_are_removed_once_from_written_directories` (symlink temp only) — privilege-dependent symlink creation; regular temp cleanup, decoy names, outside files and once-per-store behavior remain tested on Windows. +- `crates/basal-rig/src/bin/basal-rig-contract/tests.rs::seeded_fixture_rejects_foreign_paths_and_symlinked_stores` (symlinked store only) — privilege-dependent symlink creation; foreign-path and module refusal assertions remain tested on Windows. +- `crates/basal-host/tests/builtin_host_regressions.rs::escaped_listing_exceeds_encoded_cap_as_a_typed_refusal` — Unix control-character filenames, which NTFS refuses; escaped-file dispatch limits remain tested on Windows. +- `crates/basal-host/tests/builtin_host_regressions.rs::atomic_write_drops_special_permission_bits` — POSIX setuid/setgid/mode bits; Windows replacement-DACL behavior has native basal-host coverage. +- `crates/basal-host/tests/builtin_host_regressions.rs::git_reaps_descendants_that_keep_its_output_pipe_open` — `/bin/sh`/`sleep` fixture and POSIX process-group cleanup; native Windows job/descendant tests run in basal-host and basal-testkit. +- `crates/basal-testkit/src/https.rs::tests::idle_https_accept_blocks_and_shutdown_wakes_it_without_a_request` (`fcntl` flag readback only) — POSIX socket flags; shutdown wakeup and absence of HTTPS requests remain tested on Windows. +- `crates/basal-testkit/src/process.rs::tests::subprocess_deadline_reaps_descendants_holding_output_pipes` — `/bin/sh`/`sleep` process-group fixture; `crates/basal-testkit/tests/windows_process.rs::subprocess_deadline_reaps_descendants_holding_output_pipes` tests the Windows job equivalent by the same test name. + +## Tests gated on Windows + +These tests are compiled out (`cfg(unix)`) or ignored (`cfg_attr(windows, +ignore)`) on Windows rather than rewritten. Each entry below gives its +reason. They were gated when basal-module's tests first ran on Windows, after +basal-module started, supervised and killed its workers through +`basal-launch`. + +- **POSIX-only semantics** (`cfg(unix)`): + - `crates/basal-module/tests/pool.rs`: + `sigsys_deaths_are_counted_in_flow_health` and + `sigkill_deaths_are_not_confinement_violations` send signals and observe + them with `waitid`. The Windows fault accounting is covered by + `pool::bookkeeping_tests::a_fatal_status_exit_counts_as_a_confinement_fault_and_exit_70_does_not`. + - `crates/basal-module/tests/pool.rs`: + `a_wait_deadline_stops_and_reaps_its_workers` checks reaping with + `waitpid`. + - `crates/basal-module/tests/e2e.rs`: `e2e_fixture_drop_reaps_its_child` + checks that no zombie is left, with `waitpid`. + - `crates/basal-module/src/process.rs`: + `buffer_tests::a_reaped_worker_handle_never_signals_a_recycled_pid` + runs `/usr/bin/true` and guards a pid signal; on Windows the kill goes + through the process handle, which no other process can reuse. +- **Codemode admission and tests:** the integration baseline refused Windows + codemode with `unsupported_platform` and ignored its worker tests. The merged + tool implementation admits Windows with the same scope and budget checks as + Unix, starts workers with `CODEMODE_JOB_COMMIT_BYTES`, and runs the codemode + and real-SDK scope suites on both Windows images without those ignores. diff --git a/mutations.toml b/mutations.toml index cf27a5e..28ba7cd 100644 --- a/mutations.toml +++ b/mutations.toml @@ -546,8 +546,8 @@ edits = [ { file = "Cargo.lock", old = " \"subc-client-rs\",\n \"subc-protocol\",\n \"subc-transport\",\n \"tokio\",\n \"tracing\",\n \"webpki-roots 1.0.9\",\n", new = " \"subc-client-rs\",\n \"subc-protocol 0.30.0\",\n \"subc-transport\",\n \"tokio\",\n \"tracing\",\n \"webpki-roots 1.0.9\",\n" }, { file = "Cargo.lock", old = " \"serde\",\n \"serde_json\",\n \"sha2\",\n \"subc-client-rs\",\n \"subc-os\",\n \"subc-protocol\",\n \"subc-transport\",\n \"tokio\",\n \"tracing\",\n", new = " \"serde\",\n \"serde_json\",\n \"sha2\",\n \"subc-client-rs\",\n \"subc-os\",\n \"subc-protocol 0.30.0\",\n \"subc-transport\",\n \"tokio\",\n \"tracing\",\n" }, { file = "Cargo.lock", old = " \"rcgen\",\n \"rusqlite\",\n \"rustls\",\n \"serde_json\",\n \"subc-protocol\",\n", new = " \"rcgen\",\n \"rusqlite\",\n \"rustls\",\n \"serde_json\",\n \"subc-protocol 0.30.0\",\n" }, - { file = "Cargo.lock", old = " \"rusqlite\",\n \"serde_json\",\n \"sha2\",\n \"subc-client-rs\",\n \"subc-protocol\",\n \"tokio\",\n]\n", new = " \"rusqlite\",\n \"serde_json\",\n \"sha2\",\n \"subc-client-rs\",\n \"subc-protocol 0.30.0\",\n \"tokio\",\n]\n" }, - { file = "Cargo.lock", old = " \"rquickjs\",\n \"seccompiler\",\n \"serde_json\",\n]\n\n[[package]]\n", new = " \"rquickjs\",\n \"seccompiler\",\n \"serde_json\",\n \"subc-protocol 0.24.1\",\n]\n\n[[package]]\n" }, + { file = "Cargo.lock", old = " \"rusqlite\",\n \"serde_json\",\n \"sha2\",\n \"subc-client-rs\",\n \"subc-protocol\",\n \"tokio\",\n \"windows-sys 0.61.2\",\n]\n", new = " \"rusqlite\",\n \"serde_json\",\n \"sha2\",\n \"subc-client-rs\",\n \"subc-protocol 0.30.0\",\n \"tokio\",\n \"windows-sys 0.61.2\",\n]\n" }, + { file = "Cargo.lock", old = " \"rquickjs\",\n \"seccompiler\",\n \"serde_json\",\n \"windows-sys 0.61.2\",\n]\n\n[[package]]\n", new = " \"rquickjs\",\n \"seccompiler\",\n \"serde_json\",\n \"subc-protocol 0.24.1\",\n \"windows-sys 0.61.2\",\n]\n\n[[package]]\n" }, { file = "Cargo.lock", old = " \"serde_json\",\n \"subc-control\",\n \"subc-os\",\n \"subc-protocol\",\n \"subc-transport\",\n \"tokio\",\n \"tokio-util\",\n", new = " \"serde_json\",\n \"subc-control\",\n \"subc-os\",\n \"subc-protocol 0.30.0\",\n \"subc-transport\",\n \"tokio\",\n \"tokio-util\",\n" }, { file = "Cargo.lock", old = "dependencies = [\n \"serde\",\n \"serde_json\",\n \"subc-protocol\",\n]\n", new = "dependencies = [\n \"serde\",\n \"serde_json\",\n \"subc-protocol 0.30.0\",\n]\n" }, { file = "Cargo.lock", old = "dependencies = [\n \"libc\",\n \"rustix\",\n]\n\n[[package]]\n", new = "dependencies = [\n \"libc\",\n \"rustix\",\n]\n\n[[package]]\nname = \"subc-protocol\"\nversion = \"0.24.1\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\nchecksum = \"3b0fb1bc9305afe14d1202970b7e361253dba6108525b356a7e10274436bc80d\"\ndependencies = [\n \"serde\",\n \"serde_json\",\n]\n\n[[package]]\n" }, @@ -8217,22 +8217,12 @@ id = "production-random-restarts-a-predictable-counter" guards = "Fresh production processes draw unpredictable random values while replay uses the journal" file = "crates/basal-host/src/core_host.rs" old = """ -let mut x = 0u64; - // Live draws use OS entropy, not shared predictable process state. The - // runtime journals each draw and replay serves that saved value instead. - // There is deliberately no fallback when the OS cannot supply entropy. - let rc = unsafe { libc::getentropy((&mut x as *mut u64).cast(), std::mem::size_of::()) }; - assert_eq!( - rc, - 0, - "OS entropy unavailable: {}", - std::io::Error::last_os_error() - );""" + fill_entropy(&mut x);""" new = """ // NON-VACUITY BREAK use std::sync::atomic::{AtomicU64, Ordering}; static SEQUENCE: AtomicU64 = AtomicU64::new(1); - let x = SEQUENCE.fetch_add(1, Ordering::Relaxed).wrapping_mul(0x9e3779b97f4a7c15);""" + x = SEQUENCE.fetch_add(1, Ordering::Relaxed).wrapping_mul(0x9e3779b97f4a7c15);""" edits = [] test_file = "crates/basal-host/tests/host_boundary_regressions.rs" runner = "cargo" @@ -9922,12 +9912,12 @@ new = "self.fail(\"budget_exhausted\", format!(\"{kind:?}\"), true) // NON-VACUI # Codemode wire and worker-profile guards. These basal-proto library and # basal-worker codemode tests exercise platform-independent behavior. Mutation -# proofs run once on Linux; workspace gates run the tests on both supported OSes. +# proofs run once on Linux; workspace gates run the tests on all three supported OSes. [[control]] id = "codemode-protocol-version" -hub = "Both the protocol version assertion and the fixed Welcome wire fixture pin version 7" +hub = "Both the protocol version assertion and the fixed Welcome wire fixture pin version 8" hub_targets = ["basal_proto"] -guards = "the worker wire requires protocol version 7 to distinguish target-agent module calls" +guards = "the worker wire requires protocol version 8 for merged Windows confinement and target-agent calls" test_file = "crates/basal-proto/src/lib.rs" runner = "cargo" package = "basal-proto" @@ -9936,8 +9926,8 @@ target = "--lib" expect_red = ["tests::codemode_wire_codes_preserve_shell_and_round_trip_tool_json"] only = false file = "crates/basal-proto/src/limits.rs" -old = "pub const PROTOCOL_VERSION: u32 = 7;" -new = "pub const PROTOCOL_VERSION: u32 = 6; // NON-VACUITY BREAK" +old = "pub const PROTOCOL_VERSION: u32 = 8;" +new = "pub const PROTOCOL_VERSION: u32 = 7; // NON-VACUITY BREAK" [[control]] id = "codemode-address-space-headroom" @@ -11564,29 +11554,22 @@ new = "Lookup::Pruned => None, // NON-VACUITY BREAK" [[control]] id = "codemode-admission-platform" -guards = "Windows is refused before scope and request checks" +guards = "Windows codemode admission follows the same scope and budget checks as Unix" test_file = "crates/basal-core/src/codemode/admission_tests.rs" runner = "cargo" package = "basal-core" platforms = ["linux"] target = "--lib" -expect_red = ["codemode::admission::tests::injected_windows_is_refused_before_scope_or_request_checks"] -only = false +expect_red = ["codemode::admission::tests::windows_admission_uses_the_same_scope_and_budget_checks"] +only = true file = "crates/basal-core/src/codemode/admission.rs" -old = '''if platform == Platform::Windows { - return Ok(Admission::Refused(Refusal::new( - "unsupported_platform", - "codemode is not supported on Windows", - ))); +old = ''' let checked = (|| { + let scope = scope_shape(request)?;''' +new = ''' if _platform == Platform::Windows { // NON-VACUITY BREAK + return Ok(Admission::Refused(Refusal::new("unsupported_platform", "Windows refused"))); } - let checked = (|| {''' -new = '''if platform == Platform::Windows && false { // NON-VACUITY BREAK - return Ok(Admission::Refused(Refusal::new( - "unsupported_platform", - "codemode is not supported on Windows", - ))); - } - let checked = (|| {''' + let checked = (|| { + let scope = scope_shape(request)?;''' [[control]] id = "codemode-admission-reserved-owner" @@ -15864,138 +15847,1330 @@ only = false timeout_s = 600 build_timeout_s = 1800 -# Rig-only probe: agent and run identity come from the daemon's bound-route -# stamp, never values the caller supplies in tool arguments. +# Windows worker startup, launcher attestation, filesystem and Git controls. [[control]] -id = "rig-probe-run-id-required" -guards = "probe rejects an agent scope without a run id" -test_file = "crates/basal-rig/src/bin/ckdev-runscope-probe.rs" +id = "windows-manifest-root-skips-raw-spelling" +guards = "Windows manifest roots refuse unsafe raw spellings before installation" +test_file = "crates/basal-core/src/manifest.rs" runner = "cargo" -package = "basal-rig" +package = "basal-core" platforms = ["linux"] -target = "--bin ckdev-runscope-probe" -expect_red = ["tests::requires_run_id"] -only = false +target = "--lib" +expect_red = ["manifest::tests::windows_roots_are_the_fs_built_ins_drive_paths_or_under_the_profile"] +only = true build_timeout_s = 3600 timeout_s = 600 -file = "crates/basal-rig/src/bin/ckdev-runscope-probe.rs" -old = '''.run_id - .as_deref() - .ok_or("run_scope_required")?;''' -new = '''.run_id - .as_deref() - .unwrap_or("NON-VACUITY BREAK");''' +file = "crates/basal-core/src/manifest.rs" +old = "basal_host::builtins::fs::windows::validate_raw_spelling(&spelling)\n .map_err(|denial| invalid(field, format!(\"{root:?}: {}\", denial.message)))" +new = "let _ = (field, spelling); Ok(()) // NON-VACUITY BREAK" [[control]] -id = "rig-probe-agent-id-required" -guards = "probe rejects a run scope without an agent id" -test_file = "crates/basal-rig/src/bin/ckdev-runscope-probe.rs" +id = "windows-sibling-worker-loses-exe-suffix" +guards = "Worker executable naming preserves a trailing case-insensitive exe suffix" +test_file = "crates/basal-module/src/pool.rs" runner = "cargo" -package = "basal-rig" +package = "basal-module" platforms = ["linux"] -target = "--bin ckdev-runscope-probe" -expect_red = ["tests::requires_agent_id"] -only = false +target = "--lib" +expect_red = ["pool::names::worker_file_name_follows_parent_in_production_and_development"] +only = true build_timeout_s = 3600 timeout_s = 600 -file = "crates/basal-rig/src/bin/ckdev-runscope-probe.rs" -old = '.ok_or("scope_mismatch")?;' -new = '.unwrap_or("NON-VACUITY BREAK");' +file = "crates/basal-module/src/pool.rs" +old = "name.push(\".exe\");" +new = "let _ = &name; // NON-VACUITY BREAK" [[control]] -id = "rig-probe-core-owner-required" -guards = "probe rejects a run scope owned by another module" -test_file = "crates/basal-rig/src/bin/ckdev-runscope-probe.rs" +id = "retention-unix-names-become-lossy" +guards = "Retention preserves raw Unix names already stored in temporary-file records" +test_file = "crates/basal-core/src/retention.rs" runner = "cargo" -package = "basal-rig" +package = "basal-core" platforms = ["linux"] -target = "--bin ckdev-runscope-probe" -expect_red = ["tests::requires_core_owner"] +target = "--lib" +expect_red = ["retention::os_bytes_tests::unix_names_are_stored_as_their_raw_bytes"] +only = true +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-core/src/retention.rs" +old = "value.as_bytes().to_vec()" +new = "value.to_string_lossy().as_bytes().to_vec() // NON-VACUITY BREAK" + +# Windows startup refuses unsafe tokens and checks the loader/thread-pool +# handles that must remain open after its pre-input cleanup. + +[[control]] +id = "windows-worker-skips-thread-token-present" +guards = "Worker refuses startup with thread-token-present before reading its first frame" +test_file = "crates/basal-worker/tests/windows_startup.rs" +runner = "cargo" +package = "basal-worker" +platforms = ["windows"] +target = "--test windows_startup" +features = ["deviations"] +expect_red = ["a_kept_start_up_token_is_refused_as_thread_token_present"] only = false build_timeout_s = 3600 timeout_s = 600 -file = "crates/basal-rig/src/bin/ckdev-runscope-probe.rs" -old = 'if !core_owner {' -new = 'if false { // NON-VACUITY BREAK' +file = "crates/basal-worker/src/confinement/windows/startup.rs" +old = "drop_thread_token(arguments.keeps_thread_token())?;" +new = "let _ = drop_thread_token(arguments.keeps_thread_token()); // NON-VACUITY BREAK" [[control]] -id = "rig-probe-authorized-owner-required" -guards = "probe rejects an unauthorized scope owner" -test_file = "crates/basal-rig/src/bin/ckdev-runscope-probe.rs" +id = "windows-worker-skips-integrity-lower-failed" +guards = "Worker refuses startup with integrity-lower-failed before reading its first frame" +test_file = "crates/basal-worker/tests/windows_startup.rs" runner = "cargo" -package = "basal-rig" -platforms = ["linux"] -target = "--bin ckdev-runscope-probe" -expect_red = ["tests::requires_authorized_owner"] +package = "basal-worker" +platforms = ["windows"] +target = "--test windows_startup" +features = ["deviations"] +expect_red = ["a_failed_lowering_is_refused_as_integrity_lower_failed"] only = false build_timeout_s = 3600 timeout_s = 600 -file = "crates/basal-rig/src/bin/ckdev-runscope-probe.rs" -old = 'if !scope.owner_authorized {' -new = 'if false { // NON-VACUITY BREAK' +file = "crates/basal-worker/src/confinement/windows/startup.rs" +old = "native::set_untrusted(token.0).map_err(refuse)?;" +new = "let _ = native::set_untrusted(token.0); // NON-VACUITY BREAK" [[control]] -id = "rig-probe-delegation-refused" -guards = "probe rejects delegated agent authority on a run scope" -test_file = "crates/basal-rig/src/bin/ckdev-runscope-probe.rs" +id = "windows-worker-skips-not-lpac" +guards = "Worker refuses startup with not-lpac before reading its first frame" +test_file = "crates/basal-worker/tests/windows_startup.rs" runner = "cargo" -package = "basal-rig" -platforms = ["linux"] -target = "--bin ckdev-runscope-probe" -expect_red = ["tests::refuses_delegation"] +package = "basal-worker" +platforms = ["windows"] +target = "--test windows_startup" +expect_red = ["a_primary_without_the_lpac_claim_is_refused_as_not_lpac"] only = false build_timeout_s = 3600 timeout_s = 600 -file = "crates/basal-rig/src/bin/ckdev-runscope-probe.rs" -old = 'if scope.attributes.delegates {' -new = 'if false { // NON-VACUITY BREAK' +file = "crates/basal-worker/src/confinement/windows/attest.rs" +old = "if !matches!(claim, Claim::Unsigned(values) if values.as_slice() == [1]) {" +new = "if false && !matches!(claim, Claim::Unsigned(values) if values.as_slice() == [1]) { // NON-VACUITY BREAK" [[control]] -id = "rig-probe-flow-scope-refused" -guards = "probe rejects flow identity instead of run identity" -test_file = "crates/basal-rig/src/bin/ckdev-runscope-probe.rs" +id = "windows-worker-skips-capabilities-present" +guards = "Worker refuses startup with capabilities-present before reading its first frame" +test_file = "crates/basal-worker/tests/windows_startup.rs" runner = "cargo" -package = "basal-rig" -platforms = ["linux"] -target = "--bin ckdev-runscope-probe" -expect_red = ["tests::refuses_flow_scope"] +package = "basal-worker" +platforms = ["windows"] +target = "--test windows_startup" +expect_red = ["a_primary_with_a_capability_is_refused_as_capabilities_present"] only = false build_timeout_s = 3600 timeout_s = 600 -file = "crates/basal-rig/src/bin/ckdev-runscope-probe.rs" -old = 'if scope.attributes.flow_id.is_some() {' -new = 'if false { // NON-VACUITY BREAK' +file = "crates/basal-worker/src/confinement/windows/attest.rs" +old = "if !capabilities.is_empty() {" +new = "if false && !capabilities.is_empty() { // NON-VACUITY BREAK" [[control]] -id = "rig-no-model-proof-counts-real-runs" -guards = "no-model rig evidence observes newly started Broca runs" -test_file = "crates/basal-rig/tests/stores.rs" +id = "windows-worker-skips-restricting-sid-mismatch" +guards = "Worker refuses startup with restricting-sid-mismatch before reading its first frame" +test_file = "crates/basal-worker/tests/windows_startup.rs" runner = "cargo" -package = "basal-rig" -platforms = ["linux"] -target = "--test stores" -expect_red = ["broca_run_count_observes_real_insertions_and_store_errors"] +package = "basal-worker" +platforms = ["windows"] +target = "--test windows_startup" +expect_red = ["a_second_restricting_sid_is_refused_as_restricting_sid_mismatch"] only = false build_timeout_s = 3600 timeout_s = 600 -file = "crates/basal-rig/src/stores.rs" -old = 'c.query_row("SELECT COUNT(*) FROM run_index", [], |r| r.get(0))' -new = 'c.query_row("SELECT COUNT(*) FROM run_index WHERE 0", [], |r| r.get(0)) // NON-VACUITY BREAK' +file = "crates/basal-worker/src/confinement/windows/attest.rs" +old = "if sids.as_slice() != [NULL_SID] {" +new = "if false && sids.as_slice() != [NULL_SID] { // NON-VACUITY BREAK" [[control]] -id = "rig-seeded-recovery-requires-exact-next-generation" -guards = "seeded stale recovery is confirmed at exactly the learned floor plus one" -test_file = "crates/basal-rig/src/bin/basal-rig-contract/seeded.rs" +id = "windows-worker-skips-group-not-deny-only" +guards = "Worker refuses startup with group-not-deny-only before reading its first frame" +test_file = "crates/basal-worker/tests/windows_startup.rs" runner = "cargo" -package = "basal-rig" -platforms = ["linux"] -target = "--bin basal-rig-contract" -expect_red = ["seeded::tests::bounded_recovery_requires_exact_next_generation_and_confirmation"] +package = "basal-worker" +platforms = ["windows"] +target = "--test windows_startup" +expect_red = ["an_enabled_logon_group_is_refused_as_group_not_deny_only"] only = false build_timeout_s = 3600 timeout_s = 600 -file = "crates/basal-rig/src/bin/basal-rig-contract/seeded.rs" -old = '''instance["generation"] == seeded + 1 - && instance["confirmed_version"] == 1''' -new = '''instance["generation"].as_i64().is_some_and(|generation| generation >= seeded) // NON-VACUITY BREAK - && instance["confirmed_version"] == 1''' +file = "crates/basal-worker/src/confinement/windows/attest.rs" +old = "if !enabled.is_empty() {" +new = "if false && !enabled.is_empty() { // NON-VACUITY BREAK" + +[[control]] +id = "windows-worker-skips-privileges-present" +guards = "Worker refuses startup with privileges-present before reading its first frame" +test_file = "crates/basal-worker/tests/windows_startup.rs" +runner = "cargo" +package = "basal-worker" +platforms = ["windows"] +target = "--test windows_startup" +expect_red = ["a_primary_with_privileges_is_refused_as_privileges_present"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-worker/src/confinement/windows/attest.rs" +old = "if privileges != 0 {" +new = "if false && privileges != 0 { // NON-VACUITY BREAK" + +[[control]] +id = "windows-worker-skips-integrity-not-untrusted" +guards = "Worker refuses startup with integrity-not-untrusted before reading its first frame" +test_file = "crates/basal-worker/tests/windows_startup.rs" +runner = "cargo" +package = "basal-worker" +platforms = ["windows"] +target = "--test windows_startup" +features = ["deviations"] +expect_red = ["a_skipped_lowering_is_refused_as_integrity_not_untrusted"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-worker/src/confinement/windows/attest.rs" +old = "if integrity != UNTRUSTED_INTEGRITY {" +new = "if false && integrity != UNTRUSTED_INTEGRITY { // NON-VACUITY BREAK" + +[[control]] +id = "windows-worker-skips-mitigation-mismatch" +guards = "Worker refuses startup with mitigation-mismatch before reading its first frame" +test_file = "crates/basal-worker/tests/windows_startup.rs" +runner = "cargo" +package = "basal-worker" +platforms = ["windows"] +target = "--test windows_startup" +expect_red = ["allowed_dynamic_code_is_refused_as_mitigation_mismatch"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-worker/src/confinement/windows/startup.rs" +old = "mitigation::validate(&words)\n .map_err(|error| Refusal::new(reason::MITIGATION_MISMATCH, error))?;" +new = "let _ = mitigation::validate(&words); // NON-VACUITY BREAK" + +[[control]] +id = "windows-worker-skips-handle-not-allowed" +guards = "Worker refuses startup with handle-not-allowed before reading its first frame" +test_file = "crates/basal-worker/tests/windows_startup.rs" +runner = "cargo" +package = "basal-worker" +platforms = ["windows"] +target = "--test windows_startup" +expect_red = ["an_inherited_handle_beyond_the_pipes_is_refused_as_handle_not_allowed"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-worker/src/confinement/windows/startup.rs" +old = "allowlist::check(&table).map_err(|error| Refusal::new(reason::HANDLE_NOT_ALLOWED, error))?;" +new = "let _ = allowlist::check(&table); // NON-VACUITY BREAK" + +[[control]] +id = "windows-allowlist-skips-alpc-close" +guards = "windows allowlist skips alpc close" +test_file = "crates/basal-worker/src/confinement/windows/allowlist.rs" +runner = "cargo" +package = "basal-worker" +platforms = ["windows"] +target = "--lib" +expect_red = ["confinement::windows::allowlist::tests::startup_closes_ports_and_private_files_only"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-worker/src/confinement/windows/allowlist.rs" +old = "\"ALPC Port\" => true," +new = "\"ALPC Port\" => false, // NON-VACUITY BREAK" + +[[control]] +id = "windows-allowlist-skips-private-file-close" +guards = "windows allowlist skips private file close" +test_file = "crates/basal-worker/src/confinement/windows/allowlist.rs" +runner = "cargo" +package = "basal-worker" +platforms = ["windows"] +target = "--lib" +expect_red = ["confinement::windows::allowlist::tests::startup_closes_ports_and_private_files_only"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-worker/src/confinement/windows/allowlist.rs" +old = "\"File\" => !stdio && !inheritable," +new = "\"File\" => false, // NON-VACUITY BREAK" + +[[control]] +id = "windows-allowlist-skips-unknown-row" +guards = "windows allowlist skips unknown row" +test_file = "crates/basal-worker/src/confinement/windows/allowlist.rs" +runner = "cargo" +package = "basal-worker" +platforms = ["windows"] +target = "--lib" +expect_red = ["confinement::windows::allowlist::tests::an_unknown_row_is_refused"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-worker/src/confinement/windows/allowlist.rs" +old = "None => refused.push(describe(entry))," +new = "None => {} // NON-VACUITY BREAK" + +[[control]] +id = "windows-allowlist-skips-excess-count" +guards = "windows allowlist skips excess count" +test_file = "crates/basal-worker/src/confinement/windows/allowlist.rs" +runner = "cargo" +package = "basal-worker" +platforms = ["windows"] +target = "--lib" +expect_red = ["confinement::windows::allowlist::tests::an_excess_count_is_refused"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-worker/src/confinement/windows/allowlist.rs" +old = "if count > row.count {" +new = "if false && count > row.count { // NON-VACUITY BREAK" + +[[control]] +id = "windows-allowlist-skips-mixed-inventory" +guards = "windows allowlist skips mixed inventory" +test_file = "crates/basal-worker/src/confinement/windows/allowlist.rs" +runner = "cargo" +package = "basal-worker" +platforms = ["windows"] +target = "--lib" +expect_red = ["confinement::windows::allowlist::tests::the_mixed_table_fits_no_profile"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-worker/src/confinement/windows/allowlist.rs" +old = "pub const PROFILES: [&Profile; 2] = [&WINDOWS_LATEST, &WINDOWS_2022];" +new = "pub const UNION_PROFILE: Profile = Profile { // NON-VACUITY BREAK\n name: \"union\",\n ceilings: [\n ceiling(\"Event\", 0x001f_0003, 7),\n ceiling(\"IoCompletion\", 0x001f_0003, 2),\n ceiling(\"TpWorkerFactory\", 0x000f_00ff, 2),\n ceiling(\"IRTimer\", 0x0010_0002, 4),\n ceiling(\"WaitCompletionPacket\", 0x1, 6),\n ceiling(\"Semaphore\", 0x0010_0003, 2),\n ceiling(\"SchedulerSharedData\", 0x1, 1),\n ],\n total: 28,\n};\npub const PROFILES: [&Profile; 2] = [&UNION_PROFILE, &UNION_PROFILE];" + +[[control]] +id = "windows-allowlist-skips-exact-pipes" +guards = "windows allowlist skips exact pipes" +test_file = "crates/basal-worker/src/confinement/windows/allowlist.rs" +runner = "cargo" +package = "basal-worker" +platforms = ["windows"] +target = "--lib" +expect_red = ["confinement::windows::allowlist::tests::a_missing_pipe_or_known_dlls_is_refused"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-worker/src/confinement/windows/allowlist.rs" +old = "if stdio[index] != 1 {" +new = "if false && stdio[index] != 1 { // NON-VACUITY BREAK" + +[[control]] +id = "windows-allowlist-skips-known-dlls-count" +guards = "windows allowlist skips known dlls count" +test_file = "crates/basal-worker/src/confinement/windows/allowlist.rs" +runner = "cargo" +package = "basal-worker" +platforms = ["windows"] +target = "--lib" +expect_red = ["confinement::windows::allowlist::tests::a_missing_pipe_or_known_dlls_is_refused"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-worker/src/confinement/windows/allowlist.rs" +old = "if known_dlls != 1 {" +new = "if false && known_dlls != 1 { // NON-VACUITY BREAK" + +[[control]] +id = "windows-allowlist-skips-inheritable-non-stdio" +guards = "windows allowlist skips inheritable non stdio" +test_file = "crates/basal-worker/src/confinement/windows/allowlist.rs" +runner = "cargo" +package = "basal-worker" +platforms = ["windows"] +target = "--lib" +expect_red = ["confinement::windows::allowlist::tests::an_inheritable_handle_that_is_not_stdio_is_refused"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-worker/src/confinement/windows/allowlist.rs" +old = "if entry.inheritable {\n refused.push(format!(\"inheritable {}\", describe(entry)));" +new = "if false && entry.inheritable { // NON-VACUITY BREAK\n refused.push(format!(\"inheritable {}\", describe(entry)));" + +[[control]] +id = "windows-allowlist-skips-known-dlls-identity" +guards = "windows allowlist skips known dlls identity" +test_file = "crates/basal-worker/src/confinement/windows/allowlist.rs" +runner = "cargo" +package = "basal-worker" +platforms = ["windows"] +target = "--lib" +expect_red = ["confinement::windows::allowlist::tests::only_the_known_dlls_directory_is_allowed"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-worker/src/confinement/windows/allowlist.rs" +old = "if entry.name.as_deref() == Some(KNOWN_DLLS) && entry.access == KNOWN_DLLS_ACCESS {" +new = "if entry.access == KNOWN_DLLS_ACCESS { // NON-VACUITY BREAK" + +[[control]] +id = "windows-allowlist-skips-stdio-identity" +guards = "windows allowlist skips stdio identity" +test_file = "crates/basal-worker/src/confinement/windows/allowlist.rs" +runner = "cargo" +package = "basal-worker" +platforms = ["windows"] +target = "--lib" +expect_red = ["confinement::windows::allowlist::tests::a_standard_handle_must_be_its_own_pipe"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-worker/src/confinement/windows/allowlist.rs" +old = "if entry.type_name == \"File\" && entry.access == access {" +new = "if true { // NON-VACUITY BREAK" + +[[control]] +id = "windows-confinement-wire-tag" +guards = "The Windows report uses protocol tag 3 independently of its decoder" +test_file = "crates/basal-proto/src/lib.rs" +runner = "cargo" +package = "basal-proto" +platforms = ["windows"] +target = "--lib" +expect_red = ["tests::confinement_wire_tags_and_linux_fields_are_stable"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-proto/src/wire/windows.rs" +old = "enc.u8(TAG);" +new = "enc.u8(99); // NON-VACUITY BREAK" + +[[control]] +id = "windows-fault-status-is-not-counted" +guards = "windows fault status is not counted" +test_file = "crates/basal-module/src/windows.rs" +runner = "cargo" +package = "basal-module" +platforms = ["windows"] +target = "--lib" +expect_red = ["windows::tests::fault_statuses_count_and_the_refusal_and_kill_codes_do_not"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-module/src/windows.rs" +old = "matches!(code, STATUS_ACCESS_VIOLATION | STATUS_INVALID_HANDLE)" +new = "false // NON-VACUITY BREAK" + +# Before resume the parent checks the suspended child's job and tokens. +# Process creation fixes its mitigation bits; all spawns share one lock while +# their child's standard handles are inheritable. + +[[control]] +id = "windows-parent-skips-job-limits-mismatch" +guards = "windows parent skips job limits mismatch" +test_file = "crates/basal-launch/tests/windows_launch.rs" +runner = "cargo" +package = "basal-launch" +platforms = ["windows"] +target = "--test windows_launch" +features = ["deviations"] +expect_red = ["deviations::a_job_with_other_limits_is_refused_as_job_limits_mismatch"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-launch/src/windows/launch.rs" +old = "if limits != required {" +new = "if false && limits != required { // NON-VACUITY BREAK" + +[[control]] +id = "windows-parent-skips-not-in-owned-job" +guards = "windows parent skips not in owned job" +test_file = "crates/basal-launch/tests/windows_launch.rs" +runner = "cargo" +package = "basal-launch" +platforms = ["windows"] +target = "--test windows_launch" +features = ["deviations"] +expect_red = ["deviations::a_child_outside_the_owned_job_is_refused_as_not_in_owned_job"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-launch/src/windows/launch.rs" +old = "if !member {" +new = "if false && !member { // NON-VACUITY BREAK" + +[[control]] +id = "windows-parent-skips-birth-token-mismatch" +guards = "windows parent skips birth token mismatch" +test_file = "crates/basal-launch/tests/windows_launch.rs" +runner = "cargo" +package = "basal-launch" +platforms = ["windows"] +target = "--test windows_launch" +features = ["deviations"] +expect_red = ["deviations::a_primary_with_privileges_is_refused_as_birth_token_mismatch"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-launch/src/windows/launch.rs" +old = "token::check_birth(&birth, expected)\n .map_err(|error| LaunchError::refused(Refusal::BirthTokenMismatch, error))?;" +new = "let _ = token::check_birth(&birth, expected); // NON-VACUITY BREAK" + +[[control]] +id = "windows-parent-skips-initial-token-open" +guards = "windows parent skips initial token open" +test_file = "crates/basal-launch/tests/windows_launch.rs" +runner = "cargo" +package = "basal-launch" +platforms = ["windows"] +target = "--test windows_launch" +features = ["deviations"] +expect_red = ["deviations::a_missing_start_up_token_is_refused_as_initial_token_open"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-launch/src/windows/launch.rs" +old = "let assigned = thread_token(child.thread.as_raw_handle())\n .and_then(|token| token::read_facts(token.as_raw_handle()))\n .map_err(|error| LaunchError::refused(Refusal::InitialTokenOpen, error))?;\n token::check_initial(&assigned, &expected.package)\n .map_err(|error| LaunchError::refused(Refusal::InitialTokenOpen, error))?;" +new = "let _ = child; // NON-VACUITY BREAK" + +[[control]] +id = "windows-creation-drops-strict-handle-checks" +guards = "windows creation drops strict handle checks" +test_file = "crates/basal-launch/src/windows/launch.rs" +runner = "cargo" +package = "basal-launch" +platforms = ["windows"] +target = "--lib" +expect_red = ["windows::launch::tests::the_mitigation_policy_is_the_eight_always_on_bits"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-launch/src/windows/launch.rs" +old = "pub const MITIGATION_STRICT_HANDLE_CHECKS: u64 = 1 << 24;" +new = "pub const MITIGATION_STRICT_HANDLE_CHECKS: u64 = 0; // NON-VACUITY BREAK" + +[[control]] +id = "windows-creation-drops-win32k-system-call-disable" +guards = "windows creation drops win32k system call disable" +test_file = "crates/basal-launch/src/windows/launch.rs" +runner = "cargo" +package = "basal-launch" +platforms = ["windows"] +target = "--lib" +expect_red = ["windows::launch::tests::the_mitigation_policy_is_the_eight_always_on_bits"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-launch/src/windows/launch.rs" +old = "pub const MITIGATION_WIN32K_SYSTEM_CALL_DISABLE: u64 = 1 << 28;" +new = "pub const MITIGATION_WIN32K_SYSTEM_CALL_DISABLE: u64 = 0; // NON-VACUITY BREAK" + +[[control]] +id = "windows-creation-drops-extension-point-disable" +guards = "windows creation drops extension point disable" +test_file = "crates/basal-launch/src/windows/launch.rs" +runner = "cargo" +package = "basal-launch" +platforms = ["windows"] +target = "--lib" +expect_red = ["windows::launch::tests::the_mitigation_policy_is_the_eight_always_on_bits"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-launch/src/windows/launch.rs" +old = "pub const MITIGATION_EXTENSION_POINT_DISABLE: u64 = 1 << 32;" +new = "pub const MITIGATION_EXTENSION_POINT_DISABLE: u64 = 0; // NON-VACUITY BREAK" + +[[control]] +id = "windows-creation-drops-prohibit-dynamic-code" +guards = "windows creation drops prohibit dynamic code" +test_file = "crates/basal-launch/src/windows/launch.rs" +runner = "cargo" +package = "basal-launch" +platforms = ["windows"] +target = "--lib" +expect_red = ["windows::launch::tests::the_mitigation_policy_is_the_eight_always_on_bits"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-launch/src/windows/launch.rs" +old = "pub const MITIGATION_PROHIBIT_DYNAMIC_CODE: u64 = 1 << 36;" +new = "pub const MITIGATION_PROHIBIT_DYNAMIC_CODE: u64 = 0; // NON-VACUITY BREAK" + +[[control]] +id = "windows-creation-drops-microsoft-signed-only" +guards = "windows creation drops microsoft signed only" +test_file = "crates/basal-launch/src/windows/launch.rs" +runner = "cargo" +package = "basal-launch" +platforms = ["windows"] +target = "--lib" +expect_red = ["windows::launch::tests::the_mitigation_policy_is_the_eight_always_on_bits"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-launch/src/windows/launch.rs" +old = "pub const MITIGATION_MICROSOFT_SIGNED_ONLY: u64 = 1 << 44;" +new = "pub const MITIGATION_MICROSOFT_SIGNED_ONLY: u64 = 0; // NON-VACUITY BREAK" + +[[control]] +id = "windows-creation-drops-no-remote-images" +guards = "windows creation drops no remote images" +test_file = "crates/basal-launch/src/windows/launch.rs" +runner = "cargo" +package = "basal-launch" +platforms = ["windows"] +target = "--lib" +expect_red = ["windows::launch::tests::the_mitigation_policy_is_the_eight_always_on_bits"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-launch/src/windows/launch.rs" +old = "pub const MITIGATION_NO_REMOTE_IMAGES: u64 = 1 << 52;" +new = "pub const MITIGATION_NO_REMOTE_IMAGES: u64 = 0; // NON-VACUITY BREAK" + +[[control]] +id = "windows-creation-drops-no-low-label-images" +guards = "windows creation drops no low label images" +test_file = "crates/basal-launch/src/windows/launch.rs" +runner = "cargo" +package = "basal-launch" +platforms = ["windows"] +target = "--lib" +expect_red = ["windows::launch::tests::the_mitigation_policy_is_the_eight_always_on_bits"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-launch/src/windows/launch.rs" +old = "pub const MITIGATION_NO_LOW_LABEL_IMAGES: u64 = 1 << 56;" +new = "pub const MITIGATION_NO_LOW_LABEL_IMAGES: u64 = 0; // NON-VACUITY BREAK" + +[[control]] +id = "windows-creation-drops-prefer-system32-images" +guards = "windows creation drops prefer system32 images" +test_file = "crates/basal-launch/src/windows/launch.rs" +runner = "cargo" +package = "basal-launch" +platforms = ["windows"] +target = "--lib" +expect_red = ["windows::launch::tests::the_mitigation_policy_is_the_eight_always_on_bits"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-launch/src/windows/launch.rs" +old = "pub const MITIGATION_PREFER_SYSTEM32_IMAGES: u64 = 1 << 60;" +new = "pub const MITIGATION_PREFER_SYSTEM32_IMAGES: u64 = 0; // NON-VACUITY BREAK" + +[[control]] +id = "windows-plain-spawn-releases-lock-before-creation" +guards = "windows plain spawn releases lock before creation" +test_file = "crates/basal-launch/src/windows/plain/tests.rs" +runner = "cargo" +package = "basal-launch" +platforms = ["windows"] +target = "--lib" +expect_red = ["windows::plain::tests::the_child_is_created_suspended_under_the_lock_inheriting_only_its_stdio"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-launch/src/windows/plain.rs" +old = " let mut info: PROCESS_INFORMATION = unsafe { zeroed() };\n check(\n unsafe {\n CreateProcessW(\n application.as_ptr(),\n command_line.as_mut_ptr(),\n null(),\n null(),\n 1,\n CREATION_FLAGS,\n environment.as_ptr().cast::(),\n cwd.as_ref().map_or(null(), |cwd| cwd.as_ptr()),\n &startup.StartupInfo,\n &mut info,\n )\n },\n \"CreateProcessW\",\n )?;\n let created = Created {\n process: unsafe { OwnedHandle::from_raw_handle(info.hProcess) },\n thread: unsafe { OwnedHandle::from_raw_handle(info.hThread) },\n };\n #[cfg(test)]\n tests::observe(&created, &inherited);\n // The child holds its own copies now. Closing the parent's before the\n // lock is released ends the window in which another spawn could copy\n // them, and lets the pipes report end of file when the child exits.\n drop(attributes);\n let [stdin, stdout, stderr] = [stdin, stdout, stderr].map(Stdio::close_child);\n drop(spawn_lock);" +new = " drop(spawn_lock); // NON-VACUITY BREAK\n let mut info: PROCESS_INFORMATION = unsafe { zeroed() };\n check(\n unsafe {\n CreateProcessW(\n application.as_ptr(),\n command_line.as_mut_ptr(),\n null(),\n null(),\n 1,\n CREATION_FLAGS,\n environment.as_ptr().cast::(),\n cwd.as_ref().map_or(null(), |cwd| cwd.as_ptr()),\n &startup.StartupInfo,\n &mut info,\n )\n },\n \"CreateProcessW\",\n )?;\n let created = Created {\n process: unsafe { OwnedHandle::from_raw_handle(info.hProcess) },\n thread: unsafe { OwnedHandle::from_raw_handle(info.hThread) },\n };\n #[cfg(test)]\n tests::observe(&created, &inherited);\n // The child holds its own copies now. Closing the parent's before the\n // lock is released ends the window in which another spawn could copy\n // them, and lets the pipes report end of file when the child exits.\n drop(attributes);\n let [stdin, stdout, stderr] = [stdin, stdout, stderr].map(Stdio::close_child);\n" + +[[control]] +id = "windows-plain-spawn-drops-handle-list" +guards = "windows plain spawn drops handle list" +test_file = "crates/basal-launch/src/windows/plain/tests.rs" +runner = "cargo" +package = "basal-launch" +platforms = ["windows"] +target = "--lib" +expect_red = ["windows::plain::tests::the_child_is_created_suspended_under_the_lock_inheriting_only_its_stdio"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-launch/src/windows/plain.rs" +old = "attributes.add(PROC_THREAD_ATTRIBUTE_HANDLE_LIST, &inherited)?;" +new = "// NON-VACUITY BREAK: no inherited-handle list" + +[[control]] +id = "windows-plain-spawn-drops-job-list" +guards = "windows plain spawn drops job list" +test_file = "crates/basal-launch/src/windows/plain/tests.rs" +runner = "cargo" +package = "basal-launch" +platforms = ["windows"] +target = "--lib" +expect_red = ["windows::plain::tests::the_child_is_in_a_fresh_kill_on_close_job_without_breakaway"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-launch/src/windows/plain.rs" +old = "attributes.add(PROC_THREAD_ATTRIBUTE_JOB_LIST, &jobs)?;" +new = "// NON-VACUITY BREAK: no creation job list" + +[[control]] +id = "windows-launcher-context-names-are-predictable" +guards = "windows launcher context names are predictable" +test_file = "crates/basal-launch/src/windows/context.rs" +runner = "cargo" +package = "basal-launch" +platforms = ["windows"] +target = "--lib" +expect_red = ["windows::context::tests::context_names_are_random_and_never_repeat"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-launch/src/windows/context.rs" +old = "Ok(format!(\"cortexkit_basal_{hex}\"))" +new = "static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);\n Ok(format!(\"cortexkit_basal_{}_{}\", std::process::id(), NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed))) // NON-VACUITY BREAK" + +[[control]] +id = "windows-git-spawn-releases-lock-before-creation" +guards = "windows git spawn releases lock before creation" +test_file = "crates/basal-host/src/builtins/git/windows.rs" +runner = "cargo" +package = "basal-host" +platforms = ["windows"] +target = "--lib" +expect_red = ["builtins::git::windows::spawn_lock_tests::windows_git_children_are_created_under_the_spawn_lock"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-host/src/builtins/git/windows.rs" +old = " let mut process: PROCESS_INFORMATION = unsafe { std::mem::zeroed() };\n // SAFETY: buffers, owned attribute payloads, handles and startup structure\n // remain live through creation; lpApplicationName is the canonical image.\n if unsafe {\n CreateProcessW(\n application.as_ptr(),\n command_line.as_mut_ptr(),\n std::ptr::null(),\n std::ptr::null(),\n 1,\n EXTENDED_STARTUPINFO_PRESENT\n | CREATE_NO_WINDOW\n | CREATE_UNICODE_ENVIRONMENT\n | CREATE_SUSPENDED,\n env.as_ptr().cast(),\n cwd_pointer,\n &startup.StartupInfo,\n &mut process,\n )\n } == 0\n {\n return Err(native_error(\"start git\"));\n }\n #[cfg(test)]\n spawn_lock_tests::created();\n // The child now holds its own copies of its three standard handles, so\n // the parent closes its copies before releasing the spawn lock. That\n // also lets the pipes report end of file once git exits.\n drop(attributes);\n drop((stdin, stdout_writer, stderr_writer));\n drop(spawn_lock);" +new = " drop(spawn_lock); // NON-VACUITY BREAK\n let mut process: PROCESS_INFORMATION = unsafe { std::mem::zeroed() };\n // SAFETY: buffers, owned attribute payloads, handles and startup structure\n // remain live through creation; lpApplicationName is the canonical image.\n if unsafe {\n CreateProcessW(\n application.as_ptr(),\n command_line.as_mut_ptr(),\n std::ptr::null(),\n std::ptr::null(),\n 1,\n EXTENDED_STARTUPINFO_PRESENT\n | CREATE_NO_WINDOW\n | CREATE_UNICODE_ENVIRONMENT\n | CREATE_SUSPENDED,\n env.as_ptr().cast(),\n cwd_pointer,\n &startup.StartupInfo,\n &mut process,\n )\n } == 0\n {\n return Err(native_error(\"start git\"));\n }\n #[cfg(test)]\n spawn_lock_tests::created();\n // The child now holds its own copies of its three standard handles, so\n // the parent closes its copies before releasing the spawn lock. That\n // also lets the pipes report end of file once git exits.\n drop(attributes);\n drop((stdin, stdout_writer, stderr_writer));\n" + +# Windows filesystem path spelling, containment and replacement permissions; +# Git config/hook isolation, output bounds and child-tree termination. + +[[control]] +id = "windows-fs-skips-raw-spelling" +guards = "windows fs skips raw spelling" +test_file = "crates/basal-host/src/builtins/fs/windows.rs" +runner = "cargo" +package = "basal-host" +platforms = ["windows"] +target = "--lib" +expect_red = ["builtins::fs::windows::tests::spelling_refuses_unc_and_device_namespaces"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-host/src/builtins/fs/windows.rs" +old = "pub fn validate_raw_spelling(path: &str) -> Result<(), Denial> {" +new = "pub fn validate_raw_spelling(path: &str) -> Result<(), Denial> {\n return Ok(()); // NON-VACUITY BREAK" + +[[control]] +id = "windows-fs-skips-ads" +guards = "windows fs skips ads" +test_file = "crates/basal-host/src/builtins/fs/windows.rs" +runner = "cargo" +package = "basal-host" +platforms = ["windows"] +target = "--lib" +expect_red = ["builtins::fs::windows::tests::spelling_refuses_alternate_data_streams"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-host/src/builtins/fs/windows.rs" +old = "if comp.contains(':') {" +new = "if false && comp.contains(':') { // NON-VACUITY BREAK" + +[[control]] +id = "windows-fs-skips-device-names" +guards = "windows fs skips device names" +test_file = "crates/basal-host/src/builtins/fs/windows.rs" +runner = "cargo" +package = "basal-host" +platforms = ["windows"] +target = "--lib" +expect_red = ["builtins::fs::windows::tests::spelling_refuses_reserved_device_names"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-host/src/builtins/fs/windows.rs" +old = "if is_reserved_device_name(base) {" +new = "if false && is_reserved_device_name(base) { // NON-VACUITY BREAK" + +[[control]] +id = "windows-fs-skips-trailing-dot-space" +guards = "windows fs skips trailing dot space" +test_file = "crates/basal-host/src/builtins/fs/windows.rs" +runner = "cargo" +package = "basal-host" +platforms = ["windows"] +target = "--lib" +expect_red = ["builtins::fs::windows::tests::spelling_refuses_trailing_dots_and_spaces"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-host/src/builtins/fs/windows.rs" +old = "if comp.ends_with('.') || comp.ends_with(' ') {" +new = "if false && (comp.ends_with('.') || comp.ends_with(' ')) { // NON-VACUITY BREAK" + +[[control]] +id = "windows-fs-skips-empty-component" +guards = "windows fs skips empty component" +test_file = "crates/basal-host/src/builtins/fs/windows.rs" +runner = "cargo" +package = "basal-host" +platforms = ["windows"] +target = "--lib" +expect_red = ["builtins::fs::windows::tests::spelling_refuses_relative_and_empty_components"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-host/src/builtins/fs/windows.rs" +old = "if comp.is_empty() {" +new = "if false && comp.is_empty() { // NON-VACUITY BREAK" + +[[control]] +id = "windows-fs-skips-component-containment" +guards = "windows fs skips component containment" +test_file = "crates/basal-host/src/builtins/fs/windows.rs" +runner = "cargo" +package = "basal-host" +platforms = ["windows"] +target = "--lib" +expect_red = ["builtins::fs::windows::tests::guid_path_inside_component_wise_matches"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-host/src/builtins/fs/windows.rs" +old = "if r != t {" +new = "if false && r != t { // NON-VACUITY BREAK" + +[[control]] +id = "windows-fs-skips-file-root-descendants" +guards = "windows fs skips file root descendants" +test_file = "crates/basal-host/src/builtins/fs/windows.rs" +runner = "cargo" +package = "basal-host" +platforms = ["windows"] +target = "--lib" +expect_red = ["builtins::fs::windows::tests::guid_path_inside_component_wise_matches"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-host/src/builtins/fs/windows.rs" +old = "if !is_directory_root && target_parts.len() != root_parts.len() {" +new = "if false && !is_directory_root && target_parts.len() != root_parts.len() { // NON-VACUITY BREAK" + +[[control]] +id = "windows-fs-skips-reparse-refusal" +guards = "windows fs skips reparse refusal" +test_file = "crates/basal-host/src/builtins/fs/windows.rs" +runner = "cargo" +package = "basal-host" +platforms = ["windows"] +target = "--lib" +expect_red = ["builtins::fs::windows::tests::win_tests::windows_reparse_points_at_root_middle_leaf"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-host/src/builtins/fs/windows.rs" +old = "if basic.FileAttributes & ffi::FILE_ATTRIBUTE_REPARSE_POINT != 0 {" +new = "if false && basic.FileAttributes & ffi::FILE_ATTRIBUTE_REPARSE_POINT != 0 { // NON-VACUITY BREAK" + +[[control]] +id = "windows-fs-skips-write-dacl" +guards = "windows fs skips write dacl" +test_file = "crates/basal-host/src/builtins/fs/windows.rs" +runner = "cargo" +package = "basal-host" +platforms = ["windows"] +target = "--lib" +expect_red = ["builtins::fs::windows::tests::win_tests::windows_replace_keeps_the_protected_dacl"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-host/src/builtins/fs/windows.rs" +old = "security_descriptor: target_dacl.as_ref().map_or(std::ptr::null_mut(), |d| d.sd)," +new = "security_descriptor: std::ptr::null_mut(), // NON-VACUITY BREAK" + +[[control]] +id = "windows-fs-skips-write-parent-pinning" +guards = "windows fs skips write parent pinning" +test_file = "crates/basal-host/src/builtins/fs/windows.rs" +runner = "cargo" +package = "basal-host" +platforms = ["windows"] +target = "--lib" +expect_red = ["builtins::fs::windows::tests::win_tests::windows_write_parent_cannot_be_moved_out_during_the_write"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-host/src/builtins/fs/windows.rs" +old = "const SHARE_NO_DELETE: u32 = ffi::FILE_SHARE_READ | ffi::FILE_SHARE_WRITE;" +new = "const SHARE_NO_DELETE: u32 = ffi::FILE_SHARE_READ | ffi::FILE_SHARE_WRITE | ffi::FILE_SHARE_DELETE; // NON-VACUITY BREAK" + +[[control]] +id = "windows-fs-skips-flush-refusal" +guards = "windows fs skips flush refusal" +test_file = "crates/basal-host/src/builtins/fs/windows.rs" +runner = "cargo" +package = "basal-host" +platforms = ["windows"] +target = "--lib" +expect_red = ["builtins::fs::windows::tests::win_tests::windows_failed_flush_stops_the_replace"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-host/src/builtins/fs/windows.rs" +old = "if flush_status < 0 {" +new = "if false && flush_status < 0 { // NON-VACUITY BREAK" + +[[control]] +id = "windows-fs-skips-rename-refusal" +guards = "windows fs skips rename refusal" +test_file = "crates/basal-host/src/builtins/fs/windows.rs" +runner = "cargo" +package = "basal-host" +platforms = ["windows"] +target = "--lib" +expect_red = ["builtins::fs::windows::tests::win_tests::windows_posix_rename_refusal_simulated"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-host/src/builtins/fs/windows.rs" +old = "if rename_status < 0 {" +new = "if false && rename_status < 0 { // NON-VACUITY BREAK" + +[[control]] +id = "windows-fs-skips-tilde-expansion" +guards = "windows fs skips tilde expansion" +test_file = "crates/basal-host/src/builtins/fs/windows.rs" +runner = "cargo" +package = "basal-host" +platforms = ["windows"] +target = "--lib" +expect_red = ["builtins::fs::windows::tests::win_tests::tilde_root_reads_below_the_process_tokens_profile"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-host/src/builtins/fs/windows.rs" +old = "let r = expand_root(r)?;" +new = "let r = std::borrow::Cow::Borrowed(r); // NON-VACUITY BREAK" + +[[control]] +id = "windows-git-skips-global-config-isolation" +guards = "windows git skips global config isolation" +test_file = "crates/basal-host/src/builtins/git/windows/tests.rs" +runner = "cargo" +package = "basal-host" +platforms = ["windows"] +target = "--lib" +expect_red = ["builtins::git::windows::tests::windows_contaminated_global_config_positive_control_and_isolation"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-host/src/builtins/git/windows.rs" +old = " .env(\n \"GIT_CONFIG_GLOBAL\",\n isolation.directory.join(\"global.config\"),\n )" +new = " // NON-VACUITY BREAK: use the user-global configuration" + +[[control]] +id = "windows-git-skips-hooks-suppression" +guards = "windows git skips hooks suppression" +test_file = "crates/basal-host/src/builtins/git/windows/tests.rs" +runner = "cargo" +package = "basal-host" +platforms = ["windows"] +target = "--lib" +expect_red = ["builtins::git::windows::tests::windows_hook_positive_control_and_hardened_suppression"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-host/src/builtins/git/windows.rs" +old = "hooks.push(isolation.directory.join(\"hooks\"));" +new = "hooks.push(\".git/hooks\"); // NON-VACUITY BREAK" + +[[control]] +id = "windows-git-skips-fsmonitor-suppression" +guards = "windows git skips fsmonitor suppression" +test_file = "crates/basal-host/src/builtins/git/windows/tests.rs" +runner = "cargo" +package = "basal-host" +platforms = ["windows"] +target = "--lib" +expect_red = ["builtins::git::windows::tests::windows_fsmonitor_positive_control_and_hardened_suppression"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-host/src/builtins/git/windows.rs" +old = " \"-c\",\n \"core.fsmonitor=false\"," +new = " // NON-VACUITY BREAK: use the repository's fsmonitor hook" + +[[control]] +id = "windows-git-skips-environment-isolation" +guards = "windows git skips environment isolation" +test_file = "crates/basal-host/src/builtins/git/windows/tests.rs" +runner = "cargo" +package = "basal-host" +platforms = ["windows"] +target = "--lib" +expect_red = ["builtins::git::windows::tests::windows_inherited_git_environment_is_not_imported"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-host/src/builtins/git/windows.rs" +old = "if !hardened {" +new = "if true { // NON-VACUITY BREAK" + +[[control]] +id = "windows-git-skips-byte-cap" +guards = "windows git skips byte cap" +test_file = "crates/basal-host/src/builtins/git/windows/tests.rs" +runner = "cargo" +package = "basal-host" +platforms = ["windows"] +target = "--lib" +expect_red = ["builtins::git::windows::tests::windows_tags_stop_while_reading_and_enforce_byte_cap"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-host/src/builtins/git/windows.rs" +old = "if cap > STDERR_BYTES && output.bytes.len() > MAX_OUTPUT_BYTES {" +new = "if false && cap > STDERR_BYTES && output.bytes.len() > MAX_OUTPUT_BYTES { // NON-VACUITY BREAK" + +[[control]] +id = "windows-git-skips-kill-on-close" +guards = "windows git skips kill on close" +test_file = "crates/basal-host/src/builtins/git/windows/tests.rs" +runner = "cargo" +package = "basal-host" +platforms = ["windows"] +target = "--lib" +expect_red = ["builtins::git::windows::tests::windows_attribute_payloads_and_job_membership_before_work"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-host/src/builtins/git/windows.rs" +old = "limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;" +new = "limits.BasicLimitInformation.LimitFlags = 0; // NON-VACUITY BREAK" + +[[control]] +id = "windows-git-timeout-leaves-descendant-live" +guards = "windows git timeout leaves descendant live" +test_file = "crates/basal-host/src/builtins/git/windows/tests.rs" +runner = "cargo" +package = "basal-host" +platforms = ["windows"] +target = "--lib" +expect_red = ["builtins::git::windows::tests::windows_timeout_proves_live_descendant_died"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-host/src/builtins/git/windows.rs" +old = " if self.terminate().is_err() {\n let _ = self.terminate();\n }\n // Last-handle close is the kill-on-close fallback, before any joins.\n self.job.take();" +new = " if let Some(job) = self.job.take() {\n std::mem::forget(job); // NON-VACUITY BREAK\n }" + +[[control]] +id = "windows-mitigations-allow-thread-opt-out" +guards = "windows mitigations allow thread opt out" +test_file = "crates/basal-worker/src/confinement/windows/mitigation.rs" +runner = "cargo" +package = "basal-worker" +platforms = ["windows"] +target = "--lib" +expect_red = ["confinement::windows::mitigation::tests::every_required_bit_with_a_forbidden_opt_out_is_refused"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-worker/src/confinement/windows/mitigation.rs" +old = "if forbidden.policy.word(words) & forbidden.mask != 0 {" +new = "if false && forbidden.policy.word(words) & forbidden.mask != 0 { // NON-VACUITY BREAK" + +[[control]] +id = "windows-welcome-ignores-false-confinement-fields" +guards = "The parent refuses every false field in a Windows confinement report" +test_file = "crates/basal-module/src/acceptance_tests.rs" +runner = "cargo" +package = "basal-module" +platforms = ["windows"] +target = "--lib" +expect_red = ["pool::acceptance_tests::windows_confinement_acceptance"] +only = true +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-module/src/windows.rs" +old = "lpac && untrusted && no_thread_token && mitigations && handle_table" +new = "true // NON-VACUITY BREAK" + +[[control]] +id = "windows-worker-missing-package-sid-is-not-refused" +guards = "A missing package SID is refused before worker confinement starts" +test_file = "crates/basal-worker/tests/windows_startup.rs" +runner = "cargo" +package = "basal-worker" +platforms = ["windows"] +target = "--test windows_startup" +expect_red = ["a_missing_package_sid_exits_70_package_sid_argument_missing"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-worker/src/confinement/windows.rs" +old = "let package_sid = package_sid.ok_or(ArgumentError::Missing)?;" +new = "let package_sid = package_sid.unwrap_or(\"S-1-15-2-1\"); // NON-VACUITY BREAK" + +[[control]] +id = "windows-parent-loses-profile-unavailable-reason" +guards = "An unavailable AppContainer profile has its named parent refusal before any launch" +test_file = "crates/basal-launch/src/windows/mod.rs" +runner = "cargo" +package = "basal-launch" +platforms = ["windows"] +target = "--lib" +expect_red = ["windows::tests::an_unusable_profile_is_refused_by_name"] +only = true +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-launch/src/windows/profile.rs" +old = "LaunchError::refused(Refusal::AppContainerProfileUnavailable, detail)" +new = "LaunchError::Failed(detail) // NON-VACUITY BREAK" + +[[control]] +id = "windows-profile-root-uses-userprofile-environment" +guards = "Profile root expansion uses the process token rather than a caller-controlled environment variable" +test_file = "crates/basal-launch/src/windows/profile.rs" +runner = "cargo" +package = "basal-launch" +platforms = ["windows"] +target = "--lib" +expect_red = ["windows::profile::tests::the_process_token_profile_ignores_userprofile_environment"] +only = true +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-launch/src/windows/profile.rs" +old = "pub fn process_profile_directory() -> Result {" +new = "pub fn process_profile_directory() -> Result {\n return std::env::var(\"USERPROFILE\").map_err(|error| error.to_string()); // NON-VACUITY BREAK" + +[[control]] +id = "windows-worker-import-inventory-omits-kernel32" +guards = "The production worker's actual imports must be present in the committed inventory" +test_file = "crates/basal-worker/tests/windows_image.rs" +runner = "cargo" +package = "basal-worker" +platforms = ["windows"] +target = "--test windows_image" +expect_red = ["the_worker_is_a_gui_image_without_gui_com_or_c_runtime_imports"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-worker/tests/data/windows-imports.txt" +old = "kernel32.dll\n" +new = "# NON-VACUITY BREAK\n" + +[[control]] +id = "windows-confined-spawn-releases-lock-before-creation" +guards = "The full launcher holds the shared spawn lock until its inheritable pipe ends close" +test_file = "crates/basal-launch/tests/windows_launch.rs" +runner = "cargo" +package = "basal-launch" +platforms = ["windows"] +target = "--test windows_launch" +features = ["deviations"] +expect_red = ["deviations::a_confined_child_is_created_under_the_shared_spawn_lock"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-launch/src/windows/launch.rs" +old = " let spawned = match deviation.shape() {\n Shape::Confined => spawn_confined(\n options,\n commit_bytes,\n &package,\n parent_token.as_raw_handle(),\n &context,\n &inherited,\n &mut command,\n )?,\n #[cfg(feature = \"deviations\")]\n Shape::LpacOnly | Shape::Plain => spawn_control(\n options,\n &package,\n &context,\n &inherited,\n &mut command,\n deviation.shape() == Shape::LpacOnly,\n )?,\n };\n\n // The child has inherited its own copies of its three pipe ends; the\n // parent's copies would keep the pipes open after the child exits.\n drop((child_stdin, child_stdout, child_stderr));\n drop(spawn_lock);" +new = " drop(spawn_lock); // NON-VACUITY BREAK\n let spawned = match deviation.shape() {\n Shape::Confined => spawn_confined(\n options,\n commit_bytes,\n &package,\n parent_token.as_raw_handle(),\n &context,\n &inherited,\n &mut command,\n )?,\n #[cfg(feature = \"deviations\")]\n Shape::LpacOnly | Shape::Plain => spawn_control(\n options,\n &package,\n &context,\n &inherited,\n &mut command,\n deviation.shape() == Shape::LpacOnly,\n )?,\n };\n\n // The child has inherited its own copies of its three pipe ends; the\n // parent's copies would keep the pipes open after the child exits.\n drop((child_stdin, child_stdout, child_stderr));\n" + +[[control]] +id = "windows-creation-drops-child-process-policy" +guards = "The worker attests the child-process creation restriction before any activation" +test_file = "crates/basal-worker/tests/windows_startup.rs" +runner = "cargo" +package = "basal-worker" +platforms = ["windows"] +target = "--test windows_startup" +expect_red = ["an_activation_completes_under_the_full_confinement"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-launch/src/windows/launch.rs" +old = "attributes.add(PROC_THREAD_ATTRIBUTE_CHILD_PROCESS_POLICY, &child_policy)?;" +new = "// NON-VACUITY BREAK: no child-process creation policy" + +# The codemode manifest description is checked against the fixed bytes in +# crates/basal-module/tests/fixtures/codemode-description.txt, not another call +# to the manifest builder. +[[control]] +id = "codemode-tool-manifest-description" +guards = "the public codemode tool manifest preserves its pinned description" +test_file = "crates/basal-module/tests/codemode.rs" +runner = "cargo" +package = "basal-module" +platforms = ["linux"] +target = "--test codemode" +expect_red = ["tool_manifest_description_and_schema_are_pinned_and_old_entries_are_gone"] +only = true +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-module/src/manifest.rs" +old = "description: Some(crate::tool::DESCRIPTION.into())," +new = 'description: Some(format!("{} Changed", crate::tool::DESCRIPTION)), // NON-VACUITY BREAK' +# Rig-only probe: agent and run identity come from the daemon's bound-route +# stamp, never values the caller supplies in tool arguments. +[[control]] +id = "rig-probe-run-id-required" +guards = "probe rejects an agent scope without a run id" +test_file = "crates/basal-rig/src/bin/ckdev-runscope-probe.rs" +runner = "cargo" +package = "basal-rig" +platforms = ["linux"] +target = "--bin ckdev-runscope-probe" +expect_red = ["tests::requires_run_id"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-rig/src/bin/ckdev-runscope-probe.rs" +old = '''.run_id + .as_deref() + .ok_or("run_scope_required")?;''' +new = '''.run_id + .as_deref() + .unwrap_or("NON-VACUITY BREAK");''' + +[[control]] +id = "rig-probe-agent-id-required" +guards = "probe rejects a run scope without an agent id" +test_file = "crates/basal-rig/src/bin/ckdev-runscope-probe.rs" +runner = "cargo" +package = "basal-rig" +platforms = ["linux"] +target = "--bin ckdev-runscope-probe" +expect_red = ["tests::requires_agent_id"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-rig/src/bin/ckdev-runscope-probe.rs" +old = '.ok_or("scope_mismatch")?;' +new = '.unwrap_or("NON-VACUITY BREAK");' + +[[control]] +id = "rig-probe-core-owner-required" +guards = "probe rejects a run scope owned by another module" +test_file = "crates/basal-rig/src/bin/ckdev-runscope-probe.rs" +runner = "cargo" +package = "basal-rig" +platforms = ["linux"] +target = "--bin ckdev-runscope-probe" +expect_red = ["tests::requires_core_owner"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-rig/src/bin/ckdev-runscope-probe.rs" +old = 'if !core_owner {' +new = 'if false { // NON-VACUITY BREAK' + +[[control]] +id = "rig-probe-authorized-owner-required" +guards = "probe rejects an unauthorized scope owner" +test_file = "crates/basal-rig/src/bin/ckdev-runscope-probe.rs" +runner = "cargo" +package = "basal-rig" +platforms = ["linux"] +target = "--bin ckdev-runscope-probe" +expect_red = ["tests::requires_authorized_owner"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-rig/src/bin/ckdev-runscope-probe.rs" +old = 'if !scope.owner_authorized {' +new = 'if false { // NON-VACUITY BREAK' + +[[control]] +id = "rig-probe-delegation-refused" +guards = "probe rejects delegated agent authority on a run scope" +test_file = "crates/basal-rig/src/bin/ckdev-runscope-probe.rs" +runner = "cargo" +package = "basal-rig" +platforms = ["linux"] +target = "--bin ckdev-runscope-probe" +expect_red = ["tests::refuses_delegation"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-rig/src/bin/ckdev-runscope-probe.rs" +old = 'if scope.attributes.delegates {' +new = 'if false { // NON-VACUITY BREAK' + +[[control]] +id = "rig-probe-flow-scope-refused" +guards = "probe rejects flow identity instead of run identity" +test_file = "crates/basal-rig/src/bin/ckdev-runscope-probe.rs" +runner = "cargo" +package = "basal-rig" +platforms = ["linux"] +target = "--bin ckdev-runscope-probe" +expect_red = ["tests::refuses_flow_scope"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-rig/src/bin/ckdev-runscope-probe.rs" +old = 'if scope.attributes.flow_id.is_some() {' +new = 'if false { // NON-VACUITY BREAK' + +[[control]] +id = "rig-no-model-proof-counts-real-runs" +guards = "no-model rig evidence observes newly started Broca runs" +test_file = "crates/basal-rig/tests/stores.rs" +runner = "cargo" +package = "basal-rig" +platforms = ["linux"] +target = "--test stores" +expect_red = ["broca_run_count_observes_real_insertions_and_store_errors"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-rig/src/stores.rs" +old = 'c.query_row("SELECT COUNT(*) FROM run_index", [], |r| r.get(0))' +new = 'c.query_row("SELECT COUNT(*) FROM run_index WHERE 0", [], |r| r.get(0)) // NON-VACUITY BREAK' + +[[control]] +id = "rig-seeded-recovery-requires-exact-next-generation" +guards = "seeded stale recovery is confirmed at exactly the learned floor plus one" +test_file = "crates/basal-rig/src/bin/basal-rig-contract/seeded.rs" +runner = "cargo" +package = "basal-rig" +platforms = ["linux"] +target = "--bin basal-rig-contract" +expect_red = ["seeded::tests::bounded_recovery_requires_exact_next_generation_and_confirmation"] +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-rig/src/bin/basal-rig-contract/seeded.rs" +old = '''instance["generation"] == seeded + 1 + && instance["confirmed_version"] == 1''' +new = '''instance["generation"].as_i64().is_some_and(|generation| generation >= seeded) // NON-VACUITY BREAK + && instance["confirmed_version"] == 1''' + +# Teardown waits for owners, not just empty activation slots or stopped pools. +[[control]] +id = "test-engine-cleanup-joins-activations" +guards = "engine fixture deletion waits for activation threads to release the store" +test_file = "crates/basal-module/src/engine_tests.rs" +runner = "cargo" +package = "basal-module" +platforms = ["linux"] +target = "--lib" +expect_red = ["engine::lifecycle_tests::engine_cleanup_joins_activation_before_removing_its_store"] +select = "expected" +only = false +build_timeout_s = 3600 +timeout_s = 600 +file = "crates/basal-module/src/engine_tests.rs" +old = " engine.join_threads();" +new = " // NON-VACUITY BREAK: skip joining activation threads" + +[[control]] +id = "test-module-fixture-cleanup-drops-module-first" +guards = "shared fixture deletion follows module shutdown and release of its store" +test_file = "crates/basal-module/tests/fixture_teardown.rs" +runner = "cargo" +package = "basal-module" +platforms = ["linux"] +target = "--test fixture_teardown" +expect_red = ["fixture_releases_its_store_before_removing_the_directory"] +only = true +build_timeout_s = 3600 +timeout_s = 600 +edits = [ + {file="crates/basal-module/tests/common/mod.rs",old="pub struct Fixture {\n pub module: Module,",new="pub struct Fixture {\n _cleanup: DirectoryCleanup, // NON-VACUITY BREAK\n pub module: Module,"}, + {file="crates/basal-module/tests/common/mod.rs",old=" _cleanup: DirectoryCleanup,\n}",new="}"}, +] diff --git a/script/tests/__init__.py b/script/tests/__init__.py index 2a2ceb9..69884d8 100644 --- a/script/tests/__init__.py +++ b/script/tests/__init__.py @@ -10,4 +10,5 @@ def load_tests(loader, tests, pattern): "script.tests.flows_rig", "script.tests.sign_worker", "script.tests.stage_card", + "script.tests.witness_mutations", ]) diff --git a/script/tests/witness_mutations.py b/script/tests/witness_mutations.py new file mode 100644 index 0000000..1a3e4ad --- /dev/null +++ b/script/tests/witness_mutations.py @@ -0,0 +1,85 @@ +"""Mutation witnesses must observe the indexed mutant and a named test failure.""" +import importlib.util +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest import mock + +PATH = Path(__file__).resolve().parents[1] / "witness-mutations.py" +SPEC = importlib.util.spec_from_file_location("witness_mutations", PATH) +assert SPEC is not None and SPEC.loader is not None +WITNESS = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(WITNESS) + + +class MutationWitnessChecks(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + subprocess.run(["git", "init", "--quiet", str(self.root)], check=True) + self.source = self.root / "guard.rs" + self.source.write_text("guard();\n") + self.output = self.root / "proofs" + self.output.mkdir() + self.row = {"id": "guard", "runner": "cargo", "package": "fixture", "target": "--lib", + "file": "guard.rs", "old": "guard();", "new": "// NON-VACUITY BREAK", + "expect_red": ["tests::guard"], "features": ["deviations"]} + self.real_run = subprocess.run + self.root_patch = mock.patch.object(WITNESS, "ROOT", self.root) + self.root_patch.start() + self.addCleanup(self.root_patch.stop) + + def cargo(self, args, **kwargs): + if args[0] != "cargo": + return self.real_run(args, **kwargs) + if "NON-VACUITY BREAK" in self.source.read_text(): + return subprocess.CompletedProcess(args, 101, "test tests::guard ... FAILED\n0 passed; 1 failed; 9 filtered out\n") + return subprocess.CompletedProcess(args, 0, "test tests::guard ... ok\n1 passed; 0 failed\n") + + def test_feature_flags_apply_to_the_named_cargo_oracle(self): + self.assertEqual(WITNESS.command(self.row), ["cargo", "test", "--locked", "-p", "fixture", + "--lib", "--features", "deviations", "--", "tests::guard", "--exact", "--nocapture"]) + + def test_witness_stages_live_source_and_restores_it_after_a_named_failure(self): + self.source.write_text("guard();\n// live implementation\n") + with mock.patch.object(WITNESS.subprocess, "run", side_effect=self.cargo): + proof = WITNESS.witness(self.row, self.output) + self.assertEqual(proof["outcome"], "reddened") + self.assertIn("guard.rs", proof["applied_evidence"]) + self.assertIn("after: (empty)", proof["applied_evidence"]) + self.assertEqual(self.source.read_text(), "guard();\n// live implementation\n") + self.assertEqual(WITNESS.git("diff", "--stat"), "") + self.assertIn("1 file changed", proof["applied_evidence"]) + + def test_a_build_failure_is_not_a_named_mutation_catch(self): + def fail_build(args, **kwargs): + if args[0] == "cargo" and "NON-VACUITY BREAK" in self.source.read_text(): + return subprocess.CompletedProcess(args, 101, "error: could not compile fixture\n") + return self.cargo(args, **kwargs) + with mock.patch.object(WITNESS.subprocess, "run", side_effect=fail_build): + proof = WITNESS.witness(self.row, self.output) + self.assertEqual(proof["outcome"], "not_reached") + self.assertEqual(self.source.read_text(), "guard();\n") + self.assertEqual(WITNESS.git("diff", "--stat"), "") + + def test_a_zero_test_baseline_is_refused(self): + def absent(args, **kwargs): + if args[0] == "cargo": + return subprocess.CompletedProcess(args, 0, "0 passed; 0 failed; 10 filtered out\n") + return self.real_run(args, **kwargs) + with mock.patch.object(WITNESS.subprocess, "run", side_effect=absent): + with self.assertRaisesRegex(SystemExit, "named baseline did not execute"): + WITNESS.witness(self.row, self.output) + self.assertEqual(self.source.read_text(), "guard();\n") + + def test_a_nonunique_anchor_is_refused_before_mutating(self): + self.source.write_text("guard();\nguard();\n") + with self.assertRaisesRegex(SystemExit, "exactly one anchor"): + WITNESS.witness(self.row, self.output) + self.assertEqual(self.source.read_text(), "guard();\nguard();\n") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/script/witness-mutations.py b/script/witness-mutations.py new file mode 100644 index 0000000..a4e8176 --- /dev/null +++ b/script/witness-mutations.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Capture indexed source diffs and exact named-test failures for Cargo controls. + +The pinned ckdev-mutate runner grades whether catalogue mutations are caught by +their declared test targets. This script adds the applied/restored Git diff and +an exact named-test failure; it does not replace that replay or coverage grading. +""" +import argparse +import json +from pathlib import Path +import shlex +import subprocess +import tomllib + +ROOT = Path(__file__).resolve().parents[1] + + +def git(*args): + return subprocess.check_output(["git", *args], cwd=ROOT, text=True).strip() + + +def command(row): + args = ["cargo", "test", "--locked", "-p", row["package"], *shlex.split(row["target"])] + if row.get("features"): + args.extend(["--features", ",".join(row["features"])]) + if row.get("no_default_features"): + args.append("--no-default-features") + if row.get("all_features"): + args.append("--all-features") + return args + ["--", row["expect_red"][0], "--exact", "--nocapture"] + + +def witness(row, output): + if row["runner"] != "cargo" or len(row["expect_red"]) != 1 or "edits" in row: + raise SystemExit(f"{row['id']}: witness requires a single-file Cargo control with one oracle") + path = ROOT / row["file"] + subprocess.run(["git", "add", "--", row["file"]], cwd=ROOT, check=True) + if git("diff", "--stat"): + raise SystemExit("mutation safety: stage the live working state before running witnesses") + original = path.read_text() + if original.count(row["old"]) != 1: + raise SystemExit(f"{row['id']}: expected exactly one anchor") + args = command(row) + baseline = subprocess.run(args, cwd=ROOT, text=True, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, timeout=row.get("build_timeout_s", 3600)) + test = row["expect_red"][0] + if baseline.returncode or f"test {test} ... ok" not in baseline.stdout: + (output / f"{row['id']}-baseline.log").write_text(baseline.stdout) + raise SystemExit(f"{row['id']}: named baseline did not execute and pass") + result = None + try: + if "NON-VACUITY BREAK" not in row["new"]: + raise SystemExit(f"{row['id']}: mutation lacks its safety marker") + path.write_text(original.replace(row["old"], row["new"])) + during = git("diff", "--stat") + if not during: + raise SystemExit("mutation safety: empty applied diff") + try: + result = subprocess.run(args, cwd=ROOT, text=True, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, timeout=row.get("build_timeout_s", 3600)) + text = result.stdout + except subprocess.TimeoutExpired as error: + text = (error.stdout or b"").decode(errors="replace") + finally: + subprocess.run(["git", "checkout", "--", row["file"]], cwd=ROOT, check=True) + path.touch() + after = git("diff", "--stat") + if after: + raise SystemExit("mutation safety: restored diff is not empty") + (output / f"{row['id']}-mutant.log").write_text(text) + expected = f"test {test} ... FAILED" + red = result is not None and result.returncode != 0 and expected in text and "0 passed; 1 failed" in text + reached = f"test {test} ..." in text + proof = { + "control": row["id"], + "expected_red": test, + "captured_output": expected + "; 0 passed; 1 failed; all other tests filtered" if red else text[-400:], + "applied_evidence": f"{row['file']}; during: {during}; after: {after or '(empty)'}", + "outcome": "reddened" if red else "hung" if result is None else "not_reached" if not reached else "undefended", + } + print(json.dumps(proof), flush=True) + return proof + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--platform", required=True, choices=["linux", "macos", "windows"]) + parser.add_argument("--only", action="append", default=[]) + parser.add_argument("--shard", default="1/1") + parser.add_argument("--output", required=True, type=Path) + args = parser.parse_args() + shard, total = map(int, args.shard.split("/")) + if not 1 <= shard <= total: + parser.error("shard must be in 1..total") + args.output.mkdir(parents=True, exist_ok=True) + rows = tomllib.loads((ROOT / "mutations.toml").read_text())["control"] + selected = sorted((row for row in rows if row["platforms"] == [args.platform] + and (not args.only or row["id"] in args.only)), key=lambda row: row["id"]) + if not selected or set(args.only) - {row["id"] for row in selected}: + raise SystemExit("no controls selected or an unknown control requested") + proofs = [] + for index, row in enumerate(selected): + if index % total != shard - 1: + continue + proofs.append(witness(row, args.output)) + (args.output / "mutation-evidence.json").write_text(json.dumps(proofs, indent=2)) + if proofs[-1]["outcome"] != "reddened": + raise SystemExit(f"{row['id']}: exact named oracle did not redden") + print(f"Mutation witnesses: {len(proofs)} exact named failures, all sources restored", flush=True) + + +if __name__ == "__main__": + main()