diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 09d3bfa3..5874e1c0 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -8,6 +8,9 @@ on: permissions: contents: read +env: + CANDIDATE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + jobs: rust: strategy: @@ -16,9 +19,14 @@ jobs: os: [ubuntu-latest, macos-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - name: Checkout exact candidate head + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: persist-credentials: false + ref: ${{ env.CANDIDATE_SHA }} + - name: Verify checkout identity + shell: bash + run: test "$(git rev-parse HEAD)" = "$CANDIDATE_SHA" - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c with: toolchain: 1.97.1 diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 0224ec4d..cfc2fe88 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -133,14 +133,80 @@ jobs: run: | set -euo pipefail BASELINE_SHA="8e92c5612a9ddc32996ed5e08475e3c9baa5e161" - git show "${BASELINE_SHA}:tests/walking_skeleton.rs" > tests/walking_skeleton.rs - python3 scripts/ci/run_exact_cargo_test.py \ - verifies_blocks_and_promotes_without_touching_primary_checkout \ - -- cargo test --locked --test walking_skeleton \ - verifies_blocks_and_promotes_without_touching_primary_checkout \ - -- --exact --test-threads=1 --nocapture - git restore --source=HEAD --worktree -- tests/walking_skeleton.rs - git diff --exit-code -- tests/walking_skeleton.rs + TEMP_PARENT="$(cd .. && pwd)/winds-t064-baseline-${GITHUB_RUN_ID}-${RANDOM}" + TEMP_WORKTREE="$TEMP_PARENT/candidate" + BASELINE_FIXTURE="$TEMP_PARENT/walking_skeleton.rs" + CARGO_TARGET_DIR="$TEMP_PARENT/target" + mkdir "$TEMP_PARENT" + cleanup_historical_worktree() { + original_status="${1:-0}" + cleanup_status=0 + if git worktree list --porcelain | grep -Fqx "worktree $TEMP_WORKTREE"; then + git worktree remove "$TEMP_WORKTREE" >/dev/null 2>&1 || cleanup_status=1 + fi + rm -rf -- "$CARGO_TARGET_DIR" || cleanup_status=1 + rm -f -- "$BASELINE_FIXTURE" || cleanup_status=1 + rmdir "$TEMP_PARENT" >/dev/null 2>&1 || cleanup_status=1 + if [ "$original_status" -ne 0 ]; then + if [ "$cleanup_status" -ne 0 ]; then + echo "historical verification failed with status $original_status; cleanup also failed and evidence was retained where possible" >&2 + fi + return "$original_status" + fi + return "$cleanup_status" + } + trap 'status=$?; trap - EXIT; cleanup_historical_worktree "$status"; exit $?' EXIT + git worktree add --detach "$TEMP_WORKTREE" "$CANDIDATE_SHA" + git show "${BASELINE_SHA}:tests/walking_skeleton.rs" > "$BASELINE_FIXTURE" + python3 - "$TEMP_WORKTREE/tests" "$BASELINE_FIXTURE" <<'PY' + import os + import stat + import sys + from pathlib import Path + + parent = Path(sys.argv[1]) + fixture = Path(sys.argv[2]) + target = parent / "walking_skeleton.rs" + parent_stat = os.lstat(parent) + target_stat = os.lstat(target) + if not stat.S_ISDIR(parent_stat.st_mode) or stat.S_ISLNK(parent_stat.st_mode): + raise SystemExit("historical test parent is not a real directory") + if not stat.S_ISREG(target_stat.st_mode) or stat.S_ISLNK(target_stat.st_mode): + raise SystemExit("historical test target is not a real regular file") + data = fixture.read_bytes() + directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0) + directory_fd = os.open(parent, directory_flags) + try: + target_flags = os.O_WRONLY | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0) + target_fd = os.open(target.name, target_flags, dir_fd=directory_fd) + try: + if not stat.S_ISREG(os.fstat(target_fd).st_mode): + raise SystemExit("historical test target changed type during safe open") + with os.fdopen(target_fd, "wb", closefd=False) as handle: + handle.write(data) + handle.flush() + os.fsync(handle.fileno()) + finally: + os.close(target_fd) + finally: + os.close(directory_fd) + PY + ( + cd "$TEMP_WORKTREE" + export CARGO_TARGET_DIR + python3 scripts/ci/run_exact_cargo_test.py \ + verifies_blocks_and_promotes_without_touching_primary_checkout \ + -- cargo test --locked --test walking_skeleton \ + verifies_blocks_and_promotes_without_touching_primary_checkout \ + -- --exact --test-threads=1 --nocapture + ) + git -C "$TEMP_WORKTREE" restore --source="$CANDIDATE_SHA" --worktree -- tests/walking_skeleton.rs + git -C "$TEMP_WORKTREE" diff --exit-code + test -z "$(git -C "$TEMP_WORKTREE" status --porcelain=v1 --untracked-files=all)" + cleanup_historical_worktree 0 + trap - EXIT + test "$(git rev-parse HEAD)" = "$CANDIDATE_SHA" + git diff --exit-code echo "T064_PINNED_WALKING_SKELETON_PROVEN=$BASELINE_SHA" - name: Prove partial-worktree recovery is non-destructive @@ -230,14 +296,77 @@ jobs: run: | set -euo pipefail SOURCE_SHA="ad4625ecd7f9a933613890cca74129857d0b4166" - git show "${SOURCE_SHA}:tests/walking_skeleton.rs" > tests/walking_skeleton.rs - python scripts/ci/run_exact_cargo_test.py \ - native_windows_refuses_authoritative_required_checks_without_mutation \ - -- cargo test --locked --test walking_skeleton \ - native_windows_refuses_authoritative_required_checks_without_mutation \ - -- --exact --test-threads=1 --nocapture - git restore --source=HEAD --worktree -- tests/walking_skeleton.rs - git diff --exit-code -- tests/walking_skeleton.rs + TEMP_PARENT_POSIX="$(cd .. && pwd)/winds-t064-windows-${GITHUB_RUN_ID}-${RANDOM}" + TEMP_WORKTREE_POSIX="$TEMP_PARENT_POSIX/candidate" + BASELINE_FIXTURE_POSIX="$TEMP_PARENT_POSIX/walking_skeleton.rs" + TEMP_WORKTREE_TESTS_WIN="$(cygpath -w "$TEMP_WORKTREE_POSIX/tests")" + BASELINE_FIXTURE_WIN="$(cygpath -w "$BASELINE_FIXTURE_POSIX")" + CARGO_TARGET_DIR="$(cygpath -w "$TEMP_PARENT_POSIX/target")" + mkdir "$TEMP_PARENT_POSIX" + cleanup_historical_worktree() { + original_status="${1:-0}" + cleanup_status=0 + if [ -d "$TEMP_WORKTREE_POSIX" ] && git -C "$TEMP_WORKTREE_POSIX" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + git worktree remove "$TEMP_WORKTREE_POSIX" >/dev/null 2>&1 || cleanup_status=1 + fi + rm -rf -- "$TEMP_PARENT_POSIX/target" || cleanup_status=1 + rm -f -- "$BASELINE_FIXTURE_POSIX" || cleanup_status=1 + rmdir "$TEMP_PARENT_POSIX" >/dev/null 2>&1 || cleanup_status=1 + if [ "$original_status" -ne 0 ]; then + if [ "$cleanup_status" -ne 0 ]; then + echo "historical Windows verification failed with status $original_status; cleanup also failed and evidence was retained where possible" >&2 + fi + return "$original_status" + fi + return "$cleanup_status" + } + trap 'status=$?; trap - EXIT; cleanup_historical_worktree "$status"; exit $?' EXIT + git worktree add --detach "$TEMP_WORKTREE_POSIX" "$CANDIDATE_SHA" + git show "${SOURCE_SHA}:tests/walking_skeleton.rs" > "$BASELINE_FIXTURE_POSIX" + python - "$TEMP_WORKTREE_TESTS_WIN" "$BASELINE_FIXTURE_WIN" <<'PY' + import os + import stat + import sys + from pathlib import Path + + parent = Path(sys.argv[1]) + fixture = Path(sys.argv[2]) + target = parent / "walking_skeleton.rs" + parent_stat = os.lstat(parent) + target_stat = os.lstat(target) + if not stat.S_ISDIR(parent_stat.st_mode) or stat.S_ISLNK(parent_stat.st_mode): + raise SystemExit("historical Windows test parent is not a real directory") + if not stat.S_ISREG(target_stat.st_mode) or stat.S_ISLNK(target_stat.st_mode): + raise SystemExit("historical Windows test target is not a real regular file") + data = fixture.read_bytes() + flags = os.O_WRONLY | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0) + target_fd = os.open(target, flags) + try: + if not stat.S_ISREG(os.fstat(target_fd).st_mode): + raise SystemExit("historical Windows test target changed type during safe open") + with os.fdopen(target_fd, "wb", closefd=False) as handle: + handle.write(data) + handle.flush() + os.fsync(handle.fileno()) + finally: + os.close(target_fd) + PY + ( + cd "$TEMP_WORKTREE_POSIX" + export CARGO_TARGET_DIR + python scripts/ci/run_exact_cargo_test.py \ + native_windows_refuses_authoritative_required_checks_without_mutation \ + -- cargo test --locked --test walking_skeleton \ + native_windows_refuses_authoritative_required_checks_without_mutation \ + -- --exact --test-threads=1 --nocapture + ) + git -C "$TEMP_WORKTREE_POSIX" restore --source="$CANDIDATE_SHA" --worktree -- tests/walking_skeleton.rs + git -C "$TEMP_WORKTREE_POSIX" diff --exit-code + test -z "$(git -C "$TEMP_WORKTREE_POSIX" status --porcelain=v1 --untracked-files=all)" + cleanup_historical_worktree 0 + trap - EXIT + test "$(git rev-parse HEAD)" = "$CANDIDATE_SHA" + git diff --exit-code echo "T064_PINNED_WINDOWS_AUTHORITY_PROVEN=$SOURCE_SHA" soak: @@ -455,4 +584,4 @@ jobs: dist/winds-v${{ steps.metadata.outputs.version }}-${{ matrix.target }}.tar.gz dist/winds-v${{ steps.metadata.outputs.version }}-${{ matrix.target }}.tar.gz.sha256 if-no-files-found: error - retention-days: 14 + retention-days: 14 \ No newline at end of file diff --git a/.github/workflows/windows-terminal.yml b/.github/workflows/windows-terminal.yml index 36069108..b64957aa 100644 --- a/.github/workflows/windows-terminal.yml +++ b/.github/workflows/windows-terminal.yml @@ -26,6 +26,9 @@ on: permissions: contents: read +env: + CANDIDATE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + jobs: unix-terminal-integration: name: unix-terminal-integration (${{ matrix.os }}) @@ -38,9 +41,14 @@ jobs: runs-on: ${{ matrix.os }} timeout-minutes: 15 steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - name: Checkout exact candidate head + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: persist-credentials: false + ref: ${{ env.CANDIDATE_SHA }} + - name: Verify checkout identity + shell: bash + run: test "$(git rev-parse HEAD)" = "$CANDIDATE_SHA" - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c with: toolchain: 1.97.1 @@ -50,12 +58,21 @@ jobs: run: cargo test --locked --test t057_cli minimal_cli_proves_workspace_profiles_execution_and_terminal_paths -- --test-threads=1 native-windows-terminal: - runs-on: windows-latest + runs-on: windows-2025 timeout-minutes: 25 steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - name: Checkout exact candidate head + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: persist-credentials: false + ref: ${{ env.CANDIDATE_SHA }} + - name: Verify checkout identity + shell: pwsh + run: | + $actual = (git rev-parse HEAD).Trim() + if ($actual -cne $env:CANDIDATE_SHA) { + throw "checkout identity mismatch: actual=$actual expected=$env:CANDIDATE_SHA" + } - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c with: toolchain: 1.97.1 @@ -86,15 +103,14 @@ jobs: uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: persist-credentials: false - ref: ${{ github.event.pull_request.head.sha || github.sha }} + ref: ${{ env.CANDIDATE_SHA }} - name: Verify checkout identity shell: pwsh run: | - $expected = "${{ github.event.pull_request.head.sha || github.sha }}" $actual = (git rev-parse HEAD).Trim() - if ($actual -cne $expected) { - throw "checkout identity mismatch: actual=$actual expected=$expected" + if ($actual -cne $env:CANDIDATE_SHA) { + throw "checkout identity mismatch: actual=$actual expected=$env:CANDIDATE_SHA" } - name: Install pinned Rust toolchain @@ -161,7 +177,7 @@ jobs: throw "T062 evidence JSON was not produced" } $evidence = Get-Content -LiteralPath $evidencePath -Raw | ConvertFrom-Json - $expected = "${{ github.event.pull_request.head.sha || github.sha }}" + $expected = $env:CANDIDATE_SHA if ([int]$evidence.schema_version -ne 1) { throw "unexpected T062 evidence schema_version" } if ($evidence.evidence -cne "T062_REAL_WINDOWS_WSL2_INTEGRATION") { throw "unexpected T062 evidence marker" } if ($evidence.repository_head -cne $expected) { throw "T062 evidence is not bound to exact candidate head" } diff --git a/SECURITY.md b/SECURITY.md index 8ed97774..d95d151b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -33,6 +33,8 @@ Reports that only demonstrate behavior explicitly outside these security claims PTY/ConPTY ownership is lifecycle ownership for resources Winds can prove it owns. It is not proof that Winds confines every descendant process, filesystem effect, network connection, or credential reachable by the launched process. +Local-history confidentiality also depends on the configured state-root boundary. On Unix, Winds-created history directories/files request owner-only modes. On Windows, the current Spec 003 implementation inherits ACLs from `WINDS_HOME` and does not create or validate an owner-only ACL. A permissive Windows `WINDS_HOME` is therefore not a cross-local-account confidentiality boundary; users who require that isolation must restrict the state root with operating-system ACLs or disable history for sensitive sessions. + See [`specs/003-workspace-execution-spine/terminal-trust-boundary.md`](specs/003-workspace-execution-spine/terminal-trust-boundary.md) for the detailed workspace-terminal trust boundary. ## Platform boundary diff --git a/scripts/ci/run_exact_cargo_test.py b/scripts/ci/run_exact_cargo_test.py index 1110277c..23b2f65c 100644 --- a/scripts/ci/run_exact_cargo_test.py +++ b/scripts/ci/run_exact_cargo_test.py @@ -7,16 +7,38 @@ def fail(message: str) -> None: - print(f"T063 exact-test guard failed: {message}", file=sys.stderr) + print(f"exact-test guard failed: {message}", file=sys.stderr) raise SystemExit(1) def main() -> None: - if len(sys.argv) < 4 or sys.argv[2] != "--": - fail("usage: run_exact_cargo_test.py -- ") + try: + separator = sys.argv.index("--", 1) + except ValueError: + fail( + "usage: run_exact_cargo_test.py " + "[--marker-prefix ] -- " + ) - expected = sys.argv[1] - command = sys.argv[3:] + options = sys.argv[1:separator] + if len(options) == 1: + expected = options[0] + marker_prefix = "T063" + elif len(options) == 3 and options[1] == "--marker-prefix": + expected = options[0] + marker_prefix = options[2] + else: + fail( + "usage: run_exact_cargo_test.py " + "[--marker-prefix ] -- " + ) + + if not expected: + fail("expected test name must not be empty") + if not re.fullmatch(r"[A-Z][A-Z0-9_]*", marker_prefix): + fail("marker prefix must match [A-Z][A-Z0-9_]*") + + command = sys.argv[separator + 1 :] if not command or command[0] != "cargo": fail("guard only accepts an explicit cargo command") if "--exact" not in command: @@ -59,8 +81,8 @@ def main() -> None: if len(summaries) != 1: fail(f"expected exactly one one-test success summary, found {len(summaries)}") - print(f"T063_EXACT_TEST_PROVEN={expected}") + print(f"{marker_prefix}_EXACT_TEST_PROVEN={expected}") if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/scripts/ci/t062-wsl2-proof.ps1 b/scripts/ci/t062-wsl2-proof.ps1 index 20a58d89..fd6d3079 100644 --- a/scripts/ci/t062-wsl2-proof.ps1 +++ b/scripts/ci/t062-wsl2-proof.ps1 @@ -37,11 +37,23 @@ function Invoke-NativeResult { $process.Dispose() throw "native command timed out after ${TimeoutMilliseconds}ms and could not be reaped: $File $($Arguments -join ' ')" } + $stdoutCompleted = $stdoutTask.Wait(2000) + $stderrCompleted = $stderrTask.Wait(2000) + if (-not $stdoutCompleted -or -not $stderrCompleted) { + $process.Dispose() + throw "native command timed out after ${TimeoutMilliseconds}ms; owned process was reaped but redirected output did not close inside the bounded capture window: $File $($Arguments -join ' ')" + } $stdout = $stdoutTask.GetAwaiter().GetResult().Trim() $stderr = $stderrTask.GetAwaiter().GetResult().Trim() $process.Dispose() throw "native command timed out after ${TimeoutMilliseconds}ms: $File $($Arguments -join ' ')`nstdout:`n$stdout`nstderr:`n$stderr" } + $stdoutCompleted = $stdoutTask.Wait(2000) + $stderrCompleted = $stderrTask.Wait(2000) + if (-not $stdoutCompleted -or -not $stderrCompleted) { + $process.Dispose() + throw "native command exited but redirected output did not close inside the bounded capture window: $File $($Arguments -join ' ')" + } $stdout = $stdoutTask.GetAwaiter().GetResult().Trim() $stderr = $stderrTask.GetAwaiter().GetResult().Trim() $exitCode = $process.ExitCode @@ -74,14 +86,21 @@ function Invoke-Captured { function Invoke-ProductionWslBackendProof { param([Parameter(Mandatory = $true)][ValidateSet("MAPPED", "FALLBACK")][string]$ExpectedCwd) + $testName = "git::terminal::windows_tests::t062_real_wsl_backend_launch_is_opt_in_and_uses_production_path" $env:WINDS_T062_EXPECT_CWD = $ExpectedCwd try { - Invoke-Captured "cargo.exe" @( + Invoke-Captured -File "python.exe" -Arguments @( + "scripts/ci/run_exact_cargo_test.py", + $testName, + "--marker-prefix", "T062", + "--", + "cargo", "test", "--locked", "--bin", "winds", - "t062_real_wsl_backend_launch_is_opt_in_and_uses_production_path", + $testName, "--", + "--exact", "--test-threads=1" ) | Out-Null } @@ -97,6 +116,12 @@ function Resolve-CanonicalWindowsPath { return [System.IO.Path]::GetFullPath($resolved).TrimEnd('\') } +function Normalize-WindowsPath { + param([Parameter(Mandatory = $true)][string]$Path) + + return [System.IO.Path]::GetFullPath($Path).TrimEnd('\') +} + function Assert-Equal { param( [Parameter(Mandatory = $true)][string]$Label, @@ -116,8 +141,8 @@ function Assert-WindowsPathEqual { [Parameter(Mandatory = $true)][string]$Expected ) - $actualCanonical = Resolve-CanonicalWindowsPath $Actual - $expectedCanonical = Resolve-CanonicalWindowsPath $Expected + $actualCanonical = Normalize-WindowsPath -Path $Actual + $expectedCanonical = Resolve-CanonicalWindowsPath -Path $Expected if (-not [string]::Equals($actualCanonical, $expectedCanonical, [System.StringComparison]::OrdinalIgnoreCase)) { throw "$Label mismatch: actual=$actualCanonical expected=$expectedCanonical" } @@ -153,18 +178,38 @@ function Wait-ForMappedWorkspaceMismatch { "--exec", "/bin/sh", "-c", "pwd -P > $marker" ) $result = Invoke-NativeResult -File "wsl.exe" -Arguments $arguments -TimeoutMilliseconds ([Math]::Min(5000, $remainingMilliseconds)) - $diagnostic = Limit-Diagnostic ((@( + $diagnostic = Limit-Diagnostic -Value ((@( $result.Stderr, $result.Stdout ) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join "`n") if ($result.ExitCode -ne 0) { - return [pscustomobject]@{ - Behavior = "CD_REJECTED" - ExitCode = $result.ExitCode - Diagnostic = $diagnostic - ObservedCwd = $null + $remainingMilliseconds = [int][Math]::Floor(($deadline - [DateTime]::UtcNow).TotalMilliseconds) + if ($remainingMilliseconds -le 0) { + $lastDiagnostic = "mapped probe failed at deadline: $diagnostic" + break + } + $control = Invoke-NativeResult -File "wsl.exe" -Arguments @( + "--distribution", $Distribution, + "--user", "root", + "--cd", "~", + "--exec", "/bin/true" + ) -TimeoutMilliseconds ([Math]::Min(5000, $remainingMilliseconds)) + if ($control.ExitCode -eq 0) { + return [pscustomobject]@{ + Behavior = "CD_REJECTED" + ExitCode = $result.ExitCode + Diagnostic = $diagnostic + ObservedCwd = $null + } } + $controlDiagnostic = Limit-Diagnostic -Value ((@( + $control.Stderr, + $control.Stdout + ) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join "`n") + $lastDiagnostic = "mapped probe failed but the control WSL command also failed; mapped=$diagnostic; control=$controlDiagnostic" + Start-Sleep -Milliseconds 250 + continue } $remainingMilliseconds = [int][Math]::Floor(($deadline - [DateTime]::UtcNow).TotalMilliseconds) @@ -175,6 +220,7 @@ function Wait-ForMappedWorkspaceMismatch { $markerResult = Invoke-NativeResult -File "wsl.exe" -Arguments @( "--distribution", $Distribution, "--user", "root", + "--cd", "~", "--exec", "/bin/cat", $marker ) -TimeoutMilliseconds ([Math]::Min(5000, $remainingMilliseconds)) if ($markerResult.ExitCode -eq 0) { @@ -190,7 +236,7 @@ function Wait-ForMappedWorkspaceMismatch { $lastDiagnostic = "mapped workspace still active: cwd=$observedCwd; diagnostic=$diagnostic" } else { - $markerDiagnostic = Limit-Diagnostic ((@( + $markerDiagnostic = Limit-Diagnostic -Value ((@( $markerResult.Stderr, $markerResult.Stdout ) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join "`n") @@ -212,15 +258,15 @@ if ([string]::IsNullOrWhiteSpace($tempRoot)) { throw "RUNNER_TEMP must point at the runner-owned scratch directory" } -$repo = Resolve-CanonicalWindowsPath (Invoke-Captured "git.exe" @("rev-parse", "--show-toplevel")) -$hostHead = Invoke-Captured "git.exe" @("-C", $repo, "rev-parse", "--verify", "HEAD^{commit}") -$hostCommon = Resolve-CanonicalWindowsPath (Invoke-Captured "git.exe" @("-C", $repo, "rev-parse", "--path-format=absolute", "--git-common-dir")) +$repo = Resolve-CanonicalWindowsPath -Path (Invoke-Captured -File "git.exe" -Arguments @("rev-parse", "--show-toplevel")) +$hostHead = Invoke-Captured -File "git.exe" -Arguments @("-C", $repo, "rev-parse", "--verify", "HEAD^{commit}") +$hostCommon = Resolve-CanonicalWindowsPath -Path (Invoke-Captured -File "git.exe" -Arguments @("-C", $repo, "rev-parse", "--path-format=absolute", "--git-common-dir")) $windsHome = Join-Path $tempRoot ("winds-t062-home-" + $hostHead.Substring(0, 12)) if (Test-Path -LiteralPath $windsHome) { throw "refusing to reuse pre-existing exact-head T062 Winds home: $windsHome" } -Invoke-Captured "cargo.exe" @("build", "--locked", "--bin", "winds") | Out-Null +Invoke-Captured -File "cargo.exe" -Arguments @("build", "--locked", "--bin", "winds") | Out-Null $winds = Join-Path $repo "target\debug\winds.exe" if (-not (Test-Path -LiteralPath $winds -PathType Leaf)) { throw "Winds proof binary is missing: $winds" @@ -242,27 +288,27 @@ if ([int]$selected[0].version -ne 2) { throw "selected distribution is not WSL2: $($selected[0] | ConvertTo-Json -Compress)" } -Invoke-ProductionWslBackendProof "MAPPED" +Invoke-ProductionWslBackendProof -ExpectedCwd "MAPPED" $mappedBackendLaunch = "PASS" -$linuxRepo = Invoke-Captured "wsl.exe" @("--distribution", $distro, "--user", "root", "--exec", "/usr/bin/wslpath", $repo) +$linuxRepo = Invoke-Captured -File "wsl.exe" -Arguments @("--distribution", $distro, "--user", "root", "--exec", "/usr/bin/wslpath", $repo) if (-not $linuxRepo.StartsWith("/", [System.StringComparison]::Ordinal)) { throw "wslpath did not return an absolute Linux repository path: $linuxRepo" } -$effectiveCwd = Invoke-Captured "wsl.exe" @("--distribution", $distro, "--user", "root", "--cd", $linuxRepo, "--exec", "/bin/pwd", "-P") -$linuxRoot = Invoke-Captured "wsl.exe" @("--distribution", $distro, "--user", "root", "--cd", $linuxRepo, "--exec", "/usr/bin/git", "rev-parse", "--show-toplevel") -$linuxCommon = Invoke-Captured "wsl.exe" @("--distribution", $distro, "--user", "root", "--cd", $linuxRepo, "--exec", "/usr/bin/git", "rev-parse", "--path-format=absolute", "--git-common-dir") -$linuxHead = Invoke-Captured "wsl.exe" @("--distribution", $distro, "--user", "root", "--cd", $linuxRepo, "--exec", "/usr/bin/git", "rev-parse", "--verify", "HEAD^{commit}") -Invoke-Captured "wsl.exe" @("--distribution", $distro, "--user", "root", "--cd", $linuxRepo, "--exec", "/bin/sh", "-c", "exit 0") | Out-Null +$effectiveCwd = Invoke-Captured -File "wsl.exe" -Arguments @("--distribution", $distro, "--user", "root", "--cd", $linuxRepo, "--exec", "/bin/pwd", "-P") +$linuxRoot = Invoke-Captured -File "wsl.exe" -Arguments @("--distribution", $distro, "--user", "root", "--cd", $linuxRepo, "--exec", "/usr/bin/git", "rev-parse", "--show-toplevel") +$linuxCommon = Invoke-Captured -File "wsl.exe" -Arguments @("--distribution", $distro, "--user", "root", "--cd", $linuxRepo, "--exec", "/usr/bin/git", "rev-parse", "--path-format=absolute", "--git-common-dir") +$linuxHead = Invoke-Captured -File "wsl.exe" -Arguments @("--distribution", $distro, "--user", "root", "--cd", $linuxRepo, "--exec", "/usr/bin/git", "rev-parse", "--verify", "HEAD^{commit}") +Invoke-Captured -File "wsl.exe" -Arguments @("--distribution", $distro, "--user", "root", "--cd", $linuxRepo, "--exec", "/bin/sh", "-c", "exit 0") | Out-Null -$effectiveWindows = Invoke-Captured "wsl.exe" @("--distribution", $distro, "--user", "root", "--exec", "/usr/bin/wslpath", "-w", $effectiveCwd) -$rootWindows = Invoke-Captured "wsl.exe" @("--distribution", $distro, "--user", "root", "--exec", "/usr/bin/wslpath", "-w", $linuxRoot) -$commonWindows = Invoke-Captured "wsl.exe" @("--distribution", $distro, "--user", "root", "--exec", "/usr/bin/wslpath", "-w", $linuxCommon) -Assert-WindowsPathEqual "effective WSL cwd" $effectiveWindows $repo -Assert-WindowsPathEqual "WSL Git worktree root" $rootWindows $repo -Assert-WindowsPathEqual "WSL Git common directory" $commonWindows $hostCommon -Assert-Equal "WSL Git HEAD" $linuxHead $hostHead +$effectiveWindows = Invoke-Captured -File "wsl.exe" -Arguments @("--distribution", $distro, "--user", "root", "--exec", "/usr/bin/wslpath", "-w", $effectiveCwd) +$rootWindows = Invoke-Captured -File "wsl.exe" -Arguments @("--distribution", $distro, "--user", "root", "--exec", "/usr/bin/wslpath", "-w", $linuxRoot) +$commonWindows = Invoke-Captured -File "wsl.exe" -Arguments @("--distribution", $distro, "--user", "root", "--exec", "/usr/bin/wslpath", "-w", $linuxCommon) +Assert-WindowsPathEqual -Label "effective WSL cwd" -Actual $effectiveWindows -Expected $repo +Assert-WindowsPathEqual -Label "WSL Git worktree root" -Actual $rootWindows -Expected $repo +Assert-WindowsPathEqual -Label "WSL Git common directory" -Actual $commonWindows -Expected $hostCommon +Assert-Equal -Label "WSL Git HEAD" -Actual $linuxHead -Expected $hostHead $mismatchExitCode = $null $mismatchBehavior = $null @@ -272,26 +318,48 @@ $mappedWorkspaceEquivalenceBroken = $false $fallbackHome = $null $fallbackWindows = $null $fallbackBackendLaunch = $null -$wslConfBackup = "/tmp/winds-t062-wsl-conf-$($hostHead.Substring(0, 12)).bak" -$wslConfOriginalState = Invoke-Captured "wsl.exe" @( +$backupNonce = [Guid]::NewGuid().ToString("N") +$wslConfBackup = "/etc/.winds-t062-wsl-conf-$($hostHead.Substring(0, 12))-$backupNonce.bak" +$wslConfOriginalState = Invoke-Captured -File "wsl.exe" -Arguments @( "--distribution", $distro, "--user", "root", + "--cd", "~", "--exec", "/bin/sh", "-c", - "if [ -f /etc/wsl.conf ]; then cp /etc/wsl.conf '$wslConfBackup'; printf PRESENT; else rm -f '$wslConfBackup'; printf ABSENT; fi" + "set -eu; if [ -e '$wslConfBackup' ]; then exit 73; fi; if [ -f /etc/wsl.conf ]; then trap 'rm -f -- `"$wslConfBackup`"' EXIT; umask 077; cp -p -- /etc/wsl.conf '$wslConfBackup'; printf 'PRESENT:'; stat -c '%a' '$wslConfBackup'; trap - EXIT; else printf ABSENT; fi" ) -if ($wslConfOriginalState -notin @("PRESENT", "ABSENT")) { - throw "unexpected /etc/wsl.conf snapshot state: $wslConfOriginalState" +$wslConfOriginalMode = $null +if ($wslConfOriginalState -match '^PRESENT:([0-7]{3,4})$') { + $wslConfOriginalMode = $Matches[1] + $wslConfOriginalState = "PRESENT" +} +elseif ($wslConfOriginalState -cne "ABSENT") { + $snapshotFailure = "unexpected /etc/wsl.conf snapshot state: $wslConfOriginalState" + try { + Invoke-Captured -File "wsl.exe" -Arguments @( + "--distribution", $distro, + "--user", "root", + "--cd", "~", + "--exec", "/bin/rm", "-f", "--", $wslConfBackup + ) | Out-Null + } + catch { + throw "$snapshotFailure; generated backup cleanup also failed: $($_.Exception.Message)" + } + throw $snapshotFailure } +$proofFailure = $null +$cleanupFailure = $null try { - Invoke-Captured "wsl.exe" @( + Invoke-Captured -File "wsl.exe" -Arguments @( "--distribution", $distro, "--user", "root", + "--cd", "~", "--exec", "/bin/sh", "-c", "printf '[automount]\nenabled=false\n[interop]\nappendWindowsPath=false\n[user]\ndefault=root\n' > /etc/wsl.conf" ) | Out-Null - Invoke-Captured "wsl.exe" @("--terminate", $distro) | Out-Null + Invoke-Captured -File "wsl.exe" -Arguments @("--terminate", $distro) | Out-Null - $mismatchObservation = Wait-ForMappedWorkspaceMismatch $distro $linuxRepo + $mismatchObservation = Wait-ForMappedWorkspaceMismatch -Distribution $distro -LinuxWorkspaceRoot $linuxRepo $mismatchExitCode = $mismatchObservation.ExitCode $mismatchBehavior = $mismatchObservation.Behavior $mismatchDiagnostic = $mismatchObservation.Diagnostic @@ -301,47 +369,70 @@ try { throw "T062 mismatch proof did not establish broken mapped-workspace equivalence" } - Invoke-ProductionWslBackendProof "FALLBACK" + Invoke-ProductionWslBackendProof -ExpectedCwd "FALLBACK" $fallbackBackendLaunch = "PASS" - $fallbackHome = Invoke-Captured "wsl.exe" @("--distribution", $distro, "--user", "root", "--cd", "~", "--exec", "/bin/pwd", "-P") + $fallbackHome = Invoke-Captured -File "wsl.exe" -Arguments @("--distribution", $distro, "--user", "root", "--cd", "~", "--exec", "/bin/pwd", "-P") if (-not $fallbackHome.StartsWith("/", [System.StringComparison]::Ordinal)) { throw "fallback WSL home is not an absolute Linux path: $fallbackHome" } if ($fallbackHome -ceq $linuxRepo) { throw "fallback WSL home unexpectedly equals the mapped Linux workspace: $fallbackHome" } - $fallbackWindows = Invoke-Captured "wsl.exe" @( + $fallbackWindows = Invoke-Captured -File "wsl.exe" -Arguments @( "--distribution", $distro, "--user", "root", + "--cd", "~", "--exec", "/usr/bin/wslpath", "-w", $fallbackHome ) - $fallbackWindowsComparable = $fallbackWindows.TrimEnd('\') - $repoComparable = $repo.TrimEnd('\') + $fallbackWindowsComparable = Normalize-WindowsPath -Path $fallbackWindows + $repoComparable = Resolve-CanonicalWindowsPath -Path $repo if ([string]::Equals($fallbackWindowsComparable, $repoComparable, [System.StringComparison]::OrdinalIgnoreCase)) { throw "fallback WSL home unexpectedly maps back to the canonical Windows workspace: $fallbackWindows" } - Invoke-Captured "wsl.exe" @("--distribution", $distro, "--user", "root", "--cd", $fallbackHome, "--exec", "/bin/sh", "-c", "exit 0") | Out-Null + Invoke-Captured -File "wsl.exe" -Arguments @("--distribution", $distro, "--user", "root", "--cd", $fallbackHome, "--exec", "/bin/sh", "-c", "exit 0") | Out-Null +} +catch { + $proofFailure = $_ } finally { try { $restoreCommand = if ($wslConfOriginalState -ceq "PRESENT") { - "mv '$wslConfBackup' /etc/wsl.conf" + "mv -- '$wslConfBackup' /etc/wsl.conf" } else { - "rm -f /etc/wsl.conf '$wslConfBackup'" + "rm -f -- /etc/wsl.conf '$wslConfBackup'" } - Invoke-Captured "wsl.exe" @( + Invoke-Captured -File "wsl.exe" -Arguments @( "--distribution", $distro, "--user", "root", + "--cd", "~", "--exec", "/bin/sh", "-c", $restoreCommand ) | Out-Null - Invoke-Captured "wsl.exe" @("--terminate", $distro) | Out-Null + if ($wslConfOriginalState -ceq "PRESENT") { + $restoredMode = Invoke-Captured -File "wsl.exe" -Arguments @( + "--distribution", $distro, + "--user", "root", + "--cd", "~", + "--exec", "/usr/bin/stat", "-c", "%a", "/etc/wsl.conf" + ) + Assert-Equal -Label "/etc/wsl.conf restored mode" -Actual $restoredMode -Expected $wslConfOriginalMode + } + Invoke-Captured -File "wsl.exe" -Arguments @("--terminate", $distro) | Out-Null } catch { - Write-Warning "T062 cleanup could not restore the original WSL configuration: $_" + $cleanupFailure = $_ } } +if ($null -ne $proofFailure) { + if ($null -ne $cleanupFailure) { + throw "T062 proof failed: $($proofFailure.Exception.Message); cleanup also failed to restore the original WSL configuration: $($cleanupFailure.Exception.Message)" + } + throw $proofFailure +} +if ($null -ne $cleanupFailure) { + throw "T062 cleanup failed to restore the original WSL configuration: $($cleanupFailure.Exception.Message)" +} $summary = [ordered]@{ schema_version = 1 diff --git a/specs/003-workspace-execution-spine/pty-dependency-decision.md b/specs/003-workspace-execution-spine/pty-dependency-decision.md index 16e514f3..d6c7dee8 100644 --- a/specs/003-workspace-execution-spine/pty-dependency-decision.md +++ b/specs/003-workspace-execution-spine/pty-dependency-decision.md @@ -4,32 +4,34 @@ **Canonical feature**: Spec 003 — Workspace Execution Spine -**Decision**: **ACCEPT `portable-pty` 0.9.0 as the preferred direct dependency for the first PTY/ConPTY implementation slice, but do not land the crate until the first runtime slice actually uses it.** +**Historical T043 decision**: **ACCEPT `portable-pty` 0.9.0 as the preferred direct dependency for the first PTY/ConPTY implementation slice, with landing deferred until the first runtime slice that actually used it.** -This is a dependency/provenance decision, not a claim that terminal behavior is implemented or that native Windows/WSL support is proven. +**Current status**: **ACCEPTED, LANDED, LOCK-AUDITED, AND PLATFORM-PROVEN FOR THE ACCEPTED SPEC 003 WORKSPACE-TERMINAL SURFACE.** -## Accepted candidate +This document preserves the original T043 dependency/provenance reasoning while reconciling it with the runtime evidence that landed afterward. T043 itself was a dependency decision; T050-T052 and T061-T062 supplied the implementation/platform proof. -| Field | Decision evidence | +## Accepted dependency + +| Field | Decision / current evidence | |---|---| | Crate | `portable-pty` | -| Exact package version to request | `=0.9.0` | +| Exact package version | `=0.9.0` | | Upstream repository | `wezterm/wezterm` | | Published-source VCS commit | `f8921727a11b9f8b073e8c24821d72fd41283500` | | Upstream path | `pty/` | | License | MIT | | Default features | none | -| Reuse mode | direct dependency when terminal code first lands; no copied/adapted donor runtime code approved by T043 | -| Current Winds state | approved candidate only; not yet present in `Cargo.toml` or `Cargo.lock` | +| Reuse mode | direct dependency; no copied/adapted donor runtime code approved by T043 | +| Current Winds state | landed by T050 with exact pin and committed lockfile; exact locked dependency/license audit recorded in `docs/provenance/portable-pty-0.9.0-lock-audit.md` | -Primary package/source evidence: +Primary package/source evidence used by the original decision: - https://docs.rs/crate/portable-pty/0.9.0 - https://docs.rs/crate/portable-pty/0.9.0/source/Cargo.toml.orig - https://docs.rs/crate/portable-pty/0.9.0/source/.cargo_vcs_info.json - https://docs.rs/crate/portable-pty/0.9.0/source/LICENSE.md -## Why this candidate fits Spec 003 +## Why this dependency fits Spec 003 The 0.9.0 public API supplies the concrete primitives Spec 003 needs without requiring a daemon, multiplexer, terminal renderer, or async runtime: @@ -41,144 +43,94 @@ The 0.9.0 public API supplies the concrete primitives Spec 003 needs without req - child `try_wait` / `wait` and process identity while the child handle is owned; - a kill handle while Winds still owns the corresponding process/session capability. -The design is synchronous/blocking. That is acceptable for the first Winds slice because Winds can place blocking PTY reads behind bounded owned threads without introducing Tokio solely to service terminal I/O. T050/T051 remain responsible for proving actual lifecycle behavior and race handling. - -T043 does **not** authorize reconstructing process ownership from `process_id()` after restart. Spec 003 remains authoritative: persisted PID alone is not identity, and lost ownership becomes `OWNERSHIP_LOST` with no blind signal/kill. - -## Dependency footprint audit - -`portable-pty` 0.9.0 has no default features. Its published normal direct dependencies are: - -- `anyhow 1.0` -- `downcast-rs 1.0` -- `filedescriptor 0.8.3` -- `libc 0.2` -- `log 0.4` -- `nix 0.28` with `term` and `fs` -- `serial2 0.2` -- `shell-words 1.1` +The design is synchronous/blocking. Winds uses bounded owned-thread/lifecycle machinery rather than introducing Tokio solely to service terminal I/O. -Optional-only dependencies are `serde` and `serde_derive`; Winds does not need the `serde_support` feature for the initial PTY slice. +Nothing in the dependency changes Spec 003 restart authority: a persisted PID is not process identity, and lost ownership becomes `OWNERSHIP_LOST` with no blind signal/kill. -Windows additionally declares: +## Landing gates and their disposition -- `bitflags 1.3` -- `lazy_static 1.4` -- `shared_library 0.1` -- `winapi 0.3` with console/handle/file/named-pipe/synchronization features -- `winreg 0.10` - -Published dev dependencies (`smol`, `futures`) are not required by downstream Winds runtime use. - -### Footprint concern: mandatory serial support - -`serial2 0.2` is a normal, non-optional dependency in `portable-pty` 0.9.0 even though Winds does not currently need serial TTY support. Its published lock graph includes platform support such as `cfg-if`, `libc`, and `winapi`. This is accepted as a bounded cost for using the mature WezTerm PTY implementation, but it is a known Ponytail pressure point. - -The exact **Winds-resolved transitive graph** cannot truthfully be fixed before the crate is inserted into Winds' own manifest and `Cargo.lock`; Cargo version unification and target selection affect that graph. Therefore the runtime landing PR MUST: +T043 required the first runtime PR that used the crate to: 1. request exactly `portable-pty = "=0.9.0"`; -2. commit the resulting `Cargo.lock`; +2. commit the Winds-resolved `Cargo.lock`; 3. inspect the actual resolved direct/transitive additions; 4. rerun the dependency/license audit for those exact locked versions; -5. remove/reconsider `portable-pty` if the resolved footprint or license set materially violates the Spec 003 simplicity/security boundary. - -This landing condition is part of the T043 decision; T043 does not pretend the future lockfile already exists. +5. compile/clippy/test the exact graph under Winds' pinned Rust toolchain; +6. reopen the dependency decision rather than silently work around a material footprint/license failure. -## Rust 1.97.1 compatibility audit +**Those landing gates were satisfied by T050.** PR #23 landed the exact pin and lockfile, passed the pinned Rust 1.97.1 quality/release gates, and recorded the exact locked transitive/license audit in `docs/provenance/portable-pty-0.9.0-lock-audit.md`. The decision therefore no longer has `RUNTIME_PROOF_PENDING` status. -The published crate uses Rust edition 2018 and declares no `rust-version` / MSRV field. Therefore upstream metadata does not provide an exact MSRV claim. +## Dependency-footprint audit -The crate predates Winds' pinned Rust 1.97.1 toolchain and was successfully published/documented on stable Rust-era tooling. Rust's stable-language compatibility model is designed so previously stable source continues to compile on later stable releases, absent exceptional compiler/soundness breakage. This makes 1.97.1 a reasonable compatibility target, but it is **not treated as execution proof**. +The original published-package audit identified normal dependencies including `anyhow`, `downcast-rs`, `filedescriptor`, `libc`, `log`, `nix`, `serial2`, and `shell-words`, plus Windows support dependencies. T050's exact lock audit supersedes any attempt to infer the final Winds graph from published metadata alone; the committed `Cargo.lock` and the lock-audit document are the canonical resolved-graph evidence. -The first PR that actually lands `portable-pty` MUST compile/clippy/test the exact locked dependency graph under Winds' pinned Rust 1.97.1. Until then the decision is `COMPATIBILITY_EXPECTED / RUNTIME_PROOF_PENDING` rather than a false claim of compiler execution. +### Mandatory serial-support pressure -Rust stability reference: +`serial2` was identified at T043 as a non-optional footprint cost even though Winds does not need serial TTY support. That Ponytail pressure was accepted as the bounded cost of using the mature WezTerm PTY implementation. T067 later re-challenged the final direct-dependency surface and found no justified dependency removal or replacement. -- https://doc.rust-lang.org/edition-guide/editions/index.html +## Rust 1.97.1 compatibility -## Platform behavior audit +At T043, upstream metadata provided no exact MSRV proof, so compatibility was only expected. That uncertainty is now resolved for the Winds use case: T050 and subsequent quality/platform gates compiled, linted, and tested the locked graph under Winds' pinned Rust 1.97.1 toolchain. -### Linux / macOS +This is Winds execution evidence for the accepted graph; it is not a claim about every possible `portable-pty` consumer or feature combination. -The crate exposes the Unix PTY implementation needed for allocation, resize, owned reader/writer access, spawning, and child lifecycle. T050 must still prove Winds-specific resource ownership, interrupt/close behavior, bounded streams, and no leaked directly owned child in controlled lifecycle tests. +## Platform evidence after landing -### Windows +### Linux / macOS -`native_pty_system()` selects the crate's ConPTY implementation on Windows and the published package carries Windows console/handle/named-pipe dependencies. This is sufficient for a dependency decision, **not** a Winds support claim. +T050 proved the accepted Unix PTY lifecycle: allocation, canonical cwd, one output consumer, input/output, resize/current-size, owned-child observation/termination/reaping, and ownership-scoped foreground-process-group interrupt behavior. -A material risk was found: `portable-pty-psmux` exists specifically because its maintainers need newer ConPTY creation flags (`PSEUDOCONSOLE_RESIZE_QUIRK`, `WIN32_INPUT_MODE`, and `PASSTHROUGH_MODE`) that upstream `portable-pty` 0.9.0 does not expose. Winds will not pre-emptively take that fork. T051 must test Winds' actual Windows behavior first; only demonstrated failures may justify a narrowly reviewed alternative or upstream patch. +### Native Windows -Reference: +T051 proved the accepted `portable-pty` ConPTY path on native Windows for create/input/output/resize/exit/terminate/close/reap. The platform evidence did **not** prove a safe ownership-scoped ConPTY interrupt primitive, so native-Windows `interrupt()` remains explicitly fail-closed rather than falling back to process-global console signaling. T061 later broadened official-Windows touched-surface evidence. -- https://docs.rs/crate/portable-pty-psmux/0.9.6/source/README.md +The historical `portable-pty-psmux` risk remains useful reference material, but Winds did not pre-emptively adopt that fork because accepted native-Windows behavior was proven without it. ### WSL -WSL selection/path mapping is outside the PTY crate's responsibility. Spec 003 uses Microsoft's supported `wsl.exe` surface for WSL discovery/launch. T052/T062 remain responsible for real WSL integration evidence. - -## Alternatives considered - -### `xpty` 0.3.6 — REJECT for first slice - -Pros: - -- explicitly declares `rust-version = "1.70"`; -- moves `serial2` behind an optional `serial` feature; -- modernizes dependency versions and error typing; -- provides Linux/macOS/Windows CI in its own project. - -Why not now: - -- it is a young fork of `portable-pty` 0.9.0 rather than the source used by WezTerm; -- its own README describes async support and better ConPTY control as planned improvements; -- Winds currently needs mature bounded PTY mechanics more than a newer fork surface. - -Reference: - -- https://docs.rs/crate/xpty/0.3.6/source/README.md -- https://docs.rs/crate/xpty/0.3.6/source/Cargo.toml.orig +WSL identity, path mapping, and distribution selection are intentionally outside the PTY crate. T052 implemented the explicit WSL launch/mapping boundary, and T062 supplied real Windows Server 2025 + Ubuntu WSL2 integration evidence. That evidence does not convert `portable-pty` into the authority for WSL identity or Git equivalence. -### `rust-pty` 0.5.0 — REJECT for first slice +## Alternatives considered by T043 -It offers a cross-platform Unix/ConPTY abstraction with first-class async I/O, but its model is Tokio-based. Adding an async runtime solely for PTY I/O would expand Winds' runtime model before a measured need exists. +### `xpty` 0.3.6 — REJECTED for first slice -Reference: +It offered a newer fork surface and optional serial support, but at decision time Winds preferred the mature WezTerm-derived implementation and did not have a demonstrated reason to switch. No later Spec 003 evidence has required reopening that choice. -- https://docs.rs/rust-pty/0.5.0/rust_pty/ +### `rust-pty` 0.5.0 — REJECTED for first slice -### `portable-pty-psmux` 0.9.6 — REJECT pending demonstrated need +Its Tokio-oriented model would have expanded Winds' runtime model before a measured need existed. -This fork is valuable risk evidence for modern Windows ConPTY behavior, but adopting a fork before Winds demonstrates that the extra flags are required would violate Ponytail/YAGNI. Keep it as a fallback reference for T051. +### `portable-pty-psmux` 0.9.6 — RETAINED AS REFERENCE ONLY -Reference: +Its extra ConPTY flags remain useful risk evidence, but the accepted Winds native-Windows slice did not demonstrate a need to adopt the fork. -- https://docs.rs/crate/portable-pty-psmux/0.9.6/source/README.md +### Unix-only PTY crates — REJECTED -### Unix-only PTY crates — REJECT +Separate unrelated Unix/Windows libraries would increase platform divergence without a proven benefit for the accepted cross-platform slice. -`ptyprocess`, `pty-process`, and `pty` can cover Unix PTY behavior but do not satisfy Spec 003's single dependency direction for native Windows ConPTY. Using separate unrelated Unix/Windows libraries would increase integration and behavior divergence without a proven benefit. +## License / notice status -## License / notice decision +`portable-pty` 0.9.0 is MIT licensed and is now a landed dependency. Winds therefore: -`portable-pty` 0.9.0 is MIT licensed. If/when the dependency lands: +- preserves the dependency's upstream license/notice requirements in release dependency notices; +- records the exact locked package set in the release/license audit; +- does not imply that Winds' `MIT OR Apache-2.0` project license relicenses the dependency; +- has not approved copied/adapted WezTerm runtime code through this decision. -- preserve its upstream license/notice requirements in release dependency notices; -- record the exact locked package set in the release license audit; -- do not imply that Winds' dual `MIT OR Apache-2.0` license relicenses the dependency; -- no copied/adapted WezTerm code is approved by this decision. +T050 also reconciled the two exact `winapi-*-pc-windows-gnu 0.4.0` package tuples required by the locked graph through the fail-closed release license collector and provenance records. -## Final T043 verdict +## Current final verdict -**ACCEPT `portable-pty = "=0.9.0"` as the first implementation dependency candidate.** +**`portable-pty = "=0.9.0"` is ACCEPTED AND LANDED for the Spec 003 workspace-terminal implementation.** -The accepted boundary is intentionally narrow: +The accepted boundary remains narrow: -- dependency, not copied code; -- no features initially; -- no daemon/multiplexer/renderer adoption; +- direct dependency, not copied donor code; +- exact version pin and committed lockfile; +- no daemon, multiplexer, renderer, public runtime protocol, or plugin/provider framework; - no PID-based restart ownership; -- no Windows support claim until T051 evidence; -- no WSL support claim until T052/T062 evidence; -- actual Rust 1.97.1 compile and exact Winds lockfile/license graph are mandatory at dependency landing. +- native-Windows workspace/terminal support only to the behavior actually proven by T051/T061; +- WSL support only to the behavior actually proven by T052/T062; +- no implication that native-Windows authoritative `winds verify` required-check execution is supported. -If those landing gates fail, T043's candidate decision must be reopened rather than patched around silently. +Any future dependency switch or broader runtime claim requires its own evidence rather than treating the historical T043 candidate wording as current implementation truth. diff --git a/specs/003-workspace-execution-spine/t068-independent-review-reconciliation-addendum.md b/specs/003-workspace-execution-spine/t068-independent-review-reconciliation-addendum.md new file mode 100644 index 00000000..f1643e75 --- /dev/null +++ b/specs/003-workspace-execution-spine/t068-independent-review-reconciliation-addendum.md @@ -0,0 +1,186 @@ +# T068 Independent Review Reconciliation Addendum + +Status: **T068 CLOSEOUT EVIDENCE RECORDED — PR #63 REMAINS UNMERGED; T069 NOT STARTED** + +This addendum records material dispositions discovered after the initial T068 reconciliation record was created. It records the T068 closeout evidence only; it does not start T069, authorize merge of PR #62 or PR #63, or change the Spec 003 runtime scope. + +## Additional repaired supported-path findings + +### A1. Shell-command completion could be persisted without an observed exit fact + +**Disposition: REPAIRED.** + +`Store::record_shell_command_exit_observation` now rejects an observation when both `exit_code` and `observed_end_unix_ms` are absent. `finalize_shell_command_from_observation` independently requires a durable `WINDS_OBSERVED` exit fact before it can transition the execution to `EXITED`. + +A regression test proves that an empty observation leaves the execution `RUNNING` and cannot be finalized, while an observed exit code remains sufficient even when end time is unknown. + +### A2. Restart-reconciliation event time could precede request time after wall-clock regression + +**Disposition: REPAIRED.** + +Shell-command and terminal restart reconciliation now clamp the ownership-loss event timestamp to at least the persisted execution request time. The lifecycle status remains conservative: `OWNERSHIP_LOST` does not fabricate process liveness, death, end time, or duration. + +A regression test supplies a deliberately regressed `now_ms` and proves that shell and terminal ownership-loss events are not recorded before their respective request times. + +### A3. `create_terminal_session` could attach a terminal child row to a non-terminal execution + +**Disposition: REPAIRED.** + +The Store API now resolves the referenced execution kind before inserting a `terminal_sessions` row and requires `TERMINAL`. A `SHELL_COMMAND` execution cannot acquire a terminal child row through the supported Store API. + +This is intentionally enforced at the API boundary rather than by expanding T068 into a historical-schema rewrite against arbitrary direct SQLite mutation. + +### A4. T062 `/etc/wsl.conf` backup did not survive the restart it was intended to prove + +**Disposition: REPAIRED.** + +Reconciliation CI showed that keeping the temporary `/etc/wsl.conf` backup under `/tmp` was not durable across the WSL terminate/restart cycle. The proof now creates a unique, pre-existence-checked, root-owned backup under `/etc`, requests restrictive creation permissions, restores it in `finally`, and treats restore failure as fatal. + +The real Windows Server 2025 + Ubuntu WSL2 proof passed after this repair, including mapped launch, deliberate mapping mismatch, fallback launch, and configuration restoration. Only exact-head runs on the eventual final candidate may satisfy T068. + +### A5. Native-Windows canonical drive cwd needed a shell-safe spawn representation + +**Disposition: REPAIRED.** + +Rust canonicalization may produce a verbatim drive path such as `\\?\C:\...`. That value remains the canonical terminal identity, but an ordinary verbatim drive path is converted to the equivalent Win32 drive path only at the PTY child spawn boundary because `cmd.exe` may otherwise treat the verbatim form as an unsupported UNC-style cwd and silently fall back. + +Verbatim UNC/device forms and ordinary UNC forms that cannot satisfy the current native-shell cwd contract remain rejected. The ConPTY test proves the effective cwd through an output-only marker assembled by `cmd.exe`, so input echo or surrounding ANSI terminal traffic cannot satisfy the assertion. + +### A6. Unix fallback cleanup could leave an unreaped direct-child zombie + +**Disposition: REPAIRED.** + +`OwnedProcess::drop` still refuses to signal an unproven numeric process-group identity after ownership may have been lost, but a directly owned child that is still live is now killed and then reaped through a short bounded `try_wait` loop. The destructor therefore does not introduce an unbounded wait while also avoiding a permanent zombie when the direct child can be reaped promptly after `SIGKILL`. + +### A7. macOS `RLIMIT_NPROC` containment broke legitimate Git descendants + +**Disposition: REPAIRED / CLAIM NARROWED.** + +The previous macOS path used `RLIMIT_NPROC=2`, but that limit is accounted per real user rather than per Winds-owned process tree and can prevent Git from creating legitimate subprocesses during `--ignore-submodules=none` status scans. That limit is removed. + +On macOS, the supported-path ownership contract is the session/process-group boundary created by `setsid`; normal Git descendants inherit that group and bounded cleanup terminates/reaps the group. Winds does not claim hostile descendant-escape containment on macOS. Linux retains the narrower seccomp rule that denies descendant `setsid`/`setpgid` escape for the bounded read-only Git path. + +A macOS regression permits a normal descendant, proves that it keeps the owned process scope non-quiescent, then proves bounded group termination and reap. + +### A8. Clone staging shells and foreign staging entries could create persistent availability problems + +**Disposition: REPAIRED.** + +A successful clone now removes its proven-empty private staging shell with a non-recursive `remove_dir`; no recursive cleanup is introduced. Failed clone payload remains retained for recovery when safe cleanup cannot be proven. + +The retained-payload admission gate is now scoped to staging names owned by the current Winds process identity. Another process or user's `0700` staging directory is not read and cannot become a global availability gate for every clone under a shared parent. Current-process retained payload still bounds repeated allocation during that process lifetime. + +### A9. Clone publication guarantees exceeded what every supported platform documents + +**Disposition: REPAIRED / SUPPORTED FILESYSTEM CONTRACT EXPLICIT.** + +Linux continues to use `renameat2(..., RENAME_NOREPLACE)` and now reports `ENOSYS`, `EINVAL`, `EOPNOTSUPP`, and `EXDEV` as an explicit unsupported kernel/filesystem publication boundary instead of collapsing them into a generic failure. macOS continues to use `renamex_np(..., RENAME_EXCL)`. + +On Windows, staging and requested destination are siblings under the same canonical parent and `MoveFileExW` is invoked without `MOVEFILE_REPLACE_EXISTING` and without `MOVEFILE_COPY_ALLOWED`. The supported claim is therefore **single same-parent no-replace rename plus post-publication filesystem-identity verification**, not a formal cross-filesystem atomicity guarantee that Microsoft does not document for `MoveFileExW`. If the platform/filesystem cannot perform that rename, the clone fails before workspace registration. + +Microsoft documents the no-replace behavior for its handle-based rename surface (`FILE_RENAME_INFO.ReplaceIfExists = FALSE` returns an error when the target exists) and documents that file-information-class behavior can vary by underlying driver. Those facts reinforce the narrowed Winds claim: no separate check/delete/replace fallback is accepted as equivalent to a stronger universal atomicity guarantee. + +Primary references re-verified 2026-08-20: + +- https://learn.microsoft.com/en-us/windows/win32/api/winbase/ns-winbase-file_rename_info +- https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-setfileinformationbyhandle + +### A10. Post-exit WSL pipe drain could consume an already-expired command deadline + +**Disposition: REPAIRED.** + +Once the direct `wsl.exe` child has exited, stdout/stderr drain and owned-scope quiescence are cleanup work. They now use the reserved cleanup deadline rather than the command-phase deadline, avoiding spurious zero-budget reader failures when the child exits near the execution deadline. + +### A11. Large dirty worktrees were conflated with complete Git evidence capture + +**Disposition: REPAIRED.** + +The bounded Git reader now keeps only the configured byte cap while continuing to drain the pipe, and records whether stdout was truncated. Callers that require complete Git bytes or exact worktree-state digest still fail closed on truncation. Cleanliness checks use a distinct presence semantic: any stdout, including truncated stdout, proves the worktree is dirty instead of turning a large dirty repository into an infrastructure error. + +### A12. Stricter object-ID validation could make historical Git observations unreadable + +**Disposition: REPAIRED.** + +New Git observation writes continue to require full lowercase 40- or 64-hex object IDs. The read path now preserves historical compatibility by accepting a non-empty legacy stored object-ID string, including pre-T068 abbreviated or uppercase values, while retaining the rest of the stored-observation consistency checks. New admission is not weakened. + +### A13. One deferred terminal-finalization persistence failure could poison unrelated future starts + +**Disposition: REPAIRED.** + +Deferred-finalization retry still preserves the affected historical execution in the in-memory retry queue when Store load or finalization persistence fails; it does not fabricate a terminal final state. The retry sweep now reports the residual failure but returns success to the unrelated terminal-start path, so one permanently unfinalizable historical row cannot block every subsequent terminal session. Obsolete/already-final rows continue to be discarded as completed. + +### A14. History pruning could recursively delete a foreign replacement after validation + +**Disposition: REPAIRED / OBJECT-BOUND NON-RECURSIVE PRUNING RESTORED.** + +A fresh independent exact-head review of candidate `5c3a646d196abd33b96468bb95b597fc5da6fdd8`, tree `428d0949647b75d626dc048382770f9740e7bce0`, found one new material P1: `remove_owned_history_session` validated a retained history directory and then called `fs::remove_dir_all` through that mutable pathname. A concurrent pathname replacement after the last validation could therefore redirect recursive deletion to a foreign replacement directory. The reviewer reported no additional material finding in the other inspected T068 surfaces. + +The first repair removed automatic pruning entirely and failed closed when retained history could not fit the next record. That removed the recursive-delete race, but it was not behaviorally acceptable: exact-head `release-candidate #379` on candidate `c25e6fe9a479e368c2c45ad3452ab292d9172866` failed the Ubuntu T063 100-cycle terminal soak when retained history reached `64962` bytes and the next `1203`-byte record exceeded the `65536`-byte total quota. That result proved oldest-session rollover is load-bearing behavior and that the no-prune fallback could not become the T068 disposition. + +The current repair therefore restores the original oldest-session retention policy while changing the destructive primitive. Production history pruning no longer uses `remove_dir_all`. Each retained session is snapshotted as a flat, content-addressed session directory with filesystem identity, logical size, modification time, and direct known history files. Unexpected directory entries, nested objects, symlinks/reparse points, duplicate transcript/manifest blobs, invalid names, and identity changes fail closed. + +On Unix, Winds opens the history root and selected retained session as no-follow directory handles, verifies their filesystem identities, validates each direct regular-file entry relative to the already-open session directory, unlinks only direct names through `unlinkat`, revalidates the session entry from the already-open root directory, and finally removes the now-empty session directory non-recursively with `unlinkat(..., AT_REMOVEDIR)`. The security claim is deliberately scoped to containment inside the already-bound session-directory object and to the supported Winds writer path. POSIX `unlinkat` remains name-based: Winds does **not** claim protection when an external same-principal process concurrently replaces an individual direct child name inside that private session directory between validation and unlink. Such hostile same-principal filesystem mutation is outside the Spec 003 isolation claim. Winds still guarantees that pruning performs no recursive traversal and cannot redirect deletion into another directory tree through that child-name race. + +On Windows, the same flat-session policy is bound to filesystem object identity using no-follow/reparse-point-aware handles and `GetFileInformationByHandleEx`; direct files and the empty session directory are marked for deletion by handle with `SetFileInformationByHandle`. Unsupported object types or identity changes fail closed. + +A regression hook runs immediately after the last pathname-based session identity proof. The test moves the originally observed session directory, creates a foreign replacement at the original pathname, and then enters the destructive stage. Pruning rejects the identity mismatch; the foreign replacement and the moved owned session both remain unchanged. Additional regression coverage proves a valid owned flat session is pruned non-recursively and that oldest-session rollover again permits the next bounded history record. + +The repair was generated, formatted with pinned Rust `1.97.1`, and tested in GitHub Actions before publication. `quality #592` / run `32396853626` produced artifact `t068-history-prune-repair` with GitHub-recorded digest `sha256:2763fab5f03482940b313f83daeb720f323af2603b4600553263e9a25b1cde3a`; its focused history suite passed `17/17`. The published Git blobs for `src/command/history.rs` and `src/command/history/history_prune.rs` were independently matched to the exact formatted artifact before the temporary repair scaffolding was removed. + +All deterministic CI and independent-review results from earlier candidates remain historical and MUST NOT satisfy the T068 final gate. The cleaned candidate that includes this repair and this addendum requires a complete new exact-head `quality`, `windows-terminal`, and `release-candidate` cycle followed by a fresh independent exact-head review. + +### A15. WSL post-exit drain could spin indefinitely while inherited pipes remained continuously readable + +**Disposition: REPAIRED / FINAL EXACT-HEAD REVIEW CLEAN.** + +A fresh CodeRabbit review of implementation head `f77362ec658c2b3ac1c5c2a99c454eb59a0b7448` identified one remaining material post-exit availability defect in `src/wsl_launch.rs`: after the direct `wsl.exe` child exited, `drain_pair` could continue returning progress while descendants kept inherited stdout/stderr pipes readable, allowing the post-exit drain loop to outlive the reserved cleanup window. The same review separately raised a detached-`setsid` concern, then withdrew it after tracing the production call graph and confirming that the arbitrary-command fixture was not reachable through the supported WSL launch surface. No containment expansion was required for that withdrawn concern. + +The drain repair reserves only half of the remaining cleanup budget for post-exit pipe draining and routes the loop through `drain_until_idle_or_deadline`. The helper checks its deadline before each drain attempt, returns `Ok(false)` if continuous progress reaches the drain deadline, and returns `Ok(true)` only when the drain reports no further progress. A drain-deadline miss immediately invokes bounded `terminate_and_prove(cleanup_deadline, ...)` and returns an explicit error stating that WSL-side cleanup proof cannot be trusted; the subsequent Windows process-scope quiescence check is also bounded by `cleanup_deadline`. + +The first real-WSL regression fixture attempted to force continuous progress with an escaped writer. Exact-head candidate `ee79131a9752146b072fb60b176b8d5db21f2fad` correctly remained bounded but the fixture was scheduling-dependent: real Windows+Ubuntu WSL2 T062 returned after about five seconds through bounded Windows-scope cleanup rather than the specific drain-deadline branch the test expected. That candidate was rejected rather than weakening the gate. The final regression is deterministic and directly exercises the load-bearing loop property: `post_exit_drain_stops_at_deadline_under_continuous_progress` supplies a drain closure that returns `Ok(true)` continuously and proves the helper exits at its deadline instead of spinning indefinitely. Real WSL2 integration remains separately covered by T062. + +The final reviewed implementation candidate is HEAD `badfa984d7aa5552478aaba5b7da5819290253df`, tree `d5e6ffcdd97af9cf0281c2606f799fb88b9e6b0e`, against unchanged canonical base `29c394084631afd6d1890362372b8a162dac083a`, with `behind_by=0`. On that exact head: + +- `quality #613` / run `32407334800` = **SUCCESS**; +- `windows-terminal #338` / run `32407334815` = **SUCCESS**, including native Windows, Unix terminal integration, and real Windows Server 2025 + Ubuntu WSL2 T062 production proof/evidence; +- `release-candidate #405` / run `32407334775` = **SUCCESS**, including T063 100-cycle terminal lifecycle soak on Ubuntu/macOS/Windows, T064 regression gates, SC-001, native-Windows authority refusal, quality, and release builds; +- the final CodeRabbit post-exit-drain material thread was reconciled against this exact head and resolved by CodeRabbit; zero material review threads remain unresolved; and +- fresh independent Qodo full-implementation review, explicitly bound to HEAD `badfa984d7aa5552478aaba5b7da5819290253df`, tree `d5e6ffcdd97af9cf0281c2606f799fb88b9e6b0e`, and base `29c394084631afd6d1890362372b8a162dac083a`, returned **NO MATERIAL FINDING REMAINING**. Qodo specifically re-evaluated the bounded WSL drain, object-bound history pruning, clone staging cleanup, and the complete current implementation surface. + +An additional CodeRabbit incremental re-review of the final `src/wsl_launch.rs` delta was requested after the clean Qodo verdict. It is not required or counted as the independent pass unless it completes on the bound head; any later material finding from that run still invalidates closeout and must be reconciled before merge. + +This closes the T068 independent-review requirement on the reviewed implementation head. The documentation-only closeout commit that records this fact is not a new runtime implementation candidate and does not authorize merge by itself: PR #63 remains draft/unmerged until its own final exact-head CI/review gate is green. T069 remains **NOT STARTED**, and Spec 003 remains incomplete until T069 is separately executed and canonically reconciled. + +## Historical evidence attribution clarifications + +### H1. `SC-001 100-cycle soak` references in Spec 003 task evidence + +**Disposition: RECONCILED AS HISTORICAL ATTRIBUTION, NOT RENAMED TO SPEC 003 SC-005.** + +The repeated historical phrase `SC-001 100-cycle soak` in task evidence refers to **Spec 001 / SC-001**, whose pre-release gate is 100 create/verify/promote/reconcile cycles with zero source-checkout mutation. It is not a claim that Spec 003 / SC-001 is the terminal soak. + +Spec 003 defines its terminal lifecycle soak as **SC-005**. T063 separately records the dedicated 100-cycle terminal lifecycle soak and also records the legacy Spec 001 SC-001 verification soak as an additional gate. This separation is preserved rather than rewriting historical evidence to a criterion it did not execute. + +### H2. `docs/research/006-agent-fleet-donor-audit.md` portable-pty wording + +**Disposition: HISTORICAL T043 SNAPSHOT; CURRENT STATUS IS SUPERSEDED BY CANONICAL T050 EVIDENCE.** + +The donor-audit paragraph stating that `portable-pty 0.9.0` was not yet landed records the T043 decision state at the time of that research audit. It must not be read as current dependency status or as an outstanding instruction. + +Current repository truth is the later canonical T050 state: `portable-pty = "=0.9.0"` is landed, the resolved `Cargo.lock` is committed, and the exact transitive/license audit is recorded in `docs/provenance/portable-pty-0.9.0-lock-audit.md`. The reconciled Spec 003 dependency-decision documentation reflects that current state. + +The historical donor audit remains a provenance snapshot; it does not override later canonical task evidence or authorize a bespoke PTY implementation. + +## Findings that do not authorize new T068 scope + +Direct/manual mutation of the local SQLite file is outside the supported Store API and is not converted into a hostile-database security claim. Therefore T068 does not rewrite already-landed migrations solely to add constraints against arbitrary direct SQLite inserts or contradictory manual row edits. Supported API paths that could create false typed or lifecycle truth remain bugs and have been repaired above. + +Likewise, T068 does not add a daemon, broker, sandbox, renderer, multiplexer, public runtime protocol, plugin/provider system, SQL/LLM runtime, Agent Fleet runtime, or Herdr integration to answer generic pathname, hostile-repository, or local-database tampering scenarios outside the established Spec 003 boundary. + +## T068 gate result + +**T068 independent-review gate: SATISFIED on reviewed implementation head `badfa984d7aa5552478aaba5b7da5819290253df`.** + +The required exact-head implementation evidence is complete: all three deterministic CI/platform workflows succeeded on the same head; all material Qodo, CodeRabbit, Cubic, and reconciliation-discovered findings are accounted for; fresh independent Qodo review returned `NO MATERIAL FINDING REMAINING` on the exact head/tree/base; and zero material review threads remain unresolved. + +This addendum and the matching `tasks.md` update are documentation-only closeout evidence. They do not merge PR #63, do not start T069, and do not make the Spec 003 completion claim. Because they create a new documentation-only PR head, that final head must still pass the repository's exact-head CI/review landing gate before merge authorization can be considered. Any new material finding invalidates the closeout candidate and requires reconciliation plus a new exact-head cycle. diff --git a/specs/003-workspace-execution-spine/t068-independent-review-reconciliation.md b/specs/003-workspace-execution-spine/t068-independent-review-reconciliation.md new file mode 100644 index 00000000..9a7fa68e --- /dev/null +++ b/specs/003-workspace-execution-spine/t068-independent-review-reconciliation.md @@ -0,0 +1,226 @@ +# T068 Independent Review Findings Reconciliation + +Status: **IN PROGRESS — NOT A T068 CLOSEOUT** + +This document records the reconciliation of material independent-review findings raised against the complete Spec 003 implementation surface. It does **not** mark T068 complete, authorize T069, or authorize merge by itself. + +## Authority and reviewed history + +- Canonical Spec 003 task truth remains `tasks.md`. +- T067 is the last closed canonical task. +- T068 remains open until every gate in this document is satisfied. +- T069 remains not started. +- PR #62 is historical review-only evidence and MUST NOT be merged. +- PR #62 reviewed the historical implementation head `8601b7dbb44582a284813bbd50a44aeb1afd24f1` with tree `1d056bead423f02c62ace10b798ceb5c1a1c191c` and demonstrated that that implementation did not satisfy T068. +- Prior T066/T067 reviews and the PR #62 review do not count as the required fresh review of the repaired final T068 implementation. + +## Scope and threat-boundary invariants + +The reconciliation MUST NOT expand Spec 003 into a daemon, reconnect protocol, sandbox, multiplexer, renderer, plugin/provider framework, SQL Studio, LLM observatory, agent-fleet runtime, MCP/ACP/A2A runtime, or Herdr runtime integration. + +The following claims remain explicitly out of scope: + +- hostile-repository sandboxing; +- perfect secret detection; +- PID-based reconnect authority; +- native-Windows authoritative verification support; +- protection against an attacker with independent authority to rewrite the Winds SQLite database or race arbitrary filesystem namespace replacement outside the validated supported-operation boundary. + +Supported-path correctness still fails closed where Spec 003 owns the operation. Out-of-scope hostile/manual tampering is not converted into a product claim merely to satisfy a reviewer suggestion. + +## Material finding reconciliation + +### 1. Persisted Git observations hidden from CLI execution snapshots + +**Finding:** command-boundary BEFORE/AFTER `execution_git_observations` were persisted but omitted from `winds run` / `winds execution` output. + +**Disposition:** REPAIRED. + +The execution snapshot now exposes `git_observations` for shell-command executions, preserving boundary, availability, source, HEAD, branch, detached/dirty state, worktree-state format/digest, and observation time. Terminal snapshots intentionally expose an empty array because Spec 003 does not fabricate command-boundary Git observations for terminal sessions. + +### 2. Historical authority verification mutated the candidate checkout + +**Finding:** release-candidate historical verification temporarily replaced `tests/walking_skeleton.rs` in the candidate checkout; a fail-fast path could skip restoration. + +**Disposition:** REPAIRED. + +Historical authority verification now operates in an isolated detached temporary worktree and no longer mutates/restores the candidate checkout in place. + +### 3. Failed clone poisoned the reserved destination + +**Finding:** failed `git clone` could leave the reserved destination behind and prevent an immediate retry with the same destination. + +**Disposition:** REPAIRED. + +A failed clone now removes only the validated Winds-reserved real destination directory. Cleanup failure is reported fail-closed and the workspace is not registered. Tests prove destination removal and immediate retry. + +### 4. Absolute local clone source could diverge from persisted identity + +**Finding:** Winds canonicalized an absolute local source for persisted identity but could pass the original pathname to Git, allowing a symlink retarget between those operations. + +**Disposition:** REPAIRED. + +For absolute local remotes, the same canonical source identity is now used for both the Git CLI argument and persisted clone-origin identity. The supported-path test covers symlink retargeting between identity capture and Git-argument construction. + +### 5. Clone destination pathname TOCTOU + +**Finding:** the reviewer challenged replacement of the clone destination between validation and Git invocation. + +**Disposition:** RECONCILED WITH THE ESTABLISHED THREAT BOUNDARY. + +Winds reserves the destination itself, requires it to remain a real directory, canonicalizes and revalidates its identity before Git invocation and again before registration, and refuses observed identity drift. A replacement that is visible at a validation boundary fails closed. + +Spec 003 does **not** claim an OS sandbox or hostile concurrent filesystem-namespace containment against an actor independently able to rename/replace path components between every userspace check and syscall. This finding therefore does not authorize a filesystem broker, sandbox, daemon, or broader runtime redesign. The accepted claim is bounded supported-operation validation, not hostile-filesystem security. + +### 6. T057 fixture setup could continue after failed Git setup + +**Disposition:** REPAIRED. + +Fixture initialization is fail-closed so test setup cannot silently continue with invalid repository authority. + +### 7. Terminal termination/drop could block without a bounded proof + +**Disposition:** REPAIRED. + +Owned-terminal cleanup is bounded. It distinguishes exit observed before cleanup, exit proven after Winds termination, and unproven cleanup. Unproven process state records ownership loss rather than fabricating an exit/interrupt claim. + +### 8. Natural exit could be mislabeled as controlled termination + +**Disposition:** REPAIRED. + +Terminal lifecycle persistence differentiates `ExitedBeforeCleanup` from `Terminated`. Tests hold the shell live before exercising controlled termination so `Interrupted` is only asserted when Winds actually proves termination of the owned child. + +### 9. Obsolete deferred terminal finalization could poison future starts + +**Disposition:** REPAIRED. + +Deferred-finalization retry is resilient to obsolete/already-final rows while preserving fail-closed behavior for material persistence errors. + +### 10. Git observation object IDs accepted insufficiently constrained values + +**Disposition:** REPAIRED. + +Persisted Git observation object IDs are validated before admission rather than accepting arbitrary non-empty values. + +### 11. Historical dependency-status wording diverged from the actual portable-pty state + +**Disposition:** REPAIRED. + +The Spec 003 dependency-status documentation was reconciled with the actual approved `portable-pty` dependency state without broadening runtime scope. + +### 12. Windows history ACL claim exceeded the implementation boundary + +**Disposition:** REPAIRED / CLAIM NARROWED. + +Documentation now states the Windows inheritance boundary explicitly. Spec 003 does not claim a bespoke Windows ACL hardening system that it does not implement. + +### 13. T062 real-WSL proof could pass without proving the exact requested test + +**Disposition:** REPAIRED. + +The exact Cargo-test guard now requires exactly one matching test start and one one-test success summary, is task-marker neutral, and the T062 proof uses the guard for both mapped and fallback production-path launches. + +### 14. T062 mismatch proof could misclassify a general WSL outage + +**Disposition:** REPAIRED. + +A failing mapped `--cd` probe is cross-checked with an independent control WSL command before it can be classified as mapped-workspace rejection. A general distribution failure no longer satisfies the mismatch proof. + +### 15. T062 temporary `/etc/wsl.conf` restoration was not sufficiently fail-closed + +**Disposition:** REPAIRED. + +The backup path is unique per proof invocation, pre-existence is rejected, restoration is in `finally`, and cleanup/restore failure is fatal rather than silently accepted. + +### 16. ConPTY proof markers could be satisfied by terminal echo rather than shell execution + +**Disposition:** REPAIRED. + +Native-Windows markers are assembled by `cmd.exe` rather than sent literally as the input marker, and the start-cwd assertion uses an exact output line. This prevents input echo alone from satisfying the proof. + +### 17. WSL discovery command lifetime was unbounded + +**Disposition:** REPAIRED. + +WSL discovery captures stdout/stderr within fixed per-stream memory bounds, applies a bounded command lifetime, and kills/reaps the owned discovery child on timeout. + +### 18. Git observation/status subprocess output or lifetime could be unbounded + +**Disposition:** REPAIRED. + +Read-only Git observation/status commands now use bounded stdout/stderr capture and a bounded lifetime with owned-child kill/reap. Porcelain-v2 parsing also fails closed on malformed or unsupported record shapes instead of interpreting arbitrary bytes as valid dirty-state evidence. + +### 19. `winds execution --repo` compared only the worktree root + +**Finding:** a stored execution could share a root string while carrying a different Git common-directory identity. + +**Disposition:** REPAIRED. + +CLI execution lookup now requires the complete registered Git identity: canonical worktree root **and** Git common directory. A regression test proves root-only equality is insufficient. + +### 20. Command `requested_cwd` source attribution lost caller intent + +**Disposition:** REPAIRED. + +The ledger persists the caller-requested cwd with `CallerRequested` source while execution uses the validated canonical location. This preserves intent without weakening workspace containment validation. + +### 21. Observation/lifecycle wall-clock values could regress + +**Disposition:** REPAIRED. + +Supported lifecycle/observation paths reject a regressing wall-clock sample by recording unknown timing rather than persisting timestamps earlier than already-known request/start boundaries. + +### 22. First concurrent history writers could race on `history/` creation + +**Disposition:** REPAIRED. + +Creation of the shared history root treats only a benign `AlreadyExists` race as idempotent and then revalidates that the path is a real non-symlink directory. Per-session directories remain strict create-new ownership boundaries. + +### 23. Native Windows canonical cwd could be rejected by `cmd.exe` and silently fall back + +**Finding discovered during reconciliation CI:** Windows canonicalization can yield a verbatim drive path (`\\?\C:\...`), which `cmd.exe` can interpret as an unsupported UNC-style cwd and silently fall back to `C:\Windows`. + +**Disposition:** REPAIRED. + +Winds keeps the canonical path as terminal identity, converts only an ordinary verbatim drive path to a Win32 drive path at the PTY spawn boundary, and rejects UNC/device forms that cannot be represented safely for this shell-launch contract. Silent fallback to an unrelated cwd is not accepted. + +### 24. PR workflows could test GitHub's synthetic merge ref instead of the candidate head + +**Finding discovered during reconciliation:** some PR jobs relied on default checkout behavior, which can test `refs/pull//merge` rather than the PR branch's exact head. + +**Disposition:** REPAIRED. + +`quality` and every `windows-terminal` job now bind checkout to `github.event.pull_request.head.sha || github.sha` and immediately verify `git rev-parse HEAD` equality. `release-candidate` already used explicit candidate-head binding. Only runs containing the repaired exact-head checkout contract may satisfy T068. + +## Suggestions not accepted as new Spec 003 product scope + +The following classes of suggestions do not justify runtime expansion in T068: + +- adding migration-era constraints solely to defend against direct/manual mutation of an existing SQLite database outside supported Store APIs; +- claiming a reason string for every unavailable environmental observation when the current contract only requires explicit availability/unknown truth; +- turning crate-internal/dormant helper cleanup into a public runtime protocol; +- attempting to make Windows native execution an authoritative `winds verify` path; +- adding hostile-filesystem or hostile-repository sandboxing to close generic pathname TOCTOU claims. + +If a supported production/API path can produce false lifecycle, identity, or evidence truth, it remains a T068 bug and must be repaired. The boundary above only rejects claims that require a new threat model or product surface. + +## Mandatory remaining gates + +T068 remains OPEN until **all** of the following are true on one final repaired exact head/tree: + +1. `quality` passes on the exact candidate head. +2. `windows-terminal` passes on the exact candidate head, including native Windows and real WSL2 evidence. +3. `release-candidate` passes on the exact candidate head. +4. Every material Qodo/CodeRabbit/Cubic finding is reconciled on that same final surface. +5. A **fresh independent review** evaluates that repaired exact head/tree after CI is green; historical PR #62 and T066/T067 reviews do not count. +6. Any new material finding from that fresh review is repaired, followed by another complete exact-head CI and fresh review cycle. +7. Zero unresolved material review threads remain. +8. Only then may a separate canonical `tasks.md` closeout check T068. + +Until these gates are satisfied: + +- PR #63 remains unmerged; +- PR #62 remains historical and unmerged; +- T068 remains unchecked; +- T069 remains unchecked / NOT_STARTED; +- Spec 003 remains NOT_COMPLETE. diff --git a/specs/003-workspace-execution-spine/tasks.md b/specs/003-workspace-execution-spine/tasks.md index f1355436..f4817738 100644 --- a/specs/003-workspace-execution-spine/tasks.md +++ b/specs/003-workspace-execution-spine/tasks.md @@ -51,7 +51,7 @@ This checklist records implementation/evidence truth for Spec 003. A checked ite - [x] **T065** Update README/CONTRIBUTING/SECURITY/relevant docs for accepted 0.2 workspace-terminal behavior only. Do not describe SQL Studio, LLM Observatory, persistent detached terminals, terminal renderer, or native-Windows verification as implemented unless separately proven. **Canonical evidence:** PR #56 final accepted head `73dd98cf6ff211b94d86b44e7a94d15ce3fad989` / tree `aa4123b51c66accb77d72a74e1fcf2f8917b5d15` changed only `README.md`, `CONTRIBUTING.md`, `SECURITY.md`, and `CHANGELOG.md`; quality #468 and release-candidate #278 passed on that exact head. Exact-head documentation correctness/safety/authority and Ponytail v4.9.0 review passed after narrowing one ambiguous native-Windows verification sentence; fresh Qodo exact-head review reported Bugs (0), Rule violations (0), Requirement gaps (0), and no material issues; CodeRabbit's requested exact-head rerun was rate-limited and is not counted; zero review threads remained. PR #56 squash-merged with expected-head guard as canonical main `1cf0ddfc997d11bfc10ea4359f79ffdcb3c103cb`, whose tree exactly equals the accepted candidate tree `aa4123b51c66accb77d72a74e1fcf2f8917b5d15`. The accepted docs keep `v0.1.0` as the public release, describe current Spec 003 behavior as accepted-but-unreleased, separate workspace execution/history from verification authority, distinguish native-Windows workspace/ConPTY and WSL evidence from unsupported native-Windows authoritative required-check execution, and explicitly defer SQL Studio, LLM Observatory, terminal rendering, persistent detached terminals/cross-restart attachment, daemon/public runtime protocol, plugin/provider runtime, MCP/ACP/A2A/Agent Fleet, and broad sandboxing. This closes only T065; T066+ remain not started and Herdr remains a future donor reference only. - [x] **T066** Complete correctness/safety review on the exact final implementation head, explicitly covering PTY/process ownership, stale PID reuse, Windows/Unix close semantics, WSL path/domain truth, SQLite partial transitions, shell-telemetry source attribution, secret/history persistence, and separation from verification authority. **Canonical evidence:** PR #58 final accepted head `8601b7dbb44582a284813bbd50a44aeb1afd24f1` passed quality #495, windows-terminal #233, and release-candidate #302, including Ubuntu/macOS format+Clippy+full tests, native-Windows full touched-surface tests, real Windows+Ubuntu WSL2 integration, T063 soak on Ubuntu/macOS/Windows, T064 verification regression on Ubuntu/macOS, SC-001, native-Windows authority refusal, and Linux/macOS packaging. The T066 review exposed and repaired the missing user-facing restart reconciliation, startup-vs-bulk ownership races, lease unlink pathname/inode split-brain, same-kind deferral, display proof/read races, an intermediate ownership-directory TOCTOU, cross-owner durable-exit finalization, recoverable clock-regression handling, and multiple acceptance-fixture reliability gaps. The final design uses exact-ID retained SQLite ownership leases with domain-separated SHA-256 filenames directly under canonical `WINDS_HOME`, targeted reconciliation with unknown end/duration on ownership loss, no PID reconnect/blind signaling, post-proof display refresh, and durable observed-exit finalization only under exact ownership recovery. `src/command.rs` regression coverage proves starting command B cannot finalize unrelated command A; the binary T066 fixture proves future-dated stale rows still reconcile fail-closed without fabricating timing. Fresh exact-head Qodo merge-gate review found no remaining blocking correctness/safety/scope issue; all review threads were resolved, including the late Cubic findings, with the broad proposal to silently skip corrupt/persistence reconciliation failures rejected because FR-019/FR-029 require conservative truth. PR #58 merged with expected-head guard as canonical main `af89ee6a65bc796ddb74aee01becca3a7af7af8a`. This closes **T066 only**; it does not start or satisfy T067 Ponytail review, T068 independent review, or T069 final Spec 003 reconciliation, and adds no daemon/public runtime protocol/plugin/provider/MCP/ACP/A2A/Agent Fleet/Herdr behavior or native-Windows verification-authority claim. - [x] **T067** Complete Ponytail v4.9.0 simplicity review on the exact final implementation head. Challenge every dependency/module/protocol; remove custom multiplexer/renderer/plugin/provider/environment-manager machinery not required by Spec 003. **Canonical evidence:** PR #60 review head `b216f36bcd3773860cdb427b4b54bdd278d9f4e9` added only `t067-ponytail-review.md`, bound the review to exact final implementation head `8601b7dbb44582a284813bbd50a44aeb1afd24f1` / tree `1d056bead423f02c62ace10b798ceb5c1a1c191c`, and recorded verdict `T067_REVIEW_PASS_NO_REQUIRED_REMOVALS`. The review challenged all six direct dependencies, concrete module seams, forbidden daemon/public-protocol/plugin/provider/renderer/multiplexer surfaces, custom trait-framework risk, module-wide `dead_code` allowances, large-file refactor pressure, speculative SQL/LLM/Agent Fleet/Herdr/Pi abstractions, and the T066 ownership-lease machinery. It found no dependency, module, protocol, service boundary, interface, or runtime subsystem that can be removed without deleting an accepted requirement or replacing concrete code with more machinery; no runtime/dependency/migration/workflow change was required. Exact-head quality #499 passed on the review PR; Qodo reviewed `b216f36...` with Bugs (0), Rule violations (0), Requirement gaps (0), and zero review threads remained. PR #60 merged with expected-head guard as canonical main `9128133573e80dbbe4d467b95873a6740e64d672`. This closes **T067 only**; PR #60 artifact reviews do not start or satisfy T068 independent review, T068/T069 remain not started, and Spec 003 is not yet complete. -- [ ] **T068** Obtain and reconcile at least one independent reviewer pass on the exact final implementation head. External summaries or reviews bound only to older heads do not satisfy this task. +- [x] **T068** Obtain and reconcile at least one independent reviewer pass on the exact final implementation head. External summaries or reviews bound only to older heads do not satisfy this task. **Closeout evidence:** PR #63 final reviewed implementation head `badfa984d7aa5552478aaba5b7da5819290253df` / tree `d5e6ffcdd97af9cf0281c2606f799fb88b9e6b0e`, against unchanged canonical base `29c394084631afd6d1890362372b8a162dac083a` with `behind_by=0`, passed quality #613, windows-terminal #338, and release-candidate #405 on that same exact head. The Windows gate includes real Windows Server 2025 + Ubuntu WSL2 T062 production proof/evidence; release-candidate includes T063 100-cycle soak on Ubuntu/macOS/Windows, T064 regression, SC-001, native-Windows authority refusal, quality, and release builds. All material reconciliation threads are resolved, including the final CodeRabbit post-exit WSL drain finding; zero material review threads remain unresolved. Fresh independent Qodo full-implementation review was explicitly bound to the exact head/tree/base above and returned **NO MATERIAL FINDING REMAINING**, including focused re-evaluation of bounded WSL post-exit draining, object-bound history pruning, clone staging cleanup, and the complete current implementation surface. This checks T068 only; PR #63 remains draft/unmerged pending the documentation-only closeout head landing gate, T069 remains NOT STARTED, and Spec 003 remains incomplete. - [ ] **T069** Reconcile deterministic CI, platform/WSL evidence, soak results, correctness/safety, Ponytail, and independent-review findings into final canonical task truth before making the Spec 003 completion claim. ## Explicit Follow-On Specifications diff --git a/specs/003-workspace-execution-spine/terminal-trust-boundary.md b/specs/003-workspace-execution-spine/terminal-trust-boundary.md index 88821277..c3f08eac 100644 --- a/specs/003-workspace-execution-spine/terminal-trust-boundary.md +++ b/specs/003-workspace-execution-spine/terminal-trust-boundary.md @@ -71,6 +71,8 @@ Winds applies the accepted Spec 003 local history and metadata controls, includi These controls reduce unnecessary persistence; they are not a guarantee that a command cannot access or disclose a secret. A launched process may access any secret available to that process, and no secret detector can prove that arbitrary command text or output is secret-free. +On Unix, Winds-created local-history directories and files request owner-only filesystem modes (`0700` for directories and `0600` for files). On Windows, the current Spec 003 slice does **not** create or validate an owner-only ACL; history paths inherit the ACL of the configured `WINDS_HOME`. Winds therefore does not claim cross-local-account confidentiality when `WINDS_HOME` is accessible to other principals. Users who require that boundary must place `WINDS_HOME` under an appropriately restricted Windows ACL using operating-system administration controls, or disable history for sensitive sessions. + Users should disable history when the supported local-history policy is inappropriate for a sensitive session and should rely on external OS/container/credential controls when stronger isolation is required. ## PTY ownership is lifecycle ownership, not security isolation @@ -94,7 +96,7 @@ Execution-domain selection does not add sandboxing. A WSL process has the permis | Workspace identity | Canonical repository/worktree identity and accepted Git observations | That workspace code is safe or verified | | PTY/ConPTY lifecycle | Accepted directly observed lifecycle facts for the session Winds owns | OS/network/secret isolation or complete descendant ownership | | Explicit command execution | Requested command plus accepted lifecycle/exit/Git observations | That the command's claims or produced code are correct | -| Local history | Bounded retained history and its metadata under the selected policy | That retained content is secret-free or verification evidence | +| Local history | Bounded retained history and its metadata under the selected policy | That retained content is secret-free, cross-account private on a permissive state root, or verification evidence | | `winds verify` | Evidence produced under the accepted verification path for the exact candidate/base | Authorization to weaken candidate, evidence, or promotion rules | ## Scope boundary diff --git a/src/cli_workspace.rs b/src/cli_workspace.rs index 1aa5f3fb..6c111998 100644 --- a/src/cli_workspace.rs +++ b/src/cli_workspace.rs @@ -443,10 +443,12 @@ fn sqlite_busy_or_locked(error: &rusqlite::Error) -> bool { fn require_execution_repo(store: &Store, execution_id: &str, repo: &Repo) -> Result<()> { let execution = store.load_execution(execution_id)?; let workspace = store.load_workspace(&execution.workspace_id)?; - let repo_root = utf8_path(repo.root(), "repository path")?; - if workspace.canonical_worktree_root != repo_root { + let repo_root = utf8_path(repo.root(), "repository worktree root")?; + let repo_common_dir = utf8_path(repo.common_dir(), "repository Git common directory")?; + if workspace.canonical_worktree_root != repo_root || workspace.git_common_dir != repo_common_dir + { return Err(format!( - "execution {execution_id} belongs to a different Winds workspace than --repo" + "execution {execution_id} belongs to a different Winds workspace Git identity than --repo" ) .into()); } @@ -472,6 +474,29 @@ fn execution_snapshot(store: &Store, execution_id: &str) -> Result { }) }) .collect::>(); + let git_observations = if execution.kind == ExecutionKind::ShellCommand { + store + .load_execution_git_observations(execution_id)? + .into_iter() + .map(|observation| { + json!({ + "execution_id": observation.execution_id, + "boundary": observation.boundary.as_str(), + "availability": observation.availability.as_str(), + "source": observation.source, + "head_oid": observation.head_oid, + "branch": observation.branch, + "detached": observation.detached, + "dirty": observation.dirty, + "worktree_state_format": observation.worktree_state_format, + "worktree_state_sha256": observation.worktree_state_sha256, + "observed_unix_ms": observation.observed_unix_ms, + }) + }) + .collect::>() + } else { + Vec::new() + }; let (terminal, shell_command) = match execution.kind { ExecutionKind::Terminal => { @@ -523,6 +548,7 @@ fn execution_snapshot(store: &Store, execution_id: &str) -> Result { "duration_ms": execution.duration_ms, "terminal": terminal, "shell_command": shell_command, + "git_observations": git_observations, "events": events, })) } @@ -663,8 +689,17 @@ fn print_json(value: &impl Serialize) -> Result<()> { mod tests { use super::{ execution_lease_filename, parse_arguments, parse_history_policy, parse_terminal_size, + require_execution_repo, }; use crate::command::history::SessionHistoryPolicy; + use crate::domain::{ExecutionKind, FactSource}; + use crate::git::Repo; + use crate::store::{NewExecution, NewWorkspace, Store}; + use std::fs; + use std::process::Command; + use std::sync::atomic::{AtomicU64, Ordering}; + + static NEXT_ROOT: AtomicU64 = AtomicU64::new(0); #[test] fn arguments_default_to_empty_and_parse_string_arrays() { @@ -708,4 +743,57 @@ mod tests { assert!(!first.contains('/')); assert!(!first.contains(':')); } + + #[test] + fn execution_repo_requires_worktree_and_git_common_directory_identity() { + let sequence = NEXT_ROOT.fetch_add(1, Ordering::Relaxed); + let root = std::env::temp_dir().join(format!( + "winds-cli-workspace-identity-{}-{sequence}", + std::process::id() + )); + let repo_path = root.join("repo"); + let home = root.join("home"); + fs::create_dir_all(&repo_path).unwrap(); + fs::create_dir(&home).unwrap(); + let status = Command::new("git") + .args(["init", "--initial-branch=main"]) + .current_dir(&repo_path) + .status() + .unwrap(); + assert!(status.success()); + + let repo = Repo::open(&repo_path).unwrap(); + let repo_root = repo.root().to_str().unwrap().to_owned(); + let wrong_common_dir = home.canonicalize().unwrap(); + assert_ne!(wrong_common_dir, repo.common_dir()); + + let mut store = Store::open(&home).unwrap(); + store + .create_workspace( + NewWorkspace { + workspace_id: "workspace-cli-identity", + canonical_worktree_root: &repo_root, + git_common_dir: wrong_common_dir.to_str().unwrap(), + }, + 1, + ) + .unwrap(); + store + .create_execution( + NewExecution { + execution_id: "execution-cli-identity", + workspace_id: "workspace-cli-identity", + kind: ExecutionKind::ShellCommand, + request_source: FactSource::CallerRequested, + execution_domain: "{}", + }, + 2, + ) + .unwrap(); + + let error = require_execution_repo(&store, "execution-cli-identity", &repo).unwrap_err(); + assert!(error.to_string().contains("workspace Git identity")); + drop(store); + fs::remove_dir_all(root).unwrap(); + } } diff --git a/src/command.rs b/src/command.rs index f4cd77e3..35ac441e 100644 --- a/src/command.rs +++ b/src/command.rs @@ -27,6 +27,12 @@ pub struct ExplicitCommandResult { pub duration_ms: Option, } +struct ValidatedWorkspaceCwd { + requested: String, + canonical: PathBuf, + workspace: WorkspaceRecord, +} + pub fn run_explicit_command( store: &mut Store, request: ExplicitCommandRequest<'_>, @@ -55,7 +61,6 @@ pub fn run_explicit_command_with_history_policy( return Err("explicit command arguments may not contain NUL bytes".into()); } let cwd = validate_workspace_cwd(store, request.workspace_id, request.cwd)?; - let workspace = store.load_workspace(request.workspace_id)?; let execution_domain = serde_json::to_string(&ShellExecutionDomain::NativeHost { os: std::env::consts::OS.to_owned(), arch: std::env::consts::ARCH.to_owned(), @@ -75,7 +80,7 @@ pub fn run_explicit_command_with_history_policy( executable: &executable, arguments: &persisted_arguments, command_source: FactSource::CallerRequested, - requested_cwd: &cwd, + requested_cwd: &cwd.requested, cwd_source: FactSource::CallerRequested, }, requested_unix_ms, @@ -84,7 +89,7 @@ pub fn run_explicit_command_with_history_policy( if let Err(observation_error) = record_git_boundary_observation( store, request.execution_id, - &workspace, + &cwd.workspace, GitObservationBoundary::Before, ) { let failed_unix_ms = trustworthy_wall_time_after(requested_unix_ms, None); @@ -103,7 +108,7 @@ pub fn run_explicit_command_with_history_policy( let mut child = match Command::new(&executable) .args(request.arguments) - .current_dir(&cwd) + .current_dir(&cwd.canonical) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) @@ -187,7 +192,7 @@ pub fn run_explicit_command_with_history_policy( record_git_boundary_observation( store, request.execution_id, - &workspace, + &cwd.workspace, GitObservationBoundary::After, ) .map_err(|error| { @@ -213,7 +218,12 @@ fn record_git_boundary_observation( ) -> Result<()> { let root = Path::new(&workspace.canonical_worktree_root); let common_dir = Path::new(&workspace.git_common_dir); - let observed_unix_ms = unix_ms().ok(); + let execution = store.load_execution(execution_id)?; + let observed_unix_ms = non_regressing_wall_time( + unix_ms().ok(), + execution.requested_unix_ms, + execution.started_unix_ms, + ); match observe_worktree_state(root, common_dir) { Ok(observation) => store.record_execution_git_observation(NewExecutionGitObservation { execution_id, @@ -255,23 +265,32 @@ fn validate_executable(path: &Path) -> Result { // This validates caller-requested cwd against the current filesystem view. It is not an // OS sandbox or a hostile concurrent-rename containment primitive. -fn validate_workspace_cwd(store: &Store, workspace_id: &str, cwd: &Path) -> Result { +fn validate_workspace_cwd( + store: &Store, + workspace_id: &str, + cwd: &Path, +) -> Result { if !cwd.is_absolute() { return Err("explicit command cwd must be an absolute path".into()); } - let canonical_cwd = fs::canonicalize(cwd)?; - if !canonical_cwd.is_dir() { + let requested = cwd + .to_str() + .map(str::to_owned) + .ok_or("explicit command cwd is not valid UTF-8")?; + let canonical = fs::canonicalize(cwd)?; + if !canonical.is_dir() { return Err("explicit command cwd must be a directory".into()); } let workspace = store.load_workspace(workspace_id)?; let workspace_root = PathBuf::from(&workspace.canonical_worktree_root); - if !canonical_cwd.starts_with(&workspace_root) { + if !canonical.starts_with(&workspace_root) { return Err("explicit command cwd must remain inside the registered workspace".into()); } - canonical_cwd - .to_str() - .map(str::to_owned) - .ok_or_else(|| "explicit command cwd is not valid UTF-8".into()) + Ok(ValidatedWorkspaceCwd { + requested, + canonical, + workspace, + }) } fn cleanup_owned_child(child: &mut Child) -> bool { @@ -433,7 +452,7 @@ mod tests { } fn workspace_path(root: &TestRoot) -> PathBuf { - fs::canonicalize(root.path().join("workspace")).unwrap() + root.path().join("workspace") } #[cfg(unix)] @@ -638,6 +657,34 @@ mod tests { })); } + #[test] + fn explicit_command_preserves_requested_cwd_while_executing_canonical_location() { + let root = TestRoot::new("requested-cwd"); + let mut store = store_with_workspace(&root); + let workspace = workspace_path(&root); + let nested = workspace.join("nested"); + fs::create_dir(&nested).unwrap(); + let requested = nested.join(".."); + assert_ne!(requested, fs::canonicalize(&requested).unwrap()); + let (executable, arguments) = command_parts(0, false); + + run_explicit_command( + &mut store, + ExplicitCommandRequest { + execution_id: "command-requested-cwd", + workspace_id: "workspace-1", + executable: &executable, + arguments: &arguments, + cwd: &requested, + }, + ) + .unwrap(); + + let command = store.load_shell_command("command-requested-cwd").unwrap(); + assert_eq!(command.requested_cwd, requested.to_str().unwrap()); + assert_eq!(command.cwd_source, FactSource::CallerRequested); + } + #[test] fn explicit_command_redacts_obvious_secret_metadata_without_changing_runtime_arguments() { let root = TestRoot::new("secret-metadata"); diff --git a/src/command/history.rs b/src/command/history.rs index 7297787b..9decefcc 100644 --- a/src/command/history.rs +++ b/src/command/history.rs @@ -8,7 +8,9 @@ use std::io::{Read, Write}; use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; -use std::time::{Duration, SystemTime}; +use std::time::Duration; + +mod history_prune; pub(crate) const HARD_MAX_TRANSCRIPT_BYTES: usize = 8 * 1024 * 1024; const MAX_EXECUTION_ID_BYTES: usize = 512; @@ -310,28 +312,20 @@ impl SessionHistoryRecorder { Ok(()) })(); if let Err(error) = write_result { - return match remove_owned_history_session(history_root, &session_dir) { - Ok(()) => Err(error), - Err(cleanup_error) => Err(format!( - "terminal history write failed: {error}; owned-session cleanup also failed: {cleanup_error}" - ) - .into()), - }; + return Err(format!( + "terminal history write failed: {error}; partial session was retained at {} because Winds does not recursively delete history through a mutable pathname", + session_dir.display() + ) + .into()); } let usage = history_logical_bytes(history_root)?; if usage > self.policy.total_history_byte_quota { - return match remove_owned_history_session(history_root, &session_dir) { - Ok(()) => Err(format!( - "terminal history quota verification failed after write: {usage} > {}", - self.policy.total_history_byte_quota - ) - .into()), - Err(cleanup_error) => Err(format!( - "terminal history quota verification failed after write: {usage} > {}; owned-session cleanup also failed: {cleanup_error}", - self.policy.total_history_byte_quota - ) - .into()), - }; + return Err(format!( + "terminal history quota verification failed after write: {usage} > {}; session was retained at {} because Winds does not recursively delete history through a mutable pathname", + self.policy.total_history_byte_quota, + session_dir.display() + ) + .into()); } Ok(()) })?; @@ -648,7 +642,19 @@ fn ensure_private_directory(path: &Path) -> Result<()> { } } Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - create_private_directory(path)?; + let mut builder = DirBuilder::new(); + builder.recursive(false); + #[cfg(unix)] + builder.mode(0o700); + match builder.create(path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(error.into()), + } + let metadata = fs::symlink_metadata(path)?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err("terminal history path must be a real directory".into()); + } } Err(error) => return Err(error.into()), } @@ -675,101 +681,39 @@ fn write_private_file(path: &Path, bytes: &[u8]) -> Result<()> { Ok(()) } -#[derive(Debug)] -struct RetainedHistoryDir { - path: PathBuf, - logical_bytes: u64, - modified: SystemTime, -} - fn prune_for_write( history_root: &Path, new_storage_key: &str, required_bytes: u64, total_quota: u64, ) -> Result<()> { - if !is_history_storage_key(new_storage_key) { - return Err("terminal history storage key is invalid".into()); - } - if history_root.join(new_storage_key).exists() { - return Err("terminal history for this execution already exists or is incomplete".into()); - } - let mut entries = retained_history_dirs(history_root)?; - let mut existing = entries.iter().try_fold(0_u64, |sum, entry| { - sum.checked_add(entry.logical_bytes) - .ok_or("terminal history logical byte size overflowed") - })?; - let budget = total_quota - .checked_sub(required_bytes) - .ok_or("terminal history record exceeds total history quota")?; - entries.sort_by(|left, right| { - left.modified - .cmp(&right.modified) - .then_with(|| left.path.cmp(&right.path)) - }); - for entry in entries { - if existing <= budget { - break; - } - remove_owned_history_session(history_root, &entry.path)?; - existing = existing.saturating_sub(entry.logical_bytes); - } - if existing > budget { - return Err("terminal history quota could not be satisfied by retention pruning".into()); - } - Ok(()) -} - -fn remove_owned_history_session(history_root: &Path, target: &Path) -> Result<()> { - let history_metadata = fs::symlink_metadata(history_root)?; - if history_metadata.file_type().is_symlink() || !history_metadata.is_dir() { - return Err("terminal history root must be a real owned directory before deletion".into()); - } - let target_metadata = fs::symlink_metadata(target)?; - if target_metadata.file_type().is_symlink() || !target_metadata.is_dir() { - return Err("terminal history deletion target must be a real directory".into()); - } - let canonical_history_root = fs::canonicalize(history_root)?; - let canonical_target = fs::canonicalize(target)?; - if canonical_target == canonical_history_root - || canonical_target.parent() != Some(canonical_history_root.as_path()) - { - return Err("terminal history deletion target is outside the owned history root".into()); - } - let name = canonical_target - .file_name() - .and_then(|value| value.to_str()) - .ok_or("terminal history deletion target name is not valid UTF-8")?; - if !is_history_storage_key(name) { - return Err("terminal history deletion target is not an owned session directory".into()); - } - fs::remove_dir_all(&canonical_target)?; - Ok(()) + history_prune::prune_for_write( + history_root, + new_storage_key, + required_bytes, + total_quota, + || Ok(()), + ) } -fn retained_history_dirs(history_root: &Path) -> Result> { - let mut entries = Vec::new(); - for entry in fs::read_dir(history_root)? { - let entry = entry?; - let metadata = fs::symlink_metadata(entry.path())?; - if metadata.file_type().is_symlink() || !metadata.is_dir() { - return Err("terminal history root contains an unexpected non-directory entry".into()); - } - let name = entry - .file_name() - .to_str() - .ok_or("terminal history directory name is not valid UTF-8")? - .to_owned(); - if !is_history_storage_key(&name) { - return Err("terminal history root contains an unrecognized directory".into()); - } - entries.push(RetainedHistoryDir { - logical_bytes: session_logical_bytes(&entry.path())?, - modified: metadata.modified()?, - path: entry.path(), - }); - } - Ok(entries) +#[cfg(test)] +fn prune_for_write_impl( + history_root: &Path, + new_storage_key: &str, + required_bytes: u64, + total_quota: u64, + after_identity_proven: F, +) -> Result<()> +where + F: FnOnce() -> Result<()>, +{ + history_prune::prune_for_write( + history_root, + new_storage_key, + required_bytes, + total_quota, + after_identity_proven, + ) } fn is_history_storage_key(name: &str) -> bool { @@ -782,28 +726,8 @@ fn is_history_storage_key(name: &str) -> bool { .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) } -fn session_logical_bytes(session_dir: &Path) -> Result { - let mut total = 0_u64; - for entry in fs::read_dir(session_dir)? { - let entry = entry?; - let metadata = fs::symlink_metadata(entry.path())?; - if metadata.file_type().is_symlink() || !metadata.is_file() { - return Err("terminal history session contains an unexpected non-file entry".into()); - } - total = total - .checked_add(metadata.len()) - .ok_or("terminal history logical byte size overflowed")?; - } - Ok(total) -} - fn history_logical_bytes(history_root: &Path) -> Result { - retained_history_dirs(history_root)? - .into_iter() - .try_fold(0_u64, |sum, entry| { - sum.checked_add(entry.logical_bytes) - .ok_or_else(|| "terminal history logical byte size overflowed".into()) - }) + history_prune::history_logical_bytes(history_root) } fn minimum_manifest_bytes(execution_id: &str, policy: SessionHistoryPolicy) -> Result { @@ -895,9 +819,9 @@ fn utf8_relative(path: &Path) -> Result { mod tests { use super::{ HARD_MAX_TRANSCRIPT_BYTES, HISTORY_DISABLED, REDACTED, SessionHistoryPolicy, - SessionHistoryRecorder, history_logical_bytes, history_storage_key, lower_sha256, - persisted_arguments, prune_for_write, remove_owned_history_session, - sanitize_persisted_arguments, with_history_write_lock, + SessionHistoryRecorder, ensure_private_directory, history_logical_bytes, + history_storage_key, lower_sha256, persisted_arguments, prune_for_write, + prune_for_write_impl, sanitize_persisted_arguments, with_history_write_lock, }; use crate::domain::{ExecutionKind, FactSource}; use crate::store::{NewExecution, NewTerminalSession, NewWorkspace, Store}; @@ -906,6 +830,8 @@ mod tests { use std::io::{Cursor, Read}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::{Arc, Barrier}; + use std::thread; static NEXT_ROOT: AtomicU64 = AtomicU64::new(0); @@ -1054,6 +980,29 @@ mod tests { assert!(!joined.contains("opaque=value")); } + #[test] + fn concurrent_history_root_initialization_is_idempotent_and_fail_closed() { + let root = TestRoot::new("history-root-race"); + let history = Arc::new(root.path().join("history")); + let barrier = Arc::new(Barrier::new(8)); + let handles = (0..8) + .map(|_| { + let history = Arc::clone(&history); + let barrier = Arc::clone(&barrier); + thread::spawn(move || { + barrier.wait(); + ensure_private_directory(&history) + }) + }) + .collect::>(); + for handle in handles { + handle.join().unwrap().unwrap(); + } + let metadata = fs::symlink_metadata(history.as_path()).unwrap(); + assert!(!metadata.file_type().is_symlink()); + assert!(metadata.is_dir()); + } + #[test] fn history_filesystem_lock_does_not_hold_winds_database_writer_lock() { let root = TestRoot::new("history-lock-separation"); @@ -1235,74 +1184,73 @@ mod tests { } #[test] - fn total_quota_prunes_old_sessions_across_repeated_terminal_history() { + fn total_quota_prunes_oldest_history_and_allows_new_session() { let root = TestRoot::new("retention"); - let state_root = state_with_terminal_executions( - &root, - &["retention-one", "retention-two", "retention-three"], - ); + let state_root = state_with_terminal_executions(&root, &["retention-one", "retention-two"]); let policy = SessionHistoryPolicy::local_bounded(false, 4, 1_024).unwrap(); - for execution_id in ["retention-one", "retention-two", "retention-three"] { - let recorder = - SessionHistoryRecorder::new_local(execution_id, policy, &state_root).unwrap(); - capture_all(&recorder, b"abcdefgh"); - recorder.persist().unwrap().unwrap(); - assert!(history_logical_bytes(&state_root.join("history")).unwrap() <= 1_024); - } - let retained_count = fs::read_dir(state_root.join("history")).unwrap().count(); - assert!(retained_count < 3); + + let first = + SessionHistoryRecorder::new_local("retention-one", policy, &state_root).unwrap(); + capture_all(&first, b"abcdefgh"); + first.persist().unwrap().unwrap(); + + let history = state_root.join("history"); + let first_dir = history.join(history_storage_key("retention-one")); + assert!(first_dir.is_dir()); + assert!(history_logical_bytes(&history).unwrap() <= 1_024); + + let second = + SessionHistoryRecorder::new_local("retention-two", policy, &state_root).unwrap(); + capture_all(&second, b"abcdefgh"); + second.persist().unwrap().unwrap(); + + let second_dir = history.join(history_storage_key("retention-two")); + assert!(!first_dir.exists()); + assert!(second_dir.is_dir()); + assert!(history_logical_bytes(&history).unwrap() <= 1_024); } #[test] - fn quota_helper_prunes_existing_sessions_before_new_write() { + fn quota_helper_prunes_owned_flat_session_without_recursive_delete() { let root = TestRoot::new("retention-helper"); let history = root.path().join("history"); fs::create_dir(&history).unwrap(); - for execution_id in ["a", "b"] { - let dir = history.join(history_storage_key(execution_id)); - fs::create_dir(&dir).unwrap(); - fs::write(dir.join("blob"), b"1234").unwrap(); - } + let dir = history.join(history_storage_key("owned")); + fs::create_dir(&dir).unwrap(); + let transcript_name = format!("transcript.{}.bin", lower_sha256(b"1234")); + let manifest_name = format!("manifest.{}.json", lower_sha256(b"5678")); + fs::write(dir.join(transcript_name), b"1234").unwrap(); + fs::write(dir.join(manifest_name), b"5678").unwrap(); + assert_eq!(history_logical_bytes(&history).unwrap(), 8); prune_for_write(&history, &history_storage_key("new"), 8, 8).unwrap(); + assert!(!dir.exists()); assert_eq!(history_logical_bytes(&history).unwrap(), 0); } #[test] - fn recursive_history_delete_rejects_root_outside_and_unrecognized_targets() { - let root = TestRoot::new("delete-ownership"); + fn quota_pruning_preserves_foreign_replacement_after_final_identity_proof() { + let root = TestRoot::new("retention-replacement"); let history = root.path().join("history"); fs::create_dir(&history).unwrap(); - let owned = history.join(history_storage_key("owned")); - fs::create_dir(&owned).unwrap(); - fs::write(owned.join("blob"), b"safe").unwrap(); - remove_owned_history_session(&history, &owned).unwrap(); - assert!(!owned.exists()); - - assert!(remove_owned_history_session(&history, &history).is_err()); - - let outside = root.path().join(history_storage_key("outside")); - fs::create_dir(&outside).unwrap(); - assert!(remove_owned_history_session(&history, &outside).is_err()); - - let unexpected = history.join("session-not-a-sha256"); - fs::create_dir(&unexpected).unwrap(); - assert!(remove_owned_history_session(&history, &unexpected).is_err()); - } - - #[cfg(unix)] - #[test] - fn recursive_history_delete_rejects_symlink_target() { - use std::os::unix::fs::symlink; + let session = history.join(history_storage_key("owned")); + let moved_owned = root.path().join("moved-owned-session"); + fs::create_dir(&session).unwrap(); + let owned_name = format!("transcript.{}.bin", lower_sha256(b"owned")); + fs::write(session.join(&owned_name), b"owned\n").unwrap(); + let foreign_marker = session.join("foreign-marker"); + + let error = prune_for_write_impl(&history, &history_storage_key("new"), 8, 8, || { + fs::rename(&session, &moved_owned)?; + fs::create_dir(&session)?; + fs::write(&foreign_marker, b"foreign\n")?; + Ok(()) + }) + .unwrap_err() + .to_string(); - let root = TestRoot::new("delete-symlink"); - let history = root.path().join("history"); - fs::create_dir(&history).unwrap(); - let outside = root.path().join("outside"); - fs::create_dir(&outside).unwrap(); - let link = history.join(history_storage_key("linked")); - symlink(&outside, &link).unwrap(); - assert!(remove_owned_history_session(&history, &link).is_err()); - assert!(outside.exists()); + assert!(error.contains("filesystem identity changed")); + assert_eq!(fs::read(&foreign_marker).unwrap(), b"foreign\n"); + assert_eq!(fs::read(moved_owned.join(owned_name)).unwrap(), b"owned\n"); } } diff --git a/src/command/history/history_prune.rs b/src/command/history/history_prune.rs new file mode 100644 index 00000000..28c77e74 --- /dev/null +++ b/src/command/history/history_prune.rs @@ -0,0 +1,700 @@ +use crate::store::Result; +use std::ffi::OsString; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +#[cfg(unix)] +use std::ffi::CString; +#[cfg(unix)] +use std::os::fd::{AsRawFd, FromRawFd}; +#[cfg(unix)] +use std::os::unix::ffi::OsStrExt; +#[cfg(unix)] +use std::os::unix::fs::MetadataExt; + +#[cfg(windows)] +use std::ffi::c_void; +#[cfg(windows)] +use std::mem::MaybeUninit; +#[cfg(windows)] +use std::os::windows::fs::OpenOptionsExt; +#[cfg(windows)] +use std::os::windows::io::AsRawHandle; + +#[cfg(unix)] +type HistoryPathIdentity = (u64, u64); +#[cfg(windows)] +type HistoryPathIdentity = (u64, [u8; 16]); +#[cfg(not(any(unix, windows)))] +type HistoryPathIdentity = (); + +#[derive(Debug)] +struct RetainedHistoryFile { + name: OsString, + identity: HistoryPathIdentity, +} + +#[derive(Debug)] +struct RetainedHistoryDir { + path: PathBuf, + logical_bytes: u64, + modified: SystemTime, + identity: HistoryPathIdentity, + files: Vec, +} + +pub(super) fn prune_for_write( + history_root: &Path, + new_storage_key: &str, + required_bytes: u64, + total_quota: u64, + after_identity_proven: F, +) -> Result<()> +where + F: FnOnce() -> Result<()>, +{ + if !super::is_history_storage_key(new_storage_key) { + return Err("terminal history storage key is invalid".into()); + } + if history_root.join(new_storage_key).exists() { + return Err("terminal history for this execution already exists or is incomplete".into()); + } + + let root_identity = history_directory_identity(history_root, "terminal history root")?; + let mut entries = retained_history_dirs(history_root)?; + let mut existing = entries.iter().try_fold(0_u64, |sum, entry| { + sum.checked_add(entry.logical_bytes) + .ok_or("terminal history logical byte size overflowed") + })?; + let budget = total_quota + .checked_sub(required_bytes) + .ok_or("terminal history record exceeds total history quota")?; + + entries.sort_by(|left, right| { + left.modified + .cmp(&right.modified) + .then_with(|| left.path.cmp(&right.path)) + }); + + let mut hook = Some(after_identity_proven); + for entry in entries { + if existing <= budget { + break; + } + let this_hook = hook.take(); + remove_owned_history_session( + history_root, + &root_identity, + &entry, + move || match this_hook { + Some(callback) => callback(), + None => Ok(()), + }, + )?; + existing = existing.saturating_sub(entry.logical_bytes); + } + + if existing > budget { + return Err( + "terminal history quota could not be satisfied by object-bound retention pruning" + .into(), + ); + } + Ok(()) +} + +pub(super) fn history_logical_bytes(history_root: &Path) -> Result { + retained_history_dirs(history_root)? + .into_iter() + .try_fold(0_u64, |sum, entry| { + sum.checked_add(entry.logical_bytes) + .ok_or_else(|| "terminal history logical byte size overflowed".into()) + }) +} + +fn retained_history_dirs(history_root: &Path) -> Result> { + let root_identity = history_directory_identity(history_root, "terminal history root")?; + let mut entries = Vec::new(); + for entry in fs::read_dir(history_root)? { + let entry = entry?; + let name = entry + .file_name() + .to_str() + .ok_or("terminal history directory name is not valid UTF-8")? + .to_owned(); + if !super::is_history_storage_key(&name) { + return Err("terminal history root contains an unrecognized directory".into()); + } + entries.push(snapshot_history_session(&entry.path())?); + } + require_history_directory_identity(history_root, &root_identity, "terminal history root")?; + Ok(entries) +} + +fn snapshot_history_session(path: &Path) -> Result { + let metadata = fs::symlink_metadata(path)?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err("terminal history root contains an unexpected non-directory entry".into()); + } + let identity = history_directory_identity(path, "retained terminal history session")?; + let modified = metadata.modified()?; + let mut logical_bytes = 0_u64; + let mut files = Vec::new(); + let mut transcript_seen = false; + let mut manifest_seen = false; + + for entry in fs::read_dir(path)? { + let entry = entry?; + let name = entry.file_name(); + let name_str = name + .to_str() + .ok_or("terminal history file name is not valid UTF-8")?; + let kind = history_file_kind(name_str) + .ok_or("terminal history session contains an unrecognized file")?; + match kind { + HistoryFileKind::Transcript if transcript_seen => { + return Err("terminal history session contains multiple transcript blobs".into()); + } + HistoryFileKind::Manifest if manifest_seen => { + return Err("terminal history session contains multiple manifest blobs".into()); + } + HistoryFileKind::Transcript => transcript_seen = true, + HistoryFileKind::Manifest => manifest_seen = true, + } + + let file_metadata = fs::symlink_metadata(entry.path())?; + if file_metadata.file_type().is_symlink() || !file_metadata.is_file() { + return Err("terminal history session contains an unexpected non-file entry".into()); + } + logical_bytes = logical_bytes + .checked_add(file_metadata.len()) + .ok_or("terminal history logical byte size overflowed")?; + files.push(RetainedHistoryFile { + identity: history_file_identity(&entry.path(), "retained terminal history file")?, + name, + }); + } + + require_history_directory_identity(path, &identity, "retained terminal history session")?; + Ok(RetainedHistoryDir { + path: path.to_path_buf(), + logical_bytes, + modified, + identity, + files, + }) +} + +#[derive(Clone, Copy)] +enum HistoryFileKind { + Transcript, + Manifest, +} + +fn history_file_kind(name: &str) -> Option { + if valid_content_addressed_name(name, "transcript.", ".bin") { + Some(HistoryFileKind::Transcript) + } else if valid_content_addressed_name(name, "manifest.", ".json") { + Some(HistoryFileKind::Manifest) + } else { + None + } +} + +fn valid_content_addressed_name(name: &str, prefix: &str, suffix: &str) -> bool { + let Some(digest) = name + .strip_prefix(prefix) + .and_then(|value| value.strip_suffix(suffix)) + else { + return false; + }; + digest.len() == 64 + && digest + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn remove_owned_history_session( + history_root: &Path, + expected_root: &HistoryPathIdentity, + entry: &RetainedHistoryDir, + after_identity_proven: F, +) -> Result<()> +where + F: FnOnce() -> Result<()>, +{ + require_history_directory_identity(history_root, expected_root, "terminal history root")?; + if entry.path.parent() != Some(history_root) { + return Err("terminal history deletion target is outside the owned history root".into()); + } + let name = entry + .path + .file_name() + .and_then(|value| value.to_str()) + .ok_or("terminal history deletion target name is not valid UTF-8")?; + if !super::is_history_storage_key(name) { + return Err("terminal history deletion target is not an owned session directory".into()); + } + require_history_directory_identity( + &entry.path, + &entry.identity, + "terminal history deletion target", + )?; + + // The regression hook runs after the last pathname-based identity proof. + // The destructive implementation must bind to the filesystem objects again + // and refuse mutation if the pathname was replaced in this window. + after_identity_proven()?; + remove_session_object_bound(history_root, expected_root, entry) +} + +#[cfg(all(unix, target_os = "linux"))] +fn unix_stat_identity(stat: &libc::stat) -> HistoryPathIdentity { + (stat.st_dev, stat.st_ino) +} + +#[cfg(all(unix, target_os = "macos"))] +fn unix_stat_identity(stat: &libc::stat) -> HistoryPathIdentity { + (stat.st_dev as u64, stat.st_ino) +} + +#[cfg(all(unix, not(any(target_os = "linux", target_os = "macos"))))] +fn unix_stat_identity(stat: &libc::stat) -> HistoryPathIdentity { + (stat.st_dev as u64, stat.st_ino as u64) +} + +#[cfg(unix)] +fn remove_session_object_bound( + history_root: &Path, + expected_root: &HistoryPathIdentity, + entry: &RetainedHistoryDir, +) -> Result<()> { + let root = open_unix_directory(history_root, "terminal history root")?; + require_unix_handle_identity(&root, expected_root, "terminal history root")?; + + let target_name = entry + .path + .file_name() + .ok_or("terminal history deletion target has no file name")?; + let target = open_unix_directory_at( + root.as_raw_fd(), + target_name, + "terminal history deletion target", + )?; + require_unix_handle_identity(&target, &entry.identity, "terminal history deletion target")?; + + for file in &entry.files { + let name = unix_name_cstring(&file.name, "terminal history file")?; + let mut stat = std::mem::MaybeUninit::::uninit(); + let stat_result = unsafe { + libc::fstatat( + target.as_raw_fd(), + name.as_ptr(), + stat.as_mut_ptr(), + libc::AT_SYMLINK_NOFOLLOW, + ) + }; + if stat_result != 0 { + return Err(format!( + "terminal history file could not be inspected through its owned directory handle: {}", + std::io::Error::last_os_error() + ) + .into()); + } + let stat = unsafe { stat.assume_init() }; + if (stat.st_mode as libc::mode_t) & libc::S_IFMT != libc::S_IFREG { + return Err("terminal history file became a non-regular object before deletion".into()); + } + let identity = unix_stat_identity(&stat); + if identity != file.identity { + return Err( + "terminal history file filesystem identity changed before object-bound deletion" + .into(), + ); + } + let unlink_result = unsafe { libc::unlinkat(target.as_raw_fd(), name.as_ptr(), 0) }; + if unlink_result != 0 { + return Err(format!( + "terminal history file could not be deleted through its owned directory handle: {}", + std::io::Error::last_os_error() + ) + .into()); + } + } + + require_unix_handle_identity(&target, &entry.identity, "terminal history deletion target")?; + let target_name = unix_name_cstring(target_name, "terminal history session")?; + let mut stat = std::mem::MaybeUninit::::uninit(); + let stat_result = unsafe { + libc::fstatat( + root.as_raw_fd(), + target_name.as_ptr(), + stat.as_mut_ptr(), + libc::AT_SYMLINK_NOFOLLOW, + ) + }; + if stat_result != 0 { + return Err(format!( + "terminal history session entry could not be revalidated before non-recursive removal: {}", + std::io::Error::last_os_error() + ) + .into()); + } + let stat = unsafe { stat.assume_init() }; + let current = unix_stat_identity(&stat); + if current != entry.identity { + return Err( + "terminal history session filesystem identity changed before non-recursive removal" + .into(), + ); + } + let remove_result = + unsafe { libc::unlinkat(root.as_raw_fd(), target_name.as_ptr(), libc::AT_REMOVEDIR) }; + if remove_result != 0 { + return Err(format!( + "terminal history session could not be removed non-recursively: {}", + std::io::Error::last_os_error() + ) + .into()); + } + Ok(()) +} + +#[cfg(unix)] +fn open_unix_directory(path: &Path, label: &str) -> Result { + let path = CString::new(path.as_os_str().as_bytes()) + .map_err(|_| format!("{label} contains an embedded NUL byte"))?; + let fd = unsafe { + libc::open( + path.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC, + ) + }; + if fd < 0 { + return Err(format!( + "{label} could not be opened without following links: {}", + std::io::Error::last_os_error() + ) + .into()); + } + Ok(unsafe { fs::File::from_raw_fd(fd) }) +} + +#[cfg(unix)] +fn open_unix_directory_at(parent_fd: i32, name: &std::ffi::OsStr, label: &str) -> Result { + let name = unix_name_cstring(name, label)?; + let fd = unsafe { + libc::openat( + parent_fd, + name.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC, + ) + }; + if fd < 0 { + return Err(format!( + "{label} could not be opened through its owned parent without following links: {}", + std::io::Error::last_os_error() + ) + .into()); + } + Ok(unsafe { fs::File::from_raw_fd(fd) }) +} + +#[cfg(unix)] +fn unix_name_cstring(name: &std::ffi::OsStr, label: &str) -> Result { + CString::new(name.as_bytes()) + .map_err(|_| format!("{label} contains an embedded NUL byte").into()) +} + +#[cfg(unix)] +fn require_unix_handle_identity( + handle: &fs::File, + expected: &HistoryPathIdentity, + label: &str, +) -> Result<()> { + let metadata = handle + .metadata() + .map_err(|error| format!("{label} handle cannot be inspected: {error}"))?; + if !metadata.is_dir() { + return Err(format!("{label} handle is not a directory").into()); + } + let current = (metadata.dev(), metadata.ino()); + if current != *expected { + return Err( + format!("{label} filesystem identity changed during object-bound deletion").into(), + ); + } + Ok(()) +} + +#[cfg(unix)] +fn history_directory_identity(path: &Path, label: &str) -> Result { + let metadata = fs::symlink_metadata(path) + .map_err(|error| format!("{label} cannot be inspected: {error}"))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(format!("{label} is not a real directory").into()); + } + Ok((metadata.dev(), metadata.ino())) +} + +#[cfg(unix)] +fn history_file_identity(path: &Path, label: &str) -> Result { + let metadata = fs::symlink_metadata(path) + .map_err(|error| format!("{label} cannot be inspected: {error}"))?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(format!("{label} is not a real regular file").into()); + } + Ok((metadata.dev(), metadata.ino())) +} + +#[cfg(windows)] +const WINDOWS_DELETE_ACCESS: u32 = 0x0001_0000; +#[cfg(windows)] +const WINDOWS_FILE_SHARE_READ: u32 = 0x0000_0001; +#[cfg(windows)] +const WINDOWS_FILE_SHARE_WRITE: u32 = 0x0000_0002; +#[cfg(windows)] +const WINDOWS_FILE_SHARE_DELETE: u32 = 0x0000_0004; +#[cfg(windows)] +const WINDOWS_FILE_ATTRIBUTE_DIRECTORY: u32 = 0x0000_0010; +#[cfg(windows)] +const WINDOWS_FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; +#[cfg(windows)] +const WINDOWS_FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; +#[cfg(windows)] +const WINDOWS_FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000; +#[cfg(windows)] +const WINDOWS_FILE_ATTRIBUTE_TAG_INFO_CLASS: i32 = 9; +#[cfg(windows)] +const WINDOWS_FILE_ID_INFO_CLASS: i32 = 18; +#[cfg(windows)] +const WINDOWS_FILE_DISPOSITION_INFO_CLASS: i32 = 4; + +#[cfg(windows)] +#[repr(C)] +struct WindowsFileAttributeTagInfo { + file_attributes: u32, + _reparse_tag: u32, +} + +#[cfg(windows)] +#[repr(C)] +struct WindowsFileIdInfo { + volume_serial_number: u64, + file_id: [u8; 16], +} + +#[cfg(windows)] +#[repr(C)] +struct WindowsFileDispositionInfo { + delete_file: i32, +} + +#[cfg(windows)] +#[link(name = "kernel32")] +unsafe extern "system" { + fn GetFileInformationByHandleEx( + file_handle: *mut c_void, + file_information_class: i32, + file_information: *mut c_void, + buffer_size: u32, + ) -> i32; + fn SetFileInformationByHandle( + file_handle: *mut c_void, + file_information_class: i32, + file_information: *const c_void, + buffer_size: u32, + ) -> i32; +} + +#[cfg(windows)] +fn remove_session_object_bound( + _history_root: &Path, + _expected_root: &HistoryPathIdentity, + entry: &RetainedHistoryDir, +) -> Result<()> { + let directory = + open_windows_object(&entry.path, true, true, "terminal history deletion target")?; + require_windows_handle_identity( + &directory, + &entry.identity, + true, + "terminal history deletion target", + )?; + + for file in &entry.files { + let path = entry.path.join(&file.name); + let handle = open_windows_object(&path, false, true, "terminal history file")?; + require_windows_handle_identity(&handle, &file.identity, false, "terminal history file")?; + mark_windows_handle_for_deletion(&handle, "terminal history file")?; + drop(handle); + } + + require_windows_handle_identity( + &directory, + &entry.identity, + true, + "terminal history deletion target", + )?; + mark_windows_handle_for_deletion(&directory, "terminal history session")?; + drop(directory); + Ok(()) +} + +#[cfg(windows)] +fn open_windows_object( + path: &Path, + directory: bool, + delete_access: bool, + label: &str, +) -> Result { + let mut options = fs::OpenOptions::new(); + options + .access_mode(if delete_access { + WINDOWS_DELETE_ACCESS + } else { + 0 + }) + .share_mode(WINDOWS_FILE_SHARE_READ | WINDOWS_FILE_SHARE_WRITE | WINDOWS_FILE_SHARE_DELETE) + .custom_flags( + WINDOWS_FILE_FLAG_OPEN_REPARSE_POINT + | if directory { + WINDOWS_FILE_FLAG_BACKUP_SEMANTICS + } else { + 0 + }, + ); + options.open(path).map_err(|error| { + format!("{label} could not be opened without following reparse points: {error}").into() + }) +} + +#[cfg(windows)] +fn windows_handle_identity( + handle: &fs::File, + expect_directory: bool, + label: &str, +) -> Result { + let mut attribute_info = MaybeUninit::::uninit(); + let attribute_result = unsafe { + GetFileInformationByHandleEx( + handle.as_raw_handle(), + WINDOWS_FILE_ATTRIBUTE_TAG_INFO_CLASS, + attribute_info.as_mut_ptr().cast::(), + std::mem::size_of::() as u32, + ) + }; + if attribute_result == 0 { + return Err(format!( + "{label} handle attributes cannot be inspected: {}", + std::io::Error::last_os_error() + ) + .into()); + } + let attribute_info = unsafe { attribute_info.assume_init() }; + let is_directory = attribute_info.file_attributes & WINDOWS_FILE_ATTRIBUTE_DIRECTORY != 0; + if attribute_info.file_attributes & WINDOWS_FILE_ATTRIBUTE_REPARSE_POINT != 0 + || is_directory != expect_directory + { + return Err(format!("{label} is a reparse point or has the wrong object type").into()); + } + + let mut identity_info = MaybeUninit::::uninit(); + let identity_result = unsafe { + GetFileInformationByHandleEx( + handle.as_raw_handle(), + WINDOWS_FILE_ID_INFO_CLASS, + identity_info.as_mut_ptr().cast::(), + std::mem::size_of::() as u32, + ) + }; + if identity_result == 0 { + return Err(format!( + "{label} filesystem identity cannot be inspected: {}", + std::io::Error::last_os_error() + ) + .into()); + } + let identity_info = unsafe { identity_info.assume_init() }; + Ok((identity_info.volume_serial_number, identity_info.file_id)) +} + +#[cfg(windows)] +fn require_windows_handle_identity( + handle: &fs::File, + expected: &HistoryPathIdentity, + expect_directory: bool, + label: &str, +) -> Result<()> { + if windows_handle_identity(handle, expect_directory, label)? != *expected { + return Err( + format!("{label} filesystem identity changed during object-bound deletion").into(), + ); + } + Ok(()) +} + +#[cfg(windows)] +fn mark_windows_handle_for_deletion(handle: &fs::File, label: &str) -> Result<()> { + let disposition = WindowsFileDispositionInfo { delete_file: 1 }; + let result = unsafe { + SetFileInformationByHandle( + handle.as_raw_handle(), + WINDOWS_FILE_DISPOSITION_INFO_CLASS, + (&disposition as *const WindowsFileDispositionInfo).cast::(), + std::mem::size_of::() as u32, + ) + }; + if result == 0 { + return Err(format!( + "{label} could not be marked for object-bound deletion: {}", + std::io::Error::last_os_error() + ) + .into()); + } + Ok(()) +} + +#[cfg(windows)] +fn history_directory_identity(path: &Path, label: &str) -> Result { + let handle = open_windows_object(path, true, false, label)?; + windows_handle_identity(&handle, true, label) +} + +#[cfg(windows)] +fn history_file_identity(path: &Path, label: &str) -> Result { + let handle = open_windows_object(path, false, false, label)?; + windows_handle_identity(&handle, false, label) +} + +#[cfg(not(any(unix, windows)))] +fn remove_session_object_bound( + _history_root: &Path, + _expected_root: &HistoryPathIdentity, + _entry: &RetainedHistoryDir, +) -> Result<()> { + Err("object-bound terminal history pruning is unsupported on this platform".into()) +} + +#[cfg(not(any(unix, windows)))] +fn history_directory_identity(_path: &Path, label: &str) -> Result { + Err(format!("{label} filesystem identity is unsupported on this platform").into()) +} + +#[cfg(not(any(unix, windows)))] +fn history_file_identity(_path: &Path, label: &str) -> Result { + Err(format!("{label} filesystem identity is unsupported on this platform").into()) +} + +fn require_history_directory_identity( + path: &Path, + expected: &HistoryPathIdentity, + label: &str, +) -> Result<()> { + let current = history_directory_identity(path, label)?; + if current != *expected { + return Err(format!("{label} filesystem identity changed").into()); + } + Ok(()) +} diff --git a/src/execution.rs b/src/execution.rs index 76b5e64e..da44a489 100644 --- a/src/execution.rs +++ b/src/execution.rs @@ -32,6 +32,8 @@ pub struct TerminalExecution<'store> { history: SessionHistoryRecorder, pending_final: Option, final_recorded: bool, + ownership_revoked: bool, + finalization_lower_bound_unix_ms: i64, } impl<'store> TerminalExecution<'store> { @@ -43,7 +45,7 @@ impl<'store> TerminalExecution<'store> { cwd: &Path, size: TerminalSize, ) -> Result { - store.retry_deferred_terminal_finalizations()?; + store.retry_deferred_terminal_finalizations_resilient()?; let history = SessionHistoryRecorder::new_disabled(execution_id)?; start_native_with_recorder( store, @@ -65,7 +67,7 @@ impl<'store> TerminalExecution<'store> { size: TerminalSize, history: LocalTerminalHistory<'_>, ) -> Result { - store.retry_deferred_terminal_finalizations()?; + store.retry_deferred_terminal_finalizations_resilient()?; let history = SessionHistoryRecorder::new_local(execution_id, history.policy, history.state_root)?; start_native_with_recorder( @@ -87,7 +89,7 @@ impl<'store> TerminalExecution<'store> { plan: &WslTerminalLaunchPlan, size: TerminalSize, ) -> Result { - store.retry_deferred_terminal_finalizations()?; + store.retry_deferred_terminal_finalizations_resilient()?; let history = SessionHistoryRecorder::new_disabled(execution_id)?; start_wsl_with_recorder(store, execution_id, workspace_id, plan, size, history) } @@ -101,7 +103,7 @@ impl<'store> TerminalExecution<'store> { size: TerminalSize, history: LocalTerminalHistory<'_>, ) -> Result { - store.retry_deferred_terminal_finalizations()?; + store.retry_deferred_terminal_finalizations_resilient()?; let history = SessionHistoryRecorder::new_local(execution_id, history.policy, history.state_root)?; start_wsl_with_recorder(store, execution_id, workspace_id, plan, size, history) @@ -128,6 +130,7 @@ impl<'store> TerminalExecution<'store> { } pub fn take_output_reader(&mut self) -> Result> { + self.require_owned_session("take output reader")?; let reader = self.session.take_output_reader()?; self.history.wrap_output_reader(reader) } @@ -137,6 +140,7 @@ impl<'store> TerminalExecution<'store> { } pub fn send_input(&mut self, bytes: &[u8]) -> Result<()> { + self.require_owned_session("send input")?; if self.try_wait()?.is_some() { return Err("terminal execution has already exited".into()); } @@ -144,6 +148,7 @@ impl<'store> TerminalExecution<'store> { } pub fn resize(&mut self, size: TerminalSize) -> Result<()> { + self.require_owned_session("resize")?; if self.try_wait()?.is_some() { return Err("terminal execution has already exited".into()); } @@ -151,10 +156,12 @@ impl<'store> TerminalExecution<'store> { } pub fn current_size(&self) -> Result { + self.require_owned_session("read current size")?; self.session.current_size() } pub fn interrupt(&mut self) -> Result<()> { + self.require_owned_session("interrupt")?; if self.try_wait()?.is_some() { return Err("terminal execution has already exited".into()); } @@ -162,6 +169,7 @@ impl<'store> TerminalExecution<'store> { } pub fn try_wait(&mut self) -> Result> { + self.require_owned_session("try-wait")?; if self.pending_final.is_some() { self.persist_pending_final()?; } @@ -172,7 +180,7 @@ impl<'store> TerminalExecution<'store> { let exit = self.session.try_wait()?; if exit.is_some() { self.pending_final = Some(TerminalFinalization::Exited { - ended_unix_ms: unix_ms()?, + ended_unix_ms: self.finalization_unix_ms(), }); self.persist_pending_final()?; } @@ -180,6 +188,7 @@ impl<'store> TerminalExecution<'store> { } pub fn wait(&mut self) -> Result { + self.require_owned_session("wait")?; if self.pending_final.is_some() { self.persist_pending_final()?; return self.session.wait(); @@ -190,38 +199,27 @@ impl<'store> TerminalExecution<'store> { let exit = self.session.wait()?; self.pending_final = Some(TerminalFinalization::Exited { - ended_unix_ms: unix_ms()?, + ended_unix_ms: self.finalization_unix_ms(), }); self.persist_pending_final()?; Ok(exit) } pub fn terminate(&mut self) -> Result { - if self.pending_final.is_some() { - self.persist_pending_final()?; - return self.session.wait(); - } - if self.final_recorded { - return self.session.wait(); - } - if let Some(exit) = self.session.try_wait()? { - self.pending_final = Some(TerminalFinalization::Exited { - ended_unix_ms: unix_ms()?, - }); - self.persist_pending_final()?; - return Ok(exit); - } - - let exit = self.session.terminate()?; - self.pending_final = Some(TerminalFinalization::Interrupted { - ended_unix_ms: unix_ms()?, - reason: TerminalCloseReason::TerminatedByWinds, - }); - self.persist_pending_final()?; - Ok(exit) + self.require_owned_session("terminate")?; + self.controlled_cleanup(TerminalCloseReason::TerminatedByWinds, "terminate") } pub fn close(&mut self) -> Result { + self.require_owned_session("close")?; + self.controlled_cleanup(TerminalCloseReason::ClosedByWinds, "close") + } + + fn controlled_cleanup( + &mut self, + controlled_reason: TerminalCloseReason, + operation: &str, + ) -> Result { if self.pending_final.is_some() { self.persist_pending_final()?; return self.session.wait(); @@ -229,23 +227,69 @@ impl<'store> TerminalExecution<'store> { if self.final_recorded { return self.session.wait(); } - if let Some(exit) = self.session.try_wait()? { - self.pending_final = Some(TerminalFinalization::Exited { - ended_unix_ms: unix_ms()?, - }); - self.persist_pending_final()?; - return Ok(exit); - } - let exit = self.session.close()?; - self.pending_final = Some(TerminalFinalization::Interrupted { - ended_unix_ms: unix_ms()?, - reason: TerminalCloseReason::ClosedByWinds, - }); + let outcome = self.session.cleanup_for_drop(Duration::from_millis(500)); + let observed_unix_ms = self.finalization_unix_ms(); + let (exit, finalization) = match outcome { + Ok(TerminalDropCleanupOutcome::ExitedBeforeCleanup(exit)) => ( + exit, + TerminalFinalization::Exited { + ended_unix_ms: observed_unix_ms, + }, + ), + Ok(TerminalDropCleanupOutcome::Terminated(exit)) => ( + exit, + TerminalFinalization::Interrupted { + ended_unix_ms: observed_unix_ms, + reason: controlled_reason, + }, + ), + Ok(TerminalDropCleanupOutcome::Unproven) => { + self.revoke_session_ownership(); + self.pending_final = Some(TerminalFinalization::OwnershipLost { observed_unix_ms }); + self.persist_pending_final()?; + return Err(format!( + "terminal {operation} could not prove owned child exit inside bounded cleanup window" + ) + .into()); + } + Err(cleanup_error) => { + self.revoke_session_ownership(); + self.pending_final = Some(TerminalFinalization::OwnershipLost { observed_unix_ms }); + match self.persist_pending_final() { + Ok(()) => { + return Err(format!( + "terminal {operation} cleanup failed and ownership was recorded as lost: {cleanup_error}" + ) + .into()); + } + Err(persist_error) => { + return Err(format!( + "terminal {operation} cleanup failed: {cleanup_error}; ownership-loss persistence also failed: {persist_error}" + ) + .into()); + } + } + } + }; + self.pending_final = Some(finalization); self.persist_pending_final()?; Ok(exit) } + fn require_owned_session(&self, operation: &str) -> Result<()> { + ensure_execution_ownership_active(self.ownership_revoked, operation) + } + + fn revoke_session_ownership(&mut self) { + self.ownership_revoked = true; + self.session.suppress_drop_cleanup_after_ownership_loss(); + } + + fn finalization_unix_ms(&self) -> Option { + validated_finalization_time(unix_ms().ok(), self.finalization_lower_bound_unix_ms) + } + fn persist_pending_final(&mut self) -> Result<()> { let Some(pending) = self.pending_final else { return Ok(()); @@ -283,12 +327,13 @@ impl Drop for TerminalExecution<'_> { self.persist_or_defer_on_drop(pending); return; } + if self.ownership_revoked { + return; + } - let observed_unix_ms = match unix_ms() { - Ok(value) => value, - Err(_) => return, - }; - let finalization = match self.session.cleanup_for_drop(Duration::from_millis(500)) { + let cleanup = self.session.cleanup_for_drop(Duration::from_millis(500)); + let observed_unix_ms = self.finalization_unix_ms(); + let finalization = match cleanup { Ok(TerminalDropCleanupOutcome::ExitedBeforeCleanup(_)) => { TerminalFinalization::Exited { ended_unix_ms: observed_unix_ms, @@ -299,6 +344,7 @@ impl Drop for TerminalExecution<'_> { reason: TerminalCloseReason::ClosedByWinds, }, Ok(TerminalDropCleanupOutcome::Unproven) | Err(_) => { + self.revoke_session_ownership(); TerminalFinalization::OwnershipLost { observed_unix_ms } } }; @@ -307,6 +353,7 @@ impl Drop for TerminalExecution<'_> { } pub fn reconcile_terminal_executions_after_restart(store: &mut Store) -> Result { + store.retry_deferred_terminal_finalizations_resilient()?; store.reconcile_unowned_terminal_sessions_after_restart(unix_ms()?) } @@ -417,7 +464,7 @@ fn finish_started_session<'store>( let cleanup = session.terminate(); let cleanup_proven = cleanup.is_ok(); let repair = if cleanup_proven { - let ended_unix_ms = unix_ms().unwrap_or(started_unix_ms); + let ended_unix_ms = validated_finalization_time(unix_ms().ok(), started_unix_ms); store.mark_terminal_start_persistence_failed( execution_id, started_unix_ms, @@ -448,6 +495,8 @@ fn finish_started_session<'store>( history, pending_final: None, final_recorded: false, + ownership_revoked: false, + finalization_lower_bound_unix_ms: started_unix_ms, }) } @@ -456,7 +505,8 @@ fn fail_launch<'store>( execution_id: &str, launch_error: Box, ) -> Result> { - let failed_unix_ms = unix_ms()?; + let execution = store.load_execution(execution_id)?; + let failed_unix_ms = unix_ms()?.max(execution.requested_unix_ms); match store.mark_terminal_failed_to_start(execution_id, failed_unix_ms) { Ok(()) => Err(format!("terminal launch failed: {launch_error}").into()), Err(persist_error) => Err(format!( @@ -471,11 +521,43 @@ fn utf8_path<'a>(path: &'a Path, label: &str) -> Result<&'a str> { .ok_or_else(|| format!("{label} is not valid UTF-8").into()) } +fn ensure_execution_ownership_active(ownership_revoked: bool, operation: &str) -> Result<()> { + if ownership_revoked { + Err(format!("terminal execution ownership was lost; refusing to {operation}").into()) + } else { + Ok(()) + } +} + +fn validated_finalization_time(sample: Option, lower_bound_unix_ms: i64) -> Option { + sample.filter(|value| *value >= lower_bound_unix_ms) +} + fn unix_ms() -> Result { let millis = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis(); Ok(i64::try_from(millis)?) } +#[cfg(test)] +mod t068_finalization_truth_tests { + use super::{ensure_execution_ownership_active, validated_finalization_time}; + + #[test] + fn finalization_time_preserves_unknown_and_rejects_regression() { + assert_eq!(validated_finalization_time(Some(101), 100), Some(101)); + assert_eq!(validated_finalization_time(Some(100), 100), Some(100)); + assert_eq!(validated_finalization_time(None, 100), None); + assert_eq!(validated_finalization_time(Some(99), 100), None); + } + + #[test] + fn ownership_loss_revokes_terminal_control_operations() { + assert!(ensure_execution_ownership_active(false, "send input").is_ok()); + let error = ensure_execution_ownership_active(true, "send input").unwrap_err(); + assert!(error.to_string().contains("ownership was lost")); + } +} + #[cfg(all(test, unix))] mod tests { use super::{LocalTerminalHistory, TerminalExecution}; @@ -490,6 +572,7 @@ mod tests { use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::thread; + use std::time::{Duration, Instant}; static NEXT_ROOT: AtomicU64 = AtomicU64::new(0); @@ -667,6 +750,15 @@ mod tests { .unwrap(); let output = drain_output(execution.take_output_reader().unwrap()); + let ready = root.path().join("terminate-ready"); + execution + .send_input(b"printf ready > terminate-ready; while :; do sleep 1; done\n") + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + while !ready.is_file() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(10)); + } + assert!(ready.is_file(), "terminate fixture shell never became live"); execution.terminate().unwrap(); drop(execution); output.join().unwrap(); diff --git a/src/git.rs b/src/git.rs index af698af6..41641719 100644 --- a/src/git.rs +++ b/src/git.rs @@ -4,10 +4,18 @@ use std::ffi::OsStr; #[cfg(unix)] use std::ffi::OsString; use std::fs::{File, OpenOptions}; +use std::io::{self, Read}; #[cfg(unix)] use std::os::unix::ffi::OsStringExt; use std::path::{Path, PathBuf}; -use std::process::Command; +use std::process::{Command, ExitStatus, Stdio}; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError}; +use std::thread; +use std::time::{Duration, Instant}; + +#[path = "process_scope.rs"] +mod process_scope; +use process_scope::{OwnedProcess, operation_deadlines, spawn_owned_process}; #[path = "shell_profiles.rs"] pub(crate) mod shell_profiles; @@ -95,6 +103,16 @@ const GIT_CONTEXT_ENV_VARS: &[&str] = &[ "GIT_CONFIG_PARAMETERS", "GIT_PREFIX", ]; +const OBSERVATION_GIT_OUTPUT_LIMIT: usize = 1024 * 1024; +const OBSERVATION_GIT_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Debug)] +pub(super) struct BoundedGitOutput { + pub(super) status: ExitStatus, + pub(super) stdout: Vec, + pub(super) stderr: Vec, + stdout_truncated: bool, +} #[derive(Debug, Clone)] pub struct Repo { @@ -104,11 +122,16 @@ pub struct Repo { impl Repo { pub fn open(path: &Path) -> Result { - let root = run_git_text(path, ["rev-parse", "--show-toplevel"])?; + let root = run_read_only_git_text( + path, + ["rev-parse", "--show-toplevel"], + "workspace Git root discovery", + )?; let root = PathBuf::from(strip_git_line_ending(&root)).canonicalize()?; - let common_dir = run_git_text( + let common_dir = run_read_only_git_text( &root, ["rev-parse", "--path-format=absolute", "--git-common-dir"], + "workspace Git common-directory discovery", )?; let common_dir = PathBuf::from(strip_git_line_ending(&common_dir)).canonicalize()?; Ok(Self { root, common_dir }) @@ -118,6 +141,10 @@ impl Repo { &self.root } + pub fn common_dir(&self) -> &Path { + &self.common_dir + } + pub fn require_external_state_path(&self, path: &Path) -> Result<()> { if path.starts_with(&self.root) || path.starts_with(&self.common_dir) { return Err( @@ -139,11 +166,12 @@ impl Repo { } pub fn require_clean_primary(&self) -> Result<()> { - let status = run_git_bytes( + let dirty = run_read_only_git_has_output( &self.root, ["status", "--porcelain=v1", "-z", "--untracked-files=all"], + "primary checkout cleanliness inspection", )?; - if !status.is_empty() { + if dirty { return Err("primary checkout is dirty; Winds refuses to provision a candidate".into()); } Ok(()) @@ -151,9 +179,10 @@ impl Repo { pub fn resolve_commit(&self, value: &str) -> Result { let spec = format!("{value}^{{commit}}"); - Ok(run_git_text( + Ok(run_read_only_git_text( &self.root, ["rev-parse", "--verify", "--end-of-options", spec.as_str()], + "commit resolution", )? .trim() .to_owned()) @@ -161,9 +190,10 @@ impl Repo { pub fn tree_oid(&self, commit_oid: &str) -> Result { let spec = format!("{commit_oid}^{{tree}}"); - Ok(run_git_text( + Ok(run_read_only_git_text( &self.root, ["rev-parse", "--verify", "--end-of-options", spec.as_str()], + "tree resolution", )? .trim() .to_owned()) @@ -179,7 +209,7 @@ impl Repo { std::fs::create_dir_all(parent)?; } - run_git_os( + run_mutating_git_os( &self.root, [ OsStr::new("worktree"), @@ -190,7 +220,7 @@ impl Repo { ], )?; - run_git_os( + run_mutating_git_os( &self.root, [ OsStr::new("worktree"), @@ -204,19 +234,27 @@ impl Repo { } pub fn worktree_head(&self, path: &Path) -> Result { - Ok(run_git_text(path, ["rev-parse", "HEAD"])?.trim().to_owned()) + Ok( + run_read_only_git_text(path, ["rev-parse", "HEAD"], "worktree HEAD inspection")? + .trim() + .to_owned(), + ) } pub fn worktree_is_clean(&self, path: &Path) -> Result { - Ok(run_git_bytes( + Ok(!run_read_only_git_has_output( path, ["status", "--porcelain=v1", "-z", "--untracked-files=all"], - )? - .is_empty()) + "candidate worktree cleanliness inspection", + )?) } pub fn worktree_paths(&self) -> Result> { - let output = run_git_bytes(&self.root, ["worktree", "list", "--porcelain", "-z"])?; + let output = run_read_only_git_bytes( + &self.root, + ["worktree", "list", "--porcelain", "-z"], + "worktree inventory inspection", + )?; let mut paths = Vec::new(); for field in output.split(|byte| *byte == 0) { if let Some(path) = field.strip_prefix(b"worktree ") { @@ -229,15 +267,17 @@ impl Repo { pub fn create_selected_branch(&self, branch: &str, commit_oid: &str) -> Result<()> { let full_ref = format!("refs/heads/{branch}"); let spec = format!("{full_ref}^{{commit}}"); - let existing = git_command(&self.root) - .args([ + let existing = run_read_only_git_output( + &self.root, + [ "rev-parse", "--verify", "--quiet", "--end-of-options", spec.as_str(), - ]) - .output()?; + ], + "selected branch existence inspection", + )?; if existing.status.success() { let current = String::from_utf8(existing.stdout)?.trim().to_owned(); @@ -256,38 +296,306 @@ impl Repo { .into()); } - run_git_text(&self.root, ["branch", branch, commit_oid])?; + run_mutating_git_text(&self.root, ["branch", branch, commit_oid])?; Ok(()) } } fn observed_status_bytes(repo: &Repo) -> Result> { - let output = git_command(repo.root()) - .env("GIT_OPTIONAL_LOCKS", "0") - .args([ - "status", - "--porcelain=v2", - "--branch", - "--no-ahead-behind", - "-z", - "--untracked-files=all", - "--ignore-submodules=none", - "--no-renames", - ]) - .output()?; - if output.status.success() { - return Ok(output.stdout); + let mut command = git_command(repo.root()); + command.env("GIT_OPTIONAL_LOCKS", "0").args([ + "status", + "--porcelain=v2", + "--branch", + "--no-ahead-behind", + "-z", + "--untracked-files=all", + "--ignore-submodules=none", + "--no-renames", + ]); + run_bounded_read_only_git(command, "workspace Git observation") +} + +pub(super) fn run_bounded_read_only_git(command: Command, label: &str) -> Result> { + let output = run_bounded_read_only_git_output(command, label)?; + require_complete_stdout(&output, label)?; + if !output.status.success() { + return Err(format!( + "{label} failed with status {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ) + .into()); } - Err(format!( - "failed to inspect workspace Git state: {}", - String::from_utf8_lossy(&output.stderr).trim() - ) - .into()) + Ok(output.stdout) +} + +fn run_bounded_read_only_git_output(mut command: Command, label: &str) -> Result { + command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let started = Instant::now(); + let (command_deadline, cleanup_deadline) = + operation_deadlines(started, OBSERVATION_GIT_TIMEOUT); + let mut child = spawn_owned_process(&mut command, label)?; + + let stdout = match child.take_stdout() { + Some(stdout) => stdout, + None => { + let cleanup = child.terminate_and_prove(cleanup_deadline, label); + return Err(format!( + "{label} could not capture Git stdout; owned cleanup {}", + cleanup + .map(|()| "succeeded".to_owned()) + .unwrap_or_else(|error| format!("was not proven: {error}")) + ) + .into()); + } + }; + let stderr = match child.take_stderr() { + Some(stderr) => stderr, + None => { + let cleanup = child.terminate_and_prove(cleanup_deadline, label); + return Err(format!( + "{label} could not capture Git stderr; owned cleanup {}", + cleanup + .map(|()| "succeeded".to_owned()) + .unwrap_or_else(|error| format!("was not proven: {error}")) + ) + .into()); + } + }; + let stdout_reader = spawn_bounded_reader(stdout); + let stderr_reader = spawn_bounded_reader(stderr); + + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if Instant::now() >= command_deadline => { + return fail_bounded_git_observation( + &mut child, + &stdout_reader, + &stderr_reader, + cleanup_deadline, + label, + format!( + "{label} exceeded the bounded execution phase of its {} second safety timeout", + OBSERVATION_GIT_TIMEOUT.as_secs() + ), + ); + } + Ok(None) => thread::sleep(Duration::from_millis(10)), + Err(error) => { + return fail_bounded_git_observation( + &mut child, + &stdout_reader, + &stderr_reader, + cleanup_deadline, + label, + format!("{label} failed while waiting for Git: {error}"), + ); + } + } + }; + + // After Git exits, reader drain and descendant quiescence are cleanup work + // and use the cleanup reserve rather than the command-phase deadline. + let stdout = match receive_bounded_reader(&stdout_reader, label, "stdout", cleanup_deadline) { + Ok(output) => output, + Err(error) => { + return fail_bounded_git_observation( + &mut child, + &stdout_reader, + &stderr_reader, + cleanup_deadline, + label, + error.to_string(), + ); + } + }; + let stderr = match receive_bounded_reader(&stderr_reader, label, "stderr", cleanup_deadline) { + Ok(output) => output, + Err(error) => { + return fail_bounded_git_observation( + &mut child, + &stdout_reader, + &stderr_reader, + cleanup_deadline, + label, + error.to_string(), + ); + } + }; + + match child.wait_for_scope_quiescence(cleanup_deadline, label) { + Ok(true) => {} + Ok(false) => { + return fail_bounded_git_observation( + &mut child, + &stdout_reader, + &stderr_reader, + cleanup_deadline, + label, + format!("{label} direct Git child exited while owned descendants remained live"), + ); + } + Err(error) => { + return fail_bounded_git_observation( + &mut child, + &stdout_reader, + &stderr_reader, + cleanup_deadline, + label, + format!("{label} could not prove owned process-scope quiescence: {error}"), + ); + } + } + + if stderr.truncated { + return Err(format!( + "{label} stderr exceeded the {} byte safety bound", + OBSERVATION_GIT_OUTPUT_LIMIT + ) + .into()); + } + Ok(BoundedGitOutput { + status, + stdout: stdout.bytes, + stderr: stderr.bytes, + stdout_truncated: stdout.truncated, + }) +} + +fn require_complete_stdout(output: &BoundedGitOutput, label: &str) -> Result<()> { + if output.stdout_truncated { + Err(format!( + "{label} stdout exceeded the {} byte safety bound", + OBSERVATION_GIT_OUTPUT_LIMIT + ) + .into()) + } else { + Ok(()) + } +} + +fn fail_bounded_git_observation( + child: &mut OwnedProcess, + stdout_reader: &Receiver>, + stderr_reader: &Receiver>, + cleanup_deadline: Instant, + label: &str, + primary_error: String, +) -> Result { + let mut cleanup_failures = Vec::new(); + if let Err(error) = child.terminate_and_prove(cleanup_deadline, label) { + cleanup_failures.push(error.to_string()); + } + if let Err(error) = + wait_bounded_reader_shutdown(stdout_reader, label, "stdout", cleanup_deadline) + { + cleanup_failures.push(error.to_string()); + } + if let Err(error) = + wait_bounded_reader_shutdown(stderr_reader, label, "stderr", cleanup_deadline) + { + cleanup_failures.push(error.to_string()); + } + + if cleanup_failures.is_empty() { + Err(primary_error.into()) + } else { + Err(format!( + "{primary_error}; owned subprocess cleanup was not proven: {}", + cleanup_failures.join("; ") + ) + .into()) + } +} + +struct BoundedCapture { + bytes: Vec, + truncated: bool, +} + +fn spawn_bounded_reader(reader: R) -> Receiver> +where + R: Read + Send + 'static, +{ + let (sender, receiver) = mpsc::sync_channel(1); + thread::spawn(move || { + let _ = sender.send(read_bounded(reader)); + }); + receiver +} + +fn receive_bounded_reader( + receiver: &Receiver>, + label: &str, + stream: &str, + deadline: Instant, +) -> Result { + let remaining = deadline.saturating_duration_since(Instant::now()); + match receiver.recv_timeout(remaining) { + Ok(result) => { + result.map_err(|error| format!("{label} failed reading Git {stream}: {error}").into()) + } + Err(RecvTimeoutError::Timeout) => Err(format!( + "{label} {stream} reader exceeded the bounded execution phase of the overall {} second safety timeout", + OBSERVATION_GIT_TIMEOUT.as_secs() + ) + .into()), + Err(RecvTimeoutError::Disconnected) => { + Err(format!("{label} {stream} reader terminated without a result").into()) + } + } +} + +fn wait_bounded_reader_shutdown( + receiver: &Receiver>, + label: &str, + stream: &str, + deadline: Instant, +) -> Result<()> { + let remaining = deadline.saturating_duration_since(Instant::now()); + match receiver.recv_timeout(remaining) { + Ok(_) | Err(RecvTimeoutError::Disconnected) => Ok(()), + Err(RecvTimeoutError::Timeout) => Err(format!( + "{label} {stream} reader shutdown was not proven inside the bounded cleanup window" + ) + .into()), + } +} + +fn read_bounded(mut reader: R) -> io::Result { + let mut bytes = Vec::new(); + let mut truncated = false; + let mut buffer = [0_u8; 8192]; + loop { + let count = reader.read(&mut buffer)?; + if count == 0 { + break; + } + if truncated { + continue; + } + let probe_limit = OBSERVATION_GIT_OUTPUT_LIMIT + 1; + let remaining = probe_limit.saturating_sub(bytes.len()); + let retained = remaining.min(count); + bytes.extend_from_slice(&buffer[..retained]); + if bytes.len() > OBSERVATION_GIT_OUTPUT_LIMIT { + bytes.truncate(OBSERVATION_GIT_OUTPUT_LIMIT); + truncated = true; + } + } + Ok(BoundedCapture { bytes, truncated }) } fn parse_worktree_status(bytes: &[u8]) -> Result { let mut head_oid: Option> = None; let mut branch: Option> = None; + let mut upstream_seen = false; + let mut ahead_behind_seen = false; let mut dirty = false; let mut worktree_hasher = Sha256::new(); @@ -323,10 +631,25 @@ fn parse_worktree_status(bytes: &[u8]) -> Result { }); continue; } - if field.starts_with(b"# ") { + if let Some(value) = field.strip_prefix(b"# branch.upstream ") { + if upstream_seen || value.is_empty() { + return Err("Git status returned invalid branch.upstream headers".into()); + } + upstream_seen = true; + continue; + } + if let Some(value) = field.strip_prefix(b"# branch.ab ") { + if ahead_behind_seen || value.is_empty() { + return Err("Git status returned invalid branch.ab headers".into()); + } + ahead_behind_seen = true; continue; } + if field.starts_with(b"# ") { + return Err("Git status returned an unrecognized porcelain-v2 branch header".into()); + } + validate_worktree_record(field)?; dirty = true; worktree_hasher.update(field); worktree_hasher.update([0]); @@ -349,6 +672,44 @@ fn parse_worktree_status(bytes: &[u8]) -> Result { }) } +fn validate_worktree_record(field: &[u8]) -> Result<()> { + if let Some(rest) = field.strip_prefix(b"1 ") { + if fixed_fields_then_path(rest, 7) { + return Ok(()); + } + return Err("Git status returned a malformed ordinary changed-entry record".into()); + } + if let Some(rest) = field.strip_prefix(b"u ") { + if fixed_fields_then_path(rest, 9) { + return Ok(()); + } + return Err("Git status returned a malformed unmerged-entry record".into()); + } + if let Some(path) = field.strip_prefix(b"? ") { + if !path.is_empty() { + return Ok(()); + } + return Err("Git status returned an empty untracked path".into()); + } + if field.starts_with(b"2 ") { + return Err("Git status returned a rename/copy record despite --no-renames".into()); + } + Err("Git status returned an unrecognized porcelain-v2 worktree record".into()) +} + +fn fixed_fields_then_path(mut rest: &[u8], fixed_fields: usize) -> bool { + for _ in 0..fixed_fields { + let Some(separator) = rest.iter().position(|byte| *byte == b' ') else { + return false; + }; + if separator == 0 { + return false; + } + rest = &rest[separator + 1..]; + } + !rest.is_empty() +} + fn hex_digest(digest: impl AsRef<[u8]>) -> String { digest .as_ref() @@ -385,7 +746,72 @@ fn git_command(cwd: &Path) -> Command { command } -fn run_git_bytes(cwd: &Path, args: I) -> Result> +pub(super) fn run_read_only_git_output( + cwd: &Path, + args: I, + label: &str, +) -> Result +where + I: IntoIterator, + S: AsRef, +{ + let mut command = git_command(cwd); + command.env("GIT_OPTIONAL_LOCKS", "0").args(args); + let output = run_bounded_read_only_git_output(command, label)?; + require_complete_stdout(&output, label)?; + Ok(output) +} + +fn run_read_only_git_has_output(cwd: &Path, args: I, label: &str) -> Result +where + I: IntoIterator, + S: AsRef, +{ + let mut command = git_command(cwd); + command.env("GIT_OPTIONAL_LOCKS", "0").args(args); + let output = run_bounded_read_only_git_output(command, label)?; + if !output.status.success() { + return Err(format!( + "{label} failed with status {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ) + .into()); + } + Ok(output.stdout_truncated || !output.stdout.is_empty()) +} + +pub(super) fn run_read_only_git_bytes(cwd: &Path, args: I, label: &str) -> Result> +where + I: IntoIterator, + S: AsRef, +{ + let output = run_read_only_git_output(cwd, args, label)?; + if !output.status.success() { + return Err(format!( + "{label} failed with status {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ) + .into()); + } + Ok(output.stdout) +} + +pub(super) fn run_read_only_git_text(cwd: &Path, args: I, label: &str) -> Result +where + I: IntoIterator, + S: AsRef, +{ + Ok(String::from_utf8(run_read_only_git_bytes( + cwd, args, label, + )?)?) +} + +// Mutation-capable Git operations intentionally remain outside the read-only +// observation timeout/containment contract. Read-only callers must use the +// bounded helpers above. +fn run_mutating_git_bytes(cwd: &Path, args: I) -> Result> where I: IntoIterator, S: AsRef, @@ -401,20 +827,20 @@ where .into()) } -fn run_git_text(cwd: &Path, args: I) -> Result +fn run_mutating_git_text(cwd: &Path, args: I) -> Result where I: IntoIterator, S: AsRef, { - Ok(String::from_utf8(run_git_bytes(cwd, args)?)?) + Ok(String::from_utf8(run_mutating_git_bytes(cwd, args)?)?) } -fn run_git_os(cwd: &Path, args: I) -> Result<()> +fn run_mutating_git_os(cwd: &Path, args: I) -> Result<()> where I: IntoIterator, S: AsRef, { - run_git_bytes(cwd, args).map(|_| ()) + run_mutating_git_bytes(cwd, args).map(|_| ()) } fn strip_git_line_ending(value: &str) -> &str { @@ -424,7 +850,12 @@ fn strip_git_line_ending(value: &str) -> &str { #[cfg(test)] mod git_observation_tests { - use super::{GIT_WORKTREE_STATE_FORMAT, parse_worktree_status}; + use super::{ + GIT_WORKTREE_STATE_FORMAT, OBSERVATION_GIT_OUTPUT_LIMIT, parse_worktree_status, + read_bounded, run_bounded_read_only_git_output, + }; + use std::io::Cursor; + use std::process::Command; #[test] fn clean_attached_status_parses_branch_and_empty_state_digest() { @@ -489,4 +920,45 @@ mod git_observation_tests { parse_worktree_status(b"# branch.oid (initial)\0# branch.head (detached)\0").is_err() ); } + + #[test] + fn malformed_or_unexpected_porcelain_v2_records_fail_closed() { + let prefix = b"# branch.oid abc\0# branch.head main\0"; + for invalid in [ + b"garbage\0".as_slice(), + b"? \0".as_slice(), + b"1 MM N... 100644\0".as_slice(), + b"2 MM N... 100644 100644 100644 abc def R100 new\0old\0".as_slice(), + b"# unexpected header\0".as_slice(), + ] { + let mut bytes = prefix.to_vec(); + bytes.extend_from_slice(invalid); + assert!(parse_worktree_status(&bytes).is_err()); + } + } + + #[test] + fn bounded_reader_drains_after_the_safety_cap() { + let input = vec![b'x'; OBSERVATION_GIT_OUTPUT_LIMIT + 17]; + let captured = read_bounded(Cursor::new(input)).unwrap(); + assert_eq!(captured.bytes.len(), OBSERVATION_GIT_OUTPUT_LIMIT); + assert!(captured.truncated); + } + + #[test] + fn bounded_read_only_runner_preserves_expected_nonzero_status() { + let command = if cfg!(windows) { + let mut command = Command::new("cmd.exe"); + command.args(["/d", "/s", "/c", "exit 7"]); + command + } else { + let mut command = Command::new("/bin/sh"); + command.args(["-c", "exit 7"]); + command + }; + let output = + run_bounded_read_only_git_output(command, "bounded nonzero-status fixture").unwrap(); + assert_eq!(output.status.code(), Some(7)); + assert!(output.stdout.is_empty()); + } } diff --git a/src/main.rs b/src/main.rs index a227e459..3e4ccc60 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,8 @@ mod domain; mod execution; mod git; mod store; +#[cfg(test)] +mod t068_store_regression_tests; use crate::check::run_check; use crate::domain::{CheckEvidence, CheckStatus, Eligibility, EvidenceReport, PromotionReport}; diff --git a/src/process_scope.rs b/src/process_scope.rs new file mode 100644 index 00000000..cbd6b959 --- /dev/null +++ b/src/process_scope.rs @@ -0,0 +1,1113 @@ +use super::Result; +use std::io; +use std::process::{Child, ChildStderr, ChildStdout, Command, ExitStatus}; +use std::thread; +use std::time::{Duration, Instant}; + +const MAX_CLEANUP_RESERVE: Duration = Duration::from_secs(2); +const POLL_INTERVAL: Duration = Duration::from_millis(10); + +pub(super) fn operation_deadlines(started: Instant, total_timeout: Duration) -> (Instant, Instant) { + let cleanup_reserve = std::cmp::min(MAX_CLEANUP_RESERVE, total_timeout / 4); + ( + started + total_timeout.saturating_sub(cleanup_reserve), + started + total_timeout, + ) +} + +pub(super) struct OwnedProcess { + child: Child, + #[cfg(unix)] + process_group_id: Option, + #[cfg(windows)] + job: WindowsJob, +} + +impl OwnedProcess { + pub(super) fn take_stdout(&mut self) -> Option { + self.child.stdout.take() + } + + pub(super) fn take_stderr(&mut self) -> Option { + self.child.stderr.take() + } + + pub(super) fn try_wait(&mut self) -> io::Result> { + self.child.try_wait() + } + + pub(super) fn wait_for_scope_quiescence( + &mut self, + deadline: Instant, + label: &str, + ) -> Result { + loop { + if self.scope_is_quiescent(label)? { + self.disarm_unix_process_group(); + return Ok(true); + } + let now = Instant::now(); + if now >= deadline { + return Ok(false); + } + thread::sleep(POLL_INTERVAL.min(deadline.saturating_duration_since(now))); + } + } + + pub(super) fn terminate_and_prove(&mut self, deadline: Instant, label: &str) -> Result<()> { + self.terminate_scope(label)?; + loop { + let direct_exited = self + .child + .try_wait() + .map_err(|error| { + format!("{label} failed while reaping its owned direct child: {error}") + })? + .is_some(); + let scope_quiescent = self.scope_is_quiescent(label)?; + if direct_exited && scope_quiescent { + self.disarm_unix_process_group(); + return Ok(()); + } + let now = Instant::now(); + if now >= deadline { + return Err(format!( + "{label} owned process scope could not be proven terminated inside the bounded cleanup window" + ) + .into()); + } + thread::sleep(POLL_INTERVAL.min(deadline.saturating_duration_since(now))); + } + } + + #[cfg(unix)] + fn terminate_scope(&mut self, label: &str) -> Result<()> { + let Some(process_group_id) = self.process_group_id else { + return Ok(()); + }; + let result = unsafe { libc::kill(-process_group_id, libc::SIGKILL) }; + if result == 0 { + return Ok(()); + } + let error = io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::ESRCH) { + Ok(()) + } else { + Err(format!("{label} failed to terminate its owned process group: {error}").into()) + } + } + + #[cfg(windows)] + fn terminate_scope(&mut self, label: &str) -> Result<()> { + self.job.terminate(label) + } + + #[cfg(not(any(unix, windows)))] + fn terminate_scope(&mut self, label: &str) -> Result<()> { + match self.child.kill() { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::InvalidInput => Ok(()), + Err(error) => { + Err(format!("{label} failed to terminate its owned child: {error}").into()) + } + } + } + + #[cfg(unix)] + fn scope_is_quiescent(&self, label: &str) -> Result { + let Some(process_group_id) = self.process_group_id else { + return Ok(true); + }; + let result = unsafe { libc::kill(-process_group_id, 0) }; + if result == 0 { + return Ok(false); + } + let error = io::Error::last_os_error(); + match error.raw_os_error() { + Some(libc::ESRCH) => Ok(true), + Some(libc::EPERM) => Ok(false), + _ => Err(format!("{label} could not inspect its owned process group: {error}").into()), + } + } + + #[cfg(unix)] + fn disarm_unix_process_group(&mut self) { + self.process_group_id = None; + } + + #[cfg(not(unix))] + fn disarm_unix_process_group(&mut self) {} + + #[cfg(windows)] + fn scope_is_quiescent(&self, label: &str) -> Result { + Ok(self.job.active_processes(label)? == 0) + } + + #[cfg(not(any(unix, windows)))] + fn scope_is_quiescent(&self, _label: &str) -> Result { + Ok(false) + } +} + +impl Drop for OwnedProcess { + fn drop(&mut self) { + #[cfg(unix)] + { + // Destructor fallback must never signal the numeric process-group + // identity. If an earlier bounded cleanup could not prove + // quiescence, the original group may disappear and the PGID may be + // reused before Drop runs. Preserve that unproven-cleanup truth + // instead of risking a signal to an unrelated group. + self.process_group_id = None; + + // Best effort is limited to the directly-owned child identity. + // `try_wait() == None` means the direct child has not been reaped, + // so its PID cannot have been recycled at this point. + if matches!(self.child.try_wait(), Ok(None)) { + let _ = self.child.kill(); + // SIGKILL cannot be caught. Reap only through nonblocking + // `try_wait` and a short deadline so Drop cannot introduce an + // unbounded wait while also avoiding a permanent zombie. + let reap_deadline = Instant::now() + POLL_INTERVAL * 20; + while matches!(self.child.try_wait(), Ok(None)) { + let now = Instant::now(); + if now >= reap_deadline { + break; + } + thread::sleep(POLL_INTERVAL.min(reap_deadline.saturating_duration_since(now))); + } + } + } + #[cfg(not(any(unix, windows)))] + { + if self.child.try_wait().ok().flatten().is_none() { + let _ = self.child.kill(); + } + } + } +} + +#[cfg(any( + all( + target_os = "linux", + any(target_arch = "x86_64", target_arch = "aarch64") + ), + target_os = "macos" +))] +pub(super) fn spawn_owned_process(command: &mut Command, label: &str) -> Result { + use std::os::unix::process::CommandExt; + + #[cfg(target_os = "macos")] + if unsafe { libc::getuid() } == 0 { + return Err(format!("{label} refuses Unix owned-process containment as macOS root").into()); + } + + // SAFETY: pre_exec runs after fork and before exec. The callback performs + // only direct libc syscalls and stack-only filter setup: setsid plus the + // narrow platform containment primitive. It does not allocate, lock, or + // touch shared Rust state. + unsafe { + command.pre_exec(configure_unix_owned_scope); + } + let child = command + .spawn() + .map_err(|error| format!("{label} could not start its owned subprocess: {error}"))?; + let process_group_id = libc::pid_t::try_from(child.id()) + .map_err(|_| format!("{label} child process id does not fit a Unix process-group id"))?; + Ok(OwnedProcess { + child, + process_group_id: Some(process_group_id), + }) +} + +#[cfg(all( + unix, + not(any( + all( + target_os = "linux", + any(target_arch = "x86_64", target_arch = "aarch64") + ), + target_os = "macos" + )) +))] +pub(super) fn spawn_owned_process(_command: &mut Command, label: &str) -> Result { + Err( + format!("{label} owned subprocess containment is not implemented for this Unix target") + .into(), + ) +} + +#[cfg(any( + all( + target_os = "linux", + any(target_arch = "x86_64", target_arch = "aarch64") + ), + target_os = "macos" +))] +fn configure_unix_owned_scope() -> io::Result<()> { + if unsafe { libc::setsid() } == -1 { + return Err(io::Error::last_os_error()); + } + + #[cfg(target_os = "linux")] + install_linux_process_group_escape_filter()?; + + // macOS deliberately does not lower RLIMIT_NPROC here. That limit is + // accounted per real UID rather than per owned process tree, so using it as + // containment breaks legitimate Git subprocesses (including submodule + // status scans) based on unrelated processes owned by the same user. The + // macOS contract is the owned session/process-group boundary established by + // setsid above; normal Git descendants inherit that boundary and are + // terminated/reaped as one scope. + + Ok(()) +} + +#[cfg(all( + target_os = "linux", + any(target_arch = "x86_64", target_arch = "aarch64") +))] +fn install_linux_process_group_escape_filter() -> io::Result<()> { + const BPF_LD_W_ABS: u16 = 0x20; + const BPF_ALU_AND_K: u16 = 0x54; + const BPF_JMP_JEQ_K: u16 = 0x15; + const BPF_RET_K: u16 = 0x06; + + const SECCOMP_RET_KILL_THREAD: u32 = 0x0000_0000; + const SECCOMP_RET_ERRNO: u32 = 0x0005_0000; + const SECCOMP_RET_ALLOW: u32 = 0x7fff_0000; + const SECCOMP_MODE_FILTER: libc::c_ulong = 2; + const PR_SET_SECCOMP: libc::c_int = 22; + const PR_SET_NO_NEW_PRIVS: libc::c_int = 38; + + #[cfg(target_arch = "x86_64")] + const AUDIT_ARCH: u32 = 0xc000_003e; + #[cfg(target_arch = "aarch64")] + const AUDIT_ARCH: u32 = 0xc000_00b7; + + const SECCOMP_DATA_NR_OFFSET: u32 = 0; + const SECCOMP_DATA_ARCH_OFFSET: u32 = 4; + const X32_SYSCALL_BIT_CLEAR_MASK: u32 = 0xbfff_ffff; + + const fn statement(code: u16, k: u32) -> libc::sock_filter { + libc::sock_filter { + code, + jt: 0, + jf: 0, + k, + } + } + + const fn jump(code: u16, k: u32, jt: u8, jf: u8) -> libc::sock_filter { + libc::sock_filter { code, jt, jf, k } + } + + let deny_errno = SECCOMP_RET_ERRNO | (libc::EPERM as u32 & 0x0000_ffff); + let mut filter = [ + statement(BPF_LD_W_ABS, SECCOMP_DATA_ARCH_OFFSET), + jump(BPF_JMP_JEQ_K, AUDIT_ARCH, 1, 0), + statement(BPF_RET_K, SECCOMP_RET_KILL_THREAD), + statement(BPF_LD_W_ABS, SECCOMP_DATA_NR_OFFSET), + statement(BPF_ALU_AND_K, X32_SYSCALL_BIT_CLEAR_MASK), + jump(BPF_JMP_JEQ_K, libc::SYS_setsid as u32, 0, 1), + statement(BPF_RET_K, deny_errno), + jump(BPF_JMP_JEQ_K, libc::SYS_setpgid as u32, 0, 1), + statement(BPF_RET_K, deny_errno), + statement(BPF_RET_K, SECCOMP_RET_ALLOW), + ]; + let mut program = libc::sock_fprog { + len: filter.len() as u16, + filter: filter.as_mut_ptr(), + }; + + let no_new_privs = unsafe { + libc::prctl( + PR_SET_NO_NEW_PRIVS, + 1 as libc::c_ulong, + 0 as libc::c_ulong, + 0 as libc::c_ulong, + 0 as libc::c_ulong, + ) + }; + if no_new_privs != 0 { + return Err(io::Error::last_os_error()); + } + + let installed = unsafe { + libc::prctl( + PR_SET_SECCOMP, + SECCOMP_MODE_FILTER, + &mut program as *mut libc::sock_fprog, + 0 as libc::c_ulong, + 0 as libc::c_ulong, + ) + }; + if installed != 0 { + return Err(io::Error::last_os_error()); + } + + Ok(()) +} + +#[cfg(windows)] +pub(super) fn spawn_owned_process(command: &mut Command, label: &str) -> Result { + use std::os::windows::io::AsRawHandle; + use std::os::windows::process::CommandExt; + + let job = WindowsJob::new(label)?; + command.creation_flags(CREATE_SUSPENDED); + let mut child = command.spawn().map_err(|error| { + format!("{label} could not start its suspended owned subprocess: {error}") + })?; + + let assignment = job.assign(child.as_raw_handle().cast(), label); + handle_windows_job_assignment(&mut child, assignment, label)?; + + let mut owned = OwnedProcess { child, job }; + if let Err(resume_error) = resume_suspended_primary_thread(owned.child.id(), label) { + let cleanup = owned.terminate_and_prove(Instant::now() + MAX_CLEANUP_RESERVE, label); + return match cleanup { + Ok(()) => Err(resume_error), + Err(cleanup_error) => Err(format!( + "{resume_error}; suspended owned process cleanup also failed: {cleanup_error}" + ) + .into()), + }; + } + Ok(owned) +} + +#[cfg(windows)] +fn handle_windows_job_assignment( + child: &mut Child, + assignment: Result<()>, + label: &str, +) -> Result<()> { + let Err(assign_error) = assignment else { + return Ok(()); + }; + + let cleanup = + cleanup_unassigned_suspended_child(child, Instant::now() + MAX_CLEANUP_RESERVE, label); + finish_windows_job_assignment_failure(assign_error, cleanup, label) +} + +#[cfg(windows)] +fn cleanup_unassigned_suspended_child( + child: &mut Child, + deadline: Instant, + label: &str, +) -> Result<()> { + match child.try_wait() { + Ok(Some(_)) => return Ok(()), + Ok(None) => {} + Err(error) => { + return Err(format!( + "{label} could not inspect the unassigned suspended child before cleanup: {error}" + ) + .into()); + } + } + + if let Err(kill_error) = child.kill() { + return match child.try_wait() { + Ok(Some(_)) => Ok(()), + Ok(None) => Err(format!( + "{label} failed to terminate the unassigned suspended child: {kill_error}; direct-child termination and reap remain unproven" + ) + .into()), + Err(wait_error) => Err(format!( + "{label} failed to terminate the unassigned suspended child: {kill_error}; direct-child reap state is also unproven: {wait_error}" + ) + .into()), + }; + } + + loop { + match child.try_wait() { + Ok(Some(_)) => return Ok(()), + Ok(None) => {} + Err(error) => { + return Err(format!( + "{label} terminated the unassigned suspended child but could not prove reap: {error}" + ) + .into()); + } + } + + let now = Instant::now(); + if now >= deadline { + return Err(format!( + "{label} unassigned suspended child could not be proven terminated and reaped inside the bounded cleanup window" + ) + .into()); + } + thread::sleep(POLL_INTERVAL.min(deadline.saturating_duration_since(now))); + } +} + +#[cfg(windows)] +fn finish_windows_job_assignment_failure( + assign_error: Box, + cleanup: Result<()>, + label: &str, +) -> Result<()> { + match cleanup { + Ok(()) => Err(assign_error), + Err(cleanup_error) => Err(format!( + "{assign_error}; {label} suspended child was never assigned to the Windows Job Object and cleanup could not be proven: {cleanup_error}" + ) + .into()), + } +} + +#[cfg(not(any(unix, windows)))] +pub(super) fn spawn_owned_process(command: &mut Command, label: &str) -> Result { + let child = command + .spawn() + .map_err(|error| format!("{label} could not start its owned subprocess: {error}"))?; + Ok(OwnedProcess { child }) +} + +#[cfg(windows)] +type WinHandle = *mut std::ffi::c_void; + +#[cfg(windows)] +const CREATE_SUSPENDED: u32 = 0x0000_0004; +#[cfg(windows)] +const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE: u32 = 0x0000_2000; +#[cfg(windows)] +const JOB_OBJECT_BASIC_ACCOUNTING_INFORMATION_CLASS: i32 = 1; +#[cfg(windows)] +const JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_CLASS: i32 = 9; +#[cfg(windows)] +const TH32CS_SNAPTHREAD: u32 = 0x0000_0004; +#[cfg(windows)] +const THREAD_SUSPEND_RESUME: u32 = 0x0000_0002; +#[cfg(windows)] +const ERROR_NO_MORE_FILES: u32 = 18; + +#[cfg(windows)] +#[repr(C)] +struct JobObjectBasicLimitInformation { + per_process_user_time_limit: i64, + per_job_user_time_limit: i64, + limit_flags: u32, + minimum_working_set_size: usize, + maximum_working_set_size: usize, + active_process_limit: u32, + affinity: usize, + priority_class: u32, + scheduling_class: u32, +} + +#[cfg(windows)] +#[repr(C)] +struct IoCounters { + read_operation_count: u64, + write_operation_count: u64, + other_operation_count: u64, + read_transfer_count: u64, + write_transfer_count: u64, + other_transfer_count: u64, +} + +#[cfg(windows)] +#[repr(C)] +struct JobObjectExtendedLimitInformation { + basic_limit_information: JobObjectBasicLimitInformation, + io_info: IoCounters, + process_memory_limit: usize, + job_memory_limit: usize, + peak_process_memory_used: usize, + peak_job_memory_used: usize, +} + +#[cfg(windows)] +#[repr(C)] +struct JobObjectBasicAccountingInformation { + total_user_time: i64, + total_kernel_time: i64, + this_period_total_user_time: i64, + this_period_total_kernel_time: i64, + total_page_fault_count: u32, + total_processes: u32, + active_processes: u32, + total_terminated_processes: u32, +} + +#[cfg(windows)] +#[repr(C)] +struct ThreadEntry32 { + size: u32, + usage_count: u32, + thread_id: u32, + owner_process_id: u32, + base_priority: i32, + delta_priority: i32, + flags: u32, +} + +#[cfg(windows)] +#[link(name = "kernel32")] +unsafe extern "system" { + #[link_name = "CreateJobObjectW"] + fn create_job_object_w(attributes: *const std::ffi::c_void, name: *const u16) -> WinHandle; + #[link_name = "SetInformationJobObject"] + fn set_information_job_object( + job: WinHandle, + information_class: i32, + information: *const std::ffi::c_void, + information_length: u32, + ) -> i32; + #[link_name = "AssignProcessToJobObject"] + fn assign_process_to_job_object(job: WinHandle, process: WinHandle) -> i32; + #[link_name = "TerminateJobObject"] + fn terminate_job_object(job: WinHandle, exit_code: u32) -> i32; + #[link_name = "QueryInformationJobObject"] + fn query_information_job_object( + job: WinHandle, + information_class: i32, + information: *mut std::ffi::c_void, + information_length: u32, + return_length: *mut u32, + ) -> i32; + #[link_name = "CloseHandle"] + fn close_handle(handle: WinHandle) -> i32; + #[link_name = "CreateToolhelp32Snapshot"] + fn create_toolhelp32_snapshot(flags: u32, process_id: u32) -> WinHandle; + #[link_name = "Thread32First"] + fn thread32_first(snapshot: WinHandle, entry: *mut ThreadEntry32) -> i32; + #[link_name = "Thread32Next"] + fn thread32_next(snapshot: WinHandle, entry: *mut ThreadEntry32) -> i32; + #[link_name = "OpenThread"] + fn open_thread(desired_access: u32, inherit_handle: i32, thread_id: u32) -> WinHandle; + #[link_name = "ResumeThread"] + fn resume_thread(thread: WinHandle) -> u32; + #[link_name = "GetLastError"] + fn get_last_error() -> u32; +} + +#[cfg(windows)] +struct OwnedWinHandle(WinHandle); + +#[cfg(windows)] +impl OwnedWinHandle { + fn new(handle: WinHandle, label: &str) -> Result { + if handle.is_null() || handle as isize == -1 { + Err(format!("{label}: {}", io::Error::last_os_error()).into()) + } else { + Ok(Self(handle)) + } + } + + fn raw(&self) -> WinHandle { + self.0 + } +} + +#[cfg(windows)] +impl Drop for OwnedWinHandle { + fn drop(&mut self) { + unsafe { + let _ = close_handle(self.0); + } + } +} + +#[cfg(windows)] +struct WindowsJob { + handle: OwnedWinHandle, +} + +#[cfg(windows)] +impl WindowsJob { + fn new(label: &str) -> Result { + let raw = unsafe { create_job_object_w(std::ptr::null(), std::ptr::null()) }; + let handle = OwnedWinHandle::new( + raw, + &format!("{label} could not create a Windows Job Object"), + )?; + let mut information: JobObjectExtendedLimitInformation = unsafe { std::mem::zeroed() }; + information.basic_limit_information.limit_flags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + let result = unsafe { + set_information_job_object( + handle.raw(), + JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_CLASS, + (&information as *const JobObjectExtendedLimitInformation).cast(), + std::mem::size_of::() as u32, + ) + }; + if result == 0 { + return Err(format!( + "{label} could not configure KILL_ON_JOB_CLOSE on its Windows Job Object: {}", + io::Error::last_os_error() + ) + .into()); + } + Ok(Self { handle }) + } + + fn assign(&self, process: WinHandle, label: &str) -> Result<()> { + let result = unsafe { assign_process_to_job_object(self.handle.raw(), process) }; + if result == 0 { + Err(format!( + "{label} could not assign its suspended child to the owned Windows Job Object: {}", + io::Error::last_os_error() + ) + .into()) + } else { + Ok(()) + } + } + + fn terminate(&self, label: &str) -> Result<()> { + if self.active_processes(label)? == 0 { + return Ok(()); + } + let result = unsafe { terminate_job_object(self.handle.raw(), 1) }; + if result == 0 { + Err(format!( + "{label} could not terminate its owned Windows Job Object: {}", + io::Error::last_os_error() + ) + .into()) + } else { + Ok(()) + } + } + + fn active_processes(&self, label: &str) -> Result { + let mut information: JobObjectBasicAccountingInformation = unsafe { std::mem::zeroed() }; + let result = unsafe { + query_information_job_object( + self.handle.raw(), + JOB_OBJECT_BASIC_ACCOUNTING_INFORMATION_CLASS, + (&mut information as *mut JobObjectBasicAccountingInformation).cast(), + std::mem::size_of::() as u32, + std::ptr::null_mut(), + ) + }; + if result == 0 { + Err(format!( + "{label} could not query its Windows Job Object accounting state: {}", + io::Error::last_os_error() + ) + .into()) + } else { + Ok(information.active_processes) + } + } +} + +#[cfg(windows)] +fn resume_suspended_primary_thread(process_id: u32, label: &str) -> Result<()> { + let snapshot_raw = unsafe { create_toolhelp32_snapshot(TH32CS_SNAPTHREAD, 0) }; + let snapshot = OwnedWinHandle::new( + snapshot_raw, + &format!("{label} could not snapshot Windows threads for suspended-child resume"), + )?; + + let mut entry: ThreadEntry32 = unsafe { std::mem::zeroed() }; + entry.size = std::mem::size_of::() as u32; + if unsafe { thread32_first(snapshot.raw(), &mut entry) } == 0 { + return Err(format!( + "{label} could not enumerate Windows threads for suspended-child resume: {}", + io::Error::last_os_error() + ) + .into()); + } + + let mut owned_thread_id = None; + loop { + if entry.owner_process_id == process_id + && owned_thread_id.replace(entry.thread_id).is_some() + { + return Err(format!( + "{label} suspended child exposed multiple threads before resume; refusing ambiguous ownership" + ) + .into()); + } + entry.size = std::mem::size_of::() as u32; + if unsafe { thread32_next(snapshot.raw(), &mut entry) } != 0 { + continue; + } + let last_error = unsafe { get_last_error() }; + if last_error == ERROR_NO_MORE_FILES { + break; + } + return Err(format!( + "{label} failed while enumerating Windows threads for suspended-child resume: OS error {last_error}" + ) + .into()); + } + + let thread_id = owned_thread_id + .ok_or_else(|| format!("{label} suspended child primary thread could not be identified"))?; + let thread_raw = unsafe { open_thread(THREAD_SUSPEND_RESUME, 0, thread_id) }; + let thread_handle = OwnedWinHandle::new( + thread_raw, + &format!("{label} could not open its suspended primary thread"), + )?; + let previous_count = unsafe { resume_thread(thread_handle.raw()) }; + if previous_count == u32::MAX { + return Err(format!( + "{label} could not resume its suspended primary thread: {}", + io::Error::last_os_error() + ) + .into()); + } + if previous_count != 1 { + return Err(format!( + "{label} suspended primary thread had unexpected suspend count {previous_count}; refusing ambiguous resume state" + ) + .into()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + #[cfg(windows)] + use super::{ + CREATE_SUSPENDED, finish_windows_job_assignment_failure, handle_windows_job_assignment, + }; + use super::{operation_deadlines, spawn_owned_process}; + #[cfg(unix)] + use std::os::unix::process::CommandExt; + #[cfg(windows)] + use std::os::windows::process::CommandExt; + use std::process::{Command, Stdio}; + use std::thread; + use std::time::{Duration, Instant}; + + fn wait_for_direct_exit(process: &mut super::OwnedProcess, deadline: Instant) -> bool { + loop { + match process.try_wait() { + Ok(Some(_)) => return true, + Ok(None) => {} + Err(_) => return false, + } + if Instant::now() >= deadline { + return false; + } + thread::sleep(Duration::from_millis(10)); + } + } + + #[cfg(windows)] + #[test] + fn windows_assignment_failure_terminates_and_reaps_unassigned_suspended_child() { + let mut command = Command::new("cmd.exe"); + command + .args(["/d", "/s", "/c", "exit 0"]) + .creation_flags(CREATE_SUSPENDED) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + let mut child = command.spawn().unwrap(); + let error = handle_windows_job_assignment( + &mut child, + Err("forced Windows Job Object assignment failure".into()), + "process-scope forced assignment fixture", + ) + .unwrap_err(); + + assert!( + error + .to_string() + .contains("forced Windows Job Object assignment failure") + ); + assert!( + !error.to_string().contains("cleanup could not be proven"), + "successful direct-child cleanup must preserve the assignment error without falsely claiming unproven cleanup" + ); + + let reaped = child.try_wait().unwrap().is_some(); + if !reaped { + let _ = child.kill(); + let _ = child.wait(); + } + assert!( + reaped, + "assignment failure must not return until the unassigned suspended child is proven reaped" + ); + } + + #[cfg(windows)] + #[test] + fn windows_assignment_failure_reports_unproven_cleanup_truth() { + let error = finish_windows_job_assignment_failure( + "forced Windows Job Object assignment failure".into(), + Err("forced direct-child cleanup unproven".into()), + "process-scope forced cleanup fixture", + ) + .unwrap_err(); + let message = error.to_string(); + + assert!(message.contains("forced Windows Job Object assignment failure")); + assert!(message.contains("cleanup could not be proven")); + assert!(message.contains("forced direct-child cleanup unproven")); + } + + #[test] + fn short_owned_process_quiesces() { + let mut command = if cfg!(windows) { + let mut command = Command::new("cmd.exe"); + command.args(["/d", "/s", "/c", "exit 0"]); + command + } else { + let mut command = Command::new("/bin/sh"); + command.args(["-c", "exit 0"]); + command + }; + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let started = Instant::now(); + let (command_deadline, cleanup_deadline) = + operation_deadlines(started, Duration::from_secs(3)); + let mut process = spawn_owned_process(&mut command, "process-scope short fixture").unwrap(); + assert!(wait_for_direct_exit(&mut process, command_deadline)); + assert!( + process + .wait_for_scope_quiescence(cleanup_deadline, "process-scope short fixture") + .unwrap() + ); + #[cfg(unix)] + assert!( + process.process_group_id.is_none(), + "proven Unix quiescence must disarm fallback PGID signaling" + ); + } + + #[cfg(unix)] + #[test] + fn unix_drop_does_not_signal_a_reused_numeric_process_group() { + let mut unrelated_command = Command::new("/bin/sleep"); + unrelated_command + .arg("30") + .process_group(0) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let mut unrelated = unrelated_command.spawn().unwrap(); + let unrelated_pgid = libc::pid_t::try_from(unrelated.id()).unwrap(); + assert!( + unrelated.try_wait().unwrap().is_none(), + "unrelated process-group fixture must begin live" + ); + + let mut owned_command = Command::new("/bin/sh"); + owned_command + .args(["-c", "exit 0"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let mut owned = + spawn_owned_process(&mut owned_command, "process-scope recycled-PGID fixture").unwrap(); + assert!(wait_for_direct_exit( + &mut owned, + Instant::now() + Duration::from_secs(5) + )); + + // Simulate the exact unsafe destructor state from the review finding: + // the original owned group is already gone/reaped, while the stored + // numeric PGID has since been reused by an unrelated live group. + owned.process_group_id = Some(unrelated_pgid); + drop(owned); + + thread::sleep(Duration::from_millis(50)); + let unrelated_still_live = unrelated.try_wait().unwrap().is_none(); + + // Always clean up the fixture through its directly-owned Child handle. + if unrelated_still_live { + let _ = unrelated.kill(); + let _ = unrelated.wait(); + } + + assert!( + unrelated_still_live, + "Unix OwnedProcess::drop must never signal a numeric PGID whose ownership is no longer provable" + ); + } + + #[cfg(unix)] + #[test] + fn proven_quiescent_unix_scope_disarms_drop_pgid_signal() { + let mut command = Command::new("/bin/sh"); + command + .args(["-c", "exit 0"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + let mut process = + spawn_owned_process(&mut command, "process-scope Unix disarm fixture").unwrap(); + assert!(process.process_group_id.is_some()); + assert!(wait_for_direct_exit( + &mut process, + Instant::now() + Duration::from_secs(5) + )); + assert!( + process + .wait_for_scope_quiescence( + Instant::now() + Duration::from_secs(2), + "process-scope Unix disarm fixture", + ) + .unwrap() + ); + assert!( + process.process_group_id.is_none(), + "Drop must have no numeric PGID left to signal after quiescence proof" + ); + + drop(process); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_owned_scope_blocks_setsid_escape() { + let marker = + std::env::temp_dir().join(format!("winds-t068-setsid-escape-{}", std::process::id())); + let _ = std::fs::remove_file(&marker); + let marker_text = marker.to_str().unwrap(); + + let mut command = Command::new("/usr/bin/setsid"); + command.args([ + "/bin/sh", + "-c", + "printf escaped > \"$1\"", + "winds-t068-setsid", + marker_text, + ]); + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + let mut process = + spawn_owned_process(&mut command, "process-scope setsid escape fixture").unwrap(); + assert!(wait_for_direct_exit( + &mut process, + Instant::now() + Duration::from_secs(5) + )); + assert!( + process + .wait_for_scope_quiescence( + Instant::now() + Duration::from_secs(2), + "process-scope setsid escape fixture", + ) + .unwrap() + ); + + let escaped = marker.exists(); + let _ = std::fs::remove_file(&marker); + assert!( + !escaped, + "the inherited Linux containment filter must prevent a descendant from escaping with setsid" + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn macos_owned_scope_allows_and_terminates_same_group_descendants() { + let mut command = Command::new("/bin/sh"); + command.args(["-c", "/bin/sleep 30 &"]); + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + let mut process = + spawn_owned_process(&mut command, "process-scope macOS descendant fixture").unwrap(); + assert!(wait_for_direct_exit( + &mut process, + Instant::now() + Duration::from_secs(5) + )); + assert!( + !process + .wait_for_scope_quiescence( + Instant::now() + Duration::from_millis(100), + "process-scope macOS descendant fixture", + ) + .unwrap(), + "macOS owned process-group containment must observe a legitimate live descendant" + ); + process + .terminate_and_prove( + Instant::now() + Duration::from_secs(2), + "process-scope macOS descendant fixture", + ) + .unwrap(); + assert!( + process + .wait_for_scope_quiescence( + Instant::now() + Duration::from_millis(100), + "process-scope macOS descendant fixture", + ) + .unwrap(), + "macOS owned process-group cleanup must terminate and reap normal descendants" + ); + } + + #[cfg(any(target_os = "linux", windows))] + #[test] + fn surviving_descendant_is_detected_and_terminated_as_owned_scope() { + let mut command = if cfg!(windows) { + let mut command = Command::new("powershell.exe"); + command.args([ + "-NoProfile", + "-NonInteractive", + "-Command", + "Start-Process -FilePath \"$env:SystemRoot\\System32\\ping.exe\" -ArgumentList @('-n','30','127.0.0.1') -WindowStyle Hidden; exit 0", + ]); + command + } else { + let mut command = Command::new("/bin/sh"); + command.args(["-c", "sleep 30 &"]); + command + }; + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + let mut process = + spawn_owned_process(&mut command, "process-scope descendant fixture").unwrap(); + assert!(wait_for_direct_exit( + &mut process, + Instant::now() + Duration::from_secs(5) + )); + assert!( + !process + .wait_for_scope_quiescence( + Instant::now() + Duration::from_millis(100), + "process-scope descendant fixture", + ) + .unwrap(), + "descendant fixture must keep the owned process scope live" + ); + process + .terminate_and_prove( + Instant::now() + Duration::from_secs(2), + "process-scope descendant fixture", + ) + .unwrap(); + #[cfg(unix)] + assert!( + process.process_group_id.is_none(), + "successful terminate-and-prove must disarm fallback PGID signaling" + ); + assert!( + process + .wait_for_scope_quiescence( + Instant::now() + Duration::from_millis(100), + "process-scope descendant fixture", + ) + .unwrap() + ); + } +} diff --git a/src/store.rs b/src/store.rs index d75975fa..51b77445 100644 --- a/src/store.rs +++ b/src/store.rs @@ -26,14 +26,14 @@ pub struct Store { #[derive(Debug, Clone, Copy)] pub(crate) enum TerminalFinalization { Exited { - ended_unix_ms: i64, + ended_unix_ms: Option, }, Interrupted { - ended_unix_ms: i64, + ended_unix_ms: Option, reason: TerminalCloseReason, }, OwnershipLost { - observed_unix_ms: i64, + observed_unix_ms: Option, }, } @@ -583,7 +583,7 @@ impl Store { observed_unix_ms: Option, ) -> Result<()> { let tx = self.connection.transaction()?; - let (status, requested_unix_ms, _started_unix_ms) = + let (status, requested_unix_ms, started_unix_ms) = shell_command_execution_state(&tx, execution_id)?; if !matches!( status, @@ -595,9 +595,13 @@ impl Store { ) .into()); } - if observed_unix_ms.is_some_and(|value| value < requested_unix_ms) { + let observation_floor = started_unix_ms + .unwrap_or(requested_unix_ms) + .max(requested_unix_ms); + if observed_unix_ms.is_some_and(|value| value < observation_floor) { return Err( - "shell-command ownership-loss observation cannot precede its request time".into(), + "shell-command ownership-loss observation cannot precede its observed start/request time" + .into(), ); } let updated = tx.execute( @@ -643,6 +647,11 @@ impl Store { ) .into()); } + if exit_code.is_none() && observed_end_unix_ms.is_none() { + return Err( + "shell-command exit observation requires an exit code or observed end time".into(), + ); + } validate_optional_command_times(requested_unix_ms, started_unix_ms, observed_end_unix_ms)?; let updated = tx.execute( "UPDATE shell_commands @@ -696,7 +705,9 @@ impl Store { ) .into()); } - if row.4.as_deref() != Some(FactSource::WindsObserved.as_str()) { + if row.4.as_deref() != Some(FactSource::WindsObserved.as_str()) + || (row.3.is_none() && row.5.is_none()) + { return Err( "shell-command completion requires a durable WINDS_OBSERVED exit fact".into(), ); @@ -737,6 +748,7 @@ impl Store { FROM executions e INNER JOIN shell_commands c ON c.execution_id = e.execution_id WHERE e.kind = ?1 AND e.status = ?2 AND c.exit_source = ?3 + AND (c.exit_code IS NOT NULL OR c.observed_end_unix_ms IS NOT NULL) ORDER BY e.requested_unix_ms, e.execution_id", )?; statement @@ -759,9 +771,9 @@ impl Store { pub fn reconcile_unowned_shell_commands_after_restart(&mut self, now_ms: i64) -> Result { self.finalize_observed_shell_commands()?; let tx = self.connection.transaction()?; - let execution_ids = { + let executions = { let mut statement = tx.prepare( - "SELECT e.execution_id + "SELECT e.execution_id, e.requested_unix_ms, e.started_unix_ms FROM executions e INNER JOIN shell_commands c ON c.execution_id = e.execution_id WHERE e.kind = ?1 AND e.status IN (?2, ?3) @@ -774,11 +786,17 @@ impl Store { ExecutionStatus::Requested.as_str(), ExecutionStatus::Running.as_str(), ], - |row| row.get::<_, String>(0), + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, Option>(2)?, + )) + }, )? .collect::>>()? }; - for execution_id in &execution_ids { + for (execution_id, requested_unix_ms, started_unix_ms) in &executions { let updated = tx.execute( "UPDATE executions SET status = ?2, status_source = ?3, @@ -798,16 +816,19 @@ impl Store { ) .into()); } + let observation_floor = started_unix_ms + .unwrap_or(*requested_unix_ms) + .max(*requested_unix_ms); insert_execution_event( &tx, execution_id, "ShellCommandOwnershipLostAfterRestart", FactSource::WindsObserved, - now_ms, + now_ms.max(observation_floor), )?; } tx.commit()?; - Ok(execution_ids.len()) + Ok(executions.len()) } pub fn mark_terminal_running(&mut self, execution_id: &str, now_ms: i64) -> Result<()> { @@ -896,7 +917,7 @@ impl Store { &mut self, execution_id: &str, started_unix_ms: i64, - ended_unix_ms: i64, + ended_unix_ms: Option, ) -> Result<()> { let tx = self.connection.transaction()?; let (status, requested_unix_ms, persisted_started_unix_ms) = @@ -908,10 +929,12 @@ impl Store { ) .into()); } - if started_unix_ms < requested_unix_ms || ended_unix_ms < started_unix_ms { + if started_unix_ms < requested_unix_ms + || ended_unix_ms.is_some_and(|value| value < started_unix_ms) + { return Err("terminal start-persistence recovery timestamps are inconsistent".into()); } - let duration_ms = ended_unix_ms - started_unix_ms; + let duration_ms = ended_unix_ms.map(|value| value - started_unix_ms); let updated = tx.execute( "UPDATE executions SET status = ?2, status_source = ?3, started_unix_ms = ?4, @@ -935,7 +958,7 @@ impl Store { execution_id, TerminalCloseReason::StartPersistenceFailed, )?; - insert_execution_event( + insert_execution_event_if_time( &tx, execution_id, "TerminalStartPersistenceFailed", @@ -946,7 +969,11 @@ impl Store { Ok(()) } - pub fn mark_terminal_exited(&mut self, execution_id: &str, ended_unix_ms: i64) -> Result<()> { + pub fn mark_terminal_exited( + &mut self, + execution_id: &str, + ended_unix_ms: Option, + ) -> Result<()> { finalize_running_terminal( &mut self.connection, execution_id, @@ -961,7 +988,7 @@ impl Store { &mut self, execution_id: &str, reason: TerminalCloseReason, - ended_unix_ms: i64, + ended_unix_ms: Option, ) -> Result<()> { if !matches!( reason, @@ -1058,10 +1085,10 @@ impl Store { &mut self, execution_id: &str, event_kind: &str, - observed_unix_ms: i64, + observed_unix_ms: Option, ) -> Result<()> { let tx = self.connection.transaction()?; - let (status, requested_unix_ms, _started_unix_ms) = + let (status, requested_unix_ms, started_unix_ms) = terminal_execution_state(&tx, execution_id)?; if !matches!( status, @@ -1073,9 +1100,13 @@ impl Store { ) .into()); } - if observed_unix_ms < requested_unix_ms { + let observation_floor = started_unix_ms + .unwrap_or(requested_unix_ms) + .max(requested_unix_ms); + if observed_unix_ms.is_some_and(|value| value < observation_floor) { return Err( - "terminal ownership-loss observation cannot precede its request time".into(), + "terminal ownership-loss observation cannot precede its observed start/request time" + .into(), ); } let updated = tx.execute( @@ -1101,7 +1132,7 @@ impl Store { execution_id, TerminalCloseReason::OwnershipLostProcessStateUnknown, )?; - insert_execution_event( + insert_execution_event_if_time( &tx, execution_id, event_kind, @@ -1120,7 +1151,7 @@ impl Store { let tx = self.connection.transaction()?; let executions = { let mut statement = tx.prepare( - "SELECT e.execution_id, t.execution_id + "SELECT e.execution_id, t.execution_id, e.requested_unix_ms, e.started_unix_ms FROM executions e LEFT JOIN terminal_sessions t ON t.execution_id = e.execution_id WHERE e.kind = ?1 AND e.status IN (?2, ?3) @@ -1133,12 +1164,19 @@ impl Store { ExecutionStatus::Requested.as_str(), ExecutionStatus::Running.as_str(), ], - |row| Ok((row.get::<_, String>(0)?, row.get::<_, Option>(1)?)), + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, Option>(3)?, + )) + }, )? .collect::>>()? }; - for (execution_id, terminal_session_id) in &executions { + for (execution_id, terminal_session_id, requested_unix_ms, started_unix_ms) in &executions { let updated = tx.execute( "UPDATE executions SET status = ?2, status_source = ?3, @@ -1165,12 +1203,15 @@ impl Store { TerminalCloseReason::OwnershipLostProcessStateUnknown, )?; } + let observation_floor = started_unix_ms + .unwrap_or(*requested_unix_ms) + .max(*requested_unix_ms); insert_execution_event( &tx, execution_id, "TerminalOwnershipLostAfterRestart", FactSource::WindsObserved, - now_ms, + now_ms.max(observation_floor), )?; } tx.commit()?; @@ -1276,6 +1317,23 @@ impl Store { } pub fn create_terminal_session(&self, session: NewTerminalSession<'_>) -> Result<()> { + let kind = self + .connection + .query_row( + "SELECT kind FROM executions WHERE execution_id = ?1", + params![session.execution_id], + |row| row.get::<_, String>(0), + ) + .optional()? + .ok_or_else(|| { + format!( + "unknown Winds execution for terminal session: {}", + session.execution_id + ) + })?; + if kind != ExecutionKind::Terminal.as_str() { + return Err("terminal session persistence requires TERMINAL execution kind".into()); + } let shell_arguments_json = serde_json::to_string(session.shell_arguments)?; self.connection.execute( "INSERT INTO terminal_sessions( @@ -1746,7 +1804,7 @@ fn finalize_running_terminal( status: ExecutionStatus, close_reason: TerminalCloseReason, event_kind: &str, - ended_unix_ms: i64, + ended_unix_ms: Option, ) -> Result<()> { if !matches!( status, @@ -1766,10 +1824,10 @@ fn finalize_running_terminal( } let started_unix_ms = started_unix_ms.ok_or("RUNNING terminal execution is missing its observed start time")?; - if ended_unix_ms < started_unix_ms { + if ended_unix_ms.is_some_and(|value| value < started_unix_ms) { return Err("terminal end time cannot precede its observed start time".into()); } - let duration_ms = ended_unix_ms - started_unix_ms; + let duration_ms = ended_unix_ms.map(|value| value - started_unix_ms); let updated = tx.execute( "UPDATE executions SET status = ?2, status_source = ?3, @@ -1788,7 +1846,7 @@ fn finalize_running_terminal( return Err("terminal finalization lost its expected RUNNING row".into()); } set_terminal_close_reason(&tx, execution_id, close_reason)?; - insert_execution_event( + insert_execution_event_if_time( &tx, execution_id, event_kind, @@ -2135,7 +2193,9 @@ mod persistence_tests { ) .unwrap(); store.mark_terminal_running("execution-1", 120).unwrap(); - store.mark_terminal_exited("execution-1", 155).unwrap(); + store + .mark_terminal_exited("execution-1", Some(155)) + .unwrap(); let execution = store.load_execution("execution-1").unwrap(); assert_eq!(execution.status, ExecutionStatus::Exited); @@ -2162,7 +2222,11 @@ mod persistence_tests { .iter() .all(|event| event.source == FactSource::WindsObserved) ); - assert!(store.mark_terminal_exited("execution-1", 160).is_err()); + assert!( + store + .mark_terminal_exited("execution-1", Some(160)) + .is_err() + ); drop(store); cleanup_test_home(&home); @@ -2209,7 +2273,11 @@ mod persistence_tests { store.mark_terminal_failed_to_start("failed", 115).unwrap(); store.mark_terminal_running("interrupted", 120).unwrap(); store - .mark_terminal_interrupted("interrupted", TerminalCloseReason::TerminatedByWinds, 150) + .mark_terminal_interrupted( + "interrupted", + TerminalCloseReason::TerminatedByWinds, + Some(150), + ) .unwrap(); let failed = store.load_execution("failed").unwrap(); @@ -2420,7 +2488,7 @@ mod persistence_tests { store.defer_terminal_finalization( "execution-deferred", TerminalFinalization::Interrupted { - ended_unix_ms: 150, + ended_unix_ms: Some(150), reason: TerminalCloseReason::ClosedByWinds, }, ); diff --git a/src/store_git_observation.rs b/src/store_git_observation.rs index 398d94c1..0eddd8d6 100644 --- a/src/store_git_observation.rs +++ b/src/store_git_observation.rs @@ -1,5 +1,5 @@ use super::{Result, Store}; -use crate::domain::{ExecutionKind, FactSource}; +use crate::domain::{ExecutionKind, ExecutionStatus, FactSource}; use crate::git::GIT_WORKTREE_STATE_FORMAT; use rusqlite::{OptionalExtension, params}; @@ -106,6 +106,29 @@ impl Store { ); } + let observed_unix_ms = match observation.boundary { + GitObservationBoundary::Before => observation.observed_unix_ms, + GitObservationBoundary::After => { + let before_time = tx + .query_row( + "SELECT observed_unix_ms + FROM execution_git_observations + WHERE execution_id = ?1 AND boundary = ?2", + params![ + observation.execution_id, + GitObservationBoundary::Before.as_str() + ], + |row| row.get::<_, Option>(0), + ) + .optional()? + .ok_or("AFTER Git observation requires a persisted BEFORE observation")?; + match (observation.observed_unix_ms, before_time) { + (Some(candidate), Some(before)) => Some(candidate.max(before)), + (candidate, _) => candidate, + } + } + }; + tx.execute( "INSERT INTO execution_git_observations( execution_id, boundary, availability, fact_source, @@ -123,7 +146,7 @@ impl Store { observation.dirty.map(bool_to_i64), worktree_state_format, observation.worktree_state_sha256, - observation.observed_unix_ms, + observed_unix_ms, ], )?; tx.commit()?; @@ -198,6 +221,56 @@ impl Store { } Ok(observations) } + + pub(crate) fn retry_deferred_terminal_finalizations_resilient(&mut self) -> Result { + let pending = std::mem::take(&mut self.deferred_terminal_finalizations); + let mut completed = 0_usize; + let mut retryable = Vec::new(); + let mut failures = Vec::new(); + for item in pending { + match self.load_execution(&item.execution_id) { + Ok(execution) + if !matches!( + execution.status, + ExecutionStatus::Requested | ExecutionStatus::Running + ) => + { + completed += 1; + continue; + } + Ok(_) => {} + Err(error) => { + failures.push(format!("{}: {error}", item.execution_id)); + retryable.push(item); + continue; + } + } + + match self.apply_terminal_finalization(&item.execution_id, item.finalization) { + Ok(()) => completed += 1, + Err(error) => { + failures.push(format!("{}: {error}", item.execution_id)); + retryable.push(item); + } + } + } + self.deferred_terminal_finalizations = retryable; + + // A failed retry must remain fail-closed for the affected historical + // execution: keep it queued and never fabricate a final state. It must + // not, however, prevent an unrelated new terminal session from + // starting. Report the residual explicitly while returning success for + // the retry sweep itself so one permanently unfinalizable row cannot + // poison every future terminal start. + if !failures.is_empty() { + eprintln!( + "warning: {} retryable deferred terminal finalization(s) remain pending: {}", + failures.len(), + failures.join("; ") + ); + } + Ok(completed) + } } fn validate_new_observation( @@ -233,7 +306,7 @@ fn validate_new_observation( let digest = observation .worktree_state_sha256 .ok_or("OBSERVED Git observation requires worktree-state digest")?; - validate_optional_nonempty(observation.head_oid, "Git HEAD object id")?; + validate_optional_git_oid(observation.head_oid, "Git HEAD object id")?; validate_optional_nonempty(observation.branch, "Git branch")?; if !is_lower_hex_sha256(digest) { return Err( @@ -288,6 +361,10 @@ fn validate_loaded_observation(record: &ExecutionGitObservationRecord) -> Result if !is_lower_hex_sha256(digest) { return Err("stored Git worktree-state digest is invalid".into()); } + // New writes require a full lowercase object id, but historical + // stores may contain a non-empty abbreviated or uppercase OID from + // pre-T068 builds. Keep the read path backward-compatible without + // weakening validation of newly persisted observations. validate_optional_nonempty(record.head_oid.as_deref(), "stored Git HEAD object id")?; validate_optional_nonempty(record.branch.as_deref(), "stored Git branch")?; if detached { @@ -309,6 +386,20 @@ fn validate_optional_nonempty(value: Option<&str>, label: &str) -> Result<()> { Ok(()) } +fn validate_optional_git_oid(value: Option<&str>, label: &str) -> Result<()> { + let Some(value) = value else { + return Ok(()); + }; + if !matches!(value.len(), 40 | 64) + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(format!("{label} must be a lowercase 40- or 64-hex Git object id").into()); + } + Ok(()) +} + fn bool_to_i64(value: bool) -> i64 { if value { 1 } else { 0 } } @@ -331,277 +422,154 @@ fn is_lower_hex_sha256(value: &str) -> bool { #[cfg(test)] mod tests { - use super::{GitObservationAvailability, GitObservationBoundary, NewExecutionGitObservation}; - use crate::domain::{ExecutionKind, FactSource}; - use crate::store::{NewExecution, NewShellCommand, NewWorkspace, Store}; - use rusqlite::Connection; + use super::*; + use crate::domain::{ExecutionKind, FactSource, TerminalCloseReason}; + use crate::store::{ + NewExecution, NewShellCommand, NewTerminalSession, NewWorkspace, TerminalFinalization, + }; use std::fs; use std::path::PathBuf; use std::sync::atomic::{AtomicU64, Ordering}; - static NEXT_HOME: AtomicU64 = AtomicU64::new(0); + static NEXT_ROOT: AtomicU64 = AtomicU64::new(0); - fn test_home(name: &str) -> PathBuf { - let sequence = NEXT_HOME.fetch_add(1, Ordering::Relaxed); - let home = std::env::temp_dir().join(format!( - "winds-t055-store-{name}-{}-{sequence}", + fn test_root(name: &str) -> PathBuf { + let sequence = NEXT_ROOT.fetch_add(1, Ordering::Relaxed); + let root = std::env::temp_dir().join(format!( + "winds-t068-store-git-{name}-{}-{sequence}", std::process::id() )); - fs::create_dir(&home).unwrap(); - home + fs::create_dir(&root).unwrap(); + root } - fn store_with_shell_command(name: &str) -> (PathBuf, Store) { - let home = test_home(name); - let mut store = Store::open(&home).unwrap(); + fn create_workspace(store: &Store) { store .create_workspace( NewWorkspace { workspace_id: "workspace-1", - canonical_worktree_root: "/tmp/example", - git_common_dir: "/tmp/example/.git", + canonical_worktree_root: "/tmp/winds-workspace", + git_common_dir: "/tmp/winds-git-common", }, - 10, + 100, ) .unwrap(); - let arguments = vec!["status".to_owned()]; + } + + #[test] + fn historical_abbreviated_or_uppercase_git_oid_remains_readable() { + let root = test_root("legacy-oid"); + let mut store = Store::open(&root).unwrap(); + create_workspace(&store); + let arguments = Vec::new(); store .create_shell_command_execution( NewExecution { - execution_id: "command-1", + execution_id: "legacy-shell", workspace_id: "workspace-1", kind: ExecutionKind::ShellCommand, request_source: FactSource::CallerRequested, - execution_domain: "native-test", + execution_domain: "host-test", }, NewShellCommand { - execution_id: "command-1", - executable: "/usr/bin/git", + execution_id: "legacy-shell", + executable: "git", arguments: &arguments, command_source: FactSource::CallerRequested, - requested_cwd: "/tmp/example", + requested_cwd: "/tmp/winds-workspace", cwd_source: FactSource::CallerRequested, }, - 20, + 110, ) .unwrap(); - (home, store) - } - #[test] - fn observed_and_unavailable_git_states_round_trip_without_candidate_evidence() { - let (home, mut store) = store_with_shell_command("round-trip"); - let digest = "0".repeat(64); store - .record_execution_git_observation(NewExecutionGitObservation { - execution_id: "command-1", - boundary: GitObservationBoundary::Before, - availability: GitObservationAvailability::Observed, - head_oid: Some("abc123"), - branch: Some("main"), - detached: Some(false), - dirty: Some(true), - worktree_state_sha256: Some(&digest), - observed_unix_ms: Some(21), - }) - .unwrap(); - store - .record_execution_git_observation(NewExecutionGitObservation { - execution_id: "command-1", - boundary: GitObservationBoundary::After, - availability: GitObservationAvailability::Unavailable, - head_oid: None, - branch: None, - detached: None, - dirty: None, - worktree_state_sha256: None, - observed_unix_ms: Some(22), - }) - .unwrap(); - - let observations = store.load_execution_git_observations("command-1").unwrap(); - assert_eq!(observations.len(), 2); - assert_eq!(observations[0].boundary, GitObservationBoundary::Before); - assert_eq!( - observations[0].availability, - GitObservationAvailability::Observed - ); - assert_eq!(observations[0].source, FactSource::WindsObserved); - assert_eq!(observations[0].head_oid.as_deref(), Some("abc123")); - assert_eq!(observations[0].branch.as_deref(), Some("main")); - assert_eq!(observations[0].detached, Some(false)); - assert_eq!(observations[0].dirty, Some(true)); - assert_eq!( - observations[0].worktree_state_sha256.as_deref(), - Some(digest.as_str()) - ); - assert_eq!(observations[1].boundary, GitObservationBoundary::After); - assert_eq!( - observations[1].availability, - GitObservationAvailability::Unavailable - ); - assert_eq!(observations[1].head_oid, None); - assert_eq!(observations[1].dirty, None); - - let candidate_events: i64 = store - .connection - .query_row("SELECT COUNT(*) FROM events", [], |row| row.get(0)) - .unwrap(); - let evidence_reports: i64 = store .connection - .query_row("SELECT COUNT(*) FROM evidence_reports", [], |row| { - row.get(0) - }) + .execute( + "INSERT INTO execution_git_observations( + execution_id, boundary, availability, fact_source, + head_oid, branch, detached, dirty, + worktree_state_format, worktree_state_sha256, observed_unix_ms + ) VALUES (?1, 'BEFORE', 'OBSERVED', 'WINDS_OBSERVED', ?2, 'main', 0, 0, ?3, ?4, 120)", + params![ + "legacy-shell", + "ABC1234", + GIT_WORKTREE_STATE_FORMAT, + "0000000000000000000000000000000000000000000000000000000000000000" + ], + ) .unwrap(); - assert_eq!(candidate_events, 0); - assert_eq!(evidence_reports, 0); - drop(store); - fs::remove_dir_all(home).unwrap(); - } - - #[test] - fn duplicate_boundary_is_rejected() { - let (home, mut store) = store_with_shell_command("duplicate"); - store - .record_execution_git_observation(NewExecutionGitObservation { - execution_id: "command-1", - boundary: GitObservationBoundary::Before, - availability: GitObservationAvailability::Unavailable, - head_oid: None, - branch: None, - detached: None, - dirty: None, - worktree_state_sha256: None, - observed_unix_ms: Some(21), - }) + let observations = store + .load_execution_git_observations("legacy-shell") .unwrap(); - let duplicate = store.record_execution_git_observation(NewExecutionGitObservation { - execution_id: "command-1", - boundary: GitObservationBoundary::Before, - availability: GitObservationAvailability::Unavailable, - head_oid: None, - branch: None, - detached: None, - dirty: None, - worktree_state_sha256: None, - observed_unix_ms: Some(22), - }); - assert!(duplicate.is_err()); - assert_eq!( - store - .load_execution_git_observations("command-1") - .unwrap() - .len(), - 1 - ); - drop(store); - fs::remove_dir_all(home).unwrap(); - } - - #[test] - fn unavailable_observation_rejects_fabricated_state() { - let (home, mut store) = store_with_shell_command("unavailable-state"); - let result = store.record_execution_git_observation(NewExecutionGitObservation { - execution_id: "command-1", - boundary: GitObservationBoundary::Before, - availability: GitObservationAvailability::Unavailable, - head_oid: None, - branch: None, - detached: None, - dirty: Some(false), - worktree_state_sha256: None, - observed_unix_ms: Some(21), - }); - assert!(result.is_err()); - assert!( - store - .load_execution_git_observations("command-1") - .unwrap() - .is_empty() - ); - drop(store); - fs::remove_dir_all(home).unwrap(); - } - - #[test] - fn observed_state_rejects_invalid_digest_and_detached_branch() { - let (home, mut store) = store_with_shell_command("invalid-observed"); - let invalid_digest = store.record_execution_git_observation(NewExecutionGitObservation { - execution_id: "command-1", - boundary: GitObservationBoundary::Before, - availability: GitObservationAvailability::Observed, - head_oid: Some("abc123"), - branch: Some("main"), - detached: Some(false), - dirty: Some(false), - worktree_state_sha256: Some("not-a-digest"), - observed_unix_ms: Some(21), - }); - assert!(invalid_digest.is_err()); - - let digest = "0".repeat(64); - let detached_branch = store.record_execution_git_observation(NewExecutionGitObservation { - execution_id: "command-1", - boundary: GitObservationBoundary::Before, - availability: GitObservationAvailability::Observed, - head_oid: Some("abc123"), - branch: Some("main"), - detached: Some(true), - dirty: Some(false), - worktree_state_sha256: Some(&digest), - observed_unix_ms: Some(21), - }); - assert!(detached_branch.is_err()); + assert_eq!(observations.len(), 1); + assert_eq!(observations[0].head_oid.as_deref(), Some("ABC1234")); drop(store); - fs::remove_dir_all(home).unwrap(); + fs::remove_dir_all(root).unwrap(); } #[test] - fn store_open_upgrades_a_0004_database_with_the_forward_only_git_observation_table() { - let home = test_home("migration"); - let connection = Connection::open(home.join("winds.db")).unwrap(); - connection - .execute_batch(include_str!("../migrations/0001_init.sql")) - .unwrap(); - connection - .execute_batch(include_str!( - "../migrations/0002_workspace_execution_ledger.sql" - )) - .unwrap(); - connection - .execute_batch(include_str!( - "../migrations/0003_workspace_clone_origins.sql" - )) - .unwrap(); - connection - .execute_batch(include_str!("../migrations/0004_shell_commands.sql")) - .unwrap(); - let before: i64 = connection - .query_row( - "SELECT COUNT(*) FROM sqlite_master - WHERE type = 'table' AND name = 'execution_git_observations'", - [], - |row| row.get(0), + fn failed_deferred_finalization_stays_pending_without_poisoning_retry_sweep() { + let root = test_root("deferred-finalization"); + let mut store = Store::open(&root).unwrap(); + create_workspace(&store); + let shell_arguments = Vec::new(); + store + .create_terminal_execution( + NewExecution { + execution_id: "terminal-stuck", + workspace_id: "workspace-1", + kind: ExecutionKind::Terminal, + request_source: FactSource::CallerRequested, + execution_domain: "host-test", + }, + NewTerminalSession { + execution_id: "terminal-stuck", + profile_id: "profile-1", + shell_executable: "/bin/sh", + shell_arguments: &shell_arguments, + requested_cwd: "/tmp/winds-workspace", + initial_cols: Some(80), + initial_rows: Some(24), + }, + 110, ) .unwrap(); - assert_eq!(before, 0); - drop(connection); - - let store = Store::open(&home).unwrap(); - let after: i64 = store + store.mark_terminal_running("terminal-stuck", 120).unwrap(); + store.defer_terminal_finalization( + "terminal-stuck", + TerminalFinalization::Interrupted { + ended_unix_ms: Some(150), + reason: TerminalCloseReason::ClosedByWinds, + }, + ); + store .connection - .query_row( - "SELECT COUNT(*) FROM sqlite_master - WHERE type = 'table' AND name = 'execution_git_observations'", - [], - |row| row.get(0), + .execute_batch( + "CREATE TRIGGER fail_terminal_stuck_update + BEFORE UPDATE ON executions + WHEN OLD.execution_id = 'terminal-stuck' + BEGIN + SELECT RAISE(ABORT, 'forced deferred finalization failure'); + END;", ) .unwrap(); - assert_eq!(after, 1); + + assert_eq!( + store + .retry_deferred_terminal_finalizations_resilient() + .unwrap(), + 0 + ); + assert_eq!(store.deferred_terminal_finalizations.len(), 1); + assert_eq!( + store.load_execution("terminal-stuck").unwrap().status, + ExecutionStatus::Running + ); drop(store); - fs::remove_dir_all(home).unwrap(); + fs::remove_dir_all(root).unwrap(); } } diff --git a/src/t059_negative_tests.rs b/src/t059_negative_tests.rs index 62072435..4a017646 100644 --- a/src/t059_negative_tests.rs +++ b/src/t059_negative_tests.rs @@ -241,7 +241,7 @@ fn t059_clone_failure_never_registers_a_workspace() { clone_and_register_workspace(not_a_repo.to_str().unwrap(), &destination, &state_root, 30) .unwrap_err(); assert!(error.to_string().contains("system Git clone failed")); - assert!(destination.is_dir()); + assert!(!destination.exists()); assert!(!state_root.join("winds.db").exists()); } diff --git a/src/t068_store_regression_tests.rs b/src/t068_store_regression_tests.rs new file mode 100644 index 00000000..80f85874 --- /dev/null +++ b/src/t068_store_regression_tests.rs @@ -0,0 +1,366 @@ +use crate::domain::{ExecutionKind, ExecutionStatus, FactSource, TerminalCloseReason}; +use crate::store::git_observation::{ + GitObservationAvailability, GitObservationBoundary, NewExecutionGitObservation, +}; +use crate::store::{ + NewExecution, NewShellCommand, NewTerminalSession, NewWorkspace, Store, TerminalFinalization, +}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +static NEXT_HOME: AtomicU64 = AtomicU64::new(0); + +struct TestHome(PathBuf); + +impl TestHome { + fn new(name: &str) -> Self { + let sequence = NEXT_HOME.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "winds-t068-store-{name}-{}-{sequence}", + std::process::id() + )); + fs::create_dir(&path).unwrap(); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for TestHome { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn store_with_workspace(home: &TestHome) -> Store { + let store = Store::open(home.path()).unwrap(); + store + .create_workspace( + NewWorkspace { + workspace_id: "workspace-1", + canonical_worktree_root: "/tmp/t068-workspace", + git_common_dir: "/tmp/t068-workspace/.git", + }, + 90, + ) + .unwrap(); + store +} + +fn create_shell_command(store: &mut Store, execution_id: &str, requested_unix_ms: i64) { + let arguments = Vec::new(); + store + .create_shell_command_execution( + NewExecution { + execution_id, + workspace_id: "workspace-1", + kind: ExecutionKind::ShellCommand, + request_source: FactSource::CallerRequested, + execution_domain: "host-test", + }, + NewShellCommand { + execution_id, + executable: "test-shell", + arguments: &arguments, + command_source: FactSource::CallerRequested, + requested_cwd: "/tmp/t068-workspace", + cwd_source: FactSource::CallerRequested, + }, + requested_unix_ms, + ) + .unwrap(); +} + +#[test] +fn shell_command_exit_requires_a_durable_observed_fact() { + let home = TestHome::new("exit-fact"); + let mut store = store_with_workspace(&home); + create_shell_command(&mut store, "command-1", 100); + store + .mark_shell_command_running("command-1", Some(110)) + .unwrap(); + + let error = store + .record_shell_command_exit_observation("command-1", None, None) + .unwrap_err(); + assert!( + error + .to_string() + .contains("requires an exit code or observed end time") + ); + assert_eq!( + store.load_execution("command-1").unwrap().status, + ExecutionStatus::Running + ); + assert_eq!( + store.load_shell_command("command-1").unwrap().exit_source, + None + ); + assert!( + store + .finalize_shell_command_from_observation("command-1") + .is_err() + ); + + store + .record_shell_command_exit_observation("command-1", Some(0), None) + .unwrap(); + store + .finalize_shell_command_from_observation("command-1") + .unwrap(); + let execution = store.load_execution("command-1").unwrap(); + assert_eq!(execution.status, ExecutionStatus::Exited); + assert_eq!(execution.ended_unix_ms, None); + assert_eq!(execution.duration_ms, None); +} + +#[test] +fn restart_reconciliation_never_records_events_before_observed_start_time() { + let home = TestHome::new("restart-clock"); + let mut store = store_with_workspace(&home); + create_shell_command(&mut store, "command-1", 100); + store + .mark_shell_command_running("command-1", Some(130)) + .unwrap(); + + let shell_arguments = Vec::new(); + store + .create_terminal_execution( + NewExecution { + execution_id: "terminal-1", + workspace_id: "workspace-1", + kind: ExecutionKind::Terminal, + request_source: FactSource::CallerRequested, + execution_domain: "host-test", + }, + NewTerminalSession { + execution_id: "terminal-1", + profile_id: "profile-1", + shell_executable: "/bin/sh", + shell_arguments: &shell_arguments, + requested_cwd: "/tmp/t068-workspace", + initial_cols: Some(80), + initial_rows: Some(24), + }, + 110, + ) + .unwrap(); + store.mark_terminal_running("terminal-1", 140).unwrap(); + + assert!( + store + .mark_shell_command_ownership_lost("command-1", Some(120)) + .is_err() + ); + + assert_eq!( + store + .reconcile_unowned_shell_commands_after_restart(50) + .unwrap(), + 1 + ); + assert_eq!( + store + .reconcile_unowned_terminal_sessions_after_restart(50) + .unwrap(), + 1 + ); + + let shell_event = store + .execution_events("command-1") + .unwrap() + .into_iter() + .find(|event| event.kind == "ShellCommandOwnershipLostAfterRestart") + .unwrap(); + assert_eq!(shell_event.created_unix_ms, 130); + + let terminal_event = store + .execution_events("terminal-1") + .unwrap() + .into_iter() + .find(|event| event.kind == "TerminalOwnershipLostAfterRestart") + .unwrap(); + assert_eq!(terminal_event.created_unix_ms, 140); +} + +#[test] +fn git_after_boundary_time_cannot_regress_behind_before_boundary() { + let home = TestHome::new("git-boundary-clock"); + let mut store = store_with_workspace(&home); + create_shell_command(&mut store, "command-1", 100); + let head_oid = "0123456789abcdef0123456789abcdef01234567"; + let digest = "0000000000000000000000000000000000000000000000000000000000000000"; + + store + .record_execution_git_observation(NewExecutionGitObservation { + execution_id: "command-1", + boundary: GitObservationBoundary::Before, + availability: GitObservationAvailability::Observed, + head_oid: Some(head_oid), + branch: Some("main"), + detached: Some(false), + dirty: Some(false), + worktree_state_sha256: Some(digest), + observed_unix_ms: Some(200), + }) + .unwrap(); + store + .record_execution_git_observation(NewExecutionGitObservation { + execution_id: "command-1", + boundary: GitObservationBoundary::After, + availability: GitObservationAvailability::Observed, + head_oid: Some(head_oid), + branch: Some("main"), + detached: Some(false), + dirty: Some(false), + worktree_state_sha256: Some(digest), + observed_unix_ms: Some(150), + }) + .unwrap(); + + let observations = store.load_execution_git_observations("command-1").unwrap(); + assert_eq!(observations.len(), 2); + assert_eq!(observations[0].observed_unix_ms, Some(200)); + assert_eq!(observations[1].observed_unix_ms, Some(200)); +} + +#[test] +fn terminal_session_child_requires_terminal_execution_kind() { + let home = TestHome::new("terminal-kind"); + let mut store = store_with_workspace(&home); + create_shell_command(&mut store, "command-1", 100); + + let shell_arguments = Vec::new(); + let error = store + .create_terminal_session(NewTerminalSession { + execution_id: "command-1", + profile_id: "profile-1", + shell_executable: "/bin/sh", + shell_arguments: &shell_arguments, + requested_cwd: "/tmp/t068-workspace", + initial_cols: Some(80), + initial_rows: Some(24), + }) + .unwrap_err(); + assert!( + error + .to_string() + .contains("requires TERMINAL execution kind") + ); + assert!(store.load_terminal_session("command-1").is_err()); +} + +#[test] +fn restart_reconciliation_recovers_legacy_empty_observed_exit_without_poisoning_valid_exit() { + let home = TestHome::new("legacy-empty-exit"); + let mut store = store_with_workspace(&home); + create_shell_command(&mut store, "legacy-command", 100); + create_shell_command(&mut store, "valid-command", 101); + store + .mark_shell_command_running("legacy-command", Some(110)) + .unwrap(); + store + .mark_shell_command_running("valid-command", Some(111)) + .unwrap(); + store + .record_shell_command_exit_observation("valid-command", Some(0), Some(150)) + .unwrap(); + + let legacy_connection = rusqlite::Connection::open(home.path().join("winds.db")).unwrap(); + legacy_connection + .execute( + "UPDATE shell_commands + SET exit_code = NULL, exit_source = 'WINDS_OBSERVED', observed_end_unix_ms = NULL + WHERE execution_id = ?1", + ["legacy-command"], + ) + .unwrap(); + drop(legacy_connection); + + assert_eq!( + store + .reconcile_unowned_shell_commands_after_restart(200) + .unwrap(), + 1 + ); + + let legacy = store.load_execution("legacy-command").unwrap(); + assert_eq!(legacy.status, ExecutionStatus::OwnershipLost); + assert_eq!(legacy.ended_unix_ms, None); + assert_eq!(legacy.duration_ms, None); + let legacy_events = store.execution_events("legacy-command").unwrap(); + assert!( + legacy_events + .iter() + .any(|event| { event.kind == "ShellCommandOwnershipLostAfterRestart" }) + ); + assert!( + legacy_events + .iter() + .all(|event| event.kind != "ShellCommandExited") + ); + + let valid = store.load_execution("valid-command").unwrap(); + assert_eq!(valid.status, ExecutionStatus::Exited); + assert_eq!(valid.ended_unix_ms, Some(150)); + assert_eq!(valid.duration_ms, Some(39)); +} + +#[test] +fn terminal_finalization_can_preserve_unknown_end_time_without_fabrication() { + let home = TestHome::new("terminal-unknown-end"); + let mut store = store_with_workspace(&home); + let shell_arguments = Vec::new(); + store + .create_terminal_execution( + NewExecution { + execution_id: "terminal-unknown-end", + workspace_id: "workspace-1", + kind: ExecutionKind::Terminal, + request_source: FactSource::CallerRequested, + execution_domain: "host-test", + }, + NewTerminalSession { + execution_id: "terminal-unknown-end", + profile_id: "profile-1", + shell_executable: "test-shell", + shell_arguments: &shell_arguments, + requested_cwd: "/tmp/t068-workspace", + initial_cols: Some(80), + initial_rows: Some(24), + }, + 100, + ) + .unwrap(); + store + .mark_terminal_running("terminal-unknown-end", 110) + .unwrap(); + store + .apply_terminal_finalization( + "terminal-unknown-end", + TerminalFinalization::Exited { + ended_unix_ms: None, + }, + ) + .unwrap(); + + let execution = store.load_execution("terminal-unknown-end").unwrap(); + assert_eq!(execution.status, ExecutionStatus::Exited); + assert_eq!(execution.ended_unix_ms, None); + assert_eq!(execution.duration_ms, None); + let terminal = store.load_terminal_session("terminal-unknown-end").unwrap(); + assert_eq!( + terminal.close_reason, + Some(TerminalCloseReason::ProcessExited) + ); + assert!( + store + .execution_events("terminal-unknown-end") + .unwrap() + .iter() + .all(|event| event.kind != "TerminalExited") + ); +} diff --git a/src/terminal.rs b/src/terminal.rs index cc01ad74..b261c441 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -106,6 +106,7 @@ impl TerminalSession { size: TerminalSize, ) -> Result { let start_cwd = canonical_start_cwd(cwd)?; + let spawn_cwd = terminal_spawn_cwd(&start_cwd)?; let pty_size = size.to_pty_size()?; let session_id = next_session_id()?; @@ -116,7 +117,7 @@ impl TerminalSession { let mut command = CommandBuilder::new(executable.as_os_str()); command.args(arguments); - command.cwd(start_cwd.as_os_str()); + command.cwd(spawn_cwd.as_os_str()); let child = pair.slave.spawn_command(command)?; drop(pair.slave); @@ -267,22 +268,14 @@ impl TerminalSession { } pub fn terminate(&mut self) -> Result { - if let Some(exit) = self.try_wait()? { - return Ok(exit); - } - - let kill_result = self - .child - .as_mut() - .ok_or("terminal session lost its owned child handle")? - .kill(); - if let Err(kill_error) = kill_result { - if let Some(exit) = self.try_wait()? { - return Ok(exit); - } - return Err(format!("failed to terminate owned terminal child: {kill_error}").into()); + match self.cleanup_for_drop(Duration::from_millis(500))? { + TerminalDropCleanupOutcome::ExitedBeforeCleanup(exit) + | TerminalDropCleanupOutcome::Terminated(exit) => Ok(exit), + TerminalDropCleanupOutcome::Unproven => Err( + "terminal terminate could not prove owned child exit inside bounded cleanup window" + .into(), + ), } - self.wait() } pub fn close(&mut self) -> Result { @@ -310,36 +303,48 @@ impl TerminalSession { return Ok(TerminalDropCleanupOutcome::Unproven); } self.drop_cleanup_attempted = true; - self.writer.take(); - if let Some(exit) = self.try_wait()? { - return Ok(TerminalDropCleanupOutcome::ExitedBeforeCleanup(exit)); - } - let kill_result = self - .child - .as_mut() - .ok_or("terminal session lost its owned child handle")? - .kill(); - if let Err(kill_error) = kill_result { + let result = (|| { + self.writer.take(); if let Some(exit) = self.try_wait()? { return Ok(TerminalDropCleanupOutcome::ExitedBeforeCleanup(exit)); } - return Err(format!( - "failed to request bounded cleanup of owned terminal child: {kill_error}" - ) - .into()); - } - let started = Instant::now(); - loop { - if let Some(exit) = self.try_wait()? { - return Ok(TerminalDropCleanupOutcome::Terminated(exit)); + let kill_result = self + .child + .as_mut() + .ok_or("terminal session lost its owned child handle")? + .kill(); + if let Err(kill_error) = kill_result { + if let Some(exit) = self.try_wait()? { + return Ok(TerminalDropCleanupOutcome::ExitedBeforeCleanup(exit)); + } + return Err(format!( + "failed to request bounded cleanup of owned terminal child: {kill_error}" + ) + .into()); } - if started.elapsed() >= timeout { - return Ok(TerminalDropCleanupOutcome::Unproven); + + let started = Instant::now(); + loop { + if let Some(exit) = self.try_wait()? { + return Ok(TerminalDropCleanupOutcome::Terminated(exit)); + } + if started.elapsed() >= timeout { + return Ok(TerminalDropCleanupOutcome::Unproven); + } + std::thread::sleep(Duration::from_millis(10)); } - std::thread::sleep(Duration::from_millis(10)); + })(); + + if matches!(result, Err(_) | Ok(TerminalDropCleanupOutcome::Unproven)) { + self.drop_cleanup_attempted = false; } + result + } + + pub(crate) fn suppress_drop_cleanup_after_ownership_loss(&mut self) { + self.drop_cleanup_attempted = true; } fn require_active(&mut self) -> Result<()> { @@ -384,6 +389,44 @@ fn canonical_start_cwd(cwd: &Path) -> Result { Ok(canonical) } +#[cfg(not(windows))] +fn terminal_spawn_cwd(canonical_cwd: &Path) -> Result { + Ok(canonical_cwd.to_path_buf()) +} + +#[cfg(windows)] +fn terminal_spawn_cwd(canonical_cwd: &Path) -> Result { + let value = canonical_cwd + .to_str() + .ok_or("native Windows terminal cwd is not valid UTF-8")?; + if value.starts_with(r"\\?\UNC\") { + return Err( + "native Windows terminal cwd cannot use a UNC path in Spec 003 T051; refusing to let the shell silently fall back to another directory" + .into(), + ); + } + if let Some(rest) = value.strip_prefix(r"\\?\") { + let bytes = rest.as_bytes(); + let ordinary_drive_path = bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && matches!(bytes[2], b'\\' | b'/'); + if !ordinary_drive_path { + return Err( + "native Windows terminal cwd cannot be represented safely for the PTY child".into(), + ); + } + return Ok(PathBuf::from(rest)); + } + if value.starts_with(r"\\") { + return Err( + "native Windows terminal cwd cannot use a UNC path in Spec 003 T051; refusing to let the shell silently fall back to another directory" + .into(), + ); + } + Ok(canonical_cwd.to_path_buf()) +} + fn next_session_id() -> Result { let previous = NEXT_SESSION_ID .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| { diff --git a/src/terminal_windows_tests.rs b/src/terminal_windows_tests.rs index cb6c38c4..fe80c320 100644 --- a/src/terminal_windows_tests.rs +++ b/src/terminal_windows_tests.rs @@ -1,7 +1,10 @@ -use super::{TerminalSession, TerminalSize}; +use super::{TerminalSession, TerminalSize, terminal_spawn_cwd}; use crate::git::shell_profiles::{ShellProfile, discover_native_shell_profiles}; use crate::git::workspace_inventory::WorkspaceEnvironmentInventory; -use crate::git::wsl_launch::{WslCwdResolution, launch_wsl_terminal, prepare_wsl_terminal_launch}; +use crate::git::wsl_launch::{ + WslCwdResolution, launch_wsl_terminal, prepare_wsl_terminal_launch, + prove_wsl_exec_scope_cleanup_for_test, +}; use std::fs; use std::io::Read; use std::path::{Path, PathBuf}; @@ -151,6 +154,13 @@ fn output_contains_exact_marker(output: &[u8], marker: &str) -> bool { output.windows(marker.len()).any(|window| window == marker) } +fn output_contains_exact_marker_ignore_ascii_case(output: &[u8], marker: &str) -> bool { + let marker = marker.as_bytes(); + output + .windows(marker.len()) + .any(|window| window.eq_ignore_ascii_case(marker)) +} + fn default_size() -> TerminalSize { TerminalSize { rows: 24, cols: 80 } } @@ -159,6 +169,11 @@ fn default_size() -> TerminalSize { fn conpty_streams_input_output_from_exact_start_cwd_and_observes_exit() { let root = TestRoot::new("stream"); let canonical_root = root.path().canonicalize().unwrap(); + let spawn_cwd = terminal_spawn_cwd(&canonical_root).unwrap(); + let expected_cwd_marker = format!( + "WINDS_CWD_BEGIN:{}:WINDS_CWD_END", + spawn_cwd.to_string_lossy() + ); let profile = native_cmd_profile(); let mut session = TerminalSession::start(&profile, root.path(), default_size()).unwrap(); let session_id = session.session_id(); @@ -169,14 +184,15 @@ fn conpty_streams_input_output_from_exact_start_cwd_and_observes_exit() { assert!(session.take_output_reader().is_err()); complete_headless_terminal_startup(&mut session, &output); session - .send_input(b"cd\r\necho WINDS_READY\r\nexit\r\n") + .send_input( + b"set \"WINDS_CWD_PREFIX=WINDS_CWD_BEGIN:\"\r\nset \"WINDS_CWD_SUFFIX=:WINDS_CWD_END\"\r\necho %WINDS_CWD_PREFIX%%CD%%WINDS_CWD_SUFFIX%\r\nset \"WINDS_TEST_PREFIX=WINDS_\"\r\necho %WINDS_TEST_PREFIX%READY\r\nexit\r\n", + ) .unwrap(); let observed = wait_for_output(&output, b"WINDS_READY"); - let cwd = canonical_root.to_string_lossy(); assert!( - observed - .windows(cwd.len()) - .any(|window| window.eq_ignore_ascii_case(cwd.as_bytes())) + output_contains_exact_marker_ignore_ascii_case(&observed, &expected_cwd_marker), + "ConPTY shell did not emit the exact effective start-cwd marker; expected {expected_cwd_marker:?}, observed {:?}", + String::from_utf8_lossy(&observed) ); let exit = session.wait().unwrap(); @@ -208,7 +224,9 @@ fn conpty_interrupt_fails_closed_without_corrupting_the_session() { let output = start_output_reader(session.take_output_reader().unwrap()); complete_headless_terminal_startup(&mut session, &output); - session.send_input(b"echo WINDS_READY\r\n").unwrap(); + session + .send_input(b"set \"WINDS_TEST_PREFIX=WINDS_\"\r\necho %WINDS_TEST_PREFIX%READY\r\n") + .unwrap(); wait_for_output(&output, b"WINDS_READY"); let error = session.interrupt().unwrap_err(); @@ -218,7 +236,9 @@ fn conpty_interrupt_fails_closed_without_corrupting_the_session() { .contains("interrupt is unsupported on native Windows") ); - session.send_input(b"echo WINDS_AFTER\r\nexit\r\n").unwrap(); + session + .send_input(b"echo %WINDS_TEST_PREFIX%AFTER\r\nexit\r\n") + .unwrap(); wait_for_output(&output, b"WINDS_AFTER"); let exit = session.wait().unwrap(); assert_eq!(exit.exit_code, 0); @@ -233,7 +253,9 @@ fn conpty_terminate_reaps_the_exact_owned_child() { complete_headless_terminal_startup(&mut session, &output); session - .send_input(b"echo WINDS_READY\r\nset /p WINDS_BLOCK=\r\n") + .send_input( + b"set \"WINDS_TEST_PREFIX=WINDS_\"\r\necho %WINDS_TEST_PREFIX%READY\r\nset /p WINDS_BLOCK=\r\n", + ) .unwrap(); wait_for_output(&output, b"WINDS_READY"); let exit = session.terminate().unwrap(); @@ -255,6 +277,11 @@ fn t062_real_wsl_backend_launch_is_opt_in_and_uses_production_path() { .canonicalize() .expect("T062 backend proof repository must canonicalize"); + if expected == "MAPPED" { + prove_wsl_exec_scope_cleanup_for_test(&distro) + .expect("real WSL2 attestation helper must prove descendant and timeout cleanup"); + } + let plan = prepare_wsl_terminal_launch(&repo, &distro) .expect("production WSL launch preparation must succeed on the provisioned distribution"); let (expected_linux_cwd, expected_git_head) = match (expected.as_str(), &plan.cwd_resolution) { diff --git a/src/workspace.rs b/src/workspace.rs index be815a68..553ed7cb 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -1,4 +1,7 @@ -use super::{Repo, Result, git_command, run_git_text, strip_git_line_ending}; +use super::{ + Repo, Result, git_command, run_bounded_read_only_git, run_read_only_git_output, + run_read_only_git_text, strip_git_line_ending, +}; use crate::store::{NewWorkspace, Store}; use serde::Serialize; use sha2::{Digest, Sha256}; @@ -70,7 +73,7 @@ fn open_worktree(path: &Path) -> Result { fn inspect_worktree(repo: &Repo) -> Result { let canonical_worktree_root = utf8_path(repo.root(), "canonical worktree root")?.to_owned(); - let git_common_dir = utf8_path(&repo.common_dir, "Git common directory")?.to_owned(); + let git_common_dir = utf8_path(repo.common_dir(), "Git common directory")?.to_owned(); let branch = branch_name(repo)?; let head_oid = exact_head(repo, branch.as_deref())?; let detached = branch.is_none(); @@ -89,9 +92,11 @@ fn inspect_worktree(repo: &Repo) -> Result { } fn exact_head(repo: &Repo, branch: Option<&str>) -> Result> { - let output = git_command(repo.root()) - .args(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]) - .output()?; + let output = run_read_only_git_output( + repo.root(), + ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], + "workspace HEAD resolution", + )?; if output.status.success() { let head = String::from_utf8(output.stdout)?; let head = strip_git_line_ending(&head); @@ -112,10 +117,12 @@ fn exact_head(repo: &Repo, branch: Option<&str>) -> Result> { return Err("detached workspace HEAD does not resolve to a commit".into()); }; let full_ref = format!("refs/heads/{branch}"); - let ref_status = git_command(repo.root()) - .args(["show-ref", "--verify", "--quiet", full_ref.as_str()]) - .status()?; - match ref_status.code() { + let ref_status = run_read_only_git_output( + repo.root(), + ["show-ref", "--verify", "--quiet", full_ref.as_str()], + "workspace HEAD branch verification", + )?; + match ref_status.status.code() { Some(1) => Ok(None), Some(0) => Err(format!( "workspace HEAD branch exists but does not resolve to a commit: {full_ref}" @@ -126,9 +133,11 @@ fn exact_head(repo: &Repo, branch: Option<&str>) -> Result> { } fn branch_name(repo: &Repo) -> Result> { - let output = git_command(repo.root()) - .args(["symbolic-ref", "--quiet", "--short", "HEAD"]) - .output()?; + let output = run_read_only_git_output( + repo.root(), + ["symbolic-ref", "--quiet", "--short", "HEAD"], + "workspace branch-state inspection", + )?; match output.status.code() { Some(0) => { let branch = String::from_utf8(output.stdout)?; @@ -148,24 +157,15 @@ fn branch_name(repo: &Repo) -> Result> { } fn read_only_status(repo: &Repo) -> Result> { - let output = git_command(repo.root()) - .env("GIT_OPTIONAL_LOCKS", "0") - .args([ - "status", - "--porcelain=v1", - "-z", - "--untracked-files=all", - "--ignore-submodules=none", - ]) - .output()?; - if output.status.success() { - return Ok(output.stdout); - } - Err(format!( - "failed to inspect workspace dirty state: {}", - String::from_utf8_lossy(&output.stderr).trim() - ) - .into()) + let mut command = git_command(repo.root()); + command.env("GIT_OPTIONAL_LOCKS", "0").args([ + "status", + "--porcelain=v1", + "-z", + "--untracked-files=all", + "--ignore-submodules=none", + ]); + run_bounded_read_only_git(command, "workspace dirty-state inspection") } fn require_canonical_external_state_root(repo: &Repo, state_root: &Path) -> Result<()> { @@ -237,7 +237,7 @@ where I: IntoIterator, S: AsRef, { - let value = run_git_text(cwd, args)?; + let value = run_read_only_git_text(cwd, args, "workspace Git boolean inspection")?; match strip_git_line_ending(&value) { "true" => Ok(true), "false" => Ok(false), diff --git a/src/workspace_clone.rs b/src/workspace_clone.rs index 2b29694c..26ba49b3 100644 --- a/src/workspace_clone.rs +++ b/src/workspace_clone.rs @@ -2,10 +2,46 @@ use super::workspace::{WorkspaceInspection, inspect_existing_workspace}; use super::{Result, git_command}; use crate::store::{NewWorkspace, Store}; use serde::Serialize; +#[cfg(any(target_os = "linux", target_os = "macos"))] +use std::ffi::CString; use std::ffi::OsString; use std::fs; +#[cfg(any(target_os = "linux", target_os = "macos"))] +use std::os::unix::ffi::OsStrExt; +#[cfg(unix)] +use std::os::unix::fs::{DirBuilderExt, MetadataExt}; +#[cfg(windows)] +use std::os::windows::ffi::OsStrExt; +#[cfg(windows)] +use std::os::windows::fs::OpenOptionsExt; +#[cfg(windows)] +use std::os::windows::io::AsRawHandle; use std::path::{Path, PathBuf}; use std::process::Stdio; +use std::sync::atomic::{AtomicU64, Ordering}; +#[cfg(windows)] +use std::{ffi::c_void, mem::MaybeUninit}; + +static NEXT_CLONE_STAGING_ID: AtomicU64 = AtomicU64::new(0); +const MAX_STAGING_ATTEMPTS: usize = 128; + +#[cfg(unix)] +type ClonePathIdentity = (u64, u64); +#[cfg(windows)] +type ClonePathIdentity = (u64, [u8; 16]); +#[cfg(not(any(unix, windows)))] +type ClonePathIdentity = (); + +#[derive(Debug)] +struct OwnedCloneStaging { + path: PathBuf, + identity: ClonePathIdentity, + // Holding the original Unix directory open pins its inode until this + // staging owner is dropped. That prevents delete+recreate from being + // accepted through immediate inode-number reuse. + #[cfg(unix)] + _identity_handle: fs::File, +} #[allow( dead_code, @@ -15,6 +51,7 @@ use std::process::Stdio; pub struct ClonedWorkspace { pub workspace: WorkspaceInspection, pub remote_identity: String, + pub staging_cleanup_warning: Option, } #[allow( @@ -27,15 +64,57 @@ pub fn clone_and_register_workspace( canonical_state_root: &Path, now_ms: i64, ) -> Result { + clone_and_register_workspace_impl(remote, destination, canonical_state_root, now_ms, |_, _| { + Ok(()) + }) +} + +fn clone_and_register_workspace_impl( + remote: &str, + destination: &Path, + canonical_state_root: &Path, + now_ms: i64, + after_staging_created: F, +) -> Result +where + F: FnOnce(&Path, &Path) -> Result<()>, +{ let remote_identity = sanitize_remote_identity(remote)?; - let reserved_destination = reserve_clone_destination(destination, canonical_state_root)?; - let parent = reserved_destination + let planned_destination = plan_clone_destination(destination, canonical_state_root)?; + let parent = planned_destination .parent() .ok_or("clone destination has no parent directory")?; - let git_remote = git_remote_argument(remote)?; - let git_destination = git_cli_local_path(&reserved_destination)?; + require_no_retained_clone_payload(parent)?; + let git_remote = git_remote_argument(remote, &remote_identity)?; + let staging = create_private_clone_staging(parent)?; + let staged_checkout = staging.path.join("checkout"); + let git_destination = match git_cli_local_path(&staged_checkout) { + Ok(destination) => destination, + Err(error) => { + return fail_with_owned_staging_cleanup( + format!("clone destination could not be prepared for system Git: {error}"), + &staging, + ); + } + }; - let status = git_command(parent) + if let Err(error) = after_staging_created(&staged_checkout, &planned_destination) { + return fail_with_owned_staging_cleanup( + format!("clone staging callback failed before Git clone: {error}"), + &staging, + ); + } + + if let Err(error) = + require_clone_directory_identity(&staging.path, &staging.identity, "private clone staging") + { + return fail_with_owned_staging_cleanup( + format!("private clone staging ownership changed before Git clone: {error}"), + &staging, + ); + } + + let status = match git_command(&staging.path) .arg("-c") .arg("core.askPass=") .arg("clone") @@ -51,19 +130,117 @@ pub fn clone_and_register_workspace( .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) - .status()?; + .status() + { + Ok(status) => status, + Err(error) => { + return fail_with_owned_staging_cleanup( + format!( + "system Git clone could not be started or observed: {error}; requested destination was not published or registered" + ), + &staging, + ); + } + }; if !status.success() { let status = status .code() .map_or_else(|| "signal".to_owned(), |code| code.to_string()); - return Err(format!( - "system Git clone failed with status {status}; destination was not registered" - ) - .into()); + return fail_with_owned_staging_cleanup( + format!( + "system Git clone failed with status {status}; requested destination was not published or registered" + ), + &staging, + ); + } + + let checkout_identity = match require_owned_staged_checkout(&staging, &staged_checkout) { + Ok(identity) => identity, + Err(error) => { + return fail_with_owned_staging_cleanup( + format!( + "cloned checkout failed private staging validation; requested destination was not published or registered: {error}" + ), + &staging, + ); + } + }; + + if let Err(error) = + require_clone_directory_identity(&staging.path, &staging.identity, "private clone staging") + { + return fail_with_owned_staging_cleanup( + format!("private clone staging ownership changed before publication: {error}"), + &staging, + ); + } + if let Err(error) = require_clone_directory_identity( + &staged_checkout, + &checkout_identity, + "staged clone checkout", + ) { + return fail_with_owned_staging_cleanup( + format!("staged clone checkout ownership changed before publication: {error}"), + &staging, + ); } - let workspace = inspect_existing_workspace(&reserved_destination, canonical_state_root)?; + if let Err(error) = atomic_publish_no_replace(&staged_checkout, &planned_destination) { + return fail_with_owned_staging_cleanup( + format!( + "cloned checkout could not be atomically published without replacing the requested destination; requested destination was not registered: {error}" + ), + &staging, + ); + } + + let published_identity = match clone_directory_identity( + &planned_destination, + "published clone destination", + ) { + Ok(identity) => identity, + Err(error) => { + return fail_after_publication( + format!( + "published clone destination identity could not be proven after atomic publication; destination was not registered and was retained for recovery: {error}" + ), + &staging, + ); + } + }; + if published_identity != checkout_identity { + return fail_after_publication( + "published clone destination filesystem identity does not match the approved staged checkout; destination was not registered and was retained for recovery".to_owned(), + &staging, + ); + } + + // Publication and filesystem identity are already proven. Failure to remove + // the now-empty private staging shell must not discard that proven publication + // or prevent workspace registration. Preserve cleanup uncertainty in the + // returned record so callers can surface it without fabricating failure. + let staging_cleanup_warning = remove_empty_owned_clone_staging(&staging) + .err() + .map(|error| format!("empty private clone staging cleanup was not proven: {error}")); + + let workspace = inspect_existing_workspace(&planned_destination, canonical_state_root)?; + if Path::new(&workspace.canonical_worktree_root) != planned_destination { + return Err( + "cloned workspace canonical root does not match the atomically published clone destination" + .into(), + ); + } let mut store = Store::open(canonical_state_root)?; + require_clone_directory_identity( + &planned_destination, + &checkout_identity, + "published clone destination", + ) + .map_err(|error| { + format!( + "published clone destination changed filesystem identity before registration; destination was not registered and was retained for recovery: {error}" + ) + })?; store.register_cloned_workspace( NewWorkspace { workspace_id: &workspace.workspace_id, @@ -77,20 +254,14 @@ pub fn clone_and_register_workspace( Ok(ClonedWorkspace { workspace, remote_identity, + staging_cleanup_warning, }) } -fn reserve_clone_destination(destination: &Path, canonical_state_root: &Path) -> Result { +fn plan_clone_destination(destination: &Path, canonical_state_root: &Path) -> Result { if !destination.is_absolute() { return Err("clone destination must be an absolute path".into()); } - if destination.exists() { - return Err(format!( - "clone destination already exists: {}", - destination.display() - ) - .into()); - } let state_root = canonical_state_root .canonicalize() @@ -116,30 +287,503 @@ fn reserve_clone_destination(destination: &Path, canonical_state_root: &Path) -> } let planned = canonical_parent.join(file_name); + match fs::symlink_metadata(&planned) { + Ok(_) => { + return Err(format!("clone destination already exists: {}", planned.display()).into()); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "clone destination cannot be inspected before clone: {}: {error}", + planned.display() + ) + .into()); + } + } + if planned.starts_with(&state_root) || state_root.starts_with(&planned) { return Err("clone destination and Winds state root must not overlap".into()); } - fs::create_dir(&planned).map_err(|error| { + Ok(planned) +} + +fn require_no_retained_clone_payload(parent: &Path) -> Result<()> { + let current_process_prefix = format!(".winds-clone-stage-{}-", std::process::id()); + let entries = fs::read_dir(parent).map_err(|error| { format!( - "failed to reserve clone destination {}: {error}", - planned.display() + "clone destination parent cannot be inspected for retained private staging: {error}" ) })?; - let canonical_reserved = planned + + for entry in entries { + let entry = entry.map_err(|error| { + format!("clone destination parent contains an unreadable entry: {error}") + })?; + let name = entry.file_name(); + // Staging is intentionally scoped to this process identity. A staging + // directory from another Winds process or user is not evidence about + // this operation and must not become a cross-process availability + // gate. Current-process retained payload still bounds repeated retries + // during this process lifetime. + if !name + .as_encoded_bytes() + .starts_with(current_process_prefix.as_bytes()) + { + continue; + } + + let path = entry.path(); + let metadata = fs::symlink_metadata(&path).map_err(|error| { + format!( + "retained private clone staging candidate {} cannot be inspected: {error}", + path.display() + ) + })?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(format!( + "clone destination parent contains an ambiguous Winds staging entry {}; refusing a new clone until it is inspected and recovered manually", + path.display() + ) + .into()); + } + + let mut contents = fs::read_dir(&path).map_err(|error| { + format!( + "retained private clone staging {} cannot be inspected safely: {error}", + path.display() + ) + })?; + if contents.next().is_some() { + return Err(format!( + "retained private clone staging {} contains clone payload from an earlier failed operation in this process; refusing to allocate another staging payload under the same parent until manual recovery prevents unbounded disk growth", + path.display() + ) + .into()); + } + } + + Ok(()) +} + +fn create_private_clone_staging(parent: &Path) -> Result { + for _ in 0..MAX_STAGING_ATTEMPTS { + let sequence = NEXT_CLONE_STAGING_ID.fetch_add(1, Ordering::Relaxed); + let staging = parent.join(format!( + ".winds-clone-stage-{}-{sequence}", + std::process::id() + )); + let mut builder = fs::DirBuilder::new(); + builder.recursive(false); + #[cfg(unix)] + builder.mode(0o700); + match builder.create(&staging) { + Ok(()) => { + let canonical = staging.canonicalize().map_err(|error| { + format!("private clone staging cannot be canonicalized: {error}") + })?; + if canonical != staging { + return Err("private clone staging changed identity during creation".into()); + } + #[cfg(unix)] + let identity_handle = fs::File::open(&staging).map_err(|error| { + format!( + "private clone staging could not be pinned by an open directory handle after creation; staging was retained at {}: {error}", + staging.display() + ) + })?; + #[cfg(unix)] + let identity = + clone_directory_identity_from_handle(&identity_handle, "private clone staging") + .map_err(|error| { + format!( + "private clone staging filesystem identity could not be captured from its pinned handle after creation; staging was retained at {}: {error}", + staging.display() + ) + })?; + #[cfg(unix)] + require_clone_directory_identity(&staging, &identity, "private clone staging") + .map_err(|error| { + format!( + "private clone staging path no longer matches its pinned creation handle; staging was retained at {}: {error}", + staging.display() + ) + })?; + + #[cfg(not(unix))] + let identity = clone_directory_identity(&staging, "private clone staging") + .map_err(|error| { + format!( + "private clone staging filesystem identity could not be captured after creation; staging was retained at {}: {error}", + staging.display() + ) + })?; + + return Ok(OwnedCloneStaging { + path: staging, + identity, + #[cfg(unix)] + _identity_handle: identity_handle, + }); + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => { + return Err(format!( + "failed to create private clone staging under {}: {error}", + parent.display() + ) + .into()); + } + } + } + Err("could not allocate a unique private clone staging directory".into()) +} + +#[cfg(unix)] +fn clone_directory_identity_from_handle( + handle: &fs::File, + label: &str, +) -> Result { + let metadata = handle + .metadata() + .map_err(|error| format!("{label} pinned handle cannot be inspected: {error}"))?; + if !metadata.is_dir() { + return Err(format!("{label} pinned handle is not a directory").into()); + } + Ok((metadata.dev(), metadata.ino())) +} + +#[cfg(unix)] +fn clone_directory_identity(path: &Path, label: &str) -> Result { + let metadata = fs::symlink_metadata(path) + .map_err(|error| format!("{label} cannot be inspected: {error}"))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(format!("{label} is not a real directory").into()); + } + Ok((metadata.dev(), metadata.ino())) +} + +#[cfg(windows)] +fn clone_directory_identity(path: &Path, label: &str) -> Result { + let metadata = fs::symlink_metadata(path) + .map_err(|error| format!("{label} cannot be inspected: {error}"))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(format!("{label} is not a real directory").into()); + } + windows_directory_identity(path, label) +} + +#[cfg(not(any(unix, windows)))] +fn clone_directory_identity(_path: &Path, label: &str) -> Result { + Err(format!("{label} filesystem identity is unsupported on this platform").into()) +} + +fn require_clone_directory_identity( + path: &Path, + expected: &ClonePathIdentity, + label: &str, +) -> Result<()> { + let current = clone_directory_identity(path, label)?; + if current != *expected { + return Err(format!("{label} filesystem identity changed").into()); + } + Ok(()) +} + +fn require_owned_staged_checkout( + staging: &OwnedCloneStaging, + staged_checkout: &Path, +) -> Result { + require_clone_directory_identity(&staging.path, &staging.identity, "private clone staging")?; + let canonical_staging = staging + .path .canonicalize() - .map_err(|error| format!("reserved clone destination cannot be canonicalized: {error}"))?; - if canonical_reserved != planned { - return Err("reserved clone destination changed identity during validation".into()); + .map_err(|error| format!("private clone staging cannot be canonicalized: {error}"))?; + if canonical_staging != staging.path { + return Err("private clone staging path is no longer canonical".into()); } - Ok(planned) + let checkout_metadata = fs::symlink_metadata(staged_checkout) + .map_err(|error| format!("staged clone checkout cannot be inspected: {error}"))?; + if checkout_metadata.file_type().is_symlink() || !checkout_metadata.is_dir() { + return Err("staged clone checkout is not a real directory".into()); + } + let canonical_checkout = staged_checkout + .canonicalize() + .map_err(|error| format!("staged clone checkout cannot be canonicalized: {error}"))?; + if canonical_checkout != staged_checkout + || canonical_checkout.parent() != Some(canonical_staging.as_path()) + { + return Err("staged clone checkout escaped its private staging parent".into()); + } + clone_directory_identity(staged_checkout, "staged clone checkout") } -fn git_remote_argument(remote: &str) -> Result { - let local_path = Path::new(remote); - if local_path.is_absolute() { - return Ok(git_cli_local_path(local_path)?.into_os_string()); +fn retain_owned_clone_staging(staging: &OwnedCloneStaging) -> Result<()> { + retain_owned_clone_staging_impl(staging, || Ok(())) +} + +fn retain_owned_clone_staging_impl( + staging: &OwnedCloneStaging, + after_identity_proven: F, +) -> Result<()> +where + F: FnOnce() -> Result<()>, +{ + require_clone_directory_identity( + &staging.path, + &staging.identity, + "private clone staging", + ) + .map_err(|error| { + format!( + "private clone staging ownership is ambiguous; refusing recursive cleanup and retaining {}: {error}", + staging.path.display() + ) + })?; + + // No destructive operation follows this proof. Production passes a + // no-op; the regression swaps the pathname here to prove that even a + // post-proof replacement is retained untouched. + after_identity_proven()?; + Ok(()) +} + +fn remove_empty_owned_clone_staging(staging: &OwnedCloneStaging) -> Result<()> { + match fs::symlink_metadata(&staging.path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => { + return Err(format!( + "empty private clone staging cannot be inspected before non-recursive removal: {error}" + ) + .into()); + } + Ok(_) => {} + } + require_clone_directory_identity( + &staging.path, + &staging.identity, + "empty private clone staging", + ) + .map_err(|error| { + format!( + "empty private clone staging ownership is ambiguous; retaining {} without unlink: {error}", + staging.path.display() + ) + })?; + fs::remove_dir(&staging.path).map_err(|error| { + format!( + "empty private clone staging {} could not be removed non-recursively after identity proof: {error}", + staging.path.display() + ) + .into() + }) +} + +fn fail_with_owned_staging_cleanup(primary: String, staging: &OwnedCloneStaging) -> Result { + match retain_owned_clone_staging(staging) { + Ok(()) => Err(format!( + "{primary}; private clone staging was retained for recovery at {} because recursive deletion cannot be bound safely to stable filesystem objects on every supported platform", + staging.path.display() + ) + .into()), + Err(identity_error) => Err(format!( + "{primary}; private clone staging ownership is ambiguous, so Winds refused recursive cleanup and retained the staging path without mutation: {identity_error}" + ) + .into()), + } +} + +fn fail_after_publication(primary: String, staging: &OwnedCloneStaging) -> Result { + match remove_empty_owned_clone_staging(staging) { + Ok(()) => Err(format!( + "{primary}; the now-empty private clone staging shell was removed after identity proof" + ) + .into()), + Err(removal_error) => Err(format!( + "{primary}; private staging shell removal also failed and the staging path was retained: {removal_error}" + ) + .into()), + } +} + +#[cfg(target_os = "linux")] +fn atomic_publish_no_replace(source: &Path, destination: &Path) -> Result<()> { + let source = unix_path_cstring(source, "staged clone source")?; + let destination = unix_path_cstring(destination, "clone destination")?; + let result = unsafe { + libc::renameat2( + libc::AT_FDCWD, + source.as_ptr(), + libc::AT_FDCWD, + destination.as_ptr(), + libc::RENAME_NOREPLACE, + ) + }; + if result == 0 { + return Ok(()); + } + + let error = std::io::Error::last_os_error(); + if matches!( + error.raw_os_error(), + Some(libc::ENOSYS | libc::EINVAL | libc::EOPNOTSUPP | libc::EXDEV) + ) { + return Err(format!( + "atomic no-replace clone publish is unsupported by this Linux kernel/filesystem boundary: {error}" + ) + .into()); + } + Err(format!("atomic no-replace clone publish failed: {error}").into()) +} + +#[cfg(target_os = "macos")] +fn atomic_publish_no_replace(source: &Path, destination: &Path) -> Result<()> { + let source = unix_path_cstring(source, "staged clone source")?; + let destination = unix_path_cstring(destination, "clone destination")?; + let result = + unsafe { libc::renamex_np(source.as_ptr(), destination.as_ptr(), libc::RENAME_EXCL) }; + if result == 0 { + Ok(()) + } else { + Err(format!( + "atomic no-replace clone publish failed: {}", + std::io::Error::last_os_error() + ) + .into()) + } +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn unix_path_cstring(path: &Path, label: &str) -> Result { + CString::new(path.as_os_str().as_bytes()) + .map_err(|_| format!("{label} contains an embedded NUL byte").into()) +} + +#[cfg(windows)] +const WINDOWS_FILE_ATTRIBUTE_DIRECTORY: u32 = 0x0000_0010; +#[cfg(windows)] +const WINDOWS_FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; +#[cfg(windows)] +const WINDOWS_FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; +#[cfg(windows)] +const WINDOWS_FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000; +#[cfg(windows)] +const WINDOWS_FILE_ATTRIBUTE_TAG_INFO_CLASS: i32 = 9; +#[cfg(windows)] +const WINDOWS_FILE_ID_INFO_CLASS: i32 = 18; + +#[cfg(windows)] +#[repr(C)] +struct WindowsFileAttributeTagInfo { + file_attributes: u32, + _reparse_tag: u32, +} + +#[cfg(windows)] +#[repr(C)] +struct WindowsFileIdInfo { + volume_serial_number: u64, + file_id: [u8; 16], +} + +#[cfg(windows)] +#[link(name = "kernel32")] +unsafe extern "system" { + fn MoveFileExW(existing_file_name: *const u16, new_file_name: *const u16, flags: u32) -> i32; + fn GetFileInformationByHandleEx( + file_handle: *mut c_void, + file_information_class: i32, + file_information: *mut c_void, + buffer_size: u32, + ) -> i32; +} + +#[cfg(windows)] +fn windows_directory_identity(path: &Path, label: &str) -> Result { + let handle = fs::OpenOptions::new() + .access_mode(0) + .custom_flags(WINDOWS_FILE_FLAG_OPEN_REPARSE_POINT | WINDOWS_FILE_FLAG_BACKUP_SEMANTICS) + .open(path) + .map_err(|error| format!("{label} cannot be opened for identity inspection: {error}"))?; + + let mut attribute_info = MaybeUninit::::uninit(); + let attribute_result = unsafe { + GetFileInformationByHandleEx( + handle.as_raw_handle(), + WINDOWS_FILE_ATTRIBUTE_TAG_INFO_CLASS, + attribute_info.as_mut_ptr().cast::(), + std::mem::size_of::() as u32, + ) + }; + if attribute_result == 0 { + return Err(format!( + "{label} handle attributes cannot be inspected: {}", + std::io::Error::last_os_error() + ) + .into()); + } + let attribute_info = unsafe { attribute_info.assume_init() }; + if attribute_info.file_attributes & WINDOWS_FILE_ATTRIBUTE_REPARSE_POINT != 0 + || attribute_info.file_attributes & WINDOWS_FILE_ATTRIBUTE_DIRECTORY == 0 + { + return Err(format!("{label} handle is a reparse point or not a real directory").into()); + } + + let mut identity_info = MaybeUninit::::uninit(); + let identity_result = unsafe { + GetFileInformationByHandleEx( + handle.as_raw_handle(), + WINDOWS_FILE_ID_INFO_CLASS, + identity_info.as_mut_ptr().cast::(), + std::mem::size_of::() as u32, + ) + }; + if identity_result == 0 { + return Err(format!( + "{label} filesystem identity cannot be inspected: {}", + std::io::Error::last_os_error() + ) + .into()); + } + let identity_info = unsafe { identity_info.assume_init() }; + Ok((identity_info.volume_serial_number, identity_info.file_id)) +} + +#[cfg(windows)] +fn atomic_publish_no_replace(source: &Path, destination: &Path) -> Result<()> { + let source = windows_path_wide(source, "staged clone source")?; + let destination = windows_path_wide(destination, "clone destination")?; + let result = unsafe { MoveFileExW(source.as_ptr(), destination.as_ptr(), 0) }; + if result != 0 { + Ok(()) + } else { + Err(format!( + "atomic no-replace clone publish failed: {}", + std::io::Error::last_os_error() + ) + .into()) + } +} + +#[cfg(windows)] +fn windows_path_wide(path: &Path, label: &str) -> Result> { + let mut encoded = path.as_os_str().encode_wide().collect::>(); + if encoded.contains(&0) { + return Err(format!("{label} contains an embedded NUL code unit").into()); + } + encoded.push(0); + Ok(encoded) +} + +#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] +fn atomic_publish_no_replace(_source: &Path, _destination: &Path) -> Result<()> { + Err("atomic no-replace clone publish is unsupported on this platform".into()) +} + +fn git_remote_argument(remote: &str, remote_identity: &str) -> Result { + if Path::new(remote).is_absolute() { + return Ok(git_cli_local_path(Path::new(remote_identity))?.into_os_string()); } Ok(OsString::from(remote)) } @@ -285,13 +929,17 @@ fn sanitize_scp_like_remote(remote: &str) -> Option { #[cfg(test)] mod tests { - #[cfg(windows)] - use super::git_cli_local_path; - use super::{clone_and_register_workspace, sanitize_remote_identity}; + use super::{ + clone_and_register_workspace, clone_and_register_workspace_impl, clone_directory_identity, + create_private_clone_staging, require_clone_directory_identity, + retain_owned_clone_staging_impl, sanitize_remote_identity, + }; use crate::store::Store; use rusqlite::{Connection, params}; use std::ffi::OsStr; use std::fs; + #[cfg(unix)] + use std::os::unix::fs::symlink; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::atomic::{AtomicU64, Ordering}; @@ -385,6 +1033,32 @@ mod tests { fs::remove_dir_all(&canonical_root).unwrap(); } + fn private_clone_staging_paths(root: &Path) -> Vec { + fs::read_dir(root) + .unwrap() + .map(|entry| entry.unwrap().path()) + .filter(|path| { + path.file_name() + .and_then(OsStr::to_str) + .is_some_and(|name| name.starts_with(".winds-clone-stage-")) + }) + .collect() + } + fn assert_private_clone_staging_failure_state_is_safe(root: &Path) { + let staging_paths = private_clone_staging_paths(root); + assert!( + !staging_paths.is_empty(), + "fail-closed clone cleanup must retain private staging for recovery" + ); + for staging in staging_paths { + let metadata = fs::symlink_metadata(&staging).unwrap(); + assert!( + metadata.is_dir() && !metadata.file_type().is_symlink(), + "retained private staging must remain a real directory" + ); + } + } + #[test] fn clone_registers_workspace_and_persists_only_sanitized_remote_identity() { let root = test_root("clone"); @@ -412,9 +1086,14 @@ mod tests { cloned.remote_identity, remote.canonicalize().unwrap().to_str().unwrap() ); + assert_eq!(cloned.staging_cleanup_warning, None); assert!(destination.join(".envrc").is_file()); assert!(destination.join(".mise.toml").is_file()); assert!(!marker.exists()); + assert!( + private_clone_staging_paths(&root).is_empty(), + "successful clone publication must remove its empty private staging shell" + ); let connection = Connection::open(state_root.join("winds.db")).unwrap(); let (remote_identity, recorded_unix_ms): (String, i64) = connection @@ -433,7 +1112,7 @@ mod tests { } #[test] - fn clone_failure_happens_before_workspace_registration() { + fn clone_failure_happens_before_workspace_registration_and_allows_retry() { let root = test_root("failure"); let state_root = create_state_root(&root); let not_a_repo = root.join("not-a-repo"); @@ -448,8 +1127,18 @@ mod tests { ) .unwrap_err(); assert!(error.to_string().contains("system Git clone failed")); - assert!(destination.is_dir()); + assert!(!destination.exists()); assert!(!state_root.join("winds.db").exists()); + assert_private_clone_staging_failure_state_is_safe(&root); + + let marker = root.join("retry-bootstrap-ran"); + let retry_root = root.join("retry-source"); + fs::create_dir(&retry_root).unwrap(); + let (remote, _) = initialize_remote(&retry_root, &marker); + clone_and_register_workspace(remote.to_str().unwrap(), &destination, &state_root, 201) + .unwrap(); + assert!(destination.is_dir()); + assert!(!marker.exists()); cleanup_owned_root(&root); } @@ -523,6 +1212,277 @@ mod tests { cleanup_owned_root(&root); } + #[test] + fn concurrent_destination_creation_blocks_atomic_publish_without_replacement() { + let root = test_root("publish-race"); + let marker = root.join("bootstrap-ran"); + let (remote, _) = initialize_remote(&root, &marker); + let state_root = create_state_root(&root); + let destination = root.join("raced-destination"); + let replacement_marker = destination.join("replacement-marker"); + let mut staged_checkout = None; + + let error = clone_and_register_workspace_impl( + remote.to_str().unwrap(), + &destination, + &state_root, + 360, + |staged, requested| { + staged_checkout = Some(staged.to_path_buf()); + let expected_requested = destination + .parent() + .unwrap() + .canonicalize() + .unwrap() + .join(destination.file_name().unwrap()); + assert_eq!(requested, expected_requested); + fs::create_dir(requested)?; + fs::write(requested.join("replacement-marker"), b"replacement\n")?; + Ok(()) + }, + ) + .unwrap_err(); + + assert!(error.to_string().contains("atomically published")); + assert_eq!(fs::read(&replacement_marker).unwrap(), b"replacement\n"); + let staged_checkout = staged_checkout.unwrap(); + assert!(staged_checkout.is_dir()); + assert!( + fs::read_dir(&staged_checkout).unwrap().next().is_some(), + "failed publication must retain clone payload rather than recursively delete through mutable pathnames" + ); + assert_private_clone_staging_failure_state_is_safe(&root); + assert!(!state_root.join("winds.db").exists()); + + cleanup_owned_root(&root); + } + + #[test] + fn retained_failed_clone_payload_blocks_additional_staging_allocation() { + let root = test_root("retained-staging-bound"); + let marker = root.join("bootstrap-ran"); + let (remote, _) = initialize_remote(&root, &marker); + let state_root = create_state_root(&root); + let first_destination = root.join("first-raced-destination"); + + let first_error = clone_and_register_workspace_impl( + remote.to_str().unwrap(), + &first_destination, + &state_root, + 363, + |_, requested| { + fs::create_dir(requested)?; + fs::write(requested.join("foreign-marker"), b"foreign\n")?; + Ok(()) + }, + ) + .unwrap_err(); + + assert!(first_error.to_string().contains("atomically published")); + let staging_before_retry = private_clone_staging_paths(&root); + assert_eq!( + staging_before_retry.len(), + 1, + "the failed publication must retain exactly one private staging payload fixture" + ); + assert!( + fs::read_dir(&staging_before_retry[0]) + .unwrap() + .next() + .is_some(), + "the retained staging fixture must contain payload so the bounded-retention gate is exercised" + ); + + let second_destination = root.join("second-clone-destination"); + let second_error = clone_and_register_workspace( + remote.to_str().unwrap(), + &second_destination, + &state_root, + 364, + ) + .unwrap_err(); + + let second_error = second_error.to_string(); + assert!(second_error.contains("retained private clone staging")); + assert!(second_error.contains("unbounded disk growth")); + assert!(!second_destination.exists()); + assert_eq!( + private_clone_staging_paths(&root), + staging_before_retry, + "a blocked retry must not allocate another private staging directory" + ); + assert!(!state_root.join("winds.db").exists()); + + cleanup_owned_root(&root); + } + + #[test] + fn foreign_process_staging_payload_does_not_block_clone() { + let root = test_root("foreign-staging"); + let marker = root.join("bootstrap-ran"); + let (remote, _) = initialize_remote(&root, &marker); + let state_root = create_state_root(&root); + let foreign_pid = std::process::id().wrapping_add(1); + let foreign_staging = root.join(format!(".winds-clone-stage-{foreign_pid}-0")); + fs::create_dir(&foreign_staging).unwrap(); + fs::write(foreign_staging.join("foreign-payload"), b"foreign\n").unwrap(); + + let destination = root.join("clone-destination"); + clone_and_register_workspace(remote.to_str().unwrap(), &destination, &state_root, 365) + .unwrap(); + + assert!(destination.is_dir()); + assert_eq!( + fs::read(foreign_staging.join("foreign-payload")).unwrap(), + b"foreign\n" + ); + + cleanup_owned_root(&root); + } + + #[test] + fn failed_clone_never_recursively_cleans_a_concurrent_destination() { + let root = test_root("failure-race"); + let state_root = create_state_root(&root); + let not_a_repo = root.join("not-a-repo"); + fs::write(¬_a_repo, b"not git\n").unwrap(); + let destination = root.join("raced-destination"); + let replacement_marker = destination.join("replacement-marker"); + + let error = clone_and_register_workspace_impl( + not_a_repo.to_str().unwrap(), + &destination, + &state_root, + 361, + |_, requested| { + fs::create_dir(requested)?; + fs::write(requested.join("replacement-marker"), b"replacement\n")?; + Ok(()) + }, + ) + .unwrap_err(); + + assert!(error.to_string().contains("system Git clone failed")); + assert_eq!(fs::read(&replacement_marker).unwrap(), b"replacement\n"); + assert!(!state_root.join("winds.db").exists()); + + cleanup_owned_root(&root); + } + + #[cfg(any(target_os = "linux", target_os = "macos", windows))] + #[test] + fn cleanup_swap_after_identity_proof_never_deletes_foreign_replacement() { + let root = test_root("cleanup-final-identity-swap") + .canonicalize() + .unwrap(); + let staging = create_private_clone_staging(&root).unwrap(); + let original_staging_path = staging.path.clone(); + let moved_owned_staging = root.join("moved-owned-staging"); + let checkout = original_staging_path.join("checkout"); + fs::create_dir(&checkout).unwrap(); + fs::write(checkout.join("owned-payload"), b"owned\n").unwrap(); + + let foreign_marker = original_staging_path.join("foreign-replacement-marker"); + let retention = retain_owned_clone_staging_impl(&staging, || { + fs::rename(&original_staging_path, &moved_owned_staging)?; + fs::create_dir(&original_staging_path)?; + fs::write(&foreign_marker, b"foreign\n")?; + Ok(()) + }); + + retention.unwrap(); + assert_eq!( + fs::read(&foreign_marker).unwrap(), + b"foreign\n", + "post-proof pathname replacement must remain untouched" + ); + assert_eq!( + fs::read(moved_owned_staging.join("checkout").join("owned-payload")).unwrap(), + b"owned\n", + "post-proof pathname replacement must retain the original owned payload as well as the foreign replacement" + ); + + cleanup_owned_root(&root); + } + + #[test] + fn staging_path_replacement_is_not_cleaned_or_registered() { + let root = test_root("staging-replacement"); + let marker = root.join("bootstrap-ran"); + let (remote, _) = initialize_remote(&root, &marker); + let state_root = create_state_root(&root); + let destination = root.join("clone-destination"); + let mut replacement_marker = None; + + let error = clone_and_register_workspace_impl( + remote.to_str().unwrap(), + &destination, + &state_root, + 362, + |staged, _| { + let staging_root = staged.parent().unwrap(); + fs::remove_dir(staging_root)?; + fs::create_dir(staging_root)?; + let marker = staging_root.join("foreign-replacement-marker"); + fs::write(&marker, b"foreign\n")?; + replacement_marker = Some(marker); + Ok(()) + }, + ) + .unwrap_err(); + + let error = error.to_string(); + assert!(error.contains("filesystem identity changed")); + assert!(error.contains("refusing recursive cleanup")); + assert_eq!(fs::read(replacement_marker.unwrap()).unwrap(), b"foreign\n"); + assert!(!destination.exists()); + assert!(!state_root.join("winds.db").exists()); + + cleanup_owned_root(&root); + } + + #[test] + fn clone_directory_identity_rejects_same_path_replacement() { + let root = test_root("directory-identity"); + let checkout = root.join("checkout"); + let original = root.join("checkout-original"); + fs::create_dir(&checkout).unwrap(); + let identity = clone_directory_identity(&checkout, "test checkout").unwrap(); + assert_eq!( + clone_directory_identity(&checkout, "test checkout").unwrap(), + identity + ); + + fs::rename(&checkout, &original).unwrap(); + fs::create_dir(&checkout).unwrap(); + let replacement = clone_directory_identity(&checkout, "test checkout").unwrap(); + assert_ne!(replacement, identity); + let error = + require_clone_directory_identity(&checkout, &identity, "test checkout").unwrap_err(); + assert!(error.to_string().contains("filesystem identity changed")); + + cleanup_owned_root(&root); + } + + #[cfg(windows)] + #[test] + fn windows_clone_directory_identity_is_stable_and_detects_replacement() { + let root = test_root("windows-directory-identity"); + let checkout = root.join("checkout"); + let original = root.join("checkout-original"); + fs::create_dir(&checkout).unwrap(); + let first = clone_directory_identity(&checkout, "Windows checkout").unwrap(); + let same = clone_directory_identity(&checkout, "Windows checkout").unwrap(); + assert_eq!(first, same); + + fs::rename(&checkout, &original).unwrap(); + fs::create_dir(&checkout).unwrap(); + let replacement = clone_directory_identity(&checkout, "Windows checkout").unwrap(); + assert_ne!(first, replacement); + + cleanup_owned_root(&root); + } + #[test] fn remote_sanitization_removes_credentials_and_url_secret_components() { let sanitized = sanitize_remote_identity( @@ -552,19 +1512,65 @@ mod tests { assert!(sanitize_remote_identity("../relative/repo.git").is_err()); } + #[cfg(unix)] + #[test] + fn absolute_local_symlink_remote_uses_one_canonical_identity_for_git_and_persistence() { + let root = test_root("remote-symlink"); + let first_root = root.join("first"); + let second_root = root.join("second"); + fs::create_dir(&first_root).unwrap(); + fs::create_dir(&second_root).unwrap(); + let (first_remote, _) = initialize_remote(&first_root, &root.join("first-marker")); + let (second_remote, _) = initialize_remote(&second_root, &root.join("second-marker")); + let link = root.join("remote-link"); + symlink(&first_remote, &link).unwrap(); + + let identity = sanitize_remote_identity(link.to_str().unwrap()).unwrap(); + assert_eq!( + identity, + first_remote.canonicalize().unwrap().to_str().unwrap() + ); + + fs::remove_file(&link).unwrap(); + symlink(&second_remote, &link).unwrap(); + let git_argument = super::git_remote_argument(link.to_str().unwrap(), &identity).unwrap(); + assert_eq!(PathBuf::from(git_argument), PathBuf::from(&identity)); + assert_ne!( + identity, + second_remote.canonicalize().unwrap().to_str().unwrap() + ); + + cleanup_owned_root(&root); + } + + #[cfg(unix)] + #[test] + fn destination_validation_rejects_broken_symlink_before_staging() { + let root = test_root("broken-destination"); + let state_root = create_state_root(&root); + let destination = root.join("broken-destination"); + symlink(root.join("missing-target"), &destination).unwrap(); + + let error = super::plan_clone_destination(&destination, &state_root).unwrap_err(); + assert!(error.to_string().contains("already exists")); + + fs::remove_file(&destination).unwrap(); + cleanup_owned_root(&root); + } + #[cfg(windows)] #[test] fn windows_git_cli_local_path_removes_only_supported_verbatim_prefixes() { assert_eq!( - git_cli_local_path(Path::new(r"\\?\C:\Temp\Winds Clone")).unwrap(), + super::git_cli_local_path(Path::new(r"\\?\C:\Temp\Winds Clone")).unwrap(), PathBuf::from(r"C:\Temp\Winds Clone") ); assert_eq!( - git_cli_local_path(Path::new(r"\\?\UNC\server\share\Winds Clone")).unwrap(), + super::git_cli_local_path(Path::new(r"\\?\UNC\server\share\Winds Clone")).unwrap(), PathBuf::from(r"\\server\share\Winds Clone") ); - assert!(git_cli_local_path(Path::new(r"\\?\UNC\server")).is_err()); - assert!(git_cli_local_path(Path::new(r"\\?\UNC\")).is_err()); - assert!(git_cli_local_path(Path::new(r"\\?\Volume{abc}\repo")).is_err()); + assert!(super::git_cli_local_path(Path::new(r"\\?\UNC\server")).is_err()); + assert!(super::git_cli_local_path(Path::new(r"\\?\UNC\")).is_err()); + assert!(super::git_cli_local_path(Path::new(r"\\?\Volume{abc}\repo")).is_err()); } } diff --git a/src/wsl.rs b/src/wsl.rs index da7e8232..44d921a3 100644 --- a/src/wsl.rs +++ b/src/wsl.rs @@ -1,4 +1,6 @@ use super::Result; +#[cfg(windows)] +use super::process_scope::{OwnedProcess, operation_deadlines, spawn_owned_process}; use serde::Serialize; #[cfg(any(windows, test))] use std::collections::{BTreeMap, BTreeSet}; @@ -15,10 +17,16 @@ use std::path::{Path, PathBuf}; #[cfg(windows)] use std::process::{Command, Stdio}; #[cfg(windows)] +use std::sync::mpsc::{self, Receiver, RecvTimeoutError}; +#[cfg(windows)] use std::thread; +#[cfg(windows)] +use std::time::{Duration, Instant}; #[cfg(any(windows, test))] const WSL_OUTPUT_CAP_BYTES: usize = 1024 * 1024; +#[cfg(windows)] +const WSL_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(30); #[cfg(windows)] #[link(name = "kernel32")] @@ -121,67 +129,228 @@ fn system_directory() -> Result { #[cfg(windows)] fn run_wsl(executable: &Path, args: [&str; N]) -> Result> { - let mut child = Command::new(executable) + let mut command = Command::new(executable); + command .args(args) .stdin(Stdio::null()) .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|error| { - format!( - "WSL discovery unavailable: failed to execute {}: {error}", - executable.display() - ) - })?; + .stderr(Stdio::piped()); - let stdout = child - .stdout - .take() - .ok_or("WSL discovery unavailable: failed to capture wsl.exe stdout")?; - let stderr = child - .stderr - .take() - .ok_or("WSL discovery unavailable: failed to capture wsl.exe stderr")?; - - let stdout_reader = thread::spawn(move || read_capped(stdout)); - let stderr_reader = thread::spawn(move || read_capped(stderr)); - let status = child - .wait() - .map_err(|error| format!("WSL discovery failed waiting for wsl.exe: {error}"))?; - let stdout = join_reader(stdout_reader, "stdout")?; - let stderr = join_reader(stderr_reader, "stderr")?; + let started = Instant::now(); + let (command_deadline, cleanup_deadline) = operation_deadlines(started, WSL_DISCOVERY_TIMEOUT); + let mut child = spawn_owned_process(&mut command, "WSL discovery").map_err(|error| { + format!( + "WSL discovery unavailable: failed to execute {} in an owned process scope: {error}", + executable.display() + ) + })?; + + let stdout = match child.take_stdout() { + Some(stdout) => stdout, + None => { + let cleanup = child.terminate_and_prove(cleanup_deadline, "WSL discovery"); + return Err(format!( + "WSL discovery unavailable: failed to capture wsl.exe stdout; owned cleanup {}", + cleanup + .map(|()| "succeeded".to_owned()) + .unwrap_or_else(|error| format!("was not proven: {error}")) + ) + .into()); + } + }; + let stderr = match child.take_stderr() { + Some(stderr) => stderr, + None => { + let cleanup = child.terminate_and_prove(cleanup_deadline, "WSL discovery"); + return Err(format!( + "WSL discovery unavailable: failed to capture wsl.exe stderr; owned cleanup {}", + cleanup + .map(|()| "succeeded".to_owned()) + .unwrap_or_else(|error| format!("was not proven: {error}")) + ) + .into()); + } + }; + + let stdout_reader = spawn_reader(stdout); + let stderr_reader = spawn_reader(stderr); + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if Instant::now() >= command_deadline => { + return fail_wsl_observation( + &mut child, + &stdout_reader, + &stderr_reader, + cleanup_deadline, + format!( + "WSL discovery command exceeded the bounded execution phase of the {} second safety timeout", + WSL_DISCOVERY_TIMEOUT.as_secs() + ), + ); + } + Ok(None) => thread::sleep(Duration::from_millis(10)), + Err(error) => { + return fail_wsl_observation( + &mut child, + &stdout_reader, + &stderr_reader, + cleanup_deadline, + format!("WSL discovery failed waiting for wsl.exe: {error}"), + ); + } + } + }; + + // Once the direct child has exited, pipe drain and scope quiescence are + // cleanup work. They must use the reserved cleanup budget rather than the + // already-consumed command phase deadline. + let stdout = match receive_reader(&stdout_reader, "stdout", cleanup_deadline) { + Ok(output) => output, + Err(error) => { + return fail_wsl_observation( + &mut child, + &stdout_reader, + &stderr_reader, + cleanup_deadline, + error.to_string(), + ); + } + }; + let stderr = match receive_reader(&stderr_reader, "stderr", cleanup_deadline) { + Ok(output) => output, + Err(error) => { + return fail_wsl_observation( + &mut child, + &stdout_reader, + &stderr_reader, + cleanup_deadline, + error.to_string(), + ); + } + }; + + match child.wait_for_scope_quiescence(cleanup_deadline, "WSL discovery") { + Ok(true) => {} + Ok(false) => { + return fail_wsl_observation( + &mut child, + &stdout_reader, + &stderr_reader, + cleanup_deadline, + "WSL discovery direct child exited while owned descendants remained live" + .to_owned(), + ); + } + Err(error) => { + return fail_wsl_observation( + &mut child, + &stdout_reader, + &stderr_reader, + cleanup_deadline, + format!("WSL discovery could not prove owned process-scope quiescence: {error}"), + ); + } + } + if stdout.truncated || stderr.truncated { + return Err("WSL discovery output exceeded the 1 MiB per-stream safety bound".into()); + } if !status.success() { let stderr_text = decode_wsl_text(&stderr.bytes) .unwrap_or_else(|_| String::from_utf8_lossy(&stderr.bytes).into_owned()); - let suffix = if stderr.truncated { " [truncated]" } else { "" }; return Err(format!( - "WSL discovery command failed with status {status}: {}{suffix}", + "WSL discovery command failed with status {status}: {}", stderr_text.trim() ) .into()); } - if stdout.truncated || stderr.truncated { - return Err("WSL discovery output exceeded the 1 MiB per-stream safety bound".into()); - } Ok(stdout.bytes) } #[cfg(windows)] -fn join_reader( - handle: thread::JoinHandle>, +fn fail_wsl_observation( + child: &mut OwnedProcess, + stdout_reader: &Receiver>, + stderr_reader: &Receiver>, + cleanup_deadline: Instant, + primary_error: String, +) -> Result> { + let mut cleanup_failures = Vec::new(); + if let Err(error) = child.terminate_and_prove(cleanup_deadline, "WSL discovery") { + cleanup_failures.push(error.to_string()); + } + if let Err(error) = wait_reader_shutdown(stdout_reader, "stdout", cleanup_deadline) { + cleanup_failures.push(error.to_string()); + } + if let Err(error) = wait_reader_shutdown(stderr_reader, "stderr", cleanup_deadline) { + cleanup_failures.push(error.to_string()); + } + + if cleanup_failures.is_empty() { + Err(primary_error.into()) + } else { + Err(format!( + "{primary_error}; WSL owned subprocess cleanup was not proven: {}", + cleanup_failures.join("; ") + ) + .into()) + } +} + +#[cfg(windows)] +fn spawn_reader(reader: R) -> Receiver> +where + R: Read + Send + 'static, +{ + let (sender, receiver) = mpsc::sync_channel(1); + thread::spawn(move || { + let _ = sender.send(read_capped(reader)); + }); + receiver +} + +#[cfg(windows)] +fn receive_reader( + receiver: &Receiver>, name: &str, + deadline: Instant, ) -> Result { - handle - .join() - .map_err(|_| format!("WSL discovery {name} reader thread panicked"))? - .map_err(|error| format!("WSL discovery failed reading {name}: {error}").into()) + let remaining = deadline.saturating_duration_since(Instant::now()); + match receiver.recv_timeout(remaining) { + Ok(result) => { + result.map_err(|error| format!("WSL discovery failed reading {name}: {error}").into()) + } + Err(RecvTimeoutError::Timeout) => Err(format!( + "WSL discovery {name} reader exceeded the bounded execution phase of the overall {} second safety timeout", + WSL_DISCOVERY_TIMEOUT.as_secs() + ) + .into()), + Err(RecvTimeoutError::Disconnected) => { + Err(format!("WSL discovery {name} reader terminated without a result").into()) + } + } +} + +#[cfg(windows)] +fn wait_reader_shutdown( + receiver: &Receiver>, + name: &str, + deadline: Instant, +) -> Result<()> { + let remaining = deadline.saturating_duration_since(Instant::now()); + match receiver.recv_timeout(remaining) { + Ok(_) | Err(RecvTimeoutError::Disconnected) => Ok(()), + Err(RecvTimeoutError::Timeout) => Err(format!( + "WSL discovery {name} reader shutdown was not proven inside the bounded cleanup window" + ) + .into()), + } } #[cfg(any(windows, test))] fn read_capped(mut reader: R) -> io::Result { let mut captured = Vec::new(); - let mut truncated = false; let mut buffer = [0_u8; 8192]; loop { @@ -189,17 +358,22 @@ fn read_capped(mut reader: R) -> io::Result { if count == 0 { break; } - let remaining = WSL_OUTPUT_CAP_BYTES.saturating_sub(captured.len()); + let probe_limit = WSL_OUTPUT_CAP_BYTES + 1; + let remaining = probe_limit.saturating_sub(captured.len()); let keep = remaining.min(count); captured.extend_from_slice(&buffer[..keep]); - if keep < count { - truncated = true; + if captured.len() > WSL_OUTPUT_CAP_BYTES { + captured.truncate(WSL_OUTPUT_CAP_BYTES); + return Ok(BoundedBytes { + bytes: captured, + truncated: true, + }); } } Ok(BoundedBytes { bytes: captured, - truncated, + truncated: false, }) } diff --git a/src/wsl_launch.rs b/src/wsl_launch.rs index b445440a..67ac8539 100644 --- a/src/wsl_launch.rs +++ b/src/wsl_launch.rs @@ -1,18 +1,89 @@ use super::Result; +#[cfg(windows)] +use super::process_scope::{OwnedProcess, operation_deadlines, spawn_owned_process}; use super::terminal::{TerminalSession, TerminalSize}; use super::wsl::WslDistribution; #[cfg(windows)] use super::wsl::discover_wsl_distributions; #[cfg(windows)] -use super::{GIT_CONTEXT_ENV_VARS, Repo, run_git_text, strip_git_line_ending}; +use super::{GIT_CONTEXT_ENV_VARS, Repo, run_read_only_git_text, strip_git_line_ending}; use serde::Serialize; use sha2::{Digest, Sha256}; use std::path::Path; #[cfg(windows)] use std::path::PathBuf; +#[cfg(windows)] +use std::sync::atomic::{AtomicU64, Ordering}; const WSL_SHELL_EXECUTABLE: &str = "/bin/sh"; +#[cfg(windows)] +static NEXT_WSL_EXEC_SCOPE_ID: AtomicU64 = AtomicU64::new(0); + +#[cfg(windows)] +const WSL_OWNED_SCOPE_SCRIPT: &str = r#" +token="$1" +timeout_seconds="$2" +shift 2 + +for required in /usr/bin/setsid /bin/sh /bin/sleep /bin/kill; do + if [ ! -x "$required" ]; then + printf '__WINDS_WSL_SCOPE_UNSUPPORTED_%s__:%s\n' "$token" "$required" >&2 + exit 125 + fi +done +if ! /bin/sleep 0.01 2>/dev/null; then + printf '__WINDS_WSL_SCOPE_UNSUPPORTED_%s__:%s\n' "$token" '/bin/sleep:fractional-seconds' >&2 + exit 125 +fi + +/usr/bin/setsid /bin/sh -c ' + /bin/sleep 86400 & + sentinel=$! + if ! /bin/kill -0 "$sentinel" 2>/dev/null; then + exit 125 + fi + exec "$@" +' winds-wsl-target "$@" & +target_leader=$! + +/usr/bin/setsid /bin/sh -c ' + /bin/sleep "$1" + printf "__WINDS_WSL_SCOPE_TIMEOUT_%s__\n" "$2" >&2 + /bin/kill -KILL -- "$3" 2>/dev/null || : +' winds-wsl-watchdog "$timeout_seconds" "$token" "$target_leader" & +watchdog=$! + +wait "$target_leader" +target_status=$? + +/bin/kill -KILL -- "-$watchdog" 2>/dev/null || : +wait "$watchdog" 2>/dev/null || : + +if /bin/kill -0 -- "-$target_leader" 2>/dev/null; then + if ! /bin/kill -KILL -- "-$target_leader" 2>/dev/null; then + printf '__WINDS_WSL_SCOPE_UNPROVEN_%s__:group-kill\n' "$token" >&2 + exit 125 + fi +fi + +checks=0 +while /bin/kill -0 -- "-$target_leader" 2>/dev/null; do + checks=$((checks + 1)) + if [ "$checks" -ge 100 ]; then + printf '__WINDS_WSL_SCOPE_UNPROVEN_%s__:quiescence\n' "$token" >&2 + exit 125 + fi + if ! /bin/sleep 0.01; then + printf '__WINDS_WSL_SCOPE_UNPROVEN_%s__:sleep-failed\n' "$token" >&2 + exit 125 + fi +done + +printf '__WINDS_WSL_SCOPE_CLEAN_%s__:%s\n' "$token" "$target_status" >&2 +exit "$target_status" +"#; + #[derive(Debug, Clone, Serialize, PartialEq, Eq)] pub struct WslExecutionDomain { pub host_os: String, @@ -468,7 +539,11 @@ fn attest_workspace( &["rev-parse", "--verify", "HEAD^{commit}"], "WSL Git HEAD", )?; - let windows_head = run_git_text(repo.root(), ["rev-parse", "--verify", "HEAD^{commit}"])?; + let windows_head = run_read_only_git_text( + repo.root(), + ["rev-parse", "--verify", "HEAD^{commit}"], + "Windows Git HEAD attestation", + )?; let windows_head_oid = strip_git_line_ending(&windows_head); if windows_head_oid.is_empty() { return Err("Windows Git returned an empty HEAD object id".into()); @@ -589,21 +664,72 @@ fn run_wsl_exec( cwd: Option<&str>, command: &str, command_args: &[std::ffi::OsString], +) -> Result> { + run_wsl_exec_with_limits( + launcher, + distribution, + cwd, + command, + command_args, + 20, + std::time::Duration::from_secs(30), + ) +} + +#[cfg(windows)] +fn drain_until_idle_or_deadline( + deadline: std::time::Instant, + mut drain_once: F, +) -> std::io::Result +where + F: FnMut() -> std::io::Result, +{ + loop { + if std::time::Instant::now() >= deadline { + return Ok(false); + } + if !drain_once()? { + return Ok(true); + } + } +} + +#[cfg(windows)] +fn run_wsl_exec_with_limits( + launcher: &Path, + distribution: &str, + cwd: Option<&str>, + command: &str, + command_args: &[std::ffi::OsString], + linux_scope_timeout_seconds: u64, + total_timeout: std::time::Duration, ) -> Result> { use super::wsl::decode_wsl_text; use std::ffi::c_void; use std::io::{Read, Result as IoResult}; use std::os::windows::io::AsRawHandle; - use std::process::{Child, ChildStderr, ChildStdout, Command, Stdio}; + use std::process::{ChildStderr, ChildStdout, Command, Stdio}; use std::ptr; use std::thread; use std::time::{Duration, Instant}; const CAP: usize = 256 * 1024; - const TIMEOUT: Duration = Duration::from_secs(30); + const CONTROL_TAIL_CAP: usize = 16 * 1024; const ERROR_BROKEN_PIPE: i32 = 109; const ERROR_NO_DATA: i32 = 232; const ERROR_PIPE_NOT_CONNECTED: i32 = 233; + const OWNED_LABEL: &str = "WSL command launcher"; + + if linux_scope_timeout_seconds == 0 { + return Err("WSL-side command scope timeout must be positive".into()); + } + let minimum_total = Duration::from_secs(linux_scope_timeout_seconds.saturating_add(3)); + if total_timeout <= minimum_total { + return Err( + "host WSL timeout must leave a cleanup margin after the Linux-side scope timeout" + .into(), + ); + } #[link(name = "kernel32")] unsafe extern "system" { @@ -618,14 +744,29 @@ fn run_wsl_exec( ) -> i32; } + fn append_control_tail(tail: &mut Vec, bytes: &[u8]) { + if bytes.len() >= CONTROL_TAIL_CAP { + tail.clear(); + tail.extend_from_slice(&bytes[bytes.len() - CONTROL_TAIL_CAP..]); + return; + } + let overflow = tail + .len() + .saturating_add(bytes.len()) + .saturating_sub(CONTROL_TAIL_CAP); + if overflow > 0 { + tail.drain(..overflow); + } + tail.extend_from_slice(bytes); + } + fn read_available( reader: &mut R, captured: &mut Vec, truncated: &mut bool, + control_tail: Option<&mut Vec>, ) -> IoResult { let mut available = 0_u32; - // SAFETY: `reader` owns a valid pipe handle for this call. No output buffer is - // supplied; PeekNamedPipe only reports the number of bytes immediately readable. let peeked = unsafe { peek_named_pipe( reader.as_raw_handle(), @@ -656,6 +797,9 @@ fn run_wsl_exec( if count == 0 { return Ok(false); } + if let Some(tail) = control_tail { + append_control_tail(tail, &buffer[..count]); + } let remaining = CAP.saturating_sub(captured.len()); let keep = remaining.min(count); captured.extend_from_slice(&buffer[..keep]); @@ -670,18 +814,42 @@ fn run_wsl_exec( stderr: &mut ChildStderr, stdout_bytes: &mut Vec, stderr_bytes: &mut Vec, + stderr_control_tail: &mut Vec, stdout_truncated: &mut bool, stderr_truncated: &mut bool, ) -> IoResult { - let stdout_progress = read_available(stdout, stdout_bytes, stdout_truncated)?; - let stderr_progress = read_available(stderr, stderr_bytes, stderr_truncated)?; + let stdout_progress = read_available(stdout, stdout_bytes, stdout_truncated, None)?; + let stderr_progress = read_available( + stderr, + stderr_bytes, + stderr_truncated, + Some(stderr_control_tail), + )?; Ok(stdout_progress || stderr_progress) } - fn diagnostic_text(bytes: &[u8]) -> String { + fn control_value(tail: &[u8], prefix: &str) -> Option { + String::from_utf8_lossy(tail) + .lines() + .rev() + .find_map(|line| line.strip_prefix(prefix).map(str::to_owned)) + } + + fn has_control_line(tail: &[u8], expected: &str) -> bool { + String::from_utf8_lossy(tail) + .lines() + .any(|line| line == expected) + } + + fn diagnostic_text(bytes: &[u8], token: &str) -> String { let decoded = decode_wsl_text(bytes).unwrap_or_else(|_| String::from_utf8_lossy(bytes).into_owned()); - let trimmed = decoded.trim(); + let filtered = decoded + .lines() + .filter(|line| !(line.starts_with("__WINDS_WSL_SCOPE_") && line.contains(token))) + .collect::>() + .join("\n"); + let trimmed = filtered.trim(); let mut chars = trimmed.chars(); let mut diagnostic: String = chars.by_ref().take(2048).collect(); if chars.next().is_some() { @@ -699,33 +867,30 @@ fn run_wsl_exec( } } - fn cleanup_owned_launcher(child: &mut Child) -> String { - match child.try_wait() { - Ok(Some(_)) => "Windows WSL launcher had already exited".to_owned(), - Ok(None) | Err(_) => match child.kill() { - Ok(()) => match child.wait() { - Ok(_) => "Windows WSL launcher process terminated".to_owned(), - Err(error) => format!( - "Windows WSL launcher termination wait could not be proven: {error}" - ), - }, - Err(kill_error) => match child.try_wait() { - Ok(Some(_)) => "Windows WSL launcher had already exited".to_owned(), - Ok(None) => format!( - "Windows WSL launcher termination could not be proven: {kill_error}" - ), - Err(wait_error) => format!( - "Windows WSL launcher termination could not be proven: {kill_error}; status check failed: {wait_error}" - ), - }, - }, + fn fail_without_linux_scope_proof( + child: &mut OwnedProcess, + cleanup_deadline: Instant, + reason: impl std::fmt::Display, + ) -> Result> { + let windows_cleanup = child.terminate_and_prove(cleanup_deadline, OWNED_LABEL); + match windows_cleanup { + Ok(()) => Err(format!( + "{reason}; WSL-side owned command scope cleanup is unproven because no Linux cleanup marker was observed; Windows launcher process-scope cleanup was proven" + ) + .into()), + Err(cleanup_error) => Err(format!( + "{reason}; WSL-side owned command scope cleanup is unproven because no Linux cleanup marker was observed; Windows launcher process-scope cleanup was also not proven: {cleanup_error}" + ) + .into()), } } - fn fail_owned_launcher(child: &mut Child, reason: impl std::fmt::Display) -> Result> { - let cleanup = cleanup_owned_launcher(child); - Err(format!("{reason}; {cleanup}").into()) - } + let scope_sequence = NEXT_WSL_EXEC_SCOPE_ID.fetch_add(1, Ordering::Relaxed); + let scope_token = format!("{:08x}{scope_sequence:016x}", std::process::id()); + let clean_prefix = format!("__WINDS_WSL_SCOPE_CLEAN_{scope_token}__:"); + let timeout_line = format!("__WINDS_WSL_SCOPE_TIMEOUT_{scope_token}__"); + let unproven_prefix = format!("__WINDS_WSL_SCOPE_UNPROVEN_{scope_token}__:"); + let unsupported_prefix = format!("__WINDS_WSL_SCOPE_UNSUPPORTED_{scope_token}__:"); let mut process = Command::new(launcher); for key in GIT_CONTEXT_ENV_VARS { @@ -735,88 +900,205 @@ fn run_wsl_exec( if let Some(cwd) = cwd { process.arg("--cd").arg(cwd); } - process.arg("--exec").arg(command).args(command_args); - let mut child = process + process + .arg("--exec") + .arg("/bin/sh") + .arg("-c") + .arg(WSL_OWNED_SCOPE_SCRIPT) + .arg("winds-wsl-scope") + .arg(&scope_token) + .arg(linux_scope_timeout_seconds.to_string()) + .arg(command) + .args(command_args) .stdin(Stdio::null()) .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|error| format!("failed to execute selected WSL distribution: {error}"))?; + .stderr(Stdio::piped()); - let mut stdout = match child.stdout.take() { + let started = Instant::now(); + let (command_deadline, cleanup_deadline) = operation_deadlines(started, total_timeout); + let mut child = spawn_owned_process(&mut process, OWNED_LABEL).map_err(|error| { + format!( + "failed to execute selected WSL distribution in an owned Windows process scope: {error}" + ) + })?; + + let mut stdout = match child.take_stdout() { Some(stdout) => stdout, None => { - return fail_owned_launcher(&mut child, "failed to capture WSL command stdout"); + return fail_without_linux_scope_proof( + &mut child, + cleanup_deadline, + "failed to capture WSL command stdout", + ); } }; - let mut stderr = match child.stderr.take() { + let mut stderr = match child.take_stderr() { Some(stderr) => stderr, None => { - return fail_owned_launcher(&mut child, "failed to capture WSL command stderr"); + return fail_without_linux_scope_proof( + &mut child, + cleanup_deadline, + "failed to capture WSL command stderr", + ); } }; + let mut stdout_bytes = Vec::new(); let mut stderr_bytes = Vec::new(); + let mut stderr_control_tail = Vec::new(); let mut stdout_truncated = false; let mut stderr_truncated = false; - let started = Instant::now(); let status = loop { let progressed = match drain_pair( &mut stdout, &mut stderr, &mut stdout_bytes, &mut stderr_bytes, + &mut stderr_control_tail, &mut stdout_truncated, &mut stderr_truncated, ) { Ok(progressed) => progressed, Err(error) => { - return fail_owned_launcher( + return fail_without_linux_scope_proof( &mut child, + cleanup_deadline, format!("failed reading selected WSL command output: {error}"), ); } }; - let observed_exit = match child.try_wait() { - Ok(observed_exit) => observed_exit, + + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if Instant::now() >= command_deadline => { + return fail_without_linux_scope_proof( + &mut child, + cleanup_deadline, + format!( + "selected WSL command exceeded the bounded host execution phase before its Linux-side cleanup proof (Linux scope deadline: {linux_scope_timeout_seconds}s)" + ), + ); + } + Ok(None) => {} Err(error) => { - return fail_owned_launcher( + return fail_without_linux_scope_proof( &mut child, - format!("failed observing selected WSL command exit: {error}"), + cleanup_deadline, + format!("failed observing selected WSL command launcher exit: {error}"), ); } - }; - if let Some(status) = observed_exit { - while drain_pair( - &mut stdout, - &mut stderr, - &mut stdout_bytes, - &mut stderr_bytes, - &mut stdout_truncated, - &mut stderr_truncated, - ) - .map_err(|error| { - format!("failed draining selected WSL command output after observed exit: {error}") - })? {} - break status; - } - if started.elapsed() >= TIMEOUT { - return fail_owned_launcher( - &mut child, - "selected WSL command exceeded the 30 second safety timeout", - ); } + if !progressed { - thread::sleep(Duration::from_millis(10)); + let now = Instant::now(); + if now < command_deadline { + thread::sleep( + Duration::from_millis(10).min(command_deadline.saturating_duration_since(now)), + ); + } } }; - let stderr_diagnostic = diagnostic_text(&stderr_bytes); + // Post-exit pipe draining is cleanup work, but it must not consume the + // entire reserved cleanup window. Give draining at most half of the + // remaining cleanup budget so process-scope termination still has time + // to run if a descendant keeps an inherited pipe continuously writable. + let post_exit_drain_deadline = { + let now = Instant::now(); + now + cleanup_deadline.saturating_duration_since(now) / 2 + }; + match drain_until_idle_or_deadline(post_exit_drain_deadline, || { + drain_pair( + &mut stdout, + &mut stderr, + &mut stdout_bytes, + &mut stderr_bytes, + &mut stderr_control_tail, + &mut stdout_truncated, + &mut stderr_truncated, + ) + }) { + Ok(true) => {} + Ok(false) => { + let cleanup = child.terminate_and_prove(cleanup_deadline, OWNED_LABEL); + return Err(format!( + "selected WSL command launcher exited, but post-exit output draining exceeded the reserved cleanup deadline; WSL-side cleanup proof cannot be trusted; bounded Windows launcher cleanup {}", + cleanup + .map(|()| "was proven".to_owned()) + .unwrap_or_else(|error| format!("was not proven: {error}")) + ) + .into()); + } + Err(error) => { + let cleanup = child.terminate_and_prove(cleanup_deadline, OWNED_LABEL); + return Err(format!( + "selected WSL command launcher exited, but output draining failed and WSL-side cleanup proof cannot be trusted: {error}; bounded Windows launcher cleanup {}", + cleanup + .map(|()| "was proven".to_owned()) + .unwrap_or_else(|cleanup_error| format!("was not proven: {cleanup_error}")) + ) + .into()); + } + } + // The launcher has exited and output has been drained. Scope quiescence is + // cleanup work and must consume only the reserved cleanup budget. + match child.wait_for_scope_quiescence(cleanup_deadline, OWNED_LABEL) { + Ok(true) => {} + Ok(false) => { + let cleanup = child.terminate_and_prove(cleanup_deadline, OWNED_LABEL); + return Err(format!( + "Windows WSL launcher direct child exited while its owned Windows process scope remained live; bounded cleanup {}", + cleanup + .map(|()| "was proven".to_owned()) + .unwrap_or_else(|error| format!("was not proven: {error}")) + ) + .into()); + } + Err(error) => { + let cleanup = child.terminate_and_prove(cleanup_deadline, OWNED_LABEL); + return Err(format!( + "Windows WSL launcher process-scope quiescence could not be inspected: {error}; bounded cleanup {}", + cleanup + .map(|()| "was proven".to_owned()) + .unwrap_or_else(|cleanup_error| format!("was not proven: {cleanup_error}")) + ) + .into()); + } + } + + if let Some(required) = control_value(&stderr_control_tail, &unsupported_prefix) { + return Err(format!( + "selected WSL distribution lacks a required owned-scope primitive ({required}); command was not admitted" + ) + .into()); + } + if let Some(reason) = control_value(&stderr_control_tail, &unproven_prefix) { + return Err( + format!("WSL-side owned command scope cleanup could not be proven: {reason}").into(), + ); + } + + let target_status = control_value(&stderr_control_tail, &clean_prefix) + .ok_or( + "WSL-side owned command scope cleanup is unproven: the Linux supervisor exited without its cleanup marker", + )? + .parse::() + .map_err(|_| "WSL-side cleanup marker contained an invalid target exit status")?; + + if status.code() != Some(target_status) { + return Err(format!( + "WSL-side cleanup marker/launcher exit mismatch: Linux target status {target_status}, Windows launcher status {status}; cleanup truth is ambiguous" + ) + .into()); + } + + let stderr_diagnostic = diagnostic_text(&stderr_bytes, &scope_token); let suffix = truncation_suffix(stdout_truncated, stderr_truncated); - if !status.success() { + + if has_control_line(&stderr_control_tail, &timeout_line) { return Err(format!( - "selected WSL command failed with status {status}: {stderr_diagnostic}{suffix}" + "selected WSL command exceeded the {linux_scope_timeout_seconds} second WSL-side safety timeout; owned Linux process-group cleanup was proven{suffix}" ) .into()); } @@ -827,13 +1109,84 @@ fn run_wsl_exec( format!("{suffix}; stderr: {stderr_diagnostic}") }; return Err(format!( - "selected WSL command exceeded the 256 KiB per-stream safety bound{diagnostic}" + "selected WSL command exceeded the 256 KiB per-stream safety bound after WSL-side cleanup was proven{diagnostic}" ) .into()); } + if !status.success() { + return Err(format!( + "selected WSL command failed with status {status} after WSL-side cleanup was proven: {stderr_diagnostic}" + ) + .into()); + } + Ok(stdout_bytes) } +#[cfg(all(windows, test))] +pub(crate) fn prove_wsl_exec_scope_cleanup_for_test(distribution: &str) -> Result<()> { + use super::wsl::system_wsl_executable; + use std::ffi::OsString; + use std::time::Duration; + + let launcher = system_wsl_executable()?; + let descendant_script = "/bin/sleep 120 & child=$!; printf '%s\\n' \"$child\"; exit 0"; + let output = run_wsl_exec_with_limits( + &launcher, + distribution, + None, + "/bin/sh", + &[OsString::from("-c"), OsString::from(descendant_script)], + 2, + Duration::from_secs(8), + )?; + let descendant_pid = parse_single_text(&output, "WSL scope descendant pid")?; + if descendant_pid.is_empty() || !descendant_pid.bytes().all(|byte| byte.is_ascii_digit()) { + return Err("WSL scope regression returned an invalid descendant pid".into()); + } + + let absence_script = "if /bin/kill -0 \"$1\" 2>/dev/null; then exit 91; else exit 0; fi"; + run_wsl_exec_with_limits( + &launcher, + distribution, + None, + "/bin/sh", + &[ + OsString::from("-c"), + OsString::from(absence_script), + OsString::from("winds-wsl-scope-check"), + OsString::from(&descendant_pid), + ], + 2, + Duration::from_secs(8), + ) + .map_err(|error| { + format!("WSL-side descendant survived the completed owned attestation scope: {error}") + })?; + + let timeout_error = run_wsl_exec_with_limits( + &launcher, + distribution, + None, + "/bin/sleep", + &[OsString::from("120")], + 1, + Duration::from_secs(6), + ) + .unwrap_err() + .to_string(); + if !timeout_error.contains("1 second WSL-side safety timeout") + || !timeout_error.contains("cleanup was proven") + { + return Err(format!( + "WSL-side timeout regression did not report proven scope cleanup: {timeout_error}" + ) + .into()); + } + + Ok(()) +} + #[cfg(windows)] fn require_same_canonical_windows_path( observed: &Path, @@ -886,11 +1239,40 @@ fn parse_single_linux_path(bytes: &[u8], label: &str) -> Result { #[cfg(test)] mod tests { + #[cfg(windows)] + use super::drain_until_idle_or_deadline; use super::{ WslCwdStrategy, WslExecutionDomain, WslTerminalProfile, build_launch_arguments, parse_single_linux_path, stable_profile_id, validate_profile_for_launch, }; + #[cfg(windows)] + #[test] + fn post_exit_drain_stops_at_deadline_under_continuous_progress() { + use std::time::{Duration, Instant}; + + let started = Instant::now(); + let mut drain_calls = 0_u64; + let drained = drain_until_idle_or_deadline(started + Duration::from_millis(20), || { + drain_calls += 1; + Ok(true) + }) + .unwrap(); + + assert!( + !drained, + "continuous progress must stop at the drain deadline" + ); + assert!( + drain_calls > 0, + "the regression must exercise the progress loop" + ); + assert!( + started.elapsed() < Duration::from_secs(1), + "deadline enforcement must remain bounded" + ); + } + #[test] fn launch_arguments_bind_distribution_cwd_and_exact_shell_without_shell_parsing() { let args = diff --git a/tests/t057_cli.rs b/tests/t057_cli.rs index 7a9ff533..08550b45 100644 --- a/tests/t057_cli.rs +++ b/tests/t057_cli.rs @@ -7,9 +7,8 @@ use std::time::{SystemTime, UNIX_EPOCH}; #[test] fn minimal_cli_proves_workspace_profiles_execution_and_terminal_paths() { - let Some(temp) = TestTempDir::new("winds-t057-cli") else { - return; - }; + let temp = TestTempDir::new("winds-t057-cli") + .expect("T057 CLI fixture requires a canonical UTF-8 temporary directory"); let root = temp.path(); let repo = root.join("repo"); let other_repo = root.join("other-repo"); @@ -71,6 +70,18 @@ fn minimal_cli_proves_workspace_profiles_execution_and_terminal_paths() { command_json["execution"]["shell_command"]["arguments"][0], "" ); + let command_git_observations = command_json["execution"]["git_observations"] + .as_array() + .expect("winds run must expose typed Git observations"); + assert_eq!(command_git_observations.len(), 2); + assert_eq!(command_git_observations[0]["boundary"], "BEFORE"); + assert_eq!(command_git_observations[1]["boundary"], "AFTER"); + assert!(command_git_observations.iter().all(|observation| { + observation["availability"] == "OBSERVED" + && observation["source"] == "WINDS_OBSERVED" + && observation["worktree_state_format"].as_str().is_some() + && observation["worktree_state_sha256"].as_str().is_some() + })); assert_eq!(command_json["result"]["exit_code"], 1); let inspected = winds( @@ -87,6 +98,10 @@ fn minimal_cli_proves_workspace_profiles_execution_and_terminal_paths() { let inspected_json: Value = serde_json::from_slice(&inspected.stdout).unwrap(); assert_eq!(inspected_json["execution_id"], command_id); assert_eq!(inspected_json["status"], "EXITED"); + assert_eq!( + inspected_json["git_observations"], + command_json["execution"]["git_observations"] + ); assert!(inspected_json["events"].as_array().unwrap().len() >= 2); let cross_workspace = winds( @@ -131,14 +146,20 @@ fn minimal_cli_proves_workspace_profiles_execution_and_terminal_paths() { terminal_json["execution"]["terminal"]["close_reason"], "TERMINATED_BY_WINDS" ); + assert_eq!( + terminal_json["execution"]["git_observations"] + .as_array() + .unwrap() + .len(), + 0 + ); assert_eq!(terminal_json["proof"]["profile_id"], profile_id); } #[test] fn workspace_clone_rejects_unsafe_state_roots_before_creation() { - let Some(temp) = TestTempDir::new("winds-t057-clone") else { - return; - }; + let temp = TestTempDir::new("winds-t057-clone") + .expect("T057 clone fixture requires a canonical UTF-8 temporary directory"); let root = temp.path(); let source = root.join("source"); init_repo(&source, "source"); @@ -208,6 +229,7 @@ fn workspace_clone_rejects_unsafe_state_roots_before_creation() { test_path(&canonical_destination) ); assert_eq!(cloned_json["remote_identity"], test_path(&canonical_source)); + assert!(cloned_json["staging_cleanup_warning"].is_null()); } fn init_repo(path: &Path, content: &str) { diff --git a/tests/t068_exact_head_ci.rs b/tests/t068_exact_head_ci.rs new file mode 100644 index 00000000..a1245934 --- /dev/null +++ b/tests/t068_exact_head_ci.rs @@ -0,0 +1,49 @@ +use std::fs; + +fn workflow(path: &str) -> String { + fs::read_to_string(path).unwrap_or_else(|error| panic!("failed to read {path}: {error}")) +} + +fn assert_exact_head_contract(path: &str, contents: &str) { + let candidate_line = contents + .lines() + .find(|line| line.trim_start().starts_with("CANDIDATE_SHA:")) + .unwrap_or_else(|| panic!("{path} must define CANDIDATE_SHA")); + let pull_head = "github.event.pull_request.head.sha"; + let pull_head_index = candidate_line.find(pull_head).unwrap_or_else(|| { + panic!("{path} must derive candidate identity from the pull-request head SHA") + }); + let github_sha_index = candidate_line + .find("github.sha") + .unwrap_or_else(|| panic!("{path} must retain a non-PR SHA fallback")); + assert!( + pull_head_index < github_sha_index, + "{path} must prefer the pull-request head SHA over fallback candidate identity" + ); + assert!( + contents.contains("ref: ${{ env.CANDIDATE_SHA }}"), + "{path} must checkout the exact candidate SHA rather than a mutable branch/ref" + ); + assert!( + contents.contains("Verify checkout identity"), + "{path} must fail closed if checkout identity differs from the candidate SHA" + ); + assert!( + contents.contains("test \"$(git rev-parse HEAD)\" = \"$CANDIDATE_SHA\"") + || (contents.contains("$actual = (git rev-parse HEAD).Trim()") + && contents.contains("$actual -cne $env:CANDIDATE_SHA")), + "{path} must compare the actual checked-out Git commit to the exact candidate SHA" + ); +} + +#[test] +fn t068_ci_workflows_bind_evidence_to_exact_candidate_head() { + for path in [ + ".github/workflows/quality.yml", + ".github/workflows/windows-terminal.yml", + ".github/workflows/release-candidate.yml", + ] { + let contents = workflow(path); + assert_exact_head_contract(path, &contents); + } +}