diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46fdb75..06077af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,13 +16,17 @@ jobs: validate: name: Windows validation runs-on: windows-latest - timeout-minutes: 20 + # The full repository validation runs 43 persistence suites sequentially in + # Windows PowerShell child processes. On a hosted runner that takes about 33 + # minutes, so the job limit must cover it with margin for the lifecycle, + # release-contract and TrayHost steps that follow. + timeout-minutes: 90 steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: 22 @@ -49,3 +53,7 @@ jobs: - name: Run production TrayHost smoke test shell: pwsh run: ./tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionOnly + + - name: Run authenticated TrayHost production trace + shell: pwsh + run: ./tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index de23853..41a3509 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,6 +11,10 @@ on: required: true type: string +concurrency: + group: ccod-draft-${{ github.event.inputs.tag || github.ref_name }} + cancel-in-progress: false + permissions: contents: read @@ -19,6 +23,7 @@ jobs: runs-on: windows-latest outputs: tag: ${{ steps.release.outputs.tag }} + commit: ${{ steps.commit.outputs.commit }} steps: - name: Validate release tag id: release @@ -27,25 +32,76 @@ jobs: CCOD_RELEASE_TAG_INPUT: ${{ inputs.tag }} run: | $tag = if ($env:GITHUB_EVENT_NAME -eq 'workflow_dispatch') { [string]$env:CCOD_RELEASE_TAG_INPUT } else { [string]$env:GITHUB_REF_NAME } - $tag = $tag.Trim() - if ($tag -cnotmatch '^v\d+\.\d+\.\d+$') { + if ($tag -cnotmatch '^v\d+\.\d+\.\d+\z') { throw "Release tag must be canonical vX.Y.Z; received '$tag'." } "tag=$tag" >> $env:GITHUB_OUTPUT - build: + - name: Check out the validated release tag + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ steps.release.outputs.tag }} + fetch-depth: 1 + + - name: Resolve immutable release commit + id: commit + shell: pwsh + run: | + $commit = ([string](& git rev-parse HEAD)).Trim().ToLowerInvariant() + if ($LASTEXITCODE -ne 0 -or $commit -cnotmatch '^[0-9a-f]{40}$') { + throw "Release tag did not resolve to a canonical commit: '$commit'." + } + "commit=$commit" >> $env:GITHUB_OUTPUT + + preflight: needs: prepare outputs: tag: ${{ needs.prepare.outputs.tag }} + commit: ${{ needs.prepare.outputs.commit }} + runs-on: windows-latest + steps: + - name: Check out repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ needs.prepare.outputs.commit }} + + - name: Run clean release runner + shell: pwsh + env: + CCOD_RELEASE_TAG: ${{ needs.prepare.outputs.tag }} + run: | + $tag = $env:CCOD_RELEASE_TAG + if ($tag -cnotmatch '^v\d+\.\d+\.\d+\z') { + throw "Release tag output is invalid: '$tag'." + } + $version = $tag.Substring(1) + Import-Module (Join-Path $PWD 'tools\Test-CleanReleaseRunner.psm1') -Force -DisableNameChecking + $result = Test-CcodCleanReleaseRunner -RepositoryRoot $PWD -ExpectedVersion $version + $artifact = Join-Path $PWD 'build\preflight\clean-preflight.json' + New-Item -ItemType Directory -Path (Split-Path $artifact -Parent) -Force | Out-Null + Copy-Item -LiteralPath $result.PreflightPath -Destination $artifact -Force + + - name: Upload immutable clean preflight evidence + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: CodexRemote-fix clean preflight + path: build/preflight/clean-preflight.json + if-no-files-found: error + + build: + needs: preflight + outputs: + tag: ${{ needs.preflight.outputs.tag }} + commit: ${{ needs.preflight.outputs.commit }} runs-on: windows-latest steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 with: - ref: ${{ needs.prepare.outputs.tag }} + ref: ${{ needs.preflight.outputs.commit }} - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: 22 @@ -65,160 +121,109 @@ jobs: shell: pwsh run: ./tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionOnly + - name: Run authenticated TrayHost production trace + shell: pwsh + run: ./tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly + - name: Build CodexRemote-fix portable bundle shell: pwsh env: - CCOD_RELEASE_TAG: ${{ needs.prepare.outputs.tag }} + CCOD_RELEASE_TAG: ${{ needs.preflight.outputs.tag }} run: | $version = $env:CCOD_RELEASE_TAG.Substring(1) ./build/build.ps1 -Version $version - name: Upload immutable CodexRemote-fix portable bundle - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: CodexRemote-fix portable bundle - path: build/dist/* + path: build/dist if-no-files-found: error - publish: + stage: needs: build runs-on: windows-latest permissions: contents: write steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 with: - ref: ${{ needs.build.outputs.tag }} + ref: ${{ needs.build.outputs.commit }} - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: 22 - name: Download immutable CodexRemote-fix portable bundle - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: name: CodexRemote-fix portable bundle path: build/dist - - name: Verify downloaded release candidate contract + - name: Download immutable clean preflight evidence + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: CodexRemote-fix clean preflight + path: build/preflight + + - name: Materialize transferred clean preflight evidence shell: pwsh env: CCOD_RELEASE_TAG: ${{ needs.build.outputs.tag }} run: | - npm run test:release-contract $tag = $env:CCOD_RELEASE_TAG - if ($tag -cnotmatch '^v\d+\.\d+\.\d+$') { - throw "Release tag output is invalid: '$tag'." - } $version = $tag.Substring(1) - . ./tools/Test-ReleaseDefender.ps1 -Library - $manifestPath = Join-Path $PWD ("build\dist\CodexRemote-fix-$version-release-manifest.json") - Test-CcodReleaseAssetManifest -ManifestPath $manifestPath -AssetDirectory (Join-Path $PWD 'build\dist') -ExpectedVersion $version | Out-Null - $setupManifestPath = Join-Path $PWD ("build\dist\CodexRemote-fix-$version-setup-release-manifest.json") - Test-CcodReleaseAssetManifest -ManifestPath $setupManifestPath -AssetDirectory (Join-Path $PWD 'build\dist') -ExpectedVersion $version | Out-Null + $source = Join-Path $PWD 'build\preflight\clean-preflight.json' + $evidence = Join-Path $env:RUNNER_TEMP 'ccod-draft-evidence' + New-Item -ItemType Directory -Path $evidence -Force | Out-Null + Copy-Item -LiteralPath $source -Destination (Join-Path $evidence ("CodexRemote-fix-$version-clean-preflight.json")) -Force - - name: Publish GitHub release + - name: Verify downloaded release candidate contract shell: pwsh env: - GH_TOKEN: ${{ github.token }} CCOD_RELEASE_TAG: ${{ needs.build.outputs.tag }} + CCOD_RELEASE_COMMIT: ${{ needs.build.outputs.commit }} run: | + npm run test:release-contract $tag = $env:CCOD_RELEASE_TAG - if ($tag -cnotmatch '^v\d+\.\d+\.\d+$') { + if ($tag -cnotmatch '^v\d+\.\d+\.\d+\z') { throw "Release tag output is invalid: '$tag'." } $version = $tag.Substring(1) - $changelog = Get-Content -LiteralPath (Join-Path $PWD 'CHANGELOG.md') -Raw -Encoding UTF8 - $releaseSection = [regex]::Match($changelog, '(?ms)^## ' + [regex]::Escape($tag) + '\r?\n(.*?)(?=^## |\z)') - if (-not $releaseSection.Success) { - throw "CHANGELOG.md has no release section for $tag" - } - $englishSection = [regex]::Match($releaseSection.Groups[1].Value, '(?ms)^### English\r?\n(.*?)(?=^### |\z)') - if (-not $englishSection.Success) { - throw "CHANGELOG.md has no English release section for $tag" - } - $notesPath = Join-Path $env:RUNNER_TEMP ("CodexRemote-fix-$tag-release-notes.md") - Set-Content -LiteralPath $notesPath -Value ("# CodexRemote-fix $version`n`n" + $englishSection.Groups[1].Value.Trim() + "`n") -Encoding UTF8 - & gh release view $tag --repo $env:GITHUB_REPOSITORY --json assets 2>$null | Out-Null - $exists = $LASTEXITCODE -eq 0 - $existingAssets = @() - if ($exists) { - $existingAssets = @(& gh release view $tag --repo $env:GITHUB_REPOSITORY --json assets --jq '.assets[].name') - if ($LASTEXITCODE -ne 0) { - throw "Could not inspect existing release assets for $tag." - } - } - $assets = @(Get-ChildItem -LiteralPath (Join-Path $PWD 'build\dist') -File | Where-Object { $_.Extension -in @('.exe', '.zip', '.txt', '.json') }) - $assetNames = @($assets | ForEach-Object { $_.Name }) - $unexpected = @($existingAssets | Where-Object { $assetNames -cnotcontains $_ }) - if ($unexpected.Count -ne 0) { - throw "Existing release $tag contains unexpected assets and cannot be safely updated: $($unexpected -join ', ')" - } - foreach ($asset in $assets) { - if ($existingAssets -ccontains $asset.Name) { - $downloadRoot = Join-Path $env:RUNNER_TEMP ('ccod-existing-asset-' + [guid]::NewGuid().ToString('N')) - New-Item -ItemType Directory -Path $downloadRoot -Force | Out-Null - try { - & gh release download $tag --repo $env:GITHUB_REPOSITORY --pattern $asset.Name --dir $downloadRoot - if ($LASTEXITCODE -ne 0) { - throw "Could not download existing release asset $($asset.Name)." - } - $existingAsset = Join-Path $downloadRoot $asset.Name - if (-not (Test-Path -LiteralPath $existingAsset -PathType Leaf) -or - (Get-FileHash -LiteralPath $existingAsset -Algorithm SHA256).Hash.ToLowerInvariant() -cne (Get-FileHash -LiteralPath $asset.FullName -Algorithm SHA256).Hash.ToLowerInvariant()) { - throw "Existing release asset $($asset.Name) differs from the immutable candidate." - } - } finally { - if (Test-Path -LiteralPath $downloadRoot) { Remove-Item -LiteralPath $downloadRoot -Recurse -Force -ErrorAction SilentlyContinue } - } - } + Import-Module (Join-Path $PWD 'tools\ReleaseAssetContract.psm1') -Force + $names = @(Get-CcodExpectedReleaseAssetNames -Version $version) + $candidate = Test-CcodExactReleaseAssetSet -AssetDirectory (Join-Path $PWD 'build/dist') -Version $version + if ($env:CCOD_RELEASE_COMMIT -cnotmatch '^[0-9a-f]{40}\z' -or $candidate.GitCommit -cne $env:CCOD_RELEASE_COMMIT) { + throw 'CCOD_RELEASE_BUILD_COMMIT_MISMATCH' } - if (-not $exists) { - gh release create $tag --repo $env:GITHUB_REPOSITORY --title "CodexRemote-fix $version" --notes-file $notesPath - } else { - gh release edit $tag --repo $env:GITHUB_REPOSITORY --title "CodexRemote-fix $version" --notes-file $notesPath - } - foreach ($asset in $assets) { - if ($existingAssets -cnotcontains $asset.Name) { - & gh release upload $tag --repo $env:GITHUB_REPOSITORY $asset.FullName - if ($LASTEXITCODE -ne 0) { - throw "Could not upload release asset $($asset.Name)." - } - } + $manifestPath = Join-Path $PWD ('build\dist\CodexRemote-fix-' + $version + '-release-manifest.json') + Test-CcodReleaseAssetManifest -ManifestPath $manifestPath -AssetDirectory (Join-Path $PWD 'build\dist') -ExpectedVersion $version | Out-Null + $setupManifestPath = Join-Path $PWD ('build\dist\CodexRemote-fix-' + $version + '-setup-release-manifest.json') + Test-CcodReleaseAssetManifest -ManifestPath $setupManifestPath -AssetDirectory (Join-Path $PWD 'build\dist') -ExpectedVersion $version | Out-Null + if ($names.Count -ne 11) { + throw 'Release candidate does not expose the exact 11 public names.' } - - name: Read back published GitHub release assets + - name: Stage private GitHub draft shell: pwsh env: GH_TOKEN: ${{ github.token }} CCOD_RELEASE_TAG: ${{ needs.build.outputs.tag }} run: | $tag = $env:CCOD_RELEASE_TAG - if ($tag -cnotmatch '^v\d+\.\d+\.\d+$') { + if ($tag -cnotmatch '^v\d+\.\d+\.\d+\z') { throw "Release tag output is invalid: '$tag'." } - $assets = @(Get-ChildItem -LiteralPath (Join-Path $PWD 'build\dist') -File | Where-Object { $_.Extension -in @('.exe', '.zip', '.txt', '.json') }) - $assetNames = @($assets | ForEach-Object { $_.Name }) - $publishedNames = @(& gh release view $tag --repo $env:GITHUB_REPOSITORY --json assets --jq '.assets[].name') - if ($LASTEXITCODE -ne 0 -or ((@($publishedNames | Sort-Object) -join '|') -cne (@($assetNames | Sort-Object) -join '|'))) { - throw "Published release $tag does not expose the exact expected asset set for read-back." - } - $readBackRoot = Join-Path $env:RUNNER_TEMP ('ccod-release-readback-' + [guid]::NewGuid().ToString('N')) - New-Item -ItemType Directory -Path $readBackRoot -Force | Out-Null - try { - foreach ($asset in $assets) { - & gh release download $tag --repo $env:GITHUB_REPOSITORY --pattern $asset.Name --dir $readBackRoot - if ($LASTEXITCODE -ne 0) { - throw "Could not read back published release asset $($asset.Name)." - } - $readBackAsset = Join-Path $readBackRoot $asset.Name - if (-not (Test-Path -LiteralPath $readBackAsset -PathType Leaf) -or - (Get-FileHash -LiteralPath $readBackAsset -Algorithm SHA256).Hash.ToLowerInvariant() -cne (Get-FileHash -LiteralPath $asset.FullName -Algorithm SHA256).Hash.ToLowerInvariant()) { - throw "Published release asset $($asset.Name) does not match the immutable candidate bytes." - } - } - } finally { - if (Test-Path -LiteralPath $readBackRoot) { Remove-Item -LiteralPath $readBackRoot -Recurse -Force -ErrorAction SilentlyContinue } + $version = $tag.Substring(1) + $notesPath = Join-Path $env:RUNNER_TEMP ("CodexRemote-fix-$tag-release-notes.md") + & (Join-Path $PWD 'tools\New-GitHubReleaseNotes.ps1') -ChangelogPath (Join-Path $PWD 'CHANGELOG.md') -Tag $tag -OutputPath $notesPath | Out-Null + $evidence = Join-Path $env:RUNNER_TEMP 'ccod-draft-evidence' + $preflight = Join-Path $evidence ("CodexRemote-fix-$version-clean-preflight.json") + if (-not (Test-Path -LiteralPath $preflight -PathType Leaf)) { + throw "Transferred clean preflight evidence is missing: '$preflight'." } + Import-Module (Join-Path $PWD 'tools\Invoke-GitHubDraftRelease.psm1') -Force -DisableNameChecking + Invoke-CcodGitHubDraftRelease -Mode Stage -Tag $tag -AssetDirectory (Join-Path $PWD 'build\dist') -EvidenceDirectory $evidence -NotesPath $notesPath | Out-Null + Invoke-CcodGitHubDraftRelease -Mode Verify -Tag $tag -AssetDirectory (Join-Path $PWD 'build\dist') -EvidenceDirectory $evidence | Out-Null diff --git a/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-1-report.md b/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-1-report.md new file mode 100644 index 0000000..4c9448c --- /dev/null +++ b/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-1-report.md @@ -0,0 +1,303 @@ +# Task 1 report: pinned, bounded tray receipt store + +Date: 2026-08-28 + +Worktree: `C:\Users\33384\Documents\Codex-Control-other-devices-Windows\.worktrees\codex-v2522-install-runtime-reliability` + +Branch: `codex/v2522-install-runtime-reliability` + +## Status + +Task 1 is implemented and focused verification is green. This report does not +claim the later asynchronous sink, bounded UI completion, or child-session +trace from Tasks 2 and 3. + +## Files + +- `src/trayhost/TrayTerminalDiagnostic.cs` + - Replaced the caller-selected path append helper with + `TrayTerminalDiagnosticStore`. + - The production constructor has no path parameter. `TryGetDefaultPath` + resolves only + `%LOCALAPPDATA%\CodexControlOtherDevices\logs\trayhost-actions.log`. + - Opens LocalAppData, product, and logs through `CreateFileW` directory + handles using backup-semantics/open-reparse flags, `FILE_LIST_DIRECTORY`, + attribute/security read access, read/write sharing, and no delete sharing. + - Validates the actual opened directory handles for disk type, directory and + non-reparse attributes, expected final path, and (for product/logs) exact + current-user/SYSTEM/Administrators owner/DACL policy. + - Opens the fixed leaf through `CreateFileW(OPEN_ALWAYS)` with open-reparse + and write-through flags plus read sharing only. It rejects non-disk, + directory, reparse, device, multi-link, unexpected-final-path, query, and + sharing failures before any content write or truncation. + - Reads length, performs rollover with `SetLength(0)`, writes the complete + UTF-8 no-BOM record, and calls `Flush(true)` through the already validated + leaf handle. It returns `true` only after the flush succeeds. +- `src/trayhost/Program.cs` + - Owns one `TrayTerminalDiagnosticStore` and passes + `TryAppendDurably` to the existing transport callback. + - Disposes the store during the existing shutdown cleanup. + - No command-line input, protocol type, wire field, direction, sequence, or + authentication rule changed. +- `tests/trayhost/TrayHostNativeSelfTest.cs` + - Added exact UTF-8/sanitization and fixed-production-path checks. + - Added temporary-root reparse-directory, reparse-leaf, ordinary-directory + leaf, hard-link leaf, unsafe DACL, directory-replacement barrier, concurrent + writer, exact 64-KiB, crossing-rollover, and oversized-file checks. + - The directory replacement and hard-link cases retain an outside sentinel + byte-for-byte. +- `tests/trayhost/Invoke-TrayHostSelfTest.ps1` + - Defines `TRAYHOST_SELF_TEST` only for the temporary NativeOnly test + executable. The temporary-root factory and barriers are compiled out of + every production build and do not create a production CLI, environment + switch, listener, or external control surface. + +## TDD evidence + +### Clean baseline + +Command: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -NativeOnly +``` + +Output before test changes, exit `0`: + +```text +TrayHost native self-tests passed: 17 +``` + +### Initial RED + +The behavioral test surface was added before production code. + +Command: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -NativeOnly +``` + +Exit `1`; exact decisive output: + +```text +tests\trayhost\TrayHostNativeSelfTest.cs(66,20): error CS0246: ... TrayTerminalDiagnosticStore ... +CCOD_TRAYHOST_NATIVE_COMPILE_FAILED +``` + +This was the expected missing-feature failure: the current implementation did +not provide a store that owned native handles or exposed the temporary-only +barrier contract. + +### Directory replacement behavior RED and native root cause + +After the first minimal implementation compiled, the barrier remained red: + +```text +TrayHost native self-test failed: System.InvalidOperationException +the opened logs handle denies rename replacement +CCOD_TRAYHOST_NATIVE_TEST_FAILED +``` + +A temporary in-memory Win32 probe isolated the cause. With the same +no-delete-share flags, an attribute-only directory handle allowed +`Directory.Move`; adding `FILE_LIST_DIRECTORY` made the move fail with sharing +violation `0x80070020`: + +```text +ReadAttributes : move-succeeded +ListDirectory : IOException:80070020 +Delete : IOException:80070020 +GenericRead : IOException:80070020 +ReadAttributesDelete : IOException:80070020 +``` + +The production directory open therefore requests the minimal +`FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES | READ_CONTROL` access needed for +the no-delete-share pin to enforce the approved barrier. + +### ACL RED + +An additional test added an Everyone full-control ACE to the existing logs +directory. Before handle-based ACL validation, NativeOnly exited `1` with: + +```text +TrayHost native self-test failed: System.InvalidOperationException +an unexpected logs DACL is rejected before writing +CCOD_TRAYHOST_NATIVE_TEST_FAILED +``` + +The green implementation queries the security descriptor through the opened +directory handle with `GetSecurityInfo`. It never repairs an existing +unexpected ACL and rejects it before opening or creating the receipt leaf. + +## Fresh GREEN evidence + +All commands below were rerun after the last source/test change. + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -NativeOnly +``` + +Exit `0`: + +```text +TrayHost native self-tests passed: 21 +``` + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionOnly +``` + +Exit `0`, no output. This compiled the complete production TrayHost and ran +`--headless-smoke` without the self-test symbol. + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -TransportOnly +``` + +Exit `0`: + +```text +TrayHost transport self-tests passed: 10 +``` + +This preserves the existing rule that a `false` persistence result cannot +produce About/failure feedback. + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProtocolOnly +``` + +Exit `0`: + +```text +TrayHost protocol self-tests passed: 11 +``` + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ParentClientOnly +``` + +Exit `0`: + +```text +TrayHost parent-client self-tests passed: 4 +TrayHost production correlation trace passed: 2 +``` + +The ParentClient run also printed the repository's existing PowerShell warning +about unapproved verbs in `TrayHostClient`; no command in this task changed +that module or warning surface. + +```powershell +git diff --check +``` + +Exit `0`; only Git's existing LF-to-CRLF conversion notices were printed. + +## Commit + +- `365fde2430723fc8f80106e86490f7fb70be163f` — + `fix: pin tray terminal receipt storage` + +## Concerns and explicit boundaries + +- `Program` still invokes the receipt callback synchronously because the + bounded background sink is Task 2. This commit fixes filesystem identity, + pinning, safety, and bounded storage only; it does not yet make the + authenticated reader non-blocking. +- Existing product/log directories with a different owner or DACL fail closed + and are not repaired. This follows the approved design, but a pre-existing + inherited/broadened directory will suppress receipt durability and therefore + generic feedback until a separately authorized migration or repair policy is + designed. +- The private ACL, reparse, hard-link, and sharing controls contain accidental + replacement and unsafe filesystem objects. They are not represented as a + security boundary against a fully hostile process already running as the + same Windows user. +- No installer, installed Codex process, real tray UI, push, release, or public + operation was run. Release-wide and real-machine acceptance remain outside + Task 1. + +## Fix round 1/5: inherited compatibility parents + +Review finding: the first implementation required the existing product and +`logs` directories to have the protected receipt-private ACL. Supported +current and legacy installations create those parents with normal inherited +ACLs, so production receipt writes returned `false` before opening a leaf. + +### Compatibility ruling and implementation + +- The fixed production leaf moved from the legacy direct path to + `%LOCALAPPDATA%\CodexControlOtherDevices\logs\tray-receipts\trayhost-actions.log`. +- LocalAppData, product, and `logs` remain pinned compatibility parents. Their + actual opened handles must still prove disk-directory type, non-reparse + attributes, and the exact final path, and the handles continue to deny + delete sharing while the receipt child and leaf are opened and written. +- The store no longer creates product or `logs`; supported installation/runtime + setup remains responsible for those parents. A missing, wrong-type, + reparse-point, or unqueryable parent fails closed. +- Only the fixed `logs\tray-receipts` child is created with the protected + current-user/SYSTEM/Administrators ACL. Its reopened native handle must pass + the strict owner/DACL, non-reparse, type, and final-path checks. An existing + arbitrary or broadened child remains rejected and is never repaired. +- The direct legacy `logs\trayhost-actions.log` leaf is never opened, reused, + truncated, or written. + +### RED evidence + +The compatibility test precreated product and `logs` with normal inherited +ACLs, asserted `AreAccessRulesProtected == false`, placed a byte sentinel in +the legacy direct leaf, and requested a durable receipt. Before the production +fix, this command exited `1`: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -NativeOnly +``` + +Exact decisive output: + +```text +TrayHost native self-test failed: System.InvalidOperationException +inherited supported parents accept a durable receipt in the dedicated private child +CCOD_TRAYHOST_NATIVE_TEST_FAILED +``` + +This is the reviewed compatibility defect: the old store rejected supported +inherited parents before it could create a safe receipt boundary. + +### GREEN and regression evidence + +After the change, the compatibility test proves durable success, exact private +ACL creation on `tray-receipts`, exact UTF-8 content in the new leaf, and +byte-identical retention of the direct legacy leaf. Existing negative coverage +was retained or moved to the correct boundary: reparse compatibility parent, +reparse receipt child, reparse/directory/hard-link leaf, broadened receipt-child +ACL, concurrent writer, replacement barrier/outside sentinel, and exact or +crossing 64-KiB rollover. + +Fresh pre-commit commands and results: + +```text +NativeOnly: exit 0, TrayHost native self-tests passed: 22 +ProductionOnly: exit 0, headless compile/run emitted no output +TransportOnly: exit 0, TrayHost transport self-tests passed: 10 +ProtocolOnly: exit 0, TrayHost protocol self-tests passed: 11 +ParentClientOnly: exit 0, TrayHost parent-client self-tests passed: 4 +Production trace: TrayHost production correlation trace passed: 2 +git diff --check: exit 0 (only repository LF-to-CRLF notices) +``` + +The ParentClient command again emitted the repository's existing unapproved- +verbs warning for `TrayHostClient`; this fix did not modify that module. + +### Fix-round commit and remaining boundary + +- `f207496df1ceee744a09bbc5c3d24a48695e58bc` — + `fix: preserve tray receipt parent compatibility` + +Task 1 now supports the inherited parent ACLs created by existing +installations while retaining a dedicated fail-closed private receipt child. +The asynchronous/non-blocking sink remains Task 2. No installer, installed +process, real UI, release, push, or public operation was run in this fix round. diff --git a/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-2-report.md b/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-2-report.md new file mode 100644 index 0000000..86f9638 --- /dev/null +++ b/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-2-report.md @@ -0,0 +1,359 @@ +# Task 2 report: asynchronous bounded tray terminal receipts + +Date: 2026-08-28 + +Worktree: `C:\Users\33384\Documents\Codex-Control-other-devices-Windows\.worktrees\codex-v2522-install-runtime-reliability` + +Branch: `codex/v2522-install-runtime-reliability` + +## Status + +Task 2 is implemented and its focused transport/native acceptance is green. +The authenticated reader now consumes a correlated terminal result before one +zero-wait sink submission; receipt store I/O and durable UI publication run on +one bounded background writer. This report does not claim the Task 3 shared +child-session/full production trace or the Task 4 release gate. + +## Implementation + +- `src/trayhost/TrayTerminalReceiptSink.cs` + - Adds a typed `TrayTerminalReceipt` containing the correlated immutable + action result plus the existing sanitized `TrayTerminalDiagnostic` used by + the accepted handle-pinned store. + - Binds each receipt to the `HostTransport` that correlated it and permits + publication at most once. + - Starts exactly one background receipt writer. No error, timeout, or hang + starts a replacement writer. + - `TrySubmit` uses a non-waiting admission gate and admits at most eight + outstanding receipts, including the receipt currently inside store I/O or + its durable callback. + - Store `false`/throw and callback `false`/throw drop only feedback and allow + the same writer to process later receipts. + - Disposal closes admission, clears queued work, suppresses late callbacks, + and joins the one writer for at most 100 ms. If store I/O remains hung, the + original background writer owns deferred store cleanup after it eventually + returns; shutdown never starts or waits for a replacement. +- `src/trayhost/HostTransport.cs` + - `TryAcknowledgeAction` validates correlation under the existing lock, + creates the typed terminal receipt, removes the pending action, and returns + without file I/O or capacity waiting. + - `TryPublishDurableReceipt` accepts only a once-publishable receipt from the + same transport. Completed About maps to About work; rejected/failed maps to + generic failure; all other completed commands map to no UI. + - About and generic feedback share one eight-item UI bound. Queue or callback + failure consumes the publication but leaves no retryable feedback. + - UI work is provisional while the application-post callback is unresolved. + Consumers return immediately without seeing it; callback failure removes + it, while callback success makes it ready. If a posted work message probes + the provisional item before callback return, the transport posts one + replacement work message after marking it ready. Replacement-post failure + removes the ready item and returns its shared UI capacity. + - Presentation and receipt callbacks are separate. Application receipt work + posting remains outside the transport correlation lock. +- `src/trayhost/Program.cs` + - Composes one existing `TrayTerminalDiagnosticStore`, one `HostTransport`, + and one `TrayTerminalReceiptSink`. + - The authenticated reader calls correlation and one `TrySubmit`, ignores + admission failure after the action is consumed, and no longer posts UI work + immediately for terminal wire results. + - The durable callback is the only terminal path that posts About/failure + work. Its production callback checks the actual `PostMessage` result and + fails closed before UI visibility when posting fails. Sink disposal + precedes window/transport disposal, and the sink owns safe deferred store + disposal if its writer is hung. + - No command-line selector, message type, wire field, direction, sequence, + capability, authentication, or storage-security rule changed. +- `tests/trayhost/TrayHostTransportSelfTest.cs` + - Retains the parent/presentation/pending/replay/result-correlation coverage + and replaces the synchronous-writer expectations with Task 2 behavior. + - Uses a controllable fake store that blocks before open, during write, and + during flush. + - Covers current and following terminal returns under 250 ms, pending removal + before submission, eight outstanding including current, immediate ninth + drop, deterministic busy admission, closed admission, store failure, + callback failure, provisional UI invisibility, early-message repost, later + recovery on the same writer, shared UI bound, trusted once-only + publication, and hung-store disposal. + - The blocked following result is completed About, while the current result + is failed OpenLogs, so both About and generic feedback are proven absent + until explicit durable success. +- `tests/trayhost/TrayHostNativeSelfTest.cs` + - Adds a fake-native-window integration test using the real handle-pinned + store through the asynchronous sink. Generic native feedback is unavailable + before durable success and appears exactly once afterward. +- `tests/trayhost/Invoke-TrayHostSelfTest.ps1` + - Adds the new source to the existing temporary transport/native compilation + list. Production compilation already collects every `src/trayhost/*.cs`. +- `tests/trayhost/TrayHostParentClientSelfTest.cs` + - Migrates the current temporary peer caller to the typed receipt/sink API so + the complete production source set compiles. This is only an API adaptation; + it is not the Task 3 shared production child-session trace. + +## TDD evidence + +### Clean baseline + +Before test changes: + +```text +TransportOnly: exit 0, TrayHost transport self-tests passed: 10 +NativeOnly: exit 0, TrayHost native self-tests passed: 22 +``` + +### RED + +The new behavioral tests and temporary compile list were written before the +new production source/API. + +Command: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -TransportOnly +``` + +Exit `1`; decisive output: + +```text +CCOD_TRAYHOST_SOURCE_MISSING +FullyQualifiedErrorId : CCOD_TRAYHOST_SOURCE_MISSING +``` + +This was the expected missing-feature RED: the planned +`TrayTerminalReceiptSink.cs` and typed receipt API did not yet exist. No +production source had been changed when this run was captured. + +### Callback-visibility RED + +Post-commit concurrency review identified that the first implementation placed +UI work in a consumable queue before its application callback returned. A +callback that later threw could therefore race a queue consumer. A test held a +callback that would throw and probed the UI queue concurrently. Before the +two-phase UI work fix, TransportOnly exited `1` with: + +```text +TrayHost transport self-test failed: System.InvalidOperationException +provisional UI work returns immediately but remains unavailable while callback outcome is unknown +CCOD_TRAYHOST_NATIVE_TEST_FAILED +``` + +The fixed transport keeps the item provisional and nonblocking until callback +success, or removes it before visibility on callback failure. + +### Early-work-message repost RED + +The provisional design also needs to recover when the posted work message is +processed before the callback returns and before the item becomes ready. The +repost behavior was removed before its test was added. Without the repost, +TransportOnly exited `1` with: + +```text +TrayHost transport self-test failed: System.InvalidOperationException +an early work-message probe causes one replacement UI post +CCOD_TRAYHOST_NATIVE_TEST_FAILED +``` + +The green implementation records the early probe, marks the item ready only +after callback success, and posts one replacement message without duplicating +the UI item. + +### Replacement-post failure RED + +A final callback-failure test made the replacement post throw after the first +post succeeded but was processed while the item remained provisional. Before +the fail-closed replacement handling, TransportOnly exited `1` with: + +```text +TrayHost transport self-test failed: System.InvalidOperationException +replacement callback failure reports publication failure +CCOD_TRAYHOST_NATIVE_TEST_FAILED +``` + +The green path removes the ready-but-unnotified item, returns `false`, and +retains no latent UI work or occupied UI capacity. + +### Focused GREEN and stability + +After the final source/test change, the focused gates were rerun: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -TransportOnly +``` + +Exit `0`: + +```text +TrayHost transport self-tests passed: 13 +``` + +The final transport suite was also run ten consecutive times in one stability +probe; all ten runs reported `TrayHost transport self-tests passed: 13` with +exit `0`. + +The first attempted ten-run probe exposed a test-helper defect: `WaitUntil` +evaluated a consuming `TryTake*` condition twice, so the second evaluation saw +an already-consumed queue and reported a false failure. The helper now caches +the successful evaluation; no production change was made for that incident, +and the complete ten-run probe above was rerun from the start. + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -NativeOnly +``` + +Exit `0`: + +```text +TrayHost native self-tests passed: 23 +``` + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionOnly +``` + +Exit `0`, no output. This compiles every production TrayHost source, including +the new sink and all current production callers, then runs `--headless-smoke`. + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProtocolOnly +``` + +Exit `0`: + +```text +TrayHost protocol self-tests passed: 11 +``` + +No wire implementation file changed; the protocol run is the byte-level +regression gate for the existing schema/directions/sequences. + +```powershell +git diff --check +``` + +Exit `0`. Git printed only repository LF-to-CRLF conversion notices. + +## Diagnostic-only run, not Task 3 acceptance + +`ParentClientOnly` was run once after adapting its current temporary caller and +exited `0`. That command still uses the pre-Task-3 fake peer/correlation trace; +it does not exercise the approved shared `TrayHostChildSession` seam and is not +counted as production-trace acceptance here. + +## Commit + +- `7549dde154fd6c2430a13aa9ddcf66698560f717` — + `fix: isolate tray terminal receipt writes` +- `1f1ecc5f72fbbb1c070d62a745b36be003b50506` — + `fix: hide provisional tray receipt UI` +- `1212579676a1b4773450ed961094a02c6322d0f7` — + `fix: drop failed tray receipt reposts` + +## Concerns and explicit boundaries + +- Task 3 remains required to extract the shared child session and prove current + and stale results through the real authenticated ParentClient/PowerShell + path with a temporary fake native runtime. The existing fake-peer diagnostic + is not that proof. +- The legacy `tests/persistence/Supervisor.SelfTest.ps1` correlation bridge has + an explicit pre-Task-2 source list and old synchronous HostTransport calls. + It is a test-only caller, not production source, and is intentionally deferred + for the Task 3 trace/bridge migration. The full persistence suite is therefore + not claimed by Task 2. +- Task 4 release-workflow/provenance gating and release-wide validation remain + deferred. The production build itself discovers every TrayHost `.cs` source, + so the new sink is compiled without a new runtime selector. +- A receipt store that never returns leaves the one background writer alive + until process termination; this is the approved no-replacement failure mode. + Admission and pending-action capacity remain bounded and responsive. +- No installer, installed Codex process, real tray UI, push, release, install, + signing, or public operation was run. + +## Fix round 1/5: tokened STA visibility and atomic callback admission + +Review found two remaining races in the first Task 2 implementation: + +1. A receipt became consumable before its replacement UI-post callback had + returned successfully. An unrelated ordinary `WmApp+2` drain could display + the item before a later callback failure attempted to retract it. +2. The sink checked `_closed` and then invoked the durable callback separately. + `Dispose` could close and return in that gap before a new callback began. + +### Implementation + +- `HostTransport` now assigns each bounded receipt UI item one nonzero, + process-randomized `uint` token and keeps the item uncommitted while its + token-post callback is unresolved. +- Receipt UI lives in one token-keyed eight-item map shared by About and + failure. Ordinary no-token drains always return no item; only + `TryTakeReceiptUi(exactToken, ...)` can consume a committed receipt. +- An early exact-token delivery records an observation without exposing UI. + The transport permits one bounded replacement post while the item remains + uncommitted. A false/throwing post, a second early delivery, disposal, or an + invalid token retracts the item before any UI result is returned. +- `TrayHostApplication` owns internal `WmApp+3` receipt-token posting and + decoding. `WmApp+2` remains exclusively the presentation/shutdown work path. + The pipe protocol, CLI inputs, authenticated message types, and wire fields + are unchanged. +- `Program` removed receipt drains from ordinary work and maps only an exact + `WmApp+3` token to About or generic failure. Shutdown closes the sink, then + disposes the transport before destroying the window. +- `TrayTerminalReceiptSink` now sets an explicit `_callbackClaimed` state under + the same `_gate` used by `Dispose`, before invoking the callback, and clears + it in `finally`. Once close wins the gate, no new callback claim succeeds. + An already-started callback may finish, but a disposed transport rejects its + token publication. +- The callback-handoff barrier is compiled only into the temporary + `TRAYHOST_RECEIPT_SELF_TEST` TransportOnly executable. No production test + selector, environment switch, listener, or external control was added. + +### TDD RED evidence + +The existing shared-drain race was first captured behaviorally. Before the +tokened path, TransportOnly exited `1` with: + +```text +TrayHost transport self-test failed: System.InvalidOperationException +ordinary work cannot expose or display receipt UI while the replacement callback is unresolved +CCOD_TRAYHOST_NATIVE_TEST_FAILED +``` + +The durable-return/callback-begin handoff was then blocked deterministically. +Before atomic callback admission, TransportOnly exited `1` with: + +```text +TrayHost transport self-test failed: System.InvalidOperationException +close gate prevents any new durable callback from starting after Dispose +CCOD_TRAYHOST_NATIVE_TEST_FAILED +``` + +The desired token APIs were also written before implementation. TransportOnly +then failed compilation because the token-aware callback, +`TrayTerminalReceiptUiKind`, and `TryTakeReceiptUi` did not exist; NativeOnly +failed because `TrayHostApplication.TryDecodeReceiptWorkToken` did not exist. + +The final deterministic NativeOnly race delivers the first `WmApp+3` token +before its post returns, blocks the replacement token post, concurrently +dispatches ordinary `WmApp+2`, then throws the token callback. It asserts zero +generic dialogs before and after stale-token replay. + +### Fresh GREEN and stability evidence + +All commands below were run after the final source/test change: + +```text +TransportOnly: 10 consecutive runs, each exit 0, TrayHost transport self-tests passed: 16 +NativeOnly: 5 consecutive runs, each exit 0, TrayHost native self-tests passed: 25 +ProductionOnly: exit 0, complete production compile plus --headless-smoke, no output +ProtocolOnly: exit 0, TrayHost protocol self-tests passed: 11 +git diff --check: exit 0; only repository LF-to-CRLF conversion notices +``` + +### Commit and remaining boundary + +- `85882890202f420479728a9ed1532f2085b970c8` — + `fix: seal tray receipt dispatch races` + +Task 3 still owns the shared child-session/authenticated production trace and +the legacy Supervisor correlation-bridge migration; neither is claimed by this +fix round. Task 4 release gating also remains deferred. No installer, installed +Codex process, real tray UI, push, release, signing, install, or public operation +was run. diff --git a/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-3-report.md b/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-3-report.md new file mode 100644 index 0000000..af3ef87 --- /dev/null +++ b/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-3-report.md @@ -0,0 +1,227 @@ +# Task 3 report: authenticated child-session production trace + +## Outcome + +Task 3 now runs the real authenticated TrayHost child session in temporary +child processes without adding a shipped test selector or using +`TrayHostParentClient.TestProcessFactory`. + +- `Program.RunChild` is a thin argument/identity/Windows-runtime adapter. +- `TrayHostChildSession` owns the existing bootstrap, directional keys, + authenticated reader/writer sequences, action registration, presentation and + shutdown work, async receipt admission, and token-bound UI drain. +- `WindowsTrayHostRuntime` composes the unchanged `Win32TrayPlatform`, + `TrayWindow`, and `TrayHostApplication` behavior. +- `TrayHostProductionTraceSelfTest.cs` is compiled only under the runner's + temporary directory. Its fake native platform remains under `tests/`; no + test runtime, scenario, witness, macro, or selector is present in + `src/trayhost`. +- The old ParentClient fake peer is again only a narrow authenticated + parent-writer test. It no longer calls production dispatch, HostTransport + receipt drains, or the dialog directly. +- The deferred Supervisor correlation bridge now uses the real async + `TrayTerminalReceiptSink` and `TrayHostApplication` token dispatch rather + than the removed synchronous `TryTakeFailedAction` path. + +## End-to-end evidence + +### Current action + +The trace launches a distinct requested executable through the normal +`TrayHostParentClient.Start` path. After the real parent/child identity and +authenticated handshake, the temporary runtime calls the real +`TrayWindow.HandleContextMenu`; its fake `INativeTrayPlatform` returns +`OpenLogs` from `TrackPopupMenuEx`. The shared child session registers and +writes that exact action. The real PowerShell `Receive-CcodTrayHostEvents` +delivers it to `Invoke-CcodSupervisorCommand`, whose in-memory `OpenLogs` +adapter runs once and whose terminal result returns through +`Send-CcodTrayHostActionResult` and the real ParentClient writer. + +The child reader authenticates and correlates the completion, the real async +sink flushes exactly one sanitized receipt, and `HostTransport` publishes no +receipt UI for a completed `OpenLogs`. The witness contains no dialog, no +duplicate action, no fault, and the normal parent-requested shutdown receives +an authenticated acknowledgement. + +### Stale authenticated negative + +The separately compiled temporary stale fixture emits an explicit revision-8 +`OpenLogs` through the same child-session command delegate; this is test-only +and does not use a runtime-id convention or production selector. The real +PowerShell client receives the authenticated action. Because only revision 1 +is acknowledged, `Invoke-CcodSupervisorCommand` rejects it with +`CCOD_TRAY_ACTION_STALE` before the `OpenLogs` adapter and sends the exact +correlated terminal result through ParentClient. + +The child consumes the pending action, flushes one receipt containing only +`command`, `revision`, `status`, and canonical `code`, then posts a nonzero +token on `WmApp+3`. The test-only message pump invokes the real +`TrayHostApplication` handler; only `TryTakeReceiptUi(exactToken, ...)` reaches +the runtime's `TrayWindow.ShowActionFailed` delegation. The witness asserts +the order `durable receipt -> token post -> generic dialog`, exactly one dialog +with caption `trace-0` and text `trace-15`, and no internal code or correlation +identifier in that dialog. Normal authenticated shutdown then completes. + +## TDD RED evidence + +The real-process trace and runner were written before the production seam. +Running the required command: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/TrayHostProductionTrace.SelfTest.ps1 +``` + +exited 1 with the expected missing-seam failure: + +```text +tests\trayhost\TrayHostProductionTraceSelfTest.cs(84,46): error CS0246: ITrayHostRuntime +CCOD_TRAYHOST_PRODUCTION_TRACE_COMPILE_FAILED +``` + +No `src/trayhost` file had been changed when this RED was captured. + +Running the complete Supervisor fixture then exposed the Task 2 report's +explicitly deferred legacy bridge migration. It exited 1 at +`Supervisor.SelfTest.ps1:818`: `HostTransport.cs(107)` could not resolve +`TrayTerminalReceipt` because the pre-Task-2 Add-Type list omitted the async +sink, and the bridge still used the old synchronous drain. Adding the actual +sink/application sources and migrating the bridge to token dispatch made the +same fixture pass 98 tests. + +## Fresh GREEN and stability evidence + +All commands below ran on the final production/test source tree and exited 0: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/TrayHostProductionTrace.SelfTest.ps1 +# TrayHost production child-session trace passed: 2 + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ParentClientOnly +# TrayHost parent-client self-tests passed: 4 + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/TrayHostClient.SelfTest.ps1 +# 9 cases passed; TrayHost client self-tests passed. + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -NativeOnly +# TrayHost native self-tests passed: 25 + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -TransportOnly +# TrayHost transport self-tests passed: 16 + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProtocolOnly +# TrayHost protocol self-tests passed: 11 + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionOnly +# complete production compile and --headless-smoke, exit 0 + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/Supervisor.SelfTest.ps1 +# Supervisor self-tests passed: 98 + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/TrayHostBuild.SelfTest.ps1 +# 6 build/provenance cases; TrayHost build self-tests passed. +``` + +The direct production-trace command was additionally run three consecutive +times on the final tree. Every iteration launched both current and stale child +processes and printed `TrayHost production child-session trace passed: 2`. + +`git diff --check` exited 0; Git printed only the repository's expected +LF-to-CRLF conversion notices. + +## Production CLI and Task 2 boundary + +The trace runner separately compiles the production executable with +`/main:Program` and invokes it with `--production-trace-test-selector`; the +test requires exit code 2. A final source audit found no trace selector, +`TRAYHOST_TRACE` define, trace runtime-id convention, or test-factory +assignment in `Program.cs`, `TrayHostChildSession.cs`, or +`WindowsTrayHostRuntime.cs`. `Program.Main` still recognizes only the existing +single `--headless-smoke` form and seven-argument `--child` form. + +`HostTransport`, `TrayTerminalReceiptSink`, `TrayHostWire`, +`TrayHostParentClient`, and the PowerShell wire surface were not changed. +Native 25/25 and transport 16/16 confirm the Task 2 tokened `WmApp+3` +publication, provisional-visibility, repost, disposal, and failure behavior +remains green. + +## Remaining risks and safety boundary + +- The broad trace deliberately uses a fake native platform and a temporary + handle-flushed witness store. It proves the shared session, authenticated + wire, ParentClient, PowerShell client/Supervisor decision, async sink, + tokened application work, and generic dialog mapping; it is not an installed + desktop automation or real shell-notification-area test. +- Native receipt-store pinning/reparse/hard-link/rollover behavior remains + covered by the focused Task 1/native tests, not duplicated here. +- Installer, installed-runtime, Defender, release workflow, signing, and + independent review remain later acceptance boundaries. +- No real UI, Codex process, installer, installation, release, push, signing, + or public operation was started by Task 3. + +## Fix round 1/5: ShutdownAck is not process-exit proof + +Independent review found that the first trace treated `ShutdownAck` as natural +child termination. `TrayHostParentClient.ReaderLoop` enqueued `Exited` and set +its stopped event immediately after reading the authenticated ACK, while +`Dispose` subsequently terminated the child job. A child that wrote the ACK +and then hung could therefore pass the trace before being force-killed. + +### Deterministic RED + +The trace runner now compiles a third temporary executable with a test-only +`TRAYHOST_TRACE_HANG_AFTER_ACK` define. It uses the same child session, +handshake, authenticated action/result, and shutdown wire. Its fake runtime +records that the shared child session has written the shutdown ACK but +deliberately does not return from its run loop. The PowerShell test retains the +actual process identified by `TrayHostStartReceipt.HostPid` before `Dispose` +and asserts that ACK alone cannot publish `Exited` or satisfy +`WaitForStopped` while that process is still alive. + +Before the production fix, the required trace command exited 1 with the exact +behavioral failure: + +```text +ASSERT_EQUAL: ShutdownAck alone cannot satisfy WaitForStopped while the child is alive expected=[False] actual=[True] +CCOD_TRAYHOST_PRODUCTION_TRACE_FAILED +``` + +### Production correction + +`TrayHostParentClient` now treats the authenticated ACK only as permission to +wait for graceful termination. Its reader performs a bounded wait on the exact +launched `Process` and requires exit code 0 before it enqueues `Exited` and +signals stopped. A timeout, inaccessible process, or nonzero exit becomes the +existing bounded `CCOD_TRAYHOST_TRANSPORT_FAILED` fault; `Dispose` may then use +the existing job cleanup, but that cleanup can no longer masquerade as natural +exit. + +The normal current and stale cases retain their child `Process` handles and +independently require `WaitForExit(4000)` and exit code 0 before calling +`Dispose`. The hung-after-ACK case requires a 250 ms stopped wait to return +false, no `Exited` event, and a still-live child; after the bounded parent wait +it requires the transport fault while the child remains live until explicit +cleanup. + +No production CLI argument, listener, environment switch, external control +surface, wire field, message type, or authentication rule was added or +changed. The hang behavior and compile define exist only in the temporary +test executable. + +### Fresh fix-round verification + +All covering commands ran after the correction and exited 0: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/TrayHostProductionTrace.SelfTest.ps1 +# TrayHost production child-session trace passed: 3 + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ParentClientOnly +# TrayHost parent-client self-tests passed: 4 + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionOnly +# complete production compile plus --headless-smoke, exit 0 +``` + +`git diff --check` exited 0 with only the repository's expected LF-to-CRLF +conversion notices. No real UI, Codex process, installer, release, install, +signing, push, or public operation ran in this fix round. diff --git a/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-4-report.md b/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-4-report.md new file mode 100644 index 0000000..4aba45b --- /dev/null +++ b/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-4-report.md @@ -0,0 +1,226 @@ +# Task 4 report: fail-closed release trace and TrayHost provenance + +## Outcome + +Task 4 closes both release-boundary gaps without changing the TrayHost wire, +CLI, authentication, installer, version metadata, or installed runtime. + +- CI job `validate` and release job `build` each contain exactly one step named + `Run authenticated TrayHost production trace`, with `shell: pwsh` and the + exact run value + `./tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly`. +- The release trace is immediately before the asset-producing build step and + structurally precedes the real PowerShell command + `./build/build.ps1 -Version $version`. +- `Test-CcodTrayHostArtifact` now compares provenance with the exact current + `src/trayhost/*.cs` name-to-SHA-256 set. Missing records, count differences, + duplicate names, unexpected or case-different names, malformed hashes, and + name/hash mismatches fail with `CCOD_TRAYHOST_SOURCE_TAMPERED`. +- The production validator never resolves a path supplied by provenance. It + hashes the enumerated current sources first, stores them in an ordinal + dictionary, and validates records against that bounded set. + +Implementation commits: + +```text +1743de6 test: gate release on tray receipt trace +f0e388e fix: reject bypassed tray trace gates +``` + +## TDD RED evidence + +### Release workflow gate + +Only the structural release test had been changed when this command ran: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/ReleaseWorkflow.SelfTest.ps1 +``` + +It exited 1 after all preceding release/manifest/Defender cases passed: + +```text +CCOD_SELFTEST_FAILED case=CI-and-release-build-jobs-uniquely-gate-asset-production-on-the-authenticated-TrayHost-trace error=CCOD_RELEASE_TRACE_GATE_INVALID +CCOD_RELEASE_TRACE_GATE_INVALID +``` + +No workflow production file had been changed before this RED. Adding the exact +CI/release steps then made the same command exit 0. + +### Exact source provenance + +The provenance fixture and negative tests were added before changing +`build/TrayHostBuild.psm1`. The same required ReleaseWorkflow command exited 1: + +```text +CCOD_SELFTEST_FAILED case=TrayHost-artifact-validation-requires-the-exact-compiled-source-name-and-hash-set error=ASSERT_THROWS +ASSERT_THROWS: expected CCOD_TRAYHOST_SOURCE_TAMPERED +``` + +The old verifier had accepted provenance with `TrayHostChildSession.cs` +removed. After the exact-set validator was implemented, the same command +exited 0 and all four provenance mutations were rejected. + +## Workflow evidence + +- `.github/workflows/ci.yml:53-55` contains the sole CI trace step under + `jobs.validate.steps`. +- `.github/workflows/release.yml:68-70` contains the sole release trace step + under `jobs.build.steps`. +- `.github/workflows/release.yml:72-78` places it before the build step and + the AST-confirmed `build.ps1 -Version` invocation. +- `ReleaseWorkflow.SelfTest.ps1` parses job and step indentation rather than + searching the whole file. It parses each PowerShell `run` body into an AST, + counts the real trace/build commands, and compares their step indexes. +- Controlled negative YAML fixtures reject nonempty `if` and + `continue-on-error` modifiers, a comment-only decoy, a trace in `publish`, a + post-build trace, an anonymous pre-build command, duplicate named steps, and + a duplicate trace invocation under a different name. Explicitly empty or + absent execution modifiers remain acceptable. + +The checked-in workflows each had one trace name and one exact trace run value +at final review. + +## Provenance evidence + +The final source enumeration contained 19 `.cs` files. Both the lightweight +release fixture and the real compiled artifact test cover: + +- omission of `TrayHostChildSession.cs`; +- a same-count duplicate of `TrayHostChildSession.cs` replacing + `WindowsTrayHostRuntime.cs`; +- a changed source-name set; +- a wrong hash for `WindowsTrayHostRuntime.cs`. + +The real build test also requires the provenance names to equal the canonical +sorted source names and explicitly requires both new production source files. +The validator uses ordinal name comparison and canonical lowercase 64-digit +SHA-256 values. + +## Command ledger + +### Required/focused tests + +| Command | Result | +| --- | --- | +| `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/ReleaseWorkflow.SelfTest.ps1` | RED 1: exit 1, missing workflow gate (`CCOD_RELEASE_TRACE_GATE_INVALID`). | +| same ReleaseWorkflow command after workflow change | exit 0. | +| same ReleaseWorkflow command after provenance test, before verifier change | RED 2: exit 1, missing source record was wrongly accepted (`ASSERT_THROWS`). | +| final same ReleaseWorkflow command | exit 0; release workflow self-tests passed, including execution-modifier bypasses, structural decoys, and provenance mutations. | +| `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/TrayHostBuild.SelfTest.ps1` | exit 0; six build/provenance cases passed against a real compiled artifact. | +| `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly` | exit 0; `TrayHost production child-session trace passed: 3`. | +| `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/InstalledLifecycleHarness.SelfTest.ps1` | exit 0; 20 controlled adapter/temp-root scenarios passed. | +| all repository `.ps1`/`.psm1` files through `System.Management.Automation.Language.Parser` | exit 0; 92 files parsed. | +| all `src` JS/MJS files through `node --check` | exit 0; 5 files parsed. | +| `node.exe tests/CleanroomSelfTest.js` | exit 0; JSON result `ok: true`, 19 cases. | +| `node.exe tests/PackageCheckerSelfTest.mjs` | exit 0. | +| `git diff --check` | exit 0; only the repository's existing LF-to-CRLF warnings. | + +## Fix round 1/5: execution modifiers cannot bypass the trace gate + +Independent review found that the first workflow structure retained only +`Name`, `Shell`, and `Run`. A required trace step could therefore keep every +checked field while adding `if: ${{ false }}` or +`continue-on-error: true`; the former skips the trace and the latter tolerates +its failure. + +Two deterministic temporary YAML fixtures were added before changing the +parser or contract. Running the required command: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/ReleaseWorkflow.SelfTest.ps1 +``` + +exited 1 after the real workflow and all preceding release/provenance checks +passed: + +```text +CCOD_SELFTEST_FAILED case=authenticated-trace-workflow-gate-rejects-comments-wrong-jobs-duplicates-and-post-build-placement error=ASSERT_THROWS +ASSERT_THROWS: expected CCOD_RELEASE_TRACE_GATE_INVALID +``` + +This was the expected RED: the old contract accepted the first conditional +trace fixture. The step model now retains `If` and `ContinueOnError` from both +inline and following YAML keys. The exact required trace step must have both +values empty; any nonempty literal or expression fails with +`CCOD_RELEASE_TRACE_GATE_INVALID`. A positive fixture proves explicitly empty +modifiers remain accepted, while the existing valid workflow proves absence +remains accepted. + +Fresh fix-round verification on the final implementation tree: + +| Command | Result | +| --- | --- | +| `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/ReleaseWorkflow.SelfTest.ps1` | exit 0; both execution-modifier bypass fixtures rejected and all release checks passed. | +| `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/TrayHostBuild.SelfTest.ps1` | exit 0; six build/provenance cases passed. | +| `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly` | exit 0; production child-session trace passed 3 cases. | + +Fix-round scope was limited to +`tests/persistence/ReleaseWorkflow.SelfTest.ps1`; no workflow production step, +TrayHost CLI, wire, authentication, build/provenance implementation, live +Supervisor, or controlled isolation harness was changed or invoked. + +## Full Persistence/Validate isolation boundary + +The direct, non-npm aggregate command was attempted once: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/PersistenceSelfTest.ps1 +``` + +It exited 1 before reaching Task 4 tests: + +```text +CCOD_SELFTEST_FAILED case=selects-previous-runtime-after-active-exits-before-ready-and-swaps-pointer error=ASSERT_EXACT +CCOD_PERSISTENCE_SELFTEST_FAILED test=Bootstrap.SelfTest.ps1 exit=1 +``` + +Running `Bootstrap.SelfTest.ps1` alone twice reproduced +`previous fallback succeeds expected=[0] actual=[1]`. A corrected zero-wait +production mutex probe exited 0 and reported: + +```text +CCOD_ACCOUNT_TRANSITION_PROBE outcome=TimedOut +``` + +An initial diagnostic probe invocation had a parent-shell quoting error and +exited 1 before importing the module or touching a mutex; the corrected +single-quoted probe above is the relevant result. + +The installed Supervisor was live, and an independent probe showed that the +fixed account transition boundary was unavailable. `tests/Validate.ps1 -SkipInstalledPackageCheck` +was not started because it unconditionally invokes the same Persistence +aggregate; repeating it could not produce independent evidence. No process was +stopped and no kernel-name, SID, or safety check was forged to bypass the +boundary. The existing fully controlled InstalledLifecycle harness and the +safe pre-Persistence validation components were run instead and passed. + +## Scope + +Changed implementation/test files: + +- `.github/workflows/ci.yml` +- `.github/workflows/release.yml` +- `build/TrayHostBuild.psm1` +- `tests/persistence/ReleaseWorkflow.SelfTest.ps1` +- `tests/persistence/TrayHostBuild.SelfTest.ps1` + +This task did not modify production TrayHost wire fields, message types, +sequence/authentication rules, CLI selectors, child-session behavior, receipt +storage, installer behavior, version metadata, README/CHANGELOG content, or +Task 5 static manifest/version binding. + +No real UI, Codex process, installer, installation, release, push, package +signing, or public operation was started. Tests used temporary artifacts and +the Task 3 fake-native production trace only. + +## Remaining risks + +- Full `PersistenceSelfTest.ps1` and therefore `Validate.ps1` still require a + fresh run when the real account transition mutex is safely quiescent. This + task did not stop the live Supervisor to manufacture a green aggregate. +- The production trace proves the shared authenticated child-session path with + a fake native platform; installed-machine notification-area and Defender + validation remain later release acceptance boundaries. +- Version, Windows manifest identity, README/CHANGELOG, and final asset + acceptance are Task 5 scope and were intentionally not changed here. diff --git a/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-3-report.md b/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-3-report.md new file mode 100644 index 0000000..c1db9ce --- /dev/null +++ b/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-3-report.md @@ -0,0 +1,187 @@ +# Task 3 report: stable verified Electron process trees + +## Outcome and commit + +Implemented Task 3 only in the linked worktree +`C:\Users\33384\Documents\Codex-Control-other-devices-Windows\.worktrees\codex-v2522-install-runtime-reliability`. + +Implementation commit: `b34b0d1` (`fix: stabilize verified Electron process trees`). + +The report is committed separately after this entry so that it can name the +implementation commit; a commit cannot embed its own final object ID. + +## Files changed + +- `src/persistence/modules/ProcessControl.psm1` +- `src/persistence/modules/SessionEngine.psm1` +- `src/persistence/Supervisor.ps1` +- `tests/persistence/ProcessControl.SelfTest.ps1` +- `tests/persistence/SessionEngine.SelfTest.ps1` +- `tests/persistence/Supervisor.SelfTest.ps1` +- `.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-3-report.md` + +No other source, test, installer, release, WindowsApps, DPAPI, or UI file was +modified. + +## Implementation + +`Get-CcodStableVerifiedProcessTree` now wraps the existing one-pass verified +tree reader with a fixed retry budget. A retry is eligible only after a failed +or empty verified-tree acquisition. Before the retry it directly rereads the +original root PID with the same status evidence and applies the existing exact +`Test-CcodProcessMatch` contract across PID, creation time, session, SID, +executable path, package family, command line, parent/top-level shape, mode, +renderer port, and main port. A successful tree must also contain exactly one +root member that exactly matches the original snapshot. Missing, malformed, +changed, or differently mapped roots return no tree. The 50 ms adapter delay is +entered only after the failed-tree condition and exact root reread both hold. + +SessionEngine routes every pre-mutation current-package `GetTree` boundary in +Close, Recover, recovery cleanup, and CloseRequested replay through that stable +helper. Journal creation and all stop mutations remain after stable acquisition. +The existing per-member pre-stop, post-stop, and final absence/identity proofs +were not changed. + +Supervisor post-worker proof confirmation now has the same bounded policy. It +retries one empty ordinary/special enumeration only when a direct read of the +candidate PID still matches all twelve snapshot fields. It then performs one +condition-based 50 ms adapter delay and one final refresh. A nonempty mismatch, +ambiguity, missing or malformed direct read, creation-time drift, debug-port +drift, or mode drift is not delayed or retried and receives no proof authority. +The lifecycle result continues to reduce through the stable +`CCOD_REMOTE_PROOF_REBIND_FAILED` reason, and `FailedSpecialProofKey` remains +bound to the original candidate PID and creation time. + +One pre-existing recovery fixture was made deterministic by fixing its +preferred renderer port to the exact port in its expected root snapshot. The +new stable helper correctly exposed that the old fixture supplied a 41001 tree +root while the launch fixture selected 9335. + +## Baseline + +Before test or production changes: + +```text +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/ProcessControl.SelfTest.ps1 +exit 0 + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/SessionEngine.SelfTest.ps1 +exit 0 + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/Supervisor.SelfTest.ps1 +existing output reached the final production-adapter cases without a failure; the +initial combined shell capture timed out before its receipt, so the suite was +rerun independently during RED/GREEN and final verification below. +``` + +## RED evidence + +Tests were added before production changes. + +### RED 1: missing stable process-tree helper + +Command: + +```text +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/ProcessControl.SelfTest.ps1 +``` + +Result: exit `1` at +`retries one transient verified tree miss while the exact root identity remains stable`: + +```text +The term 'Get-CcodStableVerifiedProcessTree' is not recognized +``` + +### RED 2: SessionEngine did not retry transient tree churn + +Command: + +```text +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/SessionEngine.SelfTest.ps1 +``` + +Result: exit `1` at +`retries transient Electron tree churn before committing and closes every verified member`: + +```text +expected=[Closed] actual=[Error] +``` + +### RED 3: Supervisor had no bounded second rebind + +Command: + +```text +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/Supervisor.SelfTest.ps1 +``` + +After correcting the empty-array fixture to emit one array object, result was +exit `1`: + +```text +one transient empty rebind enumeration receives exactly one bounded retry +expected=[3] actual=[2] +``` + +The count includes one pre-worker candidate observation plus the two required +post-worker observations. + +### RED 4: Supervisor retry had no condition-based delay + +After the immediate bounded retry was implemented, a timing boundary test was +added before adding a Delay adapter. The same Supervisor command exited `1`: + +```text +same-candidate retry enters one bounded condition-based delay +expected=[50] actual=[] +``` + +## GREEN and final verification + +Fresh committed-content verification command: + +```powershell +$tests=@( + 'tests/persistence/ProcessControl.SelfTest.ps1', + 'tests/persistence/SessionEngine.SelfTest.ps1', + 'tests/persistence/Supervisor.SelfTest.ps1' +) +foreach($test in $tests){ + powershell.exe -NoProfile -ExecutionPolicy Bypass -File $test + if($LASTEXITCODE -ne 0){ throw "$test failed" } +} +``` + +Result: exit `0` for all three suites. + +- ProcessControl: 40 behavioral cases passed, including transient empty-tree + retry and creation-time drift fail-closed coverage. +- SessionEngine: 58 behavioral cases passed, including retry-before-journal, + child-first stop of every captured member, root drift with zero close + mutation, ordinary close behavior, and all existing per-member proof cases. +- Supervisor: 93 behavioral cases passed. The suite printed + `Supervisor self-tests passed: 93`, including one exact condition-based + 50 ms retry, creation-time drift with zero delay, port/mode drift with zero + delay, no proof authority for changed roots, and the stable + `CCOD_REMOTE_PROOF_REBIND_FAILED` terminal result. + +`git diff --cached --check` exited `0` before the implementation commit. + +## Scope, safety, and concerns + +- No real Codex, ChatGPT, installer, tray, or UI process was started, stopped, + signaled, or controlled. All process behavior used fixtures/adapters. +- Nothing was installed, pushed, published, released, uploaded, or signed. +- WindowsApps and DPAPI/device-key boundaries were not changed. +- Exact root identity was not relaxed. Retry eligibility uses the same full + snapshot matcher, including mode and both debug ports. +- The only delay is fixed at 50 ms and is adapter-driven. It is called only + after an empty/failed acquisition plus an exact candidate-root reread; all + mismatch tests assert zero delay. +- The repository-wide suite was not run because other persistence integration + paths may exercise real process-stop boundaries, which conflicts with this + task's explicit fixture-only authority. The three brief-named focused suites + are green. +- No independent agent review was run because the task explicitly prohibited + spawning agents. Parent integration review remains the next external gate. diff --git a/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-4-report.md b/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-4-report.md new file mode 100644 index 0000000..f7a2224 --- /dev/null +++ b/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-4-report.md @@ -0,0 +1,628 @@ +# Task 4 report: tray action revision correlation and diagnostics + +## Outcome and commit + +Implemented Task 4 only in the linked worktree +`C:\Users\33384\Documents\Codex-Control-other-devices-Windows\.worktrees\codex-v2522-install-runtime-reliability`. + +Implementation commit: `b312174` (`test: cover tray action revision correlation`). + +The report is committed separately after this entry so that it can name the +implementation commit; a commit cannot embed its own final object ID. + +## Files changed + +- `src/persistence/Supervisor.ps1` +- `tests/persistence/Supervisor.SelfTest.ps1` +- `tests/persistence/TrayHostClient.SelfTest.ps1` +- `tests/trayhost/TrayHostTransportSelfTest.cs` +- `.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-4-report.md` + +No protocol, installer, release, lifecycle worker, Codex, WindowsApps, DPAPI, +or user-facing localization file was changed. The planned production files +`src/persistence/modules/TrayHostClient.psm1`, `src/trayhost/HostTransport.cs`, +and `src/trayhost/TrayWindow.cs` required no production change: the existing +authenticated result boundary already preserved action id, revision, terminal +status, and canonical error code, and the existing TrayWindow already reduced +terminal failures to the approved generic localized dialog. Their behavior is +now covered more precisely by the wrapper, transport, and native tests. + +## Root cause and implementation + +The native menu action already reached TrayHost, the PowerShell wrapper already +queued the exact command and presentation revision, and Supervisor already +authorized actions against the exact acknowledged-presentation map. The missing +boundary was in `Send-CcodSupervisorTrayActionResult`: it forwarded a terminal +result to TrayHost without first writing a support-safe record. Once TrayHost +accepted a rejected or failed result, it queued only the generic failure UI, so +the end user saw the intended generic wording but the local log had no command, +revision, terminal status, or stable code with which to diagnose the failure. + +`Write-CcodSupervisorTrayActionTerminal` now writes a fixed schema before the +terminal result is sent to TrayHost: + +- `schemaVersion` +- `timestampUtc` +- `component` +- `stage` +- `code` +- `outcome` +- `command` +- `revision` +- `status` + +The record deliberately excludes action ids, transaction ids, filesystem +paths, tokens, exception messages, and arbitrary result text. `Completed` uses +the safe success code `CCOD_TRAY_ACTION_COMPLETED`. `Rejected` and `Failed` +preserve an exact canonical `CCOD_*` code; malformed codes fall back to +`CCOD_TRAY_ACTION_FAILED`. Canonical validation uses the absolute `\z` regex +terminator so a trailing newline cannot be accepted as part of a log code. + +`Accepted` is not terminal and is not logged by this helper. Lifecycle actions +therefore receive one terminal record only when their later `Completed` or +`Failed` result is produced. Existing result delivery, transaction correlation, +authenticated pipe framing, and generic dialog wording are unchanged. + +## TDD and RED evidence + +Tests were written before the production implementation. + +### RED 1: terminal diagnostic record was absent + +Command: + +```text +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/Supervisor.SelfTest.ps1 +``` + +Result: exit `1` after all earlier cases passed. The acknowledged revision 7 +test reached the real `OpenLogs` adapter, but no local terminal record existed: + +```text +ASSERT_EQUAL_FAILED: terminal action writes exactly one local diagnostic record expected=[1] actual=[0] +``` + +This RED established that the failure was the missing diagnostic boundary, not +an invalid test fixture, PowerShell parse error, or broken command handler. + +### RED 2: end-of-line regex semantics allowed an unsafe code + +After the initial minimal logger was green, a sanitation test was added before +tightening the canonical-code matcher. PowerShell's `$` anchor accepted a +terminal line-feed boundary, and the test exited `1`: + +```text +ASSERT_EQUAL_FAILED: malformed terminal code is replaced by the canonical fallback +expected=[CCOD_TRAY_ACTION_FAILED] actual=[CCOD_INJECTED\n] +``` + +The production matcher was then changed from `$` to `\z`; the test proves the +newline-bearing value cannot enter the local diagnostic record. + +### Focused baseline outside the missing logger + +Before the production change, these two brief-named boundaries were already +green: + +```text +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/TrayHostClient.SelfTest.ps1 +exit 0 + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -TransportOnly +exit 0; TrayHost transport self-tests passed: 8 +``` + +They showed that the authenticated wrapper and host-side action-result queues +were not the missing production behavior. Their assertions were then extended +to prove the exact stale result reaches the parent-client boundary once and +that HostTransport retains the exact revision, terminal status, and canonical +code before the generic UI consumes the failure. + +## GREEN and final verification + +Fresh pre-commit verification ran the three brief-named focused suites, the +native TrayHost suite, and the whitespace gate: + +```powershell +$tests=@( + @{Name='TrayHost client';Command=@('tests\persistence\TrayHostClient.SelfTest.ps1')}, + @{Name='Supervisor';Command=@('tests\persistence\Supervisor.SelfTest.ps1')}, + @{Name='TrayHost transport';Command=@('tests\trayhost\Invoke-TrayHostSelfTest.ps1','-TransportOnly')}, + @{Name='TrayHost native';Command=@('tests\trayhost\Invoke-TrayHostSelfTest.ps1','-NativeOnly')} +) +foreach($test in $tests){ + powershell.exe -NoProfile -ExecutionPolicy Bypass -File @($test.Command) + if($LASTEXITCODE -ne 0){throw "$($test.Name) failed"} +} +git diff --check +``` + +Result: exit `0` throughout. + +- TrayHost client: 8 behavioral cases passed. The only output warning is the + pre-existing PowerShell warning about unapproved imported verb names. +- Supervisor: 96 behavioral cases passed. +- TrayHost transport: 8 behavioral cases passed. +- TrayHost native: 15 behavioral cases passed. +- `git diff --check`: exit `0`. +- `git diff --cached --check`: exit `0` immediately before commit `b312174`. + +The new Supervisor cases prove the required ordering and side-effect boundary: + +1. revision 7 is present in `AcknowledgedPresentations`; +2. `OpenLogs` is invoked exactly once; +3. `CCOD_TRAY_ACTION_COMPLETED` is logged with command `OpenLogs`, revision 7, + and status `Completed`; +4. the log write occurs after the handler and before `ActionResult:Completed`; +5. revision 8 has no acknowledged map entry; +6. the stale action invokes `OpenLogs` zero times; +7. `CCOD_TRAY_ACTION_STALE` is logged before `ActionResult:Rejected`. + +The native suite retains the existing assertion that failure feedback uses only +the acknowledged snapshot's localized generic title and `Action failed` text. +No internal code is added to the dialog. + +## Scope, safety, and concerns + +- No real Codex or ChatGPT process was started, stopped, signaled, or controlled. +- No production TrayHost process, installer, release build, or interactive + product UI was launched. The explicitly required native self-test exercised + its test-owned Win32 owner/fake platform and P/Invoke surface only. +- Nothing was installed, pushed, published, released, uploaded, or signed. +- WindowsApps, application binaries, and DPAPI/device-key data were untouched. +- The authenticated pipe protocol, action-result wire schema, capability gate, + and revision ACK map were not weakened or changed. +- The repository-wide suite was not run because this task authorized the three + focused suites plus native TrayHost verification; broader persistence tests + may cross real process-control boundaries. All task-required suites are green. +- No independent agent review was run because the task explicitly prohibited + spawning agents. Parent integration review remains the next external gate. + +## Fix round 1: durable diagnostic gate and real correlation chain + +Independent review identified that implementation commit `b312174` attempted +the terminal log before delivery but swallowed `WriteLog` failure and still sent +the terminal `ActionResult`. That allowed HostTransport to queue the generic +failure dialog without any durable local terminal record. Review also found +that the original wrapper, Supervisor, transport, and native assertions were +separate tests rather than one test traversing their real correlation chain. + +Fix implementation commit: `94ca92f` +(`fix: gate tray feedback on durable diagnostics`). + +### RED evidence + +The `WriteLog` failure test was added before changing production code: + +```text +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/Supervisor.SelfTest.ps1 +``` + +Result: exit `1` at the new case after all preceding cases passed: + +```text +ASSERT_EQUAL_FAILED: failed diagnostic persistence prevents terminal feedback delivery +expected=[False] actual=[True] +``` + +This proved the exact reviewed defect: the Supervisor returned +`Delivered=True` even though the local action record had not persisted. + +The real correlation test was also written before the production fix. It uses +the test-only `TrayHostCorrelationTestBridge`, which compiles and composes the +checked-in production `TrayWindow`, `NativeMenu`, `HostTransport`, action/result +types, and revision behavior. No replacement protocol or external channel is +introduced. + +### Implementation and pending-action safety + +`Write-CcodSupervisorTrayActionTerminal` now returns `true` only after the +existing `WriteLog` adapter completes without error. It returns `false` after +adding `CCOD_SUPERVISOR_LOG_FAILED` to the Supervisor cleanup receipt on any +clock, schema-construction, or log-write exception. + +For terminal `Completed`, `Rejected`, and `Failed` results, +`Send-CcodSupervisorTrayActionResult` now stops immediately with +`Delivered=false` when that durable-record gate fails. It does not call +`SendTrayActionResult`, so HostTransport cannot queue About work or generic +failure feedback without the required local record. `Accepted` remains a +nonterminal lifecycle acknowledgement and retains its existing authenticated +delivery path. + +The pending HostTransport action is safe under this outcome: + +- it receives no false terminal acknowledgement; +- it cannot display generic terminal feedback without a durable diagnostic; +- it remains protected by the existing action-id replay cache and eight-entry + pending-action bound; +- lifecycle completion receives `Delivered=false`, so the existing repair and + SafeExit gates do not mark the terminal notification complete or exit the UI; +- the Supervisor cleanup receipt exposes `CCOD_SUPERVISOR_LOG_FAILED` for the + enclosing host outcome. + +No retry or unauthenticated fallback was added, so the change cannot duplicate +a lifecycle terminal result or weaken action identity/capability authority. + +### Real end-to-end correlation coverage + +The new Supervisor behavioral case executes both branches through one actual +chain: + +```text +TrayWindow.HandleContextMenu + -> NativeMenu selected command and displayed revision + -> HostTransport.TryRegisterAction + -> Supervisor AcknowledgedPresentations revision/capability gate + -> actual OpenLogs adapter or stale rejection + -> sanitized WriteLog record + -> Supervisor SendTrayActionResult boundary + -> HostTransport.TryAcknowledgeAction +``` + +For revision 7, the same action selected by the native menu is placed in the +ACK map, invokes `OpenLogs` exactly once, returns `Completed`, and is accepted +by the real HostTransport with revision 7. For revision 8, no ACK-map entry is +created; `OpenLogs` is invoked zero times, Supervisor returns +`CCOD_TRAY_ACTION_STALE`, and the real HostTransport accepts the exact rejected +terminal result and preserves revision 8 and the stale code before generic UI +consumption. + +The bridge is test-only and calls the production in-process APIs directly. It +does not start a production TrayHost process, open an external control surface, +or alter the authenticated wire protocol. + +### Diagnostic record shape ruling + +Tests now treat `command`, `revision`, `status`, and `code` as required inclusive +fields. They retain and validate the sanitized standard envelope +`schemaVersion`, `timestampUtc`, `component`, `stage`, and `outcome`. They also +explicitly assert the absence of `ActionId`, `TransactionId`, path, token, +exception, message, and raw arbitrary-text properties. The envelope is a local +log schema, not a new wire protocol. + +### GREEN and fresh verification + +After the minimal fail-closed implementation, the Supervisor suite passed 98 +cases, including the new durable-log failure and real correlation tests. Fresh +pre-commit verification then ran every Task 4 required suite: + +```text +TrayHost client: exit 0; 8 behavioral cases +Supervisor: exit 0; 98 behavioral cases +TrayHost transport: exit 0; 8 behavioral cases +TrayHost native: exit 0; 15 behavioral cases +git diff --check: exit 0 +git diff --cached --check: exit 0 +``` + +The existing lifecycle safety tests remained green, including SafeExit refusal +to shut down when its correlated terminal result is not delivered and terminal +lifecycle completion only after acknowledgement. + +### Fix-round scope and concerns + +- Changed only `src/persistence/Supervisor.ps1`, its behavioral self-test, the + test-only C# correlation bridge, and this report. +- No real Codex/ChatGPT or production TrayHost process was started, stopped, or + controlled. No installer, release, install, push, signing, WindowsApps, DPAPI, + or interactive product UI operation occurred. +- No independent agent was used because this fix round explicitly prohibited + agents. Parent integration review remains the next review gate. + +## Fix round 2: release pending actions through a host-side diagnostic gate + +Re-review found that fix round 1's suppression of the terminal ACK prevented +generic feedback without a record, but permanently retained the corresponding +HostTransport pending entry. Eight Supervisor-log failures could therefore fill +the pending-action bound and disable later tray commands. + +Fix implementation commit: `3bdd575` +(`fix: release tray actions after diagnostic failure`). + +### TDD and RED evidence + +Tests were changed before production code. The new transport regression +required a typed terminal record and a diagnostic-writer HostTransport boundary. +The focused transport compile failed with the expected missing production API: + +```text +CS0246: TrayTerminalDiagnostic could not be found +CS1729: HostTransport does not contain a constructor that takes 2 arguments +CCOD_TRAYHOST_NATIVE_COMPILE_FAILED +``` + +The Supervisor correlation suite independently failed to compile its real +native/transport bridge for the same missing type. This established that the +repeated-failure recovery behavior did not exist. + +After the first implementation, a second behavioral test treated a missing +writer as a persistence failure. It failed before the default was hardened: + +```text +TrayHost transport self-test failed: System.InvalidOperationException +missing diagnostic writer cannot authorize generic feedback +``` + +The default was then changed to fail closed for feedback while still consuming +the authenticated terminal result. + +### Implementation + +`TrayTerminalDiagnostic` is a new internal strong type. Its constructor accepts +only an existing `TrayCommand`, positive revision, terminal +`TrayActionResultStatus`, and canonical result code. `Completed` maps to +`CCOD_TRAY_ACTION_COMPLETED`; malformed failure values map to +`CCOD_TRAY_ACTION_FAILED`. It cannot carry an action id, transaction id, path, +token, exception, or arbitrary message. + +`TrayTerminalDiagnosticLog.TryAppend` writes exactly one fixed-format line: + +```text +command= revision= status= code= +``` + +It returns `true` only after `File.AppendAllText` closes successfully and returns +`false` without throwing on persistence failure. The native self-test writes to +an isolated temporary file, verifies the line byte-for-byte, and verifies that +an invalid file target returns `false`. + +Production `Program` derives a fixed current-user path under +`LocalApplicationData\CodexControlOtherDevices\logs\trayhost-actions.log` and +injects the typed writer into HostTransport. No path or arbitrary payload comes +from the authenticated message, and no new pipe message, command, external +control surface, or unauthenticated channel was added. + +HostTransport now processes a valid authenticated terminal result in this +order while holding its existing correlation lock: + +1. require the exact pending action id and presentation revision; +2. enforce lifecycle Accepted-before-Completed rules; +3. build the typed terminal diagnostic from the pending command and result; +4. attempt the injected local write; +5. remove the pending action regardless of write success; +6. enqueue About or generic failure feedback only when the write succeeded; +7. return terminal acceptance so the authenticated reader does not treat a + consumed valid result as a protocol violation. + +If no writer exists or the writer returns/throws failure, no feedback is +queued, but pending capacity is released. If the bounded feedback queue is +already full, the additional terminal result is likewise consumed and its +pending entry released; only the redundant UI item is suppressed. + +Supervisor again sends the authenticated terminal result after attempting its +standard sanitized envelope log. This gives two safe cases: + +- Supervisor log succeeds: the standard timestamped local record already + exists, and TrayHost also gates feedback on its typed host receipt. +- Supervisor log fails: `CCOD_SUPERVISOR_LOG_FAILED` remains in the cleanup + receipt, while the host receipt either persists before feedback or suppresses + feedback. In both cases the authenticated terminal result releases pending + capacity. + +The standard Supervisor envelope and its inclusive-field ruling were not +changed. + +### Repeated failure and recovery proof + +The transport regression submits ten distinct authenticated failed actions +while the diagnostic writer returns false. Every action registers, every exact +terminal result is accepted, every pending entry is released, and zero generic +feedback items are available. After switching the same writer to success, a new +failed action registers and produces feedback, followed by a new command that +registers and completes. The last typed record is asserted as exactly +`OpenLogs`, revision 22, `Completed`, and `CCOD_TRAY_ACTION_COMPLETED`. + +The Supervisor end-to-end test repeats ten stale native-menu actions while both +Supervisor and host diagnostic writes fail. The same real +`TrayWindow -> NativeMenu -> HostTransport -> Supervisor ACK map -> result ACK` +chain accepts every terminal result without invoking `OpenLogs` or producing +feedback. After both writers recover and revision 2 is acknowledged, the next +native command registers, reaches `OpenLogs` exactly once, and completes. + +### Fresh verification + +Fresh pre-commit verification results: + +```text +TrayHost client: exit 0; 8 behavioral cases +Supervisor: exit 0; 98 behavioral cases +TrayHost transport: exit 0; 9 behavioral cases +TrayHost native: exit 0; 16 behavioral cases +TrayHost production compile/headless smoke: exit 0 +git diff --check: exit 0 +git diff --cached --check: exit 0 +``` + +The production smoke compiles every checked-in TrayHost C# source, including +the new diagnostic type, and runs only the existing no-UI headless path. + +### Fix-round scope and concerns + +- No authenticated wire field or result status changed. +- No real Codex/ChatGPT or production interactive TrayHost process was started, + stopped, signaled, or controlled. +- Nothing was installed, pushed, published, released, signed, or written to + WindowsApps/DPAPI. Runtime logging was tested only through an isolated temp + path; the production path was compiled but not exercised against user data. +- No independent agent was used because this fix round explicitly prohibited + agents. Parent re-review remains the next gate. + +## Fix round 3: bounded reparse-safe receipts and production correlation trace + +Independent re-review retained three Important findings: the host action log +was unbounded, its fixed LocalAppData path lacked reparse-component checks, +diagnostic I/O ran while holding HostTransport's correlation lock, and the +in-process bridge did not prove the real TrayHostClient/ParentClient/Wire/Program +authenticated path. + +Fix implementation commit: `9c1e4df` +(`fix: harden tray terminal diagnostics`). + +### RED evidence + +Tests were added before each production change. + +The blocking-writer transport test deliberately held the diagnostic writer for +up to three seconds, then attempted to register another action. Current code +failed because writer I/O still held `_gate`: + +```text +TrayHost transport self-test failed: System.InvalidOperationException +blocking terminal I/O cannot hold the correlation lock or pending capacity +``` + +The bounded-retention native test failed at compile time before the fixed log +contract existed: + +```text +CS0117: TrayTerminalDiagnosticLog does not contain a definition for MaximumBytes +CCOD_TRAYHOST_NATIVE_COMPILE_FAILED +``` + +After adding the first lock/retention implementation, a missing-writer assertion +was retained from fix round 2 and remained green: no writer cannot authorize +generic feedback but still releases the terminal pending action. + +The production authenticated trace test referenced Program's actual result +dispatch before it was extracted. ParentClient-only compilation failed: + +```text +CS0117: Program does not contain a definition for TryDispatchAuthenticatedActionResult +CCOD_TRAYHOST_NATIVE_COMPILE_FAILED +``` + +This proved the old peer was not traversing Program's production result branch. + +### Bounded deterministic retention + +`TrayTerminalDiagnosticLog.MaximumBytes` is fixed at 64 KiB. Each record is +encoded as UTF-8 without a BOM. Under the logger's private file gate, the writer +selects one of two deterministic modes: + +- append when existing bytes plus the complete new record remain within 64 KiB; +- recreate the file with only the complete newest record when the next append + would exceed 64 KiB or an already oversized file is encountered. + +The stream is closed after `Flush(true)`. The test writes 1,400 distinct +records, proves the file never exceeds `MaximumBytes`, and proves the complete +latest record remains at the end after rollover. This prevents long-running +tray action volume from growing the file without bound. + +### Fixed-path and reparse defense + +Production no longer calls an unconditional `Directory.CreateDirectory` over +the full path. `TryGetDefaultPath` starts from the system-provided current-user +LocalApplicationData root and creates only the fixed +`CodexControlOtherDevices` and `logs` child directories, one level at a time. +Before each creation it verifies the existing parent chain, and after creation +it verifies the new directory. + +`TryAppend` accepts only an absolute path whose leaf is exactly +`trayhost-actions.log`. It rejects a directory leaf, a reparse-point file, any +missing directory, or any existing directory component with the +`FileAttributes.ReparsePoint` bit. Production supplies only the fixed path; no +wire field, CLI value, action property, or user payload can select a path. + +The native test creates a real junction inside an isolated temporary root and +proves a fixed-name log beneath that junction is rejected. All temporary files +and the junction are removed by the test. + +### Diagnostic I/O outside the correlation lock + +HostTransport now validates the exact action id/revision and lifecycle state, +constructs the typed diagnostic, and removes the terminal action from +`_pendingActions` while holding `_gate`. It then releases `_gate` before calling +the writer. After the writer returns, it reacquires `_gate` only to enqueue the +About/generic feedback item when persistence succeeded and the transport is +still active. + +The blocking-writer regression starts terminal acknowledgement on a background +thread, waits until the writer is blocked, then registers a distinct action. +Registration completes within 250 ms, proving neither correlation lock nor +pending capacity is held by file I/O. After releasing the writer, both the first +terminal acknowledgement and the concurrently registered action complete. + +False, throwing, absent, blocked, and full-feedback-queue outcomes continue to +release pending capacity. Only persisted records authorize UI feedback. + +### Production authenticated correlation trace + +`Program.TryDispatchAuthenticatedActionResult` is the exact production reader +operation extracted from the existing ActionResult branch: + +```text +TrayHostWire.ReadActionResult(payload) + -> HostTransport.TryAcknowledgeAction(result) +``` + +The production reader itself now calls this internal function. No new CLI mode, +frame type, wire field, external endpoint, or unauthenticated channel was added. + +The ParentClient self-test compiles Program and the real native/transport/wire +sources into its isolated test executable. Its existing test-only peer process +performs the real parent/host bootstrap, key derivation, MAC verification, +sequence checks, and Action/ActionResult serialization. The peer registers the +same action in real HostTransport and sends it over `TrayHostWire`; the result +returns over the real authenticated `TrayHostParentClient` writer and is +consumed by Program's production dispatch. + +`TrayHostProductionTrace.SelfTest.ps1` then loads that same compiled assembly +and uses the real production PowerShell wrapper: + +```text +TrayHostClient.Receive-CcodTrayHostEvents + -> TrayHostParentClient authenticated event + -> current or stale terminal decision fixture + -> TrayHostClient.Send-CcodTrayHostActionResult + -> TrayHostParentClient authenticated writer + -> TrayHostWire ActionResult + -> Program production dispatch + -> HostTransport diagnostic and feedback gate +``` + +The current case preserves `OpenLogs` revision 1, receives `Completed`, and +produces no generic failure feedback. The stale case preserves `OpenLogs` +revision 8 and `CCOD_TRAY_ACTION_STALE`; after the persisted typed receipt, the +real feedback queue is consumed and `TrayWindow.ShowActionFailed` runs against a +fake native platform. It proves the dialog contains only the acknowledged +snapshot's generic `string-0|string-15` title/message, not the internal code. +The fake platform creates no real window or interactive UI. + +Test-only peer arguments remain implemented only by the self-test main type; +they are not recognized by production Program and therefore create no product +control surface. + +### Fresh verification + +Fresh verification completed with these results: + +```text +TrayHost client: exit 0; 8 behavioral cases +Supervisor: exit 0; 98 behavioral cases +TrayHost transport: exit 0; 10 behavioral cases +TrayHost native: exit 0; 17 behavioral cases +TrayHost parent-client: exit 0; 4 behavioral cases +TrayHost production correlation trace: exit 0; 2 current/stale cases +TrayHost production compile/headless smoke: exit 0 +git diff --check: exit 0 +git diff --cached --check: exit 0 +``` + +The first combined verification command exceeded its 30-second tool window +after the ParentClient cases and before the trace receipt was printed. It was +not counted as evidence for the remaining stages. ParentClient+production trace +and ProductionOnly smoke were rerun independently and both returned explicit +exit `0` receipts. + +### Fix-round scope and concerns + +- Changed only TrayHost terminal logging/correlation production files, focused + tests/trace orchestration, and this report. +- No real Codex/ChatGPT or production interactive TrayHost process was started, + stopped, signaled, or controlled. Native behavior used fake platforms; the + subprocess trace used only the compiled self-test peer. +- Nothing was installed, pushed, published, released, signed, or written to + WindowsApps/DPAPI. Reparse and rollover writes were confined to unique temp + roots. +- No independent agent was used because the fix-round instruction prohibited + agents. Parent re-review remains the next gate. diff --git a/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-5-report.md b/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-5-report.md new file mode 100644 index 0000000..f5356b2 --- /dev/null +++ b/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-5-report.md @@ -0,0 +1,737 @@ +# Task 5 report — v2.5.22 release metadata, documentation, and contract + +## Scope and safety boundary + +- Worktree: `C:\Users\33384\Documents\Codex-Control-other-devices-Windows\.worktrees\codex-v2522-install-runtime-reliability` +- Starting reviewed HEAD: `1d6bd1f` (`docs: record tray trace gate bypass fix`) +- Implemented only release metadata, native build/artifact version verification, + release-coupled tests, stable README release references, CHANGELOG notes, and + deterministic GitHub release-note extraction. +- Did not install or run the installer, start a real Codex/UI process, stop the + real Supervisor/TrayHost, publish, push, sign, tag, or write WindowsApps/DPAPI. + +## TDD evidence + +### Release version contract RED + +After changing only the ReleaseWorkflow contract to require v2.5.22: + +```text +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ReleaseWorkflow.SelfTest.ps1 +exit 1 +ASSERT_EQUAL: package metadata is the 2.5.22 release +expected=[2.5.22] actual=[2.5.21] +``` + +All preceding release gates, including real ISCC compilation, had passed. This +proved the new contract failed for the intended missing release metadata. + +### Native static-metadata gate RED + +The next test called the real TrayHost/portable build entrypoints against +controlled package, AssemblyInfo, and manifest mismatches before production +changes: + +```text +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\TrayHostBuild.SelfTest.ps1 +exit 1 +expected=[CCOD_TRAYHOST_VERSION_MISMATCH] +actual=[CCOD_TRAYHOST_SOURCE_MISSING] +``` + +This proved a requested version mismatch was not rejected before compiler input +resolution. + +### Artifact manifest and PE version RED + +A real compiled TrayHost artifact was validated against a copied repository +contract. The test changed the manifest and then aligned package/static +metadata/provenance to a false `9.9.9` while retaining the original PE: + +```text +exit 1 +expected=[CCOD_TRAYHOST_VERSION_MISMATCH|CCOD_TRAYHOST_ARTIFACT_VERSION_INVALID] +actual=[|] +``` + +The old validator accepted both mutations. The final mutation coverage also +changes valid-version manifest bytes without changing the identity, proving the +manifest SHA binding independently. + +### Release-note extraction RED/GREEN + +The behavior test first failed because the workflow extractor did not exist. +After the tool was added, a CRLF fixture produced a second intentional RED +because the target English section retained CRLF bytes. The final extractor +normalizes output to LF and emits only the exact target English section; the +test excludes Unreleased, newer-version, Chinese, and older-version decoys. + +## Implementation + +### Version sources + +- `package.json`: `2.5.22` +- TrayHost AssemblyVersion/FileVersion and Windows manifest: `2.5.22.0` +- Portable launcher AssemblyVersion/FileVersion and Windows manifest: + `2.5.22.0` + +Historical CHANGELOG entries and non-release presentation/runtime fixtures +remain unchanged. In particular, the TrayHost native `2.5.21` strings are +acknowledged-presentation test inputs, `InstalledLifecycleHarness` uses +`2.5.21-new` runtime fixtures, and ReleaseWorkflow retains an intentional old +payload/structural workflow fixture. + +### Fail-closed native build and artifact verification + +`build/TrayHostBuild.psm1` now: + +- requires requested `-Version` to match `package.json`; +- requires exactly one matching AssemblyVersion and AssemblyFileVersion; +- parses the Windows assembly manifest and requires the exact identity name, + `win32` type, and `.0` value; +- applies that contract to both TrayHost and portable build entrypoints before + compiler/reference resolution; +- reads the compiled PE FileVersion and ProductVersion and requires exact + `.0` values before publishing either artifact; +- makes `Test-CcodTrayHostArtifact` repeat the static contract, bind the source + manifest/config hashes from provenance, and reject PE version drift. + +Regression cases independently mutate package version, AssemblyVersion, +AssemblyFileVersion, manifest version, manifest bytes, provenance/static +version, and PE version relationship. + +### Documentation and release notes + +- README/README.zh-CN now name v2.5.22 only in the stable status, Quick Start, + and release-asset descriptions; no What's new block or internal error code + was added. +- Removed the English README's literal `\r\n` text. +- Corrected the asset contract: portable publishes its payload manifest as an + external asset; Setup embeds and hash-binds its versioned + `installer-payload.manifest.json`. +- CHANGELOG has four concise v2.5.22 English bullets followed by Chinese notes. +- `.github/workflows/release.yml` calls the behavior-tested + `tools/New-GitHubReleaseNotes.ps1`, so the GitHub body contains only the + requested release's English section. + +## Verification before primary commit + +```text +TrayHostBuild.SelfTest.ps1 + exit 0; 8 cases + +ReleaseWorkflow.SelfTest.ps1 + exit 0; all cases, including real ISCC, real TrayHost PE validation, + CRLF-to-LF English-only release notes, and v2.5.22 documentation contract + +Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly + exit 0; 3 production child-session trace cases + +Invoke-TrayHostSelfTest.ps1 -NativeOnly + exit 0; 25 native fake-platform cases + +PowerShell AST parse + exit 0; TrayHostBuild module, three release-coupled test scripts, + and New-GitHubReleaseNotes.ps1 + +version audit + package=2.5.22 + tray Assembly/File/manifest=2.5.22.0 + portable Assembly/File/manifest=2.5.22.0 +``` + +### InstallLifecycle environment boundary + +The full requested test was run without stopping the real Supervisor: + +```text +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1 +exit 1 after approximately 67 seconds +case=transactional-uninstall-reaches-ReadyForInno-only-after-recovery-protection-stop-task-proof-and-application-removal +error=CCOD_LIFECYCLE_LEASE_TIMEOUT +message=Lifecycle ownership mutex timed out +``` + +That failing case begins at line 1876. The v2.5.22 release-coupled builder case +begins at line 2588, so it was not reached and is not reported as passing. No +mutex bypass or real Supervisor stop was attempted. `npm test` / aggregate +Validate were not run because they would recurse into the same known live +mutex boundary, contrary to the task's safety constraint. + +## Clean release build + +The primary release commit was created as: + +```text +49f88e2 release: prepare CodexRemote-fix v2.5.22 +``` + +From that clean HEAD, the normal release workflow completed: + +```text +powershell.exe -NoProfile -ExecutionPolicy Bypass -File build\build.ps1 -Version 2.5.22 +exit 0 +``` + +Generated assets remained under `build/dist` only: + +```text +CodexRemote-fix-2.5.22-windows-x64.zip + 55d0b0e383faa208f0795dbeb6b2ae01051807fdd2d40ddea115951ae6fae02b +CodexRemote-fix-2.5.22-setup.exe + 298dd4c821b83df3bcca32ffe0193bcaa55eff1ddcdf3115680198c635d668a0 +CodexRemote-fix-2.5.22-payload-manifest.json + 08cbbd5eb0093b15d461f42912e1395cf2c90da0c1da503b41b9bda1293b2492 +CodexRemote-fix-2.5.22-release-manifest.json + e313e9943888f3b346d20f7d6627fbf60254de40c6518d47e7c3ba46bd6fd18e +CodexRemote-fix-2.5.22-setup-release-manifest.json + b7a95f58b0cb3178a3a2e7d208b5ba931c2fe7d391970ec297c1b8c70db17579 +CodexRemote-fix-2.5.22-trayhost-provenance.json + 95bc8535ba54b4a0bfc4356fd4162e71800b599b0a1b42476c0ce9912ab0ac40 +``` + +Both release manifests passed `Test-CcodReleaseAssetManifest` for expected +version `2.5.22`; portable reported distribution `portable-zip`. Both manifests +and TrayHost provenance bound commit +`49f88e2bd511a0f98ce93f48967e4cf3a333f93b`. The generated TrayHost and +portable PE FileVersion/ProductVersion values were all `2.5.22.0`. + +The installer was compiled but never executed. No asset was installed, +published, pushed, signed, or tagged. A post-build documentation precision fix +corrected the actual embedded filename from the design spelling +`installer-payload-manifest.json` to the implementation spelling +`installer-payload.manifest.json`; therefore these local artifacts are retained +as build evidence and must not be treated as immutable publish candidates for +the follow-up documentation HEAD without a fresh clean rebuild. + +## Remaining acceptance boundary + +- A real setup install/upgrade, reboot, remote connection, About/language/logs, + repair, and Defender outcome remain outside this Task 5 local build scope. +- Independent parent review remains required before any tag, push, publish, + signing, or installation decision. + +## Fix round 1 — portable artifact verification and release-note body + +Independent review found two Important gaps: the freshly compiled portable +launcher was copied into the payload without an artifact validator analogous to +TrayHost, and the GitHub notes extractor synthesized an H1 instead of returning +only the selected English section body. + +### RED evidence + +The portable regression was added before production code and called the wished- +for validator against a real compiled launcher: + +```text +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\TrayHostBuild.SelfTest.ps1 +exit 1 +Test-CcodPortableLauncherArtifact is not recognized +``` + +The release-note expectation was independently changed to the exact English +body with one LF, producing: + +```text +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ReleaseWorkflow.SelfTest.ps1 +exit 1 +expected=[- Target English note.\n- Second target English note.\n] +actual=[# CodexRemote-fix 2.5.22\n\n- Target English note.\n- Second target English note.\n] +``` + +### Implementation + +`Test-CcodPortableLauncherArtifact` now revalidates: + +- requested version against package, portable AssemblyInfo, and the portable + Windows manifest; +- PE FileVersion and ProductVersion; +- schema/product/version/framework/reference-pack, exact commit, and canonical + timestamp provenance; +- exact `PortableLauncher.cs` and `AssemblyInfo.cs` source name/hash set; +- icon, manifest, source config, executable, and artifact config hashes. + +`build/build.ps1` invokes that validator immediately after portable compilation +and before the first portable EXE/config/provenance copy into the payload. The +tamper regression changes commit, timestamp, every required hash class, source +hashes, and a fully aligned false static/provenance version whose PE remains +2.5.22.0. + +`New-GitHubReleaseNotes.ps1` now writes only the normalized target English body +with exactly one trailing LF. The GitHub release title remains the workflow's +separate `gh release create/edit --title` responsibility. + +### GREEN evidence and boundary + +```text +TrayHostBuild.SelfTest.ps1 + exit 0; 8 cases including portable validator tamper coverage + +ReleaseWorkflow.SelfTest.ps1 + exit 0; English-body-only extraction and build-before-copy validator order +``` + +No formal release build, installer execution, install, publish, push, tag, +signing, or real Codex/UI operation was performed in this fix round. The final +fresh clean release build remains gated on scoped independent re-review. + +## Fix round 2 — CRLF-stable release-note fixture + +Final-candidate validation on a clean CRLF checkout exposed a test portability +defect. The fixture already contained CRLF, but the test blindly replaced every +LF with CRLF, producing CRCRLF. The real extractor then correctly rejected the +malformed heading boundary: + +```text +ReleaseWorkflow.SelfTest.ps1 +exit 1 +CCOD_RELEASE_NOTES_RELEASE_SECTION_INVALID +``` + +A focused local reproduction independently confirmed the old expression: + +```text +CRCRLF_REPRODUCED=True +``` + +The fixture now normalizes CRLF and lone CR to LF before converting LF to CRLF. +This preserves the intended CRLF-input coverage on LF, CRLF, and mixed-line- +ending checkouts without changing production extraction behavior. + +Fresh GREEN evidence: + +```text +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ReleaseWorkflow.SelfTest.ps1 +exit 0 +Release workflow self-tests passed. +``` + +No build, installer execution, install, publish, push, tag, or signing occurred +in this fix round. + +## Fix round 3 — current portable branding regression + +The final controlled isolated `npm test` reached +`InstallLifecycle.SelfTest.ps1:2942` and produced the expected RED in +`README and release workflow publish current portable-release branding`. The +release-coupled regression still required v2.5.21 setup/ZIP names at lines +2949/2950/2957/2958 and the retired inline +`englishSection = [regex]::Match` implementation at lines 2971/2972. + +The regression now requires the v2.5.22 setup and portable asset names in both +Quick Start sections. It preserves the public GitHub release title assertion, +requires workflow use of `tools\New-GitHubReleaseNotes.ps1`, and rejects a +second inline English extractor. The missing-English boundary is exercised +against the real tool with a controlled changelog and requires +`CCOD_RELEASE_NOTES_ENGLISH_SECTION_INVALID` with no notes output. + +Local bounded verification: + +```text +PowerShell AST parse of InstallLifecycle.SelfTest.ps1 + exit 0 + +git diff --check + exit 0 +``` + +The local full InstallLifecycle suite was not represented as GREEN because the +normal desktop environment retains the earlier real-Supervisor lifecycle mutex +boundary. The parent will rerun the controlled isolated aggregate after scoped +re-review. No build, installer execution, install, publish, push, tag, release, +or signing occurred in this fix round. + +## Final security-fix wave — exact activation bytes, hard-link leaves, and Setup proof + +### Scope and safety boundary + +- Started from clean reviewed head `31fd365` in the named v2.5.22 worktree. +- Preserved package/native version `2.5.22` / `2.5.22.0`, the authenticated + TrayHost protocol, receipt architecture, and existing CI/trace gates. +- Did not execute an installer, install or upgrade the product, start or stop + real Codex/TrayHost/Supervisor/UI processes, publish, push, tag, sign, or + read/write WindowsApps or DPAPI device-key data. + +### RED evidence + +#### Exact activation bytes + +A real cross-process barrier supplied a test-only `powershell.exe` wrapper. +The wrapper stopped after the activation parent verified the payload and before +the child interpreter ran. The test then replaced the accepted manifest, +installer, and lifecycle module. The old pathname flow returned success but +proved that it parsed/executed the replacements: + +```text +ReleaseWorkflow.SelfTest.ps1 +exit 1 +case=activation-executes-only-the-verified-manifest-installer-and-module-bytes-across-the-child-launch-barrier +expected=[original:2.5.22] +actual=[replacement:9.9.9] +``` + +No production test flag, environment switch, listener, or unauthenticated +control input was added. + +#### Hard-linked install leaves + +The lifecycle regression created an outside sentinel and hard-linked each +stable target leaf in turn. The old copy path did not reject either target: + +```text +InstallLifecycle.SelfTest.ps1 +exit 1 +case=stable-bootstrap-and-public-uninstaller-reject-hard-linked-target-leaves-before-outside-bytes-change +ASSERT_THROWS: expected CCOD_INSTALL_UNSAFE_LEAF +``` + +A separately compiled and safely executed Inno predicate fixture placed a hard +link inside the existing Setup tree. The old `IsSafeExistingSetupTree` accepted +the leaf, reached the simulated overwrite, and therefore did not write the +fixture's success receipt (`result.txt` missing). + +#### Independent Setup artifact and provenance + +The real-ISCC artifact test was added before its validator: + +```text +ReleaseWorkflow.SelfTest.ps1 +exit 1 +case=compiled-Setup-independently-binds-PE-versions-commit-and-activation-payload-manifest-hash +ASSERT_TRUE: independent Setup artifact validator exists +``` + +After the validator API existed, the setup release fixture added the required +setup-provenance asset before the release validator changed. The old validator +then failed its exact asset contract, proving the provenance was not yet part +of the accepted release: + +```text +CCOD_RELEASE_MANIFEST_INVALID +Release manifest does not bind the exact required asset set +``` + +#### Failure cleanup + +The cleanup regression injected a deterministic exception after creating the +exact GUID-named installer payload stage and destination inventory. It first +failed because the production finally scope did not exist: + +```text +ASSERT_TRUE: build exposes its production temporary Setup scope +``` + +### Implementation contract + +#### Activation byte sealing + +- The activation parent opens the compile-bound manifest once, hashes and + parses the same byte array, and opens every current manifest record with + read-only/no-write/no-delete sharing. +- It writes only those verified byte arrays into a unique sibling stage, + rehashes each staged leaf, and holds read handles for the entire child + lifetime. Original payload path replacement after the barrier cannot change + the child's inputs. +- The child independently opens and holds the staged manifest/records, hashes + and parses the same manifest bytes, verifies exact version/package/file set, + and only then imports `InstallLifecycle.psm1`. +- Lifecycle receives the accepted manifest as Base64 exact bytes plus the + expected SHA-256; it does not reopen the accepted manifest pathname for JSON + parsing. Existing final Ready pointer/runtime-manifest/version proof remains. + +#### Hard-link-safe leaves + +- Lifecycle now uses `CreateFileW` plus `GetFileInformationByHandle` and + requires `NumberOfLinks == 1`, regular/non-reparse shape, no alternate data + streams, and canonical install-root containment before/after copies. +- Production copy writes a fresh same-directory temporary leaf and uses a + namespace replace/move, avoiding in-place mutation of an existing inode. +- Stable `bootstrap.ps1`, the public uninstaller, staged runtime leaves, + runtime manifest, and recursively deleted install trees use the same leaf + contract. +- Inno `PrepareToInstall` recursively checks every existing Setup leaf with + native handle link count plus stream enumeration before `[Files]` writes. + +#### Independent Setup artifact/provenance + +- `build/SetupArtifact.psm1` verifies final Setup PE `FileVersion` and + `ProductVersion` equal `.0`. +- The final PE version resource independently binds product/version, the exact + 40-hex source commit, and the exact 64-hex installer payload-manifest SHA-256. + The negative test reuses the same final EXE with a mismatched expected hash + and requires `CCOD_SETUP_PAYLOAD_BINDING_INVALID`; no textual binary scan is + claimed. +- `CodexRemote-fix-2.5.22-setup-provenance.json` records canonical + version/commit/timestamp, payload manifest name/length/hash/file count, Inno + template and inventory hashes, ISCC hash/version, and the expected PE + resource contract. It is embedded as a `dontcopy` Setup source and published + as an explicit release asset. +- The setup release manifest hash-binds Setup, checksum, TrayHost provenance, + and Setup provenance. `Test-CcodReleaseAssetManifest` revalidates provenance + plus the final PE and rejects asset or metadata tampering. +- Provenance validation reads the raw canonical timestamp representation and + accepts the JSON integer types produced by both Windows PowerShell 5.1 and + pwsh 7. + +#### Cleanup and documentation + +- The installer payload stage and destination inventory now live inside one + exact `try/finally` scope. Cleanup requires the build-root parent, exact GUID + leaf pattern, expected file/directory kind, and a reparse-free tree. +- README and README.zh-CN now call v2.5.22 a release candidate and explicitly + leave stable Windows acceptance pending real install/upgrade/reboot/repair/UI/ + Defender evidence. No What's-new block was added. + +### GREEN and regression evidence + +```text +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ReleaseWorkflow.SelfTest.ps1 +exit 0 +Release workflow self-tests passed. + includes real ISCC compilation, activation swap barrier, Inno hard-link + fixture, Setup PE positive/negative proof, failure cleanup, and release + provenance validation + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\trayhost\Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly +exit 0; 3 production child-session trace cases + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\trayhost\Invoke-TrayHostSelfTest.ps1 -NativeOnly +exit 0; 25 native fake-platform cases + +PowerShell AST parse of all modified .ps1/.psm1 files +exit 0 + +git diff --check +exit 0 +``` + +The lifecycle suite passed the new activation/hard-link cases and continued to +the already documented live-Supervisor boundary. It was not reported green: + +```text +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1 +exit 1 +case=transactional-uninstall-reaches-ReadyForInno-only-after-recovery-protection-stop-task-proof-and-application-removal +error=CCOD_LIFECYCLE_LEASE_TIMEOUT +``` + +No mutex bypass and no real Supervisor stop was attempted. + +### Clean-head build evidence + +Normal build from clean implementation head +`1f4259f7003d29d1023a567d15c877c22dfabd6c`: + +```text +powershell.exe -NoProfile -ExecutionPolicy Bypass -File build\build.ps1 -Version 2.5.22 +exit 0 +TEMP_SETUP_INPUT_COUNT=0 +``` + +Both Windows PowerShell 5.1 and pwsh 7 independently returned +`portable=True` and `setup=True`. Setup FileVersion/ProductVersion were both +`2.5.22.0`; portable/setup release manifests and Setup provenance all bound +commit `1f4259f7003d29d1023a567d15c877c22dfabd6c`. The embedded activation +payload-manifest SHA-256 was +`e03753640f65f28aebdf46eb113f8ad26317169cbfc424e1629a6795c752a4f1`. + +Final retained build evidence hashes: + +```text +CodexRemote-fix-2.5.22-windows-x64.zip + a70419842a2509ae2035e6890474b3e7258d5f64682edb8813d6782adc1c299b +CodexRemote-fix-2.5.22-setup.exe + 5f650bc187112a5256f5a5b5d37972a6400cfe6e32586956830458adfc6b53e9 +CodexRemote-fix-2.5.22-setup-provenance.json + 8f0d1973b69d440ca8dad699bb9065b3030ea4f455d757fc5278e706f6cc76e9 +CodexRemote-fix-2.5.22-setup-release-manifest.json + 559fa329221fcd092356119b8b4aa8bf4562afe173a432ca4251969db5c4a9e6 +``` + +Two earlier ignored artifact sets were preserved rather than deleted at: + +```text +C:\Users\33384\AppData\Local\Temp\ccod-v2522-pre-final-security-build-0ff1f869906848418dc15e9ef89d5375 +C:\Users\33384\AppData\Local\Temp\ccod-v2522-ca5a5f7-build-00f3b177a3974b02bee3fe3f99f2cbc0 +``` + +### Commits and remaining acceptance boundary + +```text +cb36d0b test: expose activation payload byte swap +ed782ec fix: seal activation bytes and reject linked leaves +ca5a5f7 fix: bind setup artifact provenance +1f4259f fix: validate setup provenance across hosts +``` + +- The compiled installer was never executed. Generated assets are unsigned, + unpublished local evidence only. +- Real setup install/upgrade, reboot, remote connection, About/language/logs, + repair, Defender, and controlled isolated aggregate acceptance remain + pending. README intentionally does not call v2.5.22 stable. + +## Final security fix round 1 — whole-install delete leaf preflight + +Scoped re-review found that `Remove-CcodLifecycleInstallTree` did not consume +the strict recursive tree predicate. It checked only each top-level child's +path/reparse ancestry and then called `Remove-Item -Recurse`, so a nested hard +link bypassed the already implemented regular/non-reparse/no-ADS/single-link +leaf contract. + +### RED + +The regression created an ordinary in-tree file plus a nested hard link to an +outside sentinel, then invoked the real private whole-install delete function: + +```text +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1 +exit 1 +case=whole-install-deletion-rejects-a-nested-hard-link-before-removing-any-in-tree-leaf +expected=[CCOD_INSTALL_UNSAFE_LEAF] +actual=[] +``` + +The empty error id proves the old path accepted the hard link and completed +recursive deletion instead of failing closed. + +### GREEN implementation and evidence + +`Remove-CcodLifecycleInstallTree` now calls +`Assert-CcodLifecycleInstallTreeSafe -InstallRoot $root -Path $root` before +enumerating or deleting anything. That existing predicate walks every nested +leaf and applies the native link-count, ADS, regular-file, reparse, and root +containment checks. Therefore a failing leaf is detected before either the +hard-link name or any ordinary in-tree leaf is removed. + +The full lifecycle suite passed the new regression and advanced to the same +later live-Supervisor boundary; it is still not represented as aggregate +GREEN: + +```text +InstallLifecycle.SelfTest.ps1 +new whole-install hard-link case passed +later exit 1 +case=transactional-uninstall-reaches-ReadyForInno-only-after-recovery-protection-stop-task-proof-and-application-removal +error=CCOD_LIFECYCLE_LEASE_TIMEOUT +``` + +Additional fresh verification: + +```text +ReleaseWorkflow.SelfTest.ps1 +exit 0; real ISCC and release workflow self-tests passed + +PowerShell AST parse of modified module/test +exit 0 + +git diff --check +exit 0 +``` + +Commits: + +```text +f1ee9b7 test: expose linked whole-install deletion +1a4abec fix: preflight whole-install tree deletion +``` + +No installer/build/release/install operation was performed in this narrow +round. No real process was started or stopped, and no WindowsApps or DPAPI data +was accessed. + +## Final remediation round 2 — pinned write boundaries and canonical provenance + +### Deterministic RED evidence + +Activation stage substitution used the real production function set extracted +from `Activate-CcodRemoteFix.ps1`. A debugger barrier fired after the old stage +pathname check and before the first verified leaf write. The barrier renamed +the stage and replaced it with an outside junction: + +```text +case=activation-pins-the-stage-directory-before-any-verified-leaf-write +expected=[blocked] +actual=[substituted] +``` + +The real compiled Inno harness retained the old preflight result, launched a +concurrent PowerShell substitution attempt, and then simulated the Setup write: + +```text +case=Inno-exposes-a-pre-write-payload-directory-reparse-gate +expected concurrent attack=[blocked] +actual=[substituted] +``` + +The Setup provenance fixture supplied canonical template, inventory, compiler, +and payload files while its record used self-consistent false `111...`, +`222...`, and `333...` hashes. The old validator accepted the record: + +```text +case=Setup-provenance-rejects-self-consistent-wrong-canonical-build-input-hashes +ASSERT_THROWS: expected CCOD_SETUP_PROVENANCE_INVALID +``` + +### Implementation + +- Activation now pins the app directory and atomically creates the stage and + every nested stage directory with relative `NtCreateFile(FILE_CREATE)` + handles. Each leaf transitions from its writer handle to identity/read pins, + is rehashed through the pinned read handle, and remains non-writable and + non-deletable through the child installer lifetime. All pins close in reverse + order before exact safe cleanup. +- Inno retains a global native handle set from `PrepareToInstall` until the + start of `ssPostInstall`. Existing directories are opened with read/delete + access and no delete sharing; existing leaves are held with read/write + sharing but no delete sharing. The real harness proves the concurrent + directory rename/junction substitution is blocked while the legitimate + overwrite succeeds. `DeinitializeSetup` closes every pin on abort. +- Setup provenance validation now requires the actual canonical Inno template, + generated destination inventory, ISCC executable, and installer payload + manifest paths. It compares real hashes, compiler version, payload length, + and payload file count rather than only checking SHA-256 text shape. +- The exact Setup payload manifest and generated inventory are release-bound + assets. Build and publish validation use them with the repository template + and installed canonical ISCC path; release upload/read-back includes `.iss`. +- README's historical Windows 11 validation line is explicitly qualified as + historical development evidence, not current v2.5.22 real-machine + acceptance. + +### GREEN evidence + +```text +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ReleaseWorkflow.SelfTest.ps1 +exit 0 +Release workflow self-tests passed. + - original source-byte swap barrier passed + - activation stage substitution was blocked + - real compiled Inno concurrent substitution was blocked + - simulated legitimate Setup overwrite succeeded + - outside sentinel remained unchanged + - false canonical provenance hashes were rejected + - real ISCC fixture compilation passed + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\trayhost\Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly +exit 0; 3 cases + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\trayhost\Invoke-TrayHostSelfTest.ps1 -NativeOnly +exit 0; 25 cases + +PowerShell parser checks and git diff --check +exit 0 +``` + +Commits: + +```text +2633509 test: expose activation stage substitution +39e838f fix: pin activation stage write boundary +7bdd78c test: expose setup write TOCTOU and provenance drift +f5021b0 fix: pin setup writes and verify provenance inputs +``` + +No real installer, Codex, TrayHost, or Supervisor process was run or stopped. +No build candidate was published, pushed, tagged, signed, or installed, and no +WindowsApps or DPAPI data was accessed. Controlled aggregate validation, final +candidate rebuild, complete branch review, and real-machine acceptance remain +with the controller. diff --git a/.superpowers/sdd/2026-08-29-v2522-immutable-generation/progress.md b/.superpowers/sdd/2026-08-29-v2522-immutable-generation/progress.md new file mode 100644 index 0000000..c36cf5a --- /dev/null +++ b/.superpowers/sdd/2026-08-29-v2522-immutable-generation/progress.md @@ -0,0 +1,413 @@ +# SDD ledger — plan: docs/superpowers/plans/2026-08-29-v2522-immutable-generation.md + +## Context + +- Worktree: `C:\Users\33384\Documents\Codex-Control-other-devices-Windows\.worktrees\codex-v2522-install-runtime-reliability` +- Branch: `codex/v2522-install-runtime-reliability` +- Base: `03de616` (`docs: plan immutable generation release model`) +- Spec: `docs/superpowers/specs/2026-08-29-v2522-immutable-generation-design.md` +- User confirmed the no-privilege immutable-generation model on 2026-08-29. +- Older Task 1 handle/DACL implementation remains unreleased historical branch + work and will be rewritten or superseded; no old `build/dist` asset is valid. + +## Preflight plan scan + +| Producer / consumer | Interface or state | Result | +| --- | --- | --- | +| Task 1 -> Task 2 | opaque create-only generation and pointer primitives | Compatible; Task 2 must not reintroduce overwrite/recursive delete. | +| Task 2 -> Task 3 | sealed generation, phase record, compensation | Compatible; Setup passes package hash and consumes strict Ready. | +| Task 3 -> Task 4 | immutable stable generation/bootstrap | Compatible; registration occurs only after Ready. | +| Task 2 -> Task 5 | pointer/receipt/manifest identity | Compatible; Defender failure must precede activation. | +| Task 3 -> Task 5 | sealed Setup package and expected hashes | Compatible; package scan has a concrete bootstrap hook. | +| Task 5 -> Task 6 | explicit 11 assets and dual receipt contract | Compatible; release code consumes one asset-name source. | +| Task 6 -> Task 7 | clean candidate and private draft | Compatible; Stage never promotes or rebuilds. | +| Task 7 -> Task 8 | official draft and downloaded asset evidence | Compatible; Preflight is the only live scan producer. | +| Task 8 -> promotion | automated lifecycle and manual tray/remote evidence | Compatible; Promote is local and evidence-bound. | + +## Rulings + +- Ruling: the current-user hostile-writer guarantee is removed — a process with + unrestricted same-user write/DACL authority cannot be reliably defeated by a + current-user utility without an elevated/signed broker. The product instead + guarantees unique create-only generations, strict validation, atomic pointer + commits, and retention on failure. Cost if wrong: this is a narrower security + claim, but it is enforceable without changing install privileges. +- Ruling: install-time cleanup retires or retains old generations — it does not + recursively delete a nonempty tree. Physical reclamation is a separate, + exclusive maintenance/uninstall operation. Cost if wrong: disk usage grows + until proven reclamation, but failed installs cannot partially erase a usable + generation. +- Ruling: stable bootstrap is selected by the immutable generation/pointer + contract; Setup does not overwrite a writable `{app}` tree before Ready. + Legacy Inno registration is migrated only after the new generation is Ready. + Cost if wrong: migration has an extra registration step, but stale app files + cannot become the activation source. +- Ruling: official browser-downloaded draft assets are the live Defender scan + source. No workflow or test fabricates ZoneId 3; public promotion waits for + two actual receipts and the current-machine acceptance chain. + +Task 1: in progress (base `03de616`) + +Task 1: fix round 1/5 opened after review of `03de616..e3ad4f7` found one +Critical source/stream handoff window and three Important issues: physical +retirement remained despite the revised create-only-record model, long-lived +PowerShell re-import did not rebind its runtime type, and pointer/retirement/ +close race coverage was incomplete. The review also clarified that module +session/reflection access is outside the unprivileged threat boundary. Ruling: +retirement is record-only, sealed pins remain private through the handoff, and +the runtime ABI is explicitly rebound on every import. Cost if wrong: the +process-local API is not a sandbox, but ordinary callers cannot obtain raw +handles or a partially verified generation. + +Task 1: fix round 1/5 opened after scoped review of `e3ad4f7..90d9d8d` found +one Critical commit-then-failable handoff and two Important gaps: final leaves +were visible before sealing, and streams could escape transaction ownership on +early I/O failure. Ruling: every content write is registered before fallible +I/O, sealed through a private temporary leaf, and only then renamed no-replace; +record-only retirement is state-committed only after its record rename. Cost if +wrong: temporary diagnostic leaves may remain, but no caller observes a partial +final generation or a commit reported failed after it is visible. + +Task 1: scoped re-review of fix round 1 remains open. The reviewer marked the +handoff/retirement/coverage set NOT ADDRESSED because a record or final leaf can +still be visible before a potentially failing handoff, retirement record state +can diverge after commit, and early destination streams may not be transaction +owned. Fix round 2 is opened against `90d9d8d`; see +`task-1-fix1-brief.md` for the complete required RED/GREEN changes. No later +task may consume this file layer until the scoped re-review is clean. + +Task 1: fix round 2/5 opened after scoped review of `90d9d8d..e74cfdf` found +one Critical retirement record commit-then-fail path and one Important +exception-unsafe Close path. Ruling: retirement performs no fallible action +after record publication, and Close attempts every registered resource before +settling its closed state. Cost if wrong: a failed cleanup may retain a bounded +temporary, but it cannot report a committed record as failed or leave unrelated +handles locked behind the first disposal error. + +Task 1: complete (commits `e3ad4f7..f86d439`, scoped review clean). The final +review of `e74cfdf..f86d439` marked retirement commit consistency and +exception-safe/retriable Close ADDRESSED, with no new Critical/Important +findings. Focused immutable-generation self-test is 16/16; aggregate remains +blocked only by the documented local Bootstrap fallback/mutex case. + +Task 2: blocked before implementation. The Task 1 API cannot open an existing +retained generation read-only or write generic scoped state/receipt/log records, +so it cannot implement post-pointer compensation without bypassing the +create-only boundary. Ruling: extend Task 1 with retained-generation and +scoped-record primitives first; Task 2 must not invent a second path-based file +API. Cost if wrong: one additional reviewed Task 1 interface round, but +compensation remains bound to the same immutable file layer. + +Task 1: fix round 3/5 opened for the retained-generation/scoped-record API +extension; see `task-1-fix3-brief.md`. + +Task 1: fix round 3/5 implementation `14178fc` passed its RED/GREEN suites, +but the scoped re-review found one Important issue: Windows PowerShell +`ConvertFrom-Json` collapses duplicate top-level `runtimeId` members, so the +claimed uniqueness check was not strict. Four other findings (transitive +read-only aliases, nested retained listing, V3 re-import, and transaction-root +input) were addressed. Ruling: treat duplicate-key rejection as load-bearing +and open fix round 4/5 with a fresh implementer; no later task may consume the +extension until the scoped re-review is clean. Cost if wrong: a crafted +manifest with duplicate identity keys could be accepted as a retained target. + +Task 1: fix round 4/5 (`14178fc..e80b829`) addressed the duplicate top-level +`runtimeId` finding. A scoped re-review marked the finding ADDRESSED, with +Spec Compliance PASS, Task Quality PASS, and no new Critical/Important +findings. The bounded lexical key pass composes with `ConvertFrom-Json` to +reject duplicate members while preserving decoded semantic values. Focused +coverage is 25/25; aggregate Bootstrap ASSERT_EXACT remains an environmental +boundary. Task 1 is complete (`e3ad4f7..e80b829`, review clean). + +Task 2 ruling before resumption: stable-shell candidates remain inside each +immutable generation; the scheduled task and post-Ready shortcut/registration +adapters select the generation-specific bootstrap, while legacy root shell +files are retained and never overwritten in the install hot path. This keeps +the create-only file layer honest and still permits upgrades from an old +fixed-root task after the new generation reaches verified readiness. The +existing `Copy-CcodInstallSealedSource` is used only with a writable generation +capability; no root overwrite or second path-based copy API is introduced. +Cost if wrong: an old shortcut may need one post-Ready registration update, but +no incomplete upgrade can replace the only startup shell. + +Task 2 ruling: immutable initialization evidence and operational state are +separate planes. The transaction writes create-only defaults below +`state\install-initializations\`; only when absent are they +materialized into the existing mutable `state\*.json` and +`state\ui-preferences.json` operational plane. The Supervisor updates that +operational plane only after `Ready`; upgrades never overwrite existing or +partial files. Reason: Task1 sealing those runtime state files ReadOnly caused +real StateStore/UI writes to fail with Windows error 5. Cost if wrong: one +extra immutable evidence set per install, but normal tray/state/recovery +updates remain functional rather than silently breaking after activation. + +Task 2 ruling: same-version idempotence and package-conflict behavior require +an explicit canonical `SealedPackageSha256`. A direct/legacy invocation without +that identity follows the normal upgrade path and cannot infer package equality +from `package.json`. Task 3 Setup and portable entrypoints must pass the sealed +package identity. Cost if wrong: older direct callers may perform an unnecessary +upgrade, but no unsealed or weakly identified package can silently short-circuit +activation. + +Task 2 initial implementation `2101485` passed focused and aggregate tests, +but scoped review failed it with one Critical and six Important findings. The +Critical was a real generation-bootstrap break: the task points at the new +generation bootstrap while that bootstrap still requires root `active.json`, +uses the old runtime-ID format, and lifecycle readiness rejects its parent. +Ruling: fix round 1 must expand Task 2 to bootstrap and Bootstrap self-tests; +it implements the same append-only pointer and canonical runtime-ID contract. +Cost if wrong: a fresh install could report fake-test Ready yet fail on actual +task startup or after reboot. + +Ruling: runtime identity is `projectVersion-fileDigest16-nonce32`, revalidated +by manifest and bootstrap; transaction records bind old/new manifest hashes, +the global entrypoint rejects every nonterminal/ambiguous chain, baseline +evidence is written for each generation, and Ready terminalization may never +append Failed after a visible Ready receipt. Cost if wrong: an attacker or +failure could select an unbound generation, bypass a pending transaction, or +leave contradictory terminal evidence. + +Task 2 fix round 1 `bd5b690` passed focused suites and aggregate but scoped +re-review remained FAIL: one Critical production fence prevented actual fresh +or append-only upgrade pointer commits, two Important selector consumers still +accepted ADS/multilink/wrong root fallbacks, and the Ready-final-snapshot path +was nonterminal but not recoverable. Ruling: fix round 2 adds a V4 opaque +state-only transaction usable only to append a revalidated missing terminal +state record; it cannot touch generations or pointers. The production fence +must validate the append-only pointer directly, and every pre-runtime consumer +must reject unsafe selector roots/leaves before legacy fallback. Cost if wrong: +real installation can fail before bootstrap, authorization can trust unsafe +selector storage, or terminalization can remain permanently stuck. + +Task 2 fix round 2 `f10f725` passed focused and fresh aggregate, but scoped +re-review remained FAIL with Important selector-authorization gaps: several +consumers treated every selector-root lookup failure as absence and fell back +to legacy `active.json`; StaticProbe accepted selector schema version 2; V3 to +V4 re-import evidence loaded V4 first; and Static/Uninstall hostile matrices +omitted malformed JSON. Ruling: fix round 3 must permit legacy fallback only +on a proven ItemNotFound outcome, require schema version 1 everywhere, use a +child V3-first import process, and add malformed-selector regressions. Cost if +wrong: ACL/I/O errors or malformed selectors can silently authorize an old +runtime and the ABI containment claim remains unproved. + +Task 2 fix round 3 `8eb6920` passed focused and aggregate but scoped +re-review remained FAIL with two Important absence-proof gaps: an intermediate +`state` regular file can surface ItemNotFound for the child selector and allow +legacy fallback, and StaticProbe treats an adapter null/no-output result as a +proven missing selector. Ruling: fix round 4 must require a real nonreparse +`state` directory before a child absence can be accepted, and StaticProbe must +use a discriminated explicit absence result rather than optional empty output. +Cost if wrong: malformed parent storage or an adapter contract failure can +silently authorize a stale legacy runtime. + +Task 2 fix round 4 `d3ec629` passed focused and aggregate but scoped +re-review remained FAIL with one Important uninstall compatibility gap: when +the `state` ancestor is truly absent, Uninstall resolves its child first and +rejects before the permitted legacy pointer fallback. Ruling: fix round 5 must +prove `state` separately, then probe the child only under a real plain +directory; add a no-state legacy fallback regression distinct from later epoch +validation. Cost if wrong: an otherwise valid legacy uninstall cannot recover +its selected runtime when the new selector plane has never been created. + +Task 2 verification ruling: the Bootstrap release-failure test's 4-second +whole-process watchdog is a reproducible test-harness flake, not a production +deadlock. The real bootstrap exits about 5.1 seconds after cold start while +correctly logging release failure and killing the fallback child; the harness +kills it first at 4 seconds. Replace the fixed start-to-exit budget with a +release-attempt marker, a bounded 15-second watchdog, and a 2-second +marker-to-exit assertion while retaining log, child-exit, and mutex-release +proofs. Cost if wrong: the test becomes slightly more structured, but it keeps +detecting a swallowed release failure without being load-sensitive. + +Task 2: fix round 5 `46692e7` addressed the no-state Uninstall fallback and +the reproducible Bootstrap test-harness timing flake. Scoped re-review marked +Spec Compliance PASS and Task Quality PASS with no Critical/Important +findings. Fresh local focused verification and a full +`tests\PersistenceSelfTest.ps1` aggregate both exited zero. Task 2 is complete +(`2101485..46692e7`, review clean). + +Task 3 initial implementation `9e1c41c` passed focused suites but scoped +review failed with four Important gaps: portable did not propagate explicit +sealed identity; schema-2 provenance validation was partial; compiled Inno was +not executed through a safe pre-activation failure fixture; and the report +omitted the exact commit. Ruling: fix round 1 must bind portable identity, +enforce exact nested provenance schemas, run a mismatched-hash compiled Setup +fixture that fails before activation with no product state, and record the +full commit. Cost if wrong: portable differs from Setup identity semantics, +provenance can be cosmetically altered, and the no-write Inno claim lacks +runtime evidence. + +Task 3 reporting ruling: a Git commit cannot record its own final hash. The +fix round therefore uses an implementation commit followed by a report-only +evidence commit that records the implementation commit's exact 40-hex SHA; +the scoped review package covers both commits. Cost if wrong: one additional +auditable documentation commit, but no self-referential or ambiguous HEAD +claim. + +Task 3 fix round 1 `51def5c..728b627` passed focused suites but scoped review +remained FAIL with three Important binding gaps: portable identity was not tied +to the copied/executed payload, schema-2 provenance accepted duplicate raw JSON +members, and the compiled Setup failure fixture did not snapshot LocalAppData +product state. Ruling: fix round 2 must use the post-copy verified identity, +strictly reject duplicate keys at every provenance object level, and compare a +read-only product-tree snapshot before/after the wrong-hash compiled Setup run. +Cost if wrong: a raced portable payload or ambiguous provenance can be accepted, +and the pre-activation no-product-state claim is incomplete. + +Task 3 fix round 2 `1cb9162..a5d5958` passed focused suites but scoped +re-review remained FAIL with one Important test-compliance gap: the portable +race test called only the copy helper, so its child/lifecycle absence assertions +did not prove the portable entrypoint stopped before execution. Ruling: fix +round 3 must invoke `Install-CodexRemote-fix.ps1` with a marker-capable child +fixture and race the verified source at the validation-to-copy boundary; prove +no child marker or lifecycle state before failure. Cost if wrong: portable +production may be correct but the required end-to-end race evidence is absent. + +Task 3 fix round 3 `58bd760..9e971d1` passed focused suites but scoped +re-review remained FAIL: its actual-entrypoint mutation occurred during mocked +Defender and was caught by the earlier post-scan manifest validation, not by +the final validation-to-File.Copy race. Ruling: fix round 4 must use the real +entrypoint while mutating at the production copy barrier after the copy helper's +own validation, and require `CCOD_PORTABLE_COPY_HASH_MISMATCH` plus no child or +lifecycle mutation. Cost if wrong: the full-path test validates an earlier +check but not the claimed final copy boundary. + +Task 4 initial implementation `e43d9ce..97cf17d` passed focused suites but +scoped review failed with one Critical and four Important findings: installed +uninstall finalizes before deleting its selected generation, matched-only +uninstall cleanup is too weak, failed post-Ready registration cannot retry, +legacy cleanup can partially delete, and exported shortcut capability accepts +arbitrary source paths. Ruling: fix round 1 must add a verified external +generation finalizer, exact registration/shortcut matching with compensating +legacy migration, post-Ready retry, and sealed-generation-only shortcut source +capabilities. Cost if wrong: Windows Settings uninstall deterministically +fails, user replacements can be deleted, registration can remain permanently +absent, or an arbitrary source can be registered as product shortcut. + +Task 3 fix round 4 `141ab71..70bc231` addressed the actual production module +File.Copy-boundary portable race. Scoped re-review marked Spec Compliance PASS +and Task Quality PASS with no Critical/Important findings. Fresh local +ReleaseWorkflow, InstallLifecycle, parser/diff verification, and the full +`tests\PersistenceSelfTest.ps1` aggregate all exited zero. Task 3 is complete +(`9e1c41c..70bc231`, review clean). + +Task 3 fix round 4 `141ab71..70bc231` addressed the production module +File.Copy-boundary portable race. Scoped re-review marked Spec Compliance PASS +and Task Quality PASS with no Critical/Important findings. Fresh local +ReleaseWorkflow, InstallLifecycle, parser/diff verification, and the full +`tests\PersistenceSelfTest.ps1` aggregate all exited zero. Task 3 is complete +(`9e1c41c..70bc231`, review clean). + +Task 2 fix round 5 implementation passed its final verification matrix. The +Uninstall bootstrap now resolves and proves `state` separately, resolves +`state\active-generation` only below a proven plain directory, and permits a +valid legacy pointer when the whole state plane is absent without bypassing +the later lifecycle-epoch boundary. The real no-state regression includes an +invalid-pointer negative control so skipping authorization cannot satisfy it. +The Bootstrap release-failure regression now uses the ruled marker-relative +timing contract without changing production Bootstrap behavior. All eight +focused suites passed (26, 26, 21, 41, 17, 116, 24, and 9 cases), all three +changed PowerShell files parsed, `git diff --check` passed, and the full +`tests\PersistenceSelfTest.ps1` aggregate exited `0`. Task 2 fix round 5 is +ready for scoped re-review. + +Task 3: complete. Setup now compiles from one version/commit-bound sealed ZIP, +its canonical package manifest, the hash-bound temporary activation bootstrap, +and setup provenance as exactly four `dontcopy` inputs. It sets +`CreateAppDir=no` and `Uninstallable=no`, has no pre-Ready `{app}` product, +shortcut, registry, uninstall, recursive extraction, or legacy app-bootstrap +path, and retains its input locks through strict append-only Ready validation. +The real disposable package/bootstrap test proves exact +`SealedPackageSha256` propagation. Final focused verification passed +ReleaseWorkflow, InstallLifecycle 113/113, parser 8/8, `git diff --check`, and +the zero-violation static boundary audit. Task 4 retains all product +registration work; Task 5 retains Defender/public-asset work. + +Task 3 fix round 1 implementation +`51def5c9ae2f5e0722b9083e478b17c507e9cee8` addressed all four Important +findings from review of `46692e7..9e1c41c`: portable explicitly propagates the +twice-verified sealed identity; schema-two Setup provenance has exact ordered +nested schemas and a 24-case mutation matrix; a real wrong-hash production +Setup execution now fails nonzero from `PrepareToInstall` before activation or +product output; and `task-3-report.md` records full commit identities. Final +focused ReleaseWorkflow and InstallLifecycle 113/113 suites passed, along with +parser 4/4, diff check, and the zero-violation no-`{app}` boundary scan. The +fix-round report evidence is a separate report-only commit so the report can +truthfully contain the immutable implementation SHA. + +Task 3 fix round 2 implementation +`1cb91621f2565318143dffe08844a91a350a36d9` addressed the three Important +findings from review of `9e1c41c..728b627`: portable identity is derived from +the revalidated published copy and has a real validation-to-copy race test; +raw duplicate JSON members are rejected before deserialization at every +provenance object depth; and the wrong-hash compiled Setup fixture compares a +bounded read-only before/after snapshot of the actual LocalAppData product +tree. Final ReleaseWorkflow and InstallLifecycle 113/113 suites passed, with +parser 4/4, diff check, and zero no-`{app}` boundary violations. The exact +implementation SHA is recorded through the separate report-evidence commit. + +Task 3 fix round 3 implementation +`58bd7603fa83133d78d2f62ac045ec00d25134c9` replaced the helper-only portable +copy race with an actual `Install-CodexRemote-fix.ps1` invocation. A controlled +test-local Defender mutation changes the child source after initial validation; +the production entrypoint's post-Defender revalidation rejects it with +`CCOD_PORTABLE_MANIFEST_INVALID` before child marker or fixture lifecycle state. +ReleaseWorkflow and InstallLifecycle 113/113 passed, with parser 1/1 and clean +diff check. The exact implementation SHA is recorded in the separate report +evidence commit. + +Task 4 fix round 1 `2e76698..0fdc392` passed focused suites but scoped review +remained FAIL with four Important gaps: finalizer path/wrapper/epoch binding +was insufficient and removed the whole root; current cleanup accepted coherently +replaced registry/shortcuts; product shortcut authority was pre-Ready/unselected; +and production legacy restoration suppressed restore failures. Ruling: fix +round 2 binds finalizer to exact transaction/runtime root/manifest/epoch and +deletes only that root, makes cleanup/retry capability derive exact Ready +transaction identity, and fails loudly on any uncompensated legacy restore. +Cost if wrong: deletion can target a sibling/root, user replacements can be +removed, or a failed migration can silently leave partial legacy state. + +Task 4 and its legacy/uninstall reliability successors are complete through `9d7cc90`, with evidence and +review acceptance through `3eea951`. The true v2.5.21 lifecycle bridge, exact historical registration +profiles, durable pre-write migration plan, same-SID/new-session TaskRemoved recovery, post-reclamation +staged resume, pinned generation reclamation, and Runtime V7 ABI closure all passed their scoped reviews. +The latest frozen persistence aggregate exited 0. Task 5 is now in progress; Tasks 6–8 remain blocked. + +Task 5 implementation `fb163b7b6b594e302cf4dffe1dedd055b5f8d9d4` centralizes the exact ordered +11-asset contract, binds schema-two Defender receipts to explicit origin/status/clock and post-scan +identity evidence, requires two distinct official Internet-download receipts cross-checked against the +real asset directory, and scans Setup's held sealed package before any extraction or lifecycle mutation. +Frozen verification passed ReleaseWorkflow 58/58, PortableRelease 5/5, InstallLifecycle 151/151, parser +103/103, diff check, and all 39 aggregate suites. No real Defender/build/install/network/release action +was performed. Task 5 scoped review FAIL (1 Critical, 3 Important): the public Defender function exposes +full-fact adapters; the central contract shallow-validates nested payload/provenance; asset identity is not +handle-held across scan/promotion; and receipt writing lacks production read-back. Task 5 fix round 1 is in +progress. Tasks 6–8 remain blocked. + +Task 5 fix round 1 implementation `9f8a9e7f4aa8ba6028941026cbca776cbd908610` +closes the four scoped-review findings without changing the 11 public names, +receipt schema, candidate docs, or Setup/portable ordering. Public Defender +surfaces no longer accept adapters or a library bypass; nested portable/Setup +payload and provenance are exact; scan and promotion hold asset/receipt +identities through the decision; and receipts require production write/read-back. +Frozen verification passed ReleaseWorkflow 63 declared cases, PortableRelease +5/5, InstallLifecycle 151/151, parser 104/104, diff check, and all 39 aggregate +suites. No real Defender/build/install/network/release action was performed. +Task 5 scoped re-review of `fb163b7..9f8a9e7` is PASS (Spec Compliance PASS, +Task Quality PASS, 0 Critical, 0 Important). Original findings 1-4 are closed. +Task 5 is complete. Task 6 is in progress; Tasks 7-8 remain blocked. + +Task 6 fix round is implemented in the current worktree after the first scoped review +returned 4 Critical and 7 Important findings. The fix adds fail-closed clean-runner +inspection and create-only preflight evidence; binds prepare/preflight/build/stage to +one resolved 40-hex commit and transfers preflight as a pinned artifact; removes the +GitHub environment bypass and pins every gh call to `naipi11/CodexRemote-fix`; uses a +real same-tag named mutex; separates `defender/`, `verification/`, and `acceptance/` +evidence; persists and revalidates Verify hashes; and requires exact remote asset/state +read-back before and after promotion. No live GitHub, Defender, network, publication, +installation, or reboot action was performed. +Focused Task 6 slices, full ReleaseWorkflow, Bootstrap 27/27, PowerShell/YAML parsing, +static security checks, and the formal `npm test` validation all passed. The old +InstallLifecycle branding assertion was updated to the new draft-tool ownership and +its standalone suite passed 151. A constrained independent re-review inspected the exact staged Task 6 diff and returned PASS with no findings. The implementation was committed as `6d9bafa96c252ece692e8aff35e420285479b7b6`, and the required public module wrappers were restored and verified in corrective commit `325985d9a56bc222be1eb6a73dfb60b6326706da`; the verification report is committed separately. No live GitHub, Defender, network, publication, installation, or reboot action was performed. +Task 6 fix-round implementation and independent acceptance are complete. Task 7/8 official draft acceptance remains blocked until its own gates are implemented. diff --git a/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-1-report.md b/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-1-report.md new file mode 100644 index 0000000..b0623ef --- /dev/null +++ b/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-1-report.md @@ -0,0 +1,537 @@ +# Task 1 report: immutable generation file layer + +## Status + +Implemented and committed the unprivileged immutable-generation data plane in +the assigned worktree. No release, push, tag, signing, installer execution, +product process control, WindowsApps change, DPAPI change, or persistent DACL +mutation was performed. + +## RED evidence + +- Command: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1` +- Exit code: `1` +- Expected failure: the old module exported the pinned/DACL transaction API; + the required seven immutable-generation operations were absent. +- Additional retirement immutability RED: the same focused command exited `1` + because a retired capability could still create `post-retire.bin`; GREEN now + rejects it with `CCOD_INSTALL_GENERATION_RETIRED`. + +## GREEN and verification evidence + +- Focused command: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1` +- Exit code: `0` +- Result: `10/10` named cases passed, including duplicate generation/leaf and + pointer generation collisions, private pinned source/destination handles, + flush plus same-handle length/SHA-256, manifest immutability, reparse/ADS/ + multilink rejection, forged/cross-transaction/closed capabilities, handle + release, no-replace monotonic pointer records, and nonempty retirement. +- Parser command: explicit PowerShell parser pass over the four changed files. +- Parser exit code: `0` (`Changed PowerShell parser check passed.`) +- Diff command: `git diff --check` +- Diff exit code: `0` (only Git LF-to-CRLF checkout warnings). + +## Aggregate evidence and known local limitation + +- Command: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\PersistenceSelfTest.ps1` +- Exit code: `1` +- Exact boundary: `Bootstrap.SelfTest.ps1`, case + `selects-previous-runtime-after-active-exits-before-ready-and-swaps-pointer`, + error `ASSERT_EXACT`. +- Command: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\Validate.ps1 -SkipInstalledPackageCheck` +- Exit code: `1` +- Exact top-level marker: `CCOD_VALIDATION_FAILURE[0] Persistence self-test + failed with exit code 1; CCOD_SELFTEST_FAILED case=selects-previous-runtime- + after-active-exits-before-ready-and-swaps-pointer error=ASSERT_EXACT`. +- Classification: the task brief's known local Bootstrap fallback/mutex + aggregate boundary. It is recorded as environmental and is not reported as + a pass. The focused file-layer suite and changed-file parser checks pass. + +## Windows behavior + +- Generation and directory leaves use relative `NtCreateFile` `FILE_CREATE` + semantics below validated native parent handles. +- Sealed sources are opened without write/delete sharing; destinations are + created once, flushed, rehashed through the same handle, then handed off to + read-only pins without exposing streams or handles. +- Active pointer records are append-only, zero-padded generation files written + to unique temporary leaves and atomically renamed without replacement. +- Retirement validates the entire owned tree, retains the generation root + handle while descendant handles close, then performs one no-replace relative + rename into `retired\.`. Bytes and the original SDDL + remain unchanged; a create-only retirement record is retained. +- The only exported CLR type is the inert `CcodInstallCapabilityMarker`; all + path/handle operations remain on an internal type. PowerShell capabilities + have no public fields and are bound through module-private weak tables. + +## Commit + +- `e3ad4f703884ed6893f89565b2bcdc925977a37d` — `fix: use immutable generation file primitives` + +## Concerns + +- This is deliberately a current-user ordinary-race boundary, not protection + against a same-user process with unrestricted DACL/write authority. +- The append-only pointer reader/consumer migration and complete manifest + semantic validation belong to later plan tasks; Task 1 enforces create-only + storage, manifest presence, owned runtime identity, and monotonic record + collision behavior. +- Failed no-replace record publication can retain a unique `.ccod.*.tmp` leaf + for diagnosis; there is intentionally no install-hot-path recursive cleanup. + +## Fix round 1 — review remediation + +This section supersedes the original report's description of physical +generation retirement. Retirement is now record-only; the nonempty generation +is never renamed or deleted. + +### RED evidence + +- Command: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1` +- Exit code: `1` +- Exact first failure: case + `force-re-import-rebinds-the-current-runtime-ABI-before-real-use`; after two + `Import-Module -Force` calls, real `Open-CcodInstallGeneration` failed because + `$script:CcodRuntimeType` was unset (`VariableIsUndefined`). + +### GREEN evidence + +- Command: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1` +- Exit code: `0` +- Result: `13/13` named cases passed. New coverage proves: + - ABI v2 rebinding works after two forced imports and a real create; + - a 16-worker write/name-exchange attack during handle handoff either leaves + a final strict handle revalidated for identity/length/SHA-256 or causes a + stable seal failure that cannot commit; + - successful sealed leaves remain readable by path before transaction close; + - close races a real synchronized CLR relative create and releases source, + destination, generation, and retained diagnostic temporary handles; + - pointer generation `1 -> 2`, UInt64 overflow rejection, and two independent + Windows PowerShell contenders with exactly one no-replace winner; + - record-only retirement, collision, record-path I/O failure, post-retirement + mutation rejection, and repeated in-transaction idempotence. +- Reliability rerun: three consecutive expanded focused runs exited `0`. +- Parser command: explicit parser pass over the four Task 1 PowerShell files. +- Parser exit code: `0` (`Changed PowerShell parser check passed.`) +- Diff command: `git diff --check` +- Diff exit code: `0` (only LF-to-CRLF checkout warnings). + +### Aggregate evidence + +- `tests\PersistenceSelfTest.ps1`: exit `1` at the unchanged known local case + `selects-previous-runtime-after-active-exits-before-ready-and-swaps-pointer`, + error `ASSERT_EXACT`. +- `tests\Validate.ps1 -SkipInstalledPackageCheck`: exit `1` with the same + `CCOD_VALIDATION_FAILURE[0]` Bootstrap marker. +- These aggregate results remain environmental boundaries and are not called a + pass. The expanded immutable-generation suite exits zero. + +### Windows behavior after remediation + +- The current CLR bridge uses `CcodInstallGenerationCapabilityMarkerV2` ABI 2; + every module import resolves the marker and rebinds the internal runtime type + even when the AppDomain already contains it. +- Destination handoff opens a temporary read bridge only while the private + verified writer still pins the identity. After writer close it opens the + strict read handle and immediately rechecks file identity, final path, plain + file/stream/link invariants, length, and SHA-256 on that same strict handle. +- Retirement creates exactly + `state\retired-generations\.json` through the atomic no-replace + record path. The original `runtime\` tree and handles remain in + place. State changes to retired only after record publication succeeds. +- `ExpectedPreviousGeneration = UInt64.MaxValue` fails before addition with + `CCOD_INSTALL_POINTER_GENERATION_OVERFLOW`. + +### Fix commit + +- `90d9d8d6f4aa851073fb8973a47ac0c9c0f18b7d` — `fix: close immutable generation review gaps` + +### Remaining concerns + +- Retirement idempotence is guaranteed for repeated calls using the same live + opaque transaction. Cross-process crash reconciliation and consumption of + retired-generation records belong to the later lifecycle/state migration. +- A failed no-replace collision deliberately retains its unique temporary + record for diagnosis; `Close-CcodInstallFileTransaction` releases its handle, + and no recursive cleanup is added. + +## Fix round 2 — temp-before-validation and commit-final publication + +This section supersedes the fix-round-1 handoff description. No final +generation leaf, manifest, pointer record, or retirement record is created +before its private temporary object has completed copy/write, durable flush, +plain-file validation, length verification, and SHA-256 verification. + +### RED evidence + +- Command: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1` +- Exit code: `1` +- Exact failure: case + `requested-final-leaf-is-absent-until-private-temporary-bytes-are-sealed`. + A concurrent ordinary path reader observed requested `payload.bin` at length + `0` while the expected sealed length was `22020096` bytes. + +### GREEN evidence + +- Command: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1` +- Exit code: `0` +- Result: `15/15` named cases passed. The two added regressions prove: + - the first observable requested final leaf already has the exact sealed + length and SHA-256, while no successful `.ccod.*.tmp` name remains; and + - a source-hash failure publishes no final leaf, the source stream is owned + by the transaction until `Close(Failed)`, and all private temp handles are + released deterministically. +- Reliability evidence: three consecutive expanded focused runs exited `0`; + the final post-change focused run also exited `0`. +- Parser command: explicit parser pass over the four Task 1 PowerShell files. +- Parser exit code: `0` (`Changed PowerShell parser check passed.`) +- Diff command: `git diff --check` +- Diff exit code: `0` (only LF-to-CRLF checkout warnings). + +### Aggregate evidence + +- `tests\PersistenceSelfTest.ps1`: exit `1` at the unchanged known local case + `selects-previous-runtime-after-active-exits-before-ready-and-swaps-pointer`, + error `ASSERT_EXACT`. +- `tests\Validate.ps1 -SkipInstalledPackageCheck`: exit `1` with the same + `CCOD_VALIDATION_FAILURE[0]` Bootstrap marker. +- These aggregates remain environmental boundaries and are not called passes. + +### Windows behavior after round 2 + +- `Copy` opens and transaction-registers its source before length/hash I/O and + creates a generated `.ccod..tmp` destination registered before copy, + flush, or hash can throw. +- `Write` uses the identical private-temp path for manifest, pointer, and + retirement JSON records. +- `SealAndPublish` closes the private writer under the unguessable temp name, + reopens and verifies that temp through a strict read handle, closes it, + opens the validated rename handle, applies the read-only file attribute, + pre-registers the final dictionary key, and performs one native no-replace + relative rename. After rename success it performs no open, flush, hash, + stream handoff, or result allocation. +- Native `Write` returns an existing token rather than allocating an array + after publication. PowerShell copy/manifest/pointer/retirement result objects + and retirement capability are prepared before the native commit. +- Before pointer eligibility or retirement, `EnsurePublishedPin` reopens every + published generation file by relative handle and revalidates identity, final + path, link/stream type, length, and SHA-256; the read handle then becomes + transaction-owned. +- Retirement remains exactly one create-only + `state\retired-generations\.json` record. The in-memory retired + transition occurs only after the native record publication returns success. + +### Fix commit + +- `e74cfdf28fa05c2b0f8c61634b6542f41e734f45` — `fix: seal temporary leaves before publication` + +### Remaining concerns + +- Between copy publication and later pointer/retirement eligibility checks, + an unrestricted same-user writer can clear the read-only attribute or rename + the file. That actor is outside the approved unprivileged boundary; the + eligibility path nevertheless reopens and rejects any identity/path/hash or + unknown-leaf change before writing the control record. +- Failed no-replace publication retains a read-only diagnostic temp file but + no stream handle. It is intentionally not recursively reclaimed in the + install hot path. + +## Fix round 2 scoped re-review — terminal retirement commit and exception-safe close + +### RED evidence + +- Command: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1` +- Exit code: `1` +- Exact failing case: + `close-retries-a-failing-disposal-and-releases-every-other-registered-resource`. + An injected first `FileStream.Dispose` exception stopped cleanup; the + generation directory remained locked, and fixture cleanup failed with + `The process cannot access the file ... because it is being used by another + process.` This also proved a later close was short-circuited by the runtime's + already-disposed flag. + +### GREEN evidence + +- Command: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1` +- Exit code: `0` +- Result: `16/16` named cases passed. The new deterministic disposal regression + registers an injected throwing stream plus a real source, destination, and + generation tree. It proves: + - the first close attempts the throwing stream once and returns the stable, + sanitized `CCOD_INSTALL_CLOSE_FAILED`; + - the same first close continues to release the source and every generation + pin, so both can be moved immediately; and + - the second close retries the retained failed resource (attempt count 2) + and reports the same bounded cleanup code rather than returning early. +- Reliability evidence: three consecutive expanded focused runs exited `0`. +- Parser command: explicit parser pass over the four Task 1 PowerShell files. +- Parser exit code: `0` (`Changed PowerShell parser check passed.`) +- Diff command: `git diff --check` +- Diff exit code: `0` (only LF-to-CRLF checkout warnings). + +### Aggregate evidence + +- `tests\PersistenceSelfTest.ps1`: exit `1` at the unchanged known local case + `selects-previous-runtime-after-active-exits-before-ready-and-swaps-pointer`, + error `ASSERT_EXACT`. +- `tests\Validate.ps1 -SkipInstalledPackageCheck`: exit `1` with the same + `CCOD_VALIDATION_FAILURE[0]` Bootstrap marker. +- These aggregates remain environmental boundaries and are not called passes. + +### Implementation behavior + +- Native `Retire` now completes all generation/tree validation before calling + the shared temp-record publisher. After the no-replace retirement-record + rename, it immediately returns the already-existing generation token; the + previous post-publication `ValidateCurrent(generation)` call is removed. +- The PowerShell retirement capability and bounded result are allocated before + the native record commit. Only after native success are the prepared result + stored and the private `Retired` flag set. Collision/write failure leaves + those state fields unchanged. +- Native `Close` snapshots and attempts every registered external stream and + pin independently. Successfully released pins are removed; failures remain + registered for retry. The runtime is closed to further operations from the + first attempt onward, but subsequent cleanup calls still process retained + failures. +- `Pin.Dispose` marks a pin closed only after its handle/stream disposal + succeeds, allowing a genuine failed release to be retried. +- The exported close wrapper records deterministic closed/cleanup-failed state + and always maps internal cleanup exceptions to the fixed + `CCOD_INSTALL_CLOSE_FAILED` message. + +### Fix commit + +- `f86d4397b4a56343db9124f655c923949e6084c0` — `fix: finalize retirement and close cleanup` + +### Remaining concerns + +- A resource whose `Dispose` permanently fails remains intentionally registered + and each later `Close` reports `CCOD_INSTALL_CLOSE_FAILED`; all other + resources are nevertheless released. No recursive deletion or DACL fallback + is attempted. + +## Fix round 3 — retained-generation targets and scoped records + +### RED evidence + +- Command: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1` +- Exit code: `1` +- Exact first failure: the export-surface case expected + `Open-CcodInstallRetainedGeneration`, `New-CcodInstallDirectory`, and + `Write-CcodInstallRecord`, but the current module exported none of them and + pointer commit still accepted a naked runtime ID. + +### GREEN evidence + +- Command: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1` +- Exit code: `0` +- Result: `19/19` named cases passed. Added deterministic coverage proves: + - install-root `state` and nested `receipts` directories can be opened or + created only through opaque same-transaction scopes and single-segment + leaves; + - JSON/JSONL records use the private-temp publisher, are complete on first + visibility, collide without replacement, and reject multi-segment leaves; + - an existing generation is recursively opened read-only only when its final + path, complete structural tree, manifest hash, and manifest runtime ID + match; missing/wrong-hash/reparse/ADS/multilink/unowned fixtures fail with + stable codes; + - every create/write operation rejects a retained capability; + - pointer generations 1 and 2 can be followed by a generation-3 compensation + record targeting the retained old runtime; + - retained targets from a different transaction on the same root or a + different install root are rejected; and + - forced module re-import remains active before all new exported operations. +- Reliability evidence: three consecutive expanded focused runs exited `0`; + the final pre-commit focused run also exited `0`. +- Parser command: explicit parser pass over the four Task 1 PowerShell files. +- Parser exit code: `0` (`Changed PowerShell parser check passed.`) +- Diff command: `git diff --check` +- Diff exit code: `0` (only LF-to-CRLF checkout warnings). + +### Aggregate evidence + +- `tests\PersistenceSelfTest.ps1`: exit `1` at the unchanged known local case + `selects-previous-runtime-after-active-exits-before-ready-and-swaps-pointer`, + error `ASSERT_EXACT`. +- `tests\Validate.ps1 -SkipInstalledPackageCheck`: exit `1` with the same + `CCOD_VALIDATION_FAILURE[0]` Bootstrap marker. +- These aggregates remain environmental boundaries and are not called passes. + +### Interface behavior + +- `Open-CcodInstallRetainedGeneration` is tied to an existing writable file + transaction. Native code opens the existing `runtime\` root and + every descendant through relative read-only handles, rejects reparse/ADS/ + multilink/final-path changes, hashes each file, verifies the exact manifest + hash and uniquely matching manifest runtime ID, and then issues an opaque + generation scope marked `ReadOnly`. +- Retained scopes may be used only as `TargetGeneration`; directory creation, + source copy, manifest write, and scoped record write all fail with + `CCOD_INSTALL_GENERATION_READ_ONLY`. +- `Commit-CcodInstallActivePointer` no longer accepts `NewRuntimeId`. It derives + the target runtime ID only from a same-transaction opaque generation scope + and revalidates either the owned new tree or the retained read-only tree + before its monotonic no-replace pointer record. +- `New-CcodInstallDirectory` treats the opaque transaction itself as the + install-root parent and composes native single-leaf open/create for bounded + state directories. Returned child capabilities are recursively composable. +- `Write-CcodInstallRecord` accepts only a same-transaction writable directory, + sanitized record object, and single-segment leaf; it shares the existing + temp-before-validation/no-replace publisher. +- Native directory enumeration now removes duplicate names returned across + repeated `NtQueryDirectoryFile` buffers before retained-tree pin creation. + +### Fix commit + +- `c1e3ef08f0c820db79872f8438d66d1ced94eeba` — `feat: add retained generation file capabilities` + +### Remaining concerns + +- Retained manifest runtime-ID validation is intentionally narrow and is also + bound to the caller-supplied exact manifest SHA-256. Full manifest schema, + file-list, version, and commit semantics remain the Task 2/RuntimeManifest + consumer's responsibility. +- This round adds only Task 1 file-layer capabilities; no lifecycle module or + installer path was migrated. + +## Retained-capability extension fix — transitive read-only and ABI V3 + +### RED evidence + +The self-test supports `CCOD_INSTALL_EXTENSION_RED_CASE` only to select one +deterministic regression while preserving all earlier focused cases. Each +targeted command used: + +```powershell +$env:CCOD_INSTALL_EXTENSION_RED_CASE='' +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1 +``` + +All five targeted RED runs exited `1` for their intended production gap: + +- `retained-alias`: `ASSERT_THROWS`, expected + `CCOD_INSTALL_GENERATION_READ_ONLY`; an alias reopened through + transaction-root `runtime\` remained writable. +- `nested-retained`: `CCOD_INSTALL_RETAINED_GENERATION_INVALID`, message + `retained open failed: Access is denied`; nested retained directories lacked + directory-listing access. +- `semantic-runtime-id`: `ASSERT_THROWS`, expected + `CCOD_INSTALL_RETAINED_RUNTIME_ID_MISMATCH`; regex matching accepted a nested + `metadata.runtimeId` without a matching top-level property. +- `child-transaction`: `ASSERT_THROWS`, expected + `CCOD_INSTALL_TRANSACTION_INVALID`; a child directory capability was + accepted as `-FileTransaction`. +- `v3-reimport`: `TypeNotFound` for + `CcodInstallGenerationCapabilityMarkerV3`; re-import still bound ABI V2. + +The unfiltered required command also exited `1`; its first new failure was the +retained-alias `CCOD_INSTALL_GENERATION_READ_ONLY` assertion. + +### GREEN evidence + +- The same five targeted commands each exited `0`. +- Full command: + `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1` +- Full focused exit code: `0`. +- Result: `24/24` named cases passed. +- Parser command: explicit PowerShell parser pass over + `InstallFileTransaction.psm1` and + `InstallFileTransaction.SelfTest.ps1`. +- Parser exit code: `0` (`TASK1_PARSER_EXIT=0`). +- Diff command: + `git diff --check -- src/persistence/modules/InstallFileTransaction.psm1 tests/persistence/InstallFileTransaction.SelfTest.ps1` +- Diff exit code: `0` (only LF-to-CRLF checkout warnings). + +### Aggregate evidence + +- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\PersistenceSelfTest.ps1` + exited `1` at the unchanged known local case + `selects-previous-runtime-after-active-exits-before-ready-and-swaps-pointer`, + error `ASSERT_EXACT`. +- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\Validate.ps1 -SkipInstalledPackageCheck` + exited `1` with the same `CCOD_VALIDATION_FAILURE[0]` Bootstrap marker. +- These aggregates remain environmental boundaries and are not called passes. + +### Implementation behavior + +- The current CLR bridge is + `CcodInstallGenerationCapabilityMarkerV3` / internal runtime V3 with ABI 3. + Every import resolves and binds V3 even when the AppDomain already contains + the old V2 marker. +- Native `retainedScopes` covers the retained generation and every descendant + token. Directory opens propagate read-only state from a retained parent and + also recognize a retained root reopened from the transaction-root alias. + The PowerShell wrapper asks native `IsRetained` for each returned directory, + so all exported create/write routes fail closed with + `CCOD_INSTALL_GENERATION_READ_ONLY`. +- Nested retained child directories are pinned with + `LIST_DIRECTORY | READ_ATTRIBUTES | SYNCHRONIZE`, while files remain + read-only. Recursive final-path, reparse, ADS, multilink, identity, length, + and SHA-256 validation remains intact. +- Native code returns the exact hash-bound manifest text. The wrapper parses it + with `ConvertFrom-Json`, requires a top-level object and exactly one + top-level `runtimeId` string equal to the requested ID, and rejects escaped + or nested-only occurrences. +- `Open-CcodInstallRetainedGeneration` requires the supplied + `-FileTransaction` object itself to be the transaction-root capability; + child directory capabilities fail with `CCOD_INSTALL_TRANSACTION_INVALID`. + +### Scope note + +- The paused uncommitted Task 2 changes in `InstallLifecycle.psm1` and + `InstallLifecycle.SelfTest.ps1` were neither edited nor staged by this fix. + +## Duplicate retained-manifest identity fix + +### RED evidence + +- Targeted command: + `$env:CCOD_INSTALL_EXTENSION_RED_CASE='duplicate-runtime-id'; powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1` +- Targeted exit code: `1`. +- Exact failure: case + `rejects-duplicate-matching-top-level-runtimeId-members-in-a-retained-manifest` + reached `ASSERT_THROWS`, expected + `CCOD_INSTALL_RETAINED_RUNTIME_ID_MISMATCH`. This proved Windows PowerShell + collapsed two equal top-level `runtimeId` members before the old property + count check. +- Unfiltered focused command: + `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1` +- Unfiltered RED exit code: `1` with the same exact case and assertion. + +### GREEN and verification evidence + +- The same targeted duplicate command exited `0`. +- Full focused command: + `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1` +- Full focused exit code: `0`; `25/25` named cases passed, including all + earlier retained alias, nested tree, semantic identity, transaction-root, + and ABI V3 regressions. +- Parser command: explicit PowerShell parser pass over + `InstallFileTransaction.psm1` and + `InstallFileTransaction.SelfTest.ps1`. +- Parser exit code: `0` (`TASK1_PARSER_EXIT=0`). +- Diff command: + `git diff --check -- src/persistence/modules/InstallFileTransaction.psm1 tests/persistence/InstallFileTransaction.SelfTest.ps1 .superpowers/sdd/2026-08-29-v2522-immutable-generation/task-1-report.md` +- Diff exit code: `0` (`TASK1_DIFF_CHECK_EXIT=0`; only LF-to-CRLF checkout + warnings). + +### Implementation behavior + +- `ConvertFrom-Json` remains the authoritative complete JSON parser and + decoded-value source. The identity check still requires a top-level object + whose decoded, case-exact `runtimeId` value is a string equal to the + requested runtime ID. +- A bounded lexical pass now enumerates decoded top-level property names before + accepting that parsed value. It preserves duplicate member occurrences, + honors JSON string escaping in keys, and ignores nested objects, arrays, and + escaped key-like text inside values. Two matching top-level `runtimeId` + members are therefore rejected. +- The regression fixture supplies the exact SHA-256 of the duplicate-key + manifest and exercises the real retained-open path. Native SHA-256, + full-tree, path, reparse, ADS, multilink, and handle checks are unchanged. +- The paused Task 2 lifecycle files remained unstaged and were not edited by + this fix. + +### Commit + +- `fix: reject duplicate retained manifest identities` diff --git a/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-2-report.md b/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-2-report.md new file mode 100644 index 0000000..7f35626 --- /dev/null +++ b/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-2-report.md @@ -0,0 +1,513 @@ +# Task 2 report: immutable lifecycle generations + +## Status + +Implemented the Task 2 immutable lifecycle migration in the assigned worktree. +The install path now stages a unique manifest-bound generation, appends the +transaction and active-generation chains, selects the generation bootstrap for +the scheduled task, retains legacy root shells and failed/old generations, and +uses a higher compensating pointer after post-pointer failure. + +No release, push, tag, signing, build, installation, real product process +start/stop, WindowsApps access, DPAPI access, or external write was performed. + +## RED evidence + +- Recovery baseline command: + `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1` + exited `1` at `first-install-stages-verifies-activates-task-and-persists-consent`, + old assertion `stable bootstrap copied`. The obsolete root-shell expectation + was replaced by the approved generation-specific shell contract. +- Runtime identity RED exited `1` because `New-CcodUniqueRuntimeId` had no + `ContentSha256` parameter. GREEN binds the full SHA-256 of + `projectVersion + NUL + sealedPackageSha256` and a per-attempt nonce into a + 96-character runtime ID. +- Activation receipt RED exited `1` because `StartingProtection` wrote mutable + `state\post-install-activation.json`. GREEN writes phase-specific create-only + records below `state\activation-receipts` through the opaque transaction. +- Operational-state RED reproduced `UnauthorizedAccessException` and + `CCOD_ATOMIC_REPLACE_FAILED` after the Task 1 record seal made status and UI + files read-only. GREEN separates immutable initialization evidence below + `state\install-initializations\` from writable operational state + materialized only when all operational leaves are absent. +- Real idempotence RED expected `AlreadyInstalled` but received `Upgraded`. + GREEN performs no pointer, runtime, task, or process mutation only when the + active Ready transaction has the exact explicitly supplied package identity; + a different explicit identity returns `CCOD_INSTALL_PACKAGE_CONFLICT`. +- Bounded reader RED exited `1` because `Read-CcodStrictJson` had no + `MaxBytes` parameter. GREEN rejects oversized records before JSON parsing. +- Transaction-chain identity RED exited `1` because a contiguous snapshot + chain could change `oldRuntimeId`. GREEN binds every immutable transaction + identity field across all snapshots. + +## GREEN and verification evidence + +- Lifecycle command: + `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1` +- Lifecycle exit/result: `0`; `Install lifecycle self-tests passed: 108`. +- Runtime manifest command: + `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\RuntimeManifest.SelfTest.ps1` +- Runtime manifest exit/result: `0`; 16 named cases passed. +- Uninstall bootstrap command: + `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\UninstallBootstrap.SelfTest.ps1` +- Uninstall bootstrap exit/result: `0`; `Uninstall bootstrap self-tests passed: 12`. +- Persistence I/O command: + `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\PersistenceIO.SelfTest.ps1` +- Persistence I/O exit/result: `0`; 24 named cases passed. +- Aggregate command: + `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\PersistenceSelfTest.ps1` +- Aggregate exit: `0` (`TASK2_AGGREGATE_EXIT=0`). The prior local + Bootstrap/mutex boundary did not reproduce in this fresh run. +- Parser command: explicit + `[Management.Automation.Language.Parser]::ParseFile` pass over all eight + changed PowerShell code/test files. +- Parser exit: `0` (`TASK2_PARSER_EXIT=0`); every file reported `PARSER_OK`. +- Diff command: `git diff --check` +- Diff exit: `0` (`TASK2_DIFF_CHECK_EXIT=0`; checkout emitted only LF-to-CRLF + warnings). + +## Behavior and boundaries + +- Runtime IDs are unique per attempt and retain a full deterministic digest of + project version plus canonical sealed package identity. Runtime manifest + validation still rejects runtime-ID, project-version, file-list, length, and + content tampering. +- `state\active-generation\<20-digit>.json` is the only new active selector. + New installs never write `active.json`; legacy `active.json` remains read-only + migration input. +- Transaction snapshots have exact fields and types, strict positive integer + generations, bounded safe reads, contiguous phases, immutable identity, and + rejection for unknown objects, reparse points, ADS, multilinks, competing + nonterminal chains, duplicate/gapped/post-terminal chains, and ambiguous + terminal selection. +- Initial settings, status, verified packages, transition, and UI preference + evidence is create-only and generation-bound. The operational state plane is + separately created only when entirely absent and remains writable; the tests + perform real StateStore and UI write/read round trips without changing file + attributes. +- Pre-pointer failures retain the failed candidate and leave the old pointer + and legacy root bootstrap/uninstaller bytes unchanged. Post-pointer failures + either append a higher compensation pointer to the retained old runtime and + prove it ready, or persist exactly + `Failed/CCOD_INSTALL_ROLLBACK_FAILED`. Both generations remain retained. +- Stable shell candidates remain inside the immutable runtime generation. The + scheduled-task adapter is updated only after pointer commit and targets that + generation's bootstrap. There is no root shell overwrite or install-hot-path + recursive runtime deletion. +- Idempotence/conflict decisions require an explicit canonical + `SealedPackageSha256`. A legacy/direct call that omits it cannot claim package + identity and follows the normal unique-generation path; Task 3 Setup must + always pass the sealed package hash. +- Task 1 file-layer modules are imported lazily without `-Force` only when an + immutable install operation is actually invoked. This preserves the staged + uninstall module import closure and avoids rebinding a live Task 1 + transaction. + +## Concerns + +- The current-user ordinary-race boundary remains unchanged: this does not + claim protection from an unrestricted same-user writer or in-process module + introspection. +- The pre-Task-3 activation owner still consumes the legacy single receipt in + its synthetic compatibility tests. Task 3 owns migration of the sealed Setup + bootstrap/validator to the create-only receipt chain and must pass the + canonical package identity explicitly. +- Failed and superseded generations intentionally consume disk until a + separately proven maintenance/uninstall reclamation path runs. + +## Commit + +- `fix: commit lifecycle through immutable generations` (this Task 2 commit) + +## Fix round 1 — complete the real generation-bootstrap lifecycle + +### Review status and RED evidence + +The scoped review of `e80b829..2101485` failed with one Critical and six +Important findings. This round keeps `2101485` and remediates all seven. + +- Bootstrap command: + `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\Bootstrap.SelfTest.ps1` + exited `1`; the new real generation-bootstrap case expected exit `0` but + received `1` because production still required root `active.json`. +- RuntimeManifest command: + `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\RuntimeManifest.SelfTest.ps1` + exited `1` at the canonical identity assertion because production still + emitted `projectVersion-digest16` without a nonce. +- The lifecycle review regressions then exposed: transaction-root callbacks + executing in the wrong module session, a sealed-source fixture failing + before generation creation, missing upgrade baselines, cross-root state/UI + writes, global nonterminal state accepted at the real entry, freshly read old + manifest hashes used for compensation, and contradictory Failed evidence + after a visible Ready receipt. +- The first aggregate attempt exited `1` at + `StaticProbeWorker.SelfTest.ps1` with `CCOD_STATIC_RUNTIME_UNAUTHORIZED`; + that independent runtime authorizer still used the old ID and legacy-only + selector. The second aggregate attempt exited `1` at + `UiPreferences.SelfTest.ps1` because the upgrade baseline path had + accidentally changed the public duplicate-initialize contract. + +### GREEN and verification evidence + +- Bootstrap: `24/24`, exit `0`. Coverage includes a copied generation + bootstrap actually launching its selected Supervisor with no `active.json`, + append-only selector unknown/reparse/ADS/multilink rejection, and legacy + fallback only when the chain is absent. +- InstallLifecycle: `114/114`, exit `0`. Coverage includes same-generation + bootstrap parent readiness, legacy-root parent rejection under append-only + selection, global pointer-less/ambiguous transaction exclusion, fresh and + upgrade baselines, cross-root rejection, partial operational state, + recorded-old-manifest compensation, and the Ready final-snapshot gap. +- RuntimeManifest: 18 named cases, exit `0`, including canonical + `projectVersion-fileDigest16-nonce32`, copied/renamed identity with recorded + manifest hash, wrong nonce/content, and selector ADS/multilink checks. +- UninstallBootstrap: `13/13`, exit `0`, including canonical-ID and + append-only generation-7 authorization without legacy `active.json`. +- PersistenceIO: 24 named cases, exit `0`. +- StaticProbeWorker: 37 named cases, exit `0`, including append-only runtime + authorization without legacy `active.json`. +- UiPreferences focused suite: 9 cases, exit `0`; the default duplicate + initialize behavior remains `CCOD_UI_PREFERENCES_EXISTS`, while only the + root-bound lifecycle path opts into reading an existing upgrade baseline. +- Parser: explicit `ParseFile` checks passed for all 12 changed PowerShell + code/test files. +- Diff: `git diff --check` exited `0`; only LF-to-CRLF checkout warnings were + emitted. +- Aggregate: + `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\PersistenceSelfTest.ps1` + exited `0` (`FIX1_AGGREGATE_FINAL_EXIT=0`). + +### Remediated behavior + +- The generation bootstrap and every audited runtime authorizer read a strict, + contiguous append-only active-generation chain first and use legacy + `active.json` only when that chain is absent. +- Runtime identity is exactly + `projectVersion-fileDigest16-nonce32`; RuntimeManifest, bootstrap, + StaticProbeWorker, and UninstallBootstrap independently recompute it from + sorted file records. +- The install entry rejects any global nonterminal or ambiguous transaction + before creating a generation, independent of pointer/package identity. +- Every fresh or upgrade generation records all five immutable initialization + baselines. Complete operational state is snapshotted but not overwritten; + partial/malformed operational state fails closed. +- StateStore and UiPreferences no longer accept file-transaction capabilities. + InstallLifecycle first validates the opaque transaction root, then writes + baselines itself, so root A cannot be combined with StateRoot B. +- Active pointer readers reject unknown objects, reparse points, ADS, and + multi-linked leaves. Transaction records bind both old and new manifest + SHA-256 values; compensation uses only the recorded old hash. +- A final transaction-snapshot failure after a visible Ready activation receipt + returns `CCOD_INSTALL_READY_FINALIZATION_PENDING`, leaves the transaction at + `ProtectionReady`, and writes no contradictory Failed receipt or snapshot. + +### Fix commit + +- `fix: complete generation bootstrap lifecycle` (this fix-round commit) + +## Fix round 2 — recover append-only lifecycle finalization + +### RED and root-cause evidence + +- The pre-recovery lifecycle baseline command + `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1` + exited `0` with `Install lifecycle self-tests passed: 114`; this proved the + existing suite covered only creation of the `ProtectionReady` gap, not a + later invocation that completes it. +- After adding the second-invocation recovery regression, the same command + exited `1` with + `CCOD_SELFTEST_FAILED case=a-later-invocation-recovers-one-missing-Ready-transaction-snapshot-without-another-install-mutation error=CCOD_INSTALL_SOURCE_MISSING`. + The real entry touched the vanished source checkout before attempting any + state-only recovery. +- The first implementation run exited `1` with + `CCOD_INSTALL_READY_RECOVERY_INVALID`; temporary bounded diagnostics traced + that failure to calling the private, non-exported + `Get-CcodRuntimeDirectoryForId`. The final implementation derives the + already validated runtime path locally and applies the existing contained + install-path proof; the temporary diagnostic text was removed. + +### GREEN and final verification evidence + +- InstallFileTransaction command: + `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1` + exited `0`; all 26 named cases passed. Coverage includes V4 state-only + create-only records, duplicate-record rejection, generation/payload/ + manifest/retained/pointer/retirement exclusion, and a forced re-import with + the V3 marker already present. +- InstallLifecycle command: + `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1` + exited `0` with `Install lifecycle self-tests passed: 116`. A later + invocation with the source checkout removed appends exactly one matching + Ready transaction snapshot and creates no runtime, pointer, task start, or + Failed record. Malformed Ready receipt, mismatched append-only pointer, and + changed manifest bytes each remain `ProtectionReady` and write nothing. +- Bootstrap exited `0` with `Bootstrap self-test passed: 24`. +- RuntimeManifest exited `0`; all 19 named cases reported `True`, including + real default-adapter fresh and append-only upgrade pointer commits. +- StaticProbeWorker exited `0`; all 38 named cases reported `True`, including + unsafe selector root/leaf/JSON/generation rejection. +- UninstallBootstrap exited `0` with + `Uninstall bootstrap self-tests passed: 14`. +- PersistenceIO exited `0`; all 24 named cases reported `True`. +- UiPreferences exited `0`; all 9 named cases reported `PASS`. +- Explicit PowerShell parser checks passed for all 12 changed `.ps1`/`.psm1` + files (`PARSER_COUNT=12`). +- `git diff --check` exited `0`; it emitted only checkout LF-to-CRLF warnings. +- Aggregate command: + `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\PersistenceSelfTest.ps1` + exited `0`; after expanding the forced-V3-reimport denial matrix, the final + aggregate rerun also exited `0`. Both aggregate runs emitted no stdout/stderr + result text. + +### Recovered behavior and boundaries + +- `Open-CcodInstallStateTransaction` is ABI V4 and can traverse only + root-to-`state` directories and publish create-only state records. It cannot + create/open/mutate a generation, copy payload bytes, write a generation + manifest, select a retained generation, commit an active pointer, or retire + a generation. +- `Invoke-CcodInstall` distinguishes only the exact recoverable + `ProtectionReady` head from other nonterminal transactions. It revalidates + immutable transaction identity, the exact append-only active runtime and + generation, the recorded new-manifest SHA-256, the current runtime manifest, + and exactly one matching Ready activation receipt before and after opening + the state-only capability. +- Recovery occurs before source validation and performs no install, task, + process, pointer, log, activation-receipt, or Failed write. A proof mismatch + returns `CCOD_INSTALL_READY_RECOVERY_INVALID`; a state-only append failure + remains retryable as `CCOD_INSTALL_READY_FINALIZATION_PENDING`. +- Selector readers in Bootstrap, StaticProbeWorker, and UninstallBootstrap + reject a non-directory selector root and unsafe, malformed, duplicate, + non-integral, or noncanonical leaves. Legacy `active.json` fallback remains + available only when the append-only root is absent. +- No push, tag, release, build, installation, product-process action, + WindowsApps access, DPAPI access, or external write was performed. + +### Remaining concerns + +- The documented current-user ordinary-race boundary is unchanged; this is not + a sandbox against unrestricted same-user or in-process code. +- Failed and superseded immutable generations remain retained until the + separately proven reclamation path runs. + +## Fix round 3 — fail closed selector fallback + +### RED and root-cause evidence + +- RuntimeManifest command: + `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\RuntimeManifest.SelfTest.ps1` + exited `1` at + `default-immutable-fence-rejects-selector-root-files-and-non-not-found-lookup-failures-before-pointer-commit` + with `CCOD_INSTALL_POINTER_COMMIT_FAILED` and `The directory name is + invalid`. A selector-root file passed the authorization read and failed only + later inside pointer commit. +- Bootstrap command: + `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\Bootstrap.SelfTest.ps1` + exited `1` at + `bootstrap-selector-fallback-requires-proven-ItemNotFound-instead-of-a-lookup-error`; + the wished-for selector lookup seam did not yet exist + (`NamedParameterNotFound,Read-CcodBootstrapActivePointer`). +- StaticProbeWorker command: + `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\StaticProbeWorker.SelfTest.ps1` + exited `1` in the hostile selector matrix with + `ASSERT_THROWS: expected CCOD_STATIC_RUNTIME_UNAUTHORIZED`. The exact + identified case was `schema`: selector `schemaVersion:2` was authorized. +- UninstallBootstrap command: + `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\UninstallBootstrap.SelfTest.ps1` + exited `1` at + `uninstall-selector-fallback-requires-proven-ItemNotFound-instead-of-a-lookup-error`; + the wished-for selector lookup seam did not yet exist + (`NamedParameterNotFound,Get-CcodUninstallBootstrapVerifiedRuntimeContext`). + +### GREEN and final verification evidence + +- InstallFileTransaction exited `0`; all 26 named cases passed. The V3/V4 + evidence now uses a fresh child `powershell.exe`: V3 is defined first, V4 is + proven absent before import, the current module then exposes ABI V4, one + state record is appended, and generation leaf, copy, manifest, retained, + pointer, and retirement operations are rejected through exports only. +- Bootstrap exited `0` with `Bootstrap self-test passed: 25`. +- RuntimeManifest exited `0`; all 20 named cases reported `True`. +- StaticProbeWorker exited `0`; all 39 named cases reported `True`. +- UninstallBootstrap exited `0` with + `Uninstall bootstrap self-tests passed: 15`. +- InstallLifecycle exited `0` with + `Install lifecycle self-tests passed: 116`. +- PersistenceIO exited `0`; all 24 named cases reported `True`. +- UiPreferences exited `0`; all 9 named cases reported `PASS`. +- Explicit parser checks passed all 9 changed `.ps1`/`.psm1` files + (`PARSER_COUNT=9`). +- `git diff --check` exited `0`; it emitted only checkout LF-to-CRLF warnings. +- Aggregate command: + `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\PersistenceSelfTest.ps1` + exited `0` and emitted no stdout/stderr result text. + +### Behavior and boundaries + +- RuntimeManifest/default fence, Bootstrap, StaticProbeWorker, and + UninstallBootstrap permit legacy `active.json` fallback only after a proven + ItemNotFound result for `state\active-generation`. Root files, reparse + points, access/I/O lookup errors, null/non-proof adapter returns, malformed + stores, and unsupported records fail with the component's bounded pointer or + runtime authorization error. +- StaticProbeWorker now requires append-only selector `schemaVersion` to be + exactly integer `1`. Static and Uninstall matrices cover malformed JSON, + unsupported schema, duplicate key, ADS, multi-link, root/leaf reparse, + fractional generation, and noncanonical names before runtime authorization. + Bootstrap retains equivalent hostile-selector coverage. +- Every fix2 invariant remains covered, including the real default pointer + fence, state-only Ready finalization, recorded-manifest compensation, and no + root-shell overwrite. +- No push, tag, release, build, installation, product-process action, + WindowsApps access, DPAPI access, or external write was performed. + +### Remaining concerns + +- The documented current-user ordinary-race boundary remains unchanged. +- Failed and superseded immutable generations remain retained until the + separately proven reclamation path runs. + +## Fix round 4 — prove selector absence safely + +### RED and root-cause evidence + +- RuntimeManifest exited `1` at + `legacy-fallback-rejects-a-state-ancestor-file-before-active-runtime-authorization` + with `ASSERT_THROWS: expected CCOD_RUNTIME_POINTER_INVALID`. A valid legacy + pointer was accepted when the intermediate `state` object was a regular file. +- Bootstrap exited `1` at + `bootstrap-legacy-fallback-rejects-a-state-ancestor-file` with + `ASSERT_THROWS: expected CCOD_BOOTSTRAP_POINTER_INVALID`; the child lookup's + ItemNotFound result hid the invalid parent object. +- StaticProbeWorker exited `1` at + `selector-lookup-requires-an-explicit-discriminated-absence-proof` with + `CCOD_STATIC_RUNTIME_UNAUTHORIZED`: an actual ItemNotFound adapter result was + collapsed into generic adapter failure. After the discriminated lookup was + added, the next RED run reached + `legacy-authorization-rejects-a-state-ancestor-file` and exited `1` with + `ASSERT_THROWS`, proving that the parent check remained independently absent. +- UninstallBootstrap exited `1` at + `uninstall-legacy-fallback-rejects-a-state-ancestor-file-at-the-selector-boundary` + with `ASSERT_TRUE`; it did not reject the malformed parent at the bounded + selector authorization boundary. + +### GREEN and final verification evidence + +- InstallFileTransaction exited `0`; all 26 named cases passed, including the + V4 state-only no-escape and V3-first re-import evidence. +- Bootstrap exited `0` with `Bootstrap self-test passed: 26`. +- RuntimeManifest exited `0`; all 21 named cases reported `True`. +- StaticProbeWorker exited `0`; all 41 named cases reported `True`, including + actual ItemNotFound fallback, null/empty/malformed/unknown result rejection, + and the `state`-file boundary. +- UninstallBootstrap exited `0` with + `Uninstall bootstrap self-tests passed: 16`. +- InstallLifecycle exited `0` with + `Install lifecycle self-tests passed: 116`. +- PersistenceIO exited `0`; all 24 named cases reported `True`. +- UiPreferences exited `0`; all 9 named cases reported `PASS`. +- Explicit PowerShell parser checks passed all 8 changed `.ps1`/`.psm1` files + (`PARSER_COUNT=8`). +- `git diff --check` exited `0`; it emitted only checkout LF-to-CRLF warnings. +- Aggregate command: + `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\PersistenceSelfTest.ps1` + exited `0` and emitted no suite failure text. + +### Behavior and boundaries + +- RuntimeManifest/default fence, Bootstrap, StaticProbeWorker, and + UninstallBootstrap now consult legacy `active.json` only when `state` itself + has an exact ItemNotFound outcome, or when `state` is a proven plain + nonreparse directory and `state\active-generation` has an exact ItemNotFound + outcome. A parent file, reparse point, inaccessible object, lookup failure, + or other non-directory fails the component's bounded authorization contract. +- StaticProbe selector lookup now returns one exact discriminated + `{ Status, Item }` object. Only `Status='Missing'` with a null item, produced + from an exact ItemNotFound exception, represents absence; `Found` requires a + nonnull item, and null, empty, malformed, unknown, diagnostic, or exceptional + adapter results fail before runtime import. +- Every earlier Task 2 invariant remains covered, including V4 state-only + recovery containment, append-only Ready finalization, canonical runtime + identity, the real default pointer fence, recorded-manifest compensation, + strict selector leaf validation, and no root-shell overwrite. +- No push, tag, release, build, installation, product-process action, + WindowsApps access, DPAPI access, or external write was performed. + +### Remaining concerns + +- The documented current-user ordinary-race boundary remains unchanged. +- Failed and superseded immutable generations remain retained until the + separately proven reclamation path runs. + +## Fix round 5 — permit proven legacy selector fallback + +### RED and root-cause evidence + +- UninstallBootstrap exited `1` at + `uninstall-legacy-fallback-accepts-a-valid-pointer-when-the-entire-state-plane-is-absent`. + The regression expected a valid legacy pointer to reach the later missing + lifecycle-epoch boundary, but production rejected the selector path first. + `UninstallBootstrap` resolved `state\active-generation` before it performed + the separate ItemNotFound lookup for `state`, so a genuinely absent selector + plane could never take the permitted compatibility path. +- The first focused matrix also reproduced the independent Bootstrap harness + failure at + `fails-promptly-when-fallback-lifecycle-ownership-cannot-be-released`: + `ASSERT_EXACT` reported `TimedOut` expected `False`, actual `True`. The old + test timed the whole cold launch with a 4-second watchdog even though the + injected release attempt occurred at about 3.1 seconds, fallback promotion + at about 5.0 seconds, the stable failure log at about 5.03 seconds, and + process exit at about 5.08 seconds. The harness killed the bootstrap before + its `finally`; this was not a production bootstrap deadlock. + +### GREEN and final verification evidence + +- Uninstall now resolves and probes `state` first. Exact ItemNotFound selects + legacy fallback without resolving its child; only a proven plain nonreparse + state directory permits resolving and probing `state\active-generation`. + The no-state regression first proves that an invalid legacy pointer is still + rejected, then restores the valid pointer and proves authorization reaches + the later missing lifecycle-epoch boundary rather than skipping validation. +- The Bootstrap release-failure test is deterministic and test-only. Its + injected release function writes a marker immediately before throwing, the + whole-process watchdog is 15 seconds, and the assertion requires exit `1` + within 2 seconds after the marker. It retains the stable failure-log, + fallback-child-exited, and AccountTransition-mutex-reacquired proofs. No + production bootstrap control flow or timeout changed. +- InstallFileTransaction exited `0`; all 26 named cases passed. +- Bootstrap exited `0` with `Bootstrap self-test passed: 26`. +- RuntimeManifest exited `0`; all 21 named cases reported `True`. +- StaticProbeWorker exited `0`; all 41 named cases reported `True`. +- UninstallBootstrap exited `0` with + `Uninstall bootstrap self-tests passed: 17`. +- InstallLifecycle exited `0` with + `Install lifecycle self-tests passed: 116`. +- PersistenceIO exited `0`; all 24 named cases reported `True`. +- UiPreferences exited `0`; all 9 named cases reported `PASS`. +- Explicit PowerShell parser checks passed all 3 changed `.ps1` files + (`TASK2_FIX5_PARSER_COUNT=3`). +- `git diff --check` exited `0` (`TASK2_FIX5_DIFF_CHECK_EXIT=0`); it emitted + only checkout LF-to-CRLF warnings. +- Aggregate command: + `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\PersistenceSelfTest.ps1` + exited `0` (`TASK2_FIX5_AGGREGATE_EXIT=0`) with no suite failure output. + +### Behavior and boundaries + +- A wholly absent `state` plane is now the exact permitted Uninstall legacy + compatibility case. Existing state files, reparse points, inaccessible + objects, null or malformed lookup results, and non-ItemNotFound failures + still return `CCOD_UNINSTALL_RUNTIME_INVALID` at the selector boundary. +- When `state` is valid, child absence alone permits legacy fallback; every + hostile selector-root/leaf, JSON, schema, generation, ADS, and multilink + negative case remains covered and green. +- Legacy selection does not bypass runtime-manifest, cleanup-payload, bootstrap + fingerprint, or lifecycle-epoch validation. No production Bootstrap change, + release, install, product-process action, WindowsApps access, DPAPI access, + push, tag, signing, or external write was performed. + +### Remaining concerns + +- The documented current-user ordinary-race boundary remains unchanged. +- Failed and superseded immutable generations remain retained until the + separately proven reclamation path runs. diff --git a/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-3-report.md b/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-3-report.md new file mode 100644 index 0000000..b6f6a20 --- /dev/null +++ b/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-3-report.md @@ -0,0 +1,341 @@ +# Task 3 report: sealed Setup package and temporary bootstrap + +## Status + +Implemented the Task 3 sealed installer package and no-`{app}` Setup bootstrap. +Setup now embeds exactly four `dontcopy` inputs, retains package/manifest/ +bootstrap locks across activation and validation, and accepts success only from +the same temporary bootstrap's strict append-only `Ready` validation. + +No release build, installation, product process control, external service, +WindowsApps access, DPAPI access, signing, push, tag, or publication occurred. + +## RED evidence + +The following REDs were observed in order. Obsolete-test REDs are identified +separately from production defects so they are not mistaken for regressions. + +- `ReleaseWorkflow.SelfTest.ps1` exited `1` because + `build\InstallerPackage.psm1` did not exist. +- After the package matrix went GREEN, ReleaseWorkflow exited `1` because the + production ISS lacked `CreateAppDir=no` and still exposed the old `{app}` + product-copy contract. +- Expected obsolete-contract REDs then identified old destination-inventory, + `{app}\payload`, direct portable-launcher, recursive/reparse-gate, shortcut, + icon, uninstall, installed-validation, and legacy receipt assertions. They + were replaced with Task 3 no-product-write and append-only assertions; no + production compatibility fallback was added. +- The first real sealed-package ISCC compile exited `2` with + `Unknown identifier 'CreateGuid'`; the compiled path now uses the proven + `CoCreateGuid` / `StringFromGUID2` contract. +- PE validation REDs exposed Inno version-string field truncation for full + 64-character manifest/bootstrap hashes. The final PE contract binds each as + exact 32+32 fields and independently recomposes both hashes. +- The first real package-to-bootstrap execution returned + `CCOD_INSTALLER_PACKAGE_INVALID`. The cause was compact PowerShell text such + as `return[pscustomobject]` and `throw'CODE'` being parsed as command names. + The syntax was corrected and the same real test then passed. +- The Setup provenance RED failed because the old schema bound only the payload + manifest. Schema 2 now records and revalidates package, package-manifest, and + bootstrap hashes plus canonical build inputs and the PE split contract. +- `InstallLifecycle.SelfTest.ps1` REDs identified the pre-Task-3 mutable + `state\post-install-activation.json` fixtures. Ready, Failed, and progress + fixtures now use separate append-only leaves. A correlated nonterminal leaf + returns `CCOD_ACTIVATION_RECEIPT_NOT_READY`; no receipt returns + `CCOD_ACTIVATION_RECEIPT_MISSING`; conflicting terminal leaves fail closed. + +## Implemented behavior + +- `New-CcodInstallerPackage` creates one ZIP containing exactly the + manifest-listed payload plus `installer-payload.manifest.json` and emits a + canonical package manifest bound to version and 40-hex commit. +- `Test-CcodInstallerPackage` rejects missing, extra, duplicate, + case-colliding, unsafe, length-changed, content-changed, version-changed, + commit-changed, package-hash-changed, and manifest-hash-changed inputs. +- `build.ps1` creates and immediately revalidates the sealed package, binds the + package, manifest, and bootstrap hashes into generated ISCC input, and uses + schema-2 sealed Setup provenance. +- The ISS has `CreateAppDir=no`, `Uninstallable=no`, no product `[Files]` + destination, and no `[Icons]`, `[Registry]`, `[UninstallRun]`, recursive + extraction, legacy shortcut deletion, or `{app}` bootstrap execution. +- Inno extracts four named temporary inputs, hashes/locks/rehashes package, + manifest, and bootstrap, executes that exact bootstrap path, then invokes it + again with `-ValidateReceiptOnly` while the locks remain held. +- The bootstrap validates and extracts only the ZIP manifest entry set into a + fresh private root, passes the exact package SHA-256 as + `-SealedPackageSha256`, and validates only append-only activation receipts. +- A real disposable test proves package -> temporary bootstrap -> child hash + propagation and append-only Ready. A separate replacement barrier proves + locked original bootstrap bytes, not replacement bytes, are executed. + +## GREEN and verification evidence + +- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ReleaseWorkflow.SelfTest.ps1` + exited `0`; final output: `Release workflow self-tests passed.` The suite + includes a real Inno Setup 6.7.3 production-script compile. +- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1` + exited `0`; final output: `Install lifecycle self-tests passed: 113`. +- Explicit PowerShell parser validation passed all eight changed `.ps1`/ + `.psm1` files (`PARSER_COUNT=8`). +- `git diff --check` exited `0`; only checkout LF-to-CRLF warnings were emitted. +- Static boundary audit reported `BOUNDARY_VIOLATIONS=0` for pre-Ready `{app}` + writes, product sections, recursive extraction, old app bootstrap execution, + Setup mode, and mutable receipt-reader authority. +- The full aggregate was not requested by the Task 3 brief and was not run; no + aggregate-pass claim is made. + +## Boundaries and remaining work + +- Task 4 still owns post-Ready uninstall registration, shortcuts, icons, and + exact legacy registration/shortcut migration. +- Task 5 still owns Defender scanning and the final exact public asset contract. +- The current-user ordinary-writer boundary and retained-generation disk-use + boundary from Tasks 1-2 are unchanged. + +## Commit + +- Initial implementation commit: + `9e1c41c72d296c2d0c014a45561a855699ebd39b` + (`fix: bootstrap sealed installer package`). + +## Fix round 1: portable identity, exact provenance, and executed Setup proof + +### Review findings and RED evidence + +Scoped review of `46692e7..9e1c41c72d296c2d0c014a45561a855699ebd39b` +failed with four Important findings. + +- Portable identity RED: `ReleaseWorkflow.SelfTest.ps1` exited `1` because + `Invoke-CcodPortableLifecycleInstaller` did not exist. The portable wrapper + revalidated its manifest after Defender but discarded that result and called + `Install-CodexControlOtherDevices.ps1` without `-SealedPackageSha256`. +- Exact provenance RED: the new schema-two mutation matrix exited `1` with + `ASSERT_THROWS: expected CCOD_SETUP_PROVENANCE_INVALID`. The validator + accepted altered nested evidence because it did not require every property + order/type/name/length/count/hash. +- Executed Setup RED: the deliberately wrong-package-hash production Setup + compiled successfully and executed its real temporary input path, but the + test observed exit `0`. Its runtime log proved the hash gate did run: + `CurStepChanged raised an exception` with + `CCOD_SETUP_INPUT_BINDING_INVALID`; Inno defaulted the suppressed error box + to OK and still returned success. +- Report RED: the initial report said only “Task 3 HEAD”, which did not provide + an immutable 40-hex object identity. + +### Remediated behavior + +- Portable now retains both the initial and post-Defender verified + `PayloadManifestSha256`, requires an exact canonical match before invoking + the lifecycle child, and passes that value explicitly as + `-SealedPackageSha256`. A changed canonical identity fails with + `CCOD_PORTABLE_PACKAGE_IDENTITY_INVALID` before the child marker can run. +- `Test-CcodSealedSetupBuildProvenance` requires exact ordered top-level and + nested schemas, JSON types, artifact names, actual lengths and SHA-256s, + manifest `fileCount` and `payloadManifestSha256`, canonical version/commit/ + timestamp, compiler/template/inventory identity, and the complete split PE + contract. Twenty-four independent mutations are rejected. +- The same `ExtractAndLockCcodInputs` gate now runs from `PrepareToInstall`. + A nonempty error uses Inno's actual install-failure contract; `CurStepChanged` + consumes only the three already-held verified handles. The executed + wrong-hash fixture exits nonzero, records + `CCOD_SETUP_INPUT_BINDING_INVALID`, never runs its inert activation marker, + creates no `/DIR` app output, and never logs the real LocalAppData product + root. + +### GREEN and verification evidence + +- Final `ReleaseWorkflow.SelfTest.ps1`: exit `0`, including the portable exact + identity test, 24-case provenance mutation matrix, real production ISCC + compile, and bounded wrong-hash Setup execution. +- Final `InstallLifecycle.SelfTest.ps1`: exit `0`; + `Install lifecycle self-tests passed: 113`. +- Explicit parser checks passed all four changed PowerShell code/test files. +- `git diff --check` exited `0`; only checkout LF-to-CRLF warnings were emitted. +- Static no-`{app}` boundary audit reported `FIX1_BOUNDARY_VIOLATIONS=0`. +- No aggregate run was performed in this fix round; no aggregate-pass claim is + made. +- No release build, real install, product process control, external service, + WindowsApps, DPAPI, signing, push, tag, or publication action occurred. + +### Exact implementation commit + +- `51def5c9ae2f5e0722b9083e478b17c507e9cee8` + (`fix: harden sealed setup evidence`). + +## Fix round 2: copied portable identity and raw evidence binding + +### Review findings and RED evidence + +Scoped review of +`9e1c41c72d296c2d0c014a45561a855699ebd39b..728b62710c0729b40908e92887d3fe70903edbba` +remained FAIL with three Important findings. + +- Copied portable identity RED: `ReleaseWorkflow.SelfTest.ps1` exited `1` + with `NamedParameterNotFound` for `CopiedSealedPackageSha256`. The wrapper + still used a source identity and `Copy-CcodPortablePayload` returned its + pre-copy manifest result rather than an identity revalidated from the + published target. +- Raw provenance RED: a top-level duplicate `schemaVersion` member remained + accepted because Windows PowerShell `ConvertFrom-Json` collapsed it before + the parsed-object exact-schema checks. +- Product-state evidence RED: the executed wrong-hash Setup fixture exited `1` + because `Get-CcodReadOnlyProductTreeSnapshot` did not exist. The prior test + checked only its isolated `/DIR`, marker, and log rather than comparing the + actual current-user product root. + +### Remediated behavior + +- `Copy-CcodPortablePayload` now validates the moved published installer tree + and returns that copied manifest identity. The portable wrapper requires the + initial source, post-Defender source, and copied identities to be the same + canonical SHA-256, and only the copied value reaches the lifecycle child. +- A production-module breakpoint regression changes source bytes exactly + between manifest validation and `File.Copy`. Destination rehashing returns + `CCOD_PORTABLE_COPY_HASH_MISMATCH`; no installer root, lifecycle child, or + lifecycle state becomes visible. +- A bounded raw JSON scanner walks objects and arrays before any provenance or + package-manifest `ConvertFrom-Json`, decodes escaped property names, rejects + duplicate semantic member names at every depth, and enforces 1 MiB / depth + 32 limits. Duplicate mutations at the top level and all five nested + provenance objects fail with `CCOD_SETUP_PROVENANCE_INVALID` while the prior + 24 exact-schema mutations remain covered. +- The wrong-hash production Setup fixture now snapshots the exact LocalAppData + product root before and after execution. The read-only snapshot manually + traverses without following reparse points, rejects alternate streams, is + bounded to 8192 entries / 256 MiB per file / 1 GiB total, and records root/ + directory metadata plus file metadata and SHA-256. Before and after canonical + snapshots are identical. Fixture cleanup still targets only its unique temp + root and never creates, modifies, or removes the real product root. + +### GREEN and verification evidence + +- Final `ReleaseWorkflow.SelfTest.ps1`: exit `0`, including copied-identity, + full copy-race, six-level raw duplicate, and actual product-tree snapshot + coverage. +- Final `InstallLifecycle.SelfTest.ps1`: exit `0`; + `Install lifecycle self-tests passed: 113`. +- Explicit parser checks passed all four changed PowerShell code/test files. +- `git diff --check` exited `0`; only checkout LF-to-CRLF warnings were emitted. +- Static no-`{app}` audit reported `FIX2_BOUNDARY_VIOLATIONS=0`. +- No aggregate run was performed in this fix round; no aggregate-pass claim is + made. +- No release build, real install, product-process control, external service, + WindowsApps, DPAPI operation, signing, push, tag, or publication occurred. + +### Exact implementation commit + +- `1cb91621f2565318143dffe08844a91a350a36d9` + (`fix: bind portable sealed execution`). + +## Fix round 3: actual portable entrypoint race evidence + +### Review finding and RED evidence + +Scoped review of +`728b62710c0729b40908e92887d3fe70903edbba..a5d595892f134a5ef957feab4ec4e68228cec97b` +remained FAIL with one Important test-compliance finding. The race test invoked +`Copy-CcodPortablePayload` directly inside the module and therefore did not +prove that the real `Install-CodexRemote-fix.ps1` control flow stopped before +its child and lifecycle boundaries. This review failure is the RED evidence; +the production post-Defender revalidation behavior itself did not require a +code change. + +### Remediated evidence + +- The replacement fixture copies the production + `Install-CodexRemote-fix.ps1` byte-for-byte and asserts the copy hash before + invoking that actual script path. +- It uses the production `PortableRelease.psm1`, a valid manifest, and an + isolated child installer that would create both a marker and fixture + lifecycle directory if execution reached it. +- Test-local Defender commands remain entirely inside the fixture process. + `Start-MpScan` changes the child source after the entrypoint's initial + manifest validation and before its copy boundary. +- The actual entrypoint returns `CCOD_PORTABLE_MANIFEST_INVALID` from its + post-Defender `Test-CcodPortablePayloadManifest` call. Only after it returns, + the test proves the marker and fixture lifecycle root are both absent. +- The helper identity test remains as unit coverage, but no helper-only race + test is claimed as end-to-end evidence. + +### GREEN and verification evidence + +- Final `ReleaseWorkflow.SelfTest.ps1`: exit `0`; the named actual-entrypoint + validation-to-copy race case passed. +- Final `InstallLifecycle.SelfTest.ps1`: exit `0`; + `Install lifecycle self-tests passed: 113`. +- The changed PowerShell test file parsed with zero errors (`1/1`). +- `git diff --check` exited `0`; only the checkout LF-to-CRLF warning was + emitted. +- No aggregate run was performed in this fix round; no aggregate-pass claim is + made. +- No external release, push, tag, signing, install, product-process, + WindowsApps, or DPAPI action occurred. + +### Exact implementation commit + +- `58bd7603fa83133d78d2f62ac045ec00d25134c9` + (`test: exercise portable sealed race end to end`). + +## Fix round 4: final portable copy-barrier evidence + +### Review finding and RED evidence + +Scoped review of +`a5d595892f134a5ef957feab4ec4e68228cec97b..9e971d1fe26c0f60d8e8519318c11006798dc493` +remained FAIL with one Important test-compliance finding. The actual-entrypoint +fixture changed its child during the mocked Defender gate, so the production +post-Defender manifest revalidation returned +`CCOD_PORTABLE_MANIFEST_INVALID`; it did not exercise the later validated +source-to-`File.Copy` barrier. + +The unfinished fix-round-4 test was run before completion. The full +`ReleaseWorkflow.SelfTest.ps1` exited `1` in +`actual portable entrypoint rejects the final File.Copy barrier race before +child or lifecycle state`: its +`CCOD_PORTABLE_COPY_HASH_MISMATCH` assertion failed because the fixture never +performed the intended source mutation and `SourceMutated` remained false. +This was the expected RED for the missing test-local copy-boundary condition. + +### Remediated evidence + +- The fixture invokes a byte-identical temporary copy of the production + `Install-CodexRemote-fix.ps1` and uses a byte-identical temporary copy of the + production `PortableRelease.psm1`; both identities are asserted before the + entrypoint runs. +- The existing test-local entrypoint breakpoint redirects only the module's + private expected-installer-root function to a new disposable fixture root. + No public production parameter or externally reachable test switch was + added. +- The manifest order is asserted so the marker-capable child reaches the first + production payload copy boundary. A breakpoint on the copied module's unique + ordinary `[IO.File]::Copy($sourceFile,$destination,$false)` line writes + distinct child text after the module has validated its source record and + immediately before that production copy executes. +- The production copy and destination SHA-256 comparison then run unchanged + and return `CCOD_PORTABLE_COPY_HASH_MISMATCH`. Only after the actual + entrypoint returns does the test prove that the child marker, fixture + lifecycle/install root, and published fixture installer root are all absent. +- The separate post-Defender source-revalidation fixture remains intact and + continues to cover its earlier `CCOD_PORTABLE_MANIFEST_INVALID` boundary. + +### GREEN and verification evidence + +- Final `ReleaseWorkflow.SelfTest.ps1`: exit `0`; final output + `Release workflow self-tests passed.` The actual-entrypoint final + `File.Copy` barrier case passed. +- Final `InstallLifecycle.SelfTest.ps1`: exit `0`; final output + `Install lifecycle self-tests passed: 113`. +- The changed PowerShell test file parsed with zero errors (`1/1`). +- `git diff --check` exited `0`; only the checkout LF-to-CRLF warning was + emitted. +- No aggregate run was performed in this fix round; no aggregate-pass claim is + made. +- No real installer, network, external action, listener, product process + control, release, push, tag, signing, install, WindowsApps, or DPAPI action + occurred. All new runtime files were temporary fixture files below a unique + disposable root. + +### Exact implementation commit + +- `141ab716001e7c1048db10e248101fa8cd3f54ae` + (`test: exercise portable copy barrier race`). diff --git a/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-4-report.md b/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-4-report.md new file mode 100644 index 0000000..a9145e5 --- /dev/null +++ b/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-4-report.md @@ -0,0 +1,552 @@ +# Task 4 report: post-Ready product registration + +## Status + +Implemented current-user uninstall registration and the exact Start-menu and +desktop `CodexRemote-fix.lnk` records after the Task 2 terminal `Ready` proof. +All three new records are read back before the exact legacy migration is +authorized. Product-registration failures are reported after the file +transaction closes and never enter install compensation, append `Failed`, or +downgrade an already durable `Ready` transaction. + +The direct installed uninstaller now runs only from the canonical selected +`2.5.22--` generation, invokes that generation's +manifest-bound uninstall bootstrap, and completes through the verified +external transaction payload. No Inno uninstaller is required. + +No real registry or shortcut mutation, installer/uninstaller/product +execution, release build, network write, push, tag, signing, or publication was +performed. + +## RED evidence + +- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ProductRegistration.SelfTest.ps1` + exited `1` because + `src\persistence\modules\ProductRegistration.psm1` did not exist. +- After the six registration behavior groups were added, the lifecycle suite + exited `1` with `CCOD_INSTALL_ADAPTER_INVALID`, target + `RegisterProduct`; there was no post-Ready lifecycle seam. +- The uninstall bootstrap suite exited `1` with + `CCOD_UNINSTALL_ADAPTER_INVALID`, target `RemoveProductRegistration`; there + was no verified post-cleanup product-state remover. +- The file-transaction suite exited `1` because the exact capability export + surface did not contain the fixed SpecialFolder open/copy operations. +- The Ready-finalization recovery regression exited `1`: expected one product + registration after the missing Ready snapshot was recovered, actual zero. + +## GREEN evidence + +- Product registration: + `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ProductRegistration.SelfTest.ps1` + exited `0`; `Product registration self-tests passed: 6`. +- File capability: + `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1` + exited `0`; all 26 named cases passed. +- Uninstall bootstrap: + `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\UninstallBootstrap.SelfTest.ps1` + exited `0`; `Uninstall bootstrap self-tests passed: 17`. +- Install lifecycle: + `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1` + exited `0`; `Install lifecycle self-tests passed: 115`. +- Explicit PowerShell parser checks passed all nine changed `.ps1`/`.psm1` + implementation and test files (`PARSER_FAILURES=0`). +- `git diff --check` exited `0`; only checkout LF-to-CRLF warnings were + emitted. +- The aggregate `tests\PersistenceSelfTest.ps1` was not requested by the Task + 4 brief and was not run; no aggregate-pass claim is made. + +## Implemented boundaries + +- `New-CcodProductRegistration`, `Test-CcodProductRegistration`, + `Commit-CcodProductRegistration`, and + `Remove-CcodLegacyProductRegistration` enforce the canonical runtime ID, + exact version/hash, exact runtime bootstrap/uninstaller paths, fixed HKCU + product key, and two fixed shortcut leaves. +- Shortcut candidates are generated before runtime identity calculation, + copied into `registration/` inside the immutable generation, included in its + manifest/runtime-ID binding, and copied after Ready only through pinned + current-user Programs/Desktop handles. Arbitrary destination paths and + shortcut leaf names are not accepted. +- The shortcut candidates start the one fixed current-user Supervisor task. + Task 2 already binds that task action to the selected generation bootstrap; + this avoids embedding a not-yet-computable runtime ID in candidate bytes + while still resolving the sealed bootstrap selected and proven Ready. +- Normal install and the bounded missing-Ready-snapshot recovery both perform + post-Ready registration. The recovery uses the state transaction only for + fixed SpecialFolder capability operations; generation and pointer mutation + remain unavailable. +- Uninstall removes only a matching current product runtime key and the two + exact new shortcut paths after protected application cleanup. Legacy + migration requires the canonical AppId, verified new registration, and the + exact legacy shortcut allowlist before deletion starts. + +## Compatibility concerns + +- Legacy migration is deliberately conservative: a missing or changed legacy + shortcut name, wrong AppId, or unreadable legacy entry retains all legacy + state for later diagnosis rather than attempting partial cleanup. +- The ordinary same-user writer and in-process introspection limits documented + by the immutable-generation design remain unchanged. +- No live Windows Settings launch, real shell-link promotion, or installed + uninstall was executed in this task; those remain part of later installed + acceptance. + +## Implementation commit + +- `e43d9ce18c5d79bb438e08c9ff2973be592277aa` + (`feat: register product only after readiness`). + +## Fix round 1: matched uninstall and retriable registration + +Scoped review of `70bc231..97cf17d` found one Critical and four Important +issues. The installed wrapper could synchronously continue after deleting its +own generation; product cleanup could remove an entire key without exact +value/shortcut evidence; `AlreadyInstalled` skipped registration retry; +legacy removal was not compensating; and shortcut copy accepted a state-only +transaction plus arbitrary absolute source path. + +### RED evidence + +- UninstallBootstrap first exited `1` because + `src\persistence\InstalledUninstallFinalizer.ps1` did not exist. +- ProductRegistration exited `1` with `CCOD_PRODUCT_ADAPTER_INVALID`, target + `ReadLegacyEntry`, before exact current-state and per-entry legacy + compensation adapters existed. +- InstallLifecycle exited `1` in the exact Ready/package idempotence case: + registration calls expected `1`, actual `0`. +- InstallFileTransaction exited `1` because the export surface lacked + `Open-CcodInstallProductRegistrationTransaction` and + `Open-CcodInstallRetainedFile`; product shortcut copy still exposed + `SourcePath`. + +### GREEN and verification evidence + +- ProductRegistration exited `0` with + `Product registration self-tests passed: 9`. Coverage includes exact + registry/shortcut evidence, unknown/replaced/reparse/ambiguous no-delete, + mid-sequence legacy restoration, and replacement-preserving compensation. +- InstallFileTransaction exited `0`; all 27 named cases passed. The new case + proves product-only retained-file authority, no arbitrary shortcut source + path, state-only denial, and product-scope denial for state writes. +- UninstallBootstrap exited `0` with + `Uninstall bootstrap self-tests passed: 19`. The installed path now stops at + `TaskRemoved`; its staged finalizer waits for exact wrapper exit, validates + transaction and selected generation, performs matched removal, proves root + absence, removes only matched product state, and then finalizes. Wrong + wrapper/generation/transaction cases delete nothing. +- InstallLifecycle exited `0` with + `Install lifecycle self-tests passed: 116`. Both normal and missing-Ready + recovery registration failures remain outside rollback/Failed and are + reconciled by a later exact same-package `AlreadyInstalled` invocation. +- Explicit parser checks passed all 11 changed PowerShell source/test files + (`PARSER_COUNT=11`, `FAILURES=0`). +- `git diff --check` exited `0`; only checkout LF-to-CRLF warnings were + emitted. +- The aggregate `tests\PersistenceSelfTest.ps1` was not run in this fix round; + no aggregate-pass claim is made. + +### Remediated boundaries + +- The selected-generation wrapper launches a hidden external finalizer from + the ACL-bound staged payload and returns. Application removal cannot begin + until the exact wrapper PID/creation-time identity has exited. +- Current product cleanup accepts only the exact allowed registry value names, + kinds and values plus both shortcut hashes, targets, arguments, and regular + file identities. Registry values are revalidated and removed individually; + a key is deleted only after it is empty and has no subkeys. +- Legacy migration captures exact registry kinds/values and shortcut bytes, + deletes entries one by one, and restores prior removals in reverse order + only while their names remain absent. A replacement is never overwritten. +- Product shortcut promotion now accepts only an opaque file capability opened + from one selected generation at either fixed manifest-relative candidate. + Product-only transactions cannot write state, generations, manifests, + pointers, or retirements; state-only transactions cannot acquire shortcut + authority. + +### Compatibility and external boundaries + +- Conservative mismatch behavior may leave current or legacy registration for + diagnosis instead of partially deleting it. +- No real registry, shortcut, installer, uninstaller, product process, + WindowsApps, DPAPI, network, release, push, tag, signing, or publication + action was performed. + +### Fix implementation commit + +- `2e76698d535e6e824df1c57d81314be1c2e053e8` + (`fix: complete matched product lifecycle`). + +## Fix round 2: exact Ready-bound finalization and cleanup + +Scoped review of `97cf17d..0fdc392` found four Important gaps: installed +finalization did not durably bind wrapper/manifest/epoch and removed too broad +a root; coherent registration replacement could self-authorize cleanup; +product source authority did not require latest selected Ready evidence; and +legacy restore failures were suppressed. + +### RED evidence + +- UninstallBootstrap exited `1` with `CCOD_INSTALLED_FINALIZER_INVALID`, target + `RemoveSelectedGeneration`, then separately with `NamedParameterNotFound` + for `Invoke-CcodUninstallBootstrap -WrapperIdentity`. +- ProductRegistration exited `1` because `Remove-CcodProductRegistration` + lacked `ReadyEvidence`; the fixed `schtasks.exe` negative later failed by not + throwing `CCOD_PRODUCT_REGISTRATION_NOT_READY`. +- InstallFileTransaction exited `1` because + `Open-CcodInstallProductRegistrationTransaction` lacked `ReadyEvidence`. +- ProductRegistration exited `1` with `CCOD_PRODUCT_ADAPTER_INVALID`, target + `WriteLegacyCompensationFailure`. + +### GREEN evidence + +- ProductRegistration: `10/10`, exit `0`. +- InstallFileTransaction: `27/27`, exit `0`. +- UninstallBootstrap: `19/19`, exit `0`. +- InstallLifecycle: `116/116`, exit `0`. +- Explicit parser checks: `10/10`, zero failures. +- `git diff --check`: exit `0`; only LF-to-CRLF checkout warnings. +- Aggregate was not run; no aggregate-pass claim is made. + +### Boundaries + +- Prepare durably records exact wrapper PID/creation/session/SID, selected + runtime root, manifest hash, generation, Ready evidence and lease epoch. + Finalizer requires an initially verified wrapper and its later exact exit, + current epoch, matching leaf/manifest, and deletes only that generation. +- Registration and cleanup bind canonical install root, Ready runtime and + generation, package/manifest/candidate hashes, exact arguments, and the + canonical `%System%\schtasks.exe` target. Coherent arbitrary replacements + preserve every entry. +- Product-only and retained-file capabilities require the latest append-only + pointer, matching manifest and one matching Ready install record; pre-Ready, + stale/unselected, absolute, state-only and wrong-relative sources fail. +- Legacy compensation verifies absence before each restore. Replacement or + restore failure is never overwritten or swallowed: it writes an explicit + unresolved record and returns `CCOD_LEGACY_PRODUCT_COMPENSATION_FAILED`. + A normal migration failure is reported only after full restoration. + +No real registry, shortcut, uninstall, product process, WindowsApps, DPAPI, +network, release, push, tag, signing, or publication action was performed. + +### Fix implementation commit + +- `283ecbe3eb012f1e78377e9336aacc7b3acc9336` + (`fix: bind matched product finalization`). + +### Supplemental pre-cleanup target gate + +Final pre-commit audit added a noncanonical Ready target negative. Its RED +returned the expected validation error but observed `Cleanup=1`: validation +failure was captured without clearing the fresh context. The fix clears every +failed fresh context, validates the same canonical Ready invariant on stored +resume transactions, and rejects before cleanup. Fresh UninstallBootstrap is +`20/20`, exit `0`; focused parser `4/4` and diff check exit `0`. + +- Supplemental commit: `67c79d82e18c805f27aec8e05553938f1f706380` + (`fix: reject invalid Ready evidence before cleanup`). + +## Fix round 3: strict retained authority and default finalizer proof + +Independent review found five Important gaps in stored Ready validation, +retained authority, current-state preflight, the current partially removed +legacy entry, and mock-only finalizer coverage. + +### RED evidence + +- Stored noncanonical Ready target returned the expected error but observed + `Cleanup=1`; failed fresh validation retained its context. +- Product authority accepted a fabricated five-field Ready object and did not + bind non-derived fields such as `oldRuntimeId`. +- Current product inspection did not reject an unknown registry subkey before + returning deletable entries. +- A partially mutated current Registry entry was absent from the outer + unresolved compensation set. +- Installed finalizer tests replaced all five filesystem/identity boundaries + rather than exercising their defaults. + +### GREEN evidence + +- ProductRegistration: `11/11`, exit `0`. +- InstallFileTransaction: `27/27`, exit `0`. +- UninstallBootstrap: `21/21`, exit `0`. +- InstallLifecycle: `116/116`, exit `0`. +- Full `tests\PersistenceSelfTest.ps1` aggregate: exit `0` after about + 7 minutes 15 seconds; it emitted no failure output. +- Parser: `8/8`, zero failures. `git diff --check`: exit `0`. + +### Remediated boundaries + +- Product authority accepts the complete ordered 12-field lifecycle Ready + record. The product capability stores its canonical complete identity; each + retained-file open compares the caller's complete record, including + `oldRuntimeId`, then rechecks the latest append-only selector and unique + strict Ready record. +- Current cleanup rejects registry subkeys before returning any entry. +- Legacy compensation includes the entry whose removal itself failed; partial + Registry restore failure is recorded unresolved and cannot be reported as a + fully restored migration failure. +- A disposable default finalizer fixture preserves the production transaction + reader, selected-generation/manifest validator, epoch reader, selected-root + remover, and absence proof. Only wrapper wait, product registry cleanup and + receipt finalization are replaced. It deletes the selected runtime while + preserving sibling install state. +- Normal/recovery registration retry and durable Ready preservation remain + green. No real registry, shortcut, uninstall, product, network, release, + signing, tag, push, or publication action occurred. + +### Implementation commit + +- `1ff17bb8759e3d5312c11336ca3b2188bb3a4fe2` + (`fix: enforce strict retained product authority`). + +## Fix round 4: durable Ready identity and real finalizer negatives + +Independent review of `7c333e0..a83840b` found five Important gaps: fresh and +stored uninstall paths did not share one current-root Ready invariant; product +authority still reduced a strict lifecycle record to five fields and did not +prove a complete selector/store at every retained-file open; legacy/current +registry negatives were adapter-shaped rather than production comparisons; and +the installed-finalizer evidence covered only a safe adapter-assisted positive. + +### RED evidence + +- UninstallBootstrap exited `1` in + `fresh and stored Ready evidence require one exact current-install-root + invariant before cleanup`: a fresh rooted but unrelated `installRoot` was + accepted instead of throwing `CCOD_UNINSTALL_BOOTSTRAP_INVALID`. The same + case also covers stored `TaskRemoved` wrong-root and extra-field records with + an explicit zero-cleanup assertion. +- InstallFileTransaction exited `1` with + `ASSERT_THROWS: expected CCOD_INSTALL_PRODUCT_SCOPE`: capability open first + accepted a caller record whose `oldRuntimeId` differed while all five derived + fields matched. The completed matrix also covers transaction ID/owned-set + mismatch, a fabricated five-field object, pointer gap/unexpected directory, + a stale N capability after N+1, changed persisted Ready identity, and a + scalar `ownedObjectNames` value. +- ProductRegistration exited `1` because the production-private + `Test-CcodCurrentProductRegistryPreflight` did not exist. The regression calls + that same helper with an unexpected subkey and proves zero removals. +- ProductRegistration exited `1` because the production-private + `Compare-CcodLegacySnapshotEntry` did not exist. Its fixture keeps the + Registry key present while removing one captured value; the result must be + `Mismatch`, the unresolved create-only record must contain `Registry`, and + the stable error must be `CCOD_LEGACY_PRODUCT_COMPENSATION_FAILED`. +- The first no-adapter staged-finalizer child matrix exited `1` instead of the + stable `3`: `Write-Error` re-threw under the script's Stop preference. After + that was fixed, the wrong stored Ready root reached selected-generation + deletion, exposing a dot-sourced `$InstallRoot` parameter collision in the + default transaction reader. Both failures occurred before the final GREEN. +- InstallFileTransaction exited `1` with + `ASSERT_THROWS: expected CCOD_INSTALL_PRODUCT_SCOPE` after hard-linked + pointer, transaction, and manifest leaves were added; the first hard-linked + authority leaf was accepted. +- InstallFileTransaction exited `1` because the production-private + `Read-CcodInstallAuthorityFile` did not exist. The wished-for contract returns + bytes and SHA-256 from one verified native handle and rejects a multi-link. + +### GREEN and final verification evidence + +- ProductRegistration: `13/13`, exit `0`. +- InstallFileTransaction: `29/29`, exit `0`. +- UninstallBootstrap: `23/23`, exit `0`. The no-adapter child matrix stages the + real `InstalledUninstallFinalizer.ps1` below a fake process `LOCALAPPDATA` and + covers same-bootstrap sibling, wrong runtime path, stale epoch, wrapper + PID/creation mismatch, wrong stored Ready root, and an extra Ready field. + Every case preserves the selected generation and sibling/root state; cases + beyond the early path/identity gates also prove the disposable wrapper exited. +- InstallLifecycle: `116/116`, exit `0`, preserving normal/recovery registration + retry and durable Ready behavior. +- Explicit PowerShell parser: `8/8`, `PARSER_FAILURES=0`, exit `0`. +- Static gates: `WEAK_SUBSTITUTE_COUNT=0` and + `AUTHORITY_PATH_READ_COUNT=0`, exit `0`; the default finalizer reader receives + and forwards `ExpectedInstallRoot`. +- `git diff --check`: exit `0`; only checkout LF-to-CRLF warnings were emitted. +- Full `tests\PersistenceSelfTest.ps1` aggregate: exit `0`; no failure output. +- Final independent scoped re-review found no remaining Critical or Important + findings. + +### Remediated boundaries + +- One ordered ten-field Ready helper validates fresh contexts, durable stored + transactions/resume, and default finalizer authorization against the exact + current install root before selected-root deletion. +- Product capability open validates the ordered/type-strict 12-field lifecycle + record, complete canonical contiguous selector, all canonical transaction + chains, the unique full persisted terminal Ready record, and the selected + manifest/package identity. Every retained-file open repeats that proof and + compares caller, persisted, and stored capability identities. +- Selector JSON, lifecycle JSON, and manifest hashes are read from one native + no-write/no-delete-share handle. The handle rejects reparse, ADS and + multi-link leaves, checks final path and file identity before and after the + read, and hashes the returned bytes rather than reopening by path. +- Current-product subkeys fail a production pure preflight before shortcut + inspection. Legacy Registry compensation distinguishes exact, absent, and + mismatched key/value/kind snapshots; a partial key is never called restored + or overwritten. +- The installed finalizer accepts only a canonical absolute process + `LOCALAPPDATA` when it is present, otherwise falls back to the current-user + special folder. The staged default reader carries the expected install root + through dot-sourcing without a parameter-name collision. + +No real registry, shortcut, installer, uninstaller, product, network, release, +signing, tag, push, or publication action occurred. + +### Fix implementation commit + +- `3cd891221acfd569fd29e2244b5200fa28e43106` + (`fix: prove durable Ready product authority`). + +## Fix round 5: canonical lifecycle authority, proof-to-use lease, and first-install handoff + +Independent review of `a83840b..a60759b` found three Important production +gaps: product authority maintained a weaker parallel transaction-chain reader; +selector proof and retained-file use were not serialized against an N+1 +commit; and normal first install passed its still-writable generation +transaction to the default product-registration path. + +This was the fifth and final permitted Task 4 repair round. The implementation +below is submitted for a new independent review; Task 4 is not claimed complete. + +### RED evidence + +- InstallFileTransaction exited `1` because a selected terminal Ready chain + plus an unrelated strict Prepared head was accepted instead of throwing + `CCOD_INSTALL_PRODUCT_SCOPE`. The companion Ready-to-Failed continuation + regression was added to the same canonical-head boundary. +- The real two-process coordination regression reached + `Enter-CcodLifecycleOwnership` and `Set-CcodActiveRuntime` for N+1 while an N + product transaction was active. Before the lease fix, N+1 committed and the + subsequent N retained-file use failed with `CCOD_INSTALL_PRODUCT_SCOPE`; + proof and use were not one stable authority operation. +- Clearing only the product transaction's stored authority-lease state while + retaining the real mutex still allowed a retained-file open; the test exited + `1` with `ASSERT_THROWS: expected CCOD_INSTALL_PRODUCT_SCOPE`. +- The safe default first-install integration initially failed adapter + resolution because no lower product-side-effect seam existed. Once only the + registry/shortcut side effects were isolated, the old path failed with + `CCOD_PRODUCT_REGISTRATION_FAILED`; a targeted diagnostic recorded + `CCOD_INSTALL_RETAINED_GENERATION_INVALID` and a sharing violation because + the writable generation transaction was still pinned after Ready. +- The new post-Ready close-failure case first exited `1` with + `CCOD_INSTALL_ADAPTER_INVALID`, target `CloseReadyGeneration`, before the + narrow close handoff existed. + +### GREEN and final verification evidence + +- InstallFileTransaction: all 32 named cases passed, exit `0`. Coverage includes + the unrelated Prepared head, Ready-to-Failed continuation, missing live + authority lease, and a real N/N+1 coordination process pair. +- ProductRegistration: `13/13`, exit `0`. +- UninstallBootstrap: `23/23`, exit `0`. +- InstallLifecycle: `119/119`, exit `0`. The real default registration path + opens both retained shortcut candidates through product-only authority; + normal first install succeeds, product-write failure remains durable Ready + and retries through AlreadyInstalled, and post-Ready writable-close failure + performs zero product writes, appends no Failed snapshot, and retries. +- Explicit PowerShell parser checks passed all four changed source/test files: + `PARSER_COUNT=4`, `PARSER_FAILURES=0`. +- Static gates passed: `WEAK_SUBSTITUTE_COUNT=0`, + `AUTHORITY_PATH_READ_COUNT=0`, `CANONICAL_RESOLVER_CALL_COUNT=1`, + `WRITABLE_REGISTRATION_SOURCE_COUNT=0`, `STRICT_PRODUCT_OPEN_COUNT=1`, and + `READY_CLOSE_ADAPTER_CALL_COUNT=2`. +- `git diff --check` exited `0`; only checkout LF-to-CRLF warnings were emitted. +- Full `tests\PersistenceSelfTest.ps1` aggregate exited `0` with no failure + output. Its long-running child at the observed midpoint was the existing + TrayHost production-trace suite, not the new authority/lifecycle tests. + +### Remediated boundaries + +- InstallLifecycle owns the one canonical pure + `Resolve-CcodInstallTransactionRecordHead` implementation. Its normal reader + and InstallFileTransaction's native-handle authority reader both call that + implementation; product authority no longer carries a reduced parallel + chain checker. The native-handle selector, manifest, and transaction-file + reads remain intact, and the canonical record validator now also requires + `ownedObjectNames` to be an array. +- Product transaction open acquires the same current-user global + `AccountTransition` mutex used by lifecycle ownership before reading the + selector. The opaque transaction retains that lease through canonical + selector/manifest/transaction proof and retained-file opens. Close attempts + both native cleanup and lease release on failure paths; retained-file open + explicitly requires the live product-only lease on its owning thread. +- The deterministic N/N+1 test uses the real lifecycle-ownership and active + pointer commit paths. N+1 blocks while N opens its retained shortcut, commits + generation 2 only after N closes, and the closed stale N capability cannot + be reused. +- Normal and recovered Ready paths close their writable generation/state + transaction before product registration. The default RegisterProduct adapter + always opens and owns a separate strict selected product transaction; the + writable input is never used as shortcut authority. A close or registration + failure is post-Ready, performs no lifecycle rollback, and is reconciled by + a later same-package invocation. + +No real registry, shortcut, installer, uninstaller, product process, network, +release, signing, tag, push, or publication action occurred. + +### Fix implementation commit + +- `a9900a4b00508f9ed694375ea94a2bfb97437e96` + (`fix: serialize strict product authority`). + +## Fix round 6: durable product-close outcome and atomic lock-wait proof + +This architecture-level repair addresses the two remaining product-close and +concurrency-proof gaps. It does not claim Task 4 complete. No real registry, +shortcut, installer, uninstaller, product, network, release, signing, tag, +push, or publication action was performed. + +### RED evidence + +- The default-registration close regression initially failed with + `CCOD_INSTALL_ADAPTER_INVALID`: there was no narrow + `CloseProductTransaction` seam at the owned strict-product-transaction + boundary. The prior default `RegisterProduct` `finally` also discarded an + owned transaction close exception and could return a verified receipt. +- The previous N/N+1 test announced a marker before it had reached the real + `AccountTransition` wait, so it could not prove that the child was actually + blocked on the same mutex. + +### GREEN evidence + +- `tests\persistence\InstallFileTransaction.SelfTest.ps1`: exit `0`; all 32 + named cases passed. The N/N+1 case runs the child with `-Mta`, atomically + signals its real mutex attempt through `SignalAndWait`, proves N can open its + retained shortcut while the child waits, then proves N+1 commits only after + N closes and stale N is unusable. +- `tests\persistence\InstallLifecycle.SelfTest.ps1`: exit `0`; `120/120`. + The new default-path integration forces both the initial owned close and its + bounded same-thread retry to fail, observes no successful receipt and a + durable `Ready` record with no `Failed` snapshot, then proves an exact + same-package reconciliation drains the pending cleanup before opening fresh + strict product authority and returns verified success. +- `tests\persistence\ProductRegistration.SelfTest.ps1`: exit `0`; + `13/13`. +- `tests\persistence\UninstallBootstrap.SelfTest.ps1`: exit `0`; `23/23`. +- Explicit PowerShell parser checks on the three changed PowerShell files + passed with `PARSER_FAILURES=0`. `git diff --check` exited `0`; only + checkout LF-to-CRLF warnings were emitted. +- A fresh full `tests\PersistenceSelfTest.ps1` aggregate remains pending at + this evidence point. No aggregate-pass or Task 4 completion claim is made. + +### Remediated boundaries + +- `CloseProductTransaction` is a narrow lifecycle adapter. The default + delegates to the normal `Ready` file-transaction close. A first close + failure always makes the current registration call fail as + `CCOD_PRODUCT_REGISTRATION_FAILED`, even if its one bounded same-thread + cleanup retry succeeds; no verified receipt is returned from that call. +- If both close attempts fail, the exact opaque transaction and its bound + same-module close delegate remain in a synchronized, process-local pending + table keyed by the canonical install root and owner managed-thread ID. The + next registration on that same thread drains it before opening fresh product + authority. A different thread or another cleanup failure fails closed; no + lease or capability is transferred across threads or processes. +- The normal install, Ready-finalization recovery, and exact + `AlreadyInstalled` reconciliation all pass the same close adapter to the + default registration path. The writable generation/state handoff remains + closed before product registration begins. + +### Fix implementation commit + +- `2e86de17a7c454cc56edd123b6c6487c5194f32d` + (`fix: retain failed product transaction cleanup`). diff --git a/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-5-brief.md b/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-5-brief.md new file mode 100644 index 0000000..9ac9c3b --- /dev/null +++ b/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-5-brief.md @@ -0,0 +1,19 @@ +# Task 5 brief — exact assets and dual Defender evidence + +Implement Task 5 from `docs/superpowers/plans/2026-08-29-v2522-immutable-generation.md` with RED -> GREEN. Read the matching design section and existing release/Defender code before editing. + +## Required contracts + +1. Create `tools/ReleaseAssetContract.psm1` as the single source of the exact ordered 11 public v2.5.22 asset names. `Test-CcodExactReleaseAssetSet` rejects missing, extra, duplicate/case-varied, directory, reparse, unsafe ancestry, or noncanonical entries and cross-validates portable/Setup checksum, provenance, payload manifest, release manifest, version and 40-hex commit bindings. +2. Extend `Invoke-CcodReleaseDefenderCheck` to accept only `TrustedWorkflowArtifact` or `InternetDownload`, exact Setup or ZIP plus matching checksum/manifest, and a create-only regular evidence leaf. `InternetDownload` requires the actual Zone.Identifier ADS with ZoneId 3 and exposes no synthesis switch. `TrustedWorkflowArtifact` requires exact workflow artifact identity. Require Defender service/AV/real-time enabled, current nonempty platform/engine/signature metadata and timestamp, bounded scan timestamps, successful custom scan, and zero new detections. The canonical receipt contains no source path or raw Defender output. +3. `Test-CcodReleasePromotionEvidence` validates the two distinct official-download receipts (Setup and portable ZIP), their exact asset/manifest hashes, version, commit and `InternetDownload` origin. Reject duplicate, swapped, missing, extra, reused or mismatched receipts. Trusted-workflow receipts remain separately origin-bound evidence and never substitute for the two official-download receipts. +4. Keep portable scanning before activation. Add Setup sealed-package scanning before the lifecycle can create `Prepared`, transaction-root or product state. A scan failure must produce no activation mutation. +5. Update build/manifest validation to consume the centralized 11-name contract. Update README/README.zh-CN/CHANGELOG only to enforced candidate behavior: no unqualified stable/verified claim and no per-version What's New clutter on the README home page. Receipts are evidence, never public assets. + +## RED coverage + +Cover exact asset-set mutations; every receipt binding field; Setup/ZIP duplicate/swap/missing; both origin rules; ZoneId absent/other/synthetic attempt; disabled Defender/service/real-time; absent/stale signature; scan error/detection; existing/reparse evidence; write failure; portable and Setup scan failure before mutation; and documentation wording. + +## Verification and boundaries + +Run ReleaseWorkflow, PortableRelease, InstallLifecycle, changed parsers, `git diff --check`, and the full persistence aggregate from frozen source. Tests use adapters only and cannot claim a live Defender pass. Do not execute real scans, builds, network, GitHub, installation, restart, reboot or publication. Commit implementation and evidence separately, then request a scoped review. Tasks 6–8 stay blocked. diff --git a/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-5-fix1-brief.md b/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-5-fix1-brief.md new file mode 100644 index 0000000..263f2f1 --- /dev/null +++ b/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-5-fix1-brief.md @@ -0,0 +1,12 @@ +# Task 5 fix round 1 — non-synthesizable Defender authority + +Read Task 5's report and scoped review. Fix all four findings without changing the 11 public names, receipt schema, candidate docs, or Setup/portable ordering. + +## Required RED -> GREEN + +1. Remove `Adapters` from the public `Invoke-CcodReleaseDefenderCheck` interface and remove the public script-library bypass. Put adapter-taking logic behind a non-exported module core used only through module-scope tests. Production adapters must resolve the real Defender cmdlets and actual Zone stream; no public parameter/capability may replace Zone, Defender status, scan, detections, clock, hashes, or writer. Add a RED that enumerates public parameters/exports and attempts the old forged receipt path. +2. Move or share deep portable/Setup manifest validation with `ReleaseAssetContract.psm1`. `Test-CcodExactReleaseAssetSet` and promotion must reject malformed nested payload/provenance/inventory, wrong file lengths/hashes, ZIP entry/root-launcher mismatch, and Setup nested payload/provenance mismatch even when outer manifests and receipt hashes are self-consistent. Replace bogus baseline fixture hashes with real internally consistent bytes and add one mutation per nested binding. +3. Hold read-only, no-write/no-delete shared handles for candidate/checksum/manifest/Zone from initial proof through scan, receipt durable read-back, and return. Promotion must hold all 11 asset handles and both receipt handles across its complete decision, revalidate identity/hash and exact directory membership before return, and reject scan ABA, post-revalidation replacement, and promotion-time replacement. Use canonical final-path/file-ID/reparse/ADS/multilink checks where the existing native helpers support them. +4. Serialize one canonical receipt byte sequence, write create-only, flush, no-replace publish, reopen the final leaf, verify regular/nonreparse/default-stream identity, strict UTF-8/duplicate-free/exact 25 fields, bytes/hash and every field against the expected receipt, then return. A no-op, corrupting, wrong-target, stale-object or write-then-replace adapter must fail. Failed scan receipts require the same read-back before the stable scan error is returned. + +Run ReleaseWorkflow, PortableRelease, InstallLifecycle, all changed parsers, `git diff --check`, and the complete persistence aggregate from frozen source. Commit implementation and evidence separately and request a scoped fix-round review. Do not run real Defender/build/network/install/reboot/release operations. Tasks 6–8 remain blocked. diff --git a/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-5-fix1-report.md b/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-5-fix1-report.md new file mode 100644 index 0000000..d01c54c --- /dev/null +++ b/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-5-fix1-report.md @@ -0,0 +1,101 @@ +# Task 5 fix round 1 report: non-synthesizable Defender authority + +## Status + +Implemented Task 5 fix round 1 in commit +`9f8a9e7f4aa8ba6028941026cbca776cbd908610` +(`fix: close Defender authority review gaps`). The previous Task 5 +implementation commit remains `fb163b7b6b594e302cf4dffe1dedd055b5f8d9d4`. +The scoped review that failed Task 5 saw `1109854..fb163b7`. This round +covers `fb163b7..9f8a9e7`. + +This remains candidate infrastructure only. It does not claim a live +Defender scan, a production build, a published release, Windows +acceptance, or completion of Tasks 6-8. Scoped re-review is still +required. + +No real Defender command, official build, installation, uninstall, +registry or shortcut mutation, network request, GitHub action, restart, +reboot, signing, tag, push, upload, promotion, or publication was +performed. Defender and activation tests used temporary files and +module-scope adapters only. + +## Finding 1: public Defender surfaces cannot synthesize authority + +Public `Invoke-CcodReleaseDefenderCheck` and `tools/Test-ReleaseDefender.ps1` +now accept only candidate, checksum, manifest, origin, optional workflow +identity, version, commit, and evidence path. They expose neither +`-Adapters` nor `-Library`. Production adapters resolve only real +Defender cmdlets, the actual Zone stream, and the create-only receipt +publisher. Adapter-taking logic lives in non-exported +`Invoke-CcodReleaseDefenderCheckCore`; tests reach it only through +module-scope invocation. + +RED: `fix1-public` enumerates public parameters/exports and attempts the +old forged receipt path. Actual first-failure shape, before this round, +was a public function that accepted `-Adapters` and a script `-Library` +bypass. + +## Finding 2: nested payload and provenance are now exact + +`Test-CcodExactReleaseAssetSet` and promotion consume deep portable and +Setup validation from `tools/ReleaseAssetContract.psm1`. Portable +validation checks payload records, ZIP membership, root launcher hashes, +and embedded payload-manifest identity. Setup validation checks nested +package-manifest, provenance, inventory grammar, and PE binding. The +Task 5 fixture now uses internally consistent nested bytes instead of +placeholder hashes. + +RED: `fix1-deep` mutates nested payload length/hash, ZIP root launcher, +ZIP extra entry, TrayHost nested schema, Setup package payload binding, +Setup provenance nested schema/length, and inventory grammar while +keeping outer hashes self-consistent. Each mutation fails closed with +`CCOD_RELEASE_ASSET_SET_INVALID`. + +## Finding 3: scan and promotion hold identities through the decision + +Candidate, checksum, manifest, Zone, all 11 public assets, and both +receipts are pinned with read-only no-write/no-delete handles. Shared +final-path, file-id, reparse, ADS, and multilink checks revalidate +identity through scan, receipt durable read-back, and promotion return. +Promotion rechecks exact directory membership before return. + +RED: `fix1-handle-scan` same-byte ABA writes are denied; `fix1-handle-post` +post-revalidation replacement is denied; `fix1-handle-promotion` cannot +replace a held asset or receipt before return. + +## Finding 4: receipts require production write/read-back + +Receipt publication serializes one canonical UTF-8 byte sequence, writes +create-only under the held evidence directory, flushes, publishes +no-replace, reopens the final leaf, and compares regular/nonreparse +identity, exact 25 fields, bytes, hash, and object. Failed scan receipts +use the same read-back before the stable scan error is returned. + +RED: `fix1-receipt-NoOp`, `Corrupt`, `WrongTarget`, `StaleObject`, +`ReplacedReadback`, and `FailedNoOp` all fail with +`CCOD_DEFENDER_EVIDENCE_WRITE_FAILED`. + +## Frozen GREEN evidence + +The final source was frozen at pre-commit diff hash +`be3fc4f1c194ccc4d64bae4609e86531a10cb1da2a8f9813ca0962ad58f387a2`. +From that unchanged source: + +- `powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File tests\\persistence\\ReleaseWorkflow.SelfTest.ps1` exited `0`; 63 declared cases passed, including public-surface, deep nested, handle, receipt read-back, dual promotion, origins, status, clocks, Setup order, portable races, build wiring, and bilingual documentation. +- `powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File tests\\persistence\\PortableRelease.SelfTest.ps1` exited `0`; `5/5`. +- `powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File tests\\persistence\\InstallLifecycle.SelfTest.ps1` exited `0`; `151/151`. The existing cross-process observation still prints `exit=23 mutex=True authority=False product=False verified=False error=CCOD_PRODUCT_REGISTRATION_FAILED`. +- PowerShell parser checks covered all 104 repository `.ps1`/`.psm1` files with `PARSER_FAILURES=0`. +- `git diff --check` exited `0`; only checkout LF-to-CRLF notices appeared. +- `powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File tests\\PersistenceSelfTest.ps1` exited `0`; all 39 registered persistence suites completed. + +## Implementation commit and review boundary + +- Implementation: `9f8a9e7f4aa8ba6028941026cbca776cbd908610` + (`fix: close Defender authority review gaps`). +- Previous Task 5 implementation: `fb163b7b6b594e302cf4dffe1dedd055b5f8d9d4`. +- Plan/brief baseline remains `1109854`; fix-round brief is + `task-5-fix1-brief.md`. +- Tasks 6-8 remain blocked. Task 5 needs a scoped re-review of + `fb163b7..9f8a9e7` before it can be marked complete. + diff --git a/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-5-report.md b/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-5-report.md new file mode 100644 index 0000000..eb1ec5d --- /dev/null +++ b/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-5-report.md @@ -0,0 +1,200 @@ +# Task 5 report: exact assets and dual Defender evidence + +## Status + +Implemented the deterministic Task 5 release-security foundation in commit +`fb163b7b6b594e302cf4dffe1dedd055b5f8d9d4`. The build, release-manifest +validator, Defender checker, promotion validator, Setup bootstrap, portable +entrypoint, tests, and candidate documentation now share one exact public +asset contract. + +This is candidate infrastructure only. It does not claim a live Defender +scan, a production build, a published release, stable Windows acceptance, or +completion of Tasks 6-8. Scoped review is still required. + +No real Defender command, official build, installation, uninstall, registry +or shortcut mutation, network request, GitHub action, restart, reboot, signing, +tag, push, upload, promotion, or publication was performed. Defender and +activation tests used temporary files and test-local adapters/functions only. + +## Exact public asset authority + +`tools/ReleaseAssetContract.psm1` is the single production source for the +ordered 11-name set: + +1. `CodexRemote-fix-2.5.22-windows-x64.zip` +2. `CodexRemote-fix-2.5.22-windows-x64.zip.sha256.txt` +3. `CodexRemote-fix-2.5.22-trayhost-provenance.json` +4. `CodexRemote-fix-2.5.22-payload-manifest.json` +5. `CodexRemote-fix-2.5.22-release-manifest.json` +6. `CodexRemote-fix-2.5.22-setup.exe` +7. `CodexRemote-fix-2.5.22-setup.exe.sha256.txt` +8. `CodexRemote-fix-2.5.22-setup-provenance.json` +9. `CodexRemote-fix-2.5.22-setup-payload-manifest.json` +10. `CodexRemote-fix-2.5.22-setup-destination-inventory.iss` +11. `CodexRemote-fix-2.5.22-setup-release-manifest.json` + +`Test-CcodExactReleaseAssetSet` requires a canonical plain directory with +exactly those 11 regular non-reparse leaves. It rejects missing, extra, +case-varied, duplicate, directory, reparse, unsafe-ancestry, noncanonical, +manifest-order, hash, commit, timestamp, and shared-provenance mutations. + +Portable manifest public records are indices `0,1,2,3`, with index `4` as its +manifest leaf. Setup manifest public records are indices `5,6,2,7,8,9`, with +index `10` as its manifest leaf. Both distributions bind the same lower-case +40-hex commit, canonical build timestamp, and TrayHost provenance bytes. +Checksums bind the exact candidate leaf and bytes. A bounded recursive lexical +JSON pass rejects duplicate property names at every object depth, including an +escaped spelling such as `\u0067itCommit`, before deserialization. + +`build/build.ps1` imports this module only after the clean-checkout gate, +derives all 11 paths by their fixed indices, writes the two manifest views, +then validates the complete exact set. `tools/Test-ReleaseDefender.ps1` also +uses the central names for its portable and Setup manifest validators. + +## Defender receipt contract + +`Invoke-CcodReleaseDefenderCheck` now requires the exact candidate, matching +checksum, explicit matching manifest, expected version/commit, evidence path, +and one of two origins. Its ordered schema-two receipt has exactly these 25 +fields: + +`schemaVersion, assetType, assetName, assetSha256, checksumName, +checksumSha256, manifestName, manifestSha256, version, gitCommit, origin, +workflowArtifactIdentity, zoneId, defenderServiceEnabled, antivirusEnabled, +realTimeProtectionEnabled, defenderPlatformVersion, defenderEngineVersion, +signatureVersion, signatureUpdatedAtUtc, scanStartedAtUtc, +scanCompletedAtUtc, detectionCount, outcome, errorCode`. + +- `InternetDownload` requires the actual `Zone.Identifier` stream with exactly + one `ZoneId` property whose value is `3`. The public tool has no Zone + synthesis parameter, and workflow identity must be null. +- `TrustedWorkflowArtifact` has a null Zone and requires the exact ordered + GitHub Actions identity fields `provider, repository, runId, runAttempt, + artifactId, artifactName, artifactDigest, gitCommit`. Provider, repository, + artifact name, positive integral identifiers, `sha256:<64hex>` digest, and + expected commit are all exact. +- Defender service, antivirus, and real-time protection must be enabled. + Platform, engine, signature version, and signature timestamp must be + present. The signature can be at most 72 hours old at scan start and at most + five minutes in the future. Completion cannot precede start or exceed two + hours; no invalid-clock fallback is used. +- The checker records the pre/post detection difference, requires zero new + detections for success, and preserves stable scan/detection/write errors. + It revalidates candidate, checksum, manifest, expected commit, hashes, and + Internet Zone after the scan and before writing a receipt. +- Default evidence writing requires an existing canonical plain parent and a + new `.json` leaf. It uses a create-new temporary file, durable flush, a + second target preflight, no-replace move, and exact cleanup. Existing files, + directories, reparse leaves, reparse ancestry, noncanonical paths, and ADS + targets are rejected before scanning. + +The receipt contains no source path or raw Defender output. + +## Promotion authority + +`Test-CcodReleasePromotionEvidence` requires both `-EvidenceDirectory` and the +mandatory authoritative `-AssetDirectory`, plus version and expected commit. +It first validates the exact 11 assets and their common commit. It then accepts +exactly these two receipt leaves: + +- `CodexRemote-fix-2.5.22-setup.internet-download.defender.json` +- `CodexRemote-fix-2.5.22-windows-x64.internet-download.defender.json` + +Both must be schema-two `InternetDownload`/Zone-3/Completed/zero-detection +receipts. Setup receipt hashes are compared with actual contract indices +`5,6,10`; portable receipt hashes are compared with indices `0,1,4`. +Missing, extra, duplicate, swapped, reused, forged-but-well-formed, field- +mismatched, or trusted-workflow substitutions fail closed. Trusted workflow +receipts remain separate CI evidence and cannot satisfy official-download +promotion. + +## Pre-mutation scan ordering + +- Setup holds the already verified package seal, scans the canonical package + path returned by that seal, and only then creates the extraction temp root, + expands payload bytes, opens activation state, writes any activation log or + receipt, or launches the lifecycle worker. The production-shaped failure + injection proves no package temp root or product-state entry appears. +- Portable retains `manifest validate -> Defender scan -> manifest revalidate + -> copy -> lifecycle`. Its local gate now records the same service/AV/ + real-time/platform/engine/signature/timestamp facts. A real entrypoint copy + with a throwing test-local scan proves no child or lifecycle state is + created; existing post-scan and final-copy race tests remain green. + +## Candidate documentation + +README and README.zh-CN remain candidate-scoped. The old `always published`, +`始终发布`, and `已验证……均可用` claims were removed. The exact 11-name list is +present once in each release section inside a collapsed `
` block, and +Defender receipts are explicitly evidence rather than public assets. No +version-specific What's New section was added. CHANGELOG adds one concise +English and Chinese bullet describing the enforced dual-download evidence. + +## RED evidence + +The following failures were observed before the corresponding production +implementation: + +- Asset contract: exit `1`, `ASSERT_TRUE: central release asset contract + module exists`. +- Defender v2 API: exit `1`, + `NamedParameterNotFound,Invoke-CcodReleaseDefenderCheck` for + `-CandidatePath`. +- Promotion: exit `1`, missing central asset module. +- Evidence writer: exit `1`, missing Defender `-CandidatePath` API. +- Setup ordering: exit `1`, exact scan call count expected `1`, actual `0`. +- Documentation: exit `1`, `English README makes no unconditional publication + or verification claim`. +- Authoritative promotion follow-up: exit `1`, + `NamedParameterNotFound,Test-CcodReleasePromotionEvidence` for mandatory + `-AssetDirectory`; the old shape could not compare receipts with real + assets. +- Lexical JSON follow-up: exit `1`, `ASSERT_THROWS: expected + CCOD_RELEASE_ASSET_SET_INVALID`; a duplicate top-level property was folded. +- Zone follow-up: exit `1`, `ASSERT_THROWS: expected + CCOD_DEFENDER_ZONE_REQUIRED`; the first matching ZoneId line was accepted. +- Post-scan identity follow-up: exit `1`, `ASSERT_THROWS: expected + CCOD_RELEASE_ASSET_HASH_MISMATCH`; candidate mutation during the fake scan + reached receipt creation. + +During GREEN iteration, the first asset-path implementation looped at the +drive root after trimming `C:\` to `C:`; the session was interrupted and root +termination was corrected. The first default evidence writer closure could +not resolve its validator and was corrected without weakening preflight. +Two test-oracle defects were also corrected: base names were no longer counted +inside checksum names, and restoration of a mutated EXE explicitly restored +its test Zone ADS before later cases. + +## Frozen GREEN evidence + +The final source was frozen at pre-commit diff hash +`55eaabfc6770bee68afda76c6996ff41d569e2f0`. From that unchanged source: + +- `powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File + tests\persistence\ReleaseWorkflow.SelfTest.ps1` exited `0`; all 58 declared + cases passed, including exact assets, dual promotion, origins, full status, + unique Zone, clocks, post-scan identity, create-only evidence, Setup order, + portable failure/races, build wiring, and bilingual documentation. +- `powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File + tests\persistence\PortableRelease.SelfTest.ps1` exited `0`; `5/5`. +- `powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File + tests\persistence\InstallLifecycle.SelfTest.ps1` exited `0`; `151/151`. +- `powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File + tests\PersistenceSelfTest.ps1` exited `0`; the sorted aggregate completed + all 39 registered persistence suites. +- PowerShell parser checks covered all 103 repository `.ps1`/`.psm1` files + with `PARSER_FAILURES=0`. +- `git diff --check` exited `0`; only checkout LF-to-CRLF notices appeared. +- Before commit there were zero active worktree self-test children and zero + matching Task 5 temporary directories. Two exact directories left by the + intentionally interrupted early RED run were inspected as plain temp + directories and removed before this check. + +## Implementation commit and review boundary + +- Implementation: `fb163b7b6b594e302cf4dffe1dedd055b5f8d9d4` + (`feat: require dual Defender release evidence`). +- Plan/brief baseline: `1109854`. +- Tasks 6-8 remain blocked. Task 5 needs a scoped review of + `1109854..fb163b7` before it can be marked complete. diff --git a/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-6-fix-round-verification.md b/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-6-fix-round-verification.md new file mode 100644 index 0000000..091b69c --- /dev/null +++ b/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-6-fix-round-verification.md @@ -0,0 +1,66 @@ +# Task 6 Fix Round Verification Report + +Date: 2026-09-04 +Worktree: `C:\Users\33384\Documents\Codex-Control-other-devices-Windows\.worktrees\codex-v2522-install-runtime-reliability` +Branch: `codex/v2522-install-runtime-reliability` +Implementation commit: `6d9bafa96c252ece692e8aff35e420285479b7b6` +Corrective module-boundary commit: `325985d9a56bc222be1eb6a73dfb60b6326706da` + +## Scope + +Task 6 adds a fail-closed clean release runner and a draft-only GitHub release flow. The implementation does not perform a live release. It transfers one immutable commit and clean-runner preflight record, validates the exact eleven-asset contract, creates only a private draft, persists strict verification evidence, and requires separated Defender, verification, and acceptance evidence before promotion. + +## TDD evidence + +### RED + +Command: + +```powershell +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -Command '$env:CCOD_TASK6_RED_CASE="fix1-evidence-types"; & (Join-Path (Get-Location) "tests/persistence/ReleaseWorkflow.SelfTest.ps1")' +``` + +Observed result: exit code `1`, with `CCOD_SELFTEST_FAILED` and `ASSERT_THROWS: expected CCOD_GITHUB_DRAFT_ACCEPTANCE_INVALID`. The failing case proved that a single-element JSON array in a scalar acceptance field was accepted before the fix. + +### GREEN + +Command: + +```powershell +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -Command '$env:CCOD_TASK6_RED_CASE="fix1-evidence-types"; & (Join-Path (Get-Location) "tests/persistence/ReleaseWorkflow.SelfTest.ps1")' +``` + +Observed result: exit code `0`; the focused case reported `True` and `Release workflow self-tests passed.` + +## Verification results + +All commands were run from the exact worktree above. + +- `powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File tests/persistence/ReleaseWorkflow.SelfTest.ps1` — exit `0`, including the public-module-boundary and strict evidence-type cases after the corrective module-boundary commit. +- `powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File tests/persistence/Bootstrap.SelfTest.ps1` — exit `0`, `27/27`. +- `powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File tests/persistence/InstallLifecycle.SelfTest.ps1` — exit `0`, `151` passed. +- `npm test` — exit `0`; `Validation passed: PowerShell, JavaScript, clean-room runtime, package checker, persistence tests, repository files, and package preflight.` +- PowerShell AST parsing of all changed `.ps1` files — passed. +- YAML parsing of `ci.yml` and `release.yml` — passed. +- `git diff --cached --check` and `git diff --check` — passed. +- Added-line security scan — no hardcoded secret pattern, dangerous eval/shell pattern, floating action reference, or production release bypass. +- No test, build, GitHub, Defender, installation, reboot, tag, push, or publication action was performed. + +## Independent review + +A constrained independent reviewer inspected the exact staged Task 6 implementation diff in read-only mode and returned: + +```json +{"verdict":"PASS","findings":[]} +``` + +The reviewer reported no file modifications and no external operations. Earlier review attempts that ended with reviewer API HTTP 404 were not counted as acceptance. + +## Commit separation + +- Implementation commits: `6d9bafa96c252ece692e8aff35e420285479b7b6` and corrective module-boundary commit `325985d9a56bc222be1eb6a73dfb60b6326706da`. +- This report and the progress ledger are evidence only and are committed separately after the implementation commits. + +## Remaining gates + +Task 7/8 official draft acceptance and the real downloaded-asset Defender receipt gate remain blocked. No public release is claimed by this report. diff --git a/.superpowers/sdd/2026-09-02-v2522-durable-product-cleanup/final-fix-report.md b/.superpowers/sdd/2026-09-02-v2522-durable-product-cleanup/final-fix-report.md new file mode 100644 index 0000000..d685a7a --- /dev/null +++ b/.superpowers/sdd/2026-09-02-v2522-durable-product-cleanup/final-fix-report.md @@ -0,0 +1,186 @@ +# Final fix report: durable product cleanup recovery gaps + +## Scope and review status + +- Final-review base: `b1aec464b255661596d19c6a5d2bdb6ead4a7003`. +- Implementation: `9e00ccf3ce4fb1f5e2f1e80c415c8fd787b71509` + (`fix: close durable cleanup recovery gaps`). +- The implementation commit contains only: + - `src/persistence/modules/InstallLifecycle.psm1` + - `tests/persistence/InstallFileTransaction.SelfTest.ps1` + - `tests/persistence/InstallLifecycle.SelfTest.ps1` +- This is the plan's single final-fix wave. It fixes the three final-review + Important findings together. It does not claim release readiness or + installed-product acceptance; one scoped independent re-review remains. + +## Root causes and fixes + +### Old Pending before selector advance + +The previous flow first committed the new selector and terminal Ready record, +then called registration for the new Ready transaction. That registration saw +the old Pending record only as foreign history, while strict old authority had +already become stale and could no longer be opened for recovery. + +An upgrade now acquires the real outer `AccountTransition` product-cleanup +lease before creating the new generation or any new nonterminal transaction +record. It resolves cleanup against the exact currently selected old ordered +12-field Ready transaction, then retains that lease across the selector commit. +The later same-thread `InstallLease` and lifecycle ownership acquisitions are +recursive acquisitions of the same mutex. Normal release is LIFO: lifecycle +ownership, install lease, then the pre-upgrade product-cleanup lease. Every +exception path attempts the same order, and a resolve failure exits before a +new selector or Ready record is written. + +The existing resolver behavior remains exact: a live foreign owner blocks, a +same-thread retained capability may retry, and a proven-dead owner must open, +bind, and close fresh strict old authority before appending Completed. + +### Fence binding failure cleanup + +After strict `OpenProduct` succeeded, a binding failure previously reached a +Ready close. Its missing-fence assertion ran before native close and authority +lease release, permanently retaining the inner recursive mutex. + +The process-local cleanup entry now records whether the exact durable fence was +successfully bound. An unbound strict transaction is closed directly with +`Disposition Failed`; that path always attempts native runtime cleanup and the +inner authority lease release and never appends Completed. On successful abort +the stale transaction capability is discarded while durable Pending remains. +If the abort itself fails, the exact capability remains retryable; a later +same-thread call first retries the Failed abort, then opens and binds fresh +strict authority. Only a successful bound Ready close permits Completed. + +### Exact crash temporary recovery + +The create-only writer can leave a read-only `.ccod.<32-lowercase-hex>.tmp` +after abrupt process termination. The fence reader previously classified that +internal crash artifact as an unknown object forever. + +The final design deliberately does not alter the independently reviewed IFT +seal/rename ABI. Delete-on-close was rejected for this wave because the current +write-close, strict-reopen, read-only, and no-replace rename sequence did not +provide a sufficiently narrow proof that delete-pending could be cleared on +every rename/collision/exception path without reopening a crash window. + +Instead, the fence reader performs the brief's narrow recovery while the real +outer product-cleanup lease is live. A private inert-marker CLR bridge: + +- accepts only the exact lowercase internal temporary name; +- pins install root, `state`, and `product-cleanup-fences` through relative + non-reparse native directory handles; +- opens one leaf with `DELETE`, read/write-attribute access, and zero sharing; +- proves non-directory, non-reparse, default-stream-only, single-link identity, + exact final path, and stable volume/file index; +- clears only that pinned orphan's read-only attribute, arms native delete + disposition, revalidates the same identity, closes the handle, and requires + the pinned parent enumeration to show the leaf absent; +- re-enumerates the whole fence plane before any history is accepted. + +Unknown names, reparse objects, ADS, hardlinks, and any open/nonexclusive exact +temporary still fail closed and are not removed or ignored. There is no broad, +recursive, wildcard, or path-only deletion. + +## RED evidence + +### Upgrade with old Pending + +- Runner: Windows PowerShell 5.1 targeted execution of the checked-in lifecycle + case `upgrade resolves a dead-owner old Pending while the old Ready is still + selected`. +- Exit: `1`. +- Harness result: + `CCOD_SELFTEST_FAILED case=upgrade-resolves-a-dead-owner-old-Pending-while-the-old-Ready-is-still-selected error=ASSERT_EQUAL`. +- Exact failure: + `dead-owner old Pending is reconciled before selector advance expected=[] actual=[A different product cleanup transaction remains Pending]`. +- Meaning: the old code made the new Ready visible before trying to reconcile + old cleanup and could then only reject it as foreign Pending. + +### Bind failure leaks inner authority + +- Runner: Windows PowerShell 5.1 targeted execution of the checked-in lifecycle + case `fence bind failure aborts the real strict transaction before exact retry + recovery`. +- Exit: `1`. +- Harness result: + `CCOD_SELFTEST_FAILED case=fence-bind-failure-aborts-the-real-strict-transaction-before-exact-retry-recovery error=ASSERT_TRUE`. +- Exact failure: + `Failed abort releases the inner real AccountTransition mutex for another process`. +- The fixture used real strict `OpenProduct` and the real inner + `AccountTransition` mutex. Only the private fence-proof call was injected to + fail. The second Windows PowerShell process exhausted its real 15-second mutex + wait. Pending/no-receipt/no-product-write assertions had already passed. + +### Abrupt writer crash artifact + +- Command: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File + tests\persistence\InstallFileTransaction.SelfTest.ps1`. +- Exit: `1`. +- Harness result: + `CCOD_SELFTEST_FAILED case=abrupt-writer-exit-cannot-strand-an-exact-internal-create-only-temporary-leaf error=ASSERT_EQUAL`. +- Exact failure: + `process termination deletes every exact internal temporary through its native handle disposition expected=[0] actual=[1]`. +- The child used a real state transaction and real create-only collision, wrote + its collision marker, remained alive, and was then terminated. Exactly one + `.ccod.<32-lowercase-hex>.tmp` remained. +- After selecting narrow recovery, the final checked-in form also failed before + production support with stable `CCOD_PRODUCT_REGISTRATION_FAILED` and + `Product cleanup fence plane contains an unknown object`. + +An attempted ADS fixture using a .NET path API was rejected by that API and was +not counted as RED evidence; it was replaced with the repository's existing +Windows ADS fixture pattern. A first bind retry fixture omitted staged shortcut +candidates and was likewise rejected as behavior evidence before the final +green run. + +## Focused GREEN evidence + +- Old-Pending upgrade targeted runner: exit `0`; the old transaction retained + `1:Pending|1:Completed`, then the distinct new Ready transaction recorded its + own `1:Pending|1:Completed`. +- Bind-failure targeted runner: exit `0`; the other process acquired the real + mutex and exited `23` on live Pending before strict authority. The later exact + retry succeeded with three bind attempts and three lower closes: one unbound + Failed abort, one fresh bound recovery Ready close, and one new verification + Ready close. +- Hostile-temporary targeted runner: exit `0`; unexpected name, reparse, ADS, + hardlink, and live/open exact-name cases all remained present and failed + closed before product or shortcut writes. +- Crash-artifact case is included in the final IFT suite and passed after the + real child crash left one exact orphan, narrow recovery removed it, canonical + Pending remained readable, and the original final record hash stayed exact. + +## Fresh final matrix after source freeze + +| Command | Exit | Result | +|---|---:|---| +| `tests\persistence\InstallFileTransaction.SelfTest.ps1` | 0 | 35 named cases; `Install file transaction self-test passed.` | +| `tests\persistence\KernelObjects.SelfTest.ps1` | 0 | 16 named cases passed. | +| `tests\persistence\ProductRegistration.SelfTest.ps1` | 0 | `Product registration self-tests passed: 13`. | +| `tests\persistence\UninstallBootstrap.SelfTest.ps1` | 0 | `Uninstall bootstrap self-tests passed: 23`. | +| `tests\persistence\InstallLifecycle.SelfTest.ps1` | 0 | `Install lifecycle self-tests passed: 128`. | +| PowerShell parser over all three changed PowerShell files | 0 | `PARSER_TOTAL=3 FAIL=0`. | +| `git diff --check` | 0 | No whitespace errors; only checkout LF-to-CRLF warnings. | +| `tests\PersistenceSelfTest.ps1` | 0 | Fresh tracked aggregate; no failure output. | + +The final lifecycle run retained the required fresh-process observation: +`CCOD_CROSS_PROCESS_OBSERVED exit=23 mutex=True authority=False product=False verified=False error=CCOD_PRODUCT_REGISTRATION_FAILED`. + +The full focused and aggregate suites also retain the prior exact ten-field +fence and ordered twelve-field Ready proof, create-only history, real MTA +`SignalAndWait`, stale-N and closed-N rejection, live-owner blocking, dead-owner +strict recovery, and the absence of post-Ready lifecycle Failed or false +verified receipts. + +## No-real-action statement and remaining boundary + +No real registry, Start-menu/Desktop shortcut, installer, uninstaller, +installed product, product process, network, release, push, tag, signing, +publication, installation, restart, or reboot action was performed. Tests used +only temporary fixtures, marker-file product adapters, test child processes, +and private named mutex/event objects. + +Please perform one fresh scoped independent review of the implementation and +evidence commits, especially the pre-upgrade recursive mutex lifetime, unbound +Failed-abort retry state, and native orphan identity/delete sequence. A green +matrix and aggregate are not release-readiness or installed-product evidence. diff --git a/.superpowers/sdd/2026-09-02-v2522-durable-product-cleanup/task-1-report.md b/.superpowers/sdd/2026-09-02-v2522-durable-product-cleanup/task-1-report.md new file mode 100644 index 0000000..89eda37 --- /dev/null +++ b/.superpowers/sdd/2026-09-02-v2522-durable-product-cleanup/task-1-report.md @@ -0,0 +1,262 @@ +# Task 1 report: durable cross-process product-cleanup fence + +## Review status + +Task 1 implementation and focused evidence are recorded for independent task +review. This report does not claim Task 1 or Task 4 complete and makes no +release-readiness claim. + +## Scope and commit + +- Base: `25eedda46a7963e8951a44d472b902015d38697d` +- Implementation: `271a6e57ccfcc0568151f620c5cef0e3d43e1416` + (`fix: fence durable product cleanup`) +- Production/tests commit contains only: + - `src/persistence/modules/InstallLifecycle.psm1` + - `src/persistence/modules/InstallFileTransaction.psm1` + - `tests/persistence/InstallLifecycle.SelfTest.ps1` + - `tests/persistence/InstallFileTransaction.SelfTest.ps1` + +## RED evidence + +### Private lower native-close seam + +- Command: + `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1` +- Exit: `1` +- Exact failure: + `private-lower-close-seam-preserves-the-real-transaction-cleanup-retry-path` + reached `ASSERT_THROWS: expected CCOD_INSTALL_CLOSE_FAILED`. +- Meaning: the old close path did not traverse the injected lower native-close + boundary. The later seam is module-private and leaves the real authority + lease-release code in effect. + +### Same-process durable Pending + +- Command: + `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1` +- Exit: `1` +- Exact failure: + `first lower close failure leaves exactly one durable Pending record + expected=[1] actual=[0]`. +- This was the valid rerun after narrowing the seam to `ProductOnly`; an + earlier cleanup/setup failure was explicitly rejected as RED evidence. + +### Fresh-process release-before-fence window + +- Command: + `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1` +- Exit: `1` +- Exact observation before the fix: + `CCOD_CROSS_PROCESS_OBSERVED exit=0 mutex=True authority=True product=True verified=True error=` +- Exact assertion: + `fresh process fails closed on the durable pending fence expected=[23] + actual=[0]`. +- The child was a fresh Windows PowerShell process. It received paths and the + package hash only, acquired the real `AccountTransition` mutex, and read the + install state itself; it received no parent in-memory cleanup signal. + +### IFT release guard + +- Command: + `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1` +- Exit: `1` +- Exact failure: + `Ready product close rejects release before a durable cleanup fence is bound` + reached `ASSERT_THROWS: expected CCOD_INSTALL_CLOSE_FAILED`. + +### Proven-dead owner recovery + +- Targeted Windows PowerShell helper execution invoked the checked-in + `Invoke-CcodLifecycleDeadOwnerCleanupTest` fixture after loading the helper + prefix of `InstallLifecycle.SelfTest.ps1` in a fresh process. +- Exit: `1` +- Exact failure: + `dead owner requires a fresh strict cleanup close before the new verification + close expected=[2] actual=[1]`. +- The fixture persisted a Pending record with PID `2147483647`, a canonical old + creation time, and the current SID under a real outer account lease. The old + implementation appended Completed without a fresh strict open/close. + +## GREEN evidence + +### Lifecycle focused suite + +- Command: + `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1` +- Exit: `0` +- Result: `Install lifecycle self-tests passed: 122` +- Exact fresh-process observation: + `CCOD_CROSS_PROCESS_OBSERVED exit=23 mutex=True authority=False product=False verified=False error=CCOD_PRODUCT_REGISTRATION_FAILED` +- Covered behavior includes one-attempt lower close failure, durable Pending, + two same-thread retained-capability failures with no receipt, exact retry and + Completed append, live-owner cross-process blocking, and proven-dead owner + fresh strict cleanup before a new verification transaction. + +### Install file transaction focused suite + +- Command: + `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1` +- Exit: `0` +- Result: `34/34` declared named cases passed and the suite printed + `Install file transaction self-test passed.` +- New cases prove an unbound Ready product close retains its same-thread lease + and that the private lower-close seam preserves retry behavior. + +### Parser and diff + +- Parser command: parse the two changed modules and two changed self-tests with + `[System.Management.Automation.Language.Parser]::ParseFile`. +- Parser exit: `0`, output `PowerShell parser passed: 4/4`. +- Command: `git diff --check` +- Exit: `0`; output contained only Git LF-to-CRLF checkout warnings. + +## Durable ordering and recovery behavior + +- The state plane is + `state\product-cleanup-fences\.Pending|Completed..json`. + Records are written through an IFT state-only transaction and + `Write-CcodInstallRecord` create-only publication. +- Each payload has exactly the ordered ten fields required by the brief. The + containing path binds the canonical install root; every read also reproves + the complete ordered 12-field Ready transaction and selected generation. +- A real outer `AccountTransition` lease covers resolve, Pending publication, + strict product open/use, lower close, and Completed publication. IFT refuses + a Ready product close unless the exact Pending record is same-handle read and + bound first. +- A live matching owner blocks before product authority opens. The same owner + on the same managed thread retries only the retained original capability. +- A proven-dead owner is reconciled without old-process memory: under the fresh + outer lease, code opens a new strict product transaction, binds the existing + Pending record, performs real close, and appends Completed only after success. + Failure leaves Pending and fails closed. + +## No-real-action statement + +No real registry, Start-menu/Desktop shortcut, installer, uninstaller, +installed product, product process, network, release, push, tag, signing, +installation, restart, or reboot action was performed. Product effects in all +tests were in-memory or temporary marker-file adapters. Named mutexes and +temporary fixture state were the only real OS primitives exercised. + +## Compatibility and remaining review boundary + +- Existing `CCOD_INSTALL_CLOSE_FAILED` same-thread retry semantics remain. +- Test-only authority used solely for mutex exclusion now closes with + disposition `Failed`; real product registration uses Ready only after a + durable Pending bind. +- Full aggregate/release validation and Task 4 completion are outside this + Task 1 report and remain for the plan's later independent verification. + +## Fix round 1: fail-closed strict open and transaction-partitioned history + +### Scope and implementation commit + +- Review base: `ffb5dd0` (`docs: record durable product cleanup evidence`). +- Fix implementation: `79b4825244fe53b9af8d10bf2b7e4efae347c909` + (`fix: partition durable cleanup history`). +- The implementation commit contains only: + - `src/persistence/modules/InstallLifecycle.psm1` + - `tests/persistence/InstallLifecycle.SelfTest.ps1` +- The fix keeps every record under the exact ten-field validator and canonical + filename validator, validates Pending/Completed sequencing independently per + `transactionId`, binds current records to the exact current Ready identity, + ignores only fully completed foreign history, and blocks every unresolved + foreign Pending record. +- A new Pending entry starts with `CloseCompleted = false`. A strict open + failure therefore retains Pending and cannot append Completed. A later + same-owner/same-thread call first opens, binds, and closes fresh strict + authority for that Pending fence before starting a new verification attempt. + +### RED evidence + +#### Strict product open failure + +- Command: + `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1` +- Exit: `1`. +- Exact first failure: + `CCOD_SELFTEST_FAILED case=strict-product-open-failure-retains-Pending-and-later-reconciles-before-verified-success error=ASSERT_EQUAL` +- Exact assertion: + `ASSERT_EQUAL: strict product open failure leaves only its durable Pending record expected=[1] actual=[2]`. +- The test first created a production-shaped temporary Ready generation with + both sealed registration shortcut candidates, then used the existing + module-private native dispatcher boundary to fail only `OpenProduct` after + Pending publication. It observed no returned receipt or real product action. + +#### Completed older transaction poisoning a new Ready upgrade + +- Targeted Windows PowerShell execution loaded lines 1-681 (imports and helper + definitions) from the checked-in lifecycle self-test, parsed the same file + with the PowerShell AST, selected the exact test named + `completed cleanup history from an older Ready transaction permits new upgrade registration`, + and invoked its checked-in scriptblock. The runner was passed through + `powershell.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand` using + UTF-16LE solely to preserve shell quoting. +- Exit: `1`. +- Exact valid failure: + `ASSERT_EQUAL: older completed cleanup history is ignored for the new Ready attempt sequence expected=[] actual=[CCOD_PRODUCT_REGISTRATION_FAILED]`. +- The valid fixture kept project version `2.5.22` and the same computed package + hash, changed a sealed payload byte to create a distinct runtime and Ready + transaction, and started with the prior Ready transaction's complete + Pending/Completed history. Thus failure occurred at fence-history validation, + not package identity or adapter setup. +- An earlier same-version/different-explicit-hash attempt stopped at + `CCOD_INSTALL_PACKAGE_CONFLICT`, and an interim `2.5.23` attempt skipped the + version-gated product-registration path. Neither was counted as RED evidence. + +#### Prior Pending preservation + +- The same targeted runner selected + `unresolved Pending from an older transaction blocks new Ready registration fail closed`. +- Before the production fix it exited `0` and printed + `TARGETED_PRIOR_PENDING_PASS`: the pre-existing behavior blocked, performed + zero product and shortcut writes, emitted no receipt, retained the foreign + Pending record, and appended no lifecycle Failed snapshot. This was retained + as a must-stay-green regression while completed history was partitioned. + +### GREEN evidence + +- Targeted strict-open runner: exit `0`, + `TARGETED_STRICT_OPEN_GREEN`. +- Targeted completed-history upgrade runner: exit `0`, + `TARGETED_UPGRADE_HISTORY_GREEN`. +- Targeted prior-Pending runner: exit `0`, + `TARGETED_PRIOR_PENDING_GREEN`. +- The direct unknown-field negative is included in the full lifecycle suite. It + temporarily clears and restores the read-only bit only on its temporary test + fence, adds `unknownFenceField`, and proves default registration fails closed + before product writes. A first attempt to overwrite the still-read-only file + was rejected as fixture setup failure and was not counted as behavior evidence. + +#### Full focused suites + +- Command: + `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1` +- Exit: `0`. +- Result: `Install lifecycle self-tests passed: 126`. +- Exact cross-process observation remained: + `CCOD_CROSS_PROCESS_OBSERVED exit=23 mutex=True authority=False product=False verified=False error=CCOD_PRODUCT_REGISTRATION_FAILED`. +- Command: + `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1` +- Exit: `0`. +- Result: all `34/34` named cases printed `True`, followed by + `Install file transaction self-test passed.` + +#### Parser and diff + +- Parsed the two production modules and two focused self-tests with + `[System.Management.Automation.Language.Parser]::ParseFile`. +- Exit: `0`; output: `PowerShell parser passed: 4/4`. +- Command: `git diff --check`. +- Exit: `0`; output: `git diff --check passed`, plus only Git checkout + LF-to-CRLF warnings. + +### No-real-action and remaining boundary + +No real registry, Start-menu/Desktop shortcut, installer, uninstaller, +installed product, product process, network, release, push, tag, signing, +installation, restart, or reboot action was performed. All product effects were +temporary in-memory or marker-file adapters. This fix round is ready for scoped +re-review; it does not claim Task 1, Task 4, aggregate validation, or release +readiness complete. diff --git a/.superpowers/sdd/2026-09-02-v2522-durable-product-cleanup/task-2-report.md b/.superpowers/sdd/2026-09-02-v2522-durable-product-cleanup/task-2-report.md new file mode 100644 index 0000000..2c8f4c8 --- /dev/null +++ b/.superpowers/sdd/2026-09-02-v2522-durable-product-cleanup/task-2-report.md @@ -0,0 +1,118 @@ +# Task 2 report: atomic real-mutex proof and final verification + +## Scope and review status + +Task 2 consumes Task 1's durable Pending/Completed cleanup fence without +changing Task 1 production behavior. The only final source change is the +atomic-proof test in `tests/persistence/InstallFileTransaction.SelfTest.ps1`. +`KernelObjects.psm1` is unchanged. This report records evidence for fresh +independent review; it makes no release-readiness claim. + +## Atomic protocol + +The parent opens N under the Task 1 real outer `AccountTransition` lease, +creates the exact durable Pending fence, and binds it to the strict product +transaction. The MTA child receives only paths and event name. Its +`EnterMutex` adapter calls the real `Enter-CcodMutex -Kind +AccountTransition`; its only wait override is: + +```powershell +[Threading.WaitHandle]::SignalAndWait($enteredActualWaitEvent,$mutexHandle,15000,$false) +``` + +After the parent observes that signal it proves that N+1 has neither written +its result nor exited, opens N's retained shortcut, closes N's strict product +authority, and completes N's durable fence. It then proves N+1 remains +blocked while N's outer lease is still live, releases that outer lease, and +requires N+1 to commit generation 2. The test also asserts that the closed N +capability cannot be reused. + +## Fix round 1: fresh stale-N Ready authority + +After N+1 commits, the test now separately invokes a **fresh** +`Open-CcodInstallProductRegistrationTransaction` with N's exact old Ready +record and requires `CCOD_INSTALL_PRODUCT_SCOPE`. If a transaction were +returned, the test closes that temporary transaction with `Failed` before the +assertion reports failure, so the negative probe cannot strand an authority +lease. This validates latest-pointer/canonical-head authority selection; the +existing `CCOD_INSTALL_TRANSACTION_CLOSED` assertion remains separately tied +to reusing the already-closed N capability. + +### Fix-round temporary-mutation RED + +The temporary IFT-only mutation omitted the selected-pointer stale rejection +and, only when the latest pointer generation was newer than the supplied Ready +generation, skipped the matching canonical-head rejection. Same-generation +Prepared/Failed rejection remained active. It was restored fully before +GREEN. + +- Command: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File + tests\persistence\InstallFileTransaction.SelfTest.ps1` +- Exit: `1` +- Exact failure: `ASSERT_THROWS: expected CCOD_INSTALL_PRODUCT_SCOPE` in + `product-authority-holds-the-real-commit-coordination-lease-across-proof-and-retained-file-use`. + +Thus the new assertion demonstrably detects a stale-N selector/head removal; +the first failure was its own fresh-open boundary, not a pre-existing +same-generation lifecycle-head test. + +## Temporary-mutation RED + +The only temporary mutation replaced the real `Enter-CcodMutex` call in +`Enter-CcodLifecycleProductCleanupLease` with a shape-valid test lease having +no mutex handle. This removed only N's real *outer* lease. It was restored +immediately after the run; it is not present in the final diff. + +- Command: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File + tests\persistence\InstallFileTransaction.SelfTest.ps1` +- Exit: `1` +- Exact failure: `ASSERT_TRUE: N+1 commit remains blocked after N strict + authority closes while its durable outer AccountTransition lease is live`. + +This is the intended post-close blocked assertion. Earlier test shaping +attempts were rejected because removing the inner lease failed before the +blocked assertion during retained/fence validation; the final test opens the +retained generation only after the first blocked proof and tests the outer +lease only after the inner strict authority is closed. The accepted RED was +not caused by STA, adapter loading, marker timing, or a mocked mutex. + +## Final GREEN and verification + +All commands below were fresh direct runs after production restoration. + +Fix-round focused command: +`powershell.exe -NoProfile -ExecutionPolicy Bypass -File +tests\persistence\InstallFileTransaction.SelfTest.ps1` exited `0` and printed +`Install file transaction self-test passed.` + +| Command | Exit | Result | +|---|---:|---| +| `tests\persistence\InstallFileTransaction.SelfTest.ps1` | 0 | `Install file transaction self-test passed.` | +| `tests\persistence\KernelObjects.SelfTest.ps1` | 0 | 16 named cases passed; real cross-process mutex, ACL, timeout, abandoned, and same-thread-release cases included. | +| `tests\persistence\ProductRegistration.SelfTest.ps1` | 0 | `Product registration self-tests passed: 13` | +| `tests\persistence\UninstallBootstrap.SelfTest.ps1` | 0 | `Uninstall bootstrap self-tests passed: 23` | +| `tests\persistence\InstallLifecycle.SelfTest.ps1` | 0 | `Install lifecycle self-tests passed: 126` | +| `git diff --check` | 0 | no whitespace errors (only Git LF-to-CRLF checkout warnings) | +| `tests\PersistenceSelfTest.ps1` | 0 | fresh tracked direct aggregate exit; no failure output | + +The lifecycle run retained Task 1's fail-closed cross-process observation: +`exit=23 mutex=True authority=False product=False verified=False +error=CCOD_PRODUCT_REGISTRATION_FAILED`. + +`[System.Management.Automation.Language.Parser]::ParseFile` parsed the one +final changed PowerShell file successfully: `PowerShell parser passed: 1/1`. + +## No-real-action statement + +No real registry, Start-menu/Desktop shortcut, installer, uninstaller, +installed product, product process, network, release, tag, signing, push, +publication, installation, restart, or reboot action was performed. Tests +used temporary fixtures, marker adapters, named mutexes, and temporary event +handles only. + +## Review request and remaining boundary + +Please perform fresh independent review of the final test diff and this +evidence, with particular attention to the two post-signal blocked assertions +and exact Task 1 fence ordering. A green focused suite and aggregate are not +release or installed-product acceptance evidence. diff --git a/.superpowers/sdd/2026-09-02-v2522-legacy-upgrade-safe-reclamation/final-fix-report.md b/.superpowers/sdd/2026-09-02-v2522-legacy-upgrade-safe-reclamation/final-fix-report.md new file mode 100644 index 0000000..d56da5e --- /dev/null +++ b/.superpowers/sdd/2026-09-02-v2522-legacy-upgrade-safe-reclamation/final-fix-report.md @@ -0,0 +1,153 @@ +# Final fix wave report — legacy proof ordering and reclamation tail recovery + +## 结论与范围 + +本计划唯一一次 final-review fix wave 已实现,implementation commit 为 +`58db6fd`(`fix: close legacy upgrade recovery gaps`),基线为 `6b9bc7e`。 +本轮只修改 5 个 production PowerShell 文件和 3 个 self-test 文件;没有执行真实 +registry、shortcut、scheduled task、安装、卸载、进程重启、系统重启、网络、push、tag、 +release 或发布操作。 + +本报告只声明 final-fix implementation 与本地验证结果。独立 scoped re-review 仍是单独 +gate;原 release plan Tasks 5–8 仍保持 blocked,本报告不作 release-readiness 声明。 + +## Finding 1:selector commit 成功但 PointerCommitted journal 丢失 + +### RED + +新增真实 legacy migration 失败点:generation-two selector 通过真实 +`Set-CcodActiveRuntime` commit/read-back 后、`PointerCommitted` snapshot append 前抛错。 +第一次尝试留下 canonical `1 old -> 2 failed-new -> 3 old` selector,transaction 中有 +`RuntimePromoted`、无 `PointerCommitted`,terminal head 为 `Failed`。第二次同包调用的实际 +RED 为: + +```text +InstallLifecycle.SelfTest.ps1 -> exit 1 +case=real-v2.5.21-selector-commit-with-a-missing-PointerCommitted-journal-resumes-the-exact-retained-runtime +error=CCOD_INSTALL_UPGRADE_SOURCE_INVALID +Failed migration phases do not match the exact selector profile +``` + +### GREEN + +`PostPointerCompensated` 现在可由 exact three-record selector compensation 与同一 immutable +transaction identity / `RuntimePromoted` proof 分类,不再把 journal snapshot 当作唯一 pointer +commit proof。普通 pre-pointer 或任意 `RuntimePromoted -> Failed` 链仍不能进入该分支;selector、 +transaction、manifest、package 或 foreign-chain 变异继续在新 mutation 前失败。重试复用同一 +generation-two runtime,并只追加 generation-four Ready。 + +## Finding 2:current overlap write 洗掉 legacy profile 缺陷 + +### RED + +production-shaped lifecycle fixture 模拟 current shortcut write 会补齐 missing overlap 并替换 +foreign same-name leaf。未修复顺序在 `MissingMain` 首个 zero-write 断言的实际结果为: + +```text +InstallLifecycle.SelfTest.ps1 -> exit 1 +case=legacy-profile-is-captured-before-any-current-product-or-shortcut-write +ASSERT_EQUAL: MissingMain is rejected before the current registry write expected=[0] actual=[1] +``` + +同一 table 同时覆盖 missing Desktop 与 foreign main target/arguments/working-directory。 + +### GREEN + +新增 mandatory pre-registration migration plan。它在任何 current registry/shortcut write 前: + +- 解析一个完整 checked-in historical profile; +- 绑定 AppId、DisplayVersion、InstallLocation 与 quoted Inno uninstall command; +- 捕获 registry values/kinds 和每个 shortcut 的 canonical path、bytes、SHA-256; +- 按对应历史版本校验 target、arguments 与 working directory。 + +current registration read-back 后,cleanup 只消费此前 captured plan;overlap 只在 exact current +proof 下保留,legacy-only entry 删除前逐项 unchanged re-read,删除后再次验证同一 current proof。 +registry compensation 继续使用 same-handle / create-only、replacement-safe 路径。missing main、 +missing Desktop 和 foreign same-name 三项均在 current writes 为 0 时失败。 + +## Finding 3:irreversible generation reclamation 后的尾段不可恢复 + +### RED + +四个 post-reclaim failure injection 均先成功使 selected generation absent,再执行第二次调用。 +未修复实现的实际结果为: + +```text +PhaseWrite -> CCOD_INSTALLED_FINALIZER_INVALID, phase=TaskRemoved +ProductShortcuts -> CCOD_INSTALLED_FINALIZER_INVALID, phase=ReadyForInno +FinalReceipt -> CCOD_INSTALLED_FINALIZER_INVALID, phase=ReadyForInno +FinalRegistry -> CCOD_INSTALLED_FINALIZER_INVALID, phase=Completed +``` + +### GREEN + +`PrepareInstalled` 现在把完整 staged payload record set、transaction/user、selected runtime / +manifest / epoch、historical wrapper、resume script path/length/SHA-256 和 exact resume command +写入 external transaction。generation reclaim 前,product `UninstallString` 先切到 staged +`InstalledUninstallFinalizer.ps1 -Resume`,随后写完整 recovery metadata 并 exact read-back; +old/new partial registry publish 可收敛,foreign mixed state 拒绝。 + +finalizer 顺序为: + +1. preload 验证 current locator、transaction ACL/no-reparse、duplicate-free JSON 与完整 17-file + staged payload length/SHA; +2. 依既有顺序取得 Local uninstall lock,再取得 Global `AccountTransition`,锁内重读 transaction; +3. 验证 caller SID、fixed InstallRoot、derived RuntimeRoot、Ready/manifest/epoch 与 exact command; +4. initial 或 replacement runtime wrapper 必须等待其 exact PID/creation/session/SID 退出;public + staged `-Resume` 不复用旧 caller identity,但在 tree 仍存在时会证明 latest bound wrapper 已退出; +5. `TaskRemoved + present` 重新验证并 reclaim;`TaskRemoved + absent` 只有 exact write-ahead recovery + anchor 才可推进;随后 durable `ReadyForInno`; +6. absent-or-exact 地清理 shortcuts,保持 Apps recovery registry; +7. 写并重读 `Completed` transaction 和 exact `Completed` receipt;transaction 已 Completed 而 + receipt 丢失时只补 receipt; +8. 最后通过 exact open registry handle 删除完整 recovery key,不先逐 value 删除。此后没有必需 + 的 fallible write。已收敛的 serial replay 在 receipt exact 且 anchor absent 时为 no-op。 + +额外预审修复包括:existing `TaskRemoved` replacement `PrepareInstalled` 直接返回,不再重入 +application cleanup;replacement wrapper identity 单独持久化且不改写 historical identity;同 SID +新登录 session 可恢复;epoch 在 reclaim 紧邻边界二次读取;Bootstrap 与 finalizer 均保持 +Local -> Global lock acquisition order,避免 ABBA。 + +## 最终验证 + +最终源码冻结后运行: + +```text +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ProductRegistration.SelfTest.ps1 + Product registration self-tests passed: 24; exit 0 + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1 + Install lifecycle self-tests passed: 150; exit 0 + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\UninstallBootstrap.SelfTest.ps1 + Uninstall bootstrap self-tests passed: 37; exit 0 + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1 + 37 declared cases passed; exit 0 + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\RuntimeManifest.SelfTest.ps1 + 21/21; exit 0 + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstalledLifecycleHarness.SelfTest.ps1 + 21/21; exit 0 + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ManualWrappers.SelfTest.ps1 + pass; exit 0 + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\Bootstrap.SelfTest.ps1 + 26/26; exit 0 + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\PersistenceSelfTest.ps1 + exit 0; successful aggregate intentionally emits no stdout summary +``` + +此外,8 个修改的 PowerShell 文件经 parser 检查均为 `0 errors`;`git diff --check` exit 0, +只有 checkout LF/CRLF conversion warning。测试只使用 GUID temp roots、内存 adapters、受控 +child PowerShell 与 disposable registry doubles;未触碰真实 product/shortcut/task/install state。 + +## Review gate + +请求以 `6b9bc7e..58db6fd` 为 implementation review range 做一次 scoped re-review,重点核对三项 +原 Important、replacement-wrapper/AccountTransition 预审修复、完整 staged payload binding, +以及 Apps registry-last / Completed-receipt repair 顺序。该 review 未通过前不进入下一 wave 或 +release claim。 diff --git a/.superpowers/sdd/2026-09-02-v2522-legacy-upgrade-safe-reclamation/progress.md b/.superpowers/sdd/2026-09-02-v2522-legacy-upgrade-safe-reclamation/progress.md new file mode 100644 index 0000000..6ca9578 --- /dev/null +++ b/.superpowers/sdd/2026-09-02-v2522-legacy-upgrade-safe-reclamation/progress.md @@ -0,0 +1,46 @@ +# SDD ledger — plan: docs/superpowers/plans/2026-09-02-v2522-legacy-upgrade-safe-reclamation.md + +## Preflight interface scan + +| Producer task | Consumer task | Shared interface | Finding | Ruling | +|---|---|---|---|---| +| Task 1 | Task 2 | True v2.5.21 active.json/no-transaction fixture | Product migration tests must consume the real legacy shape, not a current-format Ready disguised as old. | Ruling: Task 1 fixture is the source of truth for Task 2 integration. Cost if wrong: old installations pass tests but fail before staging or at registration. | +| Task 1 | Task 1 | Durable cleanup resolver | A real legacy version cannot have a cleanup fence or Ready transaction. | Ruling: skip resolver only after proving every append-only/fence plane absent and exact legacy active runtime valid; mixed state fails closed. Cost if wrong: malformed current installs bypass cleanup. | +| Task 2 | Task 2 | Historical shortcut profiles | Requiring the union of every historical name is impossible. | Ruling: match one exact checked-in installer profile and dynamic snapshot count. Cost if wrong: legitimate v2.5.21 upgrades never converge or foreign shortcuts are deleted. | +| Task 3 | Task 3 | Installed finalizer reclamation | Path validation before recursive Remove-Item is not bound to deletion. | Ruling: hold exact native handles for the complete manifest tree and delete bottom-up by handle only. Cost if wrong: concurrent path changes can redirect or invalidate deletion. | + +## Status + +Task 1: in progress. Broad whole-branch review found one Critical true-legacy upgrade blocker and two independent Important production defects. + +Task 1 fix round 1 Ruling: support two exact append-only retry profiles. A pre-pointer failure has latest selector generation 1 on the legacy runtime and may converge by appending generation 2 to the retained new runtime. A post-pointer compensated failure has canonical `1 old -> 2 new -> 3 old` and may converge only by appending generation 4 back to the same retained new runtime. Never delete or rewrite selector history. Cost if wrong: a transient post-pointer failure either becomes unrecoverable or corrupts the monotonic selector chain. + +Task 1: fix round 1/5 (1 addressed, 0 open; legacy migration retry after pre/post pointer failure; commits 812b34e..4504c1a). + +Task 1: complete (commits 2aef5ff..4504c1a, scoped re-review clean: Spec Compliance PASS, Task Quality PASS). + +Task 2: in progress. Consumes Task 1 true v2.5.21 fixture and replaces the impossible union-of-history shortcut requirement with exact installer profiles. + +Task 2: fix round 1/5 (1 addressed, 0 open; registry compensation replacement overwrite; commits be47fad..3682b81). + +Task 2: complete (commits 4504c1a..3682b81, scoped re-review clean: Spec Compliance PASS, Task Quality PASS). + +Task 3: in progress. Replace pathname recursive generation deletion with manifest-complete, handle-pinned native reclamation. + +Task 3 Ruling: Windows rejects delete disposition on a nonempty directory even when every child is already pinned/armed. Use a two-phase protocol: fully pin/revalidate the exact tree; arm every file while reversible and disarm all on any file-mark failure; only after all file marks succeed enter the irreversible commit, close files, then mark/close directories deepest-first and the selected root last. Cost if wrong: a post-commit filesystem failure can leave a partially reclaimed selected tree, but all affected identities remain inside the fully pinned selected generation and no sibling/outside path can be reached. + +Task 1: fix round 1/5 (1 addressed, 0 open; exact pre/post pointer legacy migration retry; commits 812b34e..4504c1a). + +Task 1: complete (commits 2aef5ff..4504c1a, scoped re-review clean: Spec Compliance PASS, Task Quality PASS). + +Task 2: fix round 1/5 (1 addressed, 0 open; registry replacement-safe compensation; commits be47fad..3682b81). + +Task 2: complete (commits 4504c1a..3682b81, scoped re-review clean: Spec Compliance PASS, Task Quality PASS). + +Task 3: complete (commits 3682b81..6b9bc7e, task review clean: Spec Compliance PASS, Task Quality PASS). + +Final whole-plan review: FAIL (0 Critical, 3 Important): compensated selector may lack PointerCommitted journal evidence; legacy overlap proof occurs after current overwrite; irreversible reclamation has no durable tail resume. One final fix wave permitted. + +Final fix wave: implementation complete at `58db6fd` (3 original Important addressed in one wave). Final frozen verification: ProductRegistration 24, InstallLifecycle 150, UninstallBootstrap 37, InstallFileTransaction 37, RuntimeManifest 21, InstalledLifecycleHarness 21, Bootstrap 26, ManualWrappers pass; all modified PowerShell parsers 0 errors; `git diff --check` and full `tests\PersistenceSelfTest.ps1` exit 0. + +Final scoped re-review of `6b9bc7e..58db6fd`: **FAIL** (1 Critical, 1 Important). The original three findings and the staged-resume/AccountTransition fixes were addressed, but two independent cross-invocation gaps remain: a same-SID new-session replacement `PrepareInstalled` is rejected by the generic session comparison before it can publish `resumeWrapper*`; and the legacy pre-capture plan is memory-only, so a crash after a current overlap write prevents the next process from reconstructing the historical proof. Per the one-review/one-fix-wave ruling, this plan is closed as failed rather than receiving a second fix wave. Successor plan: `docs/superpowers/plans/2026-09-03-v2522-cross-process-retry-closure.md`. Tasks 5–8 and release readiness remain blocked. diff --git a/.superpowers/sdd/2026-09-02-v2522-legacy-upgrade-safe-reclamation/task-1-report.md b/.superpowers/sdd/2026-09-02-v2522-legacy-upgrade-safe-reclamation/task-1-report.md new file mode 100644 index 0000000..5bf1b89 --- /dev/null +++ b/.superpowers/sdd/2026-09-02-v2522-legacy-upgrade-safe-reclamation/task-1-report.md @@ -0,0 +1,217 @@ +# Task 1 报告:v2.5.21 legacy lifecycle upgrade bridge + +## 结论 + +Task 1 已在隔离 worktree +`C:\Users\33384\Documents\Codex-Control-other-devices-Windows\.worktrees\codex-v2522-install-runtime-reliability` +实现并通过要求的 focused tests。实现提交为 +`5114a09953e2566b1127788ffbad115c11ae072f`(`fix: bridge legacy lifecycle upgrade`), +基线为 `2aef5ff2a74219a82e98fe26c2f8170a5232a280`。 +review fix round 1 实现提交为 +`52d3962b9bc91c91350317a148a145efdc9aa0c9`(`fix: resume failed legacy migration`)。 + +本报告不声称 Task 2/Task 3、整分支验收或 release readiness 已完成。 + +## 历史来源与 fixture + +fixture 直接以 merge-base +`19803a79af3302c49f129e6a6b3ff7ffaf1b930b` 为格式来源: + +- `active.json` 是严格 schema 2,字段顺序为 + `schemaVersion,activeRuntime,previousRuntime,generation,updatedAtUtc`; +- runtime manifest 是 schema 1,字段为 + `schemaVersion,projectVersion,runtimeId,files`; +- v2.5.21 runtime ID 使用旧的 `version-digest16` 两段式算法,digest 来自按 + ordinal path 排序后的 `path/length/sha256` manifest records,不含 v2.5.22 nonce; +- mutable activation receipt 位于 `state\post-install-activation.json`; +- fixture 不创建 `state\active-generation`、`state\install-transactions` 或 + `state\product-cleanup-fences`。 + +测试没有用“projectVersion 写成 2.5.21、但 runtime ID/Ready 仍是当前格式”的伪 legacy +fixture。另有显式负例拒绝带 v2.5.22 nonce 的 current-format v2.5.21 lookalike。 + +## 实现 + +新增私有分类接口: + +```text +Get-CcodLegacyUpgradeCompatibilityContext + -InstallRoot + -ExistingPointer + -ActiveValidation + -GlobalTransaction +``` + +初始实现返回两种 `Kind`;fix round 1 增加第三种严格恢复分类: + +- `CurrentReady`:append-only selector 与 transaction plane 均存在;selected pointer、 + valid current manifest、manifest SHA-256 和 canonical terminal Ready transaction 的 + runtime/generation 完全一致。此分支仍执行原 durable product-cleanup resolver。 +- `ProvenLegacyWithoutReady`:三个新 plane 均有精确 ItemNotFound 证明;raw + `active.json` 是上述严格 schema-2 generation-one 形状;active runtime tree/manifest + 通过 contained path、non-reparse、no-ADS、single-link 和 hash/file-set 校验;current + validator 的唯一失败是 `CCOD_RUNTIME_ID_MISMATCH`;随后使用历史算法复算并精确得到 + v2.5.21 两段式 runtime ID。只有此分支跳过不存在的旧 Ready cleanup resolver。 +- `LegacyMigrationRetry`:只接受一个已到 `RuntimePromoted` 的严格 v2.5.22 Failed + transaction、仍完整有效的 failed runtime/manifest/package、无 cleanup fence,以及下列一个 + canonical selector profile。此分支复用 retained failed runtime,不创建另一个 runtime tree。 + +在真实 legacy upgrade 中,同一个 opaque file transaction 先打开 manifest-bound retained +legacy generation,并发布 generation-one selector genesis;普通新 runtime pointer 随后以 +generation two 提交。旧 `active.json` 保持 byte-for-byte 不变,只作为迁移证据。成功路径会 +写出 canonical Ready transaction;新 runtime 在 Ready 前失败时,旧 runtime 会被重新选择并 +启动。 + +## TDD 证据 + +### 基线 + +- `InstallLifecycle.SelfTest.ps1`:129/129,exit 0。 +- `InstalledLifecycleHarness.SelfTest.ps1`:20/20,exit 0。 + +### RED 1:真实旧 runtime ID + +命令: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1 +``` + +结果:exit 1。 + +```text +CCOD_SELFTEST_FAILED case=real-v2.5.21-legacy-lifecycle-state-upgrades-through-the-v2.5.22-install-entrypoint error=CCOD_INSTALL_RUNTIME_ACTIVATION_UNPROVEN +The active runtime manifest is invalid before upgrade +``` + +这比计划中预期的 null Ready resolver 更早:当前 validator 先拒绝旧两段式 runtime ID。 +实现因此只接管 exact `CCOD_RUNTIME_ID_MISMATCH`,其他 validator failure 保持拒绝。 + +### RED 2:不可重建的 legacy generation + +加入 generation two、但没有 append-only history 的 legacy pointer 后,命令 exit 1: + +```text +ASSERT_EQUAL: legacy generation without reconstructable append-only history creates no immutable generation expected=[1] actual=[2] +``` + +分类随后收紧为 generation one,并在 staging 前拒绝无法安全重建历史的输入。 + +### RED 3:generation-one previousRuntime 不一致 + +加入 generation one 但 `previousRuntime` 非 null 的 lookalike 后,命令 exit 1: + +```text +ASSERT_EQUAL: generation-one active pointer with a previous runtime uses the expected fail-closed boundary expected=[CCOD_INSTALL_UPGRADE_SOURCE_INVALID] actual=[] +``` + +raw legacy pointer 随后收紧为 generation one 且 `previousRuntime = null`。 + +### 最终 GREEN + +- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1` + -> `Install lifecycle self-tests passed: 140`,exit 0。 +- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstalledLifecycleHarness.SelfTest.ps1` + -> 21/21(含 exact checksum-bound v2.5.21 -> v2.5.22 adapted Upgrade),exit 0。 +- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\RuntimeManifest.SelfTest.ps1` + -> 21/21,exit 0。 +- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1` + -> 30/30,exit 0。 +- PowerShell parser:3 个修改文件均 0 errors。 +- `git diff --check`:exit 0。 + +Lifecycle 覆盖还包括:孤立 selector、无 Ready fence plane、schema-one active、 +generation/history ambiguity、current-format lookalike、wrong manifest version、runtime junction, +均在 staging/task mutation 前失败;以及 pre-pointer 与 post-pointer 两类 Ready 前失败均恢复 +旧 runtime。 + +## 无真实操作证明 + +- 所有 install roots、source roots、installer bytes 和证据目录均位于 GUID 临时目录; +- scheduled task、Supervisor/TrayHost、product registration、shortcut 和 installed scenario 均由 + 现有 fake/adapters 驱动; +- 没有运行真实 Setup/installer,没有修改真实 registry/shortcut/scheduled task/device key, + 没有安装、重启、重启系统、网络、push、tag 或 release 操作; +- 测试创建的 junction 只位于临时目录,使用 `.NET Directory.Delete(link)` 删除链接并验证 + 外部临时 target 未被跟随。 + +## 边界与后续 + +- 本 Task 只支持有完整历史证据可安全映射为 selector genesis 的 v2.5.21 + generation-one profile。generation greater than one 无法从单个 mutable active pointer 重建 + 完整 append-only 历史,因此在 staging 前 fail closed;如要支持该形状,需要单独设计并提供 + 可验证历史来源,不能伪造 selector records。 +- Task 2 的 exact historical product-registration profile 迁移尚未实现;Task 1 测试只用适配器 + 证明 lifecycle/Ready/rollback 边界。 +- 尚待独立 reviewer 对本提交做 Task 1 scoped review;本报告不替代该门禁。 + +## Fix round 1:Failed legacy migration 可精确重试 + +### Review finding 与裁定 + +第一次 true-legacy migration 在 Ready 前失败后,原分类器只接受 `CurrentReady` 或三个新 plane +全缺失的 `ProvenLegacyWithoutReady`。因此第二次完全相同的升级会在 staging 前返回 +`CCOD_INSTALL_UPGRADE_SOURCE_INVALID`,即使失败 runtime 已经完整 sealed 且旧 runtime 已恢复。 + +本轮接受两个、且仅两个 selector profile: + +- pre-pointer:latest selector 是 generation 1 legacy;重试复用原 Failed transaction 的 + `newRuntimeId`,提交 selector/Ready generation 2; +- post-pointer compensated:完整 selector chain 必须是 + `1 legacy -> 2 failed-new -> 3 legacy`;重试追加 generation 4 指向同一 failed-new runtime, + Ready transaction 使用 generation 4。旧 selector records 不删除、不改写。 + +### RED + +在现有 pre-pointer 与 post-pointer failure tests 中移除 transient failure 后立即执行第二次真实 +`Invoke-CcodInstall`。两者最初均 exit 1: + +```text +CCOD_SELFTEST_FAILED case=real-v2.5.21-upgrade-failure-before-new-pointer-commit-keeps-the-legacy-selector-active-and-restarts-it error=CCOD_INSTALL_UPGRADE_SOURCE_INVALID +CCOD_SELFTEST_FAILED case=real-v2.5.21-upgrade-failure-before-new-Ready-restarts-the-retained-legacy-runtime error=CCOD_INSTALL_UPGRADE_SOURCE_INVALID +``` + +自审另加入 premature-Failed RED:把 transaction chain 缩短为 +`Prepared -> PackageVerified -> Failed`,但保留 selector/runtime。旧实现错误接受并成功重试: + +```text +ASSERT_EQUAL: Failed chain without RuntimePromoted evidence fails through the retry compatibility boundary expected=[CCOD_INSTALL_UPGRADE_SOURCE_INVALID] actual=[] +``` + +根因是原 retry 分类只绑定 Failed head、selector 和 manifest,没有证明第一次尝试已经完成 +runtime promotion 与 immutable initialization。修复要求 chain 包含 `RuntimePromoted`,并交叉要求 +pre-pointer profile 不得包含 `PointerCommitted`、post-pointer compensated profile 必须包含 +`PointerCommitted`。 + +### 实现边界 + +- `LegacyMigrationRetry` 重新验证 raw legacy `active.json`、v2.5.21 historical manifest/hash、 + transaction store 中唯一 transaction ID、Failed immutable identity、current v2.5.22 failed + runtime manifest/hash、exact source file records、sealed package SHA-256 和 selector chain。 +- IFT ABI 从 V4 升为 V5,增加私有 migration-retry transaction capability。它只能在 `state` + 子树写 create-only records、打开一个 manifest-bound retained generation、并通过该 retained + capability append pointer;不能创建 generation、copy payload、写 manifest、retire generation + 或进入 product special-folder surface。公共 export surface 未扩大。 +- Failed 是 terminal,因此 retry 使用新的 transaction ID,但复用原 sealed runtime。新 Ready + transaction 的 generation 与新 append pointer 精确一致。 +- 负例覆盖:legacy manifest identity drift、Failed `newGeneration` drift、foreign terminal chain、 + 缺少 `RuntimePromoted`、cleanup fence plane、unexpected selector generation、failed runtime + manifest bytes drift、different sealed package identity。全部在 runtime/pointer/transaction/task + 新 mutation 前失败。 + +### 最终 GREEN(fix round 1) + +- `InstallLifecycle.SelfTest.ps1`:148/148,exit 0。 +- `InstalledLifecycleHarness.SelfTest.ps1`:21/21,exit 0。 +- `RuntimeManifest.SelfTest.ps1`:21/21,exit 0。 +- `InstallFileTransaction.SelfTest.ps1`:36/36,exit 0。 +- 四个修改 PowerShell 文件 parser:0 errors。 +- `git diff --check`:exit 0。 + +一次候选 final run 曾返回 `CCOD_INSTALL_TRANSACTION_INVALID`:私有 opener 用强制 re-import +创建了另一个 IFT module instance,而后续 exported commands 仍绑定旧 instance 的 capability +table。最终实现从同一个 canonical module instance 调用私有 opener;修复后的 Lifecycle +148/148 与 IFT 36/36 均重新运行通过。 + +本轮仍只使用 GUID 临时目录、fake/adapters 和子进程测试;没有执行真实 installer、registry、 +shortcut、scheduled task、产品进程、网络、安装、重启、push、tag 或 release 操作。 diff --git a/.superpowers/sdd/2026-09-02-v2522-legacy-upgrade-safe-reclamation/task-2-report.md b/.superpowers/sdd/2026-09-02-v2522-legacy-upgrade-safe-reclamation/task-2-report.md new file mode 100644 index 0000000..08818f9 --- /dev/null +++ b/.superpowers/sdd/2026-09-02-v2522-legacy-upgrade-safe-reclamation/task-2-report.md @@ -0,0 +1,196 @@ +# Task 2:精确历史注册 profile 迁移报告 + +## 结论 + +Task 2 已在隔离 worktree `codex/v2522-install-runtime-reliability` 中实现并完成要求的 focused verification。 + +- 基线:`4504c1afa665bccd3cc9f9e1ffd748c795bf936d` +- initial implementation commit:`6be1025`(`fix: migrate exact legacy registration profiles`) +- fix round 1 implementation commit:`69c762d`(`fix: make legacy registry compensation create-only`) +- 未运行真实安装器,未读写真实 registry、Start menu、Desktop shortcut、scheduled task 或产品进程。 +- 未执行 push、tag、release、安装、重启或 reboot。 +- 本报告只声明 Task 2 focused scope;全计划 aggregate、installed acceptance 与 Task 3 不在本次完成声明内。 + +## 历史来源与版本边界 + +历史 profile 直接来自 checked-in `build/CodexControlOtherDevices.iss` 与对应 `package.json`: + +| Profile | 版本范围 | AppId | 精确 shortcut 集合 | +|---|---:|---|---| +| `CodexControlOtherDevicesInitial` | `2.1.0` | `{2B9E9F2E-7A32-4A7E-9C1D-9F5B5C6D7E8F}` | 4 个旧品牌 Start-menu shortcut,无 Desktop shortcut | +| `CodexControlOtherDevicesDesktop` | `2.1.1`–`2.1.6` | 同上 | 相同 4 个 Start-menu shortcut,加 `Desktop\Codex 设备连接 (Device Connection).lnk` | +| `CodexRemoteFix` | `2.2.0`–`2.5.21` | 同上 | 3 个 `Programs\CodexRemote-fix\...` shortcut,加 `Desktop\CodexRemote-fix.lnk` | + +来源核对: + +- tag `v2.1.0`:版本 `2.1.0`,4 个 `[Icons]` entry;无 Desktop entry。 +- commit `2d8da0f8e86dd06571f2dd6428143fab9beb5918`:版本变为 `2.1.1`,首次增加旧品牌 Desktop entry。 +- tag `v2.1.6`:保留上述 5-entry 旧品牌布局。 +- commit `fedc840a66f9019de406c5e55dabd403df3b0611` / tag `v2.2.0`:切换为 4-entry `CodexRemote-fix` 布局。 +- Task 1 的真实 merge-base `19803a79af3302c49f129e6a6b3ff7ffaf1b930b` 与 tag `v2.5.21` 均为版本 `2.5.21`、相同 AppId、相同 4-entry `CodexRemote-fix` 布局。 + +三个 profile 都要求 registry `DisplayVersion` 位于本 profile 的闭区间内,并要求 `UninstallString` 精确等于由 canonical absolute `InstallLocation` 绑定出的 `"{installLocation}\unins000.exe"`,不接受附加参数、未加引号或外部路径。 + +## 实现 + +`ProductRegistration.psm1` 新增并导出: + +- `Get-CcodLegacyRegistrationProfiles` +- `Resolve-CcodLegacyRegistrationProfile -LegacyRegistration` + +解析采用 Ordinal exact set:只允许一个完整 profile,不要求历史名称并集,也不接受重复、缺失、额外、跨 profile、大小写变化或 reparse/unsafe shortcut evidence。 + +真实 reader 会: + +- 读取 legacy AppId key 的 `DisplayVersion`、`InstallLocation` 与 `UninstallString`; +- 枚举两个已知 Start-menu group 的全部直接子项,因此 unknown/extra entry 不会被 allowlist scan 隐藏; +- 读取两个历史 Desktop candidate; +- 把 directory/reparse object 标记为 unsafe,使 resolver 在任何删除前 fail closed。 + +Legacy snapshot 数量改为 `1 registry entry + selected profile shortcut count`,并再次校验 snapshot 中 registry/shortcut 名称与所选 profile 完全一致。 + +### v2.5.21 重叠路径保护 + +自审发现 v2.2.0–v2.5.21 的 main Start-menu 与 Desktop 路径和 v2.5.22 current shortcut 路径重叠。新注册会先安全替换这两个 leaf;legacy cleanup 不能随后把 current replacement 删除。 + +因此 cleanup 现在要求 exact current three-record proof:current registry、Start-menu、Desktop 的 runtime/version/package/name/hash 必须一致。只有 snapshot 中 path 和 SHA-256 同时绑定该 proof 的两个重叠 entry 才从 legacy deletion set 排除。实际删除的是 legacy registry、compatibility shortcut、uninstall shortcut。删除后再次读取并逐字段验证同一个 current proof;若漂移,则按反序补偿所有已删除 legacy-only entry。 + +旧 profile 与 current 路径不重叠,仍完整删除 `5` 或 `6` 个 snapshot entry。 + +原有安全语义保持: + +- 删除失败后只反序恢复已删除或部分删除的 exact entry; +- replacement 已占用名称时不覆盖; +- restore 失败或 replacement mismatch 会写 create-only unresolved compensation record; +- current proof 或 profile 不完整时执行零 legacy deletion。 + +## TDD 证据 + +### 干净基线 + +- `ProductRegistration.SelfTest.ps1`:`13/13`,exit `0`。 +- `InstallLifecycle.SelfTest.ps1`:`148/148`,exit `0`。 + +### RED 1:真实 v2.5.21 四 shortcut 集合 + +Production 尚未修改时运行: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ProductRegistration.SelfTest.ps1 +``` + +结果:exit `1`;首个真实 case 为 `real-v2.5.21-registration-removes-its-exact-four-shortcut-profile`,错误为 `CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID`,原因是旧实现要求 union-of-eight。 + +随后加入 table-driven profile 与 negative matrix;旧实现仍在 exact legacy cleanup 前以同一错误失败。 + +### GREEN 1:profile resolution 与动态 snapshot + +- ProductRegistration:`17/17`,exit `0`。 + +覆盖 profile 边界以及 AppId、missing、extra、cross-profile、case-varied、reparse、version binding/gap/too-new、uninstall outside/arguments/unquoted、relative InstallLocation、same-count foreign snapshot。 + +### RED 2:current shortcut overlap + +自审补充 overlap RED 后,旧 profile implementation 运行 ProductRegistration suite:exit `1`;首失败为: + +```text +ASSERT_EQUAL: exact migration removes only the three non-current legacy entries expected=[3] actual=[5] +``` + +该 RED 证明仅按 profile snapshot 全删会删掉刚写入并验证的 current Start-menu/Desktop replacement。 + +另先写入两个 safety RED:overlap snapshot hash mismatch 必须在零删除前失败;cleanup 后 current proof 漂移必须反序补偿三项 legacy-only 删除。 + +### 最终 GREEN + +- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ProductRegistration.SelfTest.ps1` + - `Product registration self-tests passed: 19` + - exit `0` +- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1` + - `Install lifecycle self-tests passed: 148` + - exit `0` + +Lifecycle 的真实 v2.5.21 fixture 现在经过实际 ProductRegistration module,仅把 registry/shortcut 外部副作用替换为内存 adapters。断言: + +- 新 current registration 在 terminal Ready 后完成; +- legacy cleanup 删除恰好 registry、compatibility、uninstall 三项并保留两个 proof-bound current shortcuts; +- 无 unresolved compensation record; +- 第二次同 package 调用返回 `AlreadyInstalled`,不增加 runtime/selector,不安装 task、不启动进程、不重复 legacy deletion。 + +最终静态检查: + +- `src\persistence\modules\ProductRegistration.psm1` parser:`0` errors。 +- `tests\persistence\ProductRegistration.SelfTest.ps1` parser:`0` errors。 +- `tests\persistence\InstallLifecycle.SelfTest.ps1` parser:`0` errors。 +- `git diff --check`:exit `0`。 + +## Fix round 1:registry replacement 不可被补偿覆盖 + +### Review finding + +独立 review 指出原 registry 删除/补偿存在 pathname race: + +- `Remove-CcodLegacySnapshotEntry` 在部分 value 删除失败后关闭原 key handle; +- catch 随后通过 `Test-Path`、`New-Item -Force`、`Get-ItemProperty` 与 `New-ItemProperty -Force` 按路径补值; +- 若原 key 已被重命名/删除且同路径出现 replacement,captured legacy values 会被写入 replacement。 + +同样,完整删除后的 `Restore-CcodLegacySnapshotEntry` 使用 `Test-Path` 后再 `New-Item -Force`,不能区分本次新建 key 与在竞争窗口内已存在的 replacement。 + +### Production-shaped RED + +测试导入 production module 的隔离副本,调用真实 `Remove-CcodLegacyProductRegistration`、`Remove-CcodLegacySnapshotEntry`、`Read-CcodLegacySnapshotEntry` 与 `Restore-CcodLegacySnapshotEntry`;只在 module 内替换 registry/native OS boundary,没有访问真实 HKCU。 + +部分删除场景在第一次 `DeleteValue` 后让原 key 脱离路径、在同路径放入 replacement,并在第二次删除抛错。未修复代码的实际结果: + +```text +ProductRegistration.SelfTest.ps1 -> exit 1 +case=partial-registry-compensation-never-writes-captured-values-into-a-replacement-key +lower=TEST_PARTIAL_REGISTRY_DELETE_FAILURE +replacement before={"DisplayName":"replacement-product","ReplacementSentinel":"unchanged"} +replacement after ={"DisplayName":"replacement-product","ReplacementSentinel":"unchanged","NoModify":1} +ReplacementWrites=1 +``` + +捕获 RED 后,最终 regression fixture 把 sentinel 加强为 Binary `de ad be ef`;GREEN 同时校验 replacement 的所有 values、Binary bytes 与 value kinds 均不变。 + +另分别观察两个 create-only RED: + +- replacement 在 absent-check 与 restore 之间赢得竞争:exit `1`,旧 `New-Item -Force` 把 `DisplayName` 改成 captured value 并加入 `NoModify`。 +- 路径保持完全 absent:exit `1`,断言 `native create-only open expected=[1] actual=[0]`,证明旧实现仍走 provider `New-Item`。 + +Native wrapper 编译测试还捕获过一次真实类型错误:`RegistryKey.Handle` 在目标 Windows PowerShell/.NET 中是 `SafeRegistryHandle`,不能直接作为 `IntPtr` 传入。实现改用 `DangerousGetHandle()` 后才进入 GREEN。 + +### Fix + +- 打开原 legacy key 时请求 `QueryValues | SetValue | EnumerateSubKeys | Delete`,删除通过原 open handle 调用 `NtDeleteKey`,不再按路径删除 registry key。 +- mid-delete 失败只通过仍打开的原 handle 写回全部 captured values;随后从同一 handle 重读 exact names/kinds/values,并以 `NtCompareObjects` 对比原 handle 与当前路径重新打开的 handle。 +- 若原 handle 已不再绑定该路径或不能精确验证,状态变为 `PartialUnresolved`;后续禁止降级到 path-level create,replacement 保持不变并记录 `Registry` unresolved。 +- 只有状态明确为 `Deleted` 的完整删除才允许 path-level restore。 +- path-level restore 使用窄 `RegCreateKeyExW(HKEY_CURRENT_USER, ...)` wrapper 并读取 disposition。只有 `REG_CREATED_NEW_KEY` 映射为 `CreatedNew` 后才能写值;`REG_OPENED_EXISTING_KEY` 立即关闭、零写入并标记 unresolved。 +- 新建后通过同一 handle 重读 exact names/kinds/values,并以 `NtCompareObjects` 证明仍绑定目标路径后才接受。写入/验证失败时尝试通过该新建 handle 删除部分 key,绝不删除同路径 replacement。 +- shortcut replacement、profile resolution、overlap retention、current proof revalidation 与 reverse compensation 行为未改变。 + +### Fix round 1 GREEN + +- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ProductRegistration.SelfTest.ps1` + - `Product registration self-tests passed: 23` + - exit `0` +- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1` + - `Install lifecycle self-tests passed: 148` + - exit `0` +- 三个相关 PowerShell 文件 parser:各 `0` errors。 +- `git diff --check`:exit `0`。 +- 本机只读 symbol check:`NtCompareObjects=True`、`NtDeleteKey=True`、`RegCreateKeyExW=True`。 +- Windows PowerShell registry rights 表达式结果:`0x1000b`,包含 exact-handle delete 所需 `DELETE`。 + +Fix-round 测试同时证明: + +- partial failure 把 captured values 恢复到原 open handle,但路径 identity 已变时仍 fail unresolved;`NativeCreateCalls=0`、provider `New-Item` calls=`0`。 +- `OpenedExisting` disposition 对 replacement values、Binary bytes 与 kinds 均执行零修改,并只记录 `Registry` unresolved。 +- `CreatedNew` disposition 写回完整 captured values/kinds,same-handle 重读通过后才被视为成功补偿。 + +## 审查边界与后续 + +- Fix round 1 focused self-review 未发现新的 Task 2 blocker。 +- Fix round 1 scoped re-review 仍由 controller 执行;本报告不把自审当成独立 review。 +- 全 persistence aggregate、installed scenario、Setup/Defender、真实升级与发布仍在后续总体验收 gate 之后。 diff --git a/.superpowers/sdd/2026-09-02-v2522-legacy-upgrade-safe-reclamation/task-3-report.md b/.superpowers/sdd/2026-09-02-v2522-legacy-upgrade-safe-reclamation/task-3-report.md new file mode 100644 index 0000000..becdbc8 --- /dev/null +++ b/.superpowers/sdd/2026-09-02-v2522-legacy-upgrade-safe-reclamation/task-3-report.md @@ -0,0 +1,144 @@ +# Task 3:manifest-bound native generation reclamation 报告 + +## 结论与范围 + +Task 3 已在隔离 worktree `codex/v2522-install-runtime-reliability` 中实现,并完成要求的 focused、shared 与 aggregate 验证。 + +- 基线:`3682b810138ea43127762c92a860d45d96ca3d3d` +- implementation commit:`1d57736197df0bfcd1c0e54d96be63dfa54602df` +- 新接口:`Remove-CcodVerifiedGenerationTree -InstallRoot -RuntimeRoot -RuntimeId -ExpectedManifestSha256` +- 未运行真实安装器,未读写真实安装目录、registry、Start menu、Desktop shortcut、scheduled task、产品进程或真实 DPAPI/device-key store。 +- 未执行 push、tag、release、安装、重启或 reboot。 +- 本报告只声明 Task 3 implementation/verification scope;controller 的独立 scoped review 与最终 whole-plan review 仍是单独 gate。 + +## 根因与生产改动 + +原 `InstalledUninstallFinalizer.ps1` 先用路径验证 selected generation,随后重新把权限降级为 pathname,并执行: + +```powershell +Remove-Item -LiteralPath $expected -Recurse -Force +``` + +验证与物理删除之间没有持有任何对象身份。树在验证后增加未知对象、发生 identity replacement,或只有一个 child 被其他 handle 打开时,旧实现会整树删除或先部分删除再失败。 + +新 `GenerationReclamation.psm1` 只导出 `Remove-CcodVerifiedGenerationTree`。其 CLR assembly 只公开 inert `CcodGenerationReclamationMarkerV1`;实际 native runtime 为 internal type。 + +生产路径现在: + +1. 严格校验 canonical `RuntimeRoot == \runtime\` 与 64-hex manifest binding。 +2. 绝对打开并验证 install root,然后只通过 parent native handle 相对打开 `runtime`、selected generation 和每个 descendant;所有 selected-tree handle 请求 `DELETE`、read-attributes、list/read-data 与 synchronize 权限,share 只允许 read,不允许 write/delete sharing,并始终使用 open-reparse-point 语义。 +3. 每个 held object 保存 final path、volume serial、file ID、type、parent/name;拒绝 reparse、ADS/non-default stream,regular file 还要求 single link。 +4. 从 pinned manifest handle 读取严格 UTF-8 JSON;拒绝任意层 duplicate property、非精确字段、非 canonical record、乱序/重复 path、错误 runtimeId digest/nonce binding。 +5. exact file set 明确为 `manifest.json + manifest.files`;exact directory set为这些 manifest files 的全部 parent directories。额外空目录也被拒绝。 +6. 全树 second enumeration、final path、volume/file ID、stream/link/length/hash revalidation 通过后才进入 disposition。 +7. `UninstallBootstrap.ps1` 的 durable payload allowlist 增加该 module;default installed finalizer 只调用该 API,生产文件不再包含 `Remove-Item`、`Copy-Item` 或 `Move-Item` 路径删除/替换。 + +成功结果是显式对象: + +```text +phase=Completed +result=Reclaimed +runtimeId= +fileCount= +directoryCount= +``` + +只有该 proof 返回且 selected root absence 再次成立,transaction 才从 `TaskRemoved` 推进到 `ReadyForInno`;之后才允许 product removal 和 completion receipt。 + +## Windows disposition ruling + +临时 NTFS 树的 native probe 得到: + +- classic `FileDispositionInfo` 对 readonly immutable file 返回 Win32 `5`; +- `FileDispositionInfoEx(Delete | IgnoreReadonly = 0x11)` 可 arm,也可用 flags `0` disarm; +- 当 child 仅被标记、handle 仍打开且名称仍存在时,对 parent directory 标记删除返回 Win32 `145` (`Directory not empty`);加入 POSIX flag 仍为 `145`,本机 ON_CLOSE 组合返回 `50`。 + +因此“files + all parent directories 全部 arm、所有 handle 始终保持打开、最后统一 close”在目标 Windows 语义上不可实现。controller 接受以下必要细化: + +- `PinnedValidated`:完整 pin、manifest exact-set 与 second validation;零 disposition。 +- `FilesArmed`:先 arm 全部 files,仍为可逆屏障。任一 file-arm 失败时,对已 arm handles 反向 disarm,并重新验证完整树;成功恢复后报 `CCOD_GENERATION_RECLAMATION_INVALID`。若恢复本身失败,使用独立 `CCOD_GENERATION_RECLAMATION_ROLLBACK_FAILED`。 +- `CommitStarted`:只有全部 file arm 成功后才进入不可逆提交;close files,随后 deepest directories 逐个 mark+close,selected root last。 +- commit 阶段的 directory mark/close/absence failure 使用稳定 `CCOD_GENERATION_RECLAMATION_COMMIT_FAILED`。此时 selected generation 可能只剩部分失败边界,但 install root、siblings、state/device key 与 outside objects 不受影响;finalizer 不执行 absence proof、product removal 或 completion receipt,也不写假成功。 + +## TDD 证据 + +### 干净基线 + +- `UninstallBootstrap.SelfTest.ps1`:`23/23`,exit `0`。 +- `InstallFileTransaction.SelfTest.ps1`:baseline suite pass,exit `0`。 + +### 有效 RED:当前 pathname deletion + +首个 production-path RED 先执行真实 `ValidateSelectedGeneration`,验证成功后插入 `unexpected-after-validation.bin`,再走默认 installed finalizer: + +```text +exit=1 +case=installed-finalizer-rejects-an-unexpected-post-validation-child-without-deleting-any-generation-state +error=CCOD_RECLAMATION_RED_PATHNAME_DELETE +recursive pathname deletion accepted and removed the changed selected tree +``` + +随后以 `CCOD_RECLAMATION_RED_CASE` 分别运行以下六项;旧生产实现全部 exit `1` 并命中 `CCOD_RECLAMATION_RED_PATHNAME_DELETE`: + +- `UnexpectedFile` +- `UnexpectedDirectory`(额外空目录) +- `Reparse` +- `Hardlink` +- `Ads` +- `OpenChild` + +前五项证明旧实现接受变化后整树删除;`OpenChild` 证明旧递归路径删除可先删除其他 manifest members,再在 open child 处失败。 + +### 有效 RED:新 native contract + +- `IdentityChange`:exit `1` / `CCOD_RECLAMATION_RED_MODULE_MISSING`;wished-for pinned boundary 尚不存在。 +- `MarkFailure`:exit `1` / `CCOD_RECLAMATION_RED_MODULE_MISSING`;wished-for arm/disarm retry boundary 尚不存在。 +- IFT production-shape case:exit `1` / `CCOD_RECLAMATION_RED_MODULE_MISSING`;真实 immutable transaction 创建并 Ready-close 的 readonly generation 尚无 reclamation API。 +- commit-failure injection:exit `1` / `ASSERT_THROWS expected CCOD_GENERATION_RECLAMATION_COMMIT_FAILED`;seam 尚未被 commit phase 消费。 + +测试构造期间出现过并已排除的非有效 RED:并行启动 suite 争用同一 user mutex、loop closure metadata error,以及长路径上的 PowerShell ADS cmdlet fixture error。它们未被计作功能证据;对应用例改为串行、同步 case capture 和短 `manifest.json` ADS target 后重新得到上述有效 RED。 + +### 最终 GREEN + +- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\UninstallBootstrap.SelfTest.ps1` + - `Uninstall bootstrap self-tests passed: 32` + - exit `0` + - 包含 six hostile mutations、identity exchange blocked、file-arm disarm/full-tree retry、CommitStarted stable failure,以及 default staged finalizer negative matrix。 +- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1` + - `37` 个 declared cases 全部 pass + - exit `0` + - 新 case 使用真实 IFT 生成 nested readonly generation;结果只删除 selected generation,install root/outside sentinel 不变。 +- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1` + - `Install lifecycle self-tests passed: 148` + - exit `0` + - 既有 unapproved-verb warnings 与负向 `CCOD_CROSS_PROCESS_OBSERVED ... CCOD_PRODUCT_REGISTRATION_FAILED` 不改变最终 pass。 +- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\PersistenceSelfTest.ps1` + - exit `0` + - aggregate 成功时无 stdout 汇总;只读进程观察确认其依次推进 Lifecycle、ProductRegistration、StateStore、TrayUi 等 child suites 后正常退出。 + +### Parser、surface 与 diff + +- 以下 5 个文件 PowerShell parser 各 `0` errors: + - `src\persistence\modules\GenerationReclamation.psm1` + - `src\persistence\InstalledUninstallFinalizer.ps1` + - `src\persistence\UninstallBootstrap.ps1` + - `tests\persistence\UninstallBootstrap.SelfTest.ps1` + - `tests\persistence\InstallFileTransaction.SelfTest.ps1` +- module exports:仅 `Remove-CcodVerifiedGenerationTree`。 +- marker ABI:`1`;assembly exported CLR types:仅 `CcodGenerationReclamationMarkerV1`。 +- production static scan:`InstalledUninstallFinalizer.ps1` 与 `GenerationReclamation.psm1` 中 `Remove-Item|Copy-Item|Move-Item` 零命中。 +- `git diff --check`:exit `0`;只有 checkout line-ending conversion warning,无 whitespace error。 + +## 自审与审查边界 + +自审逐项确认: + +- 所有 selected-tree 对象从已持有 parent handle 相对打开;reparse 不会被 follow。 +- 每个 regular file 在任何 disposition 前已绑定 path、volume/file ID、default stream、single link、length 与 SHA-256。 +- manifest 本身属于 exact file set,不由 manifest 自引用;其 hash 绑定 durable transaction。 +- unexpected file、unexpected empty directory、reparse、hardlink、ADS、open child、root identity exchange 均在零删除前失败。 +- file-arm failure 可逆恢复后,完整同一 tree 可由第二次调用成功 reclaim。 +- success 只删除 selected generation;sibling generation、install root、state 和 device-key sentinel 均保留。 +- CommitStarted failure 不声称 tree rollback;它明确 fail closed,不进入 product/final receipt。 + +独立 scoped review 与 fresh whole-plan review 由 controller 使用 implementation commit `1d57736197df0bfcd1c0e54d96be63dfa54602df` 执行;本报告不把 implementer 自审当成独立 review。Tasks 5–8 的原 release plan 仍保持 blocked,直到本计划完成独立 review 和最终总 gate。 diff --git a/.superpowers/sdd/2026-09-02-v2522-rollback-lease-ordering/task-1-report.md b/.superpowers/sdd/2026-09-02-v2522-rollback-lease-ordering/task-1-report.md new file mode 100644 index 0000000..7450204 --- /dev/null +++ b/.superpowers/sdd/2026-09-02-v2522-rollback-lease-ordering/task-1-report.md @@ -0,0 +1,177 @@ +# Task 1 report: release rollback cleanup lease before bootstrap + +## Scope and commit + +- Plan base: `3ec2a3d13f67995018b104840d121a6fadd2dcbd`. +- Implementation: `a178f01a69a622ac9bd20091d6fdb326132dd9b3` + (`fix: release rollback cleanup lease`). +- The implementation commit contains only: + - `src/persistence/modules/InstallLifecycle.psm1` + - `tests/persistence/InstallLifecycle.SelfTest.ps1` +- Evidence is recorded in this report and committed separately after the + implementation source freeze. + +This is the minimal follow-up for the rollback lease-ordering residual. It does +not claim release readiness, installed-product acceptance, or whole-branch +review completion. + +## Root cause + +An upgrade acquires `upgradeProductCleanupLease` as the outer real +`AccountTransition` mutex acquisition before resolving old durable product +cleanup. On a failure before selector commit, rollback released lifecycle +ownership and the inner install lease, but then called `StartSupervisorTask` +while the outer acquisition was still live. The scheduled-task bootstrap needs +the same real account mutex. It therefore timed out before it could restore and +prove the retained old runtime Ready. The recoverable original activation +failure was incorrectly escalated to `CCOD_INSTALL_ROLLBACK_FAILED`. + +## Test-first cross-process proof + +The new regression creates a temporary old Ready installation, mutates the +source payload to begin a real upgrade, and injects failure in the +`SetActiveRuntime` adapter before the production pointer write. It then proves: + +- the real selector still names the old runtime at generation 1; +- exact old Supervisor exit occurred before rollback; +- rollback invoked the fake scheduled-task start boundary; +- that boundary launched a hidden, bounded independent `powershell.exe` child; +- the child obtained its SID from `WindowsIdentity.GetCurrent()` and imported + the production `KernelObjects.psm1`; +- the child called production `Enter-CcodMutex -Kind AccountTransition` with a + 1500 ms mutex timeout; +- `attempted.txt` was written immediately before that real acquisition, while + `acquired.txt` was written only after the returned lease had exact + `Outcome='Acquired'`; +- the parent process wait was independently bounded at 10000 ms; +- rollback readiness could become `Ready` only for the retained old runtime, + generation 1, after the real acquired marker existed. + +No fake mutex or pre-call acquisition marker is used. The process helper is +test-only and does not create or start a Windows Scheduled Task. + +## RED evidence + +### Clean baseline before the test + +Command: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1 +``` + +- Exit: `0`. +- Result: `Install lifecycle self-tests passed: 128`. + +### Test-only change against the old production module + +The same complete command was run after adding only the cross-process helper +and rollback regression, before modifying production code. + +- Exit: `1`. +- Harness result: + +```text +CCOD_SELFTEST_FAILED case=pre-pointer-rollback-releases-the-outer-cleanup-lease-before-old-Supervisor-bootstrap error=ASSERT_TRUE +``` + +- Exact failing assertion: + +```text +ASSERT_TRUE: old bootstrap acquires AccountTransition before readiness (childExit=42; outcome=TimedOut; childFailure=) +``` + +The preceding pointer, previous-protection-stopped, and bootstrap-attempted +assertions passed. The child completed normally enough to return the production +mutex outcome `TimedOut`; there was no adapter, parser, process-launch, or child +setup failure. This is the intended behavioral RED. + +## Implementation and exact ordering + +The pre-selector rollback branch now performs the following order: + +1. Close the shutdown gate if it is still live. +2. Release live lifecycle ownership and require exact release proof. +3. Release the acquired install lease and require a literal Boolean `true`. +4. Release `upgradeProductCleanupLease` with + `Exit-CcodLifecycleProductCleanupLease`. +5. Require a literal Boolean `true` and + `upgradeProductCleanupLease.Lease.Released=true`. +6. Only after that proof, set `upgradeProductCleanupLease=$null`. +7. Read the rollback clock, invoke `StartSupervisorTask`, and wait for the old + runtime readiness proof. + +This is strict LIFO for the nested lifecycle, install, and outer product +cleanup acquisitions. If outer release throws or its exact result is not +proven, control leaves the rollback body before task start; the existing +rollback boundary normalizes the outcome to stable +`CCOD_INSTALL_ROLLBACK_FAILED`. + +The successful forward-install path is unchanged. It still retains the outer +product-cleanup lease across old-Pending resolution and the new selector +commit, then releases lifecycle ownership, the install lease, and the outer +lease before starting the new runtime. The fix therefore does not reopen the +old-Pending/new-selector race. + +The enclosing `finally` now retries outer cleanup only when the context, its +lease, and a still-unreleased lease are all present. A successful rollback +release nulls the caller variable, and an already-released non-null context is +also excluded, so final cleanup cannot release the mutex twice. + +## GREEN evidence + +Command: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1 +``` + +- Exit: `0`. +- Result: `Install lifecycle self-tests passed: 129`. +- The new case proved `PointerCommitted=false`, + `PreviousProtectionStopped=true`, `BootstrapAttempted=true`, + `BootstrapMutexAcquired=true`, and `OldRuntimeOutcome='Ready'`. +- After successful rollback, the original injected pre-pointer failure remained + `CCOD_INSTALL_RUNTIME_ACTIVATION_UNPROVEN`; it was not replaced by a false + rollback-fatal code. +- The run retained the existing fresh-process observation: + `CCOD_CROSS_PROCESS_OBSERVED exit=23 mutex=True authority=False product=False verified=False error=CCOD_PRODUCT_REGISTRATION_FAILED`. + +## Fresh final matrix after source freeze + +| Command | Exit | Result | +|---|---:|---| +| `tests\persistence\InstallLifecycle.SelfTest.ps1` | 0 | `Install lifecycle self-tests passed: 129`. | +| `tests\persistence\KernelObjects.SelfTest.ps1` | 0 | 16 named cases, all `True`. | +| `tests\persistence\InstallFileTransaction.SelfTest.ps1` | 0 | `Install file transaction self-test passed.` | +| PowerShell parser over both changed PowerShell files | 0 | `PARSE_OK`; token counts 31424 and 40901. | +| `git diff --check` | 0 | No whitespace errors; only checkout LF-to-CRLF warnings. | +| `tests\PersistenceSelfTest.ps1` | 0 | Fresh tracked aggregate over 39 discovered `*.SelfTest.ps1` files; no failure output. | + +The aggregate intentionally captures child self-test output and emits only a +stable failure line when a child exits nonzero, so empty aggregate stdout with +exit `0` is its normal passing result. + +The focused and aggregate runs retain the prior real cross-process product +fence, bind-failure abort, abrupt-writer crash temporary recovery, atomic +`SignalAndWait`, stale-N and closed-N rejection, exact create-only histories, +and wrong-thread/double-cleanup mutex tests. No test was removed, skipped, or +target-filtered for the final matrix. + +## No-real-action statement + +No real scheduled task, installed product, registry, Start-menu/Desktop +shortcut, installer, uninstaller, Codex/product process, network, push, tag, +release, signing, publication, installation, restart, or reboot action was +performed. Tests used temporary fixture directories, fake task/product +adapters, hidden bounded test child processes, and private named mutex/event +objects only. + +## Remaining review boundary + +Request a fresh task and whole-branch independent review of the implementation +and evidence commits. In particular, re-check the pre-selector rollback order, +the unchanged post-selector success protection, release-failure normalization, +and final live-lease guard. The green focused matrix and aggregate are not +installed Windows acceptance, Defender evidence, reboot acceptance, or release +proof. diff --git a/.superpowers/sdd/2026-09-03-v2522-cross-process-retry-closure/implementation-brief.md b/.superpowers/sdd/2026-09-03-v2522-cross-process-retry-closure/implementation-brief.md new file mode 100644 index 0000000..49c07c9 --- /dev/null +++ b/.superpowers/sdd/2026-09-03-v2522-cross-process-retry-closure/implementation-brief.md @@ -0,0 +1,10 @@ +# Implementation brief — cross-process retry closure + +Read the successor plan, the prior `final-fix-report.md`, and the final scoped review result supplied by the parent agent. + +Implement exactly two tasks in order with RED -> GREEN: + +1. Same-SID/new-session `TaskRemoved` replacement wrapper recovery before the staged Apps anchor exists. The generic session matcher must remain strict elsewhere. Revalidate under Local uninstall -> Global AccountTransition, persist only `resumeWrapper*`, short-circuit cleanup, and prove `-WrapperResume` waits for the exact new wrapper. +2. A create-only Ready-bound durable legacy migration plan written before current product writes. A second process must recover after each overlap/current-readback failure without accepting a washed live profile when no exact prior plan exists. + +Do not perform any real system or external action. Do not run tests concurrently with another implementation agent. After each task, run its focused suites. After both tasks, freeze source and run the complete final gate from the plan. Commit implementation and evidence separately; report exact SHAs, commands, counts, exits, parser/diff/aggregate results, and a clean status. Do not claim release readiness. diff --git a/.superpowers/sdd/2026-09-03-v2522-cross-process-retry-closure/progress.md b/.superpowers/sdd/2026-09-03-v2522-cross-process-retry-closure/progress.md new file mode 100644 index 0000000..c1db1a3 --- /dev/null +++ b/.superpowers/sdd/2026-09-03-v2522-cross-process-retry-closure/progress.md @@ -0,0 +1,39 @@ +# SDD ledger — cross-process retry closure + +Plan: `docs/superpowers/plans/2026-09-03-v2522-cross-process-retry-closure.md` + +## Review provenance + +- Prior implementation: `58db6fd`; evidence: `ea86451`. +- Prior plan's only final scoped review: FAIL, 1 Critical + 1 Important. +- Critical: generic context matching rejects same-SID new-session `TaskRemoved` before replacement-wrapper persistence. +- Important: legacy pre-capture plan is memory-only and cannot survive a partial current overlap write. + +## Rulings + +- Session relaxation is allowed only for exact installed `TaskRemoved`/`Failed(resumePhase=TaskRemoved)` after locked fresh proof. Cost if wrong: a foreign or stale runtime can adopt an uninstall transaction. +- A live current/historical hybrid is never authority by itself. It is acceptable only when a previously create-only durable plan proves the original complete historical layout and the current leaf matches the same Ready/package. Cost if wrong: the original wash-before-proof defect returns. +- The durable plan is append-only audit state; it is not deleted on success and never contains device-key material. + +## Status + +Task 1: complete. Initial implementation/evidence `9973bfd..bf4d215`; fix round 1 implementation/evidence `bf939c6..6107151`. Fix-round scoped re-review: Spec Compliance PASS, Task Quality PASS, 0 Critical, 0 Important. Final focused evidence: UninstallBootstrap 45/45, ManualWrappers pass, parser 2/2, `git diff --check` exit 0. No real system action. + +Task 2: initial implementation `148f6ee`, evidence `66a91aa`; scoped review FAIL (0 Critical, 3 Important). +Fix round 1 implementation `a0c8d71`; evidence is recorded by the commit containing the corrected +`task-2-fix1-brief.md`, updated `task-2-report.md`, and this ledger. Fix round 1 closes crash-safe +fixed-directory ACL normalization/registered-handle cleanup, exact six-field profile and raw-path schema, +independent `powershell.exe` replay, and published-write/read-back failure convergence. Final focused evidence: +InstallFileTransaction 44 groups, ProductRegistration 30/30, InstallLifecycle 151/151, parser 4/4, and +`git diff --check` exit 0. No real system action. + +Fix round 1 frozen gate: complete, including UninstallBootstrap 45/45, RuntimeManifest 21 groups, +InstalledLifecycleHarness 21 groups, Bootstrap 26/26, ManualWrappers 12 groups, and +`tests\PersistenceSelfTest.ps1` exit 0. Fix-round scoped re-review: Spec Compliance PASS, Task Quality PASS, +0 Critical, 0 Important. Task 2 is complete. + +Successor whole-plan final review: FAIL (0 Critical, 1 Important). All Task 1/2 behavioral and interaction +boundaries passed, but the Task 2 fix modified embedded CLR RuntimeV6 without advancing its marker/type ABI; +a process that preloaded the pre-fix V6 can silently reuse it after force re-import. Per the fixed review scope, +this successor plan is closed as failed rather than receiving another whole-plan fix wave. Successor micro-plan: +`docs/superpowers/plans/2026-09-03-v2522-runtime-v7-closure.md`. Tasks 5–8 remain blocked. diff --git a/.superpowers/sdd/2026-09-03-v2522-cross-process-retry-closure/task-1-fix1-brief.md b/.superpowers/sdd/2026-09-03-v2522-cross-process-retry-closure/task-1-fix1-brief.md new file mode 100644 index 0000000..0c56b2e --- /dev/null +++ b/.superpowers/sdd/2026-09-03-v2522-cross-process-retry-closure/task-1-fix1-brief.md @@ -0,0 +1,12 @@ +# Task 1 fix round 1 — failed TaskRemoved and durable freshness + +Read the Task 1 report and scoped review. Fix all four findings together; do not start Task 2. + +## Required RED -> GREEN + +1. Treat exact `Failed(resumePhase=TaskRemoved)` with an installed binding as the same narrowly authorized replacement-wrapper recovery profile as `TaskRemoved`. Under the existing Local uninstall -> Global AccountTransition order, revalidate fresh context and every immutable field, normalize back to TaskRemoved, persist the new wrapper binding, and return without `RunCleanup`. Same-session and new-session tests must prove zero application removal. All other Failed resume phases remain strict. +2. Give each replacement-wrapper persistence a fresh canonical `updatedAtUtc` strictly later than the prior transaction, clear the error, and make the TaskRemoved receipt bind that timestamp. Reject an old receipt, null/empty error-code ambiguity, write no-op, write failure, stale read-back, or malformed read-back. A retry after an interrupted replacement write must converge without changing historical evidence. +3. Require historical `wrapperUserSid == transaction.userSid` and `wrapperSessionId == transaction.sessionId` before any replacement write. Add zero-write negatives for both mutations. +4. Add a production-shaped/disk-backed test that obtains selector/Ready/manifest/epoch/payload evidence through the real verified-runtime path, changes only the current-session seam, and uses real atomic transaction/receipt storage with independent deserialized objects. Adapter-only tests may remain for fault injection but cannot be the sole proof. + +Run UninstallBootstrap, ManualWrappers, changed parsers and `git diff --check`. Commit implementation and evidence separately, update the ledger/report, leave a clean tree, and request a scoped re-review. No real system/external actions and no release claim. diff --git a/.superpowers/sdd/2026-09-03-v2522-cross-process-retry-closure/task-1-report.md b/.superpowers/sdd/2026-09-03-v2522-cross-process-retry-closure/task-1-report.md new file mode 100644 index 0000000..23f3bd4 --- /dev/null +++ b/.superpowers/sdd/2026-09-03-v2522-cross-process-retry-closure/task-1-report.md @@ -0,0 +1,167 @@ +# Task 1 report — same-SID new-session TaskRemoved recovery + +## 结论与范围 + +Task 1 已按 successor plan 完成,implementation commit 为 `9973bfd` +(`fix: recover installed TaskRemoved across sessions`),基线为 `a5ffe4a`。 +本任务只修改: + +- `src/persistence/UninstallBootstrap.ps1` +- `tests/persistence/UninstallBootstrap.SelfTest.ps1` + +Task 2 未开始、未修改。本任务没有执行真实 registry、shortcut、scheduled task、安装、卸载、 +进程重启、系统重启、网络、push、tag、release 或发布操作;device-key/DPAPI state 未触碰。 + +## 根因与 RED + +旧实现对所有 existing transaction 先调用 generic +`Assert-CcodUninstallBootstrapTransactionMatchesContext`。该 matcher 正确要求 session ID 相同, +但也因此在一个精确、已到 `TaskRemoved` 的 installed transaction 上,拒绝同 SID 的新登录 +session,尚未进入 replacement-wrapper 持久化分支。 + +production-shaped RED 使用:durable transaction session `1`、fresh verified context session `99`、 +相同 SID、exact `TaskRemoved`、完整 installed binding、尚无 Apps recovery anchor,以及 exact 新 +wrapper identity。production 未修改时实际结果: + +```text +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\UninstallBootstrap.SelfTest.ps1 +exit 1 +case=same-SID-new-session-TaskRemoved-replacement-persists-a-fresh-wrapper-without-rerunning-cleanup +error=CCOD_UNINSTALL_TRANSACTION_MISMATCH +The existing uninstall transaction does not match the current verified runtime +``` + +栈命中真实 generic context matcher,不是 adapter/setup 失败。 + +## 实现 + +generic matcher 与 `New-CcodUninstallBootstrapResumeContext` 继续严格匹配 SID + session;只有 +`PrepareInstalled` 的 exact `TaskRemoved` installed transaction 进入新专用 matcher。 + +专用路径在既有 Local uninstall -> Global `AccountTransition` 顺序取得两把锁后: + +1. 再次执行真实 `ValidateInvocation`,获得 fresh runtime/selector/Ready/manifest/epoch proof; +2. 要求 transaction ID/current locator、SID、runtime ID、generation、epoch、完整 Ready evidence、 + payload records、selected runtime path、staged resume path/hash 与 immutable installed binding + 全部一致;唯一允许不同的是 fresh context session; +3. 保留 `transaction.sessionId` 与 historical `wrapper*`,只写 exact `resumeWrapperPid`、 + `resumeWrapperCreationTimeUtc`、`resumeWrapperSessionId`、`resumeWrapperUserSid`; +4. 原子写 transaction 后锁内重读完整 transaction,再写并重读 exact TaskRemoved receipt; +5. 直接返回 verified existing TaskRemoved,绝不再次调用 `RunCleanup`。 + +若该专用 transaction/receipt 持久化或 read-back 失败,generic catch 不把 historical TaskRemoved +改写为 Failed。普通 pre-TaskRemoved、不同 SID 或 runtime/generation/epoch/Ready/payload mismatch +仍走 strict failure boundary。 + +public runtime wrapper 随后使用已存在的 `-WrapperResume` 参数集;finalizer 必须匹配并等待新持久化 +的 PID/creation/session/SID exact identity,之后才允许 generation reclamation。测试还覆盖第一次 +external finalizer start 失败、没有 anchor,第二次同 SID session 99 public invocation 成功持久化 +replacement wrapper 并完成 exact wait。 + +## TDD 与负例 + +最终 self-test 新增/强化: + +- same-SID session 1 -> 99 exact TaskRemoved GREEN;fresh validation 调用两次,第二次位于 + AccountTransition acquisition 之后; +- existing TaskRemoved `RunCleanup` 调用数保持 `0`;historical session/PID 不变; +- DifferentSid、Runtime、Generation、Epoch、Ready、Payload、PreTaskRemoved 七项全部零 transaction / + receipt write、零 cleanup; +- WrapperResume 的 PID、creation time、session、SID 四项 mutation 在 wait/reclaim 前拒绝; +- finalizer-start failure 后的新 session replacement path 等待的确切 identity 为 PID `43`、 + creation `2030-02-03T03:04:06.0000000Z`、session `99`。 + +## 最终验证 + +```text +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\UninstallBootstrap.SelfTest.ps1 + Uninstall bootstrap self-tests passed: 40 + exit 0 + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ManualWrappers.SelfTest.ps1 + Manual wrapper self-tests passed + exit 0 +``` + +`src/persistence/UninstallBootstrap.ps1` 与 +`tests/persistence/UninstallBootstrap.SelfTest.ps1` 的 PowerShell parser 均为 `0 errors`; +`git diff --check` exit 0,仅有 checkout LF/CRLF conversion warning。 + +## 边界 + +本报告不声明 Task 2、successor plan final gate、fresh scoped review 或 release readiness 完成。 +Tasks 5–8 继续 blocked。 + +## Fix round 1 — Failed/TaskRemoved 与 durable freshness + +### Review findings 与 RED + +scoped review 对 `9973bfd` 判定 FAIL(1 Critical、3 Important)。本轮在 production 未修改时 +得到四组有效 RED: + +```text +Disk-backed real runtime: + ASSERT_TRUE: replacement persistence advances updatedAtUtc strictly + +Failed(resumePhase=TaskRemoved): + same session actual=CCOD_UNINSTALL_PREPARE_FAILED cleanup=1 + new session actual=CCOD_UNINSTALL_TRANSACTION_MISMATCH cleanup=0 + +Historical wrapper binding: + HistoricalSid / HistoricalSession did not throw CCOD_UNINSTALL_TRANSACTION_INVALID + +Freshness: + stale prior TaskRemoved receipt did not throw CCOD_UNINSTALL_TRANSACTION_WRITE_FAILED +``` + +其中 disk-backed RED 使用真实 v2.5.22 runtime manifest、七代 append-only selector、canonical +`Prepared -> ... -> Ready` install transaction、真实 lifecycle epoch 与完整 payload records;external +transaction/receipt 通过 production atomic writer 写入受保护临时目录,并由 production reader 形成 +独立反序列化对象。唯一替换的是 current-session seam 和禁止误入 cleanup 的 adapter。 + +### Fix + +实现提交:`bf939c6`(`fix: harden TaskRemoved replacement recovery`)。 + +- 专用 candidate 同时接受 exact `TaskRemoved` 与 exact + `Failed(resumePhase=TaskRemoved)`;后者在持久化前规范化回 TaskRemoved,清空 errorCode, + same/new session 均不进入 `RunCleanup`。 +- 每次 replacement persistence 必须从 adapter 获取 canonical UTC,且严格晚于 prior + `updatedAtUtc`;transaction 与 TaskRemoved receipt 精确绑定该新 timestamp。 +- receipt 比较显式区分 `null` 与空字符串;stale/null/malformed receipt、transaction no-op、 + transaction/receipt write failure、stale/malformed transaction read-back 均返回 + `CCOD_UNINSTALL_TRANSACTION_WRITE_FAILED`。 +- historical `wrapperUserSid` 必须等于 transaction SID,historical `wrapperSessionId` 必须等于 + transaction session;不一致时在任何 replacement write 前拒绝。 +- transaction write 后及 receipt write 后都从 durable adapter 重新读取;专用恢复失败不把原 + TaskRemoved 另写成 Failed。receipt write 中断后,下一次 exact wrapper 可写入更晚 timestamp、 + 替换仅 `resumeWrapper*` 并收敛。 + +### Fix round 1 tests + +新增/强化覆盖: + +- real disk-backed verified-runtime / transaction / receipt independent deserialization; +- Failed/TaskRemoved same session 与 session 99 两条 early-return; +- historical SID/session zero-write negatives; +- DifferentSid、runtime、generation、epoch、Ready、payload、pre-TaskRemoved negatives; +- stale receipt、empty error、null receipt、transaction no-op/write failure/stale/malformed read-back、 + receipt write failure/malformed receipt; +- interrupted receipt write 后的新 wrapper retry; +- WrapperResume PID/creation/session/SID exact-wait matrix。 + +最终无过滤验证: + +```text +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\UninstallBootstrap.SelfTest.ps1 + Uninstall bootstrap self-tests passed: 45 + exit 0 + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ManualWrappers.SelfTest.ps1 + Manual wrapper self-tests passed + exit 0 +``` + +两个修改文件 parser 均为 `0 errors`;`git diff --check` exit 0,仅有 LF/CRLF checkout warning。 +未执行任何真实系统或外部操作。Task 2 未修改、仍 pending;本报告不作 final-gate 或 release +readiness 声明。 diff --git a/.superpowers/sdd/2026-09-03-v2522-cross-process-retry-closure/task-2-fix1-brief.md b/.superpowers/sdd/2026-09-03-v2522-cross-process-retry-closure/task-2-fix1-brief.md new file mode 100644 index 0000000..47fdf3b --- /dev/null +++ b/.superpowers/sdd/2026-09-03-v2522-cross-process-retry-closure/task-2-fix1-brief.md @@ -0,0 +1,11 @@ +# Task 2 fix round 1 — directory creation recovery and exact durable evidence + +Read the Task 2 report and scoped review. Fix the three Important findings without weakening the already accepted V6 scope, Ready/fence/lease, overlap, or cleanup-tail boundaries. + +## Required RED -> GREEN + +1. Cover the CREATE-to-ACL crash shape for `state\legacy-registration-migrations`. A newly created directory must never leave an unregistered handle on Set/Validate failure. A later ProductTransaction may normalize only an empty, non-reparse directory owned by the current SID whose inherited ACL contains only the explicitly trusted SID/SYSTEM/Administrators full-control principals; nonempty, foreign-owner, untrusted-ACE, ADS, or identity-drift cases fail closed. Reader-first retry must recover the known empty crash artifact before returning no plan. Prefer atomic creation security if the native layer can provide it more safely. +2. Require exact nested schema. Persisted `profile` must have exactly the six canonical profile fields (`profileId,appId,minimumVersion,maximumVersion,uninstallCommandShape,shortcutNames`) in canonical order and byte-equivalent values to `Resolve-CcodLegacyRegistrationProfile`. `expectedInstallRoot`, every historical shortcut path/target/working-directory, and other identity paths must equal their canonical strings, not merely normalize to them. Add extra/missing/reordered profile and equivalent-noncanonical path REDs. +3. Add separate-`powershell.exe` replay evidence that reads serialized plan/live-state data and invokes the production durable replay parser/classifier with no parent-process object identity. Add published-but-threw writer and post-write read-back failure cases: first invocation performs zero current writes; the later invocation reads the existing exact leaf, does not republish it, and converges. Use independent serialized objects for all read-backs. + +Run IFT, ProductRegistration and Lifecycle focused suites, then repeat the complete frozen gate, parsers and `git diff --check`. Commit implementation and evidence separately, update the ledger/report, and request a scoped fix-round review. No real system/external actions or release claim. diff --git a/.superpowers/sdd/2026-09-03-v2522-cross-process-retry-closure/task-2-report.md b/.superpowers/sdd/2026-09-03-v2522-cross-process-retry-closure/task-2-report.md new file mode 100644 index 0000000..3709ad4 --- /dev/null +++ b/.superpowers/sdd/2026-09-03-v2522-cross-process-retry-closure/task-2-report.md @@ -0,0 +1,246 @@ +# Task 2 report — durable legacy migration plan replay + +## 结论与范围 + +Task 2 已按 successor plan 完成,implementation commit 为 `148f6ee` +(`fix: persist legacy product migration plan`),基线为 Task 1 复审通过后的 `bc0441d`。 +本任务只修改 6 个实现/测试文件: + +- `src/persistence/modules/InstallFileTransaction.psm1` +- `src/persistence/modules/ProductRegistration.psm1` +- `src/persistence/modules/InstallLifecycle.psm1` +- `tests/persistence/InstallFileTransaction.SelfTest.ps1` +- `tests/persistence/ProductRegistration.SelfTest.ps1` +- `tests/persistence/InstallLifecycle.SelfTest.ps1` + +没有执行真实 registry、shortcut、scheduled task、安装、卸载、进程重启、系统重启、网络、push、 +tag、release 或发布操作;device-key/DPAPI state 未触碰。所有破坏性边界仅在独立临时 fixture +中模拟,wrapper 的安装路径只执行 `WhatIf`。 + +## 根因与 RED + +旧实现只把完整历史 profile 保存在调用栈内。第一次 current registration 若已替换一个或两个与 +v2.5.21 重叠的 `.lnk`,第二个进程重新执行 live capture 时会把 current/historical 混合态当成 +不完整历史 profile,永久返回 `CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID`。 + +production 未修改时,首组 production-shaped RED 的实际结果为: + +```text +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ProductRegistration.SelfTest.ps1 +exit 1 +case=second-registration-recovers-from-partial-current-overlap-writes-only-through-a-durable-pre-capture-plan +expected=DesktopWrite:Recovered|CurrentReadBack:Recovered +actual=DesktopWrite:CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID|CurrentReadBack:CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID +``` + +专用 IFT wishful API 的 RED 首错为 export surface 缺少 +`Read/Write-CcodInstallLegacyMigrationPlan`。真实 lifecycle RED 在 v2.5.21 -> v2.5.22 入口命中 +`state\legacy-registration-migrations` `PathNotFound`,证明 orchestration 尚未持久化计划。 + +自审再得到三项有效 RED: + +- registry 已删除而 legacy-only shortcuts 仍在时,second replay 错误返回 + `legacyPresent=False`,会永久遗留旧快捷方式; +- Ready proof generation 与 transaction generation 不一致时,旧 matcher 没有拒绝; +- live ProductTransaction 内刚发布的 plan 可被移动替换,`expected moved=False, actual=True`。 + +oversized historical leaf、ACL/ADS/multilink/reparse、partial cleanup、foreign replacement 与四种 +overlap 组合在同一 RED/hostile 批次加入;最终均由下述 focused/final gate 覆盖。 + +## 实现 + +### IFT V6 专用能力 + +ABI 升为 V6,仅新增两个固定用途 export;没有导出可读任意 state leaf 的通用 reader。 + +- 固定目录:`state\legacy-registration-migrations`; +- 固定叶:`<20-digit Ready generation>..json`; +- 每次读写都要求 exact ProductTransaction root、live `AccountTransition` lease、durable Pending + cleanup fence,并在锁内重读完整 selector/manifest/persisted Ready authority; +- write 为 no-replace create-only,最大 1 MiB;read 使用相对目录 native handle; +- plan 目录与文件使用 protected exact ACL:current SID、SYSTEM、Administrators 三项 FullControl, + owner 为 current SID;延迟读取在同一 native handle 上复验 ACL/owner; +- 拒绝 reparse、ADS、multilink、路径/对象 identity 变化;刚发布的 plan handle 保持到 product + transaction close,replacement 在 live proof-to-use 区间被阻止; +- generic product state directory/write surface 仍保持拒绝。 + +### canonical durable schema 与 replay + +schema v1 固定并按顺序绑定: + +`schemaVersion, transactionId, runtimeId, runtimeGeneration, manifestSha256, packageSha256, +readyTransaction, appId, expectedInstallRoot, legacyPresent, legacyRegistration, profile, snapshot, +expectedCurrentProof`。 + +历史 snapshot 保存 exact registry values/kinds,以及每个 shortcut 的 canonical path、原始 bytes、 +length-bound hash、target、arguments、working directory;单个历史 shortcut 限制为 1..262144 bytes。 +`expectedCurrentProof` 额外绑定 version/runtime/package 和两个 current shortcut 的 canonical +destination、candidate path/length/SHA、target、arguments、working directory。该 proof 只取首次 +持久记录,后续进程不以新生成候选替换它。 + +reader 要求 strict UTF-8、所有 object 无 duplicate properties、完整 exact field set、canonical JSON +bytes 与 1 MiB 总界限;Ready transaction/runtime/generation/manifest/package 会交叉匹配,而不仅比较 +单个 hash。 + +已有 exact plan 的 second process 逐项分类 overlap:captured historical exact 或同一 persisted +current proof exact;HH、CH、HC、CC 四态都收敛,foreign/missing required overlap 拒绝。没有 plan 的 +washed hybrid 仍在 plan/current write 前失败。 + +legacy-only cleanup 也按 durable snapshot 做 `Absent=already done / Exact=delete / Mismatch=fail`。 +因此 registry-first 删除后在 0/1/2 个 legacy-only 删除边界崩溃,second process 都只继续剩余 exact +项;registry present 的 partial shortcut 边界也可收敛;foreign replacement 零删除拒绝。registry +absent 不再被当成整个 cleanup 已完成。 + +### Lifecycle 接线 + +`RegisterProduct` 在既有 Local/Global 顺序和 cleanup fence 内打开同一个 owned ProductTransaction, +先读取或发布并 read-back exact durable plan,之后才允许 current registry / StartMenu / Desktop 写入。 +second `Invoke-CcodInstall` 使用新 fake adapters、丢弃所有进程内 plan,并从同一磁盘叶在 +`AlreadyInstalled` 路径恢复;不会新增 runtime 或 selector。 + +## 负例与故障边界 + +最终 focused tests 覆盖: + +- Desktop write 前(StartMenu 已替换)与 current read-back(两项已替换)跨调用恢复; +- HH、CH、HC、CC 四种独立 overlap; +- no-plan washed hybrid、tampered current proof、wrong Ready/package/generation、duplicate JSON、 + unsafe path、foreign hybrid; +- Ready proof/transaction generation、manifest、package 内部不一致; +- historical shortcut 262145-byte rejection; +- registry-first 0/1/2 legacy-only partial deletion、registry-present partial deletion与 foreign tail; +- IFT wrong scope/Ready/fence、oversized record、collision、live replacement、file/directory ACL、ADS、 + multilink、file/directory reparse; +- disk plan leaf/schema/Ready identity,以及真实 lifecycle 新进程形态的 second invocation。 + +## frozen final gate + +source 冻结后顺序执行,结果如下: + +```text +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1 + 41 named test groups; exit 0 + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ProductRegistration.SelfTest.ps1 + Product registration self-tests passed: 28; exit 0 + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1 + Install lifecycle self-tests passed: 151; exit 0 + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\UninstallBootstrap.SelfTest.ps1 + Uninstall bootstrap self-tests passed: 45; exit 0 + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\RuntimeManifest.SelfTest.ps1 + 21 named test groups; exit 0 + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstalledLifecycleHarness.SelfTest.ps1 + 21 named test groups; exit 0 + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\Bootstrap.SelfTest.ps1 + Bootstrap self-test passed: 26; exit 0 + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ManualWrappers.SelfTest.ps1 + 12 named test groups; exit 0 + +PowerShell parser: 6/6 changed files, 0 errors +git diff --check: exit 0 (仅 checkout LF/CRLF conversion warning) + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\PersistenceSelfTest.ps1 + exit 0; success path is silent +``` + +Lifecycle 的既有 cross-process guard 仍输出: +`exit=23 mutex=True authority=False product=False verified=False error=CCOD_PRODUCT_REGISTRATION_FAILED`。 + +## 边界 + +Task 2 与 successor plan 的本地 final gate 已完成;fresh scoped review 仍待 parent agent 发起。 +本报告不声明 Tasks 5–8、合并、发布或 release readiness 完成。 + +## Fix round 1 — CREATE/ACL recovery、exact nested schema 与独立进程 replay + +### Review findings 与 RED + +Task 2 scoped review 对 `148f6ee..66a91aa` 判定 FAIL(0 Critical、3 Important)。本轮在 +production 未修改时分别运行三组 RED: + +```text +IFT reader-first safe empty recovery: + exit 1 + safe inherited empty directory reached OpenExistingLegacyPlanDirectory + ACL validation threw after result.Handle was detached + fixture cleanup failed with "being used by another process" + +IFT CREATE Set/Validate failure seam: + exit 1 + InvokeMethodOnNull (the exact native failure boundary did not exist) + +Product exact nested schema: + exit 1 + ProfileExtra was accepted + ASSERT_THROWS: expected CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID + +Product post-write read-back: + exit 1 + raw "fixture durable plan read-back failure" escaped instead of the stable product error +``` + +在第一轮 production 修复后,独立 `powershell.exe` replay 又得到一条真实 RED:serialized live +registry 的 `values` 被独立反序列化为 `PSCustomObject`,旧 comparator 直接访问 `.Keys`,child +返回 `The property 'Keys' cannot be found`。该失败证明先前同进程 adapter 测试依赖了 parent +object identity。 + +### Fix + +实现提交:`a0c8d71`(`fix: harden durable legacy plan recovery`)。 + +- V6 保持不变。新建 plan 目录的 Pin 在 ACL Set/Validate 前注册;任何后续失败均由 transaction + close 释放,避免 detached native handle。 +- writer 与 reader-first 共用同一恢复判定。只允许 current SID owner、空目录、无 reparse/ADS、 + 未 protected 且仅继承 current SID/SYSTEM/Administrators 三项 FullControl 的 CREATE artifact; + normalization 前后都复验 exact native identity,之后设置并验证既有 protected exact ACL。 +- nonempty、untrusted ACE、foreign owner、错误 flags/rights/principal、identity drift 均 fail closed, + 且不会被 normalization 改写。测试对 Set 与 Validate 两个 crash seam 都证明 live handle 阻止 + replacement、close 后立即释放、later reader-first 返回 no-plan 并收敛。 +- persisted profile 必须严格为 + `profileId,appId,minimumVersion,maximumVersion,uninstallCommandShape,shortcutNames` 六字段、原顺序, + canonical JSON 值与 `Resolve-CcodLegacyRegistrationProfile` 完全一致,并将 `profile.appId` 与顶层 + AppId 交叉绑定。 +- `expectedInstallRoot` 与每条 historical shortcut 的 path/target/workingDirectory 现在既要解析到 + expected identity,也要求 raw string 本身就是 canonical string;等价的 `\.\` spelling 被拒绝。 +- initial plan read 与 post-write read-back adapter failures 都归一化为 + `CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID`。published-then-threw 与 read-back-failed 两种首调均保持 + 零 current write;later invocation 只读取 existing bytes,publish count 保持 `1` 并完成 cleanup。 +- registry comparator 通过同一个 strict map facade 同时处理 ordered dictionary 与独立反序列化的 + `PSCustomObject`,不再依赖 parent-process object shape。 + +### Fix round 1 tests 与 frozen gate + +新增覆盖 reader-first safe empty recovery、Set/Validate registered-handle cleanup、nonempty/untrusted +ACE/foreign-owner shared native predicate、profile extra/missing/reordered/value、raw root/path/target/ +working-directory、published-then-threw、post-write read-back failure,以及完全独立 +`powershell.exe` serialized plan/live-state replay。 + +focused 结果:InstallFileTransaction 44 groups、ProductRegistration 30/30、InstallLifecycle 151/151, +均 exit 0。source freeze 后完整 gate: + +```text +InstallFileTransaction.SelfTest.ps1 44 groups exit 0 +ProductRegistration.SelfTest.ps1 30/30 exit 0 +InstallLifecycle.SelfTest.ps1 151/151 exit 0 +UninstallBootstrap.SelfTest.ps1 45/45 exit 0 +RuntimeManifest.SelfTest.ps1 21 groups exit 0 +InstalledLifecycleHarness.SelfTest.ps1 21 groups exit 0 +Bootstrap.SelfTest.ps1 26/26 exit 0 +ManualWrappers.SelfTest.ps1 12 groups exit 0 +PowerShell parser 4/4 changed files, 0 errors +git diff --check exit 0 +tests\PersistenceSelfTest.ps1 exit 0 (silent success) +``` + +Lifecycle cross-process guard 仍为 +`exit=23 mutex=True authority=False product=False verified=False error=CCOD_PRODUCT_REGISTRATION_FAILED`。 +未执行任何真实 registry、shortcut、scheduled task、安装、卸载、重启、网络、push、tag、release +或发布操作;失败 RED 遗留的单一临时 fixture 已在进程退出后按 exact temp path 清理。 + +Fix round 1 的 fresh scoped review 仍待 parent agent 发起;不作 release-readiness 声明。 diff --git a/.superpowers/sdd/2026-09-03-v2522-runtime-v7-closure/implementation-brief.md b/.superpowers/sdd/2026-09-03-v2522-runtime-v7-closure/implementation-brief.md new file mode 100644 index 0000000..2827ba1 --- /dev/null +++ b/.superpowers/sdd/2026-09-03-v2522-runtime-v7-closure/implementation-brief.md @@ -0,0 +1,7 @@ +# Implementation brief — Runtime V7 ABI closure + +Read the micro-plan and the successor whole-plan final review. Use RED -> GREEN. + +The RED must preload a self-contained stale V6 marker/runtime in a new `powershell.exe`, import the current module, and prove the module selected stale V6. Then change every production marker/runtime/ABI reference to V7/7 and update exact tests. Do not change file-layer behavior, exports, durable plan schema, or product/lifecycle logic. + +After focused GREEN, freeze source and run the full gate in the plan. Commit implementation and evidence separately, leave a clean worktree, and report exact SHAs/counts/exits. No real system or external actions. diff --git a/.superpowers/sdd/2026-09-03-v2522-runtime-v7-closure/progress.md b/.superpowers/sdd/2026-09-03-v2522-runtime-v7-closure/progress.md new file mode 100644 index 0000000..18c220d --- /dev/null +++ b/.superpowers/sdd/2026-09-03-v2522-runtime-v7-closure/progress.md @@ -0,0 +1,26 @@ +# SDD ledger — Runtime V7 ABI closure + +Plan: `docs/superpowers/plans/2026-09-03-v2522-runtime-v7-closure.md` + +## Provenance + +- Pre-fix V6 implementation: `148f6ee`. +- Final V6 behavioral fix: `a0c8d71`; evidence: `f38ddff`. +- Successor whole-plan review: FAIL, 0 Critical + 1 Important, solely because the modified CLR retained V6/ABI 6. + +## Ruling + +Any embedded CLR implementation change requires a new marker, runtime type and ABI integer. Cost if wrong: +long-lived PowerShell processes silently execute an older security/recovery implementation despite importing +new PowerShell source. + +## Status + +Runtime V7 task: complete. Implementation `9d7cc90`; evidence is recorded by the commit containing +`runtime-v7-report.md` and this ledger update. Frozen evidence: InstallFileTransaction 45 groups, +ProductRegistration 30/30, InstallLifecycle 151/151, UninstallBootstrap 45/45, RuntimeManifest 21 groups, +InstalledLifecycleHarness 21 groups, Bootstrap 26/26, ManualWrappers 12 groups, parser 2/2, +`git diff --check` exit 0, and `tests\PersistenceSelfTest.ps1` exit 0. No real system action. + +Fresh scoped V7 review: Spec Compliance PASS, Task Quality PASS, 0 Critical, 0 Important. Runtime V7 +micro-plan is complete. Tasks 5–8 remain blocked until their own gates are implemented and verified. diff --git a/.superpowers/sdd/2026-09-03-v2522-runtime-v7-closure/runtime-v7-report.md b/.superpowers/sdd/2026-09-03-v2522-runtime-v7-closure/runtime-v7-report.md new file mode 100644 index 0000000..f7c2c35 --- /dev/null +++ b/.superpowers/sdd/2026-09-03-v2522-runtime-v7-closure/runtime-v7-report.md @@ -0,0 +1,82 @@ +# Runtime V7 ABI closure report + +## 结论与范围 + +micro-plan implementation commit 为 `9d7cc90`(`fix: advance install runtime ABI to V7`),基线为 +`ec8dac0`。实现只修改: + +- `src/persistence/modules/InstallFileTransaction.psm1` +- `tests/persistence/InstallFileTransaction.SelfTest.ps1` + +没有修改 exports、durable plan schema、file-layer 行为、ProductRegistration 或 InstallLifecycle +逻辑。 + +## RED + +独立 `powershell.exe` child 先通过自包含 `Add-Type` 预载最小 stale: + +- `CcodInstallGenerationCapabilityMarkerV6`,ABI `6`; +- internal `CcodInstallGenerationRuntimeV6`,不含最终实现方法。 + +然后 child 对当前模块执行 force-import。production 未修改时实际结果: + +```text +InstallFileTransaction.SelfTest.ps1 +exit 1 +case=stale-V6-preload-cannot-capture-the-final-runtime-ABI-across-force-re-imports +child exit=17 +SELECTED_RUNTIME=CcodInstallGenerationRuntimeV6 +``` + +这证明长寿命 AppDomain 中已有 V6 marker 时,原模块会跳过新的 embedded C#,并把 +`$script:CcodRuntimeType` 绑定到 stale V6。 + +## 实现与 GREEN + +production 仅做机械 ABI 迁移: + +- `CcodInstallGenerationCapabilityMarkerV6` -> `...MarkerV7`; +- `CcodInstallGenerationRuntimeV6` -> `...RuntimeV7`; +- `CapabilityAbi` 与 import-time exact assertion 从 `6` -> `7`; +- 所有 constructor、out parameter、runtime allocation 与 reflection binding 同步改为 V7。 + +exact tests 同步为 V7/7。独立 child 保留 stale V6,证明: + +1. V6 marker/runtime 仍在同一 AppDomain; +2. force-import 选择 `CcodInstallGenerationRuntimeV7`; +3. repeated force-import 复用同一个正确 V7 `System.Type`; +4. V7 state transaction 实际写入 create-only record; +5. 既有 V4-first child 也只选择 V7。 + +首轮 GREEN 的 coexistence oracle 曾错误使用 `'RuntimeV6' -as [type]` 检查 internal stale runtime, +因此返回 null。独立诊断证明 global type resolution 为 null、但 +`staleMarker.Assembly.GetType('CcodInstallGenerationRuntimeV6')` 返回该 internal type;修正测试 +oracle 后,没有追加 production 改动。 + +## Frozen gate + +source freeze 后结果: + +```text +InstallFileTransaction.SelfTest.ps1 45 groups exit 0 +ProductRegistration.SelfTest.ps1 30/30 exit 0 +InstallLifecycle.SelfTest.ps1 151/151 exit 0 +UninstallBootstrap.SelfTest.ps1 45/45 exit 0 +RuntimeManifest.SelfTest.ps1 21 groups exit 0 +InstalledLifecycleHarness.SelfTest.ps1 21 groups exit 0 +Bootstrap.SelfTest.ps1 26/26 exit 0 +ManualWrappers.SelfTest.ps1 12 groups exit 0 +PowerShell parser 2/2 changed files, 0 errors +git diff --check exit 0 +tests\PersistenceSelfTest.ps1 exit 0 (silent success) +``` + +Lifecycle 的既有 cross-process guard 仍输出: +`exit=23 mutex=True authority=False product=False verified=False error=CCOD_PRODUCT_REGISTRATION_FAILED`。 + +## 安全边界 + +未执行真实 registry、shortcut、scheduled task、安装、卸载、进程重启、系统重启、网络、push、 +tag、release 或发布操作;所有新 child 行为仅使用独立临时目录并完成清理。 + +fresh scoped V7 review 仍待 parent agent 发起;不声明 Tasks 5–8 或 release readiness 完成。 diff --git a/Activate-CcodRemoteFix.ps1 b/Activate-CcodRemoteFix.ps1 index 9837167..5ca32f0 100644 --- a/Activate-CcodRemoteFix.ps1 +++ b/Activate-CcodRemoteFix.ps1 @@ -1,7 +1,15 @@ [CmdletBinding()] param( - [Parameter(Mandatory)][string]$AppRoot, + [string]$AppRoot, [Parameter(Mandatory)][string]$InstallRoot, + [string]$PayloadRoot, + [string]$PackagePath, + [string]$PackageManifestPath, + [string]$ExpectedPackageSha256, + [string]$ExpectedPackageManifestSha256, + [string]$ExpectedGitCommit, + [string]$ExpectedVersion, + [string]$ExpectedPayloadManifestSha256, [string]$ActivationId, [switch]$ValidateReceiptOnly, [switch]$ValidateReceiptWithTimeout, @@ -51,6 +59,580 @@ function Write-CcodActivationRecord { } catch { } } +function Get-CcodActivationFileSha256 { + param([Parameter(Mandatory)][string]$Path) + $stream=$null;$sha=[Security.Cryptography.SHA256]::Create() + try{$stream=[IO.File]::Open([IO.Path]::GetFullPath($Path),[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read);return [BitConverter]::ToString($sha.ComputeHash($stream)).Replace('-','').ToLowerInvariant()} + finally{if($null-ne$stream){$stream.Dispose()};$sha.Dispose()} +} + +function Get-CcodSetupDefenderDetectionKeys { + param($Records) + $keys = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + foreach ($record in @($Records)) { + if ($null -eq $record) { continue } + $threat = if ($null -ne $record.PSObject.Properties['ThreatID']) { [string]$record.ThreatID } else { '' } + $time = if ($null -ne $record.PSObject.Properties['InitialDetectionTime']) { [string]$record.InitialDetectionTime } else { '' } + $resources = if ($null -ne $record.PSObject.Properties['Resources']) { (@($record.Resources) -join '|') } else { '' } + if (-not [string]::IsNullOrWhiteSpace($threat) -or -not [string]::IsNullOrWhiteSpace($time) -or -not [string]::IsNullOrWhiteSpace($resources)) { [void]$keys.Add("$threat|$time|$resources") } + } + Write-Output -NoEnumerate $keys +} + +function Invoke-CcodSetupPackageDefenderGate { + param([Parameter(Mandatory)][string]$PackagePath) + $started = [datetime]::UtcNow + try { $status = Get-MpComputerStatus -ErrorAction Stop } + catch { throw 'CCOD_SETUP_DEFENDER_STATUS_INVALID' } + if ($null -eq $status -or + $null -eq $status.PSObject.Properties['AMServiceEnabled'] -or $status.AMServiceEnabled -isnot [bool] -or -not $status.AMServiceEnabled -or + $null -eq $status.PSObject.Properties['AntivirusEnabled'] -or $status.AntivirusEnabled -isnot [bool] -or -not $status.AntivirusEnabled -or + $null -eq $status.PSObject.Properties['RealTimeProtectionEnabled'] -or $status.RealTimeProtectionEnabled -isnot [bool] -or -not $status.RealTimeProtectionEnabled -or + $null -eq $status.PSObject.Properties['AMProductVersion'] -or $status.AMProductVersion -isnot [string] -or [string]::IsNullOrWhiteSpace($status.AMProductVersion) -or + $null -eq $status.PSObject.Properties['AMEngineVersion'] -or $status.AMEngineVersion -isnot [string] -or [string]::IsNullOrWhiteSpace($status.AMEngineVersion) -or + $null -eq $status.PSObject.Properties['AntivirusSignatureVersion'] -or $status.AntivirusSignatureVersion -isnot [string] -or [string]::IsNullOrWhiteSpace($status.AntivirusSignatureVersion) -or + $null -eq $status.PSObject.Properties['AntivirusSignatureLastUpdated'] -or $status.AntivirusSignatureLastUpdated -isnot [datetime]) { + throw 'CCOD_SETUP_DEFENDER_STATUS_INVALID' + } + $signatureUpdated = ([datetime]$status.AntivirusSignatureLastUpdated).ToUniversalTime() + if ($signatureUpdated -lt $started.AddHours(-72) -or $signatureUpdated -gt $started.AddMinutes(5)) { throw 'CCOD_SETUP_DEFENDER_STATUS_INVALID' } + try { $before = Get-CcodSetupDefenderDetectionKeys -Records @(Get-MpThreatDetection -ErrorAction Stop) } + catch { throw 'CCOD_SETUP_DEFENDER_STATUS_INVALID' } + try { Start-MpScan -ScanType CustomScan -ScanPath $PackagePath -ErrorAction Stop } + catch { throw 'CCOD_SETUP_DEFENDER_SCAN_FAILED' } + $completed = [datetime]::UtcNow + if ($completed -lt $started -or $completed -gt $started.AddHours(2)) { throw 'CCOD_SETUP_DEFENDER_CLOCK_INVALID' } + try { $after = Get-CcodSetupDefenderDetectionKeys -Records @(Get-MpThreatDetection -ErrorAction Stop) } + catch { throw 'CCOD_SETUP_DEFENDER_STATUS_INVALID' } + $newDetections = @($after | Where-Object { -not $before.Contains($_) }) + if ($newDetections.Count -ne 0) { throw 'CCOD_SETUP_DEFENDER_DETECTIONS_FOUND' } + return [pscustomobject][ordered]@{ + defenderServiceEnabled=$true + antivirusEnabled=$true + realTimeProtectionEnabled=$true + defenderPlatformVersion=[string]$status.AMProductVersion + defenderEngineVersion=[string]$status.AMEngineVersion + signatureVersion=[string]$status.AntivirusSignatureVersion + signatureUpdatedAtUtc=$signatureUpdated.ToString('o',[Globalization.CultureInfo]::InvariantCulture) + scanStartedAtUtc=$started.ToString('o',[Globalization.CultureInfo]::InvariantCulture) + scanCompletedAtUtc=$completed.ToString('o',[Globalization.CultureInfo]::InvariantCulture) + detectionCount=0 + } +} + +function Test-CcodActivationPackageRelativePath { + param($Path) + return $Path-is[string]-and$Path-cmatch'^[A-Za-z0-9][A-Za-z0-9._/-]{0,255}$'-and-not$Path.Contains('//')-and-not$Path.Contains('..')-and-not$Path.Contains(':')-and-not$Path.Contains('\')-and-not$Path.EndsWith('/') +} + +function Open-CcodInstallerPackageSeal { + param([Parameter(Mandatory)][string]$PackagePath,[Parameter(Mandatory)][string]$PackageManifestPath,[Parameter(Mandatory)][string]$ExpectedPackageSha256,[Parameter(Mandatory)][string]$ExpectedPackageManifestSha256,[Parameter(Mandatory)][string]$ExpectedVersion,[Parameter(Mandatory)][string]$ExpectedGitCommit) + if($ExpectedPackageSha256-cnotmatch'^[0-9a-f]{64}$'-or$ExpectedPackageManifestSha256-cnotmatch'^[0-9a-f]{64}$'-or$ExpectedVersion-cnotmatch'^\d+\.\d+\.\d+$'-or$ExpectedGitCommit-cnotmatch'^[0-9a-f]{40}$'){throw 'CCOD_INSTALLER_PACKAGE_INVALID'} + Add-Type -AssemblyName System.IO.Compression -ErrorAction Stop + $package=[IO.Path]::GetFullPath($PackagePath);$manifestPath=[IO.Path]::GetFullPath($PackageManifestPath);$packageStream=$null;$manifestStream=$null;$archive=$null + try{ + foreach($path in @($package,$manifestPath)){$item=Get-Item -LiteralPath $path -Force -ErrorAction Stop;if($item.PSIsContainer-or($item.Attributes-band[IO.FileAttributes]::ReparsePoint)-ne 0-or$item.Length-le 0-or$item.Length-gt 536870912){throw 'CCOD_INSTALLER_PACKAGE_INVALID'}} + $packageStream=[IO.File]::Open($package,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read);$sha=[Security.Cryptography.SHA256]::Create();try{$actualPackage=[BitConverter]::ToString($sha.ComputeHash($packageStream)).Replace('-','').ToLowerInvariant()}finally{$sha.Dispose()};$packageStream.Position=0;if($actualPackage-cne$ExpectedPackageSha256){throw 'CCOD_INSTALLER_PACKAGE_HASH_MISMATCH'} + $manifestStream=[IO.File]::Open($manifestPath,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read);$sha=[Security.Cryptography.SHA256]::Create();try{$actualManifest=[BitConverter]::ToString($sha.ComputeHash($manifestStream)).Replace('-','').ToLowerInvariant()}finally{$sha.Dispose()};$manifestStream.Position=0;if($actualManifest-cne$ExpectedPackageManifestSha256){throw 'CCOD_INSTALLER_PACKAGE_MANIFEST_HASH_MISMATCH'} + $reader=[IO.StreamReader]::new($manifestStream,[Text.UTF8Encoding]::new($false,$true),$true,4096,$true);try{$text=$reader.ReadToEnd()}finally{$reader.Dispose()};$manifestStream.Position=0;try{$manifest=$text|ConvertFrom-Json -ErrorAction Stop}catch{throw 'CCOD_INSTALLER_PACKAGE_INVALID'} + if($manifest-isnot[pscustomobject]-or(@($manifest.PSObject.Properties.Name)-join',')-cne'schemaVersion,product,version,gitCommit,payloadManifest,files'-or$manifest.schemaVersion-isnot[int]-or$manifest.schemaVersion-ne 1-or$manifest.product-cne'CodexRemote-fix'-or$manifest.version-cne$ExpectedVersion-or$manifest.gitCommit-cne$ExpectedGitCommit-or$manifest.payloadManifest.name-cne'installer-payload.manifest.json'-or$manifest.payloadManifest.sha256-cnotmatch'^[0-9a-f]{64}$'){throw 'CCOD_INSTALLER_PACKAGE_INVALID'} + $records=@($manifest.files);if($records.Count-eq 0){throw 'CCOD_INSTALLER_PACKAGE_INVALID'};$expected=[Collections.Generic.Dictionary[string,object]]::new([StringComparer]::OrdinalIgnoreCase);$previous=$null + foreach($record in $records){if($record-isnot[pscustomobject]-or(@($record.PSObject.Properties.Name)-join',')-cne'path,length,sha256'-or-not(Test-CcodActivationPackageRelativePath $record.path)-or$record.length-isnot[ValueType]-or[decimal]$record.length-ne[decimal][int64]$record.length-or[int64]$record.length-lt 0-or$record.sha256-cnotmatch'^[0-9a-f]{64}$'-or($null-ne$previous-and[StringComparer]::Ordinal.Compare($previous,[string]$record.path)-ge 0)-or$expected.ContainsKey([string]$record.path)){throw 'CCOD_INSTALLER_PACKAGE_INVALID'};$expected.Add([string]$record.path,$record);$previous=[string]$record.path} + $archive=[IO.Compression.ZipArchive]::new($packageStream,[IO.Compression.ZipArchiveMode]::Read,$true);$entries=[Collections.Generic.Dictionary[string,object]]::new([StringComparer]::OrdinalIgnoreCase) + foreach($entry in @($archive.Entries)){if(-not(Test-CcodActivationPackageRelativePath $entry.FullName)-or$entries.ContainsKey($entry.FullName)-or$entry.Name.Length-eq 0){throw 'CCOD_INSTALLER_PACKAGE_INVALID'};$entries.Add($entry.FullName,$entry)} + if($entries.Count-ne$expected.Count+1-or-not$entries.ContainsKey('installer-payload.manifest.json')){throw 'CCOD_INSTALLER_PACKAGE_INVALID'} + foreach($pair in $expected.GetEnumerator()){$entry=$entries[$pair.Key];$record=$pair.Value;if([int64]$entry.Length-ne[int64]$record.length){throw 'CCOD_INSTALLER_PACKAGE_INVALID'};$stream=$entry.Open();$sha=[Security.Cryptography.SHA256]::Create();try{$hash=[BitConverter]::ToString($sha.ComputeHash($stream)).Replace('-','').ToLowerInvariant()}finally{$sha.Dispose();$stream.Dispose()};if($hash-cne[string]$record.sha256){throw 'CCOD_INSTALLER_PACKAGE_INVALID'}} + $payloadEntry=$entries['installer-payload.manifest.json'];$stream=$payloadEntry.Open();$sha=[Security.Cryptography.SHA256]::Create();try{$payloadHash=[BitConverter]::ToString($sha.ComputeHash($stream)).Replace('-','').ToLowerInvariant()}finally{$sha.Dispose();$stream.Dispose()};if([int64]$payloadEntry.Length-ne[int64]$manifest.payloadManifest.length-or$payloadHash-cne[string]$manifest.payloadManifest.sha256){throw 'CCOD_INSTALLER_PACKAGE_INVALID'} + return [pscustomobject]@{PackagePath=$package;PackageStream=$packageStream;ManifestStream=$manifestStream;Archive=$archive;Manifest=$manifest;Entries=$entries;PayloadManifestSha256=$payloadHash} + }catch{if($null-ne$archive){$archive.Dispose()};if($null-ne$manifestStream){$manifestStream.Dispose()};if($null-ne$packageStream){$packageStream.Dispose()};throw} +} + +function Expand-CcodInstallerPackageSeal { + param([Parameter(Mandatory)]$Seal,[Parameter(Mandatory)][string]$DestinationRoot) + $root=[IO.Path]::GetFullPath($DestinationRoot);if([IO.File]::Exists($root)-or[IO.Directory]::Exists($root)){throw 'CCOD_INSTALLER_PACKAGE_INVALID'};[IO.Directory]::CreateDirectory($root)|Out-Null + try{foreach($entry in @($Seal.Archive.Entries)){$target=[IO.Path]::GetFullPath((Join-Path $root $entry.FullName.Replace('/','\')));if(-not$target.StartsWith($root.TrimEnd('\')+'\',[StringComparison]::OrdinalIgnoreCase)){throw 'CCOD_INSTALLER_PACKAGE_INVALID'};$parent=Split-Path $target -Parent;if(-not[IO.Directory]::Exists($parent)){[IO.Directory]::CreateDirectory($parent)|Out-Null};$source=$entry.Open();$destination=[IO.File]::Open($target,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None);try{$source.CopyTo($destination);$destination.Flush($true)}finally{$destination.Dispose();$source.Dispose()}};return $root}catch{if([IO.Directory]::Exists($root)){Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue};throw} +} + +function Close-CcodInstallerPackageSeal { param($Seal) if($null-eq$Seal){return};foreach($name in @('Archive','ManifestStream','PackageStream')){try{if($null-ne$Seal.$name){$Seal.$name.Dispose()}}catch{}} } + +function Get-CcodActivationBytesSha256 { + param([Parameter(Mandatory)][byte[]]$Bytes) + $sha = [Security.Cryptography.SHA256]::Create() + try { + return [BitConverter]::ToString($sha.ComputeHash($Bytes)).Replace('-','').ToLowerInvariant() + } finally { + $sha.Dispose() + } +} + +function Open-CcodActivationLockedBytes { + param( + [Parameter(Mandatory)][string]$Path, + [ValidateRange(1,268435456)][int64]$MaximumBytes = 268435456 + ) + + $full = [IO.Path]::GetFullPath($Path) + $stream = [IO.File]::Open($full,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read) + try { + if ($stream.Length -lt 0 -or $stream.Length -gt $MaximumBytes) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' } + $memory = [IO.MemoryStream]::new() + try { + $stream.CopyTo($memory) + $bytes = $memory.ToArray() + } finally { $memory.Dispose() } + return [pscustomobject]@{ Path=$full; Stream=$stream; Bytes=$bytes } + } catch { + $stream.Dispose() + throw + } +} + +function Initialize-CcodActivationStageNative { + if ($null -ne ('CcodActivationStageNative' -as [type])) { return } + Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +public static class CcodActivationStageNative +{ + private const uint FILE_READ_DATA = 0x00000001; + private const uint FILE_LIST_DIRECTORY = 0x00000001; + private const uint FILE_READ_ATTRIBUTES = 0x00000080; + private const uint GENERIC_READ = 0x80000000; + private const uint GENERIC_WRITE = 0x40000000; + private const uint SYNCHRONIZE = 0x00100000; + private const uint FILE_SHARE_READ = 0x00000001; + private const uint FILE_SHARE_WRITE = 0x00000002; + private const uint FILE_OPEN = 0x00000001; + private const uint FILE_CREATE = 0x00000002; + private const uint FILE_DIRECTORY_FILE = 0x00000001; + private const uint FILE_NON_DIRECTORY_FILE = 0x00000040; + private const uint FILE_SYNCHRONOUS_IO_NONALERT = 0x00000020; + private const uint FILE_OPEN_REPARSE_POINT = 0x00200000; + private const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000; + private const uint FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000; + private const uint OPEN_EXISTING = 3; + private const uint OBJ_CASE_INSENSITIVE = 0x00000040; + private const uint FILE_ATTRIBUTE_DIRECTORY = 0x00000010; + private const uint FILE_ATTRIBUTE_REPARSE_POINT = 0x00000400; + + [StructLayout(LayoutKind.Sequential)] + private struct UNICODE_STRING + { + public ushort Length; + public ushort MaximumLength; + public IntPtr Buffer; + } + + [StructLayout(LayoutKind.Sequential)] + private struct OBJECT_ATTRIBUTES + { + public int Length; + public IntPtr RootDirectory; + public IntPtr ObjectName; + public uint Attributes; + public IntPtr SecurityDescriptor; + public IntPtr SecurityQualityOfService; + } + + [StructLayout(LayoutKind.Sequential)] + private struct IO_STATUS_BLOCK + { + public IntPtr Status; + public UIntPtr Information; + } + + [StructLayout(LayoutKind.Sequential)] + private struct FILETIME + { + public uint Low; + public uint High; + } + + [StructLayout(LayoutKind.Sequential)] + private struct BY_HANDLE_FILE_INFORMATION + { + public uint FileAttributes; + public FILETIME CreationTime; + public FILETIME LastAccessTime; + public FILETIME LastWriteTime; + public uint VolumeSerialNumber; + public uint FileSizeHigh; + public uint FileSizeLow; + public uint NumberOfLinks; + public uint FileIndexHigh; + public uint FileIndexLow; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFileW(string path, uint access, uint share, IntPtr security, + uint creation, uint flags, IntPtr template); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetFileInformationByHandle(SafeFileHandle handle, out BY_HANDLE_FILE_INFORMATION information); + + [DllImport("ntdll.dll")] + private static extern int NtCreateFile(out IntPtr fileHandle, uint desiredAccess, + ref OBJECT_ATTRIBUTES objectAttributes, out IO_STATUS_BLOCK ioStatusBlock, IntPtr allocationSize, + uint fileAttributes, uint shareAccess, uint createDisposition, uint createOptions, + IntPtr eaBuffer, uint eaLength); + + private static void ValidateLeaf(string name) + { + if (String.IsNullOrWhiteSpace(name) || name == "." || name == ".." || name.IndexOf('\\') >= 0 || name.IndexOf('/') >= 0) + throw new ArgumentException("Relative stage leaf is invalid", "name"); + } + + private static SafeFileHandle OpenRelative(SafeFileHandle parent, string name, uint access, uint share, + uint disposition, uint options) + { + ValidateLeaf(name); + if (parent == null || parent.IsInvalid || parent.IsClosed) throw new ArgumentException("Parent directory pin is invalid", "parent"); + IntPtr nameBuffer = IntPtr.Zero; + IntPtr unicodePointer = IntPtr.Zero; + try + { + nameBuffer = Marshal.StringToHGlobalUni(name); + UNICODE_STRING unicode = new UNICODE_STRING(); + unicode.Length = checked((ushort)(name.Length * 2)); + unicode.MaximumLength = checked((ushort)((name.Length + 1) * 2)); + unicode.Buffer = nameBuffer; + unicodePointer = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(UNICODE_STRING))); + Marshal.StructureToPtr(unicode, unicodePointer, false); + OBJECT_ATTRIBUTES attributes = new OBJECT_ATTRIBUTES(); + attributes.Length = Marshal.SizeOf(typeof(OBJECT_ATTRIBUTES)); + attributes.RootDirectory = parent.DangerousGetHandle(); + attributes.ObjectName = unicodePointer; + attributes.Attributes = OBJ_CASE_INSENSITIVE; + IO_STATUS_BLOCK statusBlock; + IntPtr rawHandle; + int status = NtCreateFile(out rawHandle, access, ref attributes, out statusBlock, IntPtr.Zero, + 0, share, disposition, options | FILE_SYNCHRONOUS_IO_NONALERT | FILE_OPEN_REPARSE_POINT, + IntPtr.Zero, 0); + if (status < 0 || rawHandle == IntPtr.Zero || rawHandle == new IntPtr(-1)) + throw new InvalidOperationException("NtCreateFile failed: 0x" + unchecked((uint)status).ToString("x8")); + return new SafeFileHandle(rawHandle, true); + } + finally + { + if (unicodePointer != IntPtr.Zero) Marshal.FreeHGlobal(unicodePointer); + if (nameBuffer != IntPtr.Zero) Marshal.FreeHGlobal(nameBuffer); + } + } + + public static SafeFileHandle OpenExistingDirectory(string path) + { + SafeFileHandle handle = CreateFileW(path, FILE_READ_ATTRIBUTES | SYNCHRONIZE, + FILE_SHARE_READ | FILE_SHARE_WRITE, IntPtr.Zero, OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, IntPtr.Zero); + if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error()); + BY_HANDLE_FILE_INFORMATION information; + if (!GetFileInformationByHandle(handle, out information) || + (information.FileAttributes & FILE_ATTRIBUTE_DIRECTORY) == 0 || + (information.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) + { + int error = Marshal.GetLastWin32Error(); + handle.Dispose(); + if (error != 0) throw new Win32Exception(error); + throw new InvalidOperationException("Existing stage parent is not a plain directory"); + } + return handle; + } + + public static SafeFileHandle CreateDirectoryRelative(SafeFileHandle parent, string name) + { + return OpenRelative(parent, name, FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES | SYNCHRONIZE, + FILE_SHARE_READ | FILE_SHARE_WRITE, FILE_CREATE, FILE_DIRECTORY_FILE); + } + + public static SafeFileHandle CreateLeafRelative(SafeFileHandle parent, string name) + { + return OpenRelative(parent, name, GENERIC_WRITE | SYNCHRONIZE, + FILE_SHARE_READ, FILE_CREATE, FILE_NON_DIRECTORY_FILE); + } + + public static SafeFileHandle OpenLeafIdentityRelative(SafeFileHandle parent, string name) + { + return OpenRelative(parent, name, FILE_READ_ATTRIBUTES | SYNCHRONIZE, + FILE_SHARE_READ | FILE_SHARE_WRITE, FILE_OPEN, FILE_NON_DIRECTORY_FILE); + } + + public static SafeFileHandle OpenLeafReadRelative(SafeFileHandle parent, string name) + { + return OpenRelative(parent, name, GENERIC_READ | SYNCHRONIZE, + FILE_SHARE_READ, FILE_OPEN, FILE_NON_DIRECTORY_FILE); + } +} +'@ +} + +function Get-CcodActivationPinnedDirectory { + param( + [Parameter(Mandatory)][Collections.Generic.Dictionary[string,object]]$Directories, + [Parameter(Mandatory)][Collections.Generic.List[object]]$DirectoryPins, + [Parameter(Mandatory)][AllowEmptyString()][string]$RelativeDirectory + ) + $normalized = $RelativeDirectory.Replace('/','\').Trim('\') + if ([string]::IsNullOrEmpty($normalized)) { return $Directories[''] } + $current = '' + $parent = $Directories[''] + foreach ($segment in ($normalized -split '\\')) { + if ($segment -notmatch '^[A-Za-z0-9._-]+$' -or $segment -in @('.','..')) { throw 'CCOD_INSTALL_PAYLOAD_PATH_INVALID' } + $current = if ([string]::IsNullOrEmpty($current)) { $segment } else { "$current\$segment" } + if ($Directories.ContainsKey($current)) { $parent = $Directories[$current]; continue } + $created = [CcodActivationStageNative]::CreateDirectoryRelative($parent,[string]$segment) + $DirectoryPins.Add($created) + $Directories.Add($current,$created) + $parent = $created + } + return $parent +} + +function Open-CcodActivationPinnedLeafBytes { + param( + [Parameter(Mandatory)]$ParentDirectory, + [Parameter(Mandatory)][string]$Leaf, + [Parameter(Mandatory)][byte[]]$Bytes + ) + $writerHandle = $null + $identityHandle = $null + $writer = $null + try { + $writerHandle = [CcodActivationStageNative]::CreateLeafRelative($ParentDirectory,$Leaf) + $writer = [IO.FileStream]::new($writerHandle,[IO.FileAccess]::Write,65536,$false) + $writerHandle = $null + $writer.Write($Bytes,0,$Bytes.Length) + $writer.Flush($true) + $identityHandle = [CcodActivationStageNative]::OpenLeafIdentityRelative($ParentDirectory,$Leaf) + $writer.Dispose(); $writer = $null + $readHandle = [CcodActivationStageNative]::OpenLeafReadRelative($ParentDirectory,$Leaf) + $stream = [IO.FileStream]::new($readHandle,[IO.FileAccess]::Read,65536,$false) + $memory = [IO.MemoryStream]::new() + try { $stream.CopyTo($memory); $readBytes = $memory.ToArray() } + finally { $memory.Dispose() } + $identityHandle.Dispose(); $identityHandle = $null + return [pscustomobject]@{ Stream=$stream; Bytes=$readBytes } + } catch { + if ($null -ne $writer) { $writer.Dispose() } + elseif ($null -ne $writerHandle) { $writerHandle.Dispose() } + if ($null -ne $identityHandle) { $identityHandle.Dispose() } + throw + } +} + +function Assert-CcodActivationNonReparsePath { + param([Parameter(Mandatory)][string]$Root,[Parameter(Mandatory)][string]$Path) + $canonicalRoot = [IO.Path]::GetFullPath($Root).TrimEnd('\') + $canonicalPath = [IO.Path]::GetFullPath($Path) + $prefix = $canonicalRoot + '\' + if (-not ($canonicalPath.Equals($canonicalRoot,[StringComparison]::OrdinalIgnoreCase) -or $canonicalPath.StartsWith($prefix,[StringComparison]::OrdinalIgnoreCase))) { + throw 'CCOD_INSTALL_PAYLOAD_PATH_INVALID' + } + $cursor = $canonicalPath + while ($true) { + $item = Get-Item -LiteralPath $cursor -Force -ErrorAction Stop + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'CCOD_INSTALL_SOURCE_REPARSE' } + if ($cursor.Equals($canonicalRoot,[StringComparison]::OrdinalIgnoreCase)) { break } + $parent = Split-Path $cursor -Parent + if ([string]::IsNullOrWhiteSpace($parent) -or $parent -ceq $cursor) { throw 'CCOD_INSTALL_PAYLOAD_PATH_INVALID' } + $cursor = $parent + } + return $canonicalPath +} + +function Test-CcodActivationAlternateDataStreams { + param([Parameter(Mandatory)][string]$Path) + try { + foreach ($stream in @(Get-Item -LiteralPath $Path -Stream * -ErrorAction Stop)) { + if ([string]$stream.Stream -cnotin @(':$DATA','::$DATA','$DATA')) { return $true } + } + return $false + } catch { + throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' + } +} + +function Remove-CcodActivationPayloadStage { + param([Parameter(Mandatory)][string]$AppRoot,[Parameter(Mandatory)][string]$StageRoot) + if (-not [IO.Directory]::Exists($StageRoot)) { return } + $app = [IO.Path]::GetFullPath($AppRoot).TrimEnd('\') + $stage = [IO.Path]::GetFullPath($StageRoot).TrimEnd('\') + $prefix = $app + '\' + if (-not $stage.StartsWith($prefix,[StringComparison]::OrdinalIgnoreCase) -or + [IO.Path]::GetFileName($stage) -cnotmatch '^\.activation-payload-stage-[0-9a-f]{32}$') { return } + foreach ($item in @(Get-ChildItem -LiteralPath $stage -Force -Recurse -ErrorAction SilentlyContinue)) { + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { return } + } + $stageItem = Get-Item -LiteralPath $stage -Force -ErrorAction SilentlyContinue + if ($null -eq $stageItem -or ($stageItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { return } + Remove-Item -LiteralPath $stage -Recurse -Force -ErrorAction SilentlyContinue +} + +function Close-CcodActivationPayloadSeal { + param($Seal) + if ($null -eq $Seal) { return } + foreach ($stream in @($Seal.Streams)) { + if ($null -ne $stream) { try { $stream.Dispose() } catch { } } + } + $directoryPins = @($Seal.DirectoryPins) + for ($index=$directoryPins.Count-1;$index-ge0;$index--) { + if ($null -ne $directoryPins[$index]) { try { $directoryPins[$index].Dispose() } catch { } } + } + Remove-CcodActivationPayloadStage -AppRoot $Seal.AppRoot -StageRoot $Seal.StageRoot +} + +function New-CcodActivationPayloadSeal { + param( + [Parameter(Mandatory)][string]$AppRoot, + [Parameter(Mandatory)][string]$Root, + [Parameter(Mandatory)][string]$Version, + [Parameter(Mandatory)][string]$ExpectedManifestSha256 + ) + + if ($Version -cnotmatch '^\d+\.\d+\.\d+$' -or $ExpectedManifestSha256 -cnotmatch '^[0-9a-f]{64}$') { throw 'CCOD_INSTALL_PAYLOAD_VERSION_MISMATCH' } + $app = [IO.Path]::GetFullPath($AppRoot).TrimEnd('\') + $payload = [IO.Path]::GetFullPath($Root).TrimEnd('\') + $expectedPayload = [IO.Path]::GetFullPath((Join-Path (Join-Path $app 'payload') $Version)).TrimEnd('\') + if (-not $payload.Equals($expectedPayload,[StringComparison]::OrdinalIgnoreCase)) { throw 'CCOD_INSTALL_PAYLOAD_PATH_INVALID' } + [void](Assert-CcodActivationNonReparsePath -Root $app -Path $app) + [void](Assert-CcodActivationNonReparsePath -Root $app -Path (Join-Path $app 'payload')) + [void](Assert-CcodActivationNonReparsePath -Root $app -Path $payload) + $packagePath = Join-Path $payload 'package.json' + $manifestPath = Join-Path $payload 'installer-payload.manifest.json' + $modulePath = Join-Path $payload 'src\persistence\modules\InstallLifecycle.psm1' + if (-not [IO.File]::Exists($packagePath) -or -not [IO.File]::Exists($manifestPath) -or -not [IO.File]::Exists($modulePath)) { + throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' + } + [void](Assert-CcodActivationNonReparsePath -Root $payload -Path $manifestPath) + $sourceLocks = [Collections.Generic.List[object]]::new() + $stageLocks = [Collections.Generic.List[object]]::new() + $stageDirectoryPins = [Collections.Generic.List[object]]::new() + $stageRoot = Join-Path $app ('.activation-payload-stage-' + [guid]::NewGuid().ToString('N')) + try { + $manifestLock = Open-CcodActivationLockedBytes -Path $manifestPath -MaximumBytes 4194304 + $sourceLocks.Add($manifestLock) + $manifestItem = Get-Item -LiteralPath $manifestPath -Force -ErrorAction Stop + if ($manifestItem.PSIsContainer -or $manifestLock.Bytes.Length -le 0 -or + (Test-CcodActivationAlternateDataStreams -Path $manifestPath) -or + (Get-CcodActivationBytesSha256 -Bytes $manifestLock.Bytes) -cne $ExpectedManifestSha256) { + throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' + } + try { + $manifestText = [Text.UTF8Encoding]::new($false,$true).GetString($manifestLock.Bytes) + $manifest = $manifestText | ConvertFrom-Json -ErrorAction Stop + } catch { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' } + $properties = @($manifest.PSObject.Properties.Name) + $expectedProperties = @('schemaVersion','projectVersion','files') + if ($manifest -isnot [pscustomobject] -or $properties.Count -ne $expectedProperties.Count) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' } + for ($index=0;$index-lt$expectedProperties.Count;$index++) { + if ($properties[$index] -cne $expectedProperties[$index]) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' } + } + if ($manifest.schemaVersion -isnot [int] -or $manifest.schemaVersion -ne 1 -or $manifest.projectVersion -isnot [string] -or $manifest.projectVersion -cne $Version) { + throw 'CCOD_INSTALL_PAYLOAD_VERSION_MISMATCH' + } + $records = @($manifest.files) + if ($records.Count -eq 0) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' } + $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + $snapshots = [Collections.Generic.List[object]]::new() + $previous = $null + foreach ($record in $records) { + $recordProperties = @($record.PSObject.Properties.Name) + if ($record -isnot [pscustomobject] -or $recordProperties.Count -ne 3 -or $recordProperties[0] -cne 'path' -or $recordProperties[1] -cne 'length' -or $recordProperties[2] -cne 'sha256' -or + $record.path -isnot [string] -or $record.path -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._/-]{0,255}$' -or $record.path.Contains('//') -or $record.path.Contains('..') -or $record.path.Contains(':') -or $record.path.Contains('\') -or + $record.length -isnot [ValueType] -or [decimal]$record.length -ne [decimal][int64]$record.length -or [int64]$record.length -lt 0 -or + $record.sha256 -isnot [string] -or $record.sha256 -cnotmatch '^[0-9a-f]{64}$') { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' } + if (($null-ne$previous -and [StringComparer]::Ordinal.Compare([string]$previous,[string]$record.path)-ge 0) -or -not $seen.Add([string]$record.path)) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' } + $previous = [string]$record.path + $source = [IO.Path]::GetFullPath((Join-Path $payload ($record.path.Replace('/','\')))) + [void](Assert-CcodActivationNonReparsePath -Root $payload -Path $source) + $locked = Open-CcodActivationLockedBytes -Path $source + $sourceLocks.Add($locked) + $item = Get-Item -LiteralPath $source -Force -ErrorAction Stop + if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + (Test-CcodActivationAlternateDataStreams -Path $source) -or [int64]$locked.Bytes.Length -ne [int64]$record.length -or + (Get-CcodActivationBytesSha256 -Bytes $locked.Bytes) -cne [string]$record.sha256) { throw 'CCOD_INSTALL_FILE_HASH_MISMATCH' } + $snapshots.Add([pscustomobject]@{ Relative=[string]$record.path; Bytes=$locked.Bytes; Sha256=[string]$record.sha256 }) + } + foreach ($required in @('package.json','Install-CodexControlOtherDevices.ps1','src/persistence/modules/InstallLifecycle.psm1','src/persistence/modules/RuntimeManifest.psm1')) { + if (-not $seen.Contains($required)) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' } + } + $actualFileList = [Collections.Generic.List[string]]::new() + foreach ($actualItem in @(Get-ChildItem -LiteralPath $payload -Force -Recurse -ErrorAction Stop)) { + if (($actualItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'CCOD_INSTALL_SOURCE_REPARSE' } + if ($actualItem.PSIsContainer -or $actualItem.FullName.Equals($manifestPath,[StringComparison]::OrdinalIgnoreCase)) { continue } + $actualFileList.Add($actualItem.FullName.Substring($payload.Length + 1).Replace('\','/')) + } + $actualFiles = @($actualFileList) + if ($actualFiles.Count -ne $records.Count) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' } + foreach ($actual in $actualFiles) { if (-not $seen.Contains([string]$actual)) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' } } + $packageSnapshot = @($snapshots | Where-Object { $_.Relative -ceq 'package.json' }) + try { $package = [Text.UTF8Encoding]::new($false,$true).GetString([byte[]]$packageSnapshot[0].Bytes) | ConvertFrom-Json -ErrorAction Stop } + catch { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' } + if ($packageSnapshot.Count -ne 1 -or $package.version -isnot [string] -or [string]$package.version -cne $Version) { throw 'CCOD_INSTALL_PAYLOAD_VERSION_MISMATCH' } + + Initialize-CcodActivationStageNative + $appPin = [CcodActivationStageNative]::OpenExistingDirectory($app) + $stageDirectoryPins.Add($appPin) + $stageName = [IO.Path]::GetFileName($stageRoot) + $stagePin = [CcodActivationStageNative]::CreateDirectoryRelative($appPin,$stageName) + $stageDirectoryPins.Add($stagePin) + $directories = [Collections.Generic.Dictionary[string,object]]::new([StringComparer]::Ordinal) + $directories.Add('',$stagePin) + [void](Assert-CcodActivationNonReparsePath -Root $app -Path $stageRoot) + foreach ($snapshot in @($snapshots) + @([pscustomobject]@{Relative='installer-payload.manifest.json';Bytes=$manifestLock.Bytes;Sha256=$ExpectedManifestSha256})) { + $destination = [IO.Path]::GetFullPath((Join-Path $stageRoot ([string]$snapshot.Relative).Replace('/','\'))) + $stagePrefix = $stageRoot.TrimEnd('\') + '\' + if (-not $destination.StartsWith($stagePrefix,[StringComparison]::OrdinalIgnoreCase)) { throw 'CCOD_INSTALL_PAYLOAD_PATH_INVALID' } + $relativeDirectory = [IO.Path]::GetDirectoryName(([string]$snapshot.Relative).Replace('/','\')) + $parentPin = Get-CcodActivationPinnedDirectory -Directories $directories -DirectoryPins $stageDirectoryPins -RelativeDirectory $relativeDirectory + $stageLock = Open-CcodActivationPinnedLeafBytes -ParentDirectory $parentPin -Leaf ([IO.Path]::GetFileName($destination)) -Bytes ([byte[]]$snapshot.Bytes) + [void](Assert-CcodActivationNonReparsePath -Root $stageRoot -Path $destination) + $stageLocks.Add($stageLock) + if ((Get-CcodActivationBytesSha256 -Bytes $stageLock.Bytes) -cne [string]$snapshot.Sha256) { throw 'CCOD_INSTALL_FILE_HASH_MISMATCH' } + } + return [pscustomobject]@{ + AppRoot=$app + StageRoot=[IO.Path]::GetFullPath($stageRoot) + ManifestPath=Join-Path $stageRoot 'installer-payload.manifest.json' + ManifestBytes=$manifestLock.Bytes + Streams=@($stageLocks | ForEach-Object { $_.Stream }) + DirectoryPins=@($stageDirectoryPins) + } + } catch { + foreach ($locked in @($stageLocks)) { try { $locked.Stream.Dispose() } catch { } } + for ($pinIndex=$stageDirectoryPins.Count-1;$pinIndex-ge0;$pinIndex--) { try { $stageDirectoryPins[$pinIndex].Dispose() } catch { } } + Remove-CcodActivationPayloadStage -AppRoot $app -StageRoot $stageRoot + throw + } finally { + foreach ($locked in @($sourceLocks)) { try { $locked.Stream.Dispose() } catch { } } + } +} + +function Assert-CcodActivatedRuntimeVersion { + param( + [Parameter(Mandatory)][string]$Root, + [Parameter(Mandatory)][string]$RuntimeId, + [Parameter(Mandatory)][string]$Version, + [Parameter(Mandatory)][string]$RuntimeModulePath + ) + + $module = Import-Module $RuntimeModulePath -Force -PassThru + try { + $pointer = Read-CcodActiveRuntime -InstallRoot $Root + if ($pointer.activeRuntime -cne $RuntimeId) { throw 'CCOD_ACTIVATION_RUNTIME_VERSION_MISMATCH' } + $runtimeRoot = Join-Path (Join-Path $Root 'runtime') $RuntimeId + $validation = Test-CcodRuntimeManifest -RuntimeDirectory $runtimeRoot -ExpectedRuntimeId $RuntimeId + if (-not $validation.Valid -or [string]$validation.Manifest.projectVersion -cne $Version) { + throw 'CCOD_ACTIVATION_RUNTIME_VERSION_MISMATCH' + } + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + } +} + function Assert-CcodActivationReceiptPathSafe { param([Parameter(Mandatory)][string]$Root,[Parameter(Mandatory)][string]$Path) $canonicalRoot = [IO.Path]::GetFullPath($Root).TrimEnd([IO.Path]::DirectorySeparatorChar) @@ -76,8 +658,21 @@ function Assert-CcodActivationReceiptPathSafe { function Read-CcodTerminalActivationReceipt { param([Parameter(Mandatory)][string]$Root,[Parameter(Mandatory)][string]$ExpectedActivationId) - $path = Join-Path $Root 'state\post-install-activation.json' - if (-not [IO.File]::Exists($path)) { throw 'CCOD_ACTIVATION_RECEIPT_MISSING' } + $directory = Join-Path $Root 'state\activation-receipts' + if (-not [IO.Directory]::Exists($directory)) { throw 'CCOD_ACTIVATION_RECEIPT_MISSING' } + try { Assert-CcodActivationReceiptPathSafe -Root $Root -Path $directory } catch { throw 'CCOD_ACTIVATION_RECEIPT_INVALID' } + $terminal = @( + foreach ($phase in @('Ready','Failed')) { + $candidate = Join-Path $directory ("$ExpectedActivationId.$phase.json") + if ([IO.File]::Exists($candidate)) { $candidate } + } + ) + if ($terminal.Count -eq 0) { + if (Test-CcodActivationReceiptObserved -Root $Root -ExpectedActivationId $ExpectedActivationId) { throw 'CCOD_ACTIVATION_RECEIPT_NOT_READY' } + throw 'CCOD_ACTIVATION_RECEIPT_MISSING' + } + if ($terminal.Count -ne 1) { throw 'CCOD_ACTIVATION_RECEIPT_INVALID' } + $path = $terminal[0] try { Assert-CcodActivationReceiptPathSafe -Root $Root -Path $path $item = Get-Item -LiteralPath $path -Force -ErrorAction Stop @@ -109,6 +704,7 @@ function Read-CcodTerminalActivationReceipt { ($receipt.phase -cnotin @('Ready','Failed') -and ($receipt.ready -or $null -ne $receipt.errorCode))) { throw 'CCOD_ACTIVATION_RECEIPT_INVALID' } + if ([IO.Path]::GetFileName($path) -cne ("$ExpectedActivationId.$($receipt.phase).json")) { throw 'CCOD_ACTIVATION_RECEIPT_INVALID' } return $receipt } @@ -158,7 +754,9 @@ function Invoke-CcodOwnedInstallWorker { param( [Parameter(Mandatory)][string]$PowerShellPath, [Parameter(Mandatory)][string]$InstallScript, + [Parameter(Mandatory)][string]$VerifiedPayloadRoot, [Parameter(Mandatory)][string]$StateRoot, + [string]$SealedPackageSha256, [Parameter(Mandatory)][int]$FirstReceiptTimeout, [Parameter(Mandatory)][int]$ActivationTimeout ) @@ -179,6 +777,16 @@ function Invoke-CcodOwnedInstallWorker { '-ActivationId', $ActivationId ) + if (-not [string]::IsNullOrWhiteSpace($ExpectedVersion)) { + $arguments += @( + '-ExpectedVersion',$ExpectedVersion, + '-PayloadManifestPath',(Join-Path $VerifiedPayloadRoot 'installer-payload.manifest.json'), + '-ExpectedPayloadManifestSha256',$ExpectedPayloadManifestSha256 + ) + } + if (-not [string]::IsNullOrWhiteSpace($SealedPackageSha256)) { + $arguments += @('-SealedPackageSha256',$SealedPackageSha256) + } $startInfo = [Diagnostics.ProcessStartInfo]::new() $startInfo.FileName = $PowerShellPath $startInfo.Arguments = (($arguments | ForEach-Object { ConvertTo-CcodNativeProcessArgument ([string]$_) }) -join ' ') @@ -191,8 +799,7 @@ function Invoke-CcodOwnedInstallWorker { while (-not $process.HasExited) { if (-not $firstReceiptObserved) { try { - $null = Read-CcodTerminalActivationReceipt -Root $StateRoot -ExpectedActivationId $ActivationId - $firstReceiptObserved = $true + $firstReceiptObserved = Test-CcodActivationReceiptObserved -Root $StateRoot -ExpectedActivationId $ActivationId } catch { } } if (-not $firstReceiptObserved -and $clock.ElapsedMilliseconds -ge $FirstReceiptTimeout) { @@ -235,12 +842,15 @@ function Invoke-CcodBoundedReceiptValidator { $PSCommandPath, '-AppRoot', $AppRoot, + '-PayloadRoot', + $PayloadRoot, '-InstallRoot', $InstallRoot, '-ValidateReceiptOnly', '-ActivationId', $ActivationId ) + if (-not [string]::IsNullOrWhiteSpace($ExpectedVersion)) { $arguments += @('-ExpectedVersion',$ExpectedVersion,'-ExpectedPayloadManifestSha256',$ExpectedPayloadManifestSha256) } $startInfo = [Diagnostics.ProcessStartInfo]::new() $startInfo.FileName = $hostExecutable $startInfo.Arguments = (($arguments | ForEach-Object { ConvertTo-CcodNativeProcessArgument ([string]$_) }) -join ' ') @@ -286,6 +896,59 @@ function Show-CcodActivationFailure { } if ($ValidateReceiptOnly -and $ValidateReceiptWithTimeout) { Write-Error 'CCOD_ACTIVATION_VALIDATOR_MODE_INVALID' -ErrorAction Continue; exit 3 } +$packageBound = -not [string]::IsNullOrWhiteSpace($PackagePath) -or -not [string]::IsNullOrWhiteSpace($PackageManifestPath) -or -not [string]::IsNullOrWhiteSpace($ExpectedPackageSha256) -or -not [string]::IsNullOrWhiteSpace($ExpectedPackageManifestSha256) -or -not [string]::IsNullOrWhiteSpace($ExpectedGitCommit) +$packageSeal = $null +$packageAppRoot = $null +$payloadSeal = $null +try { +if ($packageBound) { + if ([string]::IsNullOrWhiteSpace($PackagePath) -or [string]::IsNullOrWhiteSpace($PackageManifestPath) -or [string]::IsNullOrWhiteSpace($ExpectedPackageSha256) -or [string]::IsNullOrWhiteSpace($ExpectedPackageManifestSha256) -or [string]::IsNullOrWhiteSpace($ExpectedVersion) -or [string]::IsNullOrWhiteSpace($ExpectedGitCommit)) { throw 'CCOD_INSTALLER_PACKAGE_INVALID' } + try { + $packageSeal = Open-CcodInstallerPackageSeal -PackagePath $PackagePath -PackageManifestPath $PackageManifestPath -ExpectedPackageSha256 $ExpectedPackageSha256 -ExpectedPackageManifestSha256 $ExpectedPackageManifestSha256 -ExpectedVersion $ExpectedVersion -ExpectedGitCommit $ExpectedGitCommit + $packageDefender = Invoke-CcodSetupPackageDefenderGate -PackagePath ([string]$packageSeal.PackagePath) + $packageAppRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-activation-package-' + [guid]::NewGuid().ToString('N')) + [IO.Directory]::CreateDirectory((Join-Path $packageAppRoot 'payload')) | Out-Null + $PayloadRoot = Join-Path (Join-Path $packageAppRoot 'payload') $ExpectedVersion + [void](Expand-CcodInstallerPackageSeal -Seal $packageSeal -DestinationRoot $PayloadRoot) + $AppRoot = $packageAppRoot + $ExpectedPayloadManifestSha256 = [string]$packageSeal.PayloadManifestSha256 + } catch { + $candidate = ([string]$_.FullyQualifiedErrorId -split ',')[0] + $code = if ($candidate -cmatch '^CCOD_[A-Z0-9_]{1,96}$') { $candidate } elseif ($_.Exception.Message -cmatch '^CCOD_[A-Z0-9_]{1,96}$') { $Matches[0] } else { 'CCOD_INSTALLER_PACKAGE_INVALID' } + Write-Error $code -ErrorAction Continue + exit 3 + } +} else { + if ([string]::IsNullOrWhiteSpace($AppRoot)) { Write-Error 'CCOD_INSTALL_INPUT_INVALID' -ErrorAction Continue; exit 3 } + if ([string]::IsNullOrWhiteSpace($PayloadRoot)) { $PayloadRoot = $AppRoot } +} + +function Test-CcodActivationReceiptObserved { + param([Parameter(Mandatory)][string]$Root,[Parameter(Mandatory)][string]$ExpectedActivationId) + $directory = Join-Path $Root 'state\activation-receipts' + if (-not [IO.Directory]::Exists($directory)) { return $false } + try { + Assert-CcodActivationReceiptPathSafe -Root $Root -Path $directory + foreach ($phase in $script:CcodActivationReceiptPhases) { + $path = Join-Path $directory ("$ExpectedActivationId.$phase.json") + if ([IO.File]::Exists($path)) { + Assert-CcodActivationReceiptPathSafe -Root $Root -Path $path + return $true + } + } + } catch { return $false } + return $false +} +$PayloadRoot = [IO.Path]::GetFullPath($PayloadRoot) +if (-not [string]::IsNullOrWhiteSpace($ExpectedVersion)) { + try { $payloadSeal = New-CcodActivationPayloadSeal -AppRoot $AppRoot -Root $PayloadRoot -Version $ExpectedVersion -ExpectedManifestSha256 $ExpectedPayloadManifestSha256 } + catch { + $candidate = ([string]$_.FullyQualifiedErrorId -split ',')[0] + $code = if ($candidate -cmatch '^CCOD_[A-Z0-9_]{1,96}$') { $candidate } elseif ($_.Exception.Message -cmatch '^CCOD_[A-Z0-9_]{1,96}$') { $Matches[0] } else { 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' } + Write-Error $code -ErrorAction Continue + exit 3 + } +} if ([string]::IsNullOrWhiteSpace($ActivationId)) { if ($ValidateReceiptOnly -or $ValidateReceiptWithTimeout) { Write-Error 'CCOD_ACTIVATION_ID_INVALID' -ErrorAction Continue; exit 3 } $ActivationId = [guid]::NewGuid().ToString('D') @@ -299,7 +962,12 @@ if ($ValidateReceiptWithTimeout) { if ($ValidateReceiptOnly) { try { $terminalReceipt = Read-CcodTerminalActivationReceipt -Root ([IO.Path]::GetFullPath($InstallRoot)) -ExpectedActivationId $ActivationId - if ($terminalReceipt.phase -ceq 'Ready') { exit 0 } + if ($terminalReceipt.phase -ceq 'Ready') { + if (-not [string]::IsNullOrWhiteSpace($ExpectedVersion)) { + Assert-CcodActivatedRuntimeVersion -Root ([IO.Path]::GetFullPath($InstallRoot)) -RuntimeId ([string]$terminalReceipt.runtimeId) -Version $ExpectedVersion -RuntimeModulePath (Join-Path $payloadSeal.StageRoot 'src\persistence\modules\RuntimeManifest.psm1') + } + exit 0 + } if ($terminalReceipt.phase -ceq 'Failed') { exit 2 } throw 'CCOD_ACTIVATION_RECEIPT_NOT_READY' } catch { @@ -313,11 +981,12 @@ $clock = [Diagnostics.Stopwatch]::StartNew() try { $root = [IO.Path]::GetFullPath($AppRoot) $stateRoot = [IO.Path]::GetFullPath($InstallRoot) - $installScript = Join-Path $root 'Install-CodexControlOtherDevices.ps1' + $executionPayloadRoot = if ($null -ne $payloadSeal) { [string]$payloadSeal.StageRoot } else { $PayloadRoot } + $installScript = Join-Path $executionPayloadRoot 'Install-CodexControlOtherDevices.ps1' if (-not [IO.File]::Exists($installScript)) { throw 'CCOD_ACTIVATION_INSTALL_SCRIPT_MISSING' } $powershell = (Get-Command powershell.exe -ErrorAction Stop).Source Write-CcodActivationRecord -Code 'STARTED' -DurationMilliseconds $clock.ElapsedMilliseconds - $installExitCode = Invoke-CcodOwnedInstallWorker -PowerShellPath $powershell -InstallScript $installScript -StateRoot $stateRoot -FirstReceiptTimeout $FirstReceiptTimeoutMilliseconds -ActivationTimeout $ActivationTimeoutMilliseconds + $installExitCode = Invoke-CcodOwnedInstallWorker -PowerShellPath $powershell -InstallScript $installScript -VerifiedPayloadRoot $executionPayloadRoot -StateRoot $stateRoot -SealedPackageSha256 $(if($packageBound){$ExpectedPackageSha256}else{$null}) -FirstReceiptTimeout $FirstReceiptTimeoutMilliseconds -ActivationTimeout $ActivationTimeoutMilliseconds if ($installExitCode -ne 0) { $reportedErrorCode = $null try { @@ -328,6 +997,9 @@ try { throw 'CCOD_ACTIVATION_RUNTIME_FAILED' } $activationReceipt = Read-CcodFinalActivationReceipt -Root $stateRoot -ExpectedActivationId $ActivationId + if (-not [string]::IsNullOrWhiteSpace($ExpectedVersion)) { + Assert-CcodActivatedRuntimeVersion -Root $stateRoot -RuntimeId ([string]$activationReceipt.runtimeId) -Version $ExpectedVersion -RuntimeModulePath (Join-Path $payloadSeal.StageRoot 'src\persistence\modules\RuntimeManifest.psm1') + } Write-CcodActivationRecord -Code 'RUNTIME_ACTIVATED' -DurationMilliseconds $clock.ElapsedMilliseconds } catch { $candidate = ([string]$_.FullyQualifiedErrorId -split ',')[0] @@ -357,3 +1029,8 @@ if ($Prompt) { Write-CcodActivationRecord -Code 'COMPLETED' -DurationMilliseconds $clock.ElapsedMilliseconds exit 0 +} finally { + Close-CcodActivationPayloadSeal -Seal $payloadSeal + Close-CcodInstallerPackageSeal -Seal $packageSeal + if ($null -ne $packageAppRoot -and [IO.Directory]::Exists($packageAppRoot)) { Remove-Item -LiteralPath $packageAppRoot -Recurse -Force -ErrorAction SilentlyContinue } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index efe008c..ca61f20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,24 @@ This file keeps the release record. GitHub Release bodies are generated from the No unreleased changes. +## v2.5.22 + +### English + +- Bound Setup activation and Ready reporting to verified manifest, installer, and module bytes; rejected hard-linked install leaves and independently checked Setup PE/provenance against the version, commit, and payload-manifest hash. +- Added bounded same-root process-tree rereads while preserving strict root and per-process identity checks during close and remote rebind. +- Correlated native tray actions with the acknowledged presentation revision and durable, bounded terminal diagnostics; stale actions now produce a sanitized support code. +- Made v2.5.22 candidate promotion require two distinct InternetDownload Defender receipts for the exact Setup and portable ZIP identities; workflow-artifact evidence cannot substitute. +- Added manifest- and state-verified uninstall residue cleanup with durable retry after partial deletion; unknown files, protected user data, and unavailable process observations block completion instead of being silently ignored. + +### 简体中文 + +- 将 Setup 激活与 Ready 报告绑定到已验证的 manifest、installer 和 module 字节;写入前拒绝硬链接叶子,并独立核对 Setup PE/构建 provenance 的版本、commit 与 payload-manifest 哈希。 +- 为同一根进程加入有界 process-tree 重读,同时在关闭和远程重绑定时继续严格校验根进程与每个成员的身份。 +- 将原生托盘操作绑定到已确认的 presentation revision 和持久、有界的终态诊断;过期操作会记录清理后的支持代码。 +- v2.5.22 候选版本只有在精确 Setup 与便携 ZIP 分别具备不同的 InternetDownload Defender 收据后才能提升;工作流产物证据不能替代它们。 +- 新增基于清单与状态核验的卸载残留清理,部分删除后可持久化重试;遇到未知文件、受保护用户数据或无法确认的进程状态时停止,不再静默忽略并报告完成。 + ## v2.5.21 ### English diff --git a/Install-CodexControlOtherDevices.ps1 b/Install-CodexControlOtherDevices.ps1 index 3b5d71f..718e4a7 100644 --- a/Install-CodexControlOtherDevices.ps1 +++ b/Install-CodexControlOtherDevices.ps1 @@ -4,11 +4,138 @@ param( [switch]$EnableCandidateCompatibleUpdates, [switch]$RepairState, [switch]$DoNotStart, - [string]$ActivationId + [string]$ActivationId, + [string]$ExpectedVersion, + [string]$PayloadManifestPath, + [string]$ExpectedPayloadManifestSha256, + [string]$SealedPackageSha256 ) $ErrorActionPreference = 'Stop' +function Get-CcodInstallerBytesSha256 { + param([Parameter(Mandatory)][byte[]]$Bytes) + $sha = [Security.Cryptography.SHA256]::Create() + try { return [BitConverter]::ToString($sha.ComputeHash($Bytes)).Replace('-','').ToLowerInvariant() } + finally { $sha.Dispose() } +} + +function Open-CcodInstallerLockedBytes { + param([Parameter(Mandatory)][string]$Path,[int64]$MaximumBytes = 268435456) + $full = [IO.Path]::GetFullPath($Path) + $stream = [IO.File]::Open($full,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read) + try { + if ($stream.Length -lt 0 -or $stream.Length -gt $MaximumBytes) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' } + $memory = [IO.MemoryStream]::new() + try { $stream.CopyTo($memory); $bytes = $memory.ToArray() } finally { $memory.Dispose() } + return [pscustomobject]@{Path=$full;Stream=$stream;Bytes=$bytes} + } catch { $stream.Dispose(); throw } +} + +function Assert-CcodInstallerPayloadPath { + param([Parameter(Mandatory)][string]$Root,[Parameter(Mandatory)][string]$Path) + $canonicalRoot = [IO.Path]::GetFullPath($Root).TrimEnd('\') + $candidate = [IO.Path]::GetFullPath($Path) + $prefix = $canonicalRoot + '\' + if (-not ($candidate.Equals($canonicalRoot,[StringComparison]::OrdinalIgnoreCase) -or $candidate.StartsWith($prefix,[StringComparison]::OrdinalIgnoreCase))) { + throw 'CCOD_INSTALL_PAYLOAD_PATH_INVALID' + } + $cursor = $candidate + while ($true) { + $item = Get-Item -LiteralPath $cursor -Force -ErrorAction Stop + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'CCOD_INSTALL_SOURCE_REPARSE' } + if ($cursor.Equals($canonicalRoot,[StringComparison]::OrdinalIgnoreCase)) { break } + $cursor = Split-Path $cursor -Parent + } + return $candidate +} + +function Test-CcodInstallerAlternateDataStreams { + param([Parameter(Mandatory)][string]$Path) + try { + foreach ($stream in @(Get-Item -LiteralPath $Path -Stream * -ErrorAction Stop)) { + if ([string]$stream.Stream -cnotin @(':$DATA','::$DATA','$DATA')) { return $true } + } + return $false + } catch { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' } +} + +function Open-CcodInstallerPayloadSeal { + param( + [Parameter(Mandatory)][string]$SourceRoot, + [Parameter(Mandatory)][string]$Version, + [Parameter(Mandatory)][string]$ManifestPath, + [Parameter(Mandatory)][string]$ExpectedManifestSha256 + ) + if ($Version -cnotmatch '^\d+\.\d+\.\d+$' -or $ExpectedManifestSha256 -cnotmatch '^[0-9a-f]{64}$') { throw 'CCOD_INSTALL_PAYLOAD_VERSION_MISMATCH' } + $root = [IO.Path]::GetFullPath($SourceRoot).TrimEnd('\') + [void](Assert-CcodInstallerPayloadPath -Root $root -Path $root) + $manifestFile = Assert-CcodInstallerPayloadPath -Root $root -Path $ManifestPath + $locks = [Collections.Generic.List[object]]::new() + try { + $manifestLock = Open-CcodInstallerLockedBytes -Path $manifestFile -MaximumBytes 4194304 + $locks.Add($manifestLock) + if ((Test-CcodInstallerAlternateDataStreams -Path $manifestFile) -or + (Get-CcodInstallerBytesSha256 -Bytes $manifestLock.Bytes) -cne $ExpectedManifestSha256) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' } + try { + $manifestText = [Text.UTF8Encoding]::new($false,$true).GetString($manifestLock.Bytes) + $manifest = $manifestText | ConvertFrom-Json -ErrorAction Stop + } catch { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' } + $fields = @($manifest.PSObject.Properties.Name) + if ($manifest -isnot [pscustomobject] -or ($fields -join ',') -cne 'schemaVersion,projectVersion,files' -or + $manifest.schemaVersion -isnot [int] -or $manifest.schemaVersion -ne 1 -or + $manifest.projectVersion -isnot [string] -or $manifest.projectVersion -cne $Version) { throw 'CCOD_INSTALL_PAYLOAD_VERSION_MISMATCH' } + $records = @($manifest.files) + if ($records.Count -eq 0) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' } + $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + $previous = $null + $packageBytes = $null + foreach ($record in $records) { + $recordFields = @($record.PSObject.Properties.Name) + if ($record -isnot [pscustomobject] -or ($recordFields -join ',') -cne 'path,length,sha256' -or + $record.path -isnot [string] -or $record.path -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._/-]{0,255}$' -or + $record.path.Contains('//') -or $record.path.Contains('..') -or $record.path.Contains(':') -or $record.path.Contains('\') -or + $record.length -isnot [ValueType] -or [decimal]$record.length -ne [decimal][int64]$record.length -or [int64]$record.length -lt 0 -or + $record.sha256 -isnot [string] -or $record.sha256 -cnotmatch '^[0-9a-f]{64}$' -or + ($null -ne $previous -and [StringComparer]::Ordinal.Compare([string]$previous,[string]$record.path) -ge 0) -or + -not $seen.Add([string]$record.path)) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' } + $previous = [string]$record.path + $source = Assert-CcodInstallerPayloadPath -Root $root -Path (Join-Path $root ([string]$record.path).Replace('/','\')) + $locked = Open-CcodInstallerLockedBytes -Path $source + $locks.Add($locked) + $item = Get-Item -LiteralPath $source -Force -ErrorAction Stop + if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + (Test-CcodInstallerAlternateDataStreams -Path $source) -or [int64]$locked.Bytes.Length -ne [int64]$record.length -or + (Get-CcodInstallerBytesSha256 -Bytes $locked.Bytes) -cne [string]$record.sha256) { throw 'CCOD_INSTALL_FILE_HASH_MISMATCH' } + if ([string]$record.path -ceq 'package.json') { $packageBytes = $locked.Bytes } + } + foreach ($required in @('package.json','Install-CodexControlOtherDevices.ps1','src/persistence/modules/InstallLifecycle.psm1','src/persistence/modules/RuntimeManifest.psm1')) { + if (-not $seen.Contains($required)) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' } + } + $actual = [Collections.Generic.List[string]]::new() + foreach ($item in @(Get-ChildItem -LiteralPath $root -Force -Recurse -ErrorAction Stop)) { + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'CCOD_INSTALL_SOURCE_REPARSE' } + if ($item.PSIsContainer -or $item.FullName.Equals($manifestFile,[StringComparison]::OrdinalIgnoreCase)) { continue } + $actual.Add($item.FullName.Substring($root.Length + 1).Replace('\','/')) + } + if ($actual.Count -ne $records.Count) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' } + foreach ($relative in $actual) { if (-not $seen.Contains($relative)) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' } } + try { $package = [Text.UTF8Encoding]::new($false,$true).GetString([byte[]]$packageBytes) | ConvertFrom-Json -ErrorAction Stop } + catch { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' } + if ($package.version -isnot [string] -or [string]$package.version -cne $Version) { throw 'CCOD_INSTALL_PAYLOAD_VERSION_MISMATCH' } + return [pscustomobject]@{ManifestBytes=$manifestLock.Bytes;Locks=@($locks | ForEach-Object {$_.Stream})} + } catch { + foreach ($locked in @($locks)) { try { $locked.Stream.Dispose() } catch { } } + throw + } +} + +function Close-CcodInstallerPayloadSeal { + param($Seal) + if ($null -eq $Seal) { return } + foreach ($stream in @($Seal.Locks)) { if ($null -ne $stream) { try { $stream.Dispose() } catch { } } } +} + function Resolve-CcodInstallerModule { param([Parameter(Mandatory)][string]$CheckoutRoot) @@ -24,20 +151,44 @@ function Resolve-CcodInstallerModule { return $modulePath } -$script:InstallerModule = Resolve-CcodInstallerModule -CheckoutRoot $PSScriptRoot -Import-Module $script:InstallerModule -Force - if ([string]::IsNullOrWhiteSpace($InstallRoot)) { $InstallRoot = Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)) 'CodexControlOtherDevices' } -$receipt = Invoke-CcodInstall ` - -SourceRoot $PSScriptRoot ` - -InstallRoot $InstallRoot ` - -EnableCandidateCompatibleUpdates:([bool]$EnableCandidateCompatibleUpdates) ` - -RepairState:([bool]$RepairState) ` - -DoNotStart:([bool]$DoNotStart) ` - -ActivationId $ActivationId +$payloadBound = -not [string]::IsNullOrWhiteSpace($ExpectedVersion) -or -not [string]::IsNullOrWhiteSpace($PayloadManifestPath) -or -not [string]::IsNullOrWhiteSpace($ExpectedPayloadManifestSha256) +$payloadSeal = $null +$installerModule = $null +try { + if ($payloadBound) { + if ([string]::IsNullOrWhiteSpace($ExpectedVersion) -or [string]::IsNullOrWhiteSpace($PayloadManifestPath) -or [string]::IsNullOrWhiteSpace($ExpectedPayloadManifestSha256)) { + throw 'CCOD_INSTALL_INPUT_INVALID' + } + $payloadSeal = Open-CcodInstallerPayloadSeal -SourceRoot $PSScriptRoot -Version $ExpectedVersion -ManifestPath $PayloadManifestPath -ExpectedManifestSha256 $ExpectedPayloadManifestSha256 + } + $script:InstallerModule = Resolve-CcodInstallerModule -CheckoutRoot $PSScriptRoot + $installerModule = Import-Module $script:InstallerModule -Force -PassThru + $invoke = @{ + SourceRoot = $PSScriptRoot + InstallRoot = $InstallRoot + EnableCandidateCompatibleUpdates = [bool]$EnableCandidateCompatibleUpdates + RepairState = [bool]$RepairState + DoNotStart = [bool]$DoNotStart + ActivationId = $ActivationId + } + if ($payloadBound) { + $invoke.ExpectedVersion = $ExpectedVersion + $invoke.PayloadManifestPath = $PayloadManifestPath + $invoke.ExpectedPayloadManifestSha256 = $ExpectedPayloadManifestSha256 + $invoke.PayloadManifestBytesBase64 = [Convert]::ToBase64String([byte[]]$payloadSeal.ManifestBytes) + } + if (-not [string]::IsNullOrWhiteSpace($SealedPackageSha256)) { + $invoke.SealedPackageSha256 = $SealedPackageSha256 + } + $receipt = Invoke-CcodInstall @invoke +} finally { + if ($null -ne $installerModule) { Remove-Module -Name $installerModule.Name -Force -ErrorAction SilentlyContinue } + Close-CcodInstallerPayloadSeal -Seal $payloadSeal +} Write-Host '' Write-Host 'CodexRemote-fix - install result' -ForegroundColor Cyan diff --git a/Install-CodexRemote-fix.ps1 b/Install-CodexRemote-fix.ps1 index 816a4e7..8b15217 100644 --- a/Install-CodexRemote-fix.ps1 +++ b/Install-CodexRemote-fix.ps1 @@ -62,21 +62,29 @@ function Get-CcodPortableInstallerDetectionKeys { function Invoke-CcodPortableInstallerDefenderGate { param([Parameter(Mandatory)][string]$PayloadRoot) try { + $started = [DateTime]::UtcNow $status = Get-MpComputerStatus -ErrorAction Stop - if ($null -eq $status -or $null -eq $status.PSObject.Properties['AMProductVersion'] -or - $null -eq $status.PSObject.Properties['AntivirusSignatureVersion'] -or + if ($null -eq $status -or + $null -eq $status.PSObject.Properties['AMServiceEnabled'] -or $status.AMServiceEnabled -isnot [bool] -or -not $status.AMServiceEnabled -or + $null -eq $status.PSObject.Properties['AntivirusEnabled'] -or $status.AntivirusEnabled -isnot [bool] -or -not $status.AntivirusEnabled -or + $null -eq $status.PSObject.Properties['RealTimeProtectionEnabled'] -or $status.RealTimeProtectionEnabled -isnot [bool] -or -not $status.RealTimeProtectionEnabled -or + $null -eq $status.PSObject.Properties['AMProductVersion'] -or $status.AMProductVersion -isnot [string] -or [string]::IsNullOrWhiteSpace($status.AMProductVersion) -or + $null -eq $status.PSObject.Properties['AMEngineVersion'] -or + $status.AMEngineVersion -isnot [string] -or [string]::IsNullOrWhiteSpace($status.AMEngineVersion) -or + $null -eq $status.PSObject.Properties['AntivirusSignatureVersion'] -or $status.AntivirusSignatureVersion -isnot [string] -or [string]::IsNullOrWhiteSpace($status.AntivirusSignatureVersion) -or - $null -eq $status.PSObject.Properties['RealTimeProtectionEnabled'] -or $status.RealTimeProtectionEnabled -isnot [bool] -or - -not $status.RealTimeProtectionEnabled) { - throw 'Defender status is incomplete or real-time protection is not enabled.' + $null -eq $status.PSObject.Properties['AntivirusSignatureLastUpdated'] -or $status.AntivirusSignatureLastUpdated -isnot [datetime]) { + throw 'Defender status is incomplete or protection is not enabled.' } + $signatureUpdated = ([datetime]$status.AntivirusSignatureLastUpdated).ToUniversalTime() + if ($signatureUpdated -lt $started.AddHours(-72) -or $signatureUpdated -gt $started.AddMinutes(5)) { throw 'Defender signature timestamp is stale or future-dated.' } $before = Get-CcodPortableInstallerDetectionKeys -Records @(Get-MpThreatDetection -ErrorAction Stop) - $started = [DateTime]::UtcNow $scanError = $null try { Start-MpScan -ScanType CustomScan -ScanPath $PayloadRoot -ErrorAction Stop } catch { $scanError = $_ } $completed = [DateTime]::UtcNow + if ($completed -lt $started -or $completed -gt $started.AddHours(2)) { throw 'Defender scan clock is reversed or exceeds the two-hour bound.' } $after = Get-CcodPortableInstallerDetectionKeys -Records @(Get-MpThreatDetection -ErrorAction Stop) $newDetections = @($after | Where-Object { -not $before.Contains($_) }) if ($null -ne $scanError) { @@ -89,10 +97,16 @@ function Invoke-CcodPortableInstallerDefenderGate { Throw-CcodPortableInstallerError 'CCOD_PORTABLE_DEFENDER_DETECTIONS_FOUND' 'The portable payload disappeared during its Defender scan.' $PayloadRoot } return [pscustomobject][ordered]@{ + defenderServiceEnabled = $true + antivirusEnabled = $true + realTimeProtectionEnabled = $true defenderPlatformVersion = [string]$status.AMProductVersion + defenderEngineVersion = [string]$status.AMEngineVersion signatureVersion = [string]$status.AntivirusSignatureVersion + signatureUpdatedAtUtc = $signatureUpdated.ToString('o',[Globalization.CultureInfo]::InvariantCulture) scanStartedAtUtc = $started.ToString('o',[Globalization.CultureInfo]::InvariantCulture) scanCompletedAtUtc = $completed.ToString('o',[Globalization.CultureInfo]::InvariantCulture) + detectionCount = 0 } } catch { if ($_.FullyQualifiedErrorId -match '^CCOD_PORTABLE_') { throw } @@ -122,6 +136,29 @@ function Get-CcodPortableInstallerActiveRuntime { return [pscustomobject][ordered]@{ RuntimeId=$active.activeRuntime; Generation=$generation } } +function Invoke-CcodPortableLifecycleInstaller { + param( + [Parameter(Mandatory)][string]$InstallerPath, + [Parameter(Mandatory)][string]$InstallRoot, + [Parameter(Mandatory)][string]$CopiedSealedPackageSha256, + [Parameter(Mandatory)][string]$InitialSealedPackageSha256, + [Parameter(Mandatory)][string]$RevalidatedSourceSealedPackageSha256, + [switch]$EnableCandidateCompatibleUpdates, + [switch]$DoNotStart + ) + if ($CopiedSealedPackageSha256 -cnotmatch '^[0-9a-f]{64}$' -or + $InitialSealedPackageSha256 -cnotmatch '^[0-9a-f]{64}$' -or + $RevalidatedSourceSealedPackageSha256 -cnotmatch '^[0-9a-f]{64}$' -or + $CopiedSealedPackageSha256 -cne $InitialSealedPackageSha256 -or + $CopiedSealedPackageSha256 -cne $RevalidatedSourceSealedPackageSha256) { + Throw-CcodPortableInstallerError 'CCOD_PORTABLE_PACKAGE_IDENTITY_INVALID' 'The initial, revalidated source, and copied portable payload identities differ before lifecycle activation.' $CopiedSealedPackageSha256 + } + return & $InstallerPath -InstallRoot $InstallRoot ` + -EnableCandidateCompatibleUpdates:([bool]$EnableCandidateCompatibleUpdates) ` + -DoNotStart:([bool]$DoNotStart) ` + -SealedPackageSha256 $CopiedSealedPackageSha256 +} + $bundleRoot = Assert-CcodPortableInstallerPlainDirectory -Path $PSScriptRoot -Kind 'Portable bundle root' $payloadRoot = Assert-CcodPortableInstallerPlainDirectory -Path (Join-Path $bundleRoot 'payload') -Kind 'Portable payload root' $payloadManifestPath = Assert-CcodPortableInstallerRegularFile -Path (Join-Path $bundleRoot 'payload-manifest.json') -Kind 'Portable payload manifest' @@ -137,7 +174,7 @@ if ($null -eq $package -or $package.version -isnot [string] -or $package.version $payload = Test-CcodPortablePayloadManifest -PayloadRoot $payloadRoot -ManifestPath $payloadManifestPath -ExpectedVersion $package.version $defender = Invoke-CcodPortableInstallerDefenderGate -PayloadRoot $payloadRoot -Test-CcodPortablePayloadManifest -PayloadRoot $payloadRoot -ManifestPath $payloadManifestPath -ExpectedVersion $package.version -ExpectedGitCommit $payload.GitCommit | Out-Null +$revalidatedPayload = Test-CcodPortablePayloadManifest -PayloadRoot $payloadRoot -ManifestPath $payloadManifestPath -ExpectedVersion $package.version -ExpectedGitCommit $payload.GitCommit $localAppData = [Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData) if ([string]::IsNullOrWhiteSpace($localAppData) -or -not [IO.Path]::IsPathRooted($localAppData)) { @@ -151,7 +188,11 @@ if (-not $PSCmdlet.ShouldProcess($installRoot,'Install the verified CodexRemote- $copied = Copy-CcodPortablePayload -PayloadRoot $payloadRoot -ManifestPath $payloadManifestPath $installerPath = Assert-CcodPortableInstallerRegularFile -Path (Join-Path $copied.InstallerRoot 'Install-CodexControlOtherDevices.ps1') -Kind 'Installed lifecycle installer' try { - $installReceipt = & $installerPath -InstallRoot $installRoot -EnableCandidateCompatibleUpdates:([bool]$EnableCandidateCompatibleUpdates) -DoNotStart:([bool]$DoNotStart) + $installReceipt = Invoke-CcodPortableLifecycleInstaller -InstallerPath $installerPath -InstallRoot $installRoot ` + -CopiedSealedPackageSha256 ([string]$copied.Manifest.PayloadManifestSha256) ` + -InitialSealedPackageSha256 ([string]$payload.PayloadManifestSha256) ` + -RevalidatedSourceSealedPackageSha256 ([string]$revalidatedPayload.PayloadManifestSha256) ` + -EnableCandidateCompatibleUpdates:$EnableCandidateCompatibleUpdates -DoNotStart:$DoNotStart } catch { Throw-CcodPortableInstallerError 'CCOD_PORTABLE_INSTALL_FAILED' 'The verified portable payload could not activate the protected runtime.' $_ } diff --git a/README.md b/README.md index 7728700..1b76257 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@

> [!NOTE] -> **Release status:** v2.5.21 is the current stable release. The setup installer, EXE-based portable ZIP, tray actions, restart-and-repair flow, and persistent remote connection are covered by the release validation gates. +> **Release status:** v2.5.22 is the current release candidate. Its setup installer and EXE-based portable ZIP are version- and manifest-bound; stable Windows acceptance still requires recorded install, upgrade, reboot, repair, UI, and Defender verification. CodexRemote-fix enables the UI that ships with Codex Desktop for Windows but is hidden by a runtime defect: @@ -50,19 +50,19 @@ that existing page and leaves the Codex UI, account authorization, and enrollmen ## Quick start -1. Download either `CodexRemote-fix-2.5.21-setup.exe` (recommended installer) or `CodexRemote-fix-2.5.21-windows-x64.zip` from [Releases](https://github.com/naipi11/CodexRemote-fix/releases). Download and verify the matching `.sha256.txt` before continuing. +1. When the v2.5.22 candidate assets are available on [Releases](https://github.com/naipi11/CodexRemote-fix/releases), download either `CodexRemote-fix-2.5.22-setup.exe` (recommended installer) or `CodexRemote-fix-2.5.22-windows-x64.zip`. Download and verify the matching `.sha256.txt` before continuing. 2. If you chose the setup installer, run it and follow the wizard. If you chose the portable ZIP, extract it into a new empty folder and double-click `CodexRemote-fix.exe`. Both paths validate the payload, run a Microsoft Defender custom scan, and preserve the DPAPI device-key store. 3. The tray supervisor starts automatically. When the connection reports **Connected**, open **Settings → Connections → Control other devices** to enroll or use the device. Windows 10 users should ensure that .NET Framework 4.8 is installed for the native TrayHost. -The current portable bundle, checksum, release manifest, and payload manifest are always published on the -[Releases](https://github.com/naipi11/CodexRemote-fix/releases) page. +Use the [Releases](https://github.com/naipi11/CodexRemote-fix/releases) page only after the candidate's exact asset set and checksums have been posted. -Before replacing a portable build, use its installed `Uninstall-CodexControlOtherDevices.ps1`; settings and the DPAPI\r\ndevice-key store stay in place. +Before replacing a portable build, use its installed `Uninstall-CodexControlOtherDevices.ps1`; +settings and the DPAPI device-key store stay in place. -Verified on Windows 11 · Codex Desktop `26.820.7780.0` · Node.js `22.23.1`: -the hidden controller tab, restart-and-repair recovery, and persistent remote connection are working. +Historical development checks used Windows 11, Codex Desktop `26.820.7780.0`, +and Node.js `22.23.1`; they are not current v2.5.22 real-machine acceptance. ## Everyday use @@ -85,12 +85,26 @@ The tray reports two independent, truthful status lines instead of inferring rea ## Releases -Every tagged release ships a Windows setup installer and an EXE-entry portable ZIP, each with its SHA-256 checksum, release manifest, and the shared payload manifest. The `.github/workflows/release.yml` workflow builds both from the tag automatically, so v2.5.21 includes: +The v2.5.22 candidate has an exact 11-file public asset contract. Defender scan receipts are acceptance evidence and are not public assets. -- `CodexRemote-fix-2.5.21-setup.exe` and its checksum -- `CodexRemote-fix-2.5.21-windows-x64.zip` and its checksum -- `CodexRemote-fix-2.5.21-payload-manifest.json` -- `CodexRemote-fix-2.5.21-release-manifest.json` and `CodexRemote-fix-2.5.21-setup-release-manifest.json` +
+Exact 11-file candidate asset contract + +- `CodexRemote-fix-2.5.22-windows-x64.zip` +- `CodexRemote-fix-2.5.22-windows-x64.zip.sha256.txt` +- `CodexRemote-fix-2.5.22-trayhost-provenance.json` +- `CodexRemote-fix-2.5.22-payload-manifest.json` +- `CodexRemote-fix-2.5.22-release-manifest.json` +- `CodexRemote-fix-2.5.22-setup.exe` +- `CodexRemote-fix-2.5.22-setup.exe.sha256.txt` +- `CodexRemote-fix-2.5.22-setup-provenance.json` +- `CodexRemote-fix-2.5.22-setup-payload-manifest.json` +- `CodexRemote-fix-2.5.22-setup-destination-inventory.iss` +- `CodexRemote-fix-2.5.22-setup-release-manifest.json` + +
+ +The portable distribution publishes its payload manifest as an asset. Setup embeds and hash-binds its versioned `installer-payload.manifest.json`. Each release appends a short English change summary to the GitHub release body. The bilingual release history is kept in [CHANGELOG.md](CHANGELOG.md). diff --git a/README.zh-CN.md b/README.zh-CN.md index c8db449..93de331 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -16,7 +16,7 @@

> [!NOTE] -> **发布状态:** v2.5.21 是当前稳定版。安装包、以 EXE 启动的便携 ZIP、托盘操作、重启并修复流程和常驻远程连接均纳入发布验证门禁。 +> **发布状态:** v2.5.22 是当前候选发布版。安装包与以 EXE 启动的便携 ZIP 都已绑定版本和 manifest;Windows 稳定版验收仍需记录安装、升级、重启、修复、UI 与 Defender 的实机验证结果。 CodexRemote-fix 在 Windows 版 Codex Desktop 中启用随应用一起打包、但因运行时缺陷被隐藏的: @@ -46,19 +46,19 @@ CodexRemote-fix 在 Windows 版 Codex Desktop 中启用随应用一起打包、 ## 快速开始 -1. 从 [Releases](https://github.com/naipi11/CodexRemote-fix/releases) 下载 `CodexRemote-fix-2.5.21-setup.exe`(推荐安装包)或便携 ZIP `CodexRemote-fix-2.5.21-windows-x64.zip`;下载并核对对应的 `.sha256.txt`。 +1. 当 v2.5.22 候选资产已出现在 [Releases](https://github.com/naipi11/CodexRemote-fix/releases) 页面时,下载 `CodexRemote-fix-2.5.22-setup.exe`(推荐安装包)或便携 ZIP `CodexRemote-fix-2.5.22-windows-x64.zip`;下载并核对对应的 `.sha256.txt`。 2. 选择安装包时直接运行并跟随向导;选择便携 ZIP 时解压到新空目录,然后双击 `CodexRemote-fix.exe`。 两条路径都会校验载荷、执行 Microsoft Defender 自定义扫描,并保留 DPAPI 设备密钥。 3. 托盘守护程序会自动启动。连接状态显示 **已连接** 后,打开 **设置 → 连接 → 控制其他设备** 即可注册或使用。Windows 10 用户请确认已安装 .NET Framework 4.8,原生 TrayHost 需要该组件。 -当前便携 ZIP、SHA-256、release manifest 与 payload manifest 始终发布在 -[Releases](https://github.com/naipi11/CodexRemote-fix/releases) 页面。 +仅在候选版本的精确资产集合与校验值已经发布后,才从 +[Releases](https://github.com/naipi11/CodexRemote-fix/releases) 页面获取文件。 替换便携版本前,请先运行已安装的 `Uninstall-CodexControlOtherDevices.ps1`;设置和 DPAPI 设备密钥会保留。 -已验证:Windows 11 · Codex Desktop `26.820.7780.0` · Node.js `22.23.1`; -隐藏的控制器标签、重启并修复流程以及常驻远程连接均可用。 +历史开发检查使用 Windows 11、Codex Desktop `26.820.7780.0` 与 Node.js `22.23.1`; +这些记录不构成当前 v2.5.22 的实机验收。 ## 日常使用 @@ -81,12 +81,26 @@ CodexRemote-fix 在 Windows 版 Codex Desktop 中启用随应用一起打包、 ## 发布(Releases) -每个带 tag 的发布都会附带 Windows 安装包和以 EXE 启动的便携 ZIP,各自带 SHA-256 校验文件、release manifest,并共享 payload manifest。`.github/workflows/release.yml` 会在 tag 上自动构建两者,因此 v2.5.21 提供: +v2.5.22 候选版本采用精确的 11 文件公开资产契约。Defender 扫描收据仅作为验收证据,不是公开资产。 -- `CodexRemote-fix-2.5.21-setup.exe` 及其校验文件 -- `CodexRemote-fix-2.5.21-windows-x64.zip` 及其校验文件 -- `CodexRemote-fix-2.5.21-payload-manifest.json` -- `CodexRemote-fix-2.5.21-release-manifest.json` 和 `CodexRemote-fix-2.5.21-setup-release-manifest.json` +
+精确的 11 文件候选资产契约 + +- `CodexRemote-fix-2.5.22-windows-x64.zip` +- `CodexRemote-fix-2.5.22-windows-x64.zip.sha256.txt` +- `CodexRemote-fix-2.5.22-trayhost-provenance.json` +- `CodexRemote-fix-2.5.22-payload-manifest.json` +- `CodexRemote-fix-2.5.22-release-manifest.json` +- `CodexRemote-fix-2.5.22-setup.exe` +- `CodexRemote-fix-2.5.22-setup.exe.sha256.txt` +- `CodexRemote-fix-2.5.22-setup-provenance.json` +- `CodexRemote-fix-2.5.22-setup-payload-manifest.json` +- `CodexRemote-fix-2.5.22-setup-destination-inventory.iss` +- `CodexRemote-fix-2.5.22-setup-release-manifest.json` + +
+ +便携发行版会外发自己的 payload manifest;Setup 则内嵌并以哈希绑定版本化的 `installer-payload.manifest.json`。 GitHub Release 正文只发布英文更新说明;本 README 继续保留中英文使用说明。 完整历史见 [CHANGELOG.md](CHANGELOG.md)。 diff --git a/Uninstall-CodexControlOtherDevices.ps1 b/Uninstall-CodexControlOtherDevices.ps1 index 13b4e43..2e56ce0 100644 --- a/Uninstall-CodexControlOtherDevices.ps1 +++ b/Uninstall-CodexControlOtherDevices.ps1 @@ -15,6 +15,47 @@ function Throw-CcodPublicUninstallError { [InvalidOperationException]::new($Message),$Id,[Management.Automation.ErrorCategory]::InvalidOperation,$Target) } +function Invoke-CcodPublicUninstallPrepare { + param([Parameter(Mandatory)][string]$Path,[Parameter(Mandatory)][string]$Runtime,[Parameter(Mandatory)][string]$Root,[Parameter(Mandatory)][string]$PrepareMode,$Identity) + # Dot-source only definitions, then call the function. The script's CLI tail + # deliberately exits instead of returning a receipt to a PowerShell caller. + . $Path + Invoke-CcodUninstallBootstrap -InstallerRoot $Runtime -InstallRoot $Root -Mode $PrepareMode -WrapperIdentity $Identity +} + +function Start-CcodPublicInstalledFinalizer { + param([Parameter(Mandatory)][string]$SourceBootstrap,[Parameter(Mandatory)][string]$PayloadRoot,[Parameter(Mandatory)][object[]]$PayloadRecords,[Parameter(Mandatory)][string]$PowerShellPath,[Parameter(Mandatory)][string]$Arguments,[Parameter(Mandatory)][string]$TransactionDirectory) + $payloadAuthority=$null;$readyPipe=$null;$child=$null;$handedOff=$false + try { + . $SourceBootstrap + $payloadAuthority=Open-CcodUninstallBootstrapPayloadAuthority -PayloadRoot $PayloadRoot -Records $PayloadRecords + $readyPipe=[IO.Pipes.AnonymousPipeServerStream]::new([IO.Pipes.PipeDirection]::In,[IO.HandleInheritability]::Inheritable) + $readyHandle=$readyPipe.GetClientHandleAsString() + $child=Start-Process -FilePath $PowerShellPath -ArgumentList ($Arguments+' -AuthorityReadyHandle '+$readyHandle) -WindowStyle Hidden -RedirectStandardOutput (Join-Path $TransactionDirectory 'installed-finalizer.stdout.log') -RedirectStandardError (Join-Path $TransactionDirectory 'installed-finalizer.stderr.log') -PassThru -ErrorAction Stop + [void]$child.Handle + $readyPipe.DisposeLocalCopyOfClientHandle() + $buffer=[byte[]]::new(4);$received=0;$clock=[Diagnostics.Stopwatch]::StartNew() + while ($received -lt $buffer.Length) { + $remaining=15000-[int]$clock.ElapsedMilliseconds + if ($remaining -le 0) { throw 'finalizer authority timeout' } + $pending=$readyPipe.ReadAsync($buffer,$received,$buffer.Length-$received) + if (-not $pending.Wait($remaining) -or $pending.Result -le 0) { throw 'finalizer authority unavailable' } + $received+=$pending.Result + } + if ([BitConverter]::ToInt32($buffer,0) -ne $child.Id) { throw 'finalizer authority identity' } + # The exact native child now owns its complete staged payload closure. + # These external holds may close; installed holds close before wrapper exit. + $handedOff=$true + return $child + } finally { + if ($null -ne $readyPipe) { $readyPipe.Dispose() } + if (-not $handedOff -and $null -ne $child) { + try { if (-not $child.HasExited) { $child.Kill();$child.WaitForExit() } } finally { $child.Dispose() } + } + if ($null -ne $payloadAuthority) { $payloadAuthority.Dispose() } + } +} + if ($KeepCurrentSpecialSession -or $BackupDeviceKeyStore -or $RemoveDeviceKeyStore) { Throw-CcodPublicUninstallError 'CCOD_UNINSTALL_OPTION_REMOVED' 'Legacy session and device-key uninstall options were removed. The device key remains in place.' $PSBoundParameters } @@ -29,6 +70,33 @@ if ([string]::IsNullOrWhiteSpace($localAppData) -or -not [IO.Path]::IsPathRooted } $expectedInstallerRoot = [IO.Path]::GetFullPath((Join-Path $localAppData 'CodexControlOtherDevices-installer')) $expectedInstallRoot = [IO.Path]::GetFullPath((Join-Path $localAppData 'CodexControlOtherDevices')) +$runtimeParent = [IO.Path]::GetFullPath((Join-Path $expectedInstallRoot 'runtime')) +$runtimePrefix = $runtimeParent.TrimEnd('\') + '\' +$installedRuntimeId = if ($installerRoot.StartsWith($runtimePrefix,[StringComparison]::OrdinalIgnoreCase)) { $installerRoot.Substring($runtimePrefix.Length) } else { $null } +if ($null -ne $installedRuntimeId -and $installedRuntimeId -cmatch '^2\.5\.22-[0-9a-f]{16}-[0-9a-f]{32}$' -and $installedRuntimeId.IndexOf('\') -lt 0) { + $bootstrapPath = [IO.Path]::GetFullPath((Join-Path $installerRoot 'src\persistence\UninstallBootstrap.ps1')) + if (-not [IO.File]::Exists($bootstrapPath)) { Throw-CcodPublicUninstallError 'CCOD_UNINSTALL_INSTALLED_INVALID' 'The sealed generation uninstall bootstrap is missing.' $bootstrapPath } + $bootstrapItem = Get-Item -LiteralPath $bootstrapPath -Force -ErrorAction Stop + if ($bootstrapItem.PSIsContainer -or (($bootstrapItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) { Throw-CcodPublicUninstallError 'CCOD_UNINSTALL_INSTALLED_INVALID' 'The sealed generation uninstall bootstrap is unsafe.' $bootstrapPath } + if (-not $PSCmdlet.ShouldProcess($expectedInstallRoot,'Run manifest-bound cleanup from the selected sealed generation')) { return [pscustomobject][ordered]@{Outcome='WhatIf';KeptDeviceKeyStore=$true} } + $current=[Diagnostics.Process]::GetCurrentProcess();$windowsIdentity=[Security.Principal.WindowsIdentity]::GetCurrent();try{$wrapperId=[int]$current.Id;$wrapperCreated=$current.StartTime.ToUniversalTime().ToString('o',[Globalization.CultureInfo]::InvariantCulture);$wrapperIdentity=[pscustomobject]@{pid=$wrapperId;creationTimeUtc=$wrapperCreated;sessionId=[int]$current.SessionId;userSid=[string]$windowsIdentity.User.Value}}finally{$current.Dispose();$windowsIdentity.Dispose()} + try { $prepared=Invoke-CcodPublicUninstallPrepare -Path $bootstrapPath -Runtime $installerRoot -Root $expectedInstallRoot -PrepareMode PrepareInstalled -Identity $wrapperIdentity } + catch { Throw-CcodPublicUninstallError 'CCOD_UNINSTALL_INSTALLED_PREPARE_FAILED' 'Sealed generation cleanup did not reach its external finalization boundary.' $_ } + if($null-eq$prepared-or$prepared.transactionId-isnot[string]-or$prepared.transactionId-cnotmatch'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'-or$prepared.phase-cne'TaskRemoved'-or$prepared.runtimeId-cne$installedRuntimeId){Throw-CcodPublicUninstallError 'CCOD_UNINSTALL_INSTALLED_PREPARE_FAILED' 'Sealed generation cleanup returned an invalid receipt.' $prepared} + $transactionRoot=[IO.Path]::GetFullPath((Join-Path $localAppData 'CodexRemote-fix-uninstall')) + $transactionDirectory=[IO.Path]::GetFullPath((Join-Path $transactionRoot $prepared.transactionId)) + $finalizer=[IO.Path]::GetFullPath((Join-Path $transactionDirectory 'payload\src\persistence\InstalledUninstallFinalizer.ps1')) + if(-not[IO.File]::Exists($finalizer)){Throw-CcodPublicUninstallError 'CCOD_UNINSTALL_INSTALLED_FINALIZER_MISSING' 'The verified external installed finalizer is missing.' $finalizer} + $powershellPath=Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::System)) 'WindowsPowerShell\v1.0\powershell.exe' + if(-not[IO.File]::Exists($powershellPath)){Throw-CcodPublicUninstallError 'CCOD_UNINSTALL_INSTALLED_FINALIZER_MISSING' 'The Windows PowerShell host is unavailable.' $powershellPath} + function ConvertTo-CcodInstalledUninstallLiteral([string]$Value){ + return '"' + ($Value -replace '(\\*)"', '$1$1\"' -replace '(\\+)\z', '$1$1') + '"' + } + $historical=$prepared.installedBinding;$resumeExisting=$null-ne$historical-and([int]$historical.wrapperPid-ne$wrapperId-or[string]$historical.wrapperCreationTimeUtc-cne$wrapperCreated-or[int]$historical.wrapperSessionId-ne[int]$wrapperIdentity.sessionId-or[string]$historical.wrapperUserSid-cne[string]$wrapperIdentity.userSid) + $arguments=if($resumeExisting){'-NoProfile -NonInteractive -ExecutionPolicy Bypass -File {0} -WrapperResume -TransactionId {1} -RuntimeRoot {2} -InstallRoot {3} -WrapperProcessId {4} -WrapperCreationTimeUtc {5}'-f(ConvertTo-CcodInstalledUninstallLiteral $finalizer),(ConvertTo-CcodInstalledUninstallLiteral $prepared.transactionId),(ConvertTo-CcodInstalledUninstallLiteral $installerRoot),(ConvertTo-CcodInstalledUninstallLiteral $expectedInstallRoot),$wrapperId,(ConvertTo-CcodInstalledUninstallLiteral $wrapperCreated)}else{'-NoProfile -NonInteractive -ExecutionPolicy Bypass -File {0} -TransactionId {1} -RuntimeRoot {2} -InstallRoot {3} -WrapperProcessId {4} -WrapperCreationTimeUtc {5}'-f(ConvertTo-CcodInstalledUninstallLiteral $finalizer),(ConvertTo-CcodInstalledUninstallLiteral $prepared.transactionId),(ConvertTo-CcodInstalledUninstallLiteral $installerRoot),(ConvertTo-CcodInstalledUninstallLiteral $expectedInstallRoot),$wrapperId,(ConvertTo-CcodInstalledUninstallLiteral $wrapperCreated)} + try{$process=Start-CcodPublicInstalledFinalizer -SourceBootstrap $bootstrapPath -PayloadRoot (Join-Path $transactionDirectory 'payload') -PayloadRecords @($prepared.installedBinding.payloadRecords) -PowerShellPath $powershellPath -Arguments $arguments -TransactionDirectory $transactionDirectory}catch{Throw-CcodPublicUninstallError 'CCOD_UNINSTALL_INSTALLED_FINALIZER_START_FAILED' 'The external installed finalizer could not acquire its verified payload; application state was retained.' $_} + return [pscustomobject][ordered]@{Outcome='InstalledFinalizationStarted';TransactionId=[string]$prepared.transactionId;FinalizerProcessId=[int]$process.Id;KeptDeviceKeyStore=$true} +} $portableMarkerPath = Join-Path $installerRoot 'portable-release.json' if ([IO.File]::Exists($portableMarkerPath) -or [IO.Directory]::Exists($portableMarkerPath)) { if ($installerRoot -cne $expectedInstallerRoot) { @@ -51,7 +119,7 @@ if ([IO.File]::Exists($portableMarkerPath) -or [IO.Directory]::Exists($portableM return [pscustomobject][ordered]@{ Outcome='WhatIf'; KeptDeviceKeyStore=$true } } try { - $prepared = & $bootstrapPath -InstallerRoot $installerRoot -InstallRoot $expectedInstallRoot -Mode Prepare + $prepared = Invoke-CcodPublicUninstallPrepare -Path $bootstrapPath -Runtime $installerRoot -Root $expectedInstallRoot -PrepareMode Prepare -Identity $null } catch { Throw-CcodPublicUninstallError 'CCOD_UNINSTALL_PORTABLE_PREPARE_FAILED' 'Protected portable uninstall cleanup did not reach its finalization boundary.' $_ } @@ -75,7 +143,7 @@ if ([IO.File]::Exists($portableMarkerPath) -or [IO.Directory]::Exists($portableM } function ConvertTo-CcodPublicUninstallPowerShellLiteral { param([Parameter(Mandatory)][string]$Value) - return "'" + $Value.Replace("'","''") + "'" + return '"' + ($Value -replace '(\\*)"', '$1$1\"' -replace '(\\+)\z', '$1$1') + '"' } $stdoutPath = Join-Path $transactionDirectory 'portable-finalizer.stdout.log' $stderrPath = Join-Path $transactionDirectory 'portable-finalizer.stderr.log' diff --git a/build/CodexControlOtherDevices.iss b/build/CodexControlOtherDevices.iss index 49445df..7ab3d00 100644 --- a/build/CodexControlOtherDevices.iss +++ b/build/CodexControlOtherDevices.iss @@ -1,9 +1,34 @@ [Setup] -#ifndef TrayHostArtifactDirectory -#define TrayHostArtifactDirectory SourcePath + "\generated\trayhost" +#ifndef ProjectVersion +#error ProjectVersion must be supplied by the release builder #endif -#ifndef PortableArtifactDirectory -#define PortableArtifactDirectory SourcePath + "\generated\portable" +#ifndef InstallerPackagePath +#error InstallerPackagePath must be supplied by the release builder +#endif +#ifndef InstallerPackageManifestPath +#error InstallerPackageManifestPath must be supplied by the release builder +#endif +#ifndef InstallerPackageSha256 +#error InstallerPackageSha256 must be supplied by the release builder +#endif +#ifndef InstallerPackageManifestSha256 +#error InstallerPackageManifestSha256 must be supplied by the release builder +#endif +#define InstallerPackageManifestSha256First Copy(InstallerPackageManifestSha256, 1, 32) +#define InstallerPackageManifestSha256Last Copy(InstallerPackageManifestSha256, 33, 32) +#ifndef ActivationBootstrapPath +#error ActivationBootstrapPath must be supplied by the release builder +#endif +#ifndef ActivationBootstrapSha256 +#error ActivationBootstrapSha256 must be supplied by the release builder +#endif +#define ActivationBootstrapSha256First Copy(ActivationBootstrapSha256, 1, 32) +#define ActivationBootstrapSha256Last Copy(ActivationBootstrapSha256, 33, 32) +#ifndef SetupGitCommit +#error SetupGitCommit must be supplied by the release builder +#endif +#ifndef SetupProvenancePath +#error SetupProvenancePath must be supplied by the release builder #endif AppId={{2B9E9F2E-7A32-4A7E-9C1D-9F5B5C6D7E8F} AppName=CodexRemote-fix @@ -14,15 +39,21 @@ AppPublisherURL=https://github.com/naipi11/CodexRemote-fix AppSupportURL=https://github.com/naipi11/CodexRemote-fix/issues AppUpdatesURL=https://github.com/naipi11/CodexRemote-fix/releases VersionInfoVersion={#ProjectVersion}.0 -DefaultDirName={localappdata}\CodexControlOtherDevices-installer -DefaultGroupName=CodexRemote-fix -UsePreviousGroup=no +VersionInfoTextVersion={#ProjectVersion}.0 +VersionInfoProductName={#ActivationBootstrapSha256Last} +VersionInfoProductTextVersion={#InstallerPackageManifestSha256First} +VersionInfoDescription={#InstallerPackageManifestSha256Last} +VersionInfoCompany={#SetupGitCommit} +VersionInfoCopyright={#InstallerPackageSha256} +VersionInfoOriginalFileName={#ActivationBootstrapSha256First} +DefaultDirName={localappdata}\CodexControlOtherDevices +CreateAppDir=no +Uninstallable=no DisableProgramGroupPage=yes PrivilegesRequired=lowest OutputDir=dist OutputBaseFilename=CodexRemote-fix-{#ProjectVersion}-setup SetupIconFile=..\assets\codexremote-fix\codexremote-fix.ico -UninstallDisplayIcon={app}\assets\CodexRemote-fix.ico Compression=lzma2 SolidCompression=yes WizardStyle=modern @@ -37,405 +68,152 @@ MinVersion=10.0.17763 Name: "english"; MessagesFile: "compiler:Default.isl" [Files] -Source: "..\README.md"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\README.zh-CN.md"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\LICENSE"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\NOTICE.md"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\SECURITY.md"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\package.json"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\build\CodexControlOtherDevices.iss"; DestDir: "{app}\build"; Flags: ignoreversion -Source: "..\build\build.ps1"; DestDir: "{app}\build"; Flags: ignoreversion -Source: "..\build\build-trayhost.ps1"; DestDir: "{app}\build"; Flags: ignoreversion -Source: "..\build\TrayHostBuild.psm1"; DestDir: "{app}\build"; Flags: ignoreversion -Source: "..\build\TrayHostReferencePack.psm1"; DestDir: "{app}\build"; Flags: ignoreversion -Source: "..\build\trayhost-packages.lock.json"; DestDir: "{app}\build"; Flags: ignoreversion -Source: "{#TrayHostArtifactDirectory}\CodexRemote.TrayHost.exe"; DestDir: "{app}\bin"; Flags: ignoreversion -Source: "{#TrayHostArtifactDirectory}\CodexRemote.TrayHost.exe.config"; DestDir: "{app}\bin"; Flags: ignoreversion -Source: "{#TrayHostArtifactDirectory}\trayhost-build-provenance.json"; DestDir: "{app}\bin"; Flags: ignoreversion -Source: "{#PortableArtifactDirectory}\CodexRemote.Portable.exe"; DestDir: "{app}\bin"; Flags: ignoreversion -Source: "{#PortableArtifactDirectory}\CodexRemote.Portable.exe.config"; DestDir: "{app}\bin"; Flags: ignoreversion -Source: "{#PortableArtifactDirectory}\portable-launcher-provenance.json"; DestDir: "{app}\bin"; Flags: ignoreversion -Source: "..\.github\workflows\release.yml"; DestDir: "{app}\.github\workflows"; Flags: ignoreversion -Source: "..\assets\codexremote-fix\codexremote-fix.ico"; DestDir: "{app}\assets"; DestName: "CodexRemote-fix.ico"; Flags: ignoreversion -Source: "..\assets\codexremote-fix\codexremote-fix.ico"; DestDir: "{app}\assets\codexremote-fix"; Flags: ignoreversion -Source: "..\docs\*"; DestDir: "{app}\docs"; Flags: ignoreversion recursesubdirs createallsubdirs -Source: "..\src\*"; DestDir: "{app}\src"; Flags: ignoreversion recursesubdirs createallsubdirs -Source: "..\tests\*"; DestDir: "{app}\tests"; Flags: ignoreversion recursesubdirs createallsubdirs -Source: "..\Install-CodexControlOtherDevices.ps1"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\Uninstall-CodexControlOtherDevices.ps1"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\Start-CodexControlOtherDevices.ps1"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\Reset-CodexControlOtherDevices.ps1"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\Test-CodexControlOtherDevices.ps1"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\Activate-CcodRemoteFix.ps1"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\Prompt-CcodRestart.ps1"; DestDir: "{app}"; Flags: ignoreversion - -[InstallDelete] -Type: files; Name: "{userprograms}\Codex Control other devices\Codex Control other devices for Windows.lnk" -Type: files; Name: "{userprograms}\Codex Control other devices\Open the tray supervisor.lnk" -Type: files; Name: "{userprograms}\Codex Control other devices\Compatibility check.lnk" -Type: files; Name: "{userprograms}\Codex Control other devices\Uninstall Codex Control other devices.lnk" -Type: files; Name: "{userprograms}\Codex Control other devices\CodexRemote-fix.lnk" -Type: files; Name: "{userprograms}\Codex Control other devices\CodexRemote-fix compatibility check.lnk" -Type: files; Name: "{userprograms}\Codex Control other devices\Uninstall CodexRemote-fix.lnk" -Type: dirifempty; Name: "{userprograms}\Codex Control other devices" -Type: files; Name: "{userdesktop}\Codex 设备连接 (Device Connection).lnk" - -[Icons] -Name: "{group}\CodexRemote-fix"; Filename: "{sys}\WindowsPowerShell\v1.0\powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File ""{localappdata}\CodexControlOtherDevices\bootstrap.ps1"" -InstallRoot ""{localappdata}\CodexControlOtherDevices"" -EntryMode Explicit"; WorkingDir: "{localappdata}\CodexControlOtherDevices"; IconFilename: "{app}\assets\CodexRemote-fix.ico" -Name: "{group}\CodexRemote-fix compatibility check"; Filename: "{sys}\WindowsPowerShell\v1.0\powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\Test-CodexControlOtherDevices.ps1"""; WorkingDir: "{app}"; IconFilename: "{app}\assets\CodexRemote-fix.ico" -Name: "{group}\Uninstall CodexRemote-fix"; Filename: "{app}\unins000.exe"; IconFilename: "{app}\assets\CodexRemote-fix.ico" -Name: "{userdesktop}\CodexRemote-fix"; Filename: "{sys}\WindowsPowerShell\v1.0\powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File ""{localappdata}\CodexControlOtherDevices\bootstrap.ps1"" -InstallRoot ""{localappdata}\CodexControlOtherDevices"" -EntryMode Explicit"; WorkingDir: "{localappdata}\CodexControlOtherDevices"; IconFilename: "{app}\assets\CodexRemote-fix.ico" +Source: "{#InstallerPackagePath}"; DestName: "ccod-installer-package.zip"; Flags: dontcopy +Source: "{#InstallerPackageManifestPath}"; DestName: "ccod-installer-package.manifest.json"; Flags: dontcopy +Source: "{#ActivationBootstrapPath}"; DestName: "ccod-activation-bootstrap.ps1"; Flags: dontcopy +Source: "{#SetupProvenancePath}"; DestName: "ccod-setup-provenance.json"; Flags: dontcopy [Code] +// CCOD_INSTALLER_DESTINATION_INVENTORY const - ACTIVATION_TIMEOUT_MILLISECONDS = 300000; - FIRST_ACTIVATION_RECEIPT_TIMEOUT_MILLISECONDS = 90000; - ACTIVATION_POLL_MILLISECONDS = 50; - VALIDATION_RETRY_MILLISECONDS = 500; - VALIDATION_TIMEOUT_MILLISECONDS = 2000; - VALIDATION_LAUNCH_BUDGET_MILLISECONDS = 5000; - CCOD_FILE_ATTRIBUTE_DIRECTORY = $00000010; - CCOD_FILE_ATTRIBUTE_REPARSE_POINT = $00000400; - CCOD_INVALID_FILE_ATTRIBUTES = $FFFFFFFF; + CCOD_GENERIC_READ = $80000000; + CCOD_FILE_SHARE_READ = $00000001; + CCOD_OPEN_EXISTING = 3; + CCOD_FILE_ATTRIBUTE_NORMAL = $00000080; + CCOD_INVALID_HANDLE_VALUE = -1; + CCOD_EXPECTED_PACKAGE_SHA256 = '{#InstallerPackageSha256}'; + CCOD_EXPECTED_PACKAGE_MANIFEST_SHA256 = '{#InstallerPackageManifestSha256}'; + CCOD_EXPECTED_BOOTSTRAP_SHA256 = '{#ActivationBootstrapSha256}'; -type - TActivationPhase = (apNone, apStoppingPreviousRuntime, apInstallingRuntime, - apActivatingRuntime, apStartingProtection, apReady, apFailed); - -function GetTickCount64(): Int64; - external 'GetTickCount64@kernel32.dll stdcall'; +var + CcodInputHandles: array of Integer; + CcodPackagePath: String; + CcodPackageManifestPath: String; + CcodBootstrapPath: String; + +function CreateFileW(const FileName: String; DesiredAccess, ShareMode, + SecurityAttributes, CreationDisposition, FlagsAndAttributes, + TemplateFile: Cardinal): Integer; + external 'CreateFileW@kernel32.dll stdcall'; +function CloseHandle(Handle: Integer): Boolean; + external 'CloseHandle@kernel32.dll stdcall'; function CoCreateGuid(var Guid: TGUID): HResult; external 'CoCreateGuid@ole32.dll stdcall'; -function StringFromGUID2(var Guid: TGUID; GuidString: String; MaxCharacters: Integer): Integer; +function StringFromGUID2(var Guid: TGUID; GuidString: String; + MaxCharacters: Integer): Integer; external 'StringFromGUID2@ole32.dll stdcall'; -function GetFileAttributesW(const FileName: String): Cardinal; - external 'GetFileAttributesW@kernel32.dll stdcall'; -function NewActivationId(): String; +procedure CloseCcodInputHandles(); var - Guid: TGUID; - GuidString: String; - GuidLength: Integer; -begin - if CoCreateGuid(Guid) <> 0 then - RaiseException('CodexRemote-fix activation correlation could not be created.'); - SetLength(GuidString, 39); - GuidLength := StringFromGUID2(Guid, GuidString, 39); - if GuidLength <> 39 then - RaiseException('CodexRemote-fix activation correlation could not be formatted.'); - SetLength(GuidString, 38); - Result := LowerCase(Copy(GuidString, 2, 36)); - if Length(Result) <> 36 then - RaiseException('CodexRemote-fix activation correlation is invalid.'); -end; - -function IsSafeActivationFile(const FileName: String): Boolean; -var - Attributes, DirectoryAttributes, RootAttributes: Cardinal; - DirectoryName, RootName: String; + Index: Integer; begin - Attributes := GetFileAttributesW(FileName); - if (Attributes = CCOD_INVALID_FILE_ATTRIBUTES) or - ((Attributes and CCOD_FILE_ATTRIBUTE_DIRECTORY) <> 0) or - ((Attributes and CCOD_FILE_ATTRIBUTE_REPARSE_POINT) <> 0) then Exit; - DirectoryName := ExtractFileDir(FileName); - RootName := ExtractFileDir(DirectoryName); - DirectoryAttributes := GetFileAttributesW(DirectoryName); - RootAttributes := GetFileAttributesW(RootName); - Result := (DirectoryAttributes <> CCOD_INVALID_FILE_ATTRIBUTES) and - (RootAttributes <> CCOD_INVALID_FILE_ATTRIBUTES) and - ((DirectoryAttributes and CCOD_FILE_ATTRIBUTE_DIRECTORY) <> 0) and - ((RootAttributes and CCOD_FILE_ATTRIBUTE_DIRECTORY) <> 0) and - ((DirectoryAttributes and CCOD_FILE_ATTRIBUTE_REPARSE_POINT) = 0) and - ((RootAttributes and CCOD_FILE_ATTRIBUTE_REPARSE_POINT) = 0) and - ((Attributes and CCOD_FILE_ATTRIBUTE_DIRECTORY) = 0) and - ((Attributes and CCOD_FILE_ATTRIBUTE_REPARSE_POINT) = 0); + for Index := GetArrayLength(CcodInputHandles) - 1 downto 0 do + if CcodInputHandles[Index] <> CCOD_INVALID_HANDLE_VALUE then + CloseHandle(CcodInputHandles[Index]); + SetArrayLength(CcodInputHandles, 0); end; -procedure RefuseStaleActivationReceipt(const ReceiptPath: String); +function LockCcodInput(const Path, ExpectedSha256: String): Boolean; var - Attributes: Cardinal; + Handle: Integer; + Count: Integer; begin - Attributes := GetFileAttributesW(ReceiptPath); - if Attributes <> CCOD_INVALID_FILE_ATTRIBUTES then + Result := False; + if Lowercase(GetSHA256OfFile(Path)) <> ExpectedSha256 then Exit; + Handle := CreateFileW(Path, CCOD_GENERIC_READ, CCOD_FILE_SHARE_READ, 0, + CCOD_OPEN_EXISTING, CCOD_FILE_ATTRIBUTE_NORMAL, 0); + if Handle = CCOD_INVALID_HANDLE_VALUE then Exit; + if Lowercase(GetSHA256OfFile(Path)) <> ExpectedSha256 then begin - if not IsSafeActivationFile(ReceiptPath) then - RaiseException('CodexRemote-fix refused an unsafe stale activation receipt.'); - if not DeleteFile(ReceiptPath) then - RaiseException('CodexRemote-fix could not remove a stale activation receipt.'); + CloseHandle(Handle); + Exit; end; - if GetFileAttributesW(ReceiptPath) <> CCOD_INVALID_FILE_ATTRIBUTES then - RaiseException('CodexRemote-fix refused a stale activation receipt.'); + Count := GetArrayLength(CcodInputHandles); + SetArrayLength(CcodInputHandles, Count + 1); + CcodInputHandles[Count] := Handle; + Result := True; end; -function LoadBoundedActivationReceipt(const ReceiptPath: String; var Content: AnsiString): Boolean; -var - ReceiptSize: Int64; +function ExtractAndLockCcodInputs(): Boolean; begin Result := False; - if not FileSize64(ReceiptPath, ReceiptSize) then Exit; - if (ReceiptSize <= 0) or (ReceiptSize > 16384) then Exit; - Result := LoadStringFromFile(ReceiptPath, Content); -end; - -function IsJsonWhitespace(const Character: Char): Boolean; -begin - Result := (Character = ' ') or (Character = #9) or - (Character = #10) or (Character = #13); + ExtractTemporaryFile('ccod-installer-package.zip'); + ExtractTemporaryFile('ccod-installer-package.manifest.json'); + ExtractTemporaryFile('ccod-activation-bootstrap.ps1'); + ExtractTemporaryFile('ccod-setup-provenance.json'); + CcodPackagePath := ExpandConstant('{tmp}\ccod-installer-package.zip'); + CcodPackageManifestPath := ExpandConstant('{tmp}\ccod-installer-package.manifest.json'); + CcodBootstrapPath := ExpandConstant('{tmp}\ccod-activation-bootstrap.ps1'); + if not LockCcodInput(CcodPackagePath, CCOD_EXPECTED_PACKAGE_SHA256) then Exit; + if not LockCcodInput(CcodPackageManifestPath, CCOD_EXPECTED_PACKAGE_MANIFEST_SHA256) then Exit; + if not LockCcodInput(CcodBootstrapPath, CCOD_EXPECTED_BOOTSTRAP_SHA256) then Exit; + Result := True; end; -function HasJsonStringValue(const Content, FieldName, FieldValue: String): Boolean; +function NewActivationId(): String; var - FieldIndex, RelativeIndex, SearchOffset, ValueIndex: Integer; - FieldToken, ValueToken: String; -begin - Result := False; - FieldToken := '"' + FieldName + '"'; - ValueToken := '"' + FieldValue + '"'; - SearchOffset := 1; - while SearchOffset <= Length(Content) do - begin - RelativeIndex := Pos(FieldToken, Copy(Content, SearchOffset, - Length(Content) - SearchOffset + 1)); - if RelativeIndex = 0 then Exit; - FieldIndex := SearchOffset + RelativeIndex - 1; - ValueIndex := FieldIndex + Length(FieldToken); - while ValueIndex <= Length(Content) do - begin - if not IsJsonWhitespace(Content[ValueIndex]) then Break; - ValueIndex := ValueIndex + 1; - end; - if (ValueIndex <= Length(Content)) and (Content[ValueIndex] = ':') then - begin - ValueIndex := ValueIndex + 1; - while ValueIndex <= Length(Content) do - begin - if not IsJsonWhitespace(Content[ValueIndex]) then Break; - ValueIndex := ValueIndex + 1; - end; - if Copy(Content, ValueIndex, Length(ValueToken)) = ValueToken then - begin - Result := True; - Exit; - end; - end; - SearchOffset := FieldIndex + Length(FieldToken); - end; -end; - -function DetectActivationPhase(const Content: String): TActivationPhase; -begin - Result := apNone; - if HasJsonStringValue(Content, 'phase', 'StoppingPreviousRuntime') then - Result := apStoppingPreviousRuntime - else if HasJsonStringValue(Content, 'phase', 'InstallingRuntime') then - Result := apInstallingRuntime - else if HasJsonStringValue(Content, 'phase', 'ActivatingRuntime') then - Result := apActivatingRuntime - else if HasJsonStringValue(Content, 'phase', 'StartingProtection') then - Result := apStartingProtection - else if HasJsonStringValue(Content, 'phase', 'Ready') then - Result := apReady - else if HasJsonStringValue(Content, 'phase', 'Failed') then - Result := apFailed; -end; - -procedure UpdateActivationStartingPresentation(); -begin - WizardForm.StatusLabel.Caption := 'Starting CodexRemote-fix activation...'; - WizardForm.ProgressGauge.Position := 70; - WizardForm.Update; + Guid: TGUID; + Value: String; + Length: Integer; +begin + if CoCreateGuid(Guid) <> 0 then RaiseException('CCOD_SETUP_ACTIVATION_ID_FAILED'); + SetLength(Value, 39); + Length := StringFromGUID2(Guid, Value, 39); + if Length <> 39 then RaiseException('CCOD_SETUP_ACTIVATION_ID_FAILED'); + SetLength(Value, 38); + Result := Lowercase(Copy(Value, 2, 36)); +end; + +function GetCcodBootstrapParameters(const ActivationId: String; + ValidateOnly: Boolean): String; +begin + Result := '-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "' + + CcodBootstrapPath + '" -PackagePath "' + CcodPackagePath + + '" -PackageManifestPath "' + CcodPackageManifestPath + + '" -ExpectedPackageSha256 "{#InstallerPackageSha256}' + + '" -ExpectedPackageManifestSha256 "{#InstallerPackageManifestSha256}' + + '" -ExpectedVersion "{#ProjectVersion}' + + '" -ExpectedGitCommit "{#SetupGitCommit}' + + '" -InstallRoot "' + ExpandConstant('{localappdata}\CodexControlOtherDevices') + + '" -ActivationId "' + ActivationId + '"'; + if ValidateOnly then Result := Result + ' -ValidateReceiptOnly'; end; -procedure UpdateActivationPresentation(const Phase: TActivationPhase); +function PrepareToInstall(var NeedsRestart: Boolean): String; begin - case Phase of - apStoppingPreviousRuntime: - begin WizardForm.StatusLabel.Caption := 'Stopping the previous protected runtime...'; WizardForm.ProgressGauge.Position := 20; end; - apInstallingRuntime: - begin WizardForm.StatusLabel.Caption := 'Installing the verified runtime...'; WizardForm.ProgressGauge.Position := 40; end; - apActivatingRuntime: - begin WizardForm.StatusLabel.Caption := 'Committing the runtime generation...'; WizardForm.ProgressGauge.Position := 60; end; - apStartingProtection: - begin WizardForm.StatusLabel.Caption := 'Starting Supervisor and TrayHost protection...'; WizardForm.ProgressGauge.Position := 80; end; - apReady: - begin WizardForm.StatusLabel.Caption := 'Verifying activation completion...'; WizardForm.ProgressGauge.Position := 95; end; - apFailed: - begin WizardForm.StatusLabel.Caption := 'Activation failed safely.'; WizardForm.ProgressGauge.Position := 95; end; + Result := ''; + CloseCcodInputHandles(); + try + if not ExtractAndLockCcodInputs() then + Result := 'CCOD_SETUP_INPUT_BINDING_INVALID'; + except + Result := 'CCOD_SETUP_INPUT_BINDING_INVALID'; end; -end; - -function ReadActivationProgressPhase(const ReceiptPath, ExpectedActivationId: String): TActivationPhase; -var - Content: AnsiString; - Text: String; -begin - Result := apNone; - if not FileExists(ReceiptPath) then Exit; - if not IsSafeActivationFile(ReceiptPath) then Exit; - if not LoadBoundedActivationReceipt(ReceiptPath, Content) then Exit; - Text := String(Content); - if HasJsonStringValue(Text, 'activationId', ExpectedActivationId) then - Result := DetectActivationPhase(Text); + if Result <> '' then CloseCcodInputHandles(); end; procedure CurStepChanged(CurStep: TSetupStep); var - ActivationResultCode: Integer; - ValidationResultCode: Integer; - PromptResultCode: Integer; - Parameters, ReceiptPath, ActivationId: String; + ActivationId, Parameters: String; + ActivationResultCode, ValidationResultCode: Integer; begin - if CurStep <> ssPostInstall then - Exit; + if CurStep <> ssPostInstall then Exit; + if GetArrayLength(CcodInputHandles) <> 3 then + RaiseException('CCOD_SETUP_INPUT_BINDING_INVALID'); ActivationId := NewActivationId(); - ReceiptPath := ExpandConstant('{localappdata}\CodexControlOtherDevices\state\post-install-activation.json'); - RefuseStaleActivationReceipt(ReceiptPath); - UpdateActivationStartingPresentation(); - Parameters := '-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "' + - ExpandConstant('{app}\Activate-CcodRemoteFix.ps1') + '" -AppRoot "' + ExpandConstant('{app}') + - '" -InstallRoot "' + ExpandConstant('{localappdata}\CodexControlOtherDevices') + - '" -ActivationId "' + ActivationId + '" -FirstReceiptTimeoutMilliseconds ' + - IntToStr(FIRST_ACTIVATION_RECEIPT_TIMEOUT_MILLISECONDS) + ' -ActivationTimeoutMilliseconds ' + - IntToStr(ACTIVATION_TIMEOUT_MILLISECONDS); - if not Exec(ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe'), Parameters, '', SW_HIDE, - ewWaitUntilTerminated, ActivationResultCode) then - begin - Log('CodexRemote-fix activation owner could not be started: ' + SysErrorMessage(ActivationResultCode)); - RaiseException('CodexRemote-fix activation process could not be started.'); - end; - if ActivationResultCode <> 0 then - begin - Log('CodexRemote-fix activation owner exited unsuccessfully, result code ' + IntToStr(ActivationResultCode) + '.'); - SuppressibleMsgBox('CodexRemote-fix activation failed safely. Use the support code in post-install-activation.log.', mbError, MB_OK, IDOK); - RaiseException('CodexRemote-fix activation failed.'); - end; - Parameters := '-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "' + - ExpandConstant('{app}\Activate-CcodRemoteFix.ps1') + '" -AppRoot "' + ExpandConstant('{app}') + - '" -InstallRoot "' + ExpandConstant('{localappdata}\CodexControlOtherDevices') + - '" -ValidateReceiptWithTimeout -ValidationTimeoutMilliseconds ' + IntToStr(VALIDATION_TIMEOUT_MILLISECONDS) + - ' -ActivationId "' + ActivationId + '"'; - if not Exec(ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe'), Parameters, '', SW_HIDE, - ewWaitUntilTerminated, ValidationResultCode) then - begin - Log('CodexRemote-fix activation validator could not be started: ' + SysErrorMessage(ValidationResultCode)); - RaiseException('CodexRemote-fix activation validator could not be started.'); - end; - if ValidationResultCode <> 0 then - begin - Log('CodexRemote-fix activation validator rejected the terminal receipt, result code ' + IntToStr(ValidationResultCode) + '.'); - SuppressibleMsgBox('CodexRemote-fix activation failed safe validation. Use the support code in post-install-activation.log.', mbError, MB_OK, IDOK); - RaiseException('CodexRemote-fix activation receipt validation failed.'); - end; - if ValidationResultCode = 0 then - begin - WizardForm.StatusLabel.Caption := 'CodexRemote-fix activation is ready.'; - WizardForm.ProgressGauge.Position := 100; - WizardForm.Update; - if not WizardSilent then - begin - Parameters := '-NoProfile -ExecutionPolicy Bypass -File "' + ExpandConstant('{app}\Prompt-CcodRestart.ps1') + '" -AppRoot "' + ExpandConstant('{app}') + '" -InstallRoot "' + ExpandConstant('{localappdata}\CodexControlOtherDevices') + '" -ActivationId "' + ActivationId + '"'; - if (not Exec(ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe'), Parameters, '', SW_HIDE, ewWaitUntilTerminated, PromptResultCode)) or (PromptResultCode <> 0) then - SuppressibleMsgBox('Codex restart was not submitted. Restart Codex manually when convenient.', mbInformation, MB_OK, IDOK); - end; - end; -end; - -function IsCanonicalUninstallTransactionId(const Value: String): Boolean; -var - Index: Integer; - Character: Char; -begin - Result := Length(Value) = 36; - if not Result then Exit; - for Index := 1 to Length(Value) do - begin - Character := Value[Index]; - if (Index = 9) or (Index = 14) or (Index = 19) or (Index = 24) then - begin - if Character <> '-' then begin Result := False; Exit; end; - end - else if not (((Character >= '0') and (Character <= '9')) or ((Character >= 'a') and (Character <= 'f'))) then - begin - Result := False; - Exit; - end; - end; -end; - -function TryReadUninstallTransactionId(var TransactionId: String): Boolean; -var - CurrentPath, Text, Marker: String; - Content: AnsiString; - Position: Integer; -begin - Result := False; - TransactionId := ''; - CurrentPath := ExpandConstant('{localappdata}\CodexRemote-fix-uninstall\current.json'); - if not IsSafeActivationFile(CurrentPath) then Exit; - if not LoadBoundedActivationReceipt(CurrentPath, Content) then Exit; - Text := String(Content); - Marker := '"transactionId":"'; - Position := Pos(Marker, Text); - if Position = 0 then Exit; - Position := Position + Length(Marker); - if Position + 36 > Length(Text) + 1 then Exit; - TransactionId := Copy(Text, Position, 36); - if (Position + 36 > Length(Text)) or (Text[Position + 36] <> '"') or not IsCanonicalUninstallTransactionId(TransactionId) then - begin - TransactionId := ''; - Exit; - end; - Result := True; -end; - -function GetExternalUninstallBootstrapPath: String; -var - TransactionId: String; -begin - Result := ''; - if not TryReadUninstallTransactionId(TransactionId) then Exit; - Result := ExpandConstant('{localappdata}\CodexRemote-fix-uninstall\') + TransactionId + '\payload\src\persistence\UninstallBootstrap.ps1'; - if not FileExists(Result) then Result := ''; -end; - -function InitializeUninstall(): Boolean; -var - ResultCode: Integer; - Parameters: String; -begin - Parameters := '-NoProfile -ExecutionPolicy Bypass -File "' + - ExpandConstant('{app}\src\persistence\UninstallBootstrap.ps1') + '" -InstallerRoot "' + - ExpandConstant('{app}') + '" -InstallRoot "' + - ExpandConstant('{localappdata}\CodexControlOtherDevices') + '" -Mode Prepare'; - Result := Exec(ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe'), Parameters, - '', SW_HIDE, ewWaitUntilTerminated, ResultCode) and (ResultCode = 0); - if not Result then - begin - Log('CodexRemote-fix uninstall bootstrap refused pre-deletion cleanup, result code ' + IntToStr(ResultCode) + '.'); - SuppressibleMsgBox('CodexRemote-fix could not verify safe cleanup. No installer files were removed; retry uninstall after resolving the reported support code.', mbError, MB_OK, IDOK); - end; + Parameters := GetCcodBootstrapParameters(ActivationId, False); + if (not Exec(ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe'), Parameters, + '', SW_HIDE, ewWaitUntilTerminated, ActivationResultCode)) or + (ActivationResultCode <> 0) then RaiseException('CCOD_SETUP_ACTIVATION_FAILED'); + Parameters := GetCcodBootstrapParameters(ActivationId, True); + if (not Exec(ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe'), Parameters, + '', SW_HIDE, ewWaitUntilTerminated, ValidationResultCode)) or + (ValidationResultCode <> 0) then RaiseException('CCOD_SETUP_READY_VALIDATION_FAILED'); + WizardForm.StatusLabel.Caption := 'CodexRemote-fix activation is ready.'; + WizardForm.ProgressGauge.Position := 100; end; -procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); -var - ResultCode: Integer; - Parameters, BootstrapPath: String; +procedure DeinitializeSetup(); begin - if CurUninstallStep <> usPostUninstall then Exit; - BootstrapPath := GetExternalUninstallBootstrapPath; - if BootstrapPath = '' then - begin - Log('CodexRemote-fix uninstall completion receipt could not locate the staged bootstrap.'); - RaiseException('CCOD_UNINSTALL_FINALIZATION_MISSING'); - end; - Parameters := '-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "' + BootstrapPath + - '" -InstallerRoot "' + ExpandConstant('{app}') + '" -InstallRoot "' + - ExpandConstant('{localappdata}\CodexControlOtherDevices') + '" -Mode FinalizeReceipt'; - if (not Exec(ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe'), Parameters, - '', SW_HIDE, ewWaitUntilTerminated, ResultCode)) or (ResultCode <> 0) then - begin - Log('CodexRemote-fix uninstall completion receipt was not finalized, result code ' + IntToStr(ResultCode) + '.'); - RaiseException('CCOD_UNINSTALL_FINALIZATION_FAILED'); - end; + CloseCcodInputHandles(); end; diff --git a/build/InstallerPackage.psm1 b/build/InstallerPackage.psm1 new file mode 100644 index 0000000..0c773e0 --- /dev/null +++ b/build/InstallerPackage.psm1 @@ -0,0 +1,243 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$script:CcodInstallerPackageMaximumBytes = 536870912 +$script:CcodInstallerPackageManifestMaximumBytes = 4194304 +$script:CcodInstallerPackageEntryMaximumBytes = 268435456 + +function Throw-CcodInstallerPackageError { + param([Parameter(Mandatory)][string]$Id,[Parameter(Mandatory)][string]$Message,$Target) + throw [Management.Automation.ErrorRecord]::new( + [IO.InvalidDataException]::new($Message), + $Id, + [Management.Automation.ErrorCategory]::InvalidData, + $Target + ) +} + +function Get-CcodInstallerPackageSha256 { + param([Parameter(Mandatory)][string]$Path) + $stream = $null + $sha = [Security.Cryptography.SHA256]::Create() + try { + $stream = [IO.File]::Open([IO.Path]::GetFullPath($Path),[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read) + return [BitConverter]::ToString($sha.ComputeHash($stream)).Replace('-','').ToLowerInvariant() + } finally { + if ($null -ne $stream) { $stream.Dispose() } + $sha.Dispose() + } +} + +function Get-CcodInstallerPackageStreamSha256 { + param([Parameter(Mandatory)][IO.Stream]$Stream) + $sha = [Security.Cryptography.SHA256]::Create() + try { + $Stream.Position = 0 + $value = [BitConverter]::ToString($sha.ComputeHash($Stream)).Replace('-','').ToLowerInvariant() + $Stream.Position = 0 + return $value + } finally { $sha.Dispose() } +} + +function Test-CcodInstallerPackageRelativePath { + param([AllowNull()][string]$Path) + return $Path -is [string] -and $Path -cmatch '^[A-Za-z0-9][A-Za-z0-9._/-]{0,255}$' -and + -not $Path.Contains('//') -and -not $Path.Contains('..') -and -not $Path.Contains(':') -and + -not $Path.Contains('\') -and -not $Path.EndsWith('/') +} + +function Get-CcodInstallerPackageTopLevelJsonPropertyCount { + param([Parameter(Mandatory)][string]$Json,[Parameter(Mandatory)][string]$Name) + $count=0;$depth=0;$inString=$false;$escaped=$false;$expectKey=$false;$captureKey=$false;$keyStart=-1 + for($index=0;$index-lt$Json.Length;$index++){ + $character=$Json[$index] + if($inString){ + if($escaped){$escaped=$false;continue} + if($character-ceq'\'){$escaped=$true;continue} + if($character-ceq'"'){ + if($captureKey){ + try{$key=$Json.Substring($keyStart,$index-$keyStart+1)|ConvertFrom-Json -ErrorAction Stop}catch{return -1} + if($key-isnot[string]){return -1};if($key-ceq$Name){$count++};$expectKey=$false + } + $inString=$false;$captureKey=$false + } + continue + } + if($character-ceq'"'){$inString=$true;$captureKey=($depth-eq 1-and$expectKey);if($captureKey){$keyStart=$index};continue} + if($character-ceq'{'-or$character-ceq'['){$depth++;if($depth-eq 1-and$character-ceq'{'){$expectKey=$true};continue} + if($character-ceq'}'-or$character-ceq']'){$depth--;if($depth-lt 0){return -1};continue} + if($character-ceq','-and$depth-eq 1){$expectKey=$true} + } + if($inString-or$depth-ne 0){return -1};return $count +} + +function Assert-CcodInstallerPackageRegularFile { + param([Parameter(Mandatory)][string]$Path,[Parameter(Mandatory)][string]$Kind,[int64]$MaximumBytes = 536870912) + $full = [IO.Path]::GetFullPath($Path) + if (-not [IO.File]::Exists($full)) { Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' "$Kind is missing" $full } + $item = Get-Item -LiteralPath $full -Force -ErrorAction Stop + if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or $item.Length -le 0 -or $item.Length -gt $MaximumBytes) { + Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' "$Kind is not a bounded regular file" $full + } + return $full +} + +function Test-CcodInstallerPackageJsonInteger { + # ConvertFrom-Json yields [long] for JSON integers under PowerShell 7 and [int] + # under Windows PowerShell 5.1, so accept both shapes at one exact value. + param($Value,[long]$Expected=0) + return ($Value-is[int]-or$Value-is[long])-and[decimal]$Value-eq[decimal][long]$Value-and[long]$Value-eq$Expected +} + +function Read-CcodInstallerPayloadManifest { + param([Parameter(Mandatory)][byte[]]$Bytes,[Parameter(Mandatory)][string]$ExpectedVersion) + try { $text=[Text.UTF8Encoding]::new($false,$true).GetString($Bytes);$manifest=$text|ConvertFrom-Json -ErrorAction Stop } + catch { Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Payload manifest JSON is invalid' $null } + foreach($name in @('schemaVersion','projectVersion','files')){if((Get-CcodInstallerPackageTopLevelJsonPropertyCount $text $name)-ne 1){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Payload manifest contains duplicate or missing fields' $name}} + if($manifest-isnot[pscustomobject]-or(@($manifest.PSObject.Properties.Name)-join',')-cne'schemaVersion,projectVersion,files'-or + -not(Test-CcodInstallerPackageJsonInteger $manifest.schemaVersion 1)-or$manifest.projectVersion-isnot[string]-or$manifest.projectVersion-cne$ExpectedVersion){ + Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Payload manifest metadata is invalid' $null + } + $records=@($manifest.files);if($records.Count-eq 0){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Payload manifest is empty' $null} + $seen=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase);$previous=$null + foreach($record in $records){ + if($record-isnot[pscustomobject]-or(@($record.PSObject.Properties.Name)-join',')-cne'path,length,sha256'-or + -not(Test-CcodInstallerPackageRelativePath ([string]$record.path))-or + $record.length-isnot[ValueType]-or[decimal]$record.length-ne[decimal][int64]$record.length-or[int64]$record.length-lt 0-or[int64]$record.length-gt$script:CcodInstallerPackageEntryMaximumBytes-or + $record.sha256-isnot[string]-or$record.sha256-cnotmatch'^[0-9a-f]{64}$'-or + ($null-ne$previous-and[StringComparer]::Ordinal.Compare($previous,[string]$record.path)-ge 0)-or-not$seen.Add([string]$record.path)){ + Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Payload manifest file record is invalid' $record + } + $previous=[string]$record.path + } + return [pscustomobject]@{Text=$text;Manifest=$manifest;Records=$records} +} + +function Read-CcodInstallerPackageManifest { + param([Parameter(Mandatory)][byte[]]$Bytes,[Parameter(Mandatory)][string]$ExpectedVersion,[Parameter(Mandatory)][string]$ExpectedGitCommit) + try{$text=[Text.UTF8Encoding]::new($false,$true).GetString($Bytes);$manifest=$text|ConvertFrom-Json -ErrorAction Stop}catch{Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Installer package manifest JSON is invalid' $null} + foreach($name in @('schemaVersion','product','version','gitCommit','payloadManifest','files')){if((Get-CcodInstallerPackageTopLevelJsonPropertyCount $text $name)-ne 1){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Installer package manifest contains duplicate or missing fields' $name}} + if($manifest-isnot[pscustomobject]-or(@($manifest.PSObject.Properties.Name)-join',')-cne'schemaVersion,product,version,gitCommit,payloadManifest,files'-or + -not(Test-CcodInstallerPackageJsonInteger $manifest.schemaVersion 1)-or$manifest.product-isnot[string]-or$manifest.product-cne'CodexRemote-fix'-or + $manifest.version-isnot[string]-or$manifest.version-cne$ExpectedVersion-or$manifest.gitCommit-isnot[string]-or$manifest.gitCommit-cne$ExpectedGitCommit-or + $manifest.payloadManifest-isnot[pscustomobject]-or(@($manifest.payloadManifest.PSObject.Properties.Name)-join',')-cne'name,length,sha256'-or + $manifest.payloadManifest.name-isnot[string]-or$manifest.payloadManifest.name-cne'installer-payload.manifest.json'-or + $manifest.payloadManifest.length-isnot[ValueType]-or[decimal]$manifest.payloadManifest.length-ne[decimal][int64]$manifest.payloadManifest.length-or[int64]$manifest.payloadManifest.length-le 0-or[int64]$manifest.payloadManifest.length-gt$script:CcodInstallerPackageManifestMaximumBytes-or + $manifest.payloadManifest.sha256-isnot[string]-or$manifest.payloadManifest.sha256-cnotmatch'^[0-9a-f]{64}$'){ + Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Installer package manifest metadata is invalid' $null + } + return [pscustomobject]@{Text=$text;Manifest=$manifest;Records=@($manifest.files)} +} + +function Assert-CcodInstallerPackageFileRecords { + param([Parameter(Mandatory)]$Records) + $seen=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase);$previous=$null + foreach($record in @($Records)){ + if($record-isnot[pscustomobject]-or(@($record.PSObject.Properties.Name)-join',')-cne'path,length,sha256'-or-not(Test-CcodInstallerPackageRelativePath ([string]$record.path))-or + $record.length-isnot[ValueType]-or[decimal]$record.length-ne[decimal][int64]$record.length-or[int64]$record.length-lt 0-or[int64]$record.length-gt$script:CcodInstallerPackageEntryMaximumBytes-or + $record.sha256-isnot[string]-or$record.sha256-cnotmatch'^[0-9a-f]{64}$'-or($null-ne$previous-and[StringComparer]::Ordinal.Compare($previous,[string]$record.path)-ge 0)-or-not$seen.Add([string]$record.path)){ + Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Installer package file record is invalid' $record + };$previous=[string]$record.path + } + if($seen.Count-eq 0){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Installer package file list is empty' $null} + return [pscustomobject]@{Set=$seen;Count=$seen.Count} +} + +function Open-CcodInstallerPackageSeal { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$PackagePath,[Parameter(Mandatory)][string]$ManifestPath, + [Parameter(Mandatory)][ValidatePattern('^[0-9a-f]{64}$')][string]$ExpectedPackageSha256, + [Parameter(Mandatory)][ValidatePattern('^[0-9a-f]{64}$')][string]$ExpectedManifestSha256, + [Parameter(Mandatory)][ValidatePattern('^\d+\.\d+\.\d+$')][string]$ExpectedVersion, + [Parameter(Mandatory)][ValidatePattern('^[0-9a-f]{40}$')][string]$ExpectedGitCommit + ) + Add-Type -AssemblyName System.IO.Compression -ErrorAction Stop + $package=Assert-CcodInstallerPackageRegularFile $PackagePath 'Installer package' $script:CcodInstallerPackageMaximumBytes + $manifestPathFull=Assert-CcodInstallerPackageRegularFile $ManifestPath 'Installer package manifest' $script:CcodInstallerPackageManifestMaximumBytes + $packageStream=$null;$manifestStream=$null;$archive=$null + try{ + $packageStream=[IO.File]::Open($package,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read) + if((Get-CcodInstallerPackageStreamSha256 $packageStream)-cne$ExpectedPackageSha256){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_HASH_MISMATCH' 'Installer package hash mismatch' $package} + $manifestStream=[IO.File]::Open($manifestPathFull,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read) + if((Get-CcodInstallerPackageStreamSha256 $manifestStream)-cne$ExpectedManifestSha256){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_MANIFEST_HASH_MISMATCH' 'Installer package manifest hash mismatch' $manifestPathFull} + $manifestBytes=New-Object byte[] ([int]$manifestStream.Length);$manifestStream.Position=0;$read=$manifestStream.Read($manifestBytes,0,$manifestBytes.Length);if($read-ne$manifestBytes.Length){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Installer package manifest read was incomplete' $manifestPathFull} + $manifestResult=Read-CcodInstallerPackageManifest -Bytes $manifestBytes -ExpectedVersion $ExpectedVersion -ExpectedGitCommit $ExpectedGitCommit + $expectedResult=Assert-CcodInstallerPackageFileRecords -Records $manifestResult.Records + $expected=$expectedResult.Set + $archive=[IO.Compression.ZipArchive]::new($packageStream,[IO.Compression.ZipArchiveMode]::Read,$true) + $entries=[Collections.Generic.Dictionary[string,object]]::new([StringComparer]::OrdinalIgnoreCase) + foreach($entry in @($archive.Entries)){ + $name=[string]$entry.FullName + if(-not(Test-CcodInstallerPackageRelativePath $name)-or$entries.ContainsKey($name)-or$entry.Name.Length-eq 0-or$entry.Length-lt 0-or$entry.Length-gt$script:CcodInstallerPackageEntryMaximumBytes){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Installer package entry set is unsafe or duplicate' $name} + $entries.Add($name,$entry) + } + if($entries.Count-ne($expected.Count+1)-or-not$entries.ContainsKey('installer-payload.manifest.json')){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Installer package entry set differs from its manifest' $package} + foreach($path in $expected){if(-not$entries.ContainsKey($path)){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Installer package entry is missing' $path}} + $payloadEntry=$entries['installer-payload.manifest.json'];$payloadBytes=New-Object byte[] ([int]$payloadEntry.Length);$entryStream=$payloadEntry.Open();try{$offset=0;while($offset-lt$payloadBytes.Length){$count=$entryStream.Read($payloadBytes,$offset,$payloadBytes.Length-$offset);if($count-le 0){break};$offset+=$count};if($offset-ne$payloadBytes.Length){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Payload manifest entry read was incomplete' $null}}finally{$entryStream.Dispose()} + $payloadSha=[Security.Cryptography.SHA256]::Create();try{$payloadHash=[BitConverter]::ToString($payloadSha.ComputeHash($payloadBytes)).Replace('-','').ToLowerInvariant()}finally{$payloadSha.Dispose()} + if([int64]$payloadBytes.Length-ne[int64]$manifestResult.Manifest.payloadManifest.length-or$payloadHash-cne[string]$manifestResult.Manifest.payloadManifest.sha256){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Embedded payload manifest binding is invalid' $null} + $payloadResult=Read-CcodInstallerPayloadManifest -Bytes $payloadBytes -ExpectedVersion $ExpectedVersion + if(@($payloadResult.Records).Count-ne@($manifestResult.Records).Count){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Payload and package manifests differ' $null} + for($i=0;$i-lt@($payloadResult.Records).Count;$i++){if(($payloadResult.Records[$i]|ConvertTo-Json -Compress)-cne($manifestResult.Records[$i]|ConvertTo-Json -Compress)){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Payload and package file records differ' $null}} + foreach($record in @($manifestResult.Records)){$entry=$entries[[string]$record.path];if([int64]$entry.Length-ne[int64]$record.length){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Installer package entry length mismatch' $record.path};$s=$entry.Open();$sha=[Security.Cryptography.SHA256]::Create();try{$hash=[BitConverter]::ToString($sha.ComputeHash($s)).Replace('-','').ToLowerInvariant()}finally{$sha.Dispose();$s.Dispose()};if($hash-cne[string]$record.sha256){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Installer package entry hash mismatch' $record.path}} + return [pscustomobject]@{Valid=$true;PackagePath=$package;ManifestPath=$manifestPathFull;PackageSha256=$ExpectedPackageSha256;ManifestSha256=$ExpectedManifestSha256;PayloadManifestSha256=$payloadHash;Manifest=$manifestResult.Manifest;PayloadManifestBytes=$payloadBytes;PackageStream=$packageStream;ManifestStream=$manifestStream;Archive=$archive;Entries=$entries} + }catch{if($null-ne$archive){$archive.Dispose()};if($null-ne$manifestStream){$manifestStream.Dispose()};if($null-ne$packageStream){$packageStream.Dispose()};throw} +} + +function Close-CcodInstallerPackageSeal { param($Seal) if($null-eq$Seal){return};foreach($name in @('Archive','ManifestStream','PackageStream')){try{$value=$Seal.$name;if($null-ne$value){$value.Dispose()}}catch{}} } + +function Expand-CcodInstallerPackageSeal { + [CmdletBinding()] + param([Parameter(Mandatory)]$Seal,[Parameter(Mandatory)][string]$DestinationRoot) + if($null-eq$Seal-or-not[bool]$Seal.Valid){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Installer package seal is invalid' $null} + $destination=[IO.Path]::GetFullPath($DestinationRoot) + if([IO.File]::Exists($destination)-or[IO.Directory]::Exists($destination)){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Package extraction destination already exists' $destination} + [IO.Directory]::CreateDirectory($destination)|Out-Null + try{ + $records=@($Seal.Manifest.files)+@([pscustomobject][ordered]@{path='installer-payload.manifest.json';length=[int64]$Seal.PayloadManifestBytes.Length;sha256=[string]$Seal.PayloadManifestSha256}) + foreach($record in $records){ + $relative=[string]$record.path;$target=[IO.Path]::GetFullPath((Join-Path $destination $relative.Replace('/','\')));$prefix=$destination.TrimEnd('\')+'\' + if(-not$target.StartsWith($prefix,[StringComparison]::OrdinalIgnoreCase)){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Package extraction path escaped its destination' $relative} + $parent=Split-Path $target -Parent;if(-not[IO.Directory]::Exists($parent)){[IO.Directory]::CreateDirectory($parent)|Out-Null} + $entry=$Seal.Entries[$relative];$source=$entry.Open();$output=[IO.File]::Open($target,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) + try{$source.CopyTo($output);$output.Flush($true)}finally{$output.Dispose();$source.Dispose()} + if([int64](Get-Item -LiteralPath $target -Force).Length-ne[int64]$record.length-or(Get-CcodInstallerPackageSha256 $target)-cne[string]$record.sha256){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Extracted package entry failed verification' $relative} + } + return $destination + }catch{if([IO.Directory]::Exists($destination)){Remove-Item -LiteralPath $destination -Recurse -Force -ErrorAction SilentlyContinue};throw} +} + +function Test-CcodInstallerPackage { + [CmdletBinding()] + param([Parameter(Mandatory)][string]$PackagePath,[Parameter(Mandatory)][string]$ManifestPath,[Parameter(Mandatory)][ValidatePattern('^[0-9a-f]{64}$')][string]$ExpectedPackageSha256,[Parameter(Mandatory)][ValidatePattern('^[0-9a-f]{64}$')][string]$ExpectedManifestSha256,[Parameter(Mandatory)][ValidatePattern('^\d+\.\d+\.\d+$')][string]$ExpectedVersion,[Parameter(Mandatory)][ValidatePattern('^[0-9a-f]{40}$')][string]$ExpectedGitCommit) + $seal=$null;try{$seal=Open-CcodInstallerPackageSeal @PSBoundParameters;return [pscustomobject]@{Valid=$true;PackageSha256=$seal.PackageSha256;ManifestSha256=$seal.ManifestSha256;PayloadManifestSha256=$seal.PayloadManifestSha256;FileCount=@($seal.Manifest.files).Count}}finally{Close-CcodInstallerPackageSeal $seal} +} + +function New-CcodInstallerPackage { + [CmdletBinding()] + param([Parameter(Mandatory)][string]$PayloadRoot,[Parameter(Mandatory)][string]$PayloadManifestPath,[Parameter(Mandatory)][ValidatePattern('^\d+\.\d+\.\d+$')][string]$Version,[Parameter(Mandatory)][ValidatePattern('^[0-9a-f]{40}$')][string]$GitCommit,[Parameter(Mandatory)][string]$OutputPath,[Parameter(Mandatory)][string]$ManifestOutputPath) + Add-Type -AssemblyName System.IO.Compression -ErrorAction Stop + Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction Stop + $root=[IO.Path]::GetFullPath($PayloadRoot).TrimEnd('\');if(-not[IO.Directory]::Exists($root)){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Payload root is missing' $root};$rootItem=Get-Item -LiteralPath $root -Force;if(($rootItem.Attributes-band[IO.FileAttributes]::ReparsePoint)-ne 0){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Payload root is a reparse point' $root} + $payloadManifest=Assert-CcodInstallerPackageRegularFile $PayloadManifestPath 'Payload manifest' $script:CcodInstallerPackageManifestMaximumBytes;$payloadBytes=[IO.File]::ReadAllBytes($payloadManifest);$payloadResult=Read-CcodInstallerPayloadManifest -Bytes $payloadBytes -ExpectedVersion $Version + $records=@($payloadResult.Records);$actual=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach($item in @(Get-ChildItem -LiteralPath $root -Force -Recurse)) { if(($item.Attributes-band[IO.FileAttributes]::ReparsePoint)-ne 0){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Payload contains a reparse point' $item.FullName};if($item.PSIsContainer-or$item.FullName.Equals($payloadManifest,[StringComparison]::OrdinalIgnoreCase)){continue};$relative=$item.FullName.Substring($root.Length+1).Replace('\','/');[void]$actual.Add($relative) } + if($actual.Count-ne$records.Count){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Payload file set differs from its manifest' $root} + foreach($record in $records){$path=Join-Path $root ([string]$record.path).Replace('/','\');if(-not[IO.File]::Exists($path)-or-not$actual.Contains([string]$record.path)-or[int64](Get-Item -LiteralPath $path -Force).Length-ne[int64]$record.length-or(Get-CcodInstallerPackageSha256 $path)-cne[string]$record.sha256){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Payload file differs from its manifest' $record.path}} + $payloadSha=[Security.Cryptography.SHA256]::Create();try{$payloadHash=[BitConverter]::ToString($payloadSha.ComputeHash($payloadBytes)).Replace('-','').ToLowerInvariant()}finally{$payloadSha.Dispose()} + $packageManifest=[ordered]@{schemaVersion=1;product='CodexRemote-fix';version=$Version;gitCommit=$GitCommit;payloadManifest=[ordered]@{name='installer-payload.manifest.json';length=[int64]$payloadBytes.Length;sha256=$payloadHash};files=$records} + $output=[IO.Path]::GetFullPath($OutputPath);$manifestOutput=[IO.Path]::GetFullPath($ManifestOutputPath);if(-not[IO.Path]::IsPathRooted($OutputPath)-or-not[IO.Path]::IsPathRooted($ManifestOutputPath)-or$output-eq$manifestOutput-or[IO.File]::Exists($output)-or[IO.Directory]::Exists($output)-or[IO.File]::Exists($manifestOutput)-or[IO.Directory]::Exists($manifestOutput)){Throw-CcodInstallerPackageError 'CCOD_INSTALLER_PACKAGE_INVALID' 'Package outputs must be distinct absent absolute paths' $output} + foreach($parent in @((Split-Path $output -Parent),(Split-Path $manifestOutput -Parent))){if(-not[IO.Directory]::Exists($parent)){[IO.Directory]::CreateDirectory($parent)|Out-Null}} + $tempPackage=Join-Path (Split-Path $output -Parent) ('.ccod.'+[guid]::NewGuid().ToString('N')+'.zip.tmp');$tempManifest=Join-Path (Split-Path $manifestOutput -Parent) ('.ccod.'+[guid]::NewGuid().ToString('N')+'.json.tmp') + try{ + $archive=[IO.Compression.ZipFile]::Open($tempPackage,[IO.Compression.ZipArchiveMode]::Create);try{foreach($record in $records){$entry=$archive.CreateEntry([string]$record.path,[IO.Compression.CompressionLevel]::Optimal);$source=[IO.File]::OpenRead((Join-Path $root ([string]$record.path).Replace('/','\')));$dest=$entry.Open();try{$source.CopyTo($dest)}finally{$dest.Dispose();$source.Dispose()}};$entry=$archive.CreateEntry('installer-payload.manifest.json',[IO.Compression.CompressionLevel]::Optimal);$dest=$entry.Open();try{$dest.Write($payloadBytes,0,$payloadBytes.Length)}finally{$dest.Dispose()}}finally{$archive.Dispose()} + [IO.File]::WriteAllText($tempManifest,(($packageManifest|ConvertTo-Json -Depth 8)+[Environment]::NewLine),[Text.UTF8Encoding]::new($false)) + $packageHash=Get-CcodInstallerPackageSha256 $tempPackage;$manifestHash=Get-CcodInstallerPackageSha256 $tempManifest + Test-CcodInstallerPackage -PackagePath $tempPackage -ManifestPath $tempManifest -ExpectedPackageSha256 $packageHash -ExpectedManifestSha256 $manifestHash -ExpectedVersion $Version -ExpectedGitCommit $GitCommit|Out-Null + [IO.File]::Move($tempPackage,$output);[IO.File]::Move($tempManifest,$manifestOutput) + return [pscustomobject]@{PackagePath=$output;ManifestPath=$manifestOutput;PackageSha256=$packageHash;ManifestSha256=$manifestHash;PayloadManifestSha256=$payloadHash;FileCount=$records.Count} + }catch{foreach($path in @($tempPackage,$tempManifest)){if([IO.File]::Exists($path)){[IO.File]::Delete($path)}};if([IO.File]::Exists($output)-and-not[IO.File]::Exists($manifestOutput)){[IO.File]::Delete($output)};throw} +} + +Export-ModuleMember -Function New-CcodInstallerPackage,Test-CcodInstallerPackage,Open-CcodInstallerPackageSeal,Close-CcodInstallerPackageSeal,Expand-CcodInstallerPackageSeal,Get-CcodInstallerPackageSha256 diff --git a/build/SetupArtifact.psm1 b/build/SetupArtifact.psm1 new file mode 100644 index 0000000..441091b --- /dev/null +++ b/build/SetupArtifact.psm1 @@ -0,0 +1,492 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# PowerShell 7.5+ materializes ISO-8601 JSON strings as [datetime]. These modules +# validate provenance timestamps as canonical UTC text, so read every JSON document +# with -DateKind String when the shell offers it and keep the Windows PowerShell 5.1 +# behavior (dates stay text) identical across both shells. +$script:CcodSetupArtifactJsonKeepsDateText = @((Get-Command ConvertFrom-Json).Parameters.Keys) -contains 'DateKind' + +function Test-CcodSetupArtifactJsonInteger { + # ConvertFrom-Json yields [long] for JSON integers under PowerShell 7 and [int] + # under Windows PowerShell 5.1, so accept both shapes at one exact value. + param($Value,[long]$Expected=0) + return ($Value-is[int]-or$Value-is[long])-and[decimal]$Value-eq[decimal][long]$Value-and[long]$Value-eq$Expected +} + +function ConvertFrom-CcodSetupArtifactJson { + [CmdletBinding()] + param([Parameter(Mandatory)][AllowEmptyString()][string]$Json) + if ($script:CcodSetupArtifactJsonKeepsDateText) { return $Json | ConvertFrom-Json -DateKind String -ErrorAction Stop } + return $Json | ConvertFrom-Json -ErrorAction Stop +} + +function Throw-CcodSetupArtifactError { + param([Parameter(Mandatory)][string]$Id,[Parameter(Mandatory)][string]$Message,$Target) + throw [Management.Automation.ErrorRecord]::new( + [InvalidOperationException]::new($Message), + $Id, + [Management.Automation.ErrorCategory]::InvalidData, + $Target + ) +} + +function Get-CcodSetupArtifactHash { + param([Parameter(Mandatory)][string]$Path) + $sha = [Security.Cryptography.SHA256]::Create() + $stream = $null + try { + $stream = [IO.File]::Open([IO.Path]::GetFullPath($Path),[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read) + return [BitConverter]::ToString($sha.ComputeHash($stream)).Replace('-','').ToLowerInvariant() + } finally { + if ($null -ne $stream) { $stream.Dispose() } + $sha.Dispose() + } +} + +function Test-CcodSetupCanonicalUtc { + param([AllowNull()][string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return $false } + $parsed = [DateTime]::MinValue + return [DateTime]::TryParseExact($Value,'o',[Globalization.CultureInfo]::InvariantCulture,[Globalization.DateTimeStyles]::RoundtripKind,[ref]$parsed) -and + $parsed.Kind -eq [DateTimeKind]::Utc -and + $parsed.ToUniversalTime().ToString('o',[Globalization.CultureInfo]::InvariantCulture) -ceq $Value +} + +function Initialize-CcodSetupJsonMemberScanner { + if ($null -ne ('CcodSetupJsonMemberScannerV1' -as [type])) { return } + Add-Type -TypeDefinition @' +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; + +public static class CcodSetupJsonMemberScannerV1 +{ + public static void AssertUnique(string json) + { + if (json == null || json.Length == 0 || json.Length > 1048576) throw new InvalidDataException("json bounds"); + int index = 0; + ParseValue(json, ref index, 0); + Skip(json, ref index); + if (index != json.Length) throw new InvalidDataException("trailing json"); + } + + private static void ParseValue(string json, ref int index, int depth) + { + if (depth > 32) throw new InvalidDataException("json depth"); + Skip(json, ref index); + if (index >= json.Length) throw new InvalidDataException("missing value"); + char c = json[index]; + if (c == '{') { ParseObject(json, ref index, depth + 1); return; } + if (c == '[') { ParseArray(json, ref index, depth + 1); return; } + if (c == '"') { ParseString(json, ref index); return; } + int start = index; + while (index < json.Length && json[index] != ',' && json[index] != '}' && json[index] != ']' && !Char.IsWhiteSpace(json[index])) index++; + if (index == start) throw new InvalidDataException("invalid scalar"); + } + + private static void ParseObject(string json, ref int index, int depth) + { + index++; + var names = new HashSet(StringComparer.Ordinal); + Skip(json, ref index); + if (index < json.Length && json[index] == '}') { index++; return; } + while (true) + { + Skip(json, ref index); + if (index >= json.Length || json[index] != '"') throw new InvalidDataException("object key"); + string name = ParseString(json, ref index); + if (!names.Add(name)) throw new InvalidDataException("duplicate object member: " + name); + Skip(json, ref index); + if (index >= json.Length || json[index] != ':') throw new InvalidDataException("object colon"); + index++; + ParseValue(json, ref index, depth); + Skip(json, ref index); + if (index >= json.Length) throw new InvalidDataException("object end"); + if (json[index] == '}') { index++; return; } + if (json[index] != ',') throw new InvalidDataException("object comma"); + index++; + } + } + + private static void ParseArray(string json, ref int index, int depth) + { + index++; + Skip(json, ref index); + if (index < json.Length && json[index] == ']') { index++; return; } + while (true) + { + ParseValue(json, ref index, depth); + Skip(json, ref index); + if (index >= json.Length) throw new InvalidDataException("array end"); + if (json[index] == ']') { index++; return; } + if (json[index] != ',') throw new InvalidDataException("array comma"); + index++; + } + } + + private static string ParseString(string json, ref int index) + { + if (json[index] != '"') throw new InvalidDataException("string start"); + index++; + var value = new StringBuilder(); + while (index < json.Length) + { + char c = json[index++]; + if (c == '"') return value.ToString(); + if (c < 0x20) throw new InvalidDataException("string control"); + if (c != '\\') { value.Append(c); continue; } + if (index >= json.Length) throw new InvalidDataException("string escape"); + char escaped = json[index++]; + switch (escaped) + { + case '"': value.Append('"'); break; + case '\\': value.Append('\\'); break; + case '/': value.Append('/'); break; + case 'b': value.Append('\b'); break; + case 'f': value.Append('\f'); break; + case 'n': value.Append('\n'); break; + case 'r': value.Append('\r'); break; + case 't': value.Append('\t'); break; + case 'u': + if (index + 4 > json.Length) throw new InvalidDataException("unicode escape"); + int code; + if (!Int32.TryParse(json.Substring(index, 4), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out code)) throw new InvalidDataException("unicode escape"); + value.Append((char)code); index += 4; break; + default: throw new InvalidDataException("unknown escape"); + } + } + throw new InvalidDataException("unterminated string"); + } + + private static void Skip(string json, ref int index) + { + while (index < json.Length && Char.IsWhiteSpace(json[index])) index++; + } +} +'@ +} + +function Assert-CcodSetupUniqueJsonMembers { + param([Parameter(Mandatory)][string]$Json,[Parameter(Mandatory)]$Target) + try { + Initialize-CcodSetupJsonMemberScanner + [CcodSetupJsonMemberScannerV1]::AssertUnique($Json) + } catch { + Throw-CcodSetupArtifactError 'CCOD_SETUP_PROVENANCE_INVALID' 'JSON contains duplicate members or is outside the bounded raw schema' $Target + } +} + +function Get-CcodSetupDescription { + param([Parameter(Mandatory)][ValidatePattern('^\d+\.\d+\.\d+$')][string]$Version) + return "CCODSETUP $Version" +} + +function Assert-CcodSetupRegularFile { + param([Parameter(Mandatory)][string]$Path,[Parameter(Mandatory)][string]$Kind) + $full = [IO.Path]::GetFullPath($Path) + if (-not [IO.File]::Exists($full)) { Throw-CcodSetupArtifactError 'CCOD_SETUP_ARTIFACT_INVALID' "$Kind is missing" $full } + $item = Get-Item -LiteralPath $full -Force -ErrorAction Stop + if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + Throw-CcodSetupArtifactError 'CCOD_SETUP_ARTIFACT_INVALID' "$Kind must be a regular non-reparse file" $full + } + return $full +} + +function Test-CcodSetupArtifact { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$SetupPath, + [Parameter(Mandatory)][ValidatePattern('^\d+\.\d+\.\d+$')][string]$ExpectedVersion, + [Parameter(Mandatory)][ValidatePattern('^[0-9a-f]{40}$')][string]$ExpectedGitCommit, + [ValidatePattern('^[0-9a-f]{64}$')][string]$ExpectedPayloadManifestSha256, + [ValidatePattern('^[0-9a-f]{64}$')][string]$ExpectedPackageSha256, + [ValidatePattern('^[0-9a-f]{64}$')][string]$ExpectedPackageManifestSha256, + [ValidatePattern('^[0-9a-f]{64}$')][string]$ExpectedActivationBootstrapSha256 + ) + + $setup = Assert-CcodSetupRegularFile -Path $SetupPath -Kind 'Setup artifact' + $expectedPeVersion = "$ExpectedVersion.0" + $expectedDescription = Get-CcodSetupDescription -Version $ExpectedVersion + try { $versionInfo = [Diagnostics.FileVersionInfo]::GetVersionInfo($setup) } + catch { Throw-CcodSetupArtifactError 'CCOD_SETUP_PE_VERSION_INVALID' 'Setup PE version metadata could not be read' $setup } + $fileVersion = ([string]$versionInfo.FileVersion).Trim() + $productVersion = ([string]$versionInfo.ProductVersion).Trim() + $productName = ([string]$versionInfo.ProductName).Trim() + $fileDescription = ([string]$versionInfo.FileDescription).Trim() + $companyName = ([string]$versionInfo.CompanyName).Trim() + $legalCopyright = ([string]$versionInfo.LegalCopyright).Trim() + $originalFilename = ([string]$versionInfo.OriginalFilename).Trim() + $sealedContract = -not [string]::IsNullOrWhiteSpace($ExpectedPackageSha256) -or -not [string]::IsNullOrWhiteSpace($ExpectedPackageManifestSha256) -or -not [string]::IsNullOrWhiteSpace($ExpectedActivationBootstrapSha256) + if ($sealedContract -and ([string]::IsNullOrWhiteSpace($ExpectedPackageSha256) -or [string]::IsNullOrWhiteSpace($ExpectedPackageManifestSha256) -or [string]::IsNullOrWhiteSpace($ExpectedActivationBootstrapSha256))) { + Throw-CcodSetupArtifactError 'CCOD_SETUP_PAYLOAD_BINDING_INVALID' 'The sealed Setup PE contract requires all three hashes' $setup + } + $expectedProductVersion = if ($sealedContract) { $ExpectedPackageManifestSha256.Substring(0,32) } else { $expectedPeVersion } + if ($sealedContract) { $expectedDescription = $ExpectedPackageManifestSha256.Substring(32,32) } + $expectedProductName = if ($sealedContract) { $ExpectedActivationBootstrapSha256.Substring(32,32) } else { 'CodexRemote-fix' } + $expectedOriginalFilename = if ($sealedContract) { $ExpectedActivationBootstrapSha256.Substring(0,32) } else { $originalFilename } + if ($fileVersion -cne $expectedPeVersion -or $productVersion -cne $expectedProductVersion) { + Throw-CcodSetupArtifactError 'CCOD_SETUP_PE_VERSION_INVALID' ("Setup PE FileVersion/ProductVersion do not match the release version: file={0}; product={1}; expected={2}" -f [string]$versionInfo.FileVersion,[string]$versionInfo.ProductVersion,$expectedPeVersion) $setup + } + $expectedCopyright = if ($sealedContract) { $ExpectedPackageSha256 } else { $ExpectedPayloadManifestSha256 } + if ($productName -cne $expectedProductName -or $fileDescription -cne $expectedDescription -or + $companyName -cne $ExpectedGitCommit -or $legalCopyright -cne $expectedCopyright -or + ($sealedContract -and $originalFilename -cne $expectedOriginalFilename)) { + Throw-CcodSetupArtifactError 'CCOD_SETUP_PAYLOAD_BINDING_INVALID' ("Setup PE does not bind the expected version, commit, package, manifest, and bootstrap hashes: product={0}; productVersion={1}; description={2}; company={3}; copyright={4}; original={5}" -f $productName,$productVersion,$fileDescription,$companyName,$legalCopyright,$originalFilename) $setup + } + return [pscustomobject][ordered]@{ + Valid = $true + Sha256 = Get-CcodSetupArtifactHash -Path $setup + FileVersion = $fileVersion + ProductVersion = $productVersion + ProductName = $productName + FileDescription = $fileDescription + CompanyName = $companyName + LegalCopyright = $legalCopyright + OriginalFilename = $originalFilename + PackageManifestSha256 = if ($sealedContract) { $productVersion + $fileDescription } else { $null } + ActivationBootstrapSha256 = if ($sealedContract) { $originalFilename + $productName } else { $null } + } +} + +function New-CcodSetupBuildProvenance { + [CmdletBinding()] + param( + [Parameter(Mandatory)][ValidatePattern('^\d+\.\d+\.\d+$')][string]$Version, + [Parameter(Mandatory)][ValidatePattern('^[0-9a-f]{40}$')][string]$GitCommit, + [Parameter(Mandatory)][string]$BuildTimestampUtc, + [Parameter(Mandatory)][string]$PayloadManifestPath, + [Parameter(Mandatory)][string]$InnoTemplatePath, + [Parameter(Mandatory)][string]$DestinationInventoryPath, + [Parameter(Mandatory)][string]$CompilerPath, + [Parameter(Mandatory)][string]$OutputPath + ) + + if (-not (Test-CcodSetupCanonicalUtc $BuildTimestampUtc)) { Throw-CcodSetupArtifactError 'CCOD_SETUP_PROVENANCE_INVALID' 'Setup provenance timestamp is not canonical UTC' $BuildTimestampUtc } + $payload = Assert-CcodSetupRegularFile -Path $PayloadManifestPath -Kind 'Installer payload manifest' + $template = Assert-CcodSetupRegularFile -Path $InnoTemplatePath -Kind 'Inno template' + $inventory = Assert-CcodSetupRegularFile -Path $DestinationInventoryPath -Kind 'Destination inventory' + $compiler = Assert-CcodSetupRegularFile -Path $CompilerPath -Kind 'Inno compiler' + try { $payloadRecord = [IO.File]::ReadAllText($payload,[Text.UTF8Encoding]::new($false)) | ConvertFrom-Json -ErrorAction Stop } + catch { Throw-CcodSetupArtifactError 'CCOD_SETUP_PROVENANCE_INVALID' 'Installer payload manifest is invalid JSON' $payload } + if (-not (Test-CcodSetupArtifactJsonInteger $payloadRecord.schemaVersion 1) -or + $payloadRecord.projectVersion -isnot [string] -or $payloadRecord.projectVersion -cne $Version -or + @($payloadRecord.files).Count -eq 0) { Throw-CcodSetupArtifactError 'CCOD_SETUP_PROVENANCE_INVALID' 'Installer payload manifest metadata is invalid' $payload } + $payloadHash = Get-CcodSetupArtifactHash -Path $payload + $compilerVersion = [Diagnostics.FileVersionInfo]::GetVersionInfo($compiler) + $record = [ordered]@{ + schemaVersion = 1 + product = 'CodexRemote-fix' + version = $Version + gitCommit = $GitCommit + buildTimestampUtc = $BuildTimestampUtc + payloadManifest = [ordered]@{ + name = 'installer-payload.manifest.json' + length = [int64](Get-Item -LiteralPath $payload -Force).Length + sha256 = $payloadHash + fileCount = [int]@($payloadRecord.files).Count + } + buildInputs = [ordered]@{ + innoTemplateSha256 = Get-CcodSetupArtifactHash -Path $template + destinationInventorySha256 = Get-CcodSetupArtifactHash -Path $inventory + compilerSha256 = Get-CcodSetupArtifactHash -Path $compiler + compilerFileVersion = [string]$compilerVersion.FileVersion + } + peContract = [ordered]@{ + fileVersion = "$Version.0" + productVersion = "$Version.0" + productName = 'CodexRemote-fix' + fileDescription = Get-CcodSetupDescription -Version $Version + companyName = $GitCommit + legalCopyright = $payloadHash + } + } + $output = [IO.Path]::GetFullPath($OutputPath) + if ([IO.File]::Exists($output) -or [IO.Directory]::Exists($output)) { Throw-CcodSetupArtifactError 'CCOD_SETUP_PROVENANCE_INVALID' 'Refusing to overwrite Setup provenance' $output } + [IO.File]::WriteAllText($output,(($record | ConvertTo-Json -Depth 8) + [Environment]::NewLine),[Text.UTF8Encoding]::new($false)) + return [pscustomobject]$record +} + +function Test-CcodSetupBuildProvenance { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$ProvenancePath, + [Parameter(Mandatory)][ValidatePattern('^\d+\.\d+\.\d+$')][string]$ExpectedVersion, + [Parameter(Mandatory)][ValidatePattern('^[0-9a-f]{40}$')][string]$ExpectedGitCommit, + [Parameter(Mandatory)][ValidatePattern('^[0-9a-f]{64}$')][string]$ExpectedPayloadManifestSha256, + [string]$ExpectedBuildTimestampUtc, + [Parameter(Mandatory)][string]$InnoTemplatePath, + [Parameter(Mandatory)][string]$DestinationInventoryPath, + [Parameter(Mandatory)][string]$CompilerPath, + [Parameter(Mandatory)][string]$PayloadManifestPath + ) + $path = Assert-CcodSetupRegularFile -Path $ProvenancePath -Kind 'Setup provenance' + $template = Assert-CcodSetupRegularFile -Path $InnoTemplatePath -Kind 'Inno template' + $inventory = Assert-CcodSetupRegularFile -Path $DestinationInventoryPath -Kind 'Destination inventory' + $compiler = Assert-CcodSetupRegularFile -Path $CompilerPath -Kind 'Inno compiler' + $payload = Assert-CcodSetupRegularFile -Path $PayloadManifestPath -Kind 'Installer payload manifest' + try { $payloadRaw = [IO.File]::ReadAllText($payload,[Text.UTF8Encoding]::new($false)); $payloadRecord = ConvertFrom-CcodSetupArtifactJson -Json $payloadRaw } + catch { Throw-CcodSetupArtifactError 'CCOD_SETUP_PROVENANCE_INVALID' 'Canonical installer payload manifest is invalid' $payload } + $actualPayloadHash = Get-CcodSetupArtifactHash -Path $payload + $actualCompilerVersion = ([string][Diagnostics.FileVersionInfo]::GetVersionInfo($compiler).FileVersion).Trim() + try { $raw = [IO.File]::ReadAllText($path,[Text.UTF8Encoding]::new($false)); $record = ConvertFrom-CcodSetupArtifactJson -Json $raw } + catch { Throw-CcodSetupArtifactError 'CCOD_SETUP_PROVENANCE_INVALID' 'Setup provenance is invalid JSON' $path } + $timestampMatches = [regex]::Matches($raw,'"buildTimestampUtc"\s*:\s*"(?[^"\\]+)"') + $timestampText = if ($timestampMatches.Count -eq 1) { [string]$timestampMatches[0].Groups['value'].Value } else { $null } + $fields = @('schemaVersion','product','version','gitCommit','buildTimestampUtc','payloadManifest','buildInputs','peContract') + if ($record -isnot [pscustomobject] -or ((@($record.PSObject.Properties.Name | Sort-Object) -join '|') -cne (@($fields | Sort-Object) -join '|')) -or + -not (Test-CcodSetupArtifactJsonInteger $record.schemaVersion 1) -or $record.product -isnot [string] -or $record.product -cne 'CodexRemote-fix' -or + $record.version -isnot [string] -or $record.version -cne $ExpectedVersion -or $record.gitCommit -isnot [string] -or $record.gitCommit -cne $ExpectedGitCommit -or + -not (Test-CcodSetupCanonicalUtc $timestampText) -or + (-not [string]::IsNullOrWhiteSpace($ExpectedBuildTimestampUtc) -and $timestampText -cne $ExpectedBuildTimestampUtc) -or + $record.payloadManifest.sha256 -isnot [string] -or $record.payloadManifest.sha256 -cne $ExpectedPayloadManifestSha256 -or $record.payloadManifest.sha256 -cne $actualPayloadHash -or + $record.payloadManifest.name -isnot [string] -or $record.payloadManifest.name -cne 'installer-payload.manifest.json' -or + [int64]$record.payloadManifest.length -ne [int64](Get-Item -LiteralPath $payload -Force).Length -or + [int]$record.payloadManifest.fileCount -ne [int]@($payloadRecord.files).Count -or + $record.peContract.fileVersion -isnot [string] -or $record.peContract.fileVersion -cne "$ExpectedVersion.0" -or + $record.peContract.productVersion -isnot [string] -or $record.peContract.productVersion -cne "$ExpectedVersion.0" -or + $record.peContract.productName -isnot [string] -or $record.peContract.productName -cne 'CodexRemote-fix' -or + $record.peContract.fileDescription -isnot [string] -or $record.peContract.fileDescription -cne (Get-CcodSetupDescription -Version $ExpectedVersion) -or + $record.peContract.companyName -isnot [string] -or $record.peContract.companyName -cne $ExpectedGitCommit -or + $record.peContract.legalCopyright -isnot [string] -or $record.peContract.legalCopyright -cne $ExpectedPayloadManifestSha256 -or + $record.buildInputs.innoTemplateSha256 -isnot [string] -or $record.buildInputs.innoTemplateSha256 -cne (Get-CcodSetupArtifactHash -Path $template) -or + $record.buildInputs.destinationInventorySha256 -isnot [string] -or $record.buildInputs.destinationInventorySha256 -cne (Get-CcodSetupArtifactHash -Path $inventory) -or + $record.buildInputs.compilerSha256 -isnot [string] -or $record.buildInputs.compilerSha256 -cne (Get-CcodSetupArtifactHash -Path $compiler) -or + $record.buildInputs.compilerFileVersion -isnot [string] -or ([string]$record.buildInputs.compilerFileVersion).Trim() -cne $actualCompilerVersion) { + Throw-CcodSetupArtifactError 'CCOD_SETUP_PROVENANCE_INVALID' 'Setup provenance is not exactly bound to the release contract' $path + } + return $record +} + +function New-CcodSealedSetupBuildProvenance { + [CmdletBinding()] + param( + [Parameter(Mandatory)][ValidatePattern('^\d+\.\d+\.\d+$')][string]$Version, + [Parameter(Mandatory)][ValidatePattern('^[0-9a-f]{40}$')][string]$GitCommit, + [Parameter(Mandatory)][string]$BuildTimestampUtc, + [Parameter(Mandatory)][string]$PackagePath, + [Parameter(Mandatory)][string]$PackageManifestPath, + [Parameter(Mandatory)][string]$ActivationBootstrapPath, + [Parameter(Mandatory)][string]$InnoTemplatePath, + [Parameter(Mandatory)][string]$DestinationInventoryPath, + [Parameter(Mandatory)][string]$CompilerPath, + [Parameter(Mandatory)][string]$OutputPath + ) + if (-not (Test-CcodSetupCanonicalUtc $BuildTimestampUtc)) { Throw-CcodSetupArtifactError 'CCOD_SETUP_PROVENANCE_INVALID' 'Sealed Setup provenance timestamp is invalid' $BuildTimestampUtc } + $package=Assert-CcodSetupRegularFile $PackagePath 'Installer package';$manifest=Assert-CcodSetupRegularFile $PackageManifestPath 'Installer package manifest';$bootstrap=Assert-CcodSetupRegularFile $ActivationBootstrapPath 'Activation bootstrap';$template=Assert-CcodSetupRegularFile $InnoTemplatePath 'Inno template';$inventory=Assert-CcodSetupRegularFile $DestinationInventoryPath 'Destination inventory';$compiler=Assert-CcodSetupRegularFile $CompilerPath 'Inno compiler' + try{$manifestRecord=ConvertFrom-CcodSetupArtifactJson -Json ([IO.File]::ReadAllText($manifest,[Text.UTF8Encoding]::new($false)))}catch{Throw-CcodSetupArtifactError 'CCOD_SETUP_PROVENANCE_INVALID' 'Installer package manifest JSON is invalid' $manifest} + if(-not(Test-CcodSetupArtifactJsonInteger $manifestRecord.schemaVersion 1)-or$manifestRecord.product-cne'CodexRemote-fix'-or$manifestRecord.version-cne$Version-or$manifestRecord.gitCommit-cne$GitCommit-or@($manifestRecord.files).Count-eq0){Throw-CcodSetupArtifactError 'CCOD_SETUP_PROVENANCE_INVALID' 'Installer package manifest identity is invalid' $manifest} + $packageHash=Get-CcodSetupArtifactHash $package;$manifestHash=Get-CcodSetupArtifactHash $manifest;$bootstrapHash=Get-CcodSetupArtifactHash $bootstrap;$compilerInfo=[Diagnostics.FileVersionInfo]::GetVersionInfo($compiler) + $record=[ordered]@{ + schemaVersion=2;product='CodexRemote-fix';version=$Version;gitCommit=$GitCommit;buildTimestampUtc=$BuildTimestampUtc + installerPackage=[ordered]@{name='installer-package.zip';length=[int64](Get-Item $package -Force).Length;sha256=$packageHash} + installerPackageManifest=[ordered]@{name='installer-package.manifest.json';length=[int64](Get-Item $manifest -Force).Length;sha256=$manifestHash;fileCount=[int]@($manifestRecord.files).Count;payloadManifestSha256=[string]$manifestRecord.payloadManifest.sha256} + activationBootstrap=[ordered]@{name='Activate-CcodRemoteFix.ps1';length=[int64](Get-Item $bootstrap -Force).Length;sha256=$bootstrapHash} + buildInputs=[ordered]@{innoTemplateSha256=Get-CcodSetupArtifactHash $template;destinationInventorySha256=Get-CcodSetupArtifactHash $inventory;compilerSha256=Get-CcodSetupArtifactHash $compiler;compilerFileVersion=[string]$compilerInfo.FileVersion} + peContract=[ordered]@{fileVersion="$Version.0";packageManifestFirst=$manifestHash.Substring(0,32);packageManifestLast=$manifestHash.Substring(32,32);bootstrapFirst=$bootstrapHash.Substring(0,32);bootstrapLast=$bootstrapHash.Substring(32,32);companyName=$GitCommit;legalCopyright=$packageHash} + } + $output=[IO.Path]::GetFullPath($OutputPath);if([IO.File]::Exists($output)-or[IO.Directory]::Exists($output)){Throw-CcodSetupArtifactError 'CCOD_SETUP_PROVENANCE_INVALID' 'Refusing to overwrite sealed Setup provenance' $output} + [IO.File]::WriteAllText($output,(($record|ConvertTo-Json -Depth 8)+[Environment]::NewLine),[Text.UTF8Encoding]::new($false));return [pscustomobject]$record +} + +function Test-CcodSealedSetupBuildProvenance { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$ProvenancePath,[Parameter(Mandatory)][ValidatePattern('^\d+\.\d+\.\d+$')][string]$ExpectedVersion,[Parameter(Mandatory)][ValidatePattern('^[0-9a-f]{40}$')][string]$ExpectedGitCommit, + [Parameter(Mandatory)][ValidatePattern('^[0-9a-f]{64}$')][string]$ExpectedPackageSha256,[Parameter(Mandatory)][ValidatePattern('^[0-9a-f]{64}$')][string]$ExpectedPackageManifestSha256,[Parameter(Mandatory)][ValidatePattern('^[0-9a-f]{64}$')][string]$ExpectedActivationBootstrapSha256,[Parameter(Mandatory)][string]$ExpectedBuildTimestampUtc, + [Parameter(Mandatory)][string]$PackagePath,[Parameter(Mandatory)][string]$PackageManifestPath,[Parameter(Mandatory)][string]$ActivationBootstrapPath,[Parameter(Mandatory)][string]$InnoTemplatePath,[Parameter(Mandatory)][string]$DestinationInventoryPath,[Parameter(Mandatory)][string]$CompilerPath + ) + $path=Assert-CcodSetupRegularFile $ProvenancePath 'Sealed Setup provenance' + $package=Assert-CcodSetupRegularFile $PackagePath 'Installer package' + $manifest=Assert-CcodSetupRegularFile $PackageManifestPath 'Installer package manifest' + $bootstrap=Assert-CcodSetupRegularFile $ActivationBootstrapPath 'Activation bootstrap' + $template=Assert-CcodSetupRegularFile $InnoTemplatePath 'Inno template' + $inventory=Assert-CcodSetupRegularFile $DestinationInventoryPath 'Destination inventory' + $compiler=Assert-CcodSetupRegularFile $CompilerPath 'Inno compiler' + try { + $raw=[IO.File]::ReadAllText($path,[Text.UTF8Encoding]::new($false)) + Assert-CcodSetupUniqueJsonMembers -Json $raw -Target $path + $record=ConvertFrom-CcodSetupArtifactJson -Json $raw + $manifestRaw=[IO.File]::ReadAllText($manifest,[Text.UTF8Encoding]::new($false)) + Assert-CcodSetupUniqueJsonMembers -Json $manifestRaw -Target $manifest + $packageManifest=ConvertFrom-CcodSetupArtifactJson -Json $manifestRaw + } catch { Throw-CcodSetupArtifactError 'CCOD_SETUP_PROVENANCE_INVALID' 'Sealed Setup provenance or package manifest JSON is invalid' $path } + + function Test-CcodExactObjectProperties { + param($Value,[string[]]$Names) + return $Value-is[pscustomobject]-and(@($Value.PSObject.Properties.Name)-join',')-ceq($Names-join',') + } + function Test-CcodJsonInteger { + param($Value) + return ($Value-is[int]-or$Value-is[long])-and[decimal]$Value-eq[decimal][long]$Value + } + function Test-CcodExactString { + param($Value,[string]$Expected) + return $Value-is[string]-and$Value-ceq$Expected + } + + $top=@('schemaVersion','product','version','gitCommit','buildTimestampUtc','installerPackage','installerPackageManifest','activationBootstrap','buildInputs','peContract') + $artifactFields=@('name','length','sha256') + $manifestFields=@('name','length','sha256','fileCount','payloadManifestSha256') + $buildFields=@('innoTemplateSha256','destinationInventorySha256','compilerSha256','compilerFileVersion') + $peFields=@('fileVersion','packageManifestFirst','packageManifestLast','bootstrapFirst','bootstrapLast','companyName','legalCopyright') + $packageManifestFields=@('schemaVersion','product','version','gitCommit','payloadManifest','files') + $payloadManifestFields=@('name','length','sha256') + if(-not(Test-CcodExactObjectProperties $record $top)-or + -not(Test-CcodExactObjectProperties $record.installerPackage $artifactFields)-or + -not(Test-CcodExactObjectProperties $record.installerPackageManifest $manifestFields)-or + -not(Test-CcodExactObjectProperties $record.activationBootstrap $artifactFields)-or + -not(Test-CcodExactObjectProperties $record.buildInputs $buildFields)-or + -not(Test-CcodExactObjectProperties $record.peContract $peFields)-or + -not(Test-CcodExactObjectProperties $packageManifest $packageManifestFields)-or + -not(Test-CcodExactObjectProperties $packageManifest.payloadManifest $payloadManifestFields)){ + Throw-CcodSetupArtifactError 'CCOD_SETUP_PROVENANCE_INVALID' 'Sealed Setup provenance property schema is invalid' $path + } + $actualCompilerVersion=([string][Diagnostics.FileVersionInfo]::GetVersionInfo($compiler).FileVersion).Trim() + $packageLength=[long](Get-Item $package -Force).Length + $manifestLength=[long](Get-Item $manifest -Force).Length + $bootstrapLength=[long](Get-Item $bootstrap -Force).Length + if(-not(Test-CcodSetupArtifactJsonInteger $record.schemaVersion 2)-or + -not(Test-CcodExactString $record.product 'CodexRemote-fix')-or + -not(Test-CcodExactString $record.version $ExpectedVersion)-or + -not(Test-CcodExactString $record.gitCommit $ExpectedGitCommit)-or + -not(Test-CcodExactString $record.buildTimestampUtc $ExpectedBuildTimestampUtc)-or + -not(Test-CcodSetupCanonicalUtc $record.buildTimestampUtc)-or + -not(Test-CcodSetupArtifactJsonInteger $packageManifest.schemaVersion 1)-or + -not(Test-CcodExactString $packageManifest.product 'CodexRemote-fix')-or + -not(Test-CcodExactString $packageManifest.version $ExpectedVersion)-or + -not(Test-CcodExactString $packageManifest.gitCommit $ExpectedGitCommit)-or + -not(Test-CcodExactString $packageManifest.payloadManifest.name 'installer-payload.manifest.json')-or + -not(Test-CcodJsonInteger $packageManifest.payloadManifest.length)-or[long]$packageManifest.payloadManifest.length-le0-or + @($packageManifest.files).Count-le0-or + -not(Test-CcodExactString $record.installerPackage.name 'installer-package.zip')-or + -not(Test-CcodJsonInteger $record.installerPackage.length)-or[long]$record.installerPackage.length-ne$packageLength-or + -not(Test-CcodExactString $record.installerPackage.sha256 $ExpectedPackageSha256)-or$record.installerPackage.sha256-cne(Get-CcodSetupArtifactHash $package)-or + -not(Test-CcodExactString $record.installerPackageManifest.name 'installer-package.manifest.json')-or + -not(Test-CcodJsonInteger $record.installerPackageManifest.length)-or[long]$record.installerPackageManifest.length-ne$manifestLength-or + -not(Test-CcodExactString $record.installerPackageManifest.sha256 $ExpectedPackageManifestSha256)-or$record.installerPackageManifest.sha256-cne(Get-CcodSetupArtifactHash $manifest)-or + -not(Test-CcodSetupArtifactJsonInteger $record.installerPackageManifest.fileCount @($packageManifest.files).Count)-or + -not(Test-CcodExactString $record.installerPackageManifest.payloadManifestSha256 ([string]$packageManifest.payloadManifest.sha256))-or + $packageManifest.payloadManifest.sha256-isnot[string]-or$packageManifest.payloadManifest.sha256-cnotmatch'^[0-9a-f]{64}$'-or + -not(Test-CcodExactString $record.activationBootstrap.name 'Activate-CcodRemoteFix.ps1')-or + -not(Test-CcodJsonInteger $record.activationBootstrap.length)-or[long]$record.activationBootstrap.length-ne$bootstrapLength-or + -not(Test-CcodExactString $record.activationBootstrap.sha256 $ExpectedActivationBootstrapSha256)-or$record.activationBootstrap.sha256-cne(Get-CcodSetupArtifactHash $bootstrap)-or + -not(Test-CcodExactString $record.buildInputs.innoTemplateSha256 (Get-CcodSetupArtifactHash $template))-or + -not(Test-CcodExactString $record.buildInputs.destinationInventorySha256 (Get-CcodSetupArtifactHash $inventory))-or + -not(Test-CcodExactString $record.buildInputs.compilerSha256 (Get-CcodSetupArtifactHash $compiler))-or + -not(Test-CcodExactString $record.buildInputs.compilerFileVersion $actualCompilerVersion)-or + -not(Test-CcodExactString $record.peContract.fileVersion "$ExpectedVersion.0")-or + -not(Test-CcodExactString $record.peContract.packageManifestFirst $ExpectedPackageManifestSha256.Substring(0,32))-or + -not(Test-CcodExactString $record.peContract.packageManifestLast $ExpectedPackageManifestSha256.Substring(32,32))-or + -not(Test-CcodExactString $record.peContract.bootstrapFirst $ExpectedActivationBootstrapSha256.Substring(0,32))-or + -not(Test-CcodExactString $record.peContract.bootstrapLast $ExpectedActivationBootstrapSha256.Substring(32,32))-or + -not(Test-CcodExactString $record.peContract.companyName $ExpectedGitCommit)-or + -not(Test-CcodExactString $record.peContract.legalCopyright $ExpectedPackageSha256)){ + Throw-CcodSetupArtifactError 'CCOD_SETUP_PROVENANCE_INVALID' 'Sealed Setup provenance is not exactly bound' $path + } + return $record +} + +Export-ModuleMember -Function Get-CcodSetupArtifactHash,Get-CcodSetupDescription,New-CcodSetupBuildProvenance,Test-CcodSetupBuildProvenance,New-CcodSealedSetupBuildProvenance,Test-CcodSealedSetupBuildProvenance,Test-CcodSetupArtifact diff --git a/build/TrayHostBuild.psm1 b/build/TrayHostBuild.psm1 index cbfd380..b46022d 100644 --- a/build/TrayHostBuild.psm1 +++ b/build/TrayHostBuild.psm1 @@ -1,6 +1,19 @@ Set-StrictMode -Version Latest $ErrorActionPreference='Stop' +# PowerShell 7.5+ materializes ISO-8601 JSON strings as [datetime]. These modules +# validate provenance timestamps as canonical UTC text, so read every JSON document +# with -DateKind String when the shell offers it and keep the Windows PowerShell 5.1 +# behavior (dates stay text) identical across both shells. +$script:CcodTrayHostJsonKeepsDateText = @((Get-Command ConvertFrom-Json).Parameters.Keys) -contains 'DateKind' + +function ConvertFrom-CcodTrayHostJson { + [CmdletBinding()] + param([Parameter(Mandatory)][AllowEmptyString()][string]$Json) + if ($script:CcodTrayHostJsonKeepsDateText) { return $Json | ConvertFrom-Json -DateKind String -ErrorAction Stop } + return $Json | ConvertFrom-Json -ErrorAction Stop +} + function Get-CcodTrayHostHash { param([Parameter(Mandatory)][string]$Path) $sha=[Security.Cryptography.SHA256]::Create() @@ -35,6 +48,53 @@ function Test-CcodTrayHostOutputDirectory { if([string]::IsNullOrWhiteSpace($full) -or $full -eq [IO.Path]::GetPathRoot($full).TrimEnd('\')){throw 'CCOD_TRAYHOST_OUTPUT_PATH_INVALID'} } +function Assert-CcodNativeVersionContract { + param( + [Parameter(Mandatory)][string]$RepositoryRoot, + [Parameter(Mandatory)][string]$Version, + [Parameter(Mandatory)][ValidateSet('trayhost','portable')][string]$Component + ) + $definition=if($Component -ceq 'trayhost'){ + [pscustomobject]@{ManifestName='CodexRemote.TrayHost.manifest';IdentityName='CodexRemote.fix.TrayHost';MissingError='CCOD_TRAYHOST_SOURCE_MISSING';VersionError='CCOD_TRAYHOST_VERSION_MISMATCH'} + }else{ + [pscustomobject]@{ManifestName='CodexRemote.Portable.manifest';IdentityName='CodexRemote.fix.Portable';MissingError='CCOD_PORTABLE_LAUNCHER_SOURCE_MISSING';VersionError='CCOD_PORTABLE_LAUNCHER_VERSION_MISMATCH'} + } + $repo=[IO.Path]::GetFullPath($RepositoryRoot) + $sourceRoot=Join-Path $repo ('src\'+$Component) + $packagePath=Join-Path $repo 'package.json' + $assemblyInfoPath=Join-Path $sourceRoot 'AssemblyInfo.cs' + $manifestPath=Join-Path $sourceRoot $definition.ManifestName + foreach($required in @($packagePath,$assemblyInfoPath,$manifestPath)){if(-not(Test-Path -LiteralPath $required -PathType Leaf)){throw $definition.MissingError}} + try{ + $package=Get-Content -LiteralPath $packagePath -Raw|ConvertFrom-Json + if([string]$package.version -cne $Version){throw $definition.VersionError} + $expectedNativeVersion="$Version.0" + $assemblyInfo=[IO.File]::ReadAllText($assemblyInfoPath,[Text.UTF8Encoding]::new($false)) + $assemblyVersions=@([regex]::Matches($assemblyInfo,'(?m)^\s*\[assembly:\s*AssemblyVersion\("(?[^"]+)"\)\]\s*$')) + $fileVersions=@([regex]::Matches($assemblyInfo,'(?m)^\s*\[assembly:\s*AssemblyFileVersion\("(?[^"]+)"\)\]\s*$')) + if($assemblyVersions.Count -ne 1 -or $fileVersions.Count -ne 1 -or + [string]$assemblyVersions[0].Groups['version'].Value -cne $expectedNativeVersion -or + [string]$fileVersions[0].Groups['version'].Value -cne $expectedNativeVersion){throw $definition.VersionError} + [xml]$manifest=[IO.File]::ReadAllText($manifestPath,[Text.UTF8Encoding]::new($false)) + $namespace=[Xml.XmlNamespaceManager]::new($manifest.NameTable) + $namespace.AddNamespace('asmv1','urn:schemas-microsoft-com:asm.v1') + $identities=@($manifest.SelectNodes('/asmv1:assembly/asmv1:assemblyIdentity',$namespace)) + if($identities.Count -ne 1 -or [string]$identities[0].version -cne $expectedNativeVersion -or + [string]$identities[0].name -cne [string]$definition.IdentityName -or [string]$identities[0].type -cne 'win32'){throw $definition.VersionError} + return [pscustomobject][ordered]@{SourceRoot=$sourceRoot;AssemblyInfoPath=$assemblyInfoPath;ManifestPath=$manifestPath;NativeVersion=$expectedNativeVersion} + }catch{ + if(([string]$_.FullyQualifiedErrorId -split ',')[0] -like "$($definition.MissingError)*"){throw} + throw $definition.VersionError + } +} + +function Assert-CcodNativePeVersion { + param([Parameter(Mandatory)][string]$Path,[Parameter(Mandatory)][string]$Version,[Parameter(Mandatory)][string]$ErrorId) + $expected="$Version.0" + try{$versionInfo=[Diagnostics.FileVersionInfo]::GetVersionInfo([IO.Path]::GetFullPath($Path))}catch{throw $ErrorId} + if([string]$versionInfo.FileVersion -cne $expected -or [string]$versionInfo.ProductVersion -cne $expected){throw $ErrorId} +} + function Invoke-CcodTrayHostBuild { [CmdletBinding()] param( @@ -46,11 +106,12 @@ function Invoke-CcodTrayHostBuild { ) if($Version -notmatch '^\d+\.\d+\.\d+$'){throw 'CCOD_TRAYHOST_VERSION_INVALID'} $repo=[IO.Path]::GetFullPath($RepositoryRoot);$out=[IO.Path]::GetFullPath($OutputDirectory);Test-CcodTrayHostOutputDirectory $out + $versionContract=Assert-CcodNativeVersionContract -RepositoryRoot $repo -Version $Version -Component trayhost if([string]::IsNullOrWhiteSpace($GitCommit)){$GitCommit=Get-CcodTrayHostGitCommit -RepositoryRoot $repo}else{$GitCommit=$GitCommit.ToLowerInvariant()} if($GitCommit -cnotmatch '^[0-9a-f]{40}$'){throw 'CCOD_TRAYHOST_PROVENANCE_INVALID'} if([string]::IsNullOrWhiteSpace($BuildTimestampUtc)){$BuildTimestampUtc=[datetime]::UtcNow.ToString('o',[Globalization.CultureInfo]::InvariantCulture)} if(-not (Test-CcodTrayHostCanonicalUtc $BuildTimestampUtc)){throw 'CCOD_TRAYHOST_PROVENANCE_INVALID'} - $sourceRoot=Join-Path $repo 'src\trayhost';$manifest=Join-Path $sourceRoot 'CodexRemote.TrayHost.manifest';$config=Join-Path $sourceRoot 'CodexRemote.TrayHost.exe.config';$icon=Join-Path $repo 'assets\codexremote-fix\codexremote-fix.ico' + $sourceRoot=$versionContract.SourceRoot;$manifest=$versionContract.ManifestPath;$config=Join-Path $sourceRoot 'CodexRemote.TrayHost.exe.config';$icon=Join-Path $repo 'assets\codexremote-fix\codexremote-fix.ico' foreach($required in @($manifest,$config,$icon)){if(-not(Test-Path -LiteralPath $required -PathType Leaf)){throw 'CCOD_TRAYHOST_SOURCE_MISSING'}} $sources=@(Get-ChildItem -LiteralPath $sourceRoot -Filter '*.cs' -File|Sort-Object Name) if($sources.Count -lt 10){throw 'CCOD_TRAYHOST_SOURCE_INCOMPLETE'} @@ -65,6 +126,7 @@ function Invoke-CcodTrayHostBuild { $compilerArgs+=@($sources|ForEach-Object FullName) & $compiler @compilerArgs if($LASTEXITCODE -ne 0 -or -not(Test-Path -LiteralPath $exe -PathType Leaf)){throw 'CCOD_TRAYHOST_COMPILE_FAILED'} + Assert-CcodNativePeVersion -Path $exe -Version $Version -ErrorId 'CCOD_TRAYHOST_ARTIFACT_VERSION_INVALID' $configOut=Join-Path $work 'CodexRemote.TrayHost.exe.config';Copy-Item -LiteralPath $config -Destination $configOut -Force $stdoutPath=Join-Path $work 'stdout.txt';$stderrPath=Join-Path $work 'stderr.txt' $smoke=Start-Process -FilePath $exe -ArgumentList '--headless-smoke' -Wait -PassThru -WindowStyle Hidden -RedirectStandardOutput $stdoutPath -RedirectStandardError $stderrPath @@ -83,15 +145,38 @@ function Invoke-CcodTrayHostBuild { function Test-CcodTrayHostArtifact { [CmdletBinding()] param([Parameter(Mandatory)][string]$RepositoryRoot,[Parameter(Mandatory)][string]$Version,[Parameter(Mandatory)][string]$ArtifactDirectory,[string]$ExpectedGitCommit) + if($Version -notmatch '^\d+\.\d+\.\d+$'){throw 'CCOD_TRAYHOST_VERSION_INVALID'} $repo=[IO.Path]::GetFullPath($RepositoryRoot);$out=[IO.Path]::GetFullPath($ArtifactDirectory);Test-CcodTrayHostOutputDirectory $out + $versionContract=Assert-CcodNativeVersionContract -RepositoryRoot $repo -Version $Version -Component trayhost $exe=Join-Path $out 'CodexRemote.TrayHost.exe';$config=Join-Path $out 'CodexRemote.TrayHost.exe.config';$provenancePath=Join-Path $out 'trayhost-build-provenance.json' foreach($path in @($exe,$config,$provenancePath)){if(-not(Test-Path -LiteralPath $path -PathType Leaf)){throw 'CCOD_TRAYHOST_ARTIFACT_MISSING'}} - $provenance=Get-Content -LiteralPath $provenancePath -Raw|ConvertFrom-Json + $provenance=ConvertFrom-CcodTrayHostJson -Json (Get-Content -LiteralPath $provenancePath -Raw) $commit=$provenance.PSObject.Properties['gitCommit'];$timestamp=$provenance.PSObject.Properties['buildTimestampUtc'] if([int]$provenance.schemaVersion -ne 1 -or [string]$provenance.version -cne $Version -or [string]$provenance.targetFramework -cne 'net48' -or $null -eq $commit -or $null -eq $timestamp -or $commit.Value -isnot [string] -or $commit.Value -cnotmatch '^[0-9a-f]{40}$' -or $timestamp.Value -isnot [string] -or -not(Test-CcodTrayHostCanonicalUtc $timestamp.Value) -or (-not [string]::IsNullOrWhiteSpace($ExpectedGitCommit) -and $commit.Value -cne $ExpectedGitCommit)){throw 'CCOD_TRAYHOST_PROVENANCE_INVALID'} - $icon=Join-Path $repo 'assets\codexremote-fix\codexremote-fix.ico' - if([string]$provenance.artifactSha256 -cne (Get-CcodTrayHostHash $exe) -or [string]$provenance.configArtifactSha256 -cne (Get-CcodTrayHostHash $config) -or [string]$provenance.iconSha256 -cne (Get-CcodTrayHostHash $icon)){throw 'CCOD_TRAYHOST_ARTIFACT_TAMPERED'} - $sourceRoot=Join-Path $repo 'src\trayhost';foreach($record in @($provenance.sourceFiles)){ $source=Join-Path $sourceRoot ([string]$record.name);if(-not(Test-Path -LiteralPath $source -PathType Leaf) -or [string]$record.sha256 -cne (Get-CcodTrayHostHash $source)){throw 'CCOD_TRAYHOST_SOURCE_TAMPERED'} } + $icon=Join-Path $repo 'assets\codexremote-fix\codexremote-fix.ico';$sourceConfig=Join-Path $versionContract.SourceRoot 'CodexRemote.TrayHost.exe.config' + foreach($required in @($icon,$sourceConfig)){if(-not(Test-Path -LiteralPath $required -PathType Leaf)){throw 'CCOD_TRAYHOST_SOURCE_MISSING'}} + if([string]$provenance.artifactSha256 -cne (Get-CcodTrayHostHash $exe) -or + [string]$provenance.configArtifactSha256 -cne (Get-CcodTrayHostHash $config) -or + [string]$provenance.iconSha256 -cne (Get-CcodTrayHostHash $icon) -or + [string]$provenance.manifestSha256 -cne (Get-CcodTrayHostHash $versionContract.ManifestPath) -or + [string]$provenance.configSha256 -cne (Get-CcodTrayHostHash $sourceConfig)){throw 'CCOD_TRAYHOST_ARTIFACT_TAMPERED'} + Assert-CcodNativePeVersion -Path $exe -Version $Version -ErrorId 'CCOD_TRAYHOST_ARTIFACT_VERSION_INVALID' + $sourceRoot=$versionContract.SourceRoot + $currentSources=@(Get-ChildItem -LiteralPath $sourceRoot -Filter '*.cs' -File|Sort-Object Name) + $sourceFilesProperty=$provenance.PSObject.Properties['sourceFiles'] + $sourceRecords=if($null -eq $sourceFilesProperty){@()}else{@($sourceFilesProperty.Value)} + if($sourceRecords.Count -ne $currentSources.Count){throw 'CCOD_TRAYHOST_SOURCE_TAMPERED'} + $expectedSources=[Collections.Generic.Dictionary[string,string]]::new([StringComparer]::Ordinal) + foreach($source in $currentSources){$expectedSources.Add($source.Name,(Get-CcodTrayHostHash $source.FullName))} + $seenSources=[Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + foreach($record in $sourceRecords){ + if($null -eq $record){throw 'CCOD_TRAYHOST_SOURCE_TAMPERED'} + $nameProperty=$record.PSObject.Properties['name'];$hashProperty=$record.PSObject.Properties['sha256'] + if($null -eq $nameProperty -or $null -eq $hashProperty -or $nameProperty.Value -isnot [string] -or $hashProperty.Value -isnot [string]){throw 'CCOD_TRAYHOST_SOURCE_TAMPERED'} + $name=[string]$nameProperty.Value;$hash=[string]$hashProperty.Value + if(-not $seenSources.Add($name) -or -not $expectedSources.ContainsKey($name) -or $hash -cnotmatch '^[0-9a-f]{64}$' -or $hash -cne $expectedSources[$name]){throw 'CCOD_TRAYHOST_SOURCE_TAMPERED'} + } + if($seenSources.Count -ne $expectedSources.Count){throw 'CCOD_TRAYHOST_SOURCE_TAMPERED'} return [pscustomobject][ordered]@{Valid=$true;Executable=$exe;Version=$Version;GitCommit=[string]$commit.Value;Sha256=(Get-CcodTrayHostHash $exe)} } @@ -106,12 +191,13 @@ function Invoke-CcodPortableLauncherBuild { ) if($Version -notmatch '^\d+\.\d+\.\d+$'){throw 'CCOD_PORTABLE_LAUNCHER_VERSION_INVALID'} $repo=[IO.Path]::GetFullPath($RepositoryRoot);$out=[IO.Path]::GetFullPath($OutputDirectory);Test-CcodTrayHostOutputDirectory $out + $versionContract=Assert-CcodNativeVersionContract -RepositoryRoot $repo -Version $Version -Component portable if([string]::IsNullOrWhiteSpace($GitCommit)){$GitCommit=Get-CcodTrayHostGitCommit -RepositoryRoot $repo}else{$GitCommit=$GitCommit.ToLowerInvariant()} if($GitCommit -cnotmatch '^[0-9a-f]{40}$'){throw 'CCOD_PORTABLE_LAUNCHER_PROVENANCE_INVALID'} if([string]::IsNullOrWhiteSpace($BuildTimestampUtc)){$BuildTimestampUtc=[datetime]::UtcNow.ToString('o',[Globalization.CultureInfo]::InvariantCulture)} if(-not (Test-CcodTrayHostCanonicalUtc $BuildTimestampUtc)){throw 'CCOD_PORTABLE_LAUNCHER_PROVENANCE_INVALID'} - $sourceRoot=Join-Path $repo 'src\portable';$launcher=Join-Path $sourceRoot 'PortableLauncher.cs';$assemblyInfo=Join-Path $sourceRoot 'AssemblyInfo.cs'; - $manifest=Join-Path $sourceRoot 'CodexRemote.Portable.manifest';$config=Join-Path $sourceRoot 'CodexRemote.Portable.exe.config'; + $sourceRoot=$versionContract.SourceRoot;$launcher=Join-Path $sourceRoot 'PortableLauncher.cs';$assemblyInfo=$versionContract.AssemblyInfoPath; + $manifest=$versionContract.ManifestPath;$config=Join-Path $sourceRoot 'CodexRemote.Portable.exe.config'; $icon=Join-Path $repo 'assets\codexremote-fix\codexremote-fix.ico' foreach($required in @($launcher,$assemblyInfo,$manifest,$config,$icon)){if(-not(Test-Path -LiteralPath $required -PathType Leaf)){throw 'CCOD_PORTABLE_LAUNCHER_SOURCE_MISSING'}} Import-Module (Join-Path $repo 'build\TrayHostReferencePack.psm1') -Force @@ -124,6 +210,7 @@ function Invoke-CcodPortableLauncherBuild { $compilerArgs+=@($launcher,$assemblyInfo) & $compiler @compilerArgs if($LASTEXITCODE -ne 0 -or -not(Test-Path -LiteralPath $exe -PathType Leaf)){throw 'CCOD_PORTABLE_LAUNCHER_COMPILE_FAILED'} + Assert-CcodNativePeVersion -Path $exe -Version $Version -ErrorId 'CCOD_PORTABLE_LAUNCHER_ARTIFACT_VERSION_INVALID' $configOut=Join-Path $work 'CodexRemote.Portable.exe.config';Copy-Item -LiteralPath $config -Destination $configOut -Force $provenance=[ordered]@{schemaVersion=1;product='CodexRemote-fix';version=$Version;gitCommit=$GitCommit;buildTimestampUtc=$BuildTimestampUtc;targetFramework='net48';compiler=[ordered]@{name='csc.exe';sha256=(Get-CcodTrayHostHash $compiler)};referenceRoot='locked-net48';sourceFiles=@([ordered]@{name='PortableLauncher.cs';sha256=(Get-CcodTrayHostHash $launcher)},[ordered]@{name='AssemblyInfo.cs';sha256=(Get-CcodTrayHostHash $assemblyInfo)});iconSha256=(Get-CcodTrayHostHash $icon);manifestSha256=(Get-CcodTrayHostHash $manifest);configSha256=(Get-CcodTrayHostHash $config);artifactSha256=(Get-CcodTrayHostHash $exe);configArtifactSha256=(Get-CcodTrayHostHash $configOut)} $provenancePath=Join-Path $work 'portable-launcher-provenance.json';$provenance|ConvertTo-Json -Depth 8|Set-Content -LiteralPath $provenancePath -Encoding UTF8 @@ -136,4 +223,44 @@ function Invoke-CcodPortableLauncherBuild { }finally{if(Test-Path -LiteralPath $work){Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue}} } -Export-ModuleMember -Function Invoke-CcodTrayHostBuild,Test-CcodTrayHostArtifact,Invoke-CcodPortableLauncherBuild +function Test-CcodPortableLauncherArtifact { + [CmdletBinding()] + param([Parameter(Mandatory)][string]$RepositoryRoot,[Parameter(Mandatory)][string]$Version,[Parameter(Mandatory)][string]$ArtifactDirectory,[string]$ExpectedGitCommit) + if($Version -notmatch '^\d+\.\d+\.\d+$'){throw 'CCOD_PORTABLE_LAUNCHER_VERSION_INVALID'} + $repo=[IO.Path]::GetFullPath($RepositoryRoot);$out=[IO.Path]::GetFullPath($ArtifactDirectory);Test-CcodTrayHostOutputDirectory $out + $versionContract=Assert-CcodNativeVersionContract -RepositoryRoot $repo -Version $Version -Component portable + $exe=Join-Path $out 'CodexRemote.Portable.exe';$config=Join-Path $out 'CodexRemote.Portable.exe.config';$provenancePath=Join-Path $out 'portable-launcher-provenance.json' + foreach($path in @($exe,$config,$provenancePath)){if(-not(Test-Path -LiteralPath $path -PathType Leaf)){throw 'CCOD_PORTABLE_LAUNCHER_ARTIFACT_MISSING'}} + try{$provenance=ConvertFrom-CcodTrayHostJson -Json (Get-Content -LiteralPath $provenancePath -Raw)}catch{throw 'CCOD_PORTABLE_LAUNCHER_PROVENANCE_INVALID'} + $commit=$provenance.PSObject.Properties['gitCommit'];$timestamp=$provenance.PSObject.Properties['buildTimestampUtc'];$compiler=$provenance.PSObject.Properties['compiler'] + if([int]$provenance.schemaVersion -ne 1 -or [string]$provenance.product -cne 'CodexRemote-fix' -or + [string]$provenance.version -cne $Version -or [string]$provenance.targetFramework -cne 'net48' -or + [string]$provenance.referenceRoot -cne 'locked-net48' -or $null -eq $commit -or $null -eq $timestamp -or $null -eq $compiler -or + $commit.Value -isnot [string] -or $commit.Value -cnotmatch '^[0-9a-f]{40}$' -or + $timestamp.Value -isnot [string] -or -not(Test-CcodTrayHostCanonicalUtc $timestamp.Value) -or + (-not [string]::IsNullOrWhiteSpace($ExpectedGitCommit) -and $commit.Value -cne $ExpectedGitCommit) -or + [string]$compiler.Value.name -cne 'csc.exe' -or [string]$compiler.Value.sha256 -cnotmatch '^[0-9a-f]{64}$'){throw 'CCOD_PORTABLE_LAUNCHER_PROVENANCE_INVALID'} + $icon=Join-Path $repo 'assets\codexremote-fix\codexremote-fix.ico';$sourceConfig=Join-Path $versionContract.SourceRoot 'CodexRemote.Portable.exe.config' + foreach($required in @($icon,$sourceConfig)){if(-not(Test-Path -LiteralPath $required -PathType Leaf)){throw 'CCOD_PORTABLE_LAUNCHER_SOURCE_MISSING'}} + if([string]$provenance.artifactSha256 -cne (Get-CcodTrayHostHash $exe) -or + [string]$provenance.configArtifactSha256 -cne (Get-CcodTrayHostHash $config) -or + [string]$provenance.iconSha256 -cne (Get-CcodTrayHostHash $icon) -or + [string]$provenance.manifestSha256 -cne (Get-CcodTrayHostHash $versionContract.ManifestPath) -or + [string]$provenance.configSha256 -cne (Get-CcodTrayHostHash $sourceConfig)){throw 'CCOD_PORTABLE_LAUNCHER_ARTIFACT_TAMPERED'} + $expectedSources=[Collections.Generic.Dictionary[string,string]]::new([StringComparer]::Ordinal) + foreach($name in @('AssemblyInfo.cs','PortableLauncher.cs')){$expectedSources.Add($name,(Get-CcodTrayHostHash (Join-Path $versionContract.SourceRoot $name)))} + $sourceFilesProperty=$provenance.PSObject.Properties['sourceFiles'];$sourceRecords=if($null -eq $sourceFilesProperty){@()}else{@($sourceFilesProperty.Value)} + if($sourceRecords.Count -ne $expectedSources.Count){throw 'CCOD_PORTABLE_LAUNCHER_SOURCE_TAMPERED'} + $seenSources=[Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + foreach($record in $sourceRecords){ + if($null -eq $record){throw 'CCOD_PORTABLE_LAUNCHER_SOURCE_TAMPERED'} + $nameProperty=$record.PSObject.Properties['name'];$hashProperty=$record.PSObject.Properties['sha256'] + if($null -eq $nameProperty -or $null -eq $hashProperty -or $nameProperty.Value -isnot [string] -or $hashProperty.Value -isnot [string]){throw 'CCOD_PORTABLE_LAUNCHER_SOURCE_TAMPERED'} + $name=[string]$nameProperty.Value;$hash=[string]$hashProperty.Value + if(-not $seenSources.Add($name) -or -not $expectedSources.ContainsKey($name) -or $hash -cnotmatch '^[0-9a-f]{64}$' -or $hash -cne $expectedSources[$name]){throw 'CCOD_PORTABLE_LAUNCHER_SOURCE_TAMPERED'} + } + Assert-CcodNativePeVersion -Path $exe -Version $Version -ErrorId 'CCOD_PORTABLE_LAUNCHER_ARTIFACT_VERSION_INVALID' + return [pscustomobject][ordered]@{Valid=$true;Executable=$exe;Version=$Version;GitCommit=[string]$commit.Value;Sha256=(Get-CcodTrayHostHash $exe)} +} + +Export-ModuleMember -Function Invoke-CcodTrayHostBuild,Test-CcodTrayHostArtifact,Invoke-CcodPortableLauncherBuild,Test-CcodPortableLauncherArtifact diff --git a/build/build.ps1 b/build/build.ps1 index 3fc8236..79ec98b 100644 --- a/build/build.ps1 +++ b/build/build.ps1 @@ -2,7 +2,8 @@ param( [string]$Version, [switch]$UseExistingTrayHost, - [string]$TrayHostArtifactDirectory + [string]$TrayHostArtifactDirectory, + [switch]$Library ) Set-StrictMode -Version Latest @@ -104,6 +105,230 @@ function Write-CcodBuildUtf8 { [IO.File]::WriteAllText($Path,$Text,[Text.UTF8Encoding]::new($false)) } +function Assert-CcodBuildInstallerDestinationInventory { + param([Parameter(Mandatory)][string]$Path) + $inventoryPath = Assert-CcodBuildRegularFile -Path $Path -Kind 'Installer destination inventory' + foreach ($line in [IO.File]::ReadAllLines($inventoryPath,[Text.UTF8Encoding]::new($false))) { + if ($line -match '^\s*\[[^\[\]\r\n]+\]\s*(?:;.*)?$') { + throw "Installer destination inventory contains an Inno section header: $line" + } + } + return $inventoryPath +} + +function Assert-CcodBuildInnoPreprocessorLines { + param( + [Parameter(Mandatory)][AllowEmptyCollection()][AllowEmptyString()][string[]]$Lines, + [Parameter(Mandatory)][string]$Kind + ) + $allowedSimpleDirectives = @( + '#ifndef TrayHostArtifactDirectory', + '#define TrayHostArtifactDirectory SourcePath + "\generated\trayhost"', + '#ifndef PortableArtifactDirectory', + '#define PortableArtifactDirectory SourcePath + "\generated\portable"', + '#ifndef InstallerPayloadDirectory', + '#error InstallerPayloadDirectory must be supplied by the release builder', + '#ifndef InstallerPayloadManifestSha256', + '#error InstallerPayloadManifestSha256 must be supplied by the release builder', + '#ifndef ProjectVersion', + '#error ProjectVersion must be supplied by the release builder', + '#ifndef InstallerPackagePath', + '#error InstallerPackagePath must be supplied by the release builder', + '#ifndef InstallerPackageManifestPath', + '#error InstallerPackageManifestPath must be supplied by the release builder', + '#ifndef InstallerPackageSha256', + '#error InstallerPackageSha256 must be supplied by the release builder', + '#ifndef InstallerPackageManifestSha256', + '#error InstallerPackageManifestSha256 must be supplied by the release builder', + '#define InstallerPackageManifestSha256First Copy(InstallerPackageManifestSha256, 1, 32)', + '#define InstallerPackageManifestSha256Last Copy(InstallerPackageManifestSha256, 33, 32)', + '#ifndef ActivationBootstrapPath', + '#error ActivationBootstrapPath must be supplied by the release builder', + '#ifndef ActivationBootstrapSha256', + '#error ActivationBootstrapSha256 must be supplied by the release builder', + '#define ActivationBootstrapSha256First Copy(ActivationBootstrapSha256, 1, 32)', + '#define ActivationBootstrapSha256Last Copy(ActivationBootstrapSha256, 33, 32)', + '#ifndef SetupGitCommit', + '#error SetupGitCommit must be supplied by the release builder', + '#ifndef SetupProvenancePath', + '#error SetupProvenancePath must be supplied by the release builder', + '#endif' + ) + $allowedInlineConstructs = @( + '{#ProjectVersion}', + '{#TrayHostArtifactDirectory}', + '{#PortableArtifactDirectory}', + '{#InstallerPayloadDirectory}', + '{#InstallerPayloadManifestSha256}', + '{#InstallerPackagePath}', + '{#InstallerPackageManifestPath}', + '{#InstallerPackageSha256}', + '{#InstallerPackageManifestSha256}', + '{#InstallerPackageManifestSha256First}', + '{#InstallerPackageManifestSha256Last}', + '{#ActivationBootstrapPath}', + '{#ActivationBootstrapSha256}', + '{#ActivationBootstrapSha256First}', + '{#ActivationBootstrapSha256Last}', + '{#SetupGitCommit}', + '{#SetupProvenancePath}' + ) + for ($lineIndex = 0; $lineIndex -lt $Lines.Count; $lineIndex++) { + $line = [string]$Lines[$lineIndex] + $lineNumber = $lineIndex + 1 + if ($line -match '\\\s*$') { + throw "$Kind requires an include-free simple preprocessor source; line continuation is not permitted at line $lineNumber." + } + if ($line -match '^\s*#' -and $allowedSimpleDirectives -cnotcontains $line) { + throw "$Kind requires an include-free exact preprocessor source; unsafe simple directive at line ${lineNumber}: $line" + } + $inlineStart = $line.IndexOf('{#',[StringComparison]::Ordinal) + while ($inlineStart -ge 0) { + $inlineEnd = $line.IndexOf('}',$inlineStart + 2) + if ($inlineEnd -lt 0) { + throw "$Kind contains an unterminated inline preprocessor construct at line $lineNumber." + } + $inlineConstruct = $line.Substring($inlineStart,$inlineEnd - $inlineStart + 1) + if ($allowedInlineConstructs -cnotcontains $inlineConstruct) { + throw "$Kind contains an unsafe inline preprocessor construct at line ${lineNumber}: $inlineConstruct" + } + $inlineStart = $line.IndexOf('{#',$inlineEnd + 1,[StringComparison]::Ordinal) + } + } +} + +function New-CcodBuildGeneratedInnoScript { + param( + [Parameter(Mandatory)][string]$TemplatePath, + [Parameter(Mandatory)][string]$InventoryPath, + [Parameter(Mandatory)][string]$OutputPath + ) + $template = Assert-CcodBuildRegularFile -Path $TemplatePath -Kind 'Inno Setup template' + $inventory = Assert-CcodBuildInstallerDestinationInventory -Path $InventoryPath + $output = [IO.Path]::GetFullPath($OutputPath) + if ([IO.File]::Exists($output) -or [IO.Directory]::Exists($output)) { + throw "Refusing to overwrite generated Inno Setup script: $output" + } + $templateDirectory = [IO.Path]::GetFullPath((Split-Path $template -Parent)).TrimEnd('\') + $outputDirectory = [IO.Path]::GetFullPath((Split-Path $output -Parent)).TrimEnd('\') + if (-not $outputDirectory.Equals($templateDirectory,[StringComparison]::OrdinalIgnoreCase) -or + [IO.Path]::GetExtension($output) -cne '.iss') { + throw 'The generated Inno Setup script must be a sibling .iss file of its template.' + } + + $marker = '// CCOD_INSTALLER_DESTINATION_INVENTORY' + $templateSource = [IO.File]::ReadAllText($template,[Text.UTF8Encoding]::new($false)) + Assert-CcodBuildInnoPreprocessorLines -Lines ([IO.File]::ReadAllLines($template,[Text.UTF8Encoding]::new($false))) -Kind 'The Inno Setup template' + $markerCount = [regex]::Matches($templateSource,[regex]::Escape($marker)).Count + $markerLineCount = [regex]::Matches($templateSource,'(?m)^\s*// CCOD_INSTALLER_DESTINATION_INVENTORY\s*$').Count + if ($markerCount -ne 1 -or $markerLineCount -ne 1) { + throw "The Inno Setup template must contain exactly one inventory marker comment; found $markerCount." + } + + $inventorySource = [IO.File]::ReadAllText($inventory,[Text.UTF8Encoding]::new($false)).TrimEnd("`r","`n") + $generatedSource = $templateSource.Replace($marker,$inventorySource) + if ($generatedSource.Contains($marker)) { + throw 'The generated Inno Setup script contains an unresolved inventory marker.' + } + Assert-CcodBuildInnoPreprocessorLines -Lines ([regex]::Split($generatedSource,'\r\n|\n|\r')) -Kind 'The generated Inno Setup script' + + try { + Write-CcodBuildUtf8 -Path $output -Text $generatedSource + $writtenSource = [IO.File]::ReadAllText((Assert-CcodBuildRegularFile -Path $output -Kind 'Generated Inno Setup script'),[Text.UTF8Encoding]::new($false)) + if (-not $writtenSource.Equals($generatedSource,[StringComparison]::Ordinal)) { + throw 'The generated Inno Setup script changed during write verification.' + } + return $output + } catch { + if ([IO.File]::Exists($output)) { Remove-Item -LiteralPath $output -Force -ErrorAction SilentlyContinue } + throw + } +} + +function Invoke-CcodBuildInnoCompiler { + param( + [Parameter(Mandatory)][string]$TemplatePath, + [Parameter(Mandatory)][string]$InventoryPath, + [Parameter(Mandatory)][string]$IsccPath, + [Parameter(Mandatory)][AllowEmptyCollection()][string[]]$Arguments, + [Parameter(Mandatory)][string]$SetupPath + ) + $template = Assert-CcodBuildRegularFile -Path $TemplatePath -Kind 'Inno Setup template' + $compiler = Assert-CcodBuildRegularFile -Path $IsccPath -Kind 'Inno Setup compiler' + $setup = [IO.Path]::GetFullPath($SetupPath) + if ([IO.File]::Exists($setup) -or [IO.Directory]::Exists($setup)) { + throw "Refusing to compile over an existing setup output: $setup" + } + $generatedScriptPath = Join-Path (Split-Path $template -Parent) ('.ccod-generated-setup-' + [guid]::NewGuid().ToString('N') + '.iss') + try { + $generatedScript = New-CcodBuildGeneratedInnoScript -TemplatePath $template -InventoryPath $InventoryPath -OutputPath $generatedScriptPath + $compilerArguments = @($Arguments) + @($generatedScript) + & $compiler @compilerArguments + $compilerExitCode = $LASTEXITCODE + if ($compilerExitCode -ne 0 -or -not (Test-Path -LiteralPath $setup -PathType Leaf)) { + throw "Inno Setup compilation failed with exit code $compilerExitCode" + } + } finally { + if ([IO.File]::Exists($generatedScriptPath)) { + $generatedFull = [IO.Path]::GetFullPath($generatedScriptPath) + $templateDirectory = [IO.Path]::GetFullPath((Split-Path $template -Parent)).TrimEnd('\') + '\' + $generatedItem = Get-Item -LiteralPath $generatedFull -Force -ErrorAction Stop + if (-not $generatedFull.StartsWith($templateDirectory,[StringComparison]::OrdinalIgnoreCase) -or + [IO.Path]::GetFileName($generatedFull) -cnotmatch '^\.ccod-generated-setup-[0-9a-f]{32}\.iss$' -or + (($generatedItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) { + throw "Refusing to clean an unexpected generated Inno Setup path: $generatedFull" + } + Remove-Item -LiteralPath $generatedFull -Force -ErrorAction Stop + } + } +} + +function Remove-CcodBuildTemporarySetupInput { + param( + [Parameter(Mandatory)][string]$BuildRoot, + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][ValidateSet('PayloadDirectory','DestinationInventory')][string]$Kind + ) + $root = [IO.Path]::GetFullPath($BuildRoot).TrimEnd('\') + $candidate = [IO.Path]::GetFullPath($Path) + $parent = [IO.Path]::GetFullPath((Split-Path $candidate -Parent)).TrimEnd('\') + $leaf = [IO.Path]::GetFileName($candidate) + $expectedPattern = if ($Kind -ceq 'PayloadDirectory') { '^\.installer-payload-stage-[0-9a-f]{32}$' } else { '^\.installer-destination-inventory-[0-9a-f]{32}\.iss$' } + if (-not $parent.Equals($root,[StringComparison]::OrdinalIgnoreCase) -or $leaf -cnotmatch $expectedPattern) { + throw "Refusing to clean an unexpected temporary Setup input: $candidate" + } + if ($Kind -ceq 'PayloadDirectory') { + if (-not [IO.Directory]::Exists($candidate)) { return } + $rootItem = Get-Item -LiteralPath $candidate -Force -ErrorAction Stop + if (($rootItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "Refusing to clean a reparse Setup payload stage: $candidate" } + foreach ($item in @(Get-ChildItem -LiteralPath $candidate -Force -Recurse -ErrorAction Stop)) { + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "Refusing to clean a Setup payload stage containing a reparse point: $($item.FullName)" } + } + Remove-Item -LiteralPath $candidate -Recurse -Force -ErrorAction Stop + return + } + if (-not [IO.File]::Exists($candidate)) { return } + $inventory = Get-Item -LiteralPath $candidate -Force -ErrorAction Stop + if ($inventory.PSIsContainer -or ($inventory.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "Refusing to clean an unsafe Setup destination inventory: $candidate" } + Remove-Item -LiteralPath $candidate -Force -ErrorAction Stop +} + +function Invoke-CcodBuildTemporarySetupScope { + param( + [Parameter(Mandatory)][string]$BuildRoot, + [Parameter(Mandatory)][string]$InstallerPayloadDirectory, + [Parameter(Mandatory)][string]$DestinationInventoryPath, + [Parameter(Mandatory)][scriptblock]$Action + ) + try { & $Action $InstallerPayloadDirectory $DestinationInventoryPath } + finally { + Remove-CcodBuildTemporarySetupInput -BuildRoot $BuildRoot -Path $InstallerPayloadDirectory -Kind PayloadDirectory + Remove-CcodBuildTemporarySetupInput -BuildRoot $BuildRoot -Path $DestinationInventoryPath -Kind DestinationInventory + } +} + +if ($Library) { return } + $repoRoot = Split-Path $PSScriptRoot -Parent $package = Get-Content -LiteralPath (Join-Path $repoRoot 'package.json') -Raw | ConvertFrom-Json $packageVersion = ([string]$package.version).TrimStart('v') @@ -113,6 +338,11 @@ $Version = $Version.TrimStart('v') if ($Version -notmatch '^\d+\.\d+\.\d+$') { throw "Invalid project version for the portable bundle: $Version" } if ($Version -cne $packageVersion) { throw "Requested release version $Version does not match package.json version $packageVersion" } Assert-CcodBuildCleanCheckout -RepositoryRoot $repoRoot +$releaseAssetContractPath = Join-Path $repoRoot 'tools\ReleaseAssetContract.psm1' +if (-not [IO.File]::Exists($releaseAssetContractPath)) { throw "Release asset contract is missing: $releaseAssetContractPath" } +Import-Module $releaseAssetContractPath -Force -ErrorAction Stop +$releaseAssetNames = @(Get-CcodExpectedReleaseAssetNames -Version $Version) +if ($releaseAssetNames.Count -ne 11) { throw 'Release asset contract did not return exactly eleven ordered names.' } $gitCommit = Get-CcodBuildGitCommit -RepositoryRoot $repoRoot $buildTimestampUtc = [DateTime]::UtcNow.ToString('o',[Globalization.CultureInfo]::InvariantCulture) @@ -126,18 +356,25 @@ if ($UseExistingTrayHost) { $dist = Join-Path $PSScriptRoot 'dist' [IO.Directory]::CreateDirectory($dist) | Out-Null -$bundle = Join-Path $dist "CodexRemote-fix-$Version-windows-x64.zip" -$checksum = "$bundle.sha256.txt" -$provenance = Join-Path $dist "CodexRemote-fix-$Version-trayhost-provenance.json" -$payloadManifestAsset = Join-Path $dist "CodexRemote-fix-$Version-payload-manifest.json" -$releaseManifest = Join-Path $dist "CodexRemote-fix-$Version-release-manifest.json" -$setupExe = Join-Path $dist "CodexRemote-fix-$Version-setup.exe" -$setupChecksum = "$setupExe.sha256.txt" -foreach ($path in @($bundle,$checksum,$provenance,$payloadManifestAsset,$releaseManifest,$setupExe,$setupChecksum)) { +$bundle = Join-Path $dist $releaseAssetNames[0] +$checksum = Join-Path $dist $releaseAssetNames[1] +$provenance = Join-Path $dist $releaseAssetNames[2] +$payloadManifestAsset = Join-Path $dist $releaseAssetNames[3] +$releaseManifest = Join-Path $dist $releaseAssetNames[4] +$setupExe = Join-Path $dist $releaseAssetNames[5] +$setupChecksum = Join-Path $dist $releaseAssetNames[6] +$setupProvenance = Join-Path $dist $releaseAssetNames[7] +$setupPayloadInput = Join-Path $dist $releaseAssetNames[8] +$setupInventoryInput = Join-Path $dist $releaseAssetNames[9] +$setupReleaseManifest = Join-Path $dist $releaseAssetNames[10] +foreach ($path in @($bundle,$checksum,$provenance,$payloadManifestAsset,$releaseManifest,$setupExe,$setupChecksum,$setupProvenance,$setupPayloadInput,$setupInventoryInput,$setupReleaseManifest)) { if ([IO.File]::Exists($path) -or [IO.Directory]::Exists($path)) { throw "Refusing to overwrite immutable release output: $path" } } $stageRoot = Join-Path $PSScriptRoot ('.portable-stage-' + [guid]::NewGuid().ToString('N')) +$installerPayloadDirectory = Join-Path $PSScriptRoot ('.installer-payload-stage-' + [guid]::NewGuid().ToString('N')) +$installerDestinationInventoryPath = Join-Path $PSScriptRoot ('.installer-destination-inventory-' + [guid]::NewGuid().ToString('N') + '.iss') +Invoke-CcodBuildTemporarySetupScope -BuildRoot $PSScriptRoot -InstallerPayloadDirectory $installerPayloadDirectory -DestinationInventoryPath $installerDestinationInventoryPath -Action ({ try { [IO.Directory]::CreateDirectory($stageRoot) | Out-Null $payloadRoot = Join-Path $stageRoot 'payload' @@ -157,7 +394,8 @@ try { 'src/persistence/LifecycleWorker.ps1', 'src/persistence/bootstrap.ps1', 'src/persistence/UninstallBootstrap.ps1', - 'src/persistence/PortableUninstallFinalizer.ps1' + 'src/persistence/PortableUninstallFinalizer.ps1', + 'src/persistence/InstalledUninstallFinalizer.ps1' )) { Copy-CcodBuildPayloadFile -Source (Join-Path $repoRoot ($relative.Replace('/','\'))) -PayloadRoot $payloadRoot -Relative $relative } @@ -172,7 +410,9 @@ try { $relative = $runtimeFile.FullName.Substring($runtimeRoot.TrimEnd('\').Length + 1).Replace('\','/') Copy-CcodBuildPayloadFile -Source $runtimeFile.FullName -PayloadRoot $payloadRoot -Relative ('src/runtime/' + $relative) } - Invoke-CcodPortableLauncherBuild -RepositoryRoot $repoRoot -Version $Version -OutputDirectory (Join-Path $PSScriptRoot 'generated\portable') -GitCommit $gitCommit -BuildTimestampUtc $buildTimestampUtc | Out-Null + $portableArtifact = Join-Path $PSScriptRoot 'generated\portable' + Invoke-CcodPortableLauncherBuild -RepositoryRoot $repoRoot -Version $Version -OutputDirectory $portableArtifact -GitCommit $gitCommit -BuildTimestampUtc $buildTimestampUtc | Out-Null + Test-CcodPortableLauncherArtifact -RepositoryRoot $repoRoot -Version $Version -ArtifactDirectory $portableArtifact -ExpectedGitCommit $gitCommit | Out-Null foreach ($trayHostFile in @('CodexRemote.TrayHost.exe','CodexRemote.TrayHost.exe.config','trayhost-build-provenance.json')) { Copy-CcodBuildPayloadFile -Source (Join-Path $trayHostArtifact $trayHostFile) -PayloadRoot $payloadRoot -Relative ('bin/' + $trayHostFile) } @@ -186,6 +426,33 @@ try { Remove-Module -Name $lifecycleModule.Name -Force -ErrorAction SilentlyContinue } $payloadRecords = Get-CcodBuildPayloadRecords -PayloadRoot $payloadRoot + [IO.Directory]::CreateDirectory($installerPayloadDirectory) | Out-Null + foreach ($record in $payloadRecords) { + Copy-CcodBuildPayloadFile -Source (Join-Path $payloadRoot ([string]$record.path).Replace('/','\')) -PayloadRoot $installerPayloadDirectory -Relative ([string]$record.path) + } + $installerPayloadManifestPath = Join-Path $installerPayloadDirectory 'installer-payload.manifest.json' + $installerPayloadGenerator = Join-Path $repoRoot 'tools\New-InstallerPayloadManifest.ps1' + if (-not [IO.File]::Exists($installerPayloadGenerator)) { throw "Installer payload manifest generator is missing: $installerPayloadGenerator" } + $installerPayloadManifest = & $installerPayloadGenerator -PayloadRoot $installerPayloadDirectory -ProjectVersion $Version -OutputPath $installerPayloadManifestPath + if (@($installerPayloadManifest.files).Count -ne $payloadRecords.Count) { throw 'Installer payload generator record count differs from the verified portable payload.' } + for ($recordIndex = 0; $recordIndex -lt $payloadRecords.Count; $recordIndex++) { + if ([string]$installerPayloadManifest.files[$recordIndex].path -cne [string]$payloadRecords[$recordIndex].path -or + [int64]$installerPayloadManifest.files[$recordIndex].length -ne [int64]$payloadRecords[$recordIndex].length -or + [string]$installerPayloadManifest.files[$recordIndex].sha256 -cne [string]$payloadRecords[$recordIndex].sha256) { + throw 'Installer payload generator records differ from the verified portable payload.' + } + } + $installerPayloadManifestSha256 = Get-CcodBuildFileSha256 -Path $installerPayloadManifestPath + $installerPackagePath = Join-Path $installerPayloadDirectory 'installer-package.zip' + $installerPackageManifestPath = Join-Path $installerPayloadDirectory 'installer-package.manifest.json' + Import-Module (Join-Path $PSScriptRoot 'InstallerPackage.psm1') -Force + $installerPackage = New-CcodInstallerPackage -PayloadRoot $installerPayloadDirectory -PayloadManifestPath $installerPayloadManifestPath -Version $Version -GitCommit $gitCommit -OutputPath $installerPackagePath -ManifestOutputPath $installerPackageManifestPath + Test-CcodInstallerPackage -PackagePath $installerPackagePath -ManifestPath $installerPackageManifestPath -ExpectedPackageSha256 $installerPackage.PackageSha256 -ExpectedManifestSha256 $installerPackage.ManifestSha256 -ExpectedVersion $Version -ExpectedGitCommit $gitCommit | Out-Null + $activationBootstrapPath = Assert-CcodBuildRegularFile -Path (Join-Path $repoRoot 'Activate-CcodRemoteFix.ps1') -Kind 'Activation bootstrap' + $activationBootstrapSha256 = Get-CcodBuildFileSha256 -Path $activationBootstrapPath + $destinationInventoryGenerator = Join-Path $repoRoot 'tools\New-InstallerDestinationInventory.ps1' + if (-not [IO.File]::Exists($destinationInventoryGenerator)) { throw "Installer destination inventory generator is missing: $destinationInventoryGenerator" } + & $destinationInventoryGenerator -RepositoryRoot $repoRoot -PayloadRoot $installerPayloadDirectory -ProjectVersion $Version -InnoScriptPath (Join-Path $PSScriptRoot 'CodexControlOtherDevices.iss') -OutputPath $installerDestinationInventoryPath | Out-Null $payloadManifestPath = Join-Path $stageRoot 'payload-manifest.json' $payloadManifest = [ordered]@{ schemaVersion = 1 @@ -223,9 +490,6 @@ try { ) } Write-CcodBuildUtf8 -Path $releaseManifest -Text (($releaseRecord | ConvertTo-Json -Depth 8) + [Environment]::NewLine) - $releaseValidationTool = Join-Path $repoRoot 'tools\Test-ReleaseDefender.ps1' - if (-not (Test-Path -LiteralPath $releaseValidationTool -PathType Leaf)) { throw "Release manifest validator is missing: $releaseValidationTool" } - . $releaseValidationTool -Library Test-CcodReleaseAssetManifest -ManifestPath $releaseManifest -AssetDirectory $dist -ExpectedVersion $Version | Out-Null } finally { if ([IO.Directory]::Exists($stageRoot)) { @@ -256,14 +520,27 @@ $isccCandidates = @( $iscc = $isccCandidates | Where-Object { -not [string]::IsNullOrWhiteSpace($_) -and [IO.File]::Exists($_) } | Select-Object -First 1 if (-not $iscc) { throw 'Inno Setup 6 (ISCC.exe) was not found. Install it with: winget install --id JRSoftware.InnoSetup --exact' } $issPath = Join-Path $PSScriptRoot 'CodexControlOtherDevices.iss' -$portableArtifact = Join-Path $PSScriptRoot 'generated\portable' -& $iscc "/DProjectVersion=$Version" "/DTrayHostArtifactDirectory=$trayHostArtifact" "/DPortableArtifactDirectory=$portableArtifact" "/O$dist\." $issPath -if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $setupExe -PathType Leaf)) { - throw "Inno Setup compilation failed with exit code $LASTEXITCODE" -} -$setupHash = Get-CcodBuildFileSha256 -Path $setupExe +Import-Module (Join-Path $PSScriptRoot 'SetupArtifact.psm1') -Force +[IO.File]::Copy((Assert-CcodBuildRegularFile -Path $installerPackageManifestPath -Kind 'Installer package manifest'),$setupPayloadInput,$false) +[IO.File]::Copy((Assert-CcodBuildRegularFile -Path $installerDestinationInventoryPath -Kind 'Installer destination inventory'),$setupInventoryInput,$false) +$setupProvenanceRecord = New-CcodSealedSetupBuildProvenance -Version $Version -GitCommit $gitCommit -BuildTimestampUtc $buildTimestampUtc -PackagePath $installerPackagePath -PackageManifestPath $installerPackageManifestPath -ActivationBootstrapPath $activationBootstrapPath -InnoTemplatePath $issPath -DestinationInventoryPath $installerDestinationInventoryPath -CompilerPath $iscc -OutputPath $setupProvenance +Test-CcodSealedSetupBuildProvenance -ProvenancePath $setupProvenance -ExpectedVersion $Version -ExpectedGitCommit $gitCommit -ExpectedPackageSha256 $installerPackage.PackageSha256 -ExpectedPackageManifestSha256 $installerPackage.ManifestSha256 -ExpectedActivationBootstrapSha256 $activationBootstrapSha256 -ExpectedBuildTimestampUtc $buildTimestampUtc -PackagePath $installerPackagePath -PackageManifestPath $installerPackageManifestPath -ActivationBootstrapPath $activationBootstrapPath -InnoTemplatePath $issPath -DestinationInventoryPath $installerDestinationInventoryPath -CompilerPath $iscc | Out-Null +$isccArguments = @( + "/DProjectVersion=$Version", + "/DInstallerPackagePath=$installerPackagePath", + "/DInstallerPackageManifestPath=$installerPackageManifestPath", + "/DInstallerPackageSha256=$($installerPackage.PackageSha256)", + "/DInstallerPackageManifestSha256=$($installerPackage.ManifestSha256)", + "/DActivationBootstrapPath=$activationBootstrapPath", + "/DActivationBootstrapSha256=$activationBootstrapSha256", + "/DSetupGitCommit=$gitCommit", + "/DSetupProvenancePath=$setupProvenance", + "/O$dist\." +) +Invoke-CcodBuildInnoCompiler -TemplatePath $issPath -InventoryPath $installerDestinationInventoryPath -IsccPath $iscc -Arguments $isccArguments -SetupPath $setupExe +$setupValidation = Test-CcodSetupArtifact -SetupPath $setupExe -ExpectedVersion $Version -ExpectedGitCommit $gitCommit -ExpectedPackageSha256 $installerPackage.PackageSha256 -ExpectedPackageManifestSha256 $installerPackage.ManifestSha256 -ExpectedActivationBootstrapSha256 $activationBootstrapSha256 +$setupHash = [string]$setupValidation.Sha256 Write-CcodBuildUtf8 -Path $setupChecksum -Text ("{0} *{1}" -f $setupHash,[IO.Path]::GetFileName($setupExe)) -$setupReleaseManifest = Join-Path $dist "CodexRemote-fix-$Version-setup-release-manifest.json" $setupRecord = [ordered]@{ schemaVersion = 1 product = 'CodexRemote-fix' @@ -273,15 +550,28 @@ $setupRecord = [ordered]@{ assets = @( [ordered]@{ name = [IO.Path]::GetFileName($setupExe); sha256 = $setupHash }, [ordered]@{ name = [IO.Path]::GetFileName($setupChecksum); sha256 = Get-CcodBuildFileSha256 -Path $setupChecksum }, - [ordered]@{ name = [IO.Path]::GetFileName($provenance); sha256 = Get-CcodBuildFileSha256 -Path $provenance } + [ordered]@{ name = [IO.Path]::GetFileName($provenance); sha256 = Get-CcodBuildFileSha256 -Path $provenance }, + [ordered]@{ name = [IO.Path]::GetFileName($setupProvenance); sha256 = Get-CcodBuildFileSha256 -Path $setupProvenance }, + [ordered]@{ name = [IO.Path]::GetFileName($setupPayloadInput); sha256 = Get-CcodBuildFileSha256 -Path $setupPayloadInput }, + [ordered]@{ name = [IO.Path]::GetFileName($setupInventoryInput); sha256 = Get-CcodBuildFileSha256 -Path $setupInventoryInput } ) } Write-CcodBuildUtf8 -Path $setupReleaseManifest -Text (($setupRecord | ConvertTo-Json -Depth 8) + [Environment]::NewLine) Test-CcodReleaseAssetManifest -ManifestPath $setupReleaseManifest -AssetDirectory $dist -ExpectedVersion $Version | Out-Null +Test-CcodExactReleaseAssetSet -AssetDirectory $dist -Version $Version | Out-Null Write-Host '' Write-Host 'Installer build completed:' -ForegroundColor Green Write-Host (" Setup: {0}" -f $setupExe) Write-Host (" SHA-256: {0}" -f $setupChecksum) +Write-Host (" Provenance: {0}" -f $setupProvenance) Write-Host (" Manifest: {0}" -f $setupReleaseManifest) Write-Host '' +}) + +# NOTE: this action must stay a plain scriptblock. PowerShell 7 gives +# GetNewClosure() a fresh dynamic module whose command resolution cannot see the +# functions defined in this script, so wrapping the action would break every +# local call such as Copy-CcodBuildPayloadFile. Windows PowerShell 5.1 happens to +# resolve those names, so the defect only appears under the pwsh used by the +# release workflow. diff --git a/docs/superpowers/plans/2026-08-28-v2522-async-tray-receipts.md b/docs/superpowers/plans/2026-08-28-v2522-async-tray-receipts.md new file mode 100644 index 0000000..0a3cfea --- /dev/null +++ b/docs/superpowers/plans/2026-08-28-v2522-async-tray-receipts.md @@ -0,0 +1,242 @@ +# v2.5.22 Async Tray Receipt Reliability Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make terminal tray receipts non-blocking, bounded, handle-verified, +and genuinely traceable through the authenticated production child flow. + +**Architecture:** Terminal action correlation remains in `HostTransport`, but +receipt persistence moves to one bounded background sink. A native pinned +handle store writes the fixed local receipt without pathname re-resolution. +An internal child-session seam lets a temporary trace executable exercise the +same authenticated child behavior with a fake native platform. + +**Tech Stack:** Windows PowerShell 5.1, C#/.NET Framework 4.8, Win32 file +handles, existing TrayHost authenticated protocol and self-tests. + +**Spec:** `docs/superpowers/specs/2026-08-28-v2522-async-tray-receipts-design.md` + +## Global Constraints + +- Preserve the existing wire message types, directions, payloads, sequences, + MAC checks, command IDs, and current-user boundaries exactly. +- Receipt disk text contains only command, revision, terminal status, and a + canonical code; never persist action IDs, transaction IDs, paths, tokens, + exception text, or arbitrary payload text. +- No production command-line test mode, listener, environment-variable switch, + or external control surface may be added. +- All terminal actions leave the pending set before any receipt I/O or queue + wait; no writer hang may create a replacement writer thread. +- Generic UI feedback is produced only after durable receipt success; its queue + is bounded and runs on the existing STA work path. +- Keep primary checkout untouched; work only in this linked worktree. + +--- + +### Task 1: Add a pinned, bounded receipt store + +**Files:** +- Modify: `src/trayhost/TrayTerminalDiagnostic.cs` +- Modify: `src/trayhost/Program.cs` +- Test: `tests/trayhost/TrayHostNativeSelfTest.cs` +- Test: `tests/trayhost/Invoke-TrayHostSelfTest.ps1` + +**Interfaces:** +- `TrayTerminalDiagnosticStore` owns validated native directory/leaf handles. +- `TryAppendDurably(TrayTerminalDiagnostic)` returns `true` only after a full + write and `Flush(true)` through the validated leaf handle. +- `TryGetDefaultPath` remains fixed to the current-user receipt leaf; no caller + can choose another production path. + +- [ ] **Step 1: Write failing native-store tests** + +Add a temporary-root test that precreates a reparse directory, reparse leaf, +hard-link leaf, and an outside sentinel. Add a barrier that attempts to +replace the logs directory after the store has opened its directory chain. +Assert each unsafe case returns `false` and the sentinel bytes remain unchanged. +Add 64-KiB boundary and rollover tests that verify one whole latest record is +retained through the open handle. + +- [ ] **Step 2: Run RED** + +Run: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -NativeOnly` + +Expected: compile or behavioral failure because the current path-based store +does not own native handles or the barrier contract. + +- [ ] **Step 3: Implement the minimal handle-pinned store** + +Open the existing LocalAppData/product/logs compatibility parents and the fixed +leaf through `CreateFileW` safe handles. Create only +`logs\tray-receipts` as a protected current-user/SYSTEM/Administrators child, +then reopen and validate its exact handle; legacy direct receipt leaves are +never reused. Reject reparse, non-directory, non-file, multi-link, +unexpected-final-path, share, and handle-query failures before writes. Keep +handles while using `SetLength`, write, and `Flush(true)` for the 64-KiB +rollover. Do not re-resolve the leaf path for append or truncation. + +- [ ] **Step 4: Run GREEN** + +Run the native suite and production headless compile smoke. Assert the store +write is bounded, handle-validated, and cannot alter an outside sentinel. + +- [ ] **Step 5: Commit** + +```text +fix: pin tray terminal receipt storage +``` + +### Task 2: Make terminal receipt dispatch asynchronous and bounded + +**Files:** +- Create: `src/trayhost/TrayTerminalReceiptSink.cs` +- Modify: `src/trayhost/HostTransport.cs` +- Modify: `src/trayhost/Program.cs` +- Modify: `src/trayhost/TrayHostApplication.cs` +- Test: `tests/trayhost/TrayHostTransportSelfTest.cs` +- Test: `tests/trayhost/TrayHostNativeSelfTest.cs` + +**Interfaces:** +- `HostTransport.TryAcknowledgeAction` returns a typed terminal receipt for a + terminal result after removing its pending entry. +- `TrayTerminalReceiptSink.TrySubmit` is zero-wait and admits at most eight + outstanding receipts. +- `HostTransport.TryPublishDurableReceipt` accepts only a trusted in-memory + receipt and places one bounded UI item. + +- [ ] **Step 1: Write failing sink/transport tests** + +Use a controllable fake store that blocks before open, during write, and during +flush. Assert current and following authenticated terminal results return +within 250 ms, the ninth outstanding receipt is dropped immediately, no pending +entry remains, and no generic UI work exists until an explicit durable-success +callback. Add disposal and recovery tests without starting a replacement +writer. + +- [ ] **Step 2: Run RED** + +Run: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -TransportOnly` + +Expected: the current synchronous writer blocks the terminal call and the new +receipt-sink API is unavailable. + +- [ ] **Step 3: Implement one worker and bounded UI completion** + +Separate correlation from receipt work. The reader submits once and returns; +only the single background writer calls the store. On durable success it posts +bounded tokened About/failure work to an internal STA message path separate from +presentation/shutdown drains; no unrelated work message may make an unresolved +receipt visible. Callback admission and disposal must be atomic: no durable +callback begins after close. On admission, store, callback, queue, token-post, +or disposal failure, consume the terminal result but show no feedback. Do not +alter the wire result or add a new message type. + +- [ ] **Step 4: Run GREEN** + +Run transport and native suites. Confirm a hung writer cannot stall the reader +or action capacity, and a later durable receipt still produces exactly one +generic feedback item. + +- [ ] **Step 5: Commit** + +```text +fix: isolate tray terminal receipt writes +``` + +### Task 3: Trace the actual child session without a shipped test hook + +**Files:** +- Create: `src/trayhost/TrayHostChildSession.cs` +- Create: `src/trayhost/WindowsTrayHostRuntime.cs` +- Modify: `src/trayhost/Program.cs` +- Modify: `src/trayhost/TrayHostApplication.cs` +- Create: `tests/trayhost/TrayHostProductionTraceSelfTest.cs` +- Modify: `tests/trayhost/Invoke-TrayHostSelfTest.ps1` +- Modify: `tests/persistence/TrayHostProductionTrace.SelfTest.ps1` +- Test: `tests/trayhost/TrayHostParentClientSelfTest.cs` +- Test: `tests/persistence/TrayHostClient.SelfTest.ps1` + +**Interfaces:** +- `TrayHostChildSession.Run` accepts only internal stream/runtime/sink + dependencies; `Program.Main` exposes no new arguments. +- `WindowsTrayHostRuntime` composes the existing Windows platform, window, and + application; the temporary trace executable supplies a fake implementation. + +- [ ] **Step 1: Write failing real-trace tests** + +Compile a temporary trace executable with the same child session and a fake +native platform. Start it through normal `TrayHostParentClient.Start` (without +`TestProcessFactory`). Require a current native `OpenLogs` selection to reach +the PowerShell TrayHostClient/Supervisor fixture and a stale authenticated +negative action to show only the generic dialog after durable receipt success. + +- [ ] **Step 2: Run RED** + +Run: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/TrayHostProductionTrace.SelfTest.ps1` + +Expected: it fails because `Program.RunChild` cannot be composed with a +test-only runtime while preserving the production child session. + +- [ ] **Step 3: Extract the minimal internal session seam** + +Move the current child bootstrap, reader/writer sequencing, work drain, and +shutdown behavior into `TrayHostChildSession`. `Program.RunChild` becomes a +thin argument/identity/Windows-runtime adapter. The trace executable shares +the child session and real protocol code but has no production CLI selector. + +- [ ] **Step 4: Run GREEN** + +Run ParentClient, TrayHostClient, production trace, native, transport, and +headless-smoke suites. Confirm current completion has no generic dialog; +stale rejection has one generic dialog with no internal code or correlation +data; no production executable recognizes test arguments. + +- [ ] **Step 5: Commit** + +```text +test: trace authenticated tray terminal receipts +``` + +### Task 4: Revalidate v2.5.22 release inputs + +**Files:** +- Modify: `tests/persistence/ReleaseWorkflow.SelfTest.ps1` +- Test: `tests/PersistenceSelfTest.ps1` +- Test: `tests/Validate.ps1` + +- [ ] **Step 1: Write a failing release-surface test** + +Require the TrayHost provenance record to include every newly compiled source +and require the release workflow to run the production trace before producing +setup or portable assets. + +- [ ] **Step 2: Run RED** + +Run: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/ReleaseWorkflow.SelfTest.ps1` + +Expected: it fails until the release validation includes the receipt trace. + +- [ ] **Step 3: Add the smallest release gate** + +Invoke the production trace in the existing TrayHost/release validation path; +preserve existing manifest, Defender, payload, and exact-version checks. + +- [ ] **Step 4: Run GREEN and commit** + +Run release workflow plus the persistence/validation suites under the existing +controlled isolation harness. Commit: + +```text +test: gate release on tray receipt trace +``` + +## Plan self-review + +- Task 1 handles filesystem identity and bounded storage without changing + authenticated action semantics. +- Task 2 consumes terminal actions before zero-wait sink admission and never + lets a writer block the reader or pending-action capacity. +- Task 3 proves the shared production child session end-to-end in a temporary + executable, so tests cannot create a production test control path. +- Task 4 makes the new trace a release gate before the existing v2.5.22 + metadata/build/reinstall acceptance task begins. diff --git a/docs/superpowers/plans/2026-08-28-v2522-install-runtime-reliability.md b/docs/superpowers/plans/2026-08-28-v2522-install-runtime-reliability.md new file mode 100644 index 0000000..246a845 --- /dev/null +++ b/docs/superpowers/plans/2026-08-28-v2522-install-runtime-reliability.md @@ -0,0 +1,342 @@ +# v2.5.22 Install and Runtime Reliability Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship v2.5.22 so a fresh or legacy upgrade can only activate the +payload supplied by that setup, while stable Codex roots survive transient +Electron child churn and tray action failures remain diagnosable. + +**Architecture:** Setup uses a versioned immutable payload directory and a +hash manifest as the lifecycle source allowlist. Lifecycle close/recovery +retries only indeterminate child-tree reads while retaining the exact top-level +root identity boundary. Tray actions retain the authenticated protocol but +produce a sanitized local diagnostic result and receive end-to-end coverage. + +**Tech Stack:** Windows PowerShell 5.1, Inno Setup, C# TrayHost, Node.js 22, +existing PowerShell persistence self-tests. + +**Spec:** `docs/superpowers/specs/2026-08-28-v2522-install-runtime-reliability-design.md` + +## Global Constraints + +- Release version is exactly `2.5.22`; setup, portable ZIP, runtime manifests, + README, and release contracts must agree. +- Never modify Codex binaries or `WindowsApps`; preserve the current-user DPAPI + device-key file. +- A changed PID, creation time, session, user SID, package family, executable + path, mode, or debug-port identity is fail-closed and must not be retried as + success. +- Every implementation change begins with a failing behavioral test, then the + smallest production change, then focused and full validation. +- Keep the primary checkout untouched; all work occurs in this worktree. + +--- + +### Task 1: Bind setup activation to an immutable payload + +**Files:** +- Modify: `build/build.ps1` +- Modify: `build/CodexControlOtherDevices.iss` +- Modify: `Activate-CcodRemoteFix.ps1` +- Modify: `Install-CodexControlOtherDevices.ps1` +- Modify: `src/persistence/modules/InstallLifecycle.psm1` +- Test: `tests/persistence/InstallLifecycle.SelfTest.ps1` +- Test: `tests/persistence/ReleaseWorkflow.SelfTest.ps1` + +**Interfaces:** +- Build writes `installer-payload.manifest.json` with ordered + `{schemaVersion,projectVersion,files}` records in the setup payload root. +- `Activate-CcodRemoteFix.ps1` receives `-ExpectedVersion` and validates the + payload before activation and the active runtime after `Ready`. +- `Invoke-CcodInstall` receives `-ExpectedVersion` and `-PayloadManifestPath`. + +- [ ] **Step 1: Write failing lifecycle tests** + +```powershell +$oldPayload = New-CcodLifecycleSourceFixture -Root $source -Version '2.5.13' +Assert-CcodThrows { + Invoke-CcodInstall -SourceRoot $source -InstallRoot $install ` + -ExpectedVersion '2.5.22' -PayloadManifestPath $manifestPath -Adapters $fake.Adapters +} 'CCOD_INSTALL_PAYLOAD_VERSION_MISMATCH' +Assert-CcodEqual $oldRuntime $pointer.activeRuntime 'old payload cannot replace the active runtime' +``` + +Add a second test whose matching manifest has an extra stale source file. It +must prove the staged runtime contains only manifest-listed records. + +- [ ] **Step 2: Run the focused test and confirm RED** + +Run: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/InstallLifecycle.SelfTest.ps1` + +Expected: the new test fails because `Invoke-CcodInstall` does not accept or +enforce the expected version/manifest yet. + +- [ ] **Step 3: Implement the smallest payload-binding path** + +Generate the ordered manifest during build, copy the release payload into +`{app}\payload\{#ProjectVersion}`, and pass that directory plus +`-ExpectedVersion {#ProjectVersion}` to activation. Validate each source file +against the manifest before staging, reject an unexpected project version, and +reread `active.json` plus the selected runtime manifest after readiness before +writing `Ready`. + +- [ ] **Step 4: Run focused tests and prove GREEN** + +Run the lifecycle test, release workflow test, and Inno compile contract test. +Expected: matching new/upgrade fixtures are Ready; mismatched and stale payload +fixtures fail before active-pointer mutation. + +- [ ] **Step 5: Commit** + +```text +fix: bind setup activation to immutable payload +``` + +### Task 2: Compile an include-free generated setup script + +**Files:** +- Modify: `build/build.ps1` +- Modify: `build/CodexControlOtherDevices.iss` +- Modify: `tools/New-InstallerDestinationInventory.ps1` +- Test: `tests/persistence/ReleaseWorkflow.SelfTest.ps1` + +**Interfaces:** +- The checked-in `.iss` contains exactly one inventory marker comment. +- Build writes a temporary generated `.iss` by replacing that marker with the + verified inventory procedure and invokes ISCC only on the generated file. +- The template accepts only the required literal build directives and rejects + all external/line-spanned/pragma/generated-code preprocessor directives. + +- [ ] **Step 1: Write failing behavior tests** + +```powershell +$template = @' +[Setup] +AppName=fixture +[Files] +Source: "first.txt"; DestDir: "{app}\first" +# \ ++ "extra.iss" +'@ +Assert-CcodThrows { + & $generator -RepositoryRoot $root -PayloadRoot $payload -ProjectVersion '2.5.22' ` + -InnoScriptPath $templatePath -OutputPath $inventoryPath +} 'include' +Assert-CcodFalse (Test-Path -LiteralPath $inventoryPath) 'include bypass produces no partial inventory' +``` + +Add actual ISCC tests showing that the generated script compiles with its +injected inventory, while templates with literal or line-spanned include, +pragma, emit, and unknown preprocessor directives are rejected before ISCC is +launched. + +- [ ] **Step 2: Run the focused test and confirm RED** + +Run: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/ReleaseWorkflow.SelfTest.ps1` + +Expected: the line-spanned include-alias fixture is accepted before the new +template boundary exists. + +- [ ] **Step 3: Implement the generated include-free compiler input** + +Build reads the checked-in template, verifies every literal preprocessor line +is one of the fixed required directives, rejects continuation characters and +all other directives, substitutes the generated inventory text once, writes a +GUID-named temporary `.iss`, validates that the result has no marker or unsafe +preprocessor directive, and passes that generated path to ISCC. Clean the +temporary script in `finally`, including when the compiler returns nonzero. + +- [ ] **Step 4: Run focused tests and prove GREEN** + +Run ReleaseWorkflow and the real ISCC fixture. Confirm the compiled setup +source contains the expected injected procedure; literal and line-spanned +include aliases plus other code-generating directives leave neither setup nor +inventory output; a nonzero compiler also leaves no generated `.iss`. + +- [ ] **Step 5: Commit** + +```text +fix: compile include-free setup inventory +``` + +### Task 3: Retry only transient Electron tree reads + +**Files:** +- Modify: `src/persistence/modules/ProcessControl.psm1` +- Modify: `src/persistence/modules/SessionEngine.psm1` +- Modify: `src/persistence/Supervisor.ps1` +- Test: `tests/persistence/ProcessControl.SelfTest.ps1` +- Test: `tests/persistence/SessionEngine.SelfTest.ps1` +- Test: `tests/persistence/Supervisor.SelfTest.ps1` + +**Interfaces:** +- Add `Get-CcodStableVerifiedProcessTree` that accepts a root snapshot, + `StatusEvidence`, a fixed retry budget, and adapters. +- It returns a verified tree only if every successful attempt preserves the + original root identity; otherwise it returns no tree. +- `Confirm-CcodSupervisorLifecycleProofCandidate` uses the same bounded + candidate-root rebind rule and records a stable failure reason without + granting a changed root any authority. + +- [ ] **Step 1: Write failing process-tree tests** + +```powershell +$attempt = 0 +$tree = Get-CcodStableVerifiedProcessTree -Root $root -Adapters @{ + GetVerifiedTree = { $attempt++; if ($attempt -eq 1) { @() } else { @($root,$child) } } + GetProcess = { param($pid,$status) if ($pid -eq $root.Pid) { $root } else { $child } } + Delay = { param($milliseconds) } +} +Assert-CcodEqual 2 $tree.Count 'one transient empty tree is retried without relaxing root identity' +``` + +Add a companion test where the second root snapshot has a different creation +time; it must return no tree and perform no close mutation. + +Add a Supervisor test where the first post-worker rebind sees no special root +but the second sees the same candidate root; it must publish `RemoteVerified`. +Add its counterpart where the second root has a different creation time; it +must remain `CCOD_REMOTE_PROOF_REBIND_FAILED`. + +- [ ] **Step 2: Run focused tests and confirm RED** + +Run: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/ProcessControl.SelfTest.ps1` + +Expected: the new helper is unavailable and the tests fail for that missing +behavior. + +- [ ] **Step 3: Implement bounded stable acquisition** + +Use a fixed small retry count and condition-based delay only around a failed +tree acquisition. On each retry reread the root and require +`Test-CcodProcessMatch` against the original root. Replace pre-mutation +`GetTree` calls in close and recovery paths with this helper; leave per-member +pre-stop/post-stop/final proofs unchanged. + +- [ ] **Step 4: Run focused tests and prove GREEN** + +Run ProcessControl, SessionEngine, and Supervisor self-tests. Confirm child +churn retries successfully, a transient same-identity rebind succeeds, root +drift remains fail-closed, and ordinary close tests still prove every stopped +member. + +- [ ] **Step 5: Commit** + +```text +fix: stabilize verified Electron process trees +``` + +### Task 4: Make tray action correlation testable and diagnosable + +> **Superseded for execution:** Task 4 reached an approved architectural +> correction after three reviewed fix rounds. Execute +> `docs/superpowers/plans/2026-08-28-v2522-async-tray-receipts.md` before this +> plan's Task 5. Its receipt sink preserves this task's authenticated +> correlation and generic-dialog constraints while making persistence bounded +> and non-blocking. + +**Files:** +- Modify: `src/persistence/modules/TrayHostClient.psm1` +- Modify: `src/persistence/Supervisor.ps1` +- Modify: `src/trayhost/HostTransport.cs` +- Modify: `src/trayhost/TrayWindow.cs` +- Test: `tests/persistence/TrayHostClient.SelfTest.ps1` +- Test: `tests/persistence/Supervisor.SelfTest.ps1` +- Test: `tests/trayhost/TrayHostTransportSelfTest.cs` + +**Interfaces:** +- A terminal tray action record carries `command`, `revision`, `status`, and a + stable `CCOD_*` code to the local diagnostic log. +- A current acknowledged presentation revision reaches its command handler; + an unacknowledged revision is rejected as `CCOD_TRAY_ACTION_STALE`. + +- [ ] **Step 1: Write failing end-to-end action tests** + +```powershell +$action = [pscustomobject]@{ ActionId=[guid]::NewGuid(); Command='OpenLogs'; Revision=[UInt64]7 } +$hostState.Tray.AcknowledgedPresentations['7'] = $enabledPresentation +$result = Invoke-CcodSupervisorCommand $hostState $adapters $action +Assert-CcodEqual 'Completed' $result.Status 'acknowledged revision reaches OpenLogs' +``` + +Add the same command with revision `8` and no ACK map entry; assert +`Rejected` plus `CCOD_TRAY_ACTION_STALE` and one sanitized diagnostic record. + +- [ ] **Step 2: Run focused tests and confirm RED** + +Run the TrayHost client, Supervisor, and transport tests. Expected failure: +the diagnostic record and full correlation behavior are absent. + +- [ ] **Step 3: Implement only the action correlation record and tests** + +Record terminal action outcomes after Supervisor authorization and before the +host collapses them into the generic dialog. Keep the generic dialog wording +and authenticated pipe protocol unchanged. Do not add external control or a +new unauthenticated command channel. + +- [ ] **Step 4: Run focused tests and prove GREEN** + +Run all three focused suites and the native TrayHost self-test. Confirm a +stale revision cannot perform any action and a current revision carries the +exact terminal code into local diagnostics. + +- [ ] **Step 5: Commit** + +```text +test: cover tray action revision correlation +``` + +### Task 5: Version, documentation, and release acceptance + +**Files:** +- Modify: `package.json` +- Modify: `src/trayhost/AssemblyInfo.cs` +- Modify: `src/portable/AssemblyInfo.cs` +- Modify: `README.md` +- Modify: `README.zh-CN.md` +- Modify: `CHANGELOG.md` +- Test: `tests/persistence/ReleaseWorkflow.SelfTest.ps1` + +- [ ] **Step 1: Write failing version-contract assertions** + +Add release contract coverage requiring version `2.5.22`, the versioned setup +payload route, and both setup/portable manifests to expose the same version. + +- [ ] **Step 2: Run the release contract test and confirm RED** + +Run: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/ReleaseWorkflow.SelfTest.ps1` + +Expected: it fails while the product metadata is still `2.5.21`. + +- [ ] **Step 3: Update metadata and concise user documentation** + +Set every product version to `2.5.22`. Keep the README stable-release section +concise; put technical repair detail in `CHANGELOG.md` and the GitHub release +body. + +- [ ] **Step 4: Run focused and full validation** + +Run `npm.cmd test`, release contract tests, native TrayHost tests, and a clean +release build. Review setup and portable manifests byte-for-byte against the +generated assets. + +- [ ] **Step 5: Commit** + +```text +release: prepare CodexRemote-fix v2.5.22 +``` + +## Plan self-review + +- Task 1 produces the immutable source/expected-version contract consumed by + the new setup flow and release contract. +- Task 2 removes the preprocessor include ambiguity from the Task 1 setup + source before any release compiler invocation. +- Task 3 changes only pre-mutation tree acquisition and explicitly leaves all + identity and post-stop checks strict. +- Task 4 has no dependency on Task 3 and can be reviewed independently; its + diagnostics make future live failures actionable without weakening protocol + authentication. +- Task 5 consumes the completed behavior and performs no release publication; + publishing, installation, and reboot remain a separately authorized final + acceptance step. diff --git a/docs/superpowers/plans/2026-08-29-v2522-immutable-generation.md b/docs/superpowers/plans/2026-08-29-v2522-immutable-generation.md new file mode 100644 index 0000000..ff490be --- /dev/null +++ b/docs/superpowers/plans/2026-08-29-v2522-immutable-generation.md @@ -0,0 +1,611 @@ +# v2.5.22 Immutable Generation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the unreleased v2.5.22 install and upgrade flow create only +complete immutable generations, atomically select one active generation, and +prove official draft assets on the current machine before public promotion. + +**Architecture:** The file layer creates and verifies unique generation trees +with private handle capabilities and never overwrites an existing generation +or recursively deletes a nonempty tree during install. The lifecycle appends a +bounded phase record and uses a monotonic pointer/compensation protocol. Inno +Setup is a bootstrap-only shell, while dual Defender receipts, exact assets, +old-version upgrade, reboot, tray, and remote evidence form the final release +promotion chain. + +**Tech Stack:** Windows PowerShell 5.1, .NET Framework/C# native interop, +Inno Setup 6, Node.js 22, GitHub Actions, Microsoft Defender, existing +PowerShell persistence and TrayHost self-tests. + +**Spec:** `docs/superpowers/specs/2026-08-29-v2522-immutable-generation-design.md` + +## Global Constraints + +- Keep the unreleased version exactly `2.5.22`; no tag or public release is + created until the final external acceptance task. +- Never modify `ChatGPT.exe`, `app.asar`, `WindowsApps`, account state, or the + DPAPI device-key store. +- Preserve authenticated TrayHost wire messages, process identity checks, and + current-user installation semantics. Do not add a control channel, listener, + production test switch, or remote protocol. +- The product's security boundary is current-user integrity and ordinary race + resistance. Do not claim protection against a same-user process with + unrestricted write/DACL authority; an elevated/signed broker is out of scope. +- Staging creates unique generation files only. Existing generations and stable + files are never overwritten before readiness. Install-time cleanup retires + or retains old generations; it never recursively deletes a nonempty tree. +- A failed pre-pointer phase leaves the old pointer and stable shell unchanged. + A post-pointer failure writes a higher compensating pointer or terminal + `Failed/CCOD_INSTALL_ROLLBACK_FAILED`; it never decrements a generation. +- `Ready` requires matching pointer, generation manifest, expected version, + Supervisor, authenticated TrayHost, and activation receipt. +- Setup/portable assets are selected by an explicit 11-name allowlist, not an + extension glob. Public promotion requires two real downloaded-asset Defender + receipts, complete automated acceptance, and individual manual evidence. +- Every production change follows TDD RED → GREEN → focused review. Test + fixtures must not stop or start the real current-machine product. + +--- + +### Task 1: Reduce the file layer to immutable generation primitives + +**Files:** +- Modify: `src/persistence/modules/InstallFileTransaction.psm1` +- Modify: `tests/persistence/InstallFileTransaction.SelfTest.ps1` +- Modify: `tests/PersistenceSelfTest.ps1` +- Modify: `tests/Validate.ps1` + +**Interfaces:** + +Replace the failed DACL/retirement implementation with these private +create-only operations: + +```powershell +Open-CcodInstallGeneration -InstallRoot -RuntimeId +Open-CcodInstallRetainedGeneration -InstallRoot -RuntimeId -ExpectedManifestSha256 +New-CcodInstallDirectory -Transaction -Parent -Leaf [-CreateIfMissing] +New-CcodInstallGenerationLeaf -Generation -Leaf +Copy-CcodInstallSealedSource -Generation -SourcePath -Leaf -ExpectedLength -ExpectedSha256 +Write-CcodInstallGenerationManifest -Generation -Manifest +Write-CcodInstallRecord -Transaction -Parent -Leaf -Record +Commit-CcodInstallActivePointer -InstallRoot -TargetGeneration -ExpectedPreviousGeneration -FileTransaction +Retire-CcodInstallGeneration -InstallRoot -RuntimeId -FileTransaction +Close-CcodInstallFileTransaction -Transaction -Disposition Ready|Failed +``` + +Capabilities are opaque reference tokens stored in module-private state. The +CLR bridge exposes only a non-mutating marker; no exported method accepts an +arbitrary path or bare handle. PowerShell module scope/CLR reflection is not a +process-isolation boundary and is outside this unprivileged model. There is no +persistent DACL change and no nonempty recursive deletion operation. + +- [ ] **Step 1: Write failing immutable-generation tests** + +Add RED cases proving a duplicate generation ID, destination leaf, or pointer +generation is rejected without changing the existing object. Add a read-only +retained-generation fixture that can be selected by a compensating pointer but +cannot create or mutate a leaf. Add a generic scoped-record fixture for state, +receipt, and log files. Add barriers for +source replacement after handle open, generation manifest mutation after +write, invalid reparse/ADS/multilink entries, forged/cross-transaction +capabilities, and native handle close during a relative operation. Add a +nonempty generation case whose live name is atomically retired to a unique +owned quarantine name and whose bytes remain readable and unchanged. + +- [ ] **Step 2: Run RED** + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1 +``` + +Expected: the new immutable names/operations are absent or the current DACL +retirement/overwrite behavior fails the new assertions. + +- [ ] **Step 3: Implement minimal immutable file primitives** + +Use relative native creates under a validated generation parent, create-new +leaf semantics, private source/destination handles, flush plus same-handle +length/SHA-256 verification, and opaque capabilities. A destination that +already exists fails with a stable collision code. Retire only a fully proven +owned generation by writing one create-only retired-generation record; do not +rename or recursively delete the nonempty generation. + +- [ ] **Step 4: Run GREEN and registration checks** + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\PersistenceSelfTest.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\Validate.ps1 -SkipInstalledPackageCheck +``` + +The focused suite must exit zero. If the aggregate reaches the known local +Bootstrap fallback/mutex boundary, record the exact case as environmental and +not as a pass. + +- [ ] **Step 5: Commit** + +```text +fix: use immutable generation file primitives +``` + +### Task 2: Migrate lifecycle data and pointer commits to immutable generations + +**Files:** +- Modify: `src/persistence/modules/InstallLifecycle.psm1` +- Modify: `src/persistence/modules/RuntimeManifest.psm1` +- Modify: `src/persistence/modules/PersistenceIO.psm1` +- Modify: `src/persistence/modules/StateStore.psm1` +- Modify: `src/persistence/modules/UiPreferences.psm1` +- Modify: `Install-CodexControlOtherDevices.ps1` +- Modify: `tests/persistence/InstallLifecycle.SelfTest.ps1` +- Modify: `tests/persistence/RuntimeManifest.SelfTest.ps1` +- Modify: `tests/persistence/UninstallBootstrap.SelfTest.ps1` + +**Interfaces:** + +`Invoke-CcodInstall` consumes `-SealedPackageSha256` and a Task 1 opaque +transaction. These private operations are added or adapted: + +```powershell +New-CcodInstallTransactionRecord -TransactionId -OldRuntimeId -OldGeneration -NewRuntimeId -NewGeneration -SealedPackageSha256 -OwnedObjectNames +Read-CcodInstallTransactionRecord -InstallRoot +Set-CcodInstallTransactionPhase -InstallRoot -TransactionId -ExpectedPhase -NewPhase -FileTransaction +Open-CcodInstallRetainedGeneration -InstallRoot -RuntimeId -ExpectedManifestSha256 -FileTransaction +Set-CcodActiveRuntime -InstallRoot -TargetGeneration -FileTransaction -Ownership +Write-CcodActivationReceiptFile -InstallRoot -Receipt -FileTransaction +Invoke-CcodInstallCompensation -InstallRoot -TransactionRecord -FileTransaction -Adapters +``` + +The only phases are `Prepared`, `PackageVerified`, `RuntimeStaged`, +`PreviousProtectionStopped`, `RuntimePromoted`, `PointerCommitted`, +`StableShellCommitted`, `ProtectionReady`, `Ready`, and terminal `Failed`. + +- [ ] **Step 1: Write lifecycle RED cases** + +Inject failures through every phase. Assert pre-pointer failures leave the old +pointer, generation, stable bootstrap, and uninstaller bytes unchanged. Assert +post-pointer failures produce a higher compensating generation targeting the +old runtime, or `Failed/CCOD_INSTALL_ROLLBACK_FAILED` while retaining both +generations. Add tests for same-version/same-package idempotence, +same-version/different-package `CCOD_INSTALL_PACKAGE_CONFLICT`, legacy installs +without a transaction record, malformed phase records, and a pointer/manifests +drift immediately before `Ready`. + +- [ ] **Step 2: Run RED** + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1 +``` + +Expected: current path-based staging or pointer/receipt writes fail at the new +phase and compensation expectations. + +- [ ] **Step 3: Implement the immutable lifecycle data plane** + +Create a unique generation and write every runtime file, manifest, state file, +UI preference, transaction record, activation receipt, and sanitized lifecycle +log through Task 1 create-only operations. Commit the active pointer atomically +after runtime validation and before stable-shell commit. Never overwrite an +existing generation or use `Remove-Item -Recurse` in install/recovery paths. +On failure, retire or retain only owned candidates; do not delete unknown +legacy files. + +- [ ] **Step 4: Run GREEN** + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\RuntimeManifest.SelfTest.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\UninstallBootstrap.SelfTest.ps1 +``` + +- [ ] **Step 5: Commit** + +```text +fix: commit lifecycle through immutable generations +``` + +### Task 3: Build the sealed Setup package and bootstrap it without `{app}` writes + +**Files:** +- Create: `build/InstallerPackage.psm1` +- Modify: `build/build.ps1` +- Modify: `build/CodexControlOtherDevices.iss` +- Modify: `build/SetupArtifact.psm1` +- Modify: `tools/New-InstallerDestinationInventory.ps1` +- Modify: `Activate-CcodRemoteFix.ps1` +- Modify: `Install-CodexControlOtherDevices.ps1` +- Modify: `tests/persistence/ReleaseWorkflow.SelfTest.ps1` +- Modify: `tests/persistence/InstallLifecycle.SelfTest.ps1` + +**Interfaces:** + +```powershell +New-CcodInstallerPackage -PayloadRoot -PayloadManifestPath -Version 2.5.22 -GitCommit <40hex> -OutputPath -ManifestOutputPath +Test-CcodInstallerPackage -PackagePath -ManifestPath -ExpectedPackageSha256 -ExpectedManifestSha256 -ExpectedVersion 2.5.22 -ExpectedGitCommit <40hex> +``` + +The Setup compiler binds `InstallerPackageSha256`, +`InstallerPackageManifestSha256`, `ActivationBootstrapSha256`, +`SetupGitCommit`, and `ProjectVersion`. Package, manifest, and bootstrap are +`dontcopy` temporary inputs. Inno sets `CreateAppDir=no` and +`Uninstallable=no`; its source has no pre-Ready `{app}` `[Files]`, `[Icons]`, +`[Registry]`, or `[UninstallRun]` product write. The bootstrap scans the sealed +package, invokes Task 2, and Setup accepts only strict `Ready`. + +- [ ] **Step 1: Write package/bootstrap RED tests** + +Reject missing/extra/duplicate/unsafe package entries and every version, +commit, manifest, or package-hash mismatch before creating product state. Add a +real compiled Inno fixture that fails if any pre-Ready `{app}` product write, +recursive payload extraction, or `-File "{app}\Activate-CcodRemoteFix.ps1"` +success path remains. Add a temporary-bootstrap replacement barrier proving +the locked source bytes are the bytes executed. + +- [ ] **Step 2: Run RED** + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ReleaseWorkflow.SelfTest.ps1 +``` + +- [ ] **Step 3: Implement package binding and Setup bootstrap** + +Create/test the package archive from the existing manifest-listed payload. +Extract and hash-bind only the temporary package, manifest, and bootstrap; +retain their read-only handles through activation and terminal validation. Do +not let Setup write product payloads into `{app}`. Ensure the bootstrap uses +Task 2 and does not discover or execute a stale legacy app script. + +- [ ] **Step 4: Run GREEN** + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ReleaseWorkflow.SelfTest.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1 +``` + +- [ ] **Step 5: Commit** + +```text +fix: bootstrap sealed installer package +``` + +### Task 4: Register the product and shortcuts only after Ready + +**Files:** +- Create: `src/persistence/modules/ProductRegistration.psm1` +- Modify: `src/persistence/modules/InstallLifecycle.psm1` +- Modify: `src/persistence/modules/InstallFileTransaction.psm1` +- Modify: `src/persistence/UninstallBootstrap.ps1` +- Modify: `Uninstall-CodexControlOtherDevices.ps1` +- Create: `tests/persistence/ProductRegistration.SelfTest.ps1` +- Modify: `tests/persistence/InstallLifecycle.SelfTest.ps1` +- Modify: `tests/persistence/UninstallBootstrap.SelfTest.ps1` + +**Interfaces:** + +```powershell +New-CcodProductRegistration -InstallRoot -RuntimeId -Version 2.5.22 -PackageSha256 -FileTransaction +Test-CcodProductRegistration -Registration -ExpectedRuntimeId -ExpectedVersion 2.5.22 -ExpectedPackageSha256 +Commit-CcodProductRegistration -Registration -FileTransaction -Adapters +Remove-CcodLegacyProductRegistration -ExpectedAppId -Adapters +``` + +The module writes/read-backs exactly the current-user uninstall registration, +Start-menu `CodexRemote-fix.lnk`, and desktop `CodexRemote-fix.lnk`. Shortcut +candidate bytes are generated in a sealed runtime and copied through fixed +current-user special-folder handles; arbitrary shortcut paths are rejected. + +- [ ] **Step 1: Write registration RED tests** + +Prove registration/shortcut writes before `Ready`, path/target mismatches, +legacy-key mismatch, special-folder replacement, and write/read-back failures +leave legacy registration and shortcut names untouched. Prove a valid Ready +record reads back all three new records before removing only the exact legacy +entries. + +- [ ] **Step 2: Run RED** + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ProductRegistration.SelfTest.ps1 +``` + +- [ ] **Step 3: Implement post-Ready registration** + +Call the module only after Task 2's final `Ready` proof. Preserve legacy entries +on any failure. Make direct uninstaller and Windows Settings launch the sealed +stable generation and never remove unknown files/registry values. + +- [ ] **Step 4: Run GREEN** + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ProductRegistration.SelfTest.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\UninstallBootstrap.SelfTest.ps1 +``` + +- [ ] **Step 5: Commit** + +```text +feat: register product only after readiness +``` + +### Task 5: Enforce dual Defender receipts and the exact 11-asset contract + +**Files:** +- Create: `tools/ReleaseAssetContract.psm1` +- Modify: `tools/Test-ReleaseDefender.ps1` +- Modify: `Install-CodexRemote-fix.ps1` +- Modify: `Activate-CcodRemoteFix.ps1` +- Modify: `build/build.ps1` +- Modify: `tests/persistence/ReleaseWorkflow.SelfTest.ps1` +- Modify: `README.md` +- Modify: `README.zh-CN.md` +- Modify: `CHANGELOG.md` + +**Interfaces:** + +```powershell +Get-CcodExpectedReleaseAssetNames -Version 2.5.22 +Test-CcodExactReleaseAssetSet -AssetDirectory -Version 2.5.22 +Invoke-CcodReleaseDefenderCheck -CandidatePath -ChecksumPath -ManifestPath -Origin TrustedWorkflowArtifact|InternetDownload -ExpectedVersion 2.5.22 -ExpectedGitCommit <40hex> -EvidencePath +Test-CcodReleasePromotionEvidence -EvidenceDirectory -Version 2.5.22 -ExpectedGitCommit <40hex> +``` + +The explicit public assets are: + +```text +CodexRemote-fix-2.5.22-windows-x64.zip +CodexRemote-fix-2.5.22-windows-x64.zip.sha256.txt +CodexRemote-fix-2.5.22-trayhost-provenance.json +CodexRemote-fix-2.5.22-payload-manifest.json +CodexRemote-fix-2.5.22-release-manifest.json +CodexRemote-fix-2.5.22-setup.exe +CodexRemote-fix-2.5.22-setup.exe.sha256.txt +CodexRemote-fix-2.5.22-setup-provenance.json +CodexRemote-fix-2.5.22-setup-payload-manifest.json +CodexRemote-fix-2.5.22-setup-destination-inventory.iss +CodexRemote-fix-2.5.22-setup-release-manifest.json +``` + +Receipts are evidence only, not public assets. A receipt binds asset type/hash, +matching manifest hash, version, commit, origin, Defender service/AV/real-time +state, signature/platform versions and timestamps, and zero new detections. + +- [ ] **Step 1: Write Defender/asset RED tests** + +Add failures for missing/extra/case-varied/reparse assets, every receipt binding +mismatch, duplicate/swapped/missing Setup/ZIP receipt, disabled Defender or +real-time protection, missing/stale signatures, scan errors, detections, +existing/reparse evidence files, and portable/Setup scan failure before any +activation state is created. Add documentation REDs rejecting the old Chinese +“已验证……均可用” wording and unqualified stable claims. + +- [ ] **Step 2: Run RED** + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ReleaseWorkflow.SelfTest.ps1 +``` + +- [ ] **Step 3: Implement the contract** + +Centralize asset names and manifest checks. Keep `InternetDownload` strict +about actual ZoneId 3; allow `TrustedWorkflowArtifact` only with a matching +workflow artifact identity. Add the Setup scan before `Prepared`, preserve the +portable scan, and make README/CHANGELOG describe only enforced behavior. + +- [ ] **Step 4: Run GREEN** + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ReleaseWorkflow.SelfTest.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\PortableRelease.SelfTest.ps1 +``` + +These are deterministic contracts; live Defender evidence is produced only by +Task 7 on the official downloaded draft. + +- [ ] **Step 5: Commit** + +```text +feat: require dual Defender release evidence +``` + +### Task 6: Add clean-runner and draft-only release promotion + +**Files:** +- Create: `tools/Test-CleanReleaseRunner.ps1` +- Create: `tools/Invoke-GitHubDraftRelease.ps1` +- Modify: `.github/workflows/ci.yml` +- Modify: `.github/workflows/release.yml` +- Modify: `tests/persistence/ReleaseWorkflow.SelfTest.ps1` +- Modify: `tests/persistence/Bootstrap.SelfTest.ps1` + +**Interfaces:** + +```powershell +Test-CcodCleanReleaseRunner -RepositoryRoot -ExpectedVersion 2.5.22 +Invoke-CcodGitHubDraftRelease -Mode Stage|Verify|Promote -Tag v2.5.22 -AssetDirectory -EvidenceDirectory +``` + +`Stage` runs only after clean preflight, validation, traces, and build. It +creates a private draft, uploads exactly the 11 names above, and reads every +asset back. `Promote` is local and cannot rebuild; it requires Task 5 receipts +and Task 8/9 acceptance evidence. Pin Actions to the reviewed commits: + +```text +actions/checkout@11d5960a326750d5838078e36cf38b85af677262 +actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 +actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 +actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 +``` + +- [ ] **Step 1: Write workflow RED tests** + +Reject an installed state, occupied account mutex, dirty checkout, missing +preflight, unpinned action, extension glob, extra/missing asset, public-first +release, upload failure that calls public promotion, rebuilt publish candidate, +missing dual receipt, missing acceptance evidence, conditional bypass, +`continue-on-error`, or same-tag concurrent stage. + +- [ ] **Step 2: Run RED** + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ReleaseWorkflow.SelfTest.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\Bootstrap.SelfTest.ps1 +``` + +- [ ] **Step 3: Implement clean/draft workflow** + +Make clean preflight the first release job, transfer one immutable candidate +between jobs, and never rebuild in publish. Stage leaves a draft private on +any failure. Verify performs exact manifest/hash read-back. Promote requires +the evidence directory and changes only the already verified draft. + +- [ ] **Step 4: Run GREEN** + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ReleaseWorkflow.SelfTest.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\Bootstrap.SelfTest.ps1 +``` + +- [ ] **Step 5: Commit** + +```text +fix: stage releases behind draft promotion gates +``` + +### Task 7: Build the official-draft acceptance state machine + +**Files:** +- Create: `tests/installed/OfficialDraftAcceptance.psm1` +- Create: `tests/installed/Invoke-OfficialDraftAcceptance.ps1` +- Create: `tests/persistence/OfficialDraftAcceptanceHarness.SelfTest.ps1` +- Modify: `tests/installed/Invoke-InstalledLifecycleIntegration.ps1` +- Modify: `tests/persistence/InstalledLifecycleHarness.SelfTest.ps1` +- Modify: `tests/Validate.ps1` + +**Interfaces:** + +```powershell +Invoke-CcodOfficialDraftAcceptance ` + -Phase Preflight|LegacyUpgrade|Uninstall|FreshInstall|PreReboot|PostReboot|ReadyForManualEvidence ` + -AssetDirectory ` + -PreviousAssetDirectory ` + -EvidenceRoot ` + -AllowMachineMutation -AllowCodexRestart -AllowWindowsReboot +``` + +`Preflight` downloads must retain actual ZoneId 3 and invokes the real Defender +checker once for Setup and once for portable before any machine mutation. The +legacy upgrade must use a checksum/manifest-bound public v2.5.21 Setup. The +receipt is append-only and contains only candidate/manifest hashes, tag/ID, +phase, and sanitized facts. + +- [ ] **Step 1: Write acceptance RED tests** + +Before any adapter mutation, reject missing/extra assets, bad manifest/commit/ +hash, missing dual scan receipts, missing authorization switches, wrong phase +order, candidate changes, and receipt tampering. Add fixtures requiring the +legacy upgrade, complete uninstall with unchanged DPAPI key hash, fresh +install, pre/post reboot identity, one Supervisor/TrayHost, Idle transition, +and strict active generation/Ready facts. + +- [ ] **Step 2: Run RED** + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\OfficialDraftAcceptanceHarness.SelfTest.ps1 +``` + +- [ ] **Step 3: Implement fixture-safe phase orchestration** + +Use injectable adapters for all tests. The real runner performs the exact +sequence `Preflight → LegacyUpgrade → Uninstall → FreshInstall → PreReboot → +PostReboot → ReadyForManualEvidence`; no automated fixture starts a real +installer or reboots Windows. + +- [ ] **Step 4: Run GREEN** + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\OfficialDraftAcceptanceHarness.SelfTest.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstalledLifecycleHarness.SelfTest.ps1 +``` + +- [ ] **Step 5: Commit** + +```text +test: record official draft lifecycle acceptance +``` + +### Task 8: Require manual tray/remote evidence and prepare promotion + +**Files:** +- Modify: `tests/installed/OfficialDraftAcceptance.psm1` +- Modify: `tests/installed/Invoke-OfficialDraftAcceptance.ps1` +- Modify: `tests/persistence/OfficialDraftAcceptanceHarness.SelfTest.ps1` +- Modify: `tools/Invoke-GitHubDraftRelease.ps1` +- Modify: `README.md` +- Modify: `README.zh-CN.md` +- Modify: `CHANGELOG.md` + +**Interfaces:** + +```powershell +Invoke-CcodOfficialDraftAcceptance -Phase TrayEvidence|RemoteEvidence|Complete -TrayOperation About|Language|OpenLogs|Repair -RemoteOperation SecondDeviceControl -EvidenceRoot -ScreenshotPath -RedactedLogPath -ReviewState Reviewed +``` + +`TrayEvidence` requires one reviewed record for each named operation, with +expected terminal state and distinct screenshot/log hashes. `RemoteEvidence` +requires the second device to discover, connect, and control this device. +`Complete` requires all five manual records plus every Task 7 phase; it is not a +free-text confirmation. `Promote` requires `Complete` and the two live Defender +receipts before making the draft public. + +- [ ] **Step 1: Write manual-evidence RED tests** + +Reject missing, duplicate, unreviewed, reused, non-file, reparse, or +cross-candidate evidence. Reject completion without all four tray operations, +second-device control, or the Task 7 receipt chain. Assert documentation has +no unqualified “stable/verified” claim before evidence. + +- [ ] **Step 2: Run RED** + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\OfficialDraftAcceptanceHarness.SelfTest.ps1 +``` + +- [ ] **Step 3: Implement the promotion evidence gate and concise docs** + +Hash and review only bounded screenshot/redacted-log files. Keep both READMEs +candidate-oriented and keep technical details in CHANGELOG/release notes. The +promotion command must fail closed unless the exact draft, dual real scan +receipts, full lifecycle receipt, and all five manual records match. + +- [ ] **Step 4: Run GREEN** + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\OfficialDraftAcceptanceHarness.SelfTest.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ReleaseWorkflow.SelfTest.ps1 +``` + +- [ ] **Step 5: Commit** + +```text +test: gate release on tray and remote evidence +``` + +## Final verification and external acceptance (after all task reviews) + +1. Create a fresh detached worktree at the final reviewed commit; ensure no + pre-existing `build/dist` and run the clean-runner preflight. +2. Run the full clean-runner suite, including `npm test`, installed lifecycle, + release contract, TrayHost protocol/production/trace, the build, and both + manifest validators. Any nonzero stops publication. +3. Run `Stage` to create the private official draft. Download Setup and ZIP in + the browser so ZoneId 3 is real; run the two live Defender scans and save + receipts before machine mutation. +4. Use the manifest-bound public v2.5.21 Setup for `LegacyUpgrade`, then + complete `Uninstall`, `FreshInstall`, `PreReboot`, and `PostReboot` with the + explicit user-authorized machine mutation/reboot switches. +5. Manually perform and record About, language switching, Open logs, Repair, + and second-device connection/control. Only a reviewed `Complete` receipt + permits local `Promote` of the already verified draft. +6. After promotion, re-download the public assets, re-run exact manifest/hash + validation, and verify the public tag points to the final reviewed commit. diff --git a/docs/superpowers/plans/2026-08-29-v2522-transactional-install-release.md b/docs/superpowers/plans/2026-08-29-v2522-transactional-install-release.md new file mode 100644 index 0000000..55941c7 --- /dev/null +++ b/docs/superpowers/plans/2026-08-29-v2522-transactional-install-release.md @@ -0,0 +1,965 @@ +# v2.5.22 Transactional Install and Release Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship the unreleased v2.5.22 as a sealed, recoverable current-user +installation transaction whose final Setup and portable artifacts cannot become +public until they pass exact asset, Defender, and current-machine acceptance +gates. + +**Architecture:** A private pinned file-transaction module replaces pathname +mutation for all lifecycle staging, promotion, and owned cleanup. A sealed +Setup package and locked temporary bootstrap feed that transaction, which +persists a monotonic phase record and compensates an unsuccessful post-pointer +upgrade. Release evidence is separate from product state: exact asset +contracts, dual Defender receipts, a clean runner, a private draft, and a +multi-reboot local acceptance record form a promotion chain. + +**Tech Stack:** Windows PowerShell 5.1, .NET Framework/C# `Add-Type` interop, +Inno Setup 6, Node.js 22, GitHub Actions, Microsoft Defender, existing +PowerShell persistence self-tests. + +**Spec:** `docs/superpowers/specs/2026-08-28-v2522-transactional-install-release-design.md` + +## Global Constraints + +- Keep the release version exactly `2.5.22`; it is not tagged or public. +- Never modify `ChatGPT.exe`, `app.asar`, `WindowsApps`, account state, or the + DPAPI device-key store. +- Preserve the authenticated TrayHost wire schema and strict Codex process + identity rules. Do not add a listener, a production test mode, or an external + remote-control path. +- File mutations are authorized only by pinned parent identities plus + single-segment leaves. Path checks, hashes, ADS checks, and link counts are + validation layers, not authority for a later pathname write or delete. +- Every implementation change starts with a deterministic behavioral RED, + followed by the smallest GREEN change, focused test evidence, a commit, and + a scoped review. +- A failed pre-pointer transaction preserves the previous pointer and stable + shell. A failed post-pointer transaction records a higher compensating + generation or returns `CCOD_INSTALL_ROLLBACK_FAILED` without `Ready`. +- Use only clean, fresh build candidates. Existing `build/dist` assets are + historical evidence and must never be uploaded, installed, or used for + acceptance. +- Real Defender scans, GitHub draft/release operations, installation, Codex + restart, Windows reboot, and second-device remote confirmation are final + acceptance operations only; no task below performs them unless its command + explicitly targets a disposable test fixture. +- All production and release receipts are bounded and sanitized: stable codes, + hashes, version, commit, timestamps, and phases only; never credentials, + DPAPI material, user paths, tokens, raw logs, or exception text. + +--- + +### Task 1: Create private handle-bound installation file transactions + +**Files:** +- Create: `src/persistence/modules/InstallFileTransaction.psm1` +- Create: `tests/persistence/InstallFileTransaction.SelfTest.ps1` +- Modify: `tests/PersistenceSelfTest.ps1` +- Modify: `tests/Validate.ps1` + +**Interfaces:** + +`InstallFileTransaction.psm1` owns all native handles and exposes only these +private module functions: + +```powershell +Open-CcodInstallFileTransaction -InstallRoot +Open-CcodInstallPinnedDirectory -Transaction -ParentDirectory -Leaf [-CreateIfMissing] +New-CcodInstallPinnedTemporaryLeaf -Transaction -ParentDirectory -Leaf +Copy-CcodInstallSealedFile -Transaction -SourcePath -DestinationLeaf -ExpectedLength -ExpectedSha256 +Commit-CcodInstallPinnedPromotion -Transaction -ParentDirectory -TemporaryLeaf -DestinationLeaf +Write-CcodInstallPinnedJson -Transaction -ParentDirectory -Leaf -Value [-Compress] +Append-CcodInstallPinnedLog -Transaction -ParentDirectory -Leaf -Record +Remove-CcodInstallOwnedTree -Transaction -ParentDirectory -Leaf +Close-CcodInstallFileTransaction -Transaction -Disposition Ready|Failed +``` + +The context includes the pinned install root, owned-object names, and all leaf +or directory handles. A caller may not pass an arbitrary destination pathname +to any mutation operation. + +- [ ] **Step 1: Write deterministic failing transaction tests** + +Create fixture helpers which make a temporary root and an outside sentinel. +Add one test for every native boundary: + +```powershell +$tx = Open-CcodInstallFileTransaction -InstallRoot $install +$rootPin = $tx.RootDirectory +$child = Open-CcodInstallPinnedDirectory -Transaction $tx -ParentDirectory $rootPin -Leaf 'runtime' -CreateIfMissing +$result = Invoke-CcodAttemptDirectoryReplacement -Path (Join-Path $install 'runtime') -OutsideRoot $outside +Assert-CcodEqual 'blocked' $result.Outcome 'pinned missing directory cannot be replaced after creation' +``` + +Add RED cases for a replacement between temporary-leaf creation and promotion, +source-byte replacement after `Copy-CcodInstallSealedFile` opens the source, +reparse/ADS/multi-link leaves, a promotion whose parent was renamed, and a +owned-tree retirement containing an unknown, reparse, or multi-link leaf. Each +case must assert the outside sentinel remains unchanged and that failed cleanup +retains the unproven owned candidate. A normal nonempty owned tree must leave +its live name only through a no-replace atomic quarantine rename with every +original file preserved; an empty owned tree may be deleted on close. + +Add REDs that a pinned JSON write and a pinned log append reject a replaced +state/log parent and cannot alter an outside sentinel. These operations become +the sole mutation route for the lifecycle's active pointer, runtime manifest, +phase record, activation receipt, UI state, and install log in Tasks 2–3. + +- [ ] **Step 2: Run the new suite and confirm RED** + +Run: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1 +``` + +Expected: the module/functions are absent and at least the missing-directory +pin case fails before any production mutation code exists. + +- [ ] **Step 3: Implement the smallest private transaction module** + +Use native `SafeFileHandle` operations with `FILE_OPEN_REPARSE_POINT` and +relative child opens/creates. Directory handles deny delete sharing for their +lifetime. Temporary leaves use create-new semantics; source and destination +bytes are read/written/hashed through the opened handles, flush completes +before promotion, and promotion is limited to the pinned parent. Retire a +complete nonempty transaction-owned candidate only by no-replace atomic rename +to a random quarantine leaf under the pinned parent. Mark only an empty owned +tree for delete-on-close. Any unexpected identity stops retirement and leaves +the candidate in place. + +Do not import `LifecycleTransaction.psm1` or change any public wire schema; +this module is file-object ownership only. + +- [ ] **Step 4: Run focused GREEN and aggregate registration checks** + +Run: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\PersistenceSelfTest.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\Validate.ps1 -SkipInstalledPackageCheck +``` + +Record any expected local mutex contamination separately; do not reinterpret it +as a pass. The new isolated transaction suite itself must exit zero. + +- [ ] **Step 5: Commit** + +```text +feat: add pinned install file transaction +``` + +### Task 2: Move lifecycle runtime data and stable-shell mutations onto the transaction + +**Files:** +- Modify: `src/persistence/modules/InstallLifecycle.psm1` +- Modify: `Install-CodexControlOtherDevices.ps1` +- Modify: `src/persistence/modules/StateStore.psm1` +- Modify: `src/persistence/modules/UiPreferences.psm1` +- Modify: `tests/persistence/InstallLifecycle.SelfTest.ps1` +- Modify: `tests/persistence/UninstallBootstrap.SelfTest.ps1` + +**Interfaces:** + +Task 2 imports `InstallFileTransaction.psm1` and changes internal lifecycle +composition to: + +```powershell +Copy-CcodLifecycleStaging -SourceRoot -InstallRoot -FileTransaction -Adapters -Files +Commit-CcodLifecycleRuntime -FileTransaction -StagingDirectory -RuntimeId +Stage-CcodLifecycleStableShell -FileTransaction -StagedRuntime +Commit-CcodLifecycleStableShell -FileTransaction -StableShellCandidates +Remove-CcodLifecycleOwnedRuntime -FileTransaction -RuntimeId +``` + +`Stage-CcodLifecycleStableShell` may only create verified candidates. It must +not overwrite stable `bootstrap.ps1` or +`Uninstall-CodexControlOtherDevices.ps1` before Task 3 has committed the active +pointer. `Commit-CcodLifecycleStableShell` runs only after pointer commit and +is therefore part of the monotonic compensation boundary. + +- [ ] **Step 1: Write lifecycle RED cases before editing production paths** + +Add deterministic adapters/barriers to the lifecycle suite. Assert all of: + +```powershell +Assert-CcodThrows { + Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters $raceAdapters +} 'CCOD_INSTALL_FILE_TRANSACTION_INVALID' +Assert-CcodEqual $oldBootstrapHash (Get-CcodLifecycleFileSha256 $bootstrap) 'failed staging never changes stable bootstrap bytes' +Assert-CcodEqual $oldPointerJson (Get-Content $active -Raw) 'failed promotion never changes active pointer' +``` + +Cover a missing fresh `.staging`/`runtime` directory, a replacement after a +parent pin, a promotion failure, a stable-shell candidate hash failure, owned +old-runtime retirement after an unexpected leaf appears, and a legacy unknown +file that must be retained. Add REDs that runtime `manifest.json`, initial +state, and UI-preference writes use a pinned state/runtime parent. Keep the +current hard-link, ADS, reparse, and source-manifest coverage; adapt it to +prove mutation now goes through the new transaction module. + +- [ ] **Step 2: Run the lifecycle suite and confirm RED** + +Run: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1 +``` + +Expected: at least one new barrier case proves that the current pathname +staging/promotion or early stable-shell copy is not transaction-bound. + +- [ ] **Step 3: Replace runtime data and pre-pointer mutations** + +Replace `Directory.CreateDirectory`, direct copy/move/replace, and +`Remove-Item -Recurse` mutation paths inside lifecycle staging, runtime +promotion, runtime manifest, initial state/UI preference, stable-shell +candidate handling, old-runtime retirement, and failed-candidate retirement with +Task 1 operations. Retain the existing process identity and scheduled-task +rules. Build/validate runtime manifest bytes through its pinned leaf before +promotion. Do not commit stable shell bytes yet; Task 3 commits them only after +the active-pointer transaction succeeds. + +Do not change the external parameters of `Invoke-CcodInstall` in this task; +Task 3 adds the sealed package identity, active-pointer/receipt writes, and +transaction record. + +- [ ] **Step 4: Run focused GREEN** + +Run: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\UninstallBootstrap.SelfTest.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ReleaseWorkflow.SelfTest.ps1 +``` + +The source-level and disposable-fixture suites must exit zero. Do not stop the +real Supervisor merely to force the aggregate suite past its local mutex. + +- [ ] **Step 5: Commit** + +```text +fix: transact lifecycle file mutations +``` + +### Task 3: Persist installation phases and compensate post-pointer failures + +**Files:** +- Modify: `src/persistence/modules/InstallLifecycle.psm1` +- Modify: `src/persistence/modules/RuntimeManifest.psm1` +- Modify: `src/persistence/modules/PersistenceIO.psm1` +- Modify: `src/persistence/modules/StateStore.psm1` +- Modify: `src/persistence/modules/UiPreferences.psm1` +- Modify: `Install-CodexControlOtherDevices.ps1` +- Modify: `tests/persistence/InstallLifecycle.SelfTest.ps1` +- Modify: `tests/persistence/InstalledLifecycleHarness.SelfTest.ps1` + +**Interfaces:** + +Add private record helpers in `InstallLifecycle.psm1` and add the required +sealed package identity to the lifecycle call: + +```powershell +New-CcodInstallTransactionRecord -TransactionId -OldRuntimeId -OldGeneration -NewRuntimeId -NewGeneration -SealedPackageSha256 -OwnedObjectNames +Read-CcodInstallTransactionRecord -InstallRoot +Set-CcodInstallTransactionPhase -InstallRoot -TransactionId -ExpectedPhase -NewPhase -FileTransaction +Invoke-CcodInstallCompensation -InstallRoot -TransactionRecord -FileTransaction -Adapters + +Invoke-CcodInstall ... -SealedPackageSha256 +Set-CcodActiveRuntime ... -FileTransaction +Write-CcodActivationReceiptFile ... -FileTransaction +Write-CcodLifecycleLog ... -FileTransaction +``` + +The only accepted persisted phases are `Prepared`, `PackageVerified`, +`RuntimeStaged`, `PreviousProtectionStopped`, `RuntimePromoted`, +`PointerCommitted`, `StableShellCommitted`, `ProtectionReady`, `Ready`, and +terminal `Failed`. `Failed` has the canonical transaction fields plus exactly +one stable `CCOD_*` error code; the reader rejects any other terminal phase or +additional field. + +- [ ] **Step 1: Write phase and compensation RED cases** + +For every phase through `RuntimePromoted`, inject a failure and assert the old +pointer, generation, and stable-shell hashes remain exactly unchanged. For each +phase starting at `PointerCommitted`, inject a failure and assert a higher +generation returns to the old runtime and old stable shell: + +```powershell +Assert-CcodEqual 3 ([uint64]$finalPointer.generation) 'post-pointer compensation never decrements generation' +Assert-CcodEqual $oldRuntime $finalPointer.activeRuntime 'compensation reactivates retained old runtime' +Assert-CcodEqual 'CCOD_INSTALL_ROLLBACK_FAILED' $receipt.errorCode 'unproven compensation remains terminal failed' +``` + +Add REDs for a legacy installation with no record, same version/same sealed +hash idempotence, same version/different sealed hash +`CCOD_INSTALL_PACKAGE_CONFLICT`, malformed/expanded transaction JSON, and a +crash at each nonterminal phase. Assert `Ready` is impossible until active +pointer, manifest version, Supervisor, and authenticated TrayHost all match. +Add a RED that a canonical `Failed` record round-trips and that a missing, +noncanonical, duplicate, or private-data error field is rejected. + +- [ ] **Step 2: Run RED** + +Run: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1 +``` + +Expected: the phase-record helpers and monotonic compensation behavior are +absent or the new post-pointer expectations fail. + +- [ ] **Step 3: Implement the record and recovery state machine** + +Persist a bounded canonical JSON record with expected property order and no +absolute paths. Write the transaction record, active pointer, activation +progress/final receipt, lifecycle log, state updates, and UI preference only +through Task 1's pinned JSON/log operations. Move the record forward only after +the corresponding Task 2 file transaction commit. Commit active pointer before +stable shell. On a pre-pointer failure, retain the prior pointer and restart +the proved previous runtime when necessary. On a post-pointer failure, write +only a higher compensating generation, restore the matching stable shell +through the transaction module, and prove the previous Supervisor/TrayHost +before reporting failure. If any compensation proof fails, write `Failed` with +`CCOD_INSTALL_ROLLBACK_FAILED`, retain candidates, and do not cleanup by +pathname. + +- [ ] **Step 4: Run focused GREEN** + +Run: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstalledLifecycleHarness.SelfTest.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\RuntimeManifest.SelfTest.ps1 +``` + +All focused suites must exit zero. Record the known active-machine mutex only +if it occurs in an unrelated aggregate run. + +- [ ] **Step 5: Commit** + +```text +feat: recover installation transactions monotonically +``` + +### Task 4: Build a sealed Setup package and invoke a locked temporary bootstrap + +**Files:** +- Create: `build/InstallerPackage.psm1` +- Modify: `build/build.ps1` +- Modify: `build/CodexControlOtherDevices.iss` +- Modify: `build/SetupArtifact.psm1` +- Modify: `tools/New-InstallerDestinationInventory.ps1` +- Modify: `Activate-CcodRemoteFix.ps1` +- Modify: `Install-CodexControlOtherDevices.ps1` +- Modify: `tests/persistence/ReleaseWorkflow.SelfTest.ps1` +- Modify: `tests/persistence/InstallLifecycle.SelfTest.ps1` + +**Interfaces:** + +Add package creation/verification: + +```powershell +New-CcodInstallerPackage -PayloadRoot -PayloadManifestPath -Version 2.5.22 -GitCommit <40hex> -OutputPath -ManifestOutputPath +Test-CcodInstallerPackage -PackagePath -ManifestPath -ExpectedPackageSha256 -ExpectedManifestSha256 -ExpectedVersion 2.5.22 -ExpectedGitCommit <40hex> +``` + +The compiled Setup receives `InstallerPackageSha256`, +`InstallerPackageManifestSha256`, `ActivationBootstrapSha256`, +`SetupGitCommit`, and `ProjectVersion`. Its activation path calls the locked +temporary bootstrap with: + +```powershell +-PackagePath -PackageManifestPath +-ExpectedPackageSha256 -ExpectedPackageManifestSha256 +-ExpectedVersion 2.5.22 -ExpectedGitCommit <40hex> +-InstallRoot -ActivationId +``` + +`Activate-CcodRemoteFix.ps1` verifies these inputs through opened handles and +passes the package hash as `-SealedPackageSha256` to Task 3's lifecycle. + +- [ ] **Step 1: Write package and setup-boundary RED tests** + +Extend the release workflow suite with a disposable package fixture. It must +reject omitted, extra, duplicate, unsafe-path, version-mismatched, +commit-mismatched, or hash-mismatched package contents before the install root +is created. Add a real compiled Inno fixture which asserts every pre-Ready +product output is absent, not merely recursive payload output: + +```powershell +Assert-CcodFalse ($generatedSource -match 'DestDir:\s*"\{app\}\\payload') 'Setup does not recursively extract product payload into app' +Assert-CcodFalse ($generatedSource -match '-File "\{app\}\\Activate-CcodRemoteFix\.ps1"') 'Setup never executes activation from writable app' +Assert-CcodTrue ($generatedSource -match '(?m)^CreateAppDir=no$') 'Setup does not create an app directory before the transaction is Ready' +Assert-CcodTrue ($generatedSource -match '(?m)^Uninstallable=no$') 'Setup does not create a separate Inno uninstaller before the transaction is Ready' +Assert-CcodFalse (Test-CcodInnoPreReadyProductWrite -Source $generatedSource) 'Setup has no Files Icons Registry or UninstallRun product write before Ready' +``` + +Use a launch barrier after temporary bootstrap locking but before PowerShell +opens it. A replacement attempt must not alter the executed bytes. Add REDs +for a replaced package after validation, a stale legacy app script, missing +fresh `app/state/runtime` directories, a non-Ready receipt, and same-version +different-package conflict. + +- [ ] **Step 2: Run RED** + +Run: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ReleaseWorkflow.SelfTest.ps1 +``` + +Expected: the package module and compile-time bindings are absent, and the +current Inno source still exposes recursive payload copy or app-path execution. + +- [ ] **Step 3: Implement sealed package build and bootstrap execution** + +Build a single verified package and canonical package manifest from the +existing manifest-listed payload. Embed package, package manifest, and +bootstrap as `dontcopy` Setup inputs. Inno extracts them only to its private +temporary area, locks and rehashes them against compile-time constants, and +keeps the bootstrap lock through both activation and receipt validation. +Set `CreateAppDir=no` and `Uninstallable=no`; remove every `[Files]`, `[Icons]`, +`[Registry]`, and `[UninstallRun]` product write targeting `{app}` before +`Ready`, including docs/tools/diagnostic copies. Remove recursive `{app}` +payload extraction and all success-path execution of +`{app}\Activate-CcodRemoteFix.ps1`. + +The bootstrap must use Tasks 1–3 to create/pin target directories and perform +the entire package-to-runtime transaction. Preserve the existing Inno `AppId` +and current-user scope as migration inputs, but do not alter a legacy uninstall +entry in this task; Task 5 registers its replacement only after `Ready`. +Extend Setup provenance/PE validation to bind all three new hashes plus version +and commit. + +- [ ] **Step 4: Run focused GREEN and a clean disposable build** + +Run: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ReleaseWorkflow.SelfTest.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\trayhost\Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly +``` + +Then create a fresh detached candidate worktree at the exact current commit +whose `build/dist` does not yet exist, run the normal builder once there, and +verify its two release manifests against that worktree's exact `HEAD`. Do not +use or overwrite any existing `build/dist` directory. + +- [ ] **Step 5: Commit** + +```text +fix: seal setup package activation +``` + +### Task 5: Commit verified current-user product registration and shortcuts after Ready + +**Files:** +- Create: `src/persistence/modules/ProductRegistration.psm1` +- Modify: `src/persistence/modules/InstallFileTransaction.psm1` +- Modify: `src/persistence/modules/InstallLifecycle.psm1` +- Modify: `src/persistence/UninstallBootstrap.ps1` +- Modify: `Uninstall-CodexControlOtherDevices.ps1` +- Modify: `tests/persistence/InstallLifecycle.SelfTest.ps1` +- Modify: `tests/persistence/UninstallBootstrap.SelfTest.ps1` +- Create: `tests/persistence/ProductRegistration.SelfTest.ps1` + +**Interfaces:** + +The registration module owns only the exact current-user product key and the +two exact current-user shortcut names and exposes: + +```powershell +New-CcodProductRegistration -InstallRoot -RuntimeId -Version 2.5.22 -PackageSha256 +Test-CcodProductRegistration -Registration -ExpectedRuntimeId -ExpectedVersion 2.5.22 -ExpectedPackageSha256 +Commit-CcodProductRegistration -Registration -FileTransaction -Adapters +Remove-CcodLegacyProductRegistration -ExpectedAppId -Adapters +``` + +`InstallFileTransaction.psm1` additionally exposes internal fixed-root helpers +for the exact current-user Desktop and Start-menu Programs folders. They open +and pin only the resolved current-user special-folder identity, create only the +two approved `CodexRemote-fix.lnk` leaves, and copy verified shortcut-candidate +bytes through pinned handles; no product shortcut writer accepts an arbitrary +desktop/start-menu path. + +The registration is written only after Task 3's `Ready` proof. It points to the +sealed stable uninstaller and creates exactly the Start-menu `CodexRemote-fix` +and desktop `CodexRemote-fix` shortcuts, both targeting the verified stable +bootstrap. All three records are read back and validated before the exact +legacy Inno uninstall registry entry and exact legacy shortcut names are +removed. It never recursively removes the legacy app tree or unrelated registry +values. + +- [ ] **Step 1: Write legacy-registration RED tests** + +Use registry and shortcut adapters in disposable fixtures. Add failures for +attempting a registration before `Ready`, an uninstaller or shortcut target +outside the sealed stable runtime, package/version/runtime mismatch, unreadable +registration, a legacy-key/name mismatch, a shortcut write failure, and a +registration write failure. Assert this boundary: + +```powershell +Assert-CcodEqual $legacyUninstallString $world.LegacyUninstallString 'failure before read-back retains the legacy entry' +Assert-CcodFalse $world.LegacyEntryRemoved 'failed registration never removes legacy control-panel state' +Assert-CcodEqual 0 $world.LegacyShortcutRemovals 'failed registration never removes legacy shortcuts' +``` + +Add a RED that replaces the Desktop or Start-menu parent after it is opened; +shortcut promotion must block or fail with both outside sentinels unchanged. + +Add a GREEN-target test that a valid `Ready` registration is read back first, +then only the exact legacy `AppId` entry and exact legacy shortcut names are +removed. Verify direct uninstaller and Windows Settings wrappers resolve the +current sealed registration, and Start-menu/desktop launchers resolve the +current sealed bootstrap. + +- [ ] **Step 2: Run RED** + +Run: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\UninstallBootstrap.SelfTest.ps1 +``` + +Expected: there is no private product-registration module and legacy +uninstaller/shortcut handling is still coupled to the Inno shell. + +- [ ] **Step 3: Implement post-Ready registration migration** + +Create the private module with strict current-user registry and shortcut +adapters, exact key/property/name allowlists, and read-back verification. Call +it after Task 3's final Ready revalidation and before old-runtime cleanup. +Generate shortcut candidates inside the sealed stable runtime, then promote +their bytes only through Task 1 pinned special-folder handles. The uninstaller +must revalidate its sealed runtime and transaction ownership before touching +product state. Failed registration or legacy removal leaves both entries and +both legacy shortcuts, reports a sanitized stable code, and does not turn an +already proved runtime into an unowned installation. + +- [ ] **Step 4: Run focused GREEN** + +Run: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\UninstallBootstrap.SelfTest.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ProductRegistration.SelfTest.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstalledLifecycleHarness.SelfTest.ps1 +``` + +- [ ] **Step 5: Commit** + +```text +feat: register product entry after readiness +``` + +### Task 6: Bind Defender evidence and an exact release asset contract + +**Files:** +- Create: `tools/ReleaseAssetContract.psm1` +- Modify: `tools/Test-ReleaseDefender.ps1` +- Modify: `Install-CodexRemote-fix.ps1` +- Modify: `Activate-CcodRemoteFix.ps1` +- Modify: `Install-CodexControlOtherDevices.ps1` +- Modify: `build/build.ps1` +- Modify: `tests/persistence/ReleaseWorkflow.SelfTest.ps1` +- Modify: `tests/persistence/InstallLifecycle.SelfTest.ps1` +- Modify: `README.md` +- Modify: `README.zh-CN.md` +- Modify: `CHANGELOG.md` + +**Interfaces:** + +Define the exact 11 names in one module: + +```powershell +Get-CcodExpectedReleaseAssetNames -Version 2.5.22 +Test-CcodExactReleaseAssetSet -AssetDirectory -Version 2.5.22 +Invoke-CcodReleaseDefenderCheck -CandidatePath -ChecksumPath -ManifestPath -Origin TrustedWorkflowArtifact|InternetDownload -ExpectedVersion 2.5.22 -ExpectedGitCommit <40hex> -EvidencePath +Test-CcodReleasePromotionEvidence -EvidenceDirectory -Version 2.5.22 -ExpectedGitCommit <40hex> +``` + +For v2.5.22 the function returns exactly this ordered set, with no scan receipt +added to the public asset surface: + +```text +CodexRemote-fix-2.5.22-windows-x64.zip +CodexRemote-fix-2.5.22-windows-x64.zip.sha256.txt +CodexRemote-fix-2.5.22-trayhost-provenance.json +CodexRemote-fix-2.5.22-payload-manifest.json +CodexRemote-fix-2.5.22-release-manifest.json +CodexRemote-fix-2.5.22-setup.exe +CodexRemote-fix-2.5.22-setup.exe.sha256.txt +CodexRemote-fix-2.5.22-setup-provenance.json +CodexRemote-fix-2.5.22-setup-payload-manifest.json +CodexRemote-fix-2.5.22-setup-destination-inventory.iss +CodexRemote-fix-2.5.22-setup-release-manifest.json +``` + +Each receipt binds asset type/SHA-256, manifest SHA-256, version, commit, +origin, Defender platform/signature information, signature timestamp, +scan timestamps, and zero new detections. It never records a source path or +raw Defender result. + +- [ ] **Step 1: Write Defender and asset-contract RED tests** + +Add deterministic adapters for the Defender tool and test that +`TrustedWorkflowArtifact` requires matching manifest/version/commit/hash +bindings, while `InternetDownload` requires an actual ZoneId 3 and has no +switch that synthesizes it. Add failure cases for disabled AV/real-time +protection, absent/stale signature data, scan error, new detection, existing +receipt, reparse evidence directory, and failed receipt write. + +Add a two-asset matrix: Setup and portable ZIP must have distinct matching +receipts; a duplicate, swapped, missing, or mismatched receipt fails. Add +asset-set REDs for missing, extra, duplicate, case-varied, or reparse entries. +Finally, prove a portable scan failure prevents activation and a sealed Setup +package scan failure occurs before transaction-root/product state mutation. +Add a documentation RED that rejects the unqualified Chinese historical +“已验证……均可用” wording and any README or CHANGELOG claim that v2.5.22 is +stable before Task 9 records completed official-draft evidence. + +- [ ] **Step 2: Run RED** + +Run: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ReleaseWorkflow.SelfTest.ps1 +``` + +Expected: the exact asset module and origin-bound dual Defender receipt +contract are not present. + +- [ ] **Step 3: Implement the bounded evidence contract** + +Move exact asset-name construction into `ReleaseAssetContract.psm1`. Make +release manifest/build validation consume that one contract. Extend the +Defender tool with explicit origins and strict platform, service, real-time, +signature, manifest, and receipt validation. Keep the portable scan behavior; +add the sealed Setup package scan to Task 4's bootstrap before `Prepared`. +Correct both README files and CHANGELOG so they describe only the scan paths +actually enforced, list the exact public asset set, and describe v2.5.22 as a +candidate until Task 9's externally recorded acceptance is complete. + +- [ ] **Step 4: Run focused GREEN** + +Run: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ReleaseWorkflow.SelfTest.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\PortableRelease.SelfTest.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1 +``` + +These tests prove deterministic contracts only. A real Defender scan remains a +final external acceptance action and must not be mocked as a live pass. + +- [ ] **Step 5: Commit** + +```text +feat: gate releases on dual Defender evidence +``` + +### Task 7: Stage exact draft assets behind clean-runner and promotion gates + +**Files:** +- Create: `tools/Test-CleanReleaseRunner.ps1` +- Create: `tools/Invoke-GitHubDraftRelease.ps1` +- Modify: `.github/workflows/ci.yml` +- Modify: `.github/workflows/release.yml` +- Modify: `tests/persistence/ReleaseWorkflow.SelfTest.ps1` +- Modify: `tests/persistence/Bootstrap.SelfTest.ps1` + +**Interfaces:** + +```powershell +Test-CcodCleanReleaseRunner -RepositoryRoot -ExpectedVersion 2.5.22 +Invoke-CcodGitHubDraftRelease -Mode Stage|Verify|Promote -Tag v2.5.22 -AssetDirectory -EvidenceDirectory +``` + +`Test-CcodCleanReleaseRunner` returns stable failure +`CCOD_CLEAN_RUNNER_CONTAMINATED` if a CodexRemote-fix runtime root, task, +Supervisor/TrayHost, or AccountTransition/AccountSupervisor mutex is present. +It performs no cleanup. `Invoke-CcodGitHubDraftRelease` uses injected adapters +in tests, consumes the Task 6 exact asset contract, and never discovers assets +by extension glob. + +- [ ] **Step 1: Write workflow and runner RED tests** + +Add fixtures proving that an occupied mutex, existing product state, a missing +clean preflight, `continue-on-error`, a conditional bypass, a rebuilt publish +candidate, or an unpinned action reference rejects the workflow before any +test/build/publish step. Add script tests that Stage cannot create a public +release, upload failure leaves a draft private, and Verify downloads/rechecks +all 11 assets before any Promote path. + +Add promotion REDs for missing dual Defender evidence, mismatched receipt, +missing accepted reboot phase, missing legacy-upgrade evidence, or missing +reviewed manual evidence. Require +same-tag workflow concurrency and an explicit mode transition; tag pushes may +stage only, never publicize. + +- [ ] **Step 2: Run RED** + +Run: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ReleaseWorkflow.SelfTest.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\Bootstrap.SelfTest.ps1 +``` + +Expected: the current workflow either lacks clean preflight, uses a public +release first, accepts extension-based asset discovery, or has no explicit +promotion evidence gate. + +- [ ] **Step 3: Implement the clean/draft workflow contract** + +Add a clean preflight before source validation, TrayHost traces, and build. +The build job creates exactly one candidate; later jobs only download, hash, +and validate it. Stage creates a GitHub draft with the exact 11-item allowlist, +verifies read-back, and preserves the draft on failure. A tag-triggered workflow +can Stage only; it cannot call Promote. `InternetDownload` scan receipts are +produced by Task 8 after the user downloads the official draft in a browser and +retains ZoneId 3. `Promote` runs only through the local, adapter-tested command +after Task 8 and Task 9 evidence has been verified; it changes the already +verified draft to public without rebuilding assets. Pin the current reviewed +action sources exactly as follows and add same-tag concurrency: + +```text +actions/checkout@11d5960a326750d5838078e36cf38b85af677262 +actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 +actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 +actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 +``` + +Do not run a real `gh` command from a test or a task implementation command. + +- [ ] **Step 4: Run focused GREEN** + +Run: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ReleaseWorkflow.SelfTest.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\Bootstrap.SelfTest.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\Validate.ps1 -SkipInstalledPackageCheck +``` + +Confirm each source-level suite exits zero; a local active product mutex is +recorded as environmental contamination, not a skipped clean-runner gate. + +- [ ] **Step 5: Commit** + +```text +fix: stage releases behind clean promotion gates +``` + +### Task 8: Record official-draft upgrade, reinstall, and reboot acceptance + +**Files:** +- Create: `tests/installed/OfficialDraftAcceptance.psm1` +- Create: `tests/installed/Invoke-OfficialDraftAcceptance.ps1` +- Create: `tests/persistence/OfficialDraftAcceptanceHarness.SelfTest.ps1` +- Modify: `tests/installed/Invoke-InstalledLifecycleIntegration.ps1` +- Modify: `tests/persistence/InstalledLifecycleHarness.SelfTest.ps1` +- Modify: `tests/Validate.ps1` + +**Interfaces:** + +```powershell +Invoke-CcodOfficialDraftAcceptance ` + -Phase Preflight|LegacyUpgrade|Uninstall|FreshInstall|PreReboot|PostReboot|ReadyForManualEvidence ` + -AssetDirectory ` + -PreviousAssetDirectory ` + -EvidenceRoot ` + -AllowMachineMutation -AllowCodexRestart -AllowWindowsReboot +``` + +The persisted acceptance receipt contains only candidate/manifest hashes, +draft tag/ID, phase, and sanitized facts. It has no account content, device-key +content, private path, raw token, or raw log text. Task 9 appends manually +reviewed evidence hashes only after the automated phases are complete. + +- [ ] **Step 1: Write phase-machine RED tests** + +Add tests that fail before any adapter mutation if the official draft lacks one +of 11 exact assets, manifests/commit/hashes do not match, a caller lacks the +relevant allow switch, or a phase is out of order/reused with a different +candidate. `Preflight` is the only phase allowed to create dual Defender +receipts; every later phase must reject missing, swapped, or mismatched receipts. + +`Preflight` must invoke `Invoke-CcodReleaseDefenderCheck` once for the official +browser-downloaded Setup and once for the portable ZIP with +`-Origin InternetDownload`, then persist both exact receipts before any machine +mutation. Add a legacy-upgrade RED requiring the manifest-bound currently +public v2.5.21 Setup asset and asserting the sealed migration preserves the +DPAPI key hash. Add REDs requiring: uninstall proves task, Supervisor, TrayHost, +runtime/app root, shortcuts, and expected debug endpoints are gone while the +DPAPI key hash is unchanged; fresh install/repair proves active pointer, +manifest, generation, one authenticated TrayHost, task, terminal receipt, and +post-reboot proves a new boot ID, `Idle` transition, auto-recovered protection, +and the same key hash. + +- [ ] **Step 2: Run RED** + +Run: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\OfficialDraftAcceptanceHarness.SelfTest.ps1 +``` + +Expected: the phase API and tamper-resistant evidence contract are absent. + +- [ ] **Step 3: Implement the fixture-safe acceptance orchestrator** + +Implement all phases using injectable adapters so self-tests never touch the +real machine. Integrate existing lifecycle observations but strengthen their +assertions as listed above. `LegacyUpgrade` may run only after two valid +InternetDownload receipts; `Uninstall` follows a proved legacy upgrade; +`FreshInstall` follows a proved uninstall; and `PostReboot` rejects a missing +or altered pre-reboot receipt. `ReadyForManualEvidence` requires all automated +phases but cannot become `Complete`. Add it to validation only in self-test +mode. Do not trigger an actual uninstall, restart, reboot, tray click, or +remote session in automated tests. + +- [ ] **Step 4: Run focused GREEN** + +Run: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\OfficialDraftAcceptanceHarness.SelfTest.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstalledLifecycleHarness.SelfTest.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\Validate.ps1 -SkipInstalledPackageCheck +``` + +The real phase sequence happens only after all implementation/review/build +gates pass, using official browser-downloaded draft and current-release assets +plus the explicit user-authorized machine mutation/reboot switches. + +- [ ] **Step 5: Commit** + +```text +test: require official draft acceptance evidence +``` + +### Task 9: Require individual tray and remote evidence before promotion + +**Files:** +- Modify: `tests/installed/OfficialDraftAcceptance.psm1` +- Modify: `tests/installed/Invoke-OfficialDraftAcceptance.ps1` +- Modify: `tests/persistence/OfficialDraftAcceptanceHarness.SelfTest.ps1` +- Modify: `tools/Invoke-GitHubDraftRelease.ps1` +- Modify: `README.md` +- Modify: `README.zh-CN.md` +- Modify: `CHANGELOG.md` + +**Interfaces:** + +Extend only the completed Task 8 receipt chain: + +```powershell +Invoke-CcodOfficialDraftAcceptance ` + -Phase TrayEvidence|RemoteEvidence|Complete ` + [-TrayOperation About|Language|OpenLogs|Repair] ` + [-RemoteOperation SecondDeviceControl] ` + -EvidenceRoot ` + -ScreenshotPath ` + -RedactedLogPath ` + -ReviewState Reviewed +``` + +`TrayEvidence` stores one bounded record for each of `About`, `Language`, +`OpenLogs`, and `Repair`, including its expected terminal state and hashes of +the corresponding screenshot and redacted log. `RemoteEvidence` stores the +second-device connection/control result the same way. `Complete` requires the +five named evidence records plus every Task 8 phase; it does not accept a free +text confirmation. + +- [ ] **Step 1: Write tray/remote evidence RED tests** + +Add tests proving that `TrayEvidence` fails if any of the four named operations +is missing, duplicated, has an unexpected terminal state, shares another +operation's file hash, uses a reparse/non-file evidence path, or omits +`Reviewed`. Add tests that `RemoteEvidence` fails without a distinct +second-device proof. Assert `Complete` rejects a `ReadyForManualEvidence` +receipt lacking any individual operation: + +```powershell +Assert-CcodThrows { + Invoke-CcodOfficialDraftAcceptance -Phase Complete -EvidenceRoot $evidence -Adapters $fake +} 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INCOMPLETE' +``` + +Add docs REDs requiring the Chinese README to remove the old unconditional +“已验证……均可用” claim and both READMEs/CHANGELOG to remain candidate-oriented +until `Complete` is represented by evidence, rather than a guessed statement. + +- [ ] **Step 2: Run RED** + +Run: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\OfficialDraftAcceptanceHarness.SelfTest.ps1 +``` + +Expected: the acceptance phase API accepts no individual tray/remote evidence +or has no `Complete` evidence gate. + +- [ ] **Step 3: Implement the bounded manual-evidence gate** + +Validate the file hash and review state through injectable adapters, retain +only the allowed named operation/result/hash fields, and chain every record to +the exact Task 8 candidate receipt. Add `Invoke-CcodGitHubDraftRelease -Mode +Promote` validation of this completed evidence before it is permitted to make a +draft public. Correct both README files and CHANGELOG to avoid unsupported +stable claims; release notes may report only the recorded acceptance facts. + +- [ ] **Step 4: Run focused GREEN** + +Run: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\OfficialDraftAcceptanceHarness.SelfTest.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ReleaseWorkflow.SelfTest.ps1 +``` + +Real screenshots, tray actions, remote connection, and final promotion remain +the user-authorized final acceptance operation; fixture tests must not open a +real tray, switch a language, trigger repair, reboot, or control another +device. + +- [ ] **Step 5: Commit** + +```text +test: gate promotion on tray and remote evidence +``` + +## Plan self-review + +| Spec requirement | Implementing task | +| --- | --- | +| Pinned relative copies, promotion, and owned delete | Task 1 and Task 2 | +| Fresh install, legacy upgrade, same-version conflict, and recovery | Task 2, Task 3, Task 4, Task 5, and Task 8 | +| Sealed package and no writable-app activation execution | Task 4 and Task 5 | +| Monotonic post-pointer compensation and strict `Ready` | Task 3 | +| Dual Defender evidence and exact 11 assets | Task 6 and Task 8 | +| Clean candidate build and draft-only promotion | Task 7 and Task 9 | +| Official draft uninstall/reboot/tray/remote acceptance | Task 8 and Task 9 | + +No task adds a control channel, relaxes process identity, modifies Codex +binaries, or lets existing historical build artifacts re-enter a release. +Tasks share files only in dependency order: Tasks 1–4 form the installation +transaction chain; Task 5 commits legacy uninstall migration only after Task 3 +Ready; Task 6 consumes Task 4's sealed package; Task 7 consumes Task 6's +receipt/asset contract; Task 8 consumes Tasks 3–7 and produces official-draft +Defender/upgrade/reboot evidence; Task 9 adds individual tray/remote evidence +and is the only task that authorizes the locally executed Promote command to +consider a draft eligible for public release. diff --git a/docs/superpowers/plans/2026-09-02-v2522-durable-product-cleanup.md b/docs/superpowers/plans/2026-09-02-v2522-durable-product-cleanup.md new file mode 100644 index 0000000..991346b --- /dev/null +++ b/docs/superpowers/plans/2026-09-02-v2522-durable-product-cleanup.md @@ -0,0 +1,186 @@ +# Durable Product Cleanup Fence Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make post-Ready product-transaction cleanup fail closed across processes, so no installation can report verified product registration while a prior strict product transaction remains unresolved. + +**Architecture:** A product registration attempt obtains the existing account transition lease, writes an exact durable cleanup fence while that lease is held, then closes its strict product transaction. Completion is recorded durably before the fence is released; close failure leaves a durable pending record that blocks other processes from reconciling or reporting success until the original owner resolves it or a proven-dead owner is safely recovered. The existing `SignalAndWait` test protocol remains test-only and proves an actual wait on the real mutex. + +**Tech Stack:** Windows PowerShell, .NET named mutex/event primitives, native V4 install file transaction runtime, append-only state records, existing persistence self-tests. + +**Spec:** `docs/superpowers/specs/2026-08-29-v2522-immutable-generation-design.md` + +## Global Constraints + +- Work only in `codex/v2522-install-runtime-reliability`; do not alter `main`. +- No real registry, shortcut, installer, uninstaller, product process, network, release, tag, signing, push, publication, installation, or reboot action. +- All cleanup identity is exact: canonical install root, full Ready transaction identity, runtime ID/generation, manifest/package hashes, owner PID/creation time/SID, and account transition lease. +- State records are append-only/create-only, reject duplicate/extra fields and unsafe path objects, and never overwrite a replacement. +- A failure after lifecycle `Ready` never appends lifecycle `Failed`, never rolls back the selected generation, and never returns a verified product-registration receipt. +- Cross-process recovery may not transfer a thread-bound mutex lease. An owner still alive blocks reconciliation; a proven-dead owner may only be reconciled under a fresh real account-transition lease and exact Ready identity proof. +- Every production behavior change starts with an observed failing test; all claims require fresh focused suites, parser/diff checks, full aggregate, and independent review. + +--- + +### Task 1: Durable cross-process product-cleanup fence + +**Files:** + +- Modify: `src/persistence/modules/InstallLifecycle.psm1` +- Modify: `src/persistence/modules/InstallFileTransaction.psm1` +- Test: `tests/persistence/InstallLifecycle.SelfTest.ps1` +- Test: `tests/persistence/InstallFileTransaction.SelfTest.ps1` +- Report: `.superpowers/sdd/2026-09-02-v2522-durable-product-cleanup/task-1-report.md` + +**Interfaces:** + +- Consumes: exact `Ready` install transaction (ordered 12-field lifecycle record), `AccountTransition` mutex, `Close-CcodInstallFileTransaction` retry contract. +- Produces: + - `New-CcodLifecycleProductCleanupFence -InstallRoot -ReadyTransaction -OwnerIdentity` + - `Resolve-CcodLifecycleProductCleanupFence -InstallRoot -ReadyTransaction -CurrentIdentity -CloseProductTransaction` + - `Complete-CcodLifecycleProductCleanupFence -Fence -Outcome Completed|Pending` + - a private test-only lower close seam whose default invokes the real native runtime close. + +- [ ] **Step 1: Write failing cross-process cleanup tests** + +Add a fixture that opens a real strict product transaction, injects one lower native-close failure while still executing the real lease-release path, and records a durable fence before the close call. Start a second PowerShell process that acquires the real `AccountTransition` mutex after the first close failure. Assert it sees the exact pending fence and exits `CCOD_PRODUCT_REGISTRATION_FAILED` without opening product authority, writing shortcuts, or returning `{ verified = true }`. + +Add a same-process retry test that forces two close failures, verifies `Ready` remains and no `Failed` snapshot appears, then makes the lower close succeed. Its exact same-package `AlreadyInstalled` invocation must drain the original capability, append a completed fence record, and only then return verified registration. + +```powershell +Assert-CcodThrows { + Invoke-CcodInstall -InstallRoot $fixture.Install -SealedPackageSha256 $fixture.PackageHash -Adapters $firstFailureAdapters +} 'CCOD_PRODUCT_REGISTRATION_FAILED' +Assert-CcodEqual 'Ready' (Read-CcodInstallTransactionRecord -InstallRoot $fixture.Install).phase +Assert-CcodEqual 0 (Get-CcodFailedInstallSnapshotCount $fixture.Install) +Assert-CcodThrows { + Invoke-CcodOtherProcessReconciliation -InstallRoot $fixture.Install -PackageHash $fixture.PackageHash +} 'CCOD_PRODUCT_REGISTRATION_FAILED' +``` + +- [ ] **Step 2: Run test to verify RED** + +Run: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1 +``` + +Expected: nonzero exit. The failure must show that the second process can currently proceed after the first product close fails, or that the durable fence API is absent. Do not accept an adapter-contract or test-harness failure as the RED. + +- [ ] **Step 3: Implement durable fence and close ordering** + +While an outer real `AccountTransition` lease is held, create a create-only durable record under the proven state plane before invoking strict product close. It contains only: + +```powershell +[ordered]@{ + schemaVersion = 1 + transactionId = $ReadyTransaction.transactionId + runtimeId = $ReadyTransaction.newRuntimeId + runtimeGeneration = [uint64]$ReadyTransaction.newGeneration + manifestSha256 = $ReadyTransaction.newManifestSha256 + packageSha256 = $ReadyTransaction.sealedPackageSha256 + ownerPid = [int]$OwnerIdentity.pid + ownerCreationTimeUtc = [string]$OwnerIdentity.creationTimeUtc + ownerSid = [string]$OwnerIdentity.userSid + state = 'Pending' +} +``` + +Append a second create-only `Completed` record only after exact original close succeeds. Resolve the latest valid record under the same real account lease before opening authority: + +- pending owner alive with matching PID/creation/SID: fail closed; +- pending owner proven dead: verify exact Ready/selected generation, reconcile under a fresh real account-transition lease, then append `Completed`; +- same owner/current thread: retry the retained original capability; never transfer its mutex lease. + +Change `Close-CcodInstallFileTransaction` so a failed native runtime close cannot create a release-before-fence window. It may release an inner reentrant lease only while the outer durable-fence lease remains held. Preserve same-thread retry and return `CCOD_INSTALL_CLOSE_FAILED` on unresolved cleanup. + +- [ ] **Step 4: Run focused GREEN** + +Run: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1 +``` + +Expected: all existing cases plus cross-process pending-fence cases pass. Verify first failed close returns no verified receipt, other process opens no product authority, and exact retry returns `AlreadyInstalled` only after durable completion. + +- [ ] **Step 5: Commit implementation and report** + +```powershell +git add src/persistence/modules/InstallLifecycle.psm1 src/persistence/modules/InstallFileTransaction.psm1 tests/persistence/InstallLifecycle.SelfTest.ps1 tests/persistence/InstallFileTransaction.SelfTest.ps1 +git commit -m "fix: fence durable product cleanup" +``` + +Write the report with exact RED output, GREEN commands/counts, cross-process proof, no-real-action statement, implementation SHA, and compatibility impact. + +### Task 2: Atomic real-mutex proof and final Task 4 verification + +**Files:** + +- Modify: `tests/persistence/InstallFileTransaction.SelfTest.ps1` +- Test: `tests/persistence/KernelObjects.SelfTest.ps1` +- Test: `tests/PersistenceSelfTest.ps1` +- Report: `.superpowers/sdd/2026-09-02-v2522-durable-product-cleanup/task-2-report.md` + +**Interfaces:** + +- Consumes: `Enter-CcodLifecycleOwnership -Adapters`, real `Enter-CcodMutex -Kind AccountTransition`, Task 1 durable cleanup fence. +- Produces: an OS-level deterministic child wait proof that does not change production mutex code. + +- [ ] **Step 1: Write atomic wait RED** + +In the MTA child, use the `EnterMutex` adapter only to call real `Enter-CcodMutex` with a `WaitMutex` adapter that performs: + +```powershell +[Threading.WaitHandle]::SignalAndWait($enteredActualWaitEvent, $mutexHandle, 15000, $false) +``` + +The parent holds real N product authority, waits for `enteredActualWaitEvent`, proves child cannot write acquired/commit record while N is open, opens N retained shortcut, closes N, then requires child to acquire/commit N+1. Temporary removal of N outer lease must fail at blocked assertion; restore it before GREEN. + +- [ ] **Step 2: Run RED** + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1 +``` + +Expected: nonzero exit on blocked-after-atomic-wait assertion with temporary lease removal. It must not fail from STA, adapter loading, marker timing, or a mocked mutex. + +- [ ] **Step 3: Preserve atomic protocol and mutex invariants** + +Keep production `KernelObjects` unchanged unless a concrete failure proves it needs alteration. Child uses `-Mta`; real mutex creation, ACL, timeout, abandoned handling, and same-thread release remain default behavior. Assert stale N fails after N+1 and closed N cannot be reused. + +- [ ] **Step 4: Run GREEN and full verification** + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\KernelObjects.SelfTest.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ProductRegistration.SelfTest.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\UninstallBootstrap.SelfTest.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1 +git diff --check +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\PersistenceSelfTest.ps1 +``` + +Parse every changed PowerShell file with `[System.Management.Automation.Language.Parser]::ParseFile`. All commands must exit zero after final source changes. + +- [ ] **Step 5: Commit evidence and request independent review** + +```powershell +git add tests/persistence/InstallFileTransaction.SelfTest.ps1 tests/persistence/KernelObjects.SelfTest.ps1 .superpowers/sdd/2026-09-02-v2522-durable-product-cleanup +git commit -m "docs: record durable cleanup fence evidence" +``` + +The report distinguishes temporary mutation RED from final GREEN, states full aggregate result, makes no release-readiness claim, and requests fresh independent review. + +## Self-Review + +- Spec coverage: Task 1 covers durable cross-process cleanup and no false receipt; Task 2 covers atomic actual-wait evidence and final verification. +- Placeholder scan: no TBD/TODO markers; every test command, record schema, and failure boundary is explicit. +- Type consistency: both tasks use the same ordered Ready transaction identity, `AccountTransition` mutex, and create-only fence records; Task 2 consumes Task 1 fence rather than a second cleanup source. + +## Execution Handoff + +The user requested continued execution. Execute this plan with subagent-driven development: one fresh implementer and independent review per task, then a final Task 4 review. + diff --git a/docs/superpowers/plans/2026-09-02-v2522-legacy-upgrade-safe-reclamation.md b/docs/superpowers/plans/2026-09-02-v2522-legacy-upgrade-safe-reclamation.md new file mode 100644 index 0000000..cd39b3b --- /dev/null +++ b/docs/superpowers/plans/2026-09-02-v2522-legacy-upgrade-safe-reclamation.md @@ -0,0 +1,159 @@ +# Legacy Upgrade and Safe Reclamation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make real v2.5.21 and supported older installations converge to v2.5.22, migrate their exact product records, and reclaim an installed generation without pathname TOCTOU. + +**Architecture:** First classify legacy `active.json` state as an explicit pre-append-only upgrade source instead of demanding a nonexistent Ready transaction. Then migrate legacy registration by matching one complete known installer profile rather than the impossible union of all historical shortcut names. Finally replace recursive pathname deletion with a bounded native handle-pinned reclamation operation that re-proves the sealed tree and deletes only held identities. + +**Tech Stack:** Windows PowerShell 5.1, Inno Setup source history, V4 immutable file layer, Windows native file handles, existing lifecycle/product/uninstall self-tests. + +**Spec:** `docs/superpowers/specs/2026-08-29-v2522-immutable-generation-design.md` + +## Global Constraints + +- Work only in the existing isolated worktree; preserve current dirty/user state outside it. +- No real registry, shortcut, scheduled task, installed product, installer, network, release, push, installation, reboot, or external action. +- True legacy acceptance is limited to exact, proven known layouts. Mixed append-only/legacy state, malformed pointers, unexpected registration objects, or changed paths fail closed. +- Device-key/DPAPI state is never removed or modified. +- Every physical delete uses pinned native identities and never follows reparse points; no broad or recursive pathname deletion. +- Use RED -> GREEN, independent review per task, fresh full aggregate, and final whole-plan review. + +--- + +### Task 1: Accept a proven v2.5.21 legacy lifecycle source + +**Files:** + +- Modify: `src/persistence/modules/InstallLifecycle.psm1` +- Test: `tests/persistence/InstallLifecycle.SelfTest.ps1` +- Test: `tests/persistence/InstalledLifecycleHarness.SelfTest.ps1` +- Report: `.superpowers/sdd/2026-09-02-v2522-legacy-upgrade-safe-reclamation/task-1-report.md` + +**Interfaces:** + +- Produces: `Get-CcodLegacyUpgradeCompatibilityContext -InstallRoot -ExistingPointer -ActiveValidation -GlobalTransaction` +- Returns only `CurrentReady` or `ProvenLegacyWithoutReady`, with exact active runtime/generation/version/manifest identity. + +- [ ] **Step 1: Write a real legacy RED fixture** + +Build a fixture from merge-base `19803a79af3302c49f129e6a6b3ff7ffaf1b930b`: schema-2 `active.json`, v2.5.21 runtime/manifest and mutable legacy activation receipt, but no `state\active-generation`, `state\install-transactions`, or product cleanup fence plane. Invoke the actual v2.5.22 install path with lower OS effects adapted. Current code must fail before generation staging because it passes null Ready to the cleanup resolver. + +Also add mixed-state negatives: append-only selector without transaction chain, fence plane without Ready, malformed legacy active pointer, wrong manifest version, and legacy path reparse. + +- [ ] **Step 2: Run RED** + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1 +``` + +Expected: nonzero at the true legacy fixture before the compatibility function exists; no adapter/setup failure. + +- [ ] **Step 3: Implement exact compatibility classification** + +For a current append-only installation, retain the full Ready/fence pre-upgrade resolution. For a proven legacy installation only, require all append-only/fence planes absent, exact schema-2 `active.json`, manifest-bound active runtime, project version `2.5.21` or another explicitly supported historical profile, and current-user safe paths. Skip durable-fence resolution because that version could not create such a fence. Any mixed or ambiguous state fails before staging. + +- [ ] **Step 4: Run GREEN** + +Run Lifecycle and InstalledLifecycleHarness suites; assert a new append-only selector/Ready transaction is produced, old active state remains only as legacy evidence, and failure before new Ready still restarts the old runtime. + +- [ ] **Step 5: Commit/report** + +Commit message: `fix: bridge legacy lifecycle upgrade`. Record RED/GREEN and no-real-action evidence. + +### Task 2: Migrate exact historical registration profiles + +**Files:** + +- Modify: `src/persistence/modules/ProductRegistration.psm1` +- Test: `tests/persistence/ProductRegistration.SelfTest.ps1` +- Test: `tests/persistence/InstallLifecycle.SelfTest.ps1` +- Report: `.superpowers/sdd/2026-09-02-v2522-legacy-upgrade-safe-reclamation/task-2-report.md` + +**Interfaces:** + +- Produces: `Get-CcodLegacyRegistrationProfiles` and `Resolve-CcodLegacyRegistrationProfile -LegacyRegistration`. +- Each profile has canonical AppId, exact Start-menu/Desktop relative names, expected uninstall-command shape, and version range. + +- [ ] **Step 1: Write profile REDs** + +Use the exact v2.5.21 Inno shape: + +```text +Programs\CodexRemote-fix\CodexRemote-fix.lnk +Programs\CodexRemote-fix\CodexRemote-fix compatibility check.lnk +Programs\CodexRemote-fix\Uninstall CodexRemote-fix.lnk +Desktop\CodexRemote-fix.lnk +``` + +Add exact older profile(s) supported by checked-in installer history. Prove current union-of-eight logic rejects the real v2.5.21 set. Reject missing/extra/cross-profile/case-varied/reparse entries and a registry version/uninstall command that does not match the selected profile. + +- [ ] **Step 2: Run RED** + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ProductRegistration.SelfTest.ps1 +``` + +Expected: real v2.5.21 shape fails before profile implementation. + +- [ ] **Step 3: Implement profile resolution and dynamic snapshot count** + +Select exactly one complete profile; never require the union of historical names. Snapshot count is one registry entry plus the selected profile's shortcut count. Preserve reverse-order compensation, replacement no-overwrite, unresolved create-only records, and exact current registration proof. + +- [ ] **Step 4: Run GREEN** + +Run ProductRegistration and Lifecycle suites. The true legacy lifecycle fixture must complete new product registration and remove exactly the matching old profile; a second same-package invocation is idempotent. + +- [ ] **Step 5: Commit/report** + +Commit message: `fix: migrate exact legacy registration profiles`. + +### Task 3: Reclaim a selected generation through pinned handles + +**Files:** + +- Create: `src/persistence/modules/GenerationReclamation.psm1` +- Modify: `src/persistence/InstalledUninstallFinalizer.ps1` +- Modify: `src/persistence/UninstallBootstrap.ps1` +- Test: `tests/persistence/UninstallBootstrap.SelfTest.ps1` +- Test: `tests/persistence/InstallFileTransaction.SelfTest.ps1` +- Report: `.superpowers/sdd/2026-09-02-v2522-legacy-upgrade-safe-reclamation/task-3-report.md` + +**Interfaces:** + +```powershell +Remove-CcodVerifiedGenerationTree -InstallRoot -RuntimeRoot -RuntimeId -ExpectedManifestSha256 <64hex> +``` + +- [ ] **Step 1: Write deletion REDs** + +Use a disposable generation with nested directories and manifest files. Insert a reparse child, hardlink, ADS, unexpected file, identity replacement, and an open child between initial validation and reclaim. Current pathname `Remove-Item -Recurse` must be shown to lack a pinned proof; hostile cases must delete neither the selected root nor outside target. + +- [ ] **Step 2: Run RED** + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\UninstallBootstrap.SelfTest.ps1 +``` + +- [ ] **Step 3: Implement bounded native reclamation** + +Open the exact selected root and every expected manifest file/directory through relative native handles with DELETE/read-attributes/list rights and no delete/write sharing. Revalidate final paths, root ancestry, volume/file IDs, non-reparse, default stream, and single-link regular files. Compare the complete enumerated tree against the sealed manifest. Hold every handle, mark leaves and directories for deletion bottom-up through native handle disposition, then close. Any mismatch/open handle/change fails before deletion; never follow a path or use recursive `Remove-Item`. Preserve install root, sibling generations, state, and device-key store. + +- [ ] **Step 4: Run GREEN** + +Run UninstallBootstrap, IFT, Lifecycle, parser/diff and the full aggregate. The exact selected generation is absent only after handle-pinned deletion; sibling/root/state remain. + +- [ ] **Step 5: Commit/report** + +Commit message: `fix: reclaim generation through pinned handles`. + +## Final verification + +Run all focused persistence suites, `tests\PersistenceSelfTest.ps1`, parser/diff, and a fresh whole-plan review. Tasks 5–8 of the original release plan remain blocked until this plan is clean. + +## Self-Review + +- Spec coverage: Task 1 handles true lifecycle state; Task 2 handles actual installer registration; Task 3 handles physical reclamation. +- Placeholder scan: each task has concrete fixtures, APIs, commands and failure boundaries. +- Type consistency: Task 2 consumes Task 1's real legacy fixture; Task 3 is independent and preserves all lifecycle state. + diff --git a/docs/superpowers/plans/2026-09-02-v2522-rollback-lease-ordering.md b/docs/superpowers/plans/2026-09-02-v2522-rollback-lease-ordering.md new file mode 100644 index 0000000..d4bd0e3 --- /dev/null +++ b/docs/superpowers/plans/2026-09-02-v2522-rollback-lease-ordering.md @@ -0,0 +1,99 @@ +# Rollback Lease Ordering Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ensure every pre-selector upgrade rollback releases the outer product-cleanup AccountTransition acquisition before scheduled-task bootstrap restarts and proves the old Supervisor. + +**Architecture:** Treat `upgradeProductCleanupLease` as the outermost reentrant AccountTransition acquisition. On failure after old protection stops but before the new selector commits, release inner lifecycle/install acquisitions and then the outer product-cleanup acquisition in strict LIFO order before `StartSupervisorTask`. Clear the variable only after exact release so the finalizer is idempotent and cannot double-release. + +**Tech Stack:** Windows PowerShell, existing KernelObjects AccountTransition mutex, InstallLifecycle adapters and self-tests. + +**Spec:** `docs/superpowers/specs/2026-08-29-v2522-immutable-generation-design.md` + +## Global Constraints + +- Work only in the existing isolated branch/worktree. +- No real product, registry, shortcut, scheduled task, installer, network, publication, installation, restart, or reboot action. +- Do not change fence schemas, selector formats, Ready identity, or IFT native file behavior. +- A pre-selector failure must leave the old selector/runtime authoritative; rollback restart must run only after all AccountTransition acquisitions owned by the installer are released. +- Release order is LIFO, release result must be proved, and final cleanup must not double-release. +- Use RED -> GREEN, focused lifecycle/Kernel tests, parser/diff, full aggregate, and independent review. + +--- + +### Task 1: Release outer product-cleanup lease before rollback restart + +**Files:** + +- Modify: `src/persistence/modules/InstallLifecycle.psm1` +- Test: `tests/persistence/InstallLifecycle.SelfTest.ps1` +- Report: `.superpowers/sdd/2026-09-02-v2522-rollback-lease-ordering/task-1-report.md` + +**Interfaces:** + +- Consumes: `upgradeProductCleanupLease`, `Exit-CcodLifecycleProductCleanupLease`, lifecycle ownership/install lease release, `StartSupervisorTask`, `WaitNewRuntimeReady`. +- Produces: helper `Exit-CcodUpgradeProductCleanupLeaseForRollback` or equivalent single call site that validates exact release, sets the caller variable to null only after success, and runs before old Supervisor restart. + +- [ ] **Step 1: Write the failing real-mutex rollback test** + +Create a temporary old Ready installation and begin a real upgrade. Inject failure at `SetActiveRuntime` before selector commit after old protection has stopped. The rollback path must use a real `AccountTransition` mutex for the outer product-cleanup lease. Its fake scheduled-task bootstrap child must attempt the same real mutex atomically and write a marker only after acquisition. Current code should time out/fail because the installer still holds the outer lease. + +Assertions: + +```powershell +Assert-CcodEqual $false $world.PointerCommitted 'failure occurs before selector commit' +Assert-CcodTrue $world.PreviousProtectionStopped 'old protection was stopped' +Assert-CcodTrue $world.BootstrapAttempted 'rollback attempted old Supervisor bootstrap' +Assert-CcodTrue $world.BootstrapMutexAcquired 'old bootstrap acquires AccountTransition before readiness' +Assert-CcodEqual 'Ready' $world.OldRuntimeOutcome 'old runtime is restored and proven ready' +``` + +- [ ] **Step 2: Run RED** + +Run: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1 +``` + +Expected: nonzero exit at the bootstrap mutex-acquired/rollback-ready assertion. An adapter setup error, fake mutex, or pre-call marker is not valid RED evidence. + +- [ ] **Step 3: Implement exact LIFO release** + +After lifecycle ownership and install lease are released, but before `StartSupervisorTask`, release the live `upgradeProductCleanupLease` with `Exit-CcodLifecycleProductCleanupLease`. Require a true result and `Lease.Released=true`; then set `upgradeProductCleanupLease=$null`. If release fails, throw stable rollback failure without starting the task. The enclosing finally checks null/live state and must not release twice. + +Do not release this outer lease early on successful forward installation; it must continue protecting old-Pending resolution through selector commit. + +- [ ] **Step 4: Run GREEN and regression matrix** + +Run: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\KernelObjects.SelfTest.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallFileTransaction.SelfTest.ps1 +git diff --check +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\PersistenceSelfTest.ps1 +``` + +Parse both changed PowerShell files. Also retain cross-process fence, bind-abort, crash-temp, atomic SignalAndWait, stale-N, and closed-N tests through the aggregate. + +- [ ] **Step 5: Commit and report** + +```powershell +git add src/persistence/modules/InstallLifecycle.psm1 tests/persistence/InstallLifecycle.SelfTest.ps1 +git commit -m "fix: release rollback cleanup lease" +``` + +Write the report with exact RED/GREEN output, release ordering, no-real-action statement, implementation SHA, and full aggregate result. Commit evidence separately and request a fresh task plus whole-branch review. + +## Self-Review + +- Spec coverage: one task covers the single load-bearing residual and all regression gates. +- Placeholder scan: no TODO/TBD; exact failure injection, assertions, implementation boundary, and commands are present. +- Type consistency: the same `upgradeProductCleanupLease` created by durable cleanup is released with the existing exact function before bootstrap. + +## Execution Handoff + +The user requested continued execution. Use subagent-driven development with a fresh implementer and independent reviewer. + diff --git a/docs/superpowers/plans/2026-09-03-v2522-cross-process-retry-closure.md b/docs/superpowers/plans/2026-09-03-v2522-cross-process-retry-closure.md new file mode 100644 index 0000000..26e93dd --- /dev/null +++ b/docs/superpowers/plans/2026-09-03-v2522-cross-process-retry-closure.md @@ -0,0 +1,42 @@ +# v2.5.22 Cross-Process Retry Closure Plan + +> **For agentic workers:** Use `superpowers:subagent-driven-development` and `superpowers:test-driven-development`. This is a successor to the failed final scoped review of `6b9bc7e..58db6fd`; it is not a second fix wave of the prior plan. + +**Goal:** Make the two remaining public retry boundaries converge after a new Windows logon session or a process crash: installed `TaskRemoved` recovery before the external anchor exists, and legacy product migration after one or both overlapping shortcuts have already been replaced. + +**Architecture:** The installed-uninstall path gets a narrowly scoped same-SID TaskRemoved matcher that revalidates the current runtime under the existing Local-to-Global lock order while preserving the historical session as evidence. Legacy migration gets a create-only, Ready-bound durable plan written before any current product write; later processes may consume that plan only when every immutable identity matches and each overlap is either the captured historical leaf or the exact current replacement. + +**Constraints:** Work only in the existing worktree. No real registry, shortcut, scheduled task, installer, installation, uninstall, process restart, reboot, network, push, tag, release, or publication. Keep device-key/DPAPI state untouched. Do not weaken ordinary fresh-context session matching or accept a live hybrid legacy profile without a previously durable exact plan. + +## Task 1 — Recover TaskRemoved from the same SID in a new session + +**Files:** + +- Modify: `src/persistence/UninstallBootstrap.ps1` +- Modify only if the public command shape must change: `Uninstall-CodexControlOtherDevices.ps1`, `src/persistence/InstalledUninstallFinalizer.ps1` +- Test: `tests/persistence/UninstallBootstrap.SelfTest.ps1` + +1. Add a production-shaped RED in which the durable transaction is session 1, `ValidateInvocation` returns the same SID in session 99, phase is exact `TaskRemoved`, no staged recovery registry exists yet, and the public runtime wrapper supplies an exact new identity. Current code must fail at the generic context matcher. +2. Add a dedicated installed TaskRemoved matcher. It may ignore only the context session ID, and only after Local uninstall then Global `AccountTransition` acquisition and a fresh runtime/selector/Ready/manifest/epoch validation. Require the same SID, transaction ID, runtime ID, generation, epoch, Ready evidence, payload records, staged paths/hashes, and immutable installed binding. Preserve `transaction.sessionId` and historical `wrapper*`; write only exact `resumeWrapper*`, then persist/read back the transaction and receipt. +3. Directly return the verified existing TaskRemoved transaction; never call `RunCleanup` again. Launch `-WrapperResume` with the new wrapper identity and require its exact exit before reclamation. Ordinary pre-TaskRemoved resume and different-SID/runtime/generation/epoch/Ready/payload cases remain rejected. +4. Cover finalizer-start failure followed by a same-SID new-session second public invocation, plus session/PID/creation-time mutations. Run UninstallBootstrap and parser/diff checks. + +## Task 2 — Persist and replay the exact legacy migration plan + +**Files:** + +- Modify: `src/persistence/modules/ProductRegistration.psm1` +- Modify: `src/persistence/modules/InstallLifecycle.psm1` +- Modify if a private capability is required: `src/persistence/modules/InstallFileTransaction.psm1` +- Test: `tests/persistence/ProductRegistration.SelfTest.ps1` +- Test: `tests/persistence/InstallLifecycle.SelfTest.ps1` + +1. Add REDs that use production-shaped shortcut evidence. Persist no in-memory object across attempts. Fail after Start-menu replacement, after Desktop replacement, and after current read-back. A real second registration attempt must currently fail to recapture the historical profile. +2. Before any current registry or shortcut write, publish a create-only durable migration-plan record through the product file transaction. Bind its canonical schema and leaf to the exact Ready transaction, runtime ID, generation, manifest SHA-256, package SHA-256, AppId, historical profile, registry values/kinds, and every shortcut path/bytes/hash/target/arguments/working directory. Bound sizes, duplicate-free JSON, safe ancestry, no reparse/ADS/multilink, exact ACL/owner, and create-only conflict semantics are mandatory. +3. On a later process, an existing exact plan may be reused only after its full binding is re-read and verified. Legacy registry and legacy-only entries must remain exact. Each overlap must independently be either the captured historical leaf or the exact current replacement for this same Ready/package. Missing/foreign overlap without an existing exact plan remains a zero-current-write failure. +4. Make current registration and legacy-only cleanup idempotently converge from historical/historical, current/historical, historical/current, and current/current overlap states. Preserve replacement-safe registry compensation and leave the durable plan append-only as audit evidence. +5. Add negative tests for absent/tampered/wrong-Ready/wrong-package/wrong-generation/duplicate/unsafe plan records and foreign hybrid leaves. Run ProductRegistration, InstallLifecycle, IFT when touched, parser/diff, and the full aggregate. + +## Final gate + +Freeze source, run ProductRegistration, UninstallBootstrap, InstallLifecycle, InstallFileTransaction, RuntimeManifest, InstalledLifecycleHarness, Bootstrap, ManualWrappers, all changed parsers, `git diff --check`, and `tests\PersistenceSelfTest.ps1`. Commit implementation and evidence separately. Then request one fresh scoped review of this successor range. Tasks 5–8 remain blocked until that review is clean. diff --git a/docs/superpowers/plans/2026-09-03-v2522-runtime-v7-closure.md b/docs/superpowers/plans/2026-09-03-v2522-runtime-v7-closure.md new file mode 100644 index 0000000..949e9fe --- /dev/null +++ b/docs/superpowers/plans/2026-09-03-v2522-runtime-v7-closure.md @@ -0,0 +1,14 @@ +# v2.5.22 Runtime V7 ABI Closure Plan + +**Goal:** Ensure the final embedded install-file CLR implementation cannot silently reuse the pre-fix RuntimeV6 in a long-lived PowerShell AppDomain. + +**Scope:** `src/persistence/modules/InstallFileTransaction.psm1` and its self-test, plus only evidence/ledger files. No behavioral redesign. + +## Task + +1. Add an independent child-process RED that preloads a minimal pre-fix `CcodInstallGenerationCapabilityMarkerV6` / `CcodInstallGenerationRuntimeV6`, then force-imports the current module. It must prove the current module binds the stale V6 instead of a new ABI/type. Keep the fixture self-contained and independent of Git history. +2. Mechanically advance the embedded marker, runtime type and `CapabilityAbi` to V7/7. Update every exact ABI assertion. A preloaded V6 must coexist but never be selected; repeated force-import of the final V7 must reuse the same correct V7 type. +3. Run InstallFileTransaction, ProductRegistration, InstallLifecycle, UninstallBootstrap, RuntimeManifest, InstalledLifecycleHarness, Bootstrap, ManualWrappers, all changed parsers, `git diff --check`, and `tests\PersistenceSelfTest.ps1` from frozen source. +4. Commit implementation and evidence separately, then request one independent scoped review. Do not claim Tasks 5–8 or release readiness. + +**Constraints:** No real registry, shortcut, scheduled task, installation, uninstall, process restart, reboot, network, push, tag, release, or publication. Preserve all V6 file/plan behavior byte-for-byte except required CLR symbol/ABI changes and tests. diff --git a/docs/superpowers/specs/2026-08-28-v2522-async-tray-receipts-design.md b/docs/superpowers/specs/2026-08-28-v2522-async-tray-receipts-design.md new file mode 100644 index 0000000..f97052c --- /dev/null +++ b/docs/superpowers/specs/2026-08-28-v2522-async-tray-receipts-design.md @@ -0,0 +1,139 @@ +# v2.5.22 Async Tray Receipt Reliability Design + +## Goal + +Replace the synchronous TrayHost terminal-diagnostic write with a bounded, +asynchronous receipt pipeline. A slow, failed, or unsafe receipt write must +never block authenticated result consumption, exhaust pending tray actions, or +show a generic dialog without a durable local receipt. + +## Why this is a separate design + +The prior task established action correlation, exact acknowledgement gates, and +sanitized terminal fields. Three repair rounds showed that a synchronous file +append on the authenticated reader path cannot meet the full contract: a +path-only reparse check has a time-of-check/time-of-use gap, `Flush(true)` can +block the only reader, and an in-process test bridge does not prove the real +authenticated child session. The user approved this architectural correction +on 2026-08-28. + +## Non-negotiable constraints + +- Keep the existing authenticated TrayHost wire schema, directions, message + types, action-result fields, sequence rules, and capability policy unchanged. +- Do not add a production test command, listener, environment switch, external + control path, or unauthenticated input channel. +- A terminal action is consumed exactly once. It never returns to the pending + set after receipt admission, failure, queue saturation, writer hang, or + shutdown. +- Generic failure feedback and About display require a successfully flushed, + sanitized receipt. A failed, full, busy, closed, or hung receipt path drops + only the feedback; it never invents a result or blocks later actions. +- Receipt disk content contains exactly `command`, `revision`, `status`, and a + canonical `CCOD_*` code. It excludes action IDs, transaction IDs, paths, + tokens, exceptions, messages, and raw wire data. +- Preserve current-user operation. Do not modify Codex binaries, WindowsApps, + account data, or the DPAPI device-key store. + +## Architecture + +### 1. Terminal correlation and zero-wait admission + +`HostTransport` continues to authenticate and validate a terminal result +against its pending action ID, revision, accepted-before-completed rule, and +command policy. Under its correlation lock it constructs a typed +`TrayTerminalReceipt`, removes the pending action, and returns the receipt to +the child session. It performs no file I/O and no wait for queue capacity. + +`TrayTerminalReceiptSink.TrySubmit` accepts at most eight outstanding receipts, +including one currently writing. It returns immediately with an admission +result. Busy, full, or closed admission suppresses feedback but leaves the +action consumed. The sink has one background writer only; it never creates a +replacement thread for a stuck write. + +After a durable write, the sink calls back into `HostTransport` with the +in-memory receipt. The transport enqueues a single bounded UI work item: +completed About maps to About; rejected/failed maps to the existing generic +failure dialog; all other completed commands map to no UI. Receipt UI uses an +internal tokened work message, separate from presentation/shutdown drains. The +transport makes a token visible only for a successful tokened post; ordinary +work messages can never consume a receipt item while that post is unresolved. +If the post fails, the item is retracted before any UI path can see it. The UI +queue is also bounded at eight shared items. + +The sink claims callback admission under its close gate before invoking the +durable callback. Once `Dispose` closes that gate, no new callback can begin; +an already-started callback may finish, but the disposed transport refuses to +publish UI work. + +### 2. Pinned, bounded native receipt store + +The store uses the fixed path +`%LOCALAPPDATA%\CodexControlOtherDevices\logs\tray-receipts\trayhost-actions.log`. +The existing product and `logs` directories are created by supported historical +installations with normal inherited current-user ACLs, so they remain +compatibility parents: the store opens and pins them as non-reparse directory +handles but does not require them to have the receipt-private ACL. Beneath the +pinned `logs` handle it creates the fixed `tray-receipts` child with a protected +current-user/SYSTEM/Administrators ACL, reopens and validates that exact child +handle, and fails closed if an existing child does not match. It never reuses +the legacy direct `logs\trayhost-actions.log` leaf. + +The store holds the native directory chain with reparse-point opening flags and +delete sharing denied while it opens the fixed leaf. The leaf is opened through +a native handle before any write; its handle must prove a regular, non-reparse, +single-link file at the expected final path. Writes and rollover occur only +through that validated handle, never through a re-resolved pathname. + +The file is UTF-8 without a BOM and has a hard 64 KiB limit. If adding a whole +record would cross the limit, the validated handle is truncated and receives +only the new complete record. A receipt is durable only after the write and +`Flush(true)` both succeed. Handle or validation failure fails closed for UI +feedback. + +### 3. Child-session seam and trace executable + +Move the existing `Program.RunChild` session behavior behind an internal child +session/runtime seam. `Program.Main` retains only its existing `--child` and +`--headless-smoke` inputs and creates the Windows runtime adapter. A temporary +self-test executable uses the same child session and authenticated startup but +substitutes only a fake native tray platform. It is compiled in the existing +temporary test directory and is never shipped or selectable by the production +binary. + +The production trace uses the real PowerShell TrayHostClient exports, +TrayHostParentClient, authenticated wire, child session, HostTransport, receipt +sink, and generic-dialog invocation. It covers both a current native +`OpenLogs` selection and an explicitly authenticated stale-revision negative +fixture. The latter is required because an ordinary native click always uses +the current displayed revision. + +## Failure policy + +| Condition | Pending action | Receipt/UI | +| --- | --- | --- | +| Sink busy, full, or closed | Consumed | Drop receipt and UI feedback | +| Unsafe path, reparse, hard link, sharing failure | Consumed | Store fails; no UI feedback | +| Write or flush failure | Consumed | Store fails; no UI feedback | +| Writer hangs | Consumed | One writer remains blocked; bounded later receipts drop; no UI feedback | +| Durable success, UI queue full | Consumed | Keep durable receipt; drop only UI item | +| Disposal | Consumed | Stop admission and callbacks; bounded join only | + +## Acceptance criteria + +1. A terminal result removes its pending action before sink admission; eight or + more failed admissions cannot stop later actions from registering. +2. A blocked store leaves the authenticated reader responsive to a subsequent + presentation or ping and cannot hold the transport correlation lock. +3. The store rejects reparse directory components, a reparse leaf, a hard-link + leaf, a second writer, and a concurrent replacement attempt before any + outside target is written. +4. Receipt storage never exceeds 64 KiB and retains one complete latest record + after rollover through the validated handle. +5. Generic feedback or About work is queued only after durable success; false, + throw, full, blocked, or failed paths queue none. +6. The production trace exercises current and stale paths through TrayHostClient + to ParentClient, authenticated wire, child session, HostTransport, receipt + sink, and generic dialog, without a production-only test selector. +7. Existing installer/new-user/upgrade, process identity, package, native + TrayHost, and Defender checks remain green before release acceptance. diff --git a/docs/superpowers/specs/2026-08-28-v2522-install-runtime-reliability-design.md b/docs/superpowers/specs/2026-08-28-v2522-install-runtime-reliability-design.md new file mode 100644 index 0000000..081e19d --- /dev/null +++ b/docs/superpowers/specs/2026-08-28-v2522-install-runtime-reliability-design.md @@ -0,0 +1,118 @@ +# v2.5.22 Install and Runtime Reliability Design + +## Goal + +Make a fresh installation and an upgrade from any supported historical +CodexRemote-fix installation converge on one verified current-user runtime, +then keep remote recovery and tray actions reliable while preserving the +existing strict process-identity security boundary. + +## Evidence driving this design + +The reported v2.5.21 machine contained a current versioned runtime while its +installer shell and active pointer could still describe an older payload. The +current Inno route treats a correlated `Ready` receipt as sufficient evidence; +it does not bind the receipt to the setup version or verify the final active +runtime manifest. Separately, normal Electron child-process churn can make +the one-pass verified-tree read fail during a close operation even though the +top-level Codex root has not changed. Native tray commands reach TrayHost but +the production result path reduces every rejected or failed action to the same +generic dialog. + +## Non-negotiable constraints + +- Remain a current-user Windows installation. Do not modify `ChatGPT.exe`, + `app.asar`, `WindowsApps`, account state, or the DPAPI device-key store. +- Retain exact PID, creation time, user SID, package family, executable path, + session, and debug-port checks. A retry must never convert an identity + mismatch into success. +- The setup and portable paths must reject an incomplete or stale payload + instead of reporting successful activation. +- Preserve a working old runtime until the new active pointer, Supervisor, and + TrayHost have all been proven ready. +- Keep end-user tray text concise; write sanitized support codes to the local + log rather than exposing raw implementation details in a dialog. + +## Architecture + +### 1. Immutable, version-bound installer payload + +The setup package installs its executable payload into +`{app}\payload\` and launches activation from that exact +directory. Build generates an `installer-payload-manifest.json` in the same +directory with a schema version, project version, and ordered SHA-256 records +for every file the lifecycle installer may copy. The activation worker accepts +the compile-time expected version, validates the versioned payload manifest and +the local `package.json`, and passes the expected version and manifest path to +the lifecycle installer. + +`InstallLifecycle` consumes the validated manifest records as its source-file +allowlist. It no longer discovers arbitrary leftover files under `{app}`. +After the scheduled task signals readiness it rereads `active.json`, validates +the selected runtime manifest, and requires that the manifest project version +equals the expected setup version before it writes a terminal `Ready` receipt. +This works for new users because the versioned payload directory is created +from scratch, and for upgrades because an old app root cannot substitute its +payload for the requested version. + +Build creates a one-time generated `.iss` from the checked-in template by +replacing one fixed inventory marker with the verified destination-directory +procedure. The template accepts only the literal build directives required by +the checked-in script (`#ifndef`, `#define`, `#error`, `#endif`); it rejects +every other simple preprocessor directive, any line-spanning continuation, and +all pragma directives before ISCC runs. This prohibits external inclusion and +preprocessor-generated `[Files]` entries without reimplementing ISPP, while +making the directory inventory and the exact source compiled by ISCC one +artifact. + +### 2. Bounded stable-tree acquisition + +Add a retry wrapper around verified Electron process-tree acquisition for +close/recovery work. It makes a small, bounded number of fresh snapshots with +a short condition-based delay only when a read is indeterminate or the child +set is transient. Every successful attempt must independently prove the same +top-level root identity and a self-consistent tree. If the root PID, creation +time, session, SID, package family, executable path, command line mode, or +recorded debug ports change, the operation remains fail-closed immediately. + +The wrapper is used before a mutation starts and for a transient rich snapshot +read of an already verified member. Once a close transition is written, the +existing per-member pre-stop, post-stop, and final identity checks remain +strict. A `SameIdentity` result is required before retrying a null or +mismatched rich snapshot; an absent or changed root remains fail-closed. + +The same bounded policy applies to Supervisor's `RemoteVerified` rebind: it +may retry an indeterminate reread of the exact candidate root, but a different +root, changed creation time, changed ports, or a malformed state read remains +an immediate failed proof. Diagnostics classify the last stable failure +boundary so a future support log can distinguish tree churn from an unsafe +process identity. + +### 3. Tray action correlation and diagnostics + +Keep the authenticated TrayHost protocol and revision/capability gates. Add a +small sanitized action-result record that includes the command, presentation +revision, terminal status, and stable error code. Add an end-to-end test that +drives a native menu command through the real host transport, presentation ACK +map, Supervisor authorization, and result ACK. A missing acknowledged revision +must be reported as `CCOD_TRAY_ACTION_STALE`; a current acknowledged revision +must reach the specific handler. + +## Acceptance criteria + +1. A test simulating an old installer payload with a newer requested setup + version fails before changing the active pointer and returns a stable + activation error. +2. A matching versioned payload activates exactly the expected runtime and the + final receipt is `Ready` only after pointer and manifest revalidation. +3. Fresh and upgrade fixtures copy only manifest-listed payload files; stale + app-root leftovers cannot enter a new runtime. +4. A stable root plus one transient child-tree read succeeds within the fixed + retry budget; a changed root identity still fails without a retry-based + bypass. +5. Tray end-to-end action tests prove an acknowledged current revision succeeds + and a stale revision is rejected with a recorded support code. +6. Existing persistence, package, release, native TrayHost, and Defender gates + remain green. A real current-user setup install, reboot, remote connection, + About, language switching, open logs, and repair are all checked before + release completion. diff --git a/docs/superpowers/specs/2026-08-28-v2522-transactional-install-release-design.md b/docs/superpowers/specs/2026-08-28-v2522-transactional-install-release-design.md new file mode 100644 index 0000000..cd9f519 --- /dev/null +++ b/docs/superpowers/specs/2026-08-28-v2522-transactional-install-release-design.md @@ -0,0 +1,234 @@ +# v2.5.22 Transactional Install and Release Acceptance Design + +## Status + +The user approved this architecture on 2026-08-28. It supersedes the +incomplete installer/write-boundary portions of the earlier v2.5.22 designs. +`v2.5.22` is not tagged or released, so this design retains that version rather +than creating a second public version number. + +## Goal + +Make a fresh install, a supported legacy upgrade, and a same-version repair +converge on one sealed current-user runtime. A failed transaction must retain a +known runnable runtime or fail without creating an active mixed state. The +release path must not expose assets publicly until both Setup and the EXE-based +portable ZIP are verified, scanned, and accepted on the current Windows host. + +## Evidence and non-goals + +The existing candidate already has useful foundations: versioned manifests, +exact payload hashes, source-byte sealing in the activation worker, runtime +pointer revalidation, strict process identity, and authenticated tray receipts. +It does not yet make Setup writes, activation-script execution, lifecycle +copies/promotions/deletes, Defender release evidence, and reboot acceptance one +end-to-end transaction. + +This design does not modify `ChatGPT.exe`, `app.asar`, `WindowsApps`, account +state, or the current-user DPAPI device-key store. It does not add an external +remote-control channel or change the authenticated TrayHost protocol. + +## Invariants + +- All product state remains current-user scoped. +- The release version is exactly `2.5.22` until it is publicly released. +- A runtime may become active only from a manifest-listed sealed package whose + version, source commit, and payload hash match the transaction contract. +- A file write, promotion, or delete uses a pinned parent identity and a + single relative leaf name. Pathname checks are validation, not authority. +- A failure before pointer commit leaves the old pointer and stable shell + untouched. A failure after pointer commit uses a monotonic compensating + generation pointing back to the retained old runtime; it never rewrites a + generation number. +- `Ready` requires a matching active pointer, runtime manifest, Supervisor, + authenticated TrayHost, expected version, and terminal activation receipt. +- A release remains draft until its exact asset set and both Defender receipts + are read back successfully. A failed scan or missing evidence is a hard + release failure, never a bypass. + +## Architecture + +### 1. Sealed package generation and trusted Setup bootstrap + +Build produces one manifest-bound package archive for Setup, containing the +complete runtime source and stable-shell candidates. The Setup executable keeps +that archive, its manifest, and the activation bootstrap as `dontcopy` inputs. +The installer no longer relies on a writable `{app}` tree as the code execution +source. + +At `PrepareToInstall`, Setup: + +1. extracts the bootstrap and package only to its private temporary area; +2. rehashes the extracted files against compile-time values while their handles + deny write and delete sharing; +3. starts a sealed bootstrap process from that locked source; +4. waits for the bootstrap to create and pin the target transaction root and + return a fixed capability/ready result; and +5. performs no recursive product payload extraction itself. + +The bootstrap is the only component allowed to unpack the package into the +current-user install root. It keeps its package and destination pins until the +terminal receipt has been validated. A diagnostic copy under `{app}` may exist +after a successful transaction, but Setup must never execute that copy as part +of the install success path. + +The new Inno shell sets `CreateAppDir=no` and `Uninstallable=no`; it writes no +product file, product shortcut, or product registry entry under `{app}` before +`Ready`. The old Inno `AppId`, installed user-facing name, current-user scope, +and existing control-panel uninstall entry remain compatibility inputs during +migration. After `Ready`, the transaction writes and reads back one verified +current-user product registration pointing at the sealed stable uninstaller and +two verified current-user shortcut records: the Start-menu `CodexRemote-fix` +shortcut and the desktop `CodexRemote-fix` shortcut. Only after all three +records are read back does it remove the exact legacy registry entry and exact +legacy shortcut names. A failed transaction never unregisters or recursively +deletes a legacy entry. Legacy installer shell files are never used as a +source-file discovery root. + +### 2. Private pinned file transaction module + +Add one private persistence module, +`InstallFileTransaction.psm1`. Its native interop is internal; callers do not +receive arbitrary pathname write APIs. + +Its required operations are: + +| Operation | Contract | +| --- | --- | +| Open/create pinned directory | Opens or creates exactly one child under an already pinned parent; rejects reparse points and unexpected identities. | +| Create pinned temporary leaf | Creates only a single generated leaf under a pinned directory with no overwrite of an existing inode. | +| Copy and verify | Reads sealed source bytes, writes through the pinned leaf, flushes, and rehashes through the same handle. | +| Commit promotion | Atomically promotes a verified temporary leaf within the pinned directory; identity or sharing ambiguity fails closed. | +| Retire owned tree | Atomically moves a complete, transaction-owned nonempty tree to a no-replace quarantine name under its pinned parent. Empty owned trees may be marked for delete-on-close. | +| Close transaction | Releases handles only after a terminal success/failure decision; quarantine is retained for later diagnosis or separately proven reclamation rather than performing a pathname-recursive delete. | + +The module accepts only pinned handles and validated relative segments. It +cannot be called with an untrusted absolute destination. Existing checks for +ADS, reparse points, link count, manifest membership, length, and SHA-256 +remain mandatory at every transition. + +### 3. Transaction state machine and compatibility recovery + +The bootstrap persists a private, bounded transaction record under the existing +state root. It contains a transaction ID, old/new runtime IDs, old/new +generation, sealed package hash, phase, and candidate object names. It contains +no credentials, remote keys, arbitrary paths, or raw exception text. + +```text +Prepared + -> PackageVerified + -> RuntimeStaged + -> PreviousProtectionStopped + -> RuntimePromoted + -> PointerCommitted + -> StableShellCommitted + -> ProtectionReady + -> Ready + -> Failed +``` + +- Before `PointerCommitted`, a failure leaves the previous stable shell and + active pointer unchanged, then proves the retained previous runtime can run. +- The stable shell is committed only after pointer commit. That creates one + post-pointer compensation boundary, but preserves the pre-pointer invariant: + a failure before pointer commit cannot alter the shell a legacy bootstrap + would execute. +- After `PointerCommitted`, a failure records a new, higher compensating + generation targeting the retained previous runtime and restores the matching + stable shell. If compensation cannot be proven, it writes no `Ready`, retains + all candidates, writes terminal `Failed` with a stable rollback code, and + returns that code. +- `Failed` is the only terminal error phase. Its record carries the same sealed + transaction identity and phase order as `Ready`, plus exactly one canonical + `CCOD_*` error code; no raw exception or private path is persisted. +- Legacy installations without the transaction record start at `Idle`; their + active runtime is treated as a compatibility candidate only after the + existing strict identity and manifest checks succeed. +- A same-version repair reuses a package only when version and sealed package + hash both match. A same version with different bytes fails as a conflict, + preventing a mixed repair. +- Unknown legacy files are never recursively removed. Nonempty transaction-owned + objects are retired atomically; only a separately proven empty tree is + physically removed. Cleanup is therefore limited to transaction-owned objects + that the module can prove safe. + +### 4. Defender and draft-release promotion + +Both local installer entry paths scan their sealed input before activation: + +- portable scans its verified payload as today, retaining the existing strict + Defender failure behavior; +- Setup bootstrap scans the verified Setup package before beginning the + transaction. + +Release publishing becomes a staged promotion: + +```text +clean build -> exact asset manifests -> draft release -> official draft download +-> SHA/manifest revalidation -> Defender scan of Setup and portable ZIP +-> receipts bound to exact assets -> full install/reboot acceptance -> public release +``` + +The release workflow uploads only an explicit 11-item asset allowlist. It +creates a draft, uploads the exact set, reads every asset back, and never +publishes a partial asset set. Its Defender gate has two explicit origins: + +- `TrustedWorkflowArtifact` for an internally generated candidate whose + artifact identity, manifests, version, commit, and hashes are verified; and +- `InternetDownload` for an official browser download that must retain actual + ZoneId 3. The workflow must not synthesize a Zone identifier. + +Each receipt binds asset SHA-256, manifest SHA-256, version, commit, Defender +platform/signature data, timestamps, and zero new detections. Disabled Defender, +missing real-time protection, stale/missing signature metadata, missing receipt, +or a scan error blocks promotion. The release workflow must use a documented +Defender-capable Windows runner; if unavailable, it fails while leaving the +release draft private for review. + +### 5. Acceptance evidence + +Automated acceptance must cover: + +1. clean fresh install with initially missing transaction directories; +2. legacy upgrade from the currently released installer; +3. same-version repair with same hash and rejection of same-version/different + hash input; +4. failures injected at each state-machine phase, including post-pointer + compensation; +5. pinned copy, promotion, and deletion boundaries retaining outside sentinel + data unchanged; +6. Setup and portable artifact integrity, both manifest contracts, exact + 11-item release assets, and dual Defender receipts; +7. a clean Windows runner full suite, never treating a live local mutex as a + pass; and +8. a current-machine official-draft acceptance: full uninstall/reinstall, + reboot, active-runtime/process identity, DPAPI key preservation, tray + About/language/logs/repair actions, and a real second-device remote + connection. + +The last tray and remote interactions require recorded end-user evidence +(screenshots plus redacted logs) in addition to programmatic status checks. +No release may be called stable until this evidence exists. + +## Files and interfaces expected to change + +- `build/build.ps1`, `build/CodexControlOtherDevices.iss`, payload and + destination inventory generation; +- `Activate-CcodRemoteFix.ps1` and a private + `src/persistence/modules/InstallFileTransaction.psm1`; +- `src/persistence/modules/InstallLifecycle.psm1` and its transaction/state + tests, plus `src/persistence/modules/ProductRegistration.psm1` for the + post-Ready current-user uninstall and shortcut migration; +- `Install-CodexRemote-fix.ps1`, `tools/Test-ReleaseDefender.ps1`, CI/release + workflows, release contract tests, and installed lifecycle acceptance tests; +- concise English and Chinese README/CHANGELOG text that only describes + verified behavior. + +The implementation plan will map these changes into reviewable TDD tasks with +separate source, workflow, and installed-machine acceptance gates. + +## Explicit non-release rule + +All local `build/dist` directories produced before this transaction design are +historical evidence only. They must not be uploaded, tagged, installed, or used +for acceptance. A clean candidate is built only from the final reviewed commit. diff --git a/docs/superpowers/specs/2026-08-29-v2522-immutable-generation-design.md b/docs/superpowers/specs/2026-08-29-v2522-immutable-generation-design.md new file mode 100644 index 0000000..913db52 --- /dev/null +++ b/docs/superpowers/specs/2026-08-29-v2522-immutable-generation-design.md @@ -0,0 +1,242 @@ +# v2.5.22 Immutable Generation Install Design + +## Status + +The user confirmed this revised model on 2026-08-29. It replaces the +unreleased v2.5.22 transaction design's attempted persistent DACL/recursive +deletion boundary. No v2.5.22 tag or public release exists. + +## Goal + +Make fresh installation, legacy upgrade, same-version repair, and reboot +recovery converge on a complete, manifest-bound current-user runtime without +ever activating a partially copied generation. A failure retains the last +known-good generation and leaves the new generation inactive and diagnosable. + +## Security model + +The product remains a current-user utility. It defends against accidental and +ordinary pathname races by using unique create-only generations, strict +manifest/hash/version checks, and atomic pointer commits. It does not claim to +defeat a same-user process that already has unrestricted write/DACL authority; +that guarantee would require an elevated or signed broker, which is outside +this product's current-user scope. PowerShell module scope and CLR reflection +are also not process-isolation boundaries: code already executing inside the +same PowerShell process may inspect private module state. The enforced contract +is the exported API and sealed generation/pointer rules, not protection against +arbitrary in-process introspection. + +This design never modifies `ChatGPT.exe`, `app.asar`, `WindowsApps`, account +state, or the current-user DPAPI device-key store. It does not add a remote +control channel or change the authenticated TrayHost wire contract. + +## Invariants + +- Every generation directory and file name is unique and create-only. No + existing runtime or stable shell is overwritten during staging. +- A new generation becomes eligible only after every manifest-listed file is + copied, flushed, and rehashed, and its runtime manifest/version/commit match. +- A retained generation may be opened only read-only after its existing + manifest, runtime ID, and recorded generation identity are revalidated. It + can be selected by a higher compensating pointer but cannot receive new + files. +- The only mutable install control record is a small atomic active-generation + pointer. It includes a monotonically increasing generation number and the + previous generation ID. +- Before pointer commit, the old pointer and stable bootstrap remain untouched. + After pointer commit, any failure appends a higher compensating pointer to the + retained old generation; it never decrements or rewrites a generation. +- Installation never recursively deletes a nonempty runtime or package tree in + its hot path. Old and failed generations are retained under an owned retired + namespace until a separately verified maintenance/uninstall operation. +- `Ready` requires the active pointer, generation manifest, Supervisor, + authenticated TrayHost, expected version, and activation receipt to agree. +- Setup and portable inputs are Defender-scanned before activation; public + promotion requires exact asset read-back, two actual downloaded-asset scan + receipts, complete old-version upgrade/fresh-install/reboot evidence, and the + named tray/remote manual evidence. + +## Architecture + +### 1. Immutable package and generation layout + +Build creates a single manifest-bound Setup package containing the complete +runtime and stable-shell candidates. The package and its manifest are extracted +to a private Setup temporary directory and validated before any install-state +write. + +The bootstrap asks the file layer to create a unique generation such as +`runtime\` and copies only manifest-listed files there. The runtime +manifest is written as the final generation file, then validated through the +same create-only path. Existing generations are never used as a source root. + +Stable `bootstrap.ps1` and `Uninstall-CodexControlOtherDevices.ps1` are +generated as new candidates inside the generation. After pointer commit, the +scheduled task may select that sealed generation bootstrap solely to prove +protection readiness; user-visible registration and shortcuts are updated only +after `Ready`. An upgrade never overwrites a root stable file while the new +generation is incomplete. + +Every new runtime ID is the canonical `projectVersion-fileDigest16-nonce32` +form: `fileDigest16` is derived from the sorted manifest file records, and the +lowercase 32-hex nonce makes otherwise identical attempts unique. The runtime +manifest and bootstrap independently recompute and require that form. The +stable generation bootstrap reads the append-only active-generation chain +before it launches Supervisor; it may read legacy `active.json` only when the +new chain is absent. Lifecycle readiness likewise accepts the exact +generation bootstrap selected by the scheduled task, with the fixed root +bootstrap retained only for a proven legacy task. + +Absence of the append-only selector is a positive lookup result. Legacy +fallback is permitted only when `state` itself has an exact ItemNotFound +outcome, or when `state` is a proven plain nonreparse directory and its +`active-generation` child has an exact ItemNotFound outcome. A file, reparse +point, access or I/O failure, malformed store, or unsupported record at either +boundary fails the component's bounded authorization contract instead of +consulting `active.json`. StaticProbe carries the lookup through an exact +discriminated `Found`/`Missing` result; null, empty, malformed, or exceptional +adapter output cannot represent absence. + +`UninstallBootstrap` resolves and proves the `state` ancestor before it ever +resolves `state\active-generation`. Exact absence of `state` permits the legacy +pointer lookup, while a proven plain `state` directory is required before the +child lookup; this compatibility fallback does not suppress the later +lifecycle-epoch and cleanup validation boundaries. + +### 1a. Immutable initialization evidence and operational state + +The install transaction writes create-only, generation-bound initialization +evidence for settings, status, verified packages, transition state, and UI +preference below `state\install-initializations\`. Those records +prove the exact defaults that were accepted before activation and are never +rewritten. + +The existing `state\*.json` and `state\ui-preferences.json` files are a +separate operational state plane. They are materialized from the proven +initialization evidence only when absent, remain subject to the existing +contained-path/reparse/ADS/single-link checks, and are changed only by the +running Supervisor after `Ready`. An upgrade never overwrites a partial or +existing operational state file. This separation is necessary because sealing +the mutable runtime state files read-only would prevent normal tray settings, +status, and recovery updates after installation. + +### 2. Small pinned file layer without persistent DACL mutation + +`InstallFileTransaction.psm1` keeps only opaque transaction capabilities and +native handles needed to create/read/flush/hash a unique generation. It exposes +no arbitrary native helper, raw stream, or caller-mutable seal state. + +Required operations are create-only: + +```powershell +Open-CcodInstallGeneration -InstallRoot -RuntimeId +Open-CcodInstallStateTransaction -InstallRoot +Open-CcodInstallRetainedGeneration -InstallRoot -RuntimeId -ExpectedManifestSha256 +New-CcodInstallDirectory -Transaction -Parent -Leaf [-CreateIfMissing] +New-CcodInstallGenerationLeaf -Generation -Leaf +Copy-CcodInstallSealedSource -Generation -SourcePath -Leaf -ExpectedLength -ExpectedSha256 +Write-CcodInstallGenerationManifest -Generation -Manifest +Write-CcodInstallRecord -Transaction -Parent -Leaf -Record +Commit-CcodInstallActivePointer -InstallRoot -ExpectedPreviousGeneration -TargetGeneration -FileTransaction +Retire-CcodInstallGeneration -InstallRoot -RuntimeId -FileTransaction +Close-CcodInstallFileTransaction -Transaction -Disposition Ready|Failed +``` + +`Open-CcodInstallStateTransaction` is an opaque state-only capability for a +bounded recovery record after an interrupted terminalization. It can compose +only the install-root `state` directories and create-only records; it cannot +create a runtime generation, copy a payload, write a manifest, open a retained +generation, or commit a pointer. A recovery uses it only after revalidating the +already committed pointer, generation manifest, Ready receipt, and transaction +identity. Its V4 ABI compatibility proof runs in a fresh child process that +loads the V3 marker first and exercises only the exported capability surface. + +`Retire-CcodInstallGeneration` removes a generation from the active selection by +one create-only retired-generation record; it does not rename or recursively +delete the nonempty directory. Physical reclamation is a later operation that +requires no running owner and a complete safe-tree proof. This avoids a +partial-delete rollback claim and avoids persistent DACL changes. + +### 3. Append-only phase and compensation record + +The bootstrap writes a bounded install transaction record under `state` with +the transaction ID, old/new runtime IDs, old/new generation, package hash, +phase, and stable error code. The phases are: + +```text +Prepared -> PackageVerified -> RuntimeStaged -> PreviousProtectionStopped + -> RuntimePromoted -> PointerCommitted -> StableShellCommitted + -> ProtectionReady -> Ready +``` + +`Failed` is the only terminal error phase and carries one canonical `CCOD_*` +code. All records are atomically written through the file layer. A failure +before `PointerCommitted` leaves the old pointer and bootstrap untouched. A +failure from `PointerCommitted` onward writes a higher compensating pointer to +the retained old generation and proves its Supervisor/TrayHost; if proof fails, +it retains both generations, writes `Failed/CCOD_INSTALL_ROLLBACK_FAILED`, and +does not claim readiness. + +Legacy installations without this record are treated as `Idle` after the +existing pointer/runtime identity checks. Same-version repair is idempotent +only when the package hash matches; a different hash returns a conflict before +pointer mutation. + +The transaction records both the old and new manifest SHA-256 values before +pointer mutation. Compensation opens the old generation only against its +recorded old-manifest hash; it never treats a freshly read old manifest hash as +proof. The install entrypoint first rejects any global nonterminal or +ambiguous transaction chain, whether or not a legacy pointer is present. + +`Ready` terminalization never appends a `Failed` record after a visible Ready +activation receipt. If the final transaction snapshot cannot be persisted, the +transaction remains recoverable/nonterminal and a later recovery may finish +the missing Ready snapshot; callers do not claim readiness until both matching +terminal records exist. + +### 4. Setup and product registration + +The Inno shell uses `CreateAppDir=no` and `Uninstallable=no` and has no +pre-Ready `[Files]`, `[Icons]`, `[Registry]`, or `[UninstallRun]` product +writes. It extracts only the package, manifest, and activation bootstrap to its +private temporary area, locks and validates them, and invokes the bootstrap. +The bootstrap performs the generation transaction and Setup waits for strict +`Ready` validation. + +After `Ready`, a product-registration module writes and reads back the exact +current-user uninstall registration and the two exact `CodexRemote-fix` +Start-menu/desktop shortcut records. It then removes only exact legacy +registration/shortcut entries. A failed migration leaves the legacy entries. + +### 5. Defender, draft, and final acceptance + +Portable scans its verified payload before installation. Setup scans its +verified private package before `Prepared`. The release contract has exactly +these 11 public assets for v2.5.22: + +```text +CodexRemote-fix-2.5.22-windows-x64.zip +CodexRemote-fix-2.5.22-windows-x64.zip.sha256.txt +CodexRemote-fix-2.5.22-trayhost-provenance.json +CodexRemote-fix-2.5.22-payload-manifest.json +CodexRemote-fix-2.5.22-release-manifest.json +CodexRemote-fix-2.5.22-setup.exe +CodexRemote-fix-2.5.22-setup.exe.sha256.txt +CodexRemote-fix-2.5.22-setup-provenance.json +CodexRemote-fix-2.5.22-setup-payload-manifest.json +CodexRemote-fix-2.5.22-setup-destination-inventory.iss +CodexRemote-fix-2.5.22-setup-release-manifest.json +``` + +The destination inventory name above is checked as part of the release contract. +Defender receipts are evidence files, not public release assets. The local +official-draft preflight downloads the Setup and ZIP +through a browser, requires actual ZoneId 3, scans each with the real Defender +entry, and records receipts bound to both asset hashes and the matching commit. + +The final current-machine acceptance must prove: manifest-bound v2.5.21 Setup +upgrade; complete uninstall while preserving the DPAPI key hash; fresh v2.5.22 +install; reboot auto-recovery; exactly one Supervisor and authenticated +TrayHost; and individual About, Language, OpenLogs, Repair, and second-device +remote-control evidence. The README remains candidate-oriented until those +facts are recorded; release notes may summarize only recorded facts. diff --git a/package.json b/package.json index 6edd7f3..cc8df14 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codexremote-fix", - "version": "2.5.21", + "version": "2.5.22", "private": true, "description": "CodexRemote-fix persistent current-user tray supervisor for Windows", "license": "MIT", diff --git a/src/persistence/InstalledUninstallFinalizer.ps1 b/src/persistence/InstalledUninstallFinalizer.ps1 new file mode 100644 index 0000000..ccae596 --- /dev/null +++ b/src/persistence/InstalledUninstallFinalizer.ps1 @@ -0,0 +1,304 @@ +[CmdletBinding(DefaultParameterSetName='Initial')] +param( + [ValidatePattern('^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$')][string]$TransactionId, + [string]$RuntimeRoot, + [string]$InstallRoot, + [Parameter(ParameterSetName='Initial')][Parameter(ParameterSetName='WrapperResume')][int]$WrapperProcessId, + [Parameter(ParameterSetName='Initial')][Parameter(ParameterSetName='WrapperResume')][string]$WrapperCreationTimeUtc, + [Parameter(ParameterSetName='WrapperResume')][switch]$WrapperResume, + [Parameter(ParameterSetName='Resume')][switch]$Resume, + [Parameter(ParameterSetName='Initial')][Parameter(ParameterSetName='WrapperResume')][string]$AuthorityReadyHandle +) + +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' +$script:CcodInstalledFinalizerPayloadEntries=@('src/persistence/UninstallBootstrap.ps1','src/persistence/PortableUninstallFinalizer.ps1','src/persistence/InstalledUninstallFinalizer.ps1','src/persistence/modules/GenerationReclamation.psm1','src/persistence/modules/InstallLifecycle.psm1','src/persistence/modules/ProductRegistration.psm1','src/persistence/modules/PortableRelease.psm1','src/persistence/modules/PersistenceIO.psm1','src/persistence/modules/RuntimeManifest.psm1','src/persistence/modules/LifecycleEpoch.psm1','src/persistence/modules/StateStore.psm1','src/persistence/modules/TrustedLogonIdentity.psm1','src/persistence/modules/ScheduledTask.psm1','src/persistence/modules/KernelObjects.psm1','src/persistence/modules/CompatibilityProbe.psm1','src/persistence/modules/UiPreferences.psm1','src/persistence/modules/LifecycleTransaction.psm1') + +function Initialize-CcodUninstallPayloadAuthority { + if ($null -ne ('CcodUninstallPayloadAuthorityV1' -as [type])) { return } + Add-Type -ErrorAction Stop -TypeDefinition @' +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Win32.SafeHandles; +public sealed class CcodUninstallPayloadAuthorityV1 : IDisposable +{ + [StructLayout(LayoutKind.Sequential)] private struct Info + { + public uint Attributes; + public System.Runtime.InteropServices.ComTypes.FILETIME Creation, Access, Write; + public uint Volume, SizeHigh, SizeLow, Links, IndexHigh, IndexLow; + } + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] + private static extern SafeFileHandle CreateFileW(string path,uint access,uint share,IntPtr security,uint creation,uint flags,IntPtr template); + [DllImport("kernel32.dll", SetLastError=true)] + private static extern bool GetFileInformationByHandle(SafeFileHandle handle,out Info info); + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] + private static extern uint GetFinalPathNameByHandleW(SafeFileHandle handle,StringBuilder value,uint length,uint flags); + private readonly List handles = new List(); + private readonly HashSet paths = new HashSet(StringComparer.OrdinalIgnoreCase); + private bool disposed; + public void Add(string path) + { + if(disposed)throw new ObjectDisposedException("uninstall payload authority"); + if(String.IsNullOrWhiteSpace(path)||!Path.IsPathRooted(path)||path.StartsWith(@"\\?\")||path.StartsWith(@"\\.\"))throw new IOException("Payload path"); + string full=Path.GetFullPath(path),root=Path.GetPathRoot(full); + if(!String.Equals(full,path,StringComparison.OrdinalIgnoreCase)||full.Substring(root.Length).IndexOf(':')>=0)throw new IOException("Payload canonical path"); + var parents=new List(); + for(DirectoryInfo parent=Directory.GetParent(full);parent!=null;parent=parent.Parent)parents.Add(parent.FullName); + parents.Reverse(); + foreach(string parent in parents)Open(parent,true); + Open(full,false); + } + private void Open(string path,bool directory) + { + if(paths.Contains(path))return; + uint access=directory?0x00100081U:0x80100080U; + uint flags=0x00200000U|(directory?0x02000000U:0U); + SafeFileHandle handle=CreateFileW(path,access,1U,IntPtr.Zero,3U,flags,IntPtr.Zero); + if(handle.IsInvalid){int error=Marshal.GetLastWin32Error();handle.Dispose();throw new Win32Exception(error);} + try{ + Info info;if(!GetFileInformationByHandle(handle,out info))throw new Win32Exception(Marshal.GetLastWin32Error()); + if((info.Attributes&0x400U)!=0||((info.Attributes&0x10U)!=0)!=directory||(!directory&&info.Links!=1))throw new IOException("Payload file kind"); + var value=new StringBuilder(512);uint length=GetFinalPathNameByHandleW(handle,value,(uint)value.Capacity,0); + if(length>=value.Capacity){value.Capacity=checked((int)length+1);length=GetFinalPathNameByHandleW(handle,value,(uint)value.Capacity,0);} + if(length==0||length>=value.Capacity)throw new IOException("Payload final path unavailable"); + string final=value.ToString(); + if(final.StartsWith(@"\\?\UNC\",StringComparison.OrdinalIgnoreCase))final=@"\\"+final.Substring(8); + else if(final.StartsWith(@"\\?\",StringComparison.OrdinalIgnoreCase))final=final.Substring(4); + if(!String.Equals(final.TrimEnd('\\'),path.TrimEnd('\\'),StringComparison.OrdinalIgnoreCase))throw new IOException("Payload final path mismatch"); + handles.Add(handle);paths.Add(path); + }catch{handle.Dispose();throw;} + } + public void Dispose() + { + if(disposed)return;disposed=true; + for(int index=handles.Count-1;index>=0;index--)handles[index].Dispose(); + handles.Clear();paths.Clear(); + } +} +'@ +} + +function Throw-CcodInstalledFinalizerError { + param([string]$Id,[string]$Message,$Target) + throw [Management.Automation.ErrorRecord]::new([InvalidOperationException]::new($Message),$Id,[Management.Automation.ErrorCategory]::InvalidData,$Target) +} +function Test-CcodInstalledFinalizerIdentity($Value){$Value.pid-is[int]-and$Value.pid-gt0-and$Value.creationTimeUtc-is[string]-and$Value.creationTimeUtc-cmatch'^\d{4}-\d{2}-\d{2}T'-and$Value.sessionId-is[int]-and$Value.sessionId-ge0-and$Value.userSid-is[string]-and$Value.userSid-cmatch'^S-1-'} +function Get-CcodInstalledFinalizerLocalAppData { + $value=[Environment]::GetEnvironmentVariable('LOCALAPPDATA','Process') + if(-not[string]::IsNullOrWhiteSpace($value)){ + try{if([IO.Path]::IsPathRooted($value)){return [IO.Path]::GetFullPath($value).TrimEnd('\')}}catch{} + Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Process LOCALAPPDATA is not a canonical absolute path' $value + } + $value=[Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData) + if([string]::IsNullOrWhiteSpace($value)-or-not[IO.Path]::IsPathRooted($value)){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Current-user local application data is unavailable' $value} + [IO.Path]::GetFullPath($value).TrimEnd('\') +} +function Test-CcodInstalledFinalizerStagedPayload { + param($Transaction,[string]$PayloadRoot) + try{ + $root=[IO.Path]::GetFullPath($PayloadRoot).TrimEnd('\');$rootItem=Get-Item -LiteralPath $root -Force -ErrorAction Stop;if(-not$rootItem.PSIsContainer-or($rootItem.Attributes-band[IO.FileAttributes]::ReparsePoint)-ne0){return $false} + $records=@($Transaction.installedBinding.payloadRecords);if($records.Count-ne$script:CcodInstalledFinalizerPayloadEntries.Count){return $false} + for($index=0;$index-lt$records.Count;$index++){ + $record=$records[$index];$relative=$script:CcodInstalledFinalizerPayloadEntries[$index];$integerTypes=@([byte],[uint16],[uint32],[uint64],[int16],[int32],[int64]);if($record.path-cne$relative-or$integerTypes-cnotcontains$record.length.GetType()-or[decimal]$record.length-lt0-or$record.sha256-isnot[string]-or$record.sha256-cnotmatch'^[0-9a-f]{64}$'){return $false} + $path=[IO.Path]::GetFullPath((Join-Path $root $relative.Replace('/','\')));if(-not$path.StartsWith($root+'\',[StringComparison]::OrdinalIgnoreCase)){return $false} + $cursor=$root;foreach($segment in $relative.Replace('/','\').Split('\')){$cursor=Join-Path $cursor $segment;$item=Get-Item -LiteralPath $cursor -Force -ErrorAction Stop;if(($item.Attributes-band[IO.FileAttributes]::ReparsePoint)-ne0){return $false}} + $file=Get-Item -LiteralPath $path -Force -ErrorAction Stop;if($file.PSIsContainer-or$file-isnot[IO.FileInfo]-or[int64]$file.Length-ne[int64]$record.length){return $false};$streams=@(Get-Item -LiteralPath $path -Stream * -ErrorAction Stop);if($streams.Count-ne1-or[string]$streams[0].Stream-cne':$DATA'){return $false} + $hash=(Get-FileHash -LiteralPath $path -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant();if($hash-cne[string]$record.sha256){return $false} + } + $self=@($records|Where-Object{$_.path-ceq'src/persistence/InstalledUninstallFinalizer.ps1'});return $self.Count-eq1-and[uint64]$self[0].length-eq[uint64]$Transaction.installedBinding.resumeScriptLength-and$self[0].sha256-ceq$Transaction.installedBinding.resumeScriptSha256 + }catch{return $false} +} +function Test-CcodInstalledFinalizerJsonHasNoDuplicateProperties { + param([string]$Json) + if($null-eq$Json){return $false};$objects=[Collections.Generic.Stack[object]]::new() + for($index=0;$index-lt$Json.Length;$index++){$character=$Json[$index];if($character-eq'{'){$objects.Push([Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal));continue};if($character-eq'}'){if($objects.Count-eq0){return $false};[void]$objects.Pop();continue};if($character-ne'"'){continue};$name=[Text.StringBuilder]::new();$index++;while($index-lt$Json.Length){$character=$Json[$index];if($character-eq'"'){break};if($character-ne[char]92){[void]$name.Append($character);$index++;continue};$index++;if($index-ge$Json.Length){return $false};$escape=$Json[$index];if($escape-eq'u'){if($index+4-ge$Json.Length){return $false};try{[void]$name.Append([char][Convert]::ToInt32($Json.Substring($index+1,4),16))}catch{return $false};$index+=5;continue};switch([string]$escape){'"'{$decoded='"'};'\'{$decoded=[char]92};'/'{$decoded='/'};'b'{$decoded=[char]8};'f'{$decoded=[char]12};'n'{$decoded=[char]10};'r'{$decoded=[char]13};'t'{$decoded=[char]9};default{return $false}};[void]$name.Append($decoded);$index++};if($index-ge$Json.Length){return $false};$next=$index+1;while($next-lt$Json.Length-and[char]::IsWhiteSpace($Json[$next])){$next++};if($next-lt$Json.Length-and$Json[$next]-eq':'){if($objects.Count-eq0-or-not$objects.Peek().Add($name.ToString())){return $false}}};return $objects.Count-eq0 +} +function Read-CcodInstalledFinalizerJson { + param([string]$Path) + try{$item=Get-Item -LiteralPath $Path -Force -ErrorAction Stop;if($item.PSIsContainer-or$item-isnot[IO.FileInfo]-or($item.Attributes-band[IO.FileAttributes]::ReparsePoint)-ne0-or$item.Length-lt1-or$item.Length-gt1048576){throw 'unsafe json'};$streams=@(Get-Item -LiteralPath $Path -Stream * -ErrorAction Stop);if($streams.Count-ne1-or[string]$streams[0].Stream-cne':$DATA'){throw 'json stream'};$json=[IO.File]::ReadAllText($Path,[Text.UTF8Encoding]::new($false,$true));if(-not(Test-CcodInstalledFinalizerJsonHasNoDuplicateProperties $json)){throw 'duplicate json'};$value=$json|ConvertFrom-Json -ErrorAction Stop;if($value-isnot[pscustomobject]){throw 'json object'};$value}catch{Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Installed finalizer envelope is malformed or unsafe' $Path} +} +function Assert-CcodInstalledFinalizerDirectoryAcl { + param([string]$Path,[string]$UserSid,[switch]$RequireProtected) + try{$item=Get-Item -LiteralPath $Path -Force -ErrorAction Stop;$security=$item.GetAccessControl();$owner=$security.GetOwner([Security.Principal.SecurityIdentifier]);if($null-eq$owner-or$owner.Value-cne$UserSid-or($RequireProtected-and-not$security.AreAccessRulesProtected)){throw 'owner/protection'};$allowed=@($UserSid,'S-1-5-18');$seen=[Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal);foreach($rule in @($security.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier]))){if($rule.AccessControlType-ne[Security.AccessControl.AccessControlType]::Allow-or$allowed-cnotcontains$rule.IdentityReference.Value-or(($rule.FileSystemRights-band[Security.AccessControl.FileSystemRights]::FullControl)-ne[Security.AccessControl.FileSystemRights]::FullControl)){throw 'rule'};[void]$seen.Add($rule.IdentityReference.Value)};if($seen.Count-ne2){throw 'principals'}}catch{Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Installed finalizer directory ACL is invalid' $Path} +} +function Read-CcodInstalledFinalizerEnvelope { + param([string]$TransactionRoot,[string]$TransactionId,[string]$PayloadRoot,[string]$RuntimeRoot,[string]$InstallRoot) + $identity=[Security.Principal.WindowsIdentity]::GetCurrent();try{$sid=[string]$identity.User.Value}finally{$identity.Dispose()} + foreach($directory in @($TransactionRoot,(Join-Path $TransactionRoot $TransactionId),$PayloadRoot)){$item=Get-Item -LiteralPath $directory -Force -ErrorAction Stop;if(-not$item.PSIsContainer-or($item.Attributes-band[IO.FileAttributes]::ReparsePoint)-ne0){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Installed finalizer directory is unsafe' $directory}} + Assert-CcodInstalledFinalizerDirectoryAcl -Path $TransactionRoot -UserSid $sid -RequireProtected;Assert-CcodInstalledFinalizerDirectoryAcl -Path (Join-Path $TransactionRoot $TransactionId) -UserSid $sid -RequireProtected;Assert-CcodInstalledFinalizerDirectoryAcl -Path $PayloadRoot -UserSid $sid + $current=Read-CcodInstalledFinalizerJson (Join-Path $TransactionRoot 'current.json');if((@($current.PSObject.Properties.Name)-join'|')-cne'schemaVersion|transactionId'-or$current.schemaVersion-ne1-or$current.transactionId-cne$TransactionId){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Installed finalizer locator is invalid' $current} + $transaction=Read-CcodInstalledFinalizerJson (Join-Path (Join-Path $TransactionRoot $TransactionId) 'transaction.json');$transactionFields=@('schemaVersion','transactionId','runtimeId','runtimeGeneration','leaseEpoch','userSid','sessionId','readyEvidence','installedBinding','phase','resumePhase','startedAtUtc','updatedAtUtc','errorCode');$bindingFields=@('selectedRuntimeRoot','runtimeManifestSha256','wrapperPid','wrapperCreationTimeUtc','wrapperSessionId','wrapperUserSid','resumeWrapperPid','resumeWrapperCreationTimeUtc','resumeWrapperSessionId','resumeWrapperUserSid','resumeScriptPath','resumeScriptLength','resumeScriptSha256','resumeCommand','payloadRecords');$readyFields=@('phase','installRoot','runtimeId','runtimeGeneration','packageSha256','manifestSha256','startMenuSha256','desktopSha256','targetPath','arguments') + try{$install=[IO.Path]::GetFullPath($InstallRoot);$runtime=[IO.Path]::GetFullPath($RuntimeRoot);$resumeScript=[IO.Path]::GetFullPath((Join-Path $PayloadRoot 'src\persistence\InstalledUninstallFinalizer.ps1'));$system=[Environment]::GetFolderPath([Environment+SpecialFolder]::System);$powershell=[IO.Path]::GetFullPath((Join-Path $system 'WindowsPowerShell\v1.0\powershell.exe'));$command='"{0}" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "{1}" -Resume -TransactionId "{2}" -RuntimeRoot "{3}" -InstallRoot "{4}"'-f$powershell,$resumeScript,$TransactionId,$runtime,$install}catch{Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Installed finalizer envelope paths are invalid' $transaction} + if((@($transaction.PSObject.Properties.Name)-join'|')-cne($transactionFields-join'|')-or$transaction.schemaVersion-ne1-or$transaction.transactionId-cne$TransactionId-or$transaction.userSid-cne$sid-or + (@($transaction.readyEvidence.PSObject.Properties.Name)-join'|')-cne($readyFields-join'|')-or$transaction.readyEvidence.installRoot-cne$install-or$transaction.readyEvidence.runtimeId-cne$transaction.runtimeId-or + (@($transaction.installedBinding.PSObject.Properties.Name)-join'|')-cne($bindingFields-join'|')-or$transaction.installedBinding.selectedRuntimeRoot-cne$runtime-or$transaction.installedBinding.resumeScriptPath-cne$resumeScript-or + $transaction.installedBinding.resumeCommand-cne$command-or$transaction.installedBinding.wrapperUserSid-cne$transaction.userSid-or$transaction.installedBinding.wrapperSessionId-ne$transaction.sessionId-or + $transaction.installedBinding.runtimeManifestSha256-cne$transaction.readyEvidence.manifestSha256-or-not(Test-CcodInstalledFinalizerStagedPayload -Transaction $transaction -PayloadRoot $PayloadRoot)){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Installed finalizer envelope is not bound to the staged payload' $transaction} + return $transaction +} +function Get-CcodInstalledFinalizerAdapters { + param([hashtable]$Adapters,[string]$TransactionRoot,[string]$PayloadRoot,[string]$RuntimeRoot,[string]$InstallRoot) + $bootstrap=Join-Path $PayloadRoot 'src\persistence\UninstallBootstrap.ps1' + $productModule=Join-Path $PayloadRoot 'src\persistence\modules\ProductRegistration.psm1' + $kernelModule=Join-Path $PayloadRoot 'src\persistence\modules\KernelObjects.psm1' + $finalizerRuntimeRoot=$RuntimeRoot;$finalizerInstallRoot=$InstallRoot;$stagedBootstrapPath=$bootstrap + $defaults=@{ + GetCurrentIdentity={. $bootstrap;Get-CcodUninstallBootstrapCurrentIdentity}.GetNewClosure() + EnterAccountTransition={param($UserSid)Import-Module $kernelModule -Force -DisableNameChecking -ErrorAction Stop;$lease=Enter-CcodMutex -Kind AccountTransition -UserSid $UserSid -TimeoutMilliseconds 30000;if($null-eq$lease-or$lease.Outcome-cne'Acquired'-or$lease.Kind-cne'AccountTransition'){throw 'account transition unavailable'};$lease}.GetNewClosure() + ExitAccountTransition={param($Lease)Import-Module $kernelModule -Force -DisableNameChecking -ErrorAction Stop;[void](Exit-CcodMutex -Lease $Lease)}.GetNewClosure() + EnterTransactionLock={param($UserSid);. $bootstrap;Enter-CcodUninstallBootstrapTransactionLock -UserSid $UserSid}.GetNewClosure() + ExitTransactionLock={param($Lock);. $bootstrap;Exit-CcodUninstallBootstrapTransactionLock -Lock $Lock}.GetNewClosure() + WaitWrapperExit={ + param($Identity,$Timeout) + $observeProcess={ + param([int]$ProcessId) + try { + $observed=@(Get-Process -Id $ProcessId -ErrorAction Stop) + } catch { + if($_.CategoryInfo.Category-eq[Management.Automation.ErrorCategory]::ObjectNotFound-and + ([string]$_.FullyQualifiedErrorId-split',')[0]-ceq'NoProcessFoundForGivenId'){return $null} + throw + } + if($observed.Count-ne1-or$observed[0]-isnot[Diagnostics.Process]){throw 'wrapper process observation is unavailable'} + return $observed[0] + } + $process=&$observeProcess $Identity.pid + if($null-eq$process){return [pscustomobject]@{verifiedAtStart=$true;exited=$true}} + try { + if($process.StartTime.ToUniversalTime().ToString('o',[Globalization.CultureInfo]::InvariantCulture)-cne$Identity.creationTimeUtc-or[int]$process.SessionId-ne[int]$Identity.sessionId){return [pscustomobject]@{verifiedAtStart=$true;exited=$true}} + } finally {$process.Dispose()} + $owner=$null + try {$owner=Get-CimInstance Win32_Process -Filter ('ProcessId='+$Identity.pid) -ErrorAction Stop|Invoke-CimMethod -MethodName GetOwnerSid -ErrorAction Stop} catch {} + if($null-eq$owner-or[int]$owner.ReturnValue-ne0-or[string]$owner.Sid-cne[string]$Identity.userSid){return [pscustomobject]@{verifiedAtStart=$false;exited=$false}} + $stopwatch=[Diagnostics.Stopwatch]::StartNew() + while($stopwatch.ElapsedMilliseconds-lt$Timeout){ + $current=&$observeProcess $Identity.pid + if($null-eq$current){return [pscustomobject]@{verifiedAtStart=$true;exited=$true}} + try { + if($current.StartTime.ToUniversalTime().ToString('o',[Globalization.CultureInfo]::InvariantCulture)-cne$Identity.creationTimeUtc){return [pscustomobject]@{verifiedAtStart=$true;exited=$true}} + } finally {$current.Dispose()} + Start-Sleep -Milliseconds 100 + } + [pscustomobject]@{verifiedAtStart=$true;exited=$false} + } + ReadPreparedTransaction={param($Id,$ExpectedInstallRoot);. $bootstrap;$identity=Get-CcodUninstallBootstrapCurrentIdentity;$value=Read-CcodUninstallBootstrapStoredTransaction -TransactionRoot $TransactionRoot -ExpectedUserSid $identity.userSid -IncludeCompleted -ExpectedInstallRoot $ExpectedInstallRoot;if($null-eq$value-or$value.transactionId-cne$Id){return $null};$value}.GetNewClosure() + ValidateStagedPayload={param($Transaction,$Root)Test-CcodInstalledFinalizerStagedPayload -Transaction $Transaction -PayloadRoot $Root} + ValidateSelectedGeneration={param($SelectedRuntime,$Root,$Transaction);. $bootstrap;$context=Get-CcodUninstallBootstrapVerifiedRuntimeContext -InstallerRoot $SelectedRuntime -InstallRoot $Root -InvocationPath (Join-Path $SelectedRuntime 'src\persistence\UninstallBootstrap.ps1');$manifest=Get-CcodUninstallBootstrapFileFingerprint -Path (Join-Path $SelectedRuntime 'manifest.json');$context.runtimeId-ceq$Transaction.runtimeId-and[uint64]$context.runtimeGeneration-eq[uint64]$Transaction.runtimeGeneration-and$manifest.sha256-ceq$Transaction.installedBinding.runtimeManifestSha256}.GetNewClosure() + ReadCurrentEpoch={param($Root);$epochPath=Join-Path $Root 'state\lifecycle-epoch.json';try{Get-Item -LiteralPath $epochPath -Force -ErrorAction Stop|Out-Null}catch [Management.Automation.ItemNotFoundException]{return $null};. $bootstrap;$record=Read-CcodUninstallBootstrapJson -Path $epochPath -Kind 'Lifecycle epoch';[uint64]$record.epoch}.GetNewClosure() + GetSelectedRootState={param($Root)try{$item=Get-Item -LiteralPath $Root -Force -ErrorAction Stop;if($item.PSIsContainer-and($item.Attributes-band[IO.FileAttributes]::ReparsePoint)-eq0){'Present'}else{'Invalid'}}catch [Management.Automation.ItemNotFoundException]{'Absent'}catch{throw}} + InstallResumeProductRegistration={param($Transaction)Import-Module $productModule -Force -DisableNameChecking -ErrorAction Stop;Set-CcodInstalledUninstallResumeRegistration -Transaction $Transaction|Out-Null}.GetNewClosure() + GetResumeProductRegistrationState={param($Transaction)Import-Module $productModule -Force -DisableNameChecking -ErrorAction Stop;Get-CcodInstalledUninstallResumeRegistrationState -Transaction $Transaction}.GetNewClosure() + ReclaimSelectedGeneration={param($SelectedRuntime,$Transaction);$expected=[IO.Path]::GetFullPath([string]$Transaction.installedBinding.selectedRuntimeRoot);if([IO.Path]::GetFullPath($SelectedRuntime)-cne$expected){throw 'selected root changed'};$modulePath=Join-Path $PayloadRoot 'src\persistence\modules\GenerationReclamation.psm1';if(-not[IO.File]::Exists($modulePath)){throw 'generation reclamation module missing'};Import-Module $modulePath -Force -DisableNameChecking -ErrorAction Stop;$reclaimed=Remove-CcodVerifiedGenerationTree -InstallRoot $Transaction.readyEvidence.installRoot -RuntimeRoot $expected -RuntimeId $Transaction.runtimeId -ExpectedManifestSha256 $Transaction.installedBinding.runtimeManifestSha256;if($null-eq$reclaimed-or$reclaimed.phase-cne'Completed'-or$reclaimed.result-cne'Reclaimed'-or$reclaimed.runtimeId-cne$Transaction.runtimeId){throw 'generation reclamation proof invalid'};$reclaimed}.GetNewClosure() + WriteReadyForInno={param($Transaction);. $bootstrap;$Transaction.phase='ReadyForInno';$Transaction.resumePhase='ReadyForInno';$Transaction.updatedAtUtc=[DateTime]::UtcNow.ToString('o',[Globalization.CultureInfo]::InvariantCulture);$Transaction.errorCode=$null;Write-CcodUninstallBootstrapStoredTransaction -TransactionDirectory (Join-Path $TransactionRoot $Transaction.transactionId) -Transaction $Transaction;$Transaction}.GetNewClosure() + RemoveMatchedProductShortcuts={param($Transaction)Import-Module $productModule -Force -DisableNameChecking -ErrorAction Stop;Remove-CcodInstalledUninstallProductShortcuts -Transaction $Transaction|Out-Null}.GetNewClosure() + CleanupProductResidue={param($Transaction);Import-Module (Join-Path $PayloadRoot 'src\persistence\modules\GenerationReclamation.psm1') -Force -DisableNameChecking -ErrorAction Stop;Remove-CcodVerifiedProductResidue -InstallRoot $finalizerInstallRoot -SelectedRuntimeId $Transaction.runtimeId -ExpectedEpoch ([uint64]$Transaction.leaseEpoch) -TransactionDirectory (Join-Path $TransactionRoot $Transaction.transactionId) -TransactionId $Transaction.transactionId}.GetNewClosure() + FinalizeReceipt={param($Transaction);$installForReceipt=$finalizerInstallRoot;$bootstrapForReceipt=$stagedBootstrapPath;. $bootstrapForReceipt;$validation={param($Root,$Value,$Identity)Assert-CcodUninstallBootstrapFinalizationInvocation -TransactionRoot $Root -Transaction $Value -Identity $Identity -InvocationPath $bootstrapForReceipt};$absence={param($Root)try{Get-Item -LiteralPath $Root -Force -ErrorAction Stop|Out-Null;return $false}catch [Management.Automation.ItemNotFoundException]{return $true}catch{throw}};Invoke-CcodUninstallBootstrap -InstallerRoot $installForReceipt -InstallRoot $installForReceipt -Mode FinalizeReceipt -Adapters @{ValidateFinalizationInvocation=$validation;TestInstallRootAbsent=$absence}}.GetNewClosure() + TestCompletedReceipt={param($Transaction);. $bootstrap;$identity=Get-CcodUninstallBootstrapCurrentIdentity;Test-CcodUninstallBootstrapStoredCompletedReceipt -TransactionRoot $TransactionRoot -Transaction $Transaction -ExpectedUserSid $identity.userSid}.GetNewClosure() + RemoveResumeProductRegistration={param($Transaction)Import-Module $productModule -Force -DisableNameChecking -ErrorAction Stop;Remove-CcodInstalledUninstallResumeRegistration -Transaction $Transaction -CompletedReceiptProven}.GetNewClosure() + } + if($null-eq$Adapters){return $defaults};$resolved=@{};foreach($name in $defaults.Keys){$resolved[$name]=$defaults[$name]};foreach($name in $Adapters.Keys){if(-not$resolved.ContainsKey($name)-or$Adapters[$name]-isnot[scriptblock]){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Finalizer adapter contract is invalid' $name};$resolved[$name]=$Adapters[$name]};$resolved +} +function Assert-CcodInstalledFinalizerTransactionBinding { + param($Transaction,[string]$TransactionId,[string]$RuntimeRoot,[string]$InstallRoot,$CallerIdentity,$WrapperIdentity,[bool]$IsResume,[bool]$IsWrapperResume,[string]$PayloadRoot) + $binding=if($null-ne$Transaction){$Transaction.installedBinding}else{$null};$effectivePhase=if($null-ne$Transaction-and$Transaction.phase-ceq'Failed'){$Transaction.resumePhase}elseif($null-ne$Transaction){$Transaction.phase}else{$null} + try{$expectedRuntime=[IO.Path]::GetFullPath((Join-Path (Join-Path $InstallRoot 'runtime') ([string]$Transaction.runtimeId)));$expectedScript=[IO.Path]::GetFullPath((Join-Path $PayloadRoot 'src\persistence\InstalledUninstallFinalizer.ps1'));$system=[Environment]::GetFolderPath([Environment+SpecialFolder]::System);$powershell=[IO.Path]::GetFullPath((Join-Path $system 'WindowsPowerShell\v1.0\powershell.exe'));$expectedCommand='"{0}" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "{1}" -Resume -TransactionId "{2}" -RuntimeRoot "{3}" -InstallRoot "{4}"'-f$powershell,$expectedScript,$TransactionId,$expectedRuntime,$InstallRoot}catch{Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Durable installed uninstall paths are invalid' $Transaction} + if($null-eq$Transaction-or$Transaction.transactionId-cne$TransactionId-or$Transaction.userSid-isnot[string]-or$Transaction.userSid-cne$CallerIdentity.userSid-or + $effectivePhase-notin@('TaskRemoved','ReadyForInno','Completed')-or$null-eq$binding-or$binding.wrapperUserSid-cne$Transaction.userSid-or[int]$binding.wrapperSessionId-ne[int]$Transaction.sessionId-or + [IO.Path]::GetFullPath([string]$binding.selectedRuntimeRoot)-cne$expectedRuntime-or$RuntimeRoot-cne$expectedRuntime-or[IO.Path]::GetFullPath([string]$binding.resumeScriptPath)-cne$expectedScript-or$binding.resumeCommand-cne$expectedCommand-or + $binding.runtimeManifestSha256-isnot[string]-or$binding.runtimeManifestSha256-cnotmatch'^[0-9a-f]{64}$'-or$Transaction.readyEvidence.runtimeId-cne$Transaction.runtimeId-or + [uint64]$Transaction.readyEvidence.runtimeGeneration-ne[uint64]$Transaction.runtimeGeneration-or$Transaction.readyEvidence.manifestSha256-cne$binding.runtimeManifestSha256-or + $binding.resumeScriptSha256-isnot[string]-or$binding.resumeScriptSha256-cnotmatch'^[0-9a-f]{64}$'-or[uint64]$binding.resumeScriptLength-lt1-or$null-eq$binding.payloadRecords){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Durable installed uninstall transaction identity is invalid' $Transaction} + $resumeRecords=@($binding.payloadRecords|Where-Object{$_.path-ceq'src/persistence/InstalledUninstallFinalizer.ps1'});if($resumeRecords.Count-ne1-or[uint64]$resumeRecords[0].length-ne[uint64]$binding.resumeScriptLength-or$resumeRecords[0].sha256-cne$binding.resumeScriptSha256){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Durable resume script is not bound to the staged payload set' $binding} + if(-not$IsResume){ + if($null-eq$WrapperIdentity-or-not(Test-CcodInstalledFinalizerIdentity $WrapperIdentity)-or$effectivePhase-cne'TaskRemoved'){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Installed finalizer wrapper binding is invalid' $WrapperIdentity} + if($IsWrapperResume){if([int]$binding.resumeWrapperPid-ne$WrapperIdentity.pid-or[string]$binding.resumeWrapperCreationTimeUtc-cne$WrapperIdentity.creationTimeUtc-or[int]$binding.resumeWrapperSessionId-ne$WrapperIdentity.sessionId-or[string]$binding.resumeWrapperUserSid-cne$WrapperIdentity.userSid){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Replacement wrapper launch binding is invalid' $WrapperIdentity}} + elseif($null-ne$binding.resumeWrapperPid-or[int]$binding.wrapperPid-ne$WrapperIdentity.pid-or[string]$binding.wrapperCreationTimeUtc-cne$WrapperIdentity.creationTimeUtc-or[int]$binding.wrapperSessionId-ne$WrapperIdentity.sessionId-or[string]$binding.wrapperUserSid-cne$WrapperIdentity.userSid){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Initial historical wrapper binding is invalid or was superseded' $WrapperIdentity} + } + return $effectivePhase +} + +function Invoke-CcodInstalledUninstallFinalizer { + param([Parameter(Mandatory)][string]$TransactionId,[Parameter(Mandatory)][string]$RuntimeRoot,[Parameter(Mandatory)][string]$InstallRoot,[AllowNull()]$WrapperIdentity,[switch]$Resume,[switch]$WrapperResume,[hashtable]$Adapters,[string]$AuthorityReadyHandle) + if(($Resume-and$WrapperResume)-or$TransactionId-cnotmatch'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'-or-not[IO.Path]::IsPathRooted($RuntimeRoot)-or-not[IO.Path]::IsPathRooted($InstallRoot)-or(-not$Resume-and-not(Test-CcodInstalledFinalizerIdentity $WrapperIdentity))){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Installed finalizer inputs are invalid' $TransactionId} + $runtime=[IO.Path]::GetFullPath($RuntimeRoot);$install=[IO.Path]::GetFullPath($InstallRoot);$local=Get-CcodInstalledFinalizerLocalAppData;$transactionRoot=[IO.Path]::GetFullPath((Join-Path $local 'CodexRemote-fix-uninstall'));$payloadRoot=[IO.Path]::GetFullPath((Join-Path (Join-Path $transactionRoot $TransactionId) 'payload')) + $payloadAuthority=$null + try { + if($null-eq$Adapters){ + $expectedSelf=[IO.Path]::GetFullPath((Join-Path $payloadRoot 'src\persistence\InstalledUninstallFinalizer.ps1')) + if([string]::IsNullOrWhiteSpace($PSCommandPath)-or[IO.Path]::GetFullPath($PSCommandPath)-cne$expectedSelf){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Installed finalizer is outside the durable staged payload' $PSCommandPath} + # Bootstrap independently: no pathname-loaded dependency may establish + # its own authority. Retain the complete external closure through cleanup. + try { + Initialize-CcodUninstallPayloadAuthority + $payloadAuthority=[CcodUninstallPayloadAuthorityV1]::new() + foreach($relative in $script:CcodInstalledFinalizerPayloadEntries){$payloadAuthority.Add([IO.Path]::GetFullPath((Join-Path $payloadRoot $relative)))} + } catch { Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Staged finalizer payload could not be pinned before validation.' $null } + [void](Read-CcodInstalledFinalizerEnvelope -TransactionRoot $transactionRoot -TransactionId $TransactionId -PayloadRoot $payloadRoot -RuntimeRoot $runtime -InstallRoot $install) + if(-not[string]::IsNullOrEmpty($AuthorityReadyHandle)){ + if($Resume-or$AuthorityReadyHandle-cnotmatch'^[1-9][0-9]{0,18}\z'){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Finalizer authority handle is invalid.' $null} + $readyPipe=[IO.Pipes.AnonymousPipeClientStream]::new([IO.Pipes.PipeDirection]::Out,$AuthorityReadyHandle) + try{$bytes=[BitConverter]::GetBytes([int]$PID);$readyPipe.Write($bytes,0,$bytes.Length);$readyPipe.Flush()}finally{$readyPipe.Dispose()} + } + } + $adapter=Get-CcodInstalledFinalizerAdapters -Adapters $Adapters -TransactionRoot $transactionRoot -PayloadRoot $payloadRoot -RuntimeRoot $runtime -InstallRoot $install;$caller=&$adapter.GetCurrentIdentity + if($null-eq$caller-or$caller.userSid-isnot[string]-or$caller.userSid-cnotmatch'^S-1-'){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Current resume caller identity is invalid' $caller} + $accountLease=$null;$lock=$null + try{ + $lock=&$adapter.EnterTransactionLock $caller.userSid + if($null-eq$lock){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Installed finalizer transaction lock was not acquired' $caller.userSid} + $accountLease=&$adapter.EnterAccountTransition $caller.userSid + if($null-eq$accountLease){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Shared account transition authority was not acquired' $caller.userSid} + $transaction=&$adapter.ReadPreparedTransaction $TransactionId $install + $phase=Assert-CcodInstalledFinalizerTransactionBinding -Transaction $transaction -TransactionId $TransactionId -RuntimeRoot $runtime -InstallRoot $install -CallerIdentity $caller -WrapperIdentity $WrapperIdentity -IsResume ([bool]$Resume) -IsWrapperResume ([bool]$WrapperResume) -PayloadRoot $payloadRoot + if(-not(&$adapter.ValidateStagedPayload $transaction $payloadRoot)){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Durable staged uninstall payload is invalid' $payloadRoot} + $observedEpoch=&$adapter.ReadCurrentEpoch $install + if(($null-eq$observedEpoch-and$phase-ceq'TaskRemoved')-or($null-ne$observedEpoch-and[uint64]$observedEpoch-ne[uint64]$transaction.leaseEpoch)){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Lifecycle epoch no longer matches the uninstall transaction' $install} + + if(-not$Resume){$wrapperExit=&$adapter.WaitWrapperExit $WrapperIdentity 15000;if($null-eq$wrapperExit-or$wrapperExit.verifiedAtStart-isnot[bool]-or-not$wrapperExit.verifiedAtStart-or$wrapperExit.exited-isnot[bool]-or-not$wrapperExit.exited){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Installed wrapper was not verified before its exact exit' $WrapperIdentity}} + $resumeAnchorState=if($Resume){[string](&$adapter.GetResumeProductRegistrationState $transaction)}else{'Invalid'} + + if($phase-ceq'TaskRemoved'){ + $rootState=&$adapter.GetSelectedRootState $runtime + if($rootState-ceq'Present'){ + if($Resume){$latestWrapper=if($null-ne$transaction.installedBinding.resumeWrapperPid){[pscustomobject]@{pid=[int]$transaction.installedBinding.resumeWrapperPid;creationTimeUtc=[string]$transaction.installedBinding.resumeWrapperCreationTimeUtc;sessionId=[int]$transaction.installedBinding.resumeWrapperSessionId;userSid=[string]$transaction.installedBinding.resumeWrapperUserSid}}else{[pscustomobject]@{pid=[int]$transaction.installedBinding.wrapperPid;creationTimeUtc=[string]$transaction.installedBinding.wrapperCreationTimeUtc;sessionId=[int]$transaction.installedBinding.wrapperSessionId;userSid=[string]$transaction.installedBinding.wrapperUserSid}};$resumeWrapperExit=&$adapter.WaitWrapperExit $latestWrapper 15000;if($null-eq$resumeWrapperExit-or-not$resumeWrapperExit.verifiedAtStart-or-not$resumeWrapperExit.exited){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Public resume could not prove the latest runtime wrapper exited' $latestWrapper}} + if([uint64](&$adapter.ReadCurrentEpoch $install)-ne[uint64]$transaction.leaseEpoch){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Lifecycle epoch changed before generation reclamation' $install} + if(-not(&$adapter.ValidateSelectedGeneration $runtime $install $transaction)){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Selected generation manifest does not match the transaction' $runtime} + if($resumeAnchorState-cne'Exact'){&$adapter.InstallResumeProductRegistration $transaction;$resumeAnchorState=[string](&$adapter.GetResumeProductRegistrationState $transaction);if($resumeAnchorState-cne'Exact'){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Durable public resume entry was not committed before reclamation' $TransactionId}} + &$adapter.ReclaimSelectedGeneration $runtime $transaction|Out-Null + if((&$adapter.GetSelectedRootState $runtime)-cne'Absent'){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Selected generation removal was not proven' $runtime} + }elseif($rootState-cne'Absent'-or-not$Resume-or$resumeAnchorState-cne'Exact'){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Selected generation state is not resumable' $runtime} + if($resumeAnchorState-cne'Exact'-or[string](&$adapter.GetResumeProductRegistrationState $transaction)-cne'Exact'){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Generation absence lacks its write-ahead resume authority' $TransactionId} + $transaction=&$adapter.WriteReadyForInno $transaction + if($null-eq$transaction-or$transaction.phase-cne'ReadyForInno'){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'ReadyForInno phase was not durable' $TransactionId} + $phase='ReadyForInno' + } + if($phase-ceq'ReadyForInno'){ + if([string](&$adapter.GetResumeProductRegistrationState $transaction)-cne'Exact'){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Product tail lacks its durable public resume entry' $TransactionId} + $cleanup=&$adapter.CleanupProductResidue $transaction + if($null-eq$cleanup-or$cleanup.phase-cne'Completed'-or$cleanup.result-cne'Removed'){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Bounded product residue cleanup was not proven' $TransactionId} + &$adapter.RemoveMatchedProductShortcuts $transaction + if([string](&$adapter.GetResumeProductRegistrationState $transaction)-cne'Exact'){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Public resume entry disappeared before completion receipt' $TransactionId} + [void](&$adapter.FinalizeReceipt $transaction) + $transaction=&$adapter.ReadPreparedTransaction $TransactionId $install + $phase=Assert-CcodInstalledFinalizerTransactionBinding -Transaction $transaction -TransactionId $TransactionId -RuntimeRoot $runtime -InstallRoot $install -CallerIdentity $caller -WrapperIdentity $WrapperIdentity -IsResume $true -IsWrapperResume $false -PayloadRoot $payloadRoot + } + if($phase-cne'Completed'){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Installed uninstall tail did not reach Completed' $TransactionId} + $cleanup=&$adapter.CleanupProductResidue $transaction + if($null-eq$cleanup-or$cleanup.phase-cne'Completed'-or$cleanup.result-cne'Removed'){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Completed uninstall product root is not absent' $TransactionId} + if(-not(&$adapter.TestCompletedReceipt $transaction)){[void](&$adapter.FinalizeReceipt $transaction);$transaction=&$adapter.ReadPreparedTransaction $TransactionId $install;if(-not(&$adapter.TestCompletedReceipt $transaction)){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Completed receipt is not durable' $TransactionId}} + $completedAnchor=[string](&$adapter.GetResumeProductRegistrationState $transaction);if($completedAnchor-ceq'Exact'){[void](&$adapter.RemoveResumeProductRegistration $transaction)}elseif($completedAnchor-cne'Absent'){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Completed uninstall has an invalid final public anchor' $TransactionId} + return $transaction + }finally{if($null-ne$accountLease){&$adapter.ExitAccountTransition $accountLease};if($null-ne$lock){&$adapter.ExitTransactionLock $lock}} + } finally { if($null-ne$payloadAuthority){$payloadAuthority.Dispose()} } +} + +if($MyInvocation.InvocationName-ne'.'){ + $identity=$null + if(-not$Resume){$currentIdentity=[Security.Principal.WindowsIdentity]::GetCurrent();$process=[Diagnostics.Process]::GetCurrentProcess();try{$identity=[pscustomobject]@{pid=[int]$WrapperProcessId;creationTimeUtc=$WrapperCreationTimeUtc;sessionId=[int]$process.SessionId;userSid=[string]$currentIdentity.User.Value}}finally{$process.Dispose();$currentIdentity.Dispose()}} + try{Invoke-CcodInstalledUninstallFinalizer -TransactionId $TransactionId -RuntimeRoot $RuntimeRoot -InstallRoot $InstallRoot -WrapperIdentity $identity -Resume:$Resume -WrapperResume:$WrapperResume -AuthorityReadyHandle $AuthorityReadyHandle|Out-Null;exit 0}catch{[Console]::Error.WriteLine([string]$_.FullyQualifiedErrorId);exit 3} +} diff --git a/src/persistence/StaticProbeWorker.ps1 b/src/persistence/StaticProbeWorker.ps1 index 47b29ca..16cc097 100644 --- a/src/persistence/StaticProbeWorker.ps1 +++ b/src/persistence/StaticProbeWorker.ps1 @@ -114,6 +114,19 @@ function Test-CcodStaticRuntimeId { return $Value -is [string] -and $Value -cmatch '^[A-Za-z0-9._-]{1,96}$' } +function ConvertTo-CcodStaticRuntimeGeneration { + param([Parameter(Mandatory)]$Value,[Parameter(Mandatory)][string]$Path,[switch]$AllowZero) + $minimum=if($AllowZero){[decimal]0}else{[decimal]1} + $integerTypes=@([byte],[uint16],[uint32],[uint64],[int16],[int32],[int64]) + $typed=$Value -is [decimal] + if(-not$typed){foreach($type in $integerTypes){if($Value -is $type){$typed=$true;break}}} + if(-not$typed){Throw-CcodStaticProbeError 'CCOD_STATIC_RUNTIME_UNAUTHORIZED' 'Active generation is not an unsigned integer' $Path} + try{ + if([decimal]$Value -lt $minimum -or ($Value -is [decimal] -and [decimal]::Truncate($Value)-ne$Value)){throw 'range'} + return [uint64]$Value + }catch{Throw-CcodStaticProbeError 'CCOD_STATIC_RUNTIME_UNAUTHORIZED' 'Active generation is outside UInt64' $Path} +} + function Test-CcodStaticCanonicalSid { param($Value) if ($Value -isnot [string]) { return $false } @@ -248,6 +261,15 @@ function Get-CcodStaticProbePathAdapters { DirectoryExists={param($Path)[IO.Directory]::Exists($Path)} } if($null -ne $Adapters){foreach($key in @('GetItem','FileExists','DirectoryExists')){if($Adapters.ContainsKey($key)){$resolved[$key]=$Adapters[$key]}}} + if($null-ne$Adapters-and$Adapters.ContainsKey('GetSelectorRootResult')){$resolved.GetSelectorRootResult=$Adapters.GetSelectorRootResult} + else{ + $getItem=$resolved.GetItem + $resolved.GetSelectorRootResult={ + param($Path) + try{$item=&$getItem $Path $false}catch [Management.Automation.ItemNotFoundException]{return [pscustomobject][ordered]@{Status='Missing';Item=$null}} + return [pscustomobject][ordered]@{Status='Found';Item=$item} + }.GetNewClosure() + } return $resolved } @@ -421,12 +443,13 @@ function Test-CcodStaticManifestPath { } function Get-CcodStaticRuntimeIdFromRecords { - param([string]$ProjectVersion,[object[]]$Files) + param([string]$ProjectVersion,[object[]]$Files,[string]$ExpectedRuntimeId) $lines=[Collections.Generic.List[string]]::new() - foreach($file in $Files){$lines.Add(('{0}`t{1}`t{2}' -f [string]$file.path,[int64]$file.length,[string]$file.sha256))} + foreach($file in $Files){$lines.Add(("{0}`t{1}`t{2}" -f [string]$file.path,[int64]$file.length,[string]$file.sha256))} $canonical=$lines -join "`n";$sha=[Security.Cryptography.SHA256]::Create() try{$digest=[BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($canonical))).Replace('-','').ToLowerInvariant()}finally{$sha.Dispose()} - $runtimeId='{0}-{1}' -f $ProjectVersion,$digest.Substring(0,16) + $match=[regex]::Match($ExpectedRuntimeId,'^(?[A-Za-z0-9][A-Za-z0-9._-]{0,45})-(?[0-9a-f]{16})-(?[0-9a-f]{32})$');if(-not$match.Success-or$match.Groups['version'].Value-cne$ProjectVersion){Throw-CcodStaticProbeError 'CCOD_STATIC_RUNTIME_UNAUTHORIZED' 'Runtime identity format is invalid' $ExpectedRuntimeId} + $runtimeId='{0}-{1}-{2}' -f $ProjectVersion,$digest.Substring(0,16),$match.Groups['nonce'].Value if(-not (Test-CcodStaticRuntimeId $runtimeId)){Throw-CcodStaticProbeError 'CCOD_STATIC_RUNTIME_UNAUTHORIZED' 'Computed runtime ID is invalid' $null} return $runtimeId } @@ -442,6 +465,15 @@ function Get-CcodStaticFileSha256 { }finally{$sha.Dispose()} } +function Assert-CcodStaticSelectorFile { + param([string]$InstallRoot,[string]$Path,[hashtable]$Adapters) + Assert-CcodStaticProbeNoReparse $InstallRoot $Path -Adapters $Adapters + try{$item=Get-Item -LiteralPath $Path -Force -ErrorAction Stop;if($item.PSIsContainer-or$item-isnot[IO.FileInfo]){throw'not file'};$streams=@(Get-Item -LiteralPath $Path -Stream * -ErrorAction Stop);if($streams.Count-ne1-or[string]$streams[0].Stream-cne':$DATA'){throw'stream'};if($null-eq('CcodStaticSelectorIdentity' -as[type])){Add-Type -TypeDefinition @' +using System; using System.ComponentModel; using System.IO; using System.Runtime.InteropServices; using Microsoft.Win32.SafeHandles; +public static class CcodStaticSelectorIdentity { [StructLayout(LayoutKind.Sequential)] struct I { public uint A; public System.Runtime.InteropServices.ComTypes.FILETIME C,X,W; public uint V,H,L,N,Ih,Il; } [DllImport("kernel32.dll",SetLastError=true)] static extern bool GetFileInformationByHandle(SafeFileHandle h,out I i); public static uint Links(string p){using(FileStream s=new FileStream(p,FileMode.Open,FileAccess.Read,FileShare.Read)){I i;if(!GetFileInformationByHandle(s.SafeFileHandle,out i))throw new Win32Exception(Marshal.GetLastWin32Error());return i.N;}} } +'@};if([CcodStaticSelectorIdentity]::Links($Path)-ne1){throw'links'}}catch{Throw-CcodStaticProbeError 'CCOD_STATIC_RUNTIME_UNAUTHORIZED' 'Active selector leaf is unsafe' $Path} +} + function Get-CcodStaticProbeRuntimeAuthorization { [CmdletBinding()] param([Parameter(Mandatory)][string]$ScriptPath,[hashtable]$Adapters) @@ -452,11 +484,29 @@ function Get-CcodStaticProbeRuntimeAuthorization { if((Split-Path $persistenceRoot -Leaf) -cne 'persistence' -or (Split-Path $srcRoot -Leaf) -cne 'src' -or (Split-Path $runtimeContainer -Leaf) -cne 'runtime' -or -not (Test-CcodStaticRuntimeId $runtimeId)){throw 'layout'} $installRoot=[IO.Path]::GetFullPath($installRoot);$runtimeRoot=[IO.Path]::GetFullPath($runtimeRoot) Assert-CcodStaticProbeNoReparse -Root $installRoot -Path $ScriptPath -Adapters $Adapters - $activePath=[IO.Path]::GetFullPath((Join-Path $installRoot 'active.json'));Assert-CcodStaticProbeNoReparse $installRoot $activePath -Adapters $Adapters - $active=Read-CcodStaticProbeLocalJson $activePath;Assert-CcodStaticExactObject $active @('schemaVersion','activeRuntime','previousRuntime','updatedAtUtc') 'CCOD_STATIC_RUNTIME_UNAUTHORIZED' 'Active pointer'|Out-Null - if($active.schemaVersion -isnot [int] -or $active.schemaVersion -ne 1 -or -not(Test-CcodStaticRuntimeId $active.activeRuntime) -or - ($null -ne $active.previousRuntime -and (-not(Test-CcodStaticRuntimeId $active.previousRuntime) -or $active.previousRuntime -ceq $active.activeRuntime)) -or - -not(Test-CcodStaticCanonicalUtc $active.updatedAtUtc) -or $active.activeRuntime -cne $runtimeId){throw 'active pointer'} + $stateRoot=[IO.Path]::GetFullPath((Join-Path $installRoot 'state')) + $pointerRoot=[IO.Path]::GetFullPath((Join-Path $installRoot 'state\active-generation')) + $pathAdapters=Get-CcodStaticProbePathAdapters $Adapters + $stateResult=Invoke-CcodStaticAdapter $pathAdapters.GetSelectorRootResult @($stateRoot) Single 'CCOD_STATIC_RUNTIME_UNAUTHORIZED' + Assert-CcodStaticExactObject $stateResult @('Status','Item') 'CCOD_STATIC_RUNTIME_UNAUTHORIZED' 'Active selector state lookup'|Out-Null + if($stateResult.Status -isnot [string]){throw 'state lookup status'} + if($stateResult.Status-ceq'Missing'){if($null-ne$stateResult.Item){throw 'state missing item'};$pointerRootItem=$null} + elseif($stateResult.Status-ceq'Found'){ + if($null-eq$stateResult.Item-or-not$stateResult.Item.PSIsContainer-or($stateResult.Item.Attributes-band[IO.FileAttributes]::ReparsePoint)-ne0){throw 'state root type'} + $selectorResult=Invoke-CcodStaticAdapter $pathAdapters.GetSelectorRootResult @($pointerRoot) Single 'CCOD_STATIC_RUNTIME_UNAUTHORIZED' + Assert-CcodStaticExactObject $selectorResult @('Status','Item') 'CCOD_STATIC_RUNTIME_UNAUTHORIZED' 'Active selector lookup'|Out-Null + if($selectorResult.Status -isnot [string]){throw 'selector lookup status'} + if($selectorResult.Status-ceq'Missing'){if($null-ne$selectorResult.Item){throw 'selector missing item'};$pointerRootItem=$null} + elseif($selectorResult.Status-ceq'Found'){if($null-eq$selectorResult.Item){throw 'selector found item'};$pointerRootItem=$selectorResult.Item} + else{throw 'selector lookup status'} + }else{throw 'state lookup status'} + if($null-ne$pointerRootItem-and-not$pointerRootItem.PSIsContainer){throw 'selector root type'} + if($null-ne$pointerRootItem){ + Assert-CcodStaticProbeNoReparse $installRoot $pointerRoot -Adapters $Adapters;$entries=@(Get-ChildItem -LiteralPath $pointerRoot -Force -ErrorAction Stop);if($entries.Count-eq0){throw 'empty active chain'};$records=[Collections.Generic.List[object]]::new() + foreach($entry in $entries){if($entry.PSIsContainer-or$entry.Name-cnotmatch'^\d{20}\.json$'){throw 'active entry'};Assert-CcodStaticSelectorFile $installRoot $entry.FullName $Adapters;$record=Read-CcodStaticProbeLocalJson $entry.FullName;Assert-CcodStaticExactObject $record @('schemaVersion','generation','activeRuntime','previousGeneration') 'CCOD_STATIC_RUNTIME_UNAUTHORIZED' 'Active generation'|Out-Null;if($record.schemaVersion -isnot [int] -or $record.schemaVersion -ne 1){throw 'active generation schema'};if(-not(Test-CcodStaticRuntimeId $record.activeRuntime)){throw 'active generation'};$generation=ConvertTo-CcodStaticRuntimeGeneration -Value $record.generation -Path $entry.FullName;$previousGeneration=ConvertTo-CcodStaticRuntimeGeneration -Value $record.previousGeneration -Path $entry.FullName -AllowZero;if($generation-eq0-or$previousGeneration-eq[uint64]::MaxValue-or$generation-ne($previousGeneration+1)-or$entry.Name-cne('{0:D20}.json'-f$generation)){throw 'active generation canonical'};$records.Add([pscustomobject]@{generation=$generation;previousGeneration=$previousGeneration;activeRuntime=[string]$record.activeRuntime;path=$entry.FullName})} + $ordered=@($records|Sort-Object generation);for($i=0;$i-lt$ordered.Count;$i++){if([uint64]$ordered[$i].generation-ne[uint64]($i+1)-or[uint64]$ordered[$i].previousGeneration-ne[uint64]$i){throw 'active chain'}};$latest=$ordered[-1];$active=[pscustomobject]@{activeRuntime=$latest.activeRuntime};$activePath=$latest.path + }else{$activePath=[IO.Path]::GetFullPath((Join-Path $installRoot 'active.json'));Assert-CcodStaticProbeNoReparse $installRoot $activePath -Adapters $Adapters;$active=Read-CcodStaticProbeLocalJson $activePath;Assert-CcodStaticExactObject $active @('schemaVersion','activeRuntime','previousRuntime','updatedAtUtc') 'CCOD_STATIC_RUNTIME_UNAUTHORIZED' 'Active pointer'|Out-Null;if($active.schemaVersion -isnot [int] -or $active.schemaVersion -ne 1 -or -not(Test-CcodStaticRuntimeId $active.activeRuntime) -or($null -ne $active.previousRuntime -and (-not(Test-CcodStaticRuntimeId $active.previousRuntime) -or $active.previousRuntime -ceq $active.activeRuntime)) -or-not(Test-CcodStaticCanonicalUtc $active.updatedAtUtc)){throw 'active pointer'}} + if($active.activeRuntime-cne$runtimeId){throw 'active runtime'} $expectedRuntime=[IO.Path]::GetFullPath((Join-Path (Join-Path $installRoot 'runtime') $active.activeRuntime)) $expectedWorker=[IO.Path]::GetFullPath((Join-Path $expectedRuntime 'src\persistence\StaticProbeWorker.ps1')) if($expectedRuntime -cne $runtimeRoot -or $expectedWorker -cne $ScriptPath){throw 'self binding'} @@ -472,7 +522,7 @@ function Get-CcodStaticProbeRuntimeAuthorization { $previous=$file.path;$records.Add([pscustomobject][ordered]@{path=$file.path;length=[int64]$file.length;sha256=$file.sha256}) } foreach($required in $script:CcodStaticProbeRequiredFiles){if(@($records|Where-Object{$_.path -ceq $required}).Count -ne 1){throw 'required file'}} - $computed=Get-CcodStaticRuntimeIdFromRecords $manifest.projectVersion $records.ToArray() + $computed=Get-CcodStaticRuntimeIdFromRecords $manifest.projectVersion $records.ToArray() $manifest.runtimeId if($computed -cne $manifest.runtimeId -or $computed -cne $active.activeRuntime -or $computed -cne $runtimeId){throw 'runtime id'} foreach($required in $script:CcodStaticProbeRequiredFiles){ $record=@($records|Where-Object{$_.path -ceq $required})[0];$path=[IO.Path]::GetFullPath((Join-Path $runtimeRoot ($required.Replace('/','\')))) diff --git a/src/persistence/Supervisor.ps1 b/src/persistence/Supervisor.ps1 index 0dbfb21..1e8c725 100644 --- a/src/persistence/Supervisor.ps1 +++ b/src/persistence/Supervisor.ps1 @@ -9,7 +9,7 @@ Set-StrictMode -Version 2.0 $script:CcodSupervisorScriptPath=if([string]::IsNullOrWhiteSpace($PSCommandPath)){$null}else{[IO.Path]::GetFullPath($PSCommandPath)} $script:CcodSupervisorLogPath=$null $script:CcodSupervisorAdapterNames=@( - 'GetIdentity','ResolveLayout','StartClock','GetElapsedMilliseconds','GetUtcNow', + 'GetIdentity','GetTrayHostIdentity','ResolveLayout','StartClock','GetElapsedMilliseconds','GetUtcNow','Delay', 'EnterLease','ExitLease','OpenReadyEvent','OpenShutdownEvent','IsEventSignaled','SignalEvent','CloseEvent', 'ReadActiveRuntime','GetTrustedLogonIdentity','WriteSafeExitIntent','ClearSafeExitIntent','EnterLifecycleOwnership','AssertLifecycleFence','SuspendLifecycleOwnership','ResumeLifecycleOwnership','ExitLifecycleOwnership','OpenLifecycleWakeEvent','ResetLifecycleWakeEvent', 'ReadLifecycleRequest','ReceiveLifecycleSubmissions','WriteLifecycleSubmissionReceipt','NewLifecycleRequest','WriteLifecycleRequest','MoveLifecyclePhase','CompleteLifecycleRequest','GetLifecycleStep','ReduceLifecycleWorkerResult','NewLifecycleWorkerRequest','AssertLifecycleWorkerResult', @@ -31,8 +31,18 @@ $script:CcodSupervisorCleanupAllowlist=@( 'CCOD_SUPERVISOR_WORKER_TERMINATE_FAILED','CCOD_SUPERVISOR_WORKER_DISPOSE_FAILED','CCOD_SUPERVISOR_WORKER_FILE_DELETE_FAILED', 'CCOD_SUPERVISOR_WORKER_SURVIVED', 'CCOD_SUPERVISOR_WATCHER_STOP_FAILED','CCOD_SUPERVISOR_QUEUE_DRAIN_FAILED','CCOD_SUPERVISOR_TRAY_CLOSE_FAILED', - 'CCOD_SUPERVISOR_READY_CLOSE_FAILED','CCOD_SUPERVISOR_SHUTDOWN_CLOSE_FAILED','CCOD_SUPERVISOR_LOCAL_RELEASE_FAILED', - 'CCOD_SUPERVISOR_LIFECYCLE_WAKE_CLOSE_FAILED','CCOD_SUPERVISOR_LIFECYCLE_RELEASE_FAILED','CCOD_SUPERVISOR_ACCOUNT_RELEASE_FAILED' + 'CCOD_SUPERVISOR_READY_CLOSE_FAILED','CCOD_SUPERVISOR_SHUTDOWN_CLOSE_FAILED','CCOD_SUPERVISOR_LOCAL_RELEASE_FAILED','CCOD_SUPERVISOR_TRAY_READY_LOG_FAILED', + 'CCOD_SUPERVISOR_LIFECYCLE_WAKE_CLOSE_FAILED','CCOD_SUPERVISOR_LIFECYCLE_RELEASE_FAILED','CCOD_SUPERVISOR_ACCOUNT_RELEASE_FAILED','CCOD_SUPERVISOR_SAFE_EXIT_RECOVERY_PERSIST_FAILED' +) +$script:CcodSupervisorTrayCleanupAllowlist=@( + 'CCOD_TRAY_CLEANUP_ICON_HIDE_FAILED','CCOD_TRAY_CLEANUP_TIMER_STOP_FAILED','CCOD_TRAY_CLEANUP_TIMER_DISPOSE_FAILED', + 'CCOD_TRAY_CLEANUP_ICON_DISPOSE_FAILED','CCOD_TRAY_CLEANUP_MENU_DISPOSE_FAILED','CCOD_TRAY_CLEANUP_ICON_CLONE_DISPOSE_FAILED', + 'CCOD_TRAY_CLEANUP_CONTROL_DISPOSE_FAILED','CCOD_TRAY_CLEANUP_CALLBACK_DETACH_FAILED','CCOD_TRAY_CLEANUP_CONTEXT_EXIT_FAILED','CCOD_TRAY_CLEANUP_CONTEXT_DISPOSE_FAILED', + 'CCOD_TRAY_CLEANUP_NATIVE_MENU_END_FAILED','CCOD_TRAY_CLEANUP_NATIVE_MENU_OWNER_DISPOSE_FAILED' +) +$script:CcodSupervisorWatcherCleanupAllowlist=@( + 'CCOD_WATCHER_CLEANUP_ATTEMPT_FAILED','CCOD_WATCHER_CLEANUP_CALLBACK_DETACH_FAILED','CCOD_WATCHER_CLEANUP_UNREGISTER_FAILED', + 'CCOD_WATCHER_CLEANUP_JOB_REMOVE_FAILED','CCOD_WATCHER_CLEANUP_RESOURCE_DISPOSE_FAILED','CCOD_WATCHER_CLEANUP_QUEUE_DRAIN_FAILED','CCOD_WATCHER_CLEANUP_QUEUE_DRAIN_LIMIT' ) function Get-CcodSupervisorAdapterNames { @@ -42,11 +52,11 @@ function Get-CcodSupervisorAdapterNames { function Test-CcodSupervisorExactProperties { param($Value,[string[]]$Names) try{ - if($null -eq $Value -or $Value -isnot [pscustomobject]){return $false} + if($null -eq $Value -or $Value -is [Collections.IDictionary] -or $Value -is [array]){return $false} $actual=@($Value.PSObject.Properties.Name) if($actual.Count -ne $Names.Count){return $false} for($index=0;$index -lt $Names.Count;$index++){ - if($actual[$index] -cne $Names[$index] -or $Value.PSObject.Properties[$actual[$index]].MemberType -ne [Management.Automation.PSMemberTypes]::NoteProperty){return $false} + if($actual[$index] -cne $Names[$index] -or $Value.PSObject.Properties[$actual[$index]].MemberType -notin @([Management.Automation.PSMemberTypes]::NoteProperty,[Management.Automation.PSMemberTypes]::Property)){return $false} } return $true }catch{return $false} @@ -85,7 +95,7 @@ function Test-CcodSupervisorStaticProbeResult { if($probe.$name -isnot [string] -or [string]::IsNullOrWhiteSpace($probe.$name) -or $probe.$name -match '[\r\n]'){return $false} } foreach($name in $signatureFields){if($probe.signatures.$name -isnot [bool]){return $false}} - if($probe.appAsarSha256 -cnotmatch '^[0-9a-f]{64}$' -or $probe.nodeVersion -cnotmatch '^v(?[0-9]+)\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$' -or [int]$Matches.major -ne $probe.nodeMajor){return $false} + if($probe.appAsarSha256 -cnotmatch '^[0-9a-f]{64}\z' -or $probe.nodeVersion -cnotmatch '^v(?[0-9]+)\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?\z' -or [int]$Matches.major -ne $probe.nodeMajor){return $false} $allSentinels=@($signatureFields|Where-Object{-not $probe.signatures.$_}).Count -eq 0 return ($probe.staticClassification -ceq 'CandidateCompatible' -and $probe.ready -and $probe.affectedBuildDetected -and $allSentinels) -or ($probe.staticClassification -ceq 'NativeModulePresent' -and -not $probe.ready -and -not $probe.affectedBuildDetected -and $probe.nativeModulePresent -and -not $allSentinels) -or @@ -95,7 +105,7 @@ function Test-CcodSupervisorStaticProbeResult { function Test-CcodSupervisorCanonicalUtc { param($Value) - if($Value -isnot [string] -or $Value -cnotmatch '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{7}Z$'){return $false} + if($Value -isnot [string] -or $Value -cnotmatch '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{7}Z\z'){return $false} $parsed=[DateTime]::MinValue return [DateTime]::TryParseExact($Value,'o',[Globalization.CultureInfo]::InvariantCulture,[Globalization.DateTimeStyles]::RoundtripKind,[ref]$parsed) -and $parsed.Kind -eq [DateTimeKind]::Utc -and $parsed.ToString('o',[Globalization.CultureInfo]::InvariantCulture) -ceq $Value @@ -220,7 +230,7 @@ function Resolve-CcodSupervisorLayout { function New-CcodSupervisorLifecycleWakeEvent { param([string]$UserSid,[int]$SessionId) - if($UserSid -cnotmatch '^S-\d-\d+(?:-\d+)+$' -or $SessionId -lt 0){throw 'lifecycle wake identity is invalid'} + if($UserSid -cnotmatch '^S-\d-\d+(?:-\d+)+\z' -or $SessionId -lt 0){throw 'lifecycle wake identity is invalid'} $name="Local\CodexControlOtherDevices.LifecycleWake.$UserSid.$SessionId" $created=$false;$handle=$null try{ @@ -244,10 +254,12 @@ function Get-CcodSupervisorDefaultAdapters { [pscustomobject][ordered]@{UserSid=$windowsIdentity.User.Value;SessionId=[int]$process.SessionId;Pid=[int]$process.Id;CreationTimeUtc=$process.StartTime.ToUniversalTime().ToString('o',[Globalization.CultureInfo]::InvariantCulture)} }finally{if($null -ne $process){$process.Dispose()};if($null -ne $windowsIdentity){$windowsIdentity.Dispose()}} } + $defaults.GetTrayHostIdentity={param($ProcessId,$ExpectedCreationTimeUtc)Get-CcodProcessIdentityObservation -ProcessId ([int]$ProcessId) -ExpectedCreationTimeUtc ([string]$ExpectedCreationTimeUtc)} $defaults.ResolveLayout={Resolve-CcodSupervisorLayout} $defaults.StartClock={[Diagnostics.Stopwatch]::StartNew()} $defaults.GetElapsedMilliseconds={param($Clock)[long]$Clock.ElapsedMilliseconds} $defaults.GetUtcNow={[DateTime]::UtcNow} + $defaults.Delay={param($Milliseconds)Start-Sleep -Milliseconds ([int]$Milliseconds)} $defaults.EnterLease={param($Kind,$UserSid,$SessionId,$TimeoutMilliseconds)if($Kind -ceq 'AccountSupervisor'){Enter-CcodMutex -Kind $Kind -UserSid $UserSid -TimeoutMilliseconds $TimeoutMilliseconds}else{Enter-CcodMutex -Kind $Kind -UserSid $UserSid -SessionId $SessionId -TimeoutMilliseconds $TimeoutMilliseconds}} $defaults.ExitLease={param($Lease)Exit-CcodMutex -Lease $Lease} $defaults.OpenReadyEvent={param($UserSid,$SessionId,$Token) $kernelModule=Get-Module -Name KernelObjects|Select-Object -First 1;if($null -eq $kernelModule){throw 'kernel-object module unavailable'};& $kernelModule {param($Sid,$Sess,$Tok)Open-CcodEvent -Kind Ready -UserSid $Sid -SessionId $Sess -ReadyToken $Tok} ([string]$UserSid) ([int]$SessionId) ([string]$Token)} @@ -412,7 +424,7 @@ function Get-CcodSupervisorAdapters { function Test-CcodSupervisorIdentity { param($Identity) return (Test-CcodSupervisorExactProperties $Identity @('UserSid','SessionId','Pid','CreationTimeUtc')) -and - $Identity.UserSid -is [string] -and $Identity.UserSid -cmatch '^S-1-(?:\d+-){1,14}\d+$' -and + $Identity.UserSid -is [string] -and $Identity.UserSid -cmatch '^S-1-(?:\d+-){1,14}\d+\z' -and $Identity.SessionId -is [int] -and $Identity.SessionId -ge 0 -and $Identity.Pid -is [int] -and $Identity.Pid -gt 0 -and (Test-CcodSupervisorCanonicalUtc $Identity.CreationTimeUtc) } @@ -420,7 +432,7 @@ function Test-CcodSupervisorIdentity { function Test-CcodSupervisorLayout { param($Layout) $names=@('InstallRoot','RuntimeRoot','RuntimeId','StateRoot','WorkersRoot','ControllerPath','StaticWorkerPath','LifecycleWorkerPath','PowerShellPath','LogDirectory','TransitionPath') - if(-not (Test-CcodSupervisorExactProperties $Layout $names) -or $Layout.RuntimeId -isnot [string] -or $Layout.RuntimeId -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$'){return $false} + if(-not (Test-CcodSupervisorExactProperties $Layout $names) -or $Layout.RuntimeId -isnot [string] -or $Layout.RuntimeId -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,127}\z'){return $false} foreach($name in $names|Where-Object{$_ -ne 'RuntimeId'}){ $value=$Layout.$name;$full=$null if($value -isnot [string]){return $false} @@ -463,6 +475,28 @@ function Invoke-CcodSupervisorCleanupStage { try{& $Action}catch{Add-CcodSupervisorCleanupCode $Codes $Code} } +function Add-CcodSupervisorNestedCleanupReceipt { + param([Collections.Generic.List[string]]$Codes,$Receipt,[ValidateSet('Tray','Watcher')][string]$Kind) + if($null -eq $Receipt){throw 'nested cleanup receipt is missing'} + $nestedCodes=@();$hasFailure=$false + if($Kind -ceq 'Tray' -and (Test-CcodSupervisorExactProperties $Receipt @('Closed','ErrorCode'))){ + if($Receipt.Closed -isnot [bool] -or -not $Receipt.Closed -or + ($null -ne $Receipt.ErrorCode -and ($Receipt.ErrorCode -isnot [string] -or $Receipt.ErrorCode -cnotmatch '^CCOD_[A-Z0-9_]{1,91}\z'))){throw 'tray host close receipt is invalid'} + $hasFailure=$null -ne $Receipt.ErrorCode + }elseif($Kind -ceq 'Tray' -and (Test-CcodSupervisorExactProperties $Receipt @('SchemaVersion','Closed','CleanupCodes'))){ + if($Receipt.SchemaVersion -isnot [int] -or $Receipt.SchemaVersion -ne 1 -or $Receipt.Closed -isnot [bool] -or -not $Receipt.Closed -or $Receipt.CleanupCodes -isnot [array]){throw 'tray close receipt is invalid'} + $nestedCodes=@($Receipt.CleanupCodes) + foreach($code in $nestedCodes){if($code -isnot [string] -or $script:CcodSupervisorTrayCleanupAllowlist -cnotcontains $code){throw 'tray close cleanup code is invalid'}} + $hasFailure=$nestedCodes.Count -gt 0 + }elseif($Kind -ceq 'Watcher' -and (Test-CcodSupervisorExactProperties $Receipt @('SchemaVersion','Stopped','CleanupCodes'))){ + if($Receipt.SchemaVersion -isnot [int] -or $Receipt.SchemaVersion -ne 1 -or $Receipt.Stopped -isnot [bool] -or -not $Receipt.Stopped -or $Receipt.CleanupCodes -isnot [array]){throw 'watcher stop receipt is invalid'} + $nestedCodes=@($Receipt.CleanupCodes) + foreach($code in $nestedCodes){if($code -isnot [string] -or $script:CcodSupervisorWatcherCleanupAllowlist -cnotcontains $code){throw 'watcher cleanup code is invalid'}} + $hasFailure=$nestedCodes.Count -gt 0 + }else{throw 'nested cleanup receipt schema is invalid'} + if($hasFailure){$supervisorCode=if($Kind -ceq 'Tray'){'CCOD_SUPERVISOR_TRAY_CLOSE_FAILED'}else{'CCOD_SUPERVISOR_WATCHER_STOP_FAILED'};Add-CcodSupervisorCleanupCode $Codes $supervisorCode} +} + function Get-CcodSupervisorRemainingBudget { param($Clock,[hashtable]$Adapters) $elapsed=Invoke-CcodSupervisorAdapter $Adapters.GetElapsedMilliseconds @($Clock) 1 @@ -516,8 +550,8 @@ function Get-CcodSupervisorNowUtc { function Test-CcodSupervisorActiveRuntime { param($Pointer,[string]$RuntimeId) return (Test-CcodSupervisorExactProperties $Pointer @('schemaVersion','activeRuntime','previousRuntime','generation','updatedAtUtc')) -and - ($Pointer.schemaVersion -is [int] -or $Pointer.schemaVersion -is [int64] -or $Pointer.schemaVersion -is [long]) -and [int]$Pointer.schemaVersion -eq 2 -and $Pointer.activeRuntime -is [string] -and $Pointer.activeRuntime -cmatch '^[A-Za-z0-9._-]{1,96}$' -and $Pointer.activeRuntime -ceq $RuntimeId -and - ($null -eq $Pointer.previousRuntime -or ($Pointer.previousRuntime -is [string] -and $Pointer.previousRuntime -cmatch '^[A-Za-z0-9._-]{1,96}$')) -and + ($Pointer.schemaVersion -is [int] -or $Pointer.schemaVersion -is [int64] -or $Pointer.schemaVersion -is [long]) -and [int]$Pointer.schemaVersion -eq 2 -and $Pointer.activeRuntime -is [string] -and $Pointer.activeRuntime -cmatch '^[A-Za-z0-9._-]{1,96}\z' -and $Pointer.activeRuntime -ceq $RuntimeId -and + ($null -eq $Pointer.previousRuntime -or ($Pointer.previousRuntime -is [string] -and $Pointer.previousRuntime -cmatch '^[A-Za-z0-9._-]{1,96}\z')) -and (Test-CcodSupervisorCanonicalUtc $Pointer.updatedAtUtc) -and ($Pointer.generation -is [int] -or $Pointer.generation -is [long] -or $Pointer.generation -is [uint64] -or $Pointer.generation -is [decimal]) -and [decimal]$Pointer.generation -ge 1 -and [decimal]$Pointer.generation -le [decimal][UInt64]::MaxValue -and [decimal]::Truncate([decimal]$Pointer.generation) -eq [decimal]$Pointer.generation } @@ -534,17 +568,18 @@ function Test-CcodSupervisorLifecycleOwnership { function Test-CcodSupervisorLogonIdentity { param($Value,$Identity) return (Test-CcodSupervisorExactProperties $Value @('authenticationId','userSid','sessionId')) -and - $Value.authenticationId -is [string] -and $Value.authenticationId -cmatch '^[0-9A-F]{8}:[0-9A-F]{8}$' -and + $Value.authenticationId -is [string] -and $Value.authenticationId -cmatch '^[0-9A-F]{8}:[0-9A-F]{8}\z' -and $Value.userSid -is [string] -and $Value.userSid -ceq $Identity.UserSid -and $Value.sessionId -is [int] -and $Value.sessionId -eq $Identity.SessionId } function Test-CcodSupervisorLifecycleSubmission { param($Value) if(-not (Test-CcodSupervisorExactProperties $Value @('schemaVersion','submissionId','kind','origin','runtimeId','runtimeGeneration','createdAtUtc')) -or - $Value.schemaVersion -isnot [int] -or $Value.schemaVersion -ne 1 -or $Value.submissionId -isnot [string] -or $Value.submissionId -cnotmatch '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' -or + $Value.schemaVersion -isnot [int] -or $Value.schemaVersion -ne 1 -or $Value.submissionId -isnot [string] -or $Value.submissionId -cnotmatch '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z' -or $Value.kind -isnot [string] -or @('RestartAndRepair','CheckAndRepair','SafeExit') -cnotcontains $Value.kind -or $Value.origin -isnot [string] -or @('Installer','Tray','ExplicitStart','Guardian') -cnotcontains $Value.origin -or - $Value.runtimeId -isnot [string] -or $Value.runtimeId -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$' -or -not(Test-CcodSupervisorCanonicalUtc $Value.createdAtUtc)){return $false} - try{return [UInt64]$Value.runtimeGeneration -gt 0 -and [decimal]::Truncate([decimal]$Value.runtimeGeneration) -eq [decimal]$Value.runtimeGeneration}catch{return $false} + $Value.runtimeId -isnot [string] -or $Value.runtimeId -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,95}\z' -or -not(Test-CcodSupervisorCanonicalUtc $Value.createdAtUtc) -or + ($Value.runtimeGeneration -isnot [int] -and $Value.runtimeGeneration -isnot [long] -and $Value.runtimeGeneration -isnot [uint64])){return $false} + try{return [UInt64]$Value.runtimeGeneration -gt 0}catch{return $false} } function ConvertTo-CcodSupervisorLifecycleObservation { @@ -613,7 +648,7 @@ function Receive-CcodSupervisorLifecycleSubmission { function New-CcodSupervisorLifecycleWorkerPaths { param($HostState,[string]$TransactionId) - if($TransactionId -cnotmatch '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'){throw 'lifecycle transaction identity is invalid'} + if($TransactionId -cnotmatch '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z'){throw 'lifecycle transaction identity is invalid'} $root=$HostState.Layout.WorkersRoot return [pscustomobject][ordered]@{ RequestPath=[IO.Path]::GetFullPath((Join-Path $root ("lifecycle-$TransactionId.request.json"))) @@ -662,14 +697,21 @@ function Confirm-CcodSupervisorLifecycleProofCandidate { param($HostState,[hashtable]$Adapters,$Candidate) $candidateKey=Get-CcodSupervisorSpecialProofKey $Candidate if($null-eq$candidateKey){return $false} - Update-CcodSupervisorProofObservations $HostState $Adapters - if(@($HostState.Special).Count-ne1-or-not(Test-CcodSupervisorExactProcessSnapshotMatch $Candidate $HostState.Special[0].Snapshot)){ - $HostState.SpecialProof=$null;$HostState.FailedSpecialProofKey=$candidateKey;$HostState.SpecialNeedsInspect=$false - return $false + for($attempt=0;$attempt-lt2;$attempt++){ + Update-CcodSupervisorProofObservations $HostState $Adapters + if(@($HostState.Special).Count-eq1-and(Test-CcodSupervisorExactProcessSnapshotMatch $Candidate $HostState.Special[0].Snapshot)){ + $HostState.SpecialProof=$HostState.Special[0].Snapshot;$HostState.Special[0].ProbeValid=$true;$HostState.SpecialNeedsInspect=$false + $HostState.FailedSpecialProofKey=$null;$HostState.LifecycleObservation='RemoteVerified';$HostState.ConnectionState=ConvertTo-CcodSupervisorLifecycleObservation RemoteVerified + return $true + } + if($attempt-ne0-or@($HostState.Special).Count-ne0-or@($HostState.Ordinary).Count-ne0){break} + $statusEvidence=if($null-ne$HostState.State-and$null-ne$HostState.State.PSObject.Properties['Status']){$HostState.State.Status}else{$null} + $current=Invoke-CcodSupervisorNullableAdapter $Adapters.GetProcessSnapshot @([int]$Candidate.Pid,$statusEvidence) + if($null-eq$current-or-not(Test-CcodSupervisorExactProcessSnapshotMatch $Candidate $current)){break} + Invoke-CcodSupervisorAdapter $Adapters.Delay @([int]50) 0 } - $HostState.SpecialProof=$HostState.Special[0].Snapshot;$HostState.Special[0].ProbeValid=$true;$HostState.SpecialNeedsInspect=$false - $HostState.FailedSpecialProofKey=$null;$HostState.LifecycleObservation='RemoteVerified';$HostState.ConnectionState=ConvertTo-CcodSupervisorLifecycleObservation RemoteVerified - return $true + $HostState.SpecialProof=$null;$HostState.FailedSpecialProofKey=$candidateKey;$HostState.SpecialNeedsInspect=$false + return $false } function New-CcodSupervisorLifecycleProofFailure { @@ -765,7 +807,7 @@ function Complete-CcodSupervisorLifecycleTerminal { param($HostState,[hashtable]$Adapters) $request=$HostState.LifecycleRequest if($null-eq$request -or -not(Test-CcodSupervisorLifecycleTerminal $request.phase)){throw 'lifecycle completion is not terminal'} - $successful=$request.phase-ceq'Completed' -or ($request.phase-ceq'CancelledBeforeClose' -and $null-eq$request.error) + $successful=(@('Completed','CancelledBeforeClose') -ccontains $request.phase) -and $null-eq$request.error if($request.kind-ceq'SafeExit' -and $successful){ $markerWritten=$false try{ @@ -787,24 +829,44 @@ function Complete-CcodSupervisorLifecycleTerminal { }catch{ $HostState.ProtectionState='Running' $request.error='SAFE_EXIT_RECOVERY_FAILED' - try{Invoke-CcodSupervisorAdapter $Adapters.WriteLifecycleRequest @($HostState.Layout.StateRoot,$request) 0}catch{} + try{ + Invoke-CcodSupervisorAdapter $Adapters.AssertLifecycleFence @($HostState.Layout.InstallRoot,$HostState.LifecycleOwnership) 1|Out-Null + Invoke-CcodSupervisorAdapter $Adapters.WriteLifecycleRequest @($HostState.Layout.StateRoot,$request) 0 + }catch{ + Add-CcodSupervisorCleanupCode $HostState.RuntimeCleanupCodes 'CCOD_SUPERVISOR_SAFE_EXIT_RECOVERY_PERSIST_FAILED' + $HostState.SessionState='Error';$HostState.BlockAutomaticActions=$true;$HostState.Reason='SafeExitRecoveryFailed';$HostState.ConnectionState='Error' + try{ + $record=[pscustomobject][ordered]@{schemaVersion=1;timestampUtc=(Get-CcodSupervisorNowUtc $Adapters);component='Supervisor';stage='SafeExitRecovery';code='CCOD_SUPERVISOR_SAFE_EXIT_RECOVERY_PERSIST_FAILED';outcome='Failed'} + Invoke-CcodSupervisorAdapter $Adapters.WriteLog @($record) 0 + }catch{Add-CcodSupervisorCleanupCode $HostState.RuntimeCleanupCodes 'CCOD_SUPERVISOR_LOG_FAILED'} + } return } } - Invoke-CcodSupervisorAdapter $Adapters.AssertLifecycleFence @($HostState.Layout.InstallRoot,$HostState.LifecycleOwnership) 1|Out-Null - Invoke-CcodSupervisorAdapter $Adapters.CompleteLifecycleRequest @($HostState.Layout.StateRoot,$request) 0 $failureCode='CCOD_LIFECYCLE_ACTION_FAILED' if($request.error -is [string] -and $request.error -cmatch '^CCOD_[A-Z0-9_]{1,91}\z'){$failureCode=$request.error} - [void](Complete-CcodSupervisorLifecycleTrayAction $HostState $Adapters $request $successful $failureCode) + Invoke-CcodSupervisorAdapter $Adapters.AssertLifecycleFence @($HostState.Layout.InstallRoot,$HostState.LifecycleOwnership) 1|Out-Null + if(-not (Complete-CcodSupervisorLifecycleTrayAction $HostState $Adapters $request $successful $failureCode)){ + $HostState.ConnectionState='Error' + return $false + } + Invoke-CcodSupervisorAdapter $Adapters.AssertLifecycleFence @($HostState.Layout.InstallRoot,$HostState.LifecycleOwnership) 1|Out-Null + Invoke-CcodSupervisorAdapter $Adapters.CompleteLifecycleRequest @($HostState.Layout.StateRoot,$request) 0 if(-not$successful){$HostState.ConnectionState=$(if($null-ne$HostState.FailedSpecialProofKey){'RepairNeeded'}else{'Error'})} $HostState.ProtectionState='Running';$HostState.LifecycleRequest=$null + return $true } function Invoke-CcodSupervisorPollLifecycleSlot { param($HostState,[hashtable]$Adapters) $slot=$HostState.LifecycleWorkerSlot $poll=Invoke-CcodSupervisorAdapter $Adapters.PollWorker @($slot) 1 - if(-not(Test-CcodSupervisorExactProperties $poll @('Completed','ExitCode','StdoutText','StdoutByteCount','StdoutOverflow','StderrByteCount','StderrOverflow')) -or $poll.Completed-isnot[bool] -or $poll.StdoutText-isnot[string] -or $poll.StdoutByteCount-isnot[int] -or $poll.StderrByteCount-isnot[int]){Clear-CcodSupervisorLifecycleWorkerSlot $HostState $Adapters;throw 'lifecycle worker poll is invalid'} + if(-not(Test-CcodSupervisorExactProperties $poll @('Completed','ExitCode','StdoutText','StdoutByteCount','StdoutOverflow','StderrByteCount','StderrOverflow')) -or $poll.Completed-isnot[bool] -or $poll.StdoutText-isnot[string] -or + $poll.StdoutByteCount-isnot[int] -or $poll.StdoutByteCount-lt0 -or $poll.StdoutOverflow-isnot[bool] -or + $poll.StderrByteCount-isnot[int] -or $poll.StderrByteCount-lt0 -or $poll.StderrOverflow-isnot[bool]){ + if(-not (Stop-CcodSupervisorOwnedWorkerForShutdown $HostState $Adapters -PropertyName LifecycleWorkerSlot -Codes $HostState.RuntimeCleanupCodes)){$HostState.ConnectionState='Error';$HostState.ProtectionState='Running';$HostState.SessionState='Error';$HostState.BlockAutomaticActions=$true;$HostState.Reason='WorkerFramingFailed'} + throw 'lifecycle worker poll is invalid' + } if(-not$poll.Completed){return} try{ if($poll.ExitCode-isnot[int] -or @([int]0,[int]1)-cnotcontains$poll.ExitCode -or [string]::IsNullOrEmpty($poll.StdoutText) -or $poll.StdoutOverflow -or $poll.StderrOverflow -or $poll.StdoutByteCount-gt1048576 -or $poll.StderrByteCount-gt65536){throw 'lifecycle worker frame is invalid'} @@ -841,7 +903,7 @@ function Invoke-CcodSupervisorPollLifecycleSlot { $HostState.LifecycleRequest=$reduced;$HostState.LifecycleObservation=[string]$result.observation;$HostState.ConnectionState=ConvertTo-CcodSupervisorLifecycleObservation $HostState.LifecycleObservation }catch{$HostState.ConnectionState='Error';$HostState.ProtectionState='Running';throw} finally{Clear-CcodSupervisorLifecycleWorkerSlot $HostState $Adapters} - if(Test-CcodSupervisorLifecycleTerminal $HostState.LifecycleRequest.phase){Complete-CcodSupervisorLifecycleTerminal $HostState $Adapters} + if(Test-CcodSupervisorLifecycleTerminal $HostState.LifecycleRequest.phase){[void](Complete-CcodSupervisorLifecycleTerminal $HostState $Adapters)} } function Invoke-CcodSupervisorDriveLifecycle { @@ -849,7 +911,7 @@ function Invoke-CcodSupervisorDriveLifecycle { if($null-eq$HostState.LifecycleRequest){return $false} if($HostState.LifecycleObservation-cne'Unknown'){$HostState.ConnectionState=ConvertTo-CcodSupervisorLifecycleObservation $HostState.LifecycleObservation} if($null-ne$HostState.LifecycleWorkerSlot){Invoke-CcodSupervisorPollLifecycleSlot $HostState $Adapters;return $true} - if(Test-CcodSupervisorLifecycleTerminal $HostState.LifecycleRequest.phase){Complete-CcodSupervisorLifecycleTerminal $HostState $Adapters;return $true} + if(Test-CcodSupervisorLifecycleTerminal $HostState.LifecycleRequest.phase){[void](Complete-CcodSupervisorLifecycleTerminal $HostState $Adapters);return $true} $now=Get-CcodSupervisorNowUtc $Adapters $step=Invoke-CcodSupervisorAdapter $Adapters.GetLifecycleStep @($HostState.LifecycleRequest,$HostState.LifecycleObservation,$now) 1 if(-not(Test-CcodSupervisorExactProperties $step @('kind','nextPhase','workerAction','deadlineUtc','errorCode')) -or $step.kind-isnot[string] -or $step.workerAction-isnot[string]){throw 'lifecycle step is invalid'} @@ -859,7 +921,7 @@ function Invoke-CcodSupervisorDriveLifecycle { if($null-ne$step.errorCode){$moved.error=[string]$step.errorCode} Invoke-CcodSupervisorAdapter $Adapters.WriteLifecycleRequest @($HostState.Layout.StateRoot,$moved) 0 $HostState.LifecycleRequest=$moved - if(Test-CcodSupervisorLifecycleTerminal $moved.phase){Complete-CcodSupervisorLifecycleTerminal $HostState $Adapters;return $true} + if(Test-CcodSupervisorLifecycleTerminal $moved.phase){[void](Complete-CcodSupervisorLifecycleTerminal $HostState $Adapters);return $true} } if($step.workerAction-cne'None'){Start-CcodSupervisorLifecycleWorkerSlot $HostState $Adapters $step.workerAction $step.deadlineUtc|Out-Null} return $true @@ -867,7 +929,7 @@ function Invoke-CcodSupervisorDriveLifecycle { function Test-CcodSupervisorWorkerPaths { param($Paths,$WorkersRoot,[string]$Kind,[string]$RequestId) - if(-not (Test-CcodSupervisorExactProperties $Paths @('RequestPath','ResultPath','StderrPath')) -or $RequestId -cnotmatch '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'){return $false} + if(-not (Test-CcodSupervisorExactProperties $Paths @('RequestPath','ResultPath','StderrPath')) -or $RequestId -cnotmatch '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z'){return $false} $prefix=if($Kind -ceq 'Controller'){'controller'}elseif($Kind -ceq 'StaticProbe'){'static-probe'}else{return $false} $expected=@( "$prefix-$RequestId.request.json", @@ -900,7 +962,7 @@ function New-CcodSupervisorWorkerPaths { function New-CcodSupervisorControllerRequest { param($HostState,[ValidateSet('Inspect','Apply','RepairStale','RepairRenderer','Recover')][string]$Action,$Target) $transactionId=if($Action -ceq 'Recover' -and $null -ne $HostState.Journal){$HostState.Journal.transactionId}else{[guid]::NewGuid().ToString('D')} - if($transactionId -isnot [string] -or $transactionId -cnotmatch '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'){throw 'transaction identity is invalid'} + if($transactionId -isnot [string] -or $transactionId -cnotmatch '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z'){throw 'transaction identity is invalid'} $source=$null if($Action -ceq 'Apply' -or $Action -ceq 'RepairStale'){ if($null -eq $Target){ @@ -995,6 +1057,30 @@ function Write-CcodSupervisorRendererHandoffFailure { if($null -ne $HostState -and $HostState.SessionState -ceq 'Active'){$HostState.Reason='RendererHandoff'} } +function Test-CcodSupervisorRendererHandoffReceipt { + param($Receipt) + if(-not (Test-CcodSupervisorExactProperties $Receipt @('Outcome','Code','ProcessId')) -or + $Receipt.Outcome -isnot [string] -or @('Started','Skipped','Failed') -cnotcontains $Receipt.Outcome -or + $Receipt.Code -isnot [string]){return $false} + $processValid=$false + $codeValid=$false + switch($Receipt.Outcome){ + 'Started' { + $processValid=($Receipt.ProcessId -is [int] -or $Receipt.ProcessId -is [long]) -and $Receipt.ProcessId -gt 0 -and $Receipt.ProcessId -le [int]::MaxValue + $codeValid=$Receipt.Code -ceq 'CCOD_RENDERER_HANDOFF_STARTED' + } + 'Skipped' { + $processValid=$null -eq $Receipt.ProcessId + $codeValid=@('CCOD_RENDERER_NOT_INSTALLED','CCOD_RENDERER_PAUSED','CCOD_RENDERER_STATE_UNAVAILABLE','CCOD_RENDERER_IDENTITY_UNAVAILABLE','CCOD_RENDERER_ALREADY_ATTACHED') -ccontains $Receipt.Code + } + 'Failed' { + $processValid=$null -eq $Receipt.ProcessId + $codeValid=@('CCOD_RENDERER_STATE_INVALID','CCOD_RENDERER_PORT_UNAVAILABLE','CCOD_RENDERER_HANDOFF_FAILED') -ccontains $Receipt.Code + } + } + return [bool]($processValid -and $codeValid) +} + function Invoke-CcodSupervisorRendererHandoff { param($HostState,$Slot,$Result,[hashtable]$Adapters) try{ @@ -1006,9 +1092,7 @@ function Invoke-CcodSupervisorRendererHandoff { $rendererPort=$Result.special.rendererPort if(($rendererPort -isnot [int] -and $rendererPort -isnot [long]) -or $rendererPort -lt 1 -or $rendererPort -gt 65535){return} $receipt=Invoke-CcodSupervisorAdapter $Adapters.HandoffRenderer @($Result,[int]$rendererPort) 1 - if(-not (Test-CcodSupervisorExactProperties $receipt @('Outcome','Code','ProcessId')) -or $receipt.Outcome -isnot [string] -or - $receipt.Code -isnot [string] -or @('Started','Skipped','Failed') -cnotcontains $receipt.Outcome -or - ($receipt.Outcome -ceq 'Failed')){Write-CcodSupervisorRendererHandoffFailure $HostState $Adapters} + if($receipt.Outcome -ceq 'Failed' -or -not (Test-CcodSupervisorRendererHandoffReceipt $receipt)){Write-CcodSupervisorRendererHandoffFailure $HostState $Adapters} }catch{Write-CcodSupervisorRendererHandoffFailure $HostState $Adapters} } @@ -1034,18 +1118,23 @@ function Invoke-CcodSupervisorPollSlot { $poll.StdoutByteCount -isnot [int] -or $poll.StdoutByteCount -lt 0 -or $poll.StdoutOverflow -isnot [bool] -or $poll.StderrByteCount -isnot [int] -or $poll.StderrByteCount -lt 0 -or $poll.StderrOverflow -isnot [bool]){ if($slot.Kind-ceq'StaticProbe'){Set-CcodSupervisorStaticProbeFramingFailure $HostState $staticAttemptKey} - Clear-CcodSupervisorWorkerSlot $HostState $Adapters;return + if(-not (Stop-CcodSupervisorOwnedWorkerForShutdown $HostState $Adapters -PropertyName WorkerSlot -Codes $HostState.RuntimeCleanupCodes)){$HostState.ConnectionState='Error';$HostState.SessionState='Error';$HostState.BlockAutomaticActions=$true;$HostState.Reason='WorkerFramingFailed'} + return } if(-not $poll.Completed){return} try{ - if($poll.ExitCode -isnot [int] -or $poll.StdoutByteCount -gt 1048576 -or $poll.StderrByteCount -gt 65536 -or $poll.StdoutOverflow -or $poll.StderrOverflow){throw 'worker framing failed'} + if($poll.ExitCode -isnot [int] -or @([int]0,[int]1) -notcontains $poll.ExitCode -or $poll.StdoutByteCount -gt 1048576 -or $poll.StderrByteCount -gt 65536 -or $poll.StdoutOverflow -or $poll.StderrOverflow){throw 'worker framing failed'} + $exited=Invoke-CcodSupervisorAdapter $Adapters.WaitWorker @($slot,[int]2000) 1 + if($exited-isnot[bool]-or-not$exited){throw 'worker exit is not proven'} if($slot.Kind-ceq'StaticProbe'-and[string]::IsNullOrEmpty($poll.StdoutText)){throw 'static probe result is missing'} + if($slot.Kind-ceq'Controller'-and[string]::IsNullOrEmpty($poll.StdoutText)){throw 'controller result is missing'} if(-not [string]::IsNullOrEmpty($poll.StdoutText)){ $result=Invoke-CcodSupervisorNullableAdapter $Adapters.ReadWorkerResult @($slot.ResultPath) if($null -eq $result){throw 'worker result is missing'} $fromStdout=$poll.StdoutText|ConvertFrom-Json -ErrorAction Stop if(($fromStdout|ConvertTo-Json -Depth 20 -Compress) -cne ($result|ConvertTo-Json -Depth 20 -Compress)){throw 'worker frames differ'} if($slot.Kind -ceq 'Controller'){ + if($result.ok -isnot [bool] -or ($result.ok -and $poll.ExitCode -ne 0) -or (-not $result.ok -and $poll.ExitCode -ne 1)){throw 'controller exit code does not match result'} $expectedSource=if($slot.Action -ceq 'RepairStale'){$slot.Request.source}else{$null} $reduced=Invoke-CcodSupervisorAdapter $Adapters.CompleteControllerRun @($result,$slot.Request.transactionId,$slot.Action,$slot.RuntimeId,$expectedSource) 1 if($null -ne $reduced){ @@ -1090,7 +1179,7 @@ function Invoke-CcodSupervisorPollSlot { else{$HostState.SessionState='Error';$HostState.BlockAutomaticActions=$true;$HostState.Reason='WorkerFramingFailed'} if($null -ne $staleRepairKey){$HostState.FailedStaleRepairKey=$staleRepairKey} } - finally{Clear-CcodSupervisorWorkerSlot $HostState $Adapters} + finally{if($null-ne$HostState.WorkerSlot){[void](Stop-CcodSupervisorOwnedWorkerForShutdown $HostState $Adapters -PropertyName WorkerSlot -Codes $HostState.RuntimeCleanupCodes)}} } function Get-CcodSupervisorResourcesRoot { @@ -1146,9 +1235,60 @@ function Throw-CcodSupervisorCommandError { throw [Management.Automation.ErrorRecord]::new([InvalidOperationException]::new($Message),$Code,[Management.Automation.ErrorCategory]::InvalidData,$Target) } +function Write-CcodSupervisorTrayHostReady { + param($HostState,[hashtable]$Adapters) + try{ + if($null -eq $HostState -or $null -eq $HostState.Tray -or $null -eq $HostState.Tray.Client -or $null -eq $HostState.Tray.Client.Receipt){throw 'tray receipt missing'} + $receipt=$HostState.Tray.Client.Receipt + if(-not (Test-CcodSupervisorExactProperties $receipt @('HostPid','HostCreationFileTimeUtc','RuntimeId','ProtocolMajor','Capabilities'))){throw 'tray receipt schema invalid'} + if($receipt.HostPid -isnot [int] -or $receipt.HostPid -le 0 -or + $receipt.HostCreationFileTimeUtc -isnot [long] -or $receipt.HostCreationFileTimeUtc -le 0 -or + $receipt.RuntimeId -isnot [string] -or $receipt.RuntimeId -cne [string]$HostState.Layout.RuntimeId -or + ($receipt.ProtocolMajor -isnot [int] -and $receipt.ProtocolMajor -isnot [UInt16]) -or $receipt.ProtocolMajor -ne 2 -or + $receipt.Capabilities -isnot [UInt64] -or $receipt.Capabilities -eq 0){throw 'tray receipt invalid'} + $now=Invoke-CcodSupervisorAdapter $Adapters.GetUtcNow @() 1 + if($now -is [DateTimeOffset]){$timestamp=$now.UtcDateTime.ToString('o',[Globalization.CultureInfo]::InvariantCulture)} + elseif($now -is [DateTime]){$timestamp=$now.ToUniversalTime().ToString('o',[Globalization.CultureInfo]::InvariantCulture)} + else{throw 'tray ready clock invalid'} + $hostTime=[DateTime]::FromFileTimeUtc([Int64]$receipt.HostCreationFileTimeUtc).ToString('o',[Globalization.CultureInfo]::InvariantCulture) + $hostIdentity=Invoke-CcodSupervisorAdapter $Adapters.GetTrayHostIdentity @([int]$receipt.HostPid,[string]$hostTime) 1 + if(-not (Test-CcodSupervisorExactProperties $hostIdentity @('Outcome','Pid','CreationTimeUtc')) -or + $hostIdentity.Outcome -isnot [string] -or $hostIdentity.Outcome -cne 'SameIdentity' -or + $hostIdentity.Pid -isnot [int] -or $hostIdentity.Pid -ne [int]$receipt.HostPid -or + $hostIdentity.CreationTimeUtc -isnot [string] -or $hostIdentity.CreationTimeUtc -cne $hostTime){throw 'tray process identity invalid'} + $record=[pscustomobject][ordered]@{ + schemaVersion=1;timestampUtc=$timestamp;component='Supervisor';stage='TrayHostReady';code='CCOD_TRAYHOST_READY';outcome='Completed' + runtimeId=[string]$receipt.RuntimeId;hostPid=[int]$receipt.HostPid;hostCreationTimeUtc=$hostTime;protocolMajor=[int]$receipt.ProtocolMajor;capabilities=[UInt64]$receipt.Capabilities + } + Invoke-CcodSupervisorAdapter $Adapters.WriteLog @($record) 0 + return $true + }catch{ + Add-CcodSupervisorCleanupCode $HostState.RuntimeCleanupCodes 'CCOD_SUPERVISOR_TRAY_READY_LOG_FAILED' + return $false + } +} + +function Write-CcodSupervisorTrayActionTerminal { + param($HostState,[hashtable]$Adapters,$Action,[ValidateSet('Completed','Rejected','Failed')][string]$Status,[AllowNull()][string]$ErrorCode) + $code=if($Status-ceq'Completed'){'CCOD_TRAY_ACTION_COMPLETED'}elseif($ErrorCode-is[string]-and$ErrorCode-cmatch'^CCOD_[A-Z0-9_]{1,91}\z'){$ErrorCode}else{'CCOD_TRAY_ACTION_FAILED'} + try{ + $now=Invoke-CcodSupervisorAdapter $Adapters.GetUtcNow @() 1 + if($now-is[DateTimeOffset]){$timestamp=$now.UtcDateTime.ToString('o',[Globalization.CultureInfo]::InvariantCulture)} + elseif($now-is[DateTime]){$timestamp=$now.ToUniversalTime().ToString('o',[Globalization.CultureInfo]::InvariantCulture)} + else{throw 'tray action clock is invalid'} + $record=[pscustomobject][ordered]@{ + schemaVersion=1;timestampUtc=$timestamp;component='Supervisor';stage='TrayAction';code=$code;outcome=$Status + command=[string]$Action.Command;revision=[UInt64]$Action.Revision;status=$Status + } + Invoke-CcodSupervisorAdapter $Adapters.WriteLog @($record) 0 + return $true + }catch{Add-CcodSupervisorCleanupCode $HostState.RuntimeCleanupCodes 'CCOD_SUPERVISOR_LOG_FAILED';return $false} +} + function Send-CcodSupervisorTrayActionResult { param($HostState,[hashtable]$Adapters,$Action,[ValidateSet('Accepted','Completed','Rejected','Failed')][string]$Status,[AllowNull()][string]$ErrorCode,[AllowNull()][string]$TransactionId) $result=[pscustomobject][ordered]@{ActionId=$Action.ActionId;Revision=[UInt64]$Action.Revision;Status=$Status;ErrorCode=$ErrorCode;TransactionId=$TransactionId} + if($Status-cne'Accepted'){[void](Write-CcodSupervisorTrayActionTerminal $HostState $Adapters $Action $Status $ErrorCode)} try{ $delivered=Invoke-CcodSupervisorAdapter $Adapters.SendTrayActionResult @($HostState.Tray,$result.ActionId,$result.Revision,$result.Status,$result.ErrorCode,$result.TransactionId) 1 if($delivered-isnot[bool]-or-not$delivered){throw 'tray action result was not acknowledged'} @@ -1194,7 +1334,7 @@ function Complete-CcodSupervisorLifecycleTrayAction { $delivery=Send-CcodSupervisorTrayActionResult $HostState $Adapters $entry.Action $status $code $entry.TransactionId if(-not$delivery.Delivered){return $false} $entry.TerminalSent=$true - }catch{Write-CcodSupervisorUiFailure $HostState $Adapters 'ErrorDialog' 'CCOD_TRAY_ACTION_RESULT_FAILED'} + }catch{Write-CcodSupervisorUiFailure $HostState $Adapters 'ErrorDialog' 'CCOD_TRAY_ACTION_RESULT_FAILED';return $false} } return $true } @@ -1263,7 +1403,7 @@ function Invoke-CcodSupervisorCommand { 'ShowAbout' { try{ $about=Invoke-CcodSupervisorAdapter $Adapters.VerifyActiveRuntimeForAbout @($HostState.Layout.InstallRoot,$HostState.Layout.RuntimeId) 1 - if(-not(Test-CcodSupervisorExactProperties $about @('RuntimeId','Version'))-or$about.RuntimeId-cne$HostState.Layout.RuntimeId-or$about.Version-isnot[string]-or$about.Version-cnotmatch'^\d+\.\d+\.\d+$'){throw 'about runtime is invalid'} + if(-not(Test-CcodSupervisorExactProperties $about @('RuntimeId','Version'))-or$about.RuntimeId-cne$HostState.Layout.RuntimeId-or$about.Version-isnot[string]-or$about.Version-cnotmatch'^\d+\.\d+\.\d+\z'){throw 'about runtime is invalid'} Set-CcodSupervisorCurrentTrayPresentation $HostState $Adapters $null -WaitForAcknowledgement return Send-CcodSupervisorTrayActionResult $HostState $Adapters $Command Completed $null $null }catch{return Send-CcodSupervisorTrayActionResult $HostState $Adapters $Command Failed 'CCOD_TRAY_ACTION_FAILED' $null} @@ -1350,7 +1490,7 @@ function Get-CcodSupervisorStaleReconciliationCandidate { $codex=$State.Status.session.codex if($codex.pid -isnot [int] -or $codex.pid -lt 1 -or -not(Test-CcodSupervisorCanonicalUtc $codex.creationTimeUtc) -or $codex.packageFullName -isnot [string] -or [string]::IsNullOrWhiteSpace($codex.packageFullName) -or $codex.packageVersion -isnot [string] -or [string]::IsNullOrWhiteSpace($codex.packageVersion) -or - $codex.appAsarSha256 -isnot [string] -or $codex.appAsarSha256 -cnotmatch '^[0-9a-f]{64}$' -or $codex.mainPort -isnot [int] -or $codex.rendererPort -isnot [int] -or + $codex.appAsarSha256 -isnot [string] -or $codex.appAsarSha256 -cnotmatch '^[0-9a-f]{64}\z' -or $codex.mainPort -isnot [int] -or $codex.rendererPort -isnot [int] -or $codex.mainPort -lt 1 -or $codex.mainPort -gt 65535 -or $codex.rendererPort -lt 1 -or $codex.rendererPort -gt 65535 -or $codex.mainPort -eq $codex.rendererPort -or $codex.mainProbe -cne 'Closed' -or $codex.rendererProbe -cne 'BridgeValid'){return $null} if($LivePackage.Found -isnot [bool] -or -not $LivePackage.Found -or $LivePackage.FullName -isnot [string] -or [string]::IsNullOrWhiteSpace($LivePackage.FullName) -or @@ -1533,7 +1673,7 @@ function Invoke-CcodSupervisorTick { function Invoke-CcodSupervisorHost { [CmdletBinding()] param([Parameter(Mandatory)][AllowEmptyString()][string]$ReadyToken,[hashtable]$Adapters) - if($ReadyToken -cnotmatch '^[0-9a-f]{64}$'){return New-CcodSupervisorReceipt 'StartupRejected' 2 @()} + if($ReadyToken -cnotmatch '^[0-9a-f]{64}\z'){return New-CcodSupervisorReceipt 'StartupRejected' 2 @()} $adapter=Get-CcodSupervisorAdapters $Adapters if($null -eq $adapter){return New-CcodSupervisorReceipt 'StartupRejected' 2 @()} $codes=[Collections.Generic.List[string]]::new();$outcome='Failed';$exitCode=1 @@ -1605,6 +1745,7 @@ function Invoke-CcodSupervisorHost { $tray=Invoke-CcodSupervisorAdapter $adapter.NewTray $trayArguments 1 if($null -eq $tray){throw 'tray contract is invalid'} $hostState.Tray=$tray + if(-not (Write-CcodSupervisorTrayHostReady $hostState $adapter)){throw 'TrayHost readiness evidence could not be written'} $onFull={}.GetNewClosure() $watcherArguments=[object[]]::new(2);$watcherArguments[0]=$eventQueue;$watcherArguments[1]=$onFull $watcher=Invoke-CcodSupervisorAdapter $adapter.NewWatcher $watcherArguments 1 @@ -1621,10 +1762,10 @@ function Invoke-CcodSupervisorHost { if($null -ne $hostState){$hostState.ShutdownRequested=$true} if($null-ne$hostState){if(-not(Stop-CcodSupervisorOwnedWorkerForShutdown $hostState $adapter LifecycleWorkerSlot $codes)){$workerCleanupSafe=$false};if(-not(Stop-CcodSupervisorOwnedWorkerForShutdown $hostState $adapter WorkerSlot $codes)){$workerCleanupSafe=$false}} if($null -ne $tray){Invoke-CcodSupervisorCleanupStage {Invoke-CcodSupervisorAdapter $adapter.StopTrayTimer @($tray) 0} $codes 'CCOD_SUPERVISOR_TIMER_STOP_FAILED'} - if($null -ne $watcher){Invoke-CcodSupervisorCleanupStage {Invoke-CcodSupervisorAdapter $adapter.StopWatcher @($watcher) 1|Out-Null} $codes 'CCOD_SUPERVISOR_WATCHER_STOP_FAILED'} + if($null -ne $watcher){Invoke-CcodSupervisorCleanupStage {$cleanupReceipt=Invoke-CcodSupervisorAdapter $adapter.StopWatcher @($watcher) 1;Add-CcodSupervisorNestedCleanupReceipt -Codes $codes -Receipt $cleanupReceipt -Kind Watcher} $codes 'CCOD_SUPERVISOR_WATCHER_STOP_FAILED'} if($null -ne $eventQueue){Invoke-CcodSupervisorCleanupStage {Invoke-CcodSupervisorDrainQueue $eventQueue $adapter} $codes 'CCOD_SUPERVISOR_QUEUE_DRAIN_FAILED'} if($null -ne $commandQueue){Invoke-CcodSupervisorCleanupStage {Invoke-CcodSupervisorDrainQueue $commandQueue $adapter} $codes 'CCOD_SUPERVISOR_QUEUE_DRAIN_FAILED'} - if($null -ne $tray){Invoke-CcodSupervisorCleanupStage {Invoke-CcodSupervisorAdapter $adapter.CloseTray @($tray) 1|Out-Null} $codes 'CCOD_SUPERVISOR_TRAY_CLOSE_FAILED'} + if($null -ne $tray){Invoke-CcodSupervisorCleanupStage {$cleanupReceipt=Invoke-CcodSupervisorAdapter $adapter.CloseTray @($tray) 1;Add-CcodSupervisorNestedCleanupReceipt -Codes $codes -Receipt $cleanupReceipt -Kind Tray} $codes 'CCOD_SUPERVISOR_TRAY_CLOSE_FAILED'} if($null -ne $readyEvent){Invoke-CcodSupervisorCleanupStage {Invoke-CcodSupervisorAdapter $adapter.CloseEvent @($readyEvent) 0} $codes 'CCOD_SUPERVISOR_READY_CLOSE_FAILED'} if($null -ne $shutdownEvent){Invoke-CcodSupervisorCleanupStage {Invoke-CcodSupervisorAdapter $adapter.CloseEvent @($shutdownEvent) 0} $codes 'CCOD_SUPERVISOR_SHUTDOWN_CLOSE_FAILED'} if($null-ne$lifecycleWakeEvent){Invoke-CcodSupervisorCleanupStage {Invoke-CcodSupervisorAdapter $adapter.CloseEvent @($lifecycleWakeEvent) 0} $codes 'CCOD_SUPERVISOR_LIFECYCLE_WAKE_CLOSE_FAILED'} diff --git a/src/persistence/UninstallBootstrap.ps1 b/src/persistence/UninstallBootstrap.ps1 index 779d04e..10a00c5 100644 --- a/src/persistence/UninstallBootstrap.ps1 +++ b/src/persistence/UninstallBootstrap.ps1 @@ -2,7 +2,8 @@ param( [string]$InstallerRoot, [string]$InstallRoot, - [ValidateSet('Prepare','FinalizeReceipt')][string]$Mode + [ValidateSet('Prepare','PrepareInstalled','FinalizeReceipt')][string]$Mode, + $WrapperIdentity ) Set-StrictMode -Version Latest @@ -10,17 +11,20 @@ $ErrorActionPreference = 'Stop' $script:CcodUninstallTransactionFields = @( 'schemaVersion','transactionId','runtimeId','runtimeGeneration','leaseEpoch','userSid','sessionId', - 'phase','resumePhase','startedAtUtc','updatedAtUtc','errorCode' + 'readyEvidence','installedBinding','phase','resumePhase','startedAtUtc','updatedAtUtc','errorCode' ) $script:CcodUninstallPhases = @( 'Requested','Recovering','RecoveryProven','StoppingProtection','ProtectionStopped','TaskRemoved', 'ApplicationStateRemoved','ReadyForInno','Completed','Failed' ) -$script:CcodUninstallContextFields = @('runtimeId','runtimeGeneration','leaseEpoch','userSid','sessionId','payloadRecords') +$script:CcodUninstallContextFields = @('runtimeId','runtimeGeneration','leaseEpoch','userSid','sessionId','readyEvidence','payloadRecords') $script:CcodUninstallPayloadEntries = @( 'src/persistence/UninstallBootstrap.ps1', 'src/persistence/PortableUninstallFinalizer.ps1', + 'src/persistence/InstalledUninstallFinalizer.ps1', + 'src/persistence/modules/GenerationReclamation.psm1', 'src/persistence/modules/InstallLifecycle.psm1', + 'src/persistence/modules/ProductRegistration.psm1', 'src/persistence/modules/PortableRelease.psm1', 'src/persistence/modules/PersistenceIO.psm1', 'src/persistence/modules/RuntimeManifest.psm1', @@ -199,6 +203,11 @@ function Read-CcodUninstallBootstrapJson { try { $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop if ($item.PSIsContainer -or $item -isnot [IO.FileInfo] -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or $item.Length -lt 1 -or $item.Length -gt $MaximumBytes) { throw 'invalid file' } + $streams=@(Get-Item -LiteralPath $Path -Stream * -ErrorAction Stop);if($streams.Count-ne1-or[string]$streams[0].Stream-cne':$DATA'){throw 'alternate stream'} + if($null-eq('CcodUninstallSelectorIdentity' -as[type])){Add-Type -TypeDefinition @' +using System; using System.ComponentModel; using System.IO; using System.Runtime.InteropServices; using Microsoft.Win32.SafeHandles; +public static class CcodUninstallSelectorIdentity { [StructLayout(LayoutKind.Sequential)] struct I { public uint A; public System.Runtime.InteropServices.ComTypes.FILETIME C,X,W; public uint V,H,L,N,Ih,Il; } [DllImport("kernel32.dll",SetLastError=true)] static extern bool GetFileInformationByHandle(SafeFileHandle h,out I i); public static uint Links(string p){using(FileStream s=new FileStream(p,FileMode.Open,FileAccess.Read,FileShare.Read)){I i;if(!GetFileInformationByHandle(s.SafeFileHandle,out i))throw new Win32Exception(Marshal.GetLastWin32Error());return i.N;}} } +'@};if([CcodUninstallSelectorIdentity]::Links($Path)-ne1){throw 'multi-link'} $json = [IO.File]::ReadAllText($Path,[Text.UTF8Encoding]::new($false,$true)) if (-not (Test-CcodUninstallBootstrapJsonHasNoDuplicateProperties $json)) { throw 'duplicate JSON properties' } $value = $json | ConvertFrom-Json -ErrorAction Stop @@ -257,16 +266,17 @@ function Get-CcodUninstallBootstrapRuntimeRecords { } function Get-CcodUninstallBootstrapRuntimeId { - param([Parameter(Mandatory)][string]$ProjectVersion,[Parameter(Mandatory)][object[]]$Records) + param([Parameter(Mandatory)][string]$ProjectVersion,[Parameter(Mandatory)][object[]]$Records,[Parameter(Mandatory)][string]$Nonce) if ($ProjectVersion -isnot [string] -or $ProjectVersion -notmatch '^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?$') { Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_RUNTIME_INVALID' 'The runtime manifest version is invalid' $ProjectVersion } $lines = [Collections.Generic.List[string]]::new() - foreach ($record in $Records) { $lines.Add(('{0}`t{1}`t{2}' -f $record.path,[int64]$record.length,$record.sha256)) } + foreach ($record in $Records) { $lines.Add(("{0}`t{1}`t{2}" -f $record.path,[int64]$record.length,$record.sha256)) } $sha = [Security.Cryptography.SHA256]::Create() try { $digest = [BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes(($lines -join "`n")))).Replace('-','').ToLowerInvariant() - return '{0}-{1}' -f $ProjectVersion,$digest.Substring(0,16) + if($Nonce-cnotmatch'^[0-9a-f]{32}$'){Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_RUNTIME_INVALID' 'The runtime nonce is invalid' $Nonce} + return '{0}-{1}-{2}' -f $ProjectVersion,$digest.Substring(0,16),$Nonce } finally { $sha.Dispose() } } @@ -316,7 +326,7 @@ function New-CcodUninstallBootstrapResumeContext { [Parameter(Mandatory)]$Transaction, [Parameter(Mandatory)]$Identity ) - Assert-CcodUninstallBootstrapTransaction $Transaction + Assert-CcodUninstallBootstrapTransaction $Transaction -ExpectedInstallRoot $InstallRoot if ($Transaction.userSid -cne $Identity.userSid -or $Transaction.sessionId -ne $Identity.sessionId) { Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_TRANSACTION_MISMATCH' 'The interrupted uninstall transaction does not match the current user and session' $Transaction } @@ -340,6 +350,7 @@ function New-CcodUninstallBootstrapResumeContext { leaseEpoch = [uint64]$Transaction.leaseEpoch userSid = [string]$Transaction.userSid sessionId = [int]$Transaction.sessionId + readyEvidence = $Transaction.readyEvidence # Resume never re-stages payload after the application-state boundary. # Zero records therefore fail closed if a later change tries to stage them. payloadRecords = New-CcodUninstallBootstrapPayloadRecords -ResumeOnly @@ -347,7 +358,7 @@ function New-CcodUninstallBootstrapResumeContext { } function Get-CcodUninstallBootstrapVerifiedRuntimeContext { - param([Parameter(Mandatory)][string]$InstallerRoot,[Parameter(Mandatory)][string]$InstallRoot) + param([Parameter(Mandatory)][string]$InstallerRoot,[Parameter(Mandatory)][string]$InstallRoot,[hashtable]$SelectorAdapters,[string]$InvocationPath=$PSCommandPath) try { $installer = Get-CcodUninstallBootstrapComparablePath -Path $InstallerRoot -Kind 'Installer root' $install = Get-CcodUninstallBootstrapComparablePath -Path $InstallRoot -Kind 'Install root' @@ -359,19 +370,21 @@ function Get-CcodUninstallBootstrapVerifiedRuntimeContext { } Assert-CcodUninstallBootstrapInstallRootOwner -Path $install -UserSid $identity.userSid $expectedScript = Resolve-CcodUninstallBootstrapChildPath -Root $installer -RelativePath 'src\persistence\UninstallBootstrap.ps1' -RequireLeafFile - if ([string]::IsNullOrWhiteSpace($PSCommandPath) -or (Get-CcodUninstallBootstrapFullPath -Path $PSCommandPath -Kind 'Bootstrap script') -cne $expectedScript) { - Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_BOOTSTRAP_INVALID' 'The uninstall bootstrap was not launched from the installed application root' $PSCommandPath - } - $activePath = Resolve-CcodUninstallBootstrapChildPath -Root $install -RelativePath 'active.json' -RequireLeafFile - $active = Read-CcodUninstallBootstrapJson -Path $activePath -Kind 'Active runtime pointer' - if (-not (Test-CcodUninstallBootstrapExactProperties $active @('schemaVersion','activeRuntime','previousRuntime','generation','updatedAtUtc')) -or - $active.schemaVersion -isnot [int] -or $active.schemaVersion -ne 2 -or - $active.activeRuntime -isnot [string] -or $active.activeRuntime -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$' -or - ($null -ne $active.previousRuntime -and ($active.previousRuntime -isnot [string] -or $active.previousRuntime -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$')) -or - -not (Test-CcodUninstallBootstrapCanonicalUtc $active.updatedAtUtc)) { - Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_RUNTIME_INVALID' 'The active runtime pointer is invalid' $activePath + if ([string]::IsNullOrWhiteSpace($InvocationPath) -or (Get-CcodUninstallBootstrapFullPath -Path $InvocationPath -Kind 'Bootstrap script') -cne $expectedScript) { + Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_BOOTSTRAP_INVALID' 'The uninstall bootstrap was not launched from the installed application root' $InvocationPath } - $generation = ConvertTo-CcodUninstallBootstrapUInt64 $active.generation 'active runtime generation' + try{$stateRoot=Resolve-CcodUninstallBootstrapChildPath -Root $install -RelativePath 'state' -AllowMissingLeaf}catch{Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_RUNTIME_INVALID' 'The active generation selector path is unsafe' $install} + $pointerRootItem=$null;$pointerRootAbsent=$false;$getSelectorRootItem=if($null-ne$SelectorAdapters-and$SelectorAdapters.ContainsKey('GetSelectorRootItem')){$SelectorAdapters.GetSelectorRootItem}else{{param($Path)Get-Item -LiteralPath $Path -Force -ErrorAction Stop}} + $stateRootItem=$null;$stateRootAbsent=$false + try{$stateRootItem=&$getSelectorRootItem $stateRoot;if($null-eq$stateRootItem){throw [IO.InvalidDataException]::new('state lookup returned no proof')}}catch [Management.Automation.ItemNotFoundException]{$stateRootAbsent=$true}catch{Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_RUNTIME_INVALID' 'The active generation state ancestor lookup failed' $stateRoot} + if(-not$stateRootAbsent-and(-not$stateRootItem.PSIsContainer-or($stateRootItem.Attributes-band[IO.FileAttributes]::ReparsePoint)-ne0)){Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_RUNTIME_INVALID' 'The active generation selector state ancestor is not a plain directory' $stateRoot} + if($stateRootAbsent){$pointerRootAbsent=$true}else{try{$pointerRoot=Resolve-CcodUninstallBootstrapChildPath -Root $install -RelativePath 'state\active-generation' -AllowMissingLeaf}catch{Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_RUNTIME_INVALID' 'The active generation selector path is unsafe' $install};try{$pointerRootItem=&$getSelectorRootItem $pointerRoot;if($null-eq$pointerRootItem){throw [IO.InvalidDataException]::new('selector lookup returned no proof')}}catch [Management.Automation.ItemNotFoundException]{$pointerRootAbsent=$true}catch{Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_RUNTIME_INVALID' 'The active generation selector root lookup failed' $pointerRoot}} + if(-not$pointerRootAbsent-and-not$pointerRootItem.PSIsContainer){throw 'selector root type'} + if(-not$pointerRootAbsent){ + $entries=@(Get-ChildItem -LiteralPath $pointerRoot -Force -ErrorAction Stop);if($entries.Count-eq0){throw 'empty selector'};$records=[Collections.Generic.List[object]]::new() + foreach($entry in $entries){if($entry.PSIsContainer-or$entry.Name-cnotmatch'^\d{20}\.json$'){throw 'selector entry'};try{$path=Resolve-CcodUninstallBootstrapChildPath -Root $install -RelativePath ('state\active-generation\'+$entry.Name) -RequireLeafFile;$record=Read-CcodUninstallBootstrapJson -Path $path -Kind 'Active generation'}catch{Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_RUNTIME_INVALID' 'The active generation selector leaf is unsafe' $entry.FullName};$integerTypes=@([byte],[uint16],[uint32],[uint64],[int16],[int32],[int64]);$gType=$false;$pType=$false;foreach($t in $integerTypes){if($record.generation-is$t){$gType=$true};if($record.previousGeneration-is$t){$pType=$true}};if(-not(Test-CcodUninstallBootstrapExactProperties $record @('schemaVersion','generation','activeRuntime','previousGeneration'))-or$record.schemaVersion-isnot[int]-or$record.schemaVersion-ne1-or-not$gType-or-not$pType-or$record.activeRuntime-isnot[string]-or$record.activeRuntime-cnotmatch'^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$'){throw 'selector record'};[uint64]$g=ConvertTo-CcodUninstallBootstrapUInt64 $record.generation 'active generation';[uint64]$p=[uint64]$record.previousGeneration;if($p-eq[uint64]::MaxValue-or$g-ne($p+1)-or$entry.Name-cne('{0:D20}.json'-f$g)){throw 'selector canonical'};$records.Add([pscustomobject]@{generation=$g;previousGeneration=$p;activeRuntime=[string]$record.activeRuntime})} + $ordered=@($records|Sort-Object generation);for($i=0;$i-lt$ordered.Count;$i++){if([uint64]$ordered[$i].generation-ne[uint64]($i+1)-or[uint64]$ordered[$i].previousGeneration-ne[uint64]$i){throw 'selector chain'}};$latest=$ordered[-1];$active=[pscustomobject]@{activeRuntime=$latest.activeRuntime};$generation=[uint64]$latest.generation + }else{$activePath = Resolve-CcodUninstallBootstrapChildPath -Root $install -RelativePath 'active.json' -RequireLeafFile;$active = Read-CcodUninstallBootstrapJson -Path $activePath -Kind 'Active runtime pointer';if (-not (Test-CcodUninstallBootstrapExactProperties $active @('schemaVersion','activeRuntime','previousRuntime','generation','updatedAtUtc')) -or$active.schemaVersion -isnot [int] -or $active.schemaVersion -ne 2 -or$active.activeRuntime -isnot [string] -or $active.activeRuntime -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$' -or($null -ne $active.previousRuntime -and ($active.previousRuntime -isnot [string] -or $active.previousRuntime -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$')) -or-not (Test-CcodUninstallBootstrapCanonicalUtc $active.updatedAtUtc)){Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_RUNTIME_INVALID' 'The active runtime pointer is invalid' $activePath};$generation = ConvertTo-CcodUninstallBootstrapUInt64 $active.generation 'active runtime generation'} $runtimeRoot = Resolve-CcodUninstallBootstrapChildPath -Root $install -RelativePath ('runtime\' + $active.activeRuntime) $runtimeItem = Get-Item -LiteralPath $runtimeRoot -Force -ErrorAction Stop if (-not $runtimeItem.PSIsContainer) { Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_RUNTIME_INVALID' 'The active runtime directory is invalid' $runtimeRoot } @@ -401,7 +414,8 @@ function Get-CcodUninstallBootstrapVerifiedRuntimeContext { } $recordMap[$expected.path] = $expected } - if ((Get-CcodUninstallBootstrapRuntimeId -ProjectVersion $manifest.projectVersion -Records $actualRecords) -cne $active.activeRuntime) { + $identityMatch=[regex]::Match([string]$active.activeRuntime,'^(?[A-Za-z0-9][A-Za-z0-9._-]{0,45})-(?[0-9a-f]{16})-(?[0-9a-f]{32})$') + if(-not$identityMatch.Success-or$identityMatch.Groups['version'].Value-cne[string]$manifest.projectVersion-or(Get-CcodUninstallBootstrapRuntimeId -ProjectVersion $manifest.projectVersion -Records $actualRecords -Nonce $identityMatch.Groups['nonce'].Value) -cne $active.activeRuntime) { Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_RUNTIME_INVALID' 'The active runtime ID does not match its verified manifest' $runtimeRoot } foreach ($entry in $script:CcodUninstallPayloadEntries) { @@ -420,12 +434,15 @@ function Get-CcodUninstallBootstrapVerifiedRuntimeContext { Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_RUNTIME_INVALID' 'The lifecycle epoch is invalid' $epochPath } $epoch = ConvertTo-CcodUninstallBootstrapUInt64 $epochState.epoch 'lifecycle epoch' + $manifestFingerprint=Get-CcodUninstallBootstrapFileFingerprint -Path $manifestPath;$readyTransaction=$null;if([string]$manifest.projectVersion-ceq'2.5.22'){$lifecycleModule=Import-Module (Join-Path $runtimeRoot 'src\persistence\modules\InstallLifecycle.psm1') -Force -PassThru -ErrorAction Stop;$readyTransaction=&$lifecycleModule {param($Root)Read-CcodInstallTransactionRecord -InstallRoot $Root} $install;if($null-eq$readyTransaction-or$readyTransaction.phase-cne'Ready'-or$readyTransaction.newRuntimeId-cne$active.activeRuntime-or[uint64]$readyTransaction.newGeneration-ne[uint64]$generation-or$readyTransaction.newManifestSha256-cne$manifestFingerprint.sha256){Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_RUNTIME_INVALID' 'Exact Ready install transaction is missing' $runtimeRoot}} + $system=[Environment]::GetFolderPath([Environment+SpecialFolder]::System);$readyEvidence=[pscustomobject][ordered]@{phase='Ready';installRoot=$install;runtimeId=[string]$active.activeRuntime;runtimeGeneration=[uint64]$generation;packageSha256=$(if($null-ne$readyTransaction){[string]$readyTransaction.sealedPackageSha256}else{'0'*64});manifestSha256=[string]$manifestFingerprint.sha256;startMenuSha256=$(if($recordMap.ContainsKey('registration/StartMenu.CodexRemote-fix.lnk')){[string]$recordMap['registration/StartMenu.CodexRemote-fix.lnk'].sha256}else{'0'*64});desktopSha256=$(if($recordMap.ContainsKey('registration/Desktop.CodexRemote-fix.lnk')){[string]$recordMap['registration/Desktop.CodexRemote-fix.lnk'].sha256}else{'0'*64});targetPath=[IO.Path]::GetFullPath((Join-Path $system 'schtasks.exe'));arguments='/Run /TN "Codex Control Other Devices Supervisor"'} return [pscustomobject][ordered]@{ runtimeId = [string]$active.activeRuntime runtimeGeneration = [uint64]$generation leaseEpoch = [uint64]$epoch userSid = [string]$identity.userSid sessionId = [int]$identity.sessionId + readyEvidence = $readyEvidence payloadRecords = New-CcodUninstallBootstrapPayloadRecords -RecordMap $recordMap } } catch { @@ -588,7 +605,8 @@ function Read-CcodUninstallBootstrapStoredTransaction { param( [Parameter(Mandatory)][string]$TransactionRoot, [string]$ExpectedUserSid, - [switch]$IncludeCompleted + [switch]$IncludeCompleted, + [string]$ExpectedInstallRoot ) if (-not [IO.Directory]::Exists($TransactionRoot)) { return $null } $root = Get-CcodUninstallBootstrapComparablePath -Path $TransactionRoot -Kind 'External uninstall transaction root' @@ -606,7 +624,7 @@ function Read-CcodUninstallBootstrapStoredTransaction { Assert-CcodUninstallBootstrapSafePath -Root $root -Path $transactionDirectory | Out-Null $transactionPath = Resolve-CcodUninstallBootstrapChildPath -Root $transactionDirectory -RelativePath 'transaction.json' -RequireLeafFile $transaction = Read-CcodUninstallBootstrapJson -Path $transactionPath -Kind 'Uninstall transaction' - Assert-CcodUninstallBootstrapTransaction $transaction + Assert-CcodUninstallBootstrapTransaction $transaction -ExpectedInstallRoot $ExpectedInstallRoot if ($transaction.transactionId -cne $current.transactionId) { Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_TRANSACTION_INVALID' 'The uninstall transaction locator does not match its receipt' $transactionPath } if (-not [string]::IsNullOrWhiteSpace($ExpectedUserSid) -and $transaction.userSid -cne $ExpectedUserSid) { Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_TRANSACTION_MISMATCH' 'The external uninstall transaction belongs to another user' $transactionPath @@ -664,11 +682,12 @@ function Assert-CcodUninstallBootstrapFinalizationInvocation { param( [Parameter(Mandatory)][string]$TransactionRoot, [Parameter(Mandatory)]$Transaction, - [Parameter(Mandatory)]$Identity + [Parameter(Mandatory)]$Identity, + [string]$InvocationPath=$PSCommandPath ) Assert-CcodUninstallBootstrapTransaction $Transaction - if ($Transaction.userSid -cne $Identity.userSid -or $Transaction.sessionId -ne $Identity.sessionId) { - Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_TRANSACTION_MISMATCH' 'The finalizing uninstall transaction does not match the current user and session' $Transaction + if ($Transaction.userSid -cne $Identity.userSid) { + Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_TRANSACTION_MISMATCH' 'The finalizing uninstall transaction does not match the current user' $Transaction } $root = Get-CcodUninstallBootstrapComparablePath -Path $TransactionRoot -Kind 'External uninstall transaction root' Assert-CcodUninstallBootstrapDirectoryAcl -Path $root -UserSid $Identity.userSid @@ -676,9 +695,9 @@ function Assert-CcodUninstallBootstrapFinalizationInvocation { Assert-CcodUninstallBootstrapSafePath -Root $root -Path $directory | Out-Null Assert-CcodUninstallBootstrapDirectoryAcl -Path $directory -UserSid $Identity.userSid $expectedScript = Resolve-CcodUninstallBootstrapChildPath -Root $directory -RelativePath 'payload\src\persistence\UninstallBootstrap.ps1' -RequireLeafFile - if ([string]::IsNullOrWhiteSpace($PSCommandPath) -or - (Get-CcodUninstallBootstrapFullPath -Path $PSCommandPath -Kind 'Staged bootstrap script') -cne $expectedScript) { - Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_BOOTSTRAP_INVALID' 'The finalization bootstrap was not launched from the staged transaction payload' $PSCommandPath + if ([string]::IsNullOrWhiteSpace($InvocationPath) -or + (Get-CcodUninstallBootstrapFullPath -Path $InvocationPath -Kind 'Staged bootstrap script') -cne $expectedScript) { + Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_BOOTSTRAP_INVALID' 'The finalization bootstrap was not launched from the staged transaction payload' $InvocationPath } } @@ -710,7 +729,7 @@ function Write-CcodUninstallBootstrapStoredReceipt { function Stage-CcodUninstallBootstrapPayload { param([Parameter(Mandatory)][string]$InstallRoot,[Parameter(Mandatory)]$Context,[Parameter(Mandatory)][string]$TransactionDirectory) - Assert-CcodUninstallBootstrapContext $Context + Assert-CcodUninstallBootstrapContext $Context -ExpectedInstallRoot $InstallRoot $install = Get-CcodUninstallBootstrapComparablePath -Path $InstallRoot -Kind 'Install root' $runtimeRoot = Resolve-CcodUninstallBootstrapChildPath -Root $install -RelativePath ('runtime\' + $Context.runtimeId) $transactionDirectory = Get-CcodUninstallBootstrapComparablePath -Path $TransactionDirectory -Kind 'Uninstall transaction directory' @@ -775,17 +794,92 @@ function ConvertTo-CcodUninstallBootstrapUInt64 { try { return [uint64]$Value } catch { Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_TRANSACTION_INVALID' "$Name is outside the unsigned range" $Value } } +function Assert-CcodUninstallBootstrapReadyEvidence { + param( + [Parameter(Mandatory)]$ReadyEvidence, + [Parameter(Mandatory)][string]$ExpectedInstallRoot, + [Parameter(Mandatory)][string]$ExpectedRuntimeId, + [Parameter(Mandatory)]$ExpectedRuntimeGeneration, + [Parameter(Mandatory)][string]$ErrorId + ) + $fields=@('phase','installRoot','runtimeId','runtimeGeneration','packageSha256','manifestSha256','startMenuSha256','desktopSha256','targetPath','arguments') + try{ + $install=Get-CcodUninstallBootstrapComparablePath -Path $ExpectedInstallRoot -Kind 'Install root' + $readyInstall=Get-CcodUninstallBootstrapComparablePath -Path ([string]$ReadyEvidence.installRoot) -Kind 'Ready install root' + $canonicalTarget=Get-CcodUninstallBootstrapFullPath -Path (Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::System)) 'schtasks.exe') -Kind 'Task target' + $generation=ConvertTo-CcodUninstallBootstrapUInt64 $ReadyEvidence.runtimeGeneration 'Ready runtimeGeneration' + $expectedGeneration=ConvertTo-CcodUninstallBootstrapUInt64 $ExpectedRuntimeGeneration 'Expected runtimeGeneration' + if(-not(Test-CcodUninstallBootstrapExactProperties $ReadyEvidence $fields)-or + $ReadyEvidence.phase-isnot[string]-or$ReadyEvidence.phase-cne'Ready'-or + $readyInstall-cne$install-or$ReadyEvidence.runtimeId-isnot[string]-or$ReadyEvidence.runtimeId-cne$ExpectedRuntimeId-or + $generation-ne$expectedGeneration-or$ReadyEvidence.packageSha256-isnot[string]-or$ReadyEvidence.packageSha256-cnotmatch'^[0-9a-f]{64}$'-or + $ReadyEvidence.manifestSha256-isnot[string]-or$ReadyEvidence.manifestSha256-cnotmatch'^[0-9a-f]{64}$'-or + $ReadyEvidence.startMenuSha256-isnot[string]-or$ReadyEvidence.startMenuSha256-cnotmatch'^[0-9a-f]{64}$'-or + $ReadyEvidence.desktopSha256-isnot[string]-or$ReadyEvidence.desktopSha256-cnotmatch'^[0-9a-f]{64}$'-or + $ReadyEvidence.targetPath-isnot[string]-or(Get-CcodUninstallBootstrapFullPath -Path $ReadyEvidence.targetPath -Kind 'Ready task target')-cne$canonicalTarget-or + $ReadyEvidence.arguments-isnot[string]-or$ReadyEvidence.arguments-cne'/Run /TN "Codex Control Other Devices Supervisor"'){throw 'Ready evidence mismatch'} + }catch{ + Throw-CcodUninstallBootstrapError $ErrorId 'Ready cleanup evidence is invalid' $ReadyEvidence + } + return $ReadyEvidence +} + +function Read-CcodUninstallBootstrapStoredReceipt { + param([Parameter(Mandatory)][string]$TransactionDirectory) + $directory=Get-CcodUninstallBootstrapComparablePath -Path $TransactionDirectory -Kind 'Uninstall transaction directory';$path=Resolve-CcodUninstallBootstrapChildPath -Root $directory -RelativePath 'receipt.json' -RequireLeafFile;Read-CcodUninstallBootstrapJson -Path $path -Kind 'Uninstall receipt' +} + +function Test-CcodUninstallBootstrapReceiptMatchesTransaction { + param($Receipt,[Parameter(Mandatory)]$Transaction) + try{ + if(-not(Test-CcodUninstallBootstrapExactProperties $Receipt @('schemaVersion','transactionId','runtimeId','runtimeGeneration','leaseEpoch','phase','updatedAtUtc','errorCode'))){return $false} + $errorMatches=($null-eq$Receipt.errorCode-and$null-eq$Transaction.errorCode)-or($Receipt.errorCode-is[string]-and$Transaction.errorCode-is[string]-and$Receipt.errorCode-cne''-and$Receipt.errorCode-ceq$Transaction.errorCode) + return $Receipt.schemaVersion-is[int]-and$Receipt.schemaVersion-eq1-and$Receipt.transactionId-is[string]-and$Receipt.transactionId-ceq$Transaction.transactionId-and$Receipt.runtimeId-is[string]-and$Receipt.runtimeId-ceq$Transaction.runtimeId-and + [uint64]$Receipt.runtimeGeneration-eq[uint64]$Transaction.runtimeGeneration-and[uint64]$Receipt.leaseEpoch-eq[uint64]$Transaction.leaseEpoch-and$Receipt.phase-is[string]-and$Receipt.phase-ceq$Transaction.phase-and + $Receipt.updatedAtUtc-is[string]-and$Receipt.updatedAtUtc-ceq$Transaction.updatedAtUtc-and$errorMatches + }catch{return $false} +} + +function Test-CcodUninstallBootstrapStoredCompletedReceipt { + param([Parameter(Mandatory)][string]$TransactionRoot,[Parameter(Mandatory)]$Transaction,[Parameter(Mandatory)][string]$ExpectedUserSid) + try{ + Assert-CcodUninstallBootstrapTransaction $Transaction + if($Transaction.phase-cne'Completed'-or$Transaction.resumePhase-cne'Completed'-or$Transaction.userSid-cne$ExpectedUserSid){return $false} + $root=Get-CcodUninstallBootstrapComparablePath -Path $TransactionRoot -Kind 'External uninstall transaction root';Assert-CcodUninstallBootstrapDirectoryAcl -Path $root -UserSid $ExpectedUserSid + $directory=Get-CcodUninstallBootstrapTransactionPath -TransactionRoot $root -TransactionId $Transaction.transactionId;Assert-CcodUninstallBootstrapSafePath -Root $root -Path $directory|Out-Null;Assert-CcodUninstallBootstrapDirectoryAcl -Path $directory -UserSid $ExpectedUserSid + $path=Resolve-CcodUninstallBootstrapChildPath -Root $directory -RelativePath 'receipt.json' -RequireLeafFile;$receipt=Read-CcodUninstallBootstrapJson -Path $path -Kind 'Uninstall receipt' + if(-not(Test-CcodUninstallBootstrapExactProperties $receipt @('schemaVersion','transactionId','runtimeId','runtimeGeneration','leaseEpoch','phase','updatedAtUtc','errorCode'))){return $false} + return $receipt.schemaVersion-is[int]-and$receipt.schemaVersion-eq1-and$receipt.transactionId-cne$null-and$receipt.transactionId-ceq$Transaction.transactionId-and$receipt.runtimeId-ceq$Transaction.runtimeId-and[uint64]$receipt.runtimeGeneration-eq[uint64]$Transaction.runtimeGeneration-and[uint64]$receipt.leaseEpoch-eq[uint64]$Transaction.leaseEpoch-and$receipt.phase-ceq'Completed'-and$receipt.updatedAtUtc-ceq$Transaction.updatedAtUtc-and$null-eq$receipt.errorCode + }catch{return $false} +} + +function Get-CcodUninstallBootstrapInstalledResumeCommand { + param( + [Parameter(Mandatory)][string]$ResumeScriptPath, + [Parameter(Mandatory)][string]$TransactionId, + [Parameter(Mandatory)][string]$RuntimeRoot, + [Parameter(Mandatory)][string]$InstallRoot + ) + foreach($value in @($ResumeScriptPath,$RuntimeRoot,$InstallRoot)){if($value.Contains('"')){Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_BOOTSTRAP_INVALID' 'Installed resume command path contains a quote.' $value}} + $system=[Environment]::GetFolderPath([Environment+SpecialFolder]::System);if([string]::IsNullOrWhiteSpace($system)){Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_BOOTSTRAP_INVALID' 'Windows system folder is unavailable.' $null} + $powershell=[IO.Path]::GetFullPath((Join-Path $system 'WindowsPowerShell\v1.0\powershell.exe')) + return '"{0}" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "{1}" -Resume -TransactionId "{2}" -RuntimeRoot "{3}" -InstallRoot "{4}"'-f$powershell,[IO.Path]::GetFullPath($ResumeScriptPath),$TransactionId,[IO.Path]::GetFullPath($RuntimeRoot),[IO.Path]::GetFullPath($InstallRoot) +} + function Assert-CcodUninstallBootstrapContext { - param([Parameter(Mandatory)]$Context) + param([Parameter(Mandatory)]$Context,[string]$ExpectedInstallRoot) if (-not (Test-CcodUninstallBootstrapExactProperties $Context $script:CcodUninstallContextFields) -or $Context.runtimeId -isnot [string] -or $Context.runtimeId -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$' -or $Context.userSid -isnot [string] -or $Context.userSid -cnotmatch '^S-\d-\d+(?:-\d+)+$' -or $Context.sessionId -isnot [int] -or $Context.sessionId -lt 0 -or + $null -eq $Context.readyEvidence -or $null -eq $Context.payloadRecords) { Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_BOOTSTRAP_INVALID' 'The uninstall bootstrap context is invalid' $Context } [void](ConvertTo-CcodUninstallBootstrapUInt64 $Context.runtimeGeneration 'runtimeGeneration') [void](ConvertTo-CcodUninstallBootstrapUInt64 $Context.leaseEpoch 'leaseEpoch') + $readyRoot=if([string]::IsNullOrWhiteSpace($ExpectedInstallRoot)){[string]$Context.readyEvidence.installRoot}else{$ExpectedInstallRoot} + [void](Assert-CcodUninstallBootstrapReadyEvidence -ReadyEvidence $Context.readyEvidence -ExpectedInstallRoot $readyRoot -ExpectedRuntimeId $Context.runtimeId -ExpectedRuntimeGeneration $Context.runtimeGeneration -ErrorId 'CCOD_UNINSTALL_BOOTSTRAP_INVALID') $records = @($Context.payloadRecords) if ($records.Count -ne $script:CcodUninstallPayloadEntries.Count) { Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_BOOTSTRAP_INVALID' 'The cleanup payload record set is incomplete' $Context } for ($index = 0; $index -lt $records.Count; $index++) { @@ -801,7 +895,7 @@ function Assert-CcodUninstallBootstrapContext { } function Assert-CcodUninstallBootstrapTransaction { - param([Parameter(Mandatory)]$Transaction) + param([Parameter(Mandatory)]$Transaction,[string]$ExpectedInstallRoot) if (-not (Test-CcodUninstallBootstrapExactProperties $Transaction $script:CcodUninstallTransactionFields) -or $Transaction.schemaVersion -isnot [int] -or $Transaction.schemaVersion -ne 1 -or -not (Test-CcodUninstallBootstrapCanonicalGuid $Transaction.transactionId) -or @@ -817,6 +911,25 @@ function Assert-CcodUninstallBootstrapTransaction { } [void](ConvertTo-CcodUninstallBootstrapUInt64 $Transaction.runtimeGeneration 'runtimeGeneration') [void](ConvertTo-CcodUninstallBootstrapUInt64 $Transaction.leaseEpoch 'leaseEpoch') + $readyRoot=if([string]::IsNullOrWhiteSpace($ExpectedInstallRoot)){[string]$Transaction.readyEvidence.installRoot}else{$ExpectedInstallRoot} + [void](Assert-CcodUninstallBootstrapReadyEvidence -ReadyEvidence $Transaction.readyEvidence -ExpectedInstallRoot $readyRoot -ExpectedRuntimeId $Transaction.runtimeId -ExpectedRuntimeGeneration $Transaction.runtimeGeneration -ErrorId 'CCOD_UNINSTALL_TRANSACTION_INVALID') + if($null-ne$Transaction.installedBinding){ + $binding=$Transaction.installedBinding + try{$resumeLength=ConvertTo-CcodUninstallBootstrapUInt64 $binding.resumeScriptLength 'resumeScriptLength'}catch{Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_TRANSACTION_INVALID' 'Installed finalization binding is invalid' $binding} + $resumeWrapperEmpty=$null-eq$binding.resumeWrapperPid-and$null-eq$binding.resumeWrapperCreationTimeUtc-and$null-eq$binding.resumeWrapperSessionId-and$null-eq$binding.resumeWrapperUserSid + $resumeWrapperValid=$binding.resumeWrapperPid-is[int]-and$binding.resumeWrapperPid-gt0-and(Test-CcodUninstallBootstrapCanonicalUtc $binding.resumeWrapperCreationTimeUtc)-and$binding.resumeWrapperSessionId-is[int]-and$binding.resumeWrapperSessionId-ge0-and$binding.resumeWrapperUserSid-is[string]-and$binding.resumeWrapperUserSid-ceq$Transaction.userSid + if(-not(Test-CcodUninstallBootstrapExactProperties $binding @('selectedRuntimeRoot','runtimeManifestSha256','wrapperPid','wrapperCreationTimeUtc','wrapperSessionId','wrapperUserSid','resumeWrapperPid','resumeWrapperCreationTimeUtc','resumeWrapperSessionId','resumeWrapperUserSid','resumeScriptPath','resumeScriptLength','resumeScriptSha256','resumeCommand','payloadRecords'))-or + $binding.selectedRuntimeRoot-isnot[string]-or-not[IO.Path]::IsPathRooted($binding.selectedRuntimeRoot)-or$binding.runtimeManifestSha256-isnot[string]-or$binding.runtimeManifestSha256-cnotmatch'^[0-9a-f]{64}$'-or + $binding.wrapperPid-isnot[int]-or$binding.wrapperPid-lt1-or-not(Test-CcodUninstallBootstrapCanonicalUtc $binding.wrapperCreationTimeUtc)-or$binding.wrapperSessionId-isnot[int]-or$binding.wrapperSessionId-lt0-or$binding.wrapperSessionId-ne$Transaction.sessionId-or + $binding.wrapperUserSid-isnot[string]-or$binding.wrapperUserSid-cnotmatch'^S-'-or$binding.wrapperUserSid-cne$Transaction.userSid-or$binding.resumeScriptPath-isnot[string]-or-not[IO.Path]::IsPathRooted($binding.resumeScriptPath)-or + [char[]]$binding.resumeScriptPath-ccontains[char]'"'-or[char[]]$binding.selectedRuntimeRoot-ccontains[char]'"'-or$binding.resumeScriptSha256-isnot[string]-or$binding.resumeScriptSha256-cnotmatch'^[0-9a-f]{64}$'-or + (-not$resumeWrapperEmpty-and-not$resumeWrapperValid)-or$binding.resumeCommand-isnot[string]-or$binding.resumeCommand-cne(Get-CcodUninstallBootstrapInstalledResumeCommand -ResumeScriptPath $binding.resumeScriptPath -TransactionId $Transaction.transactionId -RuntimeRoot $binding.selectedRuntimeRoot -InstallRoot $Transaction.readyEvidence.installRoot)){ + Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_TRANSACTION_INVALID' 'Installed finalization binding is invalid' $binding + } + $records=@($binding.payloadRecords);if($records.Count-ne$script:CcodUninstallPayloadEntries.Count){Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_TRANSACTION_INVALID' 'Installed finalization payload binding is incomplete' $binding} + for($index=0;$index-lt$records.Count;$index++){$record=$records[$index];if(-not(Test-CcodUninstallBootstrapExactProperties $record @('path','length','sha256'))-or$record.path-isnot[string]-or$record.path-cne$script:CcodUninstallPayloadEntries[$index]-or$record.sha256-isnot[string]-or$record.sha256-cnotmatch'^[0-9a-f]{64}$'){Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_TRANSACTION_INVALID' 'Installed finalization payload binding is invalid' $record};[void](ConvertTo-CcodUninstallBootstrapUInt64 $record.length 'payload length')} + $resumeRecord=@($records|Where-Object{$_.path-ceq'src/persistence/InstalledUninstallFinalizer.ps1'});if($resumeRecord.Count-ne1-or[uint64]$resumeRecord[0].length-ne$resumeLength-or$resumeRecord[0].sha256-cne$binding.resumeScriptSha256){Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_TRANSACTION_INVALID' 'Installed resume script is not bound to the complete payload' $binding} + } $started = [DateTime]::ParseExact($Transaction.startedAtUtc,'o',[Globalization.CultureInfo]::InvariantCulture,[Globalization.DateTimeStyles]::RoundtripKind) $updated = [DateTime]::ParseExact($Transaction.updatedAtUtc,'o',[Globalization.CultureInfo]::InvariantCulture,[Globalization.DateTimeStyles]::RoundtripKind) $resumable = @('Requested','Recovering','RecoveryProven','StoppingProtection','ProtectionStopped','TaskRemoved','ApplicationStateRemoved','ReadyForInno') @@ -828,7 +941,7 @@ function Assert-CcodUninstallBootstrapTransaction { } function New-CcodUninstallBootstrapTransaction { - param([Parameter(Mandatory)]$Context,[Parameter(Mandatory)][string]$TransactionId,[Parameter(Mandatory)][DateTime]$NowUtc) + param([Parameter(Mandatory)]$Context,[Parameter(Mandatory)][string]$TransactionId,[Parameter(Mandatory)][DateTime]$NowUtc,$InstalledBinding) $timestamp = $NowUtc.ToUniversalTime().ToString('o',[Globalization.CultureInfo]::InvariantCulture) $transaction = [pscustomobject][ordered]@{ schemaVersion = 1 @@ -838,6 +951,8 @@ function New-CcodUninstallBootstrapTransaction { leaseEpoch = [uint64]$Context.leaseEpoch userSid = [string]$Context.userSid sessionId = [int]$Context.sessionId + readyEvidence = $Context.readyEvidence + installedBinding = $InstalledBinding phase = 'Requested' resumePhase = 'Requested' startedAtUtc = $timestamp @@ -849,15 +964,110 @@ function New-CcodUninstallBootstrapTransaction { } function Assert-CcodUninstallBootstrapTransactionMatchesContext { - param([Parameter(Mandatory)]$Transaction,[Parameter(Mandatory)]$Context) - Assert-CcodUninstallBootstrapTransaction $Transaction - Assert-CcodUninstallBootstrapContext $Context + param([Parameter(Mandatory)]$Transaction,[Parameter(Mandatory)]$Context,[string]$ExpectedInstallRoot) + Assert-CcodUninstallBootstrapTransaction $Transaction -ExpectedInstallRoot $ExpectedInstallRoot + Assert-CcodUninstallBootstrapContext $Context -ExpectedInstallRoot $ExpectedInstallRoot if ($Transaction.runtimeId -cne $Context.runtimeId -or [uint64]$Transaction.runtimeGeneration -ne [uint64]$Context.runtimeGeneration -or - [uint64]$Transaction.leaseEpoch -ne [uint64]$Context.leaseEpoch -or $Transaction.userSid -cne $Context.userSid -or $Transaction.sessionId -ne $Context.sessionId) { + [uint64]$Transaction.leaseEpoch -ne [uint64]$Context.leaseEpoch -or $Transaction.userSid -cne $Context.userSid -or $Transaction.sessionId -ne $Context.sessionId -or + ($Transaction.readyEvidence|ConvertTo-Json -Depth 8 -Compress)-cne($Context.readyEvidence|ConvertTo-Json -Depth 8 -Compress)) { Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_TRANSACTION_MISMATCH' 'The existing uninstall transaction does not match the current verified runtime' $Transaction } } +function Initialize-CcodUninstallPayloadAuthority { + if ($null -ne ('CcodUninstallPayloadAuthorityV1' -as [type])) { return } + Add-Type -ErrorAction Stop -TypeDefinition @' +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Win32.SafeHandles; +public sealed class CcodUninstallPayloadAuthorityV1 : IDisposable +{ + [StructLayout(LayoutKind.Sequential)] private struct Info + { + public uint Attributes; + public System.Runtime.InteropServices.ComTypes.FILETIME Creation, Access, Write; + public uint Volume, SizeHigh, SizeLow, Links, IndexHigh, IndexLow; + } + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] + private static extern SafeFileHandle CreateFileW(string path,uint access,uint share,IntPtr security,uint creation,uint flags,IntPtr template); + [DllImport("kernel32.dll", SetLastError=true)] + private static extern bool GetFileInformationByHandle(SafeFileHandle handle,out Info info); + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] + private static extern uint GetFinalPathNameByHandleW(SafeFileHandle handle,StringBuilder value,uint length,uint flags); + private readonly List handles = new List(); + private readonly HashSet paths = new HashSet(StringComparer.OrdinalIgnoreCase); + private bool disposed; + public void Add(string path) + { + if(disposed)throw new ObjectDisposedException("uninstall payload authority"); + if(String.IsNullOrWhiteSpace(path)||!Path.IsPathRooted(path)||path.StartsWith(@"\\?\")||path.StartsWith(@"\\.\"))throw new IOException("Payload path"); + string full=Path.GetFullPath(path),root=Path.GetPathRoot(full); + if(!String.Equals(full,path,StringComparison.OrdinalIgnoreCase)||full.Substring(root.Length).IndexOf(':')>=0)throw new IOException("Payload canonical path"); + var parents=new List(); + for(DirectoryInfo parent=Directory.GetParent(full);parent!=null;parent=parent.Parent)parents.Add(parent.FullName); + parents.Reverse(); + foreach(string parent in parents)Open(parent,true); + Open(full,false); + } + private void Open(string path,bool directory) + { + if(paths.Contains(path))return; + uint access=directory?0x00100081U:0x80100080U; + uint flags=0x00200000U|(directory?0x02000000U:0U); + SafeFileHandle handle=CreateFileW(path,access,1U,IntPtr.Zero,3U,flags,IntPtr.Zero); + if(handle.IsInvalid){int error=Marshal.GetLastWin32Error();handle.Dispose();throw new Win32Exception(error);} + try{ + Info info;if(!GetFileInformationByHandle(handle,out info))throw new Win32Exception(Marshal.GetLastWin32Error()); + if((info.Attributes&0x400U)!=0||((info.Attributes&0x10U)!=0)!=directory||(!directory&&info.Links!=1))throw new IOException("Payload file kind"); + var value=new StringBuilder(512);uint length=GetFinalPathNameByHandleW(handle,value,(uint)value.Capacity,0); + if(length>=value.Capacity){value.Capacity=checked((int)length+1);length=GetFinalPathNameByHandleW(handle,value,(uint)value.Capacity,0);} + if(length==0||length>=value.Capacity)throw new IOException("Payload final path unavailable"); + string final=value.ToString(); + if(final.StartsWith(@"\\?\UNC\",StringComparison.OrdinalIgnoreCase))final=@"\\"+final.Substring(8); + else if(final.StartsWith(@"\\?\",StringComparison.OrdinalIgnoreCase))final=final.Substring(4); + if(!String.Equals(final.TrimEnd('\\'),path.TrimEnd('\\'),StringComparison.OrdinalIgnoreCase))throw new IOException("Payload final path mismatch"); + handles.Add(handle);paths.Add(path); + }catch{handle.Dispose();throw;} + } + public void Dispose() + { + if(disposed)return;disposed=true; + for(int index=handles.Count-1;index>=0;index--)handles[index].Dispose(); + handles.Clear();paths.Clear(); + } +} +'@ +} + +function Open-CcodUninstallBootstrapPayloadAuthority { + param([Parameter(Mandatory)][string]$PayloadRoot,[Parameter(Mandatory)][object[]]$Records) + $lease=$null + try { + Initialize-CcodUninstallPayloadAuthority + $lease=[CcodUninstallPayloadAuthorityV1]::new() + if ($Records.Count -lt 1) { throw 'payload records missing' } + $seen=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($record in $Records) { + [void](Assert-CcodUninstallBootstrapManifestRecord $record) + if (-not $seen.Add($record.path)) { throw 'duplicate payload path' } + $path=Resolve-CcodUninstallBootstrapChildPath -Root $PayloadRoot -RelativePath $record.path -RequireLeafFile + $lease.Add($path) + $streams=@(Get-Item -LiteralPath $path -Stream * -ErrorAction Stop) + $fingerprint=Get-CcodUninstallBootstrapFileFingerprint $path + if ($streams.Count -ne 1 -or $streams[0].Stream -cne ':$DATA' -or + $fingerprint.length -ne $record.length -or $fingerprint.sha256 -cne $record.sha256) { throw 'staged payload changed' } + } + return $lease + } catch { + if ($null -ne $lease) { $lease.Dispose() } + Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_PAYLOAD_HASH_MISMATCH' 'Staged cleanup authority could not be acquired before consumption.' $PayloadRoot + } +} + function Get-CcodUninstallBootstrapAdapters { param([hashtable]$Adapters) $defaults = @{ @@ -868,9 +1078,17 @@ function Get-CcodUninstallBootstrapAdapters { GetTransactionRoot = { Get-CcodUninstallBootstrapDefaultTransactionRoot } + EnterAccountTransition = { + param($UserSid) + Import-Module (Join-Path $PSScriptRoot 'modules\KernelObjects.psm1') -Force -DisableNameChecking -ErrorAction Stop + $lease=Enter-CcodMutex -Kind AccountTransition -UserSid $UserSid -TimeoutMilliseconds 30000 + if($null-eq$lease-or$lease.Outcome-cne'Acquired'-or$lease.Kind-cne'AccountTransition'){Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_TRANSACTION_BUSY' 'The shared account transition authority is busy' $UserSid} + $lease + } + ExitAccountTransition = {param($Lease)Import-Module (Join-Path $PSScriptRoot 'modules\KernelObjects.psm1') -Force -DisableNameChecking -ErrorAction Stop;[void](Exit-CcodMutex -Lease $Lease)} ReadTransaction = { - param($TransactionRoot,$ExpectedUserSid,$IncludeCompleted) - Read-CcodUninstallBootstrapStoredTransaction -TransactionRoot $TransactionRoot -ExpectedUserSid $ExpectedUserSid -IncludeCompleted:([bool]$IncludeCompleted) + param($TransactionRoot,$ExpectedUserSid,$IncludeCompleted,$ExpectedInstallRoot) + Read-CcodUninstallBootstrapStoredTransaction -TransactionRoot $TransactionRoot -ExpectedUserSid $ExpectedUserSid -IncludeCompleted:([bool]$IncludeCompleted) -ExpectedInstallRoot $ExpectedInstallRoot } ValidateFinalizationInvocation = { param($TransactionRoot,$Transaction,$Identity) @@ -901,9 +1119,16 @@ function Get-CcodUninstallBootstrapAdapters { param($TransactionRoot,$Transaction) Write-CcodUninstallBootstrapStoredReceipt -TransactionDirectory $TransactionRoot -Transaction $Transaction } + ReadReceipt = { + param($TransactionRoot) + Read-CcodUninstallBootstrapStoredReceipt -TransactionDirectory $TransactionRoot + } RunCleanup = { - param($InstallerRoot,$InstallRoot,$TransactionRoot,$Transaction,$WriteTransaction) + param($InstallerRoot,$InstallRoot,$TransactionRoot,$Transaction,$WriteTransaction,$Mode,$PayloadRecords) $payloadRoot = Resolve-CcodUninstallBootstrapChildPath -Root $TransactionRoot -RelativePath 'payload' + $records=if($null-ne$Transaction.installedBinding){@($Transaction.installedBinding.payloadRecords)}else{@($PayloadRecords)} + $payloadAuthority=Open-CcodUninstallBootstrapPayloadAuthority -PayloadRoot $payloadRoot -Records $records + try { $modulePath = Resolve-CcodUninstallBootstrapChildPath -Root $payloadRoot -RelativePath 'src\persistence\modules\InstallLifecycle.psm1' -RequireLeafFile $module = Import-Module -Name $modulePath -Force -PassThru -ErrorAction Stop $writer = { @@ -911,9 +1136,23 @@ function Get-CcodUninstallBootstrapAdapters { & $WriteTransaction $TransactionRoot $Value }.GetNewClosure() return (& $module { - param($Root,$Value,$Writer) - Invoke-CcodUninstallCleanup -InstallRoot $Root -Transaction $Value -WriteTransaction $Writer - } $InstallRoot $Transaction $writer) + param($Root,$Value,$Writer,$DeferRemoval) + Invoke-CcodUninstallCleanup -InstallRoot $Root -Transaction $Value -WriteTransaction $Writer -StopAfterTaskRemoval:([bool]$DeferRemoval) + } $InstallRoot $Transaction $writer ($Mode -ceq 'PrepareInstalled')) + } finally { $payloadAuthority.Dispose() } + } + RemoveProductRegistration = { + param($Context) + Remove-CcodUninstallBootstrapProductRegistration -Context $Context + } + GetInstalledBinding = { + param($InstallerRoot,$InstallRoot,$Context,$WrapperIdentity,$TransactionDirectory,$TransactionId) + $identity=Get-CcodUninstallBootstrapCurrentIdentity;$process=[Diagnostics.Process]::GetCurrentProcess();try{$created=$process.StartTime.ToUniversalTime().ToString('o',[Globalization.CultureInfo]::InvariantCulture);if($WrapperIdentity.pid-ne[int]$process.Id-or$WrapperIdentity.creationTimeUtc-cne$created-or$WrapperIdentity.sessionId-ne[int]$process.SessionId-or$WrapperIdentity.userSid-cne$identity.userSid){Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_BOOTSTRAP_INVALID' 'Installed wrapper identity does not match the current verified process' $WrapperIdentity}}finally{$process.Dispose()} + $selected=[IO.Path]::GetFullPath((Join-Path (Join-Path $InstallRoot 'runtime') ([string]$Context.runtimeId)));if([IO.Path]::GetFullPath($InstallerRoot)-cne$selected){Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_BOOTSTRAP_INVALID' 'Installed wrapper is outside the selected runtime' $InstallerRoot};$manifest=Get-CcodUninstallBootstrapFileFingerprint -Path (Join-Path $selected 'manifest.json') + $resumeRecord=@($Context.payloadRecords|Where-Object{[string]$_.path-ceq'src/persistence/InstalledUninstallFinalizer.ps1'});if($resumeRecord.Count-ne1){Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_BOOTSTRAP_INVALID' 'Installed resume payload record is missing.' $Context.runtimeId} + $resumeScript=[IO.Path]::GetFullPath((Join-Path $TransactionDirectory 'payload\src\persistence\InstalledUninstallFinalizer.ps1')) + $resumeCommand=Get-CcodUninstallBootstrapInstalledResumeCommand -ResumeScriptPath $resumeScript -TransactionId $TransactionId -RuntimeRoot $selected -InstallRoot $InstallRoot + [pscustomobject][ordered]@{selectedRuntimeRoot=$selected;runtimeManifestSha256=[string]$manifest.sha256;wrapperPid=[int]$WrapperIdentity.pid;wrapperCreationTimeUtc=[string]$WrapperIdentity.creationTimeUtc;wrapperSessionId=[int]$WrapperIdentity.sessionId;wrapperUserSid=[string]$WrapperIdentity.userSid;resumeWrapperPid=$null;resumeWrapperCreationTimeUtc=$null;resumeWrapperSessionId=$null;resumeWrapperUserSid=$null;resumeScriptPath=$resumeScript;resumeScriptLength=[int64]$resumeRecord[0].length;resumeScriptSha256=[string]$resumeRecord[0].sha256;resumeCommand=$resumeCommand;payloadRecords=@($Context.payloadRecords)} } TestInstallRootAbsent = { param($InstallerRoot) -not ([IO.Directory]::Exists($InstallerRoot) -or [IO.File]::Exists($InstallerRoot)) } GetUtcNow = { [DateTime]::UtcNow } @@ -931,6 +1170,41 @@ function Get-CcodUninstallBootstrapAdapters { return $resolved } +function Assert-CcodUninstallBootstrapInstalledTaskRemovedContext { + param( + [Parameter(Mandatory)]$Transaction, + [Parameter(Mandatory)]$FreshContext, + [Parameter(Mandatory)][string]$InstallerRoot, + [Parameter(Mandatory)][string]$InstallRoot + ) + Assert-CcodUninstallBootstrapTransaction $Transaction -ExpectedInstallRoot $InstallRoot + Assert-CcodUninstallBootstrapContext $FreshContext -ExpectedInstallRoot $InstallRoot + $taskRemovedProfile=($Transaction.phase-ceq'TaskRemoved'-and$Transaction.resumePhase-ceq'TaskRemoved')-or($Transaction.phase-ceq'Failed'-and$Transaction.resumePhase-ceq'TaskRemoved') + if(-not$taskRemovedProfile-or$null-eq$Transaction.installedBinding){Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_TRANSACTION_MISMATCH' 'Only an exact installed TaskRemoved transaction may relax its historical session.' $Transaction} + $installer=Get-CcodUninstallBootstrapComparablePath -Path $InstallerRoot -Kind 'Installed runtime root';$install=Get-CcodUninstallBootstrapComparablePath -Path $InstallRoot -Kind 'Install root';$expectedRuntime=Get-CcodUninstallBootstrapComparablePath -Path (Join-Path (Join-Path $install 'runtime') ([string]$Transaction.runtimeId)) -Kind 'Selected runtime root' + if($installer-cne$expectedRuntime-or(Get-CcodUninstallBootstrapComparablePath -Path ([string]$Transaction.installedBinding.selectedRuntimeRoot) -Kind 'Bound selected runtime root')-cne$expectedRuntime-or + $Transaction.userSid-cne$FreshContext.userSid-or$Transaction.runtimeId-cne$FreshContext.runtimeId-or[uint64]$Transaction.runtimeGeneration-ne[uint64]$FreshContext.runtimeGeneration-or + [uint64]$Transaction.leaseEpoch-ne[uint64]$FreshContext.leaseEpoch-or($Transaction.readyEvidence|ConvertTo-Json -Depth 8 -Compress)-cne($FreshContext.readyEvidence|ConvertTo-Json -Depth 8 -Compress)-or + ($Transaction.installedBinding.payloadRecords|ConvertTo-Json -Depth 8 -Compress)-cne($FreshContext.payloadRecords|ConvertTo-Json -Depth 8 -Compress)){ + Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_TRANSACTION_MISMATCH' 'Installed TaskRemoved replacement does not match the locked fresh runtime proof.' $Transaction + } + return $true +} + +function Test-CcodUninstallBootstrapInstalledBindingResume { + param($Existing,$Observed) + if($null-eq$Existing-or$null-eq$Observed){return $false} + foreach($name in @('selectedRuntimeRoot','runtimeManifestSha256','resumeScriptPath','resumeScriptLength','resumeScriptSha256','resumeCommand')){if(($Existing.$name|ConvertTo-Json -Compress)-cne($Observed.$name|ConvertTo-Json -Compress)){return $false}} + return ($Existing.payloadRecords|ConvertTo-Json -Depth 6 -Compress)-ceq($Observed.payloadRecords|ConvertTo-Json -Depth 6 -Compress) +} + +function Remove-CcodUninstallBootstrapProductRegistration { + param([Parameter(Mandatory)]$Context) + Assert-CcodUninstallBootstrapContext $Context + $modulePath=Join-Path $PSScriptRoot 'modules\ProductRegistration.psm1';if(-not[IO.File]::Exists($modulePath)){Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_PRODUCT_REGISTRATION_INVALID' 'Matched product registration module is missing' $modulePath} + try{Import-Module $modulePath -Force -ErrorAction Stop;Remove-CcodProductRegistration -ReadyEvidence $Context.readyEvidence}catch{Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_PRODUCT_REGISTRATION_INVALID' 'Current product registration did not match exact Ready uninstall evidence' $Context.runtimeId} +} + function Set-CcodUninstallBootstrapFailedTransaction { param([Parameter(Mandatory)]$Transaction,[Parameter(Mandatory)][string]$Code,[Parameter(Mandatory)][hashtable]$Adapters,[Parameter(Mandatory)][string]$TransactionRoot) if ($Transaction.phase -ne 'Failed') { $Transaction.resumePhase = $Transaction.phase } @@ -949,7 +1223,8 @@ function Invoke-CcodUninstallBootstrap { param( [Parameter(Mandatory)][string]$InstallerRoot, [Parameter(Mandatory)][string]$InstallRoot, - [Parameter(Mandatory)][ValidateSet('Prepare','FinalizeReceipt')][string]$Mode, + [Parameter(Mandatory)][ValidateSet('Prepare','PrepareInstalled','FinalizeReceipt')][string]$Mode, + $WrapperIdentity, [hashtable]$Adapters ) @@ -959,22 +1234,24 @@ function Invoke-CcodUninstallBootstrap { $identity = Get-CcodUninstallBootstrapCurrentIdentity $transactionLock = Enter-CcodUninstallBootstrapTransactionLock -UserSid $identity.userSid try { - $transaction = & $adapter.ReadTransaction $transactionRoot $identity.userSid $true + $transaction = & $adapter.ReadTransaction $transactionRoot $identity.userSid $true $InstallRoot if ($null -eq $transaction) { Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_TRANSACTION_MISSING' 'No resumable uninstall transaction exists' $transactionRoot } - Assert-CcodUninstallBootstrapTransaction $transaction - if ($transaction.phase -ne 'ReadyForInno') { Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_FINALIZATION_INVALID' 'The uninstall transaction is not ready for Inno finalization' $transaction } + Assert-CcodUninstallBootstrapTransaction $transaction -ExpectedInstallRoot $InstallRoot + if ($transaction.phase -notin @('ReadyForInno','Completed')) { Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_FINALIZATION_INVALID' 'The uninstall transaction is not ready for Inno finalization' $transaction } & $adapter.ValidateFinalizationInvocation $transactionRoot $transaction $identity if (-not (& $adapter.TestInstallRootAbsent $InstallerRoot)) { Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_FINALIZATION_INCOMPLETE' 'Inno did not remove the application root' $InstallerRoot } $transactionDirectory = & $adapter.GetTransactionDirectory $transactionRoot $transaction.transactionId if ($transactionDirectory -isnot [string] -or [string]::IsNullOrWhiteSpace($transactionDirectory)) { Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_TRANSACTION_ROOT_INVALID' 'The external uninstall transaction root is invalid' $transactionDirectory } - $transaction.phase = 'Completed' - $transaction.resumePhase = 'Completed' - $transaction.updatedAtUtc = (& $adapter.GetUtcNow).ToUniversalTime().ToString('o',[Globalization.CultureInfo]::InvariantCulture) - $transaction.errorCode = $null - Assert-CcodUninstallBootstrapTransaction $transaction - & $adapter.WriteTransaction $transactionDirectory $transaction + if($transaction.phase-ceq'ReadyForInno'){ + $transaction.phase = 'Completed' + $transaction.resumePhase = 'Completed' + $transaction.updatedAtUtc = (& $adapter.GetUtcNow).ToUniversalTime().ToString('o',[Globalization.CultureInfo]::InvariantCulture) + $transaction.errorCode = $null + Assert-CcodUninstallBootstrapTransaction $transaction + & $adapter.WriteTransaction $transactionDirectory $transaction + } & $adapter.WriteReceipt $transactionDirectory $transaction return $transaction } finally { @@ -987,14 +1264,18 @@ function Invoke-CcodUninstallBootstrap { $transaction = $null $stageRoot = $null $stagePayload = $false + $returnExistingInstalledTaskRemoved = $false + $installedTaskRemovedRecovery = $false $transactionLock = $null + $accountTransition = $null try { $validationFailure = $null try { $context = & $adapter.ValidateInvocation $InstallerRoot $InstallRoot - Assert-CcodUninstallBootstrapContext $context + Assert-CcodUninstallBootstrapContext $context -ExpectedInstallRoot $InstallRoot } catch { $validationFailure = $_ + $context = $null } $transactionRoot = & $adapter.GetTransactionRoot $identity = if ($null -ne $context) { @@ -1003,7 +1284,8 @@ function Invoke-CcodUninstallBootstrap { Get-CcodUninstallBootstrapCurrentIdentity } $transactionLock = Enter-CcodUninstallBootstrapTransactionLock -UserSid $identity.userSid - $existing = & $adapter.ReadTransaction $transactionRoot $identity.userSid $true + if($Mode-ceq'PrepareInstalled'){$accountTransition=&$adapter.EnterAccountTransition $identity.userSid} + $existing = & $adapter.ReadTransaction $transactionRoot $identity.userSid $true $InstallRoot if ($null -eq $context) { if ($null -eq $existing) { throw $validationFailure } $context = New-CcodUninstallBootstrapResumeContext -InstallerRoot $InstallerRoot -InstallRoot $InstallRoot -Transaction $existing -Identity $identity @@ -1018,38 +1300,65 @@ function Invoke-CcodUninstallBootstrap { if ($stageRoot -isnot [string] -or [string]::IsNullOrWhiteSpace($stageRoot)) { Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_TRANSACTION_ROOT_INVALID' 'The external uninstall transaction root is invalid' $stageRoot } $now = & $adapter.GetUtcNow if ($now -isnot [DateTime]) { Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_CLOCK_INVALID' 'The uninstall clock is invalid' $now } - $transaction = New-CcodUninstallBootstrapTransaction -Context $context -TransactionId $transactionId -NowUtc $now + $installedBinding=if($Mode-ceq'PrepareInstalled'){&$adapter.GetInstalledBinding $InstallerRoot $InstallRoot $context $WrapperIdentity $stageRoot $transactionId}else{$null} + $transaction = New-CcodUninstallBootstrapTransaction -Context $context -TransactionId $transactionId -NowUtc $now -InstalledBinding $installedBinding & $adapter.WriteTransaction $stageRoot $transaction & $adapter.PublishTransaction $transactionRoot $transactionId $context.userSid ([bool]$replaceCompletedLocator) $stagePayload = $true } else { - Assert-CcodUninstallBootstrapTransactionMatchesContext $transaction $context + $taskRemovedProfile=($transaction.phase-ceq'TaskRemoved'-and$transaction.resumePhase-ceq'TaskRemoved')-or($transaction.phase-ceq'Failed'-and$transaction.resumePhase-ceq'TaskRemoved') + $installedTaskRemovedCandidate=$Mode-ceq'PrepareInstalled'-and$taskRemovedProfile-and$null-ne$transaction.installedBinding + if($installedTaskRemovedCandidate){ + $installedTaskRemovedRecovery=$true + $freshContext=&$adapter.ValidateInvocation $InstallerRoot $InstallRoot + Assert-CcodUninstallBootstrapContext $freshContext -ExpectedInstallRoot $InstallRoot + [void](Assert-CcodUninstallBootstrapInstalledTaskRemovedContext -Transaction $transaction -FreshContext $freshContext -InstallerRoot $InstallerRoot -InstallRoot $InstallRoot) + $context=$freshContext + }else{Assert-CcodUninstallBootstrapTransactionMatchesContext $transaction $context $InstallRoot} $stageRoot = & $adapter.GetTransactionDirectory $transactionRoot $transaction.transactionId if ($stageRoot -isnot [string] -or [string]::IsNullOrWhiteSpace($stageRoot)) { Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_TRANSACTION_ROOT_INVALID' 'The external uninstall transaction root is invalid' $stageRoot } + if($Mode-ceq'PrepareInstalled'){ + $binding=&$adapter.GetInstalledBinding $InstallerRoot $InstallRoot $context $WrapperIdentity $stageRoot $transaction.transactionId + if(-not(Test-CcodUninstallBootstrapInstalledBindingResume -Existing $transaction.installedBinding -Observed $binding)){Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_TRANSACTION_MISMATCH' 'Installed wrapper binding changed during resume' $binding} + if($installedTaskRemovedCandidate){ + $now=&$adapter.GetUtcNow;if($now-isnot[DateTime]){Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_TRANSACTION_WRITE_FAILED' 'Replacement wrapper clock is invalid.' $transaction.transactionId};$prior=[DateTime]::ParseExact([string]$transaction.updatedAtUtc,'o',[Globalization.CultureInfo]::InvariantCulture,[Globalization.DateTimeStyles]::RoundtripKind);$next=$now.ToUniversalTime();if($next-le$prior){Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_TRANSACTION_WRITE_FAILED' 'Replacement wrapper timestamp is not strictly newer.' $transaction.transactionId} + $transaction.installedBinding.resumeWrapperPid=[int]$WrapperIdentity.pid;$transaction.installedBinding.resumeWrapperCreationTimeUtc=[string]$WrapperIdentity.creationTimeUtc;$transaction.installedBinding.resumeWrapperSessionId=[int]$WrapperIdentity.sessionId;$transaction.installedBinding.resumeWrapperUserSid=[string]$WrapperIdentity.userSid + $transaction.phase='TaskRemoved';$transaction.resumePhase='TaskRemoved';$transaction.errorCode=$null;$transaction.updatedAtUtc=$next.ToString('o',[Globalization.CultureInfo]::InvariantCulture) + Assert-CcodUninstallBootstrapTransaction $transaction -ExpectedInstallRoot $InstallRoot;&$adapter.WriteTransaction $stageRoot $transaction + $readBack=&$adapter.ReadTransaction $transactionRoot $identity.userSid $true $InstallRoot + if($null-eq$readBack-or($readBack|ConvertTo-Json -Depth 16 -Compress)-cne($transaction|ConvertTo-Json -Depth 16 -Compress)){Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_TRANSACTION_WRITE_FAILED' 'Replacement wrapper transaction read-back did not match.' $transaction.transactionId} + $transaction=$readBack;$returnExistingInstalledTaskRemoved=$true + } + } $stagePayload = $transaction.phase -ceq 'Requested' -or ($transaction.phase -ceq 'Failed' -and $transaction.resumePhase -ceq 'Requested') } if ($stagePayload) { & $adapter.StagePayload $InstallerRoot $InstallRoot $context $stageRoot } - $result = & $adapter.RunCleanup $InstallerRoot $InstallRoot $stageRoot $transaction $adapter.WriteTransaction + if($returnExistingInstalledTaskRemoved){&$adapter.WriteReceipt $stageRoot $transaction;$receipt=&$adapter.ReadReceipt $stageRoot;if(-not(Test-CcodUninstallBootstrapReceiptMatchesTransaction -Receipt $receipt -Transaction $transaction)){Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_TRANSACTION_WRITE_FAILED' 'Replacement wrapper receipt read-back did not match.' $transaction.transactionId};return $transaction} + $result = & $adapter.RunCleanup $InstallerRoot $InstallRoot $stageRoot $transaction $adapter.WriteTransaction $Mode $context.payloadRecords if ($null -eq $result) { Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_PREPARE_FAILED' 'The staged cleanup returned no transaction receipt' $stageRoot } - Assert-CcodUninstallBootstrapTransactionMatchesContext $result $context - if ($result.phase -ne 'ReadyForInno') { Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_PREPARE_FAILED' 'The staged cleanup did not reach the Inno boundary' $result } + Assert-CcodUninstallBootstrapTransactionMatchesContext $result $context $InstallRoot + $expectedPhase=if($Mode-ceq'PrepareInstalled'){'TaskRemoved'}else{'ReadyForInno'} + if ($result.phase -cne $expectedPhase) { Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_PREPARE_FAILED' 'The staged cleanup did not reach its expected external boundary' $result } + if($Mode-ceq'Prepare'){& $adapter.RemoveProductRegistration $context} & $adapter.WriteReceipt $stageRoot $result return $result } catch { $code = Get-CcodUninstallBootstrapErrorId $_ if ($code -notmatch '^CCOD_[A-Z0-9_]+$') { $code = 'CCOD_UNINSTALL_PREPARE_FAILED' } - if ($null -ne $transaction) { Set-CcodUninstallBootstrapFailedTransaction -Transaction $transaction -Code $code -Adapters $adapter -TransactionRoot $stageRoot } + if (-not$installedTaskRemovedRecovery-and$null -ne $transaction -and -not [string]::IsNullOrWhiteSpace([string]$stageRoot)) { Set-CcodUninstallBootstrapFailedTransaction -Transaction $transaction -Code $code -Adapters $adapter -TransactionRoot $stageRoot } if ((Get-CcodUninstallBootstrapErrorId $_) -ceq $code) { throw } Throw-CcodUninstallBootstrapError $code 'The uninstall bootstrap failed before Inno deletion' $stageRoot } finally { + if($null-ne$accountTransition){try{&$adapter.ExitAccountTransition $accountTransition}catch{}} Exit-CcodUninstallBootstrapTransactionLock -Lock $transactionLock } } if ($MyInvocation.InvocationName -ne '.') { try { - $receipt = Invoke-CcodUninstallBootstrap -InstallerRoot $InstallerRoot -InstallRoot $InstallRoot -Mode $Mode + $receipt = Invoke-CcodUninstallBootstrap -InstallerRoot $InstallerRoot -InstallRoot $InstallRoot -Mode $Mode -WrapperIdentity $WrapperIdentity if ($Mode -eq 'Prepare' -and $receipt.phase -ne 'ReadyForInno') { exit 3 } + if ($Mode -eq 'PrepareInstalled' -and $receipt.phase -ne 'TaskRemoved') { exit 3 } if ($Mode -eq 'FinalizeReceipt' -and $receipt.phase -ne 'Completed') { exit 3 } exit 0 } catch { diff --git a/src/persistence/bootstrap.ps1 b/src/persistence/bootstrap.ps1 index 24a1c49..2405ece 100644 --- a/src/persistence/bootstrap.ps1 +++ b/src/persistence/bootstrap.ps1 @@ -94,6 +94,23 @@ function Get-CcodBootstrapCanonicalRoot { return $root } +function Skip-CcodBootstrapJsonWhitespace {param([string]$Text,[ref]$Index)while($Index.Value-lt$Text.Length-and($Text[$Index.Value]-eq' '-or$Text[$Index.Value]-eq"`t"-or$Text[$Index.Value]-eq"`r"-or$Text[$Index.Value]-eq"`n")){$Index.Value++}} +function Read-CcodBootstrapJsonStringToken {param([string]$Text,[ref]$Index)if($Index.Value-ge$Text.Length-or$Text[$Index.Value]-ne'"'){throw'json string'};$start=$Index.Value;$Index.Value++;while($Index.Value-lt$Text.Length){$c=$Text[$Index.Value];if($c-eq'"'){$Index.Value++;return $Text.Substring($start,$Index.Value-$start)};if([int][char]$c-lt0x20){throw'json control'};if($c-eq'\'){$Index.Value++;if($Index.Value-ge$Text.Length){throw'json escape'};$escape=$Text[$Index.Value];if($escape-eq'u'){if($Index.Value+4-ge$Text.Length-or$Text.Substring($Index.Value+1,4)-cnotmatch'^[0-9A-Fa-f]{4}$'){throw'json unicode'};$Index.Value+=5;continue};if('"\/bfnrt'.IndexOf($escape)-lt0){throw'json escape'}};$Index.Value++};throw'json string end'} +function Read-CcodBootstrapJsonValue { + param([string]$Text,[ref]$Index) + Skip-CcodBootstrapJsonWhitespace $Text $Index + if($Index.Value-ge$Text.Length){throw'json value'} + if($Text[$Index.Value]-eq'{'){ + $Index.Value++;Skip-CcodBootstrapJsonWhitespace $Text $Index;$keys=[Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + if($Index.Value-lt$Text.Length-and$Text[$Index.Value]-eq'}'){$Index.Value++;return} + while($true){$encoded=Read-CcodBootstrapJsonStringToken $Text $Index;$key=$encoded|ConvertFrom-Json -ErrorAction Stop;if($key-isnot[string]-or-not$keys.Add($key)){throw'json duplicate'};Skip-CcodBootstrapJsonWhitespace $Text $Index;if($Index.Value-ge$Text.Length-or$Text[$Index.Value]-ne':'){throw'json separator'};$Index.Value++;Read-CcodBootstrapJsonValue $Text $Index;Skip-CcodBootstrapJsonWhitespace $Text $Index;if($Index.Value-ge$Text.Length){throw'json end'};if($Text[$Index.Value]-eq'}'){$Index.Value++;return};if($Text[$Index.Value]-ne','){throw'json delimiter'};$Index.Value++;Skip-CcodBootstrapJsonWhitespace $Text $Index} + } + if($Text[$Index.Value]-eq'['){$Index.Value++;Skip-CcodBootstrapJsonWhitespace $Text $Index;if($Index.Value-lt$Text.Length-and$Text[$Index.Value]-eq']'){$Index.Value++;return};while($true){Read-CcodBootstrapJsonValue $Text $Index;Skip-CcodBootstrapJsonWhitespace $Text $Index;if($Index.Value-ge$Text.Length){throw'json array end'};if($Text[$Index.Value]-eq']'){$Index.Value++;return};if($Text[$Index.Value]-ne','){throw'json array delimiter'};$Index.Value++}} + if($Text[$Index.Value]-eq'"'){[void](Read-CcodBootstrapJsonStringToken $Text $Index);return} + $start=$Index.Value;$delimiters=",]} `t`r`n";while($Index.Value-lt$Text.Length-and$delimiters.IndexOf([string]$Text[$Index.Value])-lt0){$Index.Value++};if($Index.Value-eq$start){throw'json scalar'} +} +function Assert-CcodBootstrapJsonNoDuplicateKeys {param([string]$Text)$index=0;Read-CcodBootstrapJsonValue $Text ([ref]$index);Skip-CcodBootstrapJsonWhitespace $Text ([ref]$index);if($index-ne$Text.Length){throw'json trailing'}} + function Read-CcodBootstrapJson { param( [Parameter(Mandatory)][string]$Path, @@ -105,7 +122,9 @@ function Read-CcodBootstrapJson { } $value = $null try { - $value = ([IO.File]::ReadAllText($Path) | ConvertFrom-Json) + $item=Get-Item -LiteralPath $Path -Force -ErrorAction Stop;if($item.PSIsContainer-or($item.Attributes-band[IO.FileAttributes]::ReparsePoint)-ne0-or$item.Length-lt1-or$item.Length-gt1048576){throw'invalid file'} + $text=[Text.UTF8Encoding]::new($false,$true).GetString([IO.File]::ReadAllBytes($Path));Assert-CcodBootstrapJsonNoDuplicateKeys $text + $value = ($text | ConvertFrom-Json -ErrorAction Stop) } catch { Throw-CcodBootstrapError 'CCOD_BOOTSTRAP_STATE_MALFORMED' "$Kind is malformed" $Path } @@ -124,8 +143,60 @@ function Assert-CcodBootstrapRuntimeId { return $RuntimeId } +function Get-CcodBootstrapFileLinkCount { + param([Parameter(Mandatory)][string]$Path) + if($null-eq('CcodBootstrapFileIdentity' -as[type])){Add-Type -TypeDefinition @' +using System; using System.ComponentModel; using System.IO; using System.Runtime.InteropServices; using Microsoft.Win32.SafeHandles; +public static class CcodBootstrapFileIdentity { + [StructLayout(LayoutKind.Sequential)] struct Info { public uint Attr; public System.Runtime.InteropServices.ComTypes.FILETIME C,A,W; public uint Vol,High,Low,Links,Hi,Lo; } + [DllImport("kernel32.dll",SetLastError=true)] static extern bool GetFileInformationByHandle(SafeFileHandle h,out Info i); + public static uint Links(string p){using(FileStream s=new FileStream(p,FileMode.Open,FileAccess.Read,FileShare.Read)){Info i;if(!GetFileInformationByHandle(s.SafeFileHandle,out i))throw new Win32Exception(Marshal.GetLastWin32Error());return i.Links;}} +} +'@} + return [CcodBootstrapFileIdentity]::Links($Path) +} + +function Assert-CcodBootstrapPlainSelectorFile { + param([Parameter(Mandatory)][string]$Path) + $item=Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if($item.PSIsContainer-or($item.Attributes-band[IO.FileAttributes]::ReparsePoint)-ne0-or(Get-CcodBootstrapFileLinkCount $Path)-ne1){Throw-CcodBootstrapError 'CCOD_BOOTSTRAP_POINTER_INVALID' 'Active generation selector is not a plain single-link file' $Path} + $streams=@(Get-Item -LiteralPath $Path -Stream * -ErrorAction Stop) + if($streams.Count-ne1-or[string]$streams[0].Stream-cne':$DATA'){Throw-CcodBootstrapError 'CCOD_BOOTSTRAP_POINTER_INVALID' 'Active generation selector has alternate data streams' $Path} +} + function Read-CcodBootstrapActivePointer { - param([Parameter(Mandatory)][string]$InstallRoot) + param([Parameter(Mandatory)][string]$InstallRoot,[hashtable]$SelectorAdapters) + + try{$stateRoot=Assert-CcodBootstrapContained -Root $InstallRoot -Path (Join-Path $InstallRoot 'state') -AllowMissingLeaf;$pointerRoot=Assert-CcodBootstrapContained -Root $InstallRoot -Path (Join-Path $InstallRoot 'state\active-generation') -AllowMissingLeaf}catch{Throw-CcodBootstrapError 'CCOD_BOOTSTRAP_POINTER_INVALID' 'Active generation selector path is unsafe' (Join-Path $InstallRoot 'state\active-generation')} + $pointerRootItem=$null;$pointerRootAbsent=$false;$getSelectorRootItem=if($null-ne$SelectorAdapters-and$SelectorAdapters.ContainsKey('GetSelectorRootItem')){$SelectorAdapters.GetSelectorRootItem}else{{param($Path)Get-Item -LiteralPath $Path -Force -ErrorAction Stop}} + $stateRootItem=$null;$stateRootAbsent=$false + try{$stateRootItem=&$getSelectorRootItem $stateRoot;if($null-eq$stateRootItem){throw [IO.InvalidDataException]::new('state lookup returned no proof')}}catch [Management.Automation.ItemNotFoundException]{$stateRootAbsent=$true}catch{Throw-CcodBootstrapError 'CCOD_BOOTSTRAP_POINTER_INVALID' 'Active generation state ancestor lookup failed' $stateRoot} + if(-not$stateRootAbsent-and(-not$stateRootItem.PSIsContainer-or(Test-CcodBootstrapReparse $stateRoot))){Throw-CcodBootstrapError 'CCOD_BOOTSTRAP_POINTER_INVALID' 'Active generation state ancestor is not a plain directory' $stateRoot} + if($stateRootAbsent){$pointerRootAbsent=$true}else{try{$pointerRootItem=&$getSelectorRootItem $pointerRoot;if($null-eq$pointerRootItem){throw [IO.InvalidDataException]::new('selector lookup returned no proof')}}catch [Management.Automation.ItemNotFoundException]{$pointerRootAbsent=$true}catch{Throw-CcodBootstrapError 'CCOD_BOOTSTRAP_POINTER_INVALID' 'Active generation selector root lookup failed' $pointerRoot}} + if(-not$pointerRootAbsent-and-not$pointerRootItem.PSIsContainer){Throw-CcodBootstrapError 'CCOD_BOOTSTRAP_POINTER_INVALID' 'Active generation selector root is not a directory' $pointerRoot} + if(-not$pointerRootAbsent){ + if(Test-CcodBootstrapReparse $pointerRoot){Throw-CcodBootstrapError 'CCOD_BOOTSTRAP_POINTER_INVALID' 'Active generation selector root is a reparse point' $pointerRoot} + $entries=@(Get-ChildItem -LiteralPath $pointerRoot -Force -ErrorAction Stop) + if($entries.Count-eq0){Throw-CcodBootstrapError 'CCOD_BOOTSTRAP_POINTER_INVALID' 'Active generation selector is empty' $pointerRoot} + $records=[Collections.Generic.List[object]]::new() + foreach($entry in $entries){ + if($entry.PSIsContainer-or$entry.Name-cnotmatch'^\d{20}\.json$'){Throw-CcodBootstrapError 'CCOD_BOOTSTRAP_POINTER_INVALID' 'Active generation selector contains an unknown entry' $entry.FullName} + Assert-CcodBootstrapPlainSelectorFile $entry.FullName + try{$record=Read-CcodBootstrapJson -Path $entry.FullName -Kind 'active generation selector'}catch{Throw-CcodBootstrapError 'CCOD_BOOTSTRAP_POINTER_INVALID' 'Active generation selector JSON is invalid' $entry.FullName} + $names=@($record.PSObject.Properties.Name) + if(($names-join',')-cne'schemaVersion,generation,activeRuntime,previousGeneration'-or$record.schemaVersion-isnot[int]-or$record.schemaVersion-ne1-or$record.activeRuntime-isnot[string]){Throw-CcodBootstrapError 'CCOD_BOOTSTRAP_POINTER_INVALID' 'Active generation selector fields are invalid' $entry.FullName} + $integerTypes=@([byte],[uint16],[uint32],[uint64],[int16],[int32],[int64]);$gOk=$false;$pOk=$false;foreach($t in $integerTypes){if($record.generation-is$t){$gOk=$true};if($record.previousGeneration-is$t){$pOk=$true}} + if(-not$gOk-or-not$pOk){Throw-CcodBootstrapError 'CCOD_BOOTSTRAP_POINTER_INVALID' 'Active generation selector generations are not integers' $entry.FullName} + try{[uint64]$g=$record.generation;[uint64]$p=$record.previousGeneration}catch{Throw-CcodBootstrapError 'CCOD_BOOTSTRAP_POINTER_INVALID' 'Active generation selector generations are invalid' $entry.FullName} + if($g-eq0-or$p-eq[uint64]::MaxValue-or$g-ne($p+1)-or$entry.Name-cne('{0:D20}.json'-f$g)){Throw-CcodBootstrapError 'CCOD_BOOTSTRAP_POINTER_INVALID' 'Active generation selector record is not canonical' $entry.FullName} + Assert-CcodBootstrapRuntimeId $record.activeRuntime|Out-Null + $records.Add([pscustomobject]@{Generation=$g;PreviousGeneration=$p;ActiveRuntime=[string]$record.activeRuntime}) + } + $ordered=@($records|Sort-Object Generation) + for($i=0;$i-lt$ordered.Count;$i++){if([uint64]$ordered[$i].Generation-ne[uint64]($i+1)-or[uint64]$ordered[$i].PreviousGeneration-ne[uint64]$i){Throw-CcodBootstrapError 'CCOD_BOOTSTRAP_POINTER_INVALID' 'Active generation selector chain has a gap or duplicate' $pointerRoot}} + $latest=$ordered[-1];$previous=if($latest.PreviousGeneration-gt0){[string]$ordered[[int]$latest.PreviousGeneration-1].ActiveRuntime}else{$null} + return [pscustomobject]@{ActiveRuntime=$latest.ActiveRuntime;PreviousRuntime=$previous;SchemaVersion=3;Generation=[uint64]$latest.Generation;Selector='AppendOnly'} + } $path = Assert-CcodBootstrapContained -Root $InstallRoot -Path (Join-Path $InstallRoot 'active.json') -AllowMissingLeaf $value = Read-CcodBootstrapJson -Path $path -Kind 'active.json' @@ -157,6 +228,7 @@ function Read-CcodBootstrapActivePointer { PreviousRuntime = if ($null -eq $value.previousRuntime -or [string]::IsNullOrWhiteSpace($value.previousRuntime)) { $null } else { [string]$value.previousRuntime } SchemaVersion = [int]$value.schemaVersion Generation = $generation + Selector = 'Legacy' } } @@ -220,12 +292,14 @@ function Get-CcodBootstrapFileRecords { function Get-CcodBootstrapRuntimeId { param( [Parameter(Mandatory)][string]$ProjectVersion, - [Parameter(Mandatory)][object[]]$Files + [Parameter(Mandatory)][object[]]$Files, + [Parameter(Mandatory)][string]$Nonce ) + if($ProjectVersion-cnotmatch'^[A-Za-z0-9][A-Za-z0-9._-]{0,45}$'-or$Nonce-cnotmatch'^[0-9a-f]{32}$'){Throw-CcodBootstrapError 'CCOD_BOOTSTRAP_RUNTIME_ID_INVALID' 'Runtime identity components are invalid' $ProjectVersion} $lines = [Collections.Generic.List[string]]::new() foreach ($file in $Files) { - $lines.Add(('{0}`t{1}`t{2}' -f [string]$file.path, [int64]$file.length, [string]$file.sha256)) + $lines.Add(("{0}`t{1}`t{2}" -f [string]$file.path, [int64]$file.length, [string]$file.sha256)) } $canonical = $lines -join "`n" $sha256 = [Security.Cryptography.SHA256]::Create() @@ -234,7 +308,7 @@ function Get-CcodBootstrapRuntimeId { } finally { $sha256.Dispose() } - return ('{0}-{1}' -f $ProjectVersion, ([BitConverter]::ToString($digest).Replace('-', '')).ToLowerInvariant().Substring(0, 16)) + return ('{0}-{1}-{2}' -f $ProjectVersion, ([BitConverter]::ToString($digest).Replace('-', '')).ToLowerInvariant().Substring(0, 16),$Nonce) } function New-CcodBootstrapRuntimeValidation { @@ -278,7 +352,7 @@ function Test-CcodBootstrapRuntime { return New-CcodBootstrapRuntimeValidation -Valid $false -Code 'CCOD_BOOTSTRAP_MANIFEST_INVALID' -RuntimeDirectory $runtimeDirectory -SupervisorPath $null } $manifestRuntimeId = [string]$manifest.runtimeId - if ($manifestRuntimeId -cnotmatch '^[A-Za-z0-9._-]{1,96}$' -or $manifestRuntimeId -cne $RuntimeId) { + if ($manifestRuntimeId -cnotmatch'^(?[A-Za-z0-9][A-Za-z0-9._-]{0,45})-(?[0-9a-f]{16})-(?[0-9a-f]{32})$'-or$Matches.version-cne[string]$manifest.projectVersion-or $manifestRuntimeId -cne $RuntimeId) { return New-CcodBootstrapRuntimeValidation -Valid $false -Code 'CCOD_BOOTSTRAP_RUNTIME_ID_MISMATCH' -RuntimeDirectory $runtimeDirectory -SupervisorPath $null } @@ -323,7 +397,8 @@ function Test-CcodBootstrapRuntime { return New-CcodBootstrapRuntimeValidation -Valid $false -Code 'CCOD_BOOTSTRAP_FILE_HASH_MISMATCH' -RuntimeDirectory $runtimeDirectory -SupervisorPath $null } } - $computedRuntimeId = Get-CcodBootstrapRuntimeId -ProjectVersion ([string]$manifest.projectVersion) -Files $actualFiles + $identityMatch=[regex]::Match($manifestRuntimeId,'^(?[A-Za-z0-9][A-Za-z0-9._-]{0,45})-(?[0-9a-f]{16})-(?[0-9a-f]{32})$') + $computedRuntimeId = Get-CcodBootstrapRuntimeId -ProjectVersion ([string]$manifest.projectVersion) -Files $actualFiles -Nonce $identityMatch.Groups['nonce'].Value $requiredRuntimeFiles = @('src/persistence/Supervisor.ps1') if ($computedRuntimeId -cne $manifestRuntimeId -or @($requiredRuntimeFiles | Where-Object { -not $manifestPaths.Contains($_) }).Count -ne 0) { return New-CcodBootstrapRuntimeValidation -Valid $false -Code 'CCOD_BOOTSTRAP_RUNTIME_ID_MISMATCH' -RuntimeDirectory $runtimeDirectory -SupervisorPath $null @@ -345,7 +420,7 @@ function Import-CcodBootstrapKernelObjects { ) $candidates = @([string]$Pointer.ActiveRuntime) - if ($null -ne $Pointer.PreviousRuntime -and $Pointer.PreviousRuntime -cne $Pointer.ActiveRuntime) { + if ($Pointer.Selector -ceq 'Legacy' -and $null -ne $Pointer.PreviousRuntime -and $Pointer.PreviousRuntime -cne $Pointer.ActiveRuntime) { $candidates += [string]$Pointer.PreviousRuntime } @@ -842,7 +917,7 @@ try { $pointer = Read-CcodBootstrapActivePointer -InstallRoot $root $candidates = @($pointer.ActiveRuntime) - if ($null -ne $pointer.PreviousRuntime -and $pointer.PreviousRuntime -cne $pointer.ActiveRuntime) { + if ($pointer.Selector -ceq 'Legacy' -and $null -ne $pointer.PreviousRuntime -and $pointer.PreviousRuntime -cne $pointer.ActiveRuntime) { $candidates += $pointer.PreviousRuntime } @@ -868,7 +943,7 @@ try { $launchLease = $null $outcome = Wait-CcodBootstrapReady -Event $readyEvent -Process $child -TimeoutSeconds $ReadyTimeoutSeconds if ($outcome -ceq 'Ready') { - if ($runtimeId -cne $pointer.ActiveRuntime) { + if ($pointer.Selector -ceq 'Legacy' -and $runtimeId -cne $pointer.ActiveRuntime) { $updated = Invoke-CcodBootstrapFencedPointerPromotion -InstallRoot $root -Pointer $pointer -Validation $validation -NewRuntimeId $runtimeId -UserSid $userSid -SessionId $sessionId Write-CcodBootstrapLog -InstallRoot $root -Message ("Runtime {0} signaled ready; active pointer switched from {1}" -f $runtimeId, $pointer.ActiveRuntime) } diff --git a/src/persistence/modules/GenerationReclamation.psm1 b/src/persistence/modules/GenerationReclamation.psm1 new file mode 100644 index 0000000..eeab32a --- /dev/null +++ b/src/persistence/modules/GenerationReclamation.psm1 @@ -0,0 +1,873 @@ +Set-StrictMode -Version Latest + +$script:CcodGenerationReclamationBeforeArmForTest = $null +$script:CcodGenerationReclamationFailFileArmAtForTest = -1 +$script:CcodGenerationReclamationFailCommitAtForTest = -1 +$script:CcodProductResidueFailCommitAtForTest = -1 + +function Throw-CcodGenerationReclamationError { + param([Parameter(Mandatory)][string]$Id,[Parameter(Mandatory)][string]$Message,$Target) + throw [Management.Automation.ErrorRecord]::new( + [InvalidOperationException]::new($Message),$Id,[Management.Automation.ErrorCategory]::InvalidData,$Target) +} + +function Initialize-CcodGenerationReclamationRuntime { + $marker = 'CcodGenerationReclamationMarkerV1' -as [type] + if ($null -eq $marker) { + Add-Type -TypeDefinition @' +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using Microsoft.Win32.SafeHandles; + +public sealed class CcodGenerationReclamationMarkerV1 +{ + private CcodGenerationReclamationMarkerV1() { } + public static int CapabilityAbi { get { return 1; } } +} + +internal sealed class CcodGenerationReclamationRollbackException : Exception +{ + internal CcodGenerationReclamationRollbackException(string message,Exception inner) : base(message,inner) { } +} + +internal sealed class CcodGenerationReclamationCommitException : Exception +{ + internal CcodGenerationReclamationCommitException(string message,Exception inner) : base(message,inner) { } +} + +internal sealed class CcodGenerationReclamationSnapshotV1 +{ + internal sealed class FileRecord + { + public readonly string Path; + public readonly long Length; + public readonly string Sha256; + internal FileRecord(string path,long length,string sha256) { Path=path;Length=length;Sha256=sha256; } + } + + public readonly string ManifestText; + public readonly FileRecord[] Files; + public readonly string[] Directories; + internal CcodGenerationReclamationSnapshotV1(string manifestText,FileRecord[] files,string[] directories) + { ManifestText=manifestText;Files=files;Directories=directories; } +} + +internal sealed class CcodGenerationReclamationRuntimeV1 : IDisposable +{ + private const uint READ_DATA=0x00000001, LIST_DIRECTORY=0x00000001, READ_ATTRIBUTES=0x00000080; + private const uint DELETE=0x00010000, SYNCHRONIZE=0x00100000; + private const uint SHARE_READ=0x00000001, SHARE_WRITE=0x00000002; + private const uint OPEN=1, OPEN_EXISTING=3, OBJ_CASE_INSENSITIVE=0x40; + private const uint DIRECTORY=0x00000001, SYNC_IO=0x00000020, BACKUP_INTENT=0x00004000, OPEN_REPARSE=0x00200000; + private const uint FLAG_BACKUP=0x02000000, FLAG_REPARSE=0x00200000; + private const uint ATTR_DIRECTORY=0x00000010, ATTR_REPARSE=0x00000400; + private const uint DISPOSITION_DELETE=0x00000001, DISPOSITION_IGNORE_READONLY=0x00000010; + private const int FileStreamInfo=7, FileDirectoryInformation=1, FileDispositionInfoEx=21; + private const int STATUS_NO_MORE_FILES=unchecked((int)0x80000006); + + [StructLayout(LayoutKind.Sequential)] private struct UNICODE_STRING { public ushort Length,MaximumLength; public IntPtr Buffer; } + [StructLayout(LayoutKind.Sequential)] private struct OBJECT_ATTRIBUTES { public int Length; public IntPtr RootDirectory,ObjectName; public uint Attributes; public IntPtr SecurityDescriptor,SecurityQualityOfService; } + [StructLayout(LayoutKind.Sequential)] private struct IO_STATUS_BLOCK { public IntPtr Status; public UIntPtr Information; } + [StructLayout(LayoutKind.Sequential)] private struct FILETIME_NATIVE { public uint Low,High; } + [StructLayout(LayoutKind.Sequential)] private struct FILE_INFO + { + public uint FileAttributes; public FILETIME_NATIVE CreationTime,LastAccessTime,LastWriteTime; + public uint VolumeSerialNumber,FileSizeHigh,FileSizeLow,NumberOfLinks,FileIndexHigh,FileIndexLow; + } + + [DllImport("kernel32.dll",CharSet=CharSet.Unicode,SetLastError=true)] private static extern SafeFileHandle CreateFileW(string path,uint access,uint share,IntPtr security,uint creation,uint flags,IntPtr template); + [DllImport("kernel32.dll",SetLastError=true)] private static extern bool GetFileInformationByHandle(SafeFileHandle handle,out FILE_INFO information); + [DllImport("kernel32.dll",CharSet=CharSet.Unicode,SetLastError=true)] private static extern uint GetFinalPathNameByHandleW(SafeFileHandle handle,StringBuilder buffer,uint length,uint flags); + [DllImport("kernel32.dll",SetLastError=true)] private static extern bool GetFileInformationByHandleEx(SafeFileHandle handle,int informationClass,IntPtr information,uint size); + [DllImport("kernel32.dll",SetLastError=true)] private static extern bool SetFileInformationByHandle(SafeFileHandle handle,int informationClass,IntPtr information,uint size); + [DllImport("ntdll.dll")] private static extern int NtCreateFile(out IntPtr handle,uint access,ref OBJECT_ATTRIBUTES attributes,out IO_STATUS_BLOCK io,IntPtr allocationSize,uint fileAttributes,uint share,uint disposition,uint options,IntPtr ea,uint eaLength); + [DllImport("ntdll.dll")] private static extern int NtQueryDirectoryFile(SafeFileHandle handle,IntPtr evt,IntPtr apc,IntPtr context,out IO_STATUS_BLOCK io,IntPtr information,uint length,int informationClass,bool single,IntPtr name,bool restart); + [DllImport("ntdll.dll")] private static extern uint RtlNtStatusToDosError(int status); + + private sealed class Pin : IDisposable + { + internal readonly Pin Parent; + internal readonly string Leaf,Path,RelativePath; + internal readonly bool Directory; + internal SafeFileHandle Handle; + internal FileStream Stream; + internal readonly uint Volume; + internal readonly ulong FileId; + internal readonly long Length; + internal readonly string Sha256; + internal readonly string[] Children; + internal bool Armed,Closed; + + internal Pin(Pin parent,string leaf,string path,string relativePath,SafeFileHandle handle,FILE_INFO information,string[] children) + { + Parent=parent;Leaf=leaf;Path=path;RelativePath=relativePath;Directory=true;Handle=handle; + Volume=information.VolumeSerialNumber;FileId=((ulong)information.FileIndexHigh<<32)|information.FileIndexLow; + Length=-1;Sha256=null;Children=children; + } + + internal Pin(Pin parent,string leaf,string path,string relativePath,FileStream stream,FILE_INFO information,long length,string sha256) + { + Parent=parent;Leaf=leaf;Path=path;RelativePath=relativePath;Directory=false;Stream=stream; + Volume=information.VolumeSerialNumber;FileId=((ulong)information.FileIndexHigh<<32)|information.FileIndexLow; + Length=length;Sha256=sha256;Children=null; + } + + internal SafeFileHandle Native { get { return Directory?Handle:Stream.SafeFileHandle; } } + public void Dispose() + { + if(Closed)return; + if(Stream!=null)Stream.Dispose();else if(Handle!=null)Handle.Dispose(); + Closed=true; + } + } + + private readonly string installPath,runtimeParentPath,selectedPath; + private Pin installRoot,runtimeParent,selectedRoot,manifest; + private readonly List files=new List(); + private readonly List directories=new List(); + private readonly List residueAncestors=new List(); + private bool disposed,completed; + + private CcodGenerationReclamationRuntimeV1(string installPath,string runtimeParentPath,string selectedPath) + { this.installPath=installPath;this.runtimeParentPath=runtimeParentPath;this.selectedPath=selectedPath; } + + internal static CcodGenerationReclamationRuntimeV1 Open(string installPath,string selectedPath,string runtimeId) + { + CcodGenerationReclamationRuntimeV1 runtime=new CcodGenerationReclamationRuntimeV1(installPath,Path.Combine(installPath,"runtime"),selectedPath); + try { runtime.OpenTree(runtimeId);return runtime; } + catch { runtime.Dispose();throw; } + } + + internal static CcodGenerationReclamationRuntimeV1 OpenResidue(string path) + { + string parentPath=Directory.GetParent(path).FullName; + var runtime=new CcodGenerationReclamationRuntimeV1(parentPath,parentPath,path); + try + { + var ancestors=new List(); + for(DirectoryInfo cursor=new DirectoryInfo(parentPath);cursor!=null;cursor=cursor.Parent)ancestors.Add(cursor.FullName); + ancestors.Reverse();Pin parent=null; + foreach(string ancestor in ancestors) + { + SafeFileHandle handle=parent==null?OpenAbsoluteDirectory(ancestor):OpenRelative(parent.Native,Path.GetFileName(ancestor),LIST_DIRECTORY|READ_ATTRIBUTES|SYNCHRONIZE,SHARE_READ|SHARE_WRITE,DIRECTORY|BACKUP_INTENT); + try { parent=CreateDirectoryPin(parent,Path.GetFileName(ancestor),ancestor,"",handle,new string[0]);handle=null;runtime.residueAncestors.Add(parent); } + finally { if(handle!=null)handle.Dispose(); } + } + runtime.installRoot=parent; + SafeFileHandle selected=OpenRelative(parent.Native,Path.GetFileName(path),DELETE|LIST_DIRECTORY|READ_ATTRIBUTES|SYNCHRONIZE,SHARE_READ,DIRECTORY|BACKUP_INTENT); + try { runtime.selectedRoot=CreateDirectoryPin(parent,Path.GetFileName(path),path,"",selected,null);selected=null;runtime.directories.Add(runtime.selectedRoot);runtime.OpenChildren(runtime.selectedRoot); } + finally { if(selected!=null)selected.Dispose(); } + runtime.Revalidate();return runtime; + } + catch { runtime.Dispose();throw; } + } + + internal string ReadFileText(string relative) + { + if(disposed)throw new ObjectDisposedException("product residue"); + foreach(Pin file in files)if(String.Equals(file.RelativePath,relative,StringComparison.Ordinal))return ReadText(file.Stream); + throw new InvalidDataException("required product state is missing"); + } + + internal string RootIdentity() + { + Revalidate();return selectedRoot.Volume.ToString(System.Globalization.CultureInfo.InvariantCulture)+"-"+selectedRoot.FileId.ToString(System.Globalization.CultureInfo.InvariantCulture); + } + + internal sealed class Journal : IDisposable + { + private readonly List handles=new List(); + public FileStream Stream; + public bool Created; + internal Journal(string directory,string leaf) + { + try + { + var ancestors=new List(); + for(DirectoryInfo cursor=new DirectoryInfo(directory);cursor!=null;cursor=cursor.Parent)ancestors.Add(cursor.FullName); + ancestors.Reverse();SafeFileHandle parent=null; + foreach(string path in ancestors) + { + SafeFileHandle handle=parent==null?OpenAbsoluteDirectory(path):OpenRelative(parent,Path.GetFileName(path),LIST_DIRECTORY|READ_ATTRIBUTES|SYNCHRONIZE,SHARE_READ|SHARE_WRITE,DIRECTORY|BACKUP_INTENT); + handles.Add(handle);ValidateDirectoryIdentity(Info(handle),handle,path);parent=handle; + } + string target=Path.Combine(directory,leaf); + try { Stream=new FileStream(target,FileMode.CreateNew,FileAccess.ReadWrite,FileShare.Read,4096,FileOptions.WriteThrough);Created=true; } + catch(IOException exception) + { + int error=exception.HResult&65535;if(error!=80&&error!=183)throw; + Stream=new FileStream(target,FileMode.Open,FileAccess.Read,FileShare.Read); + } + ValidateFileIdentity(Info(Stream.SafeFileHandle),Stream.SafeFileHandle,target,Info(parent).VolumeSerialNumber); + } + catch { Dispose();throw; } + } + public void Dispose(){if(Stream!=null){Stream.Dispose();Stream=null;}for(int index=handles.Count-1;index>=0;index--)handles[index].Dispose();handles.Clear();} + } + + internal static Journal OpenJournal(string directory){return new Journal(directory,"product-residue-plan.json");} + + private void OpenTree(string runtimeId) + { + SafeFileHandle absolute=OpenAbsoluteDirectory(installPath); + try { installRoot=CreateDirectoryPin(null,"",installPath,"",absolute,null);absolute=null; } + finally { if(absolute!=null)absolute.Dispose(); } + + SafeFileHandle parentHandle=OpenRelative(installRoot.Native,"runtime",LIST_DIRECTORY|READ_ATTRIBUTES|SYNCHRONIZE,SHARE_READ|SHARE_WRITE,DIRECTORY|BACKUP_INTENT); + try { runtimeParent=CreateDirectoryPin(installRoot,"runtime",runtimeParentPath,"runtime",parentHandle,null);parentHandle=null; } + finally { if(parentHandle!=null)parentHandle.Dispose(); } + + SafeFileHandle selectedHandle=OpenRelative(runtimeParent.Native,runtimeId,DELETE|LIST_DIRECTORY|READ_ATTRIBUTES|SYNCHRONIZE,SHARE_READ,DIRECTORY|BACKUP_INTENT); + try { selectedRoot=CreateDirectoryPin(runtimeParent,runtimeId,selectedPath,"",selectedHandle,null);selectedHandle=null;directories.Add(selectedRoot);OpenChildren(selectedRoot); } + finally { if(selectedHandle!=null)selectedHandle.Dispose(); } + + if(installRoot.Volume!=runtimeParent.Volume||runtimeParent.Volume!=selectedRoot.Volume)throw new InvalidDataException("selected root volume ancestry changed"); + foreach(Pin file in files)if(String.Equals(file.RelativePath,"manifest.json",StringComparison.Ordinal)){if(manifest!=null)throw new InvalidDataException("duplicate manifest");manifest=file;} + if(manifest==null)throw new InvalidDataException("manifest missing"); + Revalidate(); + } + + private void OpenChildren(Pin directory) + { + if(Depth(directory.RelativePath)>32||directories.Count>4096||files.Count>16384)throw new InvalidDataException("bounded tree limit"); + string[] leaves=Enumerate(directory.Native); + for(int index=0;index=0||leaf.EndsWith(".",StringComparison.Ordinal)||leaf.EndsWith(" ",StringComparison.Ordinal))throw new InvalidDataException("unsafe enumerated leaf"); + if(runtimeParent==null&&(String.Equals(leaf,"device-key",StringComparison.OrdinalIgnoreCase)||String.Equals(leaf,".codex",StringComparison.OrdinalIgnoreCase)||String.Equals(leaf,".env",StringComparison.OrdinalIgnoreCase)||String.Equals(leaf,"credentials",StringComparison.OrdinalIgnoreCase)))throw new InvalidDataException("protected user data"); + string path=Path.Combine(directory.Path,leaf),relative=String.IsNullOrEmpty(directory.RelativePath)?leaf:directory.RelativePath+"/"+leaf; + SafeFileHandle handle=OpenRelative(directory.Native,leaf,DELETE|READ_DATA|READ_ATTRIBUTES|SYNCHRONIZE,SHARE_READ,BACKUP_INTENT); + try + { + FILE_INFO information=Info(handle); + if((information.FileAttributes&ATTR_REPARSE)!=0)throw new InvalidDataException("reparse child"); + if(information.VolumeSerialNumber!=selectedRoot.Volume)throw new InvalidDataException("child volume changed"); + if(IsDirectory(information)) + { + Pin child=CreateDirectoryPin(directory,leaf,path,relative,handle,null);handle=null;directories.Add(child);OpenChildren(child); + } + else + { + FileStream stream=null; + try + { + stream=new FileStream(handle,FileAccess.Read,65536,false);handle=null; + FILE_INFO current=Info(stream.SafeFileHandle);ValidateFileIdentity(current,stream.SafeFileHandle,path,selectedRoot.Volume); + long length=stream.Length;string sha=Sha(stream);Pin child=new Pin(directory,leaf,path,relative,stream,current,length,sha);stream=null;files.Add(child); + } + finally { if(stream!=null)stream.Dispose(); } + } + } + finally { if(handle!=null)handle.Dispose(); } + } + } + + internal CcodGenerationReclamationSnapshotV1 Snapshot() + { + if(disposed)throw new ObjectDisposedException("generation reclamation"); + Revalidate(); + List fileRecords=new List(); + foreach(Pin file in files)fileRecords.Add(new CcodGenerationReclamationSnapshotV1.FileRecord(file.RelativePath,file.Length,file.Sha256)); + fileRecords.Sort(delegate(CcodGenerationReclamationSnapshotV1.FileRecord left,CcodGenerationReclamationSnapshotV1.FileRecord right){return StringComparer.Ordinal.Compare(left.Path,right.Path);}); + List directoryPaths=new List();foreach(Pin directory in directories)if(!Object.ReferenceEquals(directory,selectedRoot))directoryPaths.Add(directory.RelativePath); + directoryPaths.Sort(StringComparer.Ordinal); + return new CcodGenerationReclamationSnapshotV1(manifest==null?null:ReadText(manifest.Stream),fileRecords.ToArray(),directoryPaths.ToArray()); + } + + internal void Reclaim(int failFileArmAt,int failCommitAt) + { + if(disposed)throw new ObjectDisposedException("generation reclamation"); + if(completed)throw new InvalidOperationException("generation already reclaimed"); + Revalidate(); + List armed=new List(); + try + { + int armIndex=0; + foreach(Pin file in files) + { + armIndex++; + if(failFileArmAt>0&&armIndex==failFileArmAt)throw new IOException("injected file-arm failure"); + SetDisposition(file.Native,true);file.Armed=true;armed.Add(file); + } + } + catch(Exception armFailure) + { + Exception rollbackFailure=null; + for(int index=armed.Count-1;index>=0;index--) + { + Pin file=armed[index]; + try { SetDisposition(file.Native,false);file.Armed=false; } + catch(Exception exception) { if(rollbackFailure==null)rollbackFailure=exception; } + } + try { Revalidate(); } catch(Exception exception) { if(rollbackFailure==null)rollbackFailure=exception; } + if(rollbackFailure!=null)throw new CcodGenerationReclamationRollbackException("PinnedValidated rollback failed after FilesArmed rejection",rollbackFailure); + throw new InvalidDataException("FilesArmed rejected and the complete tree was disarmed: "+armFailure.Message,armFailure); + } + + Exception commitFailure=null;int commitIndex=0; + foreach(Pin file in SortedFilesForCommit()) + { + commitIndex++; + try { if(failCommitAt>0&&commitIndex==failCommitAt)throw new IOException("injected CommitStarted file-close failure");file.Dispose(); } + catch(Exception exception) { if(commitFailure==null)commitFailure=exception; } + } + foreach(Pin directory in SortedDirectoriesForCommit()) + { + commitIndex++; + try { if(failCommitAt>0&&commitIndex==failCommitAt)throw new IOException("injected CommitStarted directory-mark failure");SetDisposition(directory.Native,true);directory.Armed=true;directory.Dispose(); } + catch(Exception exception) { if(commitFailure==null)commitFailure=exception; } + } + if(commitFailure==null&&(Directory.Exists(selectedPath)||File.Exists(selectedPath)))commitFailure=new IOException("selected root remains after handle commit"); + if(commitFailure!=null)throw new CcodGenerationReclamationCommitException("CommitStarted generation reclamation failed",commitFailure); + completed=true; + } + + private List SortedFilesForCommit() + { + List ordered=new List(files);ordered.Sort(delegate(Pin left,Pin right){int depth=Depth(right.RelativePath).CompareTo(Depth(left.RelativePath));return depth!=0?depth:StringComparer.Ordinal.Compare(left.RelativePath,right.RelativePath);});return ordered; + } + + private List SortedDirectoriesForCommit() + { + List ordered=new List(directories);ordered.Sort(delegate(Pin left,Pin right){int depth=Depth(right.RelativePath).CompareTo(Depth(left.RelativePath));return depth!=0?depth:StringComparer.Ordinal.Compare(left.RelativePath,right.RelativePath);});return ordered; + } + + private static int Depth(string relative){if(String.IsNullOrEmpty(relative))return 0;int depth=1;for(int index=0;index4194304)throw new InvalidDataException("manifest size invalid");long position=stream.Position; + try{stream.Position=0;byte[] bytes=new byte[(int)stream.Length];int offset=0;while(offset=buffer.Capacity){buffer.Capacity=(int)length+1;length=GetFinalPathNameByHandleW(handle,buffer,(uint)buffer.Capacity,0);if(length==0||length>=buffer.Capacity)throw new Win32Exception(Marshal.GetLastWin32Error());} + string path=buffer.ToString();if(path.StartsWith(@"\\?\UNC\",StringComparison.OrdinalIgnoreCase))return @"\\"+path.Substring(8);if(path.StartsWith(@"\\?\",StringComparison.OrdinalIgnoreCase))return path.Substring(4);return path; + } + + private static bool SamePath(string first,string second){return String.Equals(Path.GetFullPath(first).TrimEnd('\\'),Path.GetFullPath(second).TrimEnd('\\'),StringComparison.OrdinalIgnoreCase);} + + private static bool OnlyDefaultStream(SafeFileHandle handle) + { + IntPtr buffer=Marshal.AllocHGlobal(65536); + try + { + if(!GetFileInformationByHandleEx(handle,FileStreamInfo,buffer,65536)){int error=Marshal.GetLastWin32Error();if(error==38)return true;throw new Win32Exception(error);} + int offset=0;while(true){uint next=(uint)Marshal.ReadInt32(buffer,offset),nameLength=(uint)Marshal.ReadInt32(buffer,offset+4);string name=Marshal.PtrToStringUni(IntPtr.Add(buffer,offset+24),(int)nameLength/2);if(!String.Equals(name,"::$DATA",StringComparison.OrdinalIgnoreCase)&&!String.Equals(name,"::$INDEX_ALLOCATION",StringComparison.OrdinalIgnoreCase))return false;if(next==0)break;offset+=(int)next;}return true; + } + finally{Marshal.FreeHGlobal(buffer);} + } + + private static string[] Enumerate(SafeFileHandle handle) + { + List result=new List();HashSet seen=new HashSet(StringComparer.OrdinalIgnoreCase);IntPtr buffer=Marshal.AllocHGlobal(65536);bool restart=true; + try + { + while(true) + { + IO_STATUS_BLOCK io;int status=NtQueryDirectoryFile(handle,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,out io,buffer,65536,FileDirectoryInformation,false,IntPtr.Zero,restart);restart=false; + if(status==STATUS_NO_MORE_FILES)break;if(status<0)throw new Win32Exception((int)RtlNtStatusToDosError(status));int offset=0; + while(true){uint next=(uint)Marshal.ReadInt32(buffer,offset),nameLength=(uint)Marshal.ReadInt32(buffer,offset+60);string name=Marshal.PtrToStringUni(IntPtr.Add(buffer,offset+64),(int)nameLength/2);if(name!="."&&name!=".."){if(!seen.Add(name))throw new InvalidDataException("duplicate directory entry");result.Add(name);}if(next==0)break;offset+=(int)next;} + } + result.Sort(StringComparer.Ordinal);return result.ToArray(); + } + finally{Marshal.FreeHGlobal(buffer);} + } + + private static void SetDisposition(SafeFileHandle handle,bool delete) + { + IntPtr buffer=Marshal.AllocHGlobal(4); + try{Marshal.WriteInt32(buffer,delete?unchecked((int)(DISPOSITION_DELETE|DISPOSITION_IGNORE_READONLY)):0);if(!SetFileInformationByHandle(handle,FileDispositionInfoEx,buffer,4))throw new Win32Exception(Marshal.GetLastWin32Error());} + finally{Marshal.FreeHGlobal(buffer);} + } + + public void Dispose() + { + if(disposed)return;disposed=true; + for(int index=files.Count-1;index>=0;index--){try{files[index].Dispose();}catch{}} + for(int index=directories.Count-1;index>=0;index--){try{directories[index].Dispose();}catch{}} + try{if(runtimeParent!=null)runtimeParent.Dispose();}catch{} + try{if(installRoot!=null)installRoot.Dispose();}catch{} + for(int index=residueAncestors.Count-1;index>=0;index--){try{residueAncestors[index].Dispose();}catch{}} + } +} +'@ + $marker = 'CcodGenerationReclamationMarkerV1' -as [type] + } + if($null-eq$marker-or[int]$marker.GetProperty('CapabilityAbi').GetValue($null,$null)-ne1){Throw-CcodGenerationReclamationError 'CCOD_GENERATION_RECLAMATION_ABI_INVALID' 'Generation reclamation native ABI is unavailable' $null} + $script:CcodGenerationReclamationRuntimeType=$marker.Assembly.GetType('CcodGenerationReclamationRuntimeV1',$true) +} + +function Invoke-CcodGenerationReclamationStatic { + param([Parameter(Mandatory)][string]$Name,[object[]]$Arguments) + $method=$script:CcodGenerationReclamationRuntimeType.GetMethod($Name,[Reflection.BindingFlags]'NonPublic,Static') + try{return $method.Invoke($null,$Arguments)}catch [Reflection.TargetInvocationException]{throw $_.Exception.InnerException} +} + +function Invoke-CcodGenerationReclamationMethod { + param([Parameter(Mandatory)]$Runtime,[Parameter(Mandatory)][string]$Name,[object[]]$Arguments) + $method=$Runtime.GetType().GetMethod($Name,[Reflection.BindingFlags]'NonPublic,Instance') + try{return $method.Invoke($Runtime,$Arguments)}catch [Reflection.TargetInvocationException]{throw $_.Exception.InnerException} +} + +function Test-CcodGenerationReclamationJsonHasNoDuplicateProperties { + param([AllowNull()][string]$Json) + if($null-eq$Json){return $false};$objects=[Collections.Generic.Stack[object]]::new() + for($index=0;$index-lt$Json.Length;$index++){ + $character=$Json[$index] + if($character-eq'{'){$objects.Push([Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal));continue} + if($character-eq'}'){if($objects.Count-eq0){return $false};[void]$objects.Pop();continue} + if($character-ne'"'){continue};$name=[Text.StringBuilder]::new();$index++ + while($index-lt$Json.Length){$character=$Json[$index];if($character-eq'"'){break};if($character-ne[char]92){[void]$name.Append($character);$index++;continue};$index++;if($index-ge$Json.Length){return $false};$escape=$Json[$index];if($escape-eq'u'){if($index+4-ge$Json.Length){return $false};try{[void]$name.Append([char][Convert]::ToInt32($Json.Substring($index+1,4),16))}catch{return $false};$index+=5;continue};switch([string]$escape){'"'{$decoded='"'}'\'{$decoded=[char]92}'/'{$decoded='/'}'b'{$decoded=[char]8}'f'{$decoded=[char]12}'n'{$decoded=[char]10}'r'{$decoded=[char]13}'t'{$decoded=[char]9}default{return $false}};[void]$name.Append($decoded);$index++} + if($index-ge$Json.Length){return $false};$next=$index+1;while($next-lt$Json.Length-and[char]::IsWhiteSpace($Json[$next])){$next++};if($next-lt$Json.Length-and$Json[$next]-eq':'){if($objects.Count-eq0-or-not$objects.Peek().Add($name.ToString())){return $false}} + } + return $objects.Count-eq0 +} + +function Test-CcodGenerationReclamationExactProperties { + param($Value,[string[]]$Expected) + if($null-eq$Value-or$Value-isnot[pscustomobject]){return $false};$actual=@($Value.PSObject.Properties.Name);if($actual.Count-ne$Expected.Count){return $false};for($index=0;$index-lt$actual.Count;$index++){if($actual[$index]-cne$Expected[$index]){return $false}};return $true +} + +function ConvertTo-CcodGenerationReclamationRecord { + param([Parameter(Mandatory)]$Record) + if(-not(Test-CcodGenerationReclamationExactProperties $Record @('path','length','sha256'))-or$Record.path-isnot[string]-or$Record.path-cnotmatch'^[A-Za-z0-9][A-Za-z0-9._/-]{0,255}$'-or$Record.path.Contains('//')-or$Record.path-match'(^|/)(?:\.|\.\.)(?:/|$)'-or$Record.path.Equals('manifest.json',[StringComparison]::OrdinalIgnoreCase)-or$Record.sha256-isnot[string]-or$Record.sha256-cnotmatch'^[0-9a-f]{64}$'){Throw-CcodGenerationReclamationError 'CCOD_GENERATION_RECLAMATION_INVALID' 'A pinned manifest record is invalid' $Record} + $integerTypes=@([byte],[uint16],[uint32],[uint64],[int16],[int32],[int64]);$integer=$false;foreach($type in $integerTypes){if($Record.length-is$type){$integer=$true;break}};if(-not$integer){Throw-CcodGenerationReclamationError 'CCOD_GENERATION_RECLAMATION_INVALID' 'A pinned manifest length is not an integer' $Record};try{$length=[int64]$Record.length}catch{Throw-CcodGenerationReclamationError 'CCOD_GENERATION_RECLAMATION_INVALID' 'A pinned manifest length is outside Int64' $Record};if($length-lt0){Throw-CcodGenerationReclamationError 'CCOD_GENERATION_RECLAMATION_INVALID' 'A pinned manifest length is negative' $Record} + [pscustomobject][ordered]@{path=[string]$Record.path;length=$length;sha256=[string]$Record.sha256} +} + +function Get-CcodGenerationReclamationRuntimeId { + param([Parameter(Mandatory)][string]$ProjectVersion,[Parameter(Mandatory)][object[]]$Records,[Parameter(Mandatory)][string]$Nonce,[switch]$LegacyLiteralSeparators) + if($ProjectVersion-cnotmatch'^[A-Za-z0-9][A-Za-z0-9._-]{0,45}$'-or$Nonce-cnotmatch'^[0-9a-f]{32}$'){Throw-CcodGenerationReclamationError 'CCOD_GENERATION_RECLAMATION_INVALID' 'Pinned manifest identity components are invalid' $ProjectVersion};$lines=[Collections.Generic.List[string]]::new();foreach($record in $Records){$line=if($LegacyLiteralSeparators){'{0}`t{1}`t{2}'-f$record.path,[int64]$record.length,$record.sha256}else{"{0}`t{1}`t{2}"-f$record.path,[int64]$record.length,$record.sha256};$lines.Add($line)};$sha=[Security.Cryptography.SHA256]::Create();try{$digest=[BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes(($lines-join"`n")))).Replace('-','').ToLowerInvariant()}finally{$sha.Dispose()};'{0}-{1}-{2}'-f$ProjectVersion,$digest.Substring(0,16),$Nonce +} + +function Assert-CcodGenerationReclamationSnapshot { + param([Parameter(Mandatory)]$Snapshot,[Parameter(Mandatory)][string]$RuntimeId,[Parameter(Mandatory)][string]$ExpectedManifestSha256,[switch]$AllowLegacy2521) + $files=@($Snapshot.Files);$manifestFile=@($files|Where-Object{$_.Path-ceq'manifest.json'});if($manifestFile.Count-ne1-or[string]$manifestFile[0].Sha256-cne$ExpectedManifestSha256){Throw-CcodGenerationReclamationError 'CCOD_GENERATION_RECLAMATION_INVALID' 'Pinned manifest hash does not match the durable binding' $RuntimeId} + try{$json=[string]$Snapshot.ManifestText;if(-not(Test-CcodGenerationReclamationJsonHasNoDuplicateProperties $json)){throw 'duplicate properties'};$manifest=$json|ConvertFrom-Json -ErrorAction Stop}catch{Throw-CcodGenerationReclamationError 'CCOD_GENERATION_RECLAMATION_INVALID' 'Pinned manifest JSON is malformed or ambiguous' $RuntimeId} + if(-not(Test-CcodGenerationReclamationExactProperties $manifest @('schemaVersion','projectVersion','runtimeId','files'))-or$manifest.schemaVersion-isnot[int]-or$manifest.schemaVersion-ne1-or$manifest.projectVersion-isnot[string]-or$manifest.runtimeId-isnot[string]-or$manifest.runtimeId-cne$RuntimeId-or$null-eq$manifest.files){Throw-CcodGenerationReclamationError 'CCOD_GENERATION_RECLAMATION_INVALID' 'Pinned manifest header is invalid' $RuntimeId} + $records=[Collections.Generic.List[object]]::new();$previous=$null;foreach($record in @($manifest.files)){$validated=ConvertTo-CcodGenerationReclamationRecord $record;if($null-ne$previous-and[StringComparer]::Ordinal.Compare($previous,$validated.path)-ge0){Throw-CcodGenerationReclamationError 'CCOD_GENERATION_RECLAMATION_INVALID' 'Pinned manifest records are not strictly ordered' $RuntimeId};$previous=$validated.path;$records.Add($validated)} + $match=[regex]::Match($RuntimeId,'^(?[A-Za-z0-9][A-Za-z0-9._-]{0,45})-(?[0-9a-f]{16})-(?[0-9a-f]{32})$') + $legacy=$AllowLegacy2521-and$manifest.projectVersion-ceq'2.5.21'-and$RuntimeId-cmatch'^2\.5\.21-[0-9a-f]{16}\z' + if($legacy){ + $suffix='-'+('0'*32);$canonical=Get-CcodGenerationReclamationRuntimeId -ProjectVersion '2.5.21' -Records $records.ToArray() -Nonce ('0'*32) -LegacyLiteralSeparators + if($canonical.Substring(0,$canonical.Length-$suffix.Length)-cne$RuntimeId){Throw-CcodGenerationReclamationError 'CCOD_GENERATION_RECLAMATION_INVALID' 'Legacy manifest digest does not match its exact runtime ID' $RuntimeId} + }elseif(-not$match.Success-or$match.Groups['version'].Value-cne$manifest.projectVersion-or(Get-CcodGenerationReclamationRuntimeId -ProjectVersion $manifest.projectVersion -Records $records.ToArray() -Nonce $match.Groups['nonce'].Value)-cne$RuntimeId){Throw-CcodGenerationReclamationError 'CCOD_GENERATION_RECLAMATION_INVALID' 'Pinned manifest is not bound to the selected runtime ID' $RuntimeId} + $expectedFiles=[Collections.Generic.List[object]]::new();foreach($record in $records){$expectedFiles.Add($record)};$expectedFiles.Add([pscustomobject]@{path='manifest.json';length=[int64]$manifestFile[0].Length;sha256=[string]$manifestFile[0].Sha256});$expectedFiles.Sort([Comparison[object]]{param($left,$right)[StringComparer]::Ordinal.Compare([string]$left.path,[string]$right.path)}) + if($files.Count-ne$expectedFiles.Count){Throw-CcodGenerationReclamationError 'CCOD_GENERATION_RECLAMATION_INVALID' 'Pinned tree contains an unexpected or missing file' $RuntimeId};for($index=0;$index-lt$files.Count;$index++){$actual=$files[$index];$expected=$expectedFiles[$index];if([string]$actual.Path-cne[string]$expected.path-or[int64]$actual.Length-ne[int64]$expected.length-or[string]$actual.Sha256-cne[string]$expected.sha256){Throw-CcodGenerationReclamationError 'CCOD_GENERATION_RECLAMATION_INVALID' 'Pinned tree differs from its sealed manifest' $actual.Path}} + $expectedDirectories=[Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal);foreach($record in $records){$segments=[string]$record.path-split'/';for($count=1;$count-lt$segments.Count;$count++){[void]$expectedDirectories.Add(($segments[0..($count-1)]-join'/'))}};$expectedDirectoryList=@($expectedDirectories);[Array]::Sort($expectedDirectoryList,[StringComparer]::Ordinal);$actualDirectories=@($Snapshot.Directories);if($actualDirectories.Count-ne$expectedDirectoryList.Count){Throw-CcodGenerationReclamationError 'CCOD_GENERATION_RECLAMATION_INVALID' 'Pinned tree contains an unexpected or missing directory' $RuntimeId};for($index=0;$index-lt$actualDirectories.Count;$index++){if([string]$actualDirectories[$index]-cne[string]$expectedDirectoryList[$index]){Throw-CcodGenerationReclamationError 'CCOD_GENERATION_RECLAMATION_INVALID' 'Pinned directory set differs from the sealed manifest' $actualDirectories[$index]}} + [pscustomobject]@{FileCount=$files.Count;DirectoryCount=$actualDirectories.Count+1} +} + +function Remove-CcodVerifiedGenerationTree { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$InstallRoot, + [Parameter(Mandatory)][string]$RuntimeRoot, + [Parameter(Mandatory)][string]$RuntimeId, + [Parameter(Mandatory)][string]$ExpectedManifestSha256 + ) + try{$install=[IO.Path]::GetFullPath($InstallRoot).TrimEnd('\');$runtime=[IO.Path]::GetFullPath($RuntimeRoot).TrimEnd('\')}catch{Throw-CcodGenerationReclamationError 'CCOD_GENERATION_RECLAMATION_INVALID' 'Generation reclamation paths are invalid' $RuntimeRoot} + if(-not[IO.Path]::IsPathRooted($InstallRoot)-or-not[IO.Path]::IsPathRooted($RuntimeRoot)-or$RuntimeId-cnotmatch'^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$'-or$RuntimeId-in@('.','..')-or$RuntimeId.EndsWith('.')-or$ExpectedManifestSha256-cnotmatch'^[0-9a-f]{64}$'){Throw-CcodGenerationReclamationError 'CCOD_GENERATION_RECLAMATION_INVALID' 'Generation reclamation inputs are invalid' $RuntimeRoot} + $expected=[IO.Path]::GetFullPath((Join-Path (Join-Path $install 'runtime') $RuntimeId)).TrimEnd('\');if($runtime-cne$expected){Throw-CcodGenerationReclamationError 'CCOD_GENERATION_RECLAMATION_INVALID' 'Runtime root is not the exact selected install generation leaf' $RuntimeRoot} + Initialize-CcodGenerationReclamationRuntime;$native=$null + try{ + $native=Invoke-CcodGenerationReclamationStatic -Name Open -Arguments @($install,$runtime,$RuntimeId);$snapshot=Invoke-CcodGenerationReclamationMethod -Runtime $native -Name Snapshot -Arguments @();$proof=Assert-CcodGenerationReclamationSnapshot -Snapshot $snapshot -RuntimeId $RuntimeId -ExpectedManifestSha256 $ExpectedManifestSha256 + if($script:CcodGenerationReclamationBeforeArmForTest-is[scriptblock]){&$script:CcodGenerationReclamationBeforeArmForTest} + [void](Invoke-CcodGenerationReclamationMethod -Runtime $native -Name Reclaim -Arguments @([int]$script:CcodGenerationReclamationFailFileArmAtForTest,[int]$script:CcodGenerationReclamationFailCommitAtForTest)) + [pscustomobject][ordered]@{phase='Completed';result='Reclaimed';runtimeId=$RuntimeId;fileCount=[int]$proof.FileCount;directoryCount=[int]$proof.DirectoryCount} + }catch{ + $exception=$_.Exception;while($null-ne$exception.InnerException-and$exception-is[Management.Automation.RuntimeException]){$exception=$exception.InnerException};$type=[string]$exception.GetType().Name + if($type-ceq'CcodGenerationReclamationRollbackException'){Throw-CcodGenerationReclamationError 'CCOD_GENERATION_RECLAMATION_ROLLBACK_FAILED' $exception.Message $RuntimeRoot} + if($type-ceq'CcodGenerationReclamationCommitException'){Throw-CcodGenerationReclamationError 'CCOD_GENERATION_RECLAMATION_COMMIT_FAILED' $exception.Message $RuntimeRoot} + if(([string]$_.FullyQualifiedErrorId-split',')[0]-match'^CCOD_GENERATION_RECLAMATION_'){throw} + Throw-CcodGenerationReclamationError 'CCOD_GENERATION_RECLAMATION_INVALID' ('PinnedValidated generation reclamation rejected: '+$exception.Message) $RuntimeRoot + }finally{if($null-ne$native){try{$native.Dispose()}catch{}}} +} + +function Read-CcodProductResidueText { + param([Parameter(Mandatory)]$Native,[Parameter(Mandatory)][string]$Path) + if($Native-is[Collections.IDictionary]){ + if(-not$Native.Contains($Path)-or$Native[$Path]-isnot[string]){throw 'cleanup evidence text is missing'} + return [string]$Native[$Path] + } + return Invoke-CcodGenerationReclamationMethod -Runtime $Native -Name ReadFileText -Arguments @($Path) +} + +function Read-CcodProductResidueJson { + param([Parameter(Mandatory)]$Native,[Parameter(Mandatory)][string]$Path) + $text=Read-CcodProductResidueText -Native $Native -Path $Path + if(-not(Test-CcodGenerationReclamationJsonHasNoDuplicateProperties $text)){throw 'ambiguous product state'} + $value=$text|ConvertFrom-Json -ErrorAction Stop + if($value-isnot[pscustomobject]-or$null-eq$value.PSObject.Properties['schemaVersion']-or + ($value.schemaVersion-isnot[int]-and$value.schemaVersion-isnot[long])-or$value.schemaVersion-lt1-or$value.schemaVersion-gt3){throw 'unknown product state format'} + return $value +} + +function Assert-CcodProductResidueSnapshot { + param([Parameter(Mandatory)]$Native,[Parameter(Mandatory)]$Snapshot,[Parameter(Mandatory)][string]$SelectedRuntimeId,[Parameter(Mandatory)][uint64]$ExpectedEpoch) + $epoch=Read-CcodProductResidueJson -Native $Native -Path 'state/lifecycle-epoch.json' + if(-not(Test-CcodGenerationReclamationExactProperties $epoch @('schemaVersion','epoch'))-or + ($epoch.epoch-isnot[int]-and$epoch.epoch-isnot[long])-or$epoch.epoch-lt0-or[uint64]$epoch.epoch-ne$ExpectedEpoch){throw 'product residue epoch changed'} + $knownFiles=[Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + $expectedDirectories=[Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + [void]$knownFiles.Add('state/lifecycle-epoch.json') + foreach($directory in @('runtime','state','logs','state/active-generation','state/lifecycle','state/lifecycle/receipts','state/lifecycle/inbox','state/workers')){[void]$expectedDirectories.Add($directory)} + $pointers=[Collections.Generic.List[object]]::new() + foreach($file in $Snapshot.Files){ + $path=[string]$file.Path + if($path-cmatch'^state/active-generation/([0-9]{20})\.json\z'){ + $number=[uint64]$matches[1];$pointer=Read-CcodProductResidueJson -Native $Native -Path $path + if(-not(Test-CcodGenerationReclamationExactProperties $pointer @('schemaVersion','generation','activeRuntime','previousGeneration'))-or + $pointer.schemaVersion-ne1-or$number-ne[uint64]($pointers.Count+1)-or + ($pointer.generation-isnot[int]-and$pointer.generation-isnot[long])-or$pointer.generation-ne$number-or + ($pointer.previousGeneration-isnot[int]-and$pointer.previousGeneration-isnot[long])-or$pointer.previousGeneration-ne($number-1)-or + $pointer.activeRuntime-isnot[string]-or$pointer.activeRuntime-cnotmatch'^2\.[0-9]+\.[0-9]+-[0-9a-f]{16}(?:-[0-9a-f]{32})?\z'){throw 'product selector invalid'} + $pointers.Add($pointer);[void]$knownFiles.Add($path) + } + } + if($pointers.Count-eq0-or$pointers[$pointers.Count-1].activeRuntime-cne$SelectedRuntimeId){throw 'product residue selector changed'} + $retainedIds=@($Snapshot.Directories|Where-Object {$_-cmatch'^runtime/[^/]+\z'}|ForEach-Object {$_.Substring(8)}) + foreach($runtimeId in $retainedIds){ + if($runtimeId-ceq$SelectedRuntimeId){throw 'selected generation was not reclaimed'} + $prefix='runtime/'+$runtimeId+'/' + $manifestFile=@($Snapshot.Files|Where-Object {$_.Path-ceq($prefix+'manifest.json')}) + if($manifestFile.Count-ne1){throw 'retained generation manifest is missing'} + $manifestText=Read-CcodProductResidueText -Native $Native -Path ($prefix+'manifest.json') + $relativeFiles=@($Snapshot.Files|Where-Object {$_.Path.StartsWith($prefix,[StringComparison]::Ordinal)}|ForEach-Object {[pscustomobject]@{Path=$_.Path.Substring($prefix.Length);Length=$_.Length;Sha256=$_.Sha256}}) + $relativeDirectories=@($Snapshot.Directories|Where-Object {$_.StartsWith($prefix,[StringComparison]::Ordinal)}|ForEach-Object {$_.Substring($prefix.Length)}) + [void](Assert-CcodGenerationReclamationSnapshot -Snapshot ([pscustomobject]@{Files=$relativeFiles;Directories=$relativeDirectories;ManifestText=$manifestText}) -RuntimeId $runtimeId -ExpectedManifestSha256 $manifestFile[0].Sha256 -AllowLegacy2521) + [void]$expectedDirectories.Add('runtime/'+$runtimeId) + foreach($file in $relativeFiles){[void]$knownFiles.Add($prefix+$file.Path)} + foreach($directory in $relativeDirectories){[void]$expectedDirectories.Add($prefix+$directory)} + } + $stateFields=@{ + 'settings.json'=@('schemaVersion','automationEnabled','candidateCompatibleOptIn','nodeCandidates','updatedAtUtc') + 'status.json'=@('schemaVersion','session') + 'verified-packages.json'=@('schemaVersion','packages') + 'transition.json'=@('schemaVersion','activeTransaction') + 'ui-preferences.json'=@('schemaVersion','languageMode','updatedAtUtc') + } + $transactionFields=@('schemaVersion','transactionId','oldRuntimeId','oldGeneration','oldManifestSha256','newRuntimeId','newGeneration','newManifestSha256','sealedPackageSha256','ownedObjectNames','phase','errorCode') + $installPhases=@('Prepared','PackageVerified','RuntimeStaged','PreviousProtectionStopped','RuntimePromoted','PointerCommitted','StableShellCommitted','ProtectionReady','Ready') + $activationFields=@('schemaVersion','activationId','phase','runtimeId','previousRuntimeId','startedAtUtc','updatedAtUtc','ready','errorCode') + $activationPhases=@('StoppingPreviousRuntime','InstallingRuntime','ActivatingRuntime','StartingProtection','Ready','Failed') + $readyRecords=@{} + foreach($file in $Snapshot.Files|Where-Object {$_.Path-cmatch'^state/install-transactions/[0-9]{20}\.08\.Ready\.[0-9a-f-]{36}\.json\z'}){ + $record=Read-CcodProductResidueJson -Native $Native -Path $file.Path + if(-not(Test-CcodGenerationReclamationExactProperties $record $transactionFields)-or$record.phase-cne'Ready'-or$record.newRuntimeId-isnot[string]){throw 'invalid Ready transaction'} + $readyRecords[$record.transactionId]=$record + } + foreach($file in $Snapshot.Files){ + $path=[string]$file.Path + if($knownFiles.Contains($path)){continue} + if($path-cmatch'^logs/(install|session)\.log(?:\.(?:[1-9]|10))?\z'){ + $kind=$matches[1] + if($file.Length-gt2097152){throw 'product diagnostic exceeds its writer limit'} + $text=Read-CcodProductResidueText -Native $Native -Path $path + foreach($line in @($text-split'\r?\n'|Where-Object {$_-cne''})){ + if(-not(Test-CcodGenerationReclamationJsonHasNoDuplicateProperties $line)){throw 'ambiguous product diagnostic'} + $record=$line|ConvertFrom-Json -ErrorAction Stop + $fields=if($kind-ceq'install'){@('schemaVersion','timestampUtc','component','stage','code','outcome')}else{@('schemaVersion','timestampUtc','action','transactionId','stage','code')} + if($kind-ceq'session'-and$record.schemaVersion-eq2){$fields+=@('reason')} + if(-not(Test-CcodGenerationReclamationExactProperties $record $fields)-or$record.schemaVersion-isnot[int]-or$record.schemaVersion-notin@(1,2)-or + $record.stage-isnot[string]-or$record.code-isnot[string]-or$record.code-cnotmatch'^CCOD_[A-Z0-9_]+\z'-or$record.timestampUtc-isnot[string]){throw 'unknown product diagnostic contract'} + if($kind-ceq'install'-and($record.schemaVersion-ne1-or$record.component-cne'Install'-or$record.outcome-isnot[string])){throw 'unknown install diagnostic contract'} + if($kind-ceq'session'-and$null-ne$record.action-and@('Inspect','Close','Apply','RepairRenderer','Recover')-cnotcontains$record.action){throw 'unknown session diagnostic action'} + [void][DateTime]::ParseExact($record.timestampUtc,'o',[Globalization.CultureInfo]::InvariantCulture,[Globalization.DateTimeStyles]::RoundtripKind) + } + [void]$knownFiles.Add($path);continue + } + if($path-cmatch'^state/lifecycle/receipts/([0-9a-f-]{36})\.json\z'){ + $id=$matches[1];$value=Read-CcodProductResidueJson -Native $Native -Path $path + $fields=@('schemaVersion','transactionId','kind','origin','runtimeId','runtimeGeneration','leaseEpoch','ownerIdentity','logonIdentity','phase','createdAtUtc','updatedAtUtc','launchRequestedAtUtc','manualLaunchExpiresAtUtc','automaticLaunchAttempts','error') + if(-not(Test-CcodGenerationReclamationExactProperties $value $fields)-or$value.schemaVersion-ne1-or$value.transactionId-cne$id-or + @('Completed','CloseFailed','OrdinaryLaunchFailed','OrdinaryObservationTimedOut','LaunchWindowExpired','RepairFailed','VerificationFailed','CancelledBeforeClose','SupersededByUpgrade')-cnotcontains$value.phase-or + @($pointers|Where-Object {$_.activeRuntime-ceq$value.runtimeId}).Count-eq0){throw 'lifecycle receipt is not a completed installed operation'} + [void]$knownFiles.Add($path);continue + } + if($path-cmatch'^state/lifecycle/inbox/([0-9a-f-]{36})\.receipt\.json\z'){ + $id=$matches[1];$value=Read-CcodProductResidueJson -Native $Native -Path $path + if(-not(Test-CcodGenerationReclamationExactProperties $value @('schemaVersion','submissionId','accepted','transactionId','errorCode','completedAtUtc'))-or$value.schemaVersion-ne1-or$value.submissionId-cne$id-or$value.accepted-isnot[bool]-or + ($value.accepted-and($value.transactionId-isnot[string]-or$value.transactionId-cnotmatch'^[0-9a-f-]{36}\z'-or$null-ne$value.errorCode))){throw 'submission receipt is not exact'} + [void]$knownFiles.Add($path);continue + } + if($path-ceq'state/lifecycle/safe-exit-intent.json'){ + $value=Read-CcodProductResidueJson -Native $Native -Path $path + if(-not(Test-CcodGenerationReclamationExactProperties $value @('schemaVersion','logonIdentity','runtimeId','recoveryTransactionId','createdAtUtc'))-or$value.schemaVersion-ne1-or + @($pointers|Where-Object {$_.activeRuntime-ceq$value.runtimeId}).Count-eq0-or$value.recoveryTransactionId-isnot[string]-or$value.recoveryTransactionId-cnotmatch'^[0-9a-f-]{36}\z'-or + -not(Test-CcodGenerationReclamationExactProperties $value.logonIdentity @('authenticationId','userSid','sessionId'))){throw 'safe-exit marker is not an installed lifecycle record'} + [void]$knownFiles.Add($path);continue + } + if($path-ceq'state/transaction-completion.receipt.json'){ + $value=Read-CcodProductResidueJson -Native $Native -Path $path + if(-not(Test-CcodGenerationReclamationExactProperties $value @('schemaVersion','transactionId','disposition','terminalStage','completedAtUtc','state','archiveErrorId'))-or$value.schemaVersion-ne1-or + @('Archived','ArchiveFailed')-cnotcontains$value.state-or@('Cancelled','Activated','Recovered','Closed')-cnotcontains$value.disposition){throw 'transition completion is not terminal'} + [void]$knownFiles.Add($path);continue + } + if($path-cmatch'^logs/transactions\.log(?:\.(?:[1-9]|10))?\z'){ + if($file.Length-gt2097152){throw 'transaction log exceeds its writer limit'} + $text=Read-CcodProductResidueText -Native $Native -Path $path + foreach($line in @($text-split'\r?\n'|Where-Object {$_-cne''})){ + if(-not(Test-CcodGenerationReclamationJsonHasNoDuplicateProperties $line)){throw 'ambiguous transaction diagnostic'} + $record=$line|ConvertFrom-Json -ErrorAction Stop + $fields=@('schemaVersion','transactionId','disposition','terminalStage','sourcePid','sourceCreationTimeUtc','specialPid','specialCreationTimeUtc','recoveryPid','recoveryCreationTimeUtc','appAsarSha256','runtimeId','completedAtUtc','archiveState') + if(-not(Test-CcodGenerationReclamationExactProperties $record $fields)-or$record.schemaVersion-isnot[int]-or$record.schemaVersion-ne1-or$record.archiveState-cne'Archived'-or + @('Cancelled','Activated','Recovered','Closed')-cnotcontains$record.disposition-or@($pointers|Where-Object {$_.activeRuntime-ceq$record.runtimeId}).Count-eq0){throw 'unknown transaction archive contract'} + } + [void]$knownFiles.Add($path);continue + } + if($path-ceq'active.json'){ + $value=Read-CcodProductResidueJson -Native $Native -Path $path + if(-not(Test-CcodGenerationReclamationExactProperties $value @('schemaVersion','activeRuntime','previousRuntime','generation','updatedAtUtc'))-or + $value.schemaVersion-ne2-or$value.activeRuntime-cnotmatch'^2\.5\.21-[0-9a-f]{16}\z'-or$pointers[0].activeRuntime-cne$value.activeRuntime-or + @($retainedIds|Where-Object {$_-ceq$value.activeRuntime}).Count-ne1){throw 'legacy pointer is not bound to a retained installed generation'} + [void]$knownFiles.Add($path);continue + } + if($path-ceq'state/post-install-activation.json'){ + $value=Read-CcodProductResidueJson -Native $Native -Path $path + if(-not(Test-CcodGenerationReclamationExactProperties $value $activationFields)-or$value.schemaVersion-ne1-or$value.phase-cne'Ready'-or$value.ready-isnot[bool]-or-not$value.ready-or + $value.runtimeId-cnotmatch'^2\.5\.21-[0-9a-f]{16}\z'-or$pointers[0].activeRuntime-cne$value.runtimeId-or$null-ne$value.errorCode){throw 'legacy activation record is not an exact Ready record'} + [void]$knownFiles.Add($path);continue + } + if(@('bootstrap.ps1','Uninstall-CodexControlOtherDevices.ps1')-ccontains$path){ + $relative=if($path-ceq'bootstrap.ps1'){'src/persistence/bootstrap.ps1'}else{$path} + $matching=@($Snapshot.Files|Where-Object {$_.Path-ceq('runtime/'+$pointers[0].activeRuntime+'/'+$relative)-and$_.Length-eq$file.Length-and$_.Sha256-ceq$file.Sha256}) + if($pointers[0].activeRuntime-cnotmatch'^2\.5\.21-[0-9a-f]{16}\z'-or$matching.Count-ne1){throw 'stable legacy shell differs from verified generation'} + [void]$knownFiles.Add($path);continue + } + if($path-ceq'state/lifecycle-epoch.initialized.json'){ + $value=Read-CcodProductResidueJson -Native $Native -Path $path + if($value.schemaVersion-ne1-or-not(Test-CcodGenerationReclamationExactProperties $value @('schemaVersion'))){throw 'epoch initialization record is not exact'} + [void]$knownFiles.Add($path);continue + } + if($path-cmatch'^logs/supervisor\.log(?:\.(?:[1-9]|10))?\z'){ + if($file.Length-gt2097152){throw 'product log exceeds its writer limit'} + $text=Read-CcodProductResidueText -Native $Native -Path $path + foreach($line in @($text-split'\r?\n'|Where-Object {$_-cne''})){ + if(-not(Test-CcodGenerationReclamationJsonHasNoDuplicateProperties $line)){throw 'ambiguous supervisor diagnostic'} + $record=$line|ConvertFrom-Json -ErrorAction Stop + $fields=@('schemaVersion','timestampUtc','component','stage','code','outcome') + if($record.stage-ceq'TrayAction'){$fields+=@('command','revision','status')} + elseif($record.stage-ceq'TrayHostReady'){$fields+=@('runtimeId','hostPid','hostCreationTimeUtc','protocolMajor','capabilities')} + $abandonedWarning=$record.stage-ceq'LeaseAcquire'-and$record.code-ceq'CCOD_SUPERVISOR_LEASE_ABANDONED'-and$record.outcome-ceq'Warning' + if(-not(Test-CcodGenerationReclamationExactProperties $record $fields)-or$record.schemaVersion-isnot[int]-or$record.schemaVersion-ne1-or + $record.component-cne'Supervisor'-or$record.stage-isnot[string]-or$record.code-isnot[string]-or$record.code-cnotmatch'^CCOD_[A-Z0-9_]+\z'-or + $record.outcome-isnot[string]-or(@('Completed','Rejected','Failed','Ready')-cnotcontains$record.outcome-and-not$abandonedWarning)-or$record.timestampUtc-isnot[string]){throw 'unknown supervisor diagnostic contract'} + [void][DateTime]::ParseExact($record.timestampUtc,'o',[Globalization.CultureInfo]::InvariantCulture,[Globalization.DateTimeStyles]::RoundtripKind) + } + [void]$knownFiles.Add($path);continue + } + if($path-cmatch'^state/install-initializations/(2\.5\.22-[0-9a-f]{16}-[0-9a-f]{32})/([^/]+)\z'){ + $runtimeId=$matches[1];$leaf=$matches[2] + if(-not$stateFields.ContainsKey($leaf)-or@($pointers|Where-Object {$_.activeRuntime-ceq$runtimeId}).Count-eq0){throw 'initialization baseline is not an installed generation'} + $value=Read-CcodProductResidueJson -Native $Native -Path $path + if($value.schemaVersion-ne1-or-not(Test-CcodGenerationReclamationExactProperties $value $stateFields[$leaf])){throw 'initialization state members changed'} + [void]$expectedDirectories.Add('state/install-initializations');[void]$expectedDirectories.Add('state/install-initializations/'+$runtimeId) + [void]$knownFiles.Add($path);continue + } + if($path-cmatch'^state/install-transactions/([0-9]{20})\.([0-9]{2})\.([A-Za-z]+)\.([0-9a-f-]{36})\.json\z'){ + $generation=[uint64]$matches[1];$phaseIndex=[int]$matches[2];$phase=$matches[3];$id=$matches[4] + $value=Read-CcodProductResidueJson -Native $Native -Path $path + if(-not(Test-CcodGenerationReclamationExactProperties $value $transactionFields)-or$value.newGeneration-ne$generation-or$value.phase-cne$phase-or$value.transactionId-cne$id-or + $phaseIndex-ge$installPhases.Count-or$installPhases[$phaseIndex]-cne$phase-or-not$readyRecords.ContainsKey($id)-or$value.newRuntimeId-cne$readyRecords[$id].newRuntimeId){throw 'install transaction identity mismatch'} + [void]$expectedDirectories.Add('state/install-transactions');[void]$knownFiles.Add($path);continue + } + if($path-cmatch'^state/activation-receipts/([0-9a-f-]{36})\.([A-Za-z]+)\.json\z'){ + $id=$matches[1];$phase=$matches[2];$value=Read-CcodProductResidueJson -Native $Native -Path $path + if(-not(Test-CcodGenerationReclamationExactProperties $value $activationFields)-or$value.activationId-cne$id-or$value.phase-cne$phase-or$activationPhases-cnotcontains$phase-or + $value.ready-isnot[bool]-or$value.ready-ne($phase-ceq'Ready')-or($null-ne$value.runtimeId-and@($pointers|Where-Object {$_.activeRuntime-ceq$value.runtimeId}).Count-eq0)){throw 'activation receipt identity mismatch'} + [void]$expectedDirectories.Add('state/activation-receipts');[void]$knownFiles.Add($path);continue + } + if($path-cmatch'^state/install-logs/([0-9]{1,19})\.([A-Za-z]+)\.[0-9a-f]{32}\.json\z'){ + $ticks=$matches[1];$stage=$matches[2];$value=Read-CcodProductResidueJson -Native $Native -Path $path + if(-not(Test-CcodGenerationReclamationExactProperties $value @('schemaVersion','timestampUtc','component','stage','code','outcome'))-or + $value.component-cne'Install'-or$value.stage-cne$stage-or$value.code-cnotmatch'^CCOD_[A-Z0-9_]+\z'-or + [DateTime]::Parse($value.timestampUtc).Ticks.ToString()-cne$ticks){throw 'install log identity mismatch'} + [void]$expectedDirectories.Add('state/install-logs');[void]$knownFiles.Add($path);continue + } + if($path-cmatch'^state/product-cleanup-fences/([0-9]{20})\.(Pending|Completed)\.([0-9a-f-]{36})\.json\z'){ + $attempt=$matches[1];$state=$matches[2];$id=$matches[3];$value=Read-CcodProductResidueJson -Native $Native -Path $path + if(-not(Test-CcodGenerationReclamationExactProperties $value @('schemaVersion','transactionId','runtimeId','runtimeGeneration','manifestSha256','packageSha256','ownerPid','ownerCreationTimeUtc','ownerSid','state'))-or + $value.transactionId-cne$id-or$value.state-cne$state-or-not$readyRecords.ContainsKey($id)-or$value.runtimeId-cne$readyRecords[$id].newRuntimeId-or$value.manifestSha256-cne$readyRecords[$id].newManifestSha256-or + @($Snapshot.Files|Where-Object {$_.Path-ceq('state/product-cleanup-fences/'+$attempt+'.Completed.'+$id+'.json')}).Count-ne1){throw 'product cleanup fence is incomplete or changed'} + [void]$expectedDirectories.Add('state/product-cleanup-fences');[void]$knownFiles.Add($path);continue + } + if($path-cmatch'^state/legacy-registration-migrations/([0-9]{20})\.([0-9a-f-]{36})\.json\z'){ + $generation=[uint64]$matches[1];$id=$matches[2];$value=Read-CcodProductResidueJson -Native $Native -Path $path + if(-not(Test-CcodGenerationReclamationExactProperties $value @('schemaVersion','transactionId','runtimeId','runtimeGeneration','manifestSha256','packageSha256','readyTransaction','appId','expectedInstallRoot','legacyPresent','legacyRegistration','profile','snapshot','expectedCurrentProof'))-or + $value.transactionId-cne$id-or$value.runtimeGeneration-ne$generation-or-not$readyRecords.ContainsKey($id)-or$value.runtimeId-cne$readyRecords[$id].newRuntimeId-or + $value.manifestSha256-cne$readyRecords[$id].newManifestSha256-or($value.readyTransaction|ConvertTo-Json -Depth 8 -Compress)-cne($readyRecords[$id]|ConvertTo-Json -Depth 8 -Compress)-or + $value.appId-cne'{2B9E9F2E-7A32-4A7E-9C1D-9F5B5C6D7E8F}'){throw 'legacy migration record identity mismatch'} + [void]$expectedDirectories.Add('state/legacy-registration-migrations');[void]$knownFiles.Add($path);continue + } + if($path-cmatch'^state/([^/]+)\z'-and$stateFields.ContainsKey($matches[1])){ + $leaf=$matches[1];$value=Read-CcodProductResidueJson -Native $Native -Path $path + if(-not(Test-CcodGenerationReclamationExactProperties $value $stateFields[$leaf])){throw 'unrecognized product state members'} + if($leaf-ceq'settings.json'-and$value.automationEnabled-cne$false){throw 'automation must be stopped'} + if($leaf-ceq'status.json'-and$null-ne$value.session){throw 'active session remains'} + if($leaf-ceq'transition.json'-and$null-ne$value.activeTransaction){throw 'active transition remains'} + [void]$knownFiles.Add($path);continue + } + throw ('unrecognized product residue: '+$path) + } + foreach($directory in $Snapshot.Directories){if(-not$expectedDirectories.Contains([string]$directory)){throw ('unrecognized product directory: '+$directory)}} +} + +function Remove-CcodVerifiedProductResidue { + [CmdletBinding()] + param([Parameter(Mandatory)][string]$InstallRoot,[Parameter(Mandatory)][string]$SelectedRuntimeId,[Parameter(Mandatory)][uint64]$ExpectedEpoch,[string]$TransactionDirectory,[string]$TransactionId) + $native=$null;$journal=$null + try { + $root=[IO.Path]::GetFullPath($InstallRoot) + if($root-cne$InstallRoot-or[IO.Path]::GetFileName($root)-cne'CodexControlOtherDevices'-or + $SelectedRuntimeId-cnotmatch'^2\.5\.22-[0-9a-f]{16}-[0-9a-f]{32}\z'){throw 'product residue root or runtime identity'} + Initialize-CcodGenerationReclamationRuntime + $hasJournal=-not[string]::IsNullOrEmpty($TransactionDirectory) + $planPath=$null;$planPresent=$false;$rootPresent=$false + if($hasJournal){ + if($TransactionId-cnotmatch'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z'){throw 'product cleanup transaction ID'} + $expectedDirectory=Join-Path (Split-Path $root -Parent) ('CodexRemote-fix-uninstall/'+$TransactionId) + if([IO.Path]::GetFullPath($TransactionDirectory)-cne[IO.Path]::GetFullPath($expectedDirectory)){throw 'product cleanup journal escaped its transaction'} + $planPath=Join-Path $TransactionDirectory 'product-residue-plan.json' + try {Get-Item -LiteralPath $planPath -Force -ErrorAction Stop|Out-Null;$planPresent=$true}catch [Management.Automation.ItemNotFoundException]{} + } + try {Get-Item -LiteralPath $root -Force -ErrorAction Stop|Out-Null;$rootPresent=$true}catch [Management.Automation.ItemNotFoundException]{} + if($rootPresent){ + $native=Invoke-CcodGenerationReclamationStatic -Name OpenResidue -Arguments @($root) + $snapshot=Invoke-CcodGenerationReclamationMethod -Runtime $native -Name Snapshot -Arguments @() + }elseif(-not$planPresent){throw 'missing product root has no prior cleanup authority'} + if(-not$planPresent){Assert-CcodProductResidueSnapshot -Native $native -Snapshot $snapshot -SelectedRuntimeId $SelectedRuntimeId -ExpectedEpoch $ExpectedEpoch} + if($hasJournal){ + $journal=Invoke-CcodGenerationReclamationStatic -Name OpenJournal -Arguments @([IO.Path]::GetFullPath($TransactionDirectory)) + if($journal.Created){ + if($planPresent-or-not$rootPresent){throw 'cleanup journal changed at acquisition'} + $evidence=@($snapshot.Files|Where-Object {$_.Path-ceq'active.json'-or$_.Path.StartsWith('state/',[StringComparison]::Ordinal)-or$_.Path.StartsWith('logs/',[StringComparison]::Ordinal)-or$_.Path.EndsWith('/manifest.json',[StringComparison]::Ordinal)}|ForEach-Object {[pscustomobject][ordered]@{path=[string]$_.Path;text=(Read-CcodProductResidueText -Native $native -Path $_.Path)}}) + $plan=[pscustomobject][ordered]@{schemaVersion=1;transactionId=$TransactionId;installRoot=$root;runtimeId=$SelectedRuntimeId;epoch=$ExpectedEpoch;rootIdentity=(Invoke-CcodGenerationReclamationMethod -Runtime $native -Name RootIdentity -Arguments @());files=@($snapshot.Files|ForEach-Object {[pscustomobject][ordered]@{path=[string]$_.Path;length=[long]$_.Length;sha256=[string]$_.Sha256}});directories=@($snapshot.Directories);evidence=$evidence} + $bytes=[Text.UTF8Encoding]::new($false).GetBytes(($plan|ConvertTo-Json -Depth 8 -Compress)) + if($bytes.Length-gt4194304){throw 'cleanup journal bounds'} + $journal.Stream.Write($bytes,0,$bytes.Length);$journal.Stream.Flush($true) + } + if($journal.Stream.Length-lt1-or$journal.Stream.Length-gt4194304){throw 'invalid cleanup journal length'} + $journal.Stream.Position=0;$bytes=[byte[]]::new([int]$journal.Stream.Length);$read=0 + while($read-lt$bytes.Length){$count=$journal.Stream.Read($bytes,$read,$bytes.Length-$read);if($count-le0){throw 'incomplete cleanup journal'};$read+=$count} + $text=[Text.UTF8Encoding]::new($false,$true).GetString($bytes) + if(-not(Test-CcodGenerationReclamationJsonHasNoDuplicateProperties $text)){throw 'ambiguous cleanup journal'} + $plan=$text|ConvertFrom-Json -ErrorAction Stop + if(-not(Test-CcodGenerationReclamationExactProperties $plan @('schemaVersion','transactionId','installRoot','runtimeId','epoch','rootIdentity','files','directories','evidence'))-or + $plan.schemaVersion-isnot[int]-or$plan.schemaVersion-ne1-or$plan.transactionId-isnot[string]-or$plan.transactionId-cne$TransactionId-or$plan.installRoot-isnot[string]-or$plan.installRoot-cne$root-or$plan.runtimeId-isnot[string]-or$plan.runtimeId-cne$SelectedRuntimeId-or + ($plan.epoch-isnot[int]-and$plan.epoch-isnot[long])-or$plan.epoch-ne$ExpectedEpoch-or + $plan.rootIdentity-isnot[string]-or$plan.rootIdentity-cnotmatch'^[0-9]+-[0-9]+\z'-or$plan.files-isnot[array]-or$plan.directories-isnot[array]-or$plan.evidence-isnot[array]){throw 'cleanup journal binding changed'} + $expected=@{};foreach($file in $plan.files){ + if(-not(Test-CcodGenerationReclamationExactProperties $file @('path','length','sha256'))-or$file.path-isnot[string]-or$expected.ContainsKey($file.path)-or$file.sha256-isnot[string]-or$file.sha256-cnotmatch'^[0-9a-f]{64}\z'-or + ($file.length-isnot[int]-and$file.length-isnot[long])-or$file.length-lt0){throw 'cleanup journal file identity'} + $expected[$file.path]=$file + } + $texts=[ordered]@{};$sha=[Security.Cryptography.SHA256]::Create() + try {foreach($entry in $plan.evidence){ + if(-not(Test-CcodGenerationReclamationExactProperties $entry @('path','text'))-or$entry.path-isnot[string]-or$entry.text-isnot[string]-or$texts.Contains($entry.path)-or-not$expected.ContainsKey($entry.path)){throw 'cleanup journal evidence identity'} + $bytes=[Text.UTF8Encoding]::new($false,$true).GetBytes($entry.text);$hash=[BitConverter]::ToString($sha.ComputeHash($bytes)).Replace('-','').ToLowerInvariant() + if($bytes.LongLength-ne$expected[$entry.path].length-or$hash-cne$expected[$entry.path].sha256){throw 'cleanup journal evidence bytes changed'} + $texts[$entry.path]=$entry.text + }}finally{$sha.Dispose()} + $savedSnapshot=[pscustomobject]@{Files=@($plan.files|ForEach-Object {[pscustomobject]@{Path=$_.path;Length=$_.length;Sha256=$_.sha256}});Directories=$plan.directories} + Assert-CcodProductResidueSnapshot -Native $texts -Snapshot $savedSnapshot -SelectedRuntimeId $SelectedRuntimeId -ExpectedEpoch $ExpectedEpoch + if(-not$rootPresent){return [pscustomobject][ordered]@{phase='Completed';result='Removed';fileCount=0}} + if($plan.rootIdentity-cne(Invoke-CcodGenerationReclamationMethod -Runtime $native -Name RootIdentity -Arguments @())){throw 'product root identity changed after cleanup began'} + foreach($file in $snapshot.Files){if(-not$expected.ContainsKey($file.Path)-or$expected[$file.Path].length-ne$file.Length-or$expected[$file.Path].sha256-cne$file.Sha256){throw 'product residue changed during retry'}} + foreach($directory in $snapshot.Directories){if($plan.directories-cnotcontains$directory){throw 'product residue directory changed during retry'}} + } + [void](Invoke-CcodGenerationReclamationMethod -Runtime $native -Name Reclaim -Arguments @(-1,[int]$script:CcodProductResidueFailCommitAtForTest)) + try {Get-Item -LiteralPath $root -Force -ErrorAction Stop|Out-Null;throw 'product root remains'}catch [Management.Automation.ItemNotFoundException]{} + return [pscustomobject][ordered]@{phase='Completed';result='Removed';fileCount=[int]$snapshot.Files.Count} + } catch { + Throw-CcodGenerationReclamationError 'CCOD_PRODUCT_RESIDUE_INVALID' ('Bounded product cleanup rejected: '+$_.Exception.Message) $InstallRoot + } finally {if($null-ne$journal){$journal.Dispose()};if($null-ne$native){$native.Dispose()}} +} + +Export-ModuleMember -Function Remove-CcodVerifiedGenerationTree,Remove-CcodVerifiedProductResidue diff --git a/src/persistence/modules/InstallFileTransaction.psm1 b/src/persistence/modules/InstallFileTransaction.psm1 new file mode 100644 index 0000000..378b4b0 --- /dev/null +++ b/src/persistence/modules/InstallFileTransaction.psm1 @@ -0,0 +1,633 @@ +Set-StrictMode -Version Latest + +$script:CcodTransactions = [Runtime.CompilerServices.ConditionalWeakTable[object,object]]::new() +$script:CcodScopes = [Runtime.CompilerServices.ConditionalWeakTable[object,object]]::new() +$script:CcodInstallFileTransactionLowerCloseForTest = $null + +function Throw-CcodInstallFileError { + param([string]$Id,[string]$Message,$Target) + throw [Management.Automation.ErrorRecord]::new([InvalidOperationException]::new($Message),$Id,[Management.Automation.ErrorCategory]::InvalidData,$Target) +} + +function Initialize-CcodInstallRuntime { + $marker = 'CcodInstallGenerationCapabilityMarkerV7' -as [type] + if ($null -eq $marker) { + Add-Type -TypeDefinition @' +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Security.AccessControl; +using System.Security.Cryptography; +using System.Security.Principal; +using System.Text; +using Microsoft.Win32.SafeHandles; + +public sealed class CcodInstallGenerationCapabilityMarkerV7 +{ + private CcodInstallGenerationCapabilityMarkerV7() { } + public static int CapabilityAbi { get { return 7; } } +} + +internal sealed class CcodInstallGenerationRuntimeV7 : IDisposable +{ + private const uint READ = 0x80000000, WRITE = 0x40000000, DELETE = 0x00010000, READ_CONTROL = 0x00020000, WRITE_DAC = 0x00040000, WRITE_OWNER = 0x00080000, SYNC = 0x00100000; + private const uint READ_ATTRIBUTES = 0x80, WRITE_ATTRIBUTES = 0x100, LIST_DIRECTORY = 0x1, ADD_FILE = 0x2, ADD_SUBDIRECTORY = 0x4; + private const uint SHARE_READ = 1, SHARE_WRITE = 2, SHARE_DELETE = 4; + private const uint OPEN = 1, CREATE = 2; + private const uint DIRECTORY = 1, WRITE_THROUGH = 2, SYNC_IO = 0x20, NON_DIRECTORY = 0x40, BACKUP_INTENT = 0x4000, OPEN_REPARSE = 0x200000; + private const uint FLAG_BACKUP = 0x02000000, FLAG_REPARSE = 0x00200000; + private const uint OPEN_EXISTING = 3, OBJ_CASE_INSENSITIVE = 0x40, ATTR_DIRECTORY = 0x10, ATTR_REPARSE = 0x400; + private const int FileRenameInformation = 10, FileStreamInfo = 7, FileDirectoryInformation = 1; + private const int STATUS_NO_MORE_FILES = unchecked((int)0x80000006); + + [StructLayout(LayoutKind.Sequential)] private struct UNICODE_STRING { public ushort Length, MaximumLength; public IntPtr Buffer; } + [StructLayout(LayoutKind.Sequential)] private struct OBJECT_ATTRIBUTES { public int Length; public IntPtr RootDirectory, ObjectName; public uint Attributes; public IntPtr SecurityDescriptor, SecurityQualityOfService; } + [StructLayout(LayoutKind.Sequential)] private struct IO_STATUS_BLOCK { public IntPtr Status; public UIntPtr Information; } + [StructLayout(LayoutKind.Sequential)] private struct FILETIME_NATIVE { public uint Low, High; } + [StructLayout(LayoutKind.Sequential)] private struct FILE_INFO + { + public uint FileAttributes; public FILETIME_NATIVE CreationTime, LastAccessTime, LastWriteTime; + public uint VolumeSerialNumber, FileSizeHigh, FileSizeLow, NumberOfLinks, FileIndexHigh, FileIndexLow; + } + + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] private static extern SafeFileHandle CreateFileW(string path,uint access,uint share,IntPtr security,uint creation,uint flags,IntPtr template); + [DllImport("kernel32.dll", SetLastError=true)] private static extern bool GetFileInformationByHandle(SafeFileHandle handle,out FILE_INFO info); + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] private static extern uint GetFinalPathNameByHandleW(SafeFileHandle handle,StringBuilder buffer,uint length,uint flags); + [DllImport("kernel32.dll", SetLastError=true)] private static extern bool GetFileInformationByHandleEx(SafeFileHandle handle,int infoClass,IntPtr info,uint size); + [DllImport("ntdll.dll")] private static extern int NtCreateFile(out IntPtr handle,uint access,ref OBJECT_ATTRIBUTES attributes,out IO_STATUS_BLOCK io,IntPtr allocationSize,uint fileAttributes,uint share,uint disposition,uint options,IntPtr ea,uint eaLength); + [DllImport("ntdll.dll")] private static extern int NtQueryDirectoryFile(SafeFileHandle handle,IntPtr evt,IntPtr apc,IntPtr context,out IO_STATUS_BLOCK io,IntPtr info,uint length,int infoClass,bool single,IntPtr name,bool restart); + [DllImport("ntdll.dll")] private static extern int NtSetInformationFile(SafeFileHandle handle,out IO_STATUS_BLOCK io,IntPtr info,uint length,int infoClass); + [DllImport("ntdll.dll")] private static extern uint RtlNtStatusToDosError(int status); + [DllImport("advapi32.dll", SetLastError=true)] private static extern bool GetKernelObjectSecurity(SafeFileHandle handle,int requestedInformation,byte[] securityDescriptor,uint length,out uint needed); + [DllImport("advapi32.dll", SetLastError=true)] private static extern bool SetKernelObjectSecurity(SafeFileHandle handle,int securityInformation,byte[] securityDescriptor); + + private sealed class Pin : IDisposable + { + internal readonly object Token = new object(); + internal readonly Pin Parent; internal string Leaf, Path; + internal readonly bool Directory, Owned; internal SafeFileHandle Handle; internal FileStream Stream; + internal readonly uint Volume; internal readonly ulong Index; internal bool Closed, Sealed, Published, Manifest; internal long SealedLength; internal string SealedSha; + internal Pin(Pin parent,string leaf,string path,bool directory,bool owned,SafeFileHandle handle,FileStream stream,FILE_INFO info) + { Parent=parent;Leaf=leaf;Path=path;Directory=directory;Owned=owned;Handle=handle;Stream=stream;Volume=info.VolumeSerialNumber;Index=((ulong)info.FileIndexHigh<<32)|info.FileIndexLow; } + internal SafeFileHandle Native { get { return Stream != null ? Stream.SafeFileHandle : Handle; } } + public void Dispose() { if(Closed)return;if(Stream!=null)Stream.Dispose();else if(Handle!=null)Handle.Dispose();Closed=true; } + } + + private sealed class ReferenceComparer : IEqualityComparer + { internal static readonly ReferenceComparer Instance=new ReferenceComparer();public new bool Equals(object x,object y){return Object.ReferenceEquals(x,y);}public int GetHashCode(object x){return RuntimeHelpers.GetHashCode(x);} } + private sealed class OpenResult { internal SafeFileHandle Handle; internal bool Created; internal OpenResult(SafeFileHandle handle,bool created){Handle=handle;Created=created;} } + + private readonly string installRoot; private readonly Pin root, runtimeParent; + private readonly Dictionary pins = new Dictionary(ReferenceComparer.Instance); + private readonly Dictionary names = new Dictionary(StringComparer.OrdinalIgnoreCase); + private readonly HashSet retainedRoots = new HashSet(ReferenceComparer.Instance); + private readonly HashSet retainedScopes = new HashSet(ReferenceComparer.Instance); + private readonly HashSet productFolders = new HashSet(ReferenceComparer.Instance); + private readonly List externalStreams = new List(); + private bool disposed; private string cleanupError,legacyPlanDirectoryFailureForTest; + + private readonly bool stateOnly,productOnly,retryOnly; + private CcodInstallGenerationRuntimeV7(string installRoot,Pin root,Pin runtimeParent,bool stateOnly,bool productOnly,bool retryOnly) + { this.installRoot=installRoot;this.root=root;this.runtimeParent=runtimeParent;this.stateOnly=stateOnly;this.productOnly=productOnly;this.retryOnly=retryOnly;AddPin(root);if(runtimeParent!=null)AddPin(runtimeParent); } + + internal static object Open(string path,string runtimeId,out CcodInstallGenerationRuntimeV7 runtime) + { + runtime=null;string full=Path.GetFullPath(path).TrimEnd('\\');SafeFileHandle rootHandle=OpenAbsoluteDirectory(full,true);Pin rootPin=null,runtimePin=null; + try + { + rootPin=ValidateDirectoryPin(null,"",full,false,rootHandle);rootHandle=null; + OpenResult runtimeResult; + try { runtimeResult=OpenRelative(rootPin.Native,"runtime",LIST_DIRECTORY|ADD_FILE|ADD_SUBDIRECTORY|READ_ATTRIBUTES|SYNC,SHARE_READ|SHARE_WRITE,CREATE,DIRECTORY|BACKUP_INTENT); } + catch(Win32Exception exception) { if(exception.NativeErrorCode!=80&&exception.NativeErrorCode!=183)throw;runtimeResult=OpenRelative(rootPin.Native,"runtime",LIST_DIRECTORY|ADD_FILE|ADD_SUBDIRECTORY|READ_ATTRIBUTES|SYNC,SHARE_READ|SHARE_WRITE,OPEN,DIRECTORY|BACKUP_INTENT); } + try { runtimePin=ValidateDirectoryPin(rootPin,"runtime",Path.Combine(full,"runtime"),false,runtimeResult.Handle);runtimeResult.Handle=null; } + finally { if(runtimeResult.Handle!=null)runtimeResult.Handle.Dispose(); } + OpenResult generationResult=OpenRelative(runtimePin.Native,runtimeId,LIST_DIRECTORY|ADD_FILE|ADD_SUBDIRECTORY|READ_ATTRIBUTES|DELETE|SYNC,SHARE_READ|SHARE_WRITE,CREATE,DIRECTORY|BACKUP_INTENT); + try + { + Pin generation=ValidateDirectoryPin(runtimePin,runtimeId,Path.Combine(runtimePin.Path,runtimeId),true,generationResult.Handle);generationResult.Handle=null; + runtime=new CcodInstallGenerationRuntimeV7(full,rootPin,runtimePin,false,false,false);rootPin=null;runtimePin=null;runtime.AddPin(generation);return generation.Token; + } + finally { if(generationResult.Handle!=null)generationResult.Handle.Dispose(); } + } + catch { if(runtimePin!=null)runtimePin.Dispose();if(rootPin!=null)rootPin.Dispose();if(rootHandle!=null)rootHandle.Dispose();throw; } + } + + internal static object OpenState(string path,out CcodInstallGenerationRuntimeV7 runtime) + { + runtime=null;string full=Path.GetFullPath(path).TrimEnd('\\');SafeFileHandle rootHandle=OpenAbsoluteDirectory(full,true);Pin rootPin=null; + try{rootPin=ValidateDirectoryPin(null,"",full,false,rootHandle);rootHandle=null;runtime=new CcodInstallGenerationRuntimeV7(full,rootPin,null,true,false,false);rootPin=null;return runtime.root.Token;} + catch{if(rootPin!=null)rootPin.Dispose();if(rootHandle!=null)rootHandle.Dispose();throw;} + } + + internal static object OpenRetry(string path,out CcodInstallGenerationRuntimeV7 runtime) + { + runtime=null;string full=Path.GetFullPath(path).TrimEnd('\\');SafeFileHandle rootHandle=OpenAbsoluteDirectory(full,true);Pin rootPin=null,runtimePin=null; + try{rootPin=ValidateDirectoryPin(null,"",full,false,rootHandle);rootHandle=null;OpenResult result=OpenRelative(rootPin.Native,"runtime",LIST_DIRECTORY|READ_ATTRIBUTES|SYNC,SHARE_READ|SHARE_WRITE,OPEN,DIRECTORY|BACKUP_INTENT);try{runtimePin=ValidateDirectoryPin(rootPin,"runtime",Path.Combine(full,"runtime"),false,result.Handle);result.Handle=null;}finally{if(result.Handle!=null)result.Handle.Dispose();};runtime=new CcodInstallGenerationRuntimeV7(full,rootPin,runtimePin,false,false,true);rootPin=null;runtimePin=null;return runtime.root.Token;}catch{if(runtimePin!=null)runtimePin.Dispose();if(rootPin!=null)rootPin.Dispose();if(rootHandle!=null)rootHandle.Dispose();throw;} + } + + internal static object OpenProduct(string path,out CcodInstallGenerationRuntimeV7 runtime) + { + runtime=null;string full=Path.GetFullPath(path).TrimEnd('\\');SafeFileHandle rootHandle=OpenAbsoluteDirectory(full,false);Pin rootPin=null,runtimePin=null; + try{rootPin=ValidateDirectoryPin(null,"",full,false,rootHandle);rootHandle=null;OpenResult result=OpenRelative(rootPin.Native,"runtime",LIST_DIRECTORY|READ_ATTRIBUTES|SYNC,SHARE_READ|SHARE_WRITE,OPEN,DIRECTORY|BACKUP_INTENT);try{runtimePin=ValidateDirectoryPin(rootPin,"runtime",Path.Combine(full,"runtime"),false,result.Handle);result.Handle=null;}finally{if(result.Handle!=null)result.Handle.Dispose();};runtime=new CcodInstallGenerationRuntimeV7(full,rootPin,runtimePin,false,true,false);rootPin=null;runtimePin=null;return runtime.root.Token;}catch{if(runtimePin!=null)runtimePin.Dispose();if(rootPin!=null)rootPin.Dispose();if(rootHandle!=null)rootHandle.Dispose();throw;} + } + + private bool IsStateScope(Pin pin){for(Pin cursor=pin;cursor!=null;cursor=cursor.Parent)if(Object.ReferenceEquals(cursor.Parent,root)&&String.Equals(cursor.Leaf,"state",StringComparison.Ordinal))return true;return false;} + private void RequireStateDirectory(Pin parent,string leaf){if(productOnly)throw new InvalidOperationException("product-only scope");if(!stateOnly&&!retryOnly)return;string error=retryOnly?"migration-retry scope":"state-only scope";if(Object.ReferenceEquals(parent,root)){if(!String.Equals(leaf,"state",StringComparison.Ordinal))throw new InvalidOperationException(error);return;}if(!IsStateScope(parent))throw new InvalidOperationException(error);} + + [MethodImpl(MethodImplOptions.Synchronized)] internal object CreateDirectory(object parentToken,string leaf) + { + Pin parent=Require(parentToken,true);ValidateCurrent(parent);RequireStateDirectory(parent,leaf);OpenResult result=OpenRelative(parent.Native,leaf,LIST_DIRECTORY|ADD_FILE|ADD_SUBDIRECTORY|READ_ATTRIBUTES|DELETE|SYNC,SHARE_READ|SHARE_WRITE,CREATE,DIRECTORY|BACKUP_INTENT); + try { Pin pin=ValidateDirectoryPin(parent,leaf,Path.Combine(parent.Path,leaf),true,result.Handle);result.Handle=null;AddPin(pin);return pin.Token; } + finally { if(result.Handle!=null)result.Handle.Dispose(); } + } + + [MethodImpl(MethodImplOptions.Synchronized)] internal object OpenDirectory(object parentToken,string leaf,bool createIfMissing) + { + Pin parent=Require(parentToken,true);ValidateCurrent(parent);RequireStateDirectory(parent,leaf);Pin known;if(names.TryGetValue(Key(parent,leaf),out known)){ValidateCurrent(known);if(retainedScopes.Contains(parentToken)||retainedRoots.Contains(known.Token))MarkRetainedTree(known);return known.Token;}OpenResult result; + if(createIfMissing){try{result=OpenRelative(parent.Native,leaf,LIST_DIRECTORY|ADD_FILE|ADD_SUBDIRECTORY|READ_ATTRIBUTES|SYNC,SHARE_READ|SHARE_WRITE,CREATE,DIRECTORY|BACKUP_INTENT);}catch(Win32Exception exception){if(exception.NativeErrorCode!=80&&exception.NativeErrorCode!=183)throw;result=OpenRelative(parent.Native,leaf,LIST_DIRECTORY|ADD_FILE|ADD_SUBDIRECTORY|READ_ATTRIBUTES|SYNC,SHARE_READ|SHARE_WRITE,OPEN,DIRECTORY|BACKUP_INTENT);}} + else result=OpenRelative(parent.Native,leaf,LIST_DIRECTORY|ADD_FILE|ADD_SUBDIRECTORY|READ_ATTRIBUTES|SYNC,SHARE_READ|SHARE_WRITE,OPEN,DIRECTORY|BACKUP_INTENT); + try{Pin pin=ValidateDirectoryPin(parent,leaf,Path.Combine(parent.Path,leaf),result.Created,result.Handle);result.Handle=null;AddPin(pin);if(retainedScopes.Contains(parentToken))retainedScopes.Add(pin.Token);return pin.Token;}finally{if(result.Handle!=null)result.Handle.Dispose();} + } + + [MethodImpl(MethodImplOptions.Synchronized)] internal object GetInstallRoot(){if(disposed)throw new ObjectDisposedException("transaction");ValidateCurrent(root);return root.Token;} + [MethodImpl(MethodImplOptions.Synchronized)] internal bool IsRetained(object token){if(disposed)throw new ObjectDisposedException("transaction");return token!=null&&retainedScopes.Contains(token);} + + [MethodImpl(MethodImplOptions.Synchronized)] internal object OpenRetained(string runtimeId,string expectedManifestSha) + { + if(stateOnly)throw new InvalidOperationException("state-only scope");if(disposed)throw new ObjectDisposedException("transaction");Pin existing;if(names.TryGetValue(Key(runtimeParent,runtimeId),out existing)){if(!retainedRoots.Contains(existing.Token))throw new InvalidDataException("retained generation already scoped");ValidateRetainedTree(existing);return new object[]{existing.Token,ValidateRetainedManifest(existing,expectedManifestSha)};}OpenResult result=OpenRelative(runtimeParent.Native,runtimeId,LIST_DIRECTORY|READ_ATTRIBUTES|SYNC,SHARE_READ|SHARE_WRITE,OPEN,DIRECTORY|BACKUP_INTENT);List added=new List(); + try + { + Pin candidate=ValidateDirectoryPin(runtimeParent,runtimeId,Path.Combine(runtimeParent.Path,runtimeId),false,result.Handle);result.Handle=null;Pin generation;if(names.TryGetValue(Key(runtimeParent,runtimeId),out generation)){candidate.Dispose();}else{generation=candidate;AddPin(generation);added.Add(generation);}OpenRetainedTree(generation,added); + string manifestText=ValidateRetainedManifest(generation,expectedManifestSha);ValidateRetainedTree(generation);retainedRoots.Add(generation.Token);MarkRetainedTree(generation);return new object[]{generation.Token,manifestText}; + } + catch(Exception exception){for(int i=added.Count-1;i>=0;i--){Pin pin=added[i];retainedScopes.Remove(pin.Token);if(pin.Parent!=null)names.Remove(Key(pin.Parent,pin.Leaf));pins.Remove(pin.Token);try{pin.Dispose();}catch{}}throw new InvalidDataException("retained open failed: "+exception.Message,exception);} + finally{if(result.Handle!=null)result.Handle.Dispose();} + } + + [MethodImpl(MethodImplOptions.Synchronized)] internal object Copy(object parentToken,string leaf,string source,long expectedLength,string expectedSha) + { + if(stateOnly)throw new InvalidOperationException("state-only scope");if(retryOnly)throw new InvalidOperationException("migration-retry scope");if(productOnly)throw new InvalidOperationException("product-only scope");Pin parent=Require(parentToken,true);ValidateCurrent(parent);string full=Path.GetFullPath(source); + SafeFileHandle sourceHandle=CreateFileW(full,READ|READ_ATTRIBUTES|SYNC,SHARE_READ,IntPtr.Zero,OPEN_EXISTING,FLAG_REPARSE,IntPtr.Zero); + if(sourceHandle.IsInvalid)throw new Win32Exception(Marshal.GetLastWin32Error());FileStream sourceStream=null; + try + { + FILE_INFO sourceInfo=Info(sourceHandle);ValidatePlain(sourceInfo,sourceHandle);if(!SamePath(FinalPath(sourceHandle),full))throw new InvalidDataException("source path changed"); + sourceStream=new FileStream(sourceHandle,FileAccess.Read,65536,false);sourceHandle=null; + externalStreams.Add(sourceStream);FileStream ownedSource=sourceStream;sourceStream=null; + if(ownedSource.Length!=expectedLength||Sha(ownedSource)!=expectedSha)throw new InvalidDataException("source mismatch");ownedSource.Position=0; + Pin destination=CreateTemporaryFile(parent);ownedSource.CopyTo(destination.Stream);destination.Stream.Flush(true); + if(destination.Stream.Length!=expectedLength||Sha(destination.Stream)!=expectedSha)throw new InvalidDataException("destination mismatch");SealAndPublish(destination,leaf,expectedLength,expectedSha,false);return destination.Token; + } + finally { if(sourceStream!=null)sourceStream.Dispose();if(sourceHandle!=null)sourceHandle.Dispose(); } + } + + [MethodImpl(MethodImplOptions.Synchronized)] internal object Write(object parentToken,string leaf,byte[] bytes,bool manifest) + { + if(productOnly)throw new InvalidOperationException("product-only scope");Pin parent=Require(parentToken,true);ValidateCurrent(parent);if(stateOnly&&(manifest||!IsStateScope(parent)))throw new InvalidOperationException("state-only scope");if(retryOnly&&(manifest||!IsStateScope(parent)))throw new InvalidOperationException("migration-retry scope");Pin pin=CreateTemporaryFile(parent);pin.Stream.Write(bytes,0,bytes.Length);pin.Stream.Flush(true);string sha=Sha(pin.Stream);if(pin.Stream.Length!=bytes.LongLength)throw new InvalidDataException("destination mismatch");SealAndPublish(pin,leaf,bytes.LongLength,sha,manifest);return pin.Token; + } + + [MethodImpl(MethodImplOptions.Synchronized)] internal object WriteLegacyPlan(object rootToken,string leaf,byte[] bytes) + { + if(!productOnly||bytes==null||bytes.LongLength==0||bytes.LongLength>1048576)throw new InvalidOperationException("product-only scope");Pin requestedRoot=Require(rootToken,true);ValidateCurrent(requestedRoot);if(!Object.ReferenceEquals(requestedRoot,root))throw new InvalidOperationException("product-only scope");ValidateLegacyPlanLeaf(leaf);Pin state=OpenExistingDirectory(root,"state");if(state==null)throw new InvalidOperationException("product-only scope");Pin plans=OpenOrCreateLegacyPlanDirectory(state);if(names.ContainsKey(Key(plans,leaf)))throw new Win32Exception(183);Pin pin=CreateLegacyPlanTemporaryFile(plans);pin.Stream.Write(bytes,0,bytes.Length);pin.Stream.Flush(true);SetExactLegacyPlanSecurity(pin.Native,false);ValidateExactLegacyPlanSecurity(pin.Native,false);string sha=Sha(pin.Stream);if(pin.Stream.Length!=bytes.LongLength)throw new InvalidDataException("destination mismatch");SealAndPublish(pin,leaf,bytes.LongLength,sha,false);EnsurePublishedPin(pin);return pin.Token; + } + + [MethodImpl(MethodImplOptions.Synchronized)] internal void SetLegacyPlanDirectoryFailureForTest(string mode) + { + if(!productOnly||(mode!="Set"&&mode!="Validate"))throw new InvalidOperationException("product-only scope");legacyPlanDirectoryFailureForTest=mode; + } + + [MethodImpl(MethodImplOptions.Synchronized)] internal object[] ReadLegacyPlan(object rootToken,string leaf) + { + if(!productOnly)throw new InvalidOperationException("product-only scope");Pin requestedRoot=Require(rootToken,true);ValidateCurrent(requestedRoot);if(!Object.ReferenceEquals(requestedRoot,root))throw new InvalidOperationException("product-only scope");ValidateLegacyPlanLeaf(leaf);Pin state=OpenExistingDirectory(root,"state");if(state==null)return new object[0];Pin plans=OpenExistingLegacyPlanDirectory(state);if(plans==null)return new object[0];OpenResult result; + try{result=OpenRelative(plans.Native,leaf,READ|READ_ATTRIBUTES|READ_CONTROL|SYNC,SHARE_READ,OPEN,NON_DIRECTORY);}catch(Win32Exception exception){if(exception.NativeErrorCode==2||exception.NativeErrorCode==3)return new object[0];throw;}FileStream stream=null; + try{stream=new FileStream(result.Handle,FileAccess.Read,65536,false);result.Handle=null;FILE_INFO before=Info(stream.SafeFileHandle);ValidatePlain(before,stream.SafeFileHandle);ValidateExactLegacyPlanSecurity(stream.SafeFileHandle,false);string expected=Path.Combine(plans.Path,leaf),finalPath=FinalPath(stream.SafeFileHandle);if(!SamePath(finalPath,expected)||stream.Length<=0||stream.Length>1048576)throw new InvalidDataException("legacy plan invalid");byte[] bytes=new byte[(int)stream.Length];int offset=0;while(offset failedStreams=new List();foreach(FileStream stream in new List(externalStreams)){try{stream.Dispose();}catch(Exception exception){failedStreams.Add(stream);if(first==null)first=exception;}}externalStreams.Clear();externalStreams.AddRange(failedStreams); + List all=new List(pins.Values);for(int i=all.Count-1;i>=0;i--){Pin pin=all[i];try{pin.Dispose();}catch(Exception exception){if(first==null)first=exception;}if(pin.Closed)pins.Remove(pin.Token);}if(pins.Count==0)names.Clear(); + if(first!=null){cleanupError="registered resource cleanup failed";throw new InvalidOperationException(cleanupError);}cleanupError=null; + } + public void Dispose(){Close();} + + private Pin OpenOrCreateDirectory(Pin parent,string leaf) + { + Pin existing;if(names.TryGetValue(Key(parent,leaf),out existing)){ValidateCurrent(existing);return existing;} + OpenResult result; + try { result=OpenRelative(parent.Native,leaf,LIST_DIRECTORY|ADD_FILE|ADD_SUBDIRECTORY|READ_ATTRIBUTES|SYNC,SHARE_READ|SHARE_WRITE,CREATE,DIRECTORY|BACKUP_INTENT); } + catch(Win32Exception exception) { if(exception.NativeErrorCode!=80&&exception.NativeErrorCode!=183)throw;result=OpenRelative(parent.Native,leaf,LIST_DIRECTORY|ADD_FILE|ADD_SUBDIRECTORY|READ_ATTRIBUTES|SYNC,SHARE_READ|SHARE_WRITE,OPEN,DIRECTORY|BACKUP_INTENT); } + try { Pin pin=ValidateDirectoryPin(parent,leaf,Path.Combine(parent.Path,leaf),result.Created,result.Handle);result.Handle=null;AddPin(pin);return pin; } + finally { if(result.Handle!=null)result.Handle.Dispose(); } + } + + private Pin OpenExistingDirectory(Pin parent,string leaf) + { + Pin existing;if(names.TryGetValue(Key(parent,leaf),out existing)){ValidateCurrent(existing);return existing;}OpenResult result; + try{result=OpenRelative(parent.Native,leaf,LIST_DIRECTORY|ADD_FILE|ADD_SUBDIRECTORY|READ_ATTRIBUTES|SYNC,SHARE_READ|SHARE_WRITE,OPEN,DIRECTORY|BACKUP_INTENT);}catch(Win32Exception exception){if(exception.NativeErrorCode==2||exception.NativeErrorCode==3)return null;throw;} + try{Pin pin=ValidateDirectoryPin(parent,leaf,Path.Combine(parent.Path,leaf),false,result.Handle);result.Handle=null;AddPin(pin);return pin;}finally{if(result.Handle!=null)result.Handle.Dispose();} + } + + private Pin OpenOrCreateLegacyPlanDirectory(Pin state) + { + const string leaf="legacy-registration-migrations";Pin existing;if(names.TryGetValue(Key(state,leaf),out existing)){ValidateCurrent(existing);EnsureLegacyPlanDirectorySecurity(existing,true,null);return existing;}OpenResult result; + try{result=OpenRelative(state.Native,leaf,LIST_DIRECTORY|ADD_FILE|ADD_SUBDIRECTORY|READ_ATTRIBUTES|READ_CONTROL|WRITE_DAC|WRITE_OWNER|DELETE|SYNC,SHARE_READ|SHARE_WRITE,CREATE,DIRECTORY|BACKUP_INTENT);}catch(Win32Exception exception){if(exception.NativeErrorCode!=80&&exception.NativeErrorCode!=183)throw;result=OpenRelative(state.Native,leaf,LIST_DIRECTORY|ADD_FILE|ADD_SUBDIRECTORY|READ_ATTRIBUTES|READ_CONTROL|WRITE_DAC|WRITE_OWNER|DELETE|SYNC,SHARE_READ|SHARE_WRITE,OPEN,DIRECTORY|BACKUP_INTENT);} + Pin pin=null;bool registered=false;try{pin=ValidateDirectoryPin(state,leaf,Path.Combine(state.Path,leaf),result.Created,result.Handle);result.Handle=null;AddPin(pin);registered=true;string failure=legacyPlanDirectoryFailureForTest;legacyPlanDirectoryFailureForTest=null;EnsureLegacyPlanDirectorySecurity(pin,!result.Created,failure);return pin;}finally{if(!registered&&pin!=null)pin.Dispose();if(result.Handle!=null)result.Handle.Dispose();} + } + + private Pin OpenExistingLegacyPlanDirectory(Pin state) + { + const string leaf="legacy-registration-migrations";Pin existing;if(names.TryGetValue(Key(state,leaf),out existing)){ValidateCurrent(existing);EnsureLegacyPlanDirectorySecurity(existing,true,null);return existing;}OpenResult result; + try{result=OpenRelative(state.Native,leaf,LIST_DIRECTORY|READ_ATTRIBUTES|READ_CONTROL|WRITE_DAC|WRITE_OWNER|SYNC,SHARE_READ|SHARE_WRITE,OPEN,DIRECTORY|BACKUP_INTENT);}catch(Win32Exception exception){if(exception.NativeErrorCode==2||exception.NativeErrorCode==3)return null;throw;} + Pin pin=null;bool registered=false;try{pin=ValidateDirectoryPin(state,leaf,Path.Combine(state.Path,leaf),false,result.Handle);result.Handle=null;AddPin(pin);registered=true;EnsureLegacyPlanDirectorySecurity(pin,true,null);return pin;}finally{if(!registered&&pin!=null)pin.Dispose();if(result.Handle!=null)result.Handle.Dispose();} + } + + private void EnsureLegacyPlanDirectorySecurity(Pin pin,bool allowRecovery,string failure) + { + ValidateCurrent(pin);byte[] descriptor=ReadLegacyPlanSecurity(pin.Native);if(IsExactLegacyPlanSecurity(descriptor,true))return;if(allowRecovery&&(Enumerate(pin.Native).Length!=0||!IsRecoverableLegacyPlanDirectorySecurity(descriptor)))throw new InvalidDataException("legacy plan security");ValidateCurrent(pin);if(failure=="Set")throw new InvalidDataException("legacy plan test Set failure");SetExactLegacyPlanSecurity(pin.Native,true);ValidateCurrent(pin);if(failure=="Validate")throw new InvalidDataException("legacy plan test Validate failure");ValidateExactLegacyPlanSecurity(pin.Native,true); + } + + private static void ValidateLegacyPlanLeaf(string leaf) + { + if(leaf==null||leaf.Length!=62||leaf[20]!='.'||!leaf.EndsWith(".json",StringComparison.Ordinal))throw new InvalidOperationException("product-only scope");ulong generation;Guid transactionId;if(!UInt64.TryParse(leaf.Substring(0,20),out generation)||generation==0||!Guid.TryParseExact(leaf.Substring(21,36),"D",out transactionId)||!String.Equals(leaf,generation.ToString("D20")+"."+transactionId.ToString("D")+".json",StringComparison.Ordinal))throw new InvalidOperationException("product-only scope"); + } + + private void ValidateOwnedTree(Pin directory) + { + ValidateCurrent(directory);foreach(string leaf in Enumerate(directory.Native)){Pin child;if(!names.TryGetValue(Key(directory,leaf),out child)||!child.Owned)throw new InvalidDataException("unknown leaf");if(child.Directory){ValidateCurrent(child);ValidateOwnedTree(child);}else{if(!child.Sealed||!child.Published)throw new InvalidDataException("unsealed leaf");EnsurePublishedPin(child);}} + } + private void OpenRetainedTree(Pin directory,List added) + { + foreach(string leaf in Enumerate(directory.Native)) + { + OpenResult probe=OpenRelative(directory.Native,leaf,READ_ATTRIBUTES|SYNC,SHARE_READ|SHARE_WRITE,OPEN,BACKUP_INTENT);try{FILE_INFO info=Info(probe.Handle);if((info.FileAttributes&ATTR_REPARSE)!=0)throw new InvalidDataException("reparse leaf");string path=Path.Combine(directory.Path,leaf);if(IsDirectory(info)){probe.Handle.Dispose();probe.Handle=null;OpenResult childDirectory=OpenRelative(directory.Native,leaf,LIST_DIRECTORY|READ_ATTRIBUTES|SYNC,SHARE_READ|SHARE_WRITE,OPEN,DIRECTORY|BACKUP_INTENT);try{FILE_INFO childInfo=Info(childDirectory.Handle);if(!IsDirectory(childInfo)||(childInfo.FileAttributes&ATTR_REPARSE)!=0||!OnlyDefaultStream(childDirectory.Handle)||!SamePath(FinalPath(childDirectory.Handle),path))throw new InvalidDataException("directory invalid");Pin child=new Pin(directory,leaf,path,true,false,childDirectory.Handle,null,childInfo);childDirectory.Handle=null;AddPin(child);added.Add(child);retainedScopes.Add(child.Token);OpenRetainedTree(child,added);}finally{if(childDirectory.Handle!=null)childDirectory.Handle.Dispose();}}else{probe.Handle.Dispose();probe.Handle=null;OpenResult file=OpenRelative(directory.Native,leaf,READ|READ_ATTRIBUTES|SYNC,SHARE_READ,OPEN,NON_DIRECTORY);FileStream stream=null;try{stream=new FileStream(file.Handle,FileAccess.Read,65536,false);file.Handle=null;FILE_INFO fileInfo=Info(stream.SafeFileHandle);ValidatePlain(fileInfo,stream.SafeFileHandle);if(!SamePath(FinalPath(stream.SafeFileHandle),path))throw new InvalidDataException("pin changed");Pin child=new Pin(directory,leaf,path,false,false,null,stream,fileInfo);child.SealedLength=stream.Length;child.SealedSha=Sha(stream);child.Sealed=true;child.Published=true;AddPin(child);added.Add(child);retainedScopes.Add(child.Token);stream=null;}finally{if(stream!=null)stream.Dispose();if(file.Handle!=null)file.Handle.Dispose();}}}finally{if(probe.Handle!=null)probe.Handle.Dispose();}} + } + private void ValidateRetainedTree(Pin directory){ValidateCurrent(directory);foreach(string leaf in Enumerate(directory.Native)){Pin child;if(!names.TryGetValue(Key(directory,leaf),out child))throw new InvalidDataException("unknown retained leaf");if(child.Directory)ValidateRetainedTree(child);else{ValidateCurrent(child);if(child.Stream.Length!=child.SealedLength||Sha(child.Stream)!=child.SealedSha)throw new InvalidDataException("seal mismatch");}}} + private string ValidateRetainedManifest(Pin generation,string expectedManifestSha){Pin manifest;if(!names.TryGetValue(Key(generation,"manifest.json"),out manifest)||manifest.Directory)throw new InvalidDataException("retained manifest missing");if(manifest.SealedSha!=expectedManifestSha)throw new InvalidDataException("retained manifest mismatch");return ReadText(manifest.Stream);} + private void MarkRetainedTree(Pin directory){retainedScopes.Add(directory.Token);foreach(Pin pin in pins.Values)if(!pin.Closed&&pin.Path.StartsWith(directory.Path.TrimEnd('\\')+"\\",StringComparison.OrdinalIgnoreCase))retainedScopes.Add(pin.Token);} + private static string ReadText(FileStream stream){if(stream.Length>4194304)throw new InvalidDataException("retained manifest too large");long position=stream.Position;try{stream.Position=0;byte[] bytes=new byte[stream.Length];int offset=0;while(offsetmaximum)maximum=value;}return maximum; + } + private Pin Require(object token,bool directory){if(disposed)throw new ObjectDisposedException("transaction");Pin pin;if(token==null||!pins.TryGetValue(token,out pin)||pin.Closed||pin.Directory!=directory)throw new InvalidDataException("invalid capability");return pin;} + private void AddPin(Pin pin){if(pins.ContainsKey(pin.Token))throw new InvalidDataException("duplicate pin token");if(pin.Parent!=null&&names.ContainsKey(Key(pin.Parent,pin.Leaf)))throw new InvalidDataException("duplicate pin key "+pin.Parent.Path+" -> "+pin.Leaf);pins.Add(pin.Token,pin);if(pin.Parent!=null)names.Add(Key(pin.Parent,pin.Leaf),pin);} + private static string Key(Pin parent,string leaf){return parent.Index.ToString("x16")+"|"+leaf.ToLowerInvariant();} + private static Pin ValidateDirectoryPin(Pin parent,string leaf,string path,bool owned,SafeFileHandle handle){FILE_INFO info=Info(handle);if(!IsDirectory(info)||(info.FileAttributes&ATTR_REPARSE)!=0||!OnlyDefaultStream(handle)||!SamePath(FinalPath(handle),path))throw new InvalidDataException("directory invalid");return new Pin(parent,leaf,path,true,owned,handle,null,info);} + private void SealAndPublish(Pin pin,string finalLeaf,long expectedLength,string expectedSha,bool manifest) + { + string temporaryKey=Key(pin.Parent,pin.Leaf),finalKey=Key(pin.Parent,finalLeaf),finalPath=Path.Combine(pin.Parent.Path,finalLeaf);bool finalAlias=false,committed=false;OpenResult renameHandle=null,strict=null;FileStream strictStream=null;string stage="strict";try + { + pin.Stream.Dispose();pin.Stream=null;strict=OpenRelative(pin.Parent.Native,pin.Leaf,READ|READ_ATTRIBUTES|SYNC,SHARE_READ,OPEN,NON_DIRECTORY);strictStream=new FileStream(strict.Handle,FileAccess.Read,65536,false);strict.Handle=null; + FILE_INFO strictInfo=Info(strictStream.SafeFileHandle);ValidatePlain(strictInfo,strictStream.SafeFileHandle);if(strictInfo.VolumeSerialNumber!=pin.Volume||(((ulong)strictInfo.FileIndexHigh<<32)|strictInfo.FileIndexLow)!=pin.Index||!SamePath(FinalPath(strictStream.SafeFileHandle),pin.Path)||strictStream.Length!=expectedLength||Sha(strictStream)!=expectedSha)throw new InvalidDataException("seal mismatch"); + strictStream.Dispose();strictStream=null;stage="rename-open";renameHandle=OpenRelative(pin.Parent.Native,pin.Leaf,READ|DELETE|READ_ATTRIBUTES|WRITE_ATTRIBUTES|SYNC,SHARE_READ,OPEN,NON_DIRECTORY);FILE_INFO renameInfo=Info(renameHandle.Handle);ValidatePlain(renameInfo,renameHandle.Handle);if(renameInfo.VolumeSerialNumber!=pin.Volume||(((ulong)renameInfo.FileIndexHigh<<32)|renameInfo.FileIndexLow)!=pin.Index||!SamePath(FinalPath(renameHandle.Handle),pin.Path))throw new InvalidDataException("pin changed");int attributeError=SetReadOnly(renameHandle.Handle);if(attributeError!=0)throw new Win32Exception(attributeError);pin.SealedLength=expectedLength;pin.SealedSha=expectedSha;pin.Manifest=manifest;pin.Sealed=true; + if(names.ContainsKey(finalKey))throw new Win32Exception(183);names.Add(finalKey,pin);finalAlias=true;stage="rename";int error=Rename(renameHandle.Handle,pin.Parent.Native,finalLeaf);if(error!=0)throw new Win32Exception(error);committed=true; + names.Remove(temporaryKey);pin.Leaf=finalLeaf;pin.Path=finalPath;pin.Published=true; + } + catch(Exception exception){if(finalAlias&&!committed)names.Remove(finalKey);Win32Exception win32=exception as Win32Exception;if((stage=="rename"||stage=="rename-open")&&win32!=null&&(win32.NativeErrorCode==80||win32.NativeErrorCode==183))throw;throw new InvalidDataException("seal mismatch at "+stage+(win32==null?"":" win32="+win32.NativeErrorCode),exception);} + finally{if(strictStream!=null)strictStream.Dispose();if(strict!=null&&strict.Handle!=null)strict.Handle.Dispose();if(renameHandle!=null&&renameHandle.Handle!=null)renameHandle.Handle.Dispose();} + } + private void SealAndReplaceProduct(Pin pin,string finalLeaf,long expectedLength,string expectedSha) + { + string temporaryKey=Key(pin.Parent,pin.Leaf),finalKey=Key(pin.Parent,finalLeaf),finalPath=Path.Combine(pin.Parent.Path,finalLeaf);OpenResult renameHandle=null,strict=null;FileStream strictStream=null; + try + { + pin.Stream.Dispose();pin.Stream=null;strict=OpenRelative(pin.Parent.Native,pin.Leaf,READ|READ_ATTRIBUTES|SYNC,SHARE_READ,OPEN,NON_DIRECTORY);strictStream=new FileStream(strict.Handle,FileAccess.Read,65536,false);strict.Handle=null; + FILE_INFO strictInfo=Info(strictStream.SafeFileHandle);ValidatePlain(strictInfo,strictStream.SafeFileHandle);if(strictInfo.VolumeSerialNumber!=pin.Volume||(((ulong)strictInfo.FileIndexHigh<<32)|strictInfo.FileIndexLow)!=pin.Index||!SamePath(FinalPath(strictStream.SafeFileHandle),pin.Path)||strictStream.Length!=expectedLength||Sha(strictStream)!=expectedSha)throw new InvalidDataException("seal mismatch"); + strictStream.Dispose();strictStream=null;renameHandle=OpenRelative(pin.Parent.Native,pin.Leaf,READ|DELETE|READ_ATTRIBUTES|WRITE_ATTRIBUTES|SYNC,SHARE_READ,OPEN,NON_DIRECTORY);FILE_INFO renameInfo=Info(renameHandle.Handle);ValidatePlain(renameInfo,renameHandle.Handle);if(renameInfo.VolumeSerialNumber!=pin.Volume||(((ulong)renameInfo.FileIndexHigh<<32)|renameInfo.FileIndexLow)!=pin.Index||!SamePath(FinalPath(renameHandle.Handle),pin.Path))throw new InvalidDataException("pin changed"); + pin.SealedLength=expectedLength;pin.SealedSha=expectedSha;pin.Sealed=true; + Pin old;if(names.TryGetValue(finalKey,out old)&&!Object.ReferenceEquals(old,pin))throw new InvalidDataException("product shortcut already scoped"); + int error=RenameReplacing(renameHandle.Handle,pin.Parent.Native,finalLeaf);if(error!=0)throw new Win32Exception(error);names.Remove(temporaryKey);names[finalKey]=pin;pin.Leaf=finalLeaf;pin.Path=finalPath;pin.Published=true; + } + catch(Exception exception){throw new InvalidDataException("seal mismatch at product shortcut",exception);} + finally{if(strictStream!=null)strictStream.Dispose();if(strict!=null&&strict.Handle!=null)strict.Handle.Dispose();if(renameHandle!=null&&renameHandle.Handle!=null)renameHandle.Handle.Dispose();} + } + private void EnsurePublishedPin(Pin pin){if(pin.Stream!=null){ValidateCurrent(pin);return;}OpenResult result=OpenRelative(pin.Parent.Native,pin.Leaf,READ|READ_ATTRIBUTES|SYNC,SHARE_READ,OPEN,NON_DIRECTORY);FileStream stream=null;try{stream=new FileStream(result.Handle,FileAccess.Read,65536,false);result.Handle=null;FILE_INFO info=Info(stream.SafeFileHandle);ValidatePlain(info,stream.SafeFileHandle);if(info.VolumeSerialNumber!=pin.Volume||(((ulong)info.FileIndexHigh<<32)|info.FileIndexLow)!=pin.Index||!SamePath(FinalPath(stream.SafeFileHandle),pin.Path)||stream.Length!=pin.SealedLength||Sha(stream)!=pin.SealedSha)throw new InvalidDataException("seal mismatch");pin.Stream=stream;stream=null;}finally{if(stream!=null)stream.Dispose();if(result.Handle!=null)result.Handle.Dispose();}} + private static int SetReadOnly(SafeFileHandle handle){IntPtr buffer=Marshal.AllocHGlobal(40);try{for(int i=0;i<40;i++)Marshal.WriteByte(buffer,i,0);Marshal.WriteInt32(buffer,32,1);IO_STATUS_BLOCK io;int status=NtSetInformationFile(handle,out io,buffer,40,4);return status>=0?0:(int)RtlNtStatusToDosError(status);}finally{Marshal.FreeHGlobal(buffer);}} + private static SafeFileHandle OpenAbsoluteDirectory(string path,bool allowWrite){uint access=LIST_DIRECTORY|READ_ATTRIBUTES|SYNC;if(allowWrite)access|=ADD_FILE|ADD_SUBDIRECTORY;SafeFileHandle handle=CreateFileW(path,access,SHARE_READ|SHARE_WRITE,IntPtr.Zero,OPEN_EXISTING,FLAG_BACKUP|FLAG_REPARSE,IntPtr.Zero);if(handle.IsInvalid)throw new Win32Exception(Marshal.GetLastWin32Error());return handle;} + private static void ValidateCurrent(Pin pin){FILE_INFO info=Info(pin.Native);if(info.VolumeSerialNumber!=pin.Volume||(((ulong)info.FileIndexHigh<<32)|info.FileIndexLow)!=pin.Index||IsDirectory(info)!=pin.Directory||(info.FileAttributes&ATTR_REPARSE)!=0||!SamePath(FinalPath(pin.Native),pin.Path))throw new InvalidDataException("pin changed");if(!OnlyDefaultStream(pin.Native))throw new InvalidDataException("alternate stream");if(!pin.Directory&&info.NumberOfLinks!=1)throw new InvalidDataException("multi-link");} + private static void ValidatePlain(FILE_INFO info,SafeFileHandle handle){if(IsDirectory(info))throw new InvalidDataException("file type");if((info.FileAttributes&ATTR_REPARSE)!=0)throw new InvalidDataException("reparse leaf");if(info.NumberOfLinks!=1)throw new InvalidDataException("multi-link");if(!OnlyDefaultStream(handle))throw new InvalidDataException("alternate stream");} + private static byte[] GetExactLegacyPlanSecurity(bool directory) + { + WindowsIdentity identity=WindowsIdentity.GetCurrent();try{SecurityIdentifier user=identity.User;if(user==null)throw new InvalidDataException("legacy plan owner unavailable");FileSystemSecurity security=directory?(FileSystemSecurity)new DirectorySecurity():(FileSystemSecurity)new FileSecurity();security.SetOwner(user);security.SetAccessRuleProtection(true,false);SecurityIdentifier[] principals=new SecurityIdentifier[]{user,new SecurityIdentifier(WellKnownSidType.LocalSystemSid,null),new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid,null)};foreach(SecurityIdentifier principal in principals){FileSystemAccessRule rule=directory?new FileSystemAccessRule(principal,FileSystemRights.FullControl,InheritanceFlags.ContainerInherit|InheritanceFlags.ObjectInherit,PropagationFlags.None,AccessControlType.Allow):new FileSystemAccessRule(principal,FileSystemRights.FullControl,AccessControlType.Allow);security.AddAccessRule(rule);}return security.GetSecurityDescriptorBinaryForm();}finally{identity.Dispose();} + } + private static byte[] ReadLegacyPlanSecurity(SafeFileHandle handle) + { + const int information=1|4;uint needed;GetKernelObjectSecurity(handle,information,null,0,out needed);int error=Marshal.GetLastWin32Error();if(needed==0||(error!=0&&error!=122))throw new Win32Exception(error);byte[] descriptor=new byte[needed];if(!GetKernelObjectSecurity(handle,information,descriptor,(uint)descriptor.Length,out needed))throw new Win32Exception(Marshal.GetLastWin32Error());return descriptor; + } + private static void SetExactLegacyPlanSecurity(SafeFileHandle handle,bool directory){byte[] descriptor=GetExactLegacyPlanSecurity(directory);if(!SetKernelObjectSecurity(handle,1|4,descriptor))throw new Win32Exception(Marshal.GetLastWin32Error());} + private static bool IsExactLegacyPlanSecurity(byte[] descriptor,bool directory){return TestLegacyPlanSecurity(descriptor,directory,false);} + internal static bool IsRecoverableLegacyPlanDirectorySecurity(byte[] descriptor){return TestLegacyPlanSecurity(descriptor,true,true);} + private static bool TestLegacyPlanSecurity(byte[] descriptor,bool directory,bool inherited) + { + try{RawSecurityDescriptor raw=new RawSecurityDescriptor(descriptor,0);WindowsIdentity identity=WindowsIdentity.GetCurrent();try{SecurityIdentifier user=identity.User;bool protectedAcl=(raw.ControlFlags&ControlFlags.DiscretionaryAclProtected)!=0;if(user==null||raw.Owner==null||!raw.Owner.Equals(inherited?(identity.Owner??user):user)||protectedAcl==inherited||raw.DiscretionaryAcl==null||raw.DiscretionaryAcl.Count!=3)return false;HashSet expected=new HashSet(StringComparer.Ordinal){user.Value,new SecurityIdentifier(WellKnownSidType.LocalSystemSid,null).Value,new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid,null).Value};AceFlags expectedFlags=directory?(AceFlags.ContainerInherit|AceFlags.ObjectInherit):AceFlags.None;if(inherited)expectedFlags|=AceFlags.Inherited;foreach(GenericAce generic in raw.DiscretionaryAcl){CommonAce ace=generic as CommonAce;if(ace==null||ace.IsCallback||ace.AceQualifier!=AceQualifier.AccessAllowed||ace.AceFlags!=expectedFlags||ace.AccessMask!=(int)FileSystemRights.FullControl||ace.SecurityIdentifier==null||!expected.Remove(ace.SecurityIdentifier.Value))return false;}return expected.Count==0;}finally{identity.Dispose();}}catch{return false;} + } + private static void ValidateExactLegacyPlanSecurity(SafeFileHandle handle,bool directory){if(!IsExactLegacyPlanSecurity(ReadLegacyPlanSecurity(handle),directory))throw new InvalidDataException("legacy plan security");} + internal static object[] ReadAuthorityFile(string path){SafeFileHandle handle=CreateFileW(path,READ|READ_ATTRIBUTES|SYNC,SHARE_READ,IntPtr.Zero,OPEN_EXISTING,NON_DIRECTORY|FLAG_BACKUP|FLAG_REPARSE,IntPtr.Zero);if(handle.IsInvalid){int error=Marshal.GetLastWin32Error();handle.Dispose();throw new Win32Exception(error);}FileStream stream=null;try{stream=new FileStream(handle,FileAccess.Read,65536,false);handle=null;FILE_INFO before=Info(stream.SafeFileHandle);ValidatePlain(before,stream.SafeFileHandle);string finalPath=FinalPath(stream.SafeFileHandle);if(!SamePath(finalPath,path)||stream.Length<0||stream.Length>4194304)throw new InvalidDataException("authority file invalid");byte[] bytes=new byte[(int)stream.Length];int offset=0;while(offset=buffer.Capacity){buffer.Capacity=(int)length+1;length=GetFinalPathNameByHandleW(handle,buffer,(uint)buffer.Capacity,0);if(length==0||length>=buffer.Capacity)throw new Win32Exception(Marshal.GetLastWin32Error());}string path=buffer.ToString();if(path.StartsWith(@"\\?\UNC\",StringComparison.OrdinalIgnoreCase))return@"\\"+path.Substring(8);if(path.StartsWith(@"\\?\",StringComparison.OrdinalIgnoreCase))return path.Substring(4);return path;} + private static bool SamePath(string first,string second){return String.Equals(Path.GetFullPath(first).TrimEnd('\\'),Path.GetFullPath(second).TrimEnd('\\'),StringComparison.OrdinalIgnoreCase);} + private static bool OnlyDefaultStream(SafeFileHandle handle){IntPtr buffer=Marshal.AllocHGlobal(65536);try{if(!GetFileInformationByHandleEx(handle,FileStreamInfo,buffer,65536)){int error=Marshal.GetLastWin32Error();if(error==38)return true;throw new Win32Exception(error);}int offset=0;while(true){uint next=(uint)Marshal.ReadInt32(buffer,offset),nameLength=(uint)Marshal.ReadInt32(buffer,offset+4);string name=Marshal.PtrToStringUni(IntPtr.Add(buffer,offset+24),(int)nameLength/2);if(!String.Equals(name,"::$DATA",StringComparison.OrdinalIgnoreCase)&&!String.Equals(name,"::$INDEX_ALLOCATION",StringComparison.OrdinalIgnoreCase))return false;if(next==0)break;offset+=(int)next;}return true;}finally{Marshal.FreeHGlobal(buffer);}} + private static string[] Enumerate(SafeFileHandle handle){List result=new List();HashSet seen=new HashSet(StringComparer.OrdinalIgnoreCase);IntPtr buffer=Marshal.AllocHGlobal(65536);bool restart=true;try{while(true){IO_STATUS_BLOCK io;int status=NtQueryDirectoryFile(handle,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,out io,buffer,65536,FileDirectoryInformation,false,IntPtr.Zero,restart);restart=false;if(status==STATUS_NO_MORE_FILES)break;if(status<0)throw new Win32Exception((int)RtlNtStatusToDosError(status));int offset=0;while(true){uint next=(uint)Marshal.ReadInt32(buffer,offset),nameLength=(uint)Marshal.ReadInt32(buffer,offset+60);string name=Marshal.PtrToStringUni(IntPtr.Add(buffer,offset+64),(int)nameLength/2);if(name!="."&&name!=".."&&seen.Add(name))result.Add(name);if(next==0)break;offset+=(int)next;}}return result.ToArray();}finally{Marshal.FreeHGlobal(buffer);}} + private static OpenResult OpenRelative(SafeFileHandle parent,string name,uint access,uint share,uint disposition,uint options){IntPtr nameBuffer=IntPtr.Zero,unicodePointer=IntPtr.Zero;bool added=false;try{parent.DangerousAddRef(ref added);nameBuffer=Marshal.StringToHGlobalUni(name);UNICODE_STRING unicode=new UNICODE_STRING{Length=(ushort)(name.Length*2),MaximumLength=(ushort)((name.Length+1)*2),Buffer=nameBuffer};unicodePointer=Marshal.AllocHGlobal(Marshal.SizeOf(typeof(UNICODE_STRING)));Marshal.StructureToPtr(unicode,unicodePointer,false);OBJECT_ATTRIBUTES attributes=new OBJECT_ATTRIBUTES{Length=Marshal.SizeOf(typeof(OBJECT_ATTRIBUTES)),RootDirectory=parent.DangerousGetHandle(),ObjectName=unicodePointer,Attributes=OBJ_CASE_INSENSITIVE};IO_STATUS_BLOCK io;IntPtr raw;int status=NtCreateFile(out raw,access,ref attributes,out io,IntPtr.Zero,0,share,disposition,options|SYNC_IO|OPEN_REPARSE,IntPtr.Zero,0);if(status<0)throw new Win32Exception((int)RtlNtStatusToDosError(status));return new OpenResult(new SafeFileHandle(raw,true),io.Information.ToUInt64()==2);}finally{if(unicodePointer!=IntPtr.Zero)Marshal.FreeHGlobal(unicodePointer);if(nameBuffer!=IntPtr.Zero)Marshal.FreeHGlobal(nameBuffer);if(added)parent.DangerousRelease();}} + private static int Rename(SafeFileHandle source,SafeFileHandle parent,string destination){byte[] name=Encoding.Unicode.GetBytes(destination);int rootOffset=IntPtr.Size,lengthOffset=rootOffset+IntPtr.Size,nameOffset=lengthOffset+4,size=nameOffset+name.Length+2;IntPtr buffer=Marshal.AllocHGlobal(size);bool parentAdded=false,sourceAdded=false;try{source.DangerousAddRef(ref sourceAdded);parent.DangerousAddRef(ref parentAdded);for(int i=0;i=0?0:(int)RtlNtStatusToDosError(status);}finally{if(parentAdded)parent.DangerousRelease();if(sourceAdded)source.DangerousRelease();Marshal.FreeHGlobal(buffer);}} + private static int RenameReplacing(SafeFileHandle source,SafeFileHandle parent,string destination){byte[] name=Encoding.Unicode.GetBytes(destination);int rootOffset=IntPtr.Size,lengthOffset=rootOffset+IntPtr.Size,nameOffset=lengthOffset+4,size=nameOffset+name.Length+2;IntPtr buffer=Marshal.AllocHGlobal(size);bool parentAdded=false,sourceAdded=false;try{source.DangerousAddRef(ref sourceAdded);parent.DangerousAddRef(ref parentAdded);for(int i=0;i=0?0:(int)RtlNtStatusToDosError(status);}finally{if(parentAdded)parent.DangerousRelease();if(sourceAdded)source.DangerousRelease();Marshal.FreeHGlobal(buffer);}} +} +'@ + $marker = 'CcodInstallGenerationCapabilityMarkerV7' -as [type] + } + if ($null -eq $marker -or [int]$marker.GetProperty('CapabilityAbi').GetValue($null,$null) -ne 7) { Throw-CcodInstallFileError 'CCOD_INSTALL_RUNTIME_ABI_INVALID' 'Install generation runtime ABI is unavailable' $null } + $script:CcodRuntimeType = $marker.Assembly.GetType('CcodInstallGenerationRuntimeV7',$true) +} + +function Assert-CcodInstallLeaf([string]$Leaf,[string]$ErrorId='CCOD_INSTALL_LEAF_INVALID') { + if ([string]::IsNullOrWhiteSpace($Leaf)-or$Leaf.Length-gt 160-or$Leaf-in@('.','..')-or$Leaf-cnotmatch'^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$'-or$Leaf.EndsWith('.')-or$Leaf-match'^(?i:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)') { Throw-CcodInstallFileError $ErrorId 'Unsafe install leaf' $Leaf } +} +function Invoke-CcodRuntimeStatic([string]$Name,[object[]]$Arguments){$method=$script:CcodRuntimeType.GetMethod($Name,[Reflection.BindingFlags]'NonPublic,Static');try{return $method.Invoke($null,$Arguments)}catch [Reflection.TargetInvocationException]{throw $_.Exception.InnerException}} +function Invoke-CcodRuntimeMethod($Runtime,[string]$Name,[object[]]$Arguments){$method=$Runtime.GetType().GetMethod($Name,[Reflection.BindingFlags]'NonPublic,Instance');try{return $method.Invoke($Runtime,$Arguments)}catch [Reflection.TargetInvocationException]{throw $_.Exception.InnerException}} +function New-CcodInstallCapability { New-Object psobject } +function Get-CcodInstallTransaction($Capability,[string]$InvalidId='CCOD_INSTALL_GENERATION_INVALID',[switch]$AllowRetired){$record=$null;if($null-eq$Capability-or-not$script:CcodScopes.TryGetValue($Capability,[ref]$record)){Throw-CcodInstallFileError $InvalidId 'Invalid install generation capability' $null};$state=$null;if(-not$script:CcodTransactions.TryGetValue($record.Transaction,[ref]$state)){Throw-CcodInstallFileError $InvalidId 'Invalid install transaction capability' $null};if($state.Closed){Throw-CcodInstallFileError 'CCOD_INSTALL_TRANSACTION_CLOSED' 'Install file transaction is closed' $null};if($state.Retired-and-not$AllowRetired){Throw-CcodInstallFileError 'CCOD_INSTALL_GENERATION_RETIRED' 'Install generation is retired' $null};[pscustomobject]@{State=$state;Record=$record}} +function Add-CcodInstallScope($Transaction,$Token,[string]$Kind='Directory',[bool]$ReadOnly=$false,[AllowNull()][string]$RuntimeId=$null){$capability=New-CcodInstallCapability;$script:CcodScopes.Add($capability,[pscustomobject]@{Transaction=$Transaction;Token=$Token;Kind=$Kind;ReadOnly=$ReadOnly;RuntimeId=$RuntimeId});$capability} +function Get-CcodInstallScopedDirectory($Transaction,$Parent){$parentScope=Get-CcodInstallTransaction $Parent 'CCOD_INSTALL_DIRECTORY_INVALID';if(-not[object]::ReferenceEquals($parentScope.Record.Transaction,$Transaction)-or$parentScope.Record.Kind-cnotin@('Directory','Generation')){Throw-CcodInstallFileError 'CCOD_INSTALL_DIRECTORY_INVALID' 'Directory capability is outside the transaction scope' $null};return $parentScope} +function Assert-CcodInstallWritableScope($Scope){if($Scope.Record.ReadOnly){Throw-CcodInstallFileError 'CCOD_INSTALL_GENERATION_READ_ONLY' 'Retained generation capability is read-only' $null}} +function Convert-CcodInstallRuntimeError($Action,[string]$DefaultId){try{return&$Action}catch{$exception=$_.Exception;while($null-ne$exception.InnerException-and$exception-is[Management.Automation.RuntimeException]){$exception=$exception.InnerException};$message=[string]$exception.Message;if($exception-is[ObjectDisposedException]){Throw-CcodInstallFileError 'CCOD_INSTALL_TRANSACTION_CLOSED' $message $null};if($message-match'state-only scope'){Throw-CcodInstallFileError 'CCOD_INSTALL_STATE_SCOPE' $message $null};if($message-match'migration-retry scope'){Throw-CcodInstallFileError 'CCOD_INSTALL_MIGRATION_RETRY_SCOPE' $message $null};if($message-match'product-only scope'){Throw-CcodInstallFileError 'CCOD_INSTALL_PRODUCT_SCOPE' $message $null};if($exception-is[ComponentModel.Win32Exception]-and$exception.NativeErrorCode-in@(80,183)){Throw-CcodInstallFileError 'CCOD_INSTALL_LEAF_EXISTS' 'Install object already exists' $null};if($message-match'pointer generation exists'){Throw-CcodInstallFileError 'CCOD_INSTALL_POINTER_GENERATION_EXISTS' $message $null};if($message-match'pointer generation overflow'){Throw-CcodInstallFileError 'CCOD_INSTALL_POINTER_GENERATION_OVERFLOW' $message $null};if($message-match'retained manifest mismatch'){Throw-CcodInstallFileError 'CCOD_INSTALL_RETAINED_MANIFEST_MISMATCH' $message $null};if($message-match'retained runtime id mismatch'){Throw-CcodInstallFileError 'CCOD_INSTALL_RETAINED_RUNTIME_ID_MISMATCH' $message $null};if($message-match'product shortcut source'){Throw-CcodInstallFileError 'CCOD_INSTALL_PRODUCT_SHORTCUT_INVALID' $message $null};if($message-match'seal mismatch'){Throw-CcodInstallFileError 'CCOD_INSTALL_SEAL_MISMATCH' $message $null};if($message-match'alternate stream'){Throw-CcodInstallFileError 'CCOD_INSTALL_ADS_LEAF' $message $null};if($message-match'multi-link'){Throw-CcodInstallFileError 'CCOD_INSTALL_MULTILINK_LEAF' $message $null};if($message-match'reparse'){Throw-CcodInstallFileError 'CCOD_INSTALL_REPARSE_LEAF' $message $null};if($message-match'transaction scope'){Throw-CcodInstallFileError 'CCOD_INSTALL_TRANSACTION_SCOPE' $message $null};if($message-match'generation not owned'){Throw-CcodInstallFileError 'CCOD_INSTALL_GENERATION_NOT_OWNED' $message $null};if($message-match'unknown|unsealed'){Throw-CcodInstallFileError 'CCOD_INSTALL_UNKNOWN_LEAF' $message $null};if($message-match'pin changed|path changed'){Throw-CcodInstallFileError 'CCOD_INSTALL_PIN_CHANGED' $message $null};Throw-CcodInstallFileError $DefaultId $message $null}} +function Test-CcodInstallValue($Value,[int]$Depth=0) { + if ($Depth -gt 12) { return $false } + if ($null -eq $Value) { return $true } + if ($Value -is [string]) { return $Value.Length -le 4096 } + if ($Value -is [ValueType]) { return $true } + if ($Value -is [Collections.IDictionary]) { + foreach ($key in $Value.Keys) { + if ($key -isnot [string] -or $key -cnotmatch '^[A-Za-z0-9_.-]{1,96}$' -or -not (Test-CcodInstallValue $Value[$key] ($Depth + 1))) { return $false } + } + return $true + } + if ($Value -is [Collections.IEnumerable]) { + foreach ($item in $Value) { if (-not (Test-CcodInstallValue $item ($Depth + 1))) { return $false } } + return $true + } + if ($Value -is [pscustomobject]) { + foreach ($property in $Value.PSObject.Properties) { + if ($property.Name -cnotmatch '^[A-Za-z0-9_.-]{1,96}$' -or -not (Test-CcodInstallValue $property.Value ($Depth + 1))) { return $false } + } + return $true + } + return $false +} +function ConvertTo-CcodInstallJsonBytes($Value){if(-not(Test-CcodInstallValue $Value)){Throw-CcodInstallFileError 'CCOD_INSTALL_JSON_INVALID' 'Invalid install JSON value' $null};return ,([Text.UTF8Encoding]::new($false).GetBytes(($Value|ConvertTo-Json -Depth 32 -Compress)+"`n"))} +function Get-CcodInstallBytesSha256([byte[]]$Bytes){$sha=[Security.Cryptography.SHA256]::Create();try{return [BitConverter]::ToString($sha.ComputeHash($Bytes)).Replace('-','').ToLowerInvariant()}finally{$sha.Dispose()}} +function Get-CcodTopLevelJsonPropertyCount([string]$Json,[string]$Name){$count=0;$depth=0;$inString=$false;$escaped=$false;$expectKey=$false;$captureKey=$false;$keyStart=-1;for($index=0;$index-lt$Json.Length;$index++){$character=$Json[$index];if($inString){if($escaped){$escaped=$false;continue};if($character-ceq'\'){$escaped=$true;continue};if($character-ceq'"'){if($captureKey){try{$key=$Json.Substring($keyStart,$index-$keyStart+1)|ConvertFrom-Json -ErrorAction Stop}catch{return -1};if($key-isnot[string]){return -1};if($key-ceq$Name){$count++};$expectKey=$false};$inString=$false;$captureKey=$false};continue};if($character-ceq'"'){$inString=$true;$captureKey=($depth-eq 1-and$expectKey);if($captureKey){$keyStart=$index};continue};if($character-ceq'{'-or$character-ceq'['){$depth++;if($depth-eq 1-and$character-ceq'{'){$expectKey=$true};continue};if($character-ceq'}'-or$character-ceq']'){$depth--;continue};if($character-ceq','-and$depth-eq 1){$expectKey=$true}};if($inString-or$depth-ne 0){return -1};return $count} +function Test-CcodRetainedManifestIdentity([string]$Json,[string]$RuntimeId){try{$manifest=$Json|ConvertFrom-Json -ErrorAction Stop}catch{return $false};if($manifest-isnot[pscustomobject]-or(Get-CcodTopLevelJsonPropertyCount $Json 'runtimeId')-ne 1){return $false};$properties=@($manifest.PSObject.Properties|Where-Object Name -CEQ 'runtimeId');return ($properties.Count-eq 1-and$properties[0].Value-is[string]-and[string]$properties[0].Value-ceq$RuntimeId)} +function Assert-CcodInstallRootScope($State,[string]$InstallRoot,[string]$ErrorId){if(-not[IO.Path]::IsPathRooted($InstallRoot)-or-not[String]::Equals([IO.Path]::GetFullPath($InstallRoot).TrimEnd('\'),$State.InstallRoot,[StringComparison]::OrdinalIgnoreCase)){Throw-CcodInstallFileError $ErrorId 'Install root does not match transaction scope' $InstallRoot}} + +function Open-CcodInstallGeneration { + param([Parameter(Mandatory)][string]$InstallRoot,[Parameter(Mandatory)][string]$RuntimeId) + Initialize-CcodInstallRuntime;if(-not[IO.Path]::IsPathRooted($InstallRoot)){Throw-CcodInstallFileError 'CCOD_INSTALL_ROOT_INVALID' 'Install root must be absolute' $InstallRoot};Assert-CcodInstallLeaf $RuntimeId 'CCOD_INSTALL_RUNTIME_ID_INVALID' + $arguments=[object[]]@([IO.Path]::GetFullPath($InstallRoot),$RuntimeId,$null) + try{$token=Invoke-CcodRuntimeStatic Open $arguments}catch [ComponentModel.Win32Exception]{if($_.Exception.NativeErrorCode-in@(32,80,183)){Throw-CcodInstallFileError 'CCOD_INSTALL_GENERATION_EXISTS' 'Install generation already exists' $RuntimeId};throw} + $runtime=$arguments[2];$transaction=New-CcodInstallCapability;$script:CcodTransactions.Add($transaction,[pscustomobject]@{Runtime=$runtime;InstallRoot=[IO.Path]::GetFullPath($InstallRoot).TrimEnd('\');RuntimeId=$RuntimeId;RootToken=$token;StateOnly=$false;ProductOnly=$false;RetryOnly=$false;Closed=$false;CleanupFailed=$false;Retired=$false;RetirementResult=$null;Disposition=$null});$script:CcodScopes.Add($transaction,[pscustomobject]@{Transaction=$transaction;Token=$token;Kind='Generation';ReadOnly=$false;RuntimeId=$RuntimeId});$transaction +} +function Open-CcodInstallStateTransaction { param([Parameter(Mandatory)][string]$InstallRoot) Initialize-CcodInstallRuntime;if(-not[IO.Path]::IsPathRooted($InstallRoot)){Throw-CcodInstallFileError 'CCOD_INSTALL_ROOT_INVALID' 'Install root must be absolute' $InstallRoot};$arguments=[object[]]@([IO.Path]::GetFullPath($InstallRoot),$null);$token=Convert-CcodInstallRuntimeError {Invoke-CcodRuntimeStatic OpenState $arguments} 'CCOD_INSTALL_STATE_TRANSACTION_INVALID';$runtime=$arguments[1];$transaction=New-CcodInstallCapability;$script:CcodTransactions.Add($transaction,[pscustomobject]@{Runtime=$runtime;InstallRoot=[IO.Path]::GetFullPath($InstallRoot).TrimEnd('\');RuntimeId=$null;RootToken=$token;StateOnly=$true;ProductOnly=$false;RetryOnly=$false;Closed=$false;CleanupFailed=$false;Retired=$false;RetirementResult=$null;Disposition=$null});$script:CcodScopes.Add($transaction,[pscustomobject]@{Transaction=$transaction;Token=$token;Kind='StateTransaction';ReadOnly=$false;RuntimeId=$null});$transaction } +function Open-CcodInstallMigrationRetryTransaction { param([Parameter(Mandatory)][string]$InstallRoot) Initialize-CcodInstallRuntime;if(-not[IO.Path]::IsPathRooted($InstallRoot)){Throw-CcodInstallFileError 'CCOD_INSTALL_ROOT_INVALID' 'Install root must be absolute' $InstallRoot};$arguments=[object[]]@([IO.Path]::GetFullPath($InstallRoot),$null);$token=Convert-CcodInstallRuntimeError {Invoke-CcodRuntimeStatic OpenRetry $arguments} 'CCOD_INSTALL_MIGRATION_RETRY_SCOPE';$runtime=$arguments[1];$transaction=New-CcodInstallCapability;$script:CcodTransactions.Add($transaction,[pscustomobject]@{Runtime=$runtime;InstallRoot=[IO.Path]::GetFullPath($InstallRoot).TrimEnd('\');RuntimeId=$null;RootToken=$token;StateOnly=$false;ProductOnly=$false;RetryOnly=$true;Closed=$false;CleanupFailed=$false;Retired=$false;RetirementResult=$null;Disposition=$null});$script:CcodScopes.Add($transaction,[pscustomobject]@{Transaction=$transaction;Token=$token;Kind='MigrationRetryTransaction';ReadOnly=$false;RuntimeId=$null});$transaction } +function Open-CcodInstallRetainedGeneration { param([Parameter(Mandatory)][string]$InstallRoot,[Parameter(Mandatory)][string]$RuntimeId,[Parameter(Mandatory)][string]$ExpectedManifestSha256,[Parameter(Mandatory)]$FileTransaction) if(-not[IO.Path]::IsPathRooted($InstallRoot)-or$ExpectedManifestSha256-cnotmatch'^[0-9a-f]{64}$'){Throw-CcodInstallFileError 'CCOD_INSTALL_RETAINED_GENERATION_INVALID' 'Invalid retained generation contract' $RuntimeId};Assert-CcodInstallLeaf $RuntimeId 'CCOD_INSTALL_RUNTIME_ID_INVALID';$scope=Get-CcodInstallTransaction $FileTransaction 'CCOD_INSTALL_TRANSACTION_INVALID';if(-not[object]::ReferenceEquals($scope.Record.Transaction,$FileTransaction)){Throw-CcodInstallFileError 'CCOD_INSTALL_TRANSACTION_INVALID' 'FileTransaction must be the transaction root capability' $null};Assert-CcodInstallRootScope $scope.State $InstallRoot 'CCOD_INSTALL_TRANSACTION_SCOPE';$nativeResult=Convert-CcodInstallRuntimeError {Invoke-CcodRuntimeMethod $scope.State.Runtime OpenRetained @($RuntimeId,$ExpectedManifestSha256)} 'CCOD_INSTALL_RETAINED_GENERATION_INVALID';if(@($nativeResult).Count-ne 2-or-not(Test-CcodRetainedManifestIdentity ([string]$nativeResult[1]) $RuntimeId)){Throw-CcodInstallFileError 'CCOD_INSTALL_RETAINED_RUNTIME_ID_MISMATCH' 'Retained manifest must contain exactly one matching top-level runtimeId string' $RuntimeId};Add-CcodInstallScope $scope.Record.Transaction $nativeResult[0] Generation $true $RuntimeId } + +$script:CcodProductReadyFields=@('schemaVersion','transactionId','oldRuntimeId','oldGeneration','oldManifestSha256','newRuntimeId','newGeneration','newManifestSha256','sealedPackageSha256','ownedObjectNames','phase','errorCode') +$script:CcodInstallProductCleanupFields=@('schemaVersion','transactionId','runtimeId','runtimeGeneration','manifestSha256','packageSha256','ownerPid','ownerCreationTimeUtc','ownerSid','state') +function Test-CcodInstallOrderedProperties($Value,[string[]]$Names){if($null-eq$Value){return $false};$actual=@($Value.PSObject.Properties.Name);return(($actual-join"`0")-ceq($Names-join"`0"))} +function Test-CcodInstallUnsignedInteger($Value){return $Value-is[byte]-or$Value-is[uint16]-or$Value-is[uint32]-or$Value-is[uint64]-or$Value-is[int16]-or$Value-is[int32]-or$Value-is[int64]} +function Read-CcodInstallAuthorityFile { + param([Parameter(Mandatory)][string]$Path) + Initialize-CcodInstallRuntime + try{$result=@(Invoke-CcodRuntimeStatic ReadAuthorityFile @([IO.Path]::GetFullPath($Path)));if($result.Count-ne2-or$result[0]-isnot[string]-or$result[1]-isnot[string]-or$result[1]-cnotmatch'^[0-9a-f]{64}$'){throw 'authority read result invalid'};$bytes=[Convert]::FromBase64String([string]$result[0]);return [pscustomobject]@{Bytes=$bytes;Sha256=[string]$result[1]}} + catch{if(([string]$_.FullyQualifiedErrorId-split',')[0]-ceq'CCOD_INSTALL_PRODUCT_SCOPE'){throw};Throw-CcodInstallFileError 'CCOD_INSTALL_PRODUCT_SCOPE' 'Authority file could not be read from one verified native handle' $Path} +} +function Get-CcodInstallLifecycleValidationModule { + $path=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot 'InstallLifecycle.psm1')) + $module=Get-Module|Where-Object{$null-ne$_.Path-and[IO.Path]::GetFullPath($_.Path)-ceq$path}|Select-Object -First 1 + if($null-eq$module){$module=Import-Module $path -PassThru -ErrorAction Stop} + if($null-eq$module){throw 'canonical lifecycle module unavailable'} + return $module +} +function Assert-CcodInstallCanonicalTransactionRecord($Record){ + $module=Get-CcodInstallLifecycleValidationModule + return (&$module {param($Value)[void](Assert-CcodInstallTransactionRecord -Record $Value);Get-CcodInstallTransactionRecordLeaf -Record $Value} $Record) +} +function Resolve-CcodInstallCanonicalTransactionHead([object[]]$Records,$ActiveRuntime){ + $module=Get-CcodInstallLifecycleValidationModule + return (&$module {param($Values,$Active)Resolve-CcodInstallTransactionRecordHead -Records $Values -ActiveRuntime $Active} $Records $ActiveRuntime) +} +function Get-CcodInstallKernelObjectsModule { + $path=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot 'KernelObjects.psm1')) + $module=Get-Module|Where-Object{$null-ne$_.Path-and[IO.Path]::GetFullPath($_.Path)-ceq$path}|Select-Object -First 1 + if($null-eq$module){$module=Import-Module $path -PassThru -ErrorAction Stop} + if($null-eq$module){throw 'kernel coordination module unavailable'} + return $module +} +function Enter-CcodInstallProductAuthorityLease { + $identity=$null + try{ + $identity=[Security.Principal.WindowsIdentity]::GetCurrent();if($null-eq$identity-or$null-eq$identity.User){throw 'current user identity unavailable'} + $module=Get-CcodInstallKernelObjectsModule + $lease=&$module {param($Sid)Enter-CcodMutex -Kind AccountTransition -UserSid $Sid -TimeoutMilliseconds 30000} $identity.User.Value + if($null-eq$lease-or$lease.Outcome-cne'Acquired'){throw 'product authority coordination timed out'} + return $lease + }catch{Throw-CcodInstallFileError 'CCOD_INSTALL_PRODUCT_SCOPE' 'Product authority coordination lease could not be acquired' $null} + finally{if($null-ne$identity){$identity.Dispose()}} +} +function Exit-CcodInstallProductAuthorityLease($Lease){ + if($null-eq$Lease){return} + $module=Get-CcodInstallKernelObjectsModule + &$module {param($Value)Exit-CcodMutex -Lease $Value} $Lease|Out-Null +} +function Assert-CcodInstallProductAuthorityLease($State){ + $lease=if($null-ne$State.PSObject.Properties['AuthorityLease']){$State.AuthorityLease}else{$null} + if(-not$State.ProductOnly-or$null-eq$lease-or$lease.SchemaVersion-isnot[int]-or$lease.SchemaVersion-ne1-or$lease.Kind-cne'AccountTransition'-or$lease.Outcome-cne'Acquired'-or$lease.Released-isnot[bool]-or$lease.Released-or$lease.OwnerManagedThreadId-isnot[int]-or$lease.OwnerManagedThreadId-ne[Threading.Thread]::CurrentThread.ManagedThreadId){Throw-CcodInstallFileError 'CCOD_INSTALL_PRODUCT_SCOPE' 'Retained product authority requires its live proof-to-use coordination lease' $null} + return $true +} +function Read-CcodInstallProductStrictJson([string]$Path,[string[]]$Fields){$read=Read-CcodInstallAuthorityFile -Path $Path;$json=[Text.UTF8Encoding]::new($false,$true).GetString($read.Bytes);foreach($field in $Fields){if((Get-CcodTopLevelJsonPropertyCount $json $field)-ne1){throw 'duplicate or missing authority member'}};$value=$json|ConvertFrom-Json -ErrorAction Stop;if(-not(Test-CcodInstallOrderedProperties $value $Fields)){throw 'noncanonical authority record'};return $value} +function Get-CcodInstallProductReadyAuthority([string]$InstallRoot,$ReadyTransaction){ + try{[void](Assert-CcodInstallCanonicalTransactionRecord $ReadyTransaction)}catch{Throw-CcodInstallFileError 'CCOD_INSTALL_PRODUCT_SCOPE' 'Complete strict Ready transaction identity is required' $ReadyTransaction};if($ReadyTransaction.phase-cne'Ready'){Throw-CcodInstallFileError 'CCOD_INSTALL_PRODUCT_SCOPE' 'Terminal Ready transaction is required' $ReadyTransaction} + $root=[IO.Path]::GetFullPath($InstallRoot).TrimEnd('\');try{ + foreach($directory in @((Join-Path $root 'state'),(Join-Path $root 'state\active-generation'),(Join-Path $root 'state\install-transactions'))){$item=Get-Item -LiteralPath $directory -Force -ErrorAction Stop;if(-not$item.PSIsContainer-or($item.Attributes-band[IO.FileAttributes]::ReparsePoint)-ne0){throw 'unsafe authority directory'}} + $pointerRoot=Join-Path $root 'state\active-generation';$pointerObjects=@(Get-ChildItem -LiteralPath $pointerRoot -Force -ErrorAction Stop|Sort-Object Name);if($pointerObjects.Count-eq0){throw 'pointer chain missing'};$latest=$null + for($index=0;$index-lt$pointerObjects.Count;$index++){$expectedGeneration=[uint64]($index+1);$file=$pointerObjects[$index];$expectedLeaf='{0:D20}.json'-f$expectedGeneration;if($file.PSIsContainer-or$file.Name-cne$expectedLeaf){throw 'pointer chain is not contiguous'};$pointer=Read-CcodInstallProductStrictJson $file.FullName @('schemaVersion','generation','activeRuntime','previousGeneration');if($pointer.schemaVersion-isnot[int]-or$pointer.schemaVersion-ne1-or-not(Test-CcodInstallUnsignedInteger $pointer.generation)-or[uint64]$pointer.generation-ne$expectedGeneration-or$pointer.activeRuntime-isnot[string]-or$pointer.activeRuntime-cnotmatch'^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$'-or-not(Test-CcodInstallUnsignedInteger $pointer.previousGeneration)-or[uint64]$pointer.previousGeneration-ne[uint64]$index){throw 'pointer record invalid'};$latest=$pointer} + if([uint64]$latest.generation-ne[uint64]$ReadyTransaction.newGeneration-or$latest.activeRuntime-cne$ReadyTransaction.newRuntimeId){throw 'Ready transaction is stale'} + $manifestPath=Join-Path (Join-Path (Join-Path $root 'runtime') $ReadyTransaction.newRuntimeId) 'manifest.json';$manifestRead=Read-CcodInstallAuthorityFile -Path $manifestPath;$manifestSha=$manifestRead.Sha256;if($manifestSha-cne$ReadyTransaction.newManifestSha256){throw 'manifest mismatch'} + $store=Join-Path $root 'state\install-transactions';$records=[Collections.Generic.List[object]]::new();foreach($file in @(Get-ChildItem -LiteralPath $store -Force -ErrorAction Stop|Sort-Object Name)){if($file.PSIsContainer-or$file.Name-cnotmatch'^\d{20}\.\d{2}\.[A-Za-z]+\.[0-9a-f-]{36}\.json$'){throw 'unknown transaction object'};$record=Read-CcodInstallProductStrictJson $file.FullName $script:CcodProductReadyFields;$expectedLeaf=Assert-CcodInstallCanonicalTransactionRecord $record;if($file.Name-cne$expectedLeaf){throw 'transaction leaf is noncanonical'};$records.Add($record)} + $canonicalHead=Resolve-CcodInstallCanonicalTransactionHead $records.ToArray() $latest;$wanted=$ReadyTransaction|ConvertTo-Json -Depth 8 -Compress;if($null-eq$canonicalHead-or$canonicalHead.phase-cne'Ready'-or($canonicalHead|ConvertTo-Json -Depth 8 -Compress)-cne$wanted){throw 'canonical lifecycle head is not the selected Ready record'} + return [pscustomobject]@{ReadyCanonical=$wanted;PersistedReadyCanonical=($canonicalHead|ConvertTo-Json -Depth 8 -Compress);RuntimeId=[string]$ReadyTransaction.newRuntimeId;Generation=[uint64]$ReadyTransaction.newGeneration;ManifestSha256=[string]$manifestSha;PackageSha256=[string]$ReadyTransaction.sealedPackageSha256} + }catch{if(([string]$_.FullyQualifiedErrorId-split',')[0]-ceq'CCOD_INSTALL_PRODUCT_SCOPE'){throw};Throw-CcodInstallFileError 'CCOD_INSTALL_PRODUCT_SCOPE' 'Complete selector manifest and persisted Ready authority could not be proven' $ReadyTransaction} +} + +function Open-CcodInstallProductRegistrationTransaction { + param([Parameter(Mandatory)][string]$InstallRoot,[Parameter(Mandatory)]$ReadyTransaction) + Initialize-CcodInstallRuntime;if(-not[IO.Path]::IsPathRooted($InstallRoot)){Throw-CcodInstallFileError 'CCOD_INSTALL_ROOT_INVALID' 'Install root must be absolute' $InstallRoot};$root=[IO.Path]::GetFullPath($InstallRoot);$lease=$null + try{ + $lease=Enter-CcodInstallProductAuthorityLease + $authority=Get-CcodInstallProductReadyAuthority $root $ReadyTransaction + $arguments=[object[]]@($root,$null);$token=Convert-CcodInstallRuntimeError {Invoke-CcodRuntimeStatic OpenProduct $arguments} 'CCOD_INSTALL_PRODUCT_SCOPE';$runtime=$arguments[1];$transaction=New-CcodInstallCapability;$script:CcodTransactions.Add($transaction,[pscustomobject]@{Runtime=$runtime;InstallRoot=$root.TrimEnd('\');RuntimeId=$authority.RuntimeId;RootToken=$token;StateOnly=$false;ProductOnly=$true;RetryOnly=$false;ReadyAuthority=$authority;AuthorityLease=$lease;Closed=$false;CleanupFailed=$false;Retired=$false;RetirementResult=$null;Disposition=$null});$script:CcodScopes.Add($transaction,[pscustomobject]@{Transaction=$transaction;Token=$token;Kind='ProductTransaction';ReadOnly=$true;RuntimeId=$authority.RuntimeId});$lease=$null;return $transaction + }finally{if($null-ne$lease){try{Exit-CcodInstallProductAuthorityLease $lease}catch{}}} +} +function Get-CcodInstallProductCleanupFenceProof($State,$Fence){ + if($null-eq$State-or-not$State.ProductOnly-or$null-eq$State.ReadyAuthority-or$null-eq$Fence-or-not(Test-CcodInstallOrderedProperties $Fence @('InstallRoot','ReadyTransaction','OwnerIdentity','Attempt'))){Throw-CcodInstallFileError 'CCOD_INSTALL_CLOSE_FAILED' 'Product cleanup fence binding is invalid' $null} + $root=[IO.Path]::GetFullPath([string]$Fence.InstallRoot).TrimEnd('\') + $readyCanonical=$Fence.ReadyTransaction|ConvertTo-Json -Depth 8 -Compress + $owner=$Fence.OwnerIdentity + if($root-cne$State.InstallRoot-or$readyCanonical-cne$State.ReadyAuthority.ReadyCanonical-or-not(Test-CcodInstallUnsignedInteger $Fence.Attempt)-or[uint64]$Fence.Attempt-eq0-or$null-eq$owner-or-not(Test-CcodInstallOrderedProperties $owner @('pid','creationTimeUtc','userSid'))-or$owner.pid-isnot[int]-or$owner.pid-le0-or$owner.creationTimeUtc-isnot[string]-or$owner.userSid-isnot[string]){Throw-CcodInstallFileError 'CCOD_INSTALL_CLOSE_FAILED' 'Product cleanup fence does not match strict Ready authority' $Fence} + $leaf='{0:D20}.Pending.{1}.json'-f[uint64]$Fence.Attempt,[string]$Fence.ReadyTransaction.transactionId + $path=Join-Path $root ('state\product-cleanup-fences\'+$leaf) + try{$record=Read-CcodInstallProductStrictJson -Path $path -Fields $script:CcodInstallProductCleanupFields}catch{Throw-CcodInstallFileError 'CCOD_INSTALL_CLOSE_FAILED' 'Durable Pending product cleanup fence is unavailable' $path} + if($record.schemaVersion-isnot[int]-or$record.schemaVersion-ne1-or$record.state-isnot[string]-or$record.state-cne'Pending'-or + $record.transactionId-cne$Fence.ReadyTransaction.transactionId-or$record.runtimeId-cne$State.ReadyAuthority.RuntimeId-or + -not(Test-CcodInstallUnsignedInteger $record.runtimeGeneration)-or[uint64]$record.runtimeGeneration-ne[uint64]$State.ReadyAuthority.Generation-or + $record.manifestSha256-cne$State.ReadyAuthority.ManifestSha256-or$record.packageSha256-cne$State.ReadyAuthority.PackageSha256-or + $record.ownerPid-isnot[int]-or[int]$record.ownerPid-ne[int]$owner.pid-or$record.ownerCreationTimeUtc-isnot[string]-or$record.ownerCreationTimeUtc-cne[string]$owner.creationTimeUtc-or + $record.ownerSid-isnot[string]-or$record.ownerSid-cne[string]$owner.userSid){Throw-CcodInstallFileError 'CCOD_INSTALL_CLOSE_FAILED' 'Durable Pending product cleanup fence changed before close' $path} + return [pscustomobject]@{Path=$path;Canonical=($record|ConvertTo-Json -Depth 8 -Compress)} +} +function Set-CcodInstallProductCleanupFence { + param([Parameter(Mandatory)]$Transaction,[Parameter(Mandatory)]$Fence) + $scope=Get-CcodInstallTransaction $Transaction 'CCOD_INSTALL_TRANSACTION_INVALID' + if(-not[object]::ReferenceEquals($scope.Record.Transaction,$Transaction)-or$scope.Record.Kind-cne'ProductTransaction'){Throw-CcodInstallFileError 'CCOD_INSTALL_CLOSE_FAILED' 'Product cleanup fence requires the strict transaction root' $null} + [void](Assert-CcodInstallProductAuthorityLease $scope.State) + if($null-ne$scope.State.PSObject.Properties['ProductCleanupFence']-and$null-ne$scope.State.ProductCleanupFence){Throw-CcodInstallFileError 'CCOD_INSTALL_CLOSE_FAILED' 'Product cleanup fence is already bound' $null} + $scope.State|Add-Member -NotePropertyName ProductCleanupFence -NotePropertyValue (Get-CcodInstallProductCleanupFenceProof $scope.State $Fence) + return $true +} +function Assert-CcodInstallProductCleanupFenceBound($State){ + $proof=if($null-ne$State.PSObject.Properties['ProductCleanupFence']){$State.ProductCleanupFence}else{$null} + if($null-eq$proof-or$proof.Path-isnot[string]-or$proof.Canonical-isnot[string]){Throw-CcodInstallFileError 'CCOD_INSTALL_CLOSE_FAILED' 'Ready product close requires a durable Pending cleanup fence' $null} + try{$record=Read-CcodInstallProductStrictJson -Path $proof.Path -Fields $script:CcodInstallProductCleanupFields}catch{Throw-CcodInstallFileError 'CCOD_INSTALL_CLOSE_FAILED' 'Durable Pending cleanup fence is unavailable at close' $proof.Path} + if(($record|ConvertTo-Json -Depth 8 -Compress)-cne$proof.Canonical-or$record.state-cne'Pending'){Throw-CcodInstallFileError 'CCOD_INSTALL_CLOSE_FAILED' 'Durable Pending cleanup fence changed before close' $proof.Path} + return $true +} +function Get-CcodInstallLegacyMigrationPlanScope($Transaction,$ReadyTransaction){ + $scope=Get-CcodInstallTransaction $Transaction 'CCOD_INSTALL_PRODUCT_SCOPE' + if(-not[object]::ReferenceEquals($scope.Record.Transaction,$Transaction)-or$scope.Record.Kind-cne'ProductTransaction'-or-not$scope.State.ProductOnly){Throw-CcodInstallFileError 'CCOD_INSTALL_PRODUCT_SCOPE' 'Legacy migration plan requires the strict product transaction root' $null} + [void](Assert-CcodInstallProductAuthorityLease $scope.State) + try{[void](Assert-CcodInstallProductCleanupFenceBound $scope.State);$authority=Get-CcodInstallProductReadyAuthority $scope.State.InstallRoot $ReadyTransaction}catch{if(([string]$_.FullyQualifiedErrorId-split',')[0]-ceq'CCOD_INSTALL_PRODUCT_SCOPE'){throw};Throw-CcodInstallFileError 'CCOD_INSTALL_PRODUCT_SCOPE' 'Legacy migration plan requires the live cleanup fence and Ready authority' $null} + $stored=$scope.State.ReadyAuthority + if($null-eq$stored-or$authority.ReadyCanonical-cne$stored.ReadyCanonical-or$authority.PersistedReadyCanonical-cne$stored.PersistedReadyCanonical-or$authority.RuntimeId-cne$stored.RuntimeId-or[uint64]$authority.Generation-ne[uint64]$stored.Generation-or$authority.ManifestSha256-cne$stored.ManifestSha256-or$authority.PackageSha256-cne$stored.PackageSha256){Throw-CcodInstallFileError 'CCOD_INSTALL_PRODUCT_SCOPE' 'Legacy migration plan Ready authority changed after capability open' $ReadyTransaction} + [pscustomobject]@{Scope=$scope;Leaf=('{0:D20}.{1}.json'-f[uint64]$authority.Generation,[string]$ReadyTransaction.transactionId)} +} +function Write-CcodInstallLegacyMigrationPlan { + param([Parameter(Mandatory)]$Transaction,[Parameter(Mandatory)]$ReadyTransaction,[Parameter(Mandatory)][byte[]]$Bytes) + if($null-eq$Bytes-or$Bytes.LongLength-eq0-or$Bytes.LongLength-gt1048576){Throw-CcodInstallFileError 'CCOD_INSTALL_PRODUCT_SCOPE' 'Legacy migration plan bytes are outside the bounded contract' $null} + $context=Get-CcodInstallLegacyMigrationPlanScope $Transaction $ReadyTransaction;$result=[pscustomobject]@{Length=[int64]$Bytes.LongLength;Sha256=(Get-CcodInstallBytesSha256 $Bytes)} + try{Convert-CcodInstallRuntimeError {Invoke-CcodRuntimeMethod $context.Scope.State.Runtime WriteLegacyPlan @($context.Scope.State.RootToken,$context.Leaf,$Bytes)|Out-Null} 'CCOD_INSTALL_LEGACY_PLAN_WRITE_FAILED'}catch{if($_.FullyQualifiedErrorId-like'CCOD_INSTALL_LEAF_EXISTS*'){Throw-CcodInstallFileError 'CCOD_INSTALL_LEGACY_PLAN_EXISTS' 'Legacy migration plan already exists' $context.Leaf};throw} + return $result +} +function Read-CcodInstallLegacyMigrationPlan { + param([Parameter(Mandatory)]$Transaction,[Parameter(Mandatory)]$ReadyTransaction) + $context=Get-CcodInstallLegacyMigrationPlanScope $Transaction $ReadyTransaction + try{$native=@(Convert-CcodInstallRuntimeError {Invoke-CcodRuntimeMethod $context.Scope.State.Runtime ReadLegacyPlan @($context.Scope.State.RootToken,$context.Leaf)} 'CCOD_INSTALL_LEGACY_PLAN_READ_FAILED')}catch{$id=([string]$_.FullyQualifiedErrorId-split',')[0];if($id-in@('CCOD_INSTALL_PRODUCT_SCOPE','CCOD_INSTALL_TRANSACTION_CLOSED')){throw};Throw-CcodInstallFileError 'CCOD_INSTALL_LEGACY_PLAN_READ_FAILED' 'Legacy migration plan file identity or security is invalid' $context.Leaf} + if($native.Count-eq0){return $null} + if($native.Count-ne2-or$native[0]-isnot[string]-or$native[1]-isnot[string]-or$native[1]-cnotmatch'^[0-9a-f]{64}$'){Throw-CcodInstallFileError 'CCOD_INSTALL_LEGACY_PLAN_READ_FAILED' 'Legacy migration plan native read result is invalid' $context.Leaf} + try{$bytes=[Convert]::FromBase64String([string]$native[0])}catch{Throw-CcodInstallFileError 'CCOD_INSTALL_LEGACY_PLAN_READ_FAILED' 'Legacy migration plan native bytes are invalid' $context.Leaf} + if($bytes.LongLength-eq0-or$bytes.LongLength-gt1048576-or(Get-CcodInstallBytesSha256 $bytes)-cne[string]$native[1]){Throw-CcodInstallFileError 'CCOD_INSTALL_LEGACY_PLAN_READ_FAILED' 'Legacy migration plan native identity is invalid' $context.Leaf} + [pscustomobject]@{Bytes=$bytes;Length=[int64]$bytes.LongLength;Sha256=[string]$native[1]} +} +function Open-CcodInstallRetainedFile { + param([Parameter(Mandatory)]$Generation,[Parameter(Mandatory)][string]$RelativePath,[Parameter(Mandatory)]$ReadyTransaction) + if($RelativePath-cnotin@('registration/StartMenu.CodexRemote-fix.lnk','registration/Desktop.CodexRemote-fix.lnk')){Throw-CcodInstallFileError 'CCOD_INSTALL_PRODUCT_SHORTCUT_INVALID' 'Product shortcut source must be one fixed manifest-relative candidate' $RelativePath};$scope=Get-CcodInstallTransaction $Generation 'CCOD_INSTALL_PRODUCT_SHORTCUT_INVALID';[void](Assert-CcodInstallProductAuthorityLease $scope.State);$authority=Get-CcodInstallProductReadyAuthority $scope.State.InstallRoot $ReadyTransaction;$stored=$scope.State.ReadyAuthority;if($null-eq$stored-or$authority.ReadyCanonical-cne$stored.ReadyCanonical-or$authority.PersistedReadyCanonical-cne$stored.PersistedReadyCanonical-or$authority.RuntimeId-cne$stored.RuntimeId-or$authority.Generation-ne$stored.Generation-or$authority.ManifestSha256-cne$stored.ManifestSha256-or$authority.PackageSha256-cne$stored.PackageSha256-or$authority.RuntimeId-cne$scope.Record.RuntimeId){Throw-CcodInstallFileError 'CCOD_INSTALL_PRODUCT_SCOPE' 'Retained shortcut source authority changed after capability open' $ReadyTransaction};$result=Convert-CcodInstallRuntimeError {Invoke-CcodRuntimeMethod $scope.State.Runtime OpenRetainedFile @($scope.Record.Token,$RelativePath)} 'CCOD_INSTALL_PRODUCT_SHORTCUT_INVALID';if(@($result).Count-ne3-or[int64]$result[1]-lt0-or[string]$result[2]-cnotmatch'^[0-9a-f]{64}$'){Throw-CcodInstallFileError 'CCOD_INSTALL_PRODUCT_SHORTCUT_INVALID' 'Retained product shortcut identity is invalid' $RelativePath};Add-CcodInstallScope $scope.Record.Transaction $result[0] RetainedFile $true $scope.Record.RuntimeId +} +function New-CcodInstallDirectory { param([Parameter(Mandatory)]$Transaction,[Parameter(Mandatory)]$Parent,[Parameter(Mandatory)][string]$Leaf,[switch]$CreateIfMissing) Assert-CcodInstallLeaf $Leaf;$transactionScope=Get-CcodInstallTransaction $Transaction 'CCOD_INSTALL_TRANSACTION_INVALID';if(-not[object]::ReferenceEquals($transactionScope.Record.Transaction,$Transaction)){Throw-CcodInstallFileError 'CCOD_INSTALL_TRANSACTION_INVALID' 'Transaction capability must be the transaction root' $null};if([object]::ReferenceEquals($Parent,$Transaction)){$token=Invoke-CcodRuntimeMethod $transactionScope.State.Runtime GetInstallRoot @();$parentScope=[pscustomobject]@{State=$transactionScope.State;Record=[pscustomobject]@{Transaction=$Transaction;Token=$token;Kind='Directory';ReadOnly=$false;RuntimeId=$null}}}else{$parentScope=Get-CcodInstallScopedDirectory $Transaction $Parent};Assert-CcodInstallWritableScope $parentScope;$token=Convert-CcodInstallRuntimeError {Invoke-CcodRuntimeMethod $parentScope.State.Runtime OpenDirectory @($parentScope.Record.Token,$Leaf,[bool]$CreateIfMissing)} 'CCOD_INSTALL_DIRECTORY_OPEN_FAILED';$readOnly=[bool](Invoke-CcodRuntimeMethod $parentScope.State.Runtime IsRetained @($token));Add-CcodInstallScope $Transaction $token Directory $readOnly $(if($readOnly){$parentScope.Record.RuntimeId}else{$null}) } +function New-CcodInstallGenerationLeaf { param([Parameter(Mandatory)]$Generation,[Parameter(Mandatory)][string]$Leaf) Assert-CcodInstallLeaf $Leaf;$scope=Get-CcodInstallTransaction $Generation;Assert-CcodInstallWritableScope $scope;if($scope.State.StateOnly){Throw-CcodInstallFileError 'CCOD_INSTALL_STATE_SCOPE' 'State-only transaction cannot create a generation leaf' $Leaf};$token=Convert-CcodInstallRuntimeError {Invoke-CcodRuntimeMethod $scope.State.Runtime CreateDirectory @($scope.Record.Token,$Leaf)} 'CCOD_INSTALL_LEAF_CREATE_FAILED';Add-CcodInstallScope $scope.Record.Transaction $token Directory $scope.Record.ReadOnly $scope.Record.RuntimeId } +function Copy-CcodInstallSealedSource { param([Parameter(Mandatory)]$Generation,[Parameter(Mandatory)][string]$SourcePath,[Parameter(Mandatory)][string]$Leaf,[Parameter(Mandatory)][int64]$ExpectedLength,[Parameter(Mandatory)][string]$ExpectedSha256) Assert-CcodInstallLeaf $Leaf;if(-not[IO.Path]::IsPathRooted($SourcePath)-or$ExpectedLength-lt 0-or$ExpectedSha256-cnotmatch'^[0-9a-f]{64}$'){Throw-CcodInstallFileError 'CCOD_INSTALL_SOURCE_INVALID' 'Invalid sealed source contract' $SourcePath};$scope=Get-CcodInstallTransaction $Generation;Assert-CcodInstallWritableScope $scope;$result=[pscustomobject]@{Length=$ExpectedLength;Sha256=$ExpectedSha256};Convert-CcodInstallRuntimeError {Invoke-CcodRuntimeMethod $scope.State.Runtime Copy @($scope.Record.Token,$Leaf,[IO.Path]::GetFullPath($SourcePath),$ExpectedLength,$ExpectedSha256)|Out-Null} 'CCOD_INSTALL_SOURCE_MISMATCH';return $result } +function Write-CcodInstallGenerationManifest { param([Parameter(Mandatory)]$Generation,[Parameter(Mandatory)]$Manifest) $scope=Get-CcodInstallTransaction $Generation;Assert-CcodInstallWritableScope $scope;if($scope.Record.Token-ne$scope.State.RootToken){Throw-CcodInstallFileError 'CCOD_INSTALL_MANIFEST_SCOPE' 'Manifest must be written at the generation root' $null};$bytes=ConvertTo-CcodInstallJsonBytes $Manifest;$result=[pscustomobject]@{Length=[int64]$bytes.LongLength;Sha256=(Get-CcodInstallBytesSha256 $bytes)};Convert-CcodInstallRuntimeError {Invoke-CcodRuntimeMethod $scope.State.Runtime Write @($scope.Record.Token,'manifest.json',$bytes,$true)|Out-Null} 'CCOD_INSTALL_MANIFEST_WRITE_FAILED';return $result } +function Write-CcodInstallRecord { param([Parameter(Mandatory)]$Transaction,[Parameter(Mandatory)]$Parent,[Parameter(Mandatory)][string]$Leaf,[Parameter(Mandatory)]$Record) Assert-CcodInstallLeaf $Leaf;$scope=Get-CcodInstallScopedDirectory $Transaction $Parent;Assert-CcodInstallWritableScope $scope;$bytes=ConvertTo-CcodInstallJsonBytes $Record;$result=[pscustomobject]@{Length=[int64]$bytes.LongLength;Sha256=(Get-CcodInstallBytesSha256 $bytes)};try{Convert-CcodInstallRuntimeError {Invoke-CcodRuntimeMethod $scope.State.Runtime Write @($scope.Record.Token,$Leaf,$bytes,$false)|Out-Null} 'CCOD_INSTALL_RECORD_WRITE_FAILED'}catch{if($_.FullyQualifiedErrorId-like'CCOD_INSTALL_LEAF_EXISTS*'){Throw-CcodInstallFileError 'CCOD_INSTALL_RECORD_EXISTS' 'Install record already exists' $Leaf};throw};return $result } +function Commit-CcodInstallActivePointer { param([Parameter(Mandatory)][string]$InstallRoot,[Parameter(Mandatory)]$TargetGeneration,[Parameter(Mandatory)][uint64]$ExpectedPreviousGeneration,[Parameter(Mandatory)]$FileTransaction) if($ExpectedPreviousGeneration-eq[uint64]::MaxValue){Throw-CcodInstallFileError 'CCOD_INSTALL_POINTER_GENERATION_OVERFLOW' 'Pointer generation cannot advance beyond UInt64.MaxValue' $ExpectedPreviousGeneration};$scope=Get-CcodInstallTransaction $FileTransaction 'CCOD_INSTALL_TRANSACTION_INVALID';Assert-CcodInstallRootScope $scope.State $InstallRoot 'CCOD_INSTALL_TRANSACTION_SCOPE';$target=Get-CcodInstallTransaction $TargetGeneration 'CCOD_INSTALL_POINTER_TARGET_INVALID';if(-not[object]::ReferenceEquals($target.Record.Transaction,$FileTransaction)-or$target.Record.Kind-cne'Generation'-or[string]::IsNullOrWhiteSpace([string]$target.Record.RuntimeId)){Throw-CcodInstallFileError 'CCOD_INSTALL_POINTER_TARGET_INVALID' 'Pointer target is outside the transaction scope' $null};$generation=[uint64]($ExpectedPreviousGeneration+1);$runtimeId=[string]$target.Record.RuntimeId;$pointer=[ordered]@{schemaVersion=1;generation=$generation;activeRuntime=$runtimeId;previousGeneration=$ExpectedPreviousGeneration};$bytes=ConvertTo-CcodInstallJsonBytes $pointer;$result=[pscustomobject]@{Generation=$generation;RuntimeId=$runtimeId};try{Convert-CcodInstallRuntimeError {Invoke-CcodRuntimeMethod $scope.State.Runtime CommitPointer @($target.Record.Token,$ExpectedPreviousGeneration,$runtimeId,$bytes)|Out-Null} 'CCOD_INSTALL_POINTER_COMMIT_FAILED'}catch{if($_.FullyQualifiedErrorId-like'CCOD_INSTALL_LEAF_EXISTS*'){Throw-CcodInstallFileError 'CCOD_INSTALL_POINTER_GENERATION_EXISTS' 'Pointer generation already exists' $generation};throw};return $result } +function Retire-CcodInstallGeneration { param([Parameter(Mandatory)][string]$InstallRoot,[Parameter(Mandatory)][string]$RuntimeId,[Parameter(Mandatory)]$FileTransaction) Assert-CcodInstallLeaf $RuntimeId 'CCOD_INSTALL_RUNTIME_ID_INVALID';$scope=Get-CcodInstallTransaction $FileTransaction 'CCOD_INSTALL_TRANSACTION_INVALID' -AllowRetired;Assert-CcodInstallRootScope $scope.State $InstallRoot 'CCOD_INSTALL_TRANSACTION_SCOPE';if($RuntimeId-cne$scope.State.RuntimeId-or$scope.Record.Token-ne$scope.State.RootToken){Throw-CcodInstallFileError 'CCOD_INSTALL_GENERATION_NOT_OWNED' 'Generation is not owned by this transaction' $RuntimeId};if($scope.State.Retired){return $scope.State.RetirementResult};$recordLeaf=$RuntimeId+'.json';$record=ConvertTo-CcodInstallJsonBytes ([ordered]@{schemaVersion=1;runtimeId=$RuntimeId;state='Retired'});$capability=Add-CcodInstallScope $scope.Record.Transaction $scope.State.RootToken;$result=[pscustomobject]@{Disposition='Retired';Capability=$capability};try{Convert-CcodInstallRuntimeError {Invoke-CcodRuntimeMethod $scope.State.Runtime Retire @($scope.Record.Token,$RuntimeId,$recordLeaf,$record)|Out-Null} 'CCOD_INSTALL_RETIREMENT_FAILED'}catch{if($_.FullyQualifiedErrorId-like'CCOD_INSTALL_LEAF_EXISTS*'){Throw-CcodInstallFileError 'CCOD_INSTALL_RETIREMENT_RECORD_EXISTS' 'Retirement record already exists' $recordLeaf};throw};$scope.State.RetirementResult=$result;$scope.State.Retired=$true;return $result } +function Open-CcodInstallProductSpecialFolder { + param([Parameter(Mandatory)]$Generation,[Parameter(Mandatory)][ValidateSet('StartMenu','Desktop')][string]$Kind) + $scope=Get-CcodInstallTransaction $Generation 'CCOD_INSTALL_TRANSACTION_INVALID' + if($scope.Record.Kind-cne'Generation'-or[string]::IsNullOrWhiteSpace([string]$scope.Record.RuntimeId)-or$scope.State.StateOnly){Throw-CcodInstallFileError 'CCOD_INSTALL_PRODUCT_FOLDER_INVALID' 'Product special folder requires a generation or retained-generation capability' $Kind} + $specialFolder=if($Kind-ceq'StartMenu'){[Environment+SpecialFolder]::Programs}else{[Environment+SpecialFolder]::Desktop} + $path=[Environment]::GetFolderPath($specialFolder) + if([string]::IsNullOrWhiteSpace($path)-or-not[IO.Path]::IsPathRooted($path)){Throw-CcodInstallFileError 'CCOD_INSTALL_PRODUCT_FOLDER_INVALID' 'Current-user special folder is unavailable' $Kind} + $token=Convert-CcodInstallRuntimeError {Invoke-CcodRuntimeMethod $scope.State.Runtime OpenProductFolder @($Kind,[IO.Path]::GetFullPath($path))} 'CCOD_INSTALL_PRODUCT_FOLDER_INVALID' + Add-CcodInstallScope $scope.Record.Transaction $token ProductFolder $false $scope.Record.RuntimeId +} +function Copy-CcodInstallProductShortcut { + param([Parameter(Mandatory)]$Folder,[Parameter(Mandatory)]$Source,[Parameter(Mandatory)][ValidateSet('StartMenu','Desktop')][string]$Kind,[Parameter(Mandatory)][string]$Leaf) + if($Leaf-cne'CodexRemote-fix.lnk'){Throw-CcodInstallFileError 'CCOD_INSTALL_PRODUCT_SHORTCUT_INVALID' 'Invalid fixed product shortcut contract' $Leaf} + $scope=Get-CcodInstallTransaction $Folder 'CCOD_INSTALL_PRODUCT_FOLDER_INVALID';$sourceScope=Get-CcodInstallTransaction $Source 'CCOD_INSTALL_PRODUCT_SHORTCUT_INVALID';if($scope.Record.Kind-cne'ProductFolder'-or$sourceScope.Record.Kind-cne'RetainedFile'-or-not[object]::ReferenceEquals($scope.Record.Transaction,$sourceScope.Record.Transaction)-or$scope.Record.RuntimeId-cne$sourceScope.Record.RuntimeId){Throw-CcodInstallFileError 'CCOD_INSTALL_PRODUCT_SHORTCUT_INVALID' 'Product shortcut source and destination are outside one selected generation scope' $Kind} + Convert-CcodInstallRuntimeError {Invoke-CcodRuntimeMethod $scope.State.Runtime CopyProductShortcut @($scope.Record.Token,$Kind,$sourceScope.Record.Token,$Leaf)} 'CCOD_INSTALL_PRODUCT_SHORTCUT_FAILED'|Out-Null + [pscustomobject]@{Kind=$Kind;Leaf=$Leaf} +} +function Invoke-CcodInstallFileTransactionLowerClose { + param([Parameter(Mandatory)]$State) + $defaultClose={param($Value)Invoke-CcodRuntimeMethod $Value.Runtime Close @()|Out-Null} + if($script:CcodInstallFileTransactionLowerCloseForTest-is[scriptblock]){&$script:CcodInstallFileTransactionLowerCloseForTest $State $defaultClose;return} + &$defaultClose $State +} +function Close-CcodInstallFileTransaction { + param([Parameter(Mandatory)]$Transaction,[Parameter(Mandatory)][ValidateSet('Ready','Failed')][string]$Disposition) + $state=$null;if($null-eq$Transaction-or-not$script:CcodTransactions.TryGetValue($Transaction,[ref]$state)){Throw-CcodInstallFileError 'CCOD_INSTALL_TRANSACTION_INVALID' 'Invalid install file transaction capability' $null};if($state.Closed-and-not$state.CleanupFailed){return} + if($state.ProductOnly-and$Disposition-ceq'Ready'){[void](Assert-CcodInstallProductCleanupFenceBound $state)} + $cleanupFailed=$false + try{Invoke-CcodInstallFileTransactionLowerClose -State $state}catch{$cleanupFailed=$true} + if($null-ne$state.PSObject.Properties['AuthorityLease']-and$null-ne$state.AuthorityLease){try{Exit-CcodInstallProductAuthorityLease $state.AuthorityLease;$state.AuthorityLease=$null}catch{$cleanupFailed=$true}} + $state.CleanupFailed=$cleanupFailed;$state.Closed=$true;$state.Disposition=$Disposition + if($cleanupFailed){Throw-CcodInstallFileError 'CCOD_INSTALL_CLOSE_FAILED' 'Install file transaction cleanup failed; registered failures are retained for retry' $null} +} + +Initialize-CcodInstallRuntime +Export-ModuleMember -Function Open-CcodInstallGeneration,Open-CcodInstallStateTransaction,Open-CcodInstallProductRegistrationTransaction,Open-CcodInstallRetainedGeneration,Open-CcodInstallRetainedFile,New-CcodInstallDirectory,New-CcodInstallGenerationLeaf,Copy-CcodInstallSealedSource,Write-CcodInstallGenerationManifest,Write-CcodInstallLegacyMigrationPlan,Read-CcodInstallLegacyMigrationPlan,Write-CcodInstallRecord,Commit-CcodInstallActivePointer,Retire-CcodInstallGeneration,Open-CcodInstallProductSpecialFolder,Copy-CcodInstallProductShortcut,Close-CcodInstallFileTransaction diff --git a/src/persistence/modules/InstallLifecycle.psm1 b/src/persistence/modules/InstallLifecycle.psm1 index ffc7656..1912df1 100644 --- a/src/persistence/modules/InstallLifecycle.psm1 +++ b/src/persistence/modules/InstallLifecycle.psm1 @@ -11,9 +11,18 @@ Import-Module (Join-Path $PSScriptRoot 'UiPreferences.psm1') -Force Import-Module (Join-Path $PSScriptRoot 'LifecycleTransaction.psm1') -Force $script:CcodLifecycleTaskName = 'Codex Control Other Devices Supervisor' +$script:CcodProductVersion = '2.5.22' $script:CcodLifecycleDefaultInstallRoot = Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)) 'CodexControlOtherDevices' $script:CcodActivationReceiptFields = @('schemaVersion','activationId','phase','runtimeId','previousRuntimeId','startedAtUtc','updatedAtUtc','ready','errorCode') $script:CcodActivationPhases = @('StoppingPreviousRuntime','InstallingRuntime','ActivatingRuntime','StartingProtection','Ready','Failed') +$script:CcodInstallTransactionPhases = @('Prepared','PackageVerified','RuntimeStaged','PreviousProtectionStopped','RuntimePromoted','PointerCommitted','StableShellCommitted','ProtectionReady','Ready') +$script:CcodInstallTransactionFields = @('schemaVersion','transactionId','oldRuntimeId','oldGeneration','oldManifestSha256','newRuntimeId','newGeneration','newManifestSha256','sealedPackageSha256','ownedObjectNames','phase','errorCode') +$script:CcodInstallTransactionRoots = [Runtime.CompilerServices.ConditionalWeakTable[object,object]]::new() +$script:CcodProductCleanupFenceFields = @('schemaVersion','transactionId','runtimeId','runtimeGeneration','manifestSha256','packageSha256','ownerPid','ownerCreationTimeUtc','ownerSid','state') +$script:CcodPendingProductTransactionCleanups = [Collections.Hashtable]::Synchronized(@{}) +$script:CcodProductCleanupLeaseContexts = [Collections.Hashtable]::Synchronized(@{}) +# Exact pre-append-only profile from merge-base 19803a79af3302c49f129e6a6b3ff7ffaf1b930b. +$script:CcodLegacyUpgradeProjectVersions = @('2.5.21') function Throw-CcodLifecycleError { param( @@ -35,6 +44,616 @@ function Get-CcodLifecycleErrorId { return ([string]$ErrorRecord.FullyQualifiedErrorId -split ',')[0] } +function Get-CcodLifecycleProductCleanupKey { + param([Parameter(Mandatory)][string]$InstallRoot) + return Get-CcodLifecycleCanonicalRoot -Path $InstallRoot -Kind 'Install root' +} + +function Get-CcodLifecycleInstallFileTransactionModule { + $path = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot 'InstallFileTransaction.psm1')) + $command = Get-Command Close-CcodInstallFileTransaction -ErrorAction SilentlyContinue + $module = if ($null -ne $command -and $null -ne $command.Module -and $null -ne $command.Module.Path -and [IO.Path]::GetFullPath($command.Module.Path) -ceq $path) { $command.Module } else { $null } + if ($null -eq $module) { $module = Import-Module $path -PassThru -DisableNameChecking -ErrorAction Stop } + if ($null -eq $module) { Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Install file transaction module is unavailable' $path } + return $module +} + +function Test-CcodLifecycleProductCleanupInteger { + param($Value) + return $Value -is [byte] -or $Value -is [uint16] -or $Value -is [uint32] -or $Value -is [uint64] -or + $Value -is [int16] -or $Value -is [int32] -or $Value -is [int64] +} + +function Test-CcodLifecycleOrderedProperties { + param($Value,[string[]]$Expected) + if ($null -eq $Value -or ($Value -isnot [pscustomobject] -and $Value -isnot [Collections.IDictionary])) { return $false } + $actual = if ($Value -is [Collections.IDictionary]) { @($Value.Keys) } else { @($Value.PSObject.Properties.Name) } + return ($actual -join "`0") -ceq ($Expected -join "`0") +} + +function Assert-CcodLifecycleProductCleanupIdentity { + param([Parameter(Mandatory)]$Identity) + if (-not (Test-CcodLifecycleOrderedProperties $Identity @('pid','creationTimeUtc','userSid')) -or + $Identity.pid -isnot [int] -or $Identity.pid -le 0 -or + $Identity.creationTimeUtc -isnot [string] -or $Identity.creationTimeUtc -cnotmatch 'Z$' -or + $Identity.userSid -isnot [string] -or $Identity.userSid -cnotmatch '^S-\d-\d+(?:-\d+)+$') { + Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Product cleanup owner identity is invalid' $Identity + } + $parsed = [DateTime]::MinValue + if (-not [DateTime]::TryParseExact($Identity.creationTimeUtc,'o',[Globalization.CultureInfo]::InvariantCulture,[Globalization.DateTimeStyles]::RoundtripKind,[ref]$parsed) -or + $parsed.Kind -cne [DateTimeKind]::Utc -or + $parsed.ToUniversalTime().ToString('o',[Globalization.CultureInfo]::InvariantCulture) -cne $Identity.creationTimeUtc) { + Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Product cleanup owner creation time is invalid' $Identity + } + return $true +} + +function Get-CcodLifecycleCurrentProductCleanupIdentity { + $process = $null + $identity = $null + try { + $process = [Diagnostics.Process]::GetCurrentProcess() + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + if ($null -eq $identity -or $null -eq $identity.User) { throw 'current user SID unavailable' } + $result = [pscustomobject][ordered]@{ + pid = [int]$process.Id + creationTimeUtc = $process.StartTime.ToUniversalTime().ToString('o',[Globalization.CultureInfo]::InvariantCulture) + userSid = [string]$identity.User.Value + } + [void](Assert-CcodLifecycleProductCleanupIdentity $result) + return $result + } catch { + Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Current product cleanup identity could not be proven' $null + } finally { + if ($null -ne $process) { $process.Dispose() } + if ($null -ne $identity) { $identity.Dispose() } + } +} + +function Enter-CcodLifecycleProductCleanupLease { + $owner = Get-CcodLifecycleCurrentProductCleanupIdentity + $lease = $null + try { + $lease = Enter-CcodMutex -Kind AccountTransition -UserSid $owner.userSid -TimeoutMilliseconds 30000 + if ($null -eq $lease -or $lease.Outcome -cne 'Acquired' -or $lease.Kind -cne 'AccountTransition' -or + $lease.Released -isnot [bool] -or $lease.Released -or + $lease.OwnerManagedThreadId -isnot [int] -or $lease.OwnerManagedThreadId -ne [Threading.Thread]::CurrentThread.ManagedThreadId) { + throw 'outer product cleanup lease was not acquired exactly' + } + $threadKey = [string][Threading.Thread]::CurrentThread.ManagedThreadId + if ($script:CcodProductCleanupLeaseContexts.ContainsKey($threadKey)) { throw 'outer product cleanup lease context is already active' } + $context = [pscustomobject]@{ Lease=$lease; OwnerIdentity=$owner; ThreadKey=$threadKey } + $script:CcodProductCleanupLeaseContexts[$threadKey] = $context + return $context + } catch { + if ($null -ne $lease -and $lease.Outcome -ceq 'Acquired' -and -not $lease.Released) { try { Exit-CcodMutex -Lease $lease | Out-Null } catch { } } + Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Outer product cleanup lease could not be acquired' $owner.userSid + } +} + +function Assert-CcodLifecycleProductCleanupLease { + $threadKey = [string][Threading.Thread]::CurrentThread.ManagedThreadId + if (-not $script:CcodProductCleanupLeaseContexts.ContainsKey($threadKey)) { + Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Durable product cleanup requires the outer account-transition lease' $null + } + $context = $script:CcodProductCleanupLeaseContexts[$threadKey] + if ($null -eq $context -or $null -eq $context.Lease -or $context.Lease.Kind -cne 'AccountTransition' -or + $context.Lease.Outcome -cne 'Acquired' -or $context.Lease.Released -or + $context.Lease.OwnerManagedThreadId -ne [Threading.Thread]::CurrentThread.ManagedThreadId) { + Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Outer product cleanup lease is not live on the current thread' $null + } + return $context +} + +function Exit-CcodLifecycleProductCleanupLease { + param([Parameter(Mandatory)]$Context) + $current = Assert-CcodLifecycleProductCleanupLease + if (-not [object]::ReferenceEquals($current,$Context)) { + Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Outer product cleanup lease context changed' $null + } + try { + $released = Exit-CcodMutex -Lease $Context.Lease + if ($released -isnot [bool] -or -not $released -or -not $Context.Lease.Released) { throw 'outer lease release was not proven' } + [void]$script:CcodProductCleanupLeaseContexts.Remove($Context.ThreadKey) + return $true + } catch { + Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Outer product cleanup lease could not be released exactly' $null + } +} + +function Assert-CcodLifecycleProductCleanupReadyAuthority { + param([Parameter(Mandatory)][string]$InstallRoot,[Parameter(Mandatory)]$ReadyTransaction) + [void](Assert-CcodLifecycleProductCleanupLease) + try { + [void](Assert-CcodInstallTransactionRecord -Record $ReadyTransaction) + if ($ReadyTransaction.phase -cne 'Ready') { throw 'terminal Ready transaction required' } + $fileModule = Get-CcodLifecycleInstallFileTransactionModule + return &$fileModule {param($Root,$Record)Get-CcodInstallProductReadyAuthority -InstallRoot $Root -ReadyTransaction $Record} $InstallRoot $ReadyTransaction + } catch { + if ((Get-CcodLifecycleErrorId $_) -ceq 'CCOD_PRODUCT_REGISTRATION_FAILED') { throw } + Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Exact selected Ready authority could not be proven for product cleanup' $ReadyTransaction + } +} + +function Assert-CcodLifecycleProductCleanupRecordShape { + param([Parameter(Mandatory)]$Record,[Parameter(Mandatory)][ValidateSet('Pending','Completed')][string]$ExpectedState) + $validInteger = Test-CcodLifecycleProductCleanupInteger $Record.runtimeGeneration + if (-not (Test-CcodLifecycleOrderedProperties $Record $script:CcodProductCleanupFenceFields) -or + $Record.schemaVersion -isnot [int] -or $Record.schemaVersion -ne 1 -or + -not (Test-CcodLifecycleCanonicalGuid $Record.transactionId) -or + $Record.runtimeId -isnot [string] -or $Record.runtimeId -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$' -or + -not $validInteger -or [uint64]$Record.runtimeGeneration -eq 0 -or + $Record.manifestSha256 -isnot [string] -or $Record.manifestSha256 -cnotmatch '^[0-9a-f]{64}$' -or + $Record.packageSha256 -isnot [string] -or $Record.packageSha256 -cnotmatch '^[0-9a-f]{64}$' -or + $Record.ownerPid -isnot [int] -or $Record.ownerPid -le 0 -or + $Record.ownerCreationTimeUtc -isnot [string] -or + $Record.ownerSid -isnot [string] -or + $Record.state -isnot [string] -or $Record.state -cne $ExpectedState) { + Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Durable product cleanup record is invalid' $Record + } + [void](Assert-CcodLifecycleProductCleanupIdentity ([pscustomobject][ordered]@{pid=[int]$Record.ownerPid;creationTimeUtc=[string]$Record.ownerCreationTimeUtc;userSid=[string]$Record.ownerSid})) + return $true +} + +function Assert-CcodLifecycleProductCleanupRecord { + param([Parameter(Mandatory)]$Record,[Parameter(Mandatory)]$ReadyTransaction,[Parameter(Mandatory)][ValidateSet('Pending','Completed')][string]$ExpectedState) + [void](Assert-CcodLifecycleProductCleanupRecordShape -Record $Record -ExpectedState $ExpectedState) + [void](Assert-CcodInstallTransactionRecord -Record $ReadyTransaction) + if ($ReadyTransaction.phase -cne 'Ready' -or + $Record.transactionId -cne $ReadyTransaction.transactionId -or + $Record.runtimeId -cne $ReadyTransaction.newRuntimeId -or + [uint64]$Record.runtimeGeneration -ne [uint64]$ReadyTransaction.newGeneration -or + $Record.manifestSha256 -cne $ReadyTransaction.newManifestSha256 -or + $Record.packageSha256 -cne $ReadyTransaction.sealedPackageSha256) { + Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Durable product cleanup record does not match the exact Ready transaction' $Record + } + return $true +} + +function Get-CcodLifecycleProductCleanupFenceLeaf { + param([Parameter(Mandatory)][uint64]$Attempt,[Parameter(Mandatory)][ValidateSet('Pending','Completed')][string]$State,[Parameter(Mandatory)][string]$TransactionId) + return ('{0:D20}.{1}.{2}.json' -f $Attempt,$State,$TransactionId) +} + +function Initialize-CcodLifecycleProductCleanupOrphanRuntime { + $marker = 'CcodProductCleanupOrphanCapabilityMarkerV1' -as [type] + if ($null -eq $marker) { + Add-Type -TypeDefinition @' +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Win32.SafeHandles; + +public sealed class CcodProductCleanupOrphanCapabilityMarkerV1 +{ + private CcodProductCleanupOrphanCapabilityMarkerV1() { } + public static int CapabilityAbi { get { return 1; } } +} + +internal static class CcodProductCleanupOrphanRuntimeV1 +{ + private const uint DELETE = 0x00010000, SYNCHRONIZE = 0x00100000; + private const uint READ_ATTRIBUTES = 0x80, WRITE_ATTRIBUTES = 0x100, LIST_DIRECTORY = 0x1; + private const uint SHARE_READ = 1, SHARE_WRITE = 2; + private const uint OPEN = 1, DIRECTORY = 1, SYNC_IO = 0x20, NON_DIRECTORY = 0x40, BACKUP_INTENT = 0x4000, OPEN_REPARSE = 0x200000; + private const uint OPEN_EXISTING = 3, FLAG_BACKUP = 0x02000000, FLAG_REPARSE = 0x00200000; + private const uint OBJ_CASE_INSENSITIVE = 0x40, ATTR_READONLY = 0x1, ATTR_DIRECTORY = 0x10, ATTR_REPARSE = 0x400, ATTR_NORMAL = 0x80; + private const int FileBasicInformation = 4, FileStreamInfo = 7, FileDirectoryInformation = 1, FileDispositionInformation = 13; + private const int STATUS_NO_MORE_FILES = unchecked((int)0x80000006); + + [StructLayout(LayoutKind.Sequential)] private struct UNICODE_STRING { public ushort Length, MaximumLength; public IntPtr Buffer; } + [StructLayout(LayoutKind.Sequential)] private struct OBJECT_ATTRIBUTES { public int Length; public IntPtr RootDirectory, ObjectName; public uint Attributes; public IntPtr SecurityDescriptor, SecurityQualityOfService; } + [StructLayout(LayoutKind.Sequential)] private struct IO_STATUS_BLOCK { public IntPtr Status; public UIntPtr Information; } + [StructLayout(LayoutKind.Sequential)] private struct FILETIME_NATIVE { public uint Low, High; } + [StructLayout(LayoutKind.Sequential)] private struct FILE_INFO + { + public uint FileAttributes; public FILETIME_NATIVE CreationTime, LastAccessTime, LastWriteTime; + public uint VolumeSerialNumber, FileSizeHigh, FileSizeLow, NumberOfLinks, FileIndexHigh, FileIndexLow; + } + + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] private static extern SafeFileHandle CreateFileW(string path,uint access,uint share,IntPtr security,uint creation,uint flags,IntPtr template); + [DllImport("kernel32.dll", SetLastError=true)] private static extern bool GetFileInformationByHandle(SafeFileHandle handle,out FILE_INFO info); + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] private static extern uint GetFinalPathNameByHandleW(SafeFileHandle handle,StringBuilder buffer,uint length,uint flags); + [DllImport("kernel32.dll", SetLastError=true)] private static extern bool GetFileInformationByHandleEx(SafeFileHandle handle,int infoClass,IntPtr info,uint size); + [DllImport("ntdll.dll")] private static extern int NtCreateFile(out IntPtr handle,uint access,ref OBJECT_ATTRIBUTES attributes,out IO_STATUS_BLOCK io,IntPtr allocationSize,uint fileAttributes,uint share,uint disposition,uint options,IntPtr ea,uint eaLength); + [DllImport("ntdll.dll")] private static extern int NtQueryDirectoryFile(SafeFileHandle handle,IntPtr evt,IntPtr apc,IntPtr context,out IO_STATUS_BLOCK io,IntPtr info,uint length,int infoClass,bool single,IntPtr name,bool restart); + [DllImport("ntdll.dll")] private static extern int NtSetInformationFile(SafeFileHandle handle,out IO_STATUS_BLOCK io,IntPtr info,uint length,int infoClass); + [DllImport("ntdll.dll")] private static extern uint RtlNtStatusToDosError(int status); + + internal static bool Remove(string installRoot,string leaf) + { + if(!IsExactTemporaryLeaf(leaf))throw new InvalidDataException("temporary leaf name"); + string rootPath=Path.GetFullPath(installRoot).TrimEnd('\\'); + SafeFileHandle root=null,state=null,fences=null,file=null; + try + { + root=OpenAbsoluteDirectory(rootPath);ValidateDirectory(root,rootPath); + state=OpenRelative(root,"state",LIST_DIRECTORY|READ_ATTRIBUTES|SYNCHRONIZE,SHARE_READ|SHARE_WRITE,OPEN,DIRECTORY|BACKUP_INTENT); + string statePath=Path.Combine(rootPath,"state");ValidateDirectory(state,statePath); + fences=OpenRelative(state,"product-cleanup-fences",LIST_DIRECTORY|READ_ATTRIBUTES|SYNCHRONIZE,SHARE_READ|SHARE_WRITE,OPEN,DIRECTORY|BACKUP_INTENT); + string fencesPath=Path.Combine(statePath,"product-cleanup-fences");ValidateDirectory(fences,fencesPath); + file=OpenRelative(fences,leaf,DELETE|READ_ATTRIBUTES|WRITE_ATTRIBUTES|SYNCHRONIZE,0,OPEN,NON_DIRECTORY); + string filePath=Path.Combine(fencesPath,leaf);FILE_INFO before=Info(file);ValidatePlain(before,file);if(!SamePath(FinalPath(file),filePath))throw new InvalidDataException("temporary path changed"); + if((before.FileAttributes&ATTR_READONLY)!=0) + { + uint attributes=before.FileAttributes&~ATTR_READONLY;if(attributes==0)attributes=ATTR_NORMAL; + SetAttributes(file,attributes); + } + FILE_INFO prepared=Info(file);ValidatePlain(prepared,file);if(!SameIdentity(before,prepared)||!SamePath(FinalPath(file),filePath))throw new InvalidDataException("temporary identity changed"); + SetDelete(file);FILE_INFO armed=Info(file);ValidateDeleteArmed(armed,file);if(!SameIdentity(before,armed)||!SamePath(FinalPath(file),filePath))throw new InvalidDataException("temporary identity changed after delete disposition"); + file.Dispose();file=null; + foreach(string entry in Enumerate(fences))if(String.Equals(entry,leaf,StringComparison.OrdinalIgnoreCase))throw new IOException("temporary leaf remained after native delete"); + return true; + } + finally + { + if(file!=null)file.Dispose();if(fences!=null)fences.Dispose();if(state!=null)state.Dispose();if(root!=null)root.Dispose(); + } + } + + private static bool IsExactTemporaryLeaf(string leaf) + { + if(leaf==null||leaf.Length!=42||!leaf.StartsWith(".ccod.",StringComparison.Ordinal)||!leaf.EndsWith(".tmp",StringComparison.Ordinal))return false; + for(int index=6;index<38;index++){char value=leaf[index];if(!((value>='0'&&value<='9')||(value>='a'&&value<='f')))return false;}return true; + } + private static SafeFileHandle OpenAbsoluteDirectory(string path){SafeFileHandle handle=CreateFileW(path,LIST_DIRECTORY|READ_ATTRIBUTES|SYNCHRONIZE,SHARE_READ|SHARE_WRITE,IntPtr.Zero,OPEN_EXISTING,FLAG_BACKUP|FLAG_REPARSE,IntPtr.Zero);if(handle.IsInvalid){int error=Marshal.GetLastWin32Error();handle.Dispose();throw new Win32Exception(error);}return handle;} + private static SafeFileHandle OpenRelative(SafeFileHandle parent,string name,uint access,uint share,uint disposition,uint options) + { + IntPtr nameBuffer=IntPtr.Zero,unicodePointer=IntPtr.Zero;bool added=false; + try + { + parent.DangerousAddRef(ref added);nameBuffer=Marshal.StringToHGlobalUni(name); + UNICODE_STRING unicode=new UNICODE_STRING{Length=(ushort)(name.Length*2),MaximumLength=(ushort)((name.Length+1)*2),Buffer=nameBuffer}; + unicodePointer=Marshal.AllocHGlobal(Marshal.SizeOf(typeof(UNICODE_STRING)));Marshal.StructureToPtr(unicode,unicodePointer,false); + OBJECT_ATTRIBUTES attributes=new OBJECT_ATTRIBUTES{Length=Marshal.SizeOf(typeof(OBJECT_ATTRIBUTES)),RootDirectory=parent.DangerousGetHandle(),ObjectName=unicodePointer,Attributes=OBJ_CASE_INSENSITIVE}; + IO_STATUS_BLOCK io;IntPtr raw;int status=NtCreateFile(out raw,access,ref attributes,out io,IntPtr.Zero,0,share,disposition,options|SYNC_IO|OPEN_REPARSE,IntPtr.Zero,0); + if(status<0)throw new Win32Exception((int)RtlNtStatusToDosError(status));return new SafeFileHandle(raw,true); + } + finally{if(unicodePointer!=IntPtr.Zero)Marshal.FreeHGlobal(unicodePointer);if(nameBuffer!=IntPtr.Zero)Marshal.FreeHGlobal(nameBuffer);if(added)parent.DangerousRelease();} + } + private static void ValidateDirectory(SafeFileHandle handle,string path){FILE_INFO info=Info(handle);if(!IsDirectory(info)||(info.FileAttributes&ATTR_REPARSE)!=0||!OnlyDefaultStream(handle)||!SamePath(FinalPath(handle),path))throw new InvalidDataException("directory identity");} + private static void ValidatePlain(FILE_INFO info,SafeFileHandle handle){bool streams=OnlyDefaultStream(handle);if(IsDirectory(info)||(info.FileAttributes&ATTR_REPARSE)!=0||info.NumberOfLinks!=1||!streams)throw new InvalidDataException("temporary leaf identity attributes="+info.FileAttributes+" links="+info.NumberOfLinks+" streams="+streams);} + private static void ValidateDeleteArmed(FILE_INFO info,SafeFileHandle handle){if(IsDirectory(info)||(info.FileAttributes&ATTR_REPARSE)!=0||info.NumberOfLinks>1||!OnlyDefaultStream(handle))throw new InvalidDataException("delete-armed temporary leaf identity");} + private static bool SameIdentity(FILE_INFO first,FILE_INFO second){return first.VolumeSerialNumber==second.VolumeSerialNumber&&first.FileIndexHigh==second.FileIndexHigh&&first.FileIndexLow==second.FileIndexLow;} + private static bool IsDirectory(FILE_INFO info){return(info.FileAttributes&ATTR_DIRECTORY)!=0;} + private static FILE_INFO Info(SafeFileHandle handle){FILE_INFO info;if(handle==null||handle.IsClosed||!GetFileInformationByHandle(handle,out info))throw new Win32Exception(Marshal.GetLastWin32Error());return info;} + private static void SetAttributes(SafeFileHandle handle,uint attributes){IntPtr buffer=Marshal.AllocHGlobal(40);try{for(int index=0;index<40;index++)Marshal.WriteByte(buffer,index,0);Marshal.WriteInt32(buffer,32,unchecked((int)attributes));IO_STATUS_BLOCK io;int status=NtSetInformationFile(handle,out io,buffer,40,FileBasicInformation);if(status<0)throw new Win32Exception((int)RtlNtStatusToDosError(status));}finally{Marshal.FreeHGlobal(buffer);}} + private static void SetDelete(SafeFileHandle handle){IntPtr buffer=Marshal.AllocHGlobal(1);try{Marshal.WriteByte(buffer,0,1);IO_STATUS_BLOCK io;int status=NtSetInformationFile(handle,out io,buffer,1,FileDispositionInformation);if(status<0)throw new Win32Exception((int)RtlNtStatusToDosError(status));}finally{Marshal.FreeHGlobal(buffer);}} + private static string FinalPath(SafeFileHandle handle){StringBuilder buffer=new StringBuilder(512);uint length=GetFinalPathNameByHandleW(handle,buffer,(uint)buffer.Capacity,0);if(length==0)throw new Win32Exception(Marshal.GetLastWin32Error());if(length>=buffer.Capacity){buffer.Capacity=(int)length+1;length=GetFinalPathNameByHandleW(handle,buffer,(uint)buffer.Capacity,0);if(length==0||length>=buffer.Capacity)throw new Win32Exception(Marshal.GetLastWin32Error());}string path=buffer.ToString();if(path.StartsWith(@"\\?\UNC\",StringComparison.OrdinalIgnoreCase))return@"\\"+path.Substring(8);if(path.StartsWith(@"\\?\",StringComparison.OrdinalIgnoreCase))return path.Substring(4);return path;} + private static bool SamePath(string first,string second){return String.Equals(Path.GetFullPath(first).TrimEnd('\\'),Path.GetFullPath(second).TrimEnd('\\'),StringComparison.OrdinalIgnoreCase);} + private static bool OnlyDefaultStream(SafeFileHandle handle){IntPtr buffer=Marshal.AllocHGlobal(65536);try{if(!GetFileInformationByHandleEx(handle,FileStreamInfo,buffer,65536)){int error=Marshal.GetLastWin32Error();if(error==38)return true;throw new Win32Exception(error);}int offset=0;while(true){uint next=(uint)Marshal.ReadInt32(buffer,offset),nameLength=(uint)Marshal.ReadInt32(buffer,offset+4);string name=Marshal.PtrToStringUni(IntPtr.Add(buffer,offset+24),(int)nameLength/2);if(!String.Equals(name,"::$DATA",StringComparison.OrdinalIgnoreCase)&&!String.Equals(name,"::$INDEX_ALLOCATION",StringComparison.OrdinalIgnoreCase))return false;if(next==0)break;offset+=(int)next;}return true;}finally{Marshal.FreeHGlobal(buffer);}} + private static string[] Enumerate(SafeFileHandle handle){List result=new List();IntPtr buffer=Marshal.AllocHGlobal(65536);bool restart=true;try{while(true){IO_STATUS_BLOCK io;int status=NtQueryDirectoryFile(handle,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,out io,buffer,65536,FileDirectoryInformation,false,IntPtr.Zero,restart);restart=false;if(status==STATUS_NO_MORE_FILES)break;if(status<0)throw new Win32Exception((int)RtlNtStatusToDosError(status));int offset=0;while(true){uint next=(uint)Marshal.ReadInt32(buffer,offset),nameLength=(uint)Marshal.ReadInt32(buffer,offset+60);string name=Marshal.PtrToStringUni(IntPtr.Add(buffer,offset+64),(int)nameLength/2);if(name!="."&&name!="..")result.Add(name);if(next==0)break;offset+=(int)next;}}return result.ToArray();}finally{Marshal.FreeHGlobal(buffer);}} +} +'@ + $marker = 'CcodProductCleanupOrphanCapabilityMarkerV1' -as [type] + } + if ($null -eq $marker -or [int]$marker.GetProperty('CapabilityAbi').GetValue($null,$null) -ne 1) { + Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Product cleanup orphan runtime ABI is unavailable' $null + } + $script:CcodProductCleanupOrphanRuntimeType = $marker.Assembly.GetType('CcodProductCleanupOrphanRuntimeV1',$true) +} + +function Remove-CcodLifecycleProductCleanupOrphanTemporary { + param([Parameter(Mandatory)][string]$InstallRoot,[Parameter(Mandatory)][string]$Leaf) + [void](Assert-CcodLifecycleProductCleanupLease) + $root = Get-CcodLifecycleCanonicalRoot -Path $InstallRoot -Kind 'Install root' + if ($Leaf -cnotmatch '^\.ccod\.[0-9a-f]{32}\.tmp$') { + Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Product cleanup orphan temporary name is not exact' $Leaf + } + try { + Initialize-CcodLifecycleProductCleanupOrphanRuntime + $method = $script:CcodProductCleanupOrphanRuntimeType.GetMethod('Remove',[Reflection.BindingFlags]'NonPublic,Static') + $arguments = [object[]]@([string]$root,[string]$Leaf) + if ($null -eq $method -or -not [bool]$method.Invoke($null,$arguments)) { throw 'native orphan cleanup did not prove deletion' } + [void](Assert-CcodLifecycleProductCleanupLease) + } catch { + $exception = $_.Exception + while ($exception -is [Reflection.TargetInvocationException] -and $null -ne $exception.InnerException) { $exception = $exception.InnerException } + Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Exact product cleanup orphan temporary could not be removed safely' $Leaf + } + return $true +} + +function Read-CcodLifecycleProductCleanupFenceHistory { + param([Parameter(Mandatory)][string]$InstallRoot,[Parameter(Mandatory)]$ReadyTransaction) + [void](Assert-CcodLifecycleProductCleanupLease) + $root = Get-CcodLifecycleCanonicalRoot -Path $InstallRoot -Kind 'Install root' + $directory = Join-Path $root 'state\product-cleanup-fences' + if (-not [IO.Directory]::Exists($directory)) { + if (Test-Path -LiteralPath $directory) { Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Product cleanup fence plane is not a directory' $directory } + return @() + } + try { [void](Assert-CcodLifecycleInstallTreeSafe -InstallRoot $root -Path $directory) } + catch { Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Product cleanup fence plane is unsafe' $directory } + $files = @(Get-ChildItem -LiteralPath $directory -Force -ErrorAction Stop) + if ($files.Count -gt 4096) { Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Product cleanup fence plane exceeds its bounded history' $directory } + $orphanLeaves = [Collections.Generic.List[string]]::new() + foreach ($file in $files) { + if (-not $file.PSIsContainer -and $file.Name -cmatch '^\.ccod\.[0-9a-f]{32}\.tmp$') { $orphanLeaves.Add($file.Name); continue } + if ($file.PSIsContainer -or $file.Name -cnotmatch '^\d{20}\.(?:Pending|Completed)\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.json$') { + Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Product cleanup fence plane contains an unknown object' $file.FullName + } + } + foreach ($leaf in $orphanLeaves) { [void](Remove-CcodLifecycleProductCleanupOrphanTemporary -InstallRoot $root -Leaf $leaf) } + if ($orphanLeaves.Count -gt 0) { + $files = @(Get-ChildItem -LiteralPath $directory -Force -ErrorAction Stop) + if ($files.Count -gt 4096) { Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Product cleanup fence plane exceeds its bounded history' $directory } + } + $entries = [Collections.Generic.List[object]]::new() + foreach ($file in $files) { + if ($file.PSIsContainer -or $file.Name -cnotmatch '^(?\d{20})\.(?Pending|Completed)\.(?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.json$') { + Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Product cleanup fence plane contains an unknown object' $file.FullName + } + $attemptText = [string]$Matches.attempt + $state = [string]$Matches.state + $transactionId = [string]$Matches.transaction + [uint64]$attempt = 0 + if (-not [uint64]::TryParse($attemptText,[ref]$attempt) -or $attempt -eq 0 -or ('{0:D20}' -f $attempt) -cne $attemptText) { + Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Product cleanup fence attempt is invalid' $file.FullName + } + try { $record = Read-CcodStrictJson -Path $file.FullName -ExpectedSchema 1 -Kind 'product cleanup fence' -MaxBytes 16384 } + catch { Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Product cleanup fence is malformed' $file.FullName } + [void](Assert-CcodLifecycleProductCleanupRecordShape -Record $record -ExpectedState $state) + if ($transactionId -cne $record.transactionId -or $file.Name -cne (Get-CcodLifecycleProductCleanupFenceLeaf -Attempt $attempt -State $state -TransactionId $record.transactionId)) { + Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Product cleanup fence name is noncanonical' $file.FullName + } + $entries.Add([pscustomobject]@{Attempt=$attempt;State=$state;Record=$record;Leaf=$file.Name}) + } + foreach ($transactionGroup in @($entries | Group-Object {$_.Record.transactionId})) { + $transactionEntries = @($transactionGroup.Group) + $identity = $transactionEntries[0].Record + foreach ($entry in $transactionEntries) { + foreach ($field in @('transactionId','runtimeId','runtimeGeneration','manifestSha256','packageSha256')) { + if ([string]$entry.Record.$field -cne [string]$identity.$field) { + Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Product cleanup transaction history changed immutable Ready identity' $entry.Leaf + } + } + } + $attemptGroups = @($transactionEntries | Group-Object Attempt | Sort-Object {[uint64]$_.Name}) + [uint64]$expectedAttempt = 1 + for ($index=0; $index -lt $attemptGroups.Count; $index++) { + $group = $attemptGroups[$index] + if ([uint64]$group.Name -ne $expectedAttempt) { Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Product cleanup transaction history has a sequence gap' $directory } + $pending = @($group.Group | Where-Object {$_.State -ceq 'Pending'}) + $completed = @($group.Group | Where-Object {$_.State -ceq 'Completed'}) + if ($pending.Count -ne 1 -or $completed.Count -gt 1 -or ($index -lt $attemptGroups.Count-1 -and $completed.Count -ne 1)) { + Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Product cleanup transaction history has an invalid state transition' $directory + } + if ($completed.Count -eq 1) { + foreach ($field in @('transactionId','runtimeId','runtimeGeneration','manifestSha256','packageSha256','ownerPid','ownerCreationTimeUtc','ownerSid')) { + if ([string]$pending[0].Record.$field -cne [string]$completed[0].Record.$field) { + Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Product cleanup completion changed immutable fence identity' $completed[0].Leaf + } + } + } + $expectedAttempt++ + } + } + foreach ($entry in @($entries | Where-Object {$_.Record.transactionId -ceq $ReadyTransaction.transactionId})) { + [void](Assert-CcodLifecycleProductCleanupRecord -Record $entry.Record -ReadyTransaction $ReadyTransaction -ExpectedState $entry.State) + } + return @($entries | Sort-Object @{Expression={$_.Record.transactionId}},@{Expression={[uint64]$_.Attempt}},@{Expression={if($_.State-ceq'Pending'){0}else{1}}}) +} + +function Assert-CcodLifecycleNoForeignProductCleanupPending { + param([Parameter(Mandatory)][AllowEmptyCollection()][object[]]$History,[Parameter(Mandatory)][string]$TransactionId) + foreach ($group in @($History | Where-Object {$_.Record.transactionId -cne $TransactionId} | Group-Object {$_.Record.transactionId})) { + $ordered = @($group.Group | Sort-Object @{Expression={[uint64]$_.Attempt}},@{Expression={if($_.State-ceq'Pending'){0}else{1}}}) + if ($ordered.Count -gt 0 -and $ordered[-1].State -ceq 'Pending') { + Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'A different product cleanup transaction remains Pending' $ordered[-1].Leaf + } + } + return $true +} + +function Write-CcodLifecycleProductCleanupRecord { + param([Parameter(Mandatory)][string]$InstallRoot,[Parameter(Mandatory)][string]$Leaf,[Parameter(Mandatory)]$Record) + $fileModule = Get-CcodLifecycleInstallFileTransactionModule + try { + &$fileModule { + param($Root,$RecordLeaf,$Value) + $transaction = $null + try { + $transaction = Open-CcodInstallStateTransaction -InstallRoot $Root + $state = New-CcodInstallDirectory -Transaction $transaction -Parent $transaction -Leaf 'state' -CreateIfMissing + $fences = New-CcodInstallDirectory -Transaction $transaction -Parent $state -Leaf 'product-cleanup-fences' -CreateIfMissing + Write-CcodInstallRecord -Transaction $transaction -Parent $fences -Leaf $RecordLeaf -Record $Value | Out-Null + } finally { + if ($null -ne $transaction) { Close-CcodInstallFileTransaction -Transaction $transaction -Disposition Ready } + } + } $InstallRoot $Leaf $Record + } catch { + Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Durable product cleanup record could not be appended create-only' $Leaf + } +} + +function Complete-CcodLifecycleProductCleanupFence { + param([Parameter(Mandatory)]$Fence,[Parameter(Mandatory)][ValidateSet('Completed','Pending')][string]$Outcome) + [void](Assert-CcodLifecycleProductCleanupLease) + if ($null -eq $Fence -or -not (Test-CcodLifecycleOrderedProperties $Fence @('InstallRoot','ReadyTransaction','OwnerIdentity','Attempt')) -or + $Fence.InstallRoot -isnot [string] -or -not (Test-CcodLifecycleProductCleanupInteger $Fence.Attempt) -or [uint64]$Fence.Attempt -eq 0) { + Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Product cleanup fence capability is invalid' $Fence + } + $root = Get-CcodLifecycleCanonicalRoot -Path $Fence.InstallRoot -Kind 'Install root' + [void](Assert-CcodLifecycleProductCleanupIdentity $Fence.OwnerIdentity) + [void](Assert-CcodLifecycleProductCleanupReadyAuthority -InstallRoot $root -ReadyTransaction $Fence.ReadyTransaction) + $record = [pscustomobject][ordered]@{ + schemaVersion = 1 + transactionId = $Fence.ReadyTransaction.transactionId + runtimeId = $Fence.ReadyTransaction.newRuntimeId + runtimeGeneration = [uint64]$Fence.ReadyTransaction.newGeneration + manifestSha256 = $Fence.ReadyTransaction.newManifestSha256 + packageSha256 = $Fence.ReadyTransaction.sealedPackageSha256 + ownerPid = [int]$Fence.OwnerIdentity.pid + ownerCreationTimeUtc = [string]$Fence.OwnerIdentity.creationTimeUtc + ownerSid = [string]$Fence.OwnerIdentity.userSid + state = $Outcome + } + [void](Assert-CcodLifecycleProductCleanupRecord -Record $record -ReadyTransaction $Fence.ReadyTransaction -ExpectedState $Outcome) + $allHistory = @(Read-CcodLifecycleProductCleanupFenceHistory -InstallRoot $root -ReadyTransaction $Fence.ReadyTransaction) + [void](Assert-CcodLifecycleNoForeignProductCleanupPending -History $allHistory -TransactionId $record.transactionId) + $history = @($allHistory | Where-Object {$_.Record.transactionId -ceq $record.transactionId}) + $existing = @($history | Where-Object {[uint64]$_.Attempt -eq [uint64]$Fence.Attempt -and $_.State -ceq $Outcome}) + if ($existing.Count -eq 1) { return $existing[0].Record } + if ($existing.Count -ne 0) { Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Product cleanup fence outcome is ambiguous' $Fence.Attempt } + if ($Outcome -ceq 'Pending') { + [uint64]$expected = if ($history.Count -eq 0) { 1 } else { [uint64]$history[-1].Attempt + 1 } + if ([uint64]$Fence.Attempt -ne $expected -or ($history.Count -gt 0 -and $history[-1].State -cne 'Completed')) { + Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Product cleanup Pending append is out of sequence' $Fence.Attempt + } + } else { + $pending = @($history | Where-Object {[uint64]$_.Attempt -eq [uint64]$Fence.Attempt -and $_.State -ceq 'Pending'}) + if ($pending.Count -ne 1 -or $history[-1].State -cne 'Pending' -or [uint64]$history[-1].Attempt -ne [uint64]$Fence.Attempt) { + Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Product cleanup Completed append lacks the latest exact Pending record' $Fence.Attempt + } + } + $leaf = Get-CcodLifecycleProductCleanupFenceLeaf -Attempt ([uint64]$Fence.Attempt) -State $Outcome -TransactionId $record.transactionId + Write-CcodLifecycleProductCleanupRecord -InstallRoot $root -Leaf $leaf -Record $record + $observed = @(Read-CcodLifecycleProductCleanupFenceHistory -InstallRoot $root -ReadyTransaction $Fence.ReadyTransaction | Where-Object {$_.Record.transactionId -ceq $record.transactionId -and [uint64]$_.Attempt -eq [uint64]$Fence.Attempt -and $_.State -ceq $Outcome}) + if ($observed.Count -ne 1) { Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Durable product cleanup append is not exactly visible' $leaf } + return $observed[0].Record +} + +function New-CcodLifecycleProductCleanupFence { + param([Parameter(Mandatory)][string]$InstallRoot,[Parameter(Mandatory)]$ReadyTransaction,[Parameter(Mandatory)]$OwnerIdentity) + [void](Assert-CcodLifecycleProductCleanupLease) + $root = Get-CcodLifecycleCanonicalRoot -Path $InstallRoot -Kind 'Install root' + [void](Assert-CcodLifecycleProductCleanupIdentity $OwnerIdentity) + [void](Assert-CcodLifecycleProductCleanupReadyAuthority -InstallRoot $root -ReadyTransaction $ReadyTransaction) + $allHistory = @(Read-CcodLifecycleProductCleanupFenceHistory -InstallRoot $root -ReadyTransaction $ReadyTransaction) + [void](Assert-CcodLifecycleNoForeignProductCleanupPending -History $allHistory -TransactionId $ReadyTransaction.transactionId) + $history = @($allHistory | Where-Object {$_.Record.transactionId -ceq $ReadyTransaction.transactionId}) + if ($history.Count -gt 0 -and $history[-1].State -cne 'Completed') { + Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'A prior product cleanup fence remains Pending' $history[-1].Leaf + } + if ($history.Count -gt 0 -and [uint64]$history[-1].Attempt -eq [uint64]::MaxValue) { + Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Product cleanup fence sequence is exhausted' $root + } + [uint64]$attempt = if ($history.Count -eq 0) { 1 } else { [uint64]$history[-1].Attempt + 1 } + $fence = [pscustomobject][ordered]@{ + InstallRoot = $root + ReadyTransaction = $ReadyTransaction + OwnerIdentity = [pscustomobject][ordered]@{pid=[int]$OwnerIdentity.pid;creationTimeUtc=[string]$OwnerIdentity.creationTimeUtc;userSid=[string]$OwnerIdentity.userSid} + Attempt = $attempt + } + [void](Complete-CcodLifecycleProductCleanupFence -Fence $fence -Outcome Pending) + return $fence +} + +function Invoke-CcodLifecycleProductTransactionClose { + param([Parameter(Mandatory)]$Transaction,[Parameter(Mandatory)][scriptblock]$CloseProductTransaction,[Parameter(Mandatory)][scriptblock]$DefaultClose) + & $CloseProductTransaction $Transaction $defaultClose +} + +function Invoke-CcodLifecycleUnboundProductTransactionAbort { + param([Parameter(Mandatory)]$Transaction) + $fileModule = Get-CcodLifecycleInstallFileTransactionModule + &$fileModule {param($Value)Close-CcodInstallFileTransaction -Transaction $Value -Disposition Failed} $Transaction +} + +function Test-CcodLifecycleProductCleanupOwnerAlive { + param([Parameter(Mandatory)]$OwnerIdentity) + $process = $null + try { + $process = Get-Process -Id ([int]$OwnerIdentity.pid) -ErrorAction SilentlyContinue + if ($null -eq $process) { return $false } + return $process.StartTime.ToUniversalTime().ToString('o',[Globalization.CultureInfo]::InvariantCulture) -ceq [string]$OwnerIdentity.creationTimeUtc + } catch { + return $true + } finally { + if ($null -ne $process) { $process.Dispose() } + } +} + +function Resolve-CcodLifecycleProductCleanupFence { + param([Parameter(Mandatory)][string]$InstallRoot,[Parameter(Mandatory)]$ReadyTransaction,[Parameter(Mandatory)]$CurrentIdentity,[Parameter(Mandatory)][scriptblock]$CloseProductTransaction) + [void](Assert-CcodLifecycleProductCleanupLease) + [void](Assert-CcodLifecycleProductCleanupIdentity $CurrentIdentity) + $root = Get-CcodLifecycleCanonicalRoot -Path $InstallRoot -Kind 'Install root' + [void](Assert-CcodLifecycleProductCleanupReadyAuthority -InstallRoot $root -ReadyTransaction $ReadyTransaction) + $allHistory = @(Read-CcodLifecycleProductCleanupFenceHistory -InstallRoot $root -ReadyTransaction $ReadyTransaction) + [void](Assert-CcodLifecycleNoForeignProductCleanupPending -History $allHistory -TransactionId $ReadyTransaction.transactionId) + $history = @($allHistory | Where-Object {$_.Record.transactionId -ceq $ReadyTransaction.transactionId}) + $key = Get-CcodLifecycleProductCleanupKey -InstallRoot $root + if ($history.Count -eq 0 -or $history[-1].State -ceq 'Completed') { + if ($script:CcodPendingProductTransactionCleanups.ContainsKey($key)) { [void]$script:CcodPendingProductTransactionCleanups.Remove($key) } + return [pscustomobject]@{Outcome='None'} + } + $pendingRecord = $history[-1].Record + if ($pendingRecord.ownerSid -cne $CurrentIdentity.userSid) { + Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Pending product cleanup belongs to a different account' $pendingRecord.ownerSid + } + $sameOwner = [int]$pendingRecord.ownerPid -eq [int]$CurrentIdentity.pid -and + $pendingRecord.ownerCreationTimeUtc -ceq $CurrentIdentity.creationTimeUtc -and + $pendingRecord.ownerSid -ceq $CurrentIdentity.userSid + $entry = if ($script:CcodPendingProductTransactionCleanups.ContainsKey($key)) { $script:CcodPendingProductTransactionCleanups[$key] } else { $null } + if ($sameOwner) { + if ($null -eq $entry -or $entry.OwnerManagedThreadId -ne [Threading.Thread]::CurrentThread.ManagedThreadId -or + $null -eq $entry.Fence -or [uint64]$entry.Fence.Attempt -ne [uint64]$history[-1].Attempt) { + Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Pending product cleanup is live but its thread-bound capability is unavailable here' $root + } + if (-not $entry.CloseCompleted) { + if ($entry.CloseProductTransaction -isnot [scriptblock] -or $entry.DefaultClose -isnot [scriptblock]) { + Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Pending product cleanup capability is invalid' $root + } + $fenceBound = $null -ne $entry.PSObject.Properties['FenceBound'] -and $entry.FenceBound -is [bool] -and [bool]$entry.FenceBound + if ($null -ne $entry.Transaction -and -not $fenceBound) { + try { Invoke-CcodLifecycleUnboundProductTransactionAbort -Transaction $entry.Transaction; $entry.Transaction=$null } + catch { Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Unbound product transaction abort remains retryable' $root } + } + if ($null -eq $entry.Transaction) { + $fileModule = Get-CcodLifecycleInstallFileTransactionModule + try { + $entry.Transaction = &$fileModule {param($Root,$Record)Open-CcodInstallProductRegistrationTransaction -InstallRoot $Root -ReadyTransaction $Record} $root $ReadyTransaction + if ($null -eq $entry.PSObject.Properties['FenceBound']) { $entry | Add-Member -NotePropertyName FenceBound -NotePropertyValue $false } + else { $entry.FenceBound = $false } + [void](&$fileModule {param($Transaction,$Fence)Set-CcodInstallProductCleanupFence -Transaction $Transaction -Fence $Fence} $entry.Transaction $entry.Fence) + $entry.FenceBound = $true + } catch { + if ($null -ne $entry.Transaction) { + try { Invoke-CcodLifecycleUnboundProductTransactionAbort -Transaction $entry.Transaction; $entry.Transaction = $null } catch { } + } + Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Pending product cleanup could not open fresh strict authority' $root + } + } + try { Invoke-CcodLifecycleProductTransactionClose -Transaction $entry.Transaction -CloseProductTransaction $entry.CloseProductTransaction -DefaultClose $entry.DefaultClose } + catch { Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Pending product transaction cleanup remains retryable' $root } + $entry.CloseCompleted = $true + } + try { [void](Complete-CcodLifecycleProductCleanupFence -Fence $entry.Fence -Outcome Completed) } + catch { Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Product transaction closed but durable completion remains pending' $root } + [void]$script:CcodPendingProductTransactionCleanups.Remove($key) + return [pscustomobject]@{Outcome='SameThreadCompleted'} + } + if (Test-CcodLifecycleProductCleanupOwnerAlive ([pscustomobject][ordered]@{pid=[int]$pendingRecord.ownerPid;creationTimeUtc=[string]$pendingRecord.ownerCreationTimeUtc;userSid=[string]$pendingRecord.ownerSid})) { + Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Pending product cleanup owner is still alive' $root + } + $fence = [pscustomobject][ordered]@{ + InstallRoot = $root + ReadyTransaction = $ReadyTransaction + OwnerIdentity = [pscustomobject][ordered]@{pid=[int]$pendingRecord.ownerPid;creationTimeUtc=[string]$pendingRecord.ownerCreationTimeUtc;userSid=[string]$pendingRecord.ownerSid} + Attempt = [uint64]$history[-1].Attempt + } + $fileModule = Get-CcodLifecycleInstallFileTransactionModule + $recoveryTransaction = $null + try { + $recoveryTransaction = &$fileModule {param($Root,$Record)Open-CcodInstallProductRegistrationTransaction -InstallRoot $Root -ReadyTransaction $Record} $root $ReadyTransaction + [void](&$fileModule {param($Transaction,$Fence)Set-CcodInstallProductCleanupFence -Transaction $Transaction -Fence $Fence} $recoveryTransaction $fence) + $defaultClose = {param($Value)&$fileModule {param($Transaction)Close-CcodInstallFileTransaction -Transaction $Transaction -Disposition Ready} $Value}.GetNewClosure() + Invoke-CcodLifecycleProductTransactionClose -Transaction $recoveryTransaction -CloseProductTransaction $CloseProductTransaction -DefaultClose $defaultClose + $recoveryTransaction = $null + } catch { + if ($null -ne $recoveryTransaction) { try { &$fileModule {param($Transaction)Close-CcodInstallFileTransaction -Transaction $Transaction -Disposition Failed} $recoveryTransaction } catch { } } + Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Dead-owner product cleanup could not be reconciled through fresh strict authority' $root + } + [void](Complete-CcodLifecycleProductCleanupFence -Fence $fence -Outcome Completed) + if ($script:CcodPendingProductTransactionCleanups.ContainsKey($key)) { [void]$script:CcodPendingProductTransactionCleanups.Remove($key) } + return [pscustomobject]@{Outcome='DeadOwnerCompleted'} +} + function Test-CcodActivationExactProperties { param($Value) if ($null -eq $Value -or $Value -isnot [pscustomobject]) { return $false } @@ -90,8 +709,9 @@ function Assert-CcodActivationReceipt { } function Write-CcodActivationReceiptFile { - param([Parameter(Mandatory)][string]$InstallRoot, [Parameter(Mandatory)]$Receipt) + param([Parameter(Mandatory)][string]$InstallRoot, [Parameter(Mandatory)]$Receipt,$FileTransaction) [void](Assert-CcodActivationReceipt $Receipt) + if($null-ne$FileTransaction){[void](Assert-CcodInstallTransactionRoot $InstallRoot $FileTransaction);$state=New-CcodInstallDirectory -Transaction $FileTransaction -Parent $FileTransaction -Leaf 'state' -CreateIfMissing;$receipts=New-CcodInstallDirectory -Transaction $FileTransaction -Parent $state -Leaf 'activation-receipts' -CreateIfMissing;$leaf=('{0}.{1}.json'-f$Receipt.activationId,$Receipt.phase);Write-CcodInstallRecord -Transaction $FileTransaction -Parent $receipts -Leaf $leaf -Record $Receipt|Out-Null;return} $stateRoot = Join-Path $InstallRoot 'state' [IO.Directory]::CreateDirectory($stateRoot) | Out-Null Write-CcodAtomicJson -Path (Join-Path $stateRoot 'post-install-activation.json') -Value $Receipt -Compress @@ -105,7 +725,8 @@ function Write-CcodInstallActivationPhase { [AllowNull()]$PreviousRuntimeId, [AllowNull()]$ErrorCode, [Parameter(Mandatory)][hashtable]$Adapters, - [Parameter(Mandatory)][string]$InstallRoot + [Parameter(Mandatory)][string]$InstallRoot, + $FileTransaction ) $now = & $Adapters.UtcNow if ($now -isnot [DateTime]) { Throw-CcodLifecycleError 'CCOD_INSTALL_CLOCK_INVALID' 'Activation clock must return DateTime' $null } @@ -121,7 +742,7 @@ function Write-CcodInstallActivationPhase { errorCode = $ErrorCode } [void](Assert-CcodActivationReceipt $receipt) - try { & $Adapters.WriteActivationReceipt $InstallRoot $receipt } + try { & $Adapters.WriteActivationReceipt $InstallRoot $receipt $FileTransaction } catch { Throw-CcodLifecycleError 'CCOD_INSTALL_ACTIVATION_RECEIPT_FAILED' 'Activation receipt could not be persisted' $null } $Activation.LastPhase = $Phase return $receipt @@ -142,6 +763,679 @@ function Get-CcodLifecycleCanonicalRoot { return $root.TrimEnd('\') } +function Assert-CcodInstallTransactionRecord { + param([Parameter(Mandatory)]$Record) + + $actualFields = if ($Record -is [Collections.IDictionary]) { @($Record.Keys) } else { @($Record.PSObject.Properties.Name) } + if (($actualFields -join "`0") -cne ($script:CcodInstallTransactionFields -join "`0") -or + $Record.schemaVersion -isnot [int] -or $Record.schemaVersion -ne 1 -or + -not (Test-CcodLifecycleCanonicalGuid $Record.transactionId) -or + ($null -ne $Record.oldRuntimeId -and ($Record.oldRuntimeId -isnot [string] -or $Record.oldRuntimeId -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$')) -or + ($null -ne $Record.newRuntimeId -and ($Record.newRuntimeId -isnot [string] -or $Record.newRuntimeId -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$')) -or + ($null-ne$Record.oldManifestSha256-and($Record.oldManifestSha256-isnot[string]-or$Record.oldManifestSha256-cnotmatch'^[0-9a-f]{64}$')) -or + $Record.newManifestSha256-isnot[string]-or$Record.newManifestSha256-cnotmatch'^[0-9a-f]{64}$' -or + $Record.sealedPackageSha256 -isnot [string] -or $Record.sealedPackageSha256 -cnotmatch '^[0-9a-f]{64}$' -or + $Record.phase -isnot [string] -or (@($script:CcodInstallTransactionPhases) + 'Failed') -cnotcontains $Record.phase -or + (($Record.phase -ceq 'Failed') -and ($Record.errorCode -isnot [string] -or $Record.errorCode -cnotmatch '^CCOD_[A-Z0-9_]{1,96}$')) -or + (($Record.phase -cne 'Failed') -and $null -ne $Record.errorCode)) { + Throw-CcodLifecycleError 'CCOD_INSTALL_TRANSACTION_INVALID' 'Install transaction record contract is invalid' $null + } + $integerTypes = @([byte],[uint16],[uint32],[uint64],[int16],[int32],[int64]) + $newTypeValid = $false + foreach ($type in $integerTypes) { if ($Record.newGeneration -is $type) { $newTypeValid = $true; break } } + $oldTypeValid = $null -eq $Record.oldGeneration + if (-not $oldTypeValid) { foreach ($type in $integerTypes) { if ($Record.oldGeneration -is $type) { $oldTypeValid = $true; break } } } + try { + if (-not $newTypeValid -or -not $oldTypeValid) { throw 'type' } + [uint64]$newGeneration = $Record.newGeneration + [uint64]$oldGeneration = if ($null -eq $Record.oldGeneration) { 0 } else { $Record.oldGeneration } + if ($newGeneration -eq 0 -or ($null -ne $Record.oldGeneration -and $oldGeneration -eq 0) -or ($null -eq $Record.oldRuntimeId) -ne ($null -eq $Record.oldGeneration) -or ($null-eq$Record.oldRuntimeId)-ne($null-eq$Record.oldManifestSha256)) { throw 'generation' } + } catch { + Throw-CcodLifecycleError 'CCOD_INSTALL_TRANSACTION_INVALID' 'Install transaction generations are invalid' $null + } + if ($Record.ownedObjectNames -isnot [Array]) { + Throw-CcodLifecycleError 'CCOD_INSTALL_TRANSACTION_INVALID' 'Install transaction object names must be an array' $null + } + $owned = @($Record.ownedObjectNames) + if ($owned.Count -gt 64) { Throw-CcodLifecycleError 'CCOD_INSTALL_TRANSACTION_INVALID' 'Install transaction owns too many objects' $null } + $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + foreach ($name in $owned) { + if ($name -isnot [string] -or $name -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$' -or -not $seen.Add($name)) { + Throw-CcodLifecycleError 'CCOD_INSTALL_TRANSACTION_INVALID' 'Install transaction object names are invalid' $null + } + } + return $true +} + +function Open-CcodLifecycleInstallGeneration { + param([Parameter(Mandatory)][string]$InstallRoot,[Parameter(Mandatory)][string]$RuntimeId) + if($null-eq(Get-Command Open-CcodInstallGeneration -ErrorAction SilentlyContinue)){Import-Module (Join-Path $PSScriptRoot 'InstallFileTransaction.psm1') -ErrorAction Stop} + $canonical = Get-CcodLifecycleCanonicalRoot -Path $InstallRoot -Kind 'Install root' + $transaction = Open-CcodInstallGeneration -InstallRoot $canonical -RuntimeId $RuntimeId + $script:CcodInstallTransactionRoots.Add($transaction,[pscustomobject]@{ InstallRoot=$canonical; RuntimeId=$RuntimeId }) + return $transaction +} + +function Open-CcodLifecycleInstallStateTransaction { + param([Parameter(Mandatory)][string]$InstallRoot) + if($null-eq(Get-Command Open-CcodInstallStateTransaction -ErrorAction SilentlyContinue)){Import-Module (Join-Path $PSScriptRoot 'InstallFileTransaction.psm1') -ErrorAction Stop} + $canonical = Get-CcodLifecycleCanonicalRoot -Path $InstallRoot -Kind 'Install root' + $transaction = Open-CcodInstallStateTransaction -InstallRoot $canonical + $script:CcodInstallTransactionRoots.Add($transaction,[pscustomobject]@{ InstallRoot=$canonical; RuntimeId=$null }) + return $transaction +} + +function Open-CcodLifecycleInstallMigrationRetryTransaction { + param([Parameter(Mandatory)][string]$InstallRoot) + $canonical=Get-CcodLifecycleCanonicalRoot -Path $InstallRoot -Kind 'Install root' + $fileModule=Get-CcodLifecycleInstallFileTransactionModule + $transaction=&$fileModule {param($Root)Open-CcodInstallMigrationRetryTransaction -InstallRoot $Root} $canonical + $script:CcodInstallTransactionRoots.Add($transaction,[pscustomobject]@{InstallRoot=$canonical;RuntimeId=$null}) + return $transaction +} + +function Assert-CcodInstallTransactionRoot { + param([Parameter(Mandatory)][string]$InstallRoot,[Parameter(Mandatory)]$FileTransaction) + $binding = $null + $canonical = Get-CcodLifecycleCanonicalRoot -Path $InstallRoot -Kind 'Install root' + if (-not $script:CcodInstallTransactionRoots.TryGetValue($FileTransaction,[ref]$binding) -or $binding.InstallRoot -cne $canonical) { + Throw-CcodLifecycleError 'CCOD_INSTALL_TRANSACTION_SCOPE' 'Install root does not match the opaque file transaction' $InstallRoot + } + return $binding +} + +function New-CcodUniqueRuntimeId { + param( + [Parameter(Mandatory)][string]$ProjectVersion, + [Parameter(Mandatory)][object[]]$Files, + [scriptblock]$NewNonce = { [guid]::NewGuid().ToString('N') } + ) + $nonce = & $NewNonce + if ($ProjectVersion -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,45}$' -or + $nonce -isnot [string] -or $nonce -cnotmatch '^[0-9a-f]{32}$') { + Throw-CcodLifecycleError 'CCOD_INSTALL_RUNTIME_ID_INVALID' 'Unique runtime identity input is invalid' $null + } + $records=[Collections.Generic.List[object]]::new() + foreach($file in $Files){ + $relative=([string]$file.Relative).Replace('\','/') + $length=if($null-ne$file.PSObject.Properties['ExpectedLength']){[int64]$file.ExpectedLength}else{[int64](Get-Item -LiteralPath $file.Source -Force -ErrorAction Stop).Length} + $sha=if($null-ne$file.PSObject.Properties['ExpectedSha256']){[string]$file.ExpectedSha256}else{Get-CcodLifecycleFileSha256 -Path $file.Source} + $records.Add([pscustomobject]@{path=$relative;length=$length;sha256=$sha}) + } + $comparison=[Comparison[object]]{param($left,$right)[StringComparer]::Ordinal.Compare([string]$left.path,[string]$right.path)};$records.Sort($comparison) + try{return Get-CcodRuntimeId -ProjectVersion $ProjectVersion -Files $records.ToArray() -Nonce $nonce}catch{Throw-CcodLifecycleError 'CCOD_INSTALL_RUNTIME_ID_INVALID' 'Unique runtime identity input is invalid' $null} +} + +function New-CcodInstallTransactionRecord { + param( + [Parameter(Mandatory)][string]$TransactionId, + [AllowNull()]$OldRuntimeId, + [AllowNull()]$OldGeneration, + [AllowNull()]$OldManifestSha256, + [AllowNull()]$NewRuntimeId, + [Parameter(Mandatory)][uint64]$NewGeneration, + [Parameter(Mandatory)][string]$NewManifestSha256, + [Parameter(Mandatory)][string]$SealedPackageSha256, + [string[]]$OwnedObjectNames = @() + ) + $record = [pscustomobject][ordered]@{ + schemaVersion = 1 + transactionId = $TransactionId + oldRuntimeId = $OldRuntimeId + oldGeneration = if ($null -eq $OldGeneration) { $null } else { [uint64]$OldGeneration } + oldManifestSha256 = $OldManifestSha256 + newRuntimeId = $NewRuntimeId + newGeneration = [uint64]$NewGeneration + newManifestSha256 = $NewManifestSha256 + sealedPackageSha256 = $SealedPackageSha256 + ownedObjectNames = @($OwnedObjectNames) + phase = 'Prepared' + errorCode = $null + } + [void](Assert-CcodInstallTransactionRecord $record) + return $record +} + +function Get-CcodInstallTransactionRecordLeaf { + param([Parameter(Mandatory)]$Record) + $phaseIndex = if ($Record.phase -ceq 'Failed') { 99 } else { [Array]::IndexOf($script:CcodInstallTransactionPhases,[string]$Record.phase) } + return ('{0:D20}.{1:D2}.{2}.{3}.json' -f [uint64]$Record.newGeneration,$phaseIndex,[string]$Record.phase,[string]$Record.transactionId) +} + +function Get-CcodInstallTransactionDirectory { + param([Parameter(Mandatory)]$FileTransaction) + $state = New-CcodInstallDirectory -Transaction $FileTransaction -Parent $FileTransaction -Leaf 'state' -CreateIfMissing + return New-CcodInstallDirectory -Transaction $FileTransaction -Parent $state -Leaf 'install-transactions' -CreateIfMissing +} + +function Initialize-CcodInstallStatePlanes { + param([Parameter(Mandatory)][string]$InstallRoot,[Parameter(Mandatory)][string]$RuntimeId,[Parameter(Mandatory)]$FileTransaction,[string[]]$NodeCandidates,[bool]$CandidateCompatibleOptIn) + [void](Assert-CcodInstallTransactionRoot -InstallRoot $InstallRoot -FileTransaction $FileTransaction) + if($RuntimeId-cnotmatch'^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$'){Throw-CcodLifecycleError 'CCOD_INSTALL_RUNTIME_ID_INVALID' 'Initialization runtime identity is invalid' $RuntimeId} + $state=New-CcodInstallDirectory -Transaction $FileTransaction -Parent $FileTransaction -Leaf 'state' -CreateIfMissing + $initializations=New-CcodInstallDirectory -Transaction $FileTransaction -Parent $state -Leaf 'install-initializations' -CreateIfMissing + $baseline=New-CcodInstallDirectory -Transaction $FileTransaction -Parent $initializations -Leaf $RuntimeId -CreateIfMissing + $stateRoot=Join-Path $InstallRoot 'state' + $values=Initialize-CcodState -StateRoot $stateRoot -NodeCandidates $NodeCandidates -CandidateCompatibleOptIn $CandidateCompatibleOptIn + Write-CcodInstallRecord -Transaction $FileTransaction -Parent $baseline -Leaf 'settings.json' -Record $values.settings|Out-Null + Write-CcodInstallRecord -Transaction $FileTransaction -Parent $baseline -Leaf 'status.json' -Record $values.status|Out-Null + Write-CcodInstallRecord -Transaction $FileTransaction -Parent $baseline -Leaf 'verified-packages.json' -Record $values.verifiedPackages|Out-Null + Write-CcodInstallRecord -Transaction $FileTransaction -Parent $baseline -Leaf 'transition.json' -Record $values.transition|Out-Null + $ui=Initialize-CcodUiPreference -StateRoot $stateRoot -AllowExisting + Write-CcodInstallRecord -Transaction $FileTransaction -Parent $baseline -Leaf 'ui-preferences.json' -Record $ui|Out-Null +} + +function Write-CcodInstallTransactionRecord { + param([Parameter(Mandatory)][string]$InstallRoot,[Parameter(Mandatory)]$TransactionRecord,[Parameter(Mandatory)]$FileTransaction) + [void](Assert-CcodInstallTransactionRoot -InstallRoot $InstallRoot -FileTransaction $FileTransaction) + [void](Assert-CcodInstallTransactionRecord $TransactionRecord) + $directory = Get-CcodInstallTransactionDirectory -FileTransaction $FileTransaction + Write-CcodInstallRecord -Transaction $FileTransaction -Parent $directory -Leaf (Get-CcodInstallTransactionRecordLeaf $TransactionRecord) -Record $TransactionRecord | Out-Null + return $TransactionRecord +} + +function Resolve-CcodInstallTransactionRecordHead { + param([Parameter(Mandatory)][object[]]$Records,[string]$TransactionId,$ActiveRuntime) + if ($Records.Count -eq 0) { return $null } + $heads = [Collections.Generic.List[object]]::new() + foreach ($group in @($Records | Group-Object transactionId)) { + $chain = @($group.Group | Sort-Object @{Expression={if ($_.phase -ceq 'Failed') { 99 } else { [Array]::IndexOf($script:CcodInstallTransactionPhases,[string]$_.phase) }}}) + if ($chain[0].phase -cne 'Prepared') { Throw-CcodLifecycleError 'CCOD_INSTALL_TRANSACTION_INVALID' 'Install transaction chain does not begin at Prepared' $group.Name } + for ($index=0; $index -lt $chain.Count; $index++) { + $current = $chain[$index] + [void](Assert-CcodInstallTransactionRecord $current) + $head=$chain[0] + $oldGenerationChanged=($null-eq$head.oldGeneration)-ne($null-eq$current.oldGeneration) + if(-not$oldGenerationChanged-and$null-ne$head.oldGeneration){$oldGenerationChanged=[uint64]$current.oldGeneration-ne[uint64]$head.oldGeneration} + if ([string]$current.transactionId -cne [string]$head.transactionId -or + [string]$current.oldRuntimeId -cne [string]$head.oldRuntimeId -or $oldGenerationChanged -or + [string]$current.oldManifestSha256-cne[string]$head.oldManifestSha256 -or + [string]$current.newRuntimeId -cne [string]$head.newRuntimeId -or + [uint64]$current.newGeneration -ne [uint64]$head.newGeneration -or + [string]$current.newManifestSha256-cne[string]$head.newManifestSha256 -or + [string]$current.sealedPackageSha256 -cne [string]$head.sealedPackageSha256 -or + (@($current.ownedObjectNames)-join"`0") -cne (@($head.ownedObjectNames)-join"`0")) { + Throw-CcodLifecycleError 'CCOD_INSTALL_TRANSACTION_INVALID' 'Install transaction identity changed across snapshots' $group.Name + } + if ($index -gt 0) { + $previous = $chain[$index-1] + if ($previous.phase -in @('Ready','Failed') -or ($current.phase -cne 'Failed' -and [Array]::IndexOf($script:CcodInstallTransactionPhases,[string]$current.phase) -ne ([Array]::IndexOf($script:CcodInstallTransactionPhases,[string]$previous.phase)+1))) { Throw-CcodLifecycleError 'CCOD_INSTALL_TRANSACTION_INVALID' 'Install transaction chain has a gap, duplicate, or post-terminal snapshot' $group.Name } + } + } + $heads.Add($chain[-1]) + } + if (-not [string]::IsNullOrWhiteSpace($TransactionId)) { + $matched=@($heads|Where-Object{$_.transactionId -ceq $TransactionId});if($matched.Count-ne 1){return $null};return $matched[0] + } + $nonterminal=@($heads|Where-Object{$_.phase -notin @('Ready','Failed')}) + if($nonterminal.Count-gt 1){Throw-CcodLifecycleError 'CCOD_INSTALL_TRANSACTION_AMBIGUOUS' 'More than one nonterminal install transaction exists' $null} + if($nonterminal.Count-eq 1){return $nonterminal[0]} + if($null-ne$ActiveRuntime){$ready=@($heads|Where-Object{$_.phase-ceq'Ready'-and$_.newRuntimeId-ceq$ActiveRuntime.activeRuntime-and[uint64]$_.newGeneration-eq[uint64]$ActiveRuntime.generation});if($ready.Count-ne 0){if($ready.Count-ne 1){Throw-CcodLifecycleError 'CCOD_INSTALL_TRANSACTION_AMBIGUOUS' 'Active generation has multiple Ready records' $null};return $ready[0]}} + $failed=@($heads|Where-Object{$_.phase-ceq'Failed'}|Sort-Object @{Expression={[uint64]$_.newGeneration}}) + if($failed.Count-eq 0){return $null};$max=[uint64]$failed[-1].newGeneration;$same=@($failed|Where-Object{[uint64]$_.newGeneration-eq$max});if($same.Count-ne 1){Throw-CcodLifecycleError 'CCOD_INSTALL_TRANSACTION_AMBIGUOUS' 'Latest failed generation is ambiguous' $null};return $same[0] +} + +function Read-CcodInstallTransactionRecord { + param([Parameter(Mandatory)][string]$InstallRoot,[string]$TransactionId) + $root = Get-CcodLifecycleCanonicalRoot -Path $InstallRoot -Kind 'Install root' + $directory = Join-Path (Join-Path $root 'state') 'install-transactions' + if (-not [IO.Directory]::Exists($directory)) { return $null } + try { [void](Assert-CcodLifecycleInstallTreeSafe -InstallRoot $root -Path $directory) } + catch { Throw-CcodLifecycleError 'CCOD_INSTALL_TRANSACTION_INVALID' 'Install transaction store is unsafe' $directory } + $records = [Collections.Generic.List[object]]::new() + foreach ($file in @(Get-ChildItem -LiteralPath $directory -Force -ErrorAction Stop)) { + if ($file.PSIsContainer -or $file.Name -cnotmatch '^\d{20}\.\d{2}\.[A-Za-z]+\.[0-9a-f-]{36}\.json$') { Throw-CcodLifecycleError 'CCOD_INSTALL_TRANSACTION_INVALID' 'Install transaction store contains an unknown object' $file.FullName } + try { $record = Read-CcodStrictJson -Path $file.FullName -ExpectedSchema 1 -Kind 'install transaction' } + catch { Throw-CcodLifecycleError 'CCOD_INSTALL_TRANSACTION_INVALID' 'Install transaction snapshot is malformed' $file.FullName } + [void](Assert-CcodInstallTransactionRecord $record) + if ((Get-CcodInstallTransactionRecordLeaf $record) -cne $file.Name) { Throw-CcodLifecycleError 'CCOD_INSTALL_TRANSACTION_INVALID' 'Install transaction snapshot name is not canonical' $file.FullName } + if ([string]::IsNullOrWhiteSpace($TransactionId) -or [string]$record.transactionId -ceq $TransactionId) { $records.Add($record) } + } + $active=$null;if([string]::IsNullOrWhiteSpace($TransactionId)){try{$active=Read-CcodActiveRuntime -InstallRoot $root}catch{}} + return Resolve-CcodInstallTransactionRecordHead -Records $records.ToArray() -TransactionId $TransactionId -ActiveRuntime $active +} + +function Set-CcodInstallTransactionPhase { + param( + [Parameter(Mandatory)][string]$InstallRoot, + [Parameter(Mandatory)][string]$TransactionId, + [Parameter(Mandatory)][string]$ExpectedPhase, + [Parameter(Mandatory)][string]$NewPhase, + [string]$ErrorCode, + [Parameter(Mandatory)]$FileTransaction + ) + [void](Assert-CcodInstallTransactionRoot -InstallRoot $InstallRoot -FileTransaction $FileTransaction) + $record = Read-CcodInstallTransactionRecord -InstallRoot $InstallRoot -TransactionId $TransactionId + if ($null -eq $record -or [string]$record.phase -cne $ExpectedPhase -or $ExpectedPhase -in @('Ready','Failed')) { + Throw-CcodLifecycleError 'CCOD_INSTALL_TRANSACTION_PHASE_INVALID' 'Install transaction phase precondition is stale' $TransactionId + } + $index = [Array]::IndexOf($script:CcodInstallTransactionPhases,$ExpectedPhase) + $expectedNext = if ($index -ge 0 -and $index + 1 -lt $script:CcodInstallTransactionPhases.Count) { $script:CcodInstallTransactionPhases[$index + 1] } else { $null } + if ($NewPhase -cne 'Failed' -and $NewPhase -cne $expectedNext) { Throw-CcodLifecycleError 'CCOD_INSTALL_TRANSACTION_PHASE_INVALID' 'Install transaction phase transition is invalid' $NewPhase } + if ($NewPhase -ceq 'Failed' -and $ErrorCode -cnotmatch '^CCOD_[A-Z0-9_]{1,96}$') { Throw-CcodLifecycleError 'CCOD_INSTALL_TRANSACTION_PHASE_INVALID' 'Failed install transaction requires a canonical error code' $ErrorCode } + $next = [pscustomobject][ordered]@{} + foreach ($field in $script:CcodInstallTransactionFields) { $next | Add-Member -NotePropertyName $field -NotePropertyValue $record.$field } + $next.phase = $NewPhase + $next.errorCode = if ($NewPhase -ceq 'Failed') { $ErrorCode } else { $null } + return Write-CcodInstallTransactionRecord -InstallRoot $InstallRoot -TransactionRecord $next -FileTransaction $FileTransaction +} + +function Read-CcodReadyActivationReceiptForRecovery { + param([Parameter(Mandatory)][string]$InstallRoot,[Parameter(Mandatory)]$TransactionRecord) + $root=Get-CcodLifecycleCanonicalRoot -Path $InstallRoot -Kind 'Install root' + $directory=Join-Path (Join-Path $root 'state') 'activation-receipts' + if(-not[IO.Directory]::Exists($directory)){Throw-CcodLifecycleError 'CCOD_INSTALL_READY_RECOVERY_INVALID' 'Ready activation receipt store is missing' $directory} + try{[void](Assert-CcodLifecycleInstallTreeSafe -InstallRoot $root -Path $directory)}catch{Throw-CcodLifecycleError 'CCOD_INSTALL_READY_RECOVERY_INVALID' 'Ready activation receipt store is unsafe' $directory} + $matching=[Collections.Generic.List[object]]::new() + try{ + foreach($file in @(Get-ChildItem -LiteralPath $directory -Force -ErrorAction Stop)){ + if($file.PSIsContainer-or$file.Name-cnotmatch'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[A-Za-z]+\.json$'){throw 'unknown receipt object'} + $receipt=Read-CcodStrictJson -Path $file.FullName -ExpectedSchema 1 -Kind 'activation receipt' + [void](Assert-CcodActivationReceipt $receipt) + if($file.Name-cne('{0}.{1}.json'-f$receipt.activationId,$receipt.phase)){throw 'noncanonical receipt name'} + if($receipt.phase-ceq'Ready'-and$receipt.runtimeId-ceq$TransactionRecord.newRuntimeId-and[string]$receipt.previousRuntimeId-ceq[string]$TransactionRecord.oldRuntimeId){$matching.Add($receipt)} + } + }catch{Throw-CcodLifecycleError 'CCOD_INSTALL_READY_RECOVERY_INVALID' 'Ready activation receipt store is invalid' $directory} + if($matching.Count-ne1){Throw-CcodLifecycleError 'CCOD_INSTALL_READY_RECOVERY_INVALID' 'Exactly one matching Ready activation receipt is required' $TransactionRecord.transactionId} + return $matching[0] +} + +function Assert-CcodReadyFinalizationRecoveryProof { + param([Parameter(Mandatory)][string]$InstallRoot,[Parameter(Mandatory)]$TransactionRecord) + try{ + [void](Assert-CcodInstallTransactionRecord $TransactionRecord) + if($TransactionRecord.phase-cne'ProtectionReady'-or[string]::IsNullOrWhiteSpace([string]$TransactionRecord.newRuntimeId)){throw 'transaction phase'} + [uint64]$expectedGeneration=if($null-eq$TransactionRecord.oldGeneration){1}else{[uint64]$TransactionRecord.oldGeneration+1} + if([uint64]$TransactionRecord.newGeneration-ne$expectedGeneration){throw 'transaction generation'} + $pointerRoot=Join-Path $InstallRoot 'state\active-generation' + if(-not[IO.Directory]::Exists($pointerRoot)){throw 'append-only pointer missing'} + $pointerItem=Get-Item -LiteralPath $pointerRoot -Force -ErrorAction Stop + if(-not$pointerItem.PSIsContainer-or($pointerItem.Attributes-band[IO.FileAttributes]::ReparsePoint)-ne0){throw 'append-only pointer root'} + [void](Assert-CcodLifecycleInstallTreeSafe -InstallRoot $InstallRoot -Path $pointerRoot) + $pointer=Read-CcodActiveRuntime -InstallRoot $InstallRoot + if($pointer.activeRuntime-cne$TransactionRecord.newRuntimeId-or[uint64]$pointer.generation-ne[uint64]$TransactionRecord.newGeneration-or[string]$pointer.previousRuntime-cne[string]$TransactionRecord.oldRuntimeId){throw 'active pointer mismatch'} + $runtimeRoot=[IO.Path]::GetFullPath((Join-Path (Join-Path $InstallRoot 'runtime') $TransactionRecord.newRuntimeId)) + [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $runtimeRoot) + $validation=Test-CcodRuntimeManifest -RuntimeDirectory $runtimeRoot -ExpectedRuntimeId $TransactionRecord.newRuntimeId -ExpectedManifestSha256 $TransactionRecord.newManifestSha256 + if($null-eq$validation-or-not$validation.Valid){throw 'runtime manifest mismatch'} + $receipt=Read-CcodReadyActivationReceiptForRecovery -InstallRoot $InstallRoot -TransactionRecord $TransactionRecord + return [pscustomobject]@{Pointer=$pointer;RuntimeRoot=$runtimeRoot;Validation=$validation;Receipt=$receipt} + }catch{ + if((Get-CcodLifecycleErrorId $_)-ceq'CCOD_INSTALL_READY_RECOVERY_INVALID'){throw} + Throw-CcodLifecycleError 'CCOD_INSTALL_READY_RECOVERY_INVALID' 'Ready finalization recovery proof is invalid' $TransactionRecord.transactionId + } +} + +function Complete-CcodReadyFinalizationRecovery { + param([Parameter(Mandatory)][string]$InstallRoot,[Parameter(Mandatory)]$TransactionRecord,[Parameter(Mandatory)][hashtable]$Adapters) + $proof=Assert-CcodReadyFinalizationRecoveryProof -InstallRoot $InstallRoot -TransactionRecord $TransactionRecord + $fileTransaction=$null + $observed=$null;$productRegistrationFailure=$null;$productRegistrationVerified=$false + try{ + $fileTransaction=Open-CcodLifecycleInstallStateTransaction -InstallRoot $InstallRoot + [void](Assert-CcodReadyFinalizationRecoveryProof -InstallRoot $InstallRoot -TransactionRecord $TransactionRecord) + try{[void](Set-CcodInstallTransactionPhase -InstallRoot $InstallRoot -TransactionId $TransactionRecord.transactionId -ExpectedPhase 'ProtectionReady' -NewPhase 'Ready' -FileTransaction $fileTransaction)}catch{ + $observed=Read-CcodInstallTransactionRecord -InstallRoot $InstallRoot -TransactionId $TransactionRecord.transactionId + if($null-eq$observed-or$observed.phase-cne'Ready'){Throw-CcodLifecycleError 'CCOD_INSTALL_READY_FINALIZATION_PENDING' 'Ready finalization snapshot could not be appended' $TransactionRecord.transactionId} + } + $observed=Read-CcodInstallTransactionRecord -InstallRoot $InstallRoot -TransactionId $TransactionRecord.transactionId + if($null-eq$observed-or$observed.phase-cne'Ready'){Throw-CcodLifecycleError 'CCOD_INSTALL_READY_FINALIZATION_PENDING' 'Ready finalization snapshot is not visible' $TransactionRecord.transactionId} + if([string]$proof.Validation.Manifest.projectVersion-ceq$script:CcodProductVersion){ + try{&$Adapters.CloseReadyGeneration $fileTransaction;$fileTransaction=$null;$productAdapters=&$Adapters.GetProductRegistrationAdapters;$registration=&$Adapters.RegisterProduct $InstallRoot ([string]$observed.newRuntimeId) ([string]$proof.Validation.Manifest.projectVersion) ([string]$observed.sealedPackageSha256) $null $observed $productAdapters $Adapters.CloseProductTransaction;if($null-eq$registration-or$registration.verified-isnot[bool]-or-not$registration.verified){throw 'registration receipt invalid'};$productRegistrationVerified=$true}catch{$productRegistrationFailure=$_} + } + }catch{ + try{$observed=Read-CcodInstallTransactionRecord -InstallRoot $InstallRoot -TransactionId $TransactionRecord.transactionId}catch{} + if($null-eq$observed-or$observed.phase-cne'Ready'){ + if((Get-CcodLifecycleErrorId $_)-in@('CCOD_INSTALL_READY_RECOVERY_INVALID','CCOD_INSTALL_READY_FINALIZATION_PENDING')){throw} + Throw-CcodLifecycleError 'CCOD_INSTALL_READY_FINALIZATION_PENDING' 'Ready finalization snapshot requires another bounded recovery attempt' $TransactionRecord.transactionId + } + }finally{ + if($null-ne$fileTransaction){try{Close-CcodInstallFileTransaction -Transaction $fileTransaction -Disposition $(if($null-ne$observed-and$observed.phase-ceq'Ready'){'Ready'}else{'Failed'})}catch{}} + } + if($null-ne$productRegistrationFailure){if(([string]$productRegistrationFailure.FullyQualifiedErrorId-split',')[0]-ceq'CCOD_PRODUCT_REGISTRATION_FAILED'){throw $productRegistrationFailure};Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Recovered runtime is Ready but product registration failed; legacy state was retained' $TransactionRecord.newRuntimeId} + $ready=Read-CcodInstallTransactionRecord -InstallRoot $InstallRoot -TransactionId $TransactionRecord.transactionId + if($null-eq$ready-or$ready.phase-cne'Ready'){Throw-CcodLifecycleError 'CCOD_INSTALL_READY_FINALIZATION_PENDING' 'Ready finalization snapshot is not durable' $TransactionRecord.transactionId} + return [pscustomobject][ordered]@{Outcome='Recovered';Installed=$true;RuntimeId=[string]$ready.newRuntimeId;PreviousRuntimeId=$ready.oldRuntimeId;RepairCompleted=$false;ProductRegistrationVerified=[bool]$productRegistrationVerified} +} + +function Test-CcodInstallPackageIdentity { + param($TransactionRecord,[Parameter(Mandatory)][string]$ProjectVersion,[Parameter(Mandatory)][string]$ActiveProjectVersion,[Parameter(Mandatory)][string]$SealedPackageSha256,[string]$ActiveRuntimeId,[uint64]$ActiveGeneration) + if ($SealedPackageSha256 -cnotmatch '^[0-9a-f]{64}$') { Throw-CcodLifecycleError 'CCOD_INSTALL_PACKAGE_IDENTITY_INVALID' 'Sealed package SHA-256 is invalid' $null } + if ($ProjectVersion -cne $ActiveProjectVersion -or $null -eq $TransactionRecord) { return $false } + [void](Assert-CcodInstallTransactionRecord $TransactionRecord) + if ([string]$TransactionRecord.sealedPackageSha256 -cne $SealedPackageSha256) { Throw-CcodLifecycleError 'CCOD_INSTALL_PACKAGE_CONFLICT' 'The installed version is bound to different sealed package bytes' $null } + return ([string]$TransactionRecord.phase -ceq 'Ready' -and [string]$TransactionRecord.newRuntimeId -ceq $ActiveRuntimeId -and [uint64]$TransactionRecord.newGeneration -eq $ActiveGeneration) +} + +function Test-CcodLegacyUpgradePlaneMissing { + param([Parameter(Mandatory)][string]$InstallRoot,[Parameter(Mandatory)][string]$RelativePath) + $path = [IO.Path]::GetFullPath((Join-Path $InstallRoot $RelativePath)) + [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $path) + try { + $item = Get-Item -LiteralPath $path -Force -ErrorAction Stop + if ($null -eq $item) { throw [IO.InvalidDataException]::new('path lookup returned no object') } + return $false + } catch [Management.Automation.ItemNotFoundException] { + return $true + } catch { + Throw-CcodLifecycleError 'CCOD_INSTALL_UPGRADE_SOURCE_INVALID' 'Upgrade compatibility plane presence could not be proven' $path + } +} + +function Get-CcodLegacyRuntimeValidation { + param( + [Parameter(Mandatory)][string]$InstallRoot, + [Parameter(Mandatory)]$ExistingPointer, + [Parameter(Mandatory)]$ActiveValidation + ) + + $runtimeRoot = [IO.Path]::GetFullPath((Join-Path (Join-Path $InstallRoot 'runtime') ([string]$ExistingPointer.activeRuntime))) + [void](Assert-CcodLifecycleInstallTreeSafe -InstallRoot $InstallRoot -Path $runtimeRoot) + $manifestPath = Join-Path $runtimeRoot 'manifest.json' + [void](Assert-CcodLifecycleInstallLeafSafe -InstallRoot $InstallRoot -Path $manifestPath) + $manifest = Read-CcodStrictJson -Path $manifestPath -ExpectedSchema 1 -Kind 'legacy runtime manifest' + if (-not (Test-CcodLifecycleOrderedProperties $manifest @('schemaVersion','projectVersion','runtimeId','files')) -or + $manifest.schemaVersion -isnot [int] -or $manifest.schemaVersion -ne 1 -or + $manifest.projectVersion -isnot [string] -or $script:CcodLegacyUpgradeProjectVersions -cnotcontains [string]$manifest.projectVersion -or + $manifest.runtimeId -isnot [string] -or [string]$manifest.runtimeId -cne [string]$ExistingPointer.activeRuntime -or + $manifest.files -isnot [Array]) { + Throw-CcodLifecycleError 'CCOD_INSTALL_UPGRADE_SOURCE_INVALID' 'Legacy runtime manifest profile is not supported' $manifestPath + } + if ($null -eq $ActiveValidation -or $ActiveValidation.Valid -isnot [bool] -or $ActiveValidation.Valid -or + $ActiveValidation.Code -isnot [string] -or $ActiveValidation.Code -cne 'CCOD_RUNTIME_ID_MISMATCH' -or + $ActiveValidation.RuntimeId -isnot [string] -or $ActiveValidation.RuntimeId -cne [string]$manifest.runtimeId -or + $null -eq $ActiveValidation.Manifest -or + ($ActiveValidation.Manifest | ConvertTo-Json -Depth 16 -Compress) -cne ($manifest | ConvertTo-Json -Depth 16 -Compress)) { + Throw-CcodLifecycleError 'CCOD_INSTALL_UPGRADE_SOURCE_INVALID' 'Only the exact historical runtime-ID validation mismatch may enter legacy compatibility' $runtimeRoot + } + + # v2.5.21 Get-CcodRuntimeId used version + the first 16 hex characters of + # the sorted manifest-record digest and did not yet include a nonce. + $canonicalLines = [Collections.Generic.List[string]]::new() + $previousPath = $null + foreach ($file in @($manifest.files)) { + if (-not (Test-CcodLifecycleOrderedProperties $file @('path','length','sha256')) -or + $file.path -isnot [string] -or [string]::IsNullOrWhiteSpace([string]$file.path) -or + [IO.Path]::IsPathRooted([string]$file.path) -or [string]$file.path -match '\\' -or + [string]$file.path -match '(^|/)(\.|\.\.)(/|$)' -or [string]$file.path -match '//' -or + [string]$file.path -ceq 'manifest.json' -or + -not (Test-CcodLifecycleProductCleanupInteger $file.length) -or [int64]$file.length -lt 0 -or + $file.sha256 -isnot [string] -or [string]$file.sha256 -cnotmatch '^[0-9a-f]{64}$' -or + ($null -ne $previousPath -and [StringComparer]::Ordinal.Compare($previousPath,[string]$file.path) -ge 0)) { + Throw-CcodLifecycleError 'CCOD_INSTALL_UPGRADE_SOURCE_INVALID' 'Legacy runtime manifest file records are not canonical' $manifestPath + } + $previousPath = [string]$file.path + $canonicalLines.Add(('{0}`t{1}`t{2}' -f [string]$file.path,[int64]$file.length,[string]$file.sha256)) + } + $sha = [Security.Cryptography.SHA256]::Create() + try { + $digest = [BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes(($canonicalLines -join "`n")))).Replace('-','').ToLowerInvariant() + } finally { $sha.Dispose() } + $historicalRuntimeId = '{0}-{1}' -f [string]$manifest.projectVersion,$digest.Substring(0,16) + if ($historicalRuntimeId -cne [string]$manifest.runtimeId) { + Throw-CcodLifecycleError 'CCOD_INSTALL_UPGRADE_SOURCE_INVALID' 'Legacy runtime ID is not bound by the historical manifest algorithm' $manifestPath + } + return [pscustomobject]@{Valid=$true;Code='CCOD_RUNTIME_VALID';RuntimeId=[string]$manifest.runtimeId;Manifest=$manifest} +} + +function Get-CcodLegacyActiveEvidence { + param([Parameter(Mandatory)][string]$InstallRoot,[Parameter(Mandatory)][string]$ExpectedRuntimeId,[Parameter(Mandatory)]$ActiveValidation) + $activePath=Join-Path $InstallRoot 'active.json' + [void](Assert-CcodLifecycleInstallLeafSafe -InstallRoot $InstallRoot -Path $activePath) + $active=Read-CcodStrictJson -Path $activePath -ExpectedSchema 2 -Kind 'legacy active runtime' + if(-not(Test-CcodLifecycleOrderedProperties $active @('schemaVersion','activeRuntime','previousRuntime','generation','updatedAtUtc'))-or + $active.schemaVersion-isnot[int]-or$active.schemaVersion-ne2-or$active.activeRuntime-isnot[string]-or$active.activeRuntime-cne$ExpectedRuntimeId-or + $null-ne$active.previousRuntime-or-not(Test-CcodLifecycleProductCleanupInteger $active.generation)-or[uint64]$active.generation-ne1-or + -not(Test-CcodLifecycleCanonicalUtc $active.updatedAtUtc)){ + Throw-CcodLifecycleError 'CCOD_INSTALL_UPGRADE_SOURCE_INVALID' 'Legacy active runtime evidence is not the exact supported profile' $activePath + } + $validation=Get-CcodLegacyRuntimeValidation -InstallRoot $InstallRoot -ExistingPointer $active -ActiveValidation $ActiveValidation + $manifestPath=Join-Path (Join-Path (Join-Path $InstallRoot 'runtime') $ExpectedRuntimeId) 'manifest.json' + return [pscustomobject][ordered]@{Active=$active;Validation=$validation;ManifestSha256=(Get-CcodLifecycleFileSha256 -Path $manifestPath)} +} + +function Get-CcodLegacyMigrationRetrySelectorProfile { + param([Parameter(Mandatory)][string]$InstallRoot,[Parameter(Mandatory)]$ExistingPointer,[Parameter(Mandatory)][string]$LegacyRuntimeId,[Parameter(Mandatory)][string]$RetryRuntimeId) + $selectorRoot=Join-Path $InstallRoot 'state\active-generation' + [void](Assert-CcodLifecycleInstallTreeSafe -InstallRoot $InstallRoot -Path $selectorRoot) + $items=@(Get-ChildItem -LiteralPath $selectorRoot -Force -ErrorAction Stop|Sort-Object Name) + if($items.Count-notin@(1,3)){Throw-CcodLifecycleError 'CCOD_INSTALL_UPGRADE_SOURCE_INVALID' 'Legacy migration retry selector count is unsupported' $selectorRoot} + for($index=0;$index-lt$items.Count;$index++){ + $item=$items[$index];$generation=[uint64]($index+1);$leaf='{0:D20}.json'-f$generation + $expectedRuntimeId=if($index-eq1){$RetryRuntimeId}else{$LegacyRuntimeId} + if($item.PSIsContainer-or$item.Name-cne$leaf){Throw-CcodLifecycleError 'CCOD_INSTALL_UPGRADE_SOURCE_INVALID' 'Legacy migration retry selector contains an unknown object' $item.FullName} + [void](Assert-CcodLifecycleInstallLeafSafe -InstallRoot $InstallRoot -Path $item.FullName) + $record=Read-CcodStrictJson -Path $item.FullName -ExpectedSchema 1 -Kind 'legacy migration retry selector' + if(-not(Test-CcodLifecycleOrderedProperties $record @('schemaVersion','generation','activeRuntime','previousGeneration'))-or + $record.schemaVersion-isnot[int]-or$record.schemaVersion-ne1-or-not(Test-CcodLifecycleProductCleanupInteger $record.generation)-or[uint64]$record.generation-ne$generation-or + $record.activeRuntime-isnot[string]-or$record.activeRuntime-cne$expectedRuntimeId-or + -not(Test-CcodLifecycleProductCleanupInteger $record.previousGeneration)-or[uint64]$record.previousGeneration-ne[uint64]$index){ + Throw-CcodLifecycleError 'CCOD_INSTALL_UPGRADE_SOURCE_INVALID' 'Legacy migration retry selector identity is invalid' $item.FullName + } + } + $profile=if($items.Count-eq1){'PrePointer'}else{'PostPointerCompensated'} + $expectedPrevious=if($items.Count-eq1){$null}else{$RetryRuntimeId} + if($ExistingPointer.activeRuntime-cne$LegacyRuntimeId-or[uint64]$ExistingPointer.generation-ne[uint64]$items.Count-or[string]$ExistingPointer.previousRuntime-cne[string]$expectedPrevious){ + Throw-CcodLifecycleError 'CCOD_INSTALL_UPGRADE_SOURCE_INVALID' 'Selected legacy migration retry pointer does not match its canonical chain' $ExistingPointer + } + return [pscustomobject][ordered]@{Profile=$profile;CurrentGeneration=[uint64]$items.Count;RetryGeneration=[uint64]($items.Count+1)} +} + +function Get-CcodLegacyMigrationRetryContext { + param( + [Parameter(Mandatory)][string]$InstallRoot,[Parameter(Mandatory)]$ExistingPointer,[Parameter(Mandatory)]$ActiveValidation, + [Parameter(Mandatory)]$FailedTransaction,[Parameter(Mandatory)][string]$LegacyManifestSha256 + ) + [void](Assert-CcodInstallTransactionRecord -Record $FailedTransaction) + if($FailedTransaction.phase-cne'Failed'-or$FailedTransaction.oldRuntimeId-cne[string]$ExistingPointer.activeRuntime-or + [uint64]$FailedTransaction.oldGeneration-ne1-or$FailedTransaction.oldManifestSha256-cne$LegacyManifestSha256-or + [uint64]$FailedTransaction.newGeneration-ne2-or$FailedTransaction.newRuntimeId-isnot[string]-or$FailedTransaction.newRuntimeId-ceq$FailedTransaction.oldRuntimeId-or + @($FailedTransaction.ownedObjectNames).Count-ne1-or[string]$FailedTransaction.ownedObjectNames[0]-cne[string]$FailedTransaction.newRuntimeId){ + Throw-CcodLifecycleError 'CCOD_INSTALL_UPGRADE_SOURCE_INVALID' 'Failed legacy migration transaction identity is invalid' $FailedTransaction + } + $legacy=Get-CcodLegacyActiveEvidence -InstallRoot $InstallRoot -ExpectedRuntimeId ([string]$FailedTransaction.oldRuntimeId) -ActiveValidation $ActiveValidation + if($legacy.ManifestSha256-cne$LegacyManifestSha256){Throw-CcodLifecycleError 'CCOD_INSTALL_UPGRADE_SOURCE_INVALID' 'Legacy migration source manifest changed after failure' $FailedTransaction.oldRuntimeId} + + $transactionRoot=Join-Path $InstallRoot 'state\install-transactions' + [void](Assert-CcodLifecycleInstallTreeSafe -InstallRoot $InstallRoot -Path $transactionRoot) + $snapshots=@(Get-ChildItem -LiteralPath $transactionRoot -Force -ErrorAction Stop) + if($snapshots.Count-eq0){Throw-CcodLifecycleError 'CCOD_INSTALL_UPGRADE_SOURCE_INVALID' 'Failed legacy migration transaction chain is empty' $transactionRoot} + $observedPhases=[Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + foreach($snapshot in $snapshots){ + if($snapshot.PSIsContainer){Throw-CcodLifecycleError 'CCOD_INSTALL_UPGRADE_SOURCE_INVALID' 'Failed legacy migration transaction store contains a foreign object' $snapshot.FullName} + [void](Assert-CcodLifecycleInstallLeafSafe -InstallRoot $InstallRoot -Path $snapshot.FullName) + $record=Read-CcodStrictJson -Path $snapshot.FullName -ExpectedSchema 1 -Kind 'failed legacy migration transaction' + [void](Assert-CcodInstallTransactionRecord -Record $record) + if($record.transactionId-cne$FailedTransaction.transactionId-or(Get-CcodInstallTransactionRecordLeaf -Record $record)-cne$snapshot.Name){ + Throw-CcodLifecycleError 'CCOD_INSTALL_UPGRADE_SOURCE_INVALID' 'Failed legacy migration transaction store contains a foreign chain' $snapshot.FullName + } + [void]$observedPhases.Add([string]$record.phase) + } + if(-not$observedPhases.Contains('RuntimePromoted')){Throw-CcodLifecycleError 'CCOD_INSTALL_UPGRADE_SOURCE_INVALID' 'Failed legacy migration never proved the reusable runtime promoted' $FailedTransaction.transactionId} + + $retryRuntimeRoot=Join-Path (Join-Path $InstallRoot 'runtime') ([string]$FailedTransaction.newRuntimeId) + [void](Assert-CcodLifecycleInstallTreeSafe -InstallRoot $InstallRoot -Path $retryRuntimeRoot) + $retryManifestPath=Join-Path $retryRuntimeRoot 'manifest.json' + [void](Assert-CcodLifecycleInstallLeafSafe -InstallRoot $InstallRoot -Path $retryManifestPath) + if((Get-CcodLifecycleFileSha256 -Path $retryManifestPath)-cne[string]$FailedTransaction.newManifestSha256){Throw-CcodLifecycleError 'CCOD_INSTALL_UPGRADE_SOURCE_INVALID' 'Failed legacy migration runtime manifest identity changed' $retryManifestPath} + $retryValidation=Test-CcodRuntimeManifest -RuntimeDirectory $retryRuntimeRoot -ExpectedRuntimeId ([string]$FailedTransaction.newRuntimeId) -ExpectedManifestSha256 ([string]$FailedTransaction.newManifestSha256) + if(-not$retryValidation.Valid-or-not(Test-CcodLifecycleOrderedProperties $retryValidation.Manifest @('schemaVersion','projectVersion','runtimeId','files'))-or + $retryValidation.Manifest.projectVersion-cne$script:CcodProductVersion-or$retryValidation.Manifest.runtimeId-cne[string]$FailedTransaction.newRuntimeId){ + Throw-CcodLifecycleError 'CCOD_INSTALL_UPGRADE_SOURCE_INVALID' 'Failed legacy migration runtime is not an exact current sealed generation' $retryRuntimeRoot + } + $selector=Get-CcodLegacyMigrationRetrySelectorProfile -InstallRoot $InstallRoot -ExistingPointer $ExistingPointer -LegacyRuntimeId ([string]$FailedTransaction.oldRuntimeId) -RetryRuntimeId ([string]$FailedTransaction.newRuntimeId) + if($selector.Profile-ceq'PrePointer'-and$observedPhases.Contains('PointerCommitted')){ + Throw-CcodLifecycleError 'CCOD_INSTALL_UPGRADE_SOURCE_INVALID' 'Failed migration phases do not match the exact selector profile' $FailedTransaction.transactionId + } + return [pscustomobject][ordered]@{ + Kind='LegacyMigrationRetry';Profile=$selector.Profile;ActiveRuntimeId=[string]$FailedTransaction.oldRuntimeId;ActiveGeneration=[uint64]$selector.CurrentGeneration + ProjectVersion=[string]$legacy.Validation.Manifest.projectVersion;ManifestSha256=$legacy.ManifestSha256;Manifest=$legacy.Validation.Manifest;ActiveValidation=$legacy.Validation + FailedTransaction=$FailedTransaction;RetryRuntimeId=[string]$FailedTransaction.newRuntimeId;RetryGeneration=[uint64]$selector.RetryGeneration + RetryManifestSha256=[string]$FailedTransaction.newManifestSha256;RetryValidation=$retryValidation + } +} + +function Assert-CcodLegacyMigrationRetryPackage { + param([Parameter(Mandatory)]$CompatibilityContext,[Parameter(Mandatory)][string]$ProjectVersion,[Parameter(Mandatory)][string]$SealedPackageSha256,[Parameter(Mandatory)][object[]]$Files) + if($CompatibilityContext.Kind-cne'LegacyMigrationRetry'-or$ProjectVersion-cne$script:CcodProductVersion-or + $SealedPackageSha256-cne[string]$CompatibilityContext.FailedTransaction.sealedPackageSha256){ + Throw-CcodLifecycleError 'CCOD_INSTALL_UPGRADE_SOURCE_INVALID' 'Retry input does not match the failed legacy migration package identity' $CompatibilityContext + } + $records=[Collections.Generic.List[object]]::new() + foreach($file in $Files){$records.Add([pscustomobject][ordered]@{path=([string]$file.Relative).Replace('\','/');length=$(if($null-ne$file.PSObject.Properties['ExpectedLength']){[int64]$file.ExpectedLength}else{[int64](Get-Item -LiteralPath $file.Source -Force).Length});sha256=$(if($null-ne$file.PSObject.Properties['ExpectedSha256']){[string]$file.ExpectedSha256}else{Get-CcodLifecycleFileSha256 -Path $file.Source})})} + $comparison=[Comparison[object]]{param($left,$right)[StringComparer]::Ordinal.Compare([string]$left.path,[string]$right.path)};$records.Sort($comparison) + $expected=@($CompatibilityContext.RetryValidation.Manifest.files);$actual=@($records) + if($actual.Count-ne$expected.Count){Throw-CcodLifecycleError 'CCOD_INSTALL_UPGRADE_SOURCE_INVALID' 'Retry source file set differs from the failed sealed runtime' $CompatibilityContext.RetryRuntimeId} + for($index=0;$index-lt$actual.Count;$index++){if($actual[$index].path-cne$expected[$index].path-or[int64]$actual[$index].length-ne[int64]$expected[$index].length-or$actual[$index].sha256-cne$expected[$index].sha256){Throw-CcodLifecycleError 'CCOD_INSTALL_UPGRADE_SOURCE_INVALID' 'Retry source bytes differ from the failed sealed runtime' $actual[$index].path}} + return $true +} + +function Get-CcodLegacyUpgradeCompatibilityContext { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$InstallRoot, + [Parameter(Mandatory)]$ExistingPointer, + [Parameter(Mandatory)]$ActiveValidation, + [AllowNull()]$GlobalTransaction + ) + + $root = Get-CcodLifecycleCanonicalRoot -Path $InstallRoot -Kind 'Install root' + try { + if (-not (Test-CcodLifecycleOrderedProperties $ExistingPointer @('schemaVersion','activeRuntime','previousRuntime','generation','updatedAtUtc')) -or + $ExistingPointer.schemaVersion -isnot [int] -or $ExistingPointer.schemaVersion -ne 2 -or + $ExistingPointer.activeRuntime -isnot [string] -or $ExistingPointer.activeRuntime -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$' -or + -not (Test-CcodLifecycleProductCleanupInteger $ExistingPointer.generation) -or [uint64]$ExistingPointer.generation -eq 0) { + throw 'active pointer contract' + } + $runtimeRoot = [IO.Path]::GetFullPath((Join-Path (Join-Path $root 'runtime') ([string]$ExistingPointer.activeRuntime))) + $manifestPath = Join-Path $runtimeRoot 'manifest.json' + [void](Assert-CcodLifecycleInstallTreeSafe -InstallRoot $root -Path $runtimeRoot) + [void](Assert-CcodLifecycleInstallLeafSafe -InstallRoot $root -Path $manifestPath) + $manifestSha256 = Get-CcodLifecycleFileSha256 -Path $manifestPath + $selectorMissing = Test-CcodLegacyUpgradePlaneMissing -InstallRoot $root -RelativePath 'state\active-generation' + $transactionPlaneMissing = Test-CcodLegacyUpgradePlaneMissing -InstallRoot $root -RelativePath 'state\install-transactions' + + if (-not $selectorMissing -and -not $transactionPlaneMissing) { + [void](Assert-CcodInstallTransactionRecord -Record $GlobalTransaction) + if($GlobalTransaction.phase-ceq'Failed'){ + if(-not(Test-CcodLegacyUpgradePlaneMissing -InstallRoot $root -RelativePath 'state\product-cleanup-fences')){throw 'failed migration has a cleanup fence'} + return Get-CcodLegacyMigrationRetryContext -InstallRoot $root -ExistingPointer $ExistingPointer -ActiveValidation $ActiveValidation -FailedTransaction $GlobalTransaction -LegacyManifestSha256 $manifestSha256 + } + if ($GlobalTransaction.phase -cne 'Ready' -or + $ActiveValidation.Valid -isnot [bool] -or -not $ActiveValidation.Valid -or + $ActiveValidation.RuntimeId -cne [string]$ExistingPointer.activeRuntime -or + $GlobalTransaction.newRuntimeId -cne [string]$ExistingPointer.activeRuntime -or + [uint64]$GlobalTransaction.newGeneration -ne [uint64]$ExistingPointer.generation -or + $GlobalTransaction.newManifestSha256 -cne $manifestSha256 -or + $ActiveValidation.Manifest.runtimeId -cne [string]$ExistingPointer.activeRuntime) { + throw 'current Ready identity' + } + return [pscustomobject][ordered]@{ + Kind='CurrentReady';ActiveRuntimeId=[string]$ExistingPointer.activeRuntime;ActiveGeneration=[uint64]$ExistingPointer.generation + ProjectVersion=[string]$ActiveValidation.Manifest.projectVersion;ManifestSha256=$manifestSha256;Manifest=$ActiveValidation.Manifest;ActiveValidation=$ActiveValidation + } + } + if ($selectorMissing -ne $transactionPlaneMissing -or $null -ne $GlobalTransaction -or + -not (Test-CcodLegacyUpgradePlaneMissing -InstallRoot $root -RelativePath 'state\product-cleanup-fences')) { + throw 'mixed legacy and append-only state' + } + + $activePath = Join-Path $root 'active.json' + [void](Assert-CcodLifecycleInstallLeafSafe -InstallRoot $root -Path $activePath) + $legacyActive = Read-CcodStrictJson -Path $activePath -ExpectedSchema 2 -Kind 'legacy active runtime' + if (-not (Test-CcodLifecycleOrderedProperties $legacyActive @('schemaVersion','activeRuntime','previousRuntime','generation','updatedAtUtc')) -or + $legacyActive.schemaVersion -isnot [int] -or $legacyActive.schemaVersion -ne 2 -or + $legacyActive.activeRuntime -isnot [string] -or $legacyActive.activeRuntime -cne [string]$ExistingPointer.activeRuntime -or + $null -ne $legacyActive.previousRuntime -or $null -ne $ExistingPointer.previousRuntime -or + -not (Test-CcodLifecycleProductCleanupInteger $legacyActive.generation) -or [uint64]$legacyActive.generation -ne 1 -or [uint64]$legacyActive.generation -ne [uint64]$ExistingPointer.generation -or + -not (Test-CcodLifecycleCanonicalUtc $legacyActive.updatedAtUtc) -or $legacyActive.updatedAtUtc -cne [string]$ExistingPointer.updatedAtUtc) { + throw 'legacy active pointer identity' + } + $legacyValidation = Get-CcodLegacyRuntimeValidation -InstallRoot $root -ExistingPointer $ExistingPointer -ActiveValidation $ActiveValidation + return [pscustomobject][ordered]@{ + Kind='ProvenLegacyWithoutReady';ActiveRuntimeId=[string]$ExistingPointer.activeRuntime;ActiveGeneration=[uint64]$ExistingPointer.generation + ProjectVersion=[string]$legacyValidation.Manifest.projectVersion;ManifestSha256=$manifestSha256;Manifest=$legacyValidation.Manifest;ActiveValidation=$legacyValidation + } + } catch { + if ((Get-CcodLifecycleErrorId $_) -ceq 'CCOD_INSTALL_UPGRADE_SOURCE_INVALID') { throw } + Throw-CcodLifecycleError 'CCOD_INSTALL_UPGRADE_SOURCE_INVALID' 'Existing installation is not an exact supported upgrade source' $root + } +} + +function Initialize-CcodLegacyUpgradeSelector { + param([Parameter(Mandatory)][string]$InstallRoot,[Parameter(Mandatory)]$CompatibilityContext,[Parameter(Mandatory)]$FileTransaction) + if ($CompatibilityContext.Kind -cne 'ProvenLegacyWithoutReady' -or [uint64]$CompatibilityContext.ActiveGeneration -ne 1) { + Throw-CcodLifecycleError 'CCOD_INSTALL_UPGRADE_SOURCE_INVALID' 'Supported legacy selector migration requires exact generation one' $CompatibilityContext + } + # Publish a generation-one selector for the retained legacy runtime before + # the ordinary generation-two pointer commit. active.json remains evidence. + $retained = Open-CcodInstallRetainedGeneration -InstallRoot $InstallRoot -RuntimeId $CompatibilityContext.ActiveRuntimeId -ExpectedManifestSha256 $CompatibilityContext.ManifestSha256 -FileTransaction $FileTransaction + [void](Commit-CcodInstallActivePointer -InstallRoot $InstallRoot -TargetGeneration $retained -ExpectedPreviousGeneration ([uint64]0) -FileTransaction $FileTransaction) + $pointer = Read-CcodActiveRuntime -InstallRoot $InstallRoot + if ($pointer.activeRuntime -cne $CompatibilityContext.ActiveRuntimeId -or [uint64]$pointer.generation -ne 1) { + Throw-CcodLifecycleError 'CCOD_INSTALL_UPGRADE_SOURCE_INVALID' 'Legacy selector migration could not be proven' $CompatibilityContext.ActiveRuntimeId + } + return $pointer +} + +function Assert-CcodLifecycleInstallPathSafe { + param( + [Parameter(Mandatory)][string]$InstallRoot, + [Parameter(Mandatory)][string]$Path + ) + + $root = Get-CcodLifecycleCanonicalRoot -Path $InstallRoot -Kind 'Install root' + $candidate = [IO.Path]::GetFullPath($Path) + $prefix = $root.TrimEnd('\') + '\' + if (-not ($candidate.Equals($root,[StringComparison]::OrdinalIgnoreCase) -or $candidate.StartsWith($prefix,[StringComparison]::OrdinalIgnoreCase))) { + Throw-CcodLifecycleError 'CCOD_INSTALL_PATH_OUTSIDE_ROOT' 'Install path escaped the install root' $candidate + } + $cursor = $candidate + while (-not (Test-Path -LiteralPath $cursor)) { + $parent = Split-Path $cursor -Parent + if ([string]::IsNullOrWhiteSpace($parent) -or $parent -ceq $cursor) { + Throw-CcodLifecycleError 'CCOD_INSTALL_REPARSE_PATH' 'Install path has no safe existing ancestor' $candidate + } + $cursor = $parent + } + while ($true) { + $item = Get-Item -LiteralPath $cursor -Force -ErrorAction Stop + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + Throw-CcodLifecycleError 'CCOD_INSTALL_REPARSE_PATH' 'Install path contains a reparse point' $cursor + } + $parent = Split-Path $cursor -Parent + if ([string]::IsNullOrWhiteSpace($parent) -or $parent -ceq $cursor) { break } + $cursor = $parent + } + return $candidate +} + +function Assert-CcodLifecycleInstallTreeSafe { + param( + [Parameter(Mandatory)][string]$InstallRoot, + [Parameter(Mandatory)][string]$Path + ) + + $tree = Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $Path + if (-not [IO.Directory]::Exists($tree)) { return $tree } + $prefix = $tree.TrimEnd('\') + '\' + $pending = [Collections.Generic.Stack[string]]::new() + $pending.Push($tree) + while ($pending.Count -gt 0) { + $directory = $pending.Pop() + foreach ($item in @(Get-ChildItem -LiteralPath $directory -Force -ErrorAction Stop)) { + $full = [IO.Path]::GetFullPath($item.FullName) + if (-not $full.StartsWith($prefix,[StringComparison]::OrdinalIgnoreCase) -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + Throw-CcodLifecycleError 'CCOD_INSTALL_REPARSE_PATH' 'Install tree contains a reparse point or escaped entry' $full + } + if ($item.PSIsContainer) { $pending.Push($full) } + else { [void](Assert-CcodLifecycleInstallLeafSafe -InstallRoot $InstallRoot -Path $full) } + } + } + return $tree +} + function Test-CcodLifecycleReparse { param([Parameter(Mandatory)][string]$Path) if (-not (Test-Path -LiteralPath $Path)) { return $false } @@ -274,6 +1568,7 @@ function Get-CcodLifecycleSourceFiles { 'src\persistence\bootstrap.ps1', 'src\persistence\UninstallBootstrap.ps1', 'src\persistence\PortableUninstallFinalizer.ps1', + 'src\persistence\InstalledUninstallFinalizer.ps1', 'Test-CodexControlOtherDevices.ps1', 'Start-CodexControlOtherDevices.ps1', 'Reset-CodexControlOtherDevices.ps1' @@ -363,6 +1658,232 @@ function Get-CcodLifecycleSourceFiles { return @($files) } +function Get-CcodLifecycleFileLinkCount { + param([Parameter(Mandatory)][string]$Path) + + try { + if ($null -eq ('CcodLifecycleFileIdentityNative' -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +public static class CcodLifecycleFileIdentityNative +{ + [StructLayout(LayoutKind.Sequential)] + public struct FILETIME { public uint Low; public uint High; } + + [StructLayout(LayoutKind.Sequential)] + public struct BY_HANDLE_FILE_INFORMATION + { + public uint FileAttributes; + public FILETIME CreationTime; + public FILETIME LastAccessTime; + public FILETIME LastWriteTime; + public uint VolumeSerialNumber; + public uint FileSizeHigh; + public uint FileSizeLow; + public uint NumberOfLinks; + public uint FileIndexHigh; + public uint FileIndexLow; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFileW(string name, uint access, uint share, IntPtr security, uint creation, uint flags, IntPtr template); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetFileInformationByHandle(SafeFileHandle handle, out BY_HANDLE_FILE_INFORMATION information); + + public static uint GetLinkCount(string path) + { + const uint FILE_READ_ATTRIBUTES = 0x80; + const uint FILE_SHARE_READ = 1, FILE_SHARE_WRITE = 2, FILE_SHARE_DELETE = 4; + const uint OPEN_EXISTING = 3, FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000; + using (SafeFileHandle handle = CreateFileW(path, FILE_READ_ATTRIBUTES, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + IntPtr.Zero, OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT, IntPtr.Zero)) + { + if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error()); + BY_HANDLE_FILE_INFORMATION information; + if (!GetFileInformationByHandle(handle, out information)) throw new Win32Exception(Marshal.GetLastWin32Error()); + return information.NumberOfLinks; + } + } +} +'@ + } + return [uint32][CcodLifecycleFileIdentityNative]::GetLinkCount([IO.Path]::GetFullPath($Path)) + } catch { + Throw-CcodLifecycleError 'CCOD_INSTALL_UNSAFE_LEAF' 'Unable to prove the install leaf link identity' $Path + } +} + +function Assert-CcodLifecycleInstallLeafSafe { + param( + [Parameter(Mandatory)][string]$InstallRoot, + [Parameter(Mandatory)][string]$Path, + [switch]$AllowMissing + ) + + $candidate = Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $Path + if (-not [IO.File]::Exists($candidate)) { + if ([IO.Directory]::Exists($candidate) -or -not $AllowMissing) { + Throw-CcodLifecycleError 'CCOD_INSTALL_UNSAFE_LEAF' 'Install leaf is missing or not a regular file' $candidate + } + return $candidate + } + $item = Get-Item -LiteralPath $candidate -Force -ErrorAction Stop + $hasAds = $true + try { $hasAds = Test-CcodLifecycleAlternateDataStreams -Path $candidate } catch { + Throw-CcodLifecycleError 'CCOD_INSTALL_UNSAFE_LEAF' 'Install leaf alternate streams could not be proven absent' $candidate + } + if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + $hasAds -or (Get-CcodLifecycleFileLinkCount -Path $candidate) -ne 1) { + Throw-CcodLifecycleError 'CCOD_INSTALL_UNSAFE_LEAF' 'Install leaf must be a regular non-reparse single-link file without alternate streams' $candidate + } + return $candidate +} + +function Copy-CcodLifecycleFileAtomically { + param([Parameter(Mandatory)][string]$Source,[Parameter(Mandatory)][string]$Destination) + + $destination = [IO.Path]::GetFullPath($Destination) + $parent = Split-Path $destination -Parent + [IO.Directory]::CreateDirectory($parent) | Out-Null + $temporary = Join-Path $parent ('.ccod-copy-' + [guid]::NewGuid().ToString('N') + '.tmp') + try { + [IO.File]::Copy([IO.Path]::GetFullPath($Source),$temporary,$false) + if ([IO.File]::Exists($destination)) { + [IO.File]::Replace($temporary,$destination,$null,$true) + } else { + [IO.File]::Move($temporary,$destination) + } + } finally { + if ([IO.File]::Exists($temporary)) { try { [IO.File]::Delete($temporary) } catch { } } + } +} + +function Get-CcodLifecycleBytesSha256 { + param([Parameter(Mandatory)][byte[]]$Bytes) + $sha = [Security.Cryptography.SHA256]::Create() + try { return [BitConverter]::ToString($sha.ComputeHash($Bytes)).Replace('-', '').ToLowerInvariant() } + finally { $sha.Dispose() } +} + +function Get-CcodLifecyclePayloadManifestFiles { + param( + [Parameter(Mandatory)][string]$SourceRoot, + [Parameter(Mandatory)][string]$ExpectedVersion, + [Parameter(Mandatory)][string]$PayloadManifestPath, + [Parameter(Mandatory)][string]$ExpectedPayloadManifestSha256, + [Parameter(Mandatory)][string]$PayloadManifestBytesBase64 + ) + + if ($ExpectedVersion -cnotmatch '^\d+\.\d+\.\d+$') { + Throw-CcodLifecycleError 'CCOD_INSTALL_INPUT_INVALID' 'ExpectedVersion must be a canonical three-part project version' $ExpectedVersion + } + $root = Get-CcodLifecycleCanonicalRoot -Path $SourceRoot -Kind 'Source root' + try { $manifestPath = [IO.Path]::GetFullPath($PayloadManifestPath) } + catch { Throw-CcodLifecycleError 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' 'Payload manifest path could not be canonicalized' $PayloadManifestPath } + $prefix = $root.TrimEnd('\') + '\' + if (-not $manifestPath.StartsWith($prefix,[StringComparison]::OrdinalIgnoreCase) -or -not [IO.File]::Exists($manifestPath)) { + Throw-CcodLifecycleError 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' 'Payload manifest must be a file inside the source root' $manifestPath + } + $cursor = $manifestPath + while ($true) { + if (Test-CcodLifecycleReparse -Path $cursor) { + Throw-CcodLifecycleError 'CCOD_INSTALL_SOURCE_REPARSE' 'Payload manifest path contains a reparse point' $cursor + } + if ($cursor.Equals($root,[StringComparison]::OrdinalIgnoreCase)) { break } + $cursor = Split-Path $cursor -Parent + if ([string]::IsNullOrWhiteSpace($cursor)) { + Throw-CcodLifecycleError 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' 'Payload manifest escaped the source root' $manifestPath + } + } + if ($ExpectedPayloadManifestSha256 -cnotmatch '^[0-9a-f]{64}$' -or [string]::IsNullOrWhiteSpace($PayloadManifestBytesBase64)) { + Throw-CcodLifecycleError 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' 'Payload manifest byte binding is missing or invalid' $manifestPath + } + try { + $manifestBytes = [Convert]::FromBase64String($PayloadManifestBytesBase64) + if ($manifestBytes.Length -le 0 -or $manifestBytes.Length -gt 4194304 -or + (Get-CcodLifecycleBytesSha256 -Bytes $manifestBytes) -cne $ExpectedPayloadManifestSha256) { throw 'manifest byte binding' } + $manifestText = [Text.UTF8Encoding]::new($false,$true).GetString($manifestBytes) + $manifest = $manifestText | ConvertFrom-Json -ErrorAction Stop + } + catch { Throw-CcodLifecycleError 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' 'Payload manifest is not valid JSON' $manifestPath } + if ($manifest -isnot [pscustomobject]) { + Throw-CcodLifecycleError 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' 'Payload manifest must be one JSON object' $manifestPath + } + $properties = @($manifest.PSObject.Properties.Name) + $expectedProperties = @('schemaVersion','projectVersion','files') + if ($properties.Count -ne $expectedProperties.Count) { + Throw-CcodLifecycleError 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' 'Payload manifest fields are invalid' $manifestPath + } + for ($index = 0; $index -lt $expectedProperties.Count; $index++) { + if ($properties[$index] -cne $expectedProperties[$index]) { + Throw-CcodLifecycleError 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' 'Payload manifest fields are not canonical' $manifestPath + } + } + if ($manifest.schemaVersion -isnot [int] -or $manifest.schemaVersion -ne 1 -or $manifest.projectVersion -isnot [string]) { + Throw-CcodLifecycleError 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' 'Payload manifest header is invalid' $manifestPath + } + if ($manifest.projectVersion -cne $ExpectedVersion) { + Throw-CcodLifecycleError 'CCOD_INSTALL_PAYLOAD_VERSION_MISMATCH' 'Payload manifest does not match the expected setup version' $manifestPath + } + $records = @($manifest.files) + if ($records.Count -eq 0) { + Throw-CcodLifecycleError 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' 'Payload manifest contains no source records' $manifestPath + } + $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + $files = [Collections.Generic.List[object]]::new() + $previousPath = $null + foreach ($record in $records) { + if ($record -isnot [pscustomobject] -or + @($record.PSObject.Properties.Name).Count -ne 3 -or + @($record.PSObject.Properties.Name)[0] -cne 'path' -or + @($record.PSObject.Properties.Name)[1] -cne 'length' -or + @($record.PSObject.Properties.Name)[2] -cne 'sha256' -or + $record.path -isnot [string] -or $record.path -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._/-]{0,255}$' -or + $record.path.Contains('//') -or $record.path.Contains('..') -or $record.path.Contains(':') -or $record.path.Contains('\') -or + $record.length -isnot [ValueType] -or [int64]$record.length -lt 0 -or + $record.sha256 -isnot [string] -or $record.sha256 -cnotmatch '^[0-9a-f]{64}$') { + Throw-CcodLifecycleError 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' 'Payload manifest file record is invalid' $manifestPath + } + if (($null -ne $previousPath -and [StringComparer]::Ordinal.Compare($previousPath,[string]$record.path) -ge 0) -or -not $seen.Add([string]$record.path)) { + Throw-CcodLifecycleError 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' 'Payload manifest file records are not strictly ordered and unique' $manifestPath + } + $previousPath = [string]$record.path + $source = [IO.Path]::GetFullPath((Join-Path $root ($record.path.Replace('/','\')))) + if (-not $source.StartsWith($prefix,[StringComparison]::OrdinalIgnoreCase) -or -not [IO.File]::Exists($source)) { + Throw-CcodLifecycleError 'CCOD_INSTALL_SOURCE_INCOMPLETE' 'A manifest-listed source file is missing' $source + } + $cursor = $source + while ($true) { + if (Test-CcodLifecycleReparse -Path $cursor) { + Throw-CcodLifecycleError 'CCOD_INSTALL_SOURCE_REPARSE' 'A manifest-listed source path contains a reparse point' $cursor + } + if ($cursor.Equals($root,[StringComparison]::OrdinalIgnoreCase)) { break } + $cursor = Split-Path $cursor -Parent + } + $item = Get-Item -LiteralPath $source -Force -ErrorAction Stop + if ($item.PSIsContainer -or (Test-CcodLifecycleAlternateDataStreams -Path $source)) { + Throw-CcodLifecycleError 'CCOD_INSTALL_SOURCE_INCOMPLETE' 'A manifest-listed source file is not a plain data-stream file' $source + } + if ([int64]$item.Length -ne [int64]$record.length -or + (Get-CcodLifecycleFileSha256 -Path $source) -cne [string]$record.sha256) { + Throw-CcodLifecycleError 'CCOD_INSTALL_FILE_HASH_MISMATCH' 'A manifest-listed source file failed immutable payload validation' $source + } + $files.Add([pscustomobject][ordered]@{ + Relative = ([string]$record.path).Replace('/','\') + Source = $source + ExpectedLength = [int64]$record.length + ExpectedSha256 = [string]$record.sha256 + }) + } + return @($files) +} + function Copy-CcodLifecycleStaging { param( [Parameter(Mandatory)][string]$SourceRoot, @@ -371,10 +1892,14 @@ function Copy-CcodLifecycleStaging { [Parameter(Mandatory)][object[]]$Files ) + [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $InstallRoot) $stagingRoot = [IO.Path]::GetFullPath((Join-Path $InstallRoot '.staging')) + [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $stagingRoot) [IO.Directory]::CreateDirectory($stagingRoot) | Out-Null + [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $stagingRoot) $stagingDirectory = Join-Path $stagingRoot ([guid]::NewGuid().ToString('N')) [IO.Directory]::CreateDirectory($stagingDirectory) | Out-Null + [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $stagingDirectory) $bootstrapPath = Join-Path $InstallRoot 'bootstrap.ps1' $uninstallerPath = Join-Path $InstallRoot 'Uninstall-CodexControlOtherDevices.ps1' $bootstrapExistedBefore = [IO.File]::Exists($bootstrapPath) @@ -403,37 +1928,53 @@ function Copy-CcodLifecycleStaging { if (-not $destination.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { Throw-CcodLifecycleError 'CCOD_INSTALL_STAGING_PATH_INVALID' 'Staging path escaped the staging root' $destination } + $destinationParent = Split-Path $destination -Parent + [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $destinationParent) + [IO.Directory]::CreateDirectory($destinationParent) | Out-Null + [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $destinationParent) + [void](Assert-CcodLifecycleInstallLeafSafe -InstallRoot $InstallRoot -Path $destination -AllowMissing) & $Adapters.CopyFile $file.Source $destination + [void](Assert-CcodLifecycleInstallLeafSafe -InstallRoot $InstallRoot -Path $destination) } foreach ($file in $Files) { $destination = [IO.Path]::GetFullPath((Join-Path $stagingDirectory $file.Relative)) - $expected = Get-CcodLifecycleFileSha256 -Path $file.Source + $expected = if ($null -ne $file.PSObject.Properties['ExpectedSha256']) { [string]$file.ExpectedSha256 } else { Get-CcodLifecycleFileSha256 -Path $file.Source } $actual = Get-CcodLifecycleFileSha256 -Path $destination if ($actual -cne $expected) { Throw-CcodLifecycleError 'CCOD_INSTALL_FILE_HASH_MISMATCH' 'A staged runtime file failed its source hash comparison' $destination } } - $bootstrapSource = [IO.Path]::GetFullPath((Join-Path (Split-Path $PSScriptRoot -Parent) 'bootstrap.ps1')) + $bootstrapSource = [IO.Path]::GetFullPath((Join-Path $stagingDirectory 'src\persistence\bootstrap.ps1')) if (-not [IO.File]::Exists($bootstrapSource)) { - Throw-CcodLifecycleError 'CCOD_INSTALL_SOURCE_INCOMPLETE' 'The bootstrap script is missing from the persistence source' $bootstrapSource + Throw-CcodLifecycleError 'CCOD_INSTALL_SOURCE_INCOMPLETE' 'The verified staging bootstrap script is missing' $bootstrapSource } + [void](Assert-CcodLifecycleInstallLeafSafe -InstallRoot $InstallRoot -Path $bootstrapPath -AllowMissing) & $Adapters.CopyFile $bootstrapSource $bootstrapPath + [void](Assert-CcodLifecycleInstallLeafSafe -InstallRoot $InstallRoot -Path $bootstrapPath) + if ((Get-CcodLifecycleFileSha256 -Path $bootstrapPath) -cne (Get-CcodLifecycleFileSha256 -Path $bootstrapSource)) { + Throw-CcodLifecycleError 'CCOD_INSTALL_FILE_HASH_MISMATCH' 'The stable bootstrap copy differs from verified staging bytes' $bootstrapPath + } $copiedStableBootstrap = $true - $uninstallerSource = Join-Path $SourceRoot 'Uninstall-CodexControlOtherDevices.ps1' + $uninstallerSource = Join-Path $stagingDirectory 'Uninstall-CodexControlOtherDevices.ps1' if ([IO.File]::Exists($uninstallerSource)) { + [void](Assert-CcodLifecycleInstallLeafSafe -InstallRoot $InstallRoot -Path $uninstallerPath -AllowMissing) & $Adapters.CopyFile $uninstallerSource $uninstallerPath + [void](Assert-CcodLifecycleInstallLeafSafe -InstallRoot $InstallRoot -Path $uninstallerPath) + if ((Get-CcodLifecycleFileSha256 -Path $uninstallerPath) -cne (Get-CcodLifecycleFileSha256 -Path $uninstallerSource)) { + Throw-CcodLifecycleError 'CCOD_INSTALL_FILE_HASH_MISMATCH' 'The stable uninstaller copy differs from verified staging bytes' $uninstallerPath + } $copiedStableUninstaller = $true } return $stagingDirectory } catch { if ([IO.Directory]::Exists($stagingDirectory)) { - try { Remove-Item -LiteralPath $stagingDirectory -Recurse -Force -ErrorAction Stop } catch { } + try { [void](Assert-CcodLifecycleInstallTreeSafe -InstallRoot $InstallRoot -Path $stagingDirectory); Remove-Item -LiteralPath $stagingDirectory -Recurse -Force -ErrorAction Stop } catch { } } if ($copiedStableBootstrap -and -not $bootstrapExistedBefore -and [IO.File]::Exists($bootstrapPath)) { - try { Remove-Item -LiteralPath $bootstrapPath -Force -ErrorAction Stop } catch { } + try { [void](Assert-CcodLifecycleInstallLeafSafe -InstallRoot $InstallRoot -Path $bootstrapPath); Remove-Item -LiteralPath $bootstrapPath -Force -ErrorAction Stop } catch { } } if ($copiedStableUninstaller -and -not $uninstallerExistedBefore -and [IO.File]::Exists($uninstallerPath)) { - try { Remove-Item -LiteralPath $uninstallerPath -Force -ErrorAction Stop } catch { } + try { [void](Assert-CcodLifecycleInstallLeafSafe -InstallRoot $InstallRoot -Path $uninstallerPath); Remove-Item -LiteralPath $uninstallerPath -Force -ErrorAction Stop } catch { } } $candidateId = ([string]$_.FullyQualifiedErrorId -split ',')[0] if ($candidateId -clike 'CCOD_INSTALL_*' -or $candidateId -clike 'CCOD_*') { @@ -443,6 +1984,27 @@ function Copy-CcodLifecycleStaging { } } +function New-CcodLifecycleImmutableGeneration { + param([Parameter(Mandatory)][string]$InstallRoot,[Parameter(Mandatory)][string]$RuntimeId,[Parameter(Mandatory)][string]$ProjectVersion,[Parameter(Mandatory)][object[]]$Files) + $transaction=Open-CcodLifecycleInstallGeneration -InstallRoot $InstallRoot -RuntimeId $RuntimeId + try { + $directories=@{' '=$transaction} + $records=[Collections.Generic.List[object]]::new() + foreach($file in $Files){ + $relative=([string]$file.Relative).Replace('\','/');$segments=$relative.Split('/');$parent=$transaction;$key='' + for($i=0;$i-lt$segments.Count-1;$i++){$key=if($key){$key+'/'+$segments[$i]}else{$segments[$i]};if(-not$directories.ContainsKey($key)){$directories[$key]=New-CcodInstallGenerationLeaf -Generation $parent -Leaf $segments[$i]};$parent=$directories[$key]} + $length=if($null-ne$file.PSObject.Properties['ExpectedLength']){[int64]$file.ExpectedLength}else{[int64](Get-Item -LiteralPath $file.Source -Force -ErrorAction Stop).Length};$sha=if($null-ne$file.PSObject.Properties['ExpectedSha256']){[string]$file.ExpectedSha256}else{Get-CcodLifecycleFileSha256 $file.Source} + Copy-CcodInstallSealedSource -Generation $parent -SourcePath $file.Source -Leaf $segments[-1] -ExpectedLength $length -ExpectedSha256 $sha|Out-Null + $records.Add([pscustomobject][ordered]@{path=$relative;length=$length;sha256=$sha}) + } + $runtimeRoot=Join-Path (Join-Path $InstallRoot 'runtime') $RuntimeId;$manifest=New-CcodRuntimeManifest -RuntimeDirectory $runtimeRoot -ProjectVersion $ProjectVersion -RuntimeId $RuntimeId + $manifestSeal=Write-CcodInstallGenerationManifest -Generation $transaction -Manifest $manifest + $validation=Test-CcodRuntimeManifest -RuntimeDirectory $runtimeRoot -ExpectedRuntimeId $RuntimeId + if(-not$validation.Valid){Throw-CcodLifecycleError 'CCOD_INSTALL_MANIFEST_INVALID' ('Immutable runtime manifest validation failed: '+$validation.Code) $runtimeRoot} + return [pscustomobject]@{FileTransaction=$transaction;Generation=$transaction;RuntimeRoot=$runtimeRoot;Manifest=$manifest;ManifestSha256=$manifestSeal.Sha256} + } catch {try{Close-CcodInstallFileTransaction -Transaction $transaction -Disposition Failed}catch{};throw} +} + function Get-CcodLifecycleProjectVersion { param([Parameter(Mandatory)][string]$SourceRoot) @@ -482,7 +2044,8 @@ function Write-CcodLifecycleLog { [Parameter(Mandatory)][string]$Stage, [Parameter(Mandatory)][string]$Code, [Parameter(Mandatory)][string]$Outcome, - [switch]$ThrowOnFailure + [switch]$ThrowOnFailure, + $FileTransaction ) try { @@ -495,7 +2058,7 @@ function Write-CcodLifecycleLog { code = $Code outcome = $Outcome } - & $Adapters.WriteLog $InstallRoot $record + & $Adapters.WriteLog $InstallRoot $record $FileTransaction } catch { if ($ThrowOnFailure) { throw } } @@ -574,11 +2137,12 @@ function Get-CcodLifecycleVerifiedSupervisorFallback { if ($null -eq $Identity -or $Identity.UserSid -isnot [string] -or [string]::IsNullOrWhiteSpace($Identity.UserSid)) { return $null } $expectedSessionId = [int]$Identity.SessionId $root = Get-CcodLifecycleCanonicalRoot -Path $InstallRoot -Kind 'Install root' - $bootstrapPath = [IO.Path]::GetFullPath((Join-Path $root 'bootstrap.ps1')) - if (-not [IO.File]::Exists($bootstrapPath) -or (Test-CcodLifecycleReparse -Path $bootstrapPath)) { return $null } $runtimeRoot = Get-CcodLifecycleCanonicalRoot -Path (Join-Path $root 'runtime') -Kind 'Runtime root' $runtimePrefix = $runtimeRoot.TrimEnd([char[]]@([char]92,[char]47)) + [IO.Path]::DirectorySeparatorChar - $bootstrapFilePattern = '(?i)(?:^|\s)-File\s+"' + [regex]::Escape($bootstrapPath) + '"(?=\s|$)' + $appendOnlySelector=[IO.Directory]::Exists((Join-Path $root 'state\active-generation')) + $legacyBootstrapPath=[IO.Path]::GetFullPath((Join-Path $root 'bootstrap.ps1')) + if(-not$appendOnlySelector-and(-not[IO.File]::Exists($legacyBootstrapPath)-or(Test-CcodLifecycleReparse $legacyBootstrapPath))){return $null} + $bootstrapFilePattern = '(?i)(?:^|\s)-File\s+"(?[^"]+)"(?=\s|$)' $bootstrapRootPattern = '(?i)(?:^|\s)-InstallRoot\s+"' + [regex]::Escape($root) + '"(?=\s|$)' $supervisorFilePattern = '(?i)(?:^|\s)-File\s+"(?' + [regex]::Escape($runtimePrefix) + '[^\\/\s"]+' + [regex]::Escape('\src\persistence\Supervisor.ps1') + ')"(?=\s|$)' $tokenPattern = '(?i)(?:^|\s)-ReadyToken\s+[0-9a-f]{64}(?=\s|$)' @@ -602,9 +2166,13 @@ function Get-CcodLifecycleVerifiedSupervisorFallback { $parentPid = [int]$process.ParentProcessId if ($parentPid -lt 1 -or -not $byPid.ContainsKey($parentPid)) { continue } $parent = $byPid[$parentPid] - if ([int]$parent.SessionId -ne $expectedSessionId -or [string]::IsNullOrWhiteSpace([string]$parent.CommandLine) -or - -not [regex]::IsMatch([string]$parent.CommandLine, $bootstrapFilePattern) -or - -not [regex]::IsMatch([string]$parent.CommandLine, $bootstrapRootPattern)) { continue } + if ([int]$parent.SessionId -ne $expectedSessionId -or [string]::IsNullOrWhiteSpace([string]$parent.CommandLine) -or -not [regex]::IsMatch([string]$parent.CommandLine, $bootstrapRootPattern)) { continue } + $parentBootstrapMatch=[regex]::Match([string]$parent.CommandLine,$bootstrapFilePattern) + if(-not$parentBootstrapMatch.Success){continue} + $runtimeId=($supervisorPath.Substring($runtimePrefix.Length)-split'[\\/]')[0] + $expectedBootstrap=if($appendOnlySelector){[IO.Path]::GetFullPath((Join-Path (Join-Path $runtimeRoot $runtimeId) 'src\persistence\bootstrap.ps1'))}else{$legacyBootstrapPath} + try{$parentBootstrap=[IO.Path]::GetFullPath($parentBootstrapMatch.Groups['path'].Value)}catch{continue} + if($parentBootstrap-cne$expectedBootstrap-or-not[IO.File]::Exists($expectedBootstrap)-or(Test-CcodLifecycleReparse $expectedBootstrap)){continue} $supervisorOwner = & $OwnerSidResolver $process $parentOwner = & $OwnerSidResolver $parent if ($null -eq $supervisorOwner -or $null -eq $parentOwner -or @@ -1116,8 +2684,8 @@ function Get-CcodLifecycleAdapters { UtcNow = { [DateTime]::UtcNow } NewActivationId = { [guid]::NewGuid().ToString('D') } WriteActivationReceipt = { - param($InstallRoot, $Receipt) - Write-CcodActivationReceiptFile -InstallRoot $InstallRoot -Receipt $Receipt + param($InstallRoot, $Receipt,$FileTransaction) + Write-CcodActivationReceiptFile -InstallRoot $InstallRoot -Receipt $Receipt -FileTransaction $FileTransaction } ReadActiveLifecycleRequest = { param($StateRoot) @@ -1128,8 +2696,9 @@ function Get-CcodLifecycleAdapters { Wait-CcodLifecycleNewRuntimeReady -InstallRoot $InstallRoot -RuntimeId $RuntimeId -RuntimeGeneration $RuntimeGeneration -Identity $Identity -TaskStartedAtUtc $TaskStartedAtUtc -TimeoutMilliseconds $TimeoutMilliseconds } InstallSupervisorTask = { - param($InstallRoot, $UserSid) + param($InstallRoot, $UserSid,$RuntimeId) $spec = Get-CcodSupervisorTaskSpec -InstallRoot $InstallRoot -UserSid $UserSid + if(-not[string]::IsNullOrWhiteSpace($RuntimeId)){$bootstrap=[IO.Path]::GetFullPath((Join-Path (Join-Path (Join-Path $InstallRoot 'runtime') $RuntimeId) 'src\persistence\bootstrap.ps1'));$spec.Argument='-NoProfile -ExecutionPolicy Bypass -STA -WindowStyle Hidden -File "'+$bootstrap+'" -InstallRoot "'+([IO.Path]::GetFullPath($InstallRoot))+'" -EntryMode Task'} $definition = New-CcodSupervisorTaskDefinition -Spec $spec Install-CcodSupervisorTask -Definition $definition -TaskName $spec.TaskName } @@ -1227,8 +2796,8 @@ function Get-CcodLifecycleAdapters { Enter-CcodLifecycleOwnership -InstallRoot $InstallRoot -RuntimeId $RuntimeId -RuntimeGeneration $RuntimeGeneration -OwnerIdentity $OwnerIdentity -UserSid $UserSid -SessionId $SessionId } SetActiveRuntime = { - param($InstallRoot, $RuntimeId, $Ownership) - Set-CcodActiveRuntime -InstallRoot $InstallRoot -NewRuntimeId $RuntimeId -Ownership $Ownership + param($InstallRoot, $RuntimeId, $Ownership,$TargetGeneration,$FileTransaction) + Set-CcodActiveRuntime -InstallRoot $InstallRoot -NewRuntimeId $RuntimeId -TargetGeneration $TargetGeneration -FileTransaction $FileTransaction -Ownership $Ownership } ExitLifecycleOwnership = { param($Ownership) @@ -1271,11 +2840,97 @@ function Get-CcodLifecycleAdapters { } CopyFile = { param($Source, $Destination) - [IO.Directory]::CreateDirectory((Split-Path $Destination -Parent)) | Out-Null - [IO.File]::Copy($Source, $Destination, $true) + Copy-CcodLifecycleFileAtomically -Source $Source -Destination $Destination + } + CommitReadyTransaction = { + param($InstallRoot,$TransactionId,$FileTransaction) + Set-CcodInstallTransactionPhase -InstallRoot $InstallRoot -TransactionId $TransactionId -ExpectedPhase 'ProtectionReady' -NewPhase 'Ready' -FileTransaction $FileTransaction + } + CloseReadyGeneration = { + param($FileTransaction) + Close-CcodInstallFileTransaction -Transaction $FileTransaction -Disposition Ready + } + CloseProductTransaction = { + param($FileTransaction,$DefaultClose) + & $DefaultClose $FileTransaction + } + AddProductShortcutCandidates = { + param($Files) + $temporaryRoot=Join-Path ([IO.Path]::GetTempPath()) ('ccod-product-shortcuts-'+[guid]::NewGuid().ToString('N')) + $module=Import-Module (Join-Path $PSScriptRoot 'ProductRegistration.psm1') -Force -PassThru -ErrorAction Stop + $candidates=@(& $module {param($Root)New-CcodProductShortcutCandidates -Directory $Root} $temporaryRoot) + [pscustomobject]@{Files=@($Files)+$candidates;TemporaryRoot=$temporaryRoot} + } + GetProductRegistrationAdapters = { $null } + RegisterProduct = { + param($InstallRoot,$RuntimeId,$Version,$PackageSha256,$FileTransaction,$TransactionRecord,$ProductAdapters,$CloseProductTransaction) + if($CloseProductTransaction-isnot[scriptblock]){Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Product transaction close adapter is invalid' $InstallRoot} + $outerLease=$null + try{ + $outerLease=Enter-CcodLifecycleProductCleanupLease + $currentIdentity=$outerLease.OwnerIdentity + [void](Resolve-CcodLifecycleProductCleanupFence -InstallRoot $InstallRoot -ReadyTransaction $TransactionRecord -CurrentIdentity $currentIdentity -CloseProductTransaction $CloseProductTransaction) + $module=Import-Module (Join-Path $PSScriptRoot 'ProductRegistration.psm1') -Force -PassThru -ErrorAction Stop + $fileModule=Get-CcodLifecycleInstallFileTransactionModule + $defaultClose={param($Value)&$fileModule {param($Transaction)Close-CcodInstallFileTransaction -Transaction $Transaction -Disposition Ready} $Value}.GetNewClosure() + $fence=New-CcodLifecycleProductCleanupFence -InstallRoot $InstallRoot -ReadyTransaction $TransactionRecord -OwnerIdentity $currentIdentity + $cleanupKey=Get-CcodLifecycleProductCleanupKey -InstallRoot $InstallRoot + $cleanupEntry=[pscustomobject]@{ + Fence=$fence + Transaction=$null + DefaultClose=$defaultClose + CloseProductTransaction=$CloseProductTransaction + OwnerManagedThreadId=[Threading.Thread]::CurrentThread.ManagedThreadId + FenceBound=$false + CloseCompleted=$false + } + $script:CcodPendingProductTransactionCleanups[$cleanupKey]=$cleanupEntry + $operationFailure=$null;$closeFailure=$null;$completionFailure=$null;$receiptResult=$null + try{ + $ownedTransaction=&$fileModule {param($Root,$Record)Open-CcodInstallProductRegistrationTransaction -InstallRoot $Root -ReadyTransaction $Record} $InstallRoot $TransactionRecord + $cleanupEntry.Transaction=$ownedTransaction;$cleanupEntry.FenceBound=$false;$cleanupEntry.CloseCompleted=$false + [void](&$fileModule {param($Transaction,$Fence)Set-CcodInstallProductCleanupFence -Transaction $Transaction -Fence $Fence} $ownedTransaction $fence) + $cleanupEntry.FenceBound=$true + $sourceCapability=&$fileModule {param($Root,$Id,$Manifest,$Transaction)Open-CcodInstallRetainedGeneration -InstallRoot $Root -RuntimeId $Id -ExpectedManifestSha256 $Manifest -FileTransaction $Transaction} $InstallRoot $RuntimeId ([string]$TransactionRecord.newManifestSha256) $ownedTransaction + $registration=New-CcodProductRegistration -InstallRoot $InstallRoot -RuntimeId $RuntimeId -Version $Version -PackageSha256 $PackageSha256 -FileTransaction $sourceCapability + $system=[Environment]::GetFolderPath([Environment+SpecialFolder]::System);$proof=[pscustomobject][ordered]@{phase=[string]$TransactionRecord.phase;runtimeId=$RuntimeId;version=$Version;packageSha256=$PackageSha256;runtimeGeneration=[uint64]$TransactionRecord.newGeneration;manifestSha256=[string]$TransactionRecord.newManifestSha256;startMenuSha256=(Get-CcodLifecycleFileSha256 $registration.startMenuShortcut.candidatePath);desktopSha256=(Get-CcodLifecycleFileSha256 $registration.desktopShortcut.candidatePath);targetPath=[IO.Path]::GetFullPath((Join-Path $system 'schtasks.exe'));arguments='/Run /TN "Codex Control Other Devices Supervisor"';transactionRecord=$TransactionRecord;bootstrapPath=$registration.bootstrapPath;uninstallerPath=$registration.uninstallerPath} + $commitAdapters=@{};if($null-ne$ProductAdapters){if($ProductAdapters-isnot[hashtable]){throw 'product side-effect adapters invalid'};foreach($key in $ProductAdapters.Keys){$commitAdapters[$key]=$ProductAdapters[$key]}};$commitAdapters.GetReadyProof={param($Ignored)$proof}.GetNewClosure() + $commitAdapters.ReadLegacyMigrationPlan={param($ReadyEvidence)&$fileModule {param($Transaction,$Record)Read-CcodInstallLegacyMigrationPlan -Transaction $Transaction -ReadyTransaction $Record} $ownedTransaction $ReadyEvidence.transactionRecord}.GetNewClosure() + $commitAdapters.WriteLegacyMigrationPlan={param($ReadyEvidence,[byte[]]$Bytes)&$fileModule {param($Transaction,$Record,[byte[]]$Value)Write-CcodInstallLegacyMigrationPlan -Transaction $Transaction -ReadyTransaction $Record -Bytes $Value} $ownedTransaction $ReadyEvidence.transactionRecord $Bytes}.GetNewClosure() + $legacyMigrationPlan=Get-CcodDurableLegacyProductRegistrationMigrationPlan -ExpectedAppId '{2B9E9F2E-7A32-4A7E-9C1D-9F5B5C6D7E8F}' -Registration $registration -ReadyEvidence $proof -Adapters $commitAdapters + $receipt=Commit-CcodProductRegistration -Registration $registration -FileTransaction $sourceCapability -Adapters $commitAdapters + $expectedCurrentProof=[pscustomobject][ordered]@{verified=$true;runtimeId=$RuntimeId;version=$Version;packageSha256=$PackageSha256;shortcutNames=@('Programs\CodexRemote-fix\CodexRemote-fix.lnk','Desktop\CodexRemote-fix.lnk');startMenuSha256=[string]$proof.startMenuSha256;desktopSha256=[string]$proof.desktopSha256} + Remove-CcodLegacyProductRegistration -ExpectedAppId '{2B9E9F2E-7A32-4A7E-9C1D-9F5B5C6D7E8F}' -MigrationPlan $legacyMigrationPlan -ExpectedCurrentProof $expectedCurrentProof -Adapters $ProductAdapters + $receiptResult=[pscustomobject]@{verified=[bool]$receipt.verified;legacyRemoved=$true} + }catch{$operationFailure=$_} + finally{ + if(-not$cleanupEntry.CloseCompleted-and$null-ne$cleanupEntry.Transaction){ + if($cleanupEntry.FenceBound){ + try{Invoke-CcodLifecycleProductTransactionClose -Transaction $cleanupEntry.Transaction -CloseProductTransaction $cleanupEntry.CloseProductTransaction -DefaultClose $cleanupEntry.DefaultClose;$cleanupEntry.CloseCompleted=$true}catch{$closeFailure=$_} + }else{ + try{Invoke-CcodLifecycleUnboundProductTransactionAbort -Transaction $cleanupEntry.Transaction;$cleanupEntry.Transaction=$null}catch{$closeFailure=$_} + } + } + if($cleanupEntry.CloseCompleted){ + try{[void](Complete-CcodLifecycleProductCleanupFence -Fence $cleanupEntry.Fence -Outcome Completed);[void]$script:CcodPendingProductTransactionCleanups.Remove($cleanupKey)}catch{$completionFailure=$_} + } + } + if($null-ne$closeFailure){Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Product transaction cleanup failed after registration and requires same-thread reconciliation' $InstallRoot} + if($null-ne$completionFailure){Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Product transaction closed but durable completion could not be appended' $InstallRoot} + if($null-ne$operationFailure){Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Product registration failed while durable cleanup remained fail closed' $InstallRoot} + if($null-eq$receiptResult-or$receiptResult.verified-isnot[bool]-or-not$receiptResult.verified){Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Product registration receipt is invalid after exact cleanup completion' $InstallRoot} + return $receiptResult + }finally{ + if($null-ne$outerLease){[void](Exit-CcodLifecycleProductCleanupLease -Context $outerLease)} + } + } + NewRuntimeManifest = { + param($RuntimeDirectory, $ProjectVersion) + New-CcodRuntimeManifest -RuntimeDirectory $RuntimeDirectory -ProjectVersion $ProjectVersion } WriteLog = { - param($InstallRoot, $Record) + param($InstallRoot, $Record,$FileTransaction) + if($null-ne$FileTransaction){$state=New-CcodInstallDirectory -Transaction $FileTransaction -Parent $FileTransaction -Leaf 'state' -CreateIfMissing;$logs=New-CcodInstallDirectory -Transaction $FileTransaction -Parent $state -Leaf 'install-logs' -CreateIfMissing;$leaf=('{0}.{1}.{2}.json'-f([DateTime]::Parse($Record.timestampUtc).Ticks),$Record.stage,[guid]::NewGuid().ToString('N'));Write-CcodInstallRecord -Transaction $FileTransaction -Parent $logs -Leaf $leaf -Record $Record|Out-Null;return} $logDirectory = Join-Path $InstallRoot 'logs' [IO.Directory]::CreateDirectory($logDirectory) | Out-Null Write-CcodRotatingLog -Path (Join-Path $logDirectory 'install.log') -Message ($Record | ConvertTo-Json -Depth 6 -Compress) @@ -1300,10 +2955,11 @@ function Install-CcodLifecycleTask { param( [Parameter(Mandatory)][string]$InstallRoot, [Parameter(Mandatory)][hashtable]$Adapters, - $Identity + $Identity, + [string]$RuntimeId ) - & $Adapters.InstallSupervisorTask $InstallRoot $Identity.UserSid + & $Adapters.InstallSupervisorTask $InstallRoot $Identity.UserSid $RuntimeId } function Start-CcodLifecycleTask { @@ -1392,7 +3048,7 @@ function Wait-CcodLifecycleNewRuntimeReady { if (-not $validation.Valid) { return New-CcodLifecycleNotReadyProof } if ($null -eq $Identity -or $Identity.UserSid -isnot [string] -or $Identity.SessionId -isnot [int]) { return New-CcodLifecycleNotReadyProof } $supervisorPath = [IO.Path]::GetFullPath((Join-Path $runtimeRoot 'src\persistence\Supervisor.ps1')) - $bootstrapPath = [IO.Path]::GetFullPath((Join-Path $root 'bootstrap.ps1')) + $bootstrapPath = if([IO.Directory]::Exists((Join-Path $root 'state\active-generation'))){[IO.Path]::GetFullPath((Join-Path $runtimeRoot 'src\persistence\bootstrap.ps1'))}else{[IO.Path]::GetFullPath((Join-Path $root 'bootstrap.ps1'))} $hostPrefix = '^\s*(?:"[^"]*powershell\.exe"|[^\s"]*powershell\.exe)' $supervisorPattern = $hostPrefix + '\s+-NoProfile\s+-ExecutionPolicy\s+Bypass\s+-STA\s+-File\s+(?:\"(?[^\"]+)\"|(?[^\s]+))\s+-ReadyToken\s+(?[0-9a-f]{64})\s*$' $bootstrapPattern = $hostPrefix + '\s+-NoProfile\s+-ExecutionPolicy\s+Bypass\s+-STA\s+-WindowStyle\s+Hidden\s+-File\s+"(?[^"]+)"\s+-InstallRoot\s+"(?[^"]+)"\s+-EntryMode\s+Task\s*$' @@ -1496,6 +3152,7 @@ function Remove-CcodLifecycleInstallTree { if (Test-CcodLifecycleReparse -Path $root) { Throw-CcodLifecycleError 'CCOD_INSTALL_REPARSE_PATH' 'Install root is a reparse point' $root } + [void](Assert-CcodLifecycleInstallTreeSafe -InstallRoot $root -Path $root) foreach ($item in Get-ChildItem -LiteralPath $root -Force -ErrorAction Stop) { [void](Test-CcodLifecycleRemovePath -Root $root -Path $item.FullName) } @@ -1511,9 +3168,12 @@ function Remove-CcodLifecycleOldRuntimes { $runtimeRoot = Join-Path $InstallRoot 'runtime' if (-not [IO.Directory]::Exists($runtimeRoot)) { return } + [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $runtimeRoot) foreach ($directory in Get-ChildItem -LiteralPath $runtimeRoot -Directory -Force -ErrorAction Stop) { if ($directory.Name -ceq $ActiveRuntimeId -or $directory.Name -ceq $PreviousRuntimeId) { continue } + [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $directory.FullName) [void](Test-CcodLifecycleRemovePath -Root $InstallRoot -Path $directory.FullName) + [void](Assert-CcodLifecycleInstallTreeSafe -InstallRoot $InstallRoot -Path $directory.FullName) Remove-Item -LiteralPath $directory.FullName -Recurse -Force -ErrorAction Stop } } @@ -1553,6 +3213,31 @@ function Invoke-CcodRepairState { } } +function Invoke-CcodInstallCompensation { + param([Parameter(Mandatory)][string]$InstallRoot,[Parameter(Mandatory)]$TransactionRecord,[Parameter(Mandatory)]$FileTransaction,[Parameter(Mandatory)][hashtable]$Adapters,$Ownership,$Identity) + [void](Assert-CcodInstallTransactionRoot $InstallRoot $FileTransaction);[void](Assert-CcodInstallTransactionRecord $TransactionRecord) + if($null-eq$TransactionRecord.oldRuntimeId-or$null-eq$TransactionRecord.oldGeneration){Throw-CcodLifecycleError 'CCOD_INSTALL_ROLLBACK_FAILED' 'No retained generation exists for compensation' $null} + $manifestPath=Join-Path (Join-Path (Join-Path $InstallRoot 'runtime') $TransactionRecord.oldRuntimeId) 'manifest.json';if(-not[IO.File]::Exists($manifestPath)){Throw-CcodLifecycleError 'CCOD_INSTALL_ROLLBACK_FAILED' 'Retained generation manifest is missing' $null} + $retained=Open-CcodInstallRetainedGeneration -InstallRoot $InstallRoot -RuntimeId $TransactionRecord.oldRuntimeId -ExpectedManifestSha256 $TransactionRecord.oldManifestSha256 -FileTransaction $FileTransaction + $compensationOwnership=$Ownership + if($null-eq$compensationOwnership-or$compensationOwnership.released){ + $active=Read-CcodActiveRuntime -InstallRoot $InstallRoot + $process=[Diagnostics.Process]::GetCurrentProcess() + try{$ownerIdentity=[pscustomobject][ordered]@{pid=[int]$process.Id;creationTimeUtc=$process.StartTime.ToUniversalTime().ToString('o')}}finally{$process.Dispose()} + try{$compensationOwnership=&$Adapters.EnterLifecycleOwnership $InstallRoot ([string]$active.activeRuntime) ([uint64]$active.generation) $ownerIdentity $Identity.UserSid $Identity.SessionId}catch{Throw-CcodLifecycleError 'CCOD_INSTALL_ROLLBACK_FAILED' 'Compensation ownership could not be acquired' $null} + } + try{$pointer=&$Adapters.SetActiveRuntime $InstallRoot $TransactionRecord.oldRuntimeId $compensationOwnership $retained $FileTransaction}catch{Throw-CcodLifecycleError 'CCOD_INSTALL_ROLLBACK_FAILED' 'Compensating pointer commit failed' $null} + Install-CcodLifecycleTask -InstallRoot $InstallRoot -Adapters $Adapters -Identity $Identity -RuntimeId $TransactionRecord.oldRuntimeId + if(-not$compensationOwnership.released){ + try{$released=&$Adapters.ExitLifecycleOwnership $compensationOwnership}catch{Throw-CcodLifecycleError 'CCOD_INSTALL_ROLLBACK_FAILED' 'Compensation ownership could not be released' $null} + if($released-isnot[bool]-or-not$released-or-not$compensationOwnership.released){Throw-CcodLifecycleError 'CCOD_INSTALL_ROLLBACK_FAILED' 'Compensation ownership release was not proven' $null} + } + $started=&$Adapters.UtcNow;try{&$Adapters.StartSupervisorTask}catch{Throw-CcodLifecycleError 'CCOD_INSTALL_ROLLBACK_FAILED' 'Retained generation restart failed' $null} + $proof=&$Adapters.WaitNewRuntimeReady $InstallRoot $TransactionRecord.oldRuntimeId ([uint64]$pointer.generation) $Identity $started 15000 + if($null-eq$proof-or-not$proof.SupervisorReady-or-not$proof.TrayReady){Throw-CcodLifecycleError 'CCOD_INSTALL_ROLLBACK_FAILED' 'Retained generation readiness was not proven' $null} + return $pointer +} + function Invoke-CcodInstall { [CmdletBinding(SupportsShouldProcess = $true)] param( @@ -1562,6 +3247,11 @@ function Invoke-CcodInstall { [switch]$RepairState, [switch]$DoNotStart, [string]$ActivationId, + [string]$ExpectedVersion, + [string]$PayloadManifestPath, + [string]$ExpectedPayloadManifestSha256, + [string]$PayloadManifestBytesBase64, + [string]$SealedPackageSha256, [hashtable]$Adapters ) @@ -1569,6 +3259,7 @@ function Invoke-CcodInstall { if ([string]::IsNullOrWhiteSpace($InstallRoot)) { $InstallRoot = $script:CcodLifecycleDefaultInstallRoot } $root = Get-CcodLifecycleCanonicalRoot -Path $InstallRoot -Kind 'Install root' $sourceRoot = Get-CcodLifecycleCanonicalRoot -Path $SourceRoot -Kind 'Source root' + [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $root -Path $root) if ($RepairState) { return Invoke-CcodRepairState -InstallRoot $root -Adapters $adapters @@ -1582,10 +3273,34 @@ function Invoke-CcodInstall { RepairCompleted = $false } } + if(-not[IO.Directory]::Exists($root)){[IO.Directory]::CreateDirectory($root)|Out-Null} + + $globalTransaction=Read-CcodInstallTransactionRecord -InstallRoot $root + if($null-ne$globalTransaction-and$globalTransaction.phase-notin@('Ready','Failed')){ + if($globalTransaction.phase-ceq'ProtectionReady'){return Complete-CcodReadyFinalizationRecovery -InstallRoot $root -TransactionRecord $globalTransaction -Adapters $adapters} + Throw-CcodLifecycleError 'CCOD_INSTALL_TRANSACTION_BUSY' 'A nonterminal install transaction must be recovered before a new attempt' $globalTransaction.transactionId + } + + $existingPointer = $null + $activePath = Join-Path $root 'active.json' + try{$existingPointer=Read-CcodActiveRuntime -InstallRoot $root}catch{if([IO.File]::Exists($activePath)-or[IO.Directory]::Exists((Join-Path $root 'state\active-generation'))){throw}} if (-not [IO.Directory]::Exists($sourceRoot)) { Throw-CcodLifecycleError 'CCOD_INSTALL_SOURCE_MISSING' 'Source checkout does not exist' $sourceRoot } + $payloadBound = -not [string]::IsNullOrWhiteSpace($ExpectedVersion) -or + -not [string]::IsNullOrWhiteSpace($PayloadManifestPath) -or + -not [string]::IsNullOrWhiteSpace($ExpectedPayloadManifestSha256) -or + -not [string]::IsNullOrWhiteSpace($PayloadManifestBytesBase64) + if ($payloadBound -and ([string]::IsNullOrWhiteSpace($ExpectedVersion) -or + [string]::IsNullOrWhiteSpace($PayloadManifestPath) -or + [string]::IsNullOrWhiteSpace($ExpectedPayloadManifestSha256) -or + [string]::IsNullOrWhiteSpace($PayloadManifestBytesBase64))) { + Throw-CcodLifecycleError 'CCOD_INSTALL_INPUT_INVALID' 'ExpectedVersion, manifest path, hash, and exact bytes must be supplied together' $null + } + $files = if ($payloadBound) { + @(Get-CcodLifecyclePayloadManifestFiles -SourceRoot $sourceRoot -ExpectedVersion $ExpectedVersion -PayloadManifestPath $PayloadManifestPath -ExpectedPayloadManifestSha256 $ExpectedPayloadManifestSha256 -PayloadManifestBytesBase64 $PayloadManifestBytesBase64) + } else { $null } if (-not (& $adapters.ValidateSource $sourceRoot)) { Throw-CcodLifecycleError 'CCOD_INSTALL_SOURCE_INVALID' 'Source checkout failed hermetic validation' $sourceRoot } @@ -1594,13 +3309,44 @@ function Invoke-CcodInstall { Throw-CcodLifecycleError 'CCOD_INSTALL_IDENTITY_INVALID' 'Current user identity is unavailable' $null } $projectVersion = & $adapters.GetProjectVersion $sourceRoot + $sealedPackageIdentitySupplied = -not [string]::IsNullOrWhiteSpace($SealedPackageSha256) + if([string]::IsNullOrWhiteSpace($SealedPackageSha256)){$SealedPackageSha256=if($payloadBound){$ExpectedPayloadManifestSha256}else{Get-CcodLifecycleFileSha256 (Join-Path $sourceRoot 'package.json')}} + if($SealedPackageSha256-cnotmatch'^[0-9a-f]{64}$'){Throw-CcodLifecycleError 'CCOD_INSTALL_PACKAGE_IDENTITY_INVALID' 'Sealed package SHA-256 is required' $null} + if ($payloadBound -and [string]$projectVersion -cne $ExpectedVersion) { + Throw-CcodLifecycleError 'CCOD_INSTALL_PAYLOAD_VERSION_MISMATCH' 'Source package version does not match the expected setup version' $sourceRoot + } $nodeCandidates = @(Get-CcodLifecycleNodeCandidates -Adapters $adapters) - $files = @(Get-CcodLifecycleSourceFiles -SourceRoot $sourceRoot -RequireTrayHost) - - $existingPointer = $null - $activePath = Join-Path $root 'active.json' - if ([IO.File]::Exists($activePath)) { - $existingPointer = Read-CcodActiveRuntime -InstallRoot $root + if (-not $payloadBound) { $files = @(Get-CcodLifecycleSourceFiles -SourceRoot $sourceRoot -RequireTrayHost) } + $oldManifestSha256=$null;$activeValidation=$null;$upgradeCompatibility=$null + if($null-ne$existingPointer){ + $activeRuntimeRoot=[IO.Path]::GetFullPath((Join-Path (Join-Path $root 'runtime') ([string]$existingPointer.activeRuntime))) + $activeManifestPath=Join-Path $activeRuntimeRoot 'manifest.json' + if(-not[IO.File]::Exists($activeManifestPath)){Throw-CcodLifecycleError 'CCOD_INSTALL_RUNTIME_ACTIVATION_UNPROVEN' 'The active runtime manifest is missing before upgrade' $activeManifestPath} + [void](Assert-CcodLifecycleInstallTreeSafe -InstallRoot $root -Path $activeRuntimeRoot) + [void](Assert-CcodLifecycleInstallLeafSafe -InstallRoot $root -Path $activeManifestPath) + $oldManifestSha256=Get-CcodLifecycleFileSha256 -Path $activeManifestPath + $activeValidation=Test-CcodRuntimeManifest -RuntimeDirectory $activeRuntimeRoot -ExpectedRuntimeId ([string]$existingPointer.activeRuntime) -ExpectedManifestSha256 $oldManifestSha256 + $upgradeCompatibility=Get-CcodLegacyUpgradeCompatibilityContext -InstallRoot $root -ExistingPointer $existingPointer -ActiveValidation $activeValidation -GlobalTransaction $globalTransaction + $activeValidation=$upgradeCompatibility.ActiveValidation + } + if($null-ne$existingPointer-and$sealedPackageIdentitySupplied){ + if($activeValidation.Valid){ + $sameIdentity=Test-CcodInstallPackageIdentity -TransactionRecord $globalTransaction -ProjectVersion ([string]$projectVersion) -ActiveProjectVersion ([string]$activeValidation.Manifest.projectVersion) -SealedPackageSha256 $SealedPackageSha256 -ActiveRuntimeId ([string]$existingPointer.activeRuntime) -ActiveGeneration ([uint64]$existingPointer.generation) + if($sameIdentity){ + $productAdapters=&$adapters.GetProductRegistrationAdapters;$registration=&$adapters.RegisterProduct $root ([string]$existingPointer.activeRuntime) ([string]$projectVersion) $SealedPackageSha256 $null $globalTransaction $productAdapters $adapters.CloseProductTransaction + if($null-eq$registration-or$registration.verified-isnot[bool]-or-not$registration.verified){Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'AlreadyInstalled product registration reconciliation did not verify' $existingPointer.activeRuntime} + return [pscustomobject][ordered]@{Outcome='AlreadyInstalled';Installed=$true;RuntimeId=[string]$existingPointer.activeRuntime;PreviousRuntimeId=$existingPointer.previousRuntime;RepairCompleted=$false;ProductRegistrationVerified=$true} + } + } + } + $productShortcutTemporaryRoot=$null + if([string]$projectVersion-ceq$script:CcodProductVersion){ + $preparedProductFiles=&$adapters.AddProductShortcutCandidates $files + if($null-eq$preparedProductFiles-or$null-eq$preparedProductFiles.Files){Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Product shortcut candidate preparation returned no files' $null} + $files=@($preparedProductFiles.Files);$productShortcutTemporaryRoot=[string]$preparedProductFiles.TemporaryRoot + } + if($null-ne$upgradeCompatibility-and$upgradeCompatibility.Kind-ceq'LegacyMigrationRetry'){ + [void](Assert-CcodLegacyMigrationRetryPackage -CompatibilityContext $upgradeCompatibility -ProjectVersion ([string]$projectVersion) -SealedPackageSha256 $SealedPackageSha256 -Files $files) } if ([string]::IsNullOrWhiteSpace($ActivationId)) { $ActivationId = & $adapters.NewActivationId } @@ -1624,14 +3370,39 @@ function Invoke-CcodInstall { $installLease = $null $shutdownGate = $null $lifecycleOwnership = $null + $upgradeProductCleanupLease = $null $previousProtectionStopped = $false + $fileTransaction=$null;$generationCapability=$null;$installRecord=$null;$readyReceiptCommitted=$false;$productRegistrationVerified=$false;$productRegistrationFailure=$null try { + if($upgrade){ + if($adapters.CloseProductTransaction-isnot[scriptblock]){Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Product transaction close adapter is invalid before upgrade' $root} + $upgradeProductCleanupLease=Enter-CcodLifecycleProductCleanupLease + if($upgradeCompatibility.Kind-ceq'CurrentReady'){ + [void](Resolve-CcodLifecycleProductCleanupFence -InstallRoot $root -ReadyTransaction $globalTransaction -CurrentIdentity $upgradeProductCleanupLease.OwnerIdentity -CloseProductTransaction $adapters.CloseProductTransaction) + }elseif($upgradeCompatibility.Kind-notin@('ProvenLegacyWithoutReady','LegacyMigrationRetry')){ + Throw-CcodLifecycleError 'CCOD_INSTALL_UPGRADE_SOURCE_INVALID' 'Upgrade compatibility classification changed before staging' $upgradeCompatibility + } + } + $migrationRetry=$upgrade-and$upgradeCompatibility.Kind-ceq'LegacyMigrationRetry' + if($migrationRetry){ + $runtimeId=[string]$upgradeCompatibility.RetryRuntimeId;$runtimeRoot=Join-Path (Join-Path $root 'runtime') $runtimeId + $fileTransaction=Open-CcodLifecycleInstallMigrationRetryTransaction -InstallRoot $root + $generationCapability=Open-CcodInstallRetainedGeneration -InstallRoot $root -RuntimeId $runtimeId -ExpectedManifestSha256 ([string]$upgradeCompatibility.RetryManifestSha256) -FileTransaction $fileTransaction + $newGeneration=[uint64]$upgradeCompatibility.RetryGeneration;$newManifestSha256=[string]$upgradeCompatibility.RetryManifestSha256;$ownedObjectNames=@() + }else{ + $runtimeId=New-CcodUniqueRuntimeId -ProjectVersion $projectVersion -Files $files;$generation=New-CcodLifecycleImmutableGeneration -InstallRoot $root -RuntimeId $runtimeId -ProjectVersion $projectVersion -Files $files;$fileTransaction=$generation.FileTransaction;$generationCapability=$generation.Generation;$runtimeRoot=$generation.RuntimeRoot;$runtimeCreated=$true + $newGeneration=if($null-eq$existingPointer){[uint64]1}else{[uint64]$existingPointer.generation+1};$newManifestSha256=[string]$generation.ManifestSha256;$ownedObjectNames=@($runtimeId) + } + if($migrationRetry-and$newGeneration-ne[uint64]($existingPointer.generation+1)){Throw-CcodLifecycleError 'CCOD_INSTALL_UPGRADE_SOURCE_INVALID' 'Retry generation no longer follows the selected pointer' $upgradeCompatibility} + $installRecord=New-CcodInstallTransactionRecord -TransactionId ([guid]::NewGuid().ToString('D')) -OldRuntimeId $(if($upgrade){$existingPointer.activeRuntime}else{$null}) -OldGeneration $(if($upgrade){$existingPointer.generation}else{$null}) -OldManifestSha256 $oldManifestSha256 -NewRuntimeId $runtimeId -NewGeneration $newGeneration -NewManifestSha256 $newManifestSha256 -SealedPackageSha256 $SealedPackageSha256 -OwnedObjectNames $ownedObjectNames + Write-CcodInstallTransactionRecord -InstallRoot $root -TransactionRecord $installRecord -FileTransaction $fileTransaction|Out-Null + foreach($phase in @('PackageVerified','RuntimeStaged')){$installRecord=Set-CcodInstallTransactionPhase -InstallRoot $root -TransactionId $installRecord.transactionId -ExpectedPhase $installRecord.phase -NewPhase $phase -FileTransaction $fileTransaction} if ($upgrade) { $pending = & $adapters.ReadActiveLifecycleRequest (Join-Path $root 'state') if ($null -ne $pending) { Throw-CcodLifecycleError 'CCOD_INSTALL_LIFECYCLE_BUSY' 'A nonterminal lifecycle transaction must complete before upgrade' $null } - Write-CcodInstallActivationPhase -Activation $activation -Phase 'StoppingPreviousRuntime' -RuntimeId $null -PreviousRuntimeId ([string]$existingPointer.activeRuntime) -ErrorCode $null -Adapters $adapters -InstallRoot $root | Out-Null + Write-CcodInstallActivationPhase -Activation $activation -Phase 'StoppingPreviousRuntime' -RuntimeId $null -PreviousRuntimeId ([string]$existingPointer.activeRuntime) -ErrorCode $null -Adapters $adapters -InstallRoot $root -FileTransaction $fileTransaction | Out-Null $shutdownGate = & $adapters.CreateSupervisorShutdownGate $identity.UserSid $identity.SessionId if ($null -eq $shutdownGate) { Throw-CcodLifecycleError 'CCOD_INSTALL_SHUTDOWN_GATE_FAILED' 'The upgrade shutdown gate could not be created' $root @@ -1641,6 +3412,7 @@ function Invoke-CcodInstall { Throw-CcodLifecycleError 'CCOD_INSTALL_PREVIOUS_RUNTIME_BUSY' 'The verified previous Supervisor did not exit exactly' $null } $previousProtectionStopped = $true + $installRecord=Set-CcodInstallTransactionPhase -InstallRoot $root -TransactionId $installRecord.transactionId -ExpectedPhase 'RuntimeStaged' -NewPhase 'PreviousProtectionStopped' -FileTransaction $fileTransaction [void](Assert-CcodLifecycleTaskIdle -Adapters $adapters) $installLease = & $adapters.EnterInstallLease $identity.UserSid if ($null -eq $installLease -or $installLease.Outcome -isnot [string] -or @('Acquired', 'TimedOut') -cnotcontains $installLease.Outcome) { @@ -1654,60 +3426,34 @@ function Invoke-CcodInstall { Throw-CcodLifecycleError 'CCOD_INSTALL_LIFECYCLE_BUSY' 'A nonterminal lifecycle transaction appeared during upgrade shutdown' $null } } + if(-not$upgrade){$installRecord=Set-CcodInstallTransactionPhase -InstallRoot $root -TransactionId $installRecord.transactionId -ExpectedPhase 'RuntimeStaged' -NewPhase 'PreviousProtectionStopped' -FileTransaction $fileTransaction} - Write-CcodInstallActivationPhase -Activation $activation -Phase 'InstallingRuntime' -RuntimeId $null -PreviousRuntimeId $(if ($upgrade) { [string]$existingPointer.activeRuntime } else { $null }) -ErrorCode $null -Adapters $adapters -InstallRoot $root | Out-Null - $stagingDirectory = Copy-CcodLifecycleStaging -SourceRoot $sourceRoot -InstallRoot $root -Adapters $adapters -Files $files - $manifest = New-CcodRuntimeManifest -RuntimeDirectory $stagingDirectory -ProjectVersion $projectVersion - [IO.File]::WriteAllText( - (Join-Path $stagingDirectory 'manifest.json'), - ($manifest | ConvertTo-Json -Depth 16), - [Text.UTF8Encoding]::new($false) - ) - $runtimeId = [string]$manifest.runtimeId - $validation = Test-CcodRuntimeManifest -RuntimeDirectory $stagingDirectory -ExpectedRuntimeId $runtimeId - if (-not $validation.Valid) { - Throw-CcodLifecycleError 'CCOD_INSTALL_MANIFEST_INVALID' ("Staged runtime failed manifest validation: {0}" -f $validation.Code) $stagingDirectory - } - $runtimeRoot = [IO.Path]::GetFullPath((Join-Path (Join-Path $root 'runtime') $runtimeId)) - if ([IO.Directory]::Exists($runtimeRoot)) { - $existingValidation = Test-CcodRuntimeManifest -RuntimeDirectory $runtimeRoot -ExpectedRuntimeId $runtimeId - if (-not $existingValidation.Valid) { - Throw-CcodLifecycleError 'CCOD_INSTALL_RUNTIME_CONFLICT' 'An invalid runtime already occupies the target runtime id' $runtimeRoot - } - if ([IO.Directory]::Exists($stagingDirectory)) { - try { Remove-Item -LiteralPath $stagingDirectory -Recurse -Force -ErrorAction Stop } catch { } - } - } else { - [IO.Directory]::CreateDirectory((Split-Path $runtimeRoot -Parent)) | Out-Null - [IO.Directory]::Move($stagingDirectory, $runtimeRoot) - $runtimeCreated = $true - } - $stagingDirectory = $null - if (-not $upgrade) { - $stateRoot = Join-Path $root 'state' - Initialize-CcodState -StateRoot $stateRoot -NodeCandidates $nodeCandidates -CandidateCompatibleOptIn ([bool]$EnableCandidateCompatibleUpdates) - Initialize-CcodUiPreference -StateRoot $stateRoot | Out-Null - } - Write-CcodInstallActivationPhase -Activation $activation -Phase 'ActivatingRuntime' -RuntimeId $runtimeId -PreviousRuntimeId $(if ($upgrade) { [string]$existingPointer.activeRuntime } else { $null }) -ErrorCode $null -Adapters $adapters -InstallRoot $root | Out-Null + Write-CcodInstallActivationPhase -Activation $activation -Phase 'InstallingRuntime' -RuntimeId $runtimeId -PreviousRuntimeId $(if ($upgrade) { [string]$existingPointer.activeRuntime } else { $null }) -ErrorCode $null -Adapters $adapters -InstallRoot $root -FileTransaction $fileTransaction | Out-Null + $installRecord=Set-CcodInstallTransactionPhase -InstallRoot $root -TransactionId $installRecord.transactionId -ExpectedPhase 'PreviousProtectionStopped' -NewPhase 'RuntimePromoted' -FileTransaction $fileTransaction + if(-not$migrationRetry){Initialize-CcodInstallStatePlanes -InstallRoot $root -RuntimeId $runtimeId -FileTransaction $fileTransaction -NodeCandidates $nodeCandidates -CandidateCompatibleOptIn ([bool]$EnableCandidateCompatibleUpdates)} + Write-CcodInstallActivationPhase -Activation $activation -Phase 'ActivatingRuntime' -RuntimeId $runtimeId -PreviousRuntimeId $(if ($upgrade) { [string]$existingPointer.activeRuntime } else { $null }) -ErrorCode $null -Adapters $adapters -InstallRoot $root -FileTransaction $fileTransaction | Out-Null + if($upgrade-and$upgradeCompatibility.Kind-ceq'ProvenLegacyWithoutReady'){$existingPointer=Initialize-CcodLegacyUpgradeSelector -InstallRoot $root -CompatibilityContext $upgradeCompatibility -FileTransaction $fileTransaction;$pointer=$existingPointer} $process = [Diagnostics.Process]::GetCurrentProcess() try { $ownerIdentity = [pscustomobject][ordered]@{ pid=[int]$process.Id; creationTimeUtc=$process.StartTime.ToUniversalTime().ToString('o') } $ownershipRuntimeId = if ($null -ne $existingPointer) { [string]$existingPointer.activeRuntime } else { $runtimeId } [UInt64]$ownershipGeneration = if ($null -ne $existingPointer) { [UInt64]$existingPointer.generation } else { 1 } $lifecycleOwnership = & $adapters.EnterLifecycleOwnership $root $ownershipRuntimeId $ownershipGeneration $ownerIdentity $identity.UserSid $identity.SessionId - $pointer = & $adapters.SetActiveRuntime $root $runtimeId $lifecycleOwnership + $pointer = & $adapters.SetActiveRuntime $root $runtimeId $lifecycleOwnership $generationCapability $fileTransaction } finally { $process.Dispose() } [UInt64]$expectedGeneration = if ($null -ne $existingPointer) { [UInt64]$existingPointer.generation + 1 } else { 1 } if ($null -eq $pointer -or $pointer.activeRuntime -cne $runtimeId -or [UInt64]$pointer.generation -ne $expectedGeneration) { Throw-CcodLifecycleError 'CCOD_INSTALL_RUNTIME_ACTIVATION_UNPROVEN' 'The active runtime generation commit could not be proven' $null } $pointerCommitted = $true + $installRecord=Set-CcodInstallTransactionPhase -InstallRoot $root -TransactionId $installRecord.transactionId -ExpectedPhase 'RuntimePromoted' -NewPhase 'PointerCommitted' -FileTransaction $fileTransaction if ($null -ne $shutdownGate) { & $adapters.CloseSupervisorShutdownGate $shutdownGate $shutdownGate = $null } - Write-CcodInstallActivationPhase -Activation $activation -Phase 'StartingProtection' -RuntimeId $runtimeId -PreviousRuntimeId $pointer.previousRuntime -ErrorCode $null -Adapters $adapters -InstallRoot $root | Out-Null - Install-CcodLifecycleTask -InstallRoot $root -Adapters $adapters -Identity $identity + Write-CcodInstallActivationPhase -Activation $activation -Phase 'StartingProtection' -RuntimeId $runtimeId -PreviousRuntimeId $pointer.previousRuntime -ErrorCode $null -Adapters $adapters -InstallRoot $root -FileTransaction $fileTransaction | Out-Null + Install-CcodLifecycleTask -InstallRoot $root -Adapters $adapters -Identity $identity -RuntimeId $runtimeId + $installRecord=Set-CcodInstallTransactionPhase -InstallRoot $root -TransactionId $installRecord.transactionId -ExpectedPhase 'PointerCommitted' -NewPhase 'StableShellCommitted' -FileTransaction $fileTransaction if ($DoNotStart) { Throw-CcodLifecycleError 'CCOD_INSTALL_NEW_RUNTIME_NOT_READY' 'Activation cannot succeed without starting and proving the new runtime' $null } @@ -1722,6 +3468,10 @@ function Invoke-CcodInstall { } $installLease = $null } + if($null-ne$upgradeProductCleanupLease){ + [void](Exit-CcodLifecycleProductCleanupLease -Context $upgradeProductCleanupLease) + $upgradeProductCleanupLease=$null + } $taskStartedAt = & $adapters.UtcNow try { & $adapters.StartSupervisorTask } catch { Throw-CcodLifecycleError 'CCOD_INSTALL_SUPERVISOR_START_FAILED' 'The existing scheduled task could not start the new runtime' $null } @@ -1730,21 +3480,34 @@ function Invoke-CcodInstall { -not $readyProof.SupervisorReady -or -not $readyProof.TrayReady) { Throw-CcodLifecycleError 'CCOD_INSTALL_NEW_RUNTIME_NOT_READY' 'The new Supervisor and authenticated TrayHost readiness were not proven' $null } - Write-CcodInstallActivationPhase -Activation $activation -Phase 'Ready' -RuntimeId $runtimeId -PreviousRuntimeId $pointer.previousRuntime -ErrorCode $null -Adapters $adapters -InstallRoot $root | Out-Null - if ($upgrade) { - try { Remove-CcodLifecycleOldRuntimes -InstallRoot $root -ActiveRuntimeId $runtimeId -PreviousRuntimeId $pointer.previousRuntime } - catch { - try { Write-CcodLifecycleLog -InstallRoot $root -Adapters $adapters -Stage 'OldRuntimeCleanup' -Code 'CCOD_INSTALL_OLD_RUNTIME_CLEANUP_FAILED' -Outcome 'Retained' } catch { } - } - } + $installRecord=Set-CcodInstallTransactionPhase -InstallRoot $root -TransactionId $installRecord.transactionId -ExpectedPhase 'StableShellCommitted' -NewPhase 'ProtectionReady' -FileTransaction $fileTransaction + $finalPointer = Read-CcodActiveRuntime -InstallRoot $root + $finalValidation = Test-CcodRuntimeManifest -RuntimeDirectory $runtimeRoot -ExpectedRuntimeId $runtimeId -ExpectedManifestSha256 $installRecord.newManifestSha256 + $finalExpectedVersion = if ($payloadBound) { $ExpectedVersion } else { [string]$projectVersion } + if ($finalPointer.activeRuntime -cne $runtimeId -or [UInt64]$finalPointer.generation -ne [UInt64]$pointer.generation -or + -not $finalValidation.Valid -or [string]$finalValidation.Manifest.projectVersion -cne $finalExpectedVersion) { + Throw-CcodLifecycleError 'CCOD_INSTALL_RUNTIME_ACTIVATION_UNPROVEN' 'The final active pointer and runtime version could not be revalidated' $runtimeRoot + } + Write-CcodInstallActivationPhase -Activation $activation -Phase 'Ready' -RuntimeId $runtimeId -PreviousRuntimeId $pointer.previousRuntime -ErrorCode $null -Adapters $adapters -InstallRoot $root -FileTransaction $fileTransaction | Out-Null + $readyReceiptCommitted=$true + $installRecord=&$adapters.CommitReadyTransaction $root $installRecord.transactionId $fileTransaction try { - Write-CcodLifecycleLog -InstallRoot $root -Adapters $adapters -Stage $(if ($upgrade) { 'Upgrade' } else { 'Install' }) -Code 'CCOD_INSTALL_COMPLETED' -Outcome $(if ($upgrade) { 'Upgraded' } else { 'Installed' }) -ThrowOnFailure + Write-CcodLifecycleLog -InstallRoot $root -Adapters $adapters -Stage $(if ($upgrade) { 'Upgrade' } else { 'Install' }) -Code 'CCOD_INSTALL_COMPLETED' -Outcome $(if ($upgrade) { 'Upgraded' } else { 'Installed' }) -ThrowOnFailure -FileTransaction $fileTransaction } catch { - try { Write-CcodLifecycleLog -InstallRoot $root -Adapters $adapters -Stage 'PostReady' -Code 'CCOD_INSTALL_POST_READY_LOG_FAILED' -Outcome 'ReadyRetained' } catch { } + try { Write-CcodLifecycleLog -InstallRoot $root -Adapters $adapters -Stage 'PostReady' -Code 'CCOD_INSTALL_POST_READY_LOG_FAILED' -Outcome 'ReadyRetained' -FileTransaction $fileTransaction } catch { } + } + if([string]$projectVersion-ceq$script:CcodProductVersion){ + try{ + &$adapters.CloseReadyGeneration $fileTransaction;$fileTransaction=$null + $productAdapters=&$adapters.GetProductRegistrationAdapters;$registrationReceipt=&$adapters.RegisterProduct $root $runtimeId ([string]$projectVersion) $SealedPackageSha256 $fileTransaction $installRecord $productAdapters $adapters.CloseProductTransaction + if($null-eq$registrationReceipt-or$registrationReceipt.verified-isnot[bool]-or-not$registrationReceipt.verified){throw 'registration receipt invalid'} + $productRegistrationVerified=$true + }catch{$productRegistrationFailure=$_} } } catch { $caught = $_ $errorCode = Get-CcodLifecycleErrorId $caught + if($readyReceiptCommitted){Throw-CcodLifecycleError 'CCOD_INSTALL_READY_FINALIZATION_PENDING' 'Ready activation is visible but the final transaction snapshot requires recovery' $installRecord.transactionId} if ($errorCode -notmatch '^CCOD_[A-Z0-9_]+$') { $errorCode = switch ([string]$activation.LastPhase) { 'StoppingPreviousRuntime' { 'CCOD_INSTALL_PREVIOUS_RUNTIME_BUSY' } @@ -1754,7 +3517,7 @@ function Invoke-CcodInstall { default { 'CCOD_INSTALL_FAILED' } } } - if (-not $pointerCommitted -and $null -ne $runtimeId -and [IO.File]::Exists($activePath)) { + if (-not $pointerCommitted -and $null -ne $runtimeId) { try { $observedPointer = Read-CcodActiveRuntime -InstallRoot $root if ($observedPointer.activeRuntime -ceq $runtimeId -and ($null -eq $existingPointer -or [UInt64]$observedPointer.generation -gt [UInt64]$existingPointer.generation)) { @@ -1762,6 +3525,7 @@ function Invoke-CcodInstall { } } catch { } } + if($pointerCommitted-and$upgrade-and$null-ne$fileTransaction-and$null-ne$installRecord){try{$pointer=Invoke-CcodInstallCompensation -InstallRoot $root -TransactionRecord $installRecord -FileTransaction $fileTransaction -Adapters $adapters -Ownership $lifecycleOwnership -Identity $identity}catch{$errorCode='CCOD_INSTALL_ROLLBACK_FAILED'}} if (-not $pointerCommitted -and $upgrade -and $previousProtectionStopped) { $rollbackFailure = $null try { @@ -1782,6 +3546,13 @@ function Invoke-CcodInstall { } $installLease = $null } + if ($null -ne $upgradeProductCleanupLease) { + $cleanupReleased = Exit-CcodLifecycleProductCleanupLease -Context $upgradeProductCleanupLease + if ($cleanupReleased -isnot [bool] -or -not $cleanupReleased -or -not $upgradeProductCleanupLease.Lease.Released) { + throw 'Previous runtime product cleanup lease release was not proven' + } + $upgradeProductCleanupLease = $null + } $rollbackStartedAt = & $adapters.UtcNow if ($rollbackStartedAt -isnot [DateTime]) { throw 'Previous runtime restart clock is invalid' } & $adapters.StartSupervisorTask @@ -1790,21 +3561,19 @@ function Invoke-CcodInstall { -not $rollbackProof.SupervisorReady -or -not $rollbackProof.TrayReady) { throw 'Previous runtime readiness was not proven' } - try { Write-CcodLifecycleLog -InstallRoot $root -Adapters $adapters -Stage 'UpgradeRollback' -Code 'CCOD_INSTALL_PREVIOUS_RUNTIME_RESTORED' -Outcome 'Restored' } catch { } + try { Write-CcodLifecycleLog -InstallRoot $root -Adapters $adapters -Stage 'UpgradeRollback' -Code 'CCOD_INSTALL_PREVIOUS_RUNTIME_RESTORED' -Outcome 'Restored' -FileTransaction $fileTransaction } catch { } } catch { $rollbackFailure = $_ } if ($null -ne $rollbackFailure) { $errorCode = 'CCOD_INSTALL_ROLLBACK_FAILED' - try { Write-CcodLifecycleLog -InstallRoot $root -Adapters $adapters -Stage 'UpgradeRollback' -Code $errorCode -Outcome 'Failed' } catch { } + try { Write-CcodLifecycleLog -InstallRoot $root -Adapters $adapters -Stage 'UpgradeRollback' -Code $errorCode -Outcome 'Failed' -FileTransaction $fileTransaction } catch { } } } - if (-not $pointerCommitted -and $runtimeCreated -and $null -ne $runtimeRoot -and [IO.Directory]::Exists($runtimeRoot)) { - try { Remove-Item -LiteralPath $runtimeRoot -Recurse -Force -ErrorAction Stop } catch { } - } + if($null-ne$installRecord-and$null-ne$fileTransaction-and$installRecord.phase-notin@('Ready','Failed')){try{$installRecord=Set-CcodInstallTransactionPhase -InstallRoot $root -TransactionId $installRecord.transactionId -ExpectedPhase $installRecord.phase -NewPhase Failed -ErrorCode $errorCode -FileTransaction $fileTransaction}catch{}} if ($errorCode -cne 'CCOD_INSTALL_ACTIVATION_RECEIPT_FAILED') { try { - Write-CcodInstallActivationPhase -Activation $activation -Phase 'Failed' -RuntimeId $runtimeId -PreviousRuntimeId $(if ($null -ne $existingPointer) { [string]$existingPointer.activeRuntime } else { $null }) -ErrorCode $errorCode -Adapters $adapters -InstallRoot $root | Out-Null + Write-CcodInstallActivationPhase -Activation $activation -Phase 'Failed' -RuntimeId $runtimeId -PreviousRuntimeId $(if ($null -ne $existingPointer) { [string]$existingPointer.activeRuntime } else { $null }) -ErrorCode $errorCode -Adapters $adapters -InstallRoot $root -FileTransaction $fileTransaction | Out-Null } catch { } } if ((Get-CcodLifecycleErrorId $caught) -ceq $errorCode) { throw $caught } @@ -1813,19 +3582,22 @@ function Invoke-CcodInstall { if ($null -ne $shutdownGate) { try { & $adapters.CloseSupervisorShutdownGate $shutdownGate } catch { } } - if ($null -ne $installLease -and $installLease.Outcome -ceq 'Acquired') { - try { [void](& $adapters.ExitInstallLease $installLease) } catch { } - } if ($null -ne $lifecycleOwnership -and -not $lifecycleOwnership.released) { try { & $adapters.ExitLifecycleOwnership $lifecycleOwnership | Out-Null } catch { } } - if ($null -ne $stagingDirectory -and [IO.Directory]::Exists($stagingDirectory)) { - try { Remove-Item -LiteralPath $stagingDirectory -Recurse -Force -ErrorAction Stop } catch { } + if ($null -ne $installLease -and $installLease.Outcome -ceq 'Acquired') { + try { [void](& $adapters.ExitInstallLease $installLease) } catch { } } - $stagingRoot = [IO.Path]::GetFullPath((Join-Path $root '.staging')) - if ([IO.Directory]::Exists($stagingRoot) -and -not (Test-CcodLifecycleReparse -Path $stagingRoot)) { - try { Remove-Item -LiteralPath $stagingRoot -Recurse -Force -ErrorAction Stop } catch { } + if($null-ne$upgradeProductCleanupLease-and$null-ne$upgradeProductCleanupLease.Lease-and-not$upgradeProductCleanupLease.Lease.Released){ + try{[void](Exit-CcodLifecycleProductCleanupLease -Context $upgradeProductCleanupLease)}catch{} } + if($null-ne$fileTransaction){try{Close-CcodInstallFileTransaction -Transaction $fileTransaction -Disposition $(if($null-ne$installRecord-and$installRecord.phase-ceq'Ready'){'Ready'}else{'Failed'})}catch{}} + if(-not[string]::IsNullOrWhiteSpace($productShortcutTemporaryRoot)-and(Test-Path -LiteralPath $productShortcutTemporaryRoot)){try{Remove-Item -LiteralPath $productShortcutTemporaryRoot -Recurse -Force -ErrorAction Stop}catch{}} + } + + if($null-ne$productRegistrationFailure){ + if(([string]$productRegistrationFailure.FullyQualifiedErrorId-split',')[0]-ceq'CCOD_PRODUCT_REGISTRATION_FAILED'){throw $productRegistrationFailure} + Throw-CcodLifecycleError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Runtime is Ready but post-Ready product registration failed; legacy state was retained' $runtimeId } return [pscustomobject][ordered]@{ @@ -1834,6 +3606,7 @@ function Invoke-CcodInstall { RuntimeId = $runtimeId PreviousRuntimeId = $pointer.previousRuntime RepairCompleted = $false + ProductRegistrationVerified = [bool]$productRegistrationVerified } } @@ -1889,7 +3662,8 @@ function Invoke-CcodUninstallCleanup { [Parameter(Mandatory)][string]$InstallRoot, [Parameter(Mandatory)]$Transaction, [Parameter(Mandatory)][scriptblock]$WriteTransaction, - [hashtable]$Adapters + [hashtable]$Adapters, + [switch]$StopAfterTaskRemoval ) $adapters = Get-CcodLifecycleAdapters -Adapters $Adapters @@ -1996,6 +3770,7 @@ function Invoke-CcodUninstallCleanup { catch { Throw-CcodLifecycleError 'CCOD_UNINSTALL_TASK_REMOVAL_FAILED' 'The supervisor scheduled task could not be removed' $Transaction } Set-CcodUninstallTransactionPhase -Transaction $Transaction -Phase 'TaskRemoved' -WriteTransaction $WriteTransaction -Adapters $adapters $phase = 'TaskRemoved' + if($StopAfterTaskRemoval){return $Transaction} } if ($phase -eq 'TaskRemoved') { diff --git a/src/persistence/modules/PersistenceIO.psm1 b/src/persistence/modules/PersistenceIO.psm1 index a69517b..41f4b8b 100644 --- a/src/persistence/modules/PersistenceIO.psm1 +++ b/src/persistence/modules/PersistenceIO.psm1 @@ -546,7 +546,8 @@ function Read-CcodStrictJson { param( [Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)]$ExpectedSchema, - [Parameter(Mandatory)][string]$Kind + [Parameter(Mandatory)][string]$Kind, + [ValidateRange(1,10485760)][int64]$MaxBytes = 1048576 ) if (-not [IO.File]::Exists($Path)) { @@ -554,7 +555,31 @@ function Read-CcodStrictJson { } try { - $json = [IO.File]::ReadAllText($Path, [Text.UTF8Encoding]::new($false)) + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + Throw-CcodError 'CCOD_STATE_UNSAFE' "$Kind state is not a plain file" $Path + } + if ([int64]$item.Length -gt $MaxBytes) { + Throw-CcodError 'CCOD_STATE_TOO_LARGE' "$Kind state exceeds the bounded record size" $Path + } + $stream = [IO.File]::Open($Path,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read) + try { + if ($stream.Length -gt $MaxBytes -or $stream.Length -gt [int]::MaxValue) { + Throw-CcodError 'CCOD_STATE_TOO_LARGE' "$Kind state exceeds the bounded record size" $Path + } + $bytes = [byte[]]::new([int]$stream.Length) + $offset = 0 + while ($offset -lt $bytes.Length) { + $read = $stream.Read($bytes,$offset,$bytes.Length-$offset) + if ($read -le 0) { Throw-CcodError 'CCOD_STATE_MALFORMED' "$Kind state could not be read completely" $Path } + $offset += $read + } + if ($stream.ReadByte() -ne -1) { Throw-CcodError 'CCOD_STATE_TOO_LARGE' "$Kind state changed beyond the bounded record size" $Path } + } finally { $stream.Dispose() } + $json = [Text.UTF8Encoding]::new($false,$true).GetString($bytes) + } catch [Management.Automation.RuntimeException] { + if (([string]$_.FullyQualifiedErrorId -split ',')[0] -clike 'CCOD_*') { throw } + Throw-CcodError 'CCOD_STATE_MALFORMED' "$Kind state is not valid JSON" $Path } catch { Throw-CcodError 'CCOD_STATE_MALFORMED' "$Kind state is not valid JSON" $Path } diff --git a/src/persistence/modules/PortableRelease.psm1 b/src/persistence/modules/PortableRelease.psm1 index 859d1bd..c896c5f 100644 --- a/src/persistence/modules/PortableRelease.psm1 +++ b/src/persistence/modules/PortableRelease.psm1 @@ -356,7 +356,9 @@ function Copy-CcodPortablePayload { Clear-CcodPortableReleaseZoneIdentifier -Path $installedManifestPath Test-CcodPortablePayloadManifest -PayloadRoot $staging -ManifestPath $installedManifestPath -ExpectedVersion $manifest.Version -ExpectedGitCommit $manifest.GitCommit -AllowInstalledMetadata | Out-Null [IO.Directory]::Move($staging,$target) - return [pscustomobject][ordered]@{ InstallerRoot=$target; Manifest=$manifest } + $publishedManifestPath = Join-Path $target 'portable-payload-manifest.json' + $publishedManifest = Test-CcodPortablePayloadManifest -PayloadRoot $target -ManifestPath $publishedManifestPath -ExpectedVersion $manifest.Version -ExpectedGitCommit $manifest.GitCommit -AllowInstalledMetadata + return [pscustomobject][ordered]@{ InstallerRoot=$target; Manifest=$publishedManifest } } catch { if ([IO.Directory]::Exists($staging)) { try { Remove-Item -LiteralPath $staging -Recurse -Force -ErrorAction Stop } catch { } diff --git a/src/persistence/modules/ProcessControl.psm1 b/src/persistence/modules/ProcessControl.psm1 index f723324..98b9f50 100644 --- a/src/persistence/modules/ProcessControl.psm1 +++ b/src/persistence/modules/ProcessControl.psm1 @@ -1408,6 +1408,44 @@ function Get-CcodVerifiedProcessTree { return @($included | Sort-Object | ForEach-Object { $rereadByPid[[int]$_] }) } +function Get-CcodStableVerifiedProcessTree { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$Root, + $StatusEvidence, + [ValidateRange(0, 2)][int]$RetryBudget = 1, + [hashtable]$Adapters + ) + + $adapter = Get-CcodProcessAdapters -Adapters $Adapters + if (-not $adapter.ContainsKey('GetVerifiedTree')) { + $adapter.GetVerifiedTree = { + param($ExpectedRoot, $Evidence) + Get-CcodVerifiedProcessTree -Root $ExpectedRoot -StatusEvidence $Evidence -Adapters $adapter + }.GetNewClosure() + } + + for ($attempt = 0; $attempt -le $RetryBudget; $attempt++) { + try { $tree = @(& $adapter.GetVerifiedTree $Root $StatusEvidence) } catch { $tree = @() } + if ($tree.Count -gt 0) { + $matchingRoots = @($tree | Where-Object { + $null -ne $_ -and $_.PSObject.Properties['Pid'] -ne $null -and + $_.Pid -is [int] -and $_.Pid -eq [int]$Root.Pid -and + (Test-CcodProcessMatch -Expected $Root -Actual $_) + }) + if ($matchingRoots.Count -ne 1) { return @() } + return @($tree) + } + if ($attempt -ge $RetryBudget) { return @() } + + try { $rootOutput = @(& $adapter.GetProcess ([int]$Root.Pid) $StatusEvidence 2>&1) } catch { return @() } + if ($rootOutput.Count -ne 1 -or $rootOutput[0] -is [Management.Automation.ErrorRecord] -or + $null -eq $rootOutput[0] -or -not (Test-CcodProcessMatch -Expected $Root -Actual $rootOutput[0])) { return @() } + try { & $adapter.Delay 50 | Out-Null } catch { return @() } + } + return @() +} + function Get-CcodVerifiedStaleProcessTree { [CmdletBinding()] param( @@ -1995,4 +2033,4 @@ function Start-CcodProcess { return New-CcodStartResult -Outcome 'Started' -Snapshot $null -Process $process } -Export-ModuleMember -Function Get-CcodProcessIdentityObservation, Get-CcodProcessSnapshot, Test-CcodProcessMatch, Get-CcodStalePackageProcessSnapshot, Get-CcodStalePackageRootResult, Get-CcodVerifiedProcessTree, Get-CcodVerifiedStaleProcessTree, Get-CcodTransactionProcessResult, Find-CcodTransactionProcess, Stop-CcodProcessIfMatch, Stop-CcodStaleProcessIfMatch, Request-CcodProcessGracefulCloseIfMatch, Request-CcodStaleProcessGracefulCloseIfMatch, Wait-CcodProcessExitIfMatch, Wait-CcodStaleProcessExitIfMatch, Start-CcodProcess, Request-CcodOrdinaryPackagedLaunch, Wait-CcodVerifiedOrdinaryRoot, Get-CcodAvailableLoopbackPort, Wait-CcodPortClosed +Export-ModuleMember -Function Get-CcodProcessIdentityObservation, Get-CcodProcessSnapshot, Test-CcodProcessMatch, Get-CcodStalePackageProcessSnapshot, Get-CcodStalePackageRootResult, Get-CcodVerifiedProcessTree, Get-CcodStableVerifiedProcessTree, Get-CcodVerifiedStaleProcessTree, Get-CcodTransactionProcessResult, Find-CcodTransactionProcess, Stop-CcodProcessIfMatch, Stop-CcodStaleProcessIfMatch, Request-CcodProcessGracefulCloseIfMatch, Request-CcodStaleProcessGracefulCloseIfMatch, Wait-CcodProcessExitIfMatch, Wait-CcodStaleProcessExitIfMatch, Start-CcodProcess, Request-CcodOrdinaryPackagedLaunch, Wait-CcodVerifiedOrdinaryRoot, Get-CcodAvailableLoopbackPort, Wait-CcodPortClosed diff --git a/src/persistence/modules/ProductRegistration.psm1 b/src/persistence/modules/ProductRegistration.psm1 new file mode 100644 index 0000000..269bd0a --- /dev/null +++ b/src/persistence/modules/ProductRegistration.psm1 @@ -0,0 +1,1144 @@ +Set-StrictMode -Version Latest + +$script:CcodProductVersion = '2.5.22' +$script:CcodProductAppId = '{2B9E9F2E-7A32-4A7E-9C1D-9F5B5C6D7E8F}' +$script:CcodProductKeyName = 'CodexRemote-fix' +$script:CcodProductFields = @( + 'schemaVersion','installRoot','runtimeId','version','packageSha256','runtimeRoot','bootstrapPath','uninstallerPath', + 'productKeyName','startMenuShortcut','desktopShortcut' +) +$script:CcodShortcutFields = @('kind','leaf','relativePath','candidatePath','bootstrapPath','installRoot') +$script:CcodReadyFields = @('phase','runtimeId','version','packageSha256','runtimeGeneration','manifestSha256','startMenuSha256','desktopSha256','targetPath','arguments','transactionRecord','bootstrapPath','uninstallerPath') +$script:CcodLegacyRegistrationFields = @('appId','displayVersion','installLocation','uninstallString','shortcutNames','unsafeShortcutNames') +$script:CcodLegacyProfileFields = @('profileId','appId','minimumVersion','maximumVersion','uninstallCommandShape','shortcutNames') +$script:CcodLegacyMigrationPlanFields = @('appId','expectedInstallRoot','legacyPresent','legacyRegistration','profile','snapshot') +$script:CcodLegacyShortcutSnapshotFields = @('kind','name','path','sha256','bytesBase64','targetPath','arguments','workingDirectory') +$script:CcodDurableLegacyMigrationFields = @('schemaVersion','transactionId','runtimeId','runtimeGeneration','manifestSha256','packageSha256','readyTransaction','appId','expectedInstallRoot','legacyPresent','legacyRegistration','profile','snapshot','expectedCurrentProof') +$script:CcodDurableCurrentProofFields = @('version','runtimeId','packageSha256','shortcuts') +$script:CcodDurableCurrentShortcutFields = @('kind','name','path','candidatePath','candidateLength','candidateSha256','targetPath','arguments','workingDirectory') +$script:CcodReadyTransactionFields = @('schemaVersion','transactionId','oldRuntimeId','oldGeneration','oldManifestSha256','newRuntimeId','newGeneration','newManifestSha256','sealedPackageSha256','ownedObjectNames','phase','errorCode') +$script:CcodVerifiedRegistrationFields = @('verified','runtimeId','version','packageSha256','shortcutNames','startMenuSha256','desktopSha256') +$script:CcodCurrentShortcutNames = @('Programs\CodexRemote-fix\CodexRemote-fix.lnk','Desktop\CodexRemote-fix.lnk') +$script:CcodLegacyRegistryValueNames=@('DisplayName','DisplayVersion','UninstallString','QuietUninstallString','DisplayIcon','InstallLocation','Publisher','URLInfoAbout','HelpLink','URLUpdateInfo','NoModify','NoRepair','InstallDate','MajorVersion','MinorVersion','VersionMajor','VersionMinor','EstimatedSize','Language','Inno Setup: App Path','Inno Setup: Icon Group','Inno Setup: Setup Version','Inno Setup: User') + +function Throw-CcodProductRegistrationError { + param([Parameter(Mandatory)][string]$Id,[Parameter(Mandatory)][string]$Message,$Target) + throw [Management.Automation.ErrorRecord]::new( + [InvalidOperationException]::new($Message),$Id,[Management.Automation.ErrorCategory]::InvalidData,$Target) +} + +function Test-CcodProductExactProperties { + param($Value,[Parameter(Mandatory)][string[]]$Names) + if ($null -eq $Value) { return $false } + $properties = @($Value.PSObject.Properties) + if ($properties.Count -ne $Names.Count) { return $false } + for ($index = 0; $index -lt $Names.Count; $index++) { + if ($properties[$index].Name -cne $Names[$index]) { return $false } + } + return $true +} + +function Test-CcodProductExactStringSet { + param($Values,$ExpectedValues) + $actual=@($Values);$expected=@($ExpectedValues) + if($actual.Count-ne$expected.Count){return $false} + $set=[Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + foreach($value in $actual){if($value-isnot[string]-or[string]::IsNullOrWhiteSpace([string]$value)-or-not$set.Add([string]$value)){return $false}} + foreach($value in $expected){if($value-isnot[string]-or-not$set.Contains([string]$value)){return $false}} + return $true +} + +function Get-CcodLegacyRegistrationProfiles { + [CmdletBinding()] + param() + $oldStartMenu=@( + 'Programs\Codex Control other devices\Codex Control other devices for Windows.lnk', + 'Programs\Codex Control other devices\Open the tray supervisor.lnk', + 'Programs\Codex Control other devices\Compatibility check.lnk', + 'Programs\Codex Control other devices\Uninstall Codex Control other devices.lnk' + ) + $oldDesktop='Desktop\Codex '+[char]0x8BBE+[char]0x5907+[char]0x8FDE+[char]0x63A5+' (Device Connection).lnk' + $rebranded=@( + 'Programs\CodexRemote-fix\CodexRemote-fix.lnk', + 'Programs\CodexRemote-fix\CodexRemote-fix compatibility check.lnk', + 'Programs\CodexRemote-fix\Uninstall CodexRemote-fix.lnk', + 'Desktop\CodexRemote-fix.lnk' + ) + return @( + [pscustomobject][ordered]@{profileId='CodexControlOtherDevicesInitial';appId=$script:CcodProductAppId;minimumVersion='2.1.0';maximumVersion='2.1.0';uninstallCommandShape='"{installLocation}\unins000.exe"';shortcutNames=@($oldStartMenu)}, + [pscustomobject][ordered]@{profileId='CodexControlOtherDevicesDesktop';appId=$script:CcodProductAppId;minimumVersion='2.1.1';maximumVersion='2.1.6';uninstallCommandShape='"{installLocation}\unins000.exe"';shortcutNames=@($oldStartMenu)+@($oldDesktop)}, + [pscustomobject][ordered]@{profileId='CodexRemoteFix';appId=$script:CcodProductAppId;minimumVersion='2.2.0';maximumVersion='2.5.21';uninstallCommandShape='"{installLocation}\unins000.exe"';shortcutNames=@($rebranded)} + ) +} + +function Resolve-CcodLegacyRegistrationProfile { + [CmdletBinding()] + param([Parameter(Mandatory)]$LegacyRegistration) + if(-not(Test-CcodProductExactProperties $LegacyRegistration $script:CcodLegacyRegistrationFields)-or + $LegacyRegistration.appId-isnot[string]-or[string]$LegacyRegistration.appId-cne$script:CcodProductAppId-or + $LegacyRegistration.displayVersion-isnot[string]-or[string]$LegacyRegistration.displayVersion-cnotmatch'^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$'-or + $LegacyRegistration.installLocation-isnot[string]-or-not[IO.Path]::IsPathRooted([string]$LegacyRegistration.installLocation)-or + $LegacyRegistration.uninstallString-isnot[string]-or$null-eq$LegacyRegistration.shortcutNames-or$null-eq$LegacyRegistration.unsafeShortcutNames-or + @($LegacyRegistration.unsafeShortcutNames).Count-ne0){ + Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Legacy product registration evidence is incomplete or unsafe.' $LegacyRegistration + } + try{$installLocation=[IO.Path]::GetFullPath([string]$LegacyRegistration.installLocation).TrimEnd('\');$version=[version]::Parse([string]$LegacyRegistration.displayVersion)}catch{ + Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Legacy product registration paths or version are invalid.' $LegacyRegistration + } + if([string]::IsNullOrWhiteSpace($installLocation)-or[string]$LegacyRegistration.installLocation.TrimEnd('\')-cne$installLocation){ + Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Legacy installer location is not canonical.' $LegacyRegistration.installLocation + } + $matches=[Collections.Generic.List[object]]::new() + foreach($profile in @(Get-CcodLegacyRegistrationProfiles)){ + if(-not(Test-CcodProductExactProperties $profile $script:CcodLegacyProfileFields)){continue} + $minimum=[version]::Parse([string]$profile.minimumVersion);$maximum=[version]::Parse([string]$profile.maximumVersion) + $expectedUninstall=[string]$profile.uninstallCommandShape.Replace('{installLocation}',$installLocation) + if([string]$profile.appId-ceq[string]$LegacyRegistration.appId-and$version-ge$minimum-and$version-le$maximum-and + [string]$LegacyRegistration.uninstallString-ceq$expectedUninstall-and + (Test-CcodProductExactStringSet $LegacyRegistration.shortcutNames $profile.shortcutNames)){$matches.Add($profile)} + } + if($matches.Count-ne1){Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Legacy product entry does not match one complete historical installer profile.' $LegacyRegistration} + return $matches[0] +} + +function Test-CcodCurrentVerifiedRegistration { + param($Proof) + return (Test-CcodProductExactProperties $Proof $script:CcodVerifiedRegistrationFields) -and + $Proof.verified-is[bool] -and [bool]$Proof.verified -and + $Proof.runtimeId-is[string] -and [string]$Proof.runtimeId-cmatch'^2\.5\.22-[0-9a-f]{16}-[0-9a-f]{32}$' -and + $Proof.version-is[string] -and [string]$Proof.version-ceq$script:CcodProductVersion -and + $Proof.packageSha256-is[string] -and [string]$Proof.packageSha256-cmatch'^[0-9a-f]{64}$' -and + $Proof.startMenuSha256-is[string] -and [string]$Proof.startMenuSha256-cmatch'^[0-9a-f]{64}$' -and + $Proof.desktopSha256-is[string] -and [string]$Proof.desktopSha256-cmatch'^[0-9a-f]{64}$' -and + (Test-CcodProductExactStringSet $Proof.shortcutNames $script:CcodCurrentShortcutNames) +} + +function Test-CcodCurrentVerifiedRegistrationUnchanged { + param($Before,$After) + return (Test-CcodCurrentVerifiedRegistration $Before) -and (Test-CcodCurrentVerifiedRegistration $After) -and + [string]$Before.runtimeId-ceq[string]$After.runtimeId -and [string]$Before.version-ceq[string]$After.version -and + [string]$Before.packageSha256-ceq[string]$After.packageSha256 -and + [string]$Before.startMenuSha256-ceq[string]$After.startMenuSha256 -and [string]$Before.desktopSha256-ceq[string]$After.desktopSha256 -and + (Test-CcodProductExactStringSet $Before.shortcutNames $After.shortcutNames) +} + +function Get-CcodProductFullPath { + param([Parameter(Mandatory)][string]$Path) + if ([string]::IsNullOrWhiteSpace($Path) -or -not [IO.Path]::IsPathRooted($Path)) { + Throw-CcodProductRegistrationError 'CCOD_PRODUCT_REGISTRATION_INVALID' 'Product registration paths must be absolute.' $Path + } + try { return [IO.Path]::GetFullPath($Path).TrimEnd('\') } + catch { Throw-CcodProductRegistrationError 'CCOD_PRODUCT_REGISTRATION_INVALID' 'Product registration path is invalid.' $Path } +} + +function Test-CcodProductShortcut { + param($Shortcut,$Expected) + if (-not (Test-CcodProductExactProperties $Shortcut $script:CcodShortcutFields)) { return $false } + foreach ($name in $script:CcodShortcutFields) { + if ($Shortcut.$name -isnot [string] -or [string]$Shortcut.$name -cne [string]$Expected.$name) { return $false } + } + return $true +} + +function New-CcodProductRegistration { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$InstallRoot, + [Parameter(Mandatory)][string]$RuntimeId, + [Parameter(Mandatory)][string]$Version, + [Parameter(Mandatory)][string]$PackageSha256, + [Parameter(Mandatory)]$FileTransaction + ) + $root = Get-CcodProductFullPath $InstallRoot + if ($null -eq $FileTransaction -or $Version -cne $script:CcodProductVersion -or + $RuntimeId -cnotmatch ('^' + [regex]::Escape($Version) + '-[0-9a-f]{16}-[0-9a-f]{32}$') -or + $PackageSha256 -cnotmatch '^[0-9a-f]{64}$') { + Throw-CcodProductRegistrationError 'CCOD_PRODUCT_REGISTRATION_INVALID' 'Product registration identity is invalid.' $RuntimeId + } + $runtimeRoot = [IO.Path]::GetFullPath((Join-Path (Join-Path $root 'runtime') $RuntimeId)) + $bootstrapPath = [IO.Path]::GetFullPath((Join-Path $runtimeRoot 'src\persistence\bootstrap.ps1')) + $uninstallerPath = [IO.Path]::GetFullPath((Join-Path $runtimeRoot 'Uninstall-CodexControlOtherDevices.ps1')) + $candidateRoot = [IO.Path]::GetFullPath((Join-Path $runtimeRoot 'registration')) + $startMenu = [pscustomobject][ordered]@{ + kind = 'StartMenu'; leaf = 'CodexRemote-fix.lnk'; relativePath = 'CodexRemote-fix\CodexRemote-fix.lnk' + candidatePath = [IO.Path]::GetFullPath((Join-Path $candidateRoot 'StartMenu.CodexRemote-fix.lnk')) + bootstrapPath = $bootstrapPath; installRoot = $root + } + $desktop = [pscustomobject][ordered]@{ + kind = 'Desktop'; leaf = 'CodexRemote-fix.lnk'; relativePath = 'CodexRemote-fix.lnk' + candidatePath = [IO.Path]::GetFullPath((Join-Path $candidateRoot 'Desktop.CodexRemote-fix.lnk')) + bootstrapPath = $bootstrapPath; installRoot = $root + } + return [pscustomobject][ordered]@{ + schemaVersion = 1; installRoot = $root; runtimeId = $RuntimeId; version = $Version; packageSha256 = $PackageSha256 + runtimeRoot = $runtimeRoot; bootstrapPath = $bootstrapPath; uninstallerPath = $uninstallerPath + productKeyName = $script:CcodProductKeyName; startMenuShortcut = $startMenu; desktopShortcut = $desktop + } +} + +function Test-CcodProductRegistration { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$Registration, + [Parameter(Mandatory)][string]$ExpectedRuntimeId, + [Parameter(Mandatory)][string]$ExpectedVersion, + [Parameter(Mandatory)][string]$ExpectedPackageSha256 + ) + if (-not (Test-CcodProductExactProperties $Registration $script:CcodProductFields) -or + $Registration.schemaVersion -isnot [int] -or $Registration.schemaVersion -ne 1 -or + $Registration.installRoot -isnot [string] -or -not [IO.Path]::IsPathRooted([string]$Registration.installRoot) -or + $Registration.runtimeId -isnot [string] -or [string]$Registration.runtimeId -cne $ExpectedRuntimeId -or + $Registration.version -isnot [string] -or [string]$Registration.version -cne $ExpectedVersion -or + $Registration.packageSha256 -isnot [string] -or [string]$Registration.packageSha256 -cne $ExpectedPackageSha256 -or + $Registration.productKeyName -isnot [string] -or [string]$Registration.productKeyName -cne $script:CcodProductKeyName) { return $false } + try { + $expected = New-CcodProductRegistration -InstallRoot ([string]$Registration.installRoot) -RuntimeId $ExpectedRuntimeId -Version $ExpectedVersion -PackageSha256 $ExpectedPackageSha256 -FileTransaction ([pscustomobject]@{}) + } catch { return $false } + foreach ($name in @('runtimeRoot','bootstrapPath','uninstallerPath','productKeyName')) { + if ($Registration.$name -isnot [string] -or [string]$Registration.$name -cne [string]$expected.$name) { return $false } + } + return (Test-CcodProductShortcut $Registration.startMenuShortcut $expected.startMenuShortcut) -and + (Test-CcodProductShortcut $Registration.desktopShortcut $expected.desktopShortcut) +} + +function Test-CcodProductReadyProof { + param($Proof,$Registration) + if (-not (Test-CcodProductExactProperties $Proof $script:CcodReadyFields)) { return $false } + $canonicalTarget=[IO.Path]::GetFullPath((Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::System)) 'schtasks.exe')) + return $Proof.phase -is [string] -and [string]$Proof.phase -ceq 'Ready' -and + $Proof.runtimeId -is [string] -and [string]$Proof.runtimeId -ceq [string]$Registration.runtimeId -and + $Proof.version -is [string] -and [string]$Proof.version -ceq [string]$Registration.version -and + $Proof.packageSha256 -is [string] -and [string]$Proof.packageSha256 -ceq [string]$Registration.packageSha256 -and + [uint64]$Proof.runtimeGeneration -gt 0 -and $Proof.manifestSha256 -cmatch '^[0-9a-f]{64}$' -and $Proof.startMenuSha256 -cmatch '^[0-9a-f]{64}$' -and $Proof.desktopSha256 -cmatch '^[0-9a-f]{64}$' -and $Proof.targetPath -is [string] -and [IO.Path]::GetFullPath([string]$Proof.targetPath) -ceq $canonicalTarget -and $Proof.arguments -ceq '/Run /TN "Codex Control Other Devices Supervisor"' -and $null-ne$Proof.transactionRecord -and $Proof.transactionRecord.phase-ceq'Ready' -and $Proof.transactionRecord.newRuntimeId-ceq$Proof.runtimeId -and + $Proof.bootstrapPath -is [string] -and [string]$Proof.bootstrapPath -ceq [string]$Registration.bootstrapPath -and + $Proof.uninstallerPath -is [string] -and [string]$Proof.uninstallerPath -ceq [string]$Registration.uninstallerPath +} + +function Get-CcodProductRegistrationAdapters { + param([hashtable]$Adapters) + $defaults = @{ + GetReadyProof = { param($Registration) Throw-CcodProductRegistrationError 'CCOD_PRODUCT_REGISTRATION_NOT_READY' 'No independently verified Ready proof was supplied.' $Registration.runtimeId } + WriteProductRegistration = { param($Registration,$ReadyEvidence) Write-CcodCurrentUserProductRegistration $Registration $ReadyEvidence } + ReadProductRegistration = { param($Registration) Read-CcodCurrentUserProductRegistration $Registration } + WriteShortcut = { param($Kind,$Shortcut,$FileTransaction,$ReadyEvidence) Write-CcodCurrentUserProductShortcut -Kind $Kind -Shortcut $Shortcut -FileTransaction $FileTransaction -ReadyEvidence $ReadyEvidence } + ReadShortcut = { param($Kind,$Shortcut) Read-CcodCurrentUserProductShortcut -Kind $Kind -Shortcut $Shortcut } + ReadLegacyRegistration = { param($ExpectedAppId) Read-CcodCurrentUserLegacyRegistration -ExpectedAppId $ExpectedAppId } + ReadVerifiedRegistration = { Read-CcodCurrentUserVerifiedRegistration } + RemoveLegacyRegistration = { param($ExpectedAppId,$ExpectedShortcutNames) Remove-CcodCurrentUserLegacyRegistration -ExpectedAppId $ExpectedAppId -ExpectedShortcutNames $ExpectedShortcutNames } + ReadCurrentProductState = { param($ExpectedRuntimeId) Read-CcodCurrentProductState -ExpectedRuntimeId $ExpectedRuntimeId } + RemoveCurrentProductEntry = { param($Entry) Remove-CcodCurrentProductEntry -Entry $Entry } + ReadLegacySnapshot = { param($ExpectedAppId,$ExpectedProfile,$CapturedPlan) Read-CcodLegacySnapshot -ExpectedAppId $ExpectedAppId -ExpectedProfile $ExpectedProfile -CapturedPlan $CapturedPlan } + GetCurrentShortcutProof = { param($Kind,$Shortcut,$ReadyEvidence) Get-CcodCurrentShortcutProof -Kind $Kind -Shortcut $Shortcut -ReadyEvidence $ReadyEvidence } + ReadLegacyMigrationPlan = { param($ReadyEvidence) Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'No durable legacy migration plan reader was supplied.' $ReadyEvidence.runtimeId } + WriteLegacyMigrationPlan = { param($ReadyEvidence,[byte[]]$Bytes) Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'No durable legacy migration plan writer was supplied.' $ReadyEvidence.runtimeId } + RemoveLegacyEntry = { param($Entry) Remove-CcodLegacySnapshotEntry -Entry $Entry } + ReadLegacyEntry = { param($Entry) Read-CcodLegacySnapshotEntry -Entry $Entry } + RestoreLegacyEntry = { param($Entry) Restore-CcodLegacySnapshotEntry -Entry $Entry } + WriteLegacyCompensationFailure = {param($Record)Write-CcodLegacyCompensationFailure -Record $Record} + } + if ($null -eq $Adapters) { return $defaults } + if ($Adapters -isnot [hashtable]) { Throw-CcodProductRegistrationError 'CCOD_PRODUCT_ADAPTER_INVALID' 'Product adapters must be a hashtable.' $Adapters } + $resolved = @{}; foreach ($name in $defaults.Keys) { $resolved[$name] = $defaults[$name] } + foreach ($name in $Adapters.Keys) { + if ($name -isnot [string] -or -not $resolved.ContainsKey($name) -or $Adapters[$name] -isnot [scriptblock]) { + Throw-CcodProductRegistrationError 'CCOD_PRODUCT_ADAPTER_INVALID' 'Product adapter contract is invalid.' $name + } + $resolved[$name] = $Adapters[$name] + } + return $resolved +} + +function Get-CcodProductRegistryPath { 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\CodexRemote-fix' } +function Get-CcodLegacyRegistryPath([string]$AppId) { 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\' + $AppId + '_is1' } +function Get-CcodProductPowerShellPath { + $system=[Environment]::GetFolderPath([Environment+SpecialFolder]::System);if([string]::IsNullOrWhiteSpace($system)){throw 'system folder missing'} + [IO.Path]::GetFullPath((Join-Path $system 'WindowsPowerShell\v1.0\powershell.exe')) +} + +function Get-CcodLegacyShortcutContract { + param( + [Parameter(Mandatory)]$LegacyRegistration, + [Parameter(Mandatory)]$Profile, + [Parameter(Mandatory)][string]$Name, + [Parameter(Mandatory)][string]$ExpectedInstallRoot + ) + $installRoot=Get-CcodProductFullPath $ExpectedInstallRoot + $installerRoot=Get-CcodProductFullPath ([string]$LegacyRegistration.installLocation) + $powershell=Get-CcodProductPowerShellPath + $bootstrap=Join-Path $installRoot 'bootstrap.ps1' + $version=[version]::Parse([string]$LegacyRegistration.displayVersion) + $explicit=if($version-ge[version]'2.5.0'){' -EntryMode Explicit'}else{''} + $mainArguments='-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "{0}" -InstallRoot "{1}"{2}'-f$bootstrap,$installRoot,$explicit + $oldDesktop='Desktop\Codex '+[char]0x8BBE+[char]0x5907+[char]0x8FDE+[char]0x63A5+' (Device Connection).lnk' + if($Name-ceq$oldDesktop){return [pscustomobject][ordered]@{targetPath=$powershell;arguments=$mainArguments;workingDirectory=$installRoot}} + switch -CaseSensitive ($Name) { + 'Programs\Codex Control other devices\Codex Control other devices for Windows.lnk' { + return [pscustomobject][ordered]@{targetPath=(Join-Path $installerRoot 'README.md');arguments='';workingDirectory=''} + } + 'Programs\Codex Control other devices\Open the tray supervisor.lnk' { + return [pscustomobject][ordered]@{targetPath=$powershell;arguments=('-NoProfile -ExecutionPolicy Bypass -File "{0}"'-f(Join-Path $installerRoot 'Start-CodexControlOtherDevices.ps1'));workingDirectory=$installerRoot} + } + 'Programs\Codex Control other devices\Compatibility check.lnk' { + return [pscustomobject][ordered]@{targetPath=$powershell;arguments=('-NoProfile -ExecutionPolicy Bypass -File "{0}"'-f(Join-Path $installerRoot 'Test-CodexControlOtherDevices.ps1'));workingDirectory=$installerRoot} + } + 'Programs\Codex Control other devices\Uninstall Codex Control other devices.lnk' { + return [pscustomobject][ordered]@{targetPath=(Join-Path $installerRoot 'unins000.exe');arguments='';workingDirectory=''} + } + 'Programs\CodexRemote-fix\CodexRemote-fix.lnk' { + return [pscustomobject][ordered]@{targetPath=$powershell;arguments=$mainArguments;workingDirectory=$installRoot} + } + 'Programs\CodexRemote-fix\CodexRemote-fix compatibility check.lnk' { + return [pscustomobject][ordered]@{targetPath=$powershell;arguments=('-NoProfile -ExecutionPolicy Bypass -File "{0}"'-f(Join-Path $installerRoot 'Test-CodexControlOtherDevices.ps1'));workingDirectory=$installerRoot} + } + 'Programs\CodexRemote-fix\Uninstall CodexRemote-fix.lnk' { + return [pscustomobject][ordered]@{targetPath=(Join-Path $installerRoot 'unins000.exe');arguments='';workingDirectory=''} + } + 'Desktop\CodexRemote-fix.lnk' { + return [pscustomobject][ordered]@{targetPath=$powershell;arguments=$mainArguments;workingDirectory=$installRoot} + } + default { Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Legacy shortcut has no checked-in historical contract.' $Name } + } +} +function Get-CcodProductUninstallCommand($Registration) { + '"{0}" -NoProfile -ExecutionPolicy Bypass -File "{1}"' -f (Get-CcodProductPowerShellPath),([string]$Registration.uninstallerPath).Replace('"','') +} +function Write-CcodCurrentUserProductRegistration { + param($Registration,$ReadyEvidence) + $path=Get-CcodProductRegistryPath;$command=Get-CcodProductUninstallCommand $Registration + $shell=$null;$startLink=$null;$desktopLink=$null;try{$shell=New-Object -ComObject WScript.Shell;$startLink=$shell.CreateShortcut([string]$Registration.startMenuShortcut.candidatePath);$desktopLink=$shell.CreateShortcut([string]$Registration.desktopShortcut.candidatePath);$target=[IO.Path]::GetFullPath([string]$startLink.TargetPath);$arguments=[string]$startLink.Arguments;if([IO.Path]::GetFullPath([string]$desktopLink.TargetPath)-cne$target-or[string]$desktopLink.Arguments-cne$arguments){throw 'candidate target mismatch'}}finally{if($null-ne$startLink){[void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($startLink)};if($null-ne$desktopLink){[void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($desktopLink)};if($null-ne$shell){[void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($shell)}} + New-Item -Path $path -Force -ErrorAction Stop|Out-Null + if($ReadyEvidence.startMenuSha256-cne(Get-CcodProductFileSha256 ([string]$Registration.startMenuShortcut.candidatePath))-or$ReadyEvidence.desktopSha256-cne(Get-CcodProductFileSha256 ([string]$Registration.desktopShortcut.candidatePath))-or[IO.Path]::GetFullPath([string]$ReadyEvidence.targetPath)-cne$target-or[string]$ReadyEvidence.arguments-cne$arguments){throw 'Ready shortcut evidence mismatch'} + $values=[ordered]@{DisplayName='CodexRemote-fix';DisplayVersion=[string]$Registration.version;Publisher='naipi11';InstallLocation=[string]$Registration.runtimeRoot;UninstallString=$command;QuietUninstallString=($command+' -Confirm:$false');NoModify=1;NoRepair=1;CcodRuntimeId=[string]$Registration.runtimeId;CcodRuntimeGeneration=[int64]$ReadyEvidence.runtimeGeneration;CcodPackageSha256=[string]$Registration.packageSha256;CcodManifestSha256=[string]$ReadyEvidence.manifestSha256;CcodStartMenuSha256=[string]$ReadyEvidence.startMenuSha256;CcodDesktopSha256=[string]$ReadyEvidence.desktopSha256;CcodShortcutTarget=$target;CcodShortcutArguments=$arguments} + foreach($name in $values.Keys){$type=if($name-in@('NoModify','NoRepair')){'DWord'}elseif($name-ceq'CcodRuntimeGeneration'){'QWord'}else{'String'};New-ItemProperty -LiteralPath $path -Name $name -Value $values[$name] -PropertyType $type -Force -ErrorAction Stop|Out-Null} +} +function Read-CcodCurrentUserProductRegistration { + param($Registration) + $state=Read-CcodCurrentProductState -ExpectedRuntimeId ([string]$Registration.runtimeId);if($null-ne$state-and$state.valid){$Registration}else{$null} +} +function Get-CcodProductShortcutDestination { + param([string]$Kind) + $base=if($Kind-ceq'StartMenu'){[Environment]::GetFolderPath([Environment+SpecialFolder]::Programs)}else{[Environment]::GetFolderPath([Environment+SpecialFolder]::Desktop)} + if([string]::IsNullOrWhiteSpace($base)-or-not[IO.Path]::IsPathRooted($base)){throw 'special folder missing'} + if($Kind-ceq'StartMenu'){[IO.Path]::GetFullPath((Join-Path (Join-Path $base 'CodexRemote-fix') 'CodexRemote-fix.lnk'))}else{[IO.Path]::GetFullPath((Join-Path $base 'CodexRemote-fix.lnk'))} +} +function Get-CcodProductFileSha256([string]$Path){(Get-FileHash -LiteralPath $Path -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()} +function New-CcodProductShortcutCandidates { + param([Parameter(Mandatory)][string]$Directory) + $root=Get-CcodProductFullPath $Directory;if([IO.Directory]::Exists($root)-or[IO.File]::Exists($root)){Throw-CcodProductRegistrationError 'CCOD_PRODUCT_REGISTRATION_INVALID' 'Shortcut candidate root must be create-only.' $root} + [IO.Directory]::CreateDirectory($root)|Out-Null;$shell=$null + try{ + $shell=New-Object -ComObject WScript.Shell;$system=[Environment]::GetFolderPath([Environment+SpecialFolder]::System);$target=[IO.Path]::GetFullPath((Join-Path $system 'schtasks.exe')) + $records=[Collections.Generic.List[object]]::new() + foreach($name in @('StartMenu.CodexRemote-fix.lnk','Desktop.CodexRemote-fix.lnk')){ + $path=Join-Path $root $name;$link=$shell.CreateShortcut($path);try{$link.TargetPath=$target;$link.Arguments='/Run /TN "Codex Control Other Devices Supervisor"';$link.Description='CodexRemote-fix';$link.Save()}finally{if($null-ne$link){[void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($link)}} + $item=Get-Item -LiteralPath $path -Force -ErrorAction Stop;$records.Add([pscustomobject]@{Relative=('registration/'+$name);Source=$path;ExpectedLength=[int64]$item.Length;ExpectedSha256=Get-CcodProductFileSha256 $path}) + } + return @($records) + }finally{if($null-ne$shell){[void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($shell)}} +} +function Write-CcodCurrentUserProductShortcut { + param([string]$Kind,$Shortcut,$FileTransaction,$ReadyEvidence) + Import-Module (Join-Path $PSScriptRoot 'InstallFileTransaction.psm1') -ErrorAction Stop + $relative='registration/'+$(if($Kind-ceq'StartMenu'){'StartMenu.CodexRemote-fix.lnk'}else{'Desktop.CodexRemote-fix.lnk'}) + $source=Open-CcodInstallRetainedFile -Generation $FileTransaction -RelativePath $relative -ReadyTransaction $ReadyEvidence.transactionRecord + $folder=Open-CcodInstallProductSpecialFolder -Generation $FileTransaction -Kind $Kind + Copy-CcodInstallProductShortcut -Folder $folder -Source $source -Kind $Kind -Leaf 'CodexRemote-fix.lnk'|Out-Null +} +function Read-CcodCurrentUserProductShortcut { + param([string]$Kind,$Shortcut) + $destination=Get-CcodProductShortcutDestination $Kind;$source=[string]$Shortcut.candidatePath + if(-not[IO.File]::Exists($destination)-or-not[IO.File]::Exists($source)){return $null} + $item=Get-Item -LiteralPath $destination -Force -ErrorAction Stop;if($item.PSIsContainer-or($item.Attributes-band[IO.FileAttributes]::ReparsePoint)-ne0-or(Get-CcodProductFileSha256 $destination)-cne(Get-CcodProductFileSha256 $source)){return $null} + $Shortcut +} +function Get-CcodCurrentShortcutProof { + param([Parameter(Mandatory)][ValidateSet('StartMenu','Desktop')][string]$Kind,[Parameter(Mandatory)]$Shortcut,[Parameter(Mandatory)]$ReadyEvidence) + $candidate=[IO.Path]::GetFullPath([string]$Shortcut.candidatePath);$item=Get-Item -LiteralPath $candidate -Force -ErrorAction Stop + if($item.PSIsContainer-or($item.Attributes-band[IO.FileAttributes]::ReparsePoint)-ne0-or$item.Length-le0-or$item.Length-gt262144){throw 'current shortcut candidate is unsafe'} + $shell=$null;$link=$null + try{ + $shell=New-Object -ComObject WScript.Shell;$link=$shell.CreateShortcut($candidate);$target=[IO.Path]::GetFullPath([string]$link.TargetPath);$arguments=[string]$link.Arguments;$working=[string]$link.WorkingDirectory + if(-not[string]::IsNullOrEmpty($working)){$working=[IO.Path]::GetFullPath($working).TrimEnd('\')} + }finally{if($null-ne$link){[void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($link)};if($null-ne$shell){[void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($shell)}} + $name=if($Kind-ceq'StartMenu'){$script:CcodCurrentShortcutNames[0]}else{$script:CcodCurrentShortcutNames[1]} + [pscustomobject][ordered]@{kind=$Kind;name=$name;path=(Get-CcodLegacyShortcutPath -RelativeName $name);candidatePath=$candidate;candidateLength=[int64]$item.Length;candidateSha256=(Get-CcodProductFileSha256 $candidate);targetPath=$target;arguments=$arguments;workingDirectory=$working} +} +function Get-CcodLegacyShortcutPath { + param([Parameter(Mandatory)][string]$RelativeName) + if($RelativeName.StartsWith('Programs\',[StringComparison]::Ordinal)){$base=[Environment]::GetFolderPath([Environment+SpecialFolder]::Programs);$relative=$RelativeName.Substring(9)} + elseif($RelativeName.StartsWith('Desktop\',[StringComparison]::Ordinal)){$base=[Environment]::GetFolderPath([Environment+SpecialFolder]::Desktop);$relative=$RelativeName.Substring(8)} + else{Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Legacy shortcut name is outside the supported special folders.' $RelativeName} + if([string]::IsNullOrWhiteSpace($base)-or-not[IO.Path]::IsPathRooted($base)-or[string]::IsNullOrWhiteSpace($relative)){ + Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Legacy shortcut special-folder path is invalid.' $RelativeName + } + return [IO.Path]::GetFullPath((Join-Path $base $relative)) +} + +function Read-CcodCurrentUserLegacyRegistration { + param([string]$ExpectedAppId) + $path=Get-CcodLegacyRegistryPath $ExpectedAppId;if(-not(Test-Path -LiteralPath $path -PathType Container)){return $null} + $value=Get-ItemProperty -LiteralPath $path -ErrorAction Stop + $programs=[Environment]::GetFolderPath([Environment+SpecialFolder]::Programs);$desktop=[Environment]::GetFolderPath([Environment+SpecialFolder]::Desktop) + if([string]::IsNullOrWhiteSpace($programs)-or-not[IO.Path]::IsPathRooted($programs)-or[string]::IsNullOrWhiteSpace($desktop)-or-not[IO.Path]::IsPathRooted($desktop)){ + Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Legacy shortcut special folders are unavailable.' $ExpectedAppId + } + $groups=[Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal);$desktopNames=[Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + foreach($profile in @(Get-CcodLegacyRegistrationProfiles)){ + foreach($name in @($profile.shortcutNames)){ + if($name.StartsWith('Programs\',[StringComparison]::Ordinal)){$tail=$name.Substring(9);$separator=$tail.IndexOf('\');if($separator-lt1){Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Historical Start-menu profile is invalid.' $name};[void]$groups.Add($tail.Substring(0,$separator))} + elseif($name.StartsWith('Desktop\',[StringComparison]::Ordinal)){[void]$desktopNames.Add($name.Substring(8))} + else{Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Historical shortcut profile is invalid.' $name} + } + } + $present=[Collections.Generic.List[string]]::new();$unsafe=[Collections.Generic.List[string]]::new() + foreach($group in @($groups|Sort-Object)){ + $groupPath=Join-Path $programs $group;$groupItem=Get-Item -LiteralPath $groupPath -Force -ErrorAction SilentlyContinue + if($null-eq$groupItem){continue} + $groupName='Programs\'+[string]$groupItem.Name + if(-not$groupItem.PSIsContainer-or($groupItem.Attributes-band[IO.FileAttributes]::ReparsePoint)-ne0){$present.Add($groupName);$unsafe.Add($groupName);continue} + foreach($item in @(Get-ChildItem -LiteralPath $groupItem.FullName -Force -ErrorAction Stop)){ + $name=$groupName+'\'+[string]$item.Name;$present.Add($name) + if($item.PSIsContainer-or($item.Attributes-band[IO.FileAttributes]::ReparsePoint)-ne0){$unsafe.Add($name)} + } + } + foreach($desktopName in @($desktopNames|Sort-Object)){ + $item=Get-Item -LiteralPath (Join-Path $desktop $desktopName) -Force -ErrorAction SilentlyContinue + if($null-eq$item){continue};$name='Desktop\'+[string]$item.Name;$present.Add($name) + if($item.PSIsContainer-or($item.Attributes-band[IO.FileAttributes]::ReparsePoint)-ne0){$unsafe.Add($name)} + } + return [pscustomobject][ordered]@{ + appId=$ExpectedAppId;displayVersion=[string]$value.DisplayVersion;installLocation=[string]$value.InstallLocation + uninstallString=[string]$value.UninstallString;shortcutNames=@($present);unsafeShortcutNames=@($unsafe) + } +} +function Read-CcodCurrentUserVerifiedRegistration { + try{ + $path=Get-CcodProductRegistryPath;if(-not(Test-Path -LiteralPath $path -PathType Container)){return $null} + $value=Get-ItemProperty -LiteralPath $path -ErrorAction Stop;$runtimeId=[string]$value.CcodRuntimeId + $state=Read-CcodCurrentProductState -ExpectedRuntimeId $runtimeId + if($null-eq$state-or$state.valid-isnot[bool]-or-not$state.valid-or@($state.entries).Count-ne3-or$null-eq$state.readyEvidence){return $null} + $proof=[pscustomobject][ordered]@{ + verified=$true;runtimeId=[string]$state.readyEvidence.runtimeId;version=$script:CcodProductVersion;packageSha256=[string]$state.readyEvidence.packageSha256 + shortcutNames=@($script:CcodCurrentShortcutNames);startMenuSha256=[string]$state.readyEvidence.startMenuSha256;desktopSha256=[string]$state.readyEvidence.desktopSha256 + } + if(Test-CcodCurrentVerifiedRegistration $proof){return $proof} + return $null + }catch{return $null} +} +function Remove-CcodCurrentUserLegacyRegistration { + param([string]$ExpectedAppId,[string[]]$ExpectedShortcutNames) + $programs=[Environment]::GetFolderPath([Environment+SpecialFolder]::Programs);$desktop=[Environment]::GetFolderPath([Environment+SpecialFolder]::Desktop) + foreach($name in $ExpectedShortcutNames){$relative=$name.Substring($name.IndexOf('\')+1);$candidate=if($name.StartsWith('Programs\',[StringComparison]::Ordinal)){Join-Path $programs $relative}else{Join-Path $desktop $relative};if(Test-Path -LiteralPath $candidate -PathType Leaf){Remove-Item -LiteralPath $candidate -Force -ErrorAction Stop}} + $path=Get-CcodLegacyRegistryPath $ExpectedAppId;if(Test-Path -LiteralPath $path -PathType Container){Remove-Item -LiteralPath $path -Force -ErrorAction Stop} +} + +function Get-CcodProductShortcutEvidence([string]$Kind){ + $path=Get-CcodProductShortcutDestination $Kind;if(-not[IO.File]::Exists($path)){return $null};$item=Get-Item -LiteralPath $path -Force -ErrorAction Stop;if($item.PSIsContainer-or($item.Attributes-band[IO.FileAttributes]::ReparsePoint)-ne0){return $null};$shell=$null;$link=$null;try{$shell=New-Object -ComObject WScript.Shell;$link=$shell.CreateShortcut($path);[pscustomobject]@{kind=$Kind;path=$path;sha256=Get-CcodProductFileSha256 $path;target=[IO.Path]::GetFullPath([string]$link.TargetPath);arguments=[string]$link.Arguments}}finally{if($null-ne$link){[void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($link)};if($null-ne$shell){[void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($shell)}} +} +function Test-CcodCurrentProductRegistryPreflight { + param([string[]]$ValueNames,[string[]]$SubKeyNames,[string[]]$AllowedValueNames) + if($null-eq$ValueNames-or$null-eq$SubKeyNames-or$null-eq$AllowedValueNames-or@($SubKeyNames).Count-ne0){return $false} + return ((@($ValueNames|Sort-Object)-join"`0")-ceq(@($AllowedValueNames|Sort-Object)-join"`0")) +} +function Read-CcodCurrentProductState { + param([string]$ExpectedRuntimeId) + try{ + $subkey='Software\Microsoft\Windows\CurrentVersion\Uninstall\CodexRemote-fix';$key=[Microsoft.Win32.Registry]::CurrentUser.OpenSubKey($subkey,$false);if($null-eq$key){return [pscustomobject]@{valid=$true;reason=$null;entries=@()}} + try{$allowed=@('CcodDesktopSha256','CcodPackageSha256','CcodRuntimeId','CcodRuntimeGeneration','CcodManifestSha256','CcodShortcutArguments','CcodShortcutTarget','CcodStartMenuSha256','DisplayName','DisplayVersion','InstallLocation','NoModify','NoRepair','Publisher','QuietUninstallString','UninstallString');$names=@($key.GetValueNames()|Sort-Object);if(-not(Test-CcodCurrentProductRegistryPreflight -ValueNames $names -SubKeyNames @($key.GetSubKeyNames()) -AllowedValueNames $allowed)){return [pscustomobject]@{valid=$false;reason='UnknownRegistryValue';entries=@()}};$runtime=[string]$key.GetValue('CcodRuntimeId',$null,[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames);$installLocation=[string]$key.GetValue('InstallLocation');$expectedUninstaller=Join-Path $installLocation 'Uninstall-CodexControlOtherDevices.ps1';$expectedCommand='"{0}" -NoProfile -ExecutionPolicy Bypass -File "{1}"'-f(Get-CcodProductPowerShellPath),$expectedUninstaller;if($runtime-cne$ExpectedRuntimeId-or$installLocation-cne[IO.Path]::GetFullPath((Join-Path (Join-Path (Split-Path (Split-Path $installLocation -Parent) -Parent) 'runtime') $runtime))-or[string]$key.GetValue('DisplayName')-cne'CodexRemote-fix'-or[string]$key.GetValue('DisplayVersion')-cne'2.5.22'-or[string]$key.GetValue('Publisher')-cne'naipi11'-or[string]$key.GetValue('UninstallString')-cne$expectedCommand-or[string]$key.GetValue('QuietUninstallString')-cne($expectedCommand+' -Confirm:$false')-or[int]$key.GetValue('NoModify')-ne1-or[int]$key.GetValue('NoRepair')-ne1-or[string]$key.GetValue('CcodPackageSha256')-cnotmatch'^[0-9a-f]{64}$'){return [pscustomobject]@{valid=$false;reason='RegistryMismatch';entries=@()}};foreach($dword in @('NoModify','NoRepair')){if($key.GetValueKind($dword)-ne[Microsoft.Win32.RegistryValueKind]::DWord){return [pscustomobject]@{valid=$false;reason='RegistryType';entries=@()}}};if($key.GetValueKind('CcodRuntimeGeneration')-ne[Microsoft.Win32.RegistryValueKind]::QWord){return [pscustomobject]@{valid=$false;reason='RegistryType';entries=@()}};foreach($stringName in @($allowed|Where-Object{$_-notin@('NoModify','NoRepair','CcodRuntimeGeneration')})){if($key.GetValueKind($stringName)-ne[Microsoft.Win32.RegistryValueKind]::String){return [pscustomobject]@{valid=$false;reason='RegistryType';entries=@()}}};$target=[string]$key.GetValue('CcodShortcutTarget');$arguments=[string]$key.GetValue('CcodShortcutArguments');$start=Get-CcodProductShortcutEvidence 'StartMenu';$desktop=Get-CcodProductShortcutEvidence 'Desktop';if($null-eq$start-or$null-eq$desktop-or$start.target-cne$target-or$desktop.target-cne$target-or$start.arguments-cne$arguments-or$desktop.arguments-cne$arguments-or$start.sha256-cne[string]$key.GetValue('CcodStartMenuSha256')-or$desktop.sha256-cne[string]$key.GetValue('CcodDesktopSha256')){return [pscustomobject]@{valid=$false;reason='ShortcutMismatch';entries=@()}};$captured=[ordered]@{};foreach($name in $names){$captured[$name]=[pscustomobject]@{value=$key.GetValue($name,$null,[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames);kind=[string]$key.GetValueKind($name)}};$installRoot=[IO.Path]::GetFullPath((Split-Path (Split-Path $installLocation -Parent) -Parent));$readyEvidence=[pscustomobject][ordered]@{phase='Ready';installRoot=$installRoot;runtimeId=$runtime;runtimeGeneration=[uint64]$key.GetValue('CcodRuntimeGeneration');packageSha256=[string]$key.GetValue('CcodPackageSha256');manifestSha256=[string]$key.GetValue('CcodManifestSha256');startMenuSha256=$start.sha256;desktopSha256=$desktop.sha256;targetPath=$target;arguments=$arguments};return [pscustomobject]@{valid=$true;reason=$null;readyEvidence=$readyEvidence;entries=@([pscustomobject]@{kind='StartMenu';path=$start.path;sha256=$start.sha256},[pscustomobject]@{kind='Desktop';path=$desktop.path;sha256=$desktop.sha256},[pscustomobject]@{kind='Registry';path=$subkey;valueNames=$names;values=$captured})}} + finally{$key.Dispose()} + }catch{return [pscustomobject]@{valid=$false;reason='Ambiguous';entries=@()}} +} +function Remove-CcodCurrentProductEntry { + param($Entry) + if($Entry-is[string]){return} + if($Entry.kind-in@('StartMenu','Desktop')){$path=[IO.Path]::GetFullPath([string]$Entry.path);if(-not[IO.File]::Exists($path)-or(Get-CcodProductFileSha256 $path)-cne[string]$Entry.sha256){throw 'shortcut changed'};Remove-Item -LiteralPath $path -Force -ErrorAction Stop;return} + if($Entry.kind-cne'Registry'){throw 'unknown product entry'};$key=[Microsoft.Win32.Registry]::CurrentUser.OpenSubKey([string]$Entry.path,$true);if($null-eq$key){return};try{if((@($key.GetValueNames()|Sort-Object)-join'|')-cne((@($Entry.valueNames)|Sort-Object)-join'|')-or$key.GetSubKeyNames().Count-ne0){throw 'registry changed'};foreach($name in @($Entry.valueNames)){if([string]$key.GetValue($name,$null,[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)-cne[string]$Entry.values[$name].value-or[string]$key.GetValueKind($name)-cne[string]$Entry.values[$name].kind){throw 'registry value changed'}};foreach($name in @($Entry.valueNames)){$key.DeleteValue($name,$true)};if($key.GetValueNames().Count-ne0){throw 'registry not empty'}}finally{$key.Dispose()};[Microsoft.Win32.Registry]::CurrentUser.DeleteSubKey([string]$Entry.path,$false) +} +function Remove-CcodProductRegistration { + [CmdletBinding()]param([Parameter(Mandatory)]$ReadyEvidence,[hashtable]$Adapters) + $fields=@('phase','installRoot','runtimeId','runtimeGeneration','packageSha256','manifestSha256','startMenuSha256','desktopSha256','targetPath','arguments');$canonicalTarget=[IO.Path]::GetFullPath((Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::System)) 'schtasks.exe'));if(-not(Test-CcodProductExactProperties $ReadyEvidence $fields)-or$ReadyEvidence.phase-cne'Ready'-or$ReadyEvidence.installRoot-isnot[string]-or-not[IO.Path]::IsPathRooted($ReadyEvidence.installRoot)-or$ReadyEvidence.runtimeId-cnotmatch'^2\.5\.22-[0-9a-f]{16}-[0-9a-f]{32}$'-or[uint64]$ReadyEvidence.runtimeGeneration-lt1-or$ReadyEvidence.packageSha256-cnotmatch'^[0-9a-f]{64}$'-or$ReadyEvidence.manifestSha256-cnotmatch'^[0-9a-f]{64}$'-or$ReadyEvidence.startMenuSha256-cnotmatch'^[0-9a-f]{64}$'-or$ReadyEvidence.desktopSha256-cnotmatch'^[0-9a-f]{64}$'-or$ReadyEvidence.targetPath-isnot[string]-or[IO.Path]::GetFullPath([string]$ReadyEvidence.targetPath)-cne$canonicalTarget-or$ReadyEvidence.arguments-cne'/Run /TN "Codex Control Other Devices Supervisor"'){Throw-CcodProductRegistrationError 'CCOD_PRODUCT_CLEANUP_INVALID' 'Exact Ready cleanup evidence is invalid' $ReadyEvidence};$adapter=Get-CcodProductRegistrationAdapters $Adapters;$state=&$adapter.ReadCurrentProductState $ReadyEvidence.runtimeId;if($null-eq$state-or$state.valid-isnot[bool]-or-not$state.valid-or$null-eq$state.readyEvidence-or($state.readyEvidence|ConvertTo-Json -Compress)-cne($ReadyEvidence|ConvertTo-Json -Compress)-or$null-eq$state.entries){Throw-CcodProductRegistrationError 'CCOD_PRODUCT_CLEANUP_INVALID' 'Current product state is not bound to exact Ready evidence' $ReadyEvidence.runtimeId};$entries=@($state.entries);if($entries.Count-notin@(0,3)){Throw-CcodProductRegistrationError 'CCOD_PRODUCT_CLEANUP_INVALID' 'Current product state is ambiguous' $ReadyEvidence.runtimeId};foreach($entry in $entries){&$adapter.RemoveCurrentProductEntry $entry} +} + +function Assert-CcodInstalledResumeTransaction { + param([Parameter(Mandatory)]$Transaction) + $binding=$Transaction.installedBinding + if($Transaction.transactionId-isnot[string]-or$Transaction.transactionId-cnotmatch'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'-or + $Transaction.runtimeId-isnot[string]-or$Transaction.runtimeId-cnotmatch'^2\.5\.22-[0-9a-f]{16}-[0-9a-f]{32}$'-or$Transaction.userSid-isnot[string]-or$Transaction.userSid-cnotmatch'^S-1-'-or + $Transaction.sessionId-isnot[int]-or$Transaction.sessionId-lt0-or$null-eq$Transaction.readyEvidence-or$null-eq$binding-or + $binding.selectedRuntimeRoot-isnot[string]-or-not[IO.Path]::IsPathRooted([string]$binding.selectedRuntimeRoot)-or$binding.runtimeManifestSha256-isnot[string]-or$binding.runtimeManifestSha256-cnotmatch'^[0-9a-f]{64}$'-or + $binding.wrapperUserSid-isnot[string]-or$binding.wrapperUserSid-cne$Transaction.userSid-or$binding.wrapperSessionId-isnot[int]-or$binding.wrapperSessionId-ne$Transaction.sessionId-or + $binding.resumeScriptPath-isnot[string]-or-not[IO.Path]::IsPathRooted([string]$binding.resumeScriptPath)-or$binding.resumeScriptSha256-isnot[string]-or$binding.resumeScriptSha256-cnotmatch'^[0-9a-f]{64}$'-or + $binding.resumeCommand-isnot[string]-or[string]::IsNullOrWhiteSpace([string]$binding.resumeCommand)){ + Throw-CcodProductRegistrationError 'CCOD_PRODUCT_CLEANUP_INVALID' 'Installed uninstall resume transaction is invalid.' $Transaction + } + try{ + [uint64]$generation=$Transaction.runtimeGeneration;[uint64]$epoch=$Transaction.leaseEpoch;[int64]$scriptLength=$binding.resumeScriptLength + $install=Get-CcodProductFullPath ([string]$Transaction.readyEvidence.installRoot);$selected=Get-CcodProductFullPath ([string]$binding.selectedRuntimeRoot);$resumeScript=Get-CcodProductFullPath ([string]$binding.resumeScriptPath) + $expectedSelected=[IO.Path]::GetFullPath((Join-Path (Join-Path $install 'runtime') ([string]$Transaction.runtimeId))) + $local=[Environment]::GetEnvironmentVariable('LOCALAPPDATA','Process');if([string]::IsNullOrWhiteSpace($local)){$local=[Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)} + $expectedScript=[IO.Path]::GetFullPath((Join-Path (Join-Path (Join-Path $local 'CodexRemote-fix-uninstall') ([string]$Transaction.transactionId)) 'payload\src\persistence\InstalledUninstallFinalizer.ps1')) + $system=[Environment]::GetFolderPath([Environment+SpecialFolder]::System);$powershell=[IO.Path]::GetFullPath((Join-Path $system 'WindowsPowerShell\v1.0\powershell.exe')) + $expectedCommand='"{0}" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "{1}" -Resume -TransactionId "{2}" -RuntimeRoot "{3}" -InstallRoot "{4}"'-f$powershell,$expectedScript,$Transaction.transactionId,$expectedSelected,$install + }catch{Throw-CcodProductRegistrationError 'CCOD_PRODUCT_CLEANUP_INVALID' 'Installed uninstall resume paths are invalid.' $Transaction} + if($generation-eq0-or$scriptLength-lt0-or$selected-cne$expectedSelected-or$resumeScript-cne$expectedScript-or$binding.resumeCommand-cne$expectedCommand-or + $Transaction.readyEvidence.runtimeId-cne$Transaction.runtimeId-or[uint64]$Transaction.readyEvidence.runtimeGeneration-ne$generation-or + $Transaction.readyEvidence.manifestSha256-cne$binding.runtimeManifestSha256){Throw-CcodProductRegistrationError 'CCOD_PRODUCT_CLEANUP_INVALID' 'Installed uninstall resume binding does not match the selected generation.' $Transaction} + return [pscustomobject][ordered]@{installRoot=$install;selectedRuntimeRoot=$selected;resumeScriptPath=$resumeScript;resumeCommand=$expectedCommand;runtimeGeneration=$generation;leaseEpoch=$epoch;resumeScriptLength=$scriptLength} +} + +function Get-CcodInstalledResumeRegistryValues { + param([Parameter(Mandatory)]$Transaction) + $proof=Assert-CcodInstalledResumeTransaction $Transaction;$ready=$Transaction.readyEvidence + [ordered]@{ + DisplayName='CodexRemote-fix';DisplayVersion='2.5.22';Publisher='naipi11';InstallLocation=$proof.selectedRuntimeRoot;UninstallString=$proof.resumeCommand;QuietUninstallString=$proof.resumeCommand;NoModify=1;NoRepair=1 + CcodRuntimeId=[string]$Transaction.runtimeId;CcodRuntimeGeneration=[int64]$proof.runtimeGeneration;CcodPackageSha256=[string]$ready.packageSha256;CcodManifestSha256=[string]$ready.manifestSha256 + CcodStartMenuSha256=[string]$ready.startMenuSha256;CcodDesktopSha256=[string]$ready.desktopSha256;CcodShortcutTarget=[string]$ready.targetPath;CcodShortcutArguments=[string]$ready.arguments + CcodUninstallTransactionId=[string]$Transaction.transactionId;CcodUninstallUserSid=[string]$Transaction.userSid;CcodUninstallSessionId=[int]$Transaction.sessionId;CcodUninstallLeaseEpoch=[int64]$proof.leaseEpoch + CcodUninstallSelectedRuntimeRoot=$proof.selectedRuntimeRoot;CcodUninstallRuntimeManifestSha256=[string]$Transaction.installedBinding.runtimeManifestSha256 + CcodUninstallResumeScriptSha256=[string]$Transaction.installedBinding.resumeScriptSha256;CcodUninstallResumeScriptLength=[int64]$proof.resumeScriptLength + } +} + +function Get-CcodInstalledResumeRegistryKinds { + param([Parameter(Mandatory)][Collections.IDictionary]$Values) + $kinds=[ordered]@{};foreach($name in $Values.Keys){$kinds[$name]=if($name-in@('NoModify','NoRepair','CcodUninstallSessionId')){'DWord'}elseif($name-in@('CcodRuntimeGeneration','CcodUninstallLeaseEpoch','CcodUninstallResumeScriptLength')){'QWord'}else{'String'}};$kinds +} + +function Test-CcodProductRegistryEntryValues { + param([Parameter(Mandatory)]$Key,[Parameter(Mandatory)][Collections.IDictionary]$Values,[Parameter(Mandatory)][Collections.IDictionary]$Kinds) + if($Key.GetSubKeyNames().Count-ne0-or-not(Test-CcodProductExactStringSet @($Key.GetValueNames()) @($Values.Keys))){return $false} + foreach($name in $Values.Keys){$actual=$Key.GetValue([string]$name,$null,[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames);if([string]$Key.GetValueKind([string]$name)-cne[string]$Kinds[$name]-or($actual|ConvertTo-Json -Compress)-cne($Values[$name]|ConvertTo-Json -Compress)){return $false}} + return $true +} + +function Read-CcodInstalledResumeProductState { + param([Parameter(Mandatory)]$Transaction) + $proof=Assert-CcodInstalledResumeTransaction $Transaction;$values=Get-CcodInstalledResumeRegistryValues $Transaction;$kinds=Get-CcodInstalledResumeRegistryKinds $values + $startPath=Get-CcodProductShortcutDestination 'StartMenu';$desktopPath=Get-CcodProductShortcutDestination 'Desktop';$shortcuts=[Collections.Generic.List[object]]::new() + foreach($definition in @([pscustomobject]@{kind='StartMenu';path=$startPath;sha=[string]$Transaction.readyEvidence.startMenuSha256},[pscustomobject]@{kind='Desktop';path=$desktopPath;sha=[string]$Transaction.readyEvidence.desktopSha256})){ + if([IO.Directory]::Exists($definition.path)){return [pscustomobject]@{valid=$false;reason='ShortcutType';registryPresent=$false;shortcutEntries=@();registryEntry=$null}} + if([IO.File]::Exists($definition.path)){$evidence=Get-CcodProductShortcutEvidence $definition.kind;if($null-eq$evidence-or$evidence.sha256-cne$definition.sha-or$evidence.target-cne[string]$Transaction.readyEvidence.targetPath-or$evidence.arguments-cne[string]$Transaction.readyEvidence.arguments){return [pscustomobject]@{valid=$false;reason='ShortcutMismatch';registryPresent=$false;shortcutEntries=@();registryEntry=$null}};$shortcuts.Add([pscustomobject]@{kind=$definition.kind;path=$evidence.path;sha256=$evidence.sha256})} + } + $subkey='Software\Microsoft\Windows\CurrentVersion\Uninstall\CodexRemote-fix';$key=[Microsoft.Win32.Registry]::CurrentUser.OpenSubKey($subkey,$false) + if($null-eq$key){return [pscustomobject]@{valid=($shortcuts.Count-eq0);reason=$(if($shortcuts.Count-eq0){$null}else{'RegistryMissing'});registryPresent=$false;shortcutEntries=@($shortcuts);registryEntry=$null}} + try{if(-not(Test-CcodProductRegistryEntryValues -Key $key -Values $values -Kinds $kinds)){return [pscustomobject]@{valid=$false;reason='RegistryMismatch';registryPresent=$true;shortcutEntries=@();registryEntry=$null}};$captured=[ordered]@{};foreach($name in $values.Keys){$captured[$name]=[pscustomobject]@{value=$key.GetValue([string]$name,$null,[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames);kind=[string]$key.GetValueKind([string]$name)}};return [pscustomobject]@{valid=$true;reason=$null;registryPresent=$true;shortcutEntries=@($shortcuts);registryEntry=[pscustomobject]@{kind='Registry';path=$subkey;valueNames=@($values.Keys);values=$captured}}}finally{$key.Dispose()} +} + +function Get-CcodInstalledResumeOriginalRegistryValues { + param([Parameter(Mandatory)]$Transaction) + $proof=Assert-CcodInstalledResumeTransaction $Transaction;$ready=$Transaction.readyEvidence;$uninstaller=Join-Path $proof.selectedRuntimeRoot 'Uninstall-CodexControlOtherDevices.ps1';$command='"{0}" -NoProfile -ExecutionPolicy Bypass -File "{1}"'-f(Get-CcodProductPowerShellPath),$uninstaller + [ordered]@{DisplayName='CodexRemote-fix';DisplayVersion='2.5.22';Publisher='naipi11';InstallLocation=$proof.selectedRuntimeRoot;UninstallString=$command;QuietUninstallString=($command+' -Confirm:$false');NoModify=1;NoRepair=1;CcodRuntimeId=[string]$Transaction.runtimeId;CcodRuntimeGeneration=[int64]$proof.runtimeGeneration;CcodPackageSha256=[string]$ready.packageSha256;CcodManifestSha256=[string]$ready.manifestSha256;CcodStartMenuSha256=[string]$ready.startMenuSha256;CcodDesktopSha256=[string]$ready.desktopSha256;CcodShortcutTarget=[string]$ready.targetPath;CcodShortcutArguments=[string]$ready.arguments} +} + +function Test-CcodInstalledResumeRegistryTransition { + param([Parameter(Mandatory)]$Key,[Parameter(Mandatory)][Collections.IDictionary]$Original,[Parameter(Mandatory)][Collections.IDictionary]$Expected,[Parameter(Mandatory)][Collections.IDictionary]$ExpectedKinds) + $names=@($Key.GetValueNames());if($Key.GetSubKeyNames().Count-ne0-or@($Original.Keys|Where-Object{$names-cnotcontains$_}).Count-ne0-or@($names|Where-Object{$Expected.Keys-cnotcontains$_}).Count-ne0){return $false} + foreach($name in $names){$actual=$Key.GetValue([string]$name,$null,[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames);$kind=[string]$Key.GetValueKind([string]$name);if($kind-cne[string]$ExpectedKinds[$name]){return $false};if($name-in@('UninstallString','QuietUninstallString')){if(($actual|ConvertTo-Json -Compress)-cne($Original[$name]|ConvertTo-Json -Compress)-and($actual|ConvertTo-Json -Compress)-cne($Expected[$name]|ConvertTo-Json -Compress)){return $false}}elseif(($actual|ConvertTo-Json -Compress)-cne($Expected[$name]|ConvertTo-Json -Compress)){return $false}} + return $true +} + +function Set-CcodInstalledUninstallResumeRegistration { + [CmdletBinding()] + param([Parameter(Mandatory)]$Transaction) + if($Transaction.phase-cne'TaskRemoved'-or$Transaction.resumePhase-cne'TaskRemoved'){Throw-CcodProductRegistrationError 'CCOD_PRODUCT_CLEANUP_INVALID' 'Resume registration may be installed only at TaskRemoved.' $Transaction.transactionId} + $proof=Assert-CcodInstalledResumeTransaction $Transaction + try{$scriptItem=Get-Item -LiteralPath $proof.resumeScriptPath -Force -ErrorAction Stop;if($scriptItem.PSIsContainer-or($scriptItem.Attributes-band[IO.FileAttributes]::ReparsePoint)-ne0-or[int64]$scriptItem.Length-ne[int64]$proof.resumeScriptLength-or(Get-CcodProductFileSha256 $proof.resumeScriptPath)-cne[string]$Transaction.installedBinding.resumeScriptSha256){throw 'staged resume script mismatch'}}catch{Throw-CcodProductRegistrationError 'CCOD_PRODUCT_CLEANUP_INVALID' 'Staged installed resume script is not exact.' $proof.resumeScriptPath} + foreach($definition in @([pscustomobject]@{kind='StartMenu';sha=[string]$Transaction.readyEvidence.startMenuSha256},[pscustomobject]@{kind='Desktop';sha=[string]$Transaction.readyEvidence.desktopSha256})){$evidence=Get-CcodProductShortcutEvidence $definition.kind;if($null-eq$evidence-or$evidence.sha256-cne$definition.sha-or$evidence.target-cne[string]$Transaction.readyEvidence.targetPath-or$evidence.arguments-cne[string]$Transaction.readyEvidence.arguments){Throw-CcodProductRegistrationError 'CCOD_PRODUCT_CLEANUP_INVALID' 'Current product shortcut is not exact before installing resume entry.' $definition.kind}} + $values=Get-CcodInstalledResumeRegistryValues $Transaction;$kinds=Get-CcodInstalledResumeRegistryKinds $values;$original=Get-CcodInstalledResumeOriginalRegistryValues $Transaction;$originalKinds=Get-CcodInstalledResumeRegistryKinds $original;$subkey='Software\Microsoft\Windows\CurrentVersion\Uninstall\CodexRemote-fix';$key=[Microsoft.Win32.Registry]::CurrentUser.OpenSubKey($subkey,$true) + if($null-eq$key){Throw-CcodProductRegistrationError 'CCOD_PRODUCT_CLEANUP_INVALID' 'Current product registry disappeared before resume installation.' $Transaction.runtimeId} + try{ + if(-not(Test-CcodInstalledResumeRegistryTransition -Key $key -Original $original -Expected $values -ExpectedKinds $kinds)){throw 'current/resume registry transition set is invalid'} + $key.SetValue('UninstallString',[string]$values.UninstallString,[Microsoft.Win32.RegistryValueKind]::String) + foreach($name in @($values.Keys|Where-Object{$_-notin@('QuietUninstallString','UninstallString')})){$kind=[Enum]::Parse([Microsoft.Win32.RegistryValueKind],[string]$kinds[$name]);$key.SetValue([string]$name,$values[$name],$kind)} + $key.SetValue('QuietUninstallString',[string]$values.QuietUninstallString,[Microsoft.Win32.RegistryValueKind]::String) + if(-not(Test-CcodProductRegistryEntryValues -Key $key -Values $values -Kinds $kinds)){throw 'resume registry read-back mismatch'} + }catch{Throw-CcodProductRegistrationError 'CCOD_PRODUCT_CLEANUP_INVALID' 'Installed resume registration could not be committed exactly.' $Transaction.runtimeId}finally{$key.Dispose()} + $observed=Read-CcodInstalledResumeProductState $Transaction + if($null-eq$observed-or-not$observed.valid-or-not$observed.registryPresent-or@($observed.shortcutEntries).Count-ne2){Throw-CcodProductRegistrationError 'CCOD_PRODUCT_CLEANUP_INVALID' 'Installed resume registration is not durable.' $Transaction.runtimeId} + return $observed +} + +function Test-CcodInstalledUninstallResumeRegistration { + [CmdletBinding()] + param([Parameter(Mandatory)]$Transaction) + try{$state=Read-CcodInstalledResumeProductState $Transaction;return $null-ne$state-and$state.valid-and$state.registryPresent}catch{return $false} +} + +function Get-CcodInstalledUninstallResumeRegistrationState { + [CmdletBinding()] + param([Parameter(Mandatory)]$Transaction) + try{$state=Read-CcodInstalledResumeProductState $Transaction;if($null-eq$state-or-not$state.valid){return 'Invalid'};if($state.registryPresent){return 'Exact'};if(@($state.shortcutEntries).Count-eq0){return 'Absent'};return 'Invalid'}catch{return 'Invalid'} +} + +function Remove-CcodInstalledUninstallProductShortcuts { + [CmdletBinding()] + param([Parameter(Mandatory)]$Transaction) + $effectivePhase=if($Transaction.phase-ceq'Failed'){$Transaction.resumePhase}else{$Transaction.phase};if($effectivePhase-cne'ReadyForInno'){Throw-CcodProductRegistrationError 'CCOD_PRODUCT_CLEANUP_INVALID' 'Installed shortcuts may be removed only at ReadyForInno.' $Transaction.transactionId} + $state=Read-CcodInstalledResumeProductState $Transaction + if($null-eq$state-or-not$state.valid-or-not$state.registryPresent){Throw-CcodProductRegistrationError 'CCOD_PRODUCT_CLEANUP_INVALID' 'Durable installed resume entry is missing before shortcut cleanup.' $Transaction.transactionId} + foreach($entry in @($state.shortcutEntries)){Remove-CcodCurrentProductEntry $entry} + $after=Read-CcodInstalledResumeProductState $Transaction + if($null-eq$after-or-not$after.valid-or-not$after.registryPresent-or@($after.shortcutEntries).Count-ne0){Throw-CcodProductRegistrationError 'CCOD_PRODUCT_CLEANUP_INVALID' 'Installed product shortcuts did not converge while the resume entry remained.' $Transaction.transactionId} + return $after +} + +function Remove-CcodInstalledUninstallResumeRegistration { + [CmdletBinding()] + param([Parameter(Mandatory)]$Transaction,[switch]$CompletedReceiptProven) + if($Transaction.phase-cne'Completed'-or$Transaction.resumePhase-cne'Completed'-or-not$CompletedReceiptProven){Throw-CcodProductRegistrationError 'CCOD_PRODUCT_CLEANUP_INVALID' 'Final resume registry removal requires a durable Completed transaction and receipt proof.' $Transaction.transactionId} + $state=Read-CcodInstalledResumeProductState $Transaction + if($null-eq$state-or-not$state.valid){Throw-CcodProductRegistrationError 'CCOD_PRODUCT_CLEANUP_INVALID' 'Installed resume registration is not exact before final removal.' $Transaction.transactionId} + if(-not$state.registryPresent){return $true} + if(@($state.shortcutEntries).Count-ne0){Throw-CcodProductRegistrationError 'CCOD_PRODUCT_CLEANUP_INVALID' 'Installed shortcuts remain before final resume registry removal.' $Transaction.transactionId} + Initialize-CcodLegacyRegistryNative + $rights=[Security.AccessControl.RegistryRights]::QueryValues-bor[Security.AccessControl.RegistryRights]::EnumerateSubKeys-bor[Security.AccessControl.RegistryRights]::Delete + $key=[Microsoft.Win32.Registry]::CurrentUser.OpenSubKey([string]$state.registryEntry.path,[Microsoft.Win32.RegistryKeyPermissionCheck]::ReadWriteSubTree,$rights) + if($null-eq$key){Throw-CcodProductRegistrationError 'CCOD_PRODUCT_CLEANUP_INVALID' 'Installed resume registry disappeared before exact handle deletion.' $Transaction.transactionId} + try{$values=Get-CcodInstalledResumeRegistryValues $Transaction;$kinds=Get-CcodInstalledResumeRegistryKinds $values;if(-not(Test-CcodProductRegistryEntryValues -Key $key -Values $values -Kinds $kinds)){throw 'resume registry changed'};[CcodLegacyRegistryNativeV1]::DeleteKey($key)}catch{Throw-CcodProductRegistrationError 'CCOD_PRODUCT_CLEANUP_INVALID' 'Installed resume registry could not be deleted by its exact handle.' $Transaction.transactionId}finally{$key.Dispose()} + $after=Read-CcodInstalledResumeProductState $Transaction + if($null-eq$after-or-not$after.valid-or$after.registryPresent-or@($after.shortcutEntries).Count-ne0){Throw-CcodProductRegistrationError 'CCOD_PRODUCT_CLEANUP_INVALID' 'Installed resume registry deletion did not converge.' $Transaction.transactionId} + return $true +} +function Get-CcodLegacySnapshotEntryName { + param($Entry,[Parameter(Mandatory)][string]$ExpectedAppId) + if($Entry-is[string]){return [string]$Entry} + if($null-eq$Entry-or$null-eq$Entry.PSObject.Properties['kind']){return $null} + if($Entry.kind-ceq'Registry'){ + if($Entry.path-isnot[string]-or[string]$Entry.path-cne(Get-CcodLegacyRegistryPath $ExpectedAppId)){return $null} + return 'Registry' + } + if($Entry.kind-ceq'Shortcut'-and$Entry.name-is[string]){ + try{$expectedPath=Get-CcodLegacyShortcutPath -RelativeName ([string]$Entry.name);$actualPath=[IO.Path]::GetFullPath([string]$Entry.path)}catch{return $null} + if($actualPath-cne$expectedPath){return $null} + return [string]$Entry.name + } + return $null +} + +function Test-CcodLegacySnapshotForProfile { + param($Snapshot,[Parameter(Mandatory)]$Profile,[Parameter(Mandatory)][string]$ExpectedAppId) + if($null-eq$Snapshot-or-not(Test-CcodProductExactProperties $Snapshot @('appId','entries'))-or + $Snapshot.appId-isnot[string]-or[string]$Snapshot.appId-cne$ExpectedAppId-or$null-eq$Snapshot.entries){return $false} + $actual=[Collections.Generic.List[string]]::new() + foreach($entry in @($Snapshot.entries)){$name=Get-CcodLegacySnapshotEntryName -Entry $entry -ExpectedAppId $ExpectedAppId;if($null-eq$name){return $false};$actual.Add($name)} + return Test-CcodProductExactStringSet $actual (@('Registry')+@($Profile.shortcutNames)) +} + +function Test-CcodLegacyShortcutSnapshotEntry { + param( + $Entry, + [Parameter(Mandatory)]$LegacyRegistration, + [Parameter(Mandatory)]$Profile, + [Parameter(Mandatory)][string]$ExpectedInstallRoot + ) + if(-not(Test-CcodProductExactProperties $Entry $script:CcodLegacyShortcutSnapshotFields)-or + $Entry.kind-isnot[string]-or$Entry.kind-cne'Shortcut'-or$Entry.name-isnot[string]-or + $Entry.path-isnot[string]-or$Entry.sha256-isnot[string]-or$Entry.sha256-cnotmatch'^[0-9a-f]{64}$'-or + $Entry.bytesBase64-isnot[string]-or$Entry.targetPath-isnot[string]-or$Entry.arguments-isnot[string]-or$Entry.workingDirectory-isnot[string]){return $false} + try{ + $expectedPath=Get-CcodLegacyShortcutPath -RelativeName ([string]$Entry.name) + $actualPath=[IO.Path]::GetFullPath([string]$Entry.path) + $contract=Get-CcodLegacyShortcutContract -LegacyRegistration $LegacyRegistration -Profile $Profile -Name ([string]$Entry.name) -ExpectedInstallRoot $ExpectedInstallRoot + $target=[IO.Path]::GetFullPath([string]$Entry.targetPath) + $expectedTarget=[IO.Path]::GetFullPath([string]$contract.targetPath) + $working=if([string]::IsNullOrEmpty([string]$Entry.workingDirectory)){''}else{[IO.Path]::GetFullPath([string]$Entry.workingDirectory).TrimEnd('\')} + $expectedWorking=if([string]::IsNullOrEmpty([string]$contract.workingDirectory)){''}else{[IO.Path]::GetFullPath([string]$contract.workingDirectory).TrimEnd('\')} + $bytes=[Convert]::FromBase64String([string]$Entry.bytesBase64) + $sha=[Security.Cryptography.SHA256]::Create() + try{$capturedHash=[BitConverter]::ToString($sha.ComputeHash($bytes)).Replace('-','').ToLowerInvariant()}finally{$sha.Dispose()} + }catch{return $false} + return $bytes.LongLength-gt0-and$bytes.LongLength-le262144-and$actualPath-cne$null-and[string]$Entry.path-ceq$actualPath-and$actualPath-ceq$expectedPath-and + [string]$Entry.targetPath-ceq$target-and$target-ceq$expectedTarget-and[string]$Entry.arguments-ceq[string]$contract.arguments-and + [string]$Entry.workingDirectory-ceq$working-and$working-ceq$expectedWorking-and$capturedHash-ceq[string]$Entry.sha256 +} + +function Assert-CcodLegacyMigrationPlan { + param([Parameter(Mandatory)]$Plan,[Parameter(Mandatory)][string]$ExpectedAppId,[Parameter(Mandatory)][string]$ExpectedInstallRoot) + $root=Get-CcodProductFullPath $ExpectedInstallRoot + if(-not(Test-CcodProductExactProperties $Plan $script:CcodLegacyMigrationPlanFields)-or$Plan.appId-isnot[string]-or$Plan.appId-cne$ExpectedAppId-or + $Plan.expectedInstallRoot-isnot[string]-or[string]$Plan.expectedInstallRoot-cne$root-or(Get-CcodProductFullPath ([string]$Plan.expectedInstallRoot))-cne$root-or$Plan.legacyPresent-isnot[bool]){ + Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Legacy migration plan identity is invalid.' $Plan + } + if(-not[bool]$Plan.legacyPresent){ + if($null-ne$Plan.legacyRegistration-or$null-ne$Plan.profile-or$null-ne$Plan.snapshot){Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Absent legacy migration plan contains unexpected evidence.' $Plan} + return $Plan + } + $profile=Resolve-CcodLegacyRegistrationProfile -LegacyRegistration $Plan.legacyRegistration + if($null-eq$Plan.profile-or-not(Test-CcodProductExactProperties $Plan.profile $script:CcodLegacyProfileFields)-or + ($Plan.profile|ConvertTo-Json -Depth 8 -Compress)-cne($profile|ConvertTo-Json -Depth 8 -Compress)-or + $Plan.profile.appId-cne$ExpectedAppId-or-not(Test-CcodLegacySnapshotForProfile -Snapshot $Plan.snapshot -Profile $profile -ExpectedAppId $ExpectedAppId)){ + Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Legacy migration plan no longer matches one exact profile.' $Plan + } + foreach($entry in @($Plan.snapshot.entries)){ + if($entry-is[string]){if([string]$entry-cne'Registry'){Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Legacy migration plan contains an unbound shortcut.' $entry};continue} + if($entry.kind-ceq'Shortcut'-and-not(Test-CcodLegacyShortcutSnapshotEntry -Entry $entry -LegacyRegistration $Plan.legacyRegistration -Profile $profile -ExpectedInstallRoot $root)){ + Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Legacy shortcut evidence does not match its historical target contract.' $entry + } + } + return $Plan +} + +function Get-CcodLegacyProductRegistrationMigrationPlan { + [CmdletBinding()] + param([Parameter(Mandatory)][string]$ExpectedAppId,[Parameter(Mandatory)][string]$ExpectedInstallRoot,[hashtable]$Adapters) + if($ExpectedAppId-cne$script:CcodProductAppId){Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Legacy product migration requires the canonical AppId.' $ExpectedAppId} + $root=Get-CcodProductFullPath $ExpectedInstallRoot + $adapter=Get-CcodProductRegistrationAdapters $Adapters + $legacy=&$adapter.ReadLegacyRegistration $ExpectedAppId + if($null-eq$legacy){return [pscustomobject][ordered]@{appId=$ExpectedAppId;expectedInstallRoot=$root;legacyPresent=$false;legacyRegistration=$null;profile=$null;snapshot=$null}} + $profile=Resolve-CcodLegacyRegistrationProfile -LegacyRegistration $legacy + $snapshot=&$adapter.ReadLegacySnapshot $ExpectedAppId $profile + $plan=[pscustomobject][ordered]@{appId=$ExpectedAppId;expectedInstallRoot=$root;legacyPresent=$true;legacyRegistration=$legacy;profile=$profile;snapshot=$snapshot} + [void](Assert-CcodLegacyMigrationPlan -Plan $plan -ExpectedAppId $ExpectedAppId -ExpectedInstallRoot $root) + return $plan +} + +function Test-CcodProductJsonHasNoDuplicateProperties { + param([AllowNull()][string]$Json) + if($null-eq$Json){return $false};$objects=[Collections.Generic.Stack[object]]::new() + for($index=0;$index-lt$Json.Length;$index++){ + $character=$Json[$index] + if($character-ceq'{'){$objects.Push([Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal));continue} + if($character-ceq'}'){if($objects.Count-eq0){return $false};[void]$objects.Pop();continue} + if($character-cne'"'){continue} + $name=[Text.StringBuilder]::new();$index++ + while($index-lt$Json.Length){$character=$Json[$index];if($character-ceq'"'){break};if($character-cne[char]92){[void]$name.Append($character);$index++;continue};$index++;if($index-ge$Json.Length){return $false};$escape=$Json[$index];if($escape-ceq'u'){if($index+4-ge$Json.Length){return $false};try{[void]$name.Append([char][Convert]::ToInt32($Json.Substring($index+1,4),16))}catch{return $false};$index+=5;continue};switch([string]$escape){'"'{$decoded='"'};'\'{$decoded=[char]92};'/'{$decoded='/'};'b'{$decoded=[char]8};'f'{$decoded=[char]12};'n'{$decoded=[char]10};'r'{$decoded=[char]13};'t'{$decoded=[char]9};default{return $false}};[void]$name.Append($decoded);$index++} + if($index-ge$Json.Length){return $false};$next=$index+1;while($next-lt$Json.Length-and[char]::IsWhiteSpace($Json[$next])){$next++};if($next-lt$Json.Length-and$Json[$next]-ceq':'){if($objects.Count-eq0-or-not$objects.Peek().Add($name.ToString())){return $false}} + } + return $objects.Count-eq0 +} + +function Test-CcodProductUnsignedInteger($Value){return ($Value-is[byte]-or$Value-is[uint16]-or$Value-is[uint32]-or$Value-is[uint64]-or$Value-is[int16]-or$Value-is[int32]-or$Value-is[int64])-and[decimal]$Value-ge0} +function Test-CcodDurableReadyEvidence($ReadyEvidence,$Registration){if($null-eq$ReadyEvidence-or-not(Test-CcodProductExactProperties $ReadyEvidence.transactionRecord $script:CcodReadyTransactionFields)-or-not(Test-CcodProductUnsignedInteger $ReadyEvidence.runtimeGeneration)-or[uint64]$ReadyEvidence.runtimeGeneration-eq0){return $false};$transaction=$ReadyEvidence.transactionRecord;$guid=[guid]::Empty;if($transaction.schemaVersion-isnot[int]-or$transaction.schemaVersion-ne1-or$transaction.transactionId-isnot[string]-or-not[guid]::TryParseExact([string]$transaction.transactionId,'D',[ref]$guid)-or$guid.ToString('D')-cne[string]$transaction.transactionId-or$transaction.phase-isnot[string]-or$transaction.phase-cne'Ready'-or$transaction.newRuntimeId-isnot[string]-or$transaction.newRuntimeId-cne$Registration.runtimeId-or-not(Test-CcodProductUnsignedInteger $transaction.newGeneration)-or[uint64]$transaction.newGeneration-ne[uint64]$ReadyEvidence.runtimeGeneration-or$transaction.newManifestSha256-isnot[string]-or$transaction.newManifestSha256-cne[string]$ReadyEvidence.manifestSha256-or$transaction.sealedPackageSha256-isnot[string]-or$transaction.sealedPackageSha256-cne[string]$ReadyEvidence.packageSha256-or$transaction.sealedPackageSha256-cne$Registration.packageSha256-or-not(Test-CcodProductExactStringSet $transaction.ownedObjectNames $transaction.ownedObjectNames)-or@($transaction.ownedObjectNames)-cnotcontains[string]$Registration.runtimeId){return $false};return $true} +function Get-CcodProductMapNames($Value){if($Value-is[Collections.IDictionary]){return @($Value.Keys)};if($Value-is[pscustomobject]){return @($Value.PSObject.Properties.Name)};return @()} +function Get-CcodProductMapValue($Value,[string]$Name){if($Value-is[Collections.IDictionary]){return $Value[$Name]};if($Value-is[pscustomobject]){return ($Value.PSObject.Properties|Where-Object Name -CEQ $Name|Select-Object -First 1).Value};return $null} +function ConvertTo-CcodProductOrderedMap($Value){$result=[ordered]@{};foreach($name in @(Get-CcodProductMapNames $Value)){$result[[string]$name]=Get-CcodProductMapValue $Value ([string]$name)};return $result} +function Get-CcodDurableCurrentShortcutProof($Proof,[string]$Name){foreach($entry in @($Proof.shortcuts)){if($entry.name-is[string]-and[string]$entry.name-ceq$Name){return $entry}};return $null} + +function Assert-CcodDurableCurrentProof { + param([Parameter(Mandatory)]$Proof,[Parameter(Mandatory)]$Registration,[Parameter(Mandatory)]$ReadyEvidence) + if(-not(Test-CcodProductExactProperties $Proof $script:CcodDurableCurrentProofFields)-or$Proof.version-isnot[string]-or$Proof.version-cne$Registration.version-or$Proof.runtimeId-isnot[string]-or$Proof.runtimeId-cne$Registration.runtimeId-or$Proof.packageSha256-isnot[string]-or$Proof.packageSha256-cne$Registration.packageSha256-or@($Proof.shortcuts).Count-ne2){Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Durable current shortcut proof identity is invalid.' $Proof} + for($index=0;$index-lt2;$index++){ + $entry=@($Proof.shortcuts)[$index];$kind=if($index-eq0){'StartMenu'}else{'Desktop'};$name=$script:CcodCurrentShortcutNames[$index];$shortcut=if($index-eq0){$Registration.startMenuShortcut}else{$Registration.desktopShortcut};$sha=if($index-eq0){[string]$ReadyEvidence.startMenuSha256}else{[string]$ReadyEvidence.desktopSha256} + if(-not(Test-CcodProductExactProperties $entry $script:CcodDurableCurrentShortcutFields)-or$entry.kind-isnot[string]-or$entry.kind-cne$kind-or$entry.name-isnot[string]-or$entry.name-cne$name-or$entry.path-isnot[string]-or$entry.candidatePath-isnot[string]-or-not(Test-CcodProductUnsignedInteger $entry.candidateLength)-or[uint64]$entry.candidateLength-eq0-or[uint64]$entry.candidateLength-gt262144-or$entry.candidateSha256-isnot[string]-or$entry.candidateSha256-cne$sha-or$entry.targetPath-isnot[string]-or$entry.arguments-isnot[string]-or$entry.arguments-cne[string]$ReadyEvidence.arguments-or$entry.workingDirectory-isnot[string]){Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Durable current shortcut proof is incomplete.' $entry} + try{$path=[IO.Path]::GetFullPath([string]$entry.path);$candidate=[IO.Path]::GetFullPath([string]$entry.candidatePath);$target=[IO.Path]::GetFullPath([string]$entry.targetPath);$expectedPath=Get-CcodLegacyShortcutPath -RelativeName $name;$expectedCandidate=[IO.Path]::GetFullPath([string]$shortcut.candidatePath);$expectedTarget=[IO.Path]::GetFullPath([string]$ReadyEvidence.targetPath);$working=if([string]::IsNullOrEmpty([string]$entry.workingDirectory)){''}else{[IO.Path]::GetFullPath([string]$entry.workingDirectory).TrimEnd('\')}}catch{Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Durable current shortcut paths are invalid.' $entry} + if($path-cne[string]$entry.path-or$path-cne$expectedPath-or$candidate-cne[string]$entry.candidatePath-or$candidate-cne$expectedCandidate-or$target-cne[string]$entry.targetPath-or$target-cne$expectedTarget-or$working-cne[string]$entry.workingDirectory){Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Durable current shortcut paths changed.' $entry} + } + return $Proof +} + +function New-CcodDurableCurrentProof { + param([Parameter(Mandatory)]$Registration,[Parameter(Mandatory)]$ReadyEvidence,[Parameter(Mandatory)][hashtable]$Adapters) + $entries=[Collections.Generic.List[object]]::new() + foreach($kind in @('StartMenu','Desktop')){$shortcut=if($kind-ceq'StartMenu'){$Registration.startMenuShortcut}else{$Registration.desktopShortcut};$entries.Add((&$Adapters.GetCurrentShortcutProof $kind $shortcut $ReadyEvidence))} + $proof=[pscustomobject][ordered]@{version=[string]$Registration.version;runtimeId=[string]$Registration.runtimeId;packageSha256=[string]$Registration.packageSha256;shortcuts=@($entries)} + [void](Assert-CcodDurableCurrentProof -Proof $proof -Registration $Registration -ReadyEvidence $ReadyEvidence);return $proof +} + +function Test-CcodDurableLegacyRegistryEntry { + param($Entry,[Parameter(Mandatory)]$LegacyRegistration,[Parameter(Mandatory)][string]$ExpectedAppId) + if($null-eq$Entry-or-not(Test-CcodProductExactProperties $Entry @('kind','path','values'))-or$Entry.kind-isnot[string]-or$Entry.kind-cne'Registry'-or$Entry.path-isnot[string]-or$Entry.path-cne(Get-CcodLegacyRegistryPath $ExpectedAppId)){return $false} + $names=@(Get-CcodProductMapNames $Entry.values);if($names.Count-eq0-or(@($names|Where-Object{$script:CcodLegacyRegistryValueNames-cnotcontains[string]$_}).Count-ne0)){return $false} + foreach($required in @('DisplayVersion','InstallLocation','UninstallString')){$value=Get-CcodProductMapValue $Entry.values $required;if($null-eq$value-or-not(Test-CcodProductExactProperties $value @('value','kind'))-or$value.kind-isnot[string]-or$value.kind-cne'String'){return $false};$expected=if($required-ceq'DisplayVersion'){$LegacyRegistration.displayVersion}elseif($required-ceq'InstallLocation'){$LegacyRegistration.installLocation}else{$LegacyRegistration.uninstallString};if($value.value-isnot[string]-or[string]$value.value-cne[string]$expected){return $false}} + foreach($name in $names){$value=Get-CcodProductMapValue $Entry.values ([string]$name);if($null-eq$value-or-not(Test-CcodProductExactProperties $value @('value','kind'))-or$value.kind-isnot[string]-or$value.kind-cnotin@('String','ExpandString','Binary','DWord','QWord','MultiString','None')){return $false}} + return $true +} + +function Assert-CcodDurableLegacyMigrationRecord { + param([Parameter(Mandatory)]$Record,[Parameter(Mandatory)][string]$ExpectedAppId,[Parameter(Mandatory)]$Registration,[Parameter(Mandatory)]$ReadyEvidence) + if(-not(Test-CcodProductExactProperties $Record $script:CcodDurableLegacyMigrationFields)-or$Record.schemaVersion-isnot[int]-or$Record.schemaVersion-ne1-or$Record.transactionId-isnot[string]-or$Record.transactionId-cne[string]$ReadyEvidence.transactionRecord.transactionId-or$Record.runtimeId-isnot[string]-or$Record.runtimeId-cne$Registration.runtimeId-or-not(Test-CcodProductUnsignedInteger $Record.runtimeGeneration)-or[uint64]$Record.runtimeGeneration-ne[uint64]$ReadyEvidence.runtimeGeneration-or$Record.manifestSha256-isnot[string]-or$Record.manifestSha256-cne[ string]$ReadyEvidence.manifestSha256-or$Record.packageSha256-isnot[string]-or$Record.packageSha256-cne$Registration.packageSha256-or$Record.appId-isnot[string]-or$Record.appId-cne$ExpectedAppId-or$Record.expectedInstallRoot-isnot[string]-or(Get-CcodProductFullPath ([string]$Record.expectedInstallRoot))-cne[IO.Path]::GetFullPath([string]$Registration.installRoot)-or$Record.legacyPresent-isnot[bool]){Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Durable legacy migration plan identity is invalid.' $Record} + if(-not(Test-CcodProductExactProperties $Record.readyTransaction $script:CcodReadyTransactionFields)-or($Record.readyTransaction|ConvertTo-Json -Depth 16 -Compress)-cne($ReadyEvidence.transactionRecord|ConvertTo-Json -Depth 16 -Compress)){Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Durable legacy migration Ready transaction changed.' $Record.readyTransaction} + $plan=[pscustomobject][ordered]@{appId=[string]$Record.appId;expectedInstallRoot=[string]$Record.expectedInstallRoot;legacyPresent=[bool]$Record.legacyPresent;legacyRegistration=$Record.legacyRegistration;profile=$Record.profile;snapshot=$Record.snapshot} + [void](Assert-CcodLegacyMigrationPlan -Plan $plan -ExpectedAppId $ExpectedAppId -ExpectedInstallRoot ([string]$Registration.installRoot)) + if([bool]$plan.legacyPresent){$registry=@($plan.snapshot.entries|Where-Object{$_-isnot[string]-and$_.kind-ceq'Registry'});if($registry.Count-ne1-or-not(Test-CcodDurableLegacyRegistryEntry -Entry $registry[0] -LegacyRegistration $plan.legacyRegistration -ExpectedAppId $ExpectedAppId)){Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Durable legacy registry evidence is incomplete.' $plan.snapshot}} + [void](Assert-CcodDurableCurrentProof -Proof $Record.expectedCurrentProof -Registration $Registration -ReadyEvidence $ReadyEvidence) + return $Record +} + +function ConvertTo-CcodDurableLegacyMigrationBytes($Record){$bytes=[Text.UTF8Encoding]::new($false).GetBytes(($Record|ConvertTo-Json -Depth 32 -Compress)+"`n");if($bytes.LongLength-eq0-or$bytes.LongLength-gt1048576){Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Durable legacy migration plan exceeds its bounded record size.' $null};return ,$bytes} +function Get-CcodProductBytesSha256([byte[]]$Bytes){$sha=[Security.Cryptography.SHA256]::Create();try{[BitConverter]::ToString($sha.ComputeHash($Bytes)).Replace('-','').ToLowerInvariant()}finally{$sha.Dispose()}} + +function ConvertFrom-CcodDurableLegacyMigrationRead { + param([Parameter(Mandatory)]$Read,[Parameter(Mandatory)][string]$ExpectedAppId,[Parameter(Mandatory)]$Registration,[Parameter(Mandatory)]$ReadyEvidence) + if(-not(Test-CcodProductExactProperties $Read @('Bytes','Length','Sha256'))-or$Read.Bytes-isnot[byte[]]-or-not(Test-CcodProductUnsignedInteger $Read.Length)-or[uint64]$Read.Length-ne[uint64]$Read.Bytes.LongLength-or$Read.Bytes.LongLength-eq0-or$Read.Bytes.LongLength-gt1048576-or$Read.Sha256-isnot[string]-or$Read.Sha256-cnotmatch'^[0-9a-f]{64}$'-or(Get-CcodProductBytesSha256 $Read.Bytes)-cne[string]$Read.Sha256){Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Durable legacy migration plan read identity is invalid.' $Read} + try{$json=[Text.UTF8Encoding]::new($false,$true).GetString($Read.Bytes);if(-not(Test-CcodProductJsonHasNoDuplicateProperties $json)){throw 'duplicate JSON properties'};$record=$json|ConvertFrom-Json -ErrorAction Stop}catch{Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Durable legacy migration plan JSON is malformed or ambiguous.' $Read.Sha256} + [void](Assert-CcodDurableLegacyMigrationRecord -Record $record -ExpectedAppId $ExpectedAppId -Registration $Registration -ReadyEvidence $ReadyEvidence) + $canonical=ConvertTo-CcodDurableLegacyMigrationBytes $record;if([Convert]::ToBase64String($canonical)-cne[Convert]::ToBase64String($Read.Bytes)){Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Durable legacy migration plan bytes are noncanonical.' $Read.Sha256} + if([bool]$record.legacyPresent){foreach($entry in @($record.snapshot.entries)){if($entry-isnot[string]-and$entry.kind-ceq'Registry'){$entry.values=ConvertTo-CcodProductOrderedMap $entry.values}}} + return $record +} + +function Test-CcodLegacySnapshotCurrentReplacement { + param($Entry,[Parameter(Mandatory)][string]$Name,[Parameter(Mandatory)]$CurrentProof) + $expected=Get-CcodDurableCurrentShortcutProof -Proof $CurrentProof -Name $Name + if($null-eq$expected-or$Entry-is[string]-or$null-eq$Entry-or$Entry.kind-cne'Shortcut'-or$Entry.name-isnot[string]-or[string]$Entry.name-cne$Name-or$Entry.path-isnot[string]-or$Entry.sha256-isnot[string]-or$Entry.bytesBase64-isnot[string]-or$Entry.targetPath-isnot[string]-or$Entry.arguments-isnot[string]-or$Entry.workingDirectory-isnot[string]){return $false} + try{$actualPath=[IO.Path]::GetFullPath([string]$Entry.path);$bytes=[Convert]::FromBase64String([string]$Entry.bytesBase64);$target=[IO.Path]::GetFullPath([string]$Entry.targetPath);$working=if([string]::IsNullOrEmpty([string]$Entry.workingDirectory)){''}else{[IO.Path]::GetFullPath([string]$Entry.workingDirectory).TrimEnd('\')}}catch{return $false} + return $actualPath-ceq[string]$expected.path-and$bytes.LongLength-eq[uint64]$expected.candidateLength-and(Get-CcodProductBytesSha256 $bytes)-ceq[string]$expected.candidateSha256-and[string]$Entry.sha256-ceq[string]$expected.candidateSha256-and$target-ceq[string]$expected.targetPath-and[string]$Entry.arguments-ceq[string]$expected.arguments-and$working-ceq[string]$expected.workingDirectory +} + +function ConvertTo-CcodDurableLegacyPlan($Record){[pscustomobject][ordered]@{appId=[string]$Record.appId;expectedInstallRoot=[string]$Record.expectedInstallRoot;legacyPresent=[bool]$Record.legacyPresent;legacyRegistration=$Record.legacyRegistration;profile=$Record.profile;snapshot=$Record.snapshot}} + +function Assert-CcodDurableLegacyReplay { + param([Parameter(Mandatory)]$Record,[Parameter(Mandatory)][hashtable]$Adapters) + $plan=ConvertTo-CcodDurableLegacyPlan $Record;$legacy=&$Adapters.ReadLegacyRegistration $Record.appId + if(-not[bool]$Record.legacyPresent){if($null-ne$legacy){Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Legacy state appeared after an absent durable capture.' $Record.appId};return $plan} + if($null-eq$legacy){$start=Get-CcodDurableCurrentShortcutProof $Record.expectedCurrentProof $script:CcodCurrentShortcutNames[0];$desktop=Get-CcodDurableCurrentShortcutProof $Record.expectedCurrentProof $script:CcodCurrentShortcutNames[1];$expected=[pscustomobject][ordered]@{verified=$true;runtimeId=[string]$Record.expectedCurrentProof.runtimeId;version=[string]$Record.expectedCurrentProof.version;packageSha256=[string]$Record.expectedCurrentProof.packageSha256;shortcutNames=@($script:CcodCurrentShortcutNames);startMenuSha256=[string]$start.candidateSha256;desktopSha256=[string]$desktop.candidateSha256};$current=&$Adapters.ReadVerifiedRegistration;if(-not(Test-CcodCurrentVerifiedRegistrationUnchanged -Before $expected -After $current)){Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Absent legacy registry lacks the exact current proof.' $Record.appId};foreach($entry in @($plan.snapshot.entries)){if((Get-CcodLegacySnapshotEntryName -Entry $entry -ExpectedAppId $Record.appId)-in$script:CcodCurrentShortcutNames){continue};$state=&$Adapters.ReadLegacyEntry $entry;if($entry.kind-ceq'Registry'){if($null-ne$state){Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Legacy registry observation is internally inconsistent.' $Record.appId};continue};if($null-ne$state-and$state-cne'Exact'){Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Remaining legacy-only shortcut is foreign.' (Get-CcodLegacySnapshotEntryName -Entry $entry -ExpectedAppId $Record.appId)}};return $plan} + if($null-eq$legacy-or$legacy.appId-cne$plan.legacyRegistration.appId-or$legacy.displayVersion-cne$plan.legacyRegistration.displayVersion-or$legacy.installLocation-cne$plan.legacyRegistration.installLocation-or$legacy.uninstallString-cne$plan.legacyRegistration.uninstallString-or@($legacy.unsafeShortcutNames).Count-ne0){Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Live legacy registry no longer matches the durable capture.' $Record.appId} + $required=@($plan.profile.shortcutNames);$allowed=@($required)+@($script:CcodCurrentShortcutNames);$actual=@($legacy.shortcutNames);$requiredOverlaps=@($required|Where-Object{$script:CcodCurrentShortcutNames-ccontains[string]$_}) + if(-not(Test-CcodProductExactStringSet $actual $actual)-or@($actual|Where-Object{$allowed-cnotcontains[string]$_}).Count-ne0-or@($requiredOverlaps|Where-Object{$actual-cnotcontains[string]$_}).Count-ne0){Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Live legacy shortcut names are outside the durable transition.' $Record.appId} + $live=&$Adapters.ReadLegacySnapshot $Record.appId $plan.profile $plan + if($null-eq$live-or$live.appId-cne$Record.appId){Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Live legacy snapshot could not be recaptured.' $Record.appId} + $storedByName=@{};foreach($entry in @($plan.snapshot.entries)){$name=Get-CcodLegacySnapshotEntryName -Entry $entry -ExpectedAppId $Record.appId;if($null-eq$name-or$storedByName.ContainsKey($name)){Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Durable legacy snapshot contains ambiguous entries.' $name};$storedByName[$name]=$entry} + $liveByName=@{};foreach($entry in @($live.entries)){$name=Get-CcodLegacySnapshotEntryName -Entry $entry -ExpectedAppId $Record.appId;if($null-eq$name-or$liveByName.ContainsKey($name)){Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Live legacy snapshot contains ambiguous entries.' $name};$liveByName[$name]=$entry} + if(-not$liveByName.ContainsKey('Registry')-or(Compare-CcodLegacySnapshotEntry -Expected $storedByName['Registry'] -Current $liveByName['Registry'])-cne'Exact'){Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Live legacy registry evidence changed after durable capture.' $Record.appId} + foreach($name in $required){if(-not$liveByName.ContainsKey($name)){if($script:CcodCurrentShortcutNames-ccontains$name){Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Required overlap shortcut disappeared after durable capture.' $name};continue};$current=$liveByName[$name];$historical=$storedByName[$name];if((Compare-CcodLegacySnapshotEntry -Expected $historical -Current $current)-ceq'Exact'){continue};if($script:CcodCurrentShortcutNames-ccontains$name-and(Test-CcodLegacySnapshotCurrentReplacement -Entry $current -Name $name -CurrentProof $Record.expectedCurrentProof)){continue};Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Historical shortcut changed outside the durable current replacement.' $name} + foreach($name in $script:CcodCurrentShortcutNames){if($required-ccontains$name-or-not$liveByName.ContainsKey($name)){continue};if(-not(Test-CcodLegacySnapshotCurrentReplacement -Entry $liveByName[$name] -Name $name -CurrentProof $Record.expectedCurrentProof)){Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Optional current shortcut does not match the durable replacement.' $name}} + if(@($liveByName.Keys|Where-Object{$_-cne'Registry'-and$actual-cnotcontains[string]$_}).Count-ne0){Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Live legacy snapshot contains an unreported shortcut.' $Record.appId} + return $plan +} + +function Get-CcodDurableLegacyProductRegistrationMigrationPlan { + [CmdletBinding()] + param([Parameter(Mandatory)][string]$ExpectedAppId,[Parameter(Mandatory)]$Registration,[Parameter(Mandatory)]$ReadyEvidence,[hashtable]$Adapters) + if($ExpectedAppId-cne$script:CcodProductAppId-or-not(Test-CcodProductRegistration -Registration $Registration -ExpectedRuntimeId ([string]$Registration.runtimeId) -ExpectedVersion $script:CcodProductVersion -ExpectedPackageSha256 ([string]$Registration.packageSha256))-or-not(Test-CcodProductReadyProof $ReadyEvidence $Registration)-or-not(Test-CcodDurableReadyEvidence $ReadyEvidence $Registration)){Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Durable legacy migration requires the exact Ready product identity.' $ExpectedAppId} + $adapter=Get-CcodProductRegistrationAdapters $Adapters;try{$existing=&$adapter.ReadLegacyMigrationPlan $ReadyEvidence}catch{Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Durable legacy migration plan could not be read.' $ReadyEvidence.transactionRecord.transactionId} + if($null-ne$existing){$record=ConvertFrom-CcodDurableLegacyMigrationRead -Read $existing -ExpectedAppId $ExpectedAppId -Registration $Registration -ReadyEvidence $ReadyEvidence;return Assert-CcodDurableLegacyReplay -Record $record -Adapters $adapter} + $plan=Get-CcodLegacyProductRegistrationMigrationPlan -ExpectedAppId $ExpectedAppId -ExpectedInstallRoot ([string]$Registration.installRoot) -Adapters $adapter + $currentProof=New-CcodDurableCurrentProof -Registration $Registration -ReadyEvidence $ReadyEvidence -Adapters $adapter + $record=[pscustomobject][ordered]@{schemaVersion=1;transactionId=[string]$ReadyEvidence.transactionRecord.transactionId;runtimeId=[string]$Registration.runtimeId;runtimeGeneration=[uint64]$ReadyEvidence.runtimeGeneration;manifestSha256=[string]$ReadyEvidence.manifestSha256;packageSha256=[string]$Registration.packageSha256;readyTransaction=$ReadyEvidence.transactionRecord;appId=$ExpectedAppId;expectedInstallRoot=[string]$Registration.installRoot;legacyPresent=[bool]$plan.legacyPresent;legacyRegistration=$plan.legacyRegistration;profile=$plan.profile;snapshot=$plan.snapshot;expectedCurrentProof=$currentProof} + [void](Assert-CcodDurableLegacyMigrationRecord -Record $record -ExpectedAppId $ExpectedAppId -Registration $Registration -ReadyEvidence $ReadyEvidence);$bytes=ConvertTo-CcodDurableLegacyMigrationBytes $record + try{&$adapter.WriteLegacyMigrationPlan $ReadyEvidence $bytes|Out-Null}catch{Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Durable legacy migration plan could not be published create-only.' $Record.transactionId} + try{$read=&$adapter.ReadLegacyMigrationPlan $ReadyEvidence}catch{Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Durable legacy migration plan read-back failed after publication.' $Record.transactionId};if($null-eq$read-or[Convert]::ToBase64String([byte[]]$read.Bytes)-cne[Convert]::ToBase64String($bytes)){Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Durable legacy migration plan did not read back exactly.' $Record.transactionId} + $persisted=ConvertFrom-CcodDurableLegacyMigrationRead -Read $read -ExpectedAppId $ExpectedAppId -Registration $Registration -ReadyEvidence $ReadyEvidence + return ConvertTo-CcodDurableLegacyPlan $persisted +} + +function Read-CcodLegacySnapshot { + param([string]$ExpectedAppId,[Parameter(Mandatory)]$ExpectedProfile,[AllowNull()]$CapturedPlan) + $legacy=Read-CcodCurrentUserLegacyRegistration $ExpectedAppId;if($null-eq$legacy){return $null} + if($null-eq$CapturedPlan){$observedProfile=Resolve-CcodLegacyRegistrationProfile -LegacyRegistration $legacy;if($observedProfile.profileId-cne$ExpectedProfile.profileId){throw 'legacy profile changed before snapshot'};$shortcutNames=@($ExpectedProfile.shortcutNames)}else{$captured=$CapturedPlan.legacyRegistration;$required=@($ExpectedProfile.shortcutNames);$allowed=@($required)+@($script:CcodCurrentShortcutNames);$requiredOverlaps=@($required|Where-Object{$script:CcodCurrentShortcutNames-ccontains[string]$_});$shortcutNames=@($legacy.shortcutNames);if($legacy.appId-cne$captured.appId-or$legacy.displayVersion-cne$captured.displayVersion-or$legacy.installLocation-cne$captured.installLocation-or$legacy.uninstallString-cne$captured.uninstallString-or@($legacy.unsafeShortcutNames).Count-ne0-or@($shortcutNames|Where-Object{$allowed-cnotcontains[string]$_}).Count-ne0-or@($requiredOverlaps|Where-Object{$shortcutNames-cnotcontains[string]$_}).Count-ne0){throw 'legacy replay profile changed before snapshot'}} + $entries=[Collections.Generic.List[object]]::new();$keyPath=Get-CcodLegacyRegistryPath $ExpectedAppId;$key=Get-Item -LiteralPath $keyPath -ErrorAction Stop + try{ + $names=@($key.GetValueNames());if($key.GetSubKeyNames().Count-ne0-or@($names|Where-Object{$script:CcodLegacyRegistryValueNames-cnotcontains$_}).Count-ne0){throw 'legacy registry contains unknown state'} + $values=[ordered]@{};foreach($name in $names){$values[$name]=[pscustomobject]@{value=$key.GetValue($name,$null,[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames);kind=[string]$key.GetValueKind($name)}} + }finally{$key.Dispose()} + $entries.Add([pscustomobject]@{kind='Registry';path=$keyPath;values=$values}) + foreach($name in @($shortcutNames)){ + $path=Get-CcodLegacyShortcutPath -RelativeName $name;$item=Get-Item -LiteralPath $path -Force -ErrorAction Stop + if($item.PSIsContainer-or($item.Attributes-band[IO.FileAttributes]::ReparsePoint)-ne0-or[string]$item.Name-cne[IO.Path]::GetFileName($name)-or$item.Length-le0-or$item.Length-gt262144){throw 'legacy shortcut unsafe'} + $shell=$null;$shortcut=$null + try{ + $shell=New-Object -ComObject WScript.Shell;$shortcut=$shell.CreateShortcut($path) + $target=[IO.Path]::GetFullPath([string]$shortcut.TargetPath);$arguments=[string]$shortcut.Arguments;$workingDirectory=[string]$shortcut.WorkingDirectory + if(-not[string]::IsNullOrEmpty($workingDirectory)){$workingDirectory=[IO.Path]::GetFullPath($workingDirectory).TrimEnd('\')} + }finally{ + if($null-ne$shortcut){[void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($shortcut)} + if($null-ne$shell){[void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($shell)} + } + $bytes=[IO.File]::ReadAllBytes($path);$entries.Add([pscustomobject][ordered]@{kind='Shortcut';name=$name;path=$path;sha256=Get-CcodProductFileSha256 $path;bytesBase64=[Convert]::ToBase64String($bytes);targetPath=$target;arguments=$arguments;workingDirectory=$workingDirectory}) + } + return [pscustomobject][ordered]@{appId=$ExpectedAppId;entries=@($entries)} +} +function Initialize-CcodLegacyRegistryNative { + if($null-ne('CcodLegacyRegistryNativeV1'-as[type])){return} + Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using Microsoft.Win32; +using Microsoft.Win32.SafeHandles; + +public sealed class CcodLegacyRegistryCreateResultV1 : IDisposable +{ + private SafeRegistryHandle nativeHandle; + public RegistryKey Key { get; private set; } + public uint Disposition { get; private set; } + internal CcodLegacyRegistryCreateResultV1(IntPtr handle, uint disposition) + { + nativeHandle = new SafeRegistryHandle(handle, true); + Key = RegistryKey.FromHandle(nativeHandle); + Disposition = disposition; + } + public void Dispose() + { + if (Key != null) { Key.Dispose(); Key = null; } + if (nativeHandle != null) { nativeHandle.Dispose(); nativeHandle = null; } + } +} + +public static class CcodLegacyRegistryNativeV1 +{ + private const int KEY_QUERY_VALUE = 0x0001; + private const int KEY_SET_VALUE = 0x0002; + private const int KEY_ENUMERATE_SUB_KEYS = 0x0008; + private const int DELETE = 0x00010000; + [DllImport("advapi32.dll", CharSet = CharSet.Unicode, ExactSpelling = true)] + private static extern int RegCreateKeyExW(IntPtr hKey, string subKey, uint reserved, string keyClass, uint options, int desiredAccess, IntPtr securityAttributes, out IntPtr result, out uint disposition); + [DllImport("ntdll.dll", ExactSpelling = true)] + private static extern int NtCompareObjects(IntPtr first, IntPtr second); + [DllImport("ntdll.dll", ExactSpelling = true)] + private static extern int NtDeleteKey(IntPtr keyHandle); + [DllImport("ntdll.dll", ExactSpelling = true)] + private static extern uint RtlNtStatusToDosError(int status); + + public static CcodLegacyRegistryCreateResultV1 CreateCurrentUserKey(string subKey) + { + IntPtr handle; + uint disposition; + int error = RegCreateKeyExW(new IntPtr(unchecked((int)0x80000001)), subKey, 0, null, 0, KEY_QUERY_VALUE | KEY_SET_VALUE | KEY_ENUMERATE_SUB_KEYS | DELETE, IntPtr.Zero, out handle, out disposition); + if (error != 0) throw new Win32Exception(error); + return new CcodLegacyRegistryCreateResultV1(handle, disposition); + } + public static bool IsSameKey(RegistryKey first, RegistryKey second) + { + if (first == null || second == null) return false; + return NtCompareObjects(first.Handle.DangerousGetHandle(), second.Handle.DangerousGetHandle()) == 0; + } + public static void DeleteKey(RegistryKey key) + { + if (key == null) throw new ArgumentNullException("key"); + int status = NtDeleteKey(key.Handle.DangerousGetHandle()); + if (status < 0) throw new Win32Exception((int)RtlNtStatusToDosError(status)); + } +} +'@ -ErrorAction Stop +} + +function Get-CcodLegacyRegistryNativeSubKey { + param([Parameter(Mandatory)][string]$Path) + if(-not$Path.StartsWith('HKCU:\',[StringComparison]::Ordinal)-or$Path.Length-le6){throw 'legacy registry path is invalid'} + return $Path.Substring(6) +} + +function Get-CcodLegacyRegistryMutationAdapters { + param([hashtable]$Adapters) + $defaults=@{ + OpenExisting={ + param($Path) + $rights=[Security.AccessControl.RegistryRights]::QueryValues-bor[Security.AccessControl.RegistryRights]::SetValue-bor[Security.AccessControl.RegistryRights]::EnumerateSubKeys-bor[Security.AccessControl.RegistryRights]::Delete + $key=[Microsoft.Win32.Registry]::CurrentUser.OpenSubKey((Get-CcodLegacyRegistryNativeSubKey $Path),[Microsoft.Win32.RegistryKeyPermissionCheck]::ReadWriteSubTree,$rights) + if($null-eq$key){return $null} + [pscustomobject][ordered]@{key=$key;resource=$key;disposition='OpenedExisting'} + } + CloseKey={param($Handle)$Handle.resource.Dispose()} + GetValueNames={param($Handle)@($Handle.key.GetValueNames())} + GetSubKeyNames={param($Handle)@($Handle.key.GetSubKeyNames())} + GetValue={param($Handle,$Name)$Handle.key.GetValue([string]$Name,$null,[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)} + GetValueKind={param($Handle,$Name)[string]$Handle.key.GetValueKind([string]$Name)} + DeleteValue={param($Handle,$Name)$Handle.key.DeleteValue([string]$Name,$true)} + SetValue={param($Handle,$Name,$Value,$Kind)$valueKind=[Enum]::Parse([Microsoft.Win32.RegistryValueKind],[string]$Kind);$Handle.key.SetValue([string]$Name,$Value,$valueKind)} + DeleteKey={param($Handle)Initialize-CcodLegacyRegistryNative;[CcodLegacyRegistryNativeV1]::DeleteKey($Handle.key)} + TestKeyAtPath={ + param($Path,$Handle) + $current=[Microsoft.Win32.Registry]::CurrentUser.OpenSubKey((Get-CcodLegacyRegistryNativeSubKey $Path),$false) + if($null-eq$current){return $false} + try{Initialize-CcodLegacyRegistryNative;return [CcodLegacyRegistryNativeV1]::IsSameKey($Handle.key,$current)}finally{$current.Dispose()} + } + CreateKey={ + param($Path) + Initialize-CcodLegacyRegistryNative;$result=[CcodLegacyRegistryNativeV1]::CreateCurrentUserKey((Get-CcodLegacyRegistryNativeSubKey $Path)) + $disposition=if($result.Disposition-eq1){'CreatedNew'}elseif($result.Disposition-eq2){'OpenedExisting'}else{$result.Dispose();throw 'legacy registry create disposition is invalid'} + [pscustomobject][ordered]@{key=$result.Key;resource=$result;disposition=$disposition} + } + } + if($null-eq$Adapters){return $defaults} + if($Adapters-isnot[hashtable]){throw 'legacy registry adapters are invalid'} + $resolved=@{};foreach($name in $defaults.Keys){$resolved[$name]=$defaults[$name]} + foreach($name in $Adapters.Keys){if($name-isnot[string]-or-not$resolved.ContainsKey($name)-or$Adapters[$name]-isnot[scriptblock]){throw 'legacy registry adapters are invalid'};$resolved[$name]=$Adapters[$name]} + return $resolved +} + +function Get-CcodLegacyRegistryMutationState { + param([Parameter(Mandatory)]$Entry) + if($null-eq$Entry.PSObject.Properties['mutationState']){$Entry|Add-Member -NotePropertyName mutationState -NotePropertyValue ([pscustomobject]@{status='Captured'})} + $state=$Entry.mutationState + if($null-eq$state-or$state.status-isnot[string]){throw 'legacy registry mutation state is invalid'} + return $state +} + +function Test-CcodLegacyRegistryHandleExact { + param([Parameter(Mandatory)]$Entry,[Parameter(Mandatory)]$Handle,[Parameter(Mandatory)][hashtable]$Adapters) + $names=@(&$Adapters.GetValueNames $Handle);$subkeys=@(&$Adapters.GetSubKeyNames $Handle) + if($subkeys.Count-ne0-or-not(Test-CcodProductExactStringSet $names @($Entry.values.Keys))){return $false} + foreach($name in @($Entry.values.Keys)){ + $actualKind=&$Adapters.GetValueKind $Handle $name;$actualValue=$Adapters.GetValue.InvokeReturnAsIs($Handle,$name) + if([string]$actualKind-cne[string]$Entry.values[$name].kind-or($actualValue|ConvertTo-Json -Compress)-cne($Entry.values[$name].value|ConvertTo-Json -Compress)){return $false} + } + return $true +} + +function Remove-CcodLegacySnapshotEntry { + param($Entry,[hashtable]$RegistryAdapters) + if($Entry-is[string]){return} + if($Entry.kind-ceq'Registry'){ + $state=Get-CcodLegacyRegistryMutationState $Entry;$adapter=Get-CcodLegacyRegistryMutationAdapters $RegistryAdapters;$handle=&$adapter.OpenExisting $Entry.path + if($null-eq$handle){throw 'legacy registry disappeared before deletion'} + $mutationStarted=$false;$operationFailure=$null;$closeFailure=$null + try{ + if(-not(Test-CcodLegacyRegistryHandleExact -Entry $Entry -Handle $handle -Adapters $adapter)-or-not(&$adapter.TestKeyAtPath $Entry.path $handle)){throw 'legacy registry changed'} + foreach($name in @($Entry.values.Keys)){$mutationStarted=$true;&$adapter.DeleteValue $handle $name} + if(@(&$adapter.GetValueNames $handle).Count-ne0){throw 'legacy registry values remain after deletion'} + &$adapter.DeleteKey $handle + $state.status='Deleted' + }catch{ + $operationFailure=$_ + if($mutationStarted-and$state.status-cne'Deleted'){ + $restored=$false + try{ + foreach($name in @($Entry.values.Keys)){&$adapter.SetValue $handle $name $Entry.values[$name].value $Entry.values[$name].kind} + $restored=(Test-CcodLegacyRegistryHandleExact -Entry $Entry -Handle $handle -Adapters $adapter)-and[bool](&$adapter.TestKeyAtPath $Entry.path $handle) + }catch{$restored=$false} + $state.status=if($restored){'SameHandleRestoredExact'}else{'PartialUnresolved'} + } + }finally{try{&$adapter.CloseKey $handle}catch{$closeFailure=$_}} + if($null-ne$closeFailure){$state.status='PartialUnresolved';throw $closeFailure} + if($null-ne$operationFailure){throw $operationFailure} + return + } + if($Entry.kind-cne'Shortcut'){throw 'unknown legacy snapshot entry'} + if(-not[IO.File]::Exists($Entry.path)-or(Get-CcodProductFileSha256 $Entry.path)-cne$Entry.sha256){throw 'legacy shortcut changed'} + Remove-Item -LiteralPath $Entry.path -Force -ErrorAction Stop +} +function Compare-CcodLegacySnapshotEntry { + param($Expected,$Current) + if($null-eq$Current){return 'Absent'} + if($null-eq$Expected-or$Expected.kind-cne$Current.kind){return 'Mismatch'};$pathMatches=if($Expected.kind-ceq'Registry'){[string]$Expected.path-ceq[string]$Current.path}else{[IO.Path]::GetFullPath([string]$Expected.path)-ceq[IO.Path]::GetFullPath([string]$Current.path)};if(-not$pathMatches){return 'Mismatch'} + if($Expected.kind-ceq'Registry'){ + $subkeys=@(if($null-ne$Current.PSObject.Properties['subKeyNames']){@($Current.subKeyNames)});$expectedNames=@(Get-CcodProductMapNames $Expected.values);$currentNames=@(Get-CcodProductMapNames $Current.values);if($subkeys.Count-ne0-or(@($expectedNames|Sort-Object)-join"`0")-cne(@($currentNames|Sort-Object)-join"`0")){return 'Mismatch'} + foreach($name in $expectedNames){$expectedValue=Get-CcodProductMapValue $Expected.values ([string]$name);$currentValue=Get-CcodProductMapValue $Current.values ([string]$name);if($null-eq$expectedValue-or$null-eq$currentValue-or[string]$expectedValue.kind-cne[string]$currentValue.kind-or($expectedValue.value|ConvertTo-Json -Compress)-cne($currentValue.value|ConvertTo-Json -Compress)){return 'Mismatch'}} + return 'Exact' + } + if($Expected.kind-ceq'Shortcut'-and$Expected.sha256-is[string]-and$Current.sha256-is[string]-and$Expected.sha256-ceq$Current.sha256){return 'Exact'} + return 'Mismatch' +} +function Read-CcodLegacySnapshotEntry { + param($Entry,[hashtable]$RegistryAdapters) + if($Entry-is[string]){return $null} + if($Entry.kind-ceq'Registry'){ + $state=Get-CcodLegacyRegistryMutationState $Entry + if($state.status-in@('SameHandleRestoredExact','CreatedExact')){return 'Exact'} + if($state.status-in@('PartialUnresolved','CreateBlocked','CreateUnresolved')){return 'Mismatch'} + $adapter=Get-CcodLegacyRegistryMutationAdapters $RegistryAdapters;$handle=&$adapter.OpenExisting $Entry.path + if($null-eq$handle){return $null} + try{ + if($state.status-ceq'Deleted'){return 'Mismatch'} + if((Test-CcodLegacyRegistryHandleExact -Entry $Entry -Handle $handle -Adapters $adapter)-and[bool](&$adapter.TestKeyAtPath $Entry.path $handle)){return 'Exact'} + return 'Mismatch' + }finally{&$adapter.CloseKey $handle} + } + if([IO.File]::Exists($Entry.path)){$current=[pscustomobject]@{kind='Shortcut';path=[string]$Entry.path;sha256=Get-CcodProductFileSha256 $Entry.path};return Compare-CcodLegacySnapshotEntry -Expected $Entry -Current $current};$null +} +function Restore-CcodLegacySnapshotEntry { + param($Entry,[hashtable]$RegistryAdapters) + if($Entry-is[string]){return} + if($Entry.kind-ceq'Registry'){ + $state=Get-CcodLegacyRegistryMutationState $Entry + if($state.status-cne'Deleted'){throw 'legacy registry is not eligible for path-level restoration'} + $adapter=Get-CcodLegacyRegistryMutationAdapters $RegistryAdapters;$created=$null;$createdNew=$false;$accepted=$false;$operationFailure=$null;$closeFailure=$null + try{ + $created=&$adapter.CreateKey $Entry.path + if($null-eq$created-or$created.disposition-cne'CreatedNew'){$state.status='CreateBlocked';throw 'legacy registry replacement won the create-only race'} + $createdNew=$true + foreach($name in @($Entry.values.Keys)){&$adapter.SetValue $created $name $Entry.values[$name].value $Entry.values[$name].kind} + if(-not(Test-CcodLegacyRegistryHandleExact -Entry $Entry -Handle $created -Adapters $adapter)-or-not(&$adapter.TestKeyAtPath $Entry.path $created)){throw 'created legacy registry did not re-read exactly'} + $state.status='CreatedExact';$accepted=$true + }catch{ + $operationFailure=$_ + if($createdNew-and-not$accepted){try{&$adapter.DeleteKey $created}catch{};$state.status='CreateUnresolved'} + }finally{if($null-ne$created){try{&$adapter.CloseKey $created}catch{$closeFailure=$_}}} + if($null-ne$closeFailure){$state.status='CreateUnresolved';throw $closeFailure} + if($null-ne$operationFailure){throw $operationFailure} + return + } + if($Entry.kind-cne'Shortcut'){throw 'unknown legacy snapshot entry'} + if([IO.File]::Exists($Entry.path)-or[IO.Directory]::Exists($Entry.path)){throw 'legacy shortcut replacement present'} + [IO.Directory]::CreateDirectory((Split-Path $Entry.path -Parent))|Out-Null;$bytes=[Convert]::FromBase64String([string]$Entry.bytesBase64);$stream=[IO.File]::Open($Entry.path,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::Read) + try{$stream.Write($bytes,0,$bytes.Length);$stream.Flush($true)}finally{$stream.Dispose()} + if((Get-CcodProductFileSha256 $Entry.path)-cne$Entry.sha256){throw 'legacy shortcut restore mismatch'} +} +function Write-CcodLegacyCompensationFailure {param($Record)$local=[Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData);$root=Join-Path $local 'CodexControlOtherDevices\state\legacy-registration-compensation';[IO.Directory]::CreateDirectory($root)|Out-Null;$path=Join-Path $root (([guid]::NewGuid().ToString('D'))+'.json');$bytes=[Text.UTF8Encoding]::new($false).GetBytes(($Record|ConvertTo-Json -Depth 8 -Compress)+"`n");$stream=[IO.File]::Open($path,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::Read);try{$stream.Write($bytes,0,$bytes.Length);$stream.Flush($true)}finally{$stream.Dispose()}} + +function Commit-CcodProductRegistration { + [CmdletBinding()] + param([Parameter(Mandatory)]$Registration,[Parameter(Mandatory)]$FileTransaction,[hashtable]$Adapters) + if ($null -eq $FileTransaction -or -not (Test-CcodProductRegistration -Registration $Registration -ExpectedRuntimeId ([string]$Registration.runtimeId) -ExpectedVersion $script:CcodProductVersion -ExpectedPackageSha256 ([string]$Registration.packageSha256))) { + Throw-CcodProductRegistrationError 'CCOD_PRODUCT_REGISTRATION_INVALID' 'Product registration contract is invalid.' $Registration + } + $adapter = Get-CcodProductRegistrationAdapters $Adapters + $ready = & $adapter.GetReadyProof $Registration + if (-not (Test-CcodProductReadyProof $ready $Registration)) { + Throw-CcodProductRegistrationError 'CCOD_PRODUCT_REGISTRATION_NOT_READY' 'Product registration requires the exact terminal Ready proof.' $Registration.runtimeId + } + try { + & $adapter.WriteProductRegistration $Registration $ready + & $adapter.WriteShortcut 'StartMenu' $Registration.startMenuShortcut $FileTransaction $ready + & $adapter.WriteShortcut 'Desktop' $Registration.desktopShortcut $FileTransaction $ready + $product = & $adapter.ReadProductRegistration $Registration + $startMenu = & $adapter.ReadShortcut 'StartMenu' $Registration.startMenuShortcut + $desktop = & $adapter.ReadShortcut 'Desktop' $Registration.desktopShortcut + if (-not (Test-CcodProductRegistration -Registration $product -ExpectedRuntimeId $Registration.runtimeId -ExpectedVersion $Registration.version -ExpectedPackageSha256 $Registration.packageSha256) -or + -not (Test-CcodProductShortcut $startMenu $Registration.startMenuShortcut) -or + -not (Test-CcodProductShortcut $desktop $Registration.desktopShortcut)) { + throw 'three-record read-back mismatch' + } + } catch { + Throw-CcodProductRegistrationError 'CCOD_PRODUCT_REGISTRATION_FAILED' 'Product registration write/read-back failed; legacy state was retained.' $Registration.runtimeId + } + return [pscustomobject][ordered]@{ + schemaVersion = 1; verified = $true; runtimeId = [string]$Registration.runtimeId; version = [string]$Registration.version + packageSha256 = [string]$Registration.packageSha256; productKeyName = [string]$Registration.productKeyName + shortcutNames = @([string]$Registration.startMenuShortcut.relativePath,[string]$Registration.desktopShortcut.relativePath) + } +} + +function Remove-CcodLegacyProductRegistration { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$ExpectedAppId, + [AllowNull()]$MigrationPlan, + [AllowNull()]$ExpectedCurrentProof, + [hashtable]$Adapters + ) + if ($ExpectedAppId -cne $script:CcodProductAppId) { + Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Legacy product migration requires the canonical AppId and a verified new registration.' $ExpectedAppId + } + if($null-eq$MigrationPlan){Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Legacy product migration requires a pre-registration capture plan.' $ExpectedAppId} + if($null-eq$ExpectedCurrentProof-or-not(Test-CcodCurrentVerifiedRegistration $ExpectedCurrentProof)){Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Legacy product migration requires the exact expected current proof.' $ExpectedAppId} + $adapter = Get-CcodProductRegistrationAdapters $Adapters + [void](Assert-CcodLegacyMigrationPlan -Plan $MigrationPlan -ExpectedAppId $ExpectedAppId -ExpectedInstallRoot ([string]$MigrationPlan.expectedInstallRoot)) + if(-not[bool]$MigrationPlan.legacyPresent){return} + $verified=&$adapter.ReadVerifiedRegistration + if(-not(Test-CcodCurrentVerifiedRegistrationUnchanged -Before $ExpectedCurrentProof -After $verified)){Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Legacy product migration requires an exact current three-record read-back proof.' $ExpectedAppId} + $profile=$MigrationPlan.profile;$snapshot=$MigrationPlan.snapshot + $observedLegacy=&$adapter.ReadLegacyRegistration $ExpectedAppId + if($null-ne$observedLegacy){$allowed=@($profile.shortcutNames)+@($script:CcodCurrentShortcutNames);$actual=@($observedLegacy.shortcutNames);if(-not(Test-CcodProductExactStringSet $actual $actual)-or@($actual|Where-Object{$allowed-cnotcontains[string]$_}).Count-ne0-or@($script:CcodCurrentShortcutNames|Where-Object{$actual-cnotcontains[string]$_}).Count-ne0-or@($observedLegacy.unsafeShortcutNames).Count-ne0-or$observedLegacy.appId-cne$MigrationPlan.legacyRegistration.appId-or$observedLegacy.displayVersion-cne$MigrationPlan.legacyRegistration.displayVersion-or$observedLegacy.installLocation-cne$MigrationPlan.legacyRegistration.installLocation-or$observedLegacy.uninstallString-cne$MigrationPlan.legacyRegistration.uninstallString){Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Captured legacy profile changed before cleanup.' $ExpectedAppId}} + $removalEntries=[Collections.Generic.List[object]]::new() + foreach($entry in @($snapshot.entries)){ + $name=Get-CcodLegacySnapshotEntryName -Entry $entry -ExpectedAppId $ExpectedAppId + if($script:CcodCurrentShortcutNames-ccontains$name){ + continue + } + $state=&$adapter.ReadLegacyEntry $entry + if($name-ceq'Registry'-and(($null-eq$observedLegacy)-ne($null-eq$state))){Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Legacy registry observations are inconsistent before cleanup.' $ExpectedAppId} + if($null-eq$state){continue} + if($state-cne'Exact'){Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Captured legacy-only state changed before deletion.' $name} + $removalEntries.Add($entry) + } + $removed=[Collections.Generic.List[object]]::new();$failedEntry=$null + try{ + foreach($entry in @($removalEntries)){$failedEntry=$entry;&$adapter.RemoveLegacyEntry $entry;$removed.Add($entry);$failedEntry=$null} + $verifiedAfter=&$adapter.ReadVerifiedRegistration + if(-not(Test-CcodCurrentVerifiedRegistrationUnchanged -Before $verified -After $verifiedAfter)){throw 'current product registration changed during legacy cleanup'} + }catch{ + $unresolved=[Collections.Generic.List[string]]::new() + if($null-ne$failedEntry){ + $failedName=Get-CcodLegacySnapshotEntryName -Entry $failedEntry -ExpectedAppId $ExpectedAppId + try{$state=&$adapter.ReadLegacyEntry $failedEntry;if($null-eq$state){&$adapter.RestoreLegacyEntry $failedEntry;$state=&$adapter.ReadLegacyEntry $failedEntry};if($state-cne'Exact'){$unresolved.Add($failedName)}}catch{$unresolved.Add($failedName)} + } + for($index=$removed.Count-1;$index-ge0;$index--){$entry=$removed[$index];$name=Get-CcodLegacySnapshotEntryName -Entry $entry -ExpectedAppId $ExpectedAppId;try{$state=&$adapter.ReadLegacyEntry $entry;if($null-eq$state){&$adapter.RestoreLegacyEntry $entry;$state=&$adapter.ReadLegacyEntry $entry};if($state-cne'Exact'){$unresolved.Add($name)}}catch{$unresolved.Add($name)}} + if($unresolved.Count-gt0){$record=[pscustomobject][ordered]@{schemaVersion=1;appId=$ExpectedAppId;state='CompensationFailed';entries=@($unresolved);createdAtUtc=[DateTime]::UtcNow.ToString('o',[Globalization.CultureInfo]::InvariantCulture)};try{&$adapter.WriteLegacyCompensationFailure $record}catch{Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_COMPENSATION_FAILED' 'Legacy compensation failure record could not be persisted.' $record};Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_COMPENSATION_FAILED' 'Legacy migration compensation remains unresolved; exact entries were recorded.' $record} + Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Legacy product migration failed and the exact prior set was restored.' $ExpectedAppId + } +} + +Export-ModuleMember -Function New-CcodProductRegistration,Test-CcodProductRegistration,Commit-CcodProductRegistration,Remove-CcodProductRegistration,Remove-CcodLegacyProductRegistration,Get-CcodLegacyProductRegistrationMigrationPlan,Get-CcodDurableLegacyProductRegistrationMigrationPlan,Get-CcodLegacyRegistrationProfiles,Resolve-CcodLegacyRegistrationProfile,Set-CcodInstalledUninstallResumeRegistration,Test-CcodInstalledUninstallResumeRegistration,Get-CcodInstalledUninstallResumeRegistrationState,Remove-CcodInstalledUninstallProductShortcuts,Remove-CcodInstalledUninstallResumeRegistration diff --git a/src/persistence/modules/RuntimeManifest.psm1 b/src/persistence/modules/RuntimeManifest.psm1 index 54e0d31..c425bde 100644 --- a/src/persistence/modules/RuntimeManifest.psm1 +++ b/src/persistence/modules/RuntimeManifest.psm1 @@ -27,7 +27,7 @@ function Get-CcodErrorId { function Assert-CcodRuntimeId { param([Parameter(Mandatory)][string]$RuntimeId) - if ($RuntimeId -notmatch '^[A-Za-z0-9._-]{1,96}$') { + if ($RuntimeId -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,95}\z') { Throw-CcodRuntimeError 'CCOD_RUNTIME_ID_INVALID' 'Runtime ID must be a safe relative directory name' $RuntimeId } return $RuntimeId @@ -38,14 +38,16 @@ function Get-CcodRuntimeAdapters { $resolved = @{ UtcNow = { [DateTime]::UtcNow } + GetSelectorRootItem = { param($Path) Get-Item -LiteralPath $Path -Force -ErrorAction Stop } AssertLifecycleFence = { param($InstallRoot, $Ownership, $ExpectActivePointer, $NewRuntimeId) - $path = Resolve-CcodContainedPath -Root $InstallRoot -RelativePath 'active.json' -AllowMissingLeaf if ([bool]$ExpectActivePointer) { - if (-not [IO.File]::Exists($path)) { Throw-CcodRuntimeError 'CCOD_RUNTIME_FENCE_STALE' 'Expected active pointer disappeared during lifecycle mutation' $path } + $selected=$null;try{$selected=Read-CcodActiveRuntime -InstallRoot $InstallRoot}catch{Throw-CcodRuntimeError 'CCOD_RUNTIME_FENCE_STALE' 'Expected active selector disappeared during lifecycle mutation' $InstallRoot} + if($null-eq$selected-or$selected.activeRuntime-cne$Ownership.runtimeId-or[uint64]$selected.generation-ne[uint64]$Ownership.runtimeGeneration){Throw-CcodRuntimeError 'CCOD_RUNTIME_FENCE_STALE' 'Append-only selected runtime does not match lifecycle ownership' $Ownership} return Assert-CcodLifecycleFence -InstallRoot $InstallRoot -Ownership $Ownership } - if ([IO.File]::Exists($path)) { Throw-CcodRuntimeError 'CCOD_RUNTIME_FENCE_STALE' 'Unexpected active pointer appeared during lifecycle initialization' $path } + try{$unexpected=Read-CcodActiveRuntime -InstallRoot $InstallRoot}catch{if((Get-CcodErrorId $_)-notin@('CCOD_STATE_MISSING','CCOD_PATH_MISSING')){throw};$unexpected=$null} + if ($null-ne$unexpected-or[IO.Directory]::Exists((Join-Path $InstallRoot 'state\active-generation'))-or[IO.File]::Exists((Join-Path $InstallRoot 'active.json'))) { Throw-CcodRuntimeError 'CCOD_RUNTIME_FENCE_STALE' 'Unexpected active selector appeared during lifecycle initialization' $InstallRoot } if ([UInt64]$Ownership.runtimeGeneration -ne 1 -or [string]$Ownership.runtimeId -cne [string]$NewRuntimeId) { Throw-CcodRuntimeError 'CCOD_RUNTIME_FENCE_STALE' 'Initial active pointer requires generation-one ownership of the new runtime' $Ownership } @@ -66,16 +68,16 @@ function Get-CcodRuntimeAdapters { } function ConvertTo-CcodRuntimeGeneration { - param([Parameter(Mandatory)]$Value, [Parameter(Mandatory)][string]$Path) + param([Parameter(Mandatory)]$Value, [Parameter(Mandatory)][string]$Path, [switch]$AllowZero) if ($Value -is [decimal]) { - if ($Value -lt 1 -or [decimal]::Truncate($Value) -ne $Value) { + if ($Value -lt $(if ($AllowZero) { 0 } else { 1 }) -or [decimal]::Truncate($Value) -ne $Value) { Throw-CcodRuntimeError 'CCOD_RUNTIME_GENERATION_INVALID' 'Active runtime generation must be a positive unsigned integer' $Path } try { return [UInt64]$Value } catch { Throw-CcodRuntimeError 'CCOD_RUNTIME_GENERATION_INVALID' 'Active runtime generation is outside the unsigned 64-bit range' $Path } } if ($Value -isnot [byte] -and $Value -isnot [uint16] -and $Value -isnot [uint32] -and $Value -isnot [uint64] -and - $Value -isnot [int16] -and $Value -isnot [int32] -and $Value -isnot [int64] -or $Value -lt 1) { + $Value -isnot [int16] -and $Value -isnot [int32] -and $Value -isnot [int64] -or $Value -lt $(if ($AllowZero) { 0 } else { 1 })) { Throw-CcodRuntimeError 'CCOD_RUNTIME_GENERATION_INVALID' 'Active runtime generation must be a positive unsigned integer' $Path } return [UInt64]$Value @@ -85,14 +87,34 @@ function Get-CcodRuntimeRoot { param([Parameter(Mandatory)][string]$RuntimeDirectory) $root = [IO.Path]::GetFullPath($RuntimeDirectory) - if (-not [IO.Directory]::Exists($root)) { + if (-not [string]::Equals($root, $RuntimeDirectory, [StringComparison]::OrdinalIgnoreCase)) { + Throw-CcodRuntimeError 'CCOD_RUNTIME_PATH_INVALID' 'Runtime directory must use a canonical absolute path' $RuntimeDirectory + } + try { + $item = Get-Item -LiteralPath $root -Force -ErrorAction Stop + } catch [Management.Automation.ItemNotFoundException] { + return $null + } catch [IO.FileNotFoundException] { + return $null + } catch [IO.DirectoryNotFoundException] { + return $null + } + if (-not $item.PSIsContainer) { return $null } - - $item = Get-Item -LiteralPath $root -Force -ErrorAction Stop if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { Throw-CcodRuntimeError 'CCOD_REPARSE_PATH' 'Runtime root is a reparse point' $root } + $current = [IO.Directory]::GetParent($root) + while ($null -ne $current) { + $ancestor = Get-Item -LiteralPath $current.FullName -Force -ErrorAction Stop + if (($ancestor.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + Throw-CcodRuntimeError 'CCOD_REPARSE_PATH' 'Runtime root has a reparse-point ancestor' $current.FullName + } + $pathRoot = [IO.Path]::GetPathRoot($current.FullName) + if ($current.FullName.TrimEnd('\\') -ceq $pathRoot.TrimEnd('\\')) { break } + $current = [IO.Directory]::GetParent($current.FullName) + } return $root } @@ -112,6 +134,56 @@ function Get-CcodRuntimeFileSha256 { } } +function Get-CcodRuntimeFileLinkCount { + param([Parameter(Mandatory)][string]$Path) + + if ($null -eq ('CcodRuntimeFileIdentity' -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +public static class CcodRuntimeFileIdentity +{ + [StructLayout(LayoutKind.Sequential)] + private struct FILETIME_NATIVE { public uint Low; public uint High; } + + [StructLayout(LayoutKind.Sequential)] + private struct FILE_INFO + { + public uint FileAttributes; + public FILETIME_NATIVE CreationTime; + public FILETIME_NATIVE LastAccessTime; + public FILETIME_NATIVE LastWriteTime; + public uint VolumeSerialNumber; + public uint FileSizeHigh; + public uint FileSizeLow; + public uint NumberOfLinks; + public uint FileIndexHigh; + public uint FileIndexLow; + } + + [DllImport("kernel32.dll", SetLastError=true)] + private static extern bool GetFileInformationByHandle(SafeFileHandle handle, out FILE_INFO info); + + public static uint Links(string path) + { + using (FileStream stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read)) + { + FILE_INFO info; + if (!GetFileInformationByHandle(stream.SafeFileHandle, out info)) + throw new Win32Exception(Marshal.GetLastWin32Error()); + return info.NumberOfLinks; + } + } +} +'@ + } + return [CcodRuntimeFileIdentity]::Links($Path) +} + function ConvertTo-CcodRuntimeRelativePath { param( [Parameter(Mandatory)][string]$Root, @@ -122,15 +194,23 @@ function ConvertTo-CcodRuntimeRelativePath { if (-not $FullName.StartsWith($rootPrefix, [StringComparison]::OrdinalIgnoreCase)) { Throw-CcodRuntimeError 'CCOD_PATH_OUTSIDE_ROOT' 'Runtime file is outside its runtime root' $FullName } - return $FullName.Substring($rootPrefix.Length).Replace('\', '/') + $relative = $FullName.Substring($rootPrefix.Length).Replace('\', '/') + return Assert-CcodManifestRelativePath -Path $relative } function Assert-CcodManifestRelativePath { param([Parameter(Mandatory)][string]$Path) + foreach ($character in $Path.ToCharArray()) { + if ([char]::IsControl($character) -or + [Globalization.CharUnicodeInfo]::GetUnicodeCategory($character) -eq [Globalization.UnicodeCategory]::Format) { + Throw-CcodRuntimeError 'CCOD_PATH_OUTSIDE_ROOT' 'Manifest file path contains a control or format character' $Path + } + } if ([IO.Path]::IsPathRooted($Path) -or $Path.StartsWith('/') -or $Path.IndexOf('\') -ge 0 -or + $Path.IndexOf(':') -ge 0 -or $Path -match '(^|/)(\.|\.\.)(/|$)' -or $Path.Contains('//')) { Throw-CcodRuntimeError 'CCOD_PATH_OUTSIDE_ROOT' 'Manifest file path is not a safe relative path' $Path @@ -154,7 +234,10 @@ function Get-CcodRuntimeFileRecords { if (-not $item.PSIsContainer) { $relative = ConvertTo-CcodRuntimeRelativePath -Root $root -FullName $item.FullName if (-not $relative.Equals('manifest.json', [StringComparison]::OrdinalIgnoreCase)) { - $records.Add([pscustomobject]@{ + if ((Get-CcodRuntimeFileLinkCount -Path $item.FullName) -ne 1) { + Throw-CcodRuntimeError 'CCOD_RUNTIME_FILE_INVALID' 'Runtime payload file must be a single-link regular file' $item.FullName + } + $records.Add([pscustomobject][ordered]@{ path = $relative length = [int64]$item.Length sha256 = Get-CcodRuntimeFileSha256 -Path $item.FullName @@ -175,12 +258,17 @@ function Get-CcodRuntimeId { [CmdletBinding()] param( [Parameter(Mandatory)][string]$ProjectVersion, - [Parameter(Mandatory)][object[]]$Files + [Parameter(Mandatory)][object[]]$Files, + [string]$Nonce = ([guid]::NewGuid().ToString('N')) ) + if($ProjectVersion-cnotmatch'^[A-Za-z0-9][A-Za-z0-9._-]{0,45}\z'-or$Nonce-cnotmatch'^[0-9a-f]{32}\z'){ + Throw-CcodRuntimeError 'CCOD_RUNTIME_ID_INVALID' 'Runtime identity components are invalid' $ProjectVersion + } + $lines = [System.Collections.Generic.List[string]]::new() foreach ($file in $Files) { - $lines.Add(('{0}`t{1}`t{2}' -f [string]$file.path, [int64]$file.length, [string]$file.sha256)) + $lines.Add(("{0}`t{1}`t{2}" -f [string]$file.path, [int64]$file.length, [string]$file.sha256)) } $canonical = $lines -join "`n" $sha256 = [Security.Cryptography.SHA256]::Create() @@ -189,21 +277,28 @@ function Get-CcodRuntimeId { } finally { $sha256.Dispose() } - return Assert-CcodRuntimeId -RuntimeId ('{0}-{1}' -f $ProjectVersion, $digest.Substring(0, 16)) + return Assert-CcodRuntimeId -RuntimeId ('{0}-{1}-{2}' -f $ProjectVersion, $digest.Substring(0, 16), $Nonce) +} + +function Test-CcodRuntimeIdBinding { + param([Parameter(Mandatory)][string]$RuntimeId,[Parameter(Mandatory)][string]$ProjectVersion,[Parameter(Mandatory)][object[]]$Files) + if($RuntimeId-cnotmatch'^(?[A-Za-z0-9][A-Za-z0-9._-]{0,45})-(?[0-9a-f]{16})-(?[0-9a-f]{32})\z'-or$Matches.version-cne$ProjectVersion){return $false} + return (Get-CcodRuntimeId -ProjectVersion $ProjectVersion -Files $Files -Nonce $Matches.nonce)-ceq$RuntimeId } function New-CcodRuntimeManifest { [CmdletBinding()] param( [Parameter(Mandatory)][string]$RuntimeDirectory, - [Parameter(Mandatory)][string]$ProjectVersion + [Parameter(Mandatory)][string]$ProjectVersion, + [string]$RuntimeId ) $files = @(Get-CcodRuntimeFileRecords -RuntimeDirectory $RuntimeDirectory) return [pscustomobject][ordered]@{ schemaVersion = 1 projectVersion = $ProjectVersion - runtimeId = Get-CcodRuntimeId -ProjectVersion $ProjectVersion -Files $files + runtimeId = if ([string]::IsNullOrWhiteSpace($RuntimeId)) { Get-CcodRuntimeId -ProjectVersion $ProjectVersion -Files $files } elseif(Test-CcodRuntimeIdBinding -RuntimeId (Assert-CcodRuntimeId $RuntimeId) -ProjectVersion $ProjectVersion -Files $files){$RuntimeId}else{Throw-CcodRuntimeError 'CCOD_RUNTIME_ID_MISMATCH' 'Runtime ID is not bound to the manifest file set and project version' $RuntimeId} files = $files } } @@ -228,7 +323,8 @@ function Test-CcodRuntimeManifest { [CmdletBinding()] param( [Parameter(Mandatory)][string]$RuntimeDirectory, - [Parameter(Mandatory)][string]$ExpectedRuntimeId + [Parameter(Mandatory)][string]$ExpectedRuntimeId, + [string]$ExpectedManifestSha256 ) Assert-CcodRuntimeId -RuntimeId $ExpectedRuntimeId | Out-Null @@ -238,8 +334,23 @@ function Test-CcodRuntimeManifest { } $manifestPath = Resolve-CcodContainedPath -Root $root -RelativePath 'manifest.json' -AllowMissingLeaf - if (-not [IO.File]::Exists($manifestPath)) { + $manifestItem = $null + try { + $manifestItem = Get-Item -LiteralPath $manifestPath -Force -ErrorAction Stop + } catch [Management.Automation.ItemNotFoundException] { + return New-CcodRuntimeValidationResult -Valid $false -Code 'CCOD_RUNTIME_MANIFEST_MISSING' -RuntimeId $ExpectedRuntimeId -Manifest $null + } catch [IO.FileNotFoundException] { + return New-CcodRuntimeValidationResult -Valid $false -Code 'CCOD_RUNTIME_MANIFEST_MISSING' -RuntimeId $ExpectedRuntimeId -Manifest $null + } catch [IO.DirectoryNotFoundException] { return New-CcodRuntimeValidationResult -Valid $false -Code 'CCOD_RUNTIME_MANIFEST_MISSING' -RuntimeId $ExpectedRuntimeId -Manifest $null + } catch { + return New-CcodRuntimeValidationResult -Valid $false -Code 'CCOD_RUNTIME_MANIFEST_UNAVAILABLE' -RuntimeId $ExpectedRuntimeId -Manifest $null + } + if ($null -eq $manifestItem -or $manifestItem.PSIsContainer -or (($manifestItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) { + return New-CcodRuntimeValidationResult -Valid $false -Code 'CCOD_RUNTIME_MANIFEST_INVALID' -RuntimeId $ExpectedRuntimeId -Manifest $null + } + if(-not[string]::IsNullOrWhiteSpace($ExpectedManifestSha256)){ + if($ExpectedManifestSha256-cnotmatch'^[0-9a-f]{64}\z'-or(Get-CcodRuntimeFileSha256 -Path $manifestPath)-cne$ExpectedManifestSha256){return New-CcodRuntimeValidationResult -Valid $false -Code 'CCOD_RUNTIME_MANIFEST_HASH_MISMATCH' -RuntimeId $ExpectedRuntimeId -Manifest $null} } try { @@ -248,43 +359,51 @@ function Test-CcodRuntimeManifest { return New-CcodRuntimeValidationResult -Valid $false -Code (Get-CcodErrorId -ErrorRecord $_) -RuntimeId $ExpectedRuntimeId -Manifest $null } + $schemaVersionProperty = $manifest.PSObject.Properties['schemaVersion'] + $schemaIntegerTypes = @([byte],[uint16],[uint32],[uint64],[sbyte],[int16],[int32],[int64]) + $schemaVersionIsInteger = $false + foreach ($type in $schemaIntegerTypes) { if ($null -ne $schemaVersionProperty -and $schemaVersionProperty.Value -is $type) { $schemaVersionIsInteger = $true; break } } $runtimeIdProperty = $manifest.PSObject.Properties['runtimeId'] $projectVersionProperty = $manifest.PSObject.Properties['projectVersion'] $filesProperty = $manifest.PSObject.Properties['files'] - if ($null -eq $runtimeIdProperty -or $null -eq $projectVersionProperty -or $null -eq $filesProperty -or - $runtimeIdProperty.Value -isnot [string] -or $projectVersionProperty.Value -isnot [string] -or $null -eq $filesProperty.Value) { + $propertyNames = (@($manifest.PSObject.Properties.Name) -join ',') + $filesIsArray = $false + if ($null -ne $filesProperty -and $null -ne $filesProperty.Value) { $filesIsArray = $filesProperty.Value.GetType().IsArray } + if ($propertyNames -cne 'schemaVersion,projectVersion,runtimeId,files' -or + -not $schemaVersionIsInteger -or $null -eq $schemaVersionProperty -or $schemaVersionProperty.Value -ne 1 -or + $null -eq $runtimeIdProperty -or $null -eq $projectVersionProperty -or $null -eq $filesProperty -or + $runtimeIdProperty.Value -isnot [string] -or $projectVersionProperty.Value -isnot [string] -or -not $filesIsArray) { return New-CcodRuntimeValidationResult -Valid $false -Code 'CCOD_RUNTIME_MANIFEST_INVALID' -RuntimeId $ExpectedRuntimeId -Manifest $manifest } $manifestRuntimeId = [string]$runtimeIdProperty.Value - if ($manifestRuntimeId -notmatch '^[A-Za-z0-9._-]{1,96}$' -or $manifestRuntimeId -cne $ExpectedRuntimeId) { + if ($manifestRuntimeId -notmatch '^[A-Za-z0-9._-]{1,96}\z' -or $manifestRuntimeId -cne $ExpectedRuntimeId) { return New-CcodRuntimeValidationResult -Valid $false -Code 'CCOD_RUNTIME_ID_MISMATCH' -RuntimeId $manifestRuntimeId -Manifest $manifest } - $manifestFiles = @($filesProperty.Value) $previousPath = $null + $seenPaths = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + $integerTypes = @([byte],[uint16],[uint32],[uint64],[sbyte],[int16],[int32],[int64]) $manifestRecords = [System.Collections.Generic.List[object]]::new() - foreach ($file in $manifestFiles) { - if ($file -isnot [pscustomobject] -or - $null -eq $file.PSObject.Properties['path'] -or - $null -eq $file.PSObject.Properties['length'] -or - $null -eq $file.PSObject.Properties['sha256'] -or - $file.path -isnot [string] -or - $file.sha256 -isnot [string]) { + foreach ($file in @($filesProperty.Value)) { + if ($file -isnot [pscustomobject] -or (@($file.PSObject.Properties.Name) -join ',' -cne 'path,length,sha256')) { return New-CcodRuntimeValidationResult -Valid $false -Code 'CCOD_RUNTIME_MANIFEST_INVALID' -RuntimeId $manifestRuntimeId -Manifest $manifest } - - Assert-CcodManifestRelativePath -Path $file.path | Out-Null - if ($file.path.Equals('manifest.json', [StringComparison]::OrdinalIgnoreCase) -or - $file.sha256 -cnotmatch '^[0-9a-f]{64}$') { + $lengthTypeIsValid = $false + foreach ($type in $integerTypes) { if ($file.length -is $type) { $lengthTypeIsValid = $true; break } } + if (-not $lengthTypeIsValid -or $file.length -is [bool] -or + $file.path -isnot [string] -or [string]::IsNullOrWhiteSpace($file.path) -or + $file.sha256 -isnot [string] -or $file.sha256 -cnotmatch '^[0-9a-f]{64}\z') { return New-CcodRuntimeValidationResult -Valid $false -Code 'CCOD_RUNTIME_MANIFEST_INVALID' -RuntimeId $manifestRuntimeId -Manifest $manifest } - try { - $length = [Convert]::ToInt64($file.length, [Globalization.CultureInfo]::InvariantCulture) - } catch { + Assert-CcodManifestRelativePath -Path $file.path | Out-Null + try { $length = [int64]$file.length } catch { return New-CcodRuntimeValidationResult -Valid $false -Code 'CCOD_RUNTIME_MANIFEST_INVALID' -RuntimeId $manifestRuntimeId -Manifest $manifest } - if ($length -lt 0 -or ($null -ne $previousPath -and [StringComparer]::Ordinal.Compare($previousPath, [string]$file.path) -ge 0)) { + if ($length -lt 0 -or [IO.Path]::IsPathRooted($file.path) -or $file.path.Contains(':') -or + $file.path.Equals('manifest.json', [StringComparison]::OrdinalIgnoreCase) -or + -not $seenPaths.Add([string]$file.path) -or + ($null -ne $previousPath -and [StringComparer]::Ordinal.Compare($previousPath, [string]$file.path) -ge 0)) { return New-CcodRuntimeValidationResult -Valid $false -Code 'CCOD_RUNTIME_MANIFEST_INVALID' -RuntimeId $manifestRuntimeId -Manifest $manifest } $previousPath = [string]$file.path @@ -309,8 +428,7 @@ function Test-CcodRuntimeManifest { } } - $computedRuntimeId = Get-CcodRuntimeId -ProjectVersion ([string]$projectVersionProperty.Value) -Files $actualFiles - if ($computedRuntimeId -cne $manifestRuntimeId) { + if(-not(Test-CcodRuntimeIdBinding -RuntimeId $manifestRuntimeId -ProjectVersion ([string]$projectVersionProperty.Value) -Files $actualFiles)){ return New-CcodRuntimeValidationResult -Valid $false -Code 'CCOD_RUNTIME_ID_MISMATCH' -RuntimeId $manifestRuntimeId -Manifest $manifest } return New-CcodRuntimeValidationResult -Valid $true -Code 'CCOD_RUNTIME_VALID' -RuntimeId $manifestRuntimeId -Manifest $manifest @@ -326,13 +444,69 @@ function Get-CcodRuntimeDirectoryForId { return Resolve-CcodContainedPath -Root $InstallRoot -RelativePath (Join-Path 'runtime' $RuntimeId) -AllowMissingLeaf } -function Read-CcodActiveRuntime { - [CmdletBinding()] - param([Parameter(Mandatory)][string]$InstallRoot) +function Test-CcodRuntimeSchemaVersion { + param($Value,[Parameter(Mandatory)][int]$Expected) + $integerTypes=@([byte],[uint16],[uint32],[uint64],[sbyte],[int16],[int32],[int64]) + $typed=$false + foreach($type in $integerTypes){if($Value-is$type){$typed=$true;break}} + return $typed -and [int64]$Value -eq [int64]$Expected +} +function Test-CcodRuntimeCanonicalUtcTimestamp { + param($Value) + if($Value-isnot[string]-or$Value-cnotmatch'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{7}Z\z'){return $false} + try{$parsed=[DateTime]::ParseExact($Value,'o',[Globalization.CultureInfo]::InvariantCulture,[Globalization.DateTimeStyles]::RoundtripKind);return $parsed.Kind-eq[DateTimeKind]::Utc}catch{return $false} +} + +function Assert-CcodRuntimePointerFile { + param([Parameter(Mandatory)][string]$Path) + try{ + $item=Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if($item.PSIsContainer-or($item.Attributes-band[IO.FileAttributes]::ReparsePoint)-ne0){throw 'not plain'} + $streams=@(Get-Item -LiteralPath $Path -Stream * -ErrorAction Stop);if($streams.Count-ne1-or[string]$streams[0].Stream-cne':$DATA'){throw 'stream'} + if($null-eq('CcodRuntimePointerIdentity' -as[type])){Add-Type -TypeDefinition @' +using System; using System.ComponentModel; using System.IO; using System.Runtime.InteropServices; using Microsoft.Win32.SafeHandles; +public static class CcodRuntimePointerIdentity { [StructLayout(LayoutKind.Sequential)] struct Info { public uint A; public System.Runtime.InteropServices.ComTypes.FILETIME C,X,W; public uint V,H,L,N,Ih,Il; } [DllImport("kernel32.dll",SetLastError=true)] static extern bool GetFileInformationByHandle(SafeFileHandle h,out Info i); public static uint Links(string p){using(FileStream s=new FileStream(p,FileMode.Open,FileAccess.Read,FileShare.Read)){Info i;if(!GetFileInformationByHandle(s.SafeFileHandle,out i))throw new Win32Exception(Marshal.GetLastWin32Error());return i.N;}} } +'@} + if([CcodRuntimePointerIdentity]::Links($Path)-ne1){throw 'links'} + }catch{Throw-CcodRuntimeError 'CCOD_RUNTIME_POINTER_INVALID' 'Active generation selector is not a plain single-link file' $Path} +} + +function Read-CcodActiveRuntimeCore { + [CmdletBinding()] + param([Parameter(Mandatory)][string]$InstallRoot,[hashtable]$Adapters) + + try{$stateRoot=Resolve-CcodContainedPath -Root $InstallRoot -RelativePath 'state' -AllowMissingLeaf;$pointerRoot=Resolve-CcodContainedPath -Root $InstallRoot -RelativePath 'state\active-generation' -AllowMissingLeaf}catch{Throw-CcodRuntimeError 'CCOD_RUNTIME_POINTER_INVALID' 'Active generation selector path is unsafe' $InstallRoot} + $selectorRootItem=$null;$selectorRootAbsent=$false;$selectorRootReader=if($null-ne$Adapters-and$Adapters.ContainsKey('GetSelectorRootItem')){$Adapters.GetSelectorRootItem}else{{param($Path)Get-Item -LiteralPath $Path -Force -ErrorAction Stop}} + $stateRootItem=$null;$stateRootAbsent=$false + try{$stateRootItem=&$selectorRootReader $stateRoot;if($null-eq$stateRootItem){throw [IO.InvalidDataException]::new('state lookup returned no proof')}}catch [Management.Automation.ItemNotFoundException]{$stateRootAbsent=$true}catch{Throw-CcodRuntimeError 'CCOD_RUNTIME_POINTER_INVALID' 'Active generation state ancestor lookup failed' $stateRoot} + if(-not$stateRootAbsent){if(-not$stateRootItem.PSIsContainer-or($stateRootItem.Attributes-band[IO.FileAttributes]::ReparsePoint)-ne0){Throw-CcodRuntimeError 'CCOD_RUNTIME_POINTER_INVALID' 'Active generation state ancestor is not a plain directory' $stateRoot}} + if($stateRootAbsent){$selectorRootAbsent=$true}else{try{$selectorRootItem=&$selectorRootReader $pointerRoot;if($null-eq$selectorRootItem){throw [IO.InvalidDataException]::new('selector lookup returned no proof')}}catch [Management.Automation.ItemNotFoundException]{$selectorRootAbsent=$true}catch{Throw-CcodRuntimeError 'CCOD_RUNTIME_POINTER_INVALID' 'Active generation selector root lookup failed' $pointerRoot}} + if (-not$selectorRootAbsent) { + if(-not$selectorRootItem.PSIsContainer-or($selectorRootItem.Attributes-band[IO.FileAttributes]::ReparsePoint)-ne0){Throw-CcodRuntimeError 'CCOD_RUNTIME_POINTER_INVALID' 'Active generation selector root is not a plain directory' $pointerRoot} + $entries=@(Get-ChildItem -LiteralPath $pointerRoot -Force -ErrorAction Stop) + if($entries.Count-eq 0){Throw-CcodRuntimeError 'CCOD_RUNTIME_POINTER_INVALID' 'Active generation store is empty' $pointerRoot} + $records=[Collections.Generic.List[object]]::new() + foreach($entry in $entries){ + if($entry.PSIsContainer-or($entry.Attributes-band[IO.FileAttributes]::ReparsePoint)-ne0-or$entry.Name-cnotmatch'^\d{20}\.json\z'){Throw-CcodRuntimeError 'CCOD_RUNTIME_POINTER_INVALID' 'Active generation store contains an unknown object' $entry.FullName} + Assert-CcodRuntimePointerFile -Path $entry.FullName + $record=Read-CcodStrictJson -Path $entry.FullName -ExpectedSchema 1 -Kind 'active generation' + $names=@($record.PSObject.Properties.Name);if(($names-join',')-cne'schemaVersion,generation,activeRuntime,previousGeneration'-or$record.activeRuntime-isnot[string]-or-not(Test-CcodRuntimeSchemaVersion $record.schemaVersion 1)){Throw-CcodRuntimeError 'CCOD_RUNTIME_POINTER_INVALID' 'Active generation record fields are invalid' $entry.FullName} + $generation=ConvertTo-CcodRuntimeGeneration $record.generation $entry.FullName + try{$previous=ConvertTo-CcodRuntimeGeneration -Value $record.previousGeneration -Path $entry.FullName -AllowZero}catch{Throw-CcodRuntimeError 'CCOD_RUNTIME_POINTER_INVALID' 'Previous generation is not a nonnegative integer' $entry.FullName};if($generation-ne($previous+1)-or$entry.Name-cne('{0:D20}.json'-f$generation)){Throw-CcodRuntimeError 'CCOD_RUNTIME_POINTER_INVALID' 'Active generation record is not canonical' $entry.FullName} + Assert-CcodRuntimeId $record.activeRuntime|Out-Null;$records.Add([pscustomobject]@{generation=$generation;previousGeneration=$previous;activeRuntime=[string]$record.activeRuntime}) + } + $ordered=@($records|Sort-Object generation);for($i=0;$i-lt$ordered.Count;$i++){if([uint64]$ordered[$i].generation-ne[uint64]($i+1)-or[uint64]$ordered[$i].previousGeneration-ne[uint64]$i){Throw-CcodRuntimeError 'CCOD_RUNTIME_POINTER_INVALID' 'Active generation chain has a gap' $pointerRoot}} + $latest=$ordered[-1];$previousRuntime=if($latest.previousGeneration-gt0){[string]$ordered[[int]$latest.previousGeneration-1].activeRuntime}else{$null} + return [pscustomobject][ordered]@{schemaVersion=2;activeRuntime=$latest.activeRuntime;previousRuntime=$previousRuntime;generation=[uint64]$latest.generation;updatedAtUtc=$selectorRootItem.LastWriteTimeUtc.ToString('o')} + } $path = Resolve-CcodContainedPath -Root $InstallRoot -RelativePath 'active.json' -AllowMissingLeaf $active = $null $legacy = $false + $legacyItem = $null + try { $legacyItem = Get-Item -LiteralPath $path -Force -ErrorAction Stop } catch { if ($_.CategoryInfo.Category -eq 'ObjectNotFound' -or $_.Exception -is [IO.FileNotFoundException] -or $_.Exception -is [IO.DirectoryNotFoundException] -or $_.Exception -is [Management.Automation.ItemNotFoundException]) { Throw-CcodRuntimeError 'CCOD_PATH_MISSING' 'Active runtime pointer is not present' $path }; throw } + if ($null -eq $legacyItem) { Throw-CcodRuntimeError 'CCOD_PATH_MISSING' 'Active runtime pointer is not present' $path } + Assert-CcodRuntimePointerFile -Path $legacyItem.FullName try { $active = Read-CcodStrictJson -Path $path -ExpectedSchema 2 -Kind 'active runtime' } catch { @@ -354,6 +528,10 @@ function Read-CcodActiveRuntime { Throw-CcodRuntimeError 'CCOD_RUNTIME_ID_INVALID' 'Active runtime ID must be a string' $path } Assert-CcodRuntimeId -RuntimeId $active.activeRuntime | Out-Null + $expectedSchemaVersion = if ($legacy) { 1 } else { 2 } + if (-not (Test-CcodRuntimeSchemaVersion $active.schemaVersion $expectedSchemaVersion) -or -not (Test-CcodRuntimeCanonicalUtcTimestamp $active.updatedAtUtc)) { + Throw-CcodRuntimeError 'CCOD_RUNTIME_POINTER_INVALID' 'Active runtime pointer schema or UTC timestamp is invalid' $path + } if ($null -ne $active.previousRuntime) { if ($active.previousRuntime -isnot [string]) { Throw-CcodRuntimeError 'CCOD_RUNTIME_ID_INVALID' 'Previous runtime ID must be null or a string' $path @@ -400,11 +578,13 @@ function Resolve-CcodActiveRuntimeContext { } } -function Set-CcodActiveRuntime { +function Set-CcodActiveRuntimeCore { [CmdletBinding()] param( [Parameter(Mandatory)][string]$InstallRoot, - [Parameter(Mandatory)][string]$NewRuntimeId, + [string]$NewRuntimeId, + $TargetGeneration, + $FileTransaction, $Ownership, [hashtable]$Adapters ) @@ -413,6 +593,15 @@ function Set-CcodActiveRuntime { if ($null -eq $Ownership) { Throw-CcodRuntimeError 'CCOD_RUNTIME_FENCE_REQUIRED' 'Active runtime mutation requires proven lifecycle ownership' $InstallRoot } + if($null-ne$FileTransaction-or$null-ne$TargetGeneration){ + if($null-eq(Get-Command Commit-CcodInstallActivePointer -ErrorAction SilentlyContinue)){Import-Module (Join-Path $PSScriptRoot 'InstallFileTransaction.psm1') -ErrorAction Stop} + if($null-eq$FileTransaction-or$null-eq$TargetGeneration){Throw-CcodRuntimeError 'CCOD_RUNTIME_POINTER_INVALID' 'Pointer commit requires target and transaction' $InstallRoot} + $current=$null;try{$current=Read-CcodActiveRuntimeCore -InstallRoot $InstallRoot -Adapters @{GetSelectorRootItem=$Adapters.GetSelectorRootItem}}catch{if((Get-CcodErrorId $_)-notin@('CCOD_STATE_MISSING','CCOD_PATH_MISSING')){throw}} + [uint64]$previous=if($null-eq$current){0}else{$current.generation} + try{[void](& $Adapters.AssertLifecycleFence $InstallRoot $Ownership ($null-ne$current) $NewRuntimeId)}catch{Throw-CcodRuntimeError 'CCOD_RUNTIME_FENCE_STALE' 'Active runtime mutation lifecycle fence is stale' $InstallRoot} + $committed=Commit-CcodInstallActivePointer -InstallRoot $InstallRoot -TargetGeneration $TargetGeneration -ExpectedPreviousGeneration $previous -FileTransaction $FileTransaction + return Read-CcodActiveRuntime -InstallRoot $InstallRoot + } Assert-CcodRuntimeId -RuntimeId $NewRuntimeId | Out-Null $runtimeDirectory = Get-CcodRuntimeDirectoryForId -InstallRoot $InstallRoot -RuntimeId $NewRuntimeId $validation = Test-CcodRuntimeManifest -RuntimeDirectory $runtimeDirectory -ExpectedRuntimeId $NewRuntimeId @@ -451,4 +640,22 @@ function Set-CcodActiveRuntime { return [pscustomobject]$pointer } +function Set-CcodActiveRuntime { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$InstallRoot, + [string]$NewRuntimeId, + $TargetGeneration, + $FileTransaction, + $Ownership + ) + return Set-CcodActiveRuntimeCore -InstallRoot $InstallRoot -NewRuntimeId $NewRuntimeId -TargetGeneration $TargetGeneration -FileTransaction $FileTransaction -Ownership $Ownership +} + +function Read-CcodActiveRuntime { + [CmdletBinding()] + param([Parameter(Mandatory)][string]$InstallRoot) + return Read-CcodActiveRuntimeCore -InstallRoot $InstallRoot +} + Export-ModuleMember -Function Get-CcodRuntimeId, New-CcodRuntimeManifest, Test-CcodRuntimeManifest, Read-CcodActiveRuntime, Resolve-CcodActiveRuntimeContext, Set-CcodActiveRuntime diff --git a/src/persistence/modules/SessionEngine.psm1 b/src/persistence/modules/SessionEngine.psm1 index 35440fd..61f1b65 100644 --- a/src/persistence/modules/SessionEngine.psm1 +++ b/src/persistence/modules/SessionEngine.psm1 @@ -457,9 +457,18 @@ function Get-CcodTreeDepth { return $depth } +function Get-CcodStableSessionProcessTree { + param($Root,$StatusEvidence,[hashtable]$Adapter) + return @(Get-CcodStableVerifiedProcessTree -Root $Root -StatusEvidence $StatusEvidence -RetryBudget 1 -Adapters @{ + GetVerifiedTree=$Adapter.GetTree + GetProcess=$Adapter.GetProcess + Delay=$Adapter.Delay + }) +} + function Get-CcodChildFirstVerifiedTree { param($Root,$StatusEvidence,[hashtable]$Adapter) - $tree=@(& $Adapter.GetTree $Root $StatusEvidence) + $tree=@(Get-CcodStableSessionProcessTree $Root $StatusEvidence $Adapter) if($tree.Count -lt 1){Throw-CcodSessionError 'CCOD_RECOVERY_UNPROVEN' 'Recorded special tree could not be verified' $Root} $byPid=@{};foreach($member in $tree){$byPid[[int]$member.Pid]=$member} if(-not $byPid.ContainsKey([int]$Root.Pid)){Throw-CcodSessionError 'CCOD_RECOVERY_UNPROVEN' 'Verified tree omitted the recorded root' $Root} @@ -1366,7 +1375,7 @@ function Invoke-CcodCloseSession { if($null -eq $target -or -not (& $adapter.ProcessMatch $Request.source $target)){Throw-CcodSessionError 'CCOD_CLOSE_UNPROVEN' 'Requested close source is not the one verified current root' $Request.source} } if($null -eq $target){$result.ok=$true;$result.outcome='Closed';$result.safeState='Closed';$result.stage='Closed';return $result} - $tree=@(& $adapter.GetTree $target $state.Status) + $tree=@(Get-CcodStableSessionProcessTree $target $state.Status $adapter) if($tree.Count -lt 1){Throw-CcodSessionError 'CCOD_CLOSE_UNPROVEN' 'Current close target tree is not exact and verified' $target} $renderer=$null;$main=$null;$source=$target if($isSpecial){if($null -ne $statusCodex){$renderer=$statusCodex.rendererPort;$main=$statusCodex.mainPort}else{$renderer=$target.RendererPort;$main=$target.MainPort};$source=$null;$result.special=ConvertTo-CcodSessionSpecial $target}else{$result.source=ConvertTo-CcodSessionSource $target} @@ -1449,7 +1458,7 @@ function Invoke-CcodRecoverSession { } } if($null -eq $target){$result.ok=$true;$result.outcome='Closed';$result.safeState='Closed';$result.stage='Closed';return $result} - $tree=@(& $adapter.GetTree $target $state.Status) + $tree=@(Get-CcodStableSessionProcessTree $target $state.Status $adapter) if($tree.Count -lt 1){Throw-CcodSessionError 'CCOD_CLOSE_UNPROVEN' 'Current close target tree is not exact and verified' $target} $renderer=$null;$main=$null;$source=$target if($isSpecial){if($null -ne $statusCodex){$renderer=$statusCodex.rendererPort;$main=$statusCodex.mainPort}else{$renderer=$target.RendererPort;$main=$target.MainPort};$source=$null;$result.special=ConvertTo-CcodSessionSpecial $target}else{$result.source=ConvertTo-CcodSessionSource $target} @@ -1531,7 +1540,7 @@ function Invoke-CcodReplayTransition { $decision=Get-CcodReplayDecision -Transition $Transition -Observed $cold Throw-CcodSessionError 'CCOD_CLOSE_UNPROVEN' 'Cold close replay cannot prove absence of the complete recorded tree' $decision } - $tree=@(& $adapter.GetTree $current $state.Status) + $tree=@(Get-CcodStableSessionProcessTree $current $state.Status $adapter) if($tree.Count -lt 1){Throw-CcodSessionError 'CCOD_CLOSE_UNPROVEN' 'Recorded close root does not yield a verified tree' $current} if($null -ne $Transition.specialPid){ $fact=[pscustomobject][ordered]@{Process=$current;Evidence='PersistedIdentity';Validation='Indeterminate'} diff --git a/src/persistence/modules/StateStore.psm1 b/src/persistence/modules/StateStore.psm1 index a24a49a..aed4eb4 100644 --- a/src/persistence/modules/StateStore.psm1 +++ b/src/persistence/modules/StateStore.psm1 @@ -382,14 +382,15 @@ function Write-CcodTypedState { [Parameter(Mandatory)][string]$Leaf, [Parameter(Mandatory)]$Value, [Parameter(Mandatory)][scriptblock]$Validator, - [hashtable]$Adapters + [hashtable]$Adapters, + $FileTransaction ) if (-not (Test-CcodSchemaVersionOne -Value $Value)) { Throw-CcodStateError 'CCOD_SCHEMA_UNSUPPORTED' 'State writes require schema version 1' $Value } & $Validator $Value $Adapters - Write-CcodAtomicJson -Path (Get-CcodStatePath -StateRoot $StateRoot -Leaf $Leaf) -Value $Value + if($null-ne$FileTransaction){$state=New-CcodInstallDirectory -Transaction $FileTransaction -Parent $FileTransaction -Leaf 'state' -CreateIfMissing;Write-CcodInstallRecord -Transaction $FileTransaction -Parent $state -Leaf $Leaf -Record $Value|Out-Null}else{Write-CcodAtomicJson -Path (Get-CcodStatePath -StateRoot $StateRoot -Leaf $Leaf) -Value $Value} } function Initialize-CcodState { @@ -412,16 +413,21 @@ function Initialize-CcodState { if (-not (& $adapters.DirectoryExists $receiptRoot)) { & $adapters.CreateDirectory $receiptRoot } Read-CcodLifecycleRequest -StateRoot $StateRoot | Out-Null if ($existing.Count -eq $paths.Count) { - return + return [pscustomobject][ordered]@{settings=(Read-CcodSettings -StateRoot $StateRoot -Adapters $adapters);status=(Read-CcodStatus -StateRoot $StateRoot -Adapters $adapters);verifiedPackages=(Read-CcodVerifiedPackages -StateRoot $StateRoot -Adapters $adapters);transition=(Read-CcodTypedState -StateRoot $StateRoot -Leaf 'transition.json' -Kind 'transition' -Validator ${function:Assert-CcodTransitionShape} -Adapters $adapters)} } if ($existing.Count -ne 0) { Throw-CcodStateError 'CCOD_STATE_ALREADY_INITIALIZED' 'State initialization refuses to overwrite existing evidence; use explicit repair' $StateRoot } - Write-CcodSettings -StateRoot $StateRoot -Settings (New-CcodSettings -NodeCandidates $NodeCandidates -CandidateCompatibleOptIn $CandidateCompatibleOptIn -AutomationEnabled $true -UpdatedAtUtc (Get-CcodStateTimestamp -Adapters $adapters)) -Adapters $adapters - Write-CcodTypedState -StateRoot $StateRoot -Leaf 'status.json' -Value (New-CcodStatusStore) -Validator ${function:Assert-CcodStatusShape} -Adapters $adapters - Write-CcodVerifiedPackages -StateRoot $StateRoot -VerifiedPackages (New-CcodVerifiedPackagesStore) -Adapters $adapters - Write-CcodTypedState -StateRoot $StateRoot -Leaf 'transition.json' -Value (New-CcodTransitionStore) -Validator ${function:Assert-CcodTransitionShape} -Adapters $adapters + $settings = New-CcodSettings -NodeCandidates $NodeCandidates -CandidateCompatibleOptIn $CandidateCompatibleOptIn -AutomationEnabled $true -UpdatedAtUtc (Get-CcodStateTimestamp -Adapters $adapters) + $status = New-CcodStatusStore + $verifiedPackages = New-CcodVerifiedPackagesStore + $transition = New-CcodTransitionStore + Write-CcodSettings -StateRoot $StateRoot -Settings $settings -Adapters $adapters + Write-CcodTypedState -StateRoot $StateRoot -Leaf 'status.json' -Value $status -Validator ${function:Assert-CcodStatusShape} -Adapters $adapters + Write-CcodVerifiedPackages -StateRoot $StateRoot -VerifiedPackages $verifiedPackages -Adapters $adapters + Write-CcodTypedState -StateRoot $StateRoot -Leaf 'transition.json' -Value $transition -Validator ${function:Assert-CcodTransitionShape} -Adapters $adapters + return [pscustomobject][ordered]@{settings=$settings;status=$status;verifiedPackages=$verifiedPackages;transition=$transition} } function Read-CcodSettings { diff --git a/src/persistence/modules/UiPreferences.psm1 b/src/persistence/modules/UiPreferences.psm1 index dac297b..ef50231 100644 --- a/src/persistence/modules/UiPreferences.psm1 +++ b/src/persistence/modules/UiPreferences.psm1 @@ -100,9 +100,14 @@ function Get-CcodUiPreferenceTimestamp { function Initialize-CcodUiPreference { [CmdletBinding()] - param([Parameter(Mandatory)][string]$StateRoot) + param([Parameter(Mandatory)][string]$StateRoot,[switch]$AllowExisting) $path = Resolve-CcodContainedPath -Root $StateRoot -RelativePath 'ui-preferences.json' -AllowMissingLeaf + if([IO.File]::Exists($path)){ + if(-not$AllowExisting){Throw-CcodUiPreferenceError 'CCOD_UI_PREFERENCES_EXISTS' 'UI preferences already exist.' $path} + $existing=Read-CcodStrictJson -Path $path -ExpectedSchema 1 -Kind 'UI preferences';Assert-CcodUiPreferenceStore -Store $existing + return $existing + } $store = New-CcodUiPreferenceStore -LanguageMode 'System' -UpdatedAtUtc (Get-CcodUiPreferenceTimestamp -Adapters (Get-CcodUiPreferenceAdapters)) Assert-CcodUiPreferenceStore -Store $store try { @@ -113,6 +118,7 @@ function Initialize-CcodUiPreference { } throw } + return $store } function Read-CcodUiPreference { diff --git a/src/portable/AssemblyInfo.cs b/src/portable/AssemblyInfo.cs index 79463e3..06383d5 100644 --- a/src/portable/AssemblyInfo.cs +++ b/src/portable/AssemblyInfo.cs @@ -5,6 +5,6 @@ [assembly: AssemblyDescription("EXE launcher for the verified CodexRemote-fix portable bundle")] [assembly: AssemblyCompany("CodexRemote-fix contributors")] [assembly: AssemblyProduct("CodexRemote-fix")] -[assembly: AssemblyVersion("2.5.21.0")] -[assembly: AssemblyFileVersion("2.5.21.0")] +[assembly: AssemblyVersion("2.5.22.0")] +[assembly: AssemblyFileVersion("2.5.22.0")] [assembly: ComVisible(false)] diff --git a/src/portable/CodexRemote.Portable.manifest b/src/portable/CodexRemote.Portable.manifest index f97d0fb..915a9e1 100644 --- a/src/portable/CodexRemote.Portable.manifest +++ b/src/portable/CodexRemote.Portable.manifest @@ -1,6 +1,6 @@ - + diff --git a/src/trayhost/AssemblyInfo.cs b/src/trayhost/AssemblyInfo.cs index 9055236..8bfb17a 100644 --- a/src/trayhost/AssemblyInfo.cs +++ b/src/trayhost/AssemblyInfo.cs @@ -5,6 +5,6 @@ [assembly: AssemblyDescription("Persistent native tray host for CodexRemote-fix")] [assembly: AssemblyCompany("CodexRemote-fix contributors")] [assembly: AssemblyProduct("CodexRemote-fix")] -[assembly: AssemblyVersion("2.5.21.0")] -[assembly: AssemblyFileVersion("2.5.21.0")] +[assembly: AssemblyVersion("2.5.22.0")] +[assembly: AssemblyFileVersion("2.5.22.0")] [assembly: ComVisible(false)] diff --git a/src/trayhost/CodexRemote.TrayHost.manifest b/src/trayhost/CodexRemote.TrayHost.manifest index 34dea1e..1e54293 100644 --- a/src/trayhost/CodexRemote.TrayHost.manifest +++ b/src/trayhost/CodexRemote.TrayHost.manifest @@ -1,6 +1,6 @@ - + diff --git a/src/trayhost/HostTransport.cs b/src/trayhost/HostTransport.cs index f71a77b..b18171f 100644 --- a/src/trayhost/HostTransport.cs +++ b/src/trayhost/HostTransport.cs @@ -1,6 +1,12 @@ using System; using System.Collections.Generic; +internal enum TrayTerminalReceiptUiKind : byte +{ + About = 1, + Failure = 2 +} + internal sealed class HostTransport : IDisposable { private sealed class PendingAction @@ -9,24 +15,41 @@ private sealed class PendingAction internal bool Accepted; } + private sealed class UiWorkItem + { + internal uint Token; + internal TrayTerminalReceiptUiKind Kind; + internal TrayActionResult Result; + internal bool Committed; + internal bool DeliveryObserved; + } + + private const int MaximumUiWork = 8; + private const int MaximumTokenPostAttempts = 2; private readonly object _gate = new object(); private readonly Action _presentationReady; - private readonly Queue _completedAbout = new Queue(); - private readonly Queue _failedActions = new Queue(); + private readonly Func _receiptReady; + private readonly Dictionary _receiptUi = new Dictionary(); private readonly Dictionary _pendingActions = new Dictionary(); private readonly Queue _recentActionOrder = new Queue(); private readonly HashSet _recentActions = new HashSet(); private PresentationSnapshot _pendingPresentation; + private uint _nextReceiptToken = unchecked((uint)Guid.NewGuid().GetHashCode()); private bool _menuOpen; private bool _disposed; - internal HostTransport() : this(null) + internal HostTransport() : this(null, null) { } - internal HostTransport(Action presentationReady) + internal HostTransport(Action presentationReady) : this(presentationReady, null) + { + } + + internal HostTransport(Action presentationReady, Func receiptReady) { _presentationReady = presentationReady; + _receiptReady = receiptReady; } internal void SetMenuOpen(bool value) @@ -81,8 +104,9 @@ internal bool TryRegisterAction(TrayHostAction action) } } - internal bool TryAcknowledgeAction(TrayActionResult result) + internal bool TryAcknowledgeAction(TrayActionResult result, out TrayTerminalReceipt receipt) { + receipt = null; if (result == null) { return false; } lock (_gate) { @@ -97,44 +121,114 @@ internal bool TryAcknowledgeAction(TrayActionResult result) } if (result.Status != TrayActionResultStatus.Completed && result.Status != TrayActionResultStatus.Rejected && result.Status != TrayActionResultStatus.Failed) { return false; } if (result.Status == TrayActionResultStatus.Completed && TrayCommandPolicy.RequiresAcceptedBeforeCompleted(pending.Action.Command) && !pending.Accepted) { return false; } - if ((result.Status == TrayActionResultStatus.Rejected || result.Status == TrayActionResultStatus.Failed) && _failedActions.Count >= 8) { return false; } + receipt = new TrayTerminalReceipt(this, pending.Action.Command, result); _pendingActions.Remove(result.ActionId); - if (result.Status == TrayActionResultStatus.Completed && pending.Action.Command == TrayCommand.ShowAbout) { _completedAbout.Enqueue(result); } - if (result.Status == TrayActionResultStatus.Rejected || result.Status == TrayActionResultStatus.Failed) { _failedActions.Enqueue(result); } return true; } } - internal bool TryTakeCompletedAbout(out TrayActionResult result) + internal bool TryPublishDurableReceipt(TrayTerminalReceipt receipt) { + if (receipt == null) { return false; } + UiWorkItem work = null; + Func receiptReady = _receiptReady; lock (_gate) { - if (_completedAbout.Count == 0) { result = null; return false; } - result = _completedAbout.Dequeue(); return true; + if (_disposed || !receipt.TryClaimPublication(this)) { return false; } + TrayActionResult result = receipt.Result; + TrayTerminalReceiptUiKind kind; + if (result.Status == TrayActionResultStatus.Completed && receipt.Command == TrayCommand.ShowAbout) { kind = TrayTerminalReceiptUiKind.About; } + else if (result.Status == TrayActionResultStatus.Rejected || result.Status == TrayActionResultStatus.Failed) { kind = TrayTerminalReceiptUiKind.Failure; } + else { return true; } + if (receiptReady == null || _receiptUi.Count >= MaximumUiWork) { return false; } + uint token = NextReceiptTokenLocked(); + if (token == 0U) { return false; } + work = new UiWorkItem { Token = token, Kind = kind, Result = result }; + _receiptUi.Add(token, work); } + + for (int attempt = 0; attempt < MaximumTokenPostAttempts; attempt++) + { + bool posted = false; + try { posted = receiptReady(work.Token); } + catch { posted = false; } + if (!posted) { DropUiWork(work); return false; } + lock (_gate) + { + UiWorkItem current; + if (_disposed || !_receiptUi.TryGetValue(work.Token, out current) || !Object.ReferenceEquals(current, work)) { return false; } + if (!work.DeliveryObserved) + { + work.Committed = true; + return true; + } + work.DeliveryObserved = false; + } + } + + DropUiWork(work); + return false; } - internal bool TryTakeFailedAction(out TrayActionResult result) + internal bool TryTakeReceiptUi(uint token, out TrayTerminalReceiptUiKind kind, out TrayActionResult result) { + kind = 0; result = null; + if (token == 0U) { return false; } lock (_gate) { - if (_failedActions.Count == 0) { result = null; return false; } - result = _failedActions.Dequeue(); return true; + if (_disposed) { return false; } + UiWorkItem work; + if (!_receiptUi.TryGetValue(token, out work)) { return false; } + if (!work.Committed) { work.DeliveryObserved = true; return false; } + _receiptUi.Remove(token); + kind = work.Kind; result = work.Result; + return true; } } + internal bool TryTakeCompletedAbout(out TrayActionResult result) + { + result = null; + return false; + } + + internal bool TryTakeFailedAction(out TrayActionResult result) + { + result = null; + return false; + } + public void Dispose() { lock (_gate) { if (_disposed) { return; } _disposed = true; - _completedAbout.Clear(); - _failedActions.Clear(); + _receiptUi.Clear(); _pendingActions.Clear(); _recentActions.Clear(); _recentActionOrder.Clear(); _pendingPresentation = null; } } + + private uint NextReceiptTokenLocked() + { + for (int attempt = 0; attempt < MaximumUiWork + 2; attempt++) + { + _nextReceiptToken = unchecked(_nextReceiptToken + 1U); + if (_nextReceiptToken != 0U && !_receiptUi.ContainsKey(_nextReceiptToken)) { return _nextReceiptToken; } + } + return 0U; + } + + private void DropUiWork(UiWorkItem target) + { + if (target == null) { return; } + lock (_gate) + { + UiWorkItem current; + if (_receiptUi.TryGetValue(target.Token, out current) && Object.ReferenceEquals(current, target)) { _receiptUi.Remove(target.Token); } + } + } } diff --git a/src/trayhost/NativeMethods.cs b/src/trayhost/NativeMethods.cs index 9a27055..a71528d 100644 --- a/src/trayhost/NativeMethods.cs +++ b/src/trayhost/NativeMethods.cs @@ -77,6 +77,7 @@ internal TrayPoint(int x, int y) internal interface INativeTrayPlatform { + void SetMessageHandler(Action handler); IntPtr CreateOwner(); IntPtr AssociateOwnerInputContext(IntPtr owner, IntPtr context); IntPtr GetOwnerInputContext(IntPtr owner); @@ -113,7 +114,7 @@ internal sealed class Win32TrayPlatform : INativeTrayPlatform private Action _messageHandler; private IntPtr _owner; - internal void SetMessageHandler(Action handler) { _messageHandler = handler; } + public void SetMessageHandler(Action handler) { _messageHandler = handler; } public IntPtr CreateOwner() { diff --git a/src/trayhost/Program.cs b/src/trayhost/Program.cs index 106b3e6..04aa76e 100644 --- a/src/trayhost/Program.cs +++ b/src/trayhost/Program.cs @@ -1,13 +1,8 @@ using System; -using System.Diagnostics; using System.IO; -using System.Security.Cryptography; -using System.Threading; internal static class Program { - private static readonly object WriteGate = new object(); - internal static int Main(string[] args) { try @@ -21,89 +16,18 @@ internal static int Main(string[] args) private static int RunChild(string[] args) { - int expectedParentPid; long expectedParentCreation; string expectedRuntime; - if (!Int32.TryParse(args[2], out expectedParentPid) || !Int64.TryParse(args[4], out expectedParentCreation) || String.IsNullOrEmpty(args[6]) || !String.Equals(args[1], "--parent-pid", StringComparison.Ordinal) || !String.Equals(args[3], "--parent-created", StringComparison.Ordinal) || !String.Equals(args[5], "--runtime-id", StringComparison.Ordinal)) { return 2; } - expectedRuntime = args[6]; - Stream input = Console.OpenStandardInput(); Stream output = Console.OpenStandardOutput(); - ProtocolFrame bootstrap = ProtocolCodec.ReadBootstrap(input, ProtocolDirection.ParentToHost); - TrayHostHello parent = TrayHostWire.ReadParentHello(bootstrap.Payload); - if (parent.ProcessId != expectedParentPid || parent.CreationFileTimeUtc != expectedParentCreation || !String.Equals(parent.RuntimeId, expectedRuntime, StringComparison.Ordinal) || !VerifyParentIdentity(expectedParentPid, expectedParentCreation)) { return 2; } - byte[] nonce = new byte[32]; using (RandomNumberGenerator rng = RandomNumberGenerator.Create()) { rng.GetBytes(nonce); } - ulong epoch = (ulong)DateTime.UtcNow.Ticks; - Process self = Process.GetCurrentProcess(); - ProtocolCodec.WriteBootstrap(output, ProtocolFrame.Bootstrap(ProtocolDirection.HostToParent, TrayHostMessageType.HostHello, TrayHostWire.WriteHostHello(self.Id, self.StartTime.ToFileTimeUtc(), expectedRuntime, nonce, epoch))); - SessionKeys keys = ProtocolCodec.DeriveDirectionalKeys(parent.SessionSeed, parent.ParentChallenge, nonce, epoch); - ulong inboundSequence = 1UL; ulong outboundSequence = 1UL; - ProtocolFrame initialFrame = ProtocolCodec.ReadAuthenticated(input, ProtocolDirection.ParentToHost, epoch, inboundSequence++, keys.ParentToHost); - if (initialFrame.MessageType != TrayHostMessageType.Presentation) { return 2; } - PresentationSnapshot initial = TrayHostWire.ReadPresentation(initialFrame.Payload); - Win32TrayPlatform platform = new Win32TrayPlatform(); - TrayHostApplication application = null; - HostTransport transport = new HostTransport(delegate { TrayHostApplication current = application; if (current != null) { current.PostWork(); } }); - TrayWindow window = new TrayWindow(platform, transport.SetMenuOpen); - bool shutdownRequested = false; bool shutdownSent = false; object stateGate = new object(); - Action command = delegate(TrayCommand selected, ulong revision) - { - TrayHostAction action; - try { action = new TrayHostAction(Guid.NewGuid(), selected, revision); } - catch (ArgumentException) { return; } - if (!transport.TryRegisterAction(action)) { return; } - lock (WriteGate) { ProtocolCodec.WriteAuthenticated(output, ProtocolFrame.Authenticated(ProtocolDirection.HostToParent, TrayHostMessageType.Action, epoch, outboundSequence++, TrayHostWire.WriteAction(action)), keys.HostToParent); } - }; - Action work = delegate - { - PresentationSnapshot next; - if (transport.TryTakePresentation(out next)) + TrayHostChildIdentity identity; + if (!TrayHostChildSession.TryCreateIdentity(args, out identity)) { return 2; } + return TrayHostChildSession.Run( + Console.OpenStandardInput(), + Console.OpenStandardOutput(), + identity, + new WindowsTrayHostRuntime(), + delegate(HostTransport transport) { - if (window.Apply(next)) - { - lock (WriteGate) { ProtocolCodec.WriteAuthenticated(output, ProtocolFrame.Authenticated(ProtocolDirection.HostToParent, TrayHostMessageType.PresentationAck, epoch, outboundSequence++, TrayHostWire.WriteRevision(next.Revision)), keys.HostToParent); } - } - } - TrayActionResult about; - while (transport.TryTakeCompletedAbout(out about)) { window.ShowAbout(); } - TrayActionResult failed; - while (transport.TryTakeFailedAction(out failed)) { window.ShowActionFailed(); } - bool shouldShutdown; - lock (stateGate) { shouldShutdown = shutdownRequested && !shutdownSent; if (shouldShutdown) { shutdownSent = true; } } - if (shouldShutdown) - { - window.RequestShutdown(); - lock (WriteGate) { ProtocolCodec.WriteAuthenticated(output, ProtocolFrame.Authenticated(ProtocolDirection.HostToParent, TrayHostMessageType.ShutdownAck, epoch, outboundSequence++, TrayHostWire.WriteRevision(window.CurrentRevision)), keys.HostToParent); } - application.RequestExit(); - } - }; - window.CommandSelected += command; - application = new TrayHostApplication(platform, window, command, work); - window.Create(initial); - lock (WriteGate) - { - ProtocolCodec.WriteAuthenticated(output, ProtocolFrame.Authenticated(ProtocolDirection.HostToParent, TrayHostMessageType.PresentationAck, epoch, outboundSequence++, TrayHostWire.WriteRevision(initial.Revision)), keys.HostToParent); - ProtocolCodec.WriteAuthenticated(output, ProtocolFrame.Authenticated(ProtocolDirection.HostToParent, TrayHostMessageType.UiReady, epoch, outboundSequence++, TrayHostWire.WriteRevision(initial.Revision)), keys.HostToParent); - } - Thread reader = new Thread(new ThreadStart(delegate - { - try - { - while (true) - { - ProtocolFrame frame = ProtocolCodec.ReadAuthenticated(input, ProtocolDirection.ParentToHost, epoch, inboundSequence++, keys.ParentToHost); - if (frame.MessageType == TrayHostMessageType.Presentation) { transport.TryAcceptPresentation(TrayHostWire.ReadPresentation(frame.Payload)); application.PostWork(); } - else if (frame.MessageType == TrayHostMessageType.ActionResult) { if (!transport.TryAcknowledgeAction(TrayHostWire.ReadActionResult(frame.Payload))) { throw new ProtocolViolationException("action result is uncorrelated"); } application.PostWork(); } - else if (frame.MessageType == TrayHostMessageType.Shutdown) { lock (stateGate) { shutdownRequested = true; } application.PostWork(); } - else if (frame.MessageType == TrayHostMessageType.Ping) { lock (WriteGate) { ProtocolCodec.WriteAuthenticated(output, ProtocolFrame.Authenticated(ProtocolDirection.HostToParent, TrayHostMessageType.Pong, epoch, outboundSequence++, frame.Payload), keys.HostToParent); } } - } - } - catch { application.RequestExit(); } - })) { IsBackground = true, Name = "CodexRemote.TrayHost.Reader" }; - reader.Start(); - int result = application.Run(); - application.Dispose(); transport.Dispose(); return result; - } - - private static bool VerifyParentIdentity(int pid, long creation) - { - try { using (Process process = Process.GetProcessById(pid)) { return process.StartTime.ToFileTimeUtc() == creation; } } catch { return false; } + TrayTerminalDiagnosticStore terminalStore = new TrayTerminalDiagnosticStore(); + return new TrayTerminalReceiptSink(terminalStore.TryAppendDurably, transport.TryPublishDurableReceipt, terminalStore.Dispose); + }); } private static int RunHeadlessSmoke() diff --git a/src/trayhost/TrayHostApplication.cs b/src/trayhost/TrayHostApplication.cs index 9239f79..13ca503 100644 --- a/src/trayhost/TrayHostApplication.cs +++ b/src/trayhost/TrayHostApplication.cs @@ -5,9 +5,10 @@ internal sealed class TrayHostApplication : IDisposable { private readonly TrayWindow _window; - private readonly Win32TrayPlatform _platform; + private readonly INativeTrayPlatform _platform; private readonly Action _command; private readonly Action _work; + private readonly Action _receiptWork; private readonly uint _taskbarCreated; private bool _exitRequested; @@ -17,11 +18,16 @@ internal TrayHostApplication(TrayWindow window) _window = window; } - internal TrayHostApplication(Win32TrayPlatform platform, TrayWindow window, Action command, Action work) + internal TrayHostApplication(INativeTrayPlatform platform, TrayWindow window, Action command, Action work) + : this(platform, window, command, work, null) + { + } + + internal TrayHostApplication(INativeTrayPlatform platform, TrayWindow window, Action command, Action work, Action receiptWork) { if (platform == null) { throw new ArgumentNullException("platform"); } if (window == null) { throw new ArgumentNullException("window"); } - _platform = platform; _window = window; _command = command; _work = work; _taskbarCreated = (uint)Win32TrayPlatform.RegisterTaskbarCreatedMessage().ToInt64(); + _platform = platform; _window = window; _command = command; _work = work; _receiptWork = receiptWork; _taskbarCreated = (uint)Win32TrayPlatform.RegisterTaskbarCreatedMessage().ToInt64(); _platform.SetMessageHandler(HandleMessage); } @@ -40,7 +46,8 @@ internal int Run() return 0; } - internal void PostWork() { if (_platform != null) { Win32TrayPlatform.PostToWindow(_window.OwnerHandle, TrayNativeConstants.WmApp + 2U); } } + internal void PostWork() { if (_platform != null && _window.OwnerHandle != IntPtr.Zero) { _platform.PostMessage(_window.OwnerHandle, TrayNativeConstants.WmApp + 2U, UIntPtr.Zero, IntPtr.Zero); } } + internal bool PostReceiptWork(uint token) { return token != 0U && _platform != null && _window.OwnerHandle != IntPtr.Zero && _platform.PostMessage(_window.OwnerHandle, TrayNativeConstants.WmApp + 3U, new UIntPtr(token), IntPtr.Zero); } internal void RequestExit() { if (_exitRequested) { return; } @@ -54,6 +61,15 @@ internal static bool IsContextMenuEvent(uint message, IntPtr callbackData) return IsContextMenuEvent(message, IntPtr.Zero, callbackData); } + internal static bool TryDecodeReceiptWorkToken(uint message, IntPtr wParam, IntPtr lParam, out uint token) + { + token = 0U; + if (message != TrayNativeConstants.WmApp + 3U || lParam != IntPtr.Zero) { return false; } + ulong raw = IntPtr.Size == 4 ? unchecked((uint)wParam.ToInt32()) : unchecked((ulong)wParam.ToInt64()); + if (raw == 0UL || raw > UInt32.MaxValue) { return false; } + token = unchecked((uint)raw); return true; + } + internal static bool IsContextMenuEvent(uint message, IntPtr wParam, IntPtr lParam) { const uint wmContextMenu = 0x007bU; @@ -74,6 +90,12 @@ private static bool HasContextMenuEvent(IntPtr value, uint wmContextMenu, uint w private void HandleMessage(uint message, IntPtr wParam, IntPtr lParam) { + uint receiptToken; + if (TryDecodeReceiptWorkToken(message, wParam, lParam, out receiptToken)) + { + Action receiptWork = _receiptWork; if (receiptWork != null) { receiptWork(receiptToken); } + return; + } if (message == TrayNativeConstants.WmApp + 2U) { Action work = _work; if (work != null) { work(); } diff --git a/src/trayhost/TrayHostChildSession.cs b/src/trayhost/TrayHostChildSession.cs new file mode 100644 index 0000000..122d046 --- /dev/null +++ b/src/trayhost/TrayHostChildSession.cs @@ -0,0 +1,257 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Security.Cryptography; +using System.Threading; + +internal sealed class TrayHostChildIdentity +{ + internal int ParentProcessId { get; private set; } + internal long ParentCreationFileTimeUtc { get; private set; } + internal string RuntimeId { get; private set; } + + internal TrayHostChildIdentity(int parentProcessId, long parentCreationFileTimeUtc, string runtimeId) + { + ParentProcessId = parentProcessId; + ParentCreationFileTimeUtc = parentCreationFileTimeUtc; + RuntimeId = runtimeId; + } +} + +internal interface ITrayHostRuntime : IDisposable +{ + ulong CurrentRevision { get; } + void Initialize(Action menuOpenChanged, Action commandSelected, Action work, Action receiptWork); + void Create(PresentationSnapshot initial); + bool Apply(PresentationSnapshot snapshot); + void PostWork(); + bool PostReceiptWork(uint token); + void ShowAbout(); + void ShowActionFailed(); + void RequestShutdown(); + void RequestExit(); + int Run(); +} + +internal static class TrayHostChildSession +{ + internal static bool TryCreateIdentity(string[] args, out TrayHostChildIdentity identity) + { + identity = null; + int parentProcessId; + long parentCreation; + if (args == null || args.Length != 7 || + !String.Equals(args[0], "--child", StringComparison.Ordinal) || + !String.Equals(args[1], "--parent-pid", StringComparison.Ordinal) || + !Int32.TryParse(args[2], out parentProcessId) || + !String.Equals(args[3], "--parent-created", StringComparison.Ordinal) || + !Int64.TryParse(args[4], out parentCreation) || + !String.Equals(args[5], "--runtime-id", StringComparison.Ordinal) || + String.IsNullOrEmpty(args[6])) { return false; } + identity = new TrayHostChildIdentity(parentProcessId, parentCreation, args[6]); + return true; + } + + internal static int Run( + Stream input, + Stream output, + TrayHostChildIdentity identity, + ITrayHostRuntime runtime, + Func createReceiptSink) + { + if (input == null || output == null || identity == null || runtime == null || createReceiptSink == null) { throw new ArgumentNullException("child session dependency"); } + HostTransport transport = null; + TrayTerminalReceiptSink receiptSink = null; + try + { + ProtocolFrame bootstrap = ProtocolCodec.ReadBootstrap(input, ProtocolDirection.ParentToHost); + TrayHostHello parent = TrayHostWire.ReadParentHello(bootstrap.Payload); + if (parent.ProcessId != identity.ParentProcessId || + parent.CreationFileTimeUtc != identity.ParentCreationFileTimeUtc || + !String.Equals(parent.RuntimeId, identity.RuntimeId, StringComparison.Ordinal) || + !VerifyParentIdentity(identity.ParentProcessId, identity.ParentCreationFileTimeUtc)) { return 2; } + + byte[] nonce = new byte[32]; + using (RandomNumberGenerator random = RandomNumberGenerator.Create()) { random.GetBytes(nonce); } + ulong epoch = (ulong)DateTime.UtcNow.Ticks; + Process self = Process.GetCurrentProcess(); + try + { + ProtocolCodec.WriteBootstrap(output, ProtocolFrame.Bootstrap( + ProtocolDirection.HostToParent, + TrayHostMessageType.HostHello, + TrayHostWire.WriteHostHello(self.Id, self.StartTime.ToFileTimeUtc(), identity.RuntimeId, nonce, epoch))); + } + finally { self.Dispose(); } + + SessionKeys keys = ProtocolCodec.DeriveDirectionalKeys(parent.SessionSeed, parent.ParentChallenge, nonce, epoch); + ulong inboundSequence = 1UL; + ulong outboundSequence = 1UL; + object writeGate = new object(); + ProtocolFrame initialFrame = ProtocolCodec.ReadAuthenticated(input, ProtocolDirection.ParentToHost, epoch, inboundSequence++, keys.ParentToHost); + if (initialFrame.MessageType != TrayHostMessageType.Presentation) { return 2; } + PresentationSnapshot initial = TrayHostWire.ReadPresentation(initialFrame.Payload); + + transport = new HostTransport( + delegate { runtime.PostWork(); }, + delegate(uint token) { return runtime.PostReceiptWork(token); }); + receiptSink = createReceiptSink(transport); + if (receiptSink == null) { throw new InvalidOperationException("receipt sink is unavailable"); } + + bool shutdownRequested = false; + bool shutdownSent = false; + object stateGate = new object(); + HostTransport currentTransport = transport; + TrayTerminalReceiptSink currentReceiptSink = receiptSink; + + Action command = delegate(TrayCommand selected, ulong revision) + { + TrayHostAction action; + try { action = new TrayHostAction(Guid.NewGuid(), selected, revision); } + catch (ArgumentException) { return; } + if (!currentTransport.TryRegisterAction(action)) { return; } + lock (writeGate) + { + ProtocolCodec.WriteAuthenticated(output, ProtocolFrame.Authenticated( + ProtocolDirection.HostToParent, + TrayHostMessageType.Action, + epoch, + outboundSequence++, + TrayHostWire.WriteAction(action)), keys.HostToParent); + } + }; + + Action work = delegate + { + PresentationSnapshot next; + if (currentTransport.TryTakePresentation(out next) && runtime.Apply(next)) + { + lock (writeGate) + { + ProtocolCodec.WriteAuthenticated(output, ProtocolFrame.Authenticated( + ProtocolDirection.HostToParent, + TrayHostMessageType.PresentationAck, + epoch, + outboundSequence++, + TrayHostWire.WriteRevision(next.Revision)), keys.HostToParent); + } + } + bool shouldShutdown; + lock (stateGate) + { + shouldShutdown = shutdownRequested && !shutdownSent; + if (shouldShutdown) { shutdownSent = true; } + } + if (shouldShutdown) + { + runtime.RequestShutdown(); + lock (writeGate) + { + ProtocolCodec.WriteAuthenticated(output, ProtocolFrame.Authenticated( + ProtocolDirection.HostToParent, + TrayHostMessageType.ShutdownAck, + epoch, + outboundSequence++, + TrayHostWire.WriteRevision(runtime.CurrentRevision)), keys.HostToParent); + } + runtime.RequestExit(); + } + }; + + Action receiptWork = delegate(uint token) + { + TrayTerminalReceiptUiKind kind; + TrayActionResult receiptResult; + if (!currentTransport.TryTakeReceiptUi(token, out kind, out receiptResult)) { return; } + if (kind == TrayTerminalReceiptUiKind.About) { runtime.ShowAbout(); } + else if (kind == TrayTerminalReceiptUiKind.Failure) { runtime.ShowActionFailed(); } + }; + + runtime.Initialize(currentTransport.SetMenuOpen, command, work, receiptWork); + runtime.Create(initial); + lock (writeGate) + { + ProtocolCodec.WriteAuthenticated(output, ProtocolFrame.Authenticated( + ProtocolDirection.HostToParent, + TrayHostMessageType.PresentationAck, + epoch, + outboundSequence++, + TrayHostWire.WriteRevision(initial.Revision)), keys.HostToParent); + ProtocolCodec.WriteAuthenticated(output, ProtocolFrame.Authenticated( + ProtocolDirection.HostToParent, + TrayHostMessageType.UiReady, + epoch, + outboundSequence++, + TrayHostWire.WriteRevision(initial.Revision)), keys.HostToParent); + } + + Thread reader = new Thread(new ThreadStart(delegate + { + try + { + while (true) + { + ProtocolFrame frame = ProtocolCodec.ReadAuthenticated(input, ProtocolDirection.ParentToHost, epoch, inboundSequence++, keys.ParentToHost); + if (frame.MessageType == TrayHostMessageType.Presentation) + { + currentTransport.TryAcceptPresentation(TrayHostWire.ReadPresentation(frame.Payload)); + runtime.PostWork(); + } + else if (frame.MessageType == TrayHostMessageType.ActionResult) + { + if (!TryDispatchAuthenticatedActionResult(currentTransport, currentReceiptSink, frame.Payload)) { throw new ProtocolViolationException("action result is uncorrelated"); } + } + else if (frame.MessageType == TrayHostMessageType.Shutdown) + { + lock (stateGate) { shutdownRequested = true; } + runtime.PostWork(); + } + else if (frame.MessageType == TrayHostMessageType.Ping) + { + lock (writeGate) + { + ProtocolCodec.WriteAuthenticated(output, ProtocolFrame.Authenticated( + ProtocolDirection.HostToParent, + TrayHostMessageType.Pong, + epoch, + outboundSequence++, + frame.Payload), keys.HostToParent); + } + } + } + } + catch { runtime.RequestExit(); } + })) { IsBackground = true, Name = "CodexRemote.TrayHost.Reader" }; + reader.Start(); + return runtime.Run(); + } + finally + { + if (receiptSink != null) { receiptSink.Dispose(); } + if (transport != null) { transport.Dispose(); } + runtime.Dispose(); + } + } + + private static bool TryDispatchAuthenticatedActionResult(HostTransport transport, TrayTerminalReceiptSink receiptSink, byte[] payload) + { + if (transport == null || receiptSink == null || payload == null) { return false; } + TrayTerminalReceipt receipt; + if (!transport.TryAcknowledgeAction(TrayHostWire.ReadActionResult(payload), out receipt)) { return false; } + if (receipt != null) + { + try { receiptSink.TrySubmit(receipt); } + catch { } + } + return true; + } + + private static bool VerifyParentIdentity(int processId, long creationFileTimeUtc) + { + try + { + using (Process process = Process.GetProcessById(processId)) { return process.StartTime.ToFileTimeUtc() == creationFileTimeUtc; } + } + catch { return false; } + } +} diff --git a/src/trayhost/TrayHostParentClient.cs b/src/trayhost/TrayHostParentClient.cs index 56ab25f..dd4a5ec 100644 --- a/src/trayhost/TrayHostParentClient.cs +++ b/src/trayhost/TrayHostParentClient.cs @@ -26,6 +26,7 @@ public sealed class TrayHostStartReceipt public sealed class TrayHostParentClient : IDisposable { + private const int GracefulProcessExitMilliseconds = 1500; internal static Func TestProcessFactory = null; internal static Action TestBeforeReaderStopSignal = null; internal static Action TestBeforeWaitHandleDispose = null; @@ -180,7 +181,11 @@ private void ReaderLoop() ProtocolFrame frame = ReadAuthenticated(); if (frame.MessageType == TrayHostMessageType.PresentationAck) { EnqueueEvent(TrayHostEvent.Ack(TrayHostWire.ReadRevision(frame.Payload))); } else if (frame.MessageType == TrayHostMessageType.Action) { EnqueueEvent(TrayHostEvent.Action(TrayHostWire.ReadAction(frame.Payload))); } - else if (frame.MessageType == TrayHostMessageType.ShutdownAck) { EnqueueEvent(TrayHostEvent.Exited()); _transport.Dispose(); SignalStopped(); return; } + else if (frame.MessageType == TrayHostMessageType.ShutdownAck) + { + if (!WaitForNaturalProcessExit()) { HandleTransportFailure(new InvalidOperationException("CCOD_TRAYHOST_GRACEFUL_EXIT_FAILED")); return; } + EnqueueEvent(TrayHostEvent.Exited()); _transport.Dispose(); SignalStopped(); return; + } else if (frame.MessageType == TrayHostMessageType.Fault) { EnqueueEvent(TrayHostEvent.Fault("CCOD_TRAYHOST_REMOTE_FAULT")); } else if (frame.MessageType == TrayHostMessageType.Pong) { _work.Set(); } } @@ -216,6 +221,14 @@ private ProtocolFrame ReadAuthenticated() return ProtocolCodec.ReadAuthenticated(_childOutput, ProtocolDirection.HostToParent, _keys.HostEpoch, _readSequence++, _keys.HostToParent); } + private bool WaitForNaturalProcessExit() + { + Process process = _process; + if (process == null) { return false; } + try { return process.WaitForExit(GracefulProcessExitMilliseconds) && process.ExitCode == 0; } + catch { return false; } + } + private bool IsDisposing() { return Volatile.Read(ref _disposed) != 0; } private bool IsClosing() { return Volatile.Read(ref _disposeGate) != 0; } private void SignalWork() { try { _work.Set(); } catch (ObjectDisposedException) { } } diff --git a/src/trayhost/TrayTerminalDiagnostic.cs b/src/trayhost/TrayTerminalDiagnostic.cs new file mode 100644 index 0000000..b1b7774 --- /dev/null +++ b/src/trayhost/TrayTerminalDiagnostic.cs @@ -0,0 +1,377 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using System.Security.AccessControl; +using System.Security.Principal; +using System.Text; +using Microsoft.Win32.SafeHandles; + +internal sealed class TrayTerminalDiagnostic +{ + internal TrayCommand Command { get; private set; } + internal ulong Revision { get; private set; } + internal TrayActionResultStatus Status { get; private set; } + internal string Code { get; private set; } + + internal TrayTerminalDiagnostic(TrayCommand command, ulong revision, TrayActionResultStatus status, string errorCode) + { + if (!TrayCommandPolicy.IsWireCommand(command) || revision == 0UL || status == TrayActionResultStatus.Accepted || !Enum.IsDefined(typeof(TrayActionResultStatus), status)) { throw new ArgumentException("terminal diagnostic is invalid"); } + string code = status == TrayActionResultStatus.Completed ? "CCOD_TRAY_ACTION_COMPLETED" : errorCode; + if (!TrayCommandPolicy.IsCanonicalErrorCode(code)) { code = "CCOD_TRAY_ACTION_FAILED"; } + Command = command; Revision = revision; Status = status; Code = code; + } +} + +internal sealed class TrayTerminalDiagnosticStore : IDisposable +{ + private sealed class ReceiptPaths + { + internal string LocalRoot; + internal string Product; + internal string Logs; + internal string ReceiptDirectory; + internal string Leaf; + } + + [StructLayout(LayoutKind.Sequential)] + private struct NativeFileTime + { + internal uint Low; + internal uint High; + } + + [StructLayout(LayoutKind.Sequential)] + private struct ByHandleFileInformation + { + internal uint FileAttributes; + internal NativeFileTime CreationTime; + internal NativeFileTime LastAccessTime; + internal NativeFileTime LastWriteTime; + internal uint VolumeSerialNumber; + internal uint FileSizeHigh; + internal uint FileSizeLow; + internal uint NumberOfLinks; + internal uint FileIndexHigh; + internal uint FileIndexLow; + } + + internal const long MaximumBytes = 64L * 1024L; + private const uint GenericRead = 0x80000000U; + private const uint GenericWrite = 0x40000000U; + private const uint ReadControl = 0x00020000U; + private const uint FileListDirectory = 0x00000001U; + private const uint FileReadAttributes = 0x00000080U; + private const uint FileShareRead = 0x00000001U; + private const uint FileShareWrite = 0x00000002U; + private const uint OpenExisting = 3U; + private const uint OpenAlways = 4U; + private const uint FileAttributeDirectory = 0x00000010U; + private const uint FileAttributeDevice = 0x00000040U; + private const uint FileAttributeReparsePoint = 0x00000400U; + private const uint FileFlagWriteThrough = 0x80000000U; + private const uint FileFlagBackupSemantics = 0x02000000U; + private const uint FileFlagOpenReparsePoint = 0x00200000U; + private const uint FileTypeDisk = 1U; + private const uint SecurityInformationOwner = 0x00000001U; + private const uint SecurityInformationDacl = 0x00000004U; + private const uint SeFileObject = 1U; + private const int FullControlAccessMask = 0x001F01FF; + private readonly object _gate = new object(); + private bool _disposed; + + internal TrayTerminalDiagnosticStore() + { + } + +#if TRAYHOST_SELF_TEST + private readonly string _testLocalRoot; + private readonly Action _testDirectoryChainOpened; + private readonly Action _testLeafValidated; + + private TrayTerminalDiagnosticStore(string testLocalRoot, Action directoryChainOpened, Action leafValidated) + { + _testLocalRoot = testLocalRoot; + _testDirectoryChainOpened = directoryChainOpened; + _testLeafValidated = leafValidated; + } + + internal static TrayTerminalDiagnosticStore CreateForTesting(string localRoot, Action directoryChainOpened, Action leafValidated) + { + return new TrayTerminalDiagnosticStore(localRoot, directoryChainOpened, leafValidated); + } +#endif + + internal static bool TryGetDefaultPath(out string path) + { + path = null; + try + { + ReceiptPaths paths; + if (!TryBuildPaths(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), out paths)) { return false; } + path = paths.Leaf; + return true; + } + catch { path = null; return false; } + } + + internal bool TryAppendDurably(TrayTerminalDiagnostic record) + { + if (record == null) { return false; } + byte[] bytes; + try + { + string line = "command=" + record.Command.ToString() + " revision=" + record.Revision.ToString(System.Globalization.CultureInfo.InvariantCulture) + " status=" + record.Status.ToString() + " code=" + record.Code + Environment.NewLine; + bytes = new UTF8Encoding(false).GetBytes(line); + if (bytes.LongLength > MaximumBytes) { return false; } + } + catch { return false; } + + lock (_gate) + { + if (_disposed) { return false; } + try + { + ReceiptPaths paths; + if (!TryGetPaths(out paths)) { return false; } + return TryAppendThroughValidatedHandles(paths, bytes); + } + catch { return false; } + } + } + + public void Dispose() + { + lock (_gate) { _disposed = true; } + } + + private bool TryGetPaths(out ReceiptPaths paths) + { +#if TRAYHOST_SELF_TEST + if (_testLocalRoot != null) { return TryBuildPaths(_testLocalRoot, out paths); } +#endif + return TryBuildPaths(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), out paths); + } + + private bool TryAppendThroughValidatedHandles(ReceiptPaths paths, byte[] bytes) + { + using (SafeFileHandle local = OpenDirectory(paths.LocalRoot)) + { + if (!IsExpectedDirectory(local, paths.LocalRoot, false)) { return false; } + using (SafeFileHandle product = OpenDirectory(paths.Product)) + { + if (!IsExpectedDirectory(product, paths.Product, false)) { return false; } + using (SafeFileHandle logs = OpenDirectory(paths.Logs)) + { + if (!IsExpectedDirectory(logs, paths.Logs, false)) { return false; } + if (!TryCreatePrivateReceiptDirectory(paths.ReceiptDirectory)) { return false; } + using (SafeFileHandle receiptDirectory = OpenDirectory(paths.ReceiptDirectory)) + { + if (!IsExpectedDirectory(receiptDirectory, paths.ReceiptDirectory, true)) { return false; } +#if TRAYHOST_SELF_TEST + if (_testDirectoryChainOpened != null) { _testDirectoryChainOpened(); } +#endif + using (SafeFileHandle leaf = CreateFileW(paths.Leaf, GenericRead | GenericWrite | ReadControl, FileShareRead, IntPtr.Zero, OpenAlways, FileFlagOpenReparsePoint | FileFlagWriteThrough, IntPtr.Zero)) + { + if (!IsExpectedLeaf(leaf, paths.Leaf)) { return false; } +#if TRAYHOST_SELF_TEST + if (_testLeafValidated != null) { _testLeafValidated(); } +#endif + using (FileStream stream = new FileStream(leaf, FileAccess.ReadWrite, 4096, false)) + { + long length = stream.Length; + if (length > MaximumBytes || length > MaximumBytes - bytes.LongLength) + { + stream.SetLength(0L); + stream.Position = 0L; + } + else { stream.Position = length; } + stream.Write(bytes, 0, bytes.Length); + stream.Flush(true); + return true; + } + } + } + } + } + } + } + + private static bool TryBuildPaths(string localRoot, out ReceiptPaths paths) + { + paths = null; + if (String.IsNullOrEmpty(localRoot) || !Path.IsPathRooted(localRoot) || localRoot.StartsWith("\\\\?\\", StringComparison.Ordinal) || localRoot.StartsWith("\\\\.\\", StringComparison.Ordinal)) { return false; } + string canonicalRoot = NormalizePath(localRoot); + if (String.IsNullOrEmpty(canonicalRoot)) { return false; } + string product = NormalizePath(Path.Combine(canonicalRoot, "CodexControlOtherDevices")); + string logs = NormalizePath(Path.Combine(product, "logs")); + string receiptDirectory = NormalizePath(Path.Combine(logs, "tray-receipts")); + string leaf = NormalizePath(Path.Combine(receiptDirectory, "trayhost-actions.log")); + if (String.IsNullOrEmpty(product) || String.IsNullOrEmpty(logs) || String.IsNullOrEmpty(receiptDirectory) || String.IsNullOrEmpty(leaf)) { return false; } + if (!String.Equals(Path.GetDirectoryName(product), canonicalRoot, StringComparison.OrdinalIgnoreCase) || !String.Equals(Path.GetDirectoryName(logs), product, StringComparison.OrdinalIgnoreCase) || !String.Equals(Path.GetDirectoryName(receiptDirectory), logs, StringComparison.OrdinalIgnoreCase) || !String.Equals(Path.GetDirectoryName(leaf), receiptDirectory, StringComparison.OrdinalIgnoreCase)) { return false; } + if (!String.Equals(Path.GetFileName(product), "CodexControlOtherDevices", StringComparison.Ordinal) || !String.Equals(Path.GetFileName(logs), "logs", StringComparison.Ordinal) || !String.Equals(Path.GetFileName(receiptDirectory), "tray-receipts", StringComparison.Ordinal) || !String.Equals(Path.GetFileName(leaf), "trayhost-actions.log", StringComparison.Ordinal) || Path.GetFileName(leaf).IndexOf(':') >= 0) { return false; } + paths = new ReceiptPaths { LocalRoot = canonicalRoot, Product = product, Logs = logs, ReceiptDirectory = receiptDirectory, Leaf = leaf }; + return true; + } + + private static string NormalizePath(string path) + { + try + { + string full = Path.GetFullPath(path); + string root = Path.GetPathRoot(full); + while (!String.IsNullOrEmpty(root) && full.Length > root.Length && (full.EndsWith("\\", StringComparison.Ordinal) || full.EndsWith("/", StringComparison.Ordinal))) { full = full.Substring(0, full.Length - 1); } + return full; + } + catch { return null; } + } + + private static bool TryCreatePrivateReceiptDirectory(string path) + { + try + { + if (Directory.Exists(path)) { return true; } + Directory.CreateDirectory(path, CreatePrivateDirectorySecurity()); + return true; + } + catch { return false; } + } + + private static DirectorySecurity CreatePrivateDirectorySecurity() + { + SecurityIdentifier user = GetCurrentUserSid(); + if (user == null) { throw new InvalidOperationException("current user SID is unavailable"); } + DirectorySecurity security = new DirectorySecurity(); + security.SetOwner(user); + security.SetAccessRuleProtection(true, false); + InheritanceFlags inheritance = InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit; + SecurityIdentifier[] identities = new SecurityIdentifier[] { + user, + new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null), + new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null) + }; + for (int i = 0; i < identities.Length; i++) + { + security.AddAccessRule(new FileSystemAccessRule(identities[i], FileSystemRights.FullControl, inheritance, PropagationFlags.None, AccessControlType.Allow)); + } + return security; + } + + private static SecurityIdentifier GetCurrentUserSid() + { + try + { + using (WindowsIdentity identity = WindowsIdentity.GetCurrent()) + { + return identity == null || identity.User == null ? null : new SecurityIdentifier(identity.User.Value); + } + } + catch { return null; } + } + + private static SafeFileHandle OpenDirectory(string path) + { + return CreateFileW(path, FileListDirectory | FileReadAttributes | ReadControl, FileShareRead | FileShareWrite, IntPtr.Zero, OpenExisting, FileFlagBackupSemantics | FileFlagOpenReparsePoint, IntPtr.Zero); + } + + private static bool IsExpectedDirectory(SafeFileHandle handle, string expectedPath, bool requirePrivateSecurity) + { + ByHandleFileInformation information; + if (handle == null || handle.IsInvalid || GetFileType(handle) != FileTypeDisk || !GetFileInformationByHandle(handle, out information)) { return false; } + if ((information.FileAttributes & FileAttributeDirectory) == 0U || (information.FileAttributes & (FileAttributeReparsePoint | FileAttributeDevice)) != 0U) { return false; } + if (!IsExpectedFinalPath(handle, expectedPath)) { return false; } + return !requirePrivateSecurity || HasPrivateDirectorySecurity(handle); + } + + private static bool HasPrivateDirectorySecurity(SafeFileHandle handle) + { + IntPtr owner; IntPtr group; IntPtr dacl; IntPtr sacl; IntPtr descriptor; + uint result = GetSecurityInfo(handle, SeFileObject, SecurityInformationOwner | SecurityInformationDacl, out owner, out group, out dacl, out sacl, out descriptor); + if (result != 0U || descriptor == IntPtr.Zero) { return false; } + try + { + uint length = GetSecurityDescriptorLength(descriptor); + if (length == 0U || length > 65536U) { return false; } + byte[] bytes = new byte[checked((int)length)]; + Marshal.Copy(descriptor, bytes, 0, bytes.Length); + RawSecurityDescriptor security = new RawSecurityDescriptor(bytes, 0); + SecurityIdentifier user = GetCurrentUserSid(); + if (user == null || security.Owner == null || !String.Equals(security.Owner.Value, user.Value, StringComparison.Ordinal)) { return false; } + if ((security.ControlFlags & ControlFlags.DiscretionaryAclPresent) == 0 || (security.ControlFlags & ControlFlags.DiscretionaryAclProtected) == 0 || security.DiscretionaryAcl == null || security.DiscretionaryAcl.Count != 3) { return false; } + HashSet expected = new HashSet(StringComparer.Ordinal) { + user.Value, + new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null).Value, + new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null).Value + }; + HashSet seen = new HashSet(StringComparer.Ordinal); + AceFlags inheritance = AceFlags.ContainerInherit | AceFlags.ObjectInherit; + for (int i = 0; i < security.DiscretionaryAcl.Count; i++) + { + CommonAce ace = security.DiscretionaryAcl[i] as CommonAce; + if (ace == null || ace.IsCallback || ace.AceQualifier != AceQualifier.AccessAllowed || ace.SecurityIdentifier == null || ace.AccessMask != FullControlAccessMask) { return false; } + if ((ace.AceFlags & inheritance) != inheritance || (ace.AceFlags & ~(inheritance)) != AceFlags.None) { return false; } + if (!expected.Contains(ace.SecurityIdentifier.Value) || !seen.Add(ace.SecurityIdentifier.Value)) { return false; } + } + return seen.Count == expected.Count; + } + catch { return false; } + finally { LocalFree(descriptor); } + } + + private static bool IsExpectedLeaf(SafeFileHandle handle, string expectedPath) + { + ByHandleFileInformation information; + if (handle == null || handle.IsInvalid || GetFileType(handle) != FileTypeDisk || !GetFileInformationByHandle(handle, out information)) { return false; } + if ((information.FileAttributes & (FileAttributeDirectory | FileAttributeReparsePoint | FileAttributeDevice)) != 0U || information.NumberOfLinks != 1U) { return false; } + return IsExpectedFinalPath(handle, expectedPath); + } + + private static bool IsExpectedFinalPath(SafeFileHandle handle, string expectedPath) + { + string actual; + if (!TryGetFinalPath(handle, out actual)) { return false; } + return String.Equals(actual, NormalizePath(expectedPath), StringComparison.OrdinalIgnoreCase); + } + + private static bool TryGetFinalPath(SafeFileHandle handle, out string path) + { + path = null; + StringBuilder buffer = new StringBuilder(512); + uint length = GetFinalPathNameByHandleW(handle, buffer, (uint)buffer.Capacity, 0U); + if (length == 0U) { return false; } + if (length >= (uint)buffer.Capacity) + { + if (length > 32767U) { return false; } + buffer = new StringBuilder(checked((int)length + 1)); + length = GetFinalPathNameByHandleW(handle, buffer, (uint)buffer.Capacity, 0U); + if (length == 0U || length >= (uint)buffer.Capacity) { return false; } + } + string value = buffer.ToString(); + if (value.StartsWith("\\\\?\\UNC\\", StringComparison.OrdinalIgnoreCase)) { value = "\\\\" + value.Substring(8); } + else if (value.StartsWith("\\\\?\\", StringComparison.Ordinal)) { value = value.Substring(4); } + path = NormalizePath(value); + return !String.IsNullOrEmpty(path); + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFileW(string fileName, uint desiredAccess, uint shareMode, IntPtr securityAttributes, uint creationDisposition, uint flagsAndAttributes, IntPtr templateFile); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetFileInformationByHandle(SafeFileHandle file, out ByHandleFileInformation information); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint GetFileType(SafeFileHandle file); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern uint GetFinalPathNameByHandleW(SafeFileHandle file, StringBuilder path, uint pathLength, uint flags); + + [DllImport("advapi32.dll", SetLastError = true)] + private static extern uint GetSecurityInfo(SafeFileHandle handle, uint objectType, uint securityInformation, out IntPtr owner, out IntPtr group, out IntPtr dacl, out IntPtr sacl, out IntPtr securityDescriptor); + + [DllImport("advapi32.dll", SetLastError = true)] + private static extern uint GetSecurityDescriptorLength(IntPtr securityDescriptor); + + [DllImport("kernel32.dll")] + private static extern IntPtr LocalFree(IntPtr memory); +} diff --git a/src/trayhost/TrayTerminalReceiptSink.cs b/src/trayhost/TrayTerminalReceiptSink.cs new file mode 100644 index 0000000..9aeb213 --- /dev/null +++ b/src/trayhost/TrayTerminalReceiptSink.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Generic; +using System.Threading; + +internal sealed class TrayTerminalReceipt +{ + private readonly object _authority; + private int _publicationClaimed; + + internal TrayTerminalReceipt(object authority, TrayCommand command, TrayActionResult result) + { + if (authority == null) { throw new ArgumentNullException("authority"); } + if (!TrayCommandPolicy.IsWireCommand(command) || result == null || result.Status == TrayActionResultStatus.Accepted) { throw new ArgumentException("terminal receipt is invalid"); } + _authority = authority; + Command = command; + Result = result; + Diagnostic = new TrayTerminalDiagnostic(command, result.Revision, result.Status, result.ErrorCode); + } + + internal TrayCommand Command { get; private set; } + internal TrayActionResult Result { get; private set; } + internal TrayTerminalDiagnostic Diagnostic { get; private set; } + + internal bool TryClaimPublication(object authority) + { + return Object.ReferenceEquals(_authority, authority) && Interlocked.CompareExchange(ref _publicationClaimed, 1, 0) == 0; + } +} + +internal sealed class TrayTerminalReceiptSink : IDisposable +{ + internal const int MaximumOutstanding = 8; + private const int DisposeJoinMilliseconds = 100; + private readonly object _gate = new object(); + private readonly Queue _pending = new Queue(); + private readonly AutoResetEvent _available = new AutoResetEvent(false); + private readonly Func _writeDurably; + private readonly Func _publishDurable; + private readonly Action _disposeStore; + private readonly Thread _writer; + private int _outstanding; + private int _cleanupStarted; + private bool _writerStarted; + private bool _closed; + private bool _callbackClaimed; + +#if TRAYHOST_RECEIPT_SELF_TEST + internal static Action BeforeCallbackAdmissionForTesting; +#endif + + internal TrayTerminalReceiptSink(Func writeDurably, Func publishDurable) + : this(writeDurably, publishDurable, null) + { + } + + internal TrayTerminalReceiptSink(Func writeDurably, Func publishDurable, Action disposeStore) + { + _writeDurably = writeDurably; + _publishDurable = publishDurable; + _disposeStore = disposeStore; + if (_writeDurably == null || _publishDurable == null) { _closed = true; return; } + try + { + _writer = new Thread(new ThreadStart(WriterMain)) { IsBackground = true, Name = "CodexRemote.TrayHost.ReceiptWriter" }; + _writer.Start(); + _writerStarted = true; + } + catch { _closed = true; } + } + + internal bool TrySubmit(TrayTerminalReceipt receipt) + { + if (receipt == null || !Monitor.TryEnter(_gate)) { return false; } + try + { + if (_closed || _outstanding >= MaximumOutstanding) { return false; } + try { _available.Set(); } + catch { return false; } + try + { + _pending.Enqueue(receipt); + _outstanding++; + return true; + } + catch { return false; } + } + finally { Monitor.Exit(_gate); } + } + + public void Dispose() + { + Thread writer; + lock (_gate) + { + if (_closed) + { + writer = _writer; + } + else + { + _closed = true; + _pending.Clear(); + _outstanding = 0; + writer = _writer; + try { _available.Set(); } catch { } + } + } + if (writer == null || !_writerStarted) { CleanupAfterWriter(); return; } + if (Object.ReferenceEquals(writer, Thread.CurrentThread)) { return; } + bool stopped = false; + try { stopped = writer.Join(DisposeJoinMilliseconds); } catch { } + if (stopped) { CleanupAfterWriter(); } + } + + private void WriterMain() + { + try + { + while (true) + { + TrayTerminalReceipt receipt = null; + lock (_gate) + { + if (_closed) { return; } + if (_pending.Count != 0) { receipt = _pending.Dequeue(); } + } + if (receipt == null) + { + try { _available.WaitOne(); } + catch { return; } + continue; + } + + bool durable = false; + try { durable = _writeDurably(receipt.Diagnostic); } + catch { durable = false; } +#if TRAYHOST_RECEIPT_SELF_TEST + if (durable) + { + Action beforeCallbackAdmission = BeforeCallbackAdmissionForTesting; + if (beforeCallbackAdmission != null) { try { beforeCallbackAdmission(); } catch { durable = false; } } + } +#endif + bool callbackAdmitted = false; + if (durable) + { + lock (_gate) + { + if (!_closed && !_callbackClaimed) { _callbackClaimed = true; callbackAdmitted = true; } + } + } + if (callbackAdmitted) + { + try { _publishDurable(receipt); } + catch { } + finally { lock (_gate) { _callbackClaimed = false; } } + } + lock (_gate) + { + if (_outstanding > 0) { _outstanding--; } + if (_closed) { return; } + } + } + } + finally + { + lock (_gate) + { + _closed = true; + _pending.Clear(); + _outstanding = 0; + } + CleanupAfterWriter(); + } + } + + private void CleanupAfterWriter() + { + if (Interlocked.CompareExchange(ref _cleanupStarted, 1, 0) != 0) { return; } + Action disposeStore = _disposeStore; + if (disposeStore != null) { try { disposeStore(); } catch { } } + try { _available.Dispose(); } catch { } + } +} diff --git a/src/trayhost/WindowsTrayHostRuntime.cs b/src/trayhost/WindowsTrayHostRuntime.cs new file mode 100644 index 0000000..4280440 --- /dev/null +++ b/src/trayhost/WindowsTrayHostRuntime.cs @@ -0,0 +1,71 @@ +using System; + +internal sealed class WindowsTrayHostRuntime : ITrayHostRuntime +{ + private Win32TrayPlatform _platform; + private TrayWindow _window; + private TrayHostApplication _application; + private bool _disposed; + + public ulong CurrentRevision { get { return _window == null ? 0UL : _window.CurrentRevision; } } + + public void Initialize(Action menuOpenChanged, Action commandSelected, Action work, Action receiptWork) + { + if (_disposed || _application != null || commandSelected == null || work == null || receiptWork == null) { throw new InvalidOperationException("Windows tray runtime initialization is invalid"); } + _platform = new Win32TrayPlatform(); + _window = new TrayWindow(_platform, menuOpenChanged); + _window.CommandSelected += commandSelected; + _application = new TrayHostApplication(_platform, _window, commandSelected, work, receiptWork); + } + + public void Create(PresentationSnapshot initial) { RequireWindow().Create(initial); } + public bool Apply(PresentationSnapshot snapshot) { return RequireWindow().Apply(snapshot); } + public void PostWork() + { + TrayHostApplication application = _application; + if (!_disposed && application != null) { application.PostWork(); } + } + + public bool PostReceiptWork(uint token) + { + TrayHostApplication application = _application; + return !_disposed && application != null && application.PostReceiptWork(token); + } + public void ShowAbout() { RequireWindow().ShowAbout(); } + public void ShowActionFailed() { RequireWindow().ShowActionFailed(); } + public void RequestShutdown() + { + TrayWindow window = _window; + if (!_disposed && window != null) { window.RequestShutdown(); } + } + + public void RequestExit() + { + TrayHostApplication application = _application; + if (!_disposed && application != null) { application.RequestExit(); } + } + public int Run() { return RequireApplication().Run(); } + + public void Dispose() + { + if (_disposed) { return; } + _disposed = true; + if (_application != null) { _application.Dispose(); } + else if (_window != null) { _window.Dispose(); } + _application = null; + _window = null; + _platform = null; + } + + private TrayWindow RequireWindow() + { + if (_disposed || _window == null) { throw new InvalidOperationException("Windows tray runtime is unavailable"); } + return _window; + } + + private TrayHostApplication RequireApplication() + { + if (_disposed || _application == null) { throw new InvalidOperationException("Windows tray runtime is unavailable"); } + return _application; + } +} diff --git a/tests/PersistenceSelfTest.ps1 b/tests/PersistenceSelfTest.ps1 index e6583a7..a67276f 100644 --- a/tests/PersistenceSelfTest.ps1 +++ b/tests/PersistenceSelfTest.ps1 @@ -15,6 +15,10 @@ if ($PSVersionTable.PSEdition -eq 'Desktop') { $powershell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' $tests = Get-ChildItem -LiteralPath (Join-Path $PSScriptRoot 'persistence') -Filter '*.SelfTest.ps1' | Sort-Object Name +$requiredTransactionSelfTest = 'InstallFileTransaction.SelfTest.ps1' +if (@($tests | Where-Object Name -CEQ $requiredTransactionSelfTest).Count -ne 1) { + throw "Required install file transaction self-test is not registered exactly once: $requiredTransactionSelfTest" +} foreach ($test in $tests) { $previousErrorActionPreference = $ErrorActionPreference try { diff --git a/tests/Validate.ps1 b/tests/Validate.ps1 index 497264b..b69a12a 100644 --- a/tests/Validate.ps1 +++ b/tests/Validate.ps1 @@ -24,6 +24,8 @@ $failures = [System.Collections.Generic.List[string]]::new() $cleanRoomSelfTest = Join-Path $PSScriptRoot 'CleanroomSelfTest.js' $packageCheckerSelfTest = Join-Path $PSScriptRoot 'PackageCheckerSelfTest.mjs' $persistenceSelfTest = Join-Path $PSScriptRoot 'PersistenceSelfTest.ps1' +$installFileTransactionSelfTest = Join-Path $PSScriptRoot 'persistence\InstallFileTransaction.SelfTest.ps1' +$officialDraftAcceptanceSelfTest = Join-Path $PSScriptRoot 'persistence\OfficialDraftAcceptanceHarness.SelfTest.ps1' function Get-CcodValidationSafeChildMarkers { param($Output) @@ -94,6 +96,10 @@ if (-not $node) { } } +if (-not (Test-Path -LiteralPath $installFileTransactionSelfTest -PathType Leaf)) { + $failures.Add("Immutable generation file-layer self-test is missing: $installFileTransactionSelfTest") +} + if ($failures.Count -eq 0) { $powershellExecutable = (Get-Command powershell.exe -ErrorAction Stop).Source $previousErrorActionPreference = $ErrorActionPreference @@ -112,6 +118,30 @@ if ($failures.Count -eq 0) { } } +if (-not (Test-Path -LiteralPath $officialDraftAcceptanceSelfTest -PathType Leaf)) { + $failures.Add("Official draft acceptance self-test is missing: $officialDraftAcceptanceSelfTest") +} elseif ($failures.Count -eq 0) { + $powershellExecutable = (Get-Command powershell.exe -ErrorAction Stop).Source + $previousErrorActionPreference = $ErrorActionPreference + $hadChildExecutionPolicy = Test-Path -LiteralPath 'Env:PSExecutionPolicyPreference' + $previousChildExecutionPolicy = $env:PSExecutionPolicyPreference + try { + $ErrorActionPreference = 'Continue' + Remove-Item -LiteralPath 'Env:PSExecutionPolicyPreference' -ErrorAction SilentlyContinue + # Omit the policy switch: Windows PowerShell 5.1 maps an explicit + # Undefined argument to Restricted instead of inheriting normal policy. + $officialDraftAcceptanceOutput = & $powershellExecutable -NoProfile -File $officialDraftAcceptanceSelfTest 2>&1 + $officialDraftAcceptanceExitCode = $LASTEXITCODE + } finally { + if ($hadChildExecutionPolicy) { $env:PSExecutionPolicyPreference = $previousChildExecutionPolicy } + else { Remove-Item -LiteralPath 'Env:PSExecutionPolicyPreference' -ErrorAction SilentlyContinue } + $ErrorActionPreference = $previousErrorActionPreference + } + if ($officialDraftAcceptanceExitCode -ne 0) { + $failures.Add("Official draft acceptance self-test failed with exit code $officialDraftAcceptanceExitCode; $(Get-CcodValidationSafeChildMarkers $officialDraftAcceptanceOutput)") + } +} + foreach ($required in @( 'README.md', 'README.zh-CN.md', @@ -129,6 +159,7 @@ foreach ($required in @( 'src\persistence\UninstallBootstrap.ps1', 'src\persistence\PortableUninstallFinalizer.ps1', 'src\persistence\modules\InstallLifecycle.psm1', + 'src\persistence\modules\InstallFileTransaction.psm1', 'src\persistence\modules\PortableRelease.psm1', 'src\persistence\modules\ScheduledTask.psm1', 'src\persistence\modules\WorkerRuntime.psm1', @@ -137,10 +168,17 @@ foreach ($required in @( 'tests\persistence\Bootstrap.SelfTest.ps1', 'tests\persistence\ScheduledTask.SelfTest.ps1', 'tests\persistence\InstallLifecycle.SelfTest.ps1', + 'tests\persistence\InstallFileTransaction.SelfTest.ps1', 'tests\persistence\PortableRelease.SelfTest.ps1', 'tests\persistence\UninstallBootstrap.SelfTest.ps1', 'tests\persistence\InstalledLifecycleHarness.SelfTest.ps1', 'tests\persistence\ReleaseWorkflow.SelfTest.ps1', + 'tests\persistence\TrustedModuleImport.SelfTest.ps1', + 'tests\installed\OfficialDraftAcceptance.psm1', + 'tests\installed\Invoke-OfficialDraftAcceptance.ps1', + 'tests\persistence\OfficialDraftAcceptanceHarness.SelfTest.ps1', + 'tools\ReleaseAssetContract.psm1', + 'tools\ReleaseDefender.psm1', 'tools\Test-ReleaseDefender.ps1', 'tests\persistence\WorkerRuntime.SelfTest.ps1' )) { diff --git a/tests/installed/Invoke-InstalledLifecycleIntegration.ps1 b/tests/installed/Invoke-InstalledLifecycleIntegration.ps1 index 6856431..f0bbb6a 100644 --- a/tests/installed/Invoke-InstalledLifecycleIntegration.ps1 +++ b/tests/installed/Invoke-InstalledLifecycleIntegration.ps1 @@ -2,25 +2,190 @@ param( [Parameter(Mandatory, ParameterSetName = 'Run')][string]$InstallerPath, [Parameter(ParameterSetName = 'Run')][string]$PreviousInstallerPath, - [Parameter(Mandatory, ParameterSetName = 'Run')][ValidatePattern('^\d+\.\d+\.\d+$')][string]$ExpectedVersion, + [Parameter(ParameterSetName = 'Run')][ValidatePattern('^\d+\.\d+\.\d+\z')][string]$PreviousExpectedVersion, + [Parameter(ParameterSetName = 'Run')][ValidatePattern('^[0-9a-f]{64}\z')][string]$PreviousInstallerSha256, + [Parameter(ParameterSetName = 'Run')][ValidatePattern('^[0-9a-f]{64}\z')][string]$PreviousManifestSha256, + [Parameter(Mandatory, ParameterSetName = 'Run')][ValidatePattern('^\d+\.\d+\.\d+\z')][string]$ExpectedVersion, [Parameter(Mandatory, ParameterSetName = 'Run')][string]$EvidenceRoot, - [Parameter(ParameterSetName = 'Run')][switch]$AllowMachineMutation, - [Parameter(ParameterSetName = 'Run')][switch]$AllowCodexRestart, - [Parameter(Mandatory, ParameterSetName = 'Run')][ValidateSet('FreshLater','FreshRestart','Upgrade','SlowLaunch','ManualLaunchResume','LanguageStress','RepairStates','SafeExit','SettingsUninstall','DirectUninstall')][string]$Scenario, - [Parameter(Mandatory, ParameterSetName = 'Library')][switch]$Library + [Parameter(ParameterSetName = 'Run')][Parameter(ParameterSetName = 'VerifiedUninstall')][switch]$AllowMachineMutation, + [Parameter(ParameterSetName = 'Run')][Parameter(ParameterSetName = 'VerifiedUninstall')][switch]$AllowCodexRestart, + [Parameter(Mandatory, ParameterSetName = 'Run')][ValidateSet('FreshInstall','FreshLater','FreshRestart','Upgrade','SlowLaunch','ManualLaunchResume','LanguageStress','RepairStates','SafeExit','SettingsUninstall','DirectUninstall')][string]$Scenario, + [Parameter(Mandatory, ParameterSetName = 'Library')][switch]$Library, + [Parameter(Mandatory, ParameterSetName = 'VerifiedUninstall')][switch]$VerifiedUninstallChild ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' +# BEGIN CCOD TRUSTED IMPORT BOOTSTRAP +# Embedded in each independently trusted entrypoint; an unheld helper import +# cannot bootstrap its own protection. Keep copies identical (self-test enforced). +function Initialize-CcodTrustedImportLease { + if ($null -ne ('CcodTrustedImportLeaseV1' -as [type])) { return } + Add-Type -ErrorAction Stop -TypeDefinition @' +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Win32.SafeHandles; + +public sealed class CcodTrustedImportLeaseV1 : IDisposable +{ + [StructLayout(LayoutKind.Sequential)] private struct NativeTime { public uint Low, High; } + [StructLayout(LayoutKind.Sequential)] private struct NativeInfo + { + public uint Attributes; + public NativeTime Creation, Access, Write; + public uint Volume, SizeHigh, SizeLow, Links, IndexHigh, IndexLow; + } + private sealed class Entry + { + public SafeFileHandle Handle; + public string Path; + public bool Directory; + public uint Volume; + public ulong FileId; + } + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] + private static extern SafeFileHandle CreateFileW(string path, uint access, uint share, IntPtr security, uint creation, uint flags, IntPtr template); + [DllImport("kernel32.dll", SetLastError=true)] + private static extern bool GetFileInformationByHandle(SafeFileHandle handle, out NativeInfo info); + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] + private static extern uint GetFinalPathNameByHandleW(SafeFileHandle handle, StringBuilder value, uint length, uint flags); + + private readonly List entries = new List(); + private bool disposed; + public string Path { get; private set; } + private CcodTrustedImportLeaseV1(string path) { Path = path; } + + public static CcodTrustedImportLeaseV1 Acquire(string path) + { + if (String.IsNullOrWhiteSpace(path) || !System.IO.Path.IsPathRooted(path) || + path.StartsWith(@"\\?\", StringComparison.Ordinal) || path.StartsWith(@"\\.\", StringComparison.Ordinal)) + throw new ArgumentException("Unsupported module path"); + string full = System.IO.Path.GetFullPath(path); + string root = System.IO.Path.GetPathRoot(full); + string extension = System.IO.Path.GetExtension(full); + if (!String.Equals(full, path, StringComparison.OrdinalIgnoreCase) || String.IsNullOrEmpty(root) || + full.Substring(root.Length).IndexOf(':') >= 0 || + (!String.Equals(extension, ".psm1", StringComparison.OrdinalIgnoreCase) && !String.Equals(extension, ".ps1", StringComparison.OrdinalIgnoreCase))) + throw new ArgumentException("Noncanonical module path"); + var lease = new CcodTrustedImportLeaseV1(full); + try + { + var parents = new List(); + for (DirectoryInfo parent = Directory.GetParent(full); parent != null; parent = parent.Parent) + parents.Add(parent.FullName); + parents.Reverse(); + if (parents.Count == 0 || !SamePath(parents[0], root)) throw new IOException("Missing module root"); + // Retain root-to-leaf authority before traversing the next component. + foreach (string parent in parents) lease.Open(parent, true); + lease.Open(full, false); + lease.Revalidate(); + return lease; + } + catch { lease.Dispose(); throw; } + } + + private void Open(string path, bool directory) + { + uint access = directory ? 0x00100081U : 0x80100080U; + uint flags = 0x00200000U | (directory ? 0x02000000U : 0U); + // FILE_SHARE_READ only: preexisting writers/deleters must fail closed. + SafeFileHandle handle = CreateFileW(path, access, 1U, IntPtr.Zero, 3U, flags, IntPtr.Zero); + if (handle.IsInvalid) { int error = Marshal.GetLastWin32Error(); handle.Dispose(); throw new Win32Exception(error); } + try + { + NativeInfo info = Inspect(handle, path, directory); + entries.Add(new Entry { Handle = handle, Path = path, Directory = directory, Volume = info.Volume, FileId = FileId(info) }); + } + catch { handle.Dispose(); throw; } + } + + private static ulong FileId(NativeInfo info) { return ((ulong)info.IndexHigh << 32) | info.IndexLow; } + private static bool SamePath(string left, string right) + { + return String.Equals(left.TrimEnd('\\'), right.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase); + } + private static NativeInfo Inspect(SafeFileHandle handle, string expected, bool directory) + { + NativeInfo info; + if (!GetFileInformationByHandle(handle, out info)) throw new Win32Exception(Marshal.GetLastWin32Error()); + if ((info.Attributes & 0x400U) != 0 || ((info.Attributes & 0x10U) != 0) != directory) + throw new IOException("Unsafe module path kind"); + var value = new StringBuilder(512); + uint length = GetFinalPathNameByHandleW(handle, value, (uint)value.Capacity, 0); + if (length == 0) throw new Win32Exception(Marshal.GetLastWin32Error()); + if (length >= value.Capacity) + { + value.Capacity = checked((int)length + 1); + length = GetFinalPathNameByHandleW(handle, value, (uint)value.Capacity, 0); + if (length == 0 || length >= value.Capacity) throw new IOException("Module final path unavailable"); + } + string final = value.ToString(); + if (final.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase)) final = @"\\" + final.Substring(8); + else if (final.StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase)) final = final.Substring(4); + if (!SamePath(final, expected)) throw new IOException("Module final path mismatch"); + return info; + } + + public void Revalidate() + { + if (disposed) throw new ObjectDisposedException("trusted module import lease"); + foreach (Entry entry in entries) + { + NativeInfo info = Inspect(entry.Handle, entry.Path, entry.Directory); + if (info.Volume != entry.Volume || FileId(info) != entry.FileId) throw new IOException("Module identity changed"); + } + } + public void Dispose() + { + if (disposed) return; + disposed = true; + for (int index = entries.Count - 1; index >= 0; index--) entries[index].Handle.Dispose(); + entries.Clear(); + } +} +'@ +} + +function Open-CcodTrustedImportLease { + param([Parameter(Mandatory)][string]$Path,[Parameter(Mandatory)][string]$ErrorId) + try { + Initialize-CcodTrustedImportLease + return [CcodTrustedImportLeaseV1]::Acquire($Path) + } catch { + throw [Management.Automation.ErrorRecord]::new( + [InvalidOperationException]::new('Trusted module import authority could not be acquired.'), + $ErrorId,[Management.Automation.ErrorCategory]::InvalidData,$Path) + } +} +# END CCOD TRUSTED IMPORT BOOTSTRAP + $script:CcodInstalledLifecycleRestartScenarios = @( - 'FreshRestart','Upgrade','SlowLaunch','ManualLaunchResume','LanguageStress','RepairStates','SafeExit','SettingsUninstall','DirectUninstall' + 'FreshInstall','FreshRestart','Upgrade','SlowLaunch','ManualLaunchResume','LanguageStress','RepairStates','SafeExit','SettingsUninstall','DirectUninstall' ) $script:CcodInstalledLifecycleTaskName = 'Codex Control Other Devices Supervisor' $script:CcodInstalledLifecycleRepositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent -$script:CcodInstalledLifecyclePersistenceIoModule = Import-Module (Join-Path $script:CcodInstalledLifecycleRepositoryRoot 'src\persistence\modules\PersistenceIO.psm1') -Force -PassThru -$script:CcodInstalledLifecycleStateStoreModule = Import-Module (Join-Path $script:CcodInstalledLifecycleRepositoryRoot 'src\persistence\modules\StateStore.psm1') -Force -PassThru -$script:CcodInstalledLifecycleTransactionModule = Import-Module (Join-Path $script:CcodInstalledLifecycleRepositoryRoot 'src\persistence\modules\LifecycleTransaction.psm1') -Force -PassThru +# Pin the complete eager import closure, including StateStore's nested identity +# dependency, before any initializer runs. Imports remain in their original scope. +$importClosure = @('PersistenceIO.psm1','StateStore.psm1','TrustedLogonIdentity.psm1','LifecycleTransaction.psm1') +$importLeases = [Collections.Generic.List[IDisposable]]::new() +try { + foreach ($leaf in $importClosure) { + $path = Join-Path (Join-Path $script:CcodInstalledLifecycleRepositoryRoot 'src\persistence\modules') $leaf + $importLeases.Add((Open-CcodTrustedImportLease -Path $path -ErrorId 'CCOD_INTEGRATION_MODULE_MISSING')) + } + foreach ($lease in $importLeases) { $lease.Revalidate() } + $script:CcodInstalledLifecyclePersistenceIoModule = Import-Module (Join-Path $script:CcodInstalledLifecycleRepositoryRoot 'src\persistence\modules\PersistenceIO.psm1') -Force -PassThru + $script:CcodInstalledLifecycleStateStoreModule = Import-Module (Join-Path $script:CcodInstalledLifecycleRepositoryRoot 'src\persistence\modules\StateStore.psm1') -Force -PassThru + $script:CcodInstalledLifecycleTransactionModule = Import-Module (Join-Path $script:CcodInstalledLifecycleRepositoryRoot 'src\persistence\modules\LifecycleTransaction.psm1') -Force -PassThru + foreach ($lease in $importLeases) { $lease.Revalidate() } +} finally { + for ($index = $importLeases.Count - 1; $index -ge 0; $index--) { $importLeases[$index].Dispose() } +} function Throw-CcodInstalledLifecycleError { param( @@ -43,6 +208,20 @@ function Get-CcodInstalledLifecycleErrorId { return ($id -split ',')[0] } +function Assert-CcodInstalledLifecycleCanonicalAbsolutePath { + param([Parameter(Mandatory)][string]$Path,[Parameter(Mandatory)][string]$Kind) + if ([string]::IsNullOrWhiteSpace($Path) -or -not [IO.Path]::IsPathRooted($Path)) { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' "$Kind must be an absolute canonical path" $Path + } + try { $full = [IO.Path]::GetFullPath($Path) } catch { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' "$Kind path could not be canonicalized safely" $Path + } + if (-not [string]::Equals($full, $Path, [StringComparison]::OrdinalIgnoreCase)) { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' "$Kind must use a canonical path" $Path + } + return $full +} + function Get-CcodInstalledLifecycleHash { param([Parameter(Mandatory)][string]$Path) $sha = [Security.Cryptography.SHA256]::Create() @@ -61,6 +240,12 @@ function Test-CcodInstalledLifecycleCanonicalUtc { return $parsed.ToUniversalTime().ToString('o', [Globalization.CultureInfo]::InvariantCulture) -ceq $Value } +function Test-CcodInstalledLifecycleValidUtcClock { + param($Value) + return $Value -is [datetime] -and $Value.Kind -eq [DateTimeKind]::Utc -and + $Value -ne [datetime]::MinValue -and $Value -ne [datetime]::MaxValue +} + function ConvertTo-CcodInstalledLifecycleCreationTimeUtc { param($Value) $created = $null @@ -70,7 +255,7 @@ function ConvertTo-CcodInstalledLifecycleCreationTimeUtc { } $created = $Value } elseif ($Value -is [string]) { - if ($Value -cnotmatch '^\d{14}\.\d{6}[+-]\d{3}$') { + if ($Value -cnotmatch '^\d{14}\.\d{6}[+-]\d{3}\z') { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_FACTS_INVALID' 'An enumerated ChatGPT process has invalid creation-time evidence' $null } try { @@ -88,74 +273,245 @@ function ConvertTo-CcodInstalledLifecycleCreationTimeUtc { return $canonical } +function Get-CcodInstalledLifecycleNativeProcessCreationTimeUtc { + param([Parameter(Mandatory)][int]$ProcessId) + $process=$null + try { + $process=Get-Process -Id $ProcessId -ErrorAction Stop + if($null-eq$process-or$process.Id-ne$ProcessId-or$process.HasExited){throw 'process not current'} + $created=$process.StartTime.ToUniversalTime().ToString('o',[Globalization.CultureInfo]::InvariantCulture) + if(-not(Test-CcodInstalledLifecycleCanonicalUtc $created)){throw 'native timestamp'} + return $created + } finally {if($null-ne$process){$process.Dispose()}} +} + +function Get-CcodInstalledLifecycleCorrelatedCreationTimeUtc { + param([Parameter(Mandatory)]$Process) + try { + if(-not(Test-CcodInstalledLifecyclePositiveInteger $Process.ProcessId)-or[decimal]$Process.ProcessId-gt[int]::MaxValue){throw 'process id'} + $cim=ConvertTo-CcodInstalledLifecycleCreationTimeUtc -Value $Process.CreationDate + $native=Get-CcodInstalledLifecycleNativeProcessCreationTimeUtc -ProcessId ([int]$Process.ProcessId) + if($native-isnot[string]-or-not(Test-CcodInstalledLifecycleCanonicalUtc $native)){throw 'native timestamp'} + $style=[Globalization.DateTimeStyles]::RoundtripKind;$culture=[Globalization.CultureInfo]::InvariantCulture + $cimTicks=[datetime]::ParseExact($cim,'o',$culture,$style).Ticks + $nativeTicks=[datetime]::ParseExact($native,'o',$culture,$style).Ticks + if(($cimTicks%10)-eq0){if($nativeTicks-lt$cimTicks-or($nativeTicks-$cimTicks)-gt9){throw 'CIM identity mismatch'}} + elseif($nativeTicks-ne$cimTicks){throw 'precise identity mismatch'} + return $native + } catch { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' 'The native process creation identity does not match the enumerated CIM process.' $null + } +} + +function Test-CcodInstalledLifecycleMissingError { + param([Parameter(Mandatory)]$ErrorRecord) + return $ErrorRecord.CategoryInfo.Category -eq 'ObjectNotFound' -or + $ErrorRecord.Exception -is [IO.FileNotFoundException] -or $ErrorRecord.Exception -is [IO.DirectoryNotFoundException] +} + +function Assert-CcodInstalledLifecycleNoReparseAncestors { + param([Parameter(Mandatory)][string]$Path,[Parameter(Mandatory)][scriptblock]$GetItem) + $full = [IO.Path]::GetFullPath($Path) + $root = [IO.Path]::GetPathRoot($full) + $parent = [IO.Directory]::GetParent($full) + $current = if ($null -eq $parent) { $null } else { $parent.FullName } + while (-not [string]::IsNullOrWhiteSpace($current)) { + try { + $items = @(& $GetItem $current) + if ($items.Count -eq 1 -and $null -ne $items[0] -and (-not $items[0].PSIsContainer -or (($items[0].Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0))) { throw 'unsafe ancestry' } + } catch { + if (-not (Test-CcodInstalledLifecycleMissingError -ErrorRecord $_)) { throw } + } + if ($current.TrimEnd('\') -ceq $root.TrimEnd('\')) { break } + $nextParent = [IO.Directory]::GetParent($current) + if ($null -eq $nextParent) { break } + $next = if ($nextParent.FullName.Length -gt $root.Length) { $nextParent.FullName.TrimEnd('\') } else { $nextParent.FullName } + if ($next -ceq $current) { break } + $current = $next + } +} + function Assert-CcodInstalledLifecycleRegularFile { param([Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$Kind) if ([string]::IsNullOrWhiteSpace($Path) -or -not [IO.Path]::IsPathRooted($Path)) { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_CANDIDATE_INVALID' "$Kind must be an absolute path" $Path } $full = [IO.Path]::GetFullPath($Path) - if (-not [IO.File]::Exists($full)) { - Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_CANDIDATE_INVALID' "$Kind is missing" $full + if (-not [string]::Equals($full, $Path, [StringComparison]::OrdinalIgnoreCase)) { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_CANDIDATE_INVALID' "$Kind must use a canonical path" $Path + } + try { $item = Get-Item -LiteralPath $full -Force -ErrorAction Stop } catch { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_CANDIDATE_INVALID' "$Kind is missing or cannot be inspected" $full } - $item = Get-Item -LiteralPath $full -Force -ErrorAction Stop if ($item.PSIsContainer -or (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_CANDIDATE_INVALID' "$Kind must be a regular non-reparse file" $full } + Assert-CcodInstalledLifecycleNoReparseAncestors -Path $full -GetItem { param($CandidatePath) Get-Item -LiteralPath $CandidatePath -Force -ErrorAction Stop } return $full } +function Assert-CcodInstalledLifecycleReadableProcessText { + param([AllowNull()]$Value, [Parameter(Mandatory)][string]$Kind) + if ($Value -isnot [string] -or [string]::IsNullOrWhiteSpace([string]$Value)) { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' "$Kind could not be read safely." $null + } + return [string]$Value +} + function Assert-CcodInstalledLifecycleSafeDirectory { param([Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$Kind) + if ([string]::IsNullOrWhiteSpace($Path) -or -not [IO.Path]::IsPathRooted($Path)) { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_EVIDENCE_ROOT_INVALID' "$Kind must be an absolute path" $Path + } $full = [IO.Path]::GetFullPath($Path) - if (-not [IO.Directory]::Exists($full)) { [IO.Directory]::CreateDirectory($full) | Out-Null } - $item = Get-Item -LiteralPath $full -Force -ErrorAction Stop - if (-not $item.PSIsContainer -or (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) { + if (-not [string]::Equals($full, $Path, [StringComparison]::OrdinalIgnoreCase)) { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_EVIDENCE_ROOT_INVALID' "$Kind must use a canonical path" $Path + } + $probe = { param($CandidatePath) Get-Item -LiteralPath $CandidatePath -Force -ErrorAction Stop } + $item = $null + try { $item = & $probe $full } catch { + if (-not (Test-CcodInstalledLifecycleMissingError -ErrorRecord $_)) { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_EVIDENCE_ROOT_INVALID' "$Kind could not be inspected safely" $full + } + try { + Assert-CcodInstalledLifecycleNoReparseAncestors -Path $full -GetItem $probe + [IO.Directory]::CreateDirectory($full) | Out-Null + $item = & $probe $full + } catch { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_EVIDENCE_ROOT_INVALID' "$Kind could not be created or inspected safely" $full + } + } + Assert-CcodInstalledLifecycleNoReparseAncestors -Path $full -GetItem $probe + if ($null -eq $item -or -not $item.PSIsContainer -or (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_EVIDENCE_ROOT_INVALID' "$Kind must be a non-reparse directory" $full } return $full } +function Get-CcodInstalledLifecycleOptionalDirectoryState { + param([Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][scriptblock]$GetItem) + $full = Assert-CcodInstalledLifecycleCanonicalAbsolutePath -Path $Path -Kind 'Installed product directory' + $present = $false + try { + $items = @(& $GetItem $full) + if ($items.Count -eq 1 -and $null -ne $items[0] -and $items[0].PSIsContainer -and + ($items[0].Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) { $present = $true } + else { throw 'directory observation' } + } catch { + if (-not (Test-CcodInstalledLifecycleMissingError -ErrorRecord $_)) { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' 'An installed product directory could not be inspected safely.' $full + } + } + try { Assert-CcodInstalledLifecycleNoReparseAncestors -Path $full -GetItem $GetItem } + catch { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' 'An installed product directory ancestry could not be inspected safely.' $full } + return $present +} + +function Get-CcodInstalledLifecycleOptionalRegularFileState { + param([Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][scriptblock]$GetItem) + $full = Assert-CcodInstalledLifecycleCanonicalAbsolutePath -Path $Path -Kind 'Installed product file' + $item = $null + try { + $items = @(& $GetItem $full) + if ($items.Count -eq 1 -and $null -ne $items[0] -and -not $items[0].PSIsContainer -and + ($items[0].Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) { $item = $items[0] } + else { throw 'file observation' } + } catch { + if (-not (Test-CcodInstalledLifecycleMissingError -ErrorRecord $_)) { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' 'An installed product file could not be inspected safely.' $full + } + } + try { Assert-CcodInstalledLifecycleNoReparseAncestors -Path $full -GetItem $GetItem } + catch { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' 'An installed product file ancestry could not be inspected safely.' $full } + return $item +} + +function Write-CcodInstalledLifecycleEvidenceFile { + param([Parameter(Mandatory)][string]$EvidenceDirectory,[Parameter(Mandatory)]$Receipt,[scriptblock]$ReadbackVerifier) + $directory = Assert-CcodInstalledLifecycleSafeDirectory -Path $EvidenceDirectory -Kind 'Evidence directory' + $target = [IO.Path]::GetFullPath((Join-Path $directory 'receipt.json')) + $temporary = "$target.$([guid]::NewGuid().ToString('N')).tmp" + $published = $false + try { + $bytes = [Text.UTF8Encoding]::new($false).GetBytes(($Receipt | ConvertTo-Json -Depth 16)) + $sha = [Security.Cryptography.SHA256]::Create() + try { $expected = ([BitConverter]::ToString($sha.ComputeHash($bytes))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() } + [IO.File]::WriteAllBytes($temporary, $bytes) + [IO.File]::Move($temporary, $target) + $published = $true + $targetItem = Get-Item -LiteralPath $target -Force -ErrorAction Stop + if ($targetItem.PSIsContainer -or (($targetItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) { throw 'evidence target' } + if ($null -ne $ReadbackVerifier -and -not [bool](& $ReadbackVerifier $target)) { throw 'evidence readback' } + $parsed = [IO.File]::ReadAllText($targetItem.FullName, [Text.UTF8Encoding]::new($false, $true)) | ConvertFrom-Json -ErrorAction Stop + if ($null -eq $parsed -or (Get-CcodInstalledLifecycleHash -Path $targetItem.FullName) -cne $expected) { throw 'evidence hash readback' } + return $target + } catch { + $failure = $_ + if ($published) { + try { + $item = $null + try { $item = Get-Item -LiteralPath $target -Force -ErrorAction Stop } catch { if (-not (Test-CcodInstalledLifecycleMissingError -ErrorRecord $_)) { throw } } + if ($null -ne $item) { + if ($item.PSIsContainer -or (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) -or (Get-CcodInstalledLifecycleHash -Path $target) -cne $expected) { throw 'evidence cleanup target' } + Remove-Item -LiteralPath $target -Force -ErrorAction Stop + } + $residual = $null + try { $residual = Get-Item -LiteralPath $target -Force -ErrorAction Stop } catch { if (-not (Test-CcodInstalledLifecycleMissingError -ErrorRecord $_)) { throw } } + if ($null -ne $residual) { throw 'evidence cleanup residue' } + } catch { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_EVIDENCE_CLEANUP_FAILED' 'Failed evidence cleanup left an unverifiable destination.' $target + } + } + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_EVIDENCE_WRITE_FAILED' 'Installed lifecycle evidence could not be written and read back safely.' $target + } finally { + try { $tempItem = Get-Item -LiteralPath $temporary -Force -ErrorAction Stop; if ($null -ne $tempItem) { Remove-Item -LiteralPath $temporary -Force -ErrorAction Stop } } catch { if (-not (Test-CcodInstalledLifecycleMissingError -ErrorRecord $_)) { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_EVIDENCE_CLEANUP_FAILED' 'Temporary evidence cleanup could not be verified.' $temporary } } + } +} + function Get-CcodInstalledLifecycleDefaultAdapters { + $safeDirectory = ${function:Assert-CcodInstalledLifecycleSafeDirectory} + $hashFunction = ${function:Get-CcodInstalledLifecycleHash} + $factsFunction = ${function:Get-CcodInstalledLifecycleFacts} + $writeEvidenceFunction = ${function:Write-CcodInstalledLifecycleEvidenceFile} + $scenarioFunction = ${function:Invoke-CcodInstalledLifecycleOperatorScenario} + $verifyScenarioFunction = ${function:Test-CcodInstalledLifecycleScenario} + $rollbackFunction = ${function:Invoke-CcodInstalledLifecycleRollback} + $missingFunction = ${function:Test-CcodInstalledLifecycleMissingError} $defaults = @{} $defaults.GetGitStatus = { param($RepositoryRoot) - $status = & git -C $RepositoryRoot status --porcelain --untracked-files=all 2>$null + $status = & git -C $RepositoryRoot status --porcelain --untracked-files=all --ignored=matching 2>$null if ($LASTEXITCODE -ne 0) { throw [InvalidOperationException]::new('git status could not determine checkout cleanliness') } return @($status | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }) }.GetNewClosure() - $defaults.GetFileSha256 = { param($Path) Get-CcodInstalledLifecycleHash -Path $Path }.GetNewClosure() + $defaults.GetFileSha256 = { param($Path) & $hashFunction -Path $Path }.GetNewClosure() $defaults.ReadText = { param($Path) [IO.File]::ReadAllText($Path) }.GetNewClosure() $defaults.NewEvidenceDirectory = { param($Root, $TransactionId) - $safeRoot = Assert-CcodInstalledLifecycleSafeDirectory -Path $Root -Kind 'Evidence root' + $safeRoot = & $safeDirectory -Path $Root -Kind 'Evidence root' $directory = [IO.Path]::GetFullPath((Join-Path $safeRoot ('installed-lifecycle-' + $TransactionId))) - if ([IO.Directory]::Exists($directory) -or [IO.File]::Exists($directory)) { - Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_EVIDENCE_EXISTS' 'The generated evidence directory already exists' $directory + $probe = { param($CandidatePath) Get-Item -LiteralPath $CandidatePath -Force -ErrorAction Stop } + try { + $existing = & $probe $directory + if ($null -ne $existing) { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_EVIDENCE_EXISTS' 'The generated evidence directory already exists' $directory } + } catch { + if (-not (& $missingFunction -ErrorRecord $_)) { throw } } [IO.Directory]::CreateDirectory($directory) | Out-Null - return Assert-CcodInstalledLifecycleSafeDirectory -Path $directory -Kind 'Evidence directory' + return & $safeDirectory -Path $directory -Kind 'Evidence directory' }.GetNewClosure() $defaults.WriteEvidence = { - param($EvidenceDirectory, $Receipt) - $directory = Assert-CcodInstalledLifecycleSafeDirectory -Path $EvidenceDirectory -Kind 'Evidence directory' - $target = [IO.Path]::GetFullPath((Join-Path $directory 'receipt.json')) - $temporary = "$target.$([guid]::NewGuid().ToString('N')).tmp" - try { - [IO.File]::WriteAllText($temporary, ($Receipt | ConvertTo-Json -Depth 16), [Text.UTF8Encoding]::new($false)) - [IO.File]::Move($temporary, $target) - return $target - } finally { - if ([IO.File]::Exists($temporary)) { Remove-Item -LiteralPath $temporary -Force -ErrorAction SilentlyContinue } - } + param($EvidenceDirectory, $Receipt, $ReadbackVerifier) + & $writeEvidenceFunction -EvidenceDirectory $EvidenceDirectory -Receipt $Receipt -ReadbackVerifier $ReadbackVerifier }.GetNewClosure() - $defaults.CaptureFacts = { param($InstallRoot) Get-CcodInstalledLifecycleFacts -InstallRoot $InstallRoot }.GetNewClosure() + $defaults.CaptureFacts = { param($InstallRoot,$ExpectedVersion) & $factsFunction -InstallRoot $InstallRoot -ExpectedVersion $ExpectedVersion }.GetNewClosure() $defaults.CreateRollbackSnapshot = { param($Context, $Facts) return [pscustomobject][ordered]@{ kind = 'observation'; transactionId = $Context.transactionId; beforeFacts = $Facts } }.GetNewClosure() - $defaults.RunScenario = { param($Context) Invoke-CcodInstalledLifecycleOperatorScenario -Context $Context }.GetNewClosure() - $defaults.VerifyScenario = { param($Context, $BeforeFacts, $RunResult) Test-CcodInstalledLifecycleScenario -Context $Context -BeforeFacts $BeforeFacts -RunResult $RunResult }.GetNewClosure() - $defaults.Rollback = { param($Context, $Snapshot) Invoke-CcodInstalledLifecycleRollback -Context $Context -Snapshot $Snapshot }.GetNewClosure() + $defaults.RunScenario = { param($Context) & $scenarioFunction -Context $Context }.GetNewClosure() + $defaults.VerifyScenario = { param($Context, $BeforeFacts, $RunResult) & $verifyScenarioFunction -Context $Context -BeforeFacts $BeforeFacts -RunResult $RunResult }.GetNewClosure() + $defaults.Rollback = { param($Context, $Snapshot) & $rollbackFunction -Context $Context -Snapshot $Snapshot }.GetNewClosure() $defaults.CleanupRollback = { param($Context, $Snapshot) return $true }.GetNewClosure() $defaults.GetUtcNow = { [datetime]::UtcNow }.GetNewClosure() return $defaults @@ -174,20 +530,106 @@ function Resolve-CcodInstalledLifecycleAdapters { return $resolved } +function Get-CcodInstalledLifecycleExecutionAssetModule { + $path = Join-Path $script:CcodInstalledLifecycleRepositoryRoot 'tools\ReleaseAssetContract.psm1' + $lease = Open-CcodTrustedImportLease -Path $path -ErrorId 'CCOD_INTEGRATION_MODULE_MISSING' + try { + $lease.Revalidate() + $module = Import-Module $path -PassThru -DisableNameChecking -ErrorAction Stop + $lease.Revalidate() + return $module + } finally { $lease.Dispose() } +} + +function Close-CcodInstalledLifecycleExecutionLease { + param($Lease) + if ($null -eq $Lease -or $Lease.Closed) { return } + for ($index = $Lease.Files.Count - 1; $index -ge 0; $index--) { $Lease.Files[$index].Stream.Dispose() } + for ($index = $Lease.Parents.Count - 1; $index -ge 0; $index--) { $Lease.Parents[$index].Handle.Dispose() } + $Lease.Closed = $true +} + +function Assert-CcodInstalledLifecycleExecutionLease { + param([Parameter(Mandatory)]$Lease) + try { + if ($Lease.Closed) { throw 'closed execution authority' } + foreach ($entry in @($Lease.Parents) + @($Lease.Files)) { + $handle = if ($entry.Directory) { $entry.Handle } else { $entry.Stream.SafeFileHandle } + $current = [CcodReleaseFileAuthorityV1]::Identity($handle) + if ([CcodReleaseFileAuthorityV1]::IsReparse($current) -or [CcodReleaseFileAuthorityV1]::IsDirectory($current) -ne $entry.Directory -or + $current.Volume -ne $entry.Identity.Volume -or $current.FileId -ne $entry.Identity.FileId -or + -not $current.FinalPath.TrimEnd('\').Equals($entry.Path.TrimEnd('\'),[StringComparison]::OrdinalIgnoreCase)) { throw 'execution identity changed' } + if (-not $entry.Directory) { + if ($current.Links -ne 1 -or $current.Attributes -ne $entry.Identity.Attributes -or $entry.Stream.Length -ne $entry.Length -or + -not (& $Lease.Module {param($Value) Test-CcodReleaseAuthorityStreams -Identity $Value -AllowZone} $current) -or + (& $Lease.Module {param($Stream) Get-CcodReleaseAuthorityStreamSha256 -Stream $Stream} $entry.Stream) -cne $entry.Sha256) { throw 'execution bytes changed' } + } + } + } catch { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_CANDIDATE_INVALID' 'Held execution files changed or became unavailable.' $null } +} + +function Open-CcodInstalledLifecycleExecutionLease { + param([Parameter(Mandatory)][string[]]$Paths) + $lease = [pscustomobject]@{ Module = (Get-CcodInstalledLifecycleExecutionAssetModule); Files = [Collections.Generic.List[object]]::new(); Parents = [Collections.Generic.List[object]]::new(); Closed = $false } + try { + if ($Paths.Count -eq 0) { throw 'execution paths empty' } + $seenParents = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + $seenFiles = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($path in $Paths) { + $full = Assert-CcodInstalledLifecycleCanonicalAbsolutePath -Path $path -Kind 'Execution file' + if (-not $seenFiles.Add($full)) { throw 'duplicate execution file' } + $parents = [Collections.Generic.List[string]]::new() + for ($parent = [IO.Directory]::GetParent($full); $null -ne $parent; $parent = $parent.Parent) { $parents.Insert(0,$parent.FullName) } + foreach ($directory in $parents) { + if (-not $seenParents.Add($directory)) { continue } + $handle = [CcodReleaseFileAuthorityV1]::OpenDirectory($directory,$true) + $entry = [pscustomobject]@{ Directory = $true; Path = $directory; Handle = $handle; Identity = $null } + $lease.Parents.Add($entry) + $entry.Identity = [CcodReleaseFileAuthorityV1]::Identity($handle) + if ([CcodReleaseFileAuthorityV1]::IsReparse($entry.Identity) -or -not [CcodReleaseFileAuthorityV1]::IsDirectory($entry.Identity) -or + -not $entry.Identity.FinalPath.TrimEnd('\').Equals($directory.TrimEnd('\'),[StringComparison]::OrdinalIgnoreCase)) { throw 'execution ancestor' } + } + $stream = [CcodReleaseFileAuthorityV1]::OpenReadFile($full,$false) + $entry = [pscustomobject]@{ Directory = $false; Path = $full; Stream = $stream; Identity = $null; Length = $stream.Length; Sha256 = $null } + $lease.Files.Add($entry) + $entry.Identity = [CcodReleaseFileAuthorityV1]::Identity($stream.SafeFileHandle) + if ($entry.Length -lt 1 -or $entry.Length -gt 536870912) { throw 'execution bounds' } + $entry.Sha256 = & $lease.Module {param($Stream) Get-CcodReleaseAuthorityStreamSha256 -Stream $Stream} $stream + } + Assert-CcodInstalledLifecycleExecutionLease $lease + return $lease + } catch { + Close-CcodInstalledLifecycleExecutionLease $lease + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_CANDIDATE_INVALID' 'Execution file authority could not be acquired safely.' $null + } +} + function Get-CcodInstalledLifecycleCandidate { param( [Parameter(Mandatory)][string]$Path, [string]$ExpectedVersion, [Parameter(Mandatory)][hashtable]$Adapters, [Parameter(Mandatory)][string]$Kind, - [switch]$AllowAnyVersion + [switch]$AllowAnyVersion, + [ref]$ExecutionLease ) $installer = Assert-CcodInstalledLifecycleRegularFile -Path $Path -Kind $Kind $leaf = [IO.Path]::GetFileName($installer) - $versionMatch = [regex]::Match($leaf, '^CodexRemote-fix-(\d+\.\d+\.\d+)-setup\.exe$') + $versionMatch = [regex]::Match($leaf, '^CodexRemote-fix-(\d+\.\d+\.\d+)-setup\.exe\z') if (-not $versionMatch.Success -or (-not $AllowAnyVersion -and ([string]::IsNullOrWhiteSpace($ExpectedVersion) -or $versionMatch.Groups[1].Value -cne $ExpectedVersion))) { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_CANDIDATE_INVALID' "$Kind filename does not bind the expected version" $installer } + $executionPaths = @($installer,"$installer.sha256.txt") + if ($Kind -ceq 'Installer') { + if ([version]$versionMatch.Groups[1].Value -ge [version]'2.5.22') { + $executionModule = Get-CcodInstalledLifecycleExecutionAssetModule + $executionNames = @(& $executionModule {param($Version) Get-CcodExpectedReleaseAssetNames -Version $Version} $versionMatch.Groups[1].Value) + $executionPaths = @($executionNames | ForEach-Object { Join-Path (Split-Path $installer -Parent) $_ }) + } else { $executionPaths += Join-Path (Split-Path $installer -Parent) ("CodexRemote-fix-{0}-setup-payload-manifest.json" -f $versionMatch.Groups[1].Value) } + } + $heldExecution = Open-CcodInstalledLifecycleExecutionLease -Paths $executionPaths + $transferred = $false + try { $checksum = Assert-CcodInstalledLifecycleRegularFile -Path "$installer.sha256.txt" -Kind "$Kind checksum" $text = & $Adapters.ReadText $checksum if ($text -isnot [string]) { @@ -199,10 +641,221 @@ function Get-CcodInstalledLifecycleCandidate { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_CHECKSUM_INVALID' "$Kind checksum is malformed or names a different asset" $checksum } $actualHash = [string](& $Adapters.GetFileSha256 $installer) - if ($actualHash -cnotmatch '^[0-9a-f]{64}$' -or $actualHash -cne $match.Groups[1].Value) { + if ($actualHash -cnotmatch '^[0-9a-f]{64}\z' -or $actualHash -cne $match.Groups[1].Value) { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_CHECKSUM_INVALID' "$Kind checksum does not match the exact installer bytes" $installer } - return [pscustomobject][ordered]@{ Path = $installer; Version = $versionMatch.Groups[1].Value; Sha256 = $actualHash; ChecksumPath = $checksum } + $payloadManifestPath = $null + $payloadManifestHash = $null + $payloadFiles = @() + $candidateCommit = $null + $candidateAssets = @() + if ($Kind -ceq 'Installer') { + $payloadManifestPath = Assert-CcodInstalledLifecycleRegularFile -Path (Join-Path (Split-Path $installer -Parent) ("CodexRemote-fix-{0}-setup-payload-manifest.json" -f $versionMatch.Groups[1].Value)) -Kind "$Kind payload manifest" + if ([version]$versionMatch.Groups[1].Value -ge [version]'2.5.22') { + $lease=$null;$authority=$null;$contractModule=$null + try { + $contractPath=Join-Path $script:CcodInstalledLifecycleRepositoryRoot 'tools\ReleaseAssetContract.psm1' + $lease=Open-CcodTrustedImportLease -Path $contractPath -ErrorId 'CCOD_INTEGRATION_MODULE_MISSING' + $lease.Revalidate();$contractModule=Import-Module $contractPath -PassThru -ErrorAction Stop;$lease.Revalidate() + $directory=Split-Path $installer -Parent + $authority=&$contractModule {param($Directory,$Version)Open-CcodExactReleaseAssetAuthority -AssetDirectory $Directory -Version $Version -ErrorId 'CCOD_INTEGRATION_CANDIDATE_INVALID'} $directory $versionMatch.Groups[1].Value + $contract=&$contractModule {param($Directory,$Version)Test-CcodExactReleaseAssetSet -AssetDirectory $Directory -Version $Version} $directory $versionMatch.Groups[1].Value + if($contract.Valid-isnot[bool]-or-not$contract.Valid-or$authority.Files[5].Sha256-cne$actualHash){throw 'sealed installer binding'} + $package=&$contractModule {param($File)(Read-CcodReleaseContractPinnedJson -Authority $File -ErrorId 'CCOD_INTEGRATION_CANDIDATE_INVALID').Value} $authority.Files[8] + $payloadFiles=@($package.files) + if($payloadFiles.Count-eq0-or-not(Test-CcodInstalledLifecycleFileRecordSet -Actual $payloadFiles -Expected $payloadFiles)){throw 'sealed payload records'} + $payloadManifestHash=[string]$authority.Files[8].Sha256 + $candidateCommit=$contract.GitCommit + $candidateAssets=@($authority.Files | ForEach-Object { [pscustomobject][ordered]@{ name=$_.Leaf; sha256=$_.Sha256 } }) + &$contractModule {param($Authority)Assert-CcodExactReleaseAssetAuthorityCurrent -Authority $Authority -ErrorId 'CCOD_INTEGRATION_CANDIDATE_INVALID'|Out-Null} $authority + $lease.Revalidate() + } catch { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_CANDIDATE_INVALID' 'The sealed installer payload is not bound to the complete release asset contract.' $payloadManifestPath + } finally { + if($null-ne$authority){&$contractModule {param($Authority)Close-CcodExactReleaseAssetAuthority $Authority} $authority} + if($null-ne$lease){$lease.Dispose()} + } + } else { + $payloadText = & $Adapters.ReadText $payloadManifestPath + try { + $payloadUnique = & $script:CcodInstalledLifecyclePersistenceIoModule { param($Json) Test-CcodJsonHasNoDuplicateProperties -Json $Json } $payloadText + $payloadManifest = $payloadText | ConvertFrom-Json -ErrorAction Stop + } catch { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_CANDIDATE_INVALID' 'Installer payload manifest is malformed.' $payloadManifestPath } + if ($payloadText -isnot [string] -or -not $payloadUnique -or -not (Test-CcodInstalledLifecycleExactProperties -Value $payloadManifest -Expected @('schemaVersion','projectVersion','files')) -or + $payloadManifest.schemaVersion -isnot [int] -or $payloadManifest.schemaVersion -ne 1 -or $payloadManifest.projectVersion -isnot [string] -or $payloadManifest.projectVersion -cne $versionMatch.Groups[1].Value) { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_CANDIDATE_INVALID' 'Installer payload manifest is not bound to the candidate version.' $payloadManifestPath + } + $payloadFiles = @($payloadManifest.files) + if ($payloadFiles.Count -eq 0 -or -not (Test-CcodInstalledLifecycleFileRecordSet -Actual $payloadFiles -Expected $payloadFiles)) { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_CANDIDATE_INVALID' 'Installer payload manifest file records are not canonical.' $payloadManifestPath + } + $payloadManifestHash = [string](& $Adapters.GetFileSha256 $payloadManifestPath) + if ($payloadManifestHash -cnotmatch '^[0-9a-f]{64}\z') { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_CANDIDATE_INVALID' 'Installer payload manifest hash is invalid.' $payloadManifestPath } + } + } + Assert-CcodInstalledLifecycleExecutionLease $heldExecution + if ($null -ne $ExecutionLease) { $ExecutionLease.Value = $heldExecution; $transferred = $true } + return [pscustomobject][ordered]@{ Path = $installer; Version = $versionMatch.Groups[1].Value; Sha256 = $actualHash; ChecksumPath = $checksum; PayloadManifestPath = $payloadManifestPath; PayloadManifestSha256 = $payloadManifestHash; PayloadFiles = @($payloadFiles); GitCommit = $candidateCommit; AssetHashes = @($candidateAssets) } + } finally { if (-not $transferred) { Close-CcodInstalledLifecycleExecutionLease $heldExecution } } +} + +function Assert-CcodInstalledLifecycleExpectedCandidate { + param([Parameter(Mandatory)]$Candidate,$Expected) + try { + if (-not (Test-CcodInstalledLifecycleExactProperties $Expected @('version','gitCommit','assetHashes','manifestHashes')) -or + $Expected.version -isnot [string] -or $Expected.version -cne $Candidate.Version -or + $Expected.gitCommit -isnot [string] -or $Expected.gitCommit -cnotmatch '^[0-9a-f]{40}\z' -or + $Expected.gitCommit -cne $Candidate.GitCommit -or $Expected.assetHashes -isnot [array] -or + $Expected.assetHashes.Count -ne 11 -or $Candidate.AssetHashes.Count -ne 11) { throw 'candidate metadata' } + for ($index=0; $index -lt 11; $index++) { + $asset=$Expected.assetHashes[$index];$actual=$Candidate.AssetHashes[$index] + if (-not (Test-CcodInstalledLifecycleExactProperties $asset @('name','sha256')) -or + $asset.name -isnot [string] -or $asset.name -cne $actual.name -or + $asset.sha256 -isnot [string] -or $asset.sha256 -cnotmatch '^[0-9a-f]{64}\z' -or $asset.sha256 -cne $actual.sha256) { throw 'candidate asset' } + } + if (-not (Test-CcodInstalledLifecycleExactProperties $Expected.manifestHashes @('portable','setup')) -or + $Expected.manifestHashes.portable -isnot [string] -or $Expected.manifestHashes.portable -cne $Candidate.AssetHashes[4].sha256 -or + $Expected.manifestHashes.setup -isnot [string] -or $Expected.manifestHashes.setup -cne $Candidate.AssetHashes[10].sha256 -or + $Candidate.Sha256 -cne $Expected.assetHashes[5].sha256) { throw 'candidate manifest' } + } catch { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_CANDIDATE_MISMATCH' 'The held installer does not match the original acceptance candidate.' $null } +} + +function Get-CcodInstalledLifecycleLegacyTrayReadyProof { + param([Parameter(Mandatory)][string]$InstallRoot,[Parameter(Mandatory)][string]$RuntimeId,[Parameter(Mandatory)]$Supervisor,[Parameter(Mandatory)]$TrayHost) + $lease=$null;$event=$null + try { + if($RuntimeId-cnotmatch '^2\.5\.21-[0-9a-f]{16}\z'-or@($Supervisor).Count-ne1-or@($TrayHost).Count-ne1){throw 'legacy identity profile'} + $root=Assert-CcodInstalledLifecycleCanonicalAbsolutePath -Path $InstallRoot -Kind 'Legacy install root' + $runtimeRoot=Join-Path (Join-Path $root 'runtime') $RuntimeId + $scriptPath=[IO.Path]::GetFullPath((Join-Path $runtimeRoot 'src\persistence\Supervisor.ps1')) + $hostPath=[IO.Path]::GetFullPath((Join-Path $runtimeRoot 'bin\CodexRemote.TrayHost.exe')) + $powershellPath=[IO.Path]::GetFullPath((Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe')) + $current=[Diagnostics.Process]::GetCurrentProcess();try{$sessionId=$current.SessionId}finally{$current.Dispose()} + $windowsIdentity=[Security.Principal.WindowsIdentity]::GetCurrent();try{$sid=$windowsIdentity.User.Value}finally{$windowsIdentity.Dispose()} + $parentIdentity=@(ConvertTo-CcodInstalledLifecycleIdentities -Records $Supervisor -Kind 'Legacy supervisor')[0] + $hostIdentity=@(ConvertTo-CcodInstalledLifecycleIdentities -Records $TrayHost -Kind 'Legacy tray')[0] + $readSnapshot={ + $parents=@(Get-CimInstance -ClassName Win32_Process -Filter ('ProcessId = '+$parentIdentity.pid) -ErrorAction Stop) + $hosts=@(Get-CimInstance -ClassName Win32_Process -Filter ('ProcessId = '+$hostIdentity.pid) -ErrorAction Stop) + if($parents.Count-ne1-or$hosts.Count-ne1){throw 'legacy process count'} + $parent=$parents[0];$child=$hosts[0] + foreach($entry in @(@($parent,$parentIdentity,'powershell.exe',$powershellPath),@($child,$hostIdentity,'CodexRemote.TrayHost.exe',$hostPath))){ + $process=$entry[0];$expected=$entry[1] + if($process.Name-isnot[string]-or$process.Name-cne$entry[2]-or + -not(Test-CcodInstalledLifecyclePositiveInteger $process.ProcessId)-or[long]$process.ProcessId-ne$expected.pid-or + (Get-CcodInstalledLifecycleCorrelatedCreationTimeUtc -Process $process)-cne$expected.creationTimeUtc-or + -not(Test-CcodInstalledLifecyclePositiveInteger $process.SessionId -AllowZero)-or[long]$process.SessionId-ne$sessionId-or + $process.ExecutablePath-isnot[string]-or$process.ExecutablePath-ine$entry[3]-or + $process.CommandLine-isnot[string]-or[string]::IsNullOrWhiteSpace($process.CommandLine)){throw 'legacy process identity'} + } + if(-not(Test-CcodInstalledLifecyclePositiveInteger $child.ParentProcessId)-or[long]$child.ParentProcessId-ne$parentIdentity.pid){throw 'legacy parent pid'} + Initialize-CcodInstalledLifecycleCommandLineParser + $parentArgs=@([CcodInstalledLifecycleCommandLine]::Parse($parent.CommandLine)) + $childArgs=@([CcodInstalledLifecycleCommandLine]::Parse($child.CommandLine)) + if($parentArgs.Count-ne9-or$parentArgs[0]-ine$powershellPath-or + ($parentArgs[1..5]-join',')-cne'-NoProfile,-ExecutionPolicy,Bypass,-STA,-File'-or + $parentArgs[6]-ine$scriptPath-or$parentArgs[7]-cne'-ReadyToken'-or$parentArgs[8]-cnotmatch'^[0-9a-f]{64}\z'){throw 'legacy supervisor argv'} + $parentCreated=[datetime]::ParseExact($parentIdentity.creationTimeUtc,'o',[Globalization.CultureInfo]::InvariantCulture,[Globalization.DateTimeStyles]::RoundtripKind) + $expectedChild=@($hostPath,'--child','--parent-pid',$parentIdentity.pid.ToString([Globalization.CultureInfo]::InvariantCulture),'--parent-created',$parentCreated.ToFileTimeUtc().ToString([Globalization.CultureInfo]::InvariantCulture),'--runtime-id',$RuntimeId) + if($childArgs.Count-ne$expectedChild.Count-or$childArgs[0]-ine$hostPath-or($childArgs[1..7]-join[char]0)-cne($expectedChild[1..7]-join[char]0)){throw 'legacy host argv'} + $status=Read-CcodInstalledLifecycleStatusFact -StateRoot (Join-Path $root 'state') + $active=Read-CcodInstalledLifecycleActiveFact -InstallRoot $root + if($null-eq$active-or$active.activeRuntime-cne$RuntimeId-or$null-eq$status-or$null-eq$status.session-or$status.session.supervisorPid-ne$parentIdentity.pid-or + $status.session.supervisorCreationTimeUtc-cne$parentIdentity.creationTimeUtc-or$status.session.runtimeId-cne$RuntimeId-or + $status.session.sessionId-cne$sessionId.ToString([Globalization.CultureInfo]::InvariantCulture)-or$status.session.sessionState-cne'Active'){throw 'legacy status identity'} + return [string]$parentArgs[8] + } + $token=&$readSnapshot + $manifestPath=Assert-CcodInstalledLifecycleRegularFile -Path (Join-Path $runtimeRoot 'manifest.json') -Kind 'Legacy runtime manifest' + $manifestHash=Get-CcodInstalledLifecycleHash $manifestPath + $manifest=Read-CcodInstalledLifecycleStrictJson -Path $manifestPath -ExpectedSchema 1 -Kind 'Legacy runtime manifest' + [void](Assert-CcodInstalledLifecycleRuntimeManifest -Manifest $manifest -ExpectedVersion '2.5.21' -ExpectedRuntimeId $RuntimeId -RuntimeRoot $runtimeRoot) + foreach($required in @('bin/CodexRemote.TrayHost.exe','src/persistence/Supervisor.ps1')){if(@($manifest.files|Where-Object {$_.path-ceq$required}).Count-ne1){throw 'legacy required runtime file'}} + $kernelPath=Join-Path $script:CcodInstalledLifecycleRepositoryRoot 'src\persistence\modules\KernelObjects.psm1' + $lease=Open-CcodTrustedImportLease -Path $kernelPath -ErrorId 'CCOD_INTEGRATION_MODULE_MISSING' + $lease.Revalidate();$kernel=Import-Module $kernelPath -PassThru -ErrorAction Stop;$lease.Revalidate() + $event=&$kernel {param($Sid,$Session,$Token)Open-CcodEvent -Kind Ready -UserSid $Sid -SessionId $Session -ReadyToken $Token} $sid $sessionId $token + if($event.Handle-isnot[Threading.EventWaitHandle]-or$event.CreatedNew-isnot[bool]-or$event.CreatedNew){throw 'legacy existing event'} + $ready=$event.Handle.WaitOne(0) + if((&$readSnapshot)-cne$token-or(Get-CcodInstalledLifecycleHash $manifestPath)-cne$manifestHash){throw 'legacy observation drift'} + $lease.Revalidate() + return [bool]$ready + } catch { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' 'Legacy readiness is not bound to the current runtime, parent, host and protected startup event.' $RuntimeId + } finally { + if($null-ne$event-and$null-ne$event.Handle){$event.Handle.Dispose()} + if($null-ne$lease){$lease.Dispose()} + } +} + +function Get-CcodInstalledLifecycleTrayHostReadyProof { + param([Parameter(Mandatory)][string]$InstallRoot,[Parameter(Mandatory)][string]$RuntimeId,[Parameter(Mandatory)]$TrayHost) + if (@($TrayHost).Count -ne 1) { return $false } + if ($RuntimeId -cmatch '^2\.5\.21-[0-9a-f]{16}\z') { + $status=Read-CcodInstalledLifecycleStatusFact -StateRoot (Join-Path $InstallRoot 'state') + if($null-eq$status-or$null-eq$status.session){return $false} + $parent=@([pscustomobject]@{Pid=$status.session.supervisorPid;CreationTimeUtc=$status.session.supervisorCreationTimeUtc}) + return Get-CcodInstalledLifecycleLegacyTrayReadyProof -InstallRoot $InstallRoot -RuntimeId $RuntimeId -Supervisor $parent -TrayHost $TrayHost + } + $logPath = Join-Path $InstallRoot 'logs\supervisor.log' + $logItem = Get-CcodInstalledLifecycleOptionalRegularFileState -Path $logPath -GetItem { param($CandidatePath) Get-Item -LiteralPath $CandidatePath -Force -ErrorAction Stop } + if ($null -eq $logItem) { return $false } + try { + $text = [IO.File]::ReadAllText($logItem.FullName,[Text.UTF8Encoding]::new($false,$true)) + $matches = [Collections.Generic.List[object]]::new() + foreach ($line in @($text -split "`r?`n")) { + if ([string]::IsNullOrWhiteSpace($line)) { continue } + $unique = & $script:CcodInstalledLifecyclePersistenceIoModule { param($Json) Test-CcodJsonHasNoDuplicateProperties -Json $Json } $line + if (-not $unique) { throw 'ready log duplicate properties' } + $record = $line | ConvertFrom-Json -ErrorAction Stop + if ($record.stage -isnot [string]) { throw 'supervisor log stage' } + if ($record.stage -cne 'TrayHostReady') { + $fields = @('schemaVersion','timestampUtc','component','stage','code','outcome') + if ($record.stage -ceq 'TrayAction') { + $fields += @('command','revision','status') + if ($record.command -isnot [string] -or [string]::IsNullOrWhiteSpace($record.command) -or + -not (Test-CcodInstalledLifecyclePositiveInteger $record.revision) -or + $record.status -isnot [string] -or @('Completed','Rejected','Failed') -cnotcontains $record.status -or + $record.outcome -cne $record.status -or + ($record.status -ceq 'Completed' -and $record.code -cne 'CCOD_TRAY_ACTION_COMPLETED')) { throw 'tray action log shape' } + } elseif (@('LeaseAcquire','RendererHandoff','SafeExitRecovery','LanguageChange','ErrorDialog','TrayCallback') -ccontains $record.stage) { + $expectedEvent = switch -CaseSensitive ($record.stage) { + 'LeaseAcquire' { @('CCOD_SUPERVISOR_LEASE_ABANDONED','Warning') } + 'RendererHandoff' { @('CCOD_RENDERER_HANDOFF_FAILED','Failed') } + 'SafeExitRecovery' { @('CCOD_SUPERVISOR_SAFE_EXIT_RECOVERY_PERSIST_FAILED','Failed') } + 'LanguageChange' { @('CCOD_LANGUAGE_CHANGE_ROLLED_BACK','Failed') } + 'ErrorDialog' { @('CCOD_TRAY_ACTION_RESULT_FAILED','Failed') } + 'TrayCallback' { @('CCOD_TRAY_CALLBACK_FAILED','Failed') } + } + if ($record.code -cne $expectedEvent[0] -or $record.outcome -cne $expectedEvent[1]) { throw 'supervisor event log shape' } + } else { throw 'unsupported supervisor log stage' } + if (-not (Test-CcodInstalledLifecycleExactProperties -Value $record -Expected $fields) -or + $record.schemaVersion -isnot [int] -or $record.schemaVersion -ne 1 -or + $record.timestampUtc -isnot [string] -or -not (Test-CcodInstalledLifecycleCanonicalUtc $record.timestampUtc) -or + $record.component -isnot [string] -or $record.component -cne 'Supervisor' -or + $record.code -isnot [string] -or -not [regex]::IsMatch($record.code,'^CCOD_[A-Z0-9_]{1,91}\z') -or + $record.outcome -isnot [string]) { throw 'supervisor log frame' } + continue + } + if (-not (Test-CcodInstalledLifecycleExactProperties -Value $record -Expected @('schemaVersion','timestampUtc','component','stage','code','outcome','runtimeId','hostPid','hostCreationTimeUtc','protocolMajor','capabilities')) -or + $record.schemaVersion -isnot [int] -or $record.schemaVersion -ne 1 -or + $record.timestampUtc -isnot [string] -or -not (Test-CcodInstalledLifecycleCanonicalUtc $record.timestampUtc) -or + $record.component -isnot [string] -or $record.component -cne 'Supervisor' -or + $record.stage -isnot [string] -or $record.stage -cne 'TrayHostReady' -or + $record.code -isnot [string] -or $record.code -cne 'CCOD_TRAYHOST_READY' -or + $record.outcome -isnot [string] -or $record.outcome -cne 'Completed' -or + $record.runtimeId -isnot [string] -or [string]::IsNullOrWhiteSpace($record.runtimeId) -or + $record.hostPid -isnot [int] -or $record.hostPid -le 0 -or + $record.hostCreationTimeUtc -isnot [string] -or -not (Test-CcodInstalledLifecycleCanonicalUtc $record.hostCreationTimeUtc) -or + (($record.protocolMajor -isnot [int]) -and ($record.protocolMajor -isnot [uint16])) -or $record.protocolMajor -ne 2 -or + (($record.capabilities -isnot [int]) -and ($record.capabilities -isnot [long]) -and ($record.capabilities -isnot [uint64])) -or [uint64]$record.capabilities -eq 0) { throw 'ready log shape' } + if ($record.runtimeId -ceq $RuntimeId -and $record.hostPid -eq [int]$TrayHost[0].Pid -and $record.hostCreationTimeUtc -ceq [string]$TrayHost[0].CreationTimeUtc) { $matches.Add($record) } + } + return $matches.Count -gt 0 + } catch { + if ($_.FullyQualifiedErrorId -like 'CCOD_INTEGRATION_*') { throw } + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' 'TrayHost readiness evidence could not be read safely.' $logPath + } } function ConvertTo-CcodInstalledLifecycleIdentities { @@ -221,10 +874,80 @@ function ConvertTo-CcodInstalledLifecycleIdentities { function Test-CcodInstalledLifecycleExactProperties { param($Value, [Parameter(Mandatory)][string[]]$Expected) if ($null -eq $Value -or ($Value -isnot [pscustomobject] -and $Value -isnot [Collections.IDictionary])) { return $false } - $actual = if ($Value -is [Collections.IDictionary]) { @($Value.Keys | ForEach-Object { [string]$_ }) } else { @($Value.PSObject.Properties.Name) } + if ($Value -is [Collections.IDictionary]) { $actual = @($Value.Keys | ForEach-Object { [string]$_ }) } + else { $actual = @($Value.PSObject.Properties.Name) } return $actual.Count -eq $Expected.Count -and ($actual -join "`0") -ceq ($Expected -join "`0") } +function Get-CcodInstalledLifecycleProductState { + param([Parameter(Mandatory)][string]$RuntimeId) + $lease=$null + try { + $path=Join-Path $script:CcodInstalledLifecycleRepositoryRoot 'src\persistence\modules\ProductRegistration.psm1' + $lease=Open-CcodTrustedImportLease -Path $path -ErrorId 'CCOD_INTEGRATION_MODULE_MISSING' + $lease.Revalidate();$module=Import-Module $path -PassThru -DisableNameChecking -ErrorAction Stop;$lease.Revalidate() + $values=@(&$module {param($Id)Read-CcodCurrentProductState -ExpectedRuntimeId $Id} $RuntimeId) + $lease.Revalidate() + if($values.Count-ne1){throw 'product state count'} + return $values[0] + } finally {if($null-ne$lease){$lease.Dispose()}} +} + +function Test-CcodInstalledLifecycleCandidateFileSet { + param($Actual,$Expected,[string]$InstallRoot,[string]$RuntimeId,$Generation,[string]$ManifestSha256) + try { + if($RuntimeId-cnotmatch'^2\.5\.22-[0-9a-f]{16}-[0-9a-f]{32}\z'){return Test-CcodInstalledLifecycleFileRecordSet -Actual $Actual -Expected $Expected} + if(-not(Test-CcodInstalledLifecycleFileRecordSet -Actual $Actual -Expected $Actual)-or + -not(Test-CcodInstalledLifecycleFileRecordSet -Actual $Expected -Expected $Expected)-or + -not(Test-CcodInstalledLifecyclePositiveInteger $Generation)-or$ManifestSha256-cnotmatch'^[0-9a-f]{64}\z'){return $false} + $root=Assert-CcodInstalledLifecycleCanonicalAbsolutePath -Path $InstallRoot -Kind 'Product root' + $state=Get-CcodInstalledLifecycleProductState -RuntimeId $RuntimeId + if($state.valid-isnot[bool]-or-not$state.valid-or@($state.entries).Count-ne3-or$null-eq$state.readyEvidence){return $false} + $proof=$state.readyEvidence + $fields=@('phase','installRoot','runtimeId','runtimeGeneration','packageSha256','manifestSha256','startMenuSha256','desktopSha256','targetPath','arguments') + if(-not(Test-CcodInstalledLifecycleExactProperties -Value $proof -Expected $fields)-or$proof.phase-cne'Ready'-or + $proof.installRoot-isnot[string]-or$proof.installRoot-cne$root-or$proof.runtimeId-isnot[string]-or$proof.runtimeId-cne$RuntimeId-or + -not(Test-CcodInstalledLifecyclePositiveInteger $proof.runtimeGeneration)-or[uint64]$proof.runtimeGeneration-ne[uint64]$Generation-or + $proof.manifestSha256-isnot[string]-or$proof.manifestSha256-cne$ManifestSha256-or$proof.packageSha256-isnot[string]-or$proof.packageSha256-cnotmatch'^[0-9a-f]{64}\z'-or + $proof.targetPath-isnot[string]-or$proof.targetPath-cne[IO.Path]::GetFullPath((Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::System)) 'schtasks.exe'))-or + $proof.arguments-isnot[string]-or$proof.arguments-cne'/Run /TN "Codex Control Other Devices Supervisor"'){return $false} + $generated=@('registration/Desktop.CodexRemote-fix.lnk','registration/StartMenu.CodexRemote-fix.lnk') + foreach($pair in @(@($generated[0],'desktopSha256','Desktop'),@($generated[1],'startMenuSha256','StartMenu'))){ + $file=@($Actual|Where-Object {$_.path-ceq$pair[0]});$entry=@($state.entries|Where-Object {$_.kind-ceq$pair[2]}) + if($file.Count-ne1-or$entry.Count-ne1-or$proof.($pair[1])-isnot[string]-or$proof.($pair[1])-cnotmatch'^[0-9a-f]{64}\z'-or + $file[0].length-le0-or$file[0].length-gt262144-or$file[0].sha256-cne$proof.($pair[1])-or$entry[0].sha256-cne$file[0].sha256){return $false} + } + if(@($state.entries|Where-Object {$_.kind-ceq'Registry'}).Count-ne1-or@($Expected|Where-Object {$generated-ccontains$_.path}).Count-ne0){return $false} + $payload=@($Actual|Where-Object {$generated-cnotcontains$_.path}) + return Test-CcodInstalledLifecycleFileRecordSet -Actual $payload -Expected $Expected + } catch {return $false} +} + +function Test-CcodInstalledLifecycleFileRecordSet { + param($Actual,$Expected) + $actualRecords = @($Actual) + $expectedRecords = @($Expected) + if ($actualRecords.Count -ne $expectedRecords.Count) { return $false } + for ($index = 0; $index -lt $expectedRecords.Count; $index++) { + $actualRecord = $actualRecords[$index] + $expectedRecord = $expectedRecords[$index] + if (-not (Test-CcodInstalledLifecycleExactProperties -Value $actualRecord -Expected @('path','length','sha256')) -or + -not (Test-CcodInstalledLifecycleExactProperties -Value $expectedRecord -Expected @('path','length','sha256')) -or + $actualRecord.path -isnot [string] -or $expectedRecord.path -isnot [string] -or + $actualRecord.path -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._/-]{0,255}\z' -or $expectedRecord.path -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._/-]{0,255}\z' -or + $actualRecord.path.Contains('//') -or $expectedRecord.path.Contains('//') -or $actualRecord.path.Contains('..') -or $expectedRecord.path.Contains('..') -or + $actualRecord.path.Contains(':') -or $expectedRecord.path.Contains(':') -or $actualRecord.path.Contains('\') -or $expectedRecord.path.Contains('\') -or + $actualRecord.length -isnot [ValueType] -or $actualRecord.length -is [bool] -or $expectedRecord.length -isnot [ValueType] -or $expectedRecord.length -is [bool] -or + [decimal]$actualRecord.length -ne [decimal][int64]$actualRecord.length -or [decimal]$expectedRecord.length -ne [decimal][int64]$expectedRecord.length -or + [int64]$actualRecord.length -lt 0 -or [int64]$expectedRecord.length -lt 0 -or + $actualRecord.sha256 -isnot [string] -or $expectedRecord.sha256 -isnot [string] -or + $actualRecord.sha256 -cnotmatch '^[0-9a-f]{64}\z' -or $expectedRecord.sha256 -cnotmatch '^[0-9a-f]{64}\z' -or + $actualRecord.path -cne $expectedRecord.path -or [int64]$actualRecord.length -ne [int64]$expectedRecord.length -or $actualRecord.sha256 -cne $expectedRecord.sha256) { return $false } + if ($index -gt 0 -and [StringComparer]::Ordinal.Compare([string]$actualRecords[$index - 1].path,[string]$actualRecord.path) -ge 0) { return $false } + } + return $true +} + function Test-CcodInstalledLifecyclePositiveInteger { param($Value, [switch]$AllowZero) if ($Value -isnot [byte] -and $Value -isnot [uint16] -and $Value -isnot [uint32] -and $Value -isnot [uint64] -and @@ -247,7 +970,7 @@ function ConvertTo-CcodInstalledLifecycleReceiptFact { if (-not (Test-CcodInstalledLifecycleExactProperties -Value $Receipt -Expected $fields) -or $Receipt.kind -isnot [string] -or @('RestartAndRepair','CheckAndRepair','SafeExit') -cnotcontains $Receipt.kind -or $Receipt.origin -isnot [string] -or @('Installer','Tray','ExplicitStart','Guardian') -cnotcontains $Receipt.origin -or - $Receipt.runtimeId -isnot [string] -or $Receipt.runtimeId -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$' -or + $Receipt.runtimeId -isnot [string] -or $Receipt.runtimeId -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,95}\z' -or -not (Test-CcodInstalledLifecyclePositiveInteger -Value $Receipt.runtimeGeneration) -or $Receipt.phase -isnot [string] -or @('Completed','CloseFailed','OrdinaryLaunchFailed','OrdinaryObservationTimedOut','LaunchWindowExpired','RepairFailed','VerificationFailed','CancelledBeforeClose','SupersededByUpgrade') -cnotcontains $Receipt.phase) { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_FACTS_INVALID' 'Lifecycle receipt fact is malformed' $Receipt @@ -263,7 +986,7 @@ function ConvertTo-CcodInstalledLifecycleReceiptFact { function ConvertTo-CcodInstalledLifecycleFacts { param($Facts) - $expected = @('appPresent','activeRuntimeId','activeGeneration','runtimeManifestSha256','supervisor','trayHost','codex','taskState','statusPhase','statusRuntimeId','statusCodex','transitionStage','lifecycleReceipt','aboutVersion','deviceKeyPresent','deviceKeySha256','shortcuts') + $expected = @('installRootPresent','appPresent','runtimeRootPresent','activePointerPresent','installReady','activeRuntimeId','activeGeneration','runtimeManifestSha256','runtimeManifestFiles','supervisor','trayHost','trayHostIdentity','trayAuthenticated','codex','taskState','statusPhase','statusRuntimeId','statusCodex','transitionStage','protectionReady','lifecycleReceipt','aboutVersion','deviceKeyPresent','deviceKeySha256','shortcuts','debugPorts','debugEndpoints') if ($null -eq $Facts -or ($Facts -isnot [pscustomobject] -and $Facts -isnot [Collections.IDictionary])) { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_FACTS_INVALID' 'Machine facts are unavailable' $Facts } @@ -271,37 +994,78 @@ function ConvertTo-CcodInstalledLifecycleFacts { $property = if ($Facts -is [Collections.IDictionary]) { $Facts.Contains($name) } else { $null -ne $Facts.PSObject.Properties[$name] } if (-not $property) { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_FACTS_INVALID' "Machine facts omit $name" $Facts } } - if ($Facts.appPresent -isnot [bool] -or $Facts.deviceKeyPresent -isnot [bool] -or - ($null -ne $Facts.activeRuntimeId -and ($Facts.activeRuntimeId -isnot [string] -or $Facts.activeRuntimeId -cnotmatch '^[A-Za-z0-9._-]{1,96}$')) -or - ($null -ne $Facts.activeGeneration -and $Facts.activeGeneration -isnot [UInt64]) -or - ($null -ne $Facts.runtimeManifestSha256 -and ($Facts.runtimeManifestSha256 -isnot [string] -or $Facts.runtimeManifestSha256 -cnotmatch '^[0-9a-f]{64}$')) -or - ($null -ne $Facts.aboutVersion -and ($Facts.aboutVersion -isnot [string] -or $Facts.aboutVersion -cnotmatch '^\d+\.\d+\.\d+$')) -or - ($null -ne $Facts.statusRuntimeId -and ($Facts.statusRuntimeId -isnot [string] -or $Facts.statusRuntimeId -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$')) -or - ($null -ne $Facts.deviceKeySha256 -and ($Facts.deviceKeySha256 -isnot [string] -or $Facts.deviceKeySha256 -cnotmatch '^[0-9a-f]{64}$')) -or + if ($Facts.installRootPresent -isnot [bool] -or $Facts.appPresent -isnot [bool] -or $Facts.runtimeRootPresent -isnot [bool] -or $Facts.activePointerPresent -isnot [bool] -or + $Facts.installReady -isnot [bool] -or $Facts.trayAuthenticated -isnot [bool] -or $Facts.protectionReady -isnot [bool] -or $Facts.deviceKeyPresent -isnot [bool] -or + ($null -ne $Facts.activeRuntimeId -and ($Facts.activeRuntimeId -isnot [string] -or $Facts.activeRuntimeId -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,95}\z')) -or + ($null -ne $Facts.activeRuntimeId -and ($Facts.activeGeneration -isnot [UInt64] -or [UInt64]$Facts.activeGeneration -lt 1)) -or + ($null -eq $Facts.activeRuntimeId -and $null -ne $Facts.activeGeneration) -or + ($null -ne $Facts.runtimeManifestSha256 -and ($Facts.runtimeManifestSha256 -isnot [string] -or $Facts.runtimeManifestSha256 -cnotmatch '^[0-9a-f]{64}\z')) -or + -not (Test-CcodInstalledLifecycleFileRecordSet -Actual $Facts.runtimeManifestFiles -Expected $Facts.runtimeManifestFiles) -or + ($null -ne $Facts.aboutVersion -and ($Facts.aboutVersion -isnot [string] -or $Facts.aboutVersion -cnotmatch '^\d+\.\d+\.\d+\z')) -or + ($null -ne $Facts.statusRuntimeId -and ($Facts.statusRuntimeId -isnot [string] -or $Facts.statusRuntimeId -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,95}\z')) -or + ($null -ne $Facts.deviceKeySha256 -and ($Facts.deviceKeySha256 -isnot [string] -or $Facts.deviceKeySha256 -cnotmatch '^[0-9a-f]{64}\z')) -or ($Facts.deviceKeyPresent -and $null -eq $Facts.deviceKeySha256) -or (-not $Facts.deviceKeyPresent -and $null -ne $Facts.deviceKeySha256) -or $Facts.taskState -isnot [string] -or $Facts.statusPhase -isnot [string] -or $Facts.transitionStage -isnot [string] -or $Facts.statusPhase.Length -gt 64 -or $Facts.transitionStage.Length -gt 64 -or $null -eq $Facts.shortcuts -or $Facts.shortcuts.startMenu -isnot [bool] -or $Facts.shortcuts.desktop -isnot [bool]) { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_FACTS_INVALID' 'Machine facts have an invalid public shape' $Facts } + $debugPorts = @() + if ($null -ne $Facts.debugPorts) { $debugPorts = @($Facts.debugPorts) } + foreach ($port in $debugPorts) { if ($port -isnot [int] -or $port -lt 1 -or $port -gt 65535) { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_FACTS_INVALID' 'Debug port facts are malformed' $port } } + if (@($debugPorts | Sort-Object -Unique).Count -ne $debugPorts.Count) { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_FACTS_INVALID' 'Debug port facts contain duplicates' $debugPorts } + $codexIdentities = @(ConvertTo-CcodInstalledLifecycleIdentities -Records $Facts.codex -Kind 'Codex') + $trayIdentities = @(ConvertTo-CcodInstalledLifecycleIdentities -Records $Facts.trayHost -Kind 'TrayHost') + $trayHostIdentity = ConvertTo-CcodInstalledLifecycleNullableIdentity -Identity $Facts.trayHostIdentity -Kind 'TrayHost' + if ($trayIdentities.Count -eq 1) { + if ($null -eq $trayHostIdentity -or $trayHostIdentity.pid -ne $trayIdentities[0].pid -or $trayHostIdentity.creationTimeUtc -cne $trayIdentities[0].creationTimeUtc) { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_FACTS_INVALID' 'TrayHost identity is detached from the observed TrayHost process' $Facts.trayHostIdentity } + } elseif ($null -ne $trayHostIdentity -or $Facts.trayAuthenticated) { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_FACTS_INVALID' 'TrayHost authentication requires exactly one matching TrayHost identity' $Facts.trayHostIdentity + } + $debugNormalized = @() + if ($null -ne $Facts.debugEndpoints) { + foreach ($endpoint in @($Facts.debugEndpoints)) { + if (-not (Test-CcodInstalledLifecycleExactProperties -Value $endpoint -Expected @('localAddress','localPort','owningProcess','owningProcessCreationTimeUtc')) -or + $endpoint.localAddress -isnot [string] -or $endpoint.localAddress -cne '127.0.0.1' -or + $endpoint.localPort -isnot [int] -or $endpoint.localPort -lt 1 -or $endpoint.localPort -gt 65535 -or + $endpoint.owningProcess -isnot [int] -or $endpoint.owningProcess -lt 1 -or + $endpoint.owningProcessCreationTimeUtc -isnot [string] -or [string]::IsNullOrWhiteSpace($endpoint.owningProcessCreationTimeUtc)) { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_FACTS_INVALID' 'Debug endpoint identity is malformed' $endpoint } + if ($codexIdentities.Count -ne 1 -or $endpoint.owningProcess -ne $codexIdentities[0].Pid -or $endpoint.owningProcessCreationTimeUtc -cne $codexIdentities[0].CreationTimeUtc) { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_FACTS_INVALID' 'Debug endpoint is not owned by the verified Codex identity' $endpoint } + $debugNormalized += [pscustomobject][ordered]@{ localAddress = [string]$endpoint.localAddress; localPort = [int]$endpoint.localPort; owningProcess = [int]$endpoint.owningProcess; owningProcessCreationTimeUtc = [string]$endpoint.owningProcessCreationTimeUtc } + } + } + if ($debugNormalized.Count -gt 0) { + if ($codexIdentities.Count -ne 1 -or @($debugNormalized.owningProcess | Sort-Object -Unique).Count -ne 1 -or [int]$debugNormalized[0].owningProcess -ne [int]$codexIdentities[0].Pid) { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_FACTS_INVALID' 'Debug endpoints are not owned by the verified Codex process' $debugNormalized } + } return [pscustomobject][ordered]@{ + installRootPresent = [bool]$Facts.installRootPresent appPresent = [bool]$Facts.appPresent + runtimeRootPresent = [bool]$Facts.runtimeRootPresent + activePointerPresent = [bool]$Facts.activePointerPresent + installReady = [bool]$Facts.installReady activeRuntimeId = $Facts.activeRuntimeId activeGeneration = $Facts.activeGeneration runtimeManifestSha256 = $Facts.runtimeManifestSha256 + runtimeManifestFiles = @($Facts.runtimeManifestFiles) supervisor = @(ConvertTo-CcodInstalledLifecycleIdentities -Records $Facts.supervisor -Kind 'Supervisor') - trayHost = @(ConvertTo-CcodInstalledLifecycleIdentities -Records $Facts.trayHost -Kind 'TrayHost') + trayHost = @($trayIdentities) + trayHostIdentity = $trayHostIdentity + trayAuthenticated = [bool]$Facts.trayAuthenticated codex = @(ConvertTo-CcodInstalledLifecycleIdentities -Records $Facts.codex -Kind 'Codex') + codexCount = [int]$codexIdentities.Count taskState = [string]$Facts.taskState statusPhase = [string]$Facts.statusPhase statusRuntimeId = $Facts.statusRuntimeId statusCodex = ConvertTo-CcodInstalledLifecycleNullableIdentity -Identity $Facts.statusCodex -Kind 'Status Codex' transitionStage = [string]$Facts.transitionStage + protectionReady = [bool]$Facts.protectionReady lifecycleReceipt = ConvertTo-CcodInstalledLifecycleReceiptFact -Receipt $Facts.lifecycleReceipt aboutVersion = $Facts.aboutVersion deviceKeyPresent = [bool]$Facts.deviceKeyPresent deviceKeySha256 = $Facts.deviceKeySha256 shortcuts = [pscustomobject][ordered]@{ startMenu = [bool]$Facts.shortcuts.startMenu; desktop = [bool]$Facts.shortcuts.desktop } + debugPorts = [int[]]$debugPorts + debugEndpoints = @($debugNormalized) } } @@ -322,15 +1086,96 @@ function Read-CcodInstalledLifecycleStrictJson { } $Path $ExpectedSchema $Kind } +function Get-CcodInstalledLifecycleFileLinkCount { + param([Parameter(Mandatory)][string]$Path) + if ($null -eq ('CcodInstalledLifecycleFileIdentity' -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; +public static class CcodInstalledLifecycleFileIdentity { + [StructLayout(LayoutKind.Sequential)] private struct Info { public uint Attributes; public System.Runtime.InteropServices.ComTypes.FILETIME Creation; public System.Runtime.InteropServices.ComTypes.FILETIME Access; public System.Runtime.InteropServices.ComTypes.FILETIME Write; public uint Volume; public uint FileIndexHigh; public uint FileIndexLow; public uint Links; public uint SizeHigh; public uint SizeLow; } + [DllImport("kernel32.dll", SetLastError=true)] private static extern bool GetFileInformationByHandle(SafeFileHandle handle, out Info info); + public static uint Links(string path) { using (FileStream stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read)) { Info info; if (!GetFileInformationByHandle(stream.SafeFileHandle, out info)) throw new Win32Exception(Marshal.GetLastWin32Error()); return info.Links; } } +} +'@ + } + return [CcodInstalledLifecycleFileIdentity]::Links($Path) +} + +function Assert-CcodInstalledLifecyclePlainSelectorFile { + param([Parameter(Mandatory)][string]$Path) + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if ($item.PSIsContainer -or (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) -or (Get-CcodInstalledLifecycleFileLinkCount -Path $Path) -ne 1) { throw 'selector file identity' } + $streams = @(Get-Item -LiteralPath $Path -Stream * -ErrorAction Stop) + if ($streams.Count -ne 1 -or [string]$streams[0].Stream -cne ':$DATA') { throw 'selector file streams' } +} + function Read-CcodInstalledLifecycleActiveFact { param([Parameter(Mandatory)][string]$InstallRoot) + $probe = { param($CandidatePath) Get-Item -LiteralPath $CandidatePath -Force -ErrorAction Stop } + $selectorRoot = Join-Path (Join-Path $InstallRoot 'state') 'active-generation' + $selectorPresent = Get-CcodInstalledLifecycleOptionalDirectoryState -Path $selectorRoot -GetItem $probe + if ($selectorPresent) { + try { + $selectorItem = & $probe $selectorRoot + if (-not $selectorItem.PSIsContainer -or (($selectorItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) { throw 'selector root' } + $entries = @(Get-ChildItem -LiteralPath $selectorRoot -Force -ErrorAction Stop) + if ($entries.Count -eq 0) { throw 'selector empty' } + $records = [Collections.Generic.List[object]]::new() + foreach ($entry in $entries) { + if ($entry.PSIsContainer -or $entry -isnot [IO.FileInfo] -or (($entry.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) -or $entry.Name -notmatch '^\d{20}\.json\z') { throw 'selector entry' } + Assert-CcodInstalledLifecyclePlainSelectorFile -Path $entry.FullName + $record = Read-CcodInstalledLifecycleStrictJson -Path $entry.FullName -ExpectedSchema 1 -Kind 'installed active-generation selector' + if ($record.activeRuntime -isnot [string] -or -not [regex]::IsMatch([string]$record.activeRuntime, '^[A-Za-z0-9][A-Za-z0-9._-]{0,95}\z', [Text.RegularExpressions.RegexOptions]::CultureInvariant)) { throw 'selector active runtime' } + if (-not (Test-CcodInstalledLifecycleExactProperties -Value $record -Expected @('schemaVersion','generation','activeRuntime','previousGeneration')) -or + $record.schemaVersion -isnot [int] -or $record.schemaVersion -ne 1 -or + $record.activeRuntime -isnot [string] -or $record.activeRuntime -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,95}\z' -or + -not (Test-CcodInstalledLifecyclePositiveInteger -Value $record.generation) -or + $record.previousGeneration -isnot [ValueType] -or $record.previousGeneration -is [bool] -or + $record.previousGeneration -isnot [byte] -and $record.previousGeneration -isnot [uint16] -and $record.previousGeneration -isnot [uint32] -and $record.previousGeneration -isnot [uint64] -and + $record.previousGeneration -isnot [int16] -and $record.previousGeneration -isnot [int32] -and $record.previousGeneration -isnot [int64]) { throw 'selector record' } + [UInt64]$generation = $record.generation + [UInt64]$previous = $record.previousGeneration + if ($entry.Name -cne ('{0:D20}.json' -f $generation) -or $generation -eq 0 -or $previous -ne ($generation - 1)) { throw 'selector canonical record' } + $records.Add([pscustomobject][ordered]@{ generation = $generation; activeRuntime = [string]$record.activeRuntime; previousGeneration = $previous }) + } + $ordered = @($records | Sort-Object generation) + for ($index = 0; $index -lt $ordered.Count; $index++) { + if ([UInt64]$ordered[$index].generation -ne [UInt64]($index + 1) -or [UInt64]$ordered[$index].previousGeneration -ne [UInt64]$index) { throw 'selector chain' } + } + $latest = $ordered[-1] + return [pscustomobject][ordered]@{ schemaVersion = 2; activeRuntime = [string]$latest.activeRuntime; previousRuntime = $null; generation = [UInt64]$latest.generation; updatedAtUtc = '1970-01-01T00:00:00.0000000Z' } + } catch { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_FACTS_INVALID' 'Append-only active-generation selector is malformed' $selectorRoot + } + } $path = Join-Path $InstallRoot 'active.json' - if (-not [IO.File]::Exists($path)) { return $null } - $active = Read-CcodInstalledLifecycleStrictJson -Path $path -ExpectedSchema 2 -Kind 'installed active runtime' + $item = Get-CcodInstalledLifecycleOptionalRegularFileState -Path $path -GetItem $probe + if ($null -eq $item) { return $null } + Assert-CcodInstalledLifecyclePlainSelectorFile -Path $item.FullName + $active = $null + $legacy = $false + try { $active = Read-CcodInstalledLifecycleStrictJson -Path $item.FullName -ExpectedSchema 2 -Kind 'installed active runtime' } catch { + if ($_.FullyQualifiedErrorId -notlike '*CCOD_SCHEMA_UNSUPPORTED*') { throw } + $active = Read-CcodInstalledLifecycleStrictJson -Path $item.FullName -ExpectedSchema 1 -Kind 'installed legacy active runtime' + $legacy = $true + } + if ($legacy) { + if (-not (Test-CcodInstalledLifecycleExactProperties -Value $active -Expected @('schemaVersion','activeRuntime','previousRuntime','updatedAtUtc')) -or + $active.schemaVersion -isnot [int] -or $active.schemaVersion -ne 1 -or $active.activeRuntime -isnot [string] -or $active.activeRuntime -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,95}\z' -or + ($null -ne $active.previousRuntime -and ($active.previousRuntime -isnot [string] -or $active.previousRuntime -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,95}\z')) -or + -not (Test-CcodInstalledLifecycleCanonicalUtc -Value $active.updatedAtUtc)) { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_FACTS_INVALID' 'Active runtime schema 1 is malformed' $path + } + return [pscustomobject][ordered]@{ schemaVersion = 2; activeRuntime = [string]$active.activeRuntime; previousRuntime = $active.previousRuntime; generation = [UInt64]1; updatedAtUtc = [string]$active.updatedAtUtc } + } if (-not (Test-CcodInstalledLifecycleExactProperties -Value $active -Expected @('schemaVersion','activeRuntime','previousRuntime','generation','updatedAtUtc')) -or $active.schemaVersion -isnot [int] -or $active.schemaVersion -ne 2 -or - $active.activeRuntime -isnot [string] -or $active.activeRuntime -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$' -or - ($null -ne $active.previousRuntime -and ($active.previousRuntime -isnot [string] -or $active.previousRuntime -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$')) -or + $active.activeRuntime -isnot [string] -or $active.activeRuntime -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,95}\z' -or + ($null -ne $active.previousRuntime -and ($active.previousRuntime -isnot [string] -or $active.previousRuntime -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,95}\z')) -or -not (Test-CcodInstalledLifecyclePositiveInteger -Value $active.generation) -or -not (Test-CcodInstalledLifecycleCanonicalUtc -Value $active.updatedAtUtc)) { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_FACTS_INVALID' 'Active runtime schema 2 is malformed' $path @@ -341,8 +1186,10 @@ function Read-CcodInstalledLifecycleActiveFact { function Read-CcodInstalledLifecycleStatusFact { param([Parameter(Mandatory)][string]$StateRoot) $path = Join-Path $StateRoot 'status.json' - if (-not [IO.File]::Exists($path)) { return $null } - $status = Read-CcodInstalledLifecycleStrictJson -Path $path -ExpectedSchema 1 -Kind 'installed status' + $probe = { param($CandidatePath) Get-Item -LiteralPath $CandidatePath -Force -ErrorAction Stop } + $item = Get-CcodInstalledLifecycleOptionalRegularFileState -Path $path -GetItem $probe + if ($null -eq $item) { return $null } + $status = Read-CcodInstalledLifecycleStrictJson -Path $item.FullName -ExpectedSchema 1 -Kind 'installed status' try { & $script:CcodInstalledLifecycleStateStoreModule { param($Value) Assert-CcodStatusShape -Status $Value } $status } catch { @@ -354,8 +1201,10 @@ function Read-CcodInstalledLifecycleStatusFact { function Read-CcodInstalledLifecycleTransitionFact { param([Parameter(Mandatory)][string]$StateRoot) $path = Join-Path $StateRoot 'transition.json' - if (-not [IO.File]::Exists($path)) { return $null } - $transition = Read-CcodInstalledLifecycleStrictJson -Path $path -ExpectedSchema 1 -Kind 'installed transition' + $probe = { param($CandidatePath) Get-Item -LiteralPath $CandidatePath -Force -ErrorAction Stop } + $item = Get-CcodInstalledLifecycleOptionalRegularFileState -Path $path -GetItem $probe + if ($null -eq $item) { return $null } + $transition = Read-CcodInstalledLifecycleStrictJson -Path $item.FullName -ExpectedSchema 1 -Kind 'installed transition' try { & $script:CcodInstalledLifecycleStateStoreModule { param($Value) Assert-CcodTransitionShape -Transition $Value } $transition } catch { @@ -370,18 +1219,23 @@ function Get-CcodInstalledLifecycleReceiptFact { [AllowNull()][string]$ActiveRuntimeId, [UInt64]$ActiveGeneration ) - $directory = Join-Path $StateRoot 'lifecycle\receipts' - if (-not [IO.Directory]::Exists($directory)) { return $null } - $directoryItem = Get-Item -LiteralPath $directory -Force -ErrorAction Stop + $directory = Join-Path (Join-Path $StateRoot 'lifecycle') 'receipts' + $probe = { param($CandidatePath) Get-Item -LiteralPath $CandidatePath -Force -ErrorAction Stop } + $directoryPresent = Get-CcodInstalledLifecycleOptionalDirectoryState -Path $directory -GetItem $probe + if (-not $directoryPresent) { return $null } + $directoryItem = & $probe $directory if (-not $directoryItem.PSIsContainer -or (($directoryItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_FACTS_INVALID' 'Lifecycle receipt root is malformed' $directory } $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) $candidates = [Collections.Generic.List[object]]::new() - foreach ($item in @(Get-ChildItem -LiteralPath $directory -Force -ErrorAction Stop | Where-Object { $_.Name -like '*.json' })) { - if ($item.PSIsContainer -or $item -isnot [IO.FileInfo] -or (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) { + foreach ($item in @(Get-ChildItem -LiteralPath $directory -Force -ErrorAction Stop)) { + if ($item.PSIsContainer -or $item -isnot [IO.FileInfo] -or (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) -or + $item.Name -cnotmatch '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.json\z') { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_FACTS_INVALID' 'Lifecycle receipt entry is malformed' $item.FullName } + try { Assert-CcodInstalledLifecyclePlainSelectorFile -Path $item.FullName } + catch { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_FACTS_INVALID' 'Lifecycle receipt entry does not have plain-file identity.' $item.FullName } $receipt = Read-CcodInstalledLifecycleStrictJson -Path $item.FullName -ExpectedSchema 1 -Kind 'installed lifecycle receipt' try { & $script:CcodInstalledLifecycleTransactionModule { param($Value) Assert-CcodLifecycleRequest -Request $Value } $receipt @@ -509,18 +1363,94 @@ function Get-CcodInstalledLifecycleChatGptIdentities { } $created = ConvertTo-CcodInstalledLifecycleCreationTimeUtc -Value $process.CreationDate $classification=Get-CcodInstalledLifecycleChatGptClassification -Process $process - if($classification -ceq 'Root'){$records.Add([pscustomobject][ordered]@{Pid=[int]$process.ProcessId;CreationTimeUtc=$created})} + if($classification -ceq 'Root'){$created=Get-CcodInstalledLifecycleCorrelatedCreationTimeUtc -Process $process;$records.Add([pscustomobject][ordered]@{Pid=[int]$process.ProcessId;CreationTimeUtc=$created})} } return @($records) } +function Assert-CcodInstalledLifecycleRuntimeManifest { + param([Parameter(Mandatory)]$Manifest,[Parameter(Mandatory)][string]$ExpectedVersion,[Parameter(Mandatory)][string]$ExpectedRuntimeId,[string]$RuntimeRoot) + try { + if (-not (Test-CcodInstalledLifecycleExactProperties -Value $Manifest -Expected @('schemaVersion','projectVersion','runtimeId','files')) -or + $Manifest.files -isnot [Array] -or -not (Test-CcodInstalledLifecyclePositiveInteger $Manifest.schemaVersion -AllowZero) -or $Manifest.schemaVersion -ne 1) { throw 'manifest frame' } + $newRuntimeMatch = [regex]::Match($ExpectedRuntimeId, '^(?[A-Za-z0-9][A-Za-z0-9._-]{0,45})-(?[0-9a-f]{16})-(?[0-9a-f]{32})\z') + $legacyRuntimeMatch = [regex]::Match($ExpectedRuntimeId, '^(?[A-Za-z0-9][A-Za-z0-9._-]{0,45})-(?[0-9a-f]{16})\z') + $expectedManifestVersion = if ($newRuntimeMatch.Success) { $newRuntimeMatch.Groups['version'].Value } else { $legacyRuntimeMatch.Groups['version'].Value } + if ((-not $newRuntimeMatch.Success -and -not $legacyRuntimeMatch.Success) -or ($legacyRuntimeMatch.Success -and $legacyRuntimeMatch.Groups['version'].Value -cne '2.5.21') -or + $expectedManifestVersion -cne $ExpectedVersion -or + $Manifest.runtimeId -isnot [string] -or $Manifest.runtimeId -cne $ExpectedRuntimeId -or $Manifest.projectVersion -isnot [string] -or $Manifest.projectVersion -cne $ExpectedVersion) { throw 'manifest frame' } + $previousPath = $null + $seenPaths = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($file in @($Manifest.files)) { + if (-not (Test-CcodInstalledLifecycleExactProperties -Value $file -Expected @('path','length','sha256')) -or + $file.path -isnot [string] -or [string]::IsNullOrWhiteSpace($file.path) -or + [IO.Path]::IsPathRooted($file.path) -or $file.path.Contains('..') -or $file.path.Contains(':') -or + -not (Test-CcodInstalledLifecyclePositiveInteger $file.length -AllowZero) -or + $file.sha256 -isnot [string] -or $file.sha256 -cnotmatch '^[0-9a-f]{64}\z' -or + -not $seenPaths.Add([string]$file.path)) { throw 'manifest file' } + if ($null -ne $previousPath -and [StringComparer]::Ordinal.Compare([string]$previousPath,[string]$file.path) -ge 0) { throw 'manifest order' } + $previousPath = [string]$file.path + } + if (-not [string]::IsNullOrWhiteSpace($RuntimeRoot)) { + $runtimePath = [IO.Path]::GetFullPath($RuntimeRoot) + $runtimeItem = Get-Item -LiteralPath $runtimePath -Force -ErrorAction Stop + if ($runtimeItem.PSIsContainer -ne $true -or ($runtimeItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'runtime root' } + $actualFiles = [Collections.Generic.List[object]]::new() + $prefix = $runtimePath.TrimEnd('\') + '\' + foreach ($item in @(Get-ChildItem -LiteralPath $runtimePath -Force -Recurse -ErrorAction Stop)) { + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'runtime reparse' } + if (-not $item.PSIsContainer) { + $relative = $item.FullName.Substring($prefix.Length).Replace('\','/') + if (-not $relative.Equals('manifest.json',[StringComparison]::OrdinalIgnoreCase)) { + $actualFiles.Add([pscustomobject][ordered]@{ path = $relative; length = [int64]$item.Length; sha256 = Get-CcodInstalledLifecycleHash -Path $item.FullName }) + } + } + } + $actualFiles.Sort([Comparison[object]]{param($left,$right) [StringComparer]::Ordinal.Compare([string]$left.path,[string]$right.path)}) + $actual = @($actualFiles) + $declared = @($Manifest.files) + if ($actual.Count -ne $declared.Count) { throw 'runtime files count' } + for ($index = 0; $index -lt $actual.Count; $index++) { + if ($actual[$index].path -cne [string]$declared[$index].path -or [int64]$actual[$index].length -ne [int64]$declared[$index].length -or $actual[$index].sha256 -cne [string]$declared[$index].sha256) { throw 'runtime file binding' } + } + $canonical = if ($newRuntimeMatch.Success) { + @($declared | ForEach-Object { "{0}`t{1}`t{2}" -f [string]$_.path,[int64]$_.length,[string]$_.sha256 }) -join "`n" + } else { + @($declared | ForEach-Object { '{0}`t{1}`t{2}' -f [string]$_.path,[int64]$_.length,[string]$_.sha256 }) -join "`n" + } + $sha = [Security.Cryptography.SHA256]::Create() + try { $digest = ([BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($canonical)))).Replace('-','').ToLowerInvariant() } finally { $sha.Dispose() } + if ($newRuntimeMatch.Success) { + if ($newRuntimeMatch.Groups['digest'].Value -cne $digest.Substring(0,16)) { throw 'runtime id binding' } + } elseif ($legacyRuntimeMatch.Groups['digest'].Value -cne $digest.Substring(0,16)) { throw 'legacy runtime id binding' } + } + return $Manifest + } catch { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' 'The active runtime manifest is missing, malformed, or detached from the installed candidate.' $ExpectedRuntimeId + } +} + function Get-CcodInstalledLifecycleFacts { - param([Parameter(Mandatory)][string]$InstallRoot) - $root = [IO.Path]::GetFullPath($InstallRoot) + param([Parameter(Mandatory)][string]$InstallRoot,[string]$ExpectedVersion,[object]$ExpectedDebugPorts,[object[]]$ExpectedPayloadFiles) + $root = Assert-CcodInstalledLifecycleCanonicalAbsolutePath -Path $InstallRoot -Kind 'Install root' $appRoot = Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)) 'CodexControlOtherDevices-installer' + $pathProbe = { param($Path) Get-Item -LiteralPath $Path -Force -ErrorAction Stop }.GetNewClosure() + $installRootPresent = Get-CcodInstalledLifecycleOptionalDirectoryState -Path $root -GetItem $pathProbe + if ($installRootPresent) { [void](Assert-CcodInstalledLifecycleSafeDirectory -Path $root -Kind 'Install root') } + $runtimeRootPath = Join-Path $root 'runtime' + $selectorRootPath = Join-Path (Join-Path $root 'state') 'active-generation' + $activePointerPath = Join-Path $root 'active.json' + $runtimeRootPresent = Get-CcodInstalledLifecycleOptionalDirectoryState -Path $runtimeRootPath -GetItem $pathProbe + $selectorPresent = Get-CcodInstalledLifecycleOptionalDirectoryState -Path $selectorRootPath -GetItem $pathProbe + $activePointerItem = Get-CcodInstalledLifecycleOptionalRegularFileState -Path $activePointerPath -GetItem $pathProbe + $activePointerPresent = $selectorPresent -or ($null -ne $activePointerItem) + $appRootPresent = Get-CcodInstalledLifecycleOptionalDirectoryState -Path $appRoot -GetItem $pathProbe $activeRuntimeId = $null [UInt64]$activeGeneration = 0 $runtimeManifestSha256 = $null + $runtimeManifestFiles = @() + $activeManifestObject = $null + $manifestExpectedVersion = $ExpectedVersion $statusPhase = 'Unavailable' $statusRuntimeId = $null $statusCodex = $null @@ -530,7 +1460,14 @@ function Get-CcodInstalledLifecycleFacts { $activeRuntimeId = [string]$active.activeRuntime $activeGeneration = [UInt64]$active.generation $manifest = Join-Path (Join-Path (Join-Path $root 'runtime') $activeRuntimeId) 'manifest.json' - if ([IO.File]::Exists($manifest)) { $runtimeManifestSha256 = Get-CcodInstalledLifecycleHash -Path $manifest } + $manifestItem = Get-CcodInstalledLifecycleOptionalRegularFileState -Path $manifest -GetItem $pathProbe + if ($null -eq $manifestItem) { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' 'The active runtime manifest is missing.' $manifest } + $runtimeManifestSha256 = Get-CcodInstalledLifecycleHash -Path $manifestItem.FullName + try { $activeManifestObject = Read-CcodInstalledLifecycleStrictJson -Path $manifestItem.FullName -ExpectedSchema 1 -Kind 'installed runtime manifest' } catch { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' 'The active runtime manifest could not be read safely.' $manifest } + if ([string]::IsNullOrWhiteSpace($manifestExpectedVersion)) { + if ($activeRuntimeId -notmatch '^(?[A-Za-z0-9][A-Za-z0-9._-]{0,45})-(?[0-9a-f]{16})(?:-(?[0-9a-f]{32}))?\z') { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' 'The active runtime version binding could not be proven.' $activeRuntimeId } + $manifestExpectedVersion = [string]$Matches.version + } } $stateRoot = Join-Path $root 'state' $status = Read-CcodInstalledLifecycleStatusFact -StateRoot $stateRoot @@ -554,7 +1491,9 @@ function Get-CcodInstalledLifecycleFacts { $matches = @(Get-ScheduledTask -ErrorAction Stop | Where-Object { $_.TaskName -ceq $script:CcodInstalledLifecycleTaskName }) if ($matches.Count -eq 0) { $taskState = 'Absent' } elseif ($matches.Count -eq 1) { $taskState = [string]$matches[0].State } - } catch { $taskState = 'Unknown' } + } catch { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' 'The Supervisor scheduled task could not be inspected safely.' $null + } $makeIdentities = { param([string[]]$Names, [scriptblock]$Predicate) $records = [Collections.Generic.List[object]]::new() @@ -562,57 +1501,158 @@ function Get-CcodInstalledLifecycleFacts { try { foreach ($process in @(Get-CimInstance -ClassName Win32_Process -Filter ("Name = '{0}'" -f $name) -ErrorAction Stop)) { if (-not (& $Predicate $process)) { continue } - $created = [Management.ManagementDateTimeConverter]::ToDateTime([string]$process.CreationDate).ToUniversalTime().ToString('o', [Globalization.CultureInfo]::InvariantCulture) + $created = Get-CcodInstalledLifecycleCorrelatedCreationTimeUtc -Process $process $records.Add([pscustomobject][ordered]@{ Pid = [int]$process.ProcessId; CreationTimeUtc = $created }) } - } catch { } + } catch { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' 'Process identities could not be inspected safely.' $null + } } return @($records) - }.GetNewClosure() + } $supervisor = & $makeIdentities @('powershell.exe','pwsh.exe') { param($Process) - ([string]$Process.CommandLine).IndexOf('Supervisor.ps1', [StringComparison]::OrdinalIgnoreCase) -ge 0 -and - ([string]$Process.CommandLine).IndexOf($root, [StringComparison]::OrdinalIgnoreCase) -ge 0 + $commandLine = Assert-CcodInstalledLifecycleReadableProcessText -Value $Process.CommandLine -Kind 'Supervisor CommandLine' + $commandLine.IndexOf('Supervisor.ps1', [StringComparison]::OrdinalIgnoreCase) -ge 0 -and + $commandLine.IndexOf($root, [StringComparison]::OrdinalIgnoreCase) -ge 0 } - $trayHostPath = [IO.Path]::GetFullPath((Join-Path $root 'bin\CodexRemote.TrayHost.exe')) + $trayRuntimeRoot = if ($null -ne $activeRuntimeId) { Join-Path (Join-Path $root 'runtime') $activeRuntimeId } else { $root } + $trayHostPath = [IO.Path]::GetFullPath((Join-Path $trayRuntimeRoot 'bin\CodexRemote.TrayHost.exe')) $trayHost = & $makeIdentities @('CodexRemote.TrayHost.exe') { param($Process) - [string]$Process.ExecutablePath -ieq $trayHostPath + $executablePath = Assert-CcodInstalledLifecycleReadableProcessText -Value $Process.ExecutablePath -Kind 'TrayHost ExecutablePath' + $executablePath -ieq $trayHostPath + } + $trayHostIdentity = $null + $trayAuthenticated = $false + if (@($trayHost).Count -eq 1) { + $trayHostIdentity = [pscustomobject][ordered]@{ pid = [int]$trayHost[0].Pid; creationTimeUtc = [string]$trayHost[0].CreationTimeUtc } + if ($null -ne $activeRuntimeId) { $trayAuthenticated = [bool](Get-CcodInstalledLifecycleTrayHostReadyProof -InstallRoot $root -RuntimeId $activeRuntimeId -TrayHost $trayHost) } } $codex = @(Get-CcodInstalledLifecycleChatGptIdentities) $aboutVersion = $null - $packagePath = Join-Path $appRoot 'package.json' - if ([IO.File]::Exists($packagePath)) { + $sealedRuntime = $null -ne $activeRuntimeId -and $activeRuntimeId -cmatch '^2\.5\.22-[0-9a-f]{16}-[0-9a-f]{32}\z' + $packagePath = if ($sealedRuntime) { Join-Path $trayRuntimeRoot 'package.json' } else { Join-Path $appRoot 'package.json' } + $packageItem = Get-CcodInstalledLifecycleOptionalRegularFileState -Path $packagePath -GetItem $pathProbe + if ($sealedRuntime) { $appRootPresent = $null -ne $packageItem } + if ($null -ne $packageItem) { try { - $package = [IO.File]::ReadAllText($packagePath) | ConvertFrom-Json -ErrorAction Stop - if ($package.version -is [string] -and $package.version -cmatch '^\d+\.\d+\.\d+$') { $aboutVersion = [string]$package.version } - } catch { } + $packageText = [IO.File]::ReadAllText($packageItem.FullName, [Text.UTF8Encoding]::new($false,$true)) + $packageUnique = & $script:CcodInstalledLifecyclePersistenceIoModule { param($Json) Test-CcodJsonHasNoDuplicateProperties -Json $Json } $packageText + if (-not $packageUnique) { throw 'package duplicate properties' } + $package = $packageText | ConvertFrom-Json -ErrorAction Stop + if ($package.version -is [string] -and $package.version -cmatch '^\d+\.\d+\.\d+\z') { $aboutVersion = [string]$package.version } + } catch { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' 'The installed package metadata could not be read safely.' $packagePath + } + } + if ($null -ne $activeRuntimeId) { + if ([string]::IsNullOrWhiteSpace($manifestExpectedVersion) -or $null -eq $activeManifestObject) { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' 'The active runtime version binding could not be proven.' $activeRuntimeId } + if (-not [string]::IsNullOrWhiteSpace($ExpectedVersion) -and ($aboutVersion -isnot [string] -or $aboutVersion -cne $ExpectedVersion)) { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' 'The installed package version is detached from the expected candidate.' $ExpectedVersion } + [void](Assert-CcodInstalledLifecycleRuntimeManifest -Manifest $activeManifestObject -ExpectedVersion $manifestExpectedVersion -ExpectedRuntimeId $activeRuntimeId -RuntimeRoot (Join-Path (Join-Path $root 'runtime') $activeRuntimeId)) + $runtimeManifestFiles = @($activeManifestObject.files) + if ($null -ne $ExpectedPayloadFiles -and -not (Test-CcodInstalledLifecycleCandidateFileSet -Actual $runtimeManifestFiles -Expected $ExpectedPayloadFiles -InstallRoot $root -RuntimeId $activeRuntimeId -Generation $activeGeneration -ManifestSha256 $runtimeManifestSha256)) { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' 'The active runtime file set is detached from the candidate payload manifest.' $activeRuntimeId } } $codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process') if ([string]::IsNullOrWhiteSpace($codexHome)) { $codexHome = Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::UserProfile)) '.codex' } $deviceKey = if ([IO.Path]::IsPathRooted($codexHome)) { Join-Path $codexHome 'remote-control-device-keys.windows.json' } else { $null } - $deviceKeyPresent = $null -ne $deviceKey -and [IO.File]::Exists($deviceKey) - $deviceKeySha256 = if ($deviceKeyPresent) { Get-CcodInstalledLifecycleHash -Path $deviceKey } else { $null } + $deviceKeyItem = if ($null -eq $deviceKey) { $null } else { Get-CcodInstalledLifecycleOptionalRegularFileState -Path $deviceKey -GetItem $pathProbe } + $deviceKeyPresent = $null -ne $deviceKeyItem + $deviceKeySha256 = if ($deviceKeyPresent) { Get-CcodInstalledLifecycleHash -Path $deviceKeyItem.FullName } else { $null } + $includeDebug = -not [string]::IsNullOrWhiteSpace($ExpectedVersion) -or $null -ne $ExpectedDebugPorts + $debugPorts = [Collections.Generic.List[int]]::new() + if ($includeDebug -and $null -ne $status -and $null -ne $status.session -and $null -ne $status.session.codex) { + foreach ($name in @('mainPort','rendererPort')) { + $property = $status.session.codex.PSObject.Properties[$name] + if ($null -eq $property) { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' 'Debug port facts could not be read safely.' $name } + $portValue = $property.Value + if (($portValue -isnot [int] -and $portValue -isnot [long]) -or $portValue -lt 1 -or $portValue -gt 65535) { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' 'Debug port facts could not be read safely.' $name } + [void]$debugPorts.Add([int]$portValue) + } + } + $requestedDebugPorts = $null + if ($null -ne $ExpectedDebugPorts) { + if ($ExpectedDebugPorts -isnot [array]) { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' 'Expected debug ports must be an array of integers.' $ExpectedDebugPorts } + $requestedDebugPorts = @($ExpectedDebugPorts) + $requestedPortValues = [Collections.Generic.List[int]]::new() + foreach ($port in $requestedDebugPorts) { + if (($port -isnot [int] -and $port -isnot [long]) -or $port -is [bool] -or $port -lt 1 -or $port -gt 65535) { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' 'Expected debug port is invalid.' $port } + [void]$requestedPortValues.Add([int]$port) + } + $requestedDebugPorts = @($requestedPortValues) + if ($requestedDebugPorts.Count -gt 0) { + $requestedPortKey = (@($requestedDebugPorts | Sort-Object) -join ',') + $declaredPortKey = (@($debugPorts | ForEach-Object { [int]$_ } | Sort-Object) -join ',') + if ($requestedDebugPorts.Count -ne @($requestedDebugPorts | Sort-Object -Unique).Count -or + $debugPorts.Count -ne $requestedDebugPorts.Count -or $declaredPortKey -cne $requestedPortKey) { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' 'Expected debug ports are detached from the current runtime declaration.' $ExpectedDebugPorts + } + } + } + $debugEndpoints = [Collections.Generic.List[object]]::new() + if ($debugPorts.Count -gt 0) { + try { + foreach ($listener in @(Get-NetTCPConnection -State Listen -ErrorAction Stop)) { + if ($null -eq $listener -or $listener.PSObject.Properties['LocalPort'] -eq $null) { throw 'listener port' } + $listenerPort = $listener.LocalPort + if (($listenerPort -isnot [uint16] -and $listenerPort -isnot [int] -and $listenerPort -isnot [long]) -or + [decimal]$listenerPort -lt 1 -or [decimal]$listenerPort -gt 65535) { throw 'listener port' } + if ($debugPorts -notcontains [int]$listenerPort) { continue } + if ($listener.PSObject.Properties['LocalAddress'] -eq $null -or $listener.PSObject.Properties['OwningProcess'] -eq $null -or + $listener.LocalAddress -isnot [string] -or $listener.LocalAddress -cne '127.0.0.1') { throw 'listener address' } + $listenerOwner = $listener.OwningProcess + if (($listenerOwner -isnot [uint32] -and $listenerOwner -isnot [int] -and $listenerOwner -isnot [long]) -or + [decimal]$listenerOwner -lt 1 -or [decimal]$listenerOwner -gt [int]::MaxValue) { throw 'listener scalar' } + $owner = @($codex | Where-Object { $_.Pid -eq [int]$listenerOwner }) + if ($owner.Count -ne 1) { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' 'Debug endpoint owner is not a unique verified Codex process.' $listener.OwningProcess } + $debugEndpoints.Add([pscustomobject][ordered]@{ localAddress = '127.0.0.1'; localPort = [int]$listenerPort; owningProcess = [int]$listenerOwner; owningProcessCreationTimeUtc = [string]$owner[0].CreationTimeUtc }) + } + } catch { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' 'Debug endpoints could not be inspected safely.' $null } + } $startMenu = Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::Programs)) 'CodexRemote-fix\CodexRemote-fix.lnk' $desktop = Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::Desktop)) 'CodexRemote-fix.lnk' + $startMenuItem = Get-CcodInstalledLifecycleOptionalRegularFileState -Path $startMenu -GetItem $pathProbe + $desktopItem = Get-CcodInstalledLifecycleOptionalRegularFileState -Path $desktop -GetItem $pathProbe + $publicDebugPorts = [int[]]@() + $publicDebugEndpoints = @() + if ($includeDebug) { $publicDebugPorts = [int[]]$debugPorts; $publicDebugEndpoints = @($debugEndpoints) } + $installReady = [bool]($installRootPresent -and $appRootPresent -and $runtimeRootPresent -and $activePointerPresent -and + $null -ne $activeRuntimeId -and $activeGeneration -gt 0 -and $null -ne $activeManifestObject -and + ($null -eq $ExpectedVersion -or $aboutVersion -ceq $ExpectedVersion)) + $protectionReady = [bool]($installReady -and $taskState -in @('Ready','Running') -and $statusPhase -ceq 'Active' -and + $statusRuntimeId -ceq $activeRuntimeId -and $transitionStage -ceq 'Idle' -and $trayAuthenticated -and + @($supervisor).Count -eq 1 -and @($trayHost).Count -eq 1 -and @($codex).Count -eq 1 -and $null -ne $statusCodex -and + $statusCodex.pid -eq $codex[0].Pid -and $statusCodex.creationTimeUtc -ceq $codex[0].CreationTimeUtc -and + $null -ne $lifecycleReceipt -and $lifecycleReceipt.phase -ceq 'Completed' -and $lifecycleReceipt.runtimeId -ceq $activeRuntimeId -and + [UInt64]$lifecycleReceipt.runtimeGeneration -eq [UInt64]$activeGeneration) return [pscustomobject][ordered]@{ - appPresent = [IO.Directory]::Exists($appRoot) + installRootPresent = [bool]$installRootPresent + appPresent = [bool]$appRootPresent + runtimeRootPresent = [bool]$runtimeRootPresent + activePointerPresent = [bool]$activePointerPresent + installReady = $installReady activeRuntimeId = $activeRuntimeId activeGeneration = if ($null -eq $activeRuntimeId) { $null } else { [UInt64]$activeGeneration } runtimeManifestSha256 = $runtimeManifestSha256 + runtimeManifestFiles = @($runtimeManifestFiles) supervisor = @($supervisor) trayHost = @($trayHost) + trayHostIdentity = $trayHostIdentity + trayAuthenticated = $trayAuthenticated codex = @($codex) taskState = $taskState statusPhase = $statusPhase statusRuntimeId = $statusRuntimeId statusCodex = $statusCodex transitionStage = $transitionStage + protectionReady = $protectionReady lifecycleReceipt = $lifecycleReceipt aboutVersion = $aboutVersion deviceKeyPresent = [bool]$deviceKeyPresent deviceKeySha256 = $deviceKeySha256 - shortcuts = [pscustomobject][ordered]@{ startMenu = [IO.File]::Exists($startMenu); desktop = [IO.File]::Exists($desktop) } + shortcuts = [pscustomobject][ordered]@{ startMenu = $null -ne $startMenuItem; desktop = $null -ne $desktopItem } + debugPorts = [int[]]$publicDebugPorts + debugEndpoints = @($publicDebugEndpoints) } } @@ -628,6 +1668,128 @@ function Read-CcodInstalledLifecycleOperatorAck { } } +function Get-CcodInstalledLifecycleUninstallCommand { + param([Parameter(Mandatory)][string]$InstallRoot,[Parameter(Mandatory)][string]$ExpectedVersion,[Parameter(Mandatory)][object[]]$ExpectedPayloadFiles) + try { + if($ExpectedVersion-cne'2.5.22'){throw 'sealed uninstall version'} + $root=Assert-CcodInstalledLifecycleCanonicalAbsolutePath -Path $InstallRoot -Kind 'Uninstall root' + $facts=Get-CcodInstalledLifecycleFacts -InstallRoot $root -ExpectedVersion $ExpectedVersion -ExpectedPayloadFiles $ExpectedPayloadFiles + if($facts.activeRuntimeId-isnot[string]-or$facts.activeRuntimeId-cnotmatch'^2\.5\.22-[0-9a-f]{16}-[0-9a-f]{32}\z'-or + $facts.installReady-isnot[bool]-or-not$facts.installReady-or$facts.protectionReady-isnot[bool]-or-not$facts.protectionReady){throw 'sealed uninstall readiness'} + $runtime=Join-Path (Join-Path $root 'runtime') $facts.activeRuntimeId + $path=Assert-CcodInstalledLifecycleRegularFile -Path (Join-Path $runtime 'Uninstall-CodexControlOtherDevices.ps1') -Kind 'Sealed uninstaller' + $records=@($facts.runtimeManifestFiles|Where-Object {$_.path-ceq'Uninstall-CodexControlOtherDevices.ps1'}) + if($records.Count-ne1-or(Get-CcodInstalledLifecycleHash $path)-cne$records[0].sha256-or[long](Get-Item -LiteralPath $path -ErrorAction Stop).Length-ne[long]$records[0].length){throw 'sealed uninstaller bytes'} + $active=Read-CcodInstalledLifecycleActiveFact -InstallRoot $root + if($null-eq$active-or$active.activeRuntime-cne$facts.activeRuntimeId-or[uint64]$active.generation-ne[uint64]$facts.activeGeneration){throw 'uninstall active identity changed'} + $spec=[pscustomobject][ordered]@{schemaVersion=1;installRoot=$root;expectedVersion=$ExpectedVersion;runtimeId=$facts.activeRuntimeId;generation=$facts.activeGeneration;manifestSha256=$facts.runtimeManifestSha256;runtimeFiles=@($facts.runtimeManifestFiles);candidatePayloadFiles=@($ExpectedPayloadFiles)} + # Copy the original identity as data, not a mutable view of captured facts. + return [pscustomobject]@{Spec=($spec|ConvertTo-Json -Depth 8 -Compress|ConvertFrom-Json)} + } catch { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' 'The direct uninstaller is not bound to the active sealed candidate.' $InstallRoot + } +} + +function Invoke-CcodInstalledLifecycleVerifiedUninstall { + param([Parameter(Mandatory)]$Spec) + $lease=$null + try { + if (-not(Test-CcodInstalledLifecycleExactProperties $Spec @('schemaVersion','installRoot','expectedVersion','runtimeId','generation','manifestSha256','runtimeFiles','candidatePayloadFiles')) -or + $Spec.schemaVersion -isnot [int] -or $Spec.schemaVersion -ne 1 -or $Spec.installRoot -isnot [string] -or + $Spec.expectedVersion -isnot [string] -or $Spec.expectedVersion -cne '2.5.22' -or + $Spec.runtimeId -isnot [string] -or $Spec.runtimeId -cnotmatch '^2\.5\.22-[0-9a-f]{16}-[0-9a-f]{32}\z' -or + -not(Test-CcodInstalledLifecyclePositiveInteger $Spec.generation) -or + $Spec.manifestSha256 -isnot [string] -or $Spec.manifestSha256 -cnotmatch '^[0-9a-f]{64}\z' -or + $Spec.runtimeFiles -isnot [array] -or $Spec.runtimeFiles.Count -lt 1 -or + $Spec.candidatePayloadFiles -isnot [array] -or $Spec.candidatePayloadFiles.Count -lt 1 -or + -not(Test-CcodInstalledLifecycleFileRecordSet $Spec.runtimeFiles $Spec.runtimeFiles) -or + -not(Test-CcodInstalledLifecycleFileRecordSet $Spec.candidatePayloadFiles $Spec.candidatePayloadFiles)) { throw 'uninstall specification' } + $root=Assert-CcodInstalledLifecycleCanonicalAbsolutePath $Spec.installRoot 'Uninstall root' + $runtime=Join-Path (Join-Path $root 'runtime') $Spec.runtimeId + $manifest=Join-Path $runtime 'manifest.json' + $paths=@($manifest)+@($Spec.runtimeFiles|ForEach-Object {Join-Path $runtime $_.path}) + $lease=Open-CcodInstalledLifecycleExecutionLease -Paths $paths + if ($lease.Files[0].Sha256 -cne $Spec.manifestSha256) { throw 'original manifest replaced' } + for ($index=0; $index -lt $Spec.runtimeFiles.Count; $index++) { + $record=$Spec.runtimeFiles[$index];$held=$lease.Files[$index+1] + if ($held.Length -ne $record.length -or $held.Sha256 -cne $record.sha256) { throw 'original runtime replaced' } + } + $facts=Get-CcodInstalledLifecycleFacts -InstallRoot $root -ExpectedVersion $Spec.expectedVersion -ExpectedPayloadFiles $Spec.candidatePayloadFiles + if ($facts.activeRuntimeId -cne $Spec.runtimeId -or $facts.activeGeneration -ne $Spec.generation -or + $facts.runtimeManifestSha256 -cne $Spec.manifestSha256 -or $facts.installReady -isnot [bool] -or -not $facts.installReady -or + $facts.protectionReady -isnot [bool] -or -not $facts.protectionReady -or + -not(Test-CcodInstalledLifecycleFileRecordSet $facts.runtimeManifestFiles $Spec.runtimeFiles)) { throw 'uninstall original state' } + $wrapperRecords=@($Spec.runtimeFiles|Where-Object {$_.path -ceq 'Uninstall-CodexControlOtherDevices.ps1'}) + if ($wrapperRecords.Count -ne 1) { throw 'uninstall wrapper missing' } + $active=Read-CcodInstalledLifecycleActiveFact -InstallRoot $root + if ($null -eq $active -or $active.activeRuntime -cne $Spec.runtimeId -or $active.generation -ne $Spec.generation) { throw 'active selector changed' } + Assert-CcodInstalledLifecycleExecutionLease $lease + $values=@(& (Join-Path $runtime 'Uninstall-CodexControlOtherDevices.ps1') -Confirm:$false) + if ($values.Count -ne 1 -or -not(Test-CcodInstalledLifecycleExactProperties $values[0] @('Outcome','TransactionId','FinalizerProcessId','KeptDeviceKeyStore')) -or + $values[0].Outcome -isnot [string] -or $values[0].Outcome -cne 'InstalledFinalizationStarted' -or + $values[0].TransactionId -isnot [string] -or $values[0].TransactionId -cnotmatch '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z' -or + $values[0].FinalizerProcessId -isnot [int] -or $values[0].FinalizerProcessId -lt 1 -or + $values[0].KeptDeviceKeyStore -isnot [bool] -or -not $values[0].KeptDeviceKeyStore) { throw 'uninstall wrapper completion' } + Assert-CcodInstalledLifecycleExecutionLease $lease + return $values[0] + } catch { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' 'The direct uninstaller changed before or during verified consumption.' $null + } finally { + # This executes in the wrapper process, before its exit releases the + # detached finalizer's existing exact-wrapper-exit barrier. + Close-CcodInstalledLifecycleExecutionLease $lease + } +} + +function Start-CcodInstalledLifecycleVerifiedUninstall { + param([Parameter(Mandatory)]$Spec) + $sourceLease=$null;$process=$null + try { + $entry=Join-Path $script:CcodInstalledLifecycleRepositoryRoot 'tests\installed\Invoke-InstalledLifecycleIntegration.ps1' + $sourcePaths=@($entry,(Join-Path $script:CcodInstalledLifecycleRepositoryRoot 'tools\ReleaseAssetContract.psm1')) + foreach($leaf in @('PersistenceIO.psm1','StateStore.psm1','TrustedLogonIdentity.psm1','LifecycleTransaction.psm1','KernelObjects.psm1','ProductRegistration.psm1')) { + $sourcePaths+=Join-Path $script:CcodInstalledLifecycleRepositoryRoot ('src\persistence\modules\'+$leaf) + } + $sourceLease=Open-CcodInstalledLifecycleExecutionLease -Paths $sourcePaths + $inputJson=$Spec|ConvertTo-Json -Depth 8 -Compress + if ($inputJson.Length -lt 1 -or $inputJson.Length -gt 1048576) { throw 'uninstall input bounds' } + $start=[Diagnostics.ProcessStartInfo]::new() + $start.FileName=Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::System)) 'WindowsPowerShell\v1.0\powershell.exe' + $start.Arguments='-NoLogo -NoProfile -NonInteractive -File "'+$entry+'" -VerifiedUninstallChild -AllowMachineMutation -AllowCodexRestart' + $start.UseShellExecute=$false;$start.CreateNoWindow=$true + $start.RedirectStandardInput=$true;$start.RedirectStandardOutput=$true;$start.RedirectStandardError=$true + # Windows PowerShell 5.1 has no StandardInputEncoding, so the child must own + # its input decoding: the default stdin writer can emit a UTF-8 BOM that a + # legacy console input page would then decode into a corrupt first token. + $start.StandardOutputEncoding=[Text.UTF8Encoding]::new($false) + $start.StandardErrorEncoding=[Text.UTF8Encoding]::new($false) + [void]$start.EnvironmentVariables.Remove('PSExecutionPolicyPreference') + Assert-CcodInstalledLifecycleExecutionLease $sourceLease + $process=[Diagnostics.Process]::Start($start) + $stdout=$process.StandardOutput.ReadToEndAsync();$stderr=$process.StandardError.ReadToEndAsync() + # Write explicit UTF-8 bytes with no byte-order mark: Windows PowerShell 5.1 + # cannot pin ProcessStartInfo.StandardInputEncoding, so the inherited console + # page could otherwise prepend a BOM and corrupt the child's first JSON token. + $inputBytes=[Text.UTF8Encoding]::new($false).GetBytes($inputJson) + $process.StandardInput.BaseStream.Write($inputBytes,0,$inputBytes.Length) + $process.StandardInput.BaseStream.Flush() + $process.StandardInput.Close() + if (-not $process.WaitForExit(600000)) { throw 'verified uninstall child timeout' } + $output=$stdout.GetAwaiter().GetResult();$errorOutput=$stderr.GetAwaiter().GetResult() + if ($process.ExitCode -ne 0 -or $output -cne ('CCOD_UNINSTALL_WRAPPER_COMPLETED'+[Environment]::NewLine) -or $errorOutput.Length -ne 0) { + throw 'verified uninstall child result' + } + Assert-CcodInstalledLifecycleExecutionLease $sourceLease + return [pscustomobject]@{ExitCode=0} + } catch { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_INSTALLER_FAILED' 'The verified uninstall child did not complete its wrapper handoff.' $null } + finally { + if ($null -ne $process) { + try { if (-not $process.HasExited) { $process.Kill();$process.WaitForExit() } } finally { $process.Dispose() } + } + Close-CcodInstalledLifecycleExecutionLease $sourceLease + } +} + function Invoke-CcodInstalledLifecycleOperatorScenario { param([Parameter(Mandatory)]$Context) $launchInstaller = { @@ -637,6 +1799,10 @@ function Invoke-CcodInstalledLifecycleOperatorScenario { } }.GetNewClosure() switch ($Context.scenario) { + 'FreshInstall' { + & $launchInstaller + Read-CcodInstalledLifecycleOperatorAck -Scenario $Context.scenario -Instructions 'Complete the fresh install, choose Later, and wait for the new controlled Codex session and tray to become ready.' + } 'FreshLater' { & $launchInstaller Read-CcodInstalledLifecycleOperatorAck -Scenario $Context.scenario -Instructions 'Complete the installer and choose Later. Confirm that the pre-existing Codex session was left untouched.' @@ -654,8 +1820,13 @@ function Invoke-CcodInstalledLifecycleOperatorScenario { Read-CcodInstalledLifecycleOperatorAck -Scenario $Context.scenario -Instructions 'Use Windows Settings to uninstall CodexRemote-fix. Wait for the uninstaller to complete successfully.' } 'DirectUninstall' { - $uninstaller = Assert-CcodInstalledLifecycleRegularFile -Path (Join-Path $Context.installerApplicationRoot 'unins000.exe') -Kind 'Installed uninstaller' - $process = Start-Process -FilePath $uninstaller -PassThru -Wait -ErrorAction Stop + if($Context.expectedVersion-ceq'2.5.22'){ + $command=Get-CcodInstalledLifecycleUninstallCommand -InstallRoot $Context.installRoot -ExpectedVersion $Context.expectedVersion -ExpectedPayloadFiles $Context.candidatePayloadFiles + $process=Start-CcodInstalledLifecycleVerifiedUninstall -Spec $command.Spec + } else { + $uninstaller = Assert-CcodInstalledLifecycleRegularFile -Path (Join-Path $Context.installerApplicationRoot 'unins000.exe') -Kind 'Installed uninstaller' + $process = Start-Process -FilePath $uninstaller -PassThru -Wait -ErrorAction Stop + } if ($process.ExitCode -ne 0) { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_INSTALLER_FAILED' 'The installed uninstaller returned a nonzero exit code' $process.ExitCode } Read-CcodInstalledLifecycleOperatorAck -Scenario $Context.scenario -Instructions 'Confirm that the direct uninstaller completed and did not remove the device-key store.' } @@ -663,7 +1834,26 @@ function Invoke-CcodInstalledLifecycleOperatorScenario { Read-CcodInstalledLifecycleOperatorAck -Scenario $Context.scenario -Instructions 'Perform the named scenario through the visible CodexRemote-fix UI and wait for its observable state to settle.' } } - return [pscustomobject][ordered]@{ code = 'CCOD_INTEGRATION_OPERATOR_COMPLETED' } + $runtimeManifestSha256 = $null + if ($Context.scenario -in @('FreshInstall','FreshLater','FreshRestart','Upgrade','SlowLaunch','ManualLaunchResume','LanguageStress','RepairStates')) { + try { + $postOperationFacts = Get-CcodInstalledLifecycleFacts -InstallRoot $Context.installRoot -ExpectedVersion $Context.expectedVersion -ExpectedPayloadFiles $Context.candidatePayloadFiles + if ($postOperationFacts.runtimeManifestSha256 -isnot [string] -or $postOperationFacts.runtimeManifestSha256 -cnotmatch '^[0-9a-f]{64}\z') { throw 'runtime manifest attestation' } + $runtimeManifestSha256 = [string]$postOperationFacts.runtimeManifestSha256 + } catch { + if ($_.FullyQualifiedErrorId -like 'CCOD_INTEGRATION_*') { throw } + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' 'The operator completion did not produce a candidate-bound runtime manifest attestation.' $Context.scenario + } + } + return [pscustomobject][ordered]@{ + code = 'CCOD_INTEGRATION_OPERATOR_COMPLETED' + scenario = [string]$Context.scenario + transactionId = [string]$Context.transactionId + completed = $true + operatorAttestation = 'OperatorConfirmed' + installerSha256 = [string](Get-CcodInstalledLifecycleHash -Path $Context.installerPath) + runtimeManifestSha256 = $runtimeManifestSha256 + } } function Test-CcodInstalledLifecycleSameIdentitySet { @@ -673,22 +1863,133 @@ function Test-CcodInstalledLifecycleSameIdentitySet { return (($expectedKeys -join ';') -ceq ($actualKeys -join ';')) } +function Test-CcodInstalledLifecycleRunResult { + param([Parameter(Mandatory)]$Context,[Parameter(Mandatory)]$RunResult) + if (-not (Test-CcodInstalledLifecycleExactProperties -Value $RunResult -Expected @('code','scenario','transactionId','completed','operatorAttestation','installerSha256','runtimeManifestSha256'))) { return $false } + $parsedTransactionId = [guid]::Empty + if ($RunResult.code -isnot [string] -or $RunResult.code -cne 'CCOD_INTEGRATION_OPERATOR_COMPLETED' -or + $RunResult.scenario -isnot [string] -or $RunResult.scenario -cne [string]$Context.scenario -or + $RunResult.transactionId -isnot [string] -or -not [guid]::TryParse($RunResult.transactionId,[ref]$parsedTransactionId) -or + $RunResult.completed -isnot [bool] -or -not $RunResult.completed -or + $RunResult.operatorAttestation -isnot [string] -or $RunResult.operatorAttestation -cne 'OperatorConfirmed' -or + $RunResult.installerSha256 -isnot [string] -or $RunResult.installerSha256 -cnotmatch '^[0-9a-f]{64}\z' -or + $Context.installerSha256 -isnot [string] -or $RunResult.installerSha256 -cne [string]$Context.installerSha256) { return $false } + $requiresRuntimeManifest = $Context.scenario -in @('FreshInstall','FreshLater','FreshRestart','Upgrade','SlowLaunch','ManualLaunchResume','LanguageStress','RepairStates') + if ($requiresRuntimeManifest) { return $RunResult.runtimeManifestSha256 -is [string] -and $RunResult.runtimeManifestSha256 -cmatch '^[0-9a-f]{64}\z' } + return $null -eq $RunResult.runtimeManifestSha256 +} + +function Test-CcodInstalledLifecycleDebugPortsClosed { + param($Ports) + try { + if ($Ports -isnot [array]) { throw 'captured ports array' } + $captured = [Collections.Generic.HashSet[int]]::new() + foreach ($port in $Ports) { + if (($port -isnot [int] -and $port -isnot [long]) -or $port -lt 1 -or $port -gt 65535 -or + -not $captured.Add([int]$port)) { throw 'captured port' } + } + if ($captured.Count -eq 0) { return $true } + foreach ($listener in @(Get-NetTCPConnection -State Listen -ErrorAction Stop)) { + if ($null -eq $listener -or $null -eq $listener.PSObject.Properties['LocalPort']) { throw 'listener port unavailable' } + $port = $listener.LocalPort + if (($port -isnot [uint16] -and $port -isnot [int] -and $port -isnot [long]) -or + $port -lt 1 -or $port -gt 65535) { throw 'listener port shape' } + if ($captured.Contains([int]$port)) { return $false } + } + return $true + } catch { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' 'Previously declared debug ports could not be inspected safely.' $null + } +} + function Test-CcodInstalledLifecycleScenario { param([Parameter(Mandatory)]$Context, [Parameter(Mandatory)]$BeforeFacts, [Parameter(Mandatory)]$RunResult) - $afterFacts = ConvertTo-CcodInstalledLifecycleFacts -Facts (Get-CcodInstalledLifecycleFacts -InstallRoot $Context.installRoot) + if (-not (Test-CcodInstalledLifecycleRunResult -Context $Context -RunResult $RunResult)) { + return [pscustomobject][ordered]@{ verified = $false; code = 'CCOD_INTEGRATION_OBSERVATION_UNPROVEN'; facts = $null } + } + $expectedDebugPorts = @() + if ($Context.PSObject.Properties['expectedDebugPorts'] -and $null -ne $Context.expectedDebugPorts) { $expectedDebugPorts = @($Context.expectedDebugPorts) } + elseif ($BeforeFacts.PSObject.Properties['debugPorts'] -and $null -ne $BeforeFacts.debugPorts) { $expectedDebugPorts = @($BeforeFacts.debugPorts) } + elseif ($BeforeFacts.PSObject.Properties['debugEndpoints']) { foreach ($endpoint in @($BeforeFacts.debugEndpoints)) { if ($endpoint.PSObject.Properties['localPort'] -and $endpoint.localPort -is [ValueType]) { $expectedDebugPorts += [int]$endpoint.localPort } } } + $expectedPayloadFiles = $null + if ($Context.PSObject.Properties['candidatePayloadFiles']) { $expectedPayloadFiles = @($Context.candidatePayloadFiles) } + $requiresDebugEvidence = $Context.scenario -notin @('SafeExit','SettingsUninstall','DirectUninstall') + $afterFacts = if ($requiresDebugEvidence -and $expectedDebugPorts.Count -gt 0) { + ConvertTo-CcodInstalledLifecycleFacts -Facts (Get-CcodInstalledLifecycleFacts -InstallRoot $Context.installRoot -ExpectedVersion $Context.expectedVersion -ExpectedDebugPorts $expectedDebugPorts -ExpectedPayloadFiles $expectedPayloadFiles) + } else { + ConvertTo-CcodInstalledLifecycleFacts -Facts (Get-CcodInstalledLifecycleFacts -InstallRoot $Context.installRoot -ExpectedVersion $Context.expectedVersion -ExpectedPayloadFiles $expectedPayloadFiles) + } + $payloadEvidenceValid = $true + if ($null -ne $expectedPayloadFiles) { $payloadEvidenceValid = Test-CcodInstalledLifecycleCandidateFileSet -Actual $afterFacts.runtimeManifestFiles -Expected $expectedPayloadFiles -InstallRoot $Context.installRoot -RuntimeId $afterFacts.activeRuntimeId -Generation $afterFacts.activeGeneration -ManifestSha256 $afterFacts.runtimeManifestSha256 } + if ($Context.scenario -in @('FreshInstall','FreshRestart') -and $expectedDebugPorts.Count -eq 0) { $expectedDebugPorts = @($afterFacts.debugPorts) } + $debugEvidenceValid = $true + if ($Context.scenario -in @('FreshInstall','FreshLater','FreshRestart','Upgrade')) { + $afterPorts = @($afterFacts.debugPorts) + $afterEndpoints = @($afterFacts.debugEndpoints) + $codexIdentities = @($afterFacts.codex) + $expectedPortKey = (@($expectedDebugPorts | ForEach-Object { [int]$_ } | Sort-Object) -join ',') + $afterPortKey = (@($afterPorts | ForEach-Object { [int]$_ } | Sort-Object) -join ',') + $expectedPortCount = @($expectedDebugPorts | Sort-Object -Unique).Count + if ($expectedDebugPorts.Count -ne 2 -or $expectedPortCount -ne 2 -or + $afterPorts.Count -ne $expectedDebugPorts.Count -or $afterPortKey -cne $expectedPortKey -or + $afterEndpoints.Count -ne $expectedDebugPorts.Count -or $codexIdentities.Count -ne 1) { + $debugEvidenceValid = $false + } else { + $owner = $codexIdentities[0] + foreach ($port in $expectedDebugPorts) { + $matches = @($afterEndpoints | Where-Object { + $_.localAddress -is [string] -and $_.localAddress -ceq '127.0.0.1' -and + $_.localPort -is [ValueType] -and $_.localPort -isnot [bool] -and [int]($_.localPort) -eq [int]$port -and + $_.owningProcess -is [ValueType] -and $_.owningProcess -isnot [bool] -and [uint64]($_.owningProcess) -eq [uint64]$owner.pid -and + $_.owningProcessCreationTimeUtc -is [string] -and $_.owningProcessCreationTimeUtc -ceq [string]$owner.creationTimeUtc + }) + if ($matches.Count -ne 1) { $debugEvidenceValid = $false } + } + } + } $verified = $true $code = 'CCOD_INTEGRATION_VERIFIED' if ($Context.scenario -in @('SettingsUninstall','DirectUninstall')) { - $verified = -not $afterFacts.appPresent -and $afterFacts.taskState -ceq 'Absent' + $portsClosed = Test-CcodInstalledLifecycleDebugPortsClosed -Ports $BeforeFacts.debugPorts + $verified = $portsClosed -and -not $afterFacts.installRootPresent -and -not $afterFacts.appPresent -and -not $afterFacts.runtimeRootPresent -and -not $afterFacts.activePointerPresent -and + $afterFacts.taskState -ceq 'Absent' -and $afterFacts.statusCodex -eq $null -and $afterFacts.statusRuntimeId -eq $null -and + $afterFacts.statusPhase -in @('Unavailable','Stopped','Closed') -and $afterFacts.transitionStage -in @('Unavailable','Idle','Completed','Failed','Cancelled') -and + $afterFacts.supervisor.Count -eq 0 -and $afterFacts.trayHost.Count -eq 0 -and $afterFacts.codex.Count -eq 0 -and + $afterFacts.shortcuts.startMenu -eq $false -and $afterFacts.shortcuts.desktop -eq $false -and $afterFacts.debugEndpoints.Count -eq 0 -and $null -eq $afterFacts.lifecycleReceipt if ($BeforeFacts.deviceKeyPresent) { $verified = $verified -and $afterFacts.deviceKeyPresent -and $afterFacts.deviceKeySha256 -ceq $BeforeFacts.deviceKeySha256 } + else { $verified = $verified -and -not $afterFacts.deviceKeyPresent -and $null -eq $afterFacts.deviceKeySha256 } } elseif ($Context.scenario -eq 'SafeExit') { $verified = $afterFacts.appPresent -and $afterFacts.supervisor.Count -eq 0 -and $afterFacts.trayHost.Count -eq 0 } else { - $verified = $afterFacts.appPresent -and $afterFacts.aboutVersion -ceq $Context.expectedVersion -and $null -ne $afterFacts.activeRuntimeId -and $afterFacts.taskState -in @('Ready','Running') + $verified = $afterFacts.installRootPresent -and $afterFacts.appPresent -and $afterFacts.aboutVersion -ceq $Context.expectedVersion -and $null -ne $afterFacts.activeRuntimeId -and $afterFacts.taskState -in @('Ready','Running') -and $debugEvidenceValid -and $payloadEvidenceValid -and + $afterFacts.installReady -is [bool] -and $afterFacts.installReady -and $afterFacts.protectionReady -is [bool] -and $afterFacts.protectionReady -and + $afterFacts.trayAuthenticated -is [bool] -and $afterFacts.trayAuthenticated -and $null -ne $afterFacts.trayHostIdentity -and + $RunResult.runtimeManifestSha256 -ceq [string]$afterFacts.runtimeManifestSha256 + if ($Context.scenario -in @('FreshInstall','FreshLater','Upgrade')) { + $verified = $verified -and $afterFacts.supervisor.Count -eq 1 -and $afterFacts.trayHost.Count -eq 1 -and $afterFacts.codex.Count -eq 1 -and + $afterFacts.statusPhase -ceq 'Active' -and $afterFacts.statusRuntimeId -ceq $afterFacts.activeRuntimeId -and $null -ne $afterFacts.statusCodex -and + $afterFacts.statusCodex.pid -eq $afterFacts.codex[0].Pid -and $afterFacts.statusCodex.creationTimeUtc -ceq $afterFacts.codex[0].CreationTimeUtc -and + $afterFacts.transitionStage -ceq 'Idle' -and $null -ne $afterFacts.lifecycleReceipt -and + $afterFacts.lifecycleReceipt.kind -ceq 'RestartAndRepair' -and $afterFacts.lifecycleReceipt.origin -ceq 'Installer' -and + $afterFacts.lifecycleReceipt.runtimeId -ceq $afterFacts.activeRuntimeId -and + [UInt64]$afterFacts.lifecycleReceipt.runtimeGeneration -eq [UInt64]$afterFacts.activeGeneration -and + $afterFacts.lifecycleReceipt.phase -ceq 'Completed' + } if ($BeforeFacts.deviceKeyPresent) { $verified = $verified -and $afterFacts.deviceKeyPresent -and $afterFacts.deviceKeySha256 -ceq $BeforeFacts.deviceKeySha256 } - if ($Context.scenario -eq 'FreshLater' -and $BeforeFacts.codex.Count -gt 0) { + if ($Context.scenario -eq 'FreshLater' -and $BeforeFacts.codex.Count -eq 1) { $verified = $verified -and (Test-CcodInstalledLifecycleSameIdentitySet -Expected $BeforeFacts.codex -Actual $afterFacts.codex) } + if ($Context.scenario -eq 'FreshLater' -and $BeforeFacts.codex.Count -ne 1) { $verified = $false } + if ($Context.scenario -eq 'FreshInstall') { + $verified = $verified -and $BeforeFacts.codex.Count -eq 0 -and + -not $BeforeFacts.installRootPresent -and -not $BeforeFacts.appPresent -and -not $BeforeFacts.runtimeRootPresent -and + -not $BeforeFacts.activePointerPresent -and $BeforeFacts.taskState -ceq 'Absent' -and + $BeforeFacts.supervisor.Count -eq 0 -and $BeforeFacts.trayHost.Count -eq 0 -and + $BeforeFacts.statusPhase -in @('Unavailable','Stopped','Closed') -and $null -eq $BeforeFacts.statusRuntimeId -and + $null -eq $BeforeFacts.statusCodex -and $BeforeFacts.transitionStage -in @('Unavailable','Idle') -and + $null -eq $BeforeFacts.lifecycleReceipt -and -not $BeforeFacts.shortcuts.startMenu -and -not $BeforeFacts.shortcuts.desktop -and + $BeforeFacts.debugPorts.Count -eq 0 -and $BeforeFacts.debugEndpoints.Count -eq 0 + } if ($Context.scenario -eq 'FreshRestart') { $rootIdentityReplaced = $true if ($BeforeFacts.codex.Count -gt 0) { @@ -720,7 +2021,23 @@ function Test-CcodInstalledLifecycleScenario { function Invoke-CcodInstalledLifecycleRollback { param([Parameter(Mandatory)]$Context, [Parameter(Mandatory)]$Snapshot) if (-not [string]::IsNullOrWhiteSpace([string]$Context.previousInstallerPath)) { - $process = Start-Process -FilePath $Context.previousInstallerPath -PassThru -Wait -ErrorAction Stop + try { + $expectedVersion = if ($Context.PSObject.Properties['previousExpectedVersion']) { [string]$Context.previousExpectedVersion } else { $null } + $expectedInstallerHash = if ($Context.PSObject.Properties['previousInstallerSha256']) { [string]$Context.previousInstallerSha256 } else { $null } + $expectedManifestHash = if ($Context.PSObject.Properties['previousManifestSha256']) { [string]$Context.previousManifestSha256 } else { $null } + $installer = Assert-CcodInstalledLifecycleRegularFile -Path ([string]$Context.previousInstallerPath) -Kind 'Frozen previous installer' + $installerName = [IO.Path]::GetFileName($installer) + $versionMatch = [regex]::Match($installerName, '^CodexRemote-fix-(?\d+\.\d+\.\d+)-setup\.exe\z') + if (-not $versionMatch.Success -or $versionMatch.Groups['version'].Value -cne $expectedVersion -or + $expectedInstallerHash -notmatch '^[0-9a-f]{64}\z' -or (Get-CcodInstalledLifecycleHash -Path $installer) -cne $expectedInstallerHash) { throw 'rollback installer identity' } + $manifestName = 'CodexRemote-fix-{0}-setup-release-manifest.json' -f $expectedVersion + $manifest = Assert-CcodInstalledLifecycleRegularFile -Path (Join-Path (Split-Path -Parent $installer) $manifestName) -Kind 'Frozen previous installer release manifest' + if ($expectedManifestHash -notmatch '^[0-9a-f]{64}\z' -or (Get-CcodInstalledLifecycleHash -Path $manifest) -cne $expectedManifestHash) { throw 'rollback manifest identity' } + } catch { + if ($_.FullyQualifiedErrorId -like 'CCOD_INTEGRATION_ROLLBACK_FAILED*') { throw } + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_ROLLBACK_FAILED' 'The frozen prior installer identity could not be revalidated before rollback.' $Context.previousInstallerPath + } + $process = Start-Process -FilePath $installer -PassThru -Wait -ErrorAction Stop if ($process.ExitCode -ne 0) { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_ROLLBACK_FAILED' 'The prior installer did not complete during rollback' $process.ExitCode } Read-CcodInstalledLifecycleOperatorAck -Scenario 'ROLLBACK' -Instructions 'Verify that the prior installer restored the pre-test application state.' return [pscustomobject][ordered]@{ restored = $true; code = 'CCOD_INTEGRATION_ROLLBACK_COMPLETED' } @@ -728,27 +2045,58 @@ function Invoke-CcodInstalledLifecycleRollback { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_ROLLBACK_UNAVAILABLE' 'No prior installer was supplied for an observable rollback path' $Context.scenario } +function Remove-CcodInstalledLifecycleFrozenRoot { + param([Parameter(Mandatory)][string]$Path) + try { + $root = Assert-CcodInstalledLifecycleCanonicalAbsolutePath -Path $Path -Kind 'Frozen rollback root' + $rootItem = Get-Item -LiteralPath $root -Force -ErrorAction Stop + if ($rootItem -isnot [IO.DirectoryInfo] -or ($rootItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'frozen root identity' } + Assert-CcodInstalledLifecycleNoReparseAncestors -Path $root -GetItem { param($CandidatePath) Get-Item -LiteralPath $CandidatePath -Force -ErrorAction Stop } + $pendingDirectories = [Collections.Generic.Stack[string]]::new() + $pendingDirectories.Push($root) + while ($pendingDirectories.Count -gt 0) { + $current = $pendingDirectories.Pop() + foreach ($child in @(Get-ChildItem -LiteralPath $current -Force -ErrorAction Stop)) { + if (($child.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'frozen descendant reparse' } + if ($child -is [IO.DirectoryInfo]) { $pendingDirectories.Push($child.FullName) } + elseif ($child -isnot [IO.FileInfo]) { throw 'frozen descendant identity' } + } + } + Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction Stop + if (Test-Path -LiteralPath $root) { throw 'frozen root residue' } + return $true + } catch { + if ($_.FullyQualifiedErrorId -like 'CCOD_INTEGRATION_ROLLBACK_CLEANUP_FAILED*') { throw } + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_ROLLBACK_CLEANUP_FAILED' 'The frozen rollback root or one of its descendants was unsafe to remove.' $Path + } +} + function Invoke-CcodInstalledLifecycleIntegration { [CmdletBinding()] param( [Parameter(Mandatory)][string]$InstallerPath, [string]$PreviousInstallerPath, - [Parameter(Mandatory)][ValidatePattern('^\d+\.\d+\.\d+$')][string]$ExpectedVersion, + [string]$PreviousExpectedVersion, + [string]$PreviousInstallerSha256, + [string]$PreviousManifestSha256, + [Parameter(Mandatory)][ValidatePattern('^\d+\.\d+\.\d+\z')][string]$ExpectedVersion, [Parameter(Mandatory)][string]$EvidenceRoot, [switch]$AllowMachineMutation, [switch]$AllowCodexRestart, - [Parameter(Mandatory)][ValidateSet('FreshLater','FreshRestart','Upgrade','SlowLaunch','ManualLaunchResume','LanguageStress','RepairStates','SafeExit','SettingsUninstall','DirectUninstall')][string]$Scenario, + [Parameter(Mandatory)][ValidateSet('FreshInstall','FreshLater','FreshRestart','Upgrade','SlowLaunch','ManualLaunchResume','LanguageStress','RepairStates','SafeExit','SettingsUninstall','DirectUninstall')][string]$Scenario, + $ExpectedCandidate, [hashtable]$Adapters ) if (-not $AllowMachineMutation) { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_MUTATION_NOT_ALLOWED' 'Live installed lifecycle scenarios require -AllowMachineMutation before any candidate, checkout, or machine operation.' $Scenario } - if ($script:CcodInstalledLifecycleRestartScenarios -ccontains $Scenario -and -not $AllowCodexRestart) { + if ($script:CcodInstalledLifecycleRestartScenarios -icontains $Scenario -and -not $AllowCodexRestart) { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_CODEX_RESTART_NOT_ALLOWED' 'This scenario can stop or restart Codex and requires -AllowCodexRestart before any operation.' $Scenario } $adapters = Resolve-CcodInstalledLifecycleAdapters -Adapters $Adapters - $startedAt = & $adapters.GetUtcNow - if ($startedAt -isnot [datetime]) { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_CLOCK_INVALID' 'The integration clock did not return a DateTime value' $startedAt } + $startedAtValues = @(& $adapters.GetUtcNow) + if ($startedAtValues.Count -ne 1 -or -not (Test-CcodInstalledLifecycleValidUtcClock -Value $startedAtValues[0])) { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_CLOCK_INVALID' 'The integration clock did not return one valid UTC DateTime value' $startedAtValues } + $startedAt = $startedAtValues[0] $transactionId = [guid]::NewGuid().ToString('D') $phases = [Collections.Generic.List[object]]::new() $evidenceDirectory = $null @@ -757,15 +2105,44 @@ function Invoke-CcodInstalledLifecycleIntegration { $failureCode = $null $rollback = [pscustomobject][ordered]@{ attempted = $false; completed = $false; code = $null } $candidate = $null + $candidateExecutionLease = $null + $previousExecutionLease = $null $previousCandidate = $null + $frozenPreviousRoot = $null + $frozenPreviousInstallerPath = $null + $frozenPreviousManifestPath = $null $beforeFacts = $null $verification = $null try { - $candidate = Get-CcodInstalledLifecycleCandidate -Path $InstallerPath -ExpectedVersion $ExpectedVersion -Adapters $adapters -Kind 'Installer' + $candidate = Get-CcodInstalledLifecycleCandidate -Path $InstallerPath -ExpectedVersion $ExpectedVersion -Adapters $adapters -Kind 'Installer' -ExecutionLease ([ref]$candidateExecutionLease) + if ($PSBoundParameters.ContainsKey('ExpectedCandidate')) { Assert-CcodInstalledLifecycleExpectedCandidate -Candidate $candidate -Expected $ExpectedCandidate } if ($Scenario -ceq 'Upgrade') { if ([string]::IsNullOrWhiteSpace($PreviousInstallerPath)) { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_PREVIOUS_INSTALLER_REQUIRED' 'Upgrade scenarios require a checksum-bound previous installer for rollback.' $Scenario } - $previousCandidate = Get-CcodInstalledLifecycleCandidate -Path $PreviousInstallerPath -Adapters $adapters -Kind 'Previous installer' -AllowAnyVersion - if ([version]$previousCandidate.Version -ge [version]$ExpectedVersion) { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_PREVIOUS_INSTALLER_INVALID' 'Upgrade rollback installer must be a checksum-bound prior version.' $previousCandidate.Path } + if ([string]::IsNullOrWhiteSpace($PreviousExpectedVersion) -or [string]::IsNullOrWhiteSpace($PreviousInstallerSha256) -or [string]::IsNullOrWhiteSpace($PreviousManifestSha256)) { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_PREVIOUS_INSTALLER_REQUIRED' 'Upgrade scenarios require the exact validated previous version and manifest-bound installer hashes.' $Scenario + } + try { + $previousCandidate = Get-CcodInstalledLifecycleCandidate -Path $PreviousInstallerPath -ExpectedVersion $PreviousExpectedVersion -Adapters $adapters -Kind 'Previous installer' + if ([version]$previousCandidate.Version -ge [version]$ExpectedVersion -or $previousCandidate.Version -cne $PreviousExpectedVersion -or $previousCandidate.Sha256 -cne $PreviousInstallerSha256) { throw 'previous installer identity' } + $previousManifestName = 'CodexRemote-fix-{0}-setup-release-manifest.json' -f $previousCandidate.Version + $previousManifestPath = Assert-CcodInstalledLifecycleRegularFile -Path (Join-Path (Split-Path -Parent $previousCandidate.Path) $previousManifestName) -Kind 'Previous installer release manifest' + $previousManifestActualHash = [string](& $adapters.GetFileSha256 $previousManifestPath) + if ($previousManifestActualHash -cnotmatch '^[0-9a-f]{64}\z' -or $previousManifestActualHash -cne $PreviousManifestSha256) { throw 'previous manifest identity' } + $temporaryRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()) + [void](Assert-CcodInstalledLifecycleSafeDirectory -Path $temporaryRoot -Kind 'System temporary root') + $frozenPreviousRoot = [IO.Path]::GetFullPath((Join-Path $temporaryRoot ('ccod-frozen-previous-' + [guid]::NewGuid().ToString('N')))) + [void](Assert-CcodInstalledLifecycleSafeDirectory -Path $frozenPreviousRoot -Kind 'Frozen previous root') + $frozenPreviousInstallerPath = Join-Path $frozenPreviousRoot ([IO.Path]::GetFileName($previousCandidate.Path)) + $frozenPreviousManifestPath = Join-Path $frozenPreviousRoot $previousManifestName + [IO.File]::Copy($previousCandidate.Path, $frozenPreviousInstallerPath, $false) + [IO.File]::Copy($previousManifestPath, $frozenPreviousManifestPath, $false) + [void](Assert-CcodInstalledLifecycleRegularFile -Path $frozenPreviousInstallerPath -Kind 'Frozen previous installer') + [void](Assert-CcodInstalledLifecycleRegularFile -Path $frozenPreviousManifestPath -Kind 'Frozen previous installer release manifest') + $previousExecutionLease = Open-CcodInstalledLifecycleExecutionLease -Paths @($frozenPreviousInstallerPath,$frozenPreviousManifestPath) + if ((Get-CcodInstalledLifecycleHash -Path $frozenPreviousInstallerPath) -cne $PreviousInstallerSha256 -or (Get-CcodInstalledLifecycleHash -Path $frozenPreviousManifestPath) -cne $PreviousManifestSha256) { throw 'frozen previous identity' } + } catch { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_PREVIOUS_INSTALLER_INVALID' 'Upgrade rollback installer and manifest are not the exact validated prior identity.' $PreviousInstallerPath + } } elseif (-not [string]::IsNullOrWhiteSpace($PreviousInstallerPath)) { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_PREVIOUS_INSTALLER_UNEXPECTED' 'A previous installer is allowed only for the Upgrade scenario.' $Scenario } @@ -780,21 +2157,33 @@ function Invoke-CcodInstalledLifecycleIntegration { transactionId = $transactionId scenario = $Scenario installerPath = $candidate.Path - previousInstallerPath = if ($null -eq $previousCandidate) { $null } else { $previousCandidate.Path } + previousInstallerPath = if ($null -eq $previousCandidate) { $null } else { $frozenPreviousInstallerPath } + previousExpectedVersion = if ($null -eq $previousCandidate) { $null } else { $PreviousExpectedVersion } + previousInstallerSha256 = if ($null -eq $previousCandidate) { $null } else { $PreviousInstallerSha256 } + previousManifestSha256 = if ($null -eq $previousCandidate) { $null } else { $PreviousManifestSha256 } + previousManifestPath = if ($null -eq $previousCandidate) { $null } else { $frozenPreviousManifestPath } expectedVersion = $ExpectedVersion installRoot = [IO.Path]::GetFullPath($installRoot) installerApplicationRoot = [IO.Path]::GetFullPath($installerApplicationRoot) + installerSha256 = [string]$candidate.Sha256 + candidatePayloadManifestSha256 = [string]$candidate.PayloadManifestSha256 + candidatePayloadFiles = @($candidate.PayloadFiles) } - $beforeFacts = ConvertTo-CcodInstalledLifecycleFacts -Facts (& $adapters.CaptureFacts $context.installRoot) + $beforeObservationVersion = if ($Scenario -ceq 'Upgrade') { $PreviousExpectedVersion } else { $ExpectedVersion } + $beforeFactsRaw = & $adapters.CaptureFacts $context.installRoot $beforeObservationVersion + $context | Add-Member -NotePropertyName expectedDebugPorts -NotePropertyValue @($beforeFactsRaw.debugPorts) + $beforeFacts = ConvertTo-CcodInstalledLifecycleFacts -Facts $beforeFactsRaw $snapshot = & $adapters.CreateRollbackSnapshot $context $beforeFacts if ($null -eq $snapshot) { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_SNAPSHOT_FAILED' 'Rollback snapshot adapter returned no snapshot.' $null } $phases.Add((New-CcodInstalledLifecyclePhase -Name 'CaptureRollback' -Outcome 'Completed')) try { + Assert-CcodInstalledLifecycleExecutionLease $candidateExecutionLease $runResult = & $adapters.RunScenario $context + Assert-CcodInstalledLifecycleExecutionLease $candidateExecutionLease } catch { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_SCENARIO_FAILED' 'The live scenario failed before verification.' (Get-CcodInstalledLifecycleErrorId $_) } - if ($null -eq $runResult) { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_SCENARIO_FAILED' 'The live scenario returned no result.' $null } + if ($null -eq $runResult -or -not (Test-CcodInstalledLifecycleRunResult -Context $context -RunResult $runResult)) { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_SCENARIO_FAILED' 'The live scenario did not return a complete candidate-bound attestation.' $runResult } $phases.Add((New-CcodInstalledLifecyclePhase -Name 'RunScenario' -Outcome 'Completed')) try { $verification = & $adapters.VerifyScenario $context $beforeFacts $runResult @@ -804,10 +2193,18 @@ function Invoke-CcodInstalledLifecycleIntegration { if ($null -eq $verification -or $verification.verified -isnot [bool] -or -not [bool]$verification.verified -or $verification.code -isnot [string]) { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_VERIFICATION_FAILED' 'The live scenario did not produce a proven verification result.' $verification } + $verificationFacts = ConvertTo-CcodInstalledLifecycleFacts -Facts $verification.facts + $installedProofScenario = $Scenario -in @('FreshInstall','FreshLater','FreshRestart','Upgrade','SlowLaunch','ManualLaunchResume','LanguageStress','RepairStates') + if ($installedProofScenario -and + (-not $verificationFacts.installReady -or -not $verificationFacts.protectionReady -or -not $verificationFacts.trayAuthenticated -or $null -eq $verificationFacts.trayHostIdentity -or + -not (Test-CcodInstalledLifecycleCandidateFileSet -Actual $verificationFacts.runtimeManifestFiles -Expected $context.candidatePayloadFiles -InstallRoot $context.installRoot -RuntimeId $verificationFacts.activeRuntimeId -Generation $verificationFacts.activeGeneration -ManifestSha256 $verificationFacts.runtimeManifestSha256) -or + $runResult.runtimeManifestSha256 -cne [string]$verificationFacts.runtimeManifestSha256)) { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_VERIFICATION_FAILED' 'The returned verification facts are not bound to the candidate and authenticated Ready proof.' $verificationFacts + } $verification = [pscustomobject][ordered]@{ verified = $true code = [string]$verification.code - facts = ConvertTo-CcodInstalledLifecycleFacts -Facts $verification.facts + facts = $verificationFacts } $phases.Add((New-CcodInstalledLifecyclePhase -Name 'VerifyScenario' -Outcome 'Completed')) } catch { @@ -817,7 +2214,9 @@ function Invoke-CcodInstalledLifecycleIntegration { if ($null -ne $snapshot) { $rollback.attempted = $true try { + if ($null -ne $previousExecutionLease) { Assert-CcodInstalledLifecycleExecutionLease $previousExecutionLease } $rollbackResult = & $adapters.Rollback $context $snapshot + if ($null -ne $previousExecutionLease) { Assert-CcodInstalledLifecycleExecutionLease $previousExecutionLease } if ($null -eq $rollbackResult -or $rollbackResult.restored -isnot [bool] -or -not [bool]$rollbackResult.restored -or $rollbackResult.code -isnot [string]) { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_ROLLBACK_FAILED' 'Rollback did not return a proven completion result.' $rollbackResult } @@ -831,6 +2230,8 @@ function Invoke-CcodInstalledLifecycleIntegration { } $phases.Add((New-CcodInstalledLifecyclePhase -Name 'Failed' -Outcome $failureCode)) } finally { + Close-CcodInstalledLifecycleExecutionLease $candidateExecutionLease + Close-CcodInstalledLifecycleExecutionLease $previousExecutionLease if ($null -ne $snapshot) { try { $cleaned = & $adapters.CleanupRollback $context $snapshot @@ -845,9 +2246,22 @@ function Invoke-CcodInstalledLifecycleIntegration { $rollback.code = 'CCOD_INTEGRATION_ROLLBACK_CLEANUP_FAILED' } } + if ($null -ne $frozenPreviousRoot) { + try { + [void](Remove-CcodInstalledLifecycleFrozenRoot -Path $frozenPreviousRoot) + } catch { + if ($null -eq $failure) { $failureCode = 'CCOD_INTEGRATION_ROLLBACK_CLEANUP_FAILED' } + $rollback.completed = $false + $rollback.code = 'CCOD_INTEGRATION_ROLLBACK_CLEANUP_FAILED' + } + } if ($null -ne $evidenceDirectory) { - $completedAt = & $adapters.GetUtcNow - if ($completedAt -isnot [datetime]) { $completedAt = [datetime]::UtcNow } + $completedAtValues = @() + try { $completedAtValues = @(& $adapters.GetUtcNow) } catch { $completedAtValues = @() } + if ($completedAtValues.Count -ne 1 -or -not (Test-CcodInstalledLifecycleValidUtcClock -Value $completedAtValues[0])) { + if ($null -eq $failure) { $failureCode = 'CCOD_INTEGRATION_CLOCK_INVALID' } + $completedAt = $startedAt + } else { $completedAt = $completedAtValues[0] } $receipt = [pscustomobject][ordered]@{ schemaVersion = 1 transactionId = $transactionId @@ -877,9 +2291,28 @@ function Invoke-CcodInstalledLifecycleIntegration { return $receipt } -if (-not $Library) { +if ($VerifiedUninstallChild) { + try { + if (-not $AllowMachineMutation -or -not $AllowCodexRestart) { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_MUTATION_NOT_ALLOWED' 'The uninstall child requires both explicit mutation and restart authorization.' $null + } + [Console]::OutputEncoding=[Text.UTF8Encoding]::new($false) + # Read the wrapper input as explicit UTF-8 bytes instead of the ambient + # console code page, and tolerate an optional byte-order mark. + $reader=[IO.StreamReader]::new([Console]::OpenStandardInput(),[Text.UTF8Encoding]::new($false),$true) + try{$json=$reader.ReadToEnd()}finally{$reader.Dispose()} + if ($json.Length -gt 1048576) { throw 'uninstall input bounds' } + if (-not (& $script:CcodInstalledLifecyclePersistenceIoModule {param($Json) Test-CcodJsonHasNoDuplicateProperties -Json $Json} $json)) { throw 'duplicate uninstall input' } + $spec=$json|ConvertFrom-Json -ErrorAction Stop + $expectedRoot=[IO.Path]::GetFullPath((Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)) 'CodexControlOtherDevices')) + if ($spec.installRoot -isnot [string] -or $spec.installRoot -cne $expectedRoot) { throw 'uninstall current-user root' } + Invoke-CcodInstalledLifecycleVerifiedUninstall -Spec $spec|Out-Null + [Console]::Out.WriteLine('CCOD_UNINSTALL_WRAPPER_COMPLETED') + exit 0 + } catch { [Console]::Error.WriteLine('CCOD_INTEGRATION_UNINSTALL_CHILD_FAILED');exit 1 } +} elseif (-not $Library) { try { - $receipt = Invoke-CcodInstalledLifecycleIntegration -InstallerPath $InstallerPath -PreviousInstallerPath $PreviousInstallerPath -ExpectedVersion $ExpectedVersion -EvidenceRoot $EvidenceRoot -AllowMachineMutation:$AllowMachineMutation -AllowCodexRestart:$AllowCodexRestart -Scenario $Scenario + $receipt = Invoke-CcodInstalledLifecycleIntegration -InstallerPath $InstallerPath -PreviousInstallerPath $PreviousInstallerPath -PreviousExpectedVersion $PreviousExpectedVersion -PreviousInstallerSha256 $PreviousInstallerSha256 -PreviousManifestSha256 $PreviousManifestSha256 -ExpectedVersion $ExpectedVersion -EvidenceRoot $EvidenceRoot -AllowMachineMutation:$AllowMachineMutation -AllowCodexRestart:$AllowCodexRestart -Scenario $Scenario $receipt | ConvertTo-Json -Depth 16 } catch { Write-Error $_ diff --git a/tests/installed/Invoke-OfficialDraftAcceptance.ps1 b/tests/installed/Invoke-OfficialDraftAcceptance.ps1 new file mode 100644 index 0000000..1df85a1 --- /dev/null +++ b/tests/installed/Invoke-OfficialDraftAcceptance.ps1 @@ -0,0 +1,227 @@ +[CmdletBinding()] +param( + [string]$Phase, + [string]$AssetDirectory, + [string]$PreviousAssetDirectory, + [string]$EvidenceRoot, + [string]$DraftId, + [switch]$AllowMachineMutation, + [switch]$AllowCodexRestart, + [switch]$AllowWindowsReboot, + [string]$TrayOperation, + [string]$RemoteOperation, + [string]$ScreenshotPath, + [string]$RedactedLogPath, + [string]$ReviewState +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# BEGIN CCOD TRUSTED IMPORT BOOTSTRAP +# Embedded in each independently trusted entrypoint; an unheld helper import +# cannot bootstrap its own protection. Keep copies identical (self-test enforced). +function Initialize-CcodTrustedImportLease { + if ($null -ne ('CcodTrustedImportLeaseV1' -as [type])) { return } + Add-Type -ErrorAction Stop -TypeDefinition @' +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Win32.SafeHandles; + +public sealed class CcodTrustedImportLeaseV1 : IDisposable +{ + [StructLayout(LayoutKind.Sequential)] private struct NativeTime { public uint Low, High; } + [StructLayout(LayoutKind.Sequential)] private struct NativeInfo + { + public uint Attributes; + public NativeTime Creation, Access, Write; + public uint Volume, SizeHigh, SizeLow, Links, IndexHigh, IndexLow; + } + private sealed class Entry + { + public SafeFileHandle Handle; + public string Path; + public bool Directory; + public uint Volume; + public ulong FileId; + } + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] + private static extern SafeFileHandle CreateFileW(string path, uint access, uint share, IntPtr security, uint creation, uint flags, IntPtr template); + [DllImport("kernel32.dll", SetLastError=true)] + private static extern bool GetFileInformationByHandle(SafeFileHandle handle, out NativeInfo info); + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] + private static extern uint GetFinalPathNameByHandleW(SafeFileHandle handle, StringBuilder value, uint length, uint flags); + + private readonly List entries = new List(); + private bool disposed; + public string Path { get; private set; } + private CcodTrustedImportLeaseV1(string path) { Path = path; } + + public static CcodTrustedImportLeaseV1 Acquire(string path) + { + if (String.IsNullOrWhiteSpace(path) || !System.IO.Path.IsPathRooted(path) || + path.StartsWith(@"\\?\", StringComparison.Ordinal) || path.StartsWith(@"\\.\", StringComparison.Ordinal)) + throw new ArgumentException("Unsupported module path"); + string full = System.IO.Path.GetFullPath(path); + string root = System.IO.Path.GetPathRoot(full); + string extension = System.IO.Path.GetExtension(full); + if (!String.Equals(full, path, StringComparison.OrdinalIgnoreCase) || String.IsNullOrEmpty(root) || + full.Substring(root.Length).IndexOf(':') >= 0 || + (!String.Equals(extension, ".psm1", StringComparison.OrdinalIgnoreCase) && !String.Equals(extension, ".ps1", StringComparison.OrdinalIgnoreCase))) + throw new ArgumentException("Noncanonical module path"); + var lease = new CcodTrustedImportLeaseV1(full); + try + { + var parents = new List(); + for (DirectoryInfo parent = Directory.GetParent(full); parent != null; parent = parent.Parent) + parents.Add(parent.FullName); + parents.Reverse(); + if (parents.Count == 0 || !SamePath(parents[0], root)) throw new IOException("Missing module root"); + // Retain root-to-leaf authority before traversing the next component. + foreach (string parent in parents) lease.Open(parent, true); + lease.Open(full, false); + lease.Revalidate(); + return lease; + } + catch { lease.Dispose(); throw; } + } + + private void Open(string path, bool directory) + { + uint access = directory ? 0x00100081U : 0x80100080U; + uint flags = 0x00200000U | (directory ? 0x02000000U : 0U); + // FILE_SHARE_READ only: preexisting writers/deleters must fail closed. + SafeFileHandle handle = CreateFileW(path, access, 1U, IntPtr.Zero, 3U, flags, IntPtr.Zero); + if (handle.IsInvalid) { int error = Marshal.GetLastWin32Error(); handle.Dispose(); throw new Win32Exception(error); } + try + { + NativeInfo info = Inspect(handle, path, directory); + entries.Add(new Entry { Handle = handle, Path = path, Directory = directory, Volume = info.Volume, FileId = FileId(info) }); + } + catch { handle.Dispose(); throw; } + } + + private static ulong FileId(NativeInfo info) { return ((ulong)info.IndexHigh << 32) | info.IndexLow; } + private static bool SamePath(string left, string right) + { + return String.Equals(left.TrimEnd('\\'), right.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase); + } + private static NativeInfo Inspect(SafeFileHandle handle, string expected, bool directory) + { + NativeInfo info; + if (!GetFileInformationByHandle(handle, out info)) throw new Win32Exception(Marshal.GetLastWin32Error()); + if ((info.Attributes & 0x400U) != 0 || ((info.Attributes & 0x10U) != 0) != directory) + throw new IOException("Unsafe module path kind"); + var value = new StringBuilder(512); + uint length = GetFinalPathNameByHandleW(handle, value, (uint)value.Capacity, 0); + if (length == 0) throw new Win32Exception(Marshal.GetLastWin32Error()); + if (length >= value.Capacity) + { + value.Capacity = checked((int)length + 1); + length = GetFinalPathNameByHandleW(handle, value, (uint)value.Capacity, 0); + if (length == 0 || length >= value.Capacity) throw new IOException("Module final path unavailable"); + } + string final = value.ToString(); + if (final.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase)) final = @"\\" + final.Substring(8); + else if (final.StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase)) final = final.Substring(4); + if (!SamePath(final, expected)) throw new IOException("Module final path mismatch"); + return info; + } + + public void Revalidate() + { + if (disposed) throw new ObjectDisposedException("trusted module import lease"); + foreach (Entry entry in entries) + { + NativeInfo info = Inspect(entry.Handle, entry.Path, entry.Directory); + if (info.Volume != entry.Volume || FileId(info) != entry.FileId) throw new IOException("Module identity changed"); + } + } + public void Dispose() + { + if (disposed) return; + disposed = true; + for (int index = entries.Count - 1; index >= 0; index--) entries[index].Handle.Dispose(); + entries.Clear(); + } +} +'@ +} + +function Open-CcodTrustedImportLease { + param([Parameter(Mandatory)][string]$Path,[Parameter(Mandatory)][string]$ErrorId) + try { + Initialize-CcodTrustedImportLease + return [CcodTrustedImportLeaseV1]::Acquire($Path) + } catch { + throw [Management.Automation.ErrorRecord]::new( + [InvalidOperationException]::new('Trusted module import authority could not be acquired.'), + $ErrorId,[Management.Automation.ErrorCategory]::InvalidData,$Path) + } +} +# END CCOD TRUSTED IMPORT BOOTSTRAP +$modulePath = Join-Path $PSScriptRoot 'OfficialDraftAcceptance.psm1' +function Throw-CcodOfficialDraftWrapperError { + param([Parameter(Mandatory)][string]$ErrorId,[Parameter(Mandatory)][string]$Message) + throw [Management.Automation.ErrorRecord]::new( + [InvalidOperationException]::new($Message), + $ErrorId, + [Management.Automation.ErrorCategory]::InvalidArgument, + $null + ) +} +try { + $phaseNames = [string[]]@('Preflight','LegacyUpgrade','Uninstall','FreshInstall','PreReboot','PostReboot','ReadyForManualEvidence','TrayEvidence','RemoteEvidence','Complete') + $phaseMatch = @($phaseNames | Where-Object { $_ -ieq [string]$Phase }) + if ($phaseMatch.Count -ne 1) { Throw-CcodOfficialDraftWrapperError 'CCOD_ACCEPTANCE_PHASE_INVALID' 'The official-draft acceptance phase is unsupported.' } + $Phase = [string]$phaseMatch[0] + if ([string]::IsNullOrWhiteSpace($AssetDirectory) -or [string]::IsNullOrWhiteSpace($PreviousAssetDirectory) -or [string]::IsNullOrWhiteSpace($EvidenceRoot)) { + Throw-CcodOfficialDraftWrapperError 'CCOD_ACCEPTANCE_ARGUMENT_INVALID' 'Required acceptance paths are missing.' + } + $trayNames = [string[]]@('About','Language','OpenLogs','Repair') + if (-not [string]::IsNullOrWhiteSpace($TrayOperation)) { + $trayMatch = @($trayNames | Where-Object { $_ -ieq [string]$TrayOperation }) + if ($trayMatch.Count -ne 1) { Throw-CcodOfficialDraftWrapperError 'CCOD_ACCEPTANCE_TRAY_OPERATION_INVALID' 'The tray operation is unsupported.' } + $TrayOperation = [string]$trayMatch[0] + } + if (-not [string]::IsNullOrWhiteSpace($RemoteOperation)) { + if ($RemoteOperation -ine 'SecondDeviceControl') { Throw-CcodOfficialDraftWrapperError 'CCOD_ACCEPTANCE_REMOTE_OPERATION_INVALID' 'The remote operation is unsupported.' } + $RemoteOperation = 'SecondDeviceControl' + } + if (-not [string]::IsNullOrWhiteSpace($ReviewState)) { + if ($ReviewState -ine 'Reviewed') { Throw-CcodOfficialDraftWrapperError 'CCOD_ACCEPTANCE_REVIEW_STATE_INVALID' 'The manual evidence review state is unsupported.' } + $ReviewState = 'Reviewed' + } + $moduleLease = Open-CcodTrustedImportLease -Path $modulePath -ErrorId 'CCOD_ACCEPTANCE_MODULE_MISSING' + try { + $moduleLease.Revalidate() + Import-Module $modulePath -Force -ErrorAction Stop + $moduleLease.Revalidate() + } finally { $moduleLease.Dispose() } + $invokeParameters = @{ + Phase = $Phase + AssetDirectory = $AssetDirectory + PreviousAssetDirectory = $PreviousAssetDirectory + EvidenceRoot = $EvidenceRoot + DraftId = $DraftId + AllowMachineMutation = [bool]$AllowMachineMutation + AllowCodexRestart = [bool]$AllowCodexRestart + AllowWindowsReboot = [bool]$AllowWindowsReboot + } + if (-not [string]::IsNullOrWhiteSpace($TrayOperation)) { $invokeParameters.TrayOperation = $TrayOperation } + if (-not [string]::IsNullOrWhiteSpace($RemoteOperation)) { $invokeParameters.RemoteOperation = $RemoteOperation } + if (-not [string]::IsNullOrWhiteSpace($ScreenshotPath)) { $invokeParameters.ScreenshotPath = $ScreenshotPath } + if (-not [string]::IsNullOrWhiteSpace($RedactedLogPath)) { $invokeParameters.RedactedLogPath = $RedactedLogPath } + if (-not [string]::IsNullOrWhiteSpace($ReviewState)) { $invokeParameters.ReviewState = $ReviewState } + Invoke-CcodOfficialDraftAcceptance @invokeParameters | + ConvertTo-Json -Depth 16 +} catch { + $errorId = [string]$_.FullyQualifiedErrorId + if ($errorId -notmatch '^CCOD_ACCEPTANCE_[A-Z0-9_]+\z') { $errorId = 'CCOD_ACCEPTANCE_WRAPPER_FAILED' } + [Console]::Error.WriteLine($errorId) + exit 1 +} diff --git a/tests/installed/OfficialDraftAcceptance.psm1 b/tests/installed/OfficialDraftAcceptance.psm1 new file mode 100644 index 0000000..a890033 --- /dev/null +++ b/tests/installed/OfficialDraftAcceptance.psm1 @@ -0,0 +1,3680 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# BEGIN CCOD TRUSTED IMPORT BOOTSTRAP +# Embedded in each independently trusted entrypoint; an unheld helper import +# cannot bootstrap its own protection. Keep copies identical (self-test enforced). +function Initialize-CcodTrustedImportLease { + if ($null -ne ('CcodTrustedImportLeaseV1' -as [type])) { return } + Add-Type -ErrorAction Stop -TypeDefinition @' +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Win32.SafeHandles; + +public sealed class CcodTrustedImportLeaseV1 : IDisposable +{ + [StructLayout(LayoutKind.Sequential)] private struct NativeTime { public uint Low, High; } + [StructLayout(LayoutKind.Sequential)] private struct NativeInfo + { + public uint Attributes; + public NativeTime Creation, Access, Write; + public uint Volume, SizeHigh, SizeLow, Links, IndexHigh, IndexLow; + } + private sealed class Entry + { + public SafeFileHandle Handle; + public string Path; + public bool Directory; + public uint Volume; + public ulong FileId; + } + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] + private static extern SafeFileHandle CreateFileW(string path, uint access, uint share, IntPtr security, uint creation, uint flags, IntPtr template); + [DllImport("kernel32.dll", SetLastError=true)] + private static extern bool GetFileInformationByHandle(SafeFileHandle handle, out NativeInfo info); + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] + private static extern uint GetFinalPathNameByHandleW(SafeFileHandle handle, StringBuilder value, uint length, uint flags); + + private readonly List entries = new List(); + private bool disposed; + public string Path { get; private set; } + private CcodTrustedImportLeaseV1(string path) { Path = path; } + + public static CcodTrustedImportLeaseV1 Acquire(string path) + { + if (String.IsNullOrWhiteSpace(path) || !System.IO.Path.IsPathRooted(path) || + path.StartsWith(@"\\?\", StringComparison.Ordinal) || path.StartsWith(@"\\.\", StringComparison.Ordinal)) + throw new ArgumentException("Unsupported module path"); + string full = System.IO.Path.GetFullPath(path); + string root = System.IO.Path.GetPathRoot(full); + string extension = System.IO.Path.GetExtension(full); + if (!String.Equals(full, path, StringComparison.OrdinalIgnoreCase) || String.IsNullOrEmpty(root) || + full.Substring(root.Length).IndexOf(':') >= 0 || + (!String.Equals(extension, ".psm1", StringComparison.OrdinalIgnoreCase) && !String.Equals(extension, ".ps1", StringComparison.OrdinalIgnoreCase))) + throw new ArgumentException("Noncanonical module path"); + var lease = new CcodTrustedImportLeaseV1(full); + try + { + var parents = new List(); + for (DirectoryInfo parent = Directory.GetParent(full); parent != null; parent = parent.Parent) + parents.Add(parent.FullName); + parents.Reverse(); + if (parents.Count == 0 || !SamePath(parents[0], root)) throw new IOException("Missing module root"); + // Retain root-to-leaf authority before traversing the next component. + foreach (string parent in parents) lease.Open(parent, true); + lease.Open(full, false); + lease.Revalidate(); + return lease; + } + catch { lease.Dispose(); throw; } + } + + private void Open(string path, bool directory) + { + uint access = directory ? 0x00100081U : 0x80100080U; + uint flags = 0x00200000U | (directory ? 0x02000000U : 0U); + // FILE_SHARE_READ only: preexisting writers/deleters must fail closed. + SafeFileHandle handle = CreateFileW(path, access, 1U, IntPtr.Zero, 3U, flags, IntPtr.Zero); + if (handle.IsInvalid) { int error = Marshal.GetLastWin32Error(); handle.Dispose(); throw new Win32Exception(error); } + try + { + NativeInfo info = Inspect(handle, path, directory); + entries.Add(new Entry { Handle = handle, Path = path, Directory = directory, Volume = info.Volume, FileId = FileId(info) }); + } + catch { handle.Dispose(); throw; } + } + + private static ulong FileId(NativeInfo info) { return ((ulong)info.IndexHigh << 32) | info.IndexLow; } + private static bool SamePath(string left, string right) + { + return String.Equals(left.TrimEnd('\\'), right.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase); + } + private static NativeInfo Inspect(SafeFileHandle handle, string expected, bool directory) + { + NativeInfo info; + if (!GetFileInformationByHandle(handle, out info)) throw new Win32Exception(Marshal.GetLastWin32Error()); + if ((info.Attributes & 0x400U) != 0 || ((info.Attributes & 0x10U) != 0) != directory) + throw new IOException("Unsafe module path kind"); + var value = new StringBuilder(512); + uint length = GetFinalPathNameByHandleW(handle, value, (uint)value.Capacity, 0); + if (length == 0) throw new Win32Exception(Marshal.GetLastWin32Error()); + if (length >= value.Capacity) + { + value.Capacity = checked((int)length + 1); + length = GetFinalPathNameByHandleW(handle, value, (uint)value.Capacity, 0); + if (length == 0 || length >= value.Capacity) throw new IOException("Module final path unavailable"); + } + string final = value.ToString(); + if (final.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase)) final = @"\\" + final.Substring(8); + else if (final.StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase)) final = final.Substring(4); + if (!SamePath(final, expected)) throw new IOException("Module final path mismatch"); + return info; + } + + public void Revalidate() + { + if (disposed) throw new ObjectDisposedException("trusted module import lease"); + foreach (Entry entry in entries) + { + NativeInfo info = Inspect(entry.Handle, entry.Path, entry.Directory); + if (info.Volume != entry.Volume || FileId(info) != entry.FileId) throw new IOException("Module identity changed"); + } + } + public void Dispose() + { + if (disposed) return; + disposed = true; + for (int index = entries.Count - 1; index >= 0; index--) entries[index].Handle.Dispose(); + entries.Clear(); + } +} +'@ +} + +function Open-CcodTrustedImportLease { + param([Parameter(Mandatory)][string]$Path,[Parameter(Mandatory)][string]$ErrorId) + try { + Initialize-CcodTrustedImportLease + return [CcodTrustedImportLeaseV1]::Acquire($Path) + } catch { + throw [Management.Automation.ErrorRecord]::new( + [InvalidOperationException]::new('Trusted module import authority could not be acquired.'), + $ErrorId,[Management.Automation.ErrorCategory]::InvalidData,$Path) + } +} +# END CCOD TRUSTED IMPORT BOOTSTRAP + +$script:CcodOfficialDraftExpectedVersion = '2.5.22' +$script:CcodOfficialDraftPhases = @( + 'Preflight', + 'LegacyUpgrade', + 'Uninstall', + 'FreshInstall', + 'PreReboot', + 'PostReboot', + 'ReadyForManualEvidence' +) +$script:CcodOfficialDraftPhaseFiles = @{ + Preflight = '01-Preflight.json' + LegacyUpgrade = '02-LegacyUpgrade.json' + Uninstall = '03-Uninstall.json' + FreshInstall = '04-FreshInstall.json' + PreReboot = '05-PreReboot.json' + PostReboot = '06-PostReboot.json' + ReadyForManualEvidence = '07-ReadyForManualEvidence.json' +} +$script:CcodOfficialDraftReceiptFields = @('schemaVersion','phase','candidate','draft','facts') +$script:CcodOfficialDraftDefenderReceiptFields = @( + 'schemaVersion','assetType','assetName','assetSha256','checksumName','checksumSha256', + 'manifestName','manifestSha256','version','gitCommit','origin','workflowArtifactIdentity', + 'zoneId','defenderServiceEnabled','antivirusEnabled','realTimeProtectionEnabled', + 'defenderPlatformVersion','defenderEngineVersion','signatureVersion','signatureUpdatedAtUtc', + 'scanStartedAtUtc','scanCompletedAtUtc','detectionCount','outcome','errorCode' +) +$script:CcodOfficialDraftAssetModule = $null +$script:CcodOfficialDraftDefenderModule = $null +$script:CcodOfficialDraftIntegrationModule = $null +$script:CcodOfficialDraftInstallRoot = $null +$script:CcodOfficialDraftManualAcknowledgement = $null +$script:CcodOfficialDraftLifecycleLoaded = $false +$script:CcodOfficialDraftCompletionFile = '08-Complete.json' +$script:CcodOfficialDraftManualDirectoryName = 'manual-evidence' +$script:CcodOfficialDraftManualArtifactRootName = 'official-draft-artifacts' +$script:CcodOfficialDraftManualOperations = @('About','Language','OpenLogs','Repair','SecondDeviceControl') +$script:CcodOfficialDraftManualFiles = @{ + About = 'TrayEvidence-About.json' + Language = 'TrayEvidence-Language.json' + OpenLogs = 'TrayEvidence-OpenLogs.json' + Repair = 'TrayEvidence-Repair.json' + SecondDeviceControl = 'RemoteEvidence-SecondDeviceControl.json' +} +$script:CcodOfficialDraftManualTerminalStates = @{ + About = 'AboutVisible' + Language = 'LanguageChanged' + OpenLogs = 'LogsOpened' + Repair = 'RepairCompleted' + SecondDeviceControl = 'SecondDeviceControlled' +} +$script:CcodOfficialDraftManualResultCodes = @{ + About = 'CCOD_TRAYABOUT_COMPLETED' + Language = 'CCOD_TRAYLANGUAGE_COMPLETED' + OpenLogs = 'CCOD_TRAYOPENLOGS_COMPLETED' + Repair = 'CCOD_TRAYREPAIR_COMPLETED' + SecondDeviceControl = 'CCOD_SECONDDEVICE_COMPLETED' +} +$script:CcodOfficialDraftManualRecordFields = @( + 'schemaVersion','kind','phase','operation','terminalState','result','proof','reviewState', + 'version','gitCommit','candidateManifestSha256','screenshotSha256','redactedLogSha256' +) + +function Throw-CcodOfficialDraftError { + param( + [Parameter(Mandatory)][string]$Id, + [Parameter(Mandatory)][string]$Message, + $Target + ) + throw [Management.Automation.ErrorRecord]::new( + [InvalidOperationException]::new($Message), + $Id, + [Management.Automation.ErrorCategory]::InvalidOperation, + $Target + ) +} + +function Get-CcodOfficialDraftAssetModule { + if ($null -eq $script:CcodOfficialDraftAssetModule) { + $repositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + $path = Join-Path $repositoryRoot 'tools\ReleaseAssetContract.psm1' + if (-not [IO.File]::Exists($path)) { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_ASSET_CONTRACT_MISSING' 'The exact release asset contract module is missing.' $null + } + try { + $lease = Open-CcodTrustedImportLease -Path $path -ErrorId 'CCOD_ACCEPTANCE_ASSET_CONTRACT_MISSING' + try { + $lease.Revalidate() + $script:CcodOfficialDraftAssetModule = Import-Module $path -Force -PassThru -DisableNameChecking -ErrorAction Stop + $lease.Revalidate() + } finally { $lease.Dispose() } + } catch { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_ASSET_CONTRACT_MISSING' 'The exact release asset contract module could not be loaded.' $null + } + } + return $script:CcodOfficialDraftAssetModule +} + +function Get-CcodOfficialDraftDefenderModule { + if ($null -eq $script:CcodOfficialDraftDefenderModule) { + $repositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + $path = Join-Path $repositoryRoot 'tools\ReleaseDefender.psm1' + if (-not [IO.File]::Exists($path)) { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_DEFENDER_MODULE_MISSING' 'The release Defender module is missing.' $null + } + try { + $lease = Open-CcodTrustedImportLease -Path $path -ErrorId 'CCOD_ACCEPTANCE_DEFENDER_MODULE_MISSING' + try { + $lease.Revalidate() + $script:CcodOfficialDraftDefenderModule = Import-Module $path -Force -PassThru -DisableNameChecking -ErrorAction Stop + $lease.Revalidate() + } finally { $lease.Dispose() } + } catch { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_DEFENDER_MODULE_MISSING' 'The release Defender module could not be loaded.' $null + } + } + return $script:CcodOfficialDraftDefenderModule +} + +function Get-CcodOfficialDraftIntegrationModule { + if ($null -eq $script:CcodOfficialDraftIntegrationModule) { + $path = Join-Path $PSScriptRoot 'Invoke-InstalledLifecycleIntegration.ps1' + if (-not [IO.File]::Exists($path)) { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_INTEGRATION_MISSING' 'The installed-lifecycle integration entry point is missing.' $null + } + try { + $lease = Open-CcodTrustedImportLease -Path $path -ErrorId 'CCOD_ACCEPTANCE_INTEGRATION_MISSING' + try { + $lease.Revalidate() + $script:CcodOfficialDraftIntegrationModule = New-Module -Name ('CcodOfficialDraftIntegration-' + [guid]::NewGuid().ToString('N')) -ArgumentList $path -ScriptBlock { + param($IntegrationPath) + . $IntegrationPath -Library + } -ErrorAction Stop + $lease.Revalidate() + } finally { $lease.Dispose() } + } catch { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_INTEGRATION_MISSING' 'The installed-lifecycle integration entry point could not be loaded safely.' $null + } + } + return $script:CcodOfficialDraftIntegrationModule +} + +function Assert-CcodOfficialDraftSafeDirectory { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$ErrorId, + [switch]$AllowMissing + ) + try { + if ([string]::IsNullOrWhiteSpace($Path) -or -not [IO.Path]::IsPathRooted($Path)) { throw 'absolute' } + $full = [IO.Path]::GetFullPath($Path) + $root = [IO.Path]::GetPathRoot($full) + if ([string]::IsNullOrWhiteSpace($root)) { throw 'root' } + $canonical = if ($full.Length -gt $root.Length) { $full.TrimEnd('\') } else { $full } + $presented = if ($Path.Length -gt $root.Length) { $Path.TrimEnd('\') } else { $Path } + if ($canonical -cne $presented) { throw 'noncanonical' } + if ([IO.File]::Exists($canonical)) { throw 'file' } + if (-not [IO.Directory]::Exists($canonical)) { + if (-not $AllowMissing) { throw 'missing' } + $parent = [IO.Directory]::GetParent($canonical) + if ($null -eq $parent) { throw 'parent' } + [void](Assert-CcodOfficialDraftSafeDirectory -Path $parent.FullName -ErrorId $ErrorId) + [IO.Directory]::CreateDirectory($canonical) | Out-Null + } + $current = $canonical + while (-not [string]::IsNullOrWhiteSpace($current)) { + $item = Get-Item -LiteralPath $current -Force -ErrorAction Stop + if (-not $item.PSIsContainer -or (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) { throw 'unsafe' } + $currentRoot = [IO.Path]::GetPathRoot($current) + if ($current.TrimEnd('\') -ceq $currentRoot.TrimEnd('\')) { break } + $parent = [IO.Directory]::GetParent($current) + if ($null -eq $parent) { throw 'parent' } + $next = if ($parent.FullName.Length -gt $currentRoot.Length) { $parent.FullName.TrimEnd('\') } else { $parent.FullName } + if ($next -ceq $current) { break } + $current = $next + } + return $canonical + } catch { + Throw-CcodOfficialDraftError $ErrorId 'Official-draft evidence root is missing, noncanonical, or unsafe.' $null + } +} + +function Assert-CcodOfficialDraftRegularFile { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$ErrorId + ) + try { + if ([string]::IsNullOrWhiteSpace($Path) -or -not [IO.Path]::IsPathRooted($Path)) { throw 'absolute' } + $full = [IO.Path]::GetFullPath($Path) + $root = [IO.Path]::GetPathRoot($full) + if ([string]::IsNullOrWhiteSpace($root) -or $full.TrimEnd('\') -cne $Path.TrimEnd('\')) { throw 'canonical' } + if ([IO.File]::Exists($full) -eq $false) { throw 'missing' } + $item = Get-Item -LiteralPath $full -Force -ErrorAction Stop + if ($item.PSIsContainer -or (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) { throw 'unsafe' } + $parent = [IO.Directory]::GetParent($full) + if ($null -eq $parent) { throw 'parent' } + [void](Assert-CcodOfficialDraftSafeDirectory -Path $parent.FullName -ErrorId $ErrorId) + return $full + } catch { + Throw-CcodOfficialDraftError $ErrorId 'Official-draft asset is missing or unsafe.' $null + } +} + +function Test-CcodOfficialDraftCanonicalUtc { + param($Value) + if ($Value -isnot [string] -or [string]::IsNullOrWhiteSpace($Value)) { return $false } + $parsed = [datetime]::MinValue + return [datetime]::TryParseExact( + $Value, + 'o', + [Globalization.CultureInfo]::InvariantCulture, + [Globalization.DateTimeStyles]::RoundtripKind, + [ref]$parsed + ) -and $parsed.Kind -eq [DateTimeKind]::Utc -and + $parsed.ToUniversalTime().ToString('o',[Globalization.CultureInfo]::InvariantCulture) -ceq $Value +} + +function Test-CcodOfficialDraftExactProperties { + param($Value, [Parameter(Mandatory)][string[]]$Expected) + if ($null -eq $Value -or ($Value -isnot [pscustomobject] -and $Value -isnot [Collections.IDictionary])) { return $false } + $actual = @( + if ($Value -is [Collections.IDictionary]) { + $Value.Keys | ForEach-Object { [string]$_ } + } else { + $Value.PSObject.Properties.Name + } + ) + return $actual.Count -eq $Expected.Count -and ($actual -join "`0") -ceq ($Expected -join "`0") +} + +function Test-CcodOfficialDraftHash { + param($Value) + return $Value -is [string] -and $Value -cmatch '^[0-9a-f]{64}\z' +} + +function Test-CcodOfficialDraftInt32 { + param($Value) + if ($null -eq $Value -or $Value -is [bool]) { return $false } + if ($Value.GetType() -notin @([sbyte],[byte],[int16],[uint16],[int32],[uint32],[int64],[uint64])) { return $false } + try { + $number = [int64]$Value + return $number -ge [int32]::MinValue -and $number -le [int32]::MaxValue + } catch { return $false } +} + +function Test-CcodOfficialDraftCommit { + param($Value) + return $Value -is [string] -and $Value -cmatch '^[0-9a-f]{40}\z' +} + +function Get-CcodOfficialDraftExpectedAssetNames { + $module = Get-CcodOfficialDraftAssetModule + return @(&$module { param($Version) Get-CcodExpectedReleaseAssetNames -Version $Version } $script:CcodOfficialDraftExpectedVersion) +} + +function Get-CcodOfficialDraftAssetHash { + param([Parameter(Mandatory)]$Candidate, [Parameter(Mandatory)][string]$Name) + foreach ($asset in @($Candidate.assetHashes)) { + if ($asset.name -ceq $Name) { return [string]$asset.sha256 } + } + return $null +} + +function ConvertTo-CcodOfficialDraftCandidate { + param([Parameter(Mandatory)]$Value) + try { + if ($null -eq $Value -or $Value.Valid -isnot [bool] -or -not [bool]$Value.Valid -or + $Value.Version -isnot [string] -or $Value.Version -cne $script:CcodOfficialDraftExpectedVersion -or + -not (Test-CcodOfficialDraftCommit $Value.GitCommit)) { throw 'metadata' } + $expected = @(Get-CcodOfficialDraftExpectedAssetNames) + $assets = @($Value.Assets) + if ($assets.Count -ne $expected.Count) { throw 'asset count' } + $records = [Collections.Generic.List[object]]::new() + for ($index = 0; $index -lt $expected.Count; $index++) { + $asset = $assets[$index] + if ($null -eq $asset -or + -not (Test-CcodOfficialDraftExactProperties -Value $asset -Expected @('name','sha256')) -or + $asset.name -isnot [string] -or $asset.name -cne $expected[$index] -or + -not (Test-CcodOfficialDraftHash $asset.sha256)) { throw 'asset record' } + $records.Add([pscustomobject][ordered]@{ name = [string]$asset.name; sha256 = [string]$asset.sha256 }) + } + return [pscustomobject][ordered]@{ + version = [string]$Value.Version + gitCommit = [string]$Value.GitCommit + assetHashes = @($records) + manifestHashes = [pscustomobject][ordered]@{ + portable = [string]$records[4].sha256 + setup = [string]$records[10].sha256 + } + } + } catch { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_ASSET_CONTRACT_INVALID' 'The official draft does not satisfy the exact eleven-asset manifest contract.' $null + } +} + +function ConvertTo-CcodOfficialDraftPersistedCandidate { + param([Parameter(Mandatory)]$Value) + try { + if (-not (Test-CcodOfficialDraftExactProperties -Value $Value -Expected @('version','gitCommit','assetHashes','manifestHashes')) -or + $Value.version -isnot [string] -or $Value.version -cne $script:CcodOfficialDraftExpectedVersion -or + -not (Test-CcodOfficialDraftCommit $Value.gitCommit)) { throw 'metadata' } + $expected = @(Get-CcodOfficialDraftExpectedAssetNames) + $assets = @($Value.assetHashes) + if ($assets.Count -ne $expected.Count) { throw 'asset count' } + $records = [Collections.Generic.List[object]]::new() + for ($index = 0; $index -lt $expected.Count; $index++) { + $asset = $assets[$index] + if (-not (Test-CcodOfficialDraftExactProperties -Value $asset -Expected @('name','sha256')) -or + $asset.name -isnot [string] -or $asset.name -cne $expected[$index] -or + -not (Test-CcodOfficialDraftHash $asset.sha256)) { throw 'asset record' } + $records.Add([pscustomobject][ordered]@{ name = [string]$asset.name; sha256 = [string]$asset.sha256 }) + } + if (-not (Test-CcodOfficialDraftExactProperties -Value $Value.manifestHashes -Expected @('portable','setup')) -or + -not (Test-CcodOfficialDraftHash $Value.manifestHashes.portable) -or + -not (Test-CcodOfficialDraftHash $Value.manifestHashes.setup) -or + [string]$Value.manifestHashes.portable -cne [string]$records[4].sha256 -or + [string]$Value.manifestHashes.setup -cne [string]$records[10].sha256) { throw 'manifest hashes' } + return [pscustomobject][ordered]@{ + version = [string]$Value.version + gitCommit = [string]$Value.gitCommit + assetHashes = @($records) + manifestHashes = [pscustomobject][ordered]@{ + portable = [string]$records[4].sha256 + setup = [string]$records[10].sha256 + } + } + } catch { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_RECEIPT_INVALID' 'The persisted official-draft candidate identity is malformed.' $null + } +} + +function ConvertTo-CcodOfficialDraftIdentity { + param($Value) + try { + if ($null -eq $Value) { throw 'draft' } + if (-not (Test-CcodOfficialDraftExactProperties -Value $Value -Expected @('tag','id')) -or + $Value.tag -isnot [string] -or $Value.tag -cne 'v2.5.22') { throw 'draft' } + if ($Value.id -isnot [string] -or [string]::IsNullOrWhiteSpace([string]$Value.id) -or + [string]$Value.id -notmatch '^[1-9][0-9]*\z') { throw 'id' } + return [pscustomobject][ordered]@{ + tag = [string]$Value.tag + id = [string]$Value.id + } + } catch { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_DRAFT_IDENTITY_INVALID' 'The official draft tag or identifier is malformed.' $null + } +} + +function Test-CcodOfficialDraftCandidateEqual { + param([Parameter(Mandatory)]$Left, [Parameter(Mandatory)]$Right) + if ($Left.version -cne $Right.version -or $Left.gitCommit -cne $Right.gitCommit) { return $false } + $leftAssets = @($Left.assetHashes); $rightAssets = @($Right.assetHashes) + if ($leftAssets.Count -ne $rightAssets.Count) { return $false } + for ($index = 0; $index -lt $leftAssets.Count; $index++) { + if ($leftAssets[$index].name -cne $rightAssets[$index].name -or $leftAssets[$index].sha256 -cne $rightAssets[$index].sha256) { return $false } + } + if ($Left.manifestHashes.portable -cne $Right.manifestHashes.portable -or + $Left.manifestHashes.setup -cne $Right.manifestHashes.setup) { return $false } + return $true +} + +function Test-CcodOfficialDraftDraftEqual { + param([AllowNull()]$Left,[AllowNull()]$Right) + if ($null -eq $Left -or $null -eq $Right) { return $false } + $leftTag = $Left.PSObject.Properties['tag']; $rightTag = $Right.PSObject.Properties['tag'] + $leftId = $Left.PSObject.Properties['id']; $rightId = $Right.PSObject.Properties['id'] + if (@($leftTag).Count -ne 1 -or @($rightTag).Count -ne 1 -or @($leftId).Count -ne 1 -or @($rightId).Count -ne 1) { return $false } + return [string]$leftTag.Value -ceq [string]$rightTag.Value -and [string]$leftId.Value -ceq [string]$rightId.Value +} + +function Close-CcodOfficialDraftOperationLease { + param($Lease) + if ($null -eq $Lease -or $Lease.Closed) { return } + $failed = $false + if ($null -ne $Lease.Stream) { try { $Lease.Stream.Dispose() } catch { $failed = $true } } + for ($index = $Lease.Parents.Count - 1; $index -ge 0; $index--) { + try { $Lease.Parents[$index].Handle.Dispose() } catch { $failed = $true } + } + $Lease.Closed = $true + if ($failed) { Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_OPERATION_LOCK_FAILED' 'Acceptance operation authority could not be released.' $null } +} + +function Open-CcodOfficialDraftOperationLease { + param([Parameter(Mandatory)][string]$EvidenceRoot) + $root = Assert-CcodOfficialDraftSafeDirectory -Path $EvidenceRoot -ErrorId 'CCOD_ACCEPTANCE_EVIDENCE_ROOT_INVALID' + $assetModule = Get-CcodOfficialDraftAssetModule + $lease = [pscustomobject]@{ Stream = $null; Parents = [Collections.Generic.List[object]]::new(); Closed = $false } + try { + $directories = [Collections.Generic.List[string]]::new() + for ($directory = [IO.DirectoryInfo]::new($root); $null -ne $directory; $directory = $directory.Parent) { $directories.Insert(0,$directory.FullName) } + foreach ($path in $directories) { + $handle = [CcodReleaseFileAuthorityV1]::OpenDirectory($path,$true) + $entry = [pscustomobject]@{ Path = $path; Handle = $handle } + $lease.Parents.Add($entry) + $identity = [CcodReleaseFileAuthorityV1]::Identity($handle) + if ([CcodReleaseFileAuthorityV1]::IsReparse($identity) -or -not [CcodReleaseFileAuthorityV1]::IsDirectory($identity) -or + -not $identity.FinalPath.TrimEnd('\').Equals($path.TrimEnd('\'),[StringComparison]::OrdinalIgnoreCase)) { throw 'operation ancestry' } + } + $lockPath = Join-Path $root '.ccod-official-draft-operation.lock' + try { $lease.Stream = [CcodReleaseFileAuthorityV1]::OpenExclusiveOperationFile($lockPath) } + catch { + $exception = $_.Exception + while ($null -ne $exception -and $exception -isnot [ComponentModel.Win32Exception]) { $exception = $exception.InnerException } + if ($null -ne $exception -and $exception.NativeErrorCode -in @(32,33)) { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_OPERATION_BUSY' 'Another acceptance operation owns this evidence root.' $null + } + throw + } + $identity = [CcodReleaseFileAuthorityV1]::Identity($lease.Stream.SafeFileHandle) + if ([CcodReleaseFileAuthorityV1]::IsDirectory($identity) -or [CcodReleaseFileAuthorityV1]::IsReparse($identity) -or + $identity.Links -ne 1 -or $lease.Stream.Length -ne 0 -or + -not $identity.FinalPath.Equals($lockPath,[StringComparison]::OrdinalIgnoreCase) -or + -not (& $assetModule {param($Value) Test-CcodReleaseAuthorityStreams -Identity $Value} $identity)) { throw 'operation lock identity' } + # The empty lock leaf is retained. Deleting it on release would race the next opener. + return $lease + } catch { + $failure = $_ + Close-CcodOfficialDraftOperationLease $lease + if ($failure.FullyQualifiedErrorId -like 'CCOD_ACCEPTANCE_*') { throw $failure } + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_OPERATION_LOCK_FAILED' 'Acceptance operation authority could not be acquired safely.' $null + } +} + +function Get-CcodOfficialDraftStateDirectory { + param([Parameter(Mandatory)][string]$EvidenceRoot) + $root = Assert-CcodOfficialDraftSafeDirectory -Path $EvidenceRoot -ErrorId 'CCOD_ACCEPTANCE_EVIDENCE_ROOT_INVALID' + $state = [IO.Path]::GetFullPath((Join-Path $root 'official-draft-acceptance')) + if ([IO.File]::Exists($state)) { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_EVIDENCE_ROOT_INVALID' 'The official-draft state directory is not a directory.' $null + } + if (-not [IO.Directory]::Exists($state)) { + try { [IO.Directory]::CreateDirectory($state) | Out-Null } catch { Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_EVIDENCE_ROOT_INVALID' 'The official-draft state directory could not be created safely.' $null } + } + return (Assert-CcodOfficialDraftSafeDirectory -Path $state -ErrorId 'CCOD_ACCEPTANCE_EVIDENCE_ROOT_INVALID') +} + +function Get-CcodOfficialDraftManualDirectory { + param([Parameter(Mandatory)][string]$StateDirectory) + $path = Join-Path $StateDirectory $script:CcodOfficialDraftManualDirectoryName + if ([IO.File]::Exists($path)) { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_EVIDENCE_ROOT_INVALID' 'The manual-evidence state path is a file.' $null + } + if (-not [IO.Directory]::Exists($path)) { + try { [IO.Directory]::CreateDirectory($path) | Out-Null } + catch { Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_EVIDENCE_ROOT_INVALID' 'The manual-evidence state directory could not be created safely.' $null } + } + return (Assert-CcodOfficialDraftSafeDirectory -Path $path -ErrorId 'CCOD_ACCEPTANCE_EVIDENCE_ROOT_INVALID') +} + +function Get-CcodOfficialDraftManualEvidencePath { + param([Parameter(Mandatory)][string]$StateDirectory,[Parameter(Mandatory)][string]$Operation) + if (-not $script:CcodOfficialDraftManualFiles.ContainsKey($Operation)) { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_MANUAL_OPERATION_INVALID' 'The manual evidence operation is unsupported.' $null + } + $directory = Get-CcodOfficialDraftManualDirectory -StateDirectory $StateDirectory + $path = Join-Path $directory $script:CcodOfficialDraftManualFiles[$Operation] + if ([IO.Directory]::Exists($path)) { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_PHASE_REUSED' 'The manual evidence operation already has a receipt.' $null + } + if ([IO.File]::Exists($path)) { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_PHASE_REUSED' 'The manual evidence operation already has a receipt.' $null + } + return $path +} + +function Get-CcodOfficialDraftCompleteAcceptancePath { + param([Parameter(Mandatory)][string]$EvidenceRoot) + $root = Assert-CcodOfficialDraftSafeDirectory -Path $EvidenceRoot -ErrorId 'CCOD_ACCEPTANCE_EVIDENCE_ROOT_INVALID' + $directory = Join-Path $root 'acceptance' + if ([IO.File]::Exists($directory)) { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_EVIDENCE_ROOT_INVALID' 'The acceptance evidence path is a file.' $null + } + if (-not [IO.Directory]::Exists($directory)) { + try { [IO.Directory]::CreateDirectory($directory) | Out-Null } + catch { Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_EVIDENCE_ROOT_INVALID' 'The acceptance evidence directory could not be created safely.' $null } + } + $directory = Assert-CcodOfficialDraftSafeDirectory -Path $directory -ErrorId 'CCOD_ACCEPTANCE_EVIDENCE_ROOT_INVALID' + $path = Join-Path $directory 'CodexRemote-fix-2.5.22-official-draft.complete.json' + if ([IO.File]::Exists($path) -or [IO.Directory]::Exists($path)) { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_PHASE_REUSED' 'The complete acceptance receipt already exists.' $null + } + return $path +} + +function Read-CcodOfficialDraftJson { + param([Parameter(Mandatory)][string]$Path) + $module = Get-CcodOfficialDraftAssetModule + try { + return & $module { param($JsonPath) Read-CcodReleaseContractJson -Path $JsonPath -ErrorId 'CCOD_ACCEPTANCE_RECEIPT_INVALID' -MaximumBytes 1048576 } $Path + } catch { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_RECEIPT_INVALID' 'An official-draft receipt is malformed or unreadable.' $null + } +} + +function Assert-CcodOfficialDraftDefenderReceipt { + param( + [Parameter(Mandatory)]$Receipt, + [Parameter(Mandatory)]$Candidate, + [Parameter(Mandatory)][ValidateSet('Setup','PortableZip')][string]$AssetType + ) + try { + if (-not (Test-CcodOfficialDraftExactProperties -Value $Receipt -Expected $script:CcodOfficialDraftDefenderReceiptFields) -or + -not (Test-CcodOfficialDraftInt32 $Receipt.schemaVersion) -or $Receipt.schemaVersion -ne 2 -or + $Receipt.assetType -isnot [string] -or $Receipt.assetType -cne $AssetType) { throw 'receipt frame' } + $expected = @(Get-CcodOfficialDraftExpectedAssetNames) + $assetIndex = if ($AssetType -ceq 'Setup') { 5 } else { 0 } + $checksumIndex = if ($AssetType -ceq 'Setup') { 6 } else { 1 } + $manifestIndex = if ($AssetType -ceq 'Setup') { 10 } else { 4 } + if ($Receipt.assetName -isnot [string] -or $Receipt.checksumName -isnot [string] -or $Receipt.manifestName -isnot [string] -or + $Receipt.assetSha256 -isnot [string] -or $Receipt.checksumSha256 -isnot [string] -or $Receipt.manifestSha256 -isnot [string] -or + $Receipt.version -isnot [string] -or $Receipt.gitCommit -isnot [string] -or $Receipt.origin -isnot [string]) { throw 'receipt scalar types' } + if ($Receipt.assetName -cne $expected[$assetIndex] -or $Receipt.checksumName -cne $expected[$checksumIndex] -or + $Receipt.manifestName -cne $expected[$manifestIndex] -or + $Receipt.assetSha256 -cne (Get-CcodOfficialDraftAssetHash $Candidate $expected[$assetIndex]) -or + $Receipt.checksumSha256 -cne (Get-CcodOfficialDraftAssetHash $Candidate $expected[$checksumIndex]) -or + $Receipt.manifestSha256 -cne (Get-CcodOfficialDraftAssetHash $Candidate $expected[$manifestIndex]) -or + $Receipt.version -cne $Candidate.version -or $Receipt.gitCommit -cne $Candidate.gitCommit -or + $Receipt.origin -cne 'InternetDownload' -or $null -ne $Receipt.workflowArtifactIdentity -or + -not (Test-CcodOfficialDraftInt32 $Receipt.zoneId) -or $Receipt.zoneId -ne 3 -or + $Receipt.defenderServiceEnabled -isnot [bool] -or -not $Receipt.defenderServiceEnabled -or + $Receipt.antivirusEnabled -isnot [bool] -or -not $Receipt.antivirusEnabled -or + $Receipt.realTimeProtectionEnabled -isnot [bool] -or -not $Receipt.realTimeProtectionEnabled -or + $Receipt.defenderPlatformVersion -isnot [string] -or [string]::IsNullOrWhiteSpace($Receipt.defenderPlatformVersion) -or $Receipt.defenderPlatformVersion.Length -gt 128 -or + $Receipt.defenderEngineVersion -isnot [string] -or [string]::IsNullOrWhiteSpace($Receipt.defenderEngineVersion) -or $Receipt.defenderEngineVersion.Length -gt 128 -or + $Receipt.signatureVersion -isnot [string] -or [string]::IsNullOrWhiteSpace($Receipt.signatureVersion) -or $Receipt.signatureVersion.Length -gt 128 -or + -not (Test-CcodOfficialDraftCanonicalUtc $Receipt.signatureUpdatedAtUtc) -or + -not (Test-CcodOfficialDraftCanonicalUtc $Receipt.scanStartedAtUtc) -or + -not (Test-CcodOfficialDraftCanonicalUtc $Receipt.scanCompletedAtUtc) -or + -not (Test-CcodOfficialDraftInt32 $Receipt.detectionCount) -or $Receipt.detectionCount -ne 0 -or + $Receipt.outcome -isnot [string] -or $Receipt.outcome -cne 'Completed' -or $null -ne $Receipt.errorCode) { throw 'receipt binding' } + $started = [datetime]::ParseExact($Receipt.scanStartedAtUtc,'o',[Globalization.CultureInfo]::InvariantCulture,[Globalization.DateTimeStyles]::RoundtripKind) + $completed = [datetime]::ParseExact($Receipt.scanCompletedAtUtc,'o',[Globalization.CultureInfo]::InvariantCulture,[Globalization.DateTimeStyles]::RoundtripKind) + $signature = [datetime]::ParseExact($Receipt.signatureUpdatedAtUtc,'o',[Globalization.CultureInfo]::InvariantCulture,[Globalization.DateTimeStyles]::RoundtripKind) + if ($completed -lt $started -or $completed -gt $started.AddHours(2) -or $signature -lt $started.AddHours(-72) -or $signature -gt $started.AddMinutes(5)) { throw 'receipt time' } + return [pscustomobject][ordered]@{ + schemaVersion = 2 + assetType = [string]$Receipt.assetType + assetName = [string]$Receipt.assetName + assetSha256 = [string]$Receipt.assetSha256 + checksumName = [string]$Receipt.checksumName + checksumSha256 = [string]$Receipt.checksumSha256 + manifestName = [string]$Receipt.manifestName + manifestSha256 = [string]$Receipt.manifestSha256 + version = [string]$Receipt.version + gitCommit = [string]$Receipt.gitCommit + origin = 'InternetDownload' + workflowArtifactIdentity = $null + zoneId = 3 + defenderServiceEnabled = $true + antivirusEnabled = $true + realTimeProtectionEnabled = $true + defenderPlatformVersion = [string]$Receipt.defenderPlatformVersion + defenderEngineVersion = [string]$Receipt.defenderEngineVersion + signatureVersion = [string]$Receipt.signatureVersion + signatureUpdatedAtUtc = [string]$Receipt.signatureUpdatedAtUtc + scanStartedAtUtc = [string]$Receipt.scanStartedAtUtc + scanCompletedAtUtc = [string]$Receipt.scanCompletedAtUtc + detectionCount = 0 + outcome = 'Completed' + errorCode = $null + } + } catch { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_DEFENDER_RECEIPT_INVALID' 'The official downloaded-asset Defender receipt is missing, mismatched, or incomplete.' $null + } +} + +function ConvertTo-CcodOfficialDraftDefenderReceipt { + param([Parameter(Mandatory)]$Receipt, [Parameter(Mandatory)]$Candidate, [Parameter(Mandatory)][string]$AssetType) + $normalized = Assert-CcodOfficialDraftDefenderReceipt -Receipt $Receipt -Candidate $Candidate -AssetType $AssetType + return $normalized +} + +function Test-CcodOfficialDraftPositiveInteger { + param($Value) + if (-not (Test-CcodOfficialDraftIntegerValue $Value)) { return $false } + try { return [UInt64]$Value -gt 0 } catch { return $false } +} + +function ConvertTo-CcodOfficialDraftTrayHostIdentity { + param([Parameter(Mandatory)]$Value) + try { + if (-not (Test-CcodOfficialDraftExactProperties -Value $Value -Expected @('pid','creationTimeUtc')) -or + -not (Test-CcodOfficialDraftIntegerValue $Value.pid) -or [UInt64]$Value.pid -le 0 -or [UInt64]$Value.pid -gt [UInt64][int]::MaxValue -or + $Value.creationTimeUtc -isnot [string] -or -not (Test-CcodOfficialDraftCanonicalUtc $Value.creationTimeUtc)) { throw 'tray host identity' } + return [pscustomobject][ordered]@{ pid = [int]$Value.pid; creationTimeUtc = [string]$Value.creationTimeUtc } + } catch { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_RECEIPT_INVALID' 'TrayHost identity is missing or malformed.' $null + } +} + +function ConvertTo-CcodOfficialDraftSafeProcessIdentity { + param([Parameter(Mandatory)]$Value,[Parameter(Mandatory)][string]$Label) + if ($null -eq $Value -or -not (Test-CcodOfficialDraftExactProperties -Value $Value -Expected @('pid','creationTimeUtc')) -or + -not (Test-CcodOfficialDraftPositiveInteger $Value.pid) -or [UInt64]$Value.pid -gt [UInt64][int]::MaxValue -or + $Value.creationTimeUtc -isnot [string] -or -not (Test-CcodOfficialDraftCanonicalUtc $Value.creationTimeUtc)) { throw "$Label identity" } + return [pscustomobject][ordered]@{ pid = [int]$Value.pid; creationTimeUtc = [string]$Value.creationTimeUtc } +} + +function ConvertTo-CcodOfficialDraftLegacyObservation { + param([Parameter(Mandatory)]$Facts,[Parameter(Mandatory)][string]$ExpectedVersion) + try { + $required = @('installRootPresent','installReady','appRootPresent','runtimeRootPresent','activePointerPresent','activeRuntimeId','activeGeneration','runtimeManifestSha256','supervisor','trayHost','codex','trayHostIdentity','trayAuthenticated','taskState','statusPhase','statusRuntimeId','statusCodex','transitionStage','lifecycleReceipt','aboutVersion','deviceKeyPresent','deviceKeySha256','shortcuts','debugPorts','debugEndpoints','protectionReady','bootId') + foreach ($name in $required) { + if (-not (Test-CcodOfficialDraftHasFact -Facts $Facts -Name $name)) { throw 'legacy observation shape' } + } + foreach ($name in @('installRootPresent','installReady','appRootPresent','runtimeRootPresent','activePointerPresent','trayAuthenticated','deviceKeyPresent','protectionReady')) { + $value = Get-CcodOfficialDraftFactValue -Facts $Facts -Name $name + if ($value -isnot [bool] -or -not [bool]$value) { throw 'legacy observation readiness' } + } + $activeRuntimeId = Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'activeRuntimeId' + $activeGeneration = Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'activeGeneration' + $manifestHash = Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'runtimeManifestSha256' + if ($activeRuntimeId -isnot [string] -or $activeRuntimeId -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,95}\z' -or + -not (Test-CcodOfficialDraftPositiveInteger $activeGeneration) -or + -not (Test-CcodOfficialDraftHash $manifestHash)) { throw 'legacy observation runtime' } + $aboutVersion = Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'aboutVersion' + $statusPhase = Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'statusPhase' + $statusRuntimeId = Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'statusRuntimeId' + $taskState = Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'taskState' + $transitionStage = Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'transitionStage' + $bootId = Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'bootId' + if ($aboutVersion -isnot [string] -or $aboutVersion -cne $ExpectedVersion -or + $statusPhase -isnot [string] -or $statusPhase -cne 'Active' -or + $statusRuntimeId -isnot [string] -or $statusRuntimeId -cne $activeRuntimeId -or + $taskState -isnot [string] -or $taskState -notin @('Ready','Running') -or + $transitionStage -isnot [string] -or $transitionStage -cne 'Idle' -or + $bootId -isnot [string] -or $bootId -notmatch '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\z') { throw 'legacy observation status' } + $supervisor = [Collections.Generic.List[object]]::new() + foreach ($record in @((Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'supervisor'))) { [void]$supervisor.Add((ConvertTo-CcodOfficialDraftSafeProcessIdentity -Value $record -Label 'supervisor')) } + $trayHost = [Collections.Generic.List[object]]::new() + foreach ($record in @((Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'trayHost'))) { [void]$trayHost.Add((ConvertTo-CcodOfficialDraftSafeProcessIdentity -Value $record -Label 'tray host')) } + $codex = [Collections.Generic.List[object]]::new() + foreach ($record in @((Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'codex'))) { [void]$codex.Add((ConvertTo-CcodOfficialDraftSafeProcessIdentity -Value $record -Label 'codex')) } + if ($supervisor.Count -ne 1 -or $trayHost.Count -ne 1 -or $codex.Count -ne 1) { throw 'legacy observation process count' } + $trayIdentity = ConvertTo-CcodOfficialDraftTrayHostIdentity -Value (Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'trayHostIdentity') + if ($trayIdentity.pid -ne $trayHost[0].pid -or $trayIdentity.creationTimeUtc -cne $trayHost[0].creationTimeUtc) { throw 'legacy observation tray identity' } + $statusCodex = ConvertTo-CcodOfficialDraftSafeProcessIdentity -Value (Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'statusCodex') -Label 'status codex' + if ($statusCodex.pid -ne $codex[0].pid -or $statusCodex.creationTimeUtc -cne $codex[0].creationTimeUtc) { throw 'legacy observation codex identity' } + $terminal = ConvertTo-CcodOfficialDraftTerminalReceipt -Receipt (Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'lifecycleReceipt') + if ($terminal.runtimeId -cne $activeRuntimeId -or [UInt64]$terminal.runtimeGeneration -ne [UInt64]$activeGeneration) { throw 'legacy observation receipt' } + $shortcuts = Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'shortcuts' + if (-not (Test-CcodOfficialDraftExactProperties -Value $shortcuts -Expected @('startMenu','desktop')) -or + $shortcuts.startMenu -isnot [bool] -or -not [bool]$shortcuts.startMenu -or + $shortcuts.desktop -isnot [bool] -or -not [bool]$shortcuts.desktop) { throw 'legacy observation shortcuts' } + $debugPorts = @((Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'debugPorts')) + if ($debugPorts.Count -ne 2 -or @($debugPorts | Sort-Object -Unique).Count -ne 2) { throw 'legacy observation debug ports' } + foreach ($port in $debugPorts) { + if (-not (Test-CcodOfficialDraftPositiveInteger $port) -or [UInt64]$port -gt 65535) { throw 'legacy observation debug port' } + } + $debugEndpoints = @((Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'debugEndpoints')) + [void](Assert-CcodOfficialDraftDebugEndpointOwnership -Endpoints $debugEndpoints -ExpectedPorts ([int[]]$debugPorts) -ExpectedPid $codex[0].pid -ExpectedCreationTimeUtc $codex[0].creationTimeUtc) + $keyHash = Get-CcodOfficialDraftObservedKeyHash -Facts $Facts + if (-not (Test-CcodOfficialDraftHash $keyHash)) { throw 'legacy observation key' } + return [pscustomobject][ordered]@{ + version = $ExpectedVersion + bootId = [string]$bootId + installRootPresent = $true + installReady = $true + appRootPresent = $true + runtimeRootPresent = $true + activePointerPresent = $true + activeRuntimeId = [string]$activeRuntimeId + activeGeneration = [UInt64]$activeGeneration + runtimeManifestSha256 = [string]$manifestHash + supervisor = @($supervisor) + trayHost = @($trayHost) + codex = @($codex) + trayHostIdentity = $trayIdentity + trayAuthenticated = $true + taskState = [string]$taskState + statusPhase = 'Active' + statusRuntimeId = [string]$statusRuntimeId + statusCodex = $statusCodex + transitionStage = 'Idle' + lifecycleReceipt = $terminal + aboutVersion = $ExpectedVersion + deviceKeyPresent = $true + deviceKeySha256 = [string]$keyHash + shortcuts = [pscustomobject][ordered]@{ startMenu = $true; desktop = $true } + debugPorts = [int[]]$debugPorts + debugEndpoints = @($debugEndpoints) + protectionReady = $true + } + } catch { + if ($_.FullyQualifiedErrorId -like 'CCOD_ACCEPTANCE_*') { throw } + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_OBSERVATION_UNAVAILABLE' 'Installed lifecycle readiness could not be proven for the legacy upgrade boundary.' $null + } +} + +function Assert-CcodOfficialDraftPersistedPhaseFacts { + param([Parameter(Mandatory)][string]$Phase,[Parameter(Mandatory)]$Facts) + try { + switch ($Phase) { + 'LegacyUpgrade' { + if (-not (Test-CcodOfficialDraftExactProperties -Value $Facts -Expected @('previousSetup','before','after','deviceKeySha256Before','deviceKeySha256After','keyHashPreserved','operationOutcome')) -or + -not (Test-CcodOfficialDraftExactProperties -Value $Facts.previousSetup -Expected @('version','gitCommit','assetSha256','manifestSha256')) -or + $Facts.previousSetup.version -cne '2.5.21' -or -not (Test-CcodOfficialDraftCommit $Facts.previousSetup.gitCommit) -or + -not (Test-CcodOfficialDraftHash $Facts.previousSetup.assetSha256) -or -not (Test-CcodOfficialDraftHash $Facts.previousSetup.manifestSha256) -or + -not (Test-CcodOfficialDraftHash $Facts.deviceKeySha256Before) -or -not (Test-CcodOfficialDraftHash $Facts.deviceKeySha256After) -or + $Facts.deviceKeySha256Before -cne $Facts.deviceKeySha256After -or $Facts.keyHashPreserved -isnot [bool] -or + -not [bool]$Facts.keyHashPreserved -or $Facts.operationOutcome -isnot [string] -or $Facts.operationOutcome -cne 'Completed') { throw 'legacy facts' } + $before = ConvertTo-CcodOfficialDraftLegacyObservation -Facts $Facts.before -ExpectedVersion '2.5.21' + $after = ConvertTo-CcodOfficialDraftLegacyObservation -Facts $Facts.after -ExpectedVersion $script:CcodOfficialDraftExpectedVersion + if ($before.deviceKeySha256 -cne [string]$Facts.deviceKeySha256Before -or $after.deviceKeySha256 -cne [string]$Facts.deviceKeySha256After) { throw 'legacy observation key binding' } + return [pscustomobject][ordered]@{ + previousSetup = $Facts.previousSetup + before = $before + after = $after + deviceKeySha256Before = [string]$Facts.deviceKeySha256Before + deviceKeySha256After = [string]$Facts.deviceKeySha256After + keyHashPreserved = $true + operationOutcome = 'Completed' + } + } + 'Uninstall' { + if (-not (Test-CcodOfficialDraftExactProperties -Value $Facts -Expected @('stateRemoved','installRootPresent','appRootPresent','runtimeRootPresent','activePointerPresent','taskState','supervisorCount','trayHostCount','codexCount','shortcuts','debugEndpointsGone','deviceKeySha256Before','deviceKeySha256After','keyHashPreserved','operationOutcome')) -or + $Facts.stateRemoved -isnot [bool] -or -not $Facts.stateRemoved -or + $Facts.installRootPresent -isnot [bool] -or $Facts.installRootPresent -or + $Facts.appRootPresent -isnot [bool] -or $Facts.appRootPresent -or $Facts.runtimeRootPresent -isnot [bool] -or $Facts.runtimeRootPresent -or + $Facts.activePointerPresent -isnot [bool] -or $Facts.activePointerPresent -or $Facts.taskState -isnot [string] -or $Facts.taskState -cne 'Absent' -or + -not (Test-CcodOfficialDraftInt32 $Facts.supervisorCount) -or $Facts.supervisorCount -ne 0 -or -not (Test-CcodOfficialDraftInt32 $Facts.trayHostCount) -or $Facts.trayHostCount -ne 0 -or + -not (Test-CcodOfficialDraftInt32 $Facts.codexCount) -or $Facts.codexCount -ne 0 -or + $Facts.debugEndpointsGone -isnot [bool] -or -not $Facts.debugEndpointsGone -or + -not (Test-CcodOfficialDraftExactProperties -Value $Facts.shortcuts -Expected @('startMenu','desktop')) -or + $Facts.shortcuts.startMenu -isnot [bool] -or $Facts.shortcuts.startMenu -or $Facts.shortcuts.desktop -isnot [bool] -or $Facts.shortcuts.desktop -or + -not (Test-CcodOfficialDraftHash $Facts.deviceKeySha256Before) -or -not (Test-CcodOfficialDraftHash $Facts.deviceKeySha256After) -or + $Facts.deviceKeySha256Before -cne $Facts.deviceKeySha256After -or $Facts.keyHashPreserved -isnot [bool] -or -not $Facts.keyHashPreserved -or + $Facts.operationOutcome -isnot [string] -or $Facts.operationOutcome -cne 'Completed') { throw 'uninstall facts' } + } + 'FreshInstall' { + if (-not (Test-CcodOfficialDraftExactProperties -Value $Facts -Expected @('installRootPresent','installReady','activePointerPresent','activeRuntimeId','activeGeneration','runtimeManifestSha256','supervisorCount','trayHostCount','codexCount','trayHostIdentity','trayAuthenticated','taskState','terminalReceipt','protectionReady','deviceKeySha256','operationOutcome')) -or + $Facts.installRootPresent -isnot [bool] -or -not $Facts.installRootPresent -or + $Facts.installReady -isnot [bool] -or -not $Facts.installReady -or $Facts.activePointerPresent -isnot [bool] -or -not $Facts.activePointerPresent -or + $Facts.activeRuntimeId -isnot [string] -or $Facts.activeRuntimeId -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,95}\z' -or + -not (Test-CcodOfficialDraftPositiveInteger $Facts.activeGeneration) -or -not (Test-CcodOfficialDraftHash $Facts.runtimeManifestSha256) -or + -not (Test-CcodOfficialDraftInt32 $Facts.supervisorCount) -or $Facts.supervisorCount -ne 1 -or -not (Test-CcodOfficialDraftInt32 $Facts.trayHostCount) -or $Facts.trayHostCount -ne 1 -or + -not (Test-CcodOfficialDraftInt32 $Facts.codexCount) -or $Facts.codexCount -ne 1 -or + $Facts.trayAuthenticated -isnot [bool] -or -not $Facts.trayAuthenticated -or $Facts.taskState -notin @('Ready','Running') -or + $Facts.protectionReady -isnot [bool] -or -not $Facts.protectionReady -or -not (Test-CcodOfficialDraftHash $Facts.deviceKeySha256)) { throw 'fresh facts' } + $trayIdentity = ConvertTo-CcodOfficialDraftTrayHostIdentity -Value $Facts.trayHostIdentity + $terminal = ConvertTo-CcodOfficialDraftTerminalReceipt -Receipt $Facts.terminalReceipt + if ($terminal.runtimeId -cne $Facts.activeRuntimeId -or [UInt64]$terminal.runtimeGeneration -ne [UInt64]$Facts.activeGeneration -or + $Facts.operationOutcome -isnot [string] -or $Facts.operationOutcome -cne 'Completed') { throw 'fresh receipt binding' } + } + 'PreReboot' { + if (-not (Test-CcodOfficialDraftExactProperties -Value $Facts -Expected @('rebootRequested','bootIdBefore','freshInstallReceiptSha256','activeRuntimeId','activeGeneration','runtimeManifestSha256','deviceKeySha256','operationOutcome','observation')) -or + $Facts.rebootRequested -isnot [bool] -or -not $Facts.rebootRequested -or + $Facts.bootIdBefore -isnot [string] -or $Facts.bootIdBefore -notmatch '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\z' -or + -not (Test-CcodOfficialDraftHash $Facts.freshInstallReceiptSha256) -or $Facts.activeRuntimeId -isnot [string] -or + $Facts.activeRuntimeId -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,95}\z' -or -not (Test-CcodOfficialDraftPositiveInteger $Facts.activeGeneration) -or + -not (Test-CcodOfficialDraftHash $Facts.runtimeManifestSha256) -or + -not (Test-CcodOfficialDraftHash $Facts.deviceKeySha256) -or $Facts.operationOutcome -isnot [string] -or $Facts.operationOutcome -cne 'Completed') { throw 'pre facts' } + $observation = ConvertTo-CcodOfficialDraftLegacyObservation -Facts $Facts.observation -ExpectedVersion $script:CcodOfficialDraftExpectedVersion + if ($observation.bootId -cne $Facts.bootIdBefore -or $observation.activeRuntimeId -cne $Facts.activeRuntimeId -or + [UInt64]$observation.activeGeneration -ne [UInt64]$Facts.activeGeneration -or + $observation.runtimeManifestSha256 -cne $Facts.runtimeManifestSha256 -or + $observation.deviceKeySha256 -cne $Facts.deviceKeySha256) { throw 'pre observation continuity' } + $Facts.observation = $observation + } + 'PostReboot' { + if (-not (Test-CcodOfficialDraftExactProperties -Value $Facts -Expected @('observation','installRootPresent','activePointerPresent','bootIdBefore','bootIdAfter','bootChanged','receiptContinuity','activeRuntimeId','activeGeneration','runtimeManifestSha256','transitionStage','protectionRecovered','protectionReady','taskState','supervisorCount','trayHostCount','codexCount','trayHostIdentity','trayAuthenticated','terminalReceipt','deviceKeySha256','keyHashPreserved','operationOutcome')) -or + $Facts.installRootPresent -isnot [bool] -or -not $Facts.installRootPresent -or $Facts.activePointerPresent -isnot [bool] -or -not $Facts.activePointerPresent -or + $Facts.bootIdBefore -isnot [string] -or $Facts.bootIdBefore -notmatch '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\z' -or + $Facts.bootIdAfter -isnot [string] -or $Facts.bootIdAfter -notmatch '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\z' -or $Facts.bootIdAfter -ceq $Facts.bootIdBefore -or + $Facts.bootChanged -isnot [bool] -or -not $Facts.bootChanged -or $Facts.receiptContinuity -isnot [bool] -or -not $Facts.receiptContinuity -or + $Facts.activeRuntimeId -isnot [string] -or $Facts.activeRuntimeId -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,95}\z' -or + -not (Test-CcodOfficialDraftPositiveInteger $Facts.activeGeneration) -or -not (Test-CcodOfficialDraftHash $Facts.runtimeManifestSha256) -or $Facts.transitionStage -isnot [string] -or $Facts.transitionStage -cne 'Idle' -or + $Facts.protectionRecovered -isnot [bool] -or -not $Facts.protectionRecovered -or $Facts.protectionReady -isnot [bool] -or -not $Facts.protectionReady -or + $Facts.taskState -notin @('Ready','Running') -or -not (Test-CcodOfficialDraftInt32 $Facts.supervisorCount) -or $Facts.supervisorCount -ne 1 -or + -not (Test-CcodOfficialDraftInt32 $Facts.trayHostCount) -or $Facts.trayHostCount -ne 1 -or -not (Test-CcodOfficialDraftInt32 $Facts.codexCount) -or $Facts.codexCount -ne 1 -or $Facts.trayAuthenticated -isnot [bool] -or -not $Facts.trayAuthenticated -or + -not (Test-CcodOfficialDraftHash $Facts.deviceKeySha256) -or $Facts.keyHashPreserved -isnot [bool] -or -not $Facts.keyHashPreserved -or + $Facts.operationOutcome -isnot [string] -or $Facts.operationOutcome -cne 'Completed') { throw 'post facts' } + $trayIdentity = ConvertTo-CcodOfficialDraftTrayHostIdentity -Value $Facts.trayHostIdentity + $null = ConvertTo-CcodOfficialDraftTerminalReceipt -Receipt $Facts.terminalReceipt + $observation = ConvertTo-CcodOfficialDraftLegacyObservation -Facts $Facts.observation -ExpectedVersion $script:CcodOfficialDraftExpectedVersion + $observationTrayIdentity = ConvertTo-CcodOfficialDraftTrayHostIdentity -Value $observation.trayHostIdentity + if ($trayIdentity.pid -ne $observationTrayIdentity.pid -or $trayIdentity.creationTimeUtc -cne $observationTrayIdentity.creationTimeUtc) { throw 'post tray identity binding' } + if ($observation.bootId -cne $Facts.bootIdAfter -or $observation.activeRuntimeId -cne $Facts.activeRuntimeId -or + [UInt64]$observation.activeGeneration -ne [UInt64]$Facts.activeGeneration -or + $observation.runtimeManifestSha256 -cne $Facts.runtimeManifestSha256 -or + $observation.deviceKeySha256 -cne $Facts.deviceKeySha256) { throw 'post observation continuity' } + $Facts.observation = $observation + } + 'ReadyForManualEvidence' { + if (-not (Test-CcodOfficialDraftExactProperties -Value $Facts -Expected @('automatedPhases','status','manualEvidencePending','complete','manualChallenge')) -or + (@($Facts.automatedPhases).Count -ne 6) -or + ((@($Facts.automatedPhases) -join "`0") -cne (@('Preflight','LegacyUpgrade','Uninstall','FreshInstall','PreReboot','PostReboot') -join "`0")) -or + $Facts.status -isnot [string] -or $Facts.status -cne 'ReadyForManualEvidence' -or + $Facts.manualEvidencePending -isnot [bool] -or -not $Facts.manualEvidencePending -or $Facts.complete -isnot [bool] -or $Facts.complete -or + $Facts.manualChallenge -isnot [string] -or $Facts.manualChallenge -cnotmatch '^[0-9a-f]{32}\z') { throw 'ready facts' } + } + default { throw 'phase facts' } + } + return $Facts + } catch { + if ($_.FullyQualifiedErrorId -like 'CCOD_ACCEPTANCE_*') { throw } + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_RECEIPT_INVALID' 'An official-draft phase receipt contains invalid bounded facts.' $null + } +} + +function Assert-CcodOfficialDraftReceiptShape { + param([Parameter(Mandatory)]$Receipt, [Parameter(Mandatory)][string]$ExpectedPhase) + try { + if (-not (Test-CcodOfficialDraftExactProperties -Value $Receipt -Expected $script:CcodOfficialDraftReceiptFields) -or + -not (Test-CcodOfficialDraftInt32 $Receipt.schemaVersion) -or $Receipt.schemaVersion -ne 1 -or + $Receipt.phase -isnot [string] -or $Receipt.phase -cne $ExpectedPhase) { throw 'frame' } + $candidate = ConvertTo-CcodOfficialDraftPersistedCandidate -Value $Receipt.candidate + if (-not (Test-CcodOfficialDraftExactProperties -Value $Receipt.draft -Expected @('tag','id'))) { throw 'draft' } + $draft = ConvertTo-CcodOfficialDraftIdentity -Value $Receipt.draft + if ($null -eq $Receipt.facts -or $Receipt.facts -isnot [pscustomobject]) { throw 'facts' } + if ($ExpectedPhase -ceq 'Preflight') { + if (-not (Test-CcodOfficialDraftExactProperties -Value $Receipt.facts -Expected @('defenderReceipts'))) { throw 'preflight facts' } + $defenders = @($Receipt.facts.defenderReceipts) + if ($defenders.Count -ne 2) { throw 'defender count' } + $first = Assert-CcodOfficialDraftDefenderReceipt -Receipt $defenders[0] -Candidate $candidate -AssetType Setup + $second = Assert-CcodOfficialDraftDefenderReceipt -Receipt $defenders[1] -Candidate $candidate -AssetType PortableZip + return [pscustomobject][ordered]@{ + schemaVersion = 1 + phase = $ExpectedPhase + candidate = $candidate + draft = $draft + facts = [pscustomobject][ordered]@{ defenderReceipts = @($first,$second) } + } + } + if ($ExpectedPhase -ceq 'Complete') { + if (-not (Test-CcodOfficialDraftExactProperties -Value $Receipt.facts -Expected @('manualEvidence','status','complete')) -or + $Receipt.facts.status -isnot [string] -or $Receipt.facts.status -cne 'Complete' -or + $Receipt.facts.complete -isnot [bool] -or -not [bool]$Receipt.facts.complete) { throw 'complete facts' } + $manual = Assert-CcodOfficialDraftManualEvidenceSet -Records $Receipt.facts.manualEvidence -Candidate $candidate + return [pscustomobject][ordered]@{ + schemaVersion = 1 + phase = $ExpectedPhase + candidate = $candidate + draft = $draft + facts = [pscustomobject][ordered]@{ manualEvidence = @($manual); status = 'Complete'; complete = $true } + } + } + $normalizedFacts = Assert-CcodOfficialDraftPersistedPhaseFacts -Phase $ExpectedPhase -Facts $Receipt.facts + return [pscustomobject][ordered]@{ schemaVersion = 1; phase = $ExpectedPhase; candidate = $candidate; draft = $draft; facts = $normalizedFacts } + } catch { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_RECEIPT_INVALID' 'An official-draft acceptance receipt is malformed or tampered.' $null + } +} + +function ConvertTo-CcodOfficialDraftTrayProof { + param([Parameter(Mandatory)]$Proof,[Parameter(Mandatory)][string]$Operation) + try { + $allowed = @{ + About = @('ShowAbout') + Language = @('SetLanguageSystem','SetLanguageChinese','SetLanguageEnglish') + OpenLogs = @('OpenLogs') + Repair = @('CheckAndRepair') + } + if ($null -eq $Proof -or -not (Test-CcodOfficialDraftExactProperties -Value $Proof -Expected @('timestampUtc','command','revision','code','status')) -or + $Proof.timestampUtc -isnot [string] -or $Proof.command -isnot [string] -or + -not $allowed.ContainsKey($Operation) -or $allowed[$Operation] -cnotcontains [string]$Proof.command -or + -not (Test-CcodOfficialDraftPositiveInteger $Proof.revision) -or + $Proof.code -isnot [string] -or $Proof.code -cne 'CCOD_TRAY_ACTION_COMPLETED' -or + $Proof.status -isnot [string] -or $Proof.status -cne 'Completed') { throw 'tray proof' } + if (-not (Test-CcodOfficialDraftCanonicalUtc $Proof.timestampUtc)) { throw 'tray timestamp' } + return [pscustomobject][ordered]@{ + timestampUtc = [string]$Proof.timestampUtc + command = [string]$Proof.command + revision = [UInt64]$Proof.revision + code = 'CCOD_TRAY_ACTION_COMPLETED' + status = 'Completed' + } + } catch { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID' 'Tray operation proof is malformed or not bound to the requested operation.' $null + } +} + +function ConvertTo-CcodOfficialDraftRemoteProof { + param([Parameter(Mandatory)]$Proof) + try { + $expected = @('schemaVersion','timestampUtc','kind','operation','deviceRole','challenge','candidateVersion','runtimeId','runtimeGeneration','runtimeManifestSha256','attestation','connection','control','outcome','code') + if ($null -eq $Proof -or -not (Test-CcodOfficialDraftExactProperties -Value $Proof -Expected $expected) -or + -not (Test-CcodOfficialDraftIntegerValue $Proof.schemaVersion) -or [int64]$Proof.schemaVersion -ne 1 -or + $Proof.timestampUtc -isnot [string] -or + $Proof.kind -isnot [string] -or $Proof.kind -cne 'remote-control-manual-proof' -or + $Proof.operation -isnot [string] -or $Proof.operation -cne 'SecondDeviceControl' -or + $Proof.deviceRole -isnot [string] -or $Proof.deviceRole -cne 'SecondDevice' -or + $Proof.challenge -isnot [string] -or [string]::IsNullOrWhiteSpace([string]$Proof.challenge) -or + $Proof.candidateVersion -isnot [string] -or [string]::IsNullOrWhiteSpace([string]$Proof.candidateVersion) -or + $Proof.runtimeId -isnot [string] -or $Proof.runtimeId -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,95}\z' -or + -not (Test-CcodOfficialDraftPositiveInteger $Proof.runtimeGeneration) -or + $Proof.runtimeManifestSha256 -isnot [string] -or -not (Test-CcodOfficialDraftHash $Proof.runtimeManifestSha256) -or + $Proof.attestation -isnot [string] -or $Proof.attestation -cne 'HumanReviewedStructuredAttestation' -or + $Proof.connection -isnot [string] -or $Proof.connection -cne 'Connected' -or + $Proof.control -isnot [string] -or $Proof.control -cne 'Completed' -or + $Proof.outcome -isnot [string] -or $Proof.outcome -cne 'Completed' -or + $Proof.code -isnot [string] -or $Proof.code -cne 'CCOD_REMOTE_ACTION_COMPLETED') { throw 'remote proof' } + if (-not (Test-CcodOfficialDraftCanonicalUtc $Proof.timestampUtc)) { throw 'remote timestamp' } + return [pscustomobject][ordered]@{ + schemaVersion = 1 + timestampUtc = [string]$Proof.timestampUtc + kind = 'remote-control-manual-proof' + operation = 'SecondDeviceControl' + deviceRole = 'SecondDevice' + challenge = [string]$Proof.challenge + candidateVersion = [string]$Proof.candidateVersion + runtimeId = [string]$Proof.runtimeId + runtimeGeneration = [UInt64]$Proof.runtimeGeneration + runtimeManifestSha256 = [string]$Proof.runtimeManifestSha256 + attestation = 'HumanReviewedStructuredAttestation' + connection = 'Connected' + control = 'Completed' + outcome = 'Completed' + code = 'CCOD_REMOTE_ACTION_COMPLETED' + } + } catch { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_REMOTE_UNPROVEN' 'Second-device proof is malformed or does not prove a completed connection and control.' $null + } +} + +function Assert-CcodOfficialDraftManualRecordShape { + param([Parameter(Mandatory)]$Record) + try { + if (-not (Test-CcodOfficialDraftExactProperties -Value $Record -Expected $script:CcodOfficialDraftManualRecordFields) -or + -not (Test-CcodOfficialDraftInt32 $Record.schemaVersion) -or $Record.schemaVersion -ne 1 -or + $Record.kind -isnot [string] -or $Record.kind -cne 'manual-evidence' -or + $Record.phase -isnot [string] -or $Record.phase -cnotin @('TrayEvidence','RemoteEvidence') -or + $Record.operation -isnot [string] -or -not $script:CcodOfficialDraftManualFiles.ContainsKey($Record.operation) -or + $Record.terminalState -isnot [string] -or $Record.terminalState -cne [string]$script:CcodOfficialDraftManualTerminalStates[$Record.operation] -or + $Record.result -isnot [string] -or $Record.result -cne [string]$script:CcodOfficialDraftManualResultCodes[$Record.operation] -or + $Record.reviewState -isnot [string] -or $Record.reviewState -cne 'Reviewed' -or + $Record.version -isnot [string] -or $Record.version -cne $script:CcodOfficialDraftExpectedVersion -or + -not (Test-CcodOfficialDraftCommit $Record.gitCommit) -or + -not (Test-CcodOfficialDraftHash $Record.candidateManifestSha256) -or + -not (Test-CcodOfficialDraftHash $Record.screenshotSha256) -or + -not (Test-CcodOfficialDraftHash $Record.redactedLogSha256) -or + $Record.screenshotSha256 -ceq $Record.redactedLogSha256) { throw 'manual record' } + if (($Record.phase -ceq 'TrayEvidence' -and $Record.operation -ceq 'SecondDeviceControl') -or + ($Record.phase -ceq 'RemoteEvidence' -and $Record.operation -cne 'SecondDeviceControl')) { throw 'manual phase' } + $proof = $null + if ($Record.operation -ceq 'SecondDeviceControl') { $proof = ConvertTo-CcodOfficialDraftRemoteProof -Proof $Record.proof } else { $proof = ConvertTo-CcodOfficialDraftTrayProof -Proof $Record.proof -Operation ([string]$Record.operation) } + return [pscustomobject][ordered]@{ + schemaVersion = 1 + kind = 'manual-evidence' + phase = [string]$Record.phase + operation = [string]$Record.operation + terminalState = [string]$Record.terminalState + result = [string]$Record.result + proof = $proof + reviewState = 'Reviewed' + version = [string]$Record.version + gitCommit = [string]$Record.gitCommit + candidateManifestSha256 = [string]$Record.candidateManifestSha256 + screenshotSha256 = [string]$Record.screenshotSha256 + redactedLogSha256 = [string]$Record.redactedLogSha256 + } + } catch { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID' 'Manual evidence is malformed, unreviewed, or not operation-bound.' $null + } +} + +function Assert-CcodOfficialDraftManualEvidenceSet { + param([Parameter(Mandatory)]$Records, [Parameter(Mandatory)]$Candidate) + try { + $values = if ($Records -is [Collections.IDictionary]) { @($Records.Values) } else { @($Records) } + $expectedOperations = @('About','Language','OpenLogs','Repair','SecondDeviceControl') + if ($values.Count -ne $expectedOperations.Count) { throw 'manual count' } + $normalized = [Collections.Generic.List[object]]::new() + $seenOperations = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + $seenHashes = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + foreach ($operation in $expectedOperations) { + $matches = @($values | Where-Object { $_.operation -ceq $operation }) + if ($matches.Count -ne 1) { throw 'manual operation' } + $record = Assert-CcodOfficialDraftManualRecordShape -Record $matches[0] + if (-not $seenOperations.Add($record.operation) -or + $record.version -cne $Candidate.version -or $record.gitCommit -cne $Candidate.gitCommit -or + $record.candidateManifestSha256 -cne $Candidate.manifestHashes.portable -or + ($record.operation -ceq 'SecondDeviceControl' -and ($record.proof.candidateVersion -isnot [string] -or $record.proof.candidateVersion -cne $Candidate.version)) -or + -not $seenHashes.Add($record.screenshotSha256) -or -not $seenHashes.Add($record.redactedLogSha256)) { throw 'manual binding' } + $normalized.Add($record) + } + return @($normalized) + } catch { + if ($_.FullyQualifiedErrorId -like 'CCOD_ACCEPTANCE_*') { throw } + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INCOMPLETE' 'All four tray records and the distinct second-device record are required.' $null + } +} + +function Read-CcodOfficialDraftState { + param([Parameter(Mandatory)][string]$StateDirectory,[string]$PreviousAssetDirectory) + [void](Assert-CcodOfficialDraftSafeDirectory -Path $StateDirectory -ErrorId 'CCOD_ACCEPTANCE_RECEIPT_INVALID') + [void](Repair-CcodOfficialDraftOrphanTemporaryFiles -StateDirectory $StateDirectory) + $records = @{} + try { $children = @(Get-ChildItem -LiteralPath $StateDirectory -Force -ErrorAction Stop) } catch { Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_RECEIPT_INVALID' 'Official-draft acceptance state could not be enumerated safely.' $null } + $allowed = @($script:CcodOfficialDraftPhaseFiles.Values) + @($script:CcodOfficialDraftCompletionFile,$script:CcodOfficialDraftManualDirectoryName) + foreach ($child in $children) { + if (($child.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + $allowed -cnotcontains [string]$child.Name -or + ([string]$child.Name -ceq $script:CcodOfficialDraftManualDirectoryName -and -not $child.PSIsContainer) -or + ([string]$child.Name -cne $script:CcodOfficialDraftManualDirectoryName -and $child.PSIsContainer)) { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_RECEIPT_INVALID' 'Official-draft acceptance state contains an unexpected entry.' $null + } + } + foreach ($phase in $script:CcodOfficialDraftPhases) { + $path = Join-Path $StateDirectory $script:CcodOfficialDraftPhaseFiles[$phase] + if (-not (Get-CcodOfficialDraftOptionalPathState -Path $path -ExpectDirectory $false -GetItem { param($CandidatePath) Get-Item -LiteralPath $CandidatePath -Force -ErrorAction Stop } -ErrorId 'CCOD_ACCEPTANCE_RECEIPT_INVALID')) { continue } + $json = Read-CcodOfficialDraftJson -Path $path + $records[$phase] = Assert-CcodOfficialDraftReceiptShape -Receipt $json.Value -ExpectedPhase $phase + } + $completePath = Join-Path $StateDirectory $script:CcodOfficialDraftCompletionFile + if (Get-CcodOfficialDraftOptionalPathState -Path $completePath -ExpectDirectory $false -GetItem { param($CandidatePath) Get-Item -LiteralPath $CandidatePath -Force -ErrorAction Stop } -ErrorId 'CCOD_ACCEPTANCE_RECEIPT_INVALID') { + $json = Read-CcodOfficialDraftJson -Path $completePath + $records['Complete'] = Assert-CcodOfficialDraftReceiptShape -Receipt $json.Value -ExpectedPhase 'Complete' + } + $manual = @{} + $manualDirectory = Join-Path $StateDirectory $script:CcodOfficialDraftManualDirectoryName + if (Get-CcodOfficialDraftOptionalPathState -Path $manualDirectory -ExpectDirectory $true -GetItem { param($CandidatePath) Get-Item -LiteralPath $CandidatePath -Force -ErrorAction Stop } -ErrorId 'CCOD_ACCEPTANCE_RECEIPT_INVALID') { + [void](Assert-CcodOfficialDraftSafeDirectory -Path $manualDirectory -ErrorId 'CCOD_ACCEPTANCE_RECEIPT_INVALID') + try { $manualChildren = @(Get-ChildItem -LiteralPath $manualDirectory -Force -ErrorAction Stop) } + catch { Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_RECEIPT_INVALID' 'Manual evidence state could not be enumerated safely.' $null } + foreach ($child in $manualChildren) { + if ($child.PSIsContainer -or ($child.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + @($script:CcodOfficialDraftManualFiles.Values) -cnotcontains [string]$child.Name) { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_RECEIPT_INVALID' 'Manual evidence state contains an unexpected entry.' $null + } + $fileOperation = @($script:CcodOfficialDraftManualFiles.GetEnumerator() | Where-Object { [string]$_.Value -ceq [string]$child.Name } | ForEach-Object { [string]$_.Key }) + if ($fileOperation.Count -ne 1) { Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID' 'Manual evidence filename is not recognized.' $child.FullName } + $record = Assert-CcodOfficialDraftManualRecordShape -Record (Read-CcodOfficialDraftJson -Path $child.FullName).Value + if ([string]$record.operation -cne [string]$fileOperation[0]) { Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID' 'Manual evidence filename is detached from its operation.' $child.FullName } + if ($manual.ContainsKey([string]$record.operation)) { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID' 'Manual evidence contains a duplicate operation.' $null + } + $manual[[string]$record.operation] = $record + } + } + $records['ManualEvidence'] = $manual + if ($records.ContainsKey('LegacyUpgrade') -and -not [string]::IsNullOrWhiteSpace($PreviousAssetDirectory)) { + [void](Assert-CcodOfficialDraftPreviousSetupBinding -PreviousSetup $records['LegacyUpgrade'].facts.previousSetup -PreviousAssetDirectory $PreviousAssetDirectory) + } + if (@($script:CcodOfficialDraftPhases | Where-Object { -not $records.ContainsKey($_) }).Count -eq 0) { + [void](Assert-CcodOfficialDraftAutomatedReceiptChain -Records $records -StateDirectory $StateDirectory) + } + return $records +} + +function Repair-CcodOfficialDraftOrphanTemporaryFiles { + param([Parameter(Mandatory)][string]$StateDirectory) + try { + $temporaryEntries = @(Get-ChildItem -LiteralPath $StateDirectory -Force -ErrorAction Stop | Where-Object { [string]$_.Name -cmatch '^\.(acceptance|manual)-[0-9a-f]{32}\.tmp\z' }) + foreach ($entry in $temporaryEntries) { + if ($entry -isnot [IO.FileInfo] -or ($entry.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'orphan temporary identity' } + $stream = [IO.File]::Open($entry.FullName,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::None) + try { $stream.Flush() } finally { $stream.Dispose() } + Remove-Item -LiteralPath $entry.FullName -Force -ErrorAction Stop + if (Test-Path -LiteralPath $entry.FullName) { throw 'orphan temporary residue' } + } + return $true + } catch { + if ($_.FullyQualifiedErrorId -like 'CCOD_ACCEPTANCE_*') { throw } + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_RECEIPT_INVALID' 'An orphan acceptance temporary could not be proven safe to remove.' $StateDirectory + } +} + +function Get-CcodOfficialDraftPreviousSetupContract { + param([Parameter(Mandatory)][string]$Directory,[Parameter(Mandatory)][string]$Version) + $assetModule=Get-CcodOfficialDraftAssetModule + $directoryAuthority=$null + $files=[Collections.Generic.Dictionary[string,object]]::new([StringComparer]::Ordinal) + $errorId='CCOD_ACCEPTANCE_PREVIOUS_ASSET_INVALID' + try { + if($Version-cne'2.5.21'){throw 'unsupported previous version'} + $names=@('CodexRemote-fix-2.5.21-windows-x64.zip','CodexRemote-fix-2.5.21-windows-x64.zip.sha256.txt','CodexRemote-fix-2.5.21-trayhost-provenance.json','CodexRemote-fix-2.5.21-payload-manifest.json','CodexRemote-fix-2.5.21-release-manifest.json','CodexRemote-fix-2.5.21-setup.exe','CodexRemote-fix-2.5.21-setup.exe.sha256.txt','CodexRemote-fix-2.5.21-setup-release-manifest.json') + $directoryAuthority=&$assetModule {param($Path,$ErrorId)Open-CcodReleaseDirectoryAuthority -Path $Path -ErrorId $ErrorId} $Directory $errorId + if(@($directoryAuthority.Children).Count-ne$names.Count-or@($directoryAuthority.Children|Where-Object {$names-cnotcontains$_}).Count-ne0){throw 'previous asset membership'} + foreach($name in $names){ + $file=&$assetModule {param($Leaf,$Directory,$ErrorId)Open-CcodReleaseFileAuthority -Directory $Directory -Leaf $Leaf -ErrorId $ErrorId -AllowZone} $name $directoryAuthority $errorId + $files.Add($name,$file) + } + $setup=&$assetModule {param($File,$ErrorId)(Read-CcodReleaseContractPinnedJson -Authority $File -ErrorId $ErrorId).Value} $files[$names[7]] $errorId + if((@($setup.PSObject.Properties.Name)-join',')-cne'schemaVersion,product,version,gitCommit,buildTimestampUtc,assets'-or + -not(Test-CcodOfficialDraftIntegerValue $setup.schemaVersion)-or[long]$setup.schemaVersion-ne1-or + $setup.product-isnot[string]-or$setup.product-cne'CodexRemote-fix'-or + $setup.version-isnot[string]-or$setup.version-cne$Version-or + -not(Test-CcodOfficialDraftCommit $setup.gitCommit)-or-not(Test-CcodOfficialDraftCanonicalUtc $setup.buildTimestampUtc)){throw 'legacy setup manifest schema'} + $map=&$assetModule {param($Manifest,$Names,$ErrorId,$Target)Get-CcodReleaseContractManifestMap -Manifest $Manifest -ExpectedNames $Names -ErrorId $ErrorId -Target $Target} $setup @($names[5],$names[6],$names[2]) $errorId $files[$names[7]].Path + foreach($name in @($names[5],$names[6],$names[2])){if($files[$name].Sha256-cne$map[$name]){throw 'legacy setup asset hash'}} + $checksum=&$assetModule {param($File)$bytes=Get-CcodReleaseAuthorityStreamBytes -Stream $File.Stream -MaximumBytes 16384;[Text.UTF8Encoding]::new($false,$true).GetString($bytes)} $files[$names[6]] + if($checksum.TrimEnd([char]13,[char]10)-cne($map[$names[5]]+' *'+$names[5])){throw 'legacy setup checksum'} + $portable=&$assetModule { + param($File,$Directory,$Version,$ErrorId) + $loaded=Read-CcodReleaseContractPinnedJson -Authority $File -ErrorId $ErrorId + Test-CcodReleasePortableManifestDeep -Manifest $loaded.Value -ManifestRaw $loaded.Raw -ManifestPath $File.Path -Directory $Directory -Version $Version -ErrorId $ErrorId + } $files[$names[4]] $directoryAuthority.Path $Version $errorId + if($portable.Valid-isnot[bool]-or-not$portable.Valid-or$portable.GitCommit-cne$setup.gitCommit-or$portable.BuildTimestampUtc-cne$setup.buildTimestampUtc){throw 'legacy distribution binding'} + foreach($file in $files.Values){&$assetModule {param($File,$ErrorId)Assert-CcodReleaseAuthorityCurrent -Authority $File -ErrorId $ErrorId -CheckBytes|Out-Null} $file $errorId} + &$assetModule {param($Directory,$ErrorId)Assert-CcodReleaseAuthorityCurrent -Authority $Directory -ErrorId $ErrorId|Out-Null} $directoryAuthority $errorId + return [pscustomobject][ordered]@{Valid=$true;Version=$Version;GitCommit=[string]$setup.gitCommit;InstallerName=$names[5];InstallerSha256=[string]$files[$names[5]].Sha256;ManifestSha256=[string]$files[$names[7]].Sha256} + } catch { + Throw-CcodOfficialDraftError $errorId 'The previous v2.5.21 assets do not satisfy their original eight-file contract.' $Directory + } finally { + foreach($file in $files.Values){&$assetModule {param($File)Close-CcodReleaseAuthority $File} $file} + if($null-ne$directoryAuthority){&$assetModule {param($Directory)Close-CcodReleaseAuthority $Directory} $directoryAuthority} + } +} + +function Assert-CcodOfficialDraftPreviousSetupBinding { + param([Parameter(Mandatory)]$PreviousSetup,[Parameter(Mandatory)][string]$PreviousAssetDirectory) + try { + if (-not (Test-CcodOfficialDraftExactProperties -Value $PreviousSetup -Expected @('version','gitCommit','assetSha256','manifestSha256')) -or + $PreviousSetup.version -isnot [string] -or $PreviousSetup.version -cne '2.5.21' -or + -not (Test-CcodOfficialDraftCommit $PreviousSetup.gitCommit) -or + -not (Test-CcodOfficialDraftHash $PreviousSetup.assetSha256) -or + -not (Test-CcodOfficialDraftHash $PreviousSetup.manifestSha256)) { throw 'previous setup shape' } + $contract=Get-CcodOfficialDraftPreviousSetupContract -Directory $PreviousAssetDirectory -Version '2.5.21' + if($contract.GitCommit-cne$PreviousSetup.gitCommit-or$contract.InstallerSha256-cne$PreviousSetup.assetSha256-or$contract.ManifestSha256-cne$PreviousSetup.manifestSha256){throw 'previous setup binding'} + return $true + } catch { + if ($_.FullyQualifiedErrorId -like 'CCOD_ACCEPTANCE_RECEIPT_INVALID*') { throw } + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_RECEIPT_INVALID' 'Persisted v2.5.21 setup evidence is detached from the validated previous asset files.' $PreviousAssetDirectory + } +} + +function Assert-CcodOfficialDraftRemoteProofRuntimeContinuity { + param([Parameter(Mandatory)]$ManualEvidence,[Parameter(Mandatory)]$FreshFacts,[Parameter(Mandatory)]$PostFacts) + try { + $remote = @($ManualEvidence | Where-Object { $_.operation -ceq 'SecondDeviceControl' }) + if ($remote.Count -ne 1) { throw 'remote proof count' } + $proof = $remote[0].proof + $freshRuntimeId = Get-CcodOfficialDraftFactValue -Facts $FreshFacts -Name 'activeRuntimeId' + $postRuntimeId = Get-CcodOfficialDraftFactValue -Facts $PostFacts -Name 'activeRuntimeId' + $freshGeneration = Get-CcodOfficialDraftFactValue -Facts $FreshFacts -Name 'activeGeneration' + $postGeneration = Get-CcodOfficialDraftFactValue -Facts $PostFacts -Name 'activeGeneration' + $freshManifestHash = Get-CcodOfficialDraftFactValue -Facts $FreshFacts -Name 'runtimeManifestSha256' + $postManifestHash = Get-CcodOfficialDraftFactValue -Facts $PostFacts -Name 'runtimeManifestSha256' + if ($proof.runtimeId -isnot [string] -or $proof.runtimeId -cne [string]$freshRuntimeId -or $proof.runtimeId -cne [string]$postRuntimeId -or + -not (Test-CcodOfficialDraftPositiveInteger $proof.runtimeGeneration) -or -not (Test-CcodOfficialDraftPositiveInteger $freshGeneration) -or + -not (Test-CcodOfficialDraftPositiveInteger $postGeneration) -or [UInt64]$proof.runtimeGeneration -ne [UInt64]$freshGeneration -or [UInt64]$proof.runtimeGeneration -ne [UInt64]$postGeneration -or + -not (Test-CcodOfficialDraftHash $proof.runtimeManifestSha256) -or $proof.runtimeManifestSha256 -cne [string]$freshManifestHash -or $proof.runtimeManifestSha256 -cne [string]$postManifestHash) { throw 'remote runtime continuity' } + return $true + } catch { + if ($_.FullyQualifiedErrorId -like 'CCOD_ACCEPTANCE_*') { throw } + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_RECEIPT_INVALID' 'Second-device proof is not bound to the current installed runtime.' $null + } +} + +function Assert-CcodOfficialDraftAutomatedReceiptChain { + param([Parameter(Mandatory)]$Records,[Parameter(Mandatory)][string]$StateDirectory) + try { + foreach ($phase in $script:CcodOfficialDraftPhases) { + if (-not $Records.ContainsKey($phase)) { throw 'automated phase missing' } + } + $candidate = $Records['Preflight'].candidate + $draftIdentity = $Records['Preflight'].draft + if ($draftIdentity.tag -isnot [string] -or $draftIdentity.tag -cne 'v2.5.22' -or + $draftIdentity.id -isnot [string] -or [string]::IsNullOrWhiteSpace([string]$draftIdentity.id)) { throw 'draft identity chain' } + foreach ($phase in $script:CcodOfficialDraftPhases) { + $receipt = $Records[$phase] + if (-not (Test-CcodOfficialDraftCandidateEqual -Left $candidate -Right $receipt.candidate) -or + $receipt.draft.tag -isnot [string] -or $receipt.draft.tag -cne 'v2.5.22' -or + $receipt.draft.id -isnot [string] -or $receipt.draft.id -cne [string]$draftIdentity.id) { throw 'automated candidate chain' } + } + if ($Records.ContainsKey('Complete') -and + (-not (Test-CcodOfficialDraftCandidateEqual -Left $candidate -Right $Records['Complete'].candidate) -or + $Records['Complete'].draft.tag -isnot [string] -or $Records['Complete'].draft.tag -cne [string]$draftIdentity.tag -or + $Records['Complete'].draft.id -isnot [string] -or $Records['Complete'].draft.id -cne [string]$draftIdentity.id)) { throw 'complete candidate/draft chain' } + $freshFacts = $Records['FreshInstall'].facts + $preFacts = $Records['PreReboot'].facts + $postFacts = $Records['PostReboot'].facts + $legacyFacts = $Records['LegacyUpgrade'].facts + $uninstallFacts = $Records['Uninstall'].facts + $freshLeaf = [string]$script:CcodOfficialDraftPhaseFiles['FreshInstall'] + if ([string]::IsNullOrWhiteSpace($StateDirectory) -or [string]::IsNullOrWhiteSpace($freshLeaf)) { throw 'fresh receipt path' } + $freshHash = Get-CcodOfficialDraftFileSha256 -Path (Join-Path $StateDirectory $freshLeaf) + if ((Get-CcodOfficialDraftFactValue -Facts $preFacts -Name 'freshInstallReceiptSha256') -cne $freshHash) { throw 'fresh receipt chain' } + $expectedKeyHash = Get-CcodOfficialDraftFactValue -Facts $freshFacts -Name 'deviceKeySha256' + $legacyAfterKeyHash = Get-CcodOfficialDraftFactValue -Facts $legacyFacts -Name 'deviceKeySha256After' + $uninstallBeforeKeyHash = Get-CcodOfficialDraftFactValue -Facts $uninstallFacts -Name 'deviceKeySha256Before' + $uninstallAfterKeyHash = Get-CcodOfficialDraftFactValue -Facts $uninstallFacts -Name 'deviceKeySha256After' + if (-not (Test-CcodOfficialDraftHash $expectedKeyHash) -or + -not (Test-CcodOfficialDraftHash $legacyAfterKeyHash) -or + -not (Test-CcodOfficialDraftHash $uninstallBeforeKeyHash) -or + -not (Test-CcodOfficialDraftHash $uninstallAfterKeyHash) -or + $legacyAfterKeyHash -cne $uninstallBeforeKeyHash -or + $uninstallBeforeKeyHash -cne $uninstallAfterKeyHash -or + $uninstallAfterKeyHash -cne $expectedKeyHash) { throw 'device-key chain' } + $preRuntimeId = Get-CcodOfficialDraftFactValue -Facts $preFacts -Name 'activeRuntimeId' + $preGeneration = Get-CcodOfficialDraftFactValue -Facts $preFacts -Name 'activeGeneration' + $preManifestHash = Get-CcodOfficialDraftFactValue -Facts $preFacts -Name 'runtimeManifestSha256' + $preKeyHash = Get-CcodOfficialDraftFactValue -Facts $preFacts -Name 'deviceKeySha256' + $freshRuntimeId = Get-CcodOfficialDraftFactValue -Facts $freshFacts -Name 'activeRuntimeId' + $freshGeneration = Get-CcodOfficialDraftFactValue -Facts $freshFacts -Name 'activeGeneration' + $freshManifestHash = Get-CcodOfficialDraftFactValue -Facts $freshFacts -Name 'runtimeManifestSha256' + if ($preRuntimeId -isnot [string] -or $freshRuntimeId -isnot [string] -or $preRuntimeId -cne $freshRuntimeId -or + -not (Test-CcodOfficialDraftPositiveInteger $preGeneration) -or -not (Test-CcodOfficialDraftPositiveInteger $freshGeneration) -or [UInt64]$preGeneration -ne [UInt64]$freshGeneration -or + -not (Test-CcodOfficialDraftHash $preManifestHash) -or -not (Test-CcodOfficialDraftHash $freshManifestHash) -or $preManifestHash -cne $freshManifestHash -or + -not (Test-CcodOfficialDraftHash $preKeyHash) -or $preKeyHash -cne $expectedKeyHash) { throw 'pre-reboot identity chain' } + $bootBefore = Get-CcodOfficialDraftFactValue -Facts $preFacts -Name 'bootIdBefore' + if (-not (Test-CcodOfficialDraftHash $expectedKeyHash) -or $bootBefore -isnot [string]) { throw 'reboot continuity inputs' } + [void](Assert-CcodOfficialDraftPersistedPostRebootContinuity -Facts $postFacts -FreshFacts $freshFacts -PreFacts $preFacts -ExpectedKeyHash $expectedKeyHash) + $readyFacts = $Records['ReadyForManualEvidence'].facts + if (-not (Test-CcodOfficialDraftExactProperties -Value $readyFacts -Expected @('automatedPhases','status','manualEvidencePending','complete','manualChallenge')) -or + (@($readyFacts.automatedPhases) -join '|') -cne 'Preflight|LegacyUpgrade|Uninstall|FreshInstall|PreReboot|PostReboot' -or + $readyFacts.status -isnot [string] -or $readyFacts.status -cne 'ReadyForManualEvidence' -or + $readyFacts.manualEvidencePending -isnot [bool] -or -not $readyFacts.manualEvidencePending -or + $readyFacts.complete -isnot [bool] -or $readyFacts.complete -or + $readyFacts.manualChallenge -isnot [string] -or $readyFacts.manualChallenge -cnotmatch '^[0-9a-f]{32}\z') { throw 'ready chain' } + if ($Records.ContainsKey('Complete')) { + $completeManual = @($Records['Complete'].facts.manualEvidence) + $fileManual = @() + foreach ($operation in $script:CcodOfficialDraftManualOperations) { + if (-not $Records['ManualEvidence'].ContainsKey($operation)) { throw 'manual state missing' } + $fileManual += $Records['ManualEvidence'][$operation] + } + if ($completeManual.Count -ne $script:CcodOfficialDraftManualOperations.Count -or $fileManual.Count -ne $completeManual.Count) { throw 'manual state count' } + $manualFields = @('schemaVersion','kind','phase','operation','terminalState','result','proof','reviewState','version','gitCommit','candidateManifestSha256','screenshotSha256','redactedLogSha256') + for ($manualIndex = 0; $manualIndex -lt $completeManual.Count; $manualIndex++) { + foreach ($right in @($fileManual[$manualIndex])) { + foreach ($field in $manualFields) { + $leftProperty = @($completeManual[$manualIndex].PSObject.Properties | Where-Object { $_.Name -ceq $field }) + $rightProperty = @($right.PSObject.Properties | Where-Object { $_.Name -ceq $field }) + if ($leftProperty.Count -ne 1 -or $rightProperty.Count -ne 1) { throw 'manual state binding' } + $leftValue = [string]$leftProperty[0].Value + $rightValue = [string]$rightProperty[0].Value + if ($field -ceq 'proof') { + $leftValue = $leftProperty[0].Value | ConvertTo-Json -Depth 12 -Compress + $rightValue = $rightProperty[0].Value | ConvertTo-Json -Depth 12 -Compress + } + if ($leftValue -cne $rightValue) { throw 'manual state binding' } + } + } + } + [void](Assert-CcodOfficialDraftRemoteProofRuntimeContinuity -ManualEvidence $completeManual -FreshFacts $freshFacts -PostFacts $postFacts) + $remoteManual = @($completeManual | Where-Object { $_.operation -ceq 'SecondDeviceControl' }) + $readyChallenge = Get-CcodOfficialDraftFactValue -Facts $readyFacts -Name 'manualChallenge' + if ($remoteManual.Count -ne 1 -or $remoteManual[0].proof.challenge -isnot [string] -or $remoteManual[0].proof.challenge -cne [string]$readyChallenge -or + $remoteManual[0].proof.candidateVersion -isnot [string] -or $remoteManual[0].proof.candidateVersion -cne [string]$candidate.version) { throw 'remote proof chain' } + } + return $true + } catch { + if ($_.FullyQualifiedErrorId -like 'CCOD_ACCEPTANCE_*') { throw } + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_RECEIPT_INVALID' 'The automated official-draft receipt chain is missing, tampered, or inconsistent.' $null + } +} + +function Assert-CcodOfficialDraftPersistedPostRebootContinuity { + param([Parameter(Mandatory)]$Facts,[Parameter(Mandatory)]$FreshFacts,[Parameter(Mandatory)]$PreFacts,[Parameter(Mandatory)][string]$ExpectedKeyHash) + try { + $bootBefore = Get-CcodOfficialDraftFactValue -Facts $PreFacts -Name 'bootIdBefore' + $postBefore = Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'bootIdBefore' + $postAfter = Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'bootIdAfter' + if ($bootBefore -isnot [string] -or $postBefore -isnot [string] -or $postAfter -isnot [string] -or + $bootBefore -cne $postBefore -or $postAfter -ceq $postBefore -or + (Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'bootChanged') -isnot [bool] -or -not (Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'bootChanged') -or + (Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'receiptContinuity') -isnot [bool] -or -not (Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'receiptContinuity')) { throw 'boot continuity' } + $runtimeId = Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'activeRuntimeId' + $generation = Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'activeGeneration' + $freshRuntimeId = Get-CcodOfficialDraftFactValue -Facts $FreshFacts -Name 'activeRuntimeId' + $freshGeneration = Get-CcodOfficialDraftFactValue -Facts $FreshFacts -Name 'activeGeneration' + $postManifestHash = Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'runtimeManifestSha256' + $freshManifestHash = Get-CcodOfficialDraftFactValue -Facts $FreshFacts -Name 'runtimeManifestSha256' + if ($runtimeId -isnot [string] -or $runtimeId -cne $freshRuntimeId -or + -not (Test-CcodOfficialDraftPositiveInteger $generation) -or [UInt64]$generation -ne [UInt64]$freshGeneration -or + -not (Test-CcodOfficialDraftHash $postManifestHash) -or $postManifestHash -cne $freshManifestHash -or + -not (Test-CcodOfficialDraftInt32 (Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'codexCount')) -or [int](Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'codexCount') -ne 1 -or + -not (Test-CcodOfficialDraftInt32 (Get-CcodOfficialDraftFactValue -Facts $FreshFacts -Name 'codexCount')) -or [int](Get-CcodOfficialDraftFactValue -Facts $FreshFacts -Name 'codexCount') -ne 1 -or + (Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'transitionStage') -cne 'Idle' -or + (Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'protectionRecovered') -isnot [bool] -or -not (Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'protectionRecovered') -or + (Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'protectionReady') -isnot [bool] -or -not (Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'protectionReady') -or + (Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'trayAuthenticated') -isnot [bool] -or -not (Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'trayAuthenticated')) { throw 'runtime continuity' } + $terminal = ConvertTo-CcodOfficialDraftTerminalReceipt -Receipt (Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'terminalReceipt') + $freshTerminal = ConvertTo-CcodOfficialDraftTerminalReceipt -Receipt (Get-CcodOfficialDraftFactValue -Facts $FreshFacts -Name 'terminalReceipt') + if ($terminal.runtimeId -cne $runtimeId -or [UInt64]$terminal.runtimeGeneration -ne [UInt64]$generation -or + $terminal.runtimeId -cne $freshTerminal.runtimeId -or [UInt64]$terminal.runtimeGeneration -ne [UInt64]$freshTerminal.runtimeGeneration -or + (Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'deviceKeySha256') -cne $ExpectedKeyHash -or + (Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'keyHashPreserved') -isnot [bool] -or -not (Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'keyHashPreserved')) { throw 'terminal continuity' } + return $true + } catch { + if ($_.FullyQualifiedErrorId -like 'CCOD_ACCEPTANCE_*') { throw } + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_RECEIPT_INVALID' 'Persisted post-reboot identity is not bound to FreshInstall and PreReboot.' $null + } +} + +function Write-CcodOfficialDraftReceipt { + param( + [Parameter(Mandatory)][string]$StateDirectory, + [Parameter(Mandatory)][string]$Phase, + [Parameter(Mandatory)]$Candidate, + [Parameter(Mandatory)]$Draft, + [Parameter(Mandatory)]$Facts, + [scriptblock]$ReadbackVerifier, + [ref]$PublishedHash + ) + $leaf = if ($Phase -ceq 'Complete') { $script:CcodOfficialDraftCompletionFile } else { $script:CcodOfficialDraftPhaseFiles[$Phase] } + if ([string]::IsNullOrWhiteSpace([string]$leaf)) { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_PHASE_INVALID' 'The official-draft acceptance phase has no state file.' $Phase + } + $path = Join-Path $StateDirectory $leaf + $existing = $null + try { $existing = Get-Item -LiteralPath $path -Force -ErrorAction Stop } catch { if (-not (Test-CcodOfficialDraftMissingError -ErrorRecord $_)) { Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_PHASE_REUSED' 'The acceptance receipt path could not be inspected safely.' $path } } + if ($null -ne $existing) { + if ($Phase -ne 'Complete') { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_PHASE_REUSED' 'An official-draft acceptance phase receipt already exists.' $null + } + try { + $existingReceipt = Assert-CcodOfficialDraftReceiptShape -Receipt (Read-CcodOfficialDraftJson -Path $path).Value -ExpectedPhase 'Complete' + if (-not (Test-CcodOfficialDraftCandidateEqual -Left $Candidate -Right $existingReceipt.candidate) -or + -not (Test-CcodOfficialDraftDraftEqual -Left $Draft -Right $existingReceipt.draft)) { throw 'complete identity' } + if ($null -ne $PublishedHash) { $PublishedHash.Value = $null } + return $existingReceipt + } catch { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_PHASE_REUSED' 'An existing Complete receipt is not safely resumable.' $path + } + } + $receipt = [pscustomobject][ordered]@{ + schemaVersion = 1 + phase = $Phase + candidate = $Candidate + draft = $Draft + facts = $Facts + } + $temporary = Join-Path $StateDirectory ('.acceptance-' + [guid]::NewGuid().ToString('N') + '.tmp') + $published = $false + $expectedHash = $null + try { + $bytes = [Text.UTF8Encoding]::new($false).GetBytes(($receipt | ConvertTo-Json -Depth 16)) + $sha = [Security.Cryptography.SHA256]::Create() + try { $expectedHash = ([BitConverter]::ToString($sha.ComputeHash($bytes))).Replace('-','').ToLowerInvariant() } finally { $sha.Dispose() } + $stream = [IO.File]::Open($temporary,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::Read) + try { $stream.Write($bytes,0,$bytes.Length); $stream.Flush($true) } finally { $stream.Dispose() } + [IO.File]::Move($temporary,$path) + $published = $true + if ($null -ne $PublishedHash) { $PublishedHash.Value = $expectedHash } + if ($null -ne $ReadbackVerifier -and -not [bool](& $ReadbackVerifier $path)) { throw 'receipt readback' } + $normalizedReceipt = Assert-CcodOfficialDraftReceiptShape -Receipt (Read-CcodOfficialDraftJson -Path $path).Value -ExpectedPhase $Phase + if ((Get-CcodOfficialDraftFileSha256 -Path $path) -cne $expectedHash) { throw 'receipt semantic mismatch' } + return $normalizedReceipt + } catch { + $failure = $_ + if ($null -ne $temporary) { + try { $tempItem = Get-Item -LiteralPath $temporary -Force -ErrorAction Stop; if ($null -ne $tempItem) { Remove-Item -LiteralPath $temporary -Force -ErrorAction Stop } } catch { if (-not (Test-CcodOfficialDraftMissingError -ErrorRecord $_)) { Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_RECEIPT_CLEANUP_FAILED' 'Temporary acceptance receipt cleanup could not be proven.' $temporary } } + } + if ($published) { + try { + $item = $null + try { $item = Get-Item -LiteralPath $path -Force -ErrorAction Stop } catch { if (-not (Test-CcodOfficialDraftMissingError -ErrorRecord $_)) { throw } } + if ($null -ne $item) { + if ($item -isnot [IO.FileInfo] -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + ($null -ne $expectedHash -and (Get-CcodOfficialDraftFileSha256 -Path $path) -cne $expectedHash)) { throw 'receipt cleanup target' } + Remove-Item -LiteralPath $path -Force -ErrorAction Stop + } + $residual = $null + try { $residual = Get-Item -LiteralPath $path -Force -ErrorAction Stop } catch { if (-not (Test-CcodOfficialDraftMissingError -ErrorRecord $_)) { throw } } + if ($null -ne $residual) { throw 'receipt cleanup residue' } + } catch { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_RECEIPT_CLEANUP_FAILED' 'Receipt destination cleanup could not be proven after a failed readback.' $path + } + } + if ($failure.FullyQualifiedErrorId -like 'CCOD_ACCEPTANCE_*') { throw $failure } + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_RECEIPT_WRITE_FAILED' 'Official-draft acceptance receipt could not be durably written.' $null + } +} + +function Test-CcodOfficialDraftIntegerValue { + param($Value) + if ($null -eq $Value -or $Value -is [bool]) { return $false } + return $Value.GetType() -in @([sbyte],[byte],[int16],[uint16],[int32],[uint32],[int64],[uint64]) +} + +function Test-CcodOfficialDraftMissingError { + param([Parameter(Mandatory)]$ErrorRecord) + return $ErrorRecord.CategoryInfo.Category -eq 'ObjectNotFound' -or + $ErrorRecord.Exception -is [IO.FileNotFoundException] -or $ErrorRecord.Exception -is [IO.DirectoryNotFoundException] +} + +function Assert-CcodOfficialDraftMissingPathAncestorsSafe { + param([Parameter(Mandatory)][string]$Path,[Parameter(Mandatory)][scriptblock]$GetItem,[Parameter(Mandatory)][string]$ErrorId) + try { + $current = [IO.Path]::GetFullPath($Path) + $pathRoot = [IO.Path]::GetPathRoot($current) + while ($null -ne $current) { + $parent = [IO.Directory]::GetParent($current) + if ($null -eq $parent) { break } + $current = $parent.FullName + try { + $items = @(& $GetItem $current) + if ($items.Count -ne 1 -or $null -eq $items[0] -or -not [bool]$items[0].PSIsContainer) { throw 'ancestor shape' } + if (($items[0].Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'ancestor reparse' } + } catch { + if (-not (Test-CcodOfficialDraftMissingError -ErrorRecord $_)) { throw } + } + if ($current.TrimEnd('\') -ceq $pathRoot.TrimEnd('\')) { break } + } + } catch { + if ($_.FullyQualifiedErrorId -like 'CCOD_ACCEPTANCE_*') { throw } + Throw-CcodOfficialDraftError $ErrorId 'Installed lifecycle path ancestry could not be inspected safely.' $Path + } +} + +function Get-CcodOfficialDraftOptionalPathState { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][bool]$ExpectDirectory, + [Parameter(Mandatory)][scriptblock]$GetItem, + [Parameter(Mandatory)][string]$ErrorId + ) + try { + $items = @(& $GetItem $Path) + if ($items.Count -ne 1 -or $null -eq $items[0]) { throw 'path observation' } + $item = $items[0] + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + [bool]$item.PSIsContainer -ne $ExpectDirectory) { throw 'wrong type or reparse' } + return $true + } catch { + $missing = $_.CategoryInfo.Category -eq 'ObjectNotFound' -or + $_.Exception -is [IO.FileNotFoundException] -or $_.Exception -is [IO.DirectoryNotFoundException] + if ($missing) { + Assert-CcodOfficialDraftMissingPathAncestorsSafe -Path $Path -GetItem $GetItem -ErrorId $ErrorId + return $false + } + Throw-CcodOfficialDraftError $ErrorId 'Installed lifecycle path could not be inspected safely.' $Path + } +} + +function Assert-CcodOfficialDraftRegularEvidenceFile { + param([Parameter(Mandatory)][string]$Path) + try { + if ([string]::IsNullOrWhiteSpace($Path) -or -not [IO.Path]::IsPathRooted($Path)) { throw 'absolute' } + $full = [IO.Path]::GetFullPath($Path) + $root = [IO.Path]::GetPathRoot($full) + if ([string]::IsNullOrWhiteSpace($root) -or $full.TrimEnd('\') -cne $Path.TrimEnd('\')) { throw 'canonical' } + $item = Get-Item -LiteralPath $full -Force -ErrorAction Stop + if ($item -isnot [IO.FileInfo] -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + [int64]$item.Length -lt 1 -or [int64]$item.Length -gt 2MB) { throw 'evidence file' } + $parent = [IO.Directory]::GetParent($full) + if ($null -eq $parent) { throw 'parent' } + [void](Assert-CcodOfficialDraftSafeDirectory -Path $parent.FullName -ErrorId 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID') + return $full + } catch { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID' 'Manual evidence must be a bounded regular file.' $Path + } +} + +function Close-CcodOfficialDraftEvidenceReadLease { + param($Lease) + if ($null -eq $Lease -or $Lease.Closed) { return } + $failed = $false + for ($index = $Lease.Entries.Count - 1; $index -ge 0; $index--) { + $entry = $Lease.Entries[$index] + try { + if ($null -ne $entry.Stream) { $entry.Stream.Dispose() } else { $entry.Handle.Dispose() } + } catch { $failed = $true } + } + $Lease.Closed = $true + if ($failed) { Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID' 'Manual evidence read authority could not be released.' $null } +} + +function Assert-CcodOfficialDraftEvidenceReadLease { + param([Parameter(Mandatory)]$Lease) + try { + if ($Lease.Closed) { throw 'closed read authority' } + foreach ($entry in $Lease.Entries) { + $current = [CcodReleaseFileAuthorityV1]::Identity($entry.Handle) + if ([CcodReleaseFileAuthorityV1]::IsReparse($current) -or + [CcodReleaseFileAuthorityV1]::IsDirectory($current) -ne $entry.Directory -or + $current.Volume -ne $entry.Identity.Volume -or $current.FileId -ne $entry.Identity.FileId -or + -not $current.FinalPath.TrimEnd('\').Equals($entry.Path.TrimEnd('\'),[StringComparison]::OrdinalIgnoreCase)) { throw 'read identity drift' } + if (-not $entry.Directory) { + if ($current.Links -ne 1 -or $current.Attributes -ne $entry.Identity.Attributes -or $entry.Stream.Length -ne $entry.Length) { throw 'read file drift' } + $validStreams = & $Lease.AssetModule { param($Identity) Test-CcodReleaseAuthorityStreams -Identity $Identity -AllowZone } $current + $currentHash = & $Lease.AssetModule { param($Stream) Get-CcodReleaseAuthorityStreamSha256 -Stream $Stream } $entry.Stream + if (-not $validStreams -or $currentHash -cne $entry.Sha256) { throw 'read bytes drift' } + } + } + } catch { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID' 'Held manual evidence changed or became unavailable.' $null + } +} + +function Open-CcodOfficialDraftEvidenceReadLease { + param([Parameter(Mandatory)][string[]]$Paths) + $lease = [pscustomobject]@{ Entries = [Collections.Generic.List[object]]::new(); AssetModule = (Get-CcodOfficialDraftAssetModule); Closed = $false } + try { + if ($Paths.Count -eq 0) { throw 'evidence paths missing' } + $directories = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + $files = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($path in $Paths) { + $full = Assert-CcodOfficialDraftRegularEvidenceFile -Path $path + if (-not $files.Add($full)) { continue } + $parents = [Collections.Generic.List[string]]::new() + for ($parent = [IO.Directory]::GetParent($full); $null -ne $parent; $parent = $parent.Parent) { $parents.Insert(0,$parent.FullName) } + if ($parents.Count -eq 0 -or -not $parents[0].TrimEnd('\').Equals([IO.Path]::GetPathRoot($full).TrimEnd('\'),[StringComparison]::OrdinalIgnoreCase)) { throw 'evidence ancestry' } + foreach ($directory in $parents) { + if (-not $directories.Add($directory)) { continue } + $handle = [CcodReleaseFileAuthorityV1]::OpenDirectory($directory,$true) + $entry = [pscustomobject]@{ Directory = $true; Path = $directory; Handle = $handle; Stream = $null; Identity = $null; Length = 0; Sha256 = $null } + $lease.Entries.Add($entry) + $entry.Identity = [CcodReleaseFileAuthorityV1]::Identity($handle) + if ([CcodReleaseFileAuthorityV1]::IsReparse($entry.Identity) -or -not [CcodReleaseFileAuthorityV1]::IsDirectory($entry.Identity) -or + -not $entry.Identity.FinalPath.TrimEnd('\').Equals($directory.TrimEnd('\'),[StringComparison]::OrdinalIgnoreCase)) { throw 'unsafe evidence ancestor' } + } + $stream = [CcodReleaseFileAuthorityV1]::OpenReadFile($full,$false) + $entry = [pscustomobject]@{ Directory = $false; Path = $full; Handle = $stream.SafeFileHandle; Stream = $stream; Identity = $null; Length = 0; Sha256 = $null } + $lease.Entries.Add($entry) + $entry.Identity = [CcodReleaseFileAuthorityV1]::Identity($entry.Handle) + if ([CcodReleaseFileAuthorityV1]::IsReparse($entry.Identity) -or [CcodReleaseFileAuthorityV1]::IsDirectory($entry.Identity) -or + $entry.Identity.Links -ne 1 -or $stream.Length -lt 1 -or $stream.Length -gt 2MB -or + -not $entry.Identity.FinalPath.Equals($full,[StringComparison]::OrdinalIgnoreCase)) { throw 'unsafe evidence file' } + $entry.Length = $stream.Length + $entry.Sha256 = & $lease.AssetModule { param($Stream) Get-CcodReleaseAuthorityStreamSha256 -Stream $Stream } $stream + } + Assert-CcodOfficialDraftEvidenceReadLease -Lease $lease + return $lease + } catch { + Close-CcodOfficialDraftEvidenceReadLease -Lease $lease + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID' 'Immutable manual evidence read authority could not be acquired.' $null + } +} + +function Read-CcodOfficialDraftJsonStringToken { + param([Parameter(Mandatory)][string]$Raw,[Parameter(Mandatory)][ref]$Index) + if ($Index.Value -ge $Raw.Length -or $Raw[$Index.Value] -cne [char]34) { throw 'json string' } + $start = $Index.Value + $Index.Value++ + $escaped = $false + while ($Index.Value -lt $Raw.Length) { + $character = $Raw[$Index.Value] + $Index.Value++ + if ($escaped) { $escaped = $false; continue } + if ($character -eq [char]92) { $escaped = $true; continue } + if ($character -eq [char]34) { + $encoded = ([string][char]34) + $Raw.Substring($start + 1, $Index.Value - $start - 2) + ([string][char]34) + return [string]($encoded | ConvertFrom-Json -ErrorAction Stop) + } + } + throw 'json string' +} + +function Skip-CcodOfficialDraftJsonWhitespace { + param([Parameter(Mandatory)][string]$Raw,[Parameter(Mandatory)][ref]$Index) + while ($Index.Value -lt $Raw.Length -and [char]::IsWhiteSpace($Raw[$Index.Value])) { $Index.Value++ } +} + +function Skip-CcodOfficialDraftJsonValue { + param([Parameter(Mandatory)][string]$Raw,[Parameter(Mandatory)][ref]$Index) + Skip-CcodOfficialDraftJsonWhitespace -Raw $Raw -Index $Index + if ($Index.Value -ge $Raw.Length) { throw 'json value' } + switch ([string]$Raw[$Index.Value]) { + '{' { Skip-CcodOfficialDraftJsonObject -Raw $Raw -Index $Index; return } + '[' { Skip-CcodOfficialDraftJsonArray -Raw $Raw -Index $Index; return } + '"' { [void](Read-CcodOfficialDraftJsonStringToken -Raw $Raw -Index $Index); return } + default { + $start = $Index.Value + while ($Index.Value -lt $Raw.Length -and $Raw[$Index.Value] -notin @([char]44,[char]93,[char]125) -and -not [char]::IsWhiteSpace($Raw[$Index.Value])) { $Index.Value++ } + if ($Index.Value -eq $start) { throw 'json scalar' } + } + } +} + +function Skip-CcodOfficialDraftJsonArray { + param([Parameter(Mandatory)][string]$Raw,[Parameter(Mandatory)][ref]$Index) + if ($Raw[$Index.Value] -cne [char]91) { throw 'json array' } + $Index.Value++ + Skip-CcodOfficialDraftJsonWhitespace -Raw $Raw -Index $Index + if ($Index.Value -lt $Raw.Length -and $Raw[$Index.Value] -ceq [char]93) { $Index.Value++; return } + while ($true) { + Skip-CcodOfficialDraftJsonValue -Raw $Raw -Index $Index + Skip-CcodOfficialDraftJsonWhitespace -Raw $Raw -Index $Index + if ($Index.Value -ge $Raw.Length) { throw 'json array' } + if ($Raw[$Index.Value] -ceq [char]93) { $Index.Value++; return } + if ($Raw[$Index.Value] -cne [char]44) { throw 'json array separator' } + $Index.Value++ + } +} + +function Skip-CcodOfficialDraftJsonObject { + param([Parameter(Mandatory)][string]$Raw,[Parameter(Mandatory)][ref]$Index) + if ($Raw[$Index.Value] -cne [char]123) { throw 'json object' } + $Index.Value++ + $names = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + Skip-CcodOfficialDraftJsonWhitespace -Raw $Raw -Index $Index + if ($Index.Value -lt $Raw.Length -and $Raw[$Index.Value] -ceq [char]125) { $Index.Value++; return } + while ($true) { + Skip-CcodOfficialDraftJsonWhitespace -Raw $Raw -Index $Index + $name = Read-CcodOfficialDraftJsonStringToken -Raw $Raw -Index $Index + if (-not $names.Add($name)) { throw 'duplicate json property' } + Skip-CcodOfficialDraftJsonWhitespace -Raw $Raw -Index $Index + if ($Index.Value -ge $Raw.Length -or $Raw[$Index.Value] -cne [char]58) { throw 'json object separator' } + $Index.Value++ + Skip-CcodOfficialDraftJsonValue -Raw $Raw -Index $Index + Skip-CcodOfficialDraftJsonWhitespace -Raw $Raw -Index $Index + if ($Index.Value -ge $Raw.Length) { throw 'json object' } + if ($Raw[$Index.Value] -ceq [char]125) { $Index.Value++; return } + if ($Raw[$Index.Value] -cne [char]44) { throw 'json object separator' } + $Index.Value++ + } +} + +function Assert-CcodOfficialDraftJsonNoDuplicateProperties { + param([Parameter(Mandatory)][string]$Raw) + try { + $index = 0 + Skip-CcodOfficialDraftJsonValue -Raw $Raw -Index ([ref]$index) + Skip-CcodOfficialDraftJsonWhitespace -Raw $Raw -Index ([ref]$index) + if ($index -ne $Raw.Length) { throw 'json trailing data' } + } catch { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID' 'A JSONL evidence record is malformed or contains duplicate properties.' $null + } +} + +function Get-CcodOfficialDraftJsonLines { + param( + [Parameter(Mandatory)][string]$Path, + [AllowNull()][Nullable[DateTimeOffset]]$NotBefore + ) + $full = Assert-CcodOfficialDraftRegularEvidenceFile -Path $Path + try { + $text = [IO.File]::ReadAllText($full, [Text.UTF8Encoding]::new($false, $true)) + $records = [Collections.Generic.List[object]]::new() + $lines = @($text -split "`r?`n") + for ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex++) { + $line = $lines[$lineIndex] + if ([string]::IsNullOrWhiteSpace($line)) { + if ($lineIndex -eq ($lines.Count - 1) -and $line -ceq '' -and ($text.EndsWith("`n") -or $text.EndsWith("`r"))) { continue } + throw 'json blank line' + } + Assert-CcodOfficialDraftJsonNoDuplicateProperties -Raw $line + $value = $line | ConvertFrom-Json -ErrorAction Stop + if ($value -isnot [pscustomobject]) { throw 'json line object' } + $timestampProperties = @($value.PSObject.Properties | Where-Object { $_.Name -ceq 'timestampUtc' }) + if ($timestampProperties.Count -ne 1 -or $timestampProperties[0].Value -isnot [string] -or -not (Test-CcodOfficialDraftCanonicalUtc $timestampProperties[0].Value)) { throw 'timestamp field' } + if ($null -ne $NotBefore) { + $timestamp = [DateTimeOffset]::ParseExact([string]$timestampProperties[0].Value, 'o', [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::AssumeUniversal) + if ($timestamp -lt [DateTimeOffset]$NotBefore) { continue } + } + $records.Add($value) + } + return @($records) + } catch { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID' 'Manual evidence log is not valid UTF-8 JSONL.' $full + } +} + +function Get-CcodOfficialDraftTrayActionProof { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$Operation, + [AllowNull()][Nullable[DateTimeOffset]]$NotBefore + ) + $allowedCommands = @{ + About = @('ShowAbout') + Language = @('SetLanguageSystem','SetLanguageChinese','SetLanguageEnglish') + OpenLogs = @('OpenLogs') + Repair = @('CheckAndRepair') + } + $matches = [Collections.Generic.List[object]]::new() + foreach ($record in @(Get-CcodOfficialDraftJsonLines -Path $Path -NotBefore $NotBefore)) { + if ([string]$record.stage -cne 'TrayAction') { continue } + $fields = @($record.PSObject.Properties.Name) + if (($fields -join ',') -cne 'schemaVersion,timestampUtc,component,stage,code,outcome,command,revision,status' -or + -not (Test-CcodOfficialDraftIntegerValue $record.schemaVersion) -or [int64]$record.schemaVersion -ne 1 -or + $record.timestampUtc -isnot [string] -or $record.component -isnot [string] -or $record.stage -isnot [string] -or $record.stage -cne 'TrayAction' -or $record.component -cne 'Supervisor' -or + $record.code -isnot [string] -or $record.code -cne 'CCOD_TRAY_ACTION_COMPLETED' -or + $record.outcome -isnot [string] -or $record.outcome -cne 'Completed' -or + $record.command -isnot [string] -or -not $allowedCommands.ContainsKey($Operation) -or $allowedCommands[$Operation] -cnotcontains [string]$record.command -or + -not (Test-CcodOfficialDraftIntegerValue $record.revision) -or [uint64]$record.revision -eq 0 -or + $record.status -isnot [string] -or $record.status -cne 'Completed') { Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID' 'Tray action proof is malformed or has invalid field types.' $Path } + $matches.Add($record) + } + if ($matches.Count -ne 1) { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_MANUAL_OPERATION_UNPROVEN' 'The requested tray operation has no unique verified terminal result.' $Operation + } + return $matches[0] +} + +function Get-CcodOfficialDraftRemoteProof { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$ExpectedChallenge, + [Parameter(Mandatory)][Nullable[DateTimeOffset]]$NotBefore, + [Parameter(Mandatory)][string]$ExpectedVersion, + [Parameter(Mandatory)][string]$ExpectedRuntimeId, + [Parameter(Mandatory)][UInt64]$ExpectedGeneration, + [Parameter(Mandatory)][string]$ExpectedManifestSha256 + ) + $records = @(Get-CcodOfficialDraftJsonLines -Path $Path -NotBefore $NotBefore) + if ($records.Count -ne 1) { Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_REMOTE_UNPROVEN' 'Second-device proof must contain exactly one structured record.' $Path } + $record = $records[0] + $expected = @('schemaVersion','timestampUtc','kind','operation','deviceRole','challenge','candidateVersion','runtimeId','runtimeGeneration','runtimeManifestSha256','attestation','connection','control','outcome','code') + if (($record.PSObject.Properties.Name -join ',') -cne ($expected -join ',') -or + -not (Test-CcodOfficialDraftIntegerValue $record.schemaVersion) -or [int64]$record.schemaVersion -ne 1 -or + $record.timestampUtc -isnot [string] -or + $record.kind -isnot [string] -or $record.kind -cne 'remote-control-manual-proof' -or + $record.operation -isnot [string] -or $record.operation -cne 'SecondDeviceControl' -or + $record.deviceRole -isnot [string] -or $record.deviceRole -cne 'SecondDevice' -or + $record.challenge -isnot [string] -or $record.challenge -cne $ExpectedChallenge -or + $record.candidateVersion -isnot [string] -or $record.candidateVersion -cne $ExpectedVersion -or + $record.runtimeId -isnot [string] -or $record.runtimeId -cne $ExpectedRuntimeId -or + -not (Test-CcodOfficialDraftPositiveInteger $record.runtimeGeneration) -or [UInt64]$record.runtimeGeneration -ne $ExpectedGeneration -or + $record.runtimeManifestSha256 -isnot [string] -or $record.runtimeManifestSha256 -cne $ExpectedManifestSha256 -or + $record.attestation -isnot [string] -or $record.attestation -cne 'HumanReviewedStructuredAttestation' -or + $record.connection -isnot [string] -or $record.connection -cne 'Connected' -or + $record.control -isnot [string] -or $record.control -cne 'Completed' -or + $record.outcome -isnot [string] -or $record.outcome -cne 'Completed' -or + $record.code -isnot [string] -or $record.code -cne 'CCOD_REMOTE_ACTION_COMPLETED') { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_REMOTE_UNPROVEN' 'Second-device proof is malformed or does not prove a completed connection and control.' $Path + } + return $record +} + +function Get-CcodOfficialDraftTrayHostReadyProof { + param( + [Parameter(Mandatory)][string]$InstallRoot, + [Parameter(Mandatory)][string]$RuntimeId, + [Parameter(Mandatory)]$TrayHosts + ) + $logPath = Join-Path ([IO.Path]::GetFullPath($InstallRoot)) 'logs\supervisor.log' + if ($TrayHosts.Count -ne 1) { return $null } + $host = $TrayHosts[0] + if (-not (Test-CcodOfficialDraftExactProperties -Value $host -Expected @('pid','creationTimeUtc')) -or + -not (Test-CcodOfficialDraftInt32 $host.pid) -or [int]$host.pid -le 0 -or + $host.creationTimeUtc -isnot [string] -or -not (Test-CcodOfficialDraftCanonicalUtc $host.creationTimeUtc)) { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID' 'TrayHost identity is malformed or not canonical.' $logPath + } + $matches = [Collections.Generic.List[object]]::new() + foreach ($record in @(Get-CcodOfficialDraftJsonLines -Path $logPath)) { + if ([string]$record.stage -cne 'TrayHostReady') { continue } + $fields = @($record.PSObject.Properties.Name) + if (($fields -join ',') -cne 'schemaVersion,timestampUtc,component,stage,code,outcome,runtimeId,hostPid,hostCreationTimeUtc,protocolMajor,capabilities' -or + -not (Test-CcodOfficialDraftIntegerValue $record.schemaVersion) -or [int64]$record.schemaVersion -ne 1 -or + $record.timestampUtc -isnot [string] -or $record.component -isnot [string] -or $record.stage -isnot [string] -or $record.stage -cne 'TrayHostReady' -or $record.component -cne 'Supervisor' -or + $record.code -isnot [string] -or $record.code -cne 'CCOD_TRAYHOST_READY' -or + $record.outcome -isnot [string] -or $record.outcome -cne 'Completed' -or + $record.runtimeId -isnot [string] -or [string]::IsNullOrWhiteSpace($record.runtimeId) -or + -not (Test-CcodOfficialDraftInt32 $record.hostPid) -or [int]$record.hostPid -le 0 -or + $record.hostCreationTimeUtc -isnot [string] -or -not (Test-CcodOfficialDraftCanonicalUtc $record.hostCreationTimeUtc) -or + -not (Test-CcodOfficialDraftIntegerValue $record.protocolMajor) -or [int64]$record.protocolMajor -ne 2 -or + -not (Test-CcodOfficialDraftPositiveInteger $record.capabilities) -or [uint64]$record.capabilities -eq 0) { Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID' 'TrayHost ready proof is malformed or has invalid field types.' $logPath } + if ($record.runtimeId -cne $RuntimeId) { continue } + $matches.Add($record) + } + $identityMatches = [Collections.Generic.List[object]]::new() + foreach ($record in $matches) { + if ([int]$record.hostPid -eq [int]$host.pid -and $record.hostCreationTimeUtc -ceq $host.creationTimeUtc) { $identityMatches.Add($record) } + } + if ($identityMatches.Count -ne 1) { return $null } + return $identityMatches[0] +} + +function Assert-CcodOfficialDraftDebugEndpointOwnership { + param([AllowNull()]$Endpoints,[Parameter(Mandatory)][int[]]$ExpectedPorts,[Parameter(Mandatory)][int]$ExpectedPid,[Parameter(Mandatory)][string]$ExpectedCreationTimeUtc) + try { + if ($ExpectedPid -le 0 -or $null -eq $Endpoints) { throw 'debug endpoint owner' } + $expected = @($ExpectedPorts | Sort-Object -Unique) + if ($expected.Count -eq 0 -or @($Endpoints).Count -ne $expected.Count) { throw 'debug endpoint count' } + $seen = [Collections.Generic.HashSet[int]]::new() + foreach ($endpoint in @($Endpoints)) { + if (-not (Test-CcodOfficialDraftExactProperties -Value $endpoint -Expected @('localAddress','localPort','owningProcess','owningProcessCreationTimeUtc')) -or + $endpoint.localAddress -isnot [string] -or $endpoint.localAddress -cne '127.0.0.1' -or + -not (Test-CcodOfficialDraftPositiveInteger $endpoint.localPort) -or [int]$endpoint.localPort -gt 65535 -or + -not (Test-CcodOfficialDraftPositiveInteger $endpoint.owningProcess) -or [int]$endpoint.owningProcess -ne $ExpectedPid -or + $endpoint.owningProcessCreationTimeUtc -isnot [string] -or [string]::IsNullOrWhiteSpace($endpoint.owningProcessCreationTimeUtc) -or $endpoint.owningProcessCreationTimeUtc -cne $ExpectedCreationTimeUtc -or + -not $seen.Add([int]$endpoint.localPort)) { throw 'debug endpoint owner' } + } + if (@($seen | Where-Object { $expected -contains $_ }).Count -ne $expected.Count) { throw 'debug endpoint ports' } + return $true + } catch { + if ($_.FullyQualifiedErrorId -like 'CCOD_ACCEPTANCE_*') { throw } + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_OBSERVATION_UNAVAILABLE' 'Debug endpoints are not bound to the verified process identity.' $null + } +} + +function Get-CcodOfficialDraftDefaultAdapters { + $debugOwnershipFunction = ${function:Assert-CcodOfficialDraftDebugEndpointOwnership} + $manualTerminalStates = $script:CcodOfficialDraftManualTerminalStates + $manualResultCodes = $script:CcodOfficialDraftManualResultCodes + $defaults = @{} + $assetModule = Get-CcodOfficialDraftAssetModule + $defenderModule = Get-CcodOfficialDraftDefenderModule + $moduleInfo = $ExecutionContext.SessionState.Module + $openEvidenceReadLease = { param($Paths) & $moduleInfo { param($EvidencePaths) Open-CcodOfficialDraftEvidenceReadLease -Paths $EvidencePaths } $Paths }.GetNewClosure() + $checkEvidenceReadLease = { param($Lease) & $moduleInfo { param($EvidenceLease) Assert-CcodOfficialDraftEvidenceReadLease -Lease $EvidenceLease } $Lease }.GetNewClosure() + $closeEvidenceReadLease = { param($Lease) & $moduleInfo { param($EvidenceLease) Close-CcodOfficialDraftEvidenceReadLease -Lease $EvidenceLease } $Lease }.GetNewClosure() + $normalizeObservedIdentities = { + param($Records,$Kind) + & $moduleInfo { + param($Values,$Label) + if ($Values -isnot [array]) { throw "$Label observation array" } + foreach ($value in $Values) { + if (Test-CcodOfficialDraftExactProperties -Value $value -Expected @('Pid','CreationTimeUtc')) { + $canonical = [pscustomobject][ordered]@{ pid = $value.Pid; creationTimeUtc = $value.CreationTimeUtc } + } elseif (Test-CcodOfficialDraftExactProperties -Value $value -Expected @('pid','creationTimeUtc')) { + $canonical = $value + } else { throw "$Label observation identity" } + ConvertTo-CcodOfficialDraftSafeProcessIdentity -Value $canonical -Label $Label + } + } $Records $Kind + }.GetNewClosure() + $trayActionProof = { + param($Path,$Operation,$NotBefore) + & $moduleInfo { param($ProofPath,$ProofOperation,$ProofNotBefore) Get-CcodOfficialDraftTrayActionProof -Path $ProofPath -Operation $ProofOperation -NotBefore $ProofNotBefore } $Path $Operation $NotBefore + }.GetNewClosure() + $remoteProof = { + param($Path,$ExpectedChallenge,$NotBefore,$ExpectedVersion,$ExpectedRuntimeId,$ExpectedGeneration,$ExpectedManifestSha256) + & $moduleInfo { param($ProofPath,$Challenge,$ProofNotBefore,$Version,$RuntimeId,$Generation,$ManifestHash) Get-CcodOfficialDraftRemoteProof -Path $ProofPath -ExpectedChallenge $Challenge -NotBefore $ProofNotBefore -ExpectedVersion $Version -ExpectedRuntimeId $RuntimeId -ExpectedGeneration $Generation -ExpectedManifestSha256 $ManifestHash } $Path $ExpectedChallenge $NotBefore $ExpectedVersion $ExpectedRuntimeId $ExpectedGeneration $ExpectedManifestSha256 + }.GetNewClosure() + $trayReadyProofCommand = { + param($Root,$RuntimeId,$Hosts) + & $moduleInfo { param($InstallRoot,$ExpectedRuntimeId,$TrayHosts) Get-CcodOfficialDraftTrayHostReadyProof -InstallRoot $InstallRoot -RuntimeId $ExpectedRuntimeId -TrayHosts $TrayHosts } $Root $RuntimeId $Hosts + }.GetNewClosure() + $integrationModule = Get-CcodOfficialDraftIntegrationModule + $localAppData = [Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData) + $throwAcceptanceError = { + param($Id, $Message, $Target) + throw [Management.Automation.ErrorRecord]::new( + [InvalidOperationException]::new($Message), + $Id, + [Management.Automation.ErrorCategory]::InvalidOperation, + $Target + ) + }.GetNewClosure() + if ([string]::IsNullOrWhiteSpace($localAppData)) { + & $throwAcceptanceError 'CCOD_ACCEPTANCE_OBSERVATION_UNAVAILABLE' 'Local application data is unavailable for installed-lifecycle observation.' $null + } + $configuredInstallRoot = [string]$script:CcodOfficialDraftInstallRoot + if ([string]::IsNullOrWhiteSpace($configuredInstallRoot)) { + $configuredInstallRoot = Join-Path $localAppData 'CodexControlOtherDevices' + } + $pathProbe = { param($Path) Get-Item -LiteralPath $Path -Force -ErrorAction Stop }.GetNewClosure() + $pathState = { + param($Path,$ExpectDirectory,$Probe,$ErrorId) + & $moduleInfo { + param($ObservedPath,$ExpectedDirectory,$GetItem,$AcceptanceErrorId) + Get-CcodOfficialDraftOptionalPathState -Path $ObservedPath -ExpectDirectory $ExpectedDirectory -GetItem $GetItem -ErrorId $AcceptanceErrorId + } $Path $ExpectDirectory $Probe $ErrorId + }.GetNewClosure() + $integerValue = { + param($Value) + & $moduleInfo { param($ObservedValue) Test-CcodOfficialDraftIntegerValue -Value $ObservedValue } $Value + }.GetNewClosure() + $fileHash = { + param($Path) + & $moduleInfo { param($ObservedPath) Get-CcodOfficialDraftFileSha256 -Path $ObservedPath } $Path + }.GetNewClosure() + $hashValue = { + param($Value) + & $moduleInfo { param($ObservedValue) Test-CcodOfficialDraftHash $ObservedValue } $Value + }.GetNewClosure() + try { + if (-not [IO.Path]::IsPathRooted($configuredInstallRoot)) { throw 'install root absolute' } + $installRoot = [IO.Path]::GetFullPath($configuredInstallRoot) + if ($installRoot -cne $configuredInstallRoot.TrimEnd('\')) { throw 'install root canonical' } + $installRootPresent = & $pathState $installRoot $true $pathProbe 'CCOD_ACCEPTANCE_OBSERVATION_UNAVAILABLE' + if ($installRootPresent) { + [void](Assert-CcodOfficialDraftSafeDirectory -Path $installRoot -ErrorId 'CCOD_ACCEPTANCE_OBSERVATION_UNAVAILABLE') + } + } catch { + if ($_.FullyQualifiedErrorId -like 'CCOD_ACCEPTANCE_*') { throw } + & $throwAcceptanceError 'CCOD_ACCEPTANCE_OBSERVATION_UNAVAILABLE' 'The installed lifecycle root is unavailable or unsafe.' $null + } + $debugPorts = [Collections.Generic.List[int]]::new() + $defaults.ValidateAssetSet = { + param($Path, $Version) + & $assetModule { param($AssetDirectory,$ExpectedVersion) Test-CcodExactReleaseAssetSet -AssetDirectory $AssetDirectory -Version $ExpectedVersion } $Path $Version + }.GetNewClosure() + $defaults.ValidatePreviousSetup = { + param($Path, $Version) + try { + & $moduleInfo {param($Directory,$ExpectedVersion)Get-CcodOfficialDraftPreviousSetupContract -Directory $Directory -Version $ExpectedVersion} $Path $Version + } catch { + & $throwAcceptanceError 'CCOD_ACCEPTANCE_PREVIOUS_ASSET_INVALID' 'The previous public v2.5.21 Setup is not manifest-bound.' $null + } + }.GetNewClosure() + $defaults.GetDraftIdentity = { + param($Path, $Candidate, $ExpectedDraftId) + if ([string]::IsNullOrWhiteSpace([string]$ExpectedDraftId)) { + & $throwAcceptanceError 'CCOD_ACCEPTANCE_DRAFT_IDENTITY_INVALID' 'The official draft database identifier is required.' $null + } + [pscustomobject][ordered]@{ tag = 'v2.5.22'; id = [string]$ExpectedDraftId } + }.GetNewClosure() + $defaults.InvokeDefenderCheck = { + param($Path, $AssetType, $Origin, $Version, $GitCommit, $EvidencePath) + $names = @(& $assetModule { param($ExpectedVersion) Get-CcodExpectedReleaseAssetNames -Version $ExpectedVersion } $Version) + $assetIndex = if ($AssetType -ceq 'Setup') { 5 } else { 0 } + $checksumIndex = if ($AssetType -ceq 'Setup') { 6 } else { 1 } + $manifestIndex = if ($AssetType -ceq 'Setup') { 10 } else { 4 } + $candidatePath = Join-Path $Path $names[$assetIndex] + $checksumPath = Join-Path $Path $names[$checksumIndex] + $manifestPath = Join-Path $Path $names[$manifestIndex] + & $defenderModule { + param($CandidatePath,$ChecksumPath,$ManifestPath,$ScanOrigin,$ExpectedVersion,$ExpectedGitCommit,$ReceiptPath) + Invoke-CcodReleaseDefenderCheck -CandidatePath $CandidatePath -ChecksumPath $ChecksumPath -ManifestPath $ManifestPath -Origin $ScanOrigin -ExpectedVersion $ExpectedVersion -ExpectedGitCommit $ExpectedGitCommit -EvidencePath $ReceiptPath + } $candidatePath $checksumPath $manifestPath $Origin $Version $GitCommit $EvidencePath + }.GetNewClosure() + $defaults.GetUtcNow = { [datetime]::UtcNow }.GetNewClosure() + $defaults.CaptureFacts = { + param($Context, $Point) + try { + $expectedVersion = if ($Context.PSObject.Properties['expectedVersion'] -and $Context.expectedVersion -is [string]) { [string]$Context.expectedVersion } else { $null } + $observationVersion = $expectedVersion + if ($Context.phase -ceq 'LegacyUpgrade' -and $Point -ceq 'Before' -and $Context.PSObject.Properties['previousSetup'] -and $null -ne $Context.previousSetup -and $Context.previousSetup.version -is [string]) { $observationVersion = [string]$Context.previousSetup.version } + $values = @(& $integrationModule { param($Root,$Version) Get-CcodInstalledLifecycleFacts -InstallRoot $Root -ExpectedVersion $Version } $installRoot $observationVersion) + if ($values.Count -ne 1 -or $null -eq $values[0]) { throw 'facts' } + $raw = $values[0] + foreach ($name in @('installRootPresent','appPresent','runtimeRootPresent','activePointerPresent','activeRuntimeId','activeGeneration','runtimeManifestSha256','supervisor','trayHost','codex','taskState','statusPhase','statusRuntimeId','statusCodex','transitionStage','lifecycleReceipt','aboutVersion','deviceKeyPresent','deviceKeySha256','shortcuts','debugPorts','debugEndpoints')) { + if ($null -eq $raw.PSObject.Properties[$name]) { throw 'facts shape' } + } + $get = { + param($Name) + $property = $raw.PSObject.Properties[$Name] + if ($null -eq $property) { return $null } + return ,$property.Value + }.GetNewClosure() + $supervisor = @(& $normalizeObservedIdentities $raw.PSObject.Properties['supervisor'].Value 'supervisor') + $trayHost = @(& $normalizeObservedIdentities $raw.PSObject.Properties['trayHost'].Value 'tray host') + $activeRuntimeId = & $get 'activeRuntimeId' + $activeGeneration = & $get 'activeGeneration' + $receipt = & $get 'lifecycleReceipt' + $taskState = & $get 'taskState' + $appPresent = & $get 'appPresent' + $aboutVersion = & $get 'aboutVersion' + $statusPhase = & $get 'statusPhase' + $statusRuntimeId = & $get 'statusRuntimeId' + $statusCodex = & $get 'statusCodex' + $transitionStage = & $get 'transitionStage' + if ($transitionStage -isnot [string] -or [string]::IsNullOrWhiteSpace($transitionStage)) { throw 'transition stage' } + $codex = @(& $normalizeObservedIdentities $raw.PSObject.Properties['codex'].Value 'codex') + if ($null -ne $raw.PSObject.Properties['codexCount']) { + $rawCodexCount = & $get 'codexCount' + if (-not (& $integerValue $rawCodexCount) -or [int64]$rawCodexCount -ne [int64]$codex.Count) { throw 'codex count' } + } + $deviceKeyPresent = & $get 'deviceKeyPresent' + $deviceKeySha256 = & $get 'deviceKeySha256' + $shortcutsValue = & $get 'shortcuts' + $shortcuts = if ($null -eq $shortcutsValue) { + [pscustomobject][ordered]@{ startMenu = $false; desktop = $false } + } else { $shortcutsValue } + $installRootPresent = [bool](& $pathState $installRoot $true $pathProbe 'CCOD_ACCEPTANCE_OBSERVATION_UNAVAILABLE') + $runtimeRootPresent = & $get 'runtimeRootPresent' + $activePointerPresent = & $get 'activePointerPresent' + if ($runtimeRootPresent -isnot [bool] -or $activePointerPresent -isnot [bool]) { throw 'selector facts' } + $terminalValid = $null -ne $receipt -and + $receipt.kind -is [string] -and $receipt.kind -ceq 'RestartAndRepair' -and + $receipt.origin -is [string] -and $receipt.origin -ceq 'Installer' -and + $receipt.runtimeId -is [string] -and $receipt.runtimeGeneration -is [ValueType] -and + $receipt.phase -is [string] -and $receipt.phase -ceq 'Completed' + if ($null -ne $activeGeneration -and -not [bool](& $integerValue $activeGeneration)) { throw 'active generation type' } + $generationValid = $false + $generation = [UInt64]0 + try { + if ($null -ne $activeGeneration -and [bool](& $integerValue $activeGeneration)) { + $generation = [UInt64]$activeGeneration + $generationValid = $generation -gt 0 + } + } catch { $generationValid = $false } + $runtimeValid = $activeRuntimeId -is [string] -and $activeRuntimeId -cmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,95}\z' + $manifestHash = & $get 'runtimeManifestSha256' + $expectedVersionProperties = @($Context.PSObject.Properties | Where-Object { $_.Name -ceq 'expectedVersion' }) + $expectedVersion = if ($expectedVersionProperties.Count -eq 1 -and $expectedVersionProperties[0].Value -is [string]) { [string]$expectedVersionProperties[0].Value } else { $null } + $requiresCandidateReady = $Context.phase -in @('FreshInstall','PreReboot','PostReboot','ReadyForManualEvidence','TrayEvidence','RemoteEvidence') -or + ($Context.phase -ceq 'LegacyUpgrade' -and ($Point -ceq 'Before' -or ($Point -ceq 'After' -and $aboutVersion -ceq $expectedVersion))) + $readinessVersion = if ($Context.phase -ceq 'LegacyUpgrade' -and $Point -ceq 'Before') { $observationVersion } else { $expectedVersion } + $observedDebugValue = & $get 'debugEndpoints' + $expectedDebugValue = & $get 'debugPorts' + $debugEndpoints = @() + if ($null -ne $observedDebugValue) { $debugEndpoints = @($observedDebugValue) } + $expectedPorts = @() + if ($null -ne $expectedDebugValue) { $expectedPorts = @($expectedDebugValue) } + $debugOwnershipValid = $true + $trayReadyProof = $null + $collectorAuthenticated = $false + if ($requiresCandidateReady) { + $collectorAuthenticated = & $get 'trayAuthenticated' + if ($collectorAuthenticated -isnot [bool]) { throw 'tray authentication observation' } + $ownerPid = $null + if ($null -ne $statusCodex -and $statusCodex.PSObject.Properties['pid']) { $ownerPid = $statusCodex.pid } + elseif ($codex.Count -eq 1) { $ownerPid = $codex[0].pid } + if ($null -eq $ownerPid -or $ownerPid -isnot [ValueType] -or $ownerPid -is [bool] -or $expectedPorts.Count -ne 2 -or @($expectedPorts | Sort-Object -Unique).Count -ne 2) { throw 'debug endpoint readiness' } + [void](& $debugOwnershipFunction -Endpoints $debugEndpoints -ExpectedPorts $expectedPorts -ExpectedPid ([int]$ownerPid) -ExpectedCreationTimeUtc ([string]$codex[0].creationTimeUtc)) + } + if ($requiresCandidateReady -and $trayHost.Count -eq 1 -and $activeRuntimeId -is [string]) { + if($Context.phase-ceq'LegacyUpgrade'-and$Point-ceq'Before'-and$readinessVersion-ceq'2.5.21'){ + if($activeRuntimeId-cnotmatch'^2\.5\.21-[0-9a-f]{16}\z'){throw 'legacy runtime profile'} + $legacyProof=@(&$integrationModule {param($Root,$RuntimeId,$Parent,$HostIdentity)Get-CcodInstalledLifecycleLegacyTrayReadyProof -InstallRoot $Root -RuntimeId $RuntimeId -Supervisor $Parent -TrayHost $HostIdentity} $installRoot $activeRuntimeId $supervisor $trayHost) + if($legacyProof.Count-ne1-or$legacyProof[0]-isnot[bool]){throw 'legacy readiness proof'} + if($legacyProof[0]){$trayReadyProof=$true} + } else { + $trayReadyProof = & $trayReadyProofCommand $installRoot ([string]$activeRuntimeId) $trayHost + } + } + $statusCodexPid = if ($null -ne $statusCodex) { $statusCodex.pid } else { $null } + $codexPid = if ($codex.Count -eq 1) { $codex[0].pid } else { $null } + $codexStatusMatches = $false + if ($statusCodexPid -ne $null -and $codexPid -ne $null -and + [bool](& $integerValue $statusCodexPid) -and [bool](& $integerValue $codexPid) -and + [uint64]$statusCodexPid -gt 0 -and [uint64]$codexPid -gt 0 -and + [uint64]$statusCodexPid -eq [uint64]$codexPid -and [string]$statusCodex.creationTimeUtc -ceq [string]$codex[0].creationTimeUtc) { + $codexStatusMatches = $true + } + $ready = $appPresent -is [bool] -and [bool]$appPresent -and + $runtimeRootPresent -and $activePointerPresent -and + $runtimeValid -and $generationValid -and $manifestHash -is [string] -and $manifestHash -cmatch '^[0-9a-f]{64}\z' -and + $taskState -is [string] -and $taskState -in @('Ready','Running') -and + $supervisor.Count -eq 1 -and $trayHost.Count -eq 1 -and $trayReadyProof -ne $null -and $collectorAuthenticated -and $debugOwnershipValid -and $terminalValid -and + $aboutVersion -is [string] -and $aboutVersion -ceq $readinessVersion -and + $statusPhase -is [string] -and $statusPhase -ceq 'Active' -and $transitionStage -ceq 'Idle' -and + $statusRuntimeId -is [string] -and $statusRuntimeId -ceq [string]$activeRuntimeId -and $codexStatusMatches -and + [string]$receipt.runtimeId -ceq [string]$activeRuntimeId -and [UInt64]$receipt.runtimeGeneration -eq $generation + if ($Context.phase -ceq 'Uninstall' -and $Point -ceq 'Before') { + $debugPorts.Clear() + if ($expectedDebugValue -isnot [array]) { throw 'debug port observation' } + foreach ($port in $expectedPorts) { + if (($port -isnot [int] -and $port -isnot [long]) -or $port -lt 1 -or $port -gt 65535) { throw 'debug port observation' } + [void]$debugPorts.Add([int]$port) + } + if ($debugPorts.Count -ne 2 -or @($debugPorts | Sort-Object -Unique).Count -ne 2) { throw 'debug port observation' } + } + if ($Context.phase -ceq 'Uninstall' -and $Point -ceq 'After' -and $debugPorts.Count -gt 0) { + $closed = @(& $integrationModule { param($Ports) Test-CcodInstalledLifecycleDebugPortsClosed -Ports $Ports } ([int[]]$debugPorts)) + if ($closed.Count -ne 1 -or $closed[0] -isnot [bool]) { throw 'debug endpoint observation' } + if (-not $closed[0]) { + & $throwAcceptanceError 'CCOD_ACCEPTANCE_UNINSTALL_OBSERVATION_INVALID' 'A previously declared debug port still has a listener after uninstall.' $null + } + } + $bootValues = @(Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop) + if ($bootValues.Count -ne 1 -or $null -eq $bootValues[0].LastBootUpTime) { throw 'boot identity' } + $lastBoot = $bootValues[0].LastBootUpTime + if ($lastBoot -isnot [datetime]) { $lastBoot = [datetime]::Parse([string]$lastBoot, [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::RoundtripKind) } + $bootId = 'boot-' + $lastBoot.ToUniversalTime().ToString('yyyyMMddTHHmmssfffffffZ', [Globalization.CultureInfo]::InvariantCulture) + $trayHostIdentity = $null + if ($trayHost.Count -eq 1) { $trayHostIdentity = [pscustomobject][ordered]@{ pid = $trayHost[0].pid; creationTimeUtc = [string]$trayHost[0].creationTimeUtc } } + return [pscustomobject][ordered]@{ + installRootPresent = [bool]$installRootPresent + installReady = [bool]$ready + appRootPresent = if ($appPresent -is [bool]) { [bool]$appPresent } else { $false } + runtimeRootPresent = $runtimeRootPresent + activePointerPresent = $activePointerPresent + activeRuntimeId = if ($runtimeValid) { [string]$activeRuntimeId } else { $null } + activeGeneration = if ($generationValid) { $generation } else { $null } + runtimeManifestSha256 = if ($manifestHash -is [string] -and $manifestHash -cmatch '^[0-9a-f]{64}\z') { [string]$manifestHash } else { $null } + supervisor = @($supervisor) + trayHost = @($trayHost) + codex = @($codex) + codexCount = [int]$codex.Count + trayHostIdentity = $trayHostIdentity + trayAuthenticated = [bool]$ready + taskState = if ($taskState -is [string]) { [string]$taskState } else { 'Unknown' } + statusPhase = if ($statusPhase -is [string]) { [string]$statusPhase } else { 'Unavailable' } + statusRuntimeId = if ($statusRuntimeId -is [string]) { [string]$statusRuntimeId } else { $null } + statusCodex = $statusCodex + transitionStage = $transitionStage + lifecycleReceipt = $receipt + aboutVersion = if ($aboutVersion -is [string]) { [string]$aboutVersion } else { $null } + shortcuts = $shortcuts + debugPorts = [int[]]$expectedPorts + protectionReady = [bool]$ready + protectionRecovered = [bool]($ready -and $Context.phase -ceq 'PostReboot' -and $Point -ceq 'After') + deviceKeyPresent = if ($deviceKeyPresent -is [bool]) { [bool]$deviceKeyPresent } else { $false } + deviceKeySha256 = if ($deviceKeySha256 -is [string]) { [string]$deviceKeySha256 } else { $null } + bootId = $bootId + debugEndpoints = @($debugEndpoints) + } + } catch { + if ($_.FullyQualifiedErrorId -like 'CCOD_ACCEPTANCE_*') { throw } + & $throwAcceptanceError 'CCOD_ACCEPTANCE_OBSERVATION_UNAVAILABLE' 'Installed lifecycle observations are unavailable or cannot be proven safely.' $null + } + }.GetNewClosure() + $assertManualReadiness = { + param([string]$ExpectedVersion,[string]$ExpectedRuntimeId,$ExpectedGeneration,[string]$ExpectedManifestSha256,[string]$ExpectedBootId,[string]$ExpectedDeviceKeySha256) + $readinessContext = [pscustomobject][ordered]@{ phase = 'PostReboot'; expectedVersion = $ExpectedVersion } + $values = @(& $defaults.CaptureFacts $readinessContext 'After') + if ($values.Count -ne 1 -or $null -eq $values[0]) { throw 'manual readiness observation' } + $facts = $values[0] + $terminal = $facts.lifecycleReceipt + if ($facts.appRootPresent -isnot [bool] -or -not [bool]$facts.appRootPresent -or + $facts.runtimeRootPresent -isnot [bool] -or -not [bool]$facts.runtimeRootPresent -or + $facts.activePointerPresent -isnot [bool] -or -not [bool]$facts.activePointerPresent -or + $facts.activeRuntimeId -isnot [string] -or $facts.activeRuntimeId -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,95}\z' -or + $facts.activeGeneration -isnot [UInt64] -or [UInt64]$facts.activeGeneration -lt 1 -or + @($facts.supervisor).Count -ne 1 -or @($facts.trayHost).Count -ne 1 -or + $facts.trayAuthenticated -isnot [bool] -or -not [bool]$facts.trayAuthenticated -or + $facts.protectionReady -isnot [bool] -or -not [bool]$facts.protectionReady -or + $facts.protectionRecovered -isnot [bool] -or -not [bool]$facts.protectionRecovered -or + $facts.taskState -isnot [string] -or $facts.taskState -notin @('Ready','Running') -or + $facts.runtimeManifestSha256 -isnot [string] -or -not (& $hashValue $facts.runtimeManifestSha256) -or + $facts.bootId -isnot [string] -or $facts.bootId -notmatch '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\z' -or + $facts.deviceKeyPresent -isnot [bool] -or -not [bool]$facts.deviceKeyPresent -or + $facts.deviceKeySha256 -isnot [string] -or $facts.deviceKeySha256 -notmatch '^[0-9a-f]{64}\z' -or + $null -eq $terminal -or $terminal.kind -isnot [string] -or $terminal.kind -cne 'RestartAndRepair' -or + $terminal.origin -isnot [string] -or $terminal.origin -cne 'Installer' -or + $terminal.runtimeId -isnot [string] -or $terminal.runtimeId -cne [string]$facts.activeRuntimeId -or + $terminal.runtimeGeneration -isnot [UInt64] -or [UInt64]$terminal.runtimeGeneration -ne [UInt64]$facts.activeGeneration -or + $terminal.phase -isnot [string] -or $terminal.phase -cne 'Completed') { throw 'manual readiness' } + if (-not [string]::IsNullOrEmpty([string]$ExpectedRuntimeId) -and ($ExpectedRuntimeId -isnot [string] -or $facts.activeRuntimeId -cne $ExpectedRuntimeId)) { throw 'manual persisted runtime' } + if ($null -ne $ExpectedGeneration -and ($ExpectedGeneration -isnot [UInt64] -or [UInt64]$facts.activeGeneration -ne [UInt64]$ExpectedGeneration)) { throw 'manual persisted generation' } + if (-not [string]::IsNullOrEmpty([string]$ExpectedManifestSha256) -and ($ExpectedManifestSha256 -isnot [string] -or -not (& $hashValue $ExpectedManifestSha256) -or $facts.runtimeManifestSha256 -cne $ExpectedManifestSha256)) { throw 'manual persisted manifest' } + if (-not [string]::IsNullOrEmpty([string]$ExpectedDeviceKeySha256) -and ($ExpectedDeviceKeySha256 -isnot [string] -or -not (& $hashValue $ExpectedDeviceKeySha256) -or $facts.deviceKeySha256 -cne $ExpectedDeviceKeySha256)) { throw 'manual persisted device key' } + if (-not [string]::IsNullOrEmpty([string]$ExpectedBootId) -and ($ExpectedBootId -isnot [string] -or $facts.bootId -cne $ExpectedBootId)) { throw 'manual persisted boot' } + return $facts + }.GetNewClosure() + $defaults.RunPhase = { + param($Context) + try { + if ($Context.allowMachineMutation -isnot [bool] -or -not [bool]$Context.allowMachineMutation) { + & $throwAcceptanceError 'CCOD_ACCEPTANCE_MACHINE_MUTATION_NOT_ALLOWED' 'This installed-lifecycle operation requires explicit machine-mutation authorization.' $null + } + if ($Context.allowCodexRestart -isnot [bool] -or -not [bool]$Context.allowCodexRestart) { + & $throwAcceptanceError 'CCOD_ACCEPTANCE_CODEX_RESTART_NOT_ALLOWED' 'This installed-lifecycle operation requires explicit Codex-restart authorization.' $null + } + $scenario = switch ([string]$Context.phase) { + 'LegacyUpgrade' { 'Upgrade'; break } + 'Uninstall' { 'DirectUninstall'; break } + 'FreshInstall' { 'FreshInstall'; break } + default { & $throwAcceptanceError 'CCOD_ACCEPTANCE_MACHINE_OPERATION_UNAVAILABLE' 'The requested official-draft phase has no installed-lifecycle scenario mapping.' $null } + } + $previousPath = if ([string]$Context.phase -ceq 'LegacyUpgrade') { [string]$Context.previousSetupPath } else { $null } + $values = @(& $integrationModule { + param($InstallerPath,$PreviousInstallerPath,$PreviousExpectedVersion,$PreviousInstallerSha256,$PreviousManifestSha256,$ExpectedVersion,$EvidenceRoot,$Mutation,$Restart,$ScenarioName,$ExpectedCandidate) + Invoke-CcodInstalledLifecycleIntegration -InstallerPath $InstallerPath -PreviousInstallerPath $PreviousInstallerPath -PreviousExpectedVersion $PreviousExpectedVersion -PreviousInstallerSha256 $PreviousInstallerSha256 -PreviousManifestSha256 $PreviousManifestSha256 -ExpectedVersion $ExpectedVersion -EvidenceRoot $EvidenceRoot -AllowMachineMutation:$Mutation -AllowCodexRestart:$Restart -Scenario $ScenarioName -ExpectedCandidate $ExpectedCandidate + } $Context.setupPath $previousPath $Context.previousSetupVersion $Context.previousSetupSha256 $Context.previousManifestSha256 $Context.expectedVersion $Context.evidenceRoot $Context.allowMachineMutation $Context.allowCodexRestart $scenario $Context.candidate) + if ($values.Count -ne 1 -or $null -eq $values[0] -or + $values[0].outcome -isnot [string] -or $values[0].outcome -cne 'Completed' -or + $null -eq $values[0].verification -or $values[0].verification.verified -isnot [bool] -or + -not [bool]$values[0].verification.verified) { throw 'integration result' } + $setupName = [IO.Path]::GetFileName([string]$Context.setupPath) + $expectedInstallerHash = @($Context.candidate.assetHashes | Where-Object { $_.name -ceq $setupName }) + $installerHash = if ($null -ne $values[0].PSObject.Properties['installerSha256']) { $values[0].installerSha256 } else { $null } + $runtimeHash = if ($null -ne $values[0].verification.facts.PSObject.Properties['runtimeManifestSha256']) { $values[0].verification.facts.runtimeManifestSha256 } else { $null } + if ($expectedInstallerHash.Count -ne 1 -or $installerHash -isnot [string] -or -not (& $hashValue $installerHash) -or + $installerHash -cne [string]$expectedInstallerHash[0].sha256 -or + ($null -ne $runtimeHash -and ($runtimeHash -isnot [string] -or -not (& $hashValue $runtimeHash))) -or + ([string]$Context.phase -ceq 'FreshInstall' -and -not (& $hashValue $runtimeHash))) { throw 'integration binding' } + return [pscustomobject][ordered]@{ completed = $true; outcome = 'Completed'; installerSha256 = [string]$installerHash; runtimeManifestSha256 = if ($null -eq $runtimeHash) { $null } else { [string]$runtimeHash } } + } catch { + if ($_.FullyQualifiedErrorId -like 'CCOD_ACCEPTANCE_*') { throw } + & $throwAcceptanceError 'CCOD_ACCEPTANCE_MACHINE_OPERATION_FAILED' 'The installed-lifecycle operation did not return proven completion.' $null + } + }.GetNewClosure() + $defaults.RunManualOperation = { + param($Context,$Operation) + $evidenceReadLease = $null + try { + if ($null -eq $Context -or $Operation -isnot [string] -or + [string]::IsNullOrWhiteSpace([string]$Context.screenshotPath) -or + [string]::IsNullOrWhiteSpace([string]$Context.redactedLogPath)) { throw 'manual context' } + $operation = [string]$Operation + $startedAt = [DateTimeOffset]::UtcNow + $expectedRuntimeId = if ($null -ne $Context.PSObject.Properties['expectedRuntimeId']) { $Context.expectedRuntimeId } else { $null } + $expectedGeneration = if ($null -ne $Context.PSObject.Properties['expectedGeneration']) { $Context.expectedGeneration } else { $null } + $expectedManifestHash = if ($null -ne $Context.PSObject.Properties['expectedRuntimeManifestSha256']) { $Context.expectedRuntimeManifestSha256 } else { $null } + $expectedDeviceKeyHash = if ($null -ne $Context.PSObject.Properties['expectedDeviceKeySha256']) { $Context.expectedDeviceKeySha256 } else { $null } + $expectedBootId = if ($null -ne $Context.PSObject.Properties['expectedBootId']) { $Context.expectedBootId } else { $null } + $expectedAck = 'CCOD_MANUAL_' + $operation.ToUpperInvariant() + '_COMPLETED' + $instructions = @{ + About = 'Open the CodexRemote-fix tray menu, select About, confirm the dialog, and keep the redacted supervisor action record.' + Language = 'Open the CodexRemote-fix tray menu, select any enabled language, confirm the presentation changes, and keep the redacted supervisor action record.' + OpenLogs = 'Open the CodexRemote-fix tray menu, select Open logs, confirm the log directory opens, and keep the redacted supervisor action record.' + Repair = 'Open the CodexRemote-fix tray menu, select Check and repair remote connection, wait for the terminal result, and keep the redacted supervisor action record.' + SecondDeviceControl = 'Use the second device to connect to and control this Windows Codex session, then provide one strict redacted JSON proof record.' + } + if (-not $instructions.ContainsKey($operation)) { throw 'unsupported manual operation' } + Write-Host '' + Write-Host ('Manual official-draft evidence: {0}' -f $operation) -ForegroundColor Cyan + Write-Host $instructions[$operation] -ForegroundColor Yellow + if ($operation -ceq 'SecondDeviceControl') { Write-Host ('Include this one-time challenge in the proof: {0}' -f $Context.manualChallenge) -ForegroundColor Yellow } + $readyBefore = & $assertManualReadiness ([string]$Context.expectedVersion) $expectedRuntimeId $expectedGeneration $expectedManifestHash $expectedBootId $expectedDeviceKeyHash + $prompt = ('After independently completing the operation, type {0}' -f $expectedAck) + $ack = if ($null -ne $Context.PSObject.Properties['manualAcknowledgement']) { [string]$Context.manualAcknowledgement } elseif ($null -ne $script:CcodOfficialDraftManualAcknowledgement) { & $script:CcodOfficialDraftManualAcknowledgement $prompt } else { Read-Host $prompt } + if ([string]$ack -cne $expectedAck) { throw 'manual acknowledgement' } + $readyAfter = & $assertManualReadiness ([string]$Context.expectedVersion) $expectedRuntimeId $expectedGeneration $expectedManifestHash $expectedBootId $expectedDeviceKeyHash + if ([string]$readyAfter.activeRuntimeId -cne [string]$readyBefore.activeRuntimeId -or + [UInt64]$readyAfter.activeGeneration -ne [UInt64]$readyBefore.activeGeneration -or + [string]$readyAfter.bootId -cne [string]$readyBefore.bootId) { throw 'manual readiness changed' } + $evidenceReadLease = & $openEvidenceReadLease @([string]$Context.redactedLogPath,[string]$Context.screenshotPath) + if ($operation -ceq 'SecondDeviceControl') { + $redactedHashBefore = & $fileHash $Context.redactedLogPath + $proof = & $remoteProof -Path $Context.redactedLogPath -ExpectedChallenge ([string]$Context.manualChallenge) -NotBefore ([DateTimeOffset]$startedAt) -ExpectedVersion ([string]$Context.expectedVersion) -ExpectedRuntimeId ([string]$readyAfter.activeRuntimeId) -ExpectedGeneration ([UInt64]$readyAfter.activeGeneration) -ExpectedManifestSha256 ([string]$readyAfter.runtimeManifestSha256) + $remoteDebugPorts = @($readyAfter.debugEndpoints | ForEach-Object { if ($_.localPort -is [ValueType] -and $_.localPort -isnot [bool]) { [int]$_.localPort } }) + $readyAfterCodex = @($readyAfter.codex) + if ($remoteDebugPorts.Count -ne 2 -or @($remoteDebugPorts | Sort-Object -Unique).Count -ne 2 -or $readyAfterCodex.Count -ne 1) { throw 'remote debug ports' } + $values = @(& $integrationModule { + param($Root,$Version,$DebugPorts) + Get-CcodInstalledLifecycleFacts -InstallRoot $Root -ExpectedVersion $Version -ExpectedDebugPorts ([int[]]$DebugPorts) + } $installRoot ([string]$Context.expectedVersion) ([int[]]$remoteDebugPorts)) + if ($values.Count -ne 1 -or $null -eq $values[0]) { throw 'remote status' } + $facts = $values[0] + $statusCodex = $facts.statusCodex + $codex = @($facts.codex) + $codexMatchesReady = $codex.Count -eq 1 -and $readyAfterCodex.Count -eq 1 -and [int]$codex[0].pid -eq [int]$readyAfterCodex[0].pid -and [string]$codex[0].creationTimeUtc -ceq [string]$readyAfterCodex[0].creationTimeUtc + $remotePortKey = (@($facts.debugPorts | ForEach-Object { [int]$_ } | Sort-Object) -join ',') + $expectedRemotePortKey = (@($remoteDebugPorts | Sort-Object) -join ',') + $remoteEndpointValid = $true + if ($facts.debugEndpoints.Count -ne 2 -or $remotePortKey -cne $expectedRemotePortKey -or -not $codexMatchesReady) { $remoteEndpointValid = $false } + else { + foreach ($port in $remoteDebugPorts) { + $endpointMatches = @($facts.debugEndpoints | Where-Object { + $_.localAddress -is [string] -and $_.localAddress -ceq '127.0.0.1' -and + $_.localPort -is [ValueType] -and $_.localPort -isnot [bool] -and [int]($_.localPort) -eq [int]$port -and + $_.owningProcess -is [ValueType] -and $_.owningProcess -isnot [bool] -and [uint64]($_.owningProcess) -eq [uint64]$readyAfterCodex[0].pid -and + $_.owningProcessCreationTimeUtc -is [string] -and $_.owningProcessCreationTimeUtc -ceq [string]$readyAfterCodex[0].creationTimeUtc + }) + if ($endpointMatches.Count -ne 1) { $remoteEndpointValid = $false } + } + } + if ($facts.aboutVersion -isnot [string] -or $facts.aboutVersion -cne [string]$Context.expectedVersion -or + $facts.runtimeManifestSha256 -isnot [string] -or $facts.runtimeManifestSha256 -cne [string]$readyAfter.runtimeManifestSha256 -or + $facts.activeRuntimeId -isnot [string] -or $facts.activeRuntimeId -cne [string]$readyAfter.activeRuntimeId -or + $facts.activeGeneration -isnot [UInt64] -or [UInt64]$facts.activeGeneration -ne [UInt64]$readyAfter.activeGeneration -or + $facts.deviceKeyPresent -isnot [bool] -or -not [bool]$facts.deviceKeyPresent -or + $facts.deviceKeySha256 -isnot [string] -or $facts.deviceKeySha256 -cne [string]$readyAfter.deviceKeySha256 -or + -not $remoteEndpointValid -or + $facts.appPresent -isnot [bool] -or -not [bool]$facts.appPresent -or + $facts.statusPhase -isnot [string] -or $facts.statusPhase -cne 'Active' -or + $facts.statusRuntimeId -isnot [string] -or $facts.statusRuntimeId -cne [string]$facts.activeRuntimeId -or + $facts.taskState -isnot [string] -or $facts.taskState -notin @('Ready','Running') -or + @($facts.trayHost).Count -ne 1 -or $codex.Count -ne 1 -or $null -eq $statusCodex -or + [int]$statusCodex.pid -ne [int]$codex[0].pid -or [string]$statusCodex.creationTimeUtc -cne [string]$codex[0].creationTimeUtc) { throw 'remote status' } + $redactedHashAfter = & $fileHash $Context.redactedLogPath + if ($redactedHashBefore -cne $redactedHashAfter) { throw 'remote evidence changed' } + & $checkEvidenceReadLease $evidenceReadLease + return [pscustomobject][ordered]@{ verified = $true; operation = $operation; terminalState = [string]$manualTerminalStates[$operation]; code = [string]$manualResultCodes[$operation]; proof = $proof; screenshotSha256 = [string](& $fileHash $Context.screenshotPath); redactedLogSha256 = [string]$redactedHashAfter } + } + $liveLog = Join-Path $installRoot 'logs\supervisor.log' + $live = & $trayActionProof -Path $liveLog -Operation $operation -NotBefore ([DateTimeOffset]$startedAt) + $redactedHashBefore = & $fileHash $Context.redactedLogPath + $redacted = & $trayActionProof -Path $Context.redactedLogPath -Operation $operation -NotBefore ([DateTimeOffset]$startedAt) + $redactedHashAfter = & $fileHash $Context.redactedLogPath + if ([string]$redacted.timestampUtc -cne [string]$live.timestampUtc -or + [string]$redacted.command -cne [string]$live.command -or [uint64]$redacted.revision -ne [uint64]$live.revision -or + [string]$redacted.code -cne [string]$live.code -or [string]$redacted.status -cne [string]$live.status -or + $redactedHashBefore -cne $redactedHashAfter) { throw 'tray proof mismatch' } + $trayProof = [pscustomobject][ordered]@{ timestampUtc = [string]$redacted.timestampUtc; command = [string]$redacted.command; revision = [UInt64]$redacted.revision; code = 'CCOD_TRAY_ACTION_COMPLETED'; status = 'Completed' } + & $checkEvidenceReadLease $evidenceReadLease + return [pscustomobject][ordered]@{ verified = $true; operation = $operation; terminalState = [string]$manualTerminalStates[$operation]; code = [string]$manualResultCodes[$operation]; proof = $trayProof; screenshotSha256 = [string](& $fileHash $Context.screenshotPath); redactedLogSha256 = [string]$redactedHashAfter } + } catch { + if ($_.FullyQualifiedErrorId -like 'CCOD_ACCEPTANCE_*') { throw } + & $throwAcceptanceError 'CCOD_ACCEPTANCE_MANUAL_OPERATION_UNPROVEN' 'The requested tray or second-device operation did not produce a unique verified result.' $null + } finally { & $closeEvidenceReadLease $evidenceReadLease } + }.GetNewClosure() + $defaults.GetBootIdentity = { + try { + $values = @(Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop) + if ($values.Count -ne 1 -or $null -eq $values[0].LastBootUpTime) { throw 'boot' } + $lastBoot = $values[0].LastBootUpTime + if ($lastBoot -isnot [datetime]) { $lastBoot = [datetime]::Parse([string]$lastBoot, [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::RoundtripKind) } + return 'boot-' + $lastBoot.ToUniversalTime().ToString('yyyyMMddTHHmmssfffffffZ', [Globalization.CultureInfo]::InvariantCulture) + } catch { + & $throwAcceptanceError 'CCOD_ACCEPTANCE_BOOT_ID_UNAVAILABLE' 'The Windows boot identity could not be observed safely.' $null + } + }.GetNewClosure() + $defaults.Reboot = { + param($Context) + if ($null -eq $Context -or $Context.allowWindowsReboot -isnot [bool] -or -not [bool]$Context.allowWindowsReboot) { + & $throwAcceptanceError 'CCOD_ACCEPTANCE_WINDOWS_REBOOT_NOT_ALLOWED' 'Windows reboot requires explicit authorization.' $null + } + try { + Restart-Computer -Force -ErrorAction Stop + return $true + } catch { + & $throwAcceptanceError 'CCOD_ACCEPTANCE_REBOOT_FAILED' 'The authorized Windows reboot could not be initiated.' $null + } + }.GetNewClosure() + return $defaults +} + +function Resolve-CcodOfficialDraftAdapters { + param([hashtable]$Adapters) + $resolved = Get-CcodOfficialDraftDefaultAdapters + if ($null -eq $Adapters) { return $resolved } + foreach ($name in $Adapters.Keys) { + if (-not $resolved.ContainsKey([string]$name) -or $Adapters[$name] -isnot [scriptblock]) { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_ADAPTER_INVALID' 'Private acceptance adapters must replace known scriptblock operations only.' $null + } + $resolved[[string]$name] = $Adapters[$name] + } + return $resolved +} + +function Assert-CcodOfficialDraftPhaseState { + param([Parameter(Mandatory)][string]$Phase, [Parameter(Mandatory)]$Records, [Parameter(Mandatory)]$Candidate, [Parameter(Mandatory)]$Draft,[switch]$AllowExistingComplete) + $manualValues = @() + if ($Records.ContainsKey('ManualEvidence') -and $null -ne $Records['ManualEvidence']) { + $manualValues = @($Records['ManualEvidence'].Values) + if ($null -eq $manualValues) { $manualValues = @() } + } + if ($Phase -notin @('TrayEvidence','RemoteEvidence','Complete') -and $manualValues.Count -gt 0) { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_EARLY' 'Manual evidence cannot exist before ReadyForManualEvidence.' $null + } + if ($Phase -in @('TrayEvidence','RemoteEvidence','Complete')) { + foreach ($automatedPhase in $script:CcodOfficialDraftPhases) { + if (-not $Records.ContainsKey($automatedPhase)) { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_PHASE_ORDER_INVALID' 'Manual evidence requires all automated acceptance phases first.' $null + } + if (-not (Test-CcodOfficialDraftCandidateEqual -Left $Candidate -Right $Records[$automatedPhase].candidate)) { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_CANDIDATE_CHANGED' 'The official-draft candidate changed before manual evidence.' $null + } + if (-not (Test-CcodOfficialDraftDraftEqual -Left $Draft -Right $Records[$automatedPhase].draft)) { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_DRAFT_IDENTITY_CHANGED' 'The official-draft identity changed before manual evidence.' $null + } + } + if ($Records.ContainsKey('Complete') -and -not ($Phase -ceq 'Complete' -and $AllowExistingComplete)) { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_PHASE_REUSED' 'Complete acceptance evidence cannot be reused.' $null + } + return + } + $index = [array]::IndexOf([string[]]$script:CcodOfficialDraftPhases,$Phase) + if ($index -lt 0) { Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_PHASE_INVALID' 'The official-draft acceptance phase is unsupported.' $null } + if ($Records.ContainsKey('Complete')) { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_PHASE_REUSED' 'Automated acceptance cannot continue after Complete evidence.' $null + } + for ($prior = 0; $prior -lt $index; $prior++) { + $name = $script:CcodOfficialDraftPhases[$prior] + if (-not $Records.ContainsKey($name)) { Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_PHASE_ORDER_INVALID' 'Official-draft acceptance phases must be completed in order.' $null } + if (-not (Test-CcodOfficialDraftCandidateEqual -Left $Candidate -Right $Records[$name].candidate)) { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_CANDIDATE_CHANGED' 'The official-draft candidate changed during acceptance.' $null + } + if (-not (Test-CcodOfficialDraftDraftEqual -Left $Draft -Right $Records[$name].draft)) { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_DRAFT_IDENTITY_CHANGED' 'The official-draft identity changed during acceptance.' $null + } + } + for ($later = $index; $later -lt $script:CcodOfficialDraftPhases.Count; $later++) { + if ($Records.ContainsKey($script:CcodOfficialDraftPhases[$later])) { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_PHASE_REUSED' 'An official-draft acceptance phase cannot be reused or skipped.' $null + } + } +} + +function Assert-CcodOfficialDraftPhaseAuthorization { + param( + [Parameter(Mandatory)][string]$Phase, + [switch]$AllowMachineMutation, + [switch]$AllowCodexRestart, + [switch]$AllowWindowsReboot + ) + if ($Phase -in @('LegacyUpgrade','Uninstall','FreshInstall','PreReboot','PostReboot')) { + if (-not $AllowMachineMutation) { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_MACHINE_MUTATION_NOT_ALLOWED' 'This official-draft phase requires explicit machine-mutation authorization.' $null + } + if (-not $AllowCodexRestart) { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_CODEX_RESTART_NOT_ALLOWED' 'This official-draft phase requires explicit Codex-restart authorization.' $null + } + } + if ($Phase -in @('PreReboot','PostReboot') -and -not $AllowWindowsReboot) { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_WINDOWS_REBOOT_NOT_ALLOWED' 'This official-draft phase requires explicit Windows-reboot authorization.' $null + } +} + +function ConvertTo-CcodOfficialDraftPreviousSetup { + param([Parameter(Mandatory)]$Value) + try { + if ($null -eq $Value -or $Value.Valid -isnot [bool] -or -not [bool]$Value.Valid -or + $Value.Version -isnot [string] -or $Value.Version -cne '2.5.21' -or + $Value.InstallerName -isnot [string] -or $Value.InstallerName -cne 'CodexRemote-fix-2.5.21-setup.exe' -or + -not (Test-CcodOfficialDraftCommit $Value.GitCommit) -or + -not (Test-CcodOfficialDraftHash $Value.InstallerSha256) -or + -not (Test-CcodOfficialDraftHash $Value.ManifestSha256)) { throw 'previous setup' } + return [pscustomobject][ordered]@{ + version = '2.5.21' + gitCommit = [string]$Value.GitCommit + assetSha256 = [string]$Value.InstallerSha256 + manifestSha256 = [string]$Value.ManifestSha256 + } + } catch { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_PREVIOUS_ASSET_INVALID' 'The previous public v2.5.21 Setup is not manifest-bound.' $null + } +} + +function Get-CcodOfficialDraftFactValue { + param([Parameter(Mandatory)]$Facts, [Parameter(Mandatory)][string]$Name) + if ($Facts -is [Collections.IDictionary]) { + if (-not $Facts.Contains($Name)) { return $null } + return $Facts[$Name] + } + $property = $Facts.PSObject.Properties[$Name] + if ($null -eq $property) { return $null } + return $property.Value +} + +function Get-CcodOfficialDraftObservedKeyHash { + param([Parameter(Mandatory)]$Facts) + try { + $present = Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'deviceKeyPresent' + $hash = Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'deviceKeySha256' + if ($present -isnot [bool] -or -not [bool]$present -or -not (Test-CcodOfficialDraftHash $hash)) { throw 'key' } + return [string]$hash + } catch { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_KEY_OBSERVATION_INVALID' 'The DPAPI device-key observation is missing or malformed.' $null + } +} + +function Invoke-CcodOfficialDraftPhaseOperation { + param([Parameter(Mandatory)]$Adapters, [Parameter(Mandatory)]$Context) + try { + $values = @(& $Adapters.RunPhase $Context) + if ($values.Count -ne 1 -or $null -eq $values[0]) { throw 'operation result' } + $result = $values[0] + if (-not (Test-CcodOfficialDraftExactProperties -Value $result -Expected @('completed','outcome','installerSha256','runtimeManifestSha256')) -or + $result.installerSha256 -isnot [string] -or -not (Test-CcodOfficialDraftHash $result.installerSha256) -or + ($null -ne $result.runtimeManifestSha256 -and ($result.runtimeManifestSha256 -isnot [string] -or -not (Test-CcodOfficialDraftHash $result.runtimeManifestSha256)))) { throw 'operation binding' } + $setupName = [IO.Path]::GetFileName([string]$Context.setupPath) + $expectedInstallerHash = Get-CcodOfficialDraftAssetHash -Candidate $Context.candidate -Name $setupName + if (-not (Test-CcodOfficialDraftHash $expectedInstallerHash) -or $result.installerSha256 -cne $expectedInstallerHash) { throw 'operation candidate binding' } + $completed = $false + $outcomeCompleted = $false + $hasCompletion = $false + $property = $result.PSObject.Properties['completed'] + if ($null -ne $property) { + if ($property.Value -isnot [bool]) { throw 'completed type' } + $completed = [bool]$property.Value + $hasCompletion = $true + } + $property = $result.PSObject.Properties['outcome'] + if ($null -ne $property) { + if ($property.Value -isnot [string]) { throw 'outcome type' } + if ([string]$property.Value -cne 'Completed') { throw 'outcome' } + $outcomeCompleted = $true + $hasCompletion = $true + } + if (-not $hasCompletion -or -not $completed -or -not $outcomeCompleted) { throw 'operation result' } + return $result + } catch { + if ($_.FullyQualifiedErrorId -like 'CCOD_ACCEPTANCE_*') { throw } + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_MACHINE_OPERATION_FAILED' 'The official-draft machine operation did not return proven completion.' $null + } +} + +function New-CcodOfficialDraftContext { + param( + [Parameter(Mandatory)][string]$Phase, + [Parameter(Mandatory)][string]$AssetDirectory, + [Parameter(Mandatory)][string]$PreviousAssetDirectory, + [Parameter(Mandatory)][string]$EvidenceRoot, + [Parameter(Mandatory)]$Candidate, + [Parameter(Mandatory)]$Draft, + [Parameter(Mandatory)]$PreviousSetup, + [switch]$AllowMachineMutation, + [switch]$AllowCodexRestart, + [switch]$AllowWindowsReboot + ) + $names = @(Get-CcodOfficialDraftExpectedAssetNames) + return [pscustomobject][ordered]@{ + phase = $Phase + assetDirectory = [IO.Path]::GetFullPath($AssetDirectory) + previousAssetDirectory = [IO.Path]::GetFullPath($PreviousAssetDirectory) + evidenceRoot = [IO.Path]::GetFullPath($EvidenceRoot) + installRoot = [IO.Path]::GetFullPath((Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)) 'CodexControlOtherDevices')) + expectedVersion = [string]$Candidate.version + setupPath = [IO.Path]::GetFullPath((Join-Path $AssetDirectory $names[5])) + portablePath = [IO.Path]::GetFullPath((Join-Path $AssetDirectory $names[0])) + previousSetupPath = [IO.Path]::GetFullPath((Join-Path $PreviousAssetDirectory 'CodexRemote-fix-2.5.21-setup.exe')) + previousSetupVersion = [string]$PreviousSetup.version + previousSetupSha256 = [string]$PreviousSetup.assetSha256 + previousManifestSha256 = [string]$PreviousSetup.manifestSha256 + candidate = $Candidate + draft = $Draft + previousSetup = $PreviousSetup + allowMachineMutation = [bool]$AllowMachineMutation + allowCodexRestart = [bool]$AllowCodexRestart + allowWindowsReboot = [bool]$AllowWindowsReboot + } +} + +function ConvertTo-CcodOfficialDraftLegacyFacts { + param([Parameter(Mandatory)]$PreviousSetup,[Parameter(Mandatory)]$BeforeObservation,[Parameter(Mandatory)]$AfterObservation,[Parameter(Mandatory)][string]$BeforeHash,[Parameter(Mandatory)][string]$AfterHash) + return [pscustomobject][ordered]@{ + previousSetup = $PreviousSetup + before = $BeforeObservation + after = $AfterObservation + deviceKeySha256Before = $BeforeHash + deviceKeySha256After = $AfterHash + keyHashPreserved = $true + operationOutcome = 'Completed' + } +} + +function Test-CcodOfficialDraftHasFact { + param([Parameter(Mandatory)]$Facts, [Parameter(Mandatory)][string]$Name) + if ($Facts -is [Collections.IDictionary]) { return $Facts.Contains($Name) } + return $null -ne $Facts.PSObject.Properties[$Name] +} + +function Assert-CcodOfficialDraftUninstallFacts { + param([Parameter(Mandatory)]$Facts, [Parameter(Mandatory)][string]$ExpectedKeyHash) + try { + foreach ($name in @('installRootPresent','appRootPresent','runtimeRootPresent','activePointerPresent','taskState','supervisor','trayHost','codex','shortcuts','debugEndpoints')) { + if (-not (Test-CcodOfficialDraftHasFact -Facts $Facts -Name $name)) { throw 'missing fact' } + } + foreach ($name in @('appRootPresent','runtimeRootPresent','activePointerPresent')) { + $value = Get-CcodOfficialDraftFactValue -Facts $Facts -Name $name + if ($value -isnot [bool] -or [bool]$value) { throw 'owned state remains' } + } + $rootValue = Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'installRootPresent' + if ($rootValue -isnot [bool] -or [bool]$rootValue) { throw 'install root remains' } + if ((Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'taskState') -isnot [string] -or + (Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'taskState') -cne 'Absent') { throw 'task' } + if (@(Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'supervisor').Count -ne 0 -or + @(Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'trayHost').Count -ne 0 -or + @(Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'codex').Count -ne 0 -or + @(Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'debugEndpoints').Count -ne 0) { throw 'process or endpoint remains' } + $shortcuts = Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'shortcuts' + if (-not (Test-CcodOfficialDraftExactProperties -Value $shortcuts -Expected @('startMenu','desktop')) -or + $shortcuts.startMenu -isnot [bool] -or [bool]$shortcuts.startMenu -or + $shortcuts.desktop -isnot [bool] -or [bool]$shortcuts.desktop) { throw 'shortcuts' } + $afterHash = Get-CcodOfficialDraftObservedKeyHash -Facts $Facts + if ($afterHash -cne $ExpectedKeyHash) { throw 'key' } + return $afterHash + } catch { + if ($_.FullyQualifiedErrorId -like 'CCOD_ACCEPTANCE_*') { throw } + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_UNINSTALL_OBSERVATION_INVALID' 'Uninstall did not prove complete owned-state removal.' $null + } +} + +function ConvertTo-CcodOfficialDraftUninstallFacts { + param([Parameter(Mandatory)]$Facts,[Parameter(Mandatory)][string]$BeforeHash,[Parameter(Mandatory)][string]$AfterHash) + return [pscustomobject][ordered]@{ + stateRemoved = $true + installRootPresent = $false + appRootPresent = $false + runtimeRootPresent = $false + activePointerPresent = $false + taskState = 'Absent' + supervisorCount = 0 + trayHostCount = 0 + codexCount = 0 + shortcuts = [pscustomobject][ordered]@{ startMenu = $false; desktop = $false } + debugEndpointsGone = $true + deviceKeySha256Before = $BeforeHash + deviceKeySha256After = $AfterHash + keyHashPreserved = $true + operationOutcome = 'Completed' + } +} + +function ConvertTo-CcodOfficialDraftTerminalReceipt { + param([Parameter(Mandatory)]$Receipt) + try { + if (-not (Test-CcodOfficialDraftExactProperties -Value $Receipt -Expected @('kind','origin','runtimeId','runtimeGeneration','phase')) -or + $Receipt.kind -isnot [string] -or $Receipt.kind -cne 'RestartAndRepair' -or + $Receipt.origin -isnot [string] -or $Receipt.origin -cne 'Installer' -or + $Receipt.runtimeId -isnot [string] -or $Receipt.runtimeId -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,95}\z' -or + -not (Test-CcodOfficialDraftPositiveInteger $Receipt.runtimeGeneration) -or + $Receipt.phase -isnot [string] -or $Receipt.phase -cne 'Completed') { throw 'terminal receipt' } + return [pscustomobject][ordered]@{ + kind = 'RestartAndRepair' + origin = 'Installer' + runtimeId = [string]$Receipt.runtimeId + runtimeGeneration = [UInt64]$Receipt.runtimeGeneration + phase = 'Completed' + } + } catch { + if ($_.FullyQualifiedErrorId -like 'CCOD_ACCEPTANCE_*') { throw } + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_FRESH_INSTALL_OBSERVATION_INVALID' 'Fresh install did not expose a valid terminal lifecycle receipt.' $null + } +} + +function Assert-CcodOfficialDraftFreshFacts { + param([Parameter(Mandatory)]$Facts,[Parameter(Mandatory)][string]$ExpectedKeyHash,[string]$ExpectedRuntimeManifestSha256) + try { + foreach ($name in @('installRootPresent','appRootPresent','runtimeRootPresent','activePointerPresent','activeRuntimeId','activeGeneration','runtimeManifestSha256','supervisor','trayHost','codex','codexCount','statusCodex','trayAuthenticated','taskState','lifecycleReceipt','protectionReady')) { + if (-not (Test-CcodOfficialDraftHasFact -Facts $Facts -Name $name)) { throw 'missing fact' } + } + foreach ($name in @('installRootPresent','appRootPresent','runtimeRootPresent','activePointerPresent','trayAuthenticated','protectionReady')) { + $value = Get-CcodOfficialDraftFactValue -Facts $Facts -Name $name + if ($value -isnot [bool] -or -not [bool]$value) { throw 'readiness flag' } + } + $runtimeId = Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'activeRuntimeId' + $generation = Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'activeGeneration' + $manifestHash = Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'runtimeManifestSha256' + if ($runtimeId -isnot [string] -or $runtimeId -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,95}\z' -or + -not (Test-CcodOfficialDraftPositiveInteger $generation) -or + -not (Test-CcodOfficialDraftHash $manifestHash) -or + ($null -ne $ExpectedRuntimeManifestSha256 -and ($manifestHash -cne $ExpectedRuntimeManifestSha256))) { throw 'active generation' } + if ((Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'taskState') -isnot [string] -or + (Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'taskState') -notin @('Ready','Running')) { throw 'task' } + $codex = @(Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'codex') + $statusCodex = Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'statusCodex' + if (@(Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'supervisor').Count -ne 1 -or + @(Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'trayHost').Count -ne 1 -or + -not (Test-CcodOfficialDraftInt32 (Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'codexCount')) -or + [int](Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'codexCount') -ne 1 -or $codex.Count -ne 1 -or $null -eq $statusCodex) { throw 'process count' } + $codexIdentity = ConvertTo-CcodOfficialDraftSafeProcessIdentity -Value $codex[0] -Label 'fresh codex' + $statusCodexIdentity = ConvertTo-CcodOfficialDraftSafeProcessIdentity -Value $statusCodex -Label 'fresh status codex' + if ($codexIdentity.pid -ne $statusCodexIdentity.pid -or $codexIdentity.creationTimeUtc -cne $statusCodexIdentity.creationTimeUtc) { throw 'codex identity binding' } + $trayIdentity = ConvertTo-CcodOfficialDraftTrayHostIdentity -Value (Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'trayHostIdentity') + $observedTrayIdentity = ConvertTo-CcodOfficialDraftTrayHostIdentity -Value (Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'trayHost')[0] + if ($trayIdentity.pid -ne $observedTrayIdentity.pid -or $trayIdentity.creationTimeUtc -cne $observedTrayIdentity.creationTimeUtc) { throw 'tray identity binding' } + $terminal = ConvertTo-CcodOfficialDraftTerminalReceipt -Receipt (Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'lifecycleReceipt') + if ($terminal.runtimeId -cne $runtimeId -or [UInt64]$terminal.runtimeGeneration -ne [UInt64]$generation) { throw 'receipt binding' } + $keyHash = Get-CcodOfficialDraftObservedKeyHash -Facts $Facts + if ($keyHash -cne $ExpectedKeyHash) { throw 'key' } + return [pscustomobject][ordered]@{ + installRootPresent = $true + installReady = $true + activePointerPresent = $true + activeRuntimeId = [string]$runtimeId + activeGeneration = [UInt64]$generation + runtimeManifestSha256 = [string]$manifestHash + supervisorCount = 1 + trayHostCount = 1 + codexCount = 1 + trayHostIdentity = $trayIdentity + trayAuthenticated = $true + taskState = [string](Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'taskState') + terminalReceipt = $terminal + protectionReady = $true + deviceKeySha256 = $keyHash + operationOutcome = 'Completed' + } + } catch { + if ($_.FullyQualifiedErrorId -like 'CCOD_ACCEPTANCE_*') { throw } + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_FRESH_INSTALL_OBSERVATION_INVALID' 'Fresh install did not prove an active generation and authenticated protection.' $null + } +} + +function Get-CcodOfficialDraftFileSha256 { + param([Parameter(Mandatory)][string]$Path,[string]$ErrorId='CCOD_ACCEPTANCE_RECEIPT_INVALID',[string]$Message='The acceptance receipt could not be hashed safely.') + try { + $full = Assert-CcodOfficialDraftRegularFile -Path $Path -ErrorId $ErrorId + $sha = [Security.Cryptography.SHA256]::Create() + $stream = [IO.File]::OpenRead($full) + try { return ([BitConverter]::ToString($sha.ComputeHash($stream))).Replace('-','').ToLowerInvariant() } + finally { $stream.Dispose(); $sha.Dispose() } + } catch { + if ($_.FullyQualifiedErrorId -like 'CCOD_ACCEPTANCE_*') { throw } + Throw-CcodOfficialDraftError $ErrorId $Message $null + } +} + +function Write-CcodOfficialDraftJsonCreateOnly { + param([Parameter(Mandatory)][string]$Path,[Parameter(Mandatory)]$Record,[Parameter(Mandatory)][string]$ErrorId,[scriptblock]$Readback,[ref]$PublishedHash,[scriptblock]$BeforeTemporaryOpen) + $temporary = $null + $full = $null + $published = $false + $expectedHash = $null + try { + $full = [IO.Path]::GetFullPath($Path) + $parent = [IO.Directory]::GetParent($full) + if ($null -eq $parent) { throw 'parent' } + [void](Assert-CcodOfficialDraftSafeDirectory -Path $parent.FullName -ErrorId $ErrorId) + $existing = $null + try { $existing = Get-Item -LiteralPath $full -Force -ErrorAction Stop } catch { if (-not (Test-CcodOfficialDraftMissingError -ErrorRecord $_)) { throw } } + if ($null -ne $existing) { throw 'exists' } + $temporary = Join-Path $parent.FullName ('.manual-' + [guid]::NewGuid().ToString('N') + '.tmp') + $bytes = [Text.UTF8Encoding]::new($false).GetBytes(($Record | ConvertTo-Json -Depth 16)) + $sha = [Security.Cryptography.SHA256]::Create() + try { $expectedHash = ([BitConverter]::ToString($sha.ComputeHash($bytes))).Replace('-','').ToLowerInvariant() } finally { $sha.Dispose() } + if ($null -ne $BeforeTemporaryOpen) { & $BeforeTemporaryOpen $temporary } + $stream = [IO.File]::Open($temporary,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::Read) + try { $stream.Write($bytes,0,$bytes.Length); $stream.Flush($true) } finally { $stream.Dispose() } + [IO.File]::Move($temporary,$full) + $published = $true + if ($null -ne $PublishedHash) { $PublishedHash.Value = $expectedHash } + $targetItem = Get-Item -LiteralPath $full -Force -ErrorAction Stop + if ($targetItem -isnot [IO.FileInfo] -or ($targetItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'readback target' } + if ($null -ne $Readback -and -not [bool](& $Readback $full)) { throw 'readback verifier' } + $readbackObject = [IO.File]::ReadAllText($full,[Text.UTF8Encoding]::new($false,$true)) | ConvertFrom-Json -ErrorAction Stop + if ($null -eq $readbackObject -or (Get-CcodOfficialDraftFileSha256 -Path $full) -cne $expectedHash) { throw 'readback semantic mismatch' } + if ($null -ne $PublishedHash) { $PublishedHash.Value = $expectedHash } + return $full + } catch { + $failure = $_ + if ($null -ne $temporary) { + try { + $tempItems = @() + try { $tempItems = @(Get-Item -LiteralPath $temporary -Force -ErrorAction Stop) } catch { if (-not (Test-CcodOfficialDraftMissingError -ErrorRecord $_)) { throw } } + if ($tempItems.Count -gt 0) { + if ($tempItems.Count -ne 1 -or $tempItems[0] -isnot [IO.FileInfo] -or + ($tempItems[0].Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + $expectedHash -isnot [string] -or $expectedHash -notmatch '^[0-9a-f]{64}\z' -or + (Get-CcodOfficialDraftFileSha256 -Path $temporary) -cne $expectedHash) { throw 'temporary cleanup target' } + Remove-Item -LiteralPath $temporary -Force -ErrorAction Stop + } + $residual = $null + try { $residual = @(Get-Item -LiteralPath $temporary -Force -ErrorAction Stop) } catch { if (-not (Test-CcodOfficialDraftMissingError -ErrorRecord $_)) { throw } } + if ($null -ne $residual -and @($residual).Count -gt 0) { throw 'temporary cleanup residue' } + } catch { Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_CLEANUP_FAILED' 'Temporary manual receipt cleanup could not be proven.' $temporary } + } + if ($published) { + try { + $item = $null + try { $item = Get-Item -LiteralPath $full -Force -ErrorAction Stop } catch { if (-not (Test-CcodOfficialDraftMissingError -ErrorRecord $_)) { throw } } + if ($null -ne $item) { + if ($item -isnot [IO.FileInfo] -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + ($null -ne $expectedHash -and (Get-CcodOfficialDraftFileSha256 -Path $full) -cne $expectedHash)) { throw 'readback cleanup target' } + Remove-Item -LiteralPath $full -Force -ErrorAction Stop + } + $residual = $null + try { $residual = Get-Item -LiteralPath $full -Force -ErrorAction Stop } catch { if (-not (Test-CcodOfficialDraftMissingError -ErrorRecord $_)) { throw } } + if ($null -ne $residual) { throw 'readback cleanup residue' } + } catch { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_CLEANUP_FAILED' 'Receipt destination cleanup could not be proven after a failed readback.' $full + } + } + if ($failure.FullyQualifiedErrorId -like 'CCOD_ACCEPTANCE_*') { throw $failure } + Throw-CcodOfficialDraftError $ErrorId 'Manual acceptance evidence could not be written durably.' $full + } +} + +function Remove-CcodOfficialDraftCompleteArtifacts { + param([Parameter(Mandatory)][string[]]$Paths,[Parameter(Mandatory)][hashtable]$ExpectedHashes,[scriptblock]$RemovePath) + $failures = [Collections.Generic.List[string]]::new() + foreach ($path in $Paths) { + $full = [IO.Path]::GetFullPath($path) + try { + $hasExpected = $ExpectedHashes.ContainsKey($full) + $expected = $null + if ($hasExpected) { $expected = [string]$ExpectedHashes[$full] } + $items = @(Get-Item -LiteralPath $full -Force -ErrorAction Stop) + if ($items.Count -ne 1 -or $items[0] -isnot [IO.FileInfo] -or + ($items[0].Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + -not $hasExpected -or $expected -notmatch '^[0-9a-f]{64}\z' -or (Get-CcodOfficialDraftFileSha256 -Path $full) -cne $expected) { throw 'cleanup target' } + if ($null -eq $RemovePath) { Remove-Item -LiteralPath $full -Force -ErrorAction Stop } + else { & $RemovePath $full } + try { + @(Get-Item -LiteralPath $full -Force -ErrorAction Stop) | Out-Null + [void]$failures.Add($full) + } catch { + $missing = $_.CategoryInfo.Category -eq 'ObjectNotFound' -or + $_.Exception -is [IO.FileNotFoundException] -or $_.Exception -is [IO.DirectoryNotFoundException] + if (-not $missing) { [void]$failures.Add($full) } + } + } catch { + $missing = $_.CategoryInfo.Category -eq 'ObjectNotFound' -or + $_.Exception -is [IO.FileNotFoundException] -or $_.Exception -is [IO.DirectoryNotFoundException] + if ($missing) { + try { + [void](Assert-CcodOfficialDraftMissingPathAncestorsSafe -Path $full -GetItem { param($CandidatePath) Get-Item -LiteralPath $CandidatePath -Force -ErrorAction Stop } -ErrorId 'CCOD_ACCEPTANCE_COMPLETE_CLEANUP_FAILED') + } catch { [void]$failures.Add($full) } + } else { [void]$failures.Add($full) } + } + } + if ($failures.Count -gt 0) { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_COMPLETE_CLEANUP_FAILED' 'Complete acceptance cleanup could not prove terminal artifacts were removed.' ($failures -join ';') + } + return $true +} + +function Assert-CcodOfficialDraftEvidenceHashesStable { + param( + [Parameter(Mandatory)][string]$ScreenshotPath, + [Parameter(Mandatory)][string]$RedactedLogPath, + [Parameter(Mandatory)][string]$ExpectedScreenshotSha256, + [Parameter(Mandatory)][string]$ExpectedRedactedLogSha256 + ) + try { + if (-not (Test-CcodOfficialDraftHash $ExpectedScreenshotSha256) -or + -not (Test-CcodOfficialDraftHash $ExpectedRedactedLogSha256)) { throw 'expected evidence hashes' } + [void](Assert-CcodOfficialDraftRegularEvidenceFile -Path $ScreenshotPath) + [void](Assert-CcodOfficialDraftRegularEvidenceFile -Path $RedactedLogPath) + $screenshotHash = Get-CcodOfficialDraftFileSha256 -Path $ScreenshotPath -ErrorId 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID' -Message 'Manual screenshot could not be hashed safely.' + $redactedLogHash = Get-CcodOfficialDraftFileSha256 -Path $RedactedLogPath -ErrorId 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID' -Message 'Manual redacted log could not be hashed safely.' + if ($screenshotHash -cne $ExpectedScreenshotSha256 -or $redactedLogHash -cne $ExpectedRedactedLogSha256) { throw 'evidence changed' } + return [pscustomobject][ordered]@{ screenshotSha256 = $screenshotHash; redactedLogSha256 = $redactedLogHash } + } catch { + if ($_.FullyQualifiedErrorId -like 'CCOD_ACCEPTANCE_*') { throw } + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID' 'Manual evidence changed after operation proof.' $null + } +} + +function Get-CcodOfficialDraftManualArtifactPaths { + param([Parameter(Mandatory)][string]$ArtifactDirectory,[Parameter(Mandatory)][string]$Operation,[switch]$Create) + try { + $root = [IO.Path]::GetFullPath($ArtifactDirectory) + $parent = [IO.Directory]::GetParent($root) + if ($null -eq $parent) { throw 'artifact parent' } + [void](Assert-CcodOfficialDraftSafeDirectory -Path $parent.FullName -ErrorId 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID') + $rootItem = $null + try { $rootItem = Get-Item -LiteralPath $root -Force -ErrorAction Stop } catch { if (-not (Test-CcodOfficialDraftMissingError -ErrorRecord $_)) { throw } } + if ($null -ne $rootItem -and -not $rootItem.PSIsContainer) { throw 'artifact root file' } + if ($null -eq $rootItem) { + if (-not $Create) { throw 'artifact root missing' } + [IO.Directory]::CreateDirectory($root) | Out-Null + } + [void](Assert-CcodOfficialDraftSafeDirectory -Path $root -ErrorId 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID') + $operationDirectory = Join-Path $root $Operation + $operationItem = $null + try { $operationItem = Get-Item -LiteralPath $operationDirectory -Force -ErrorAction Stop } catch { if (-not (Test-CcodOfficialDraftMissingError -ErrorRecord $_)) { throw } } + if ($null -ne $operationItem -and -not $operationItem.PSIsContainer) { throw 'artifact operation file' } + if ($null -eq $operationItem) { + if (-not $Create) { throw 'artifact operation missing' } + [IO.Directory]::CreateDirectory($operationDirectory) | Out-Null + } + [void](Assert-CcodOfficialDraftSafeDirectory -Path $operationDirectory -ErrorId 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID') + return [pscustomobject][ordered]@{ + Directory = $operationDirectory + Screenshot = Join-Path $operationDirectory 'screenshot.bin' + RedactedLog = Join-Path $operationDirectory 'redacted.log' + } + } catch { + if ($_.FullyQualifiedErrorId -like 'CCOD_ACCEPTANCE_*') { throw } + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID' 'Manual artifact directory is missing, reused, or unsafe.' $ArtifactDirectory + } +} + +function Write-CcodOfficialDraftManualArtifacts { + param( + [Parameter(Mandatory)][string]$ArtifactDirectory, + [Parameter(Mandatory)][string]$Operation, + [Parameter(Mandatory)][string]$ScreenshotPath, + [Parameter(Mandatory)][string]$RedactedLogPath, + [Parameter(Mandatory)][string]$ExpectedScreenshotSha256, + [Parameter(Mandatory)][string]$ExpectedRedactedLogSha256 + ) + $paths = $null + $created = [Collections.Generic.List[string]]::new() + $temporary = [Collections.Generic.List[string]]::new() + $temporaryHashes = @{} + $createdHashes = @{} + try { + $paths = Get-CcodOfficialDraftManualArtifactPaths -ArtifactDirectory $ArtifactDirectory -Operation $Operation -Create + foreach ($target in @($paths.Screenshot,$paths.RedactedLog)) { + $targetItem = $null + try { $targetItem = Get-Item -LiteralPath $target -Force -ErrorAction Stop } catch { if (-not (Test-CcodOfficialDraftMissingError -ErrorRecord $_)) { throw } } + if ($null -ne $targetItem) { throw 'artifact reused' } + } + foreach ($pair in @( + [pscustomobject]@{ Source = $ScreenshotPath; Target = $paths.Screenshot; ExpectedHash = $ExpectedScreenshotSha256 } + [pscustomobject]@{ Source = $RedactedLogPath; Target = $paths.RedactedLog; ExpectedHash = $ExpectedRedactedLogSha256 } + )) { + $temporaryPath = Join-Path $paths.Directory ('.manual-artifact-' + [guid]::NewGuid().ToString('N') + '.tmp') + $temporary.Add($temporaryPath) + [IO.File]::Copy((Assert-CcodOfficialDraftRegularEvidenceFile -Path $pair.Source), $temporaryPath, $false) + [void](Assert-CcodOfficialDraftRegularEvidenceFile -Path $temporaryPath) + $temporaryHash = Get-CcodOfficialDraftFileSha256 -Path $temporaryPath -ErrorId 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID' -Message 'Temporary manual artifact could not be hashed safely.' + $temporaryHashes[[IO.Path]::GetFullPath($temporaryPath)] = $temporaryHash + if ($temporaryHash -cne $pair.ExpectedHash) { throw 'artifact hash mismatch' } + [IO.File]::Move($temporaryPath, $pair.Target) + [void]$temporary.Remove($temporaryPath) + $created.Add($pair.Target) + $createdHashes[[IO.Path]::GetFullPath($pair.Target)] = $temporaryHash + } + [void](Assert-CcodOfficialDraftRegularEvidenceFile -Path $paths.Screenshot) + [void](Assert-CcodOfficialDraftRegularEvidenceFile -Path $paths.RedactedLog) + $screenshotHash = Get-CcodOfficialDraftFileSha256 -Path $paths.Screenshot -ErrorId 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID' -Message 'Published manual screenshot could not be hashed safely.' + $redactedLogHash = Get-CcodOfficialDraftFileSha256 -Path $paths.RedactedLog -ErrorId 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID' -Message 'Published manual redacted log could not be hashed safely.' + if ($screenshotHash -cne $ExpectedScreenshotSha256 -or $redactedLogHash -cne $ExpectedRedactedLogSha256) { throw 'artifact hash mismatch' } + return $paths + } catch { + if ($temporary.Count -gt 0) { + try { [void](Remove-CcodOfficialDraftCompleteArtifacts -Paths @($temporary) -ExpectedHashes $temporaryHashes) } + catch { Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_CLEANUP_FAILED' 'Temporary manual artifact cleanup could not prove removal.' ($temporary -join ';') } + } + if ($created.Count -gt 0) { + try { [void](Remove-CcodOfficialDraftCompleteArtifacts -Paths @($created) -ExpectedHashes $createdHashes) } + catch { Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_CLEANUP_FAILED' 'Manual artifact cleanup could not prove removal after a failed evidence write.' ($created -join ';') } + } + if ($_.FullyQualifiedErrorId -like 'CCOD_ACCEPTANCE_*') { throw } + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID' 'Manual evidence artifacts could not be copied and verified.' $ArtifactDirectory + } +} + +function Write-CcodOfficialDraftManualEvidence { + param( + [Parameter(Mandatory)][string]$StateDirectory, + [Parameter(Mandatory)][string]$Phase, + [Parameter(Mandatory)][string]$Operation, + [Parameter(Mandatory)]$Candidate, + [Parameter(Mandatory)]$Existing, + [Parameter(Mandatory)]$ProgrammaticResult, + [Parameter(Mandatory)][string]$ScreenshotPath, + [Parameter(Mandatory)][string]$RedactedLogPath, + [Parameter(Mandatory)][string]$ArtifactDirectory, + [Parameter(Mandatory)][string]$ReviewState, + [string]$ExpectedChallenge, + [string]$ExpectedRuntimeId, + $ExpectedGeneration, + [string]$ExpectedManifestSha256 + ) + $artifactPaths = $null + $receiptPath = $null + $cleanupHashes = @{} + try { + if ($Phase -notin @('TrayEvidence','RemoteEvidence') -or + -not $script:CcodOfficialDraftManualFiles.ContainsKey($Operation) -or + ($Phase -ceq 'TrayEvidence' -and $Operation -ceq 'SecondDeviceControl') -or + ($Phase -ceq 'RemoteEvidence' -and $Operation -cne 'SecondDeviceControl') -or + $ReviewState -cne 'Reviewed' -or + -not (Test-CcodOfficialDraftExactProperties -Value $ProgrammaticResult -Expected @('verified','operation','terminalState','code','proof','screenshotSha256','redactedLogSha256')) -or + $ProgrammaticResult.verified -isnot [bool] -or -not [bool]$ProgrammaticResult.verified -or + $ProgrammaticResult.operation -isnot [string] -or $ProgrammaticResult.operation -cne $Operation -or + $ProgrammaticResult.terminalState -isnot [string] -or $ProgrammaticResult.terminalState -cne [string]$script:CcodOfficialDraftManualTerminalStates[$Operation] -or + $ProgrammaticResult.code -isnot [string] -or $ProgrammaticResult.code -cne [string]$script:CcodOfficialDraftManualResultCodes[$Operation] -or + $ProgrammaticResult.screenshotSha256 -isnot [string] -or $ProgrammaticResult.redactedLogSha256 -isnot [string]) { throw 'manual input' } + $proof = $null + if ($Operation -ceq 'SecondDeviceControl') { + $proof = ConvertTo-CcodOfficialDraftRemoteProof -Proof $ProgrammaticResult.proof + if (-not [string]::IsNullOrEmpty([string]$ExpectedChallenge) -and $proof.challenge -cne $ExpectedChallenge -or + -not [string]::IsNullOrEmpty([string]$ExpectedRuntimeId) -and $proof.runtimeId -cne $ExpectedRuntimeId -or + $null -ne $ExpectedGeneration -and ([UInt64]$proof.runtimeGeneration -ne [UInt64]$ExpectedGeneration) -or + -not [string]::IsNullOrEmpty([string]$ExpectedManifestSha256) -and $proof.runtimeManifestSha256 -cne $ExpectedManifestSha256 -or + $proof.candidateVersion -cne [string]$Candidate.version) { throw 'remote proof binding' } + } else { + $proof = ConvertTo-CcodOfficialDraftTrayProof -Proof $ProgrammaticResult.proof -Operation $Operation + } + $stableHashes = Assert-CcodOfficialDraftEvidenceHashesStable -ScreenshotPath $ScreenshotPath -RedactedLogPath $RedactedLogPath -ExpectedScreenshotSha256 $ProgrammaticResult.screenshotSha256 -ExpectedRedactedLogSha256 $ProgrammaticResult.redactedLogSha256 + $screenshotHash = [string]$stableHashes.screenshotSha256 + $logHash = [string]$stableHashes.redactedLogSha256 + if ($screenshotHash -ceq $logHash) { throw 'same file' } + foreach ($record in @($Existing.Values)) { + if ($null -eq $record) { continue } + if ([string]$record.operation -ceq $Operation -or + [string]$record.screenshotSha256 -ceq $screenshotHash -or + [string]$record.screenshotSha256 -ceq $logHash -or + [string]$record.redactedLogSha256 -ceq $screenshotHash -or + [string]$record.redactedLogSha256 -ceq $logHash) { throw 'manual reuse' } + } + $artifactPaths = Write-CcodOfficialDraftManualArtifacts -ArtifactDirectory $ArtifactDirectory -Operation $Operation -ScreenshotPath $ScreenshotPath -RedactedLogPath $RedactedLogPath -ExpectedScreenshotSha256 $screenshotHash -ExpectedRedactedLogSha256 $logHash + $cleanupHashes[[IO.Path]::GetFullPath($artifactPaths.Screenshot)] = $screenshotHash + $cleanupHashes[[IO.Path]::GetFullPath($artifactPaths.RedactedLog)] = $logHash + $record = [pscustomobject][ordered]@{ + schemaVersion = 1 + kind = 'manual-evidence' + phase = $Phase + operation = $Operation + terminalState = [string]$ProgrammaticResult.terminalState + result = [string]$ProgrammaticResult.code + proof = $proof + reviewState = 'Reviewed' + version = [string]$Candidate.version + gitCommit = [string]$Candidate.gitCommit + candidateManifestSha256 = [string]$Candidate.manifestHashes.portable + screenshotSha256 = $screenshotHash + redactedLogSha256 = $logHash + } + $path = Get-CcodOfficialDraftManualEvidencePath -StateDirectory $StateDirectory -Operation $Operation + $publishedReceiptHash = $null + $receiptPath = Write-CcodOfficialDraftJsonCreateOnly -Path $path -Record $record -ErrorId 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_WRITE_FAILED' -PublishedHash ([ref]$publishedReceiptHash) + $cleanupHashes[[IO.Path]::GetFullPath($receiptPath)] = $publishedReceiptHash + return (Assert-CcodOfficialDraftManualRecordShape -Record (Read-CcodOfficialDraftJson -Path $receiptPath).Value) + } catch { + $failure = $_ + $cleanupPaths = [Collections.Generic.List[string]]::new() + if ($null -ne $receiptPath) { $cleanupPaths.Add($receiptPath) } + if ($null -ne $artifactPaths) { $cleanupPaths.Add($artifactPaths.Screenshot); $cleanupPaths.Add($artifactPaths.RedactedLog) } + if ($cleanupPaths.Count -gt 0) { + try { [void](Remove-CcodOfficialDraftCompleteArtifacts -Paths @($cleanupPaths) -ExpectedHashes $cleanupHashes) } + catch { Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_CLEANUP_FAILED' 'Manual receipt and artifact cleanup could not prove removal after a failed record write.' ($cleanupPaths -join ';') } + } + if ($failure.FullyQualifiedErrorId -like 'CCOD_ACCEPTANCE_*') { throw $failure } + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID' 'Manual evidence is missing, reused, unreviewed, or unsafe.' $null + } +} + +function Assert-CcodOfficialDraftManualEvidenceArtifacts { + param([Parameter(Mandatory)][string]$EvidenceRoot,[Parameter(Mandatory)]$Records) + try { + $artifactRoot = Join-Path $EvidenceRoot $script:CcodOfficialDraftManualArtifactRootName + foreach ($record in @($Records)) { + $paths = Get-CcodOfficialDraftManualArtifactPaths -ArtifactDirectory $artifactRoot -Operation ([string]$record.operation) + [void](Assert-CcodOfficialDraftRegularEvidenceFile -Path $paths.Screenshot) + [void](Assert-CcodOfficialDraftRegularEvidenceFile -Path $paths.RedactedLog) + $screenshotHash = Get-CcodOfficialDraftFileSha256 -Path $paths.Screenshot -ErrorId 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID' -Message 'Persisted manual screenshot could not be hashed safely.' + $redactedLogHash = Get-CcodOfficialDraftFileSha256 -Path $paths.RedactedLog -ErrorId 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID' -Message 'Persisted manual redacted log could not be hashed safely.' + if ($screenshotHash.ToLowerInvariant() -cne ([string]$record.screenshotSha256).ToLowerInvariant() -or $redactedLogHash.ToLowerInvariant() -cne ([string]$record.redactedLogSha256).ToLowerInvariant()) { throw 'manual artifact hash' } + } + return $true + } catch { + if ($_.FullyQualifiedErrorId -like 'CCOD_ACCEPTANCE_*') { throw } + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID' 'Persisted manual evidence artifacts do not match their recorded hashes.' $EvidenceRoot + } +} + +function Write-CcodOfficialDraftCompleteAcceptance { + param([Parameter(Mandatory)][string]$EvidenceRoot,[string]$PreviousAssetDirectory,[Parameter(Mandatory)]$Candidate,[Parameter(Mandatory)]$Draft,[Parameter(Mandatory)]$ManualEvidence,[Parameter(Mandatory)]$AutomatedReceiptHashes,[Parameter(Mandatory)][datetime]$CompletedAtUtc,[string]$Path,[ref]$PublishedHash) + try { + if ($CompletedAtUtc.Kind -ne [DateTimeKind]::Utc) { throw 'complete clock kind' } + $stateDirectory = Get-CcodOfficialDraftStateDirectory -EvidenceRoot $EvidenceRoot + $reloadedRecords = Read-CcodOfficialDraftState -StateDirectory $stateDirectory -PreviousAssetDirectory $PreviousAssetDirectory + if (-not $reloadedRecords.ContainsKey('Preflight') -or + -not (Test-CcodOfficialDraftCandidateEqual -Left $Candidate -Right $reloadedRecords['Preflight'].candidate) -or + -not (Test-CcodOfficialDraftDraftEqual -Left $Draft -Right $reloadedRecords['Preflight'].draft)) { + throw 'complete chain identity' + } + $draftIdentity = ConvertTo-CcodOfficialDraftIdentity -Value $Draft + $manual = Assert-CcodOfficialDraftManualEvidenceSet -Records $reloadedRecords.ManualEvidence -Candidate $Candidate + [void](Assert-CcodOfficialDraftManualEvidenceArtifacts -EvidenceRoot $EvidenceRoot -Records $manual) + $automated = @($AutomatedReceiptHashes) + if ($automated.Count -ne ($script:CcodOfficialDraftPhases.Count + 1)) { throw 'automated receipt hash count' } + $normalizedAutomated = [Collections.Generic.List[object]]::new() + $expectedPhases = @($script:CcodOfficialDraftPhases) + @('Complete') + for ($index = 0; $index -lt $expectedPhases.Count; $index++) { + $entry = $automated[$index] + if ($null -eq $entry -or -not (Test-CcodOfficialDraftExactProperties -Value $entry -Expected @('phase','sha256')) -or + $entry.phase -isnot [string] -or $entry.phase -cne $expectedPhases[$index] -or + -not (Test-CcodOfficialDraftHash $entry.sha256)) { throw 'automated receipt hash shape' } + $leaf = if ($expectedPhases[$index] -ceq 'Complete') { $script:CcodOfficialDraftCompletionFile } else { $script:CcodOfficialDraftPhaseFiles[$expectedPhases[$index]] } + $statePath = Join-Path $stateDirectory $leaf + if ((Get-CcodOfficialDraftFileSha256 -Path $statePath) -cne [string]$entry.sha256) { throw 'automated receipt hash binding' } + $normalizedAutomated.Add([pscustomobject][ordered]@{ phase = $expectedPhases[$index]; sha256 = [string]$entry.sha256 }) + } + $path = if ([string]::IsNullOrWhiteSpace($Path)) { Get-CcodOfficialDraftCompleteAcceptancePath -EvidenceRoot $EvidenceRoot } else { [IO.Path]::GetFullPath($Path) } + $existing = $null + try { $existing = Get-Item -LiteralPath $path -Force -ErrorAction Stop } catch { if (-not (Test-CcodOfficialDraftMissingError -ErrorRecord $_)) { throw } } + if ($null -ne $existing) { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_PHASE_REUSED' 'The complete acceptance receipt already exists.' $null + } + $record = [pscustomobject][ordered]@{ + schemaVersion = 1 + kind = 'official-draft-acceptance' + version = [string]$Candidate.version + gitCommit = [string]$Candidate.gitCommit + draft = $draftIdentity + candidateManifestSha256 = [string]$Candidate.manifestHashes.portable + phase = 'Complete' + completedAtUtc = $CompletedAtUtc.ToUniversalTime().ToString('o',[Globalization.CultureInfo]::InvariantCulture) + automatedReceiptSha256 = @($normalizedAutomated) + manualEvidence = @($manual) + } + [void](Write-CcodOfficialDraftJsonCreateOnly -Path $path -Record $record -ErrorId 'CCOD_ACCEPTANCE_COMPLETE_WRITE_FAILED' -PublishedHash $PublishedHash) + return (Read-CcodOfficialDraftJson -Path $path).Value + } catch { + if ($_.FullyQualifiedErrorId -like 'CCOD_ACCEPTANCE_*') { throw } + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_COMPLETE_WRITE_FAILED' 'The complete official-draft acceptance receipt could not be written.' $null + } +} + +function Get-CcodOfficialDraftBootIdentity { + param([Parameter(Mandatory)]$Adapters) + try { + $values = @(& $Adapters.GetBootIdentity) + if ($values.Count -ne 1 -or $values[0] -isnot [string] -or + [string]$values[0] -notmatch '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\z') { throw 'boot identity' } + return [string]$values[0] + } catch { + if ($_.FullyQualifiedErrorId -like 'CCOD_ACCEPTANCE_*') { throw } + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_BOOT_ID_INVALID' 'The boot identity observation is missing or malformed.' $null + } +} + +function ConvertTo-CcodOfficialDraftPreRebootFacts { + param( + [Parameter(Mandatory)][string]$BootId, + [Parameter(Mandatory)][string]$FreshReceiptHash, + [Parameter(Mandatory)][string]$RuntimeId, + [Parameter(Mandatory)][UInt64]$Generation, + [Parameter(Mandatory)][string]$ManifestHash, + [Parameter(Mandatory)][string]$KeyHash, + [Parameter(Mandatory)]$Observation + ) + return [pscustomobject][ordered]@{ + rebootRequested = $true + bootIdBefore = $BootId + freshInstallReceiptSha256 = $FreshReceiptHash + activeRuntimeId = $RuntimeId + activeGeneration = $Generation + runtimeManifestSha256 = $ManifestHash + deviceKeySha256 = $KeyHash + operationOutcome = 'Completed' + observation = $Observation + } +} + +function Assert-CcodOfficialDraftPostRebootFacts { + param( + [Parameter(Mandatory)]$Facts, + [Parameter(Mandatory)]$FreshFacts, + [Parameter(Mandatory)][string]$ExpectedKeyHash, + [Parameter(Mandatory)][string]$BootBefore, + [Parameter(Mandatory)][string]$ExpectedVersion + ) + try { + foreach ($name in @('installRootPresent','activePointerPresent','bootId','transitionStage','protectionRecovered','protectionReady','taskState','supervisor','trayHost','codex','codexCount','statusCodex','trayAuthenticated','lifecycleReceipt','runtimeManifestSha256')) { + if (-not (Test-CcodOfficialDraftHasFact -Facts $Facts -Name $name)) { throw 'missing fact' } + } + foreach ($name in @('installRootPresent','activePointerPresent')) { + $presence = Get-CcodOfficialDraftFactValue -Facts $Facts -Name $name + if ($presence -isnot [bool] -or -not [bool]$presence) { throw 'installed state presence' } + } + $bootAfter = Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'bootId' + if ($bootAfter -isnot [string] -or $bootAfter -notmatch '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\z' -or $bootAfter -ceq $BootBefore) { throw 'boot' } + if ((Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'transitionStage') -isnot [string] -or + (Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'transitionStage') -cne 'Idle') { throw 'transition' } + foreach ($name in @('protectionRecovered','protectionReady','trayAuthenticated')) { + $value = Get-CcodOfficialDraftFactValue -Facts $Facts -Name $name + if ($value -isnot [bool] -or -not [bool]$value) { throw 'protection' } + } + if ((Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'taskState') -notin @('Ready','Running') -or + @(Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'supervisor').Count -ne 1 -or + @(Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'trayHost').Count -ne 1 -or + -not (Test-CcodOfficialDraftInt32 (Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'codexCount')) -or + [int](Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'codexCount') -ne 1 -or + @(Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'codex').Count -ne 1 -or + $null -eq (Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'statusCodex')) { throw 'runtime' } + $trayIdentity = ConvertTo-CcodOfficialDraftTrayHostIdentity -Value (Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'trayHostIdentity') + $observedTrayIdentity = ConvertTo-CcodOfficialDraftTrayHostIdentity -Value (Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'trayHost')[0] + if ($trayIdentity.pid -ne $observedTrayIdentity.pid -or $trayIdentity.creationTimeUtc -cne $observedTrayIdentity.creationTimeUtc) { throw 'tray identity binding' } + $terminal = ConvertTo-CcodOfficialDraftTerminalReceipt -Receipt (Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'lifecycleReceipt') + $expectedTerminal = Get-CcodOfficialDraftFactValue -Facts $FreshFacts -Name 'terminalReceipt' + $observedRuntimeId = Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'activeRuntimeId' + $observedGeneration = Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'activeGeneration' + $expectedRuntimeId = Get-CcodOfficialDraftFactValue -Facts $FreshFacts -Name 'activeRuntimeId' + $expectedGeneration = Get-CcodOfficialDraftFactValue -Facts $FreshFacts -Name 'activeGeneration' + $observedManifestHash = Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'runtimeManifestSha256' + $expectedManifestHash = Get-CcodOfficialDraftFactValue -Facts $FreshFacts -Name 'runtimeManifestSha256' + if ($observedRuntimeId -isnot [string] -or $observedRuntimeId -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,95}\z' -or + -not (Test-CcodOfficialDraftPositiveInteger $observedGeneration) -or + -not (Test-CcodOfficialDraftHash $observedManifestHash) -or $observedManifestHash -cne $expectedManifestHash -or + $terminal.runtimeId -cne $observedRuntimeId -or [UInt64]$terminal.runtimeGeneration -ne [UInt64]$observedGeneration -or + $observedRuntimeId -cne $expectedRuntimeId -or [UInt64]$observedGeneration -ne [UInt64]$expectedGeneration -or + $terminal.runtimeId -cne $expectedTerminal.runtimeId -or [UInt64]$terminal.runtimeGeneration -ne [UInt64]$expectedTerminal.runtimeGeneration) { throw 'receipt continuity' } + $keyHash = Get-CcodOfficialDraftObservedKeyHash -Facts $Facts + if ($keyHash -cne $ExpectedKeyHash) { throw 'key' } + $observation = ConvertTo-CcodOfficialDraftLegacyObservation -Facts $Facts -ExpectedVersion $ExpectedVersion + if ($observation.bootId -cne [string]$bootAfter -or $observation.activeRuntimeId -cne [string]$observedRuntimeId -or + [UInt64]$observation.activeGeneration -ne [UInt64]$observedGeneration -or + $observation.runtimeManifestSha256 -cne [string]$observedManifestHash -or + $observation.deviceKeySha256 -cne [string]$keyHash) { throw 'observation continuity' } + return [pscustomobject][ordered]@{ + observation = $observation + installRootPresent = $true + activePointerPresent = $true + bootIdBefore = $BootBefore + bootIdAfter = [string]$bootAfter + bootChanged = $true + receiptContinuity = $true + activeRuntimeId = [string]$observedRuntimeId + activeGeneration = [UInt64]$observedGeneration + runtimeManifestSha256 = [string]$observedManifestHash + transitionStage = 'Idle' + protectionRecovered = $true + protectionReady = $true + taskState = [string](Get-CcodOfficialDraftFactValue -Facts $Facts -Name 'taskState') + supervisorCount = 1 + trayHostCount = 1 + codexCount = 1 + trayHostIdentity = $trayIdentity + trayAuthenticated = $true + terminalReceipt = $terminal + deviceKeySha256 = $keyHash + keyHashPreserved = $true + operationOutcome = 'Completed' + } + } catch { + if ($_.FullyQualifiedErrorId -like 'CCOD_ACCEPTANCE_*') { throw } + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_POST_REBOOT_OBSERVATION_INVALID' 'Post-reboot observations did not prove a new boot and recovered protected runtime.' $null + } +} + +function Invoke-CcodOfficialDraftAcceptanceCore { + [CmdletBinding()] + param( + [Parameter(Mandatory)][ValidateSet('Preflight','LegacyUpgrade','Uninstall','FreshInstall','PreReboot','PostReboot','ReadyForManualEvidence','TrayEvidence','RemoteEvidence','Complete')][string]$Phase, + [Parameter(Mandatory)][string]$AssetDirectory, + [Parameter(Mandatory)][string]$PreviousAssetDirectory, + [Parameter(Mandatory)][string]$EvidenceRoot, + [string]$DraftId, + [switch]$AllowMachineMutation, + [switch]$AllowCodexRestart, + [switch]$AllowWindowsReboot, + [string]$TrayOperation, + [string]$RemoteOperation, + [string]$ScreenshotPath, + [string]$RedactedLogPath, + [string]$ReviewState, + [hashtable]$Adapters + ) + $phaseMatches = @(@($script:CcodOfficialDraftPhases) + @('TrayEvidence','RemoteEvidence','Complete') | Where-Object { [string]$_ -ieq [string]$Phase }) + if ($phaseMatches.Count -ne 1) { Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_PHASE_INVALID' 'The official-draft acceptance phase is unsupported.' $Phase } + $Phase = [string]$phaseMatches[0] + $operationLease = Open-CcodOfficialDraftOperationLease -EvidenceRoot $EvidenceRoot + try { + $stateDirectory = Get-CcodOfficialDraftStateDirectory -EvidenceRoot $EvidenceRoot + [void](Assert-CcodOfficialDraftSafeDirectory -Path $PreviousAssetDirectory -ErrorId 'CCOD_ACCEPTANCE_PREVIOUS_ASSET_INVALID') + $adapters = Resolve-CcodOfficialDraftAdapters -Adapters $Adapters + $candidateValue = $null + try { + $candidateValue = & $adapters.ValidateAssetSet $AssetDirectory $script:CcodOfficialDraftExpectedVersion + } catch { + if ($_.FullyQualifiedErrorId -like 'CCOD_ACCEPTANCE_*') { throw } + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_ASSET_CONTRACT_INVALID' 'The official draft does not satisfy the exact eleven-asset manifest contract.' $null + } + $candidate = ConvertTo-CcodOfficialDraftCandidate -Value $candidateValue + $draftValue = $null + try { $draftValue = & $adapters.GetDraftIdentity $AssetDirectory $candidate $DraftId } catch { Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_DRAFT_IDENTITY_INVALID' 'The official draft identity could not be read.' $null } + $draft = ConvertTo-CcodOfficialDraftIdentity -Value $draftValue + $records = Read-CcodOfficialDraftState -StateDirectory $stateDirectory -PreviousAssetDirectory $PreviousAssetDirectory + $allowExistingComplete = $false + if ($Phase -ceq 'Complete' -and $records.ContainsKey('Complete')) { + $existingAcceptance = $null + $acceptancePathForResume = Get-CcodOfficialDraftCompleteAcceptancePath -EvidenceRoot $EvidenceRoot + try { $existingAcceptance = Get-Item -LiteralPath $acceptancePathForResume -Force -ErrorAction Stop } catch { if (-not (Test-CcodOfficialDraftMissingError -ErrorRecord $_)) { Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_PHASE_REUSED' 'The complete acceptance artifact could not be inspected safely.' $acceptancePathForResume } } + $allowExistingComplete = $null -eq $existingAcceptance + } + Assert-CcodOfficialDraftPhaseState -Phase $Phase -Records $records -Candidate $candidate -Draft $draft -AllowExistingComplete:$allowExistingComplete + Assert-CcodOfficialDraftPhaseAuthorization -Phase $Phase -AllowMachineMutation:$AllowMachineMutation -AllowCodexRestart:$AllowCodexRestart -AllowWindowsReboot:$AllowWindowsReboot + if ($Phase -ceq 'Preflight') { + $scanReceipts = [Collections.Generic.List[object]]::new() + $expectedScans = @( + [pscustomobject]@{ Type = 'Setup'; Index = 5 }, + [pscustomobject]@{ Type = 'PortableZip'; Index = 0 } + ) + foreach ($scan in $expectedScans) { + $pending = Join-Path $stateDirectory ('.defender-' + $scan.Type + '-' + [guid]::NewGuid().ToString('N') + '.json') + $pendingExpected = @{ ([IO.Path]::GetFullPath($pending)) = $null } + try { + $rawReceipt = & $adapters.InvokeDefenderCheck $AssetDirectory $scan.Type 'InternetDownload' $candidate.version $candidate.gitCommit $pending + $pendingHash = $null + try { $pendingItem = Get-Item -LiteralPath $pending -Force -ErrorAction Stop; if ($null -ne $pendingItem) { $pendingHash = Get-CcodOfficialDraftFileSha256 -Path $pending } } catch { if (-not (Test-CcodOfficialDraftMissingError -ErrorRecord $_)) { throw } } + $pendingExpected = @{ ([IO.Path]::GetFullPath($pending)) = $pendingHash } + if ($null -eq $rawReceipt) { throw 'empty' } + $scanReceipts.Add((ConvertTo-CcodOfficialDraftDefenderReceipt -Receipt $rawReceipt -Candidate $candidate -AssetType $scan.Type)) + } catch { + if ($_.FullyQualifiedErrorId -like 'CCOD_ACCEPTANCE_*') { throw } + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_DEFENDER_RECEIPT_INVALID' 'The official downloaded-asset Defender scan did not produce a valid receipt.' $null + } finally { + try { [void](Remove-CcodOfficialDraftCompleteArtifacts -Paths @($pending) -ExpectedHashes $pendingExpected) } + catch { Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_DEFENDER_RECEIPT_CLEANUP_FAILED' 'Temporary Defender receipt cleanup could not be proven.' $pending } + } + } + $facts = [pscustomobject][ordered]@{ defenderReceipts = @($scanReceipts) } + $written = Write-CcodOfficialDraftReceipt -StateDirectory $stateDirectory -Phase $Phase -Candidate $candidate -Draft $draft -Facts $facts + return [pscustomobject][ordered]@{ phase = 'Preflight'; outcome = 'Completed'; complete = $false; candidate = $written.candidate; facts = $written.facts } + } + if ($Phase -ceq 'LegacyUpgrade') { + try { + $previousValues = @(& $adapters.ValidatePreviousSetup $PreviousAssetDirectory '2.5.21') + if ($previousValues.Count -ne 1 -or $null -eq $previousValues[0]) { throw 'previous' } + $previous = ConvertTo-CcodOfficialDraftPreviousSetup -Value $previousValues[0] + } catch { + if ($_.FullyQualifiedErrorId -like 'CCOD_ACCEPTANCE_*') { throw } + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_PREVIOUS_ASSET_INVALID' 'The previous public v2.5.21 Setup is not manifest-bound.' $null + } + $context = New-CcodOfficialDraftContext -Phase $Phase -AssetDirectory $AssetDirectory -PreviousAssetDirectory $PreviousAssetDirectory -EvidenceRoot $EvidenceRoot -Candidate $candidate -Draft $draft -PreviousSetup $previous -AllowMachineMutation:$AllowMachineMutation -AllowCodexRestart:$AllowCodexRestart -AllowWindowsReboot:$AllowWindowsReboot + try { + $beforeValues = @(& $adapters.CaptureFacts $context 'Before') + if ($beforeValues.Count -ne 1 -or $null -eq $beforeValues[0]) { throw 'before facts' } + $beforeObservation = ConvertTo-CcodOfficialDraftLegacyObservation -Facts $beforeValues[0] -ExpectedVersion $previous.version + $beforeHash = [string]$beforeObservation.deviceKeySha256 + } catch { + if ($_.FullyQualifiedErrorId -like 'CCOD_ACCEPTANCE_*') { throw } + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_OBSERVATION_UNAVAILABLE' 'The pre-upgrade lifecycle observation is unavailable.' $null + } + [void](Invoke-CcodOfficialDraftPhaseOperation -Adapters $adapters -Context $context) + try { + $afterValues = @(& $adapters.CaptureFacts $context 'After') + if ($afterValues.Count -ne 1 -or $null -eq $afterValues[0]) { throw 'after facts' } + $afterObservation = ConvertTo-CcodOfficialDraftLegacyObservation -Facts $afterValues[0] -ExpectedVersion $candidate.version + $afterHash = [string]$afterObservation.deviceKeySha256 + } catch { + if ($_.FullyQualifiedErrorId -like 'CCOD_ACCEPTANCE_*') { throw } + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_OBSERVATION_UNAVAILABLE' 'The post-upgrade lifecycle observation is unavailable.' $null + } + if ($beforeHash -cne $afterHash) { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_KEY_HASH_CHANGED' 'The DPAPI device-key hash changed during legacy upgrade.' $null + } + $facts = ConvertTo-CcodOfficialDraftLegacyFacts -PreviousSetup $previous -BeforeObservation $beforeObservation -AfterObservation $afterObservation -BeforeHash $beforeHash -AfterHash $afterHash + $written = Write-CcodOfficialDraftReceipt -StateDirectory $stateDirectory -Phase $Phase -Candidate $candidate -Draft $draft -Facts $facts + return [pscustomobject][ordered]@{ phase = 'LegacyUpgrade'; outcome = 'Completed'; complete = $false; candidate = $written.candidate; facts = $written.facts } + } + if ($Phase -ceq 'Uninstall') { + $legacyFacts = $records.LegacyUpgrade.facts + $expectedKeyHash = Get-CcodOfficialDraftFactValue -Facts $legacyFacts -Name 'deviceKeySha256After' + if (-not (Test-CcodOfficialDraftHash $expectedKeyHash)) { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_RECEIPT_INVALID' 'LegacyUpgrade does not contain a usable device-key hash.' $null + } + $context = New-CcodOfficialDraftContext -Phase $Phase -AssetDirectory $AssetDirectory -PreviousAssetDirectory $PreviousAssetDirectory -EvidenceRoot $EvidenceRoot -Candidate $candidate -Draft $draft -PreviousSetup $legacyFacts.previousSetup -AllowMachineMutation:$AllowMachineMutation -AllowCodexRestart:$AllowCodexRestart -AllowWindowsReboot:$AllowWindowsReboot + try { + $beforeValues = @(& $adapters.CaptureFacts $context 'Before') + if ($beforeValues.Count -ne 1 -or $null -eq $beforeValues[0]) { throw 'before facts' } + $beforeHash = Get-CcodOfficialDraftObservedKeyHash -Facts $beforeValues[0] + } catch { + if ($_.FullyQualifiedErrorId -like 'CCOD_ACCEPTANCE_*') { throw } + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_OBSERVATION_UNAVAILABLE' 'The pre-uninstall lifecycle observation is unavailable.' $null + } + if ($beforeHash -cne [string]$expectedKeyHash) { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_KEY_HASH_CHANGED' 'The DPAPI device-key hash changed before uninstall.' $null + } + [void](Invoke-CcodOfficialDraftPhaseOperation -Adapters $adapters -Context $context) + try { + $afterValues = @(& $adapters.CaptureFacts $context 'After') + if ($afterValues.Count -ne 1 -or $null -eq $afterValues[0]) { throw 'after facts' } + $afterHash = Assert-CcodOfficialDraftUninstallFacts -Facts $afterValues[0] -ExpectedKeyHash $expectedKeyHash + } catch { + if ($_.FullyQualifiedErrorId -like 'CCOD_ACCEPTANCE_*') { throw } + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_UNINSTALL_OBSERVATION_INVALID' 'Uninstall did not prove complete owned-state removal.' $null + } + $facts = ConvertTo-CcodOfficialDraftUninstallFacts -Facts $afterValues[0] -BeforeHash $beforeHash -AfterHash $afterHash + $written = Write-CcodOfficialDraftReceipt -StateDirectory $stateDirectory -Phase $Phase -Candidate $candidate -Draft $draft -Facts $facts + return [pscustomobject][ordered]@{ phase = 'Uninstall'; outcome = 'Completed'; complete = $false; candidate = $written.candidate; facts = $written.facts } + } + if ($Phase -ceq 'FreshInstall') { + $uninstallFacts = $records.Uninstall.facts + $expectedKeyHash = Get-CcodOfficialDraftFactValue -Facts $uninstallFacts -Name 'deviceKeySha256After' + if (-not (Test-CcodOfficialDraftHash $expectedKeyHash)) { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_RECEIPT_INVALID' 'Uninstall does not contain a usable device-key hash.' $null + } + $previousSetup = $records.LegacyUpgrade.facts.previousSetup + $context = New-CcodOfficialDraftContext -Phase $Phase -AssetDirectory $AssetDirectory -PreviousAssetDirectory $PreviousAssetDirectory -EvidenceRoot $EvidenceRoot -Candidate $candidate -Draft $draft -PreviousSetup $previousSetup -AllowMachineMutation:$AllowMachineMutation -AllowCodexRestart:$AllowCodexRestart -AllowWindowsReboot:$AllowWindowsReboot + $operationResult = Invoke-CcodOfficialDraftPhaseOperation -Adapters $adapters -Context $context + try { + $afterValues = @(& $adapters.CaptureFacts $context 'After') + if ($afterValues.Count -ne 1 -or $null -eq $afterValues[0]) { throw 'after facts' } + $facts = Assert-CcodOfficialDraftFreshFacts -Facts $afterValues[0] -ExpectedKeyHash $expectedKeyHash -ExpectedRuntimeManifestSha256 $operationResult.runtimeManifestSha256 + } catch { + if ($_.FullyQualifiedErrorId -like 'CCOD_ACCEPTANCE_*') { throw } + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_FRESH_INSTALL_OBSERVATION_INVALID' 'Fresh install did not prove an active generation and authenticated protection.' $null + } + $written = Write-CcodOfficialDraftReceipt -StateDirectory $stateDirectory -Phase $Phase -Candidate $candidate -Draft $draft -Facts $facts + return [pscustomobject][ordered]@{ phase = 'FreshInstall'; outcome = 'Completed'; complete = $false; candidate = $written.candidate; facts = $written.facts } + } + if ($Phase -ceq 'PreReboot') { + $freshFacts = $records.FreshInstall.facts + $expectedKeyHash = Get-CcodOfficialDraftFactValue -Facts $freshFacts -Name 'deviceKeySha256' + $runtimeId = Get-CcodOfficialDraftFactValue -Facts $freshFacts -Name 'activeRuntimeId' + $generation = Get-CcodOfficialDraftFactValue -Facts $freshFacts -Name 'activeGeneration' + if (-not (Test-CcodOfficialDraftHash $expectedKeyHash) -or + $runtimeId -isnot [string] -or $runtimeId -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,95}\z' -or + -not (Test-CcodOfficialDraftPositiveInteger $generation)) { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_RECEIPT_INVALID' 'FreshInstall does not contain a usable continuity identity.' $null + } + $freshPath = Join-Path $stateDirectory $script:CcodOfficialDraftPhaseFiles.FreshInstall + $freshHash = Get-CcodOfficialDraftFileSha256 -Path $freshPath + $bootBefore = Get-CcodOfficialDraftBootIdentity -Adapters $adapters + $context = New-CcodOfficialDraftContext -Phase $Phase -AssetDirectory $AssetDirectory -PreviousAssetDirectory $PreviousAssetDirectory -EvidenceRoot $EvidenceRoot -Candidate $candidate -Draft $draft -PreviousSetup $records.LegacyUpgrade.facts.previousSetup -AllowMachineMutation:$AllowMachineMutation -AllowCodexRestart:$AllowCodexRestart -AllowWindowsReboot:$AllowWindowsReboot + try { + $beforeValues = @(& $adapters.CaptureFacts $context 'Before') + if ($beforeValues.Count -ne 1 -or $null -eq $beforeValues[0]) { throw 'before facts' } + $beforeObservation = ConvertTo-CcodOfficialDraftLegacyObservation -Facts $beforeValues[0] -ExpectedVersion $candidate.version + if ($beforeObservation.deviceKeySha256 -cne [string]$expectedKeyHash -or + $beforeObservation.activeRuntimeId -cne [string]$runtimeId -or + [UInt64]$beforeObservation.activeGeneration -ne [UInt64]$generation -or + $beforeObservation.runtimeManifestSha256 -cne [string]$freshFacts.runtimeManifestSha256 -or + $beforeObservation.bootId -isnot [string] -or $beforeObservation.bootId -cne $bootBefore) { throw 'pre-reboot continuity' } + } catch { + if ($_.FullyQualifiedErrorId -like 'CCOD_ACCEPTANCE_*') { throw } + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_OBSERVATION_UNAVAILABLE' 'The pre-reboot lifecycle observation is unavailable.' $null + } + $facts = ConvertTo-CcodOfficialDraftPreRebootFacts -BootId $bootBefore -FreshReceiptHash $freshHash -RuntimeId $runtimeId -Generation ([UInt64]$generation) -ManifestHash ([string]$freshFacts.runtimeManifestSha256) -KeyHash $expectedKeyHash -Observation $beforeObservation + $preRebootHash = $null + $written = Write-CcodOfficialDraftReceipt -StateDirectory $stateDirectory -Phase $Phase -Candidate $candidate -Draft $draft -Facts $facts -PublishedHash ([ref]$preRebootHash) + $preRebootPath = Join-Path $stateDirectory $script:CcodOfficialDraftPhaseFiles.PreReboot + $preRebootExpected = @{ ([IO.Path]::GetFullPath($preRebootPath)) = $preRebootHash } + try { + $rebootValues = @(& $adapters.Reboot $context) + if ($rebootValues.Count -ne 1 -or $rebootValues[0] -isnot [bool] -or -not [bool]$rebootValues[0]) { throw 'reboot' } + } catch { + try { + [void](Remove-CcodOfficialDraftCompleteArtifacts -Paths @($preRebootPath) -ExpectedHashes $preRebootExpected) + } catch { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_REBOOT_FAILED' 'The failed reboot receipt could not be removed safely.' $null + } + if ($_.FullyQualifiedErrorId -like 'CCOD_ACCEPTANCE_*') { throw } + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_REBOOT_FAILED' 'The authorized Windows reboot did not return proven completion.' $null + } + return [pscustomobject][ordered]@{ phase = 'PreReboot'; outcome = 'Completed'; complete = $false; candidate = $written.candidate; facts = $written.facts } + } + if ($Phase -ceq 'PostReboot') { + $freshFacts = $records.FreshInstall.facts + $preFacts = $records.PreReboot.facts + $expectedKeyHash = Get-CcodOfficialDraftFactValue -Facts $freshFacts -Name 'deviceKeySha256' + $bootBefore = Get-CcodOfficialDraftFactValue -Facts $preFacts -Name 'bootIdBefore' + $freshReceiptHash = Get-CcodOfficialDraftFactValue -Facts $preFacts -Name 'freshInstallReceiptSha256' + if (-not (Test-CcodOfficialDraftHash $expectedKeyHash) -or + $bootBefore -isnot [string] -or $bootBefore -notmatch '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\z' -or + -not (Test-CcodOfficialDraftHash $freshReceiptHash)) { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_RECEIPT_INVALID' 'PreReboot does not contain a usable continuity identity.' $null + } + $freshPath = Join-Path $stateDirectory $script:CcodOfficialDraftPhaseFiles.FreshInstall + if ((Get-CcodOfficialDraftFileSha256 -Path $freshPath) -cne [string]$freshReceiptHash) { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_RECEIPT_INVALID' 'The FreshInstall receipt changed after the reboot boundary.' $null + } + $bootAfter = Get-CcodOfficialDraftBootIdentity -Adapters $adapters + $context = New-CcodOfficialDraftContext -Phase $Phase -AssetDirectory $AssetDirectory -PreviousAssetDirectory $PreviousAssetDirectory -EvidenceRoot $EvidenceRoot -Candidate $candidate -Draft $draft -PreviousSetup $records.LegacyUpgrade.facts.previousSetup -AllowMachineMutation:$AllowMachineMutation -AllowCodexRestart:$AllowCodexRestart -AllowWindowsReboot:$AllowWindowsReboot + try { + $afterValues = @(& $adapters.CaptureFacts $context 'After') + if ($afterValues.Count -ne 1 -or $null -eq $afterValues[0]) { throw 'after facts' } + $facts = Assert-CcodOfficialDraftPostRebootFacts -Facts $afterValues[0] -FreshFacts $freshFacts -ExpectedKeyHash $expectedKeyHash -BootBefore $bootBefore -ExpectedVersion $candidate.version + } catch { + if ($_.FullyQualifiedErrorId -like 'CCOD_ACCEPTANCE_*') { throw } + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_POST_REBOOT_OBSERVATION_INVALID' 'Post-reboot observations did not prove a new boot and recovered protected runtime.' $null + } + if ($bootAfter -cne [string]$facts.bootIdAfter) { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_BOOT_ID_INVALID' 'The post-reboot boot identity changed during observation.' $null + } + $written = Write-CcodOfficialDraftReceipt -StateDirectory $stateDirectory -Phase $Phase -Candidate $candidate -Draft $draft -Facts $facts + return [pscustomobject][ordered]@{ phase = 'PostReboot'; outcome = 'Completed'; complete = $false; candidate = $written.candidate; facts = $written.facts } + } + if ($Phase -ceq 'ReadyForManualEvidence') { + $manualChallenge = [guid]::NewGuid().ToString('N') + $facts = [pscustomobject][ordered]@{ + automatedPhases = @('Preflight','LegacyUpgrade','Uninstall','FreshInstall','PreReboot','PostReboot') + status = 'ReadyForManualEvidence' + manualEvidencePending = $true + complete = $false + manualChallenge = $manualChallenge + } + $written = Write-CcodOfficialDraftReceipt -StateDirectory $stateDirectory -Phase $Phase -Candidate $candidate -Draft $draft -Facts $facts + return [pscustomobject][ordered]@{ phase = 'ReadyForManualEvidence'; outcome = 'ReadyForManualEvidence'; complete = $false; candidate = $written.candidate; facts = $written.facts } + } + if ($Phase -in @('TrayEvidence','RemoteEvidence')) { + if (($Phase -ceq 'TrayEvidence' -and [string]::IsNullOrWhiteSpace($TrayOperation)) -or + ($Phase -ceq 'RemoteEvidence' -and [string]::IsNullOrWhiteSpace($RemoteOperation)) -or + ($Phase -ceq 'TrayEvidence' -and -not [string]::IsNullOrWhiteSpace($RemoteOperation)) -or + ($Phase -ceq 'RemoteEvidence' -and -not [string]::IsNullOrWhiteSpace($TrayOperation)) -or + [string]::IsNullOrWhiteSpace($ScreenshotPath) -or [string]::IsNullOrWhiteSpace($RedactedLogPath) -or + [string]::IsNullOrWhiteSpace($ReviewState)) { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID' 'Manual evidence requires exactly one bounded operation, two evidence files, and Reviewed state.' $null + } + $rawOperation = if ($Phase -ceq 'TrayEvidence') { $TrayOperation } else { $RemoteOperation } + $operationMatches = @($script:CcodOfficialDraftManualOperations | Where-Object { [string]$_ -ieq [string]$rawOperation }) + if ($operationMatches.Count -ne 1) { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_MANUAL_OPERATION_INVALID' 'The manual evidence operation is unsupported.' $null + } + $operation = [string]$operationMatches[0] + $canonicalReviewState = if ([string]$ReviewState -ieq 'Reviewed') { 'Reviewed' } else { [string]$ReviewState } + $context = New-CcodOfficialDraftContext -Phase $Phase -AssetDirectory $AssetDirectory -PreviousAssetDirectory $PreviousAssetDirectory -EvidenceRoot $EvidenceRoot -Candidate $candidate -Draft $draft -PreviousSetup $records.LegacyUpgrade.facts.previousSetup -AllowMachineMutation:$AllowMachineMutation -AllowCodexRestart:$AllowCodexRestart -AllowWindowsReboot:$AllowWindowsReboot + $context | Add-Member -NotePropertyName operation -NotePropertyValue $operation + $context | Add-Member -NotePropertyName screenshotPath -NotePropertyValue ([IO.Path]::GetFullPath($ScreenshotPath)) + $context | Add-Member -NotePropertyName redactedLogPath -NotePropertyValue ([IO.Path]::GetFullPath($RedactedLogPath)) + $readyChallenge = Get-CcodOfficialDraftFactValue -Facts $records.ReadyForManualEvidence.facts -Name 'manualChallenge' + if ($readyChallenge -isnot [string] -or $readyChallenge -cnotmatch '^[0-9a-f]{32}\z') { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_RECEIPT_INVALID' 'ReadyForManualEvidence does not contain a usable manual challenge.' $null + } + $context | Add-Member -NotePropertyName manualChallenge -NotePropertyValue ([string]$readyChallenge) + $context | Add-Member -NotePropertyName expectedRuntimeId -NotePropertyValue ([string](Get-CcodOfficialDraftFactValue -Facts $records.FreshInstall.facts -Name 'activeRuntimeId')) + $context | Add-Member -NotePropertyName expectedGeneration -NotePropertyValue ([UInt64](Get-CcodOfficialDraftFactValue -Facts $records.FreshInstall.facts -Name 'activeGeneration')) + $context | Add-Member -NotePropertyName expectedRuntimeManifestSha256 -NotePropertyValue ([string](Get-CcodOfficialDraftFactValue -Facts $records.FreshInstall.facts -Name 'runtimeManifestSha256')) + $context | Add-Member -NotePropertyName expectedDeviceKeySha256 -NotePropertyValue ([string](Get-CcodOfficialDraftFactValue -Facts $records.FreshInstall.facts -Name 'deviceKeySha256')) + $context | Add-Member -NotePropertyName expectedBootId -NotePropertyValue ([string](Get-CcodOfficialDraftFactValue -Facts $records.PostReboot.facts -Name 'bootIdAfter')) + try { + $programmaticValues = @(& $adapters.RunManualOperation $context $operation) + if ($programmaticValues.Count -ne 1 -or $null -eq $programmaticValues[0] -or + -not (Test-CcodOfficialDraftExactProperties -Value $programmaticValues[0] -Expected @('verified','operation','terminalState','code','proof','screenshotSha256','redactedLogSha256')) -or + $programmaticValues[0].verified -isnot [bool] -or -not [bool]$programmaticValues[0].verified -or + $programmaticValues[0].screenshotSha256 -isnot [string] -or -not (Test-CcodOfficialDraftHash $programmaticValues[0].screenshotSha256) -or + $programmaticValues[0].redactedLogSha256 -isnot [string] -or -not (Test-CcodOfficialDraftHash $programmaticValues[0].redactedLogSha256)) { throw 'manual operation result' } + $programmaticResult = $programmaticValues[0] + } catch { + if ($_.FullyQualifiedErrorId -like 'CCOD_ACCEPTANCE_*') { throw } + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_MANUAL_OPERATION_UNPROVEN' 'The requested manual operation did not produce programmatic proof.' $operation + } + try { + $manualObservationValues = @(& $adapters.CaptureFacts $context 'AfterManualOperation') + if ($manualObservationValues.Count -ne 1 -or $null -eq $manualObservationValues[0]) { throw 'manual observation' } + $manualObservation = ConvertTo-CcodOfficialDraftLegacyObservation -Facts $manualObservationValues[0] -ExpectedVersion ([string]$context.expectedVersion) + if ($manualObservation.activeRuntimeId -cne [string]$context.expectedRuntimeId -or + [UInt64]$manualObservation.activeGeneration -ne [UInt64]$context.expectedGeneration -or + $manualObservation.runtimeManifestSha256 -cne [string]$context.expectedRuntimeManifestSha256 -or + $manualObservation.deviceKeySha256 -cne [string]$context.expectedDeviceKeySha256 -or + $manualObservation.bootId -cne [string]$context.expectedBootId) { throw 'manual observation continuity' } + } catch { + $failure = $_ + if ($failure.FullyQualifiedErrorId -like 'CCOD_ACCEPTANCE_*') { throw $failure } + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_MANUAL_OPERATION_UNPROVEN' 'The current runtime identity changed or could not be re-observed after the manual operation.' $operation + } + $existing = if ($records.ContainsKey('ManualEvidence')) { $records.ManualEvidence } else { @{} } + $manualRecord = Write-CcodOfficialDraftManualEvidence -StateDirectory $stateDirectory -Phase $Phase -Operation $operation -Candidate $candidate -Existing $existing -ProgrammaticResult $programmaticResult -ScreenshotPath $ScreenshotPath -RedactedLogPath $RedactedLogPath -ArtifactDirectory (Join-Path ([IO.Path]::GetFullPath($EvidenceRoot)) 'official-draft-artifacts') -ReviewState $canonicalReviewState -ExpectedChallenge ([string]$context.manualChallenge) -ExpectedRuntimeId ([string]$context.expectedRuntimeId) -ExpectedGeneration $context.expectedGeneration -ExpectedManifestSha256 ([string]$context.expectedRuntimeManifestSha256) + return [pscustomobject][ordered]@{ phase = $Phase; outcome = 'Completed'; complete = $false; candidate = $candidate; facts = $manualRecord } + } + if ($Phase -ceq 'Complete') { + if (-not [string]::IsNullOrWhiteSpace($TrayOperation) -or -not [string]::IsNullOrWhiteSpace($RemoteOperation) -or + -not [string]::IsNullOrWhiteSpace($ScreenshotPath) -or -not [string]::IsNullOrWhiteSpace($RedactedLogPath) -or + -not [string]::IsNullOrWhiteSpace($ReviewState)) { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID' 'Complete does not accept an individual manual evidence payload.' $null + } + $manual = Assert-CcodOfficialDraftManualEvidenceSet -Records $records.ManualEvidence -Candidate $candidate + $nowValues = @(& $adapters.GetUtcNow) + if ($nowValues.Count -ne 1 -or $nowValues[0] -isnot [datetime] -or $nowValues[0].Kind -ne [DateTimeKind]::Utc) { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_COMPLETE_WRITE_FAILED' 'Complete acceptance requires a valid UTC clock.' $null + } + $facts = [pscustomobject][ordered]@{ manualEvidence = @($manual); status = 'Complete'; complete = $true } + $acceptancePath = Get-CcodOfficialDraftCompleteAcceptancePath -EvidenceRoot $EvidenceRoot + $statePath = Join-Path $stateDirectory $script:CcodOfficialDraftCompletionFile + $completeCleanupExpected = @{} + $completeReceiptHash = $null + $completeAcceptanceHash = $null + try { + $written = Write-CcodOfficialDraftReceipt -StateDirectory $stateDirectory -Phase $Phase -Candidate $candidate -Draft $draft -Facts $facts -PublishedHash ([ref]$completeReceiptHash) + if (Test-CcodOfficialDraftHash $completeReceiptHash) { $completeCleanupExpected[[IO.Path]::GetFullPath($statePath)] = $completeReceiptHash } + $automatedHashes = [Collections.Generic.List[object]]::new() + foreach ($automatedPhase in $script:CcodOfficialDraftPhases) { + $automatedHashes.Add([pscustomobject][ordered]@{ + phase = [string]$automatedPhase + sha256 = Get-CcodOfficialDraftFileSha256 -Path (Join-Path $stateDirectory $script:CcodOfficialDraftPhaseFiles[$automatedPhase]) + }) + } + $automatedHashes.Add([pscustomobject][ordered]@{ phase = 'Complete'; sha256 = Get-CcodOfficialDraftFileSha256 -Path $statePath }) + $writtenAcceptance = Write-CcodOfficialDraftCompleteAcceptance -EvidenceRoot $EvidenceRoot -PreviousAssetDirectory $PreviousAssetDirectory -Candidate $candidate -Draft $draft -ManualEvidence $manual -AutomatedReceiptHashes @($automatedHashes) -CompletedAtUtc $nowValues[0] -Path $acceptancePath -PublishedHash ([ref]$completeAcceptanceHash) + if (Test-CcodOfficialDraftHash $completeAcceptanceHash) { $completeCleanupExpected[[IO.Path]::GetFullPath($acceptancePath)] = $completeAcceptanceHash } + } catch { + $failure = $_ + if ($completeReceiptHash -is [string] -and $completeReceiptHash -cmatch '^[0-9a-f]{64}\z') { + $completeCleanupExpected[[IO.Path]::GetFullPath($statePath)] = $completeReceiptHash + } + if ($completeAcceptanceHash -is [string] -and $completeAcceptanceHash -cmatch '^[0-9a-f]{64}\z') { + $completeCleanupExpected[[IO.Path]::GetFullPath($acceptancePath)] = $completeAcceptanceHash + } + try { + $ownedPaths = @($acceptancePath,$statePath | Where-Object { $completeCleanupExpected.ContainsKey([IO.Path]::GetFullPath($_)) }) + if ($ownedPaths.Count -gt 0) { [void](Remove-CcodOfficialDraftCompleteArtifacts -Paths $ownedPaths -ExpectedHashes $completeCleanupExpected) } + } catch { + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_COMPLETE_CLEANUP_FAILED' 'Complete acceptance cleanup failed and terminal evidence may remain.' $null + } + throw $failure + } + return [pscustomobject][ordered]@{ phase = 'Complete'; outcome = 'Complete'; complete = $true; candidate = $written.candidate; facts = $written.facts } + } + Throw-CcodOfficialDraftError 'CCOD_ACCEPTANCE_NOT_IMPLEMENTED' 'The official-draft acceptance state machine phase is not implemented.' $Phase + } finally { Close-CcodOfficialDraftOperationLease $operationLease } +} + +function Invoke-CcodOfficialDraftAcceptance { + [CmdletBinding()] + param( + [Parameter(Mandatory)][ValidateSet('Preflight','LegacyUpgrade','Uninstall','FreshInstall','PreReboot','PostReboot','ReadyForManualEvidence','TrayEvidence','RemoteEvidence','Complete')][string]$Phase, + [Parameter(Mandatory)][string]$AssetDirectory, + [Parameter(Mandatory)][string]$PreviousAssetDirectory, + [Parameter(Mandatory)][string]$EvidenceRoot, + [string]$DraftId, + [switch]$AllowMachineMutation, + [switch]$AllowCodexRestart, + [switch]$AllowWindowsReboot, + [ValidateSet('About','Language','OpenLogs','Repair')][string]$TrayOperation, + [ValidateSet('SecondDeviceControl')][string]$RemoteOperation, + [string]$ScreenshotPath, + [string]$RedactedLogPath, + [ValidateSet('Reviewed')][string]$ReviewState + ) + Invoke-CcodOfficialDraftAcceptanceCore ` + -Phase $Phase ` + -AssetDirectory $AssetDirectory ` + -PreviousAssetDirectory $PreviousAssetDirectory ` + -EvidenceRoot $EvidenceRoot ` + -DraftId $DraftId ` + -AllowMachineMutation:$AllowMachineMutation ` + -AllowCodexRestart:$AllowCodexRestart ` + -AllowWindowsReboot:$AllowWindowsReboot ` + -TrayOperation $TrayOperation ` + -RemoteOperation $RemoteOperation ` + -ScreenshotPath $ScreenshotPath ` + -RedactedLogPath $RedactedLogPath ` + -ReviewState $ReviewState +} + +Export-ModuleMember -Function Invoke-CcodOfficialDraftAcceptance diff --git a/tests/persistence/Bootstrap.SelfTest.ps1 b/tests/persistence/Bootstrap.SelfTest.ps1 index d8473b4..39e18d1 100644 --- a/tests/persistence/Bootstrap.SelfTest.ps1 +++ b/tests/persistence/Bootstrap.SelfTest.ps1 @@ -114,7 +114,9 @@ function Add-CcodTestRuntime { [Parameter(Mandatory)][string]$SupervisorScript, [AllowNull()][string]$RuntimeId, [bool]$IncludeFenceModules = $true, - [bool]$FailLifecycleRelease = $false + [bool]$FailLifecycleRelease = $false, + [AllowNull()][string]$LifecycleReleaseMarkerPath, + [bool]$IncludeGenerationBootstrap = $false ) if ([string]::IsNullOrWhiteSpace($RuntimeId)) { @@ -131,26 +133,31 @@ function Add-CcodTestRuntime { New-Item -ItemType Directory -Path $kernelDirectory -Force | Out-Null $kernelPath = Join-Path $kernelDirectory 'KernelObjects.psm1' [IO.File]::Copy($kernelObjectsModule, $kernelPath, $true) + if($IncludeGenerationBootstrap){[IO.File]::Copy($bootstrapScript,(Join-Path $supervisorDirectory 'bootstrap.ps1'),$true)} if ($IncludeFenceModules) { foreach ($moduleName in @('PersistenceIO.psm1','LifecycleEpoch.psm1','LifecycleTransaction.psm1','RuntimeManifest.psm1','TrustedLogonIdentity.psm1')) { [IO.File]::Copy((Join-Path $repositoryRoot ('src\persistence\modules\' + $moduleName)), (Join-Path $kernelDirectory $moduleName), $true) } if ($FailLifecycleRelease) { - [IO.File]::AppendAllText((Join-Path $kernelDirectory 'LifecycleEpoch.psm1'), @' + if ([string]::IsNullOrWhiteSpace($LifecycleReleaseMarkerPath)) { throw 'A lifecycle release failure marker path is required' } + $releaseMarkerBase64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([IO.Path]::GetFullPath($LifecycleReleaseMarkerPath))) + $releaseFailureInjection = @' function Exit-CcodLifecycleOwnership { [CmdletBinding()] param([Parameter(Mandatory)]$Ownership, [hashtable]$Adapters) + [IO.File]::WriteAllText([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('__CCOD_RELEASE_MARKER_BASE64__')),'release-attempted',[Text.UTF8Encoding]::new($false)) throw [Management.Automation.ErrorRecord]::new( [InvalidOperationException]::new('injected lifecycle release failure'), 'CCOD_LIFECYCLE_RELEASE_FAILED', [Management.Automation.ErrorCategory]::CloseError, $Ownership) } -'@, [Text.UTF8Encoding]::new($false)) +'@ + [IO.File]::AppendAllText((Join-Path $kernelDirectory 'LifecycleEpoch.psm1'), $releaseFailureInjection.Replace('__CCOD_RELEASE_MARKER_BASE64__',$releaseMarkerBase64), [Text.UTF8Encoding]::new($false)) } } - $manifest = New-CcodRuntimeManifest -RuntimeDirectory $runtimeDirectory -ProjectVersion '0.0.0-bootstrap-test' + $manifest = New-CcodRuntimeManifest -RuntimeDirectory $runtimeDirectory -ProjectVersion '0.0.0-b' if ([string]::IsNullOrWhiteSpace($RuntimeId)) { $RuntimeId = $manifest.runtimeId $targetDirectory = Join-Path $Root "runtime\$RuntimeId" @@ -200,15 +207,23 @@ function Read-CcodTestActivePointer { return (Get-Content -LiteralPath (Join-Path $Root 'active.json') -Raw | ConvertFrom-Json) } +function Set-CcodTestActiveGenerationPointer { + param([Parameter(Mandatory)][string]$Root,[Parameter(Mandatory)][string]$ActiveRuntime,[UInt64]$Generation=1) + $pointerRoot=Join-Path $Root 'state\active-generation';[IO.Directory]::CreateDirectory($pointerRoot)|Out-Null + $record=[pscustomobject][ordered]@{schemaVersion=1;generation=$Generation;activeRuntime=$ActiveRuntime;previousGeneration=($Generation-1)} + [IO.File]::WriteAllText((Join-Path $pointerRoot ('{0:D20}.json'-f$Generation)),($record|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new($false)) +} + function Invoke-CcodBootstrapUnderTest { param( [Parameter(Mandatory)][string]$Root, [Parameter(Mandatory)][string]$ReadyToken, [int]$ReadyTimeoutSeconds = 3, - [ValidateSet('Task','Explicit')][string]$EntryMode = 'Explicit' + [ValidateSet('Task','Explicit')][string]$EntryMode = 'Explicit', + [string]$BootstrapPath = $bootstrapScript ) - $output = & $powershellExecutable -NoProfile -ExecutionPolicy Bypass -File $bootstrapScript ` + $output = & $powershellExecutable -NoProfile -ExecutionPolicy Bypass -File $BootstrapPath ` -InstallRoot $Root -ReadyToken $ReadyToken -ReadyTimeoutSeconds $ReadyTimeoutSeconds -EntryMode $EntryMode 2>&1 $exitCode = [int]$LASTEXITCODE return $exitCode @@ -218,7 +233,8 @@ function Invoke-CcodBootstrapTimed { param( [Parameter(Mandatory)][string]$Root, [Parameter(Mandatory)][string]$ReadyToken, - [int]$TimeoutMilliseconds = 4000 + [int]$TimeoutMilliseconds = 15000, + [AllowNull()][string]$ReleaseAttemptMarkerPath ) $startInfo = [Diagnostics.ProcessStartInfo]::new() @@ -231,7 +247,21 @@ function Invoke-CcodBootstrapTimed { $stopwatch = [Diagnostics.Stopwatch]::StartNew() try { [void]$process.Start() - $exited = $process.WaitForExit($TimeoutMilliseconds) + $markerObserved = $false + $markerAtMilliseconds = $null + if (-not [string]::IsNullOrWhiteSpace($ReleaseAttemptMarkerPath)) { + while ($stopwatch.ElapsedMilliseconds -lt $TimeoutMilliseconds) { + if ([IO.File]::Exists($ReleaseAttemptMarkerPath)) { + $markerObserved = $true + $markerAtMilliseconds = [long]$stopwatch.ElapsedMilliseconds + break + } + if ($process.HasExited) { break } + Start-Sleep -Milliseconds 10 + } + } + $remainingMilliseconds = [Math]::Max(0,$TimeoutMilliseconds - [int]$stopwatch.ElapsedMilliseconds) + $exited = if ($process.HasExited) { $true } else { $process.WaitForExit($remainingMilliseconds) } if (-not $exited) { $process.Kill() $process.WaitForExit() @@ -240,6 +270,8 @@ function Invoke-CcodBootstrapTimed { TimedOut = -not $exited ExitCode = if ($exited) { [int]$process.ExitCode } else { $null } ElapsedMilliseconds = [long]$stopwatch.ElapsedMilliseconds + ReleaseAttemptObserved = $markerObserved + ElapsedAfterReleaseAttemptMilliseconds = if ($markerObserved) { [long]($stopwatch.ElapsedMilliseconds - $markerAtMilliseconds) } else { $null } } } finally { $stopwatch.Stop() @@ -328,6 +360,50 @@ $results += Invoke-CcodTest 'production suppression caller fails closed for unsa } +$results += Invoke-CcodTest 'bootstrap append-only selector rejects unsafe roots leaves JSON and generations' { + foreach($kind in @('unknown','root-file','root-reparse','leaf-reparse','ads','multilink','malformed','schema','duplicate','fractional','noncanonical')){$root=Join-Path ([IO.Path]::GetTempPath()) ("ccod-bootstrap-pointer-$kind-"+[guid]::NewGuid().ToString('N'));$outside=Join-Path ([IO.Path]::GetTempPath()) ("ccod-bootstrap-pointer-outside-$kind-"+[guid]::NewGuid().ToString('N'));try{New-CcodBootstrapFixture -Root $root|Out-Null;[IO.Directory]::CreateDirectory($outside)|Out-Null;$pointerRoot=Join-Path $root 'state\active-generation';[IO.Directory]::CreateDirectory($pointerRoot)|Out-Null;$path=Join-Path $pointerRoot '00000000000000000001.json';$id='2.5.22-1111111111111111-22222222222222222222222222222222';$json='{"schemaVersion":1,"generation":1,"activeRuntime":"'+$id+'","previousGeneration":0}';if($kind-ceq'unknown'){[IO.File]::WriteAllText((Join-Path $pointerRoot 'unknown.bin'),'x',[Text.UTF8Encoding]::new($false))}elseif($kind-ceq'root-file'){Remove-Item $pointerRoot -Recurse -Force;[IO.File]::WriteAllText($pointerRoot,'x',[Text.UTF8Encoding]::new($false))}elseif($kind-ceq'root-reparse'){Remove-Item $pointerRoot -Recurse -Force;New-Item -ItemType Junction -Path $pointerRoot -Target $outside|Out-Null}elseif($kind-ceq'leaf-reparse'){New-Item -ItemType Junction -Path $path -Target $outside|Out-Null}elseif($kind-ceq'ads'){[IO.File]::WriteAllText($path,$json,[Text.UTF8Encoding]::new($false));Set-Content -LiteralPath $path -Stream evidence -Value x -NoNewline}elseif($kind-ceq'multilink'){$outsideFile=Join-Path $outside 'pointer.json';[IO.File]::WriteAllText($outsideFile,$json,[Text.UTF8Encoding]::new($false));New-Item -ItemType HardLink -Path $path -Target $outsideFile|Out-Null}elseif($kind-ceq'malformed'){[IO.File]::WriteAllText($path,'{',[Text.UTF8Encoding]::new($false))}elseif($kind-ceq'schema'){[IO.File]::WriteAllText($path,$json.Replace('"schemaVersion":1','"schemaVersion":2'),[Text.UTF8Encoding]::new($false))}elseif($kind-ceq'duplicate'){[IO.File]::WriteAllText($path,('{"schemaVersion":1,"schemaVersion":1,"generation":1,"activeRuntime":"'+$id+'","previousGeneration":0}'),[Text.UTF8Encoding]::new($false))}elseif($kind-ceq'fractional'){[IO.File]::WriteAllText($path,('{"schemaVersion":1,"generation":1.5,"activeRuntime":"'+$id+'","previousGeneration":0}'),[Text.UTF8Encoding]::new($false))}else{[IO.File]::WriteAllText((Join-Path $pointerRoot '00000000000000000002.json'),$json,[Text.UTF8Encoding]::new($false))};Assert-CcodThrows {Read-CcodBootstrapActivePointer -InstallRoot $root|Out-Null} 'CCOD_BOOTSTRAP_POINTER_INVALID'}finally{if(Test-Path $root){Remove-Item $root -Recurse -Force};if(Test-Path $outside){Remove-Item $outside -Recurse -Force}}} +} + +$results += Invoke-CcodTest 'bootstrap selector fallback requires proven ItemNotFound instead of a lookup error' { + $root=Join-Path ([IO.Path]::GetTempPath()) ('ccod-bootstrap-pointer-lookup-'+[guid]::NewGuid().ToString('N')) + try{New-CcodBootstrapFixture -Root $root|Out-Null;Assert-CcodThrows {Read-CcodBootstrapActivePointer -InstallRoot $root -SelectorAdapters @{GetSelectorRootItem={param($Path)throw [UnauthorizedAccessException]::new('selector lookup denied')}}|Out-Null} 'CCOD_BOOTSTRAP_POINTER_INVALID'}finally{if(Test-Path $root){Remove-Item $root -Recurse -Force}} +} + +$results += Invoke-CcodTest 'bootstrap legacy fallback rejects a state ancestor file' { + $root=Join-Path ([IO.Path]::GetTempPath()) ('ccod-bootstrap-pointer-state-file-'+[guid]::NewGuid().ToString('N')) + try{ + [IO.Directory]::CreateDirectory($root)|Out-Null + $runtimeId='2.5.22-1111111111111111-22222222222222222222222222222222' + Set-CcodTestActivePointer -Root $root -ActiveRuntime $runtimeId + [IO.File]::WriteAllText((Join-Path $root 'state'),'not-a-directory',[Text.UTF8Encoding]::new($false)) + Assert-CcodThrows {Read-CcodBootstrapActivePointer -InstallRoot $root|Out-Null} 'CCOD_BOOTSTRAP_POINTER_INVALID' + }finally{if(Test-Path $root){Remove-Item $root -Recurse -Force}} +} + +$results += Invoke-CcodTest 'bootstrap runtime IDs use canonical TAB delimiters' { + $files = @( + [pscustomobject]@{ path = 'a.txt'; length = [int64]5; sha256 = ('a' * 64) } + [pscustomobject]@{ path = 'b.txt'; length = [int64]4; sha256 = ('b' * 64) } + ) + $nonce = '0123456789abcdef0123456789abcdef' + $expected = '2.5.22-e71f4818a0f8e98f-0123456789abcdef0123456789abcdef' + Assert-CcodExactEqual $expected (Get-CcodBootstrapRuntimeId -ProjectVersion '2.5.22' -Files $files -Nonce $nonce) 'bootstrap runtime ID digest input must use literal TAB delimiters' +} + +$results += Invoke-CcodTest 'generation bootstrap launches the append-only selected runtime without root active json' { + $root=Join-Path ([IO.Path]::GetTempPath()) ('ccod-bootstrap-generation-'+[guid]::NewGuid().ToString('N')) + try{ + New-CcodBootstrapFixture -Root $root|Out-Null + $marker=Join-Path $root 'generation.started' + $runtimeId=Add-CcodTestRuntime -Root $root -SupervisorScript (New-CcodTestSupervisorScript -Kind 'Ready' -MarkerPath $marker) -IncludeGenerationBootstrap $true + Set-CcodTestActiveGenerationPointer -Root $root -ActiveRuntime $runtimeId + $generationBootstrap=Join-Path $root "runtime\$runtimeId\src\persistence\bootstrap.ps1" + Assert-CcodExactEqual $false (Test-Path -LiteralPath (Join-Path $root 'active.json')) 'fresh append-only fixture has no root active json' + Assert-CcodExactEqual 0 (Invoke-CcodBootstrapUnderTest -Root $root -ReadyToken (New-CcodBootstrapToken) -EntryMode Task -BootstrapPath $generationBootstrap) 'generation bootstrap launches selected Supervisor' + Assert-CcodTrue (Test-Path -LiteralPath $marker) 'selected generation Supervisor actually starts and signals ready' + }finally{if(Test-Path -LiteralPath $root){Remove-Item -LiteralPath $root -Recurse -Force}} +} + $results += Invoke-CcodTest 'selects previous runtime after active exits before ready and swaps pointer' { $root = Join-Path ([IO.Path]::GetTempPath()) ("ccod-bootstrap-" + [guid]::NewGuid().ToString('N')) try { @@ -440,17 +516,22 @@ $results += Invoke-CcodTest 'fails promptly when fallback lifecycle ownership ca New-CcodBootstrapFixture -Root $root | Out-Null $activeId = Add-CcodTestRuntime -Root $root -SupervisorScript (New-CcodTestSupervisorScript -Kind 'ExitEarly' -MarkerPath (Join-Path $root 'release-active.started')) $pidPath = Join-Path $root 'release-previous.pid' - $previousId = Add-CcodTestRuntime -Root $root -SupervisorScript (New-CcodTestSupervisorScript -Kind 'ReadyLongLived' -MarkerPath $pidPath) -FailLifecycleRelease $true + $releaseAttemptPath = Join-Path $root 'release-attempted' + $previousId = Add-CcodTestRuntime -Root $root -SupervisorScript (New-CcodTestSupervisorScript -Kind 'ReadyLongLived' -MarkerPath $pidPath) -FailLifecycleRelease $true -LifecycleReleaseMarkerPath $releaseAttemptPath Set-CcodTestActivePointer -Root $root -ActiveRuntime $activeId -PreviousRuntime $previousId -SchemaVersion 2 -Generation 9 - $run = Invoke-CcodBootstrapTimed -Root $root -ReadyToken (New-CcodBootstrapToken) + $run = Invoke-CcodBootstrapTimed -Root $root -ReadyToken (New-CcodBootstrapToken) -ReleaseAttemptMarkerPath $releaseAttemptPath if ([IO.File]::Exists($pidPath)) { $supervisorPid = [int][IO.File]::ReadAllText($pidPath) } + Assert-CcodExactEqual $true $run.ReleaseAttemptObserved 'release failure timing starts only after the injected release attempt' Assert-CcodExactEqual $false $run.TimedOut 'release failure exits instead of waiting for the ready long-lived Supervisor' Assert-CcodExactEqual 1 $run.ExitCode 'release failure is a stable nonzero bootstrap outcome' - Assert-CcodTrue ($run.ElapsedMilliseconds -lt 4000) 'release failure returns within the bounded prompt-exit window' + Assert-CcodTrue ($run.ElapsedAfterReleaseAttemptMilliseconds -le 2000) 'release failure exits within two seconds of the injected release attempt' $log = [IO.File]::ReadAllText((Join-Path $root 'logs\bootstrap.log')) Assert-CcodTrue ($log.Contains('CCOD_BOOTSTRAP_FENCE_RELEASE_FAILED')) 'release failure is normalized to the stable bootstrap code' Assert-CcodTrue (-not $log.Contains('signaled ready; active pointer switched')) 'release failure never logs successful fallback readiness' + Assert-CcodTrue ($null-ne$supervisorPid) 'fallback Supervisor published its process identity before release failed' + $fallbackProcess = Get-Process -Id $supervisorPid -ErrorAction SilentlyContinue + try { Assert-CcodExactEqual $null $fallbackProcess 'bootstrap failure exits the fallback Supervisor before returning' } finally { if ($null-ne$fallbackProcess) { $fallbackProcess.Dispose() } } $sid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value $lease = Enter-CcodMutex -Kind AccountTransition -UserSid $sid -TimeoutMilliseconds 1000 @@ -666,5 +747,13 @@ $results += Invoke-CcodTest 'prelaunch evaluates safe-exit policy before lease a Assert-CcodExactEqual $null $suppressed.LaunchLease 'suppressed startup owns no launch lease' } +$results += Invoke-CcodTest 'clean release runner fail-closes contaminated product state without invoking bootstrap' { + $path = Join-Path $repositoryRoot 'tools\Test-CleanReleaseRunner.ps1' + Assert-CcodTrue (Test-Path -LiteralPath $path -PathType Leaf) 'clean release runner script exists' + $source = [IO.File]::ReadAllText($path, [Text.UTF8Encoding]::new($false)) + Assert-CcodTrue ($source -cnotmatch 'bootstrap\.ps1') 'clean runner does not invoke bootstrap' + Assert-CcodTrue ($source -cmatch 'CCOD_CLEAN_RUNNER_CONTAMINATED') 'clean runner fail-closes contaminated product state' +} + $results | Format-Table -AutoSize Write-Host ("Bootstrap self-test passed: {0}" -f $results.Count) diff --git a/tests/persistence/InstallFileTransaction.SelfTest.ps1 b/tests/persistence/InstallFileTransaction.SelfTest.ps1 new file mode 100644 index 0000000..e27891c --- /dev/null +++ b/tests/persistence/InstallFileTransaction.SelfTest.ps1 @@ -0,0 +1,1142 @@ +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'TestSupport.ps1') + +$projectRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +$modulePath = Join-Path $projectRoot 'src\persistence\modules\InstallFileTransaction.psm1' +$module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + +if ($null -eq ('CcodInstallFileRaceProbe' -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; + +public sealed class CcodInstallHandoffAttack : IDisposable +{ + private readonly CancellationTokenSource stop = new CancellationTokenSource(); + private readonly ManualResetEventSlim ready = new ManualResetEventSlim(false); + private readonly Task[] workers; + public CcodInstallHandoffAttack(string path,string replacement) + { + workers=new Task[16]; + for(int i=0;i { ready.Set();while(!stop.IsCancellationRequested){try{string backup=path+".attacker."+Guid.NewGuid().ToString("N");File.Move(path,backup);try{File.Copy(replacement,path);}catch(IOException){}catch(UnauthorizedAccessException){}stop.Cancel();return "exchanged";}catch(IOException){}catch(UnauthorizedAccessException){}}return "blocked";}); + } + public bool WaitReady(int milliseconds){return ready.Wait(milliseconds);} + public string Stop() + { + stop.Cancel();Task.WaitAll(workers);foreach(Task worker in workers)if(worker.Result=="exchanged")return "exchanged";return "blocked"; + } + public void Dispose(){Stop();stop.Dispose();ready.Dispose();} +} + +public static class CcodInstallFileRaceProbe +{ + public static Task ObserveFirstVisibleBytes(string path) + { + return Task.Run(() => {DateTime deadline=DateTime.UtcNow.AddSeconds(15);while(DateTime.UtcNow {barrier.SignalAndWait();try{create.Invoke(runtime,new object[]{token,leaf});createResult="created";}catch(TargetInvocationException e){createResult=e.InnerException is ObjectDisposedException?"closed":e.InnerException.GetType().Name;}}); + Task second=Task.Run(() => {barrier.SignalAndWait();try{close.Invoke(runtime,new object[0]);closeResult="closed";}catch(TargetInvocationException e){closeResult=e.InnerException.GetType().Name;}}); + Task.WaitAll(first,second);barrier.Dispose();return createResult+"|"+closeResult; + } +} + +public sealed class CcodThrowingFileStream : FileStream +{ + private static int attempts; + public static int Attempts { get { return Volatile.Read(ref attempts); } } + public static void Reset(){Volatile.Write(ref attempts,0);} + public CcodThrowingFileStream(string path) : base(path,FileMode.CreateNew,FileAccess.ReadWrite,FileShare.Read) { } + protected override void Dispose(bool disposing) + { + if(!disposing){base.Dispose(false);return;} + Interlocked.Increment(ref attempts); + try{base.Dispose(true);}finally{throw new IOException("test dispose failure");} + } +} +'@ +} + +function New-CcodInstallFileFixture { + $base = Join-Path ([IO.Path]::GetTempPath()) ('ccod-install-file-' + [guid]::NewGuid().ToString('N')) + $outside = Join-Path ([IO.Path]::GetTempPath()) ('ccod-install-outside-' + [guid]::NewGuid().ToString('N')) + $install = Join-Path $base 'install' + [IO.Directory]::CreateDirectory($install) | Out-Null + [IO.Directory]::CreateDirectory($outside) | Out-Null + $sentinel = Join-Path $outside 'sentinel.txt' + [IO.File]::WriteAllText($sentinel, 'outside-sentinel-v1') + [pscustomobject]@{ Base=$base; Outside=$outside; Install=$install; Sentinel=$sentinel; SentinelSha256=(Get-CcodTestFileSha256 $sentinel); Transactions=[Collections.Generic.List[object]]::new() } +} + +function Remove-CcodInstallFileFixture { + param([Parameter(Mandatory)]$Fixture) + foreach ($transaction in @($Fixture.Transactions)) { try { Close-CcodInstallFileTransaction -Transaction $transaction -Disposition Failed | Out-Null } catch { } } + $tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + '\' + foreach ($candidate in @($Fixture.Base,$Fixture.Outside)) { + $full = [IO.Path]::GetFullPath([string]$candidate) + if (-not $full.StartsWith($tempRoot,[StringComparison]::OrdinalIgnoreCase)) { throw "Refusing to remove non-temporary fixture path: $full" } + if (Test-Path -LiteralPath $full) { Remove-Item -LiteralPath $full -Recurse -Force } + } +} + +function Open-CcodFixtureGeneration { + param([Parameter(Mandatory)]$Fixture,[Parameter(Mandatory)][string]$RuntimeId) + $generation = Open-CcodInstallGeneration -InstallRoot $Fixture.Install -RuntimeId $RuntimeId + $Fixture.Transactions.Add($generation) + $generation +} + +function New-CcodSourceFile { + param([Parameter(Mandatory)]$Fixture,[Parameter(Mandatory)][string]$Name,[Parameter(Mandatory)][string]$Content) + $path = Join-Path $Fixture.Base $Name + [IO.File]::WriteAllText($path,$Content) + [pscustomobject]@{ Path=$path; Length=[int64](Get-Item -LiteralPath $path).Length; Sha256=(Get-CcodTestFileSha256 $path) } +} + +function New-CcodGenerationManifest { + param([Parameter(Mandatory)][string]$RuntimeId,[object[]]$Files=@()) + [ordered]@{ schemaVersion=1; projectVersion='2.5.22'; runtimeId=$RuntimeId; commit='0123456789abcdef0123456789abcdef01234567'; files=@($Files) } +} + +function Assert-CcodOutsideUnchanged { + param([Parameter(Mandatory)]$Fixture,[Parameter(Mandatory)][string]$Message) + Assert-CcodTrue ([IO.File]::Exists($Fixture.Sentinel)) "$Message sentinel exists" + Assert-CcodEqual $Fixture.SentinelSha256 (Get-CcodTestFileSha256 $Fixture.Sentinel) "$Message sentinel bytes" +} + +function Invoke-CcodExtensionRedTest { + param([Parameter(Mandatory)][string]$Id,[Parameter(Mandatory)][string]$Name,[Parameter(Mandatory)][scriptblock]$Action) + if (-not [string]::IsNullOrWhiteSpace($env:CCOD_INSTALL_EXTENSION_RED_CASE) -and $env:CCOD_INSTALL_EXTENSION_RED_CASE -cne $Id) { return } + Invoke-CcodTest $Name $Action +} + +function Invoke-CcodTask2Fix1Test { + param([Parameter(Mandatory)][string]$Id,[Parameter(Mandatory)][string]$Name,[Parameter(Mandatory)][scriptblock]$Action) + if(-not[string]::IsNullOrWhiteSpace($env:CCOD_TASK2_FIX1_RED_CASE)-and$env:CCOD_TASK2_FIX1_RED_CASE-cne$Id){return} + Invoke-CcodTest $Name $Action +} + +function Set-CcodInstallTestTrustedDirectoryAcl { + param([Parameter(Mandatory)][string]$Path) + $identity=[Security.Principal.WindowsIdentity]::GetCurrent() + try{ + $security=[Security.AccessControl.DirectorySecurity]::new();$security.SetOwner($identity.User);$security.SetAccessRuleProtection($true,$false) + foreach($sid in @($identity.User.Value,'S-1-5-18','S-1-5-32-544')){[void]$security.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new([Security.Principal.SecurityIdentifier]::new($sid),[Security.AccessControl.FileSystemRights]::FullControl,[Security.AccessControl.InheritanceFlags]::ContainerInherit-bor[Security.AccessControl.InheritanceFlags]::ObjectInherit,[Security.AccessControl.PropagationFlags]::None,[Security.AccessControl.AccessControlType]::Allow))} + [IO.Directory]::SetAccessControl($Path,$security) + }finally{$identity.Dispose()} +} + +function Assert-CcodInstallTestExactPlanDirectoryAcl { + param([Parameter(Mandatory)][string]$Path) + $identity=[Security.Principal.WindowsIdentity]::GetCurrent() + try{$security=[IO.Directory]::GetAccessControl($Path);$rules=@($security.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier]));Assert-CcodEqual $identity.User.Value $security.GetOwner([Security.Principal.SecurityIdentifier]).Value 'normalized plan directory owner is exact';Assert-CcodEqual $true $security.AreAccessRulesProtected 'normalized plan directory ACL is protected';Assert-CcodEqual 3 $rules.Count 'normalized plan directory ACL has exactly three rules';Assert-CcodEqual (($identity.User.Value,'S-1-5-18','S-1-5-32-544'|Sort-Object)-join'|') ((@($rules.IdentityReference.Value)|Sort-Object)-join'|') 'normalized plan directory ACL has only trusted principals'}finally{$identity.Dispose()} +} + +function Invoke-CcodMoveAttempt { + param([Parameter(Mandatory)][string]$Path) + try { [IO.File]::Move($Path,$Path+'.moved'); 'moved' } catch [IO.IOException] { 'blocked' } catch [UnauthorizedAccessException] { 'blocked' } +} + +function New-CcodJunction { + param([Parameter(Mandatory)][string]$Path,[Parameter(Mandatory)][string]$Target) + $previous=$ErrorActionPreference + try { $ErrorActionPreference='Continue'; $output=& cmd.exe /d /c mklink /J $Path $Target 2>&1; $exitCode=$LASTEXITCODE } finally { $ErrorActionPreference=$previous } + if($exitCode-ne 0){throw "Could not create junction: $($output -join ' ')"} +} + +function New-CcodHardLink { + param([Parameter(Mandatory)][string]$Path,[Parameter(Mandatory)][string]$Existing) + $previous=$ErrorActionPreference + try { $ErrorActionPreference='Continue'; $output=& cmd.exe /d /c mklink /H $Path $Existing 2>&1; $exitCode=$LASTEXITCODE } finally { $ErrorActionPreference=$previous } + if($exitCode-ne 0){throw "Could not create hard link: $($output -join ' ')"} +} + +function Assert-CcodProductTransactionRejected { + param([Parameter(Mandatory)][scriptblock]$Action,[Parameter(Mandatory)]$Fixture) + try{ + $unexpected=&$Action + if($null-ne$unexpected){$Fixture.Transactions.Add($unexpected)} + }catch{ + if(([string]$_.FullyQualifiedErrorId-split',')[0]-ceq'CCOD_INSTALL_PRODUCT_SCOPE'){return} + throw + } + throw 'ASSERT_THROWS: expected CCOD_INSTALL_PRODUCT_SCOPE' +} + +Invoke-CcodTest 'exports only immutable generation operations and an inert CLR marker' { + $expected=@('Close-CcodInstallFileTransaction','Commit-CcodInstallActivePointer','Copy-CcodInstallProductShortcut','Copy-CcodInstallSealedSource','New-CcodInstallDirectory','New-CcodInstallGenerationLeaf','Open-CcodInstallGeneration','Open-CcodInstallProductRegistrationTransaction','Open-CcodInstallProductSpecialFolder','Open-CcodInstallRetainedFile','Open-CcodInstallRetainedGeneration','Open-CcodInstallStateTransaction','Read-CcodInstallLegacyMigrationPlan','Retire-CcodInstallGeneration','Write-CcodInstallGenerationManifest','Write-CcodInstallLegacyMigrationPlan','Write-CcodInstallRecord') + Assert-CcodEqual ($expected -join '|') ((@($module.ExportedCommands.Keys)|Sort-Object)-join '|') 'module export surface is capability-only' + Assert-CcodEqual 7 ([CcodInstallGenerationCapabilityMarkerV7]::CapabilityAbi) 'marker exposes the current non-mutating ABI value' + Assert-CcodEqual 'CcodInstallGenerationCapabilityMarkerV7' ((@([CcodInstallGenerationCapabilityMarkerV7].Assembly.GetExportedTypes()|ForEach-Object FullName)) -join '|') 'current CLR bridge exports only the inert marker' + $dangerous=@([CcodInstallGenerationCapabilityMarkerV7].GetMethods([Reflection.BindingFlags]'Public,Static,DeclaredOnly')|Where-Object{@($_.GetParameters()|Where-Object{$_.ParameterType-in@([string],[IntPtr],[IO.Stream])-or[Microsoft.Win32.SafeHandles.SafeHandle].IsAssignableFrom($_.ParameterType)}).Count-ne 0}) + Assert-CcodEqual 0 $dangerous.Count 'marker accepts no path stream or bare handle' + Assert-CcodTrue (-not $module.ExportedCommands['Copy-CcodInstallProductShortcut'].Parameters.ContainsKey('SourcePath')) 'product shortcut copy accepts no arbitrary absolute source path' +} + +Invoke-CcodTest 'authority reader returns bytes and hash from one verified native file handle' { + $fixture=New-CcodInstallFileFixture + try{ + $source=New-CcodSourceFile $fixture 'authority-reader.json' '{"schemaVersion":1,"value":"bound"}' + $read=&$module {param($Path)Read-CcodInstallAuthorityFile -Path $Path} $source.Path + Assert-CcodEqual $source.Sha256 $read.Sha256 'authority reader hashes the bytes from its verified handle' + Assert-CcodEqual '{"schemaVersion":1,"value":"bound"}' ([Text.Encoding]::UTF8.GetString($read.Bytes)) 'authority reader returns the same verified-handle bytes' + New-CcodHardLink -Path (Join-Path $fixture.Base 'authority-reader-link.json') -Existing $source.Path + Assert-CcodThrows {&$module {param($Path)Read-CcodInstallAuthorityFile -Path $Path|Out-Null} $source.Path} 'CCOD_INSTALL_PRODUCT_SCOPE' + }finally{Remove-CcodInstallFileFixture $fixture} +} + +function Write-CcodProductReadyTransactionChain { + param([Parameter(Mandatory)][string]$InstallRoot,[Parameter(Mandatory)]$ReadyRecord) + $phases=@('Prepared','PackageVerified','RuntimeStaged','PreviousProtectionStopped','RuntimePromoted','PointerCommitted','StableShellCommitted','ProtectionReady','Ready') + $transactions=Join-Path $InstallRoot 'state\install-transactions';[IO.Directory]::CreateDirectory($transactions)|Out-Null + for($index=0;$index-lt$phases.Count;$index++){ + $record=$ReadyRecord.PSObject.Copy();$record.phase=$phases[$index] + $leaf='{0:D20}.{1:D2}.{2}.{3}.json'-f[uint64]$record.newGeneration,$index,$record.phase,$record.transactionId + [IO.File]::WriteAllText((Join-Path $transactions $leaf),($record|ConvertTo-Json -Depth 8 -Compress),[Text.UTF8Encoding]::new($false)) + } + return (Join-Path $transactions ('{0:D20}.08.Ready.{1}.json'-f[uint64]$ReadyRecord.newGeneration,$ReadyRecord.transactionId)) +} + +function New-CcodProductAuthorityFixture { + $fixture=New-CcodInstallFileFixture + $runtimeId='runtime-product-canonical-authority' + $source=New-CcodSourceFile $fixture 'canonical-authority.lnk' 'canonical authority shortcut' + $generation=Open-CcodFixtureGeneration $fixture $runtimeId + $registration=New-CcodInstallGenerationLeaf -Generation $generation -Leaf 'registration' + Copy-CcodInstallSealedSource -Generation $registration -SourcePath $source.Path -Leaf 'StartMenu.CodexRemote-fix.lnk' -ExpectedLength $source.Length -ExpectedSha256 $source.Sha256|Out-Null + $manifest=Write-CcodInstallGenerationManifest -Generation $generation -Manifest (New-CcodGenerationManifest $runtimeId @([ordered]@{path='registration/StartMenu.CodexRemote-fix.lnk';length=$source.Length;sha256=$source.Sha256})) + Commit-CcodInstallActivePointer -InstallRoot $fixture.Install -TargetGeneration $generation -ExpectedPreviousGeneration 0 -FileTransaction $generation|Out-Null + Close-CcodInstallFileTransaction -Transaction $generation -Disposition Ready + $ready=[pscustomobject][ordered]@{schemaVersion=1;transactionId='33333333-4444-4555-8666-777777777777';oldRuntimeId=$null;oldGeneration=$null;oldManifestSha256=$null;newRuntimeId=$runtimeId;newGeneration=[uint64]1;newManifestSha256=$manifest.Sha256;sealedPackageSha256=('a'*64);ownedObjectNames=@($runtimeId);phase='Ready';errorCode=$null} + $readyPath=Write-CcodProductReadyTransactionChain -InstallRoot $fixture.Install -ReadyRecord $ready + [pscustomobject]@{Fixture=$fixture;RuntimeId=$runtimeId;Manifest=$manifest;Ready=$ready;ReadyPath=$readyPath} +} + +# Production mutation caught: product code can persist or read arbitrary state instead of one Ready-derived, cleanup-fence-bound plan. +Invoke-CcodTest 'product transaction publishes and rereads only its exact Ready-derived legacy migration plan create-only' { + $authority=New-CcodProductAuthorityFixture;$fixture=$authority.Fixture + $outer=$null;$product=$null;$lifecycleModule=$null;$fence=$null + try { + $lifecycleModule=Import-Module (Join-Path $projectRoot 'src\persistence\modules\InstallLifecycle.psm1') -Force -PassThru -DisableNameChecking + $outer=&$lifecycleModule {Enter-CcodLifecycleProductCleanupLease} + $fence=&$lifecycleModule {param($Root,$Ready,$Identity)New-CcodLifecycleProductCleanupFence -InstallRoot $Root -ReadyTransaction $Ready -OwnerIdentity $Identity} $fixture.Install $authority.Ready $outer.OwnerIdentity + $product=Open-CcodInstallProductRegistrationTransaction -InstallRoot $fixture.Install -ReadyTransaction $authority.Ready;$fixture.Transactions.Add($product) + &$module {param($Transaction,$Fence)Set-CcodInstallProductCleanupFence -Transaction $Transaction -Fence $Fence|Out-Null} $product $fence + $bytes=[Text.UTF8Encoding]::new($false).GetBytes('{"schemaVersion":1,"transactionId":"33333333-4444-4555-8666-777777777777"}') + $written=Write-CcodInstallLegacyMigrationPlan -Transaction $product -ReadyTransaction $authority.Ready -Bytes $bytes + $read=Read-CcodInstallLegacyMigrationPlan -Transaction $product -ReadyTransaction $authority.Ready + $expectedPath=Join-Path $fixture.Install ('state\legacy-registration-migrations\00000000000000000001.'+$authority.Ready.transactionId+'.json') + Assert-CcodEqual ([int64]$bytes.LongLength) ([int64]$written.Length) 'specialized plan write returns exact length' + Assert-CcodEqual (Get-CcodTestFileSha256 $expectedPath) $written.Sha256 'specialized plan write returns exact digest' + Assert-CcodEqual ([Convert]::ToBase64String($bytes)) ([Convert]::ToBase64String($read.Bytes)) 'specialized plan reader returns exact persisted bytes' + Assert-CcodEqual $written.Sha256 $read.Sha256 'specialized plan reader hashes the same pinned file' + Assert-CcodThrows {Write-CcodInstallLegacyMigrationPlan -Transaction $product -ReadyTransaction $authority.Ready -Bytes $bytes|Out-Null} 'CCOD_INSTALL_LEGACY_PLAN_EXISTS' + $wrong=$authority.Ready.PSObject.Copy();$wrong.newGeneration=[uint64]2 + Assert-CcodThrows {Read-CcodInstallLegacyMigrationPlan -Transaction $product -ReadyTransaction $wrong|Out-Null} 'CCOD_INSTALL_PRODUCT_SCOPE' + $other=Open-CcodInstallStateTransaction -InstallRoot $fixture.Install;$fixture.Transactions.Add($other) + Assert-CcodThrows {Write-CcodInstallLegacyMigrationPlan -Transaction $other -ReadyTransaction $authority.Ready -Bytes $bytes|Out-Null} 'CCOD_INSTALL_PRODUCT_SCOPE' + Close-CcodInstallFileTransaction -Transaction $product -Disposition Ready;$product=$null + &$lifecycleModule {param($Fence)Complete-CcodLifecycleProductCleanupFence -Fence $Fence -Outcome Completed|Out-Null} $fence + } finally { + if($null-ne$product){try{Close-CcodInstallFileTransaction -Transaction $product -Disposition Failed}catch{}} + if($null-ne$outer){try{&$lifecycleModule {param($Context)Exit-CcodLifecycleProductCleanupLease -Context $Context|Out-Null} $outer}catch{}} + Remove-CcodInstallFileFixture $fixture + } +} + +# Production mutations caught: a generic product capability writes a plan without its durable cleanup authority or size bound. +Invoke-CcodTest 'legacy migration plan requires its live cleanup fence and bounded specialized scope' { + $authority=New-CcodProductAuthorityFixture;$fixture=$authority.Fixture;$outer=$null;$product=$null;$lifecycleModule=$null;$fence=$null + try{ + $lifecycleModule=Import-Module (Join-Path $projectRoot 'src\persistence\modules\InstallLifecycle.psm1') -Force -PassThru -DisableNameChecking;$outer=&$lifecycleModule {Enter-CcodLifecycleProductCleanupLease} + $product=Open-CcodInstallProductRegistrationTransaction -InstallRoot $fixture.Install -ReadyTransaction $authority.Ready;$fixture.Transactions.Add($product) + Assert-CcodThrows {Write-CcodInstallLegacyMigrationPlan -Transaction $product -ReadyTransaction $authority.Ready -Bytes ([byte[]]@(1))|Out-Null} 'CCOD_INSTALL_PRODUCT_SCOPE' + Close-CcodInstallFileTransaction -Transaction $product -Disposition Failed;$product=$null + $fence=&$lifecycleModule {param($Root,$Ready,$Identity)New-CcodLifecycleProductCleanupFence -InstallRoot $Root -ReadyTransaction $Ready -OwnerIdentity $Identity} $fixture.Install $authority.Ready $outer.OwnerIdentity + $product=Open-CcodInstallProductRegistrationTransaction -InstallRoot $fixture.Install -ReadyTransaction $authority.Ready;$fixture.Transactions.Add($product);&$module {param($Transaction,$Fence)Set-CcodInstallProductCleanupFence -Transaction $Transaction -Fence $Fence|Out-Null} $product $fence + Assert-CcodThrows {Write-CcodInstallLegacyMigrationPlan -Transaction $product -ReadyTransaction $authority.Ready -Bytes ([byte[]]::new(1048577))|Out-Null} 'CCOD_INSTALL_PRODUCT_SCOPE' + Assert-CcodEqual $false (Test-Path -LiteralPath (Join-Path $fixture.Install 'state\legacy-registration-migrations')) 'rejected specialized writes create no plan directory' + Close-CcodInstallFileTransaction -Transaction $product -Disposition Ready;$product=$null;&$lifecycleModule {param($Fence)Complete-CcodLifecycleProductCleanupFence -Fence $Fence -Outcome Completed|Out-Null} $fence + }finally{if($null-ne$product){try{Close-CcodInstallFileTransaction -Transaction $product -Disposition Failed}catch{}};if($null-ne$outer){try{&$lifecycleModule {param($Context)Exit-CcodLifecycleProductCleanupLease -Context $Context|Out-Null} $outer}catch{}};Remove-CcodInstallFileFixture $fixture} +} + +# Production mutation caught: the just-published plan is not held by identity until the product transaction closes. +Invoke-CcodTest 'legacy migration plan remains identity-pinned for the live product transaction' { + $authority=New-CcodProductAuthorityFixture;$fixture=$authority.Fixture;$outer=$null;$product=$null;$lifecycleModule=$null;$fence=$null + try{ + $lifecycleModule=Import-Module (Join-Path $projectRoot 'src\persistence\modules\InstallLifecycle.psm1') -Force -PassThru -DisableNameChecking;$outer=&$lifecycleModule {Enter-CcodLifecycleProductCleanupLease};$fence=&$lifecycleModule {param($Root,$Ready,$Identity)New-CcodLifecycleProductCleanupFence -InstallRoot $Root -ReadyTransaction $Ready -OwnerIdentity $Identity} $fixture.Install $authority.Ready $outer.OwnerIdentity + $product=Open-CcodInstallProductRegistrationTransaction -InstallRoot $fixture.Install -ReadyTransaction $authority.Ready;$fixture.Transactions.Add($product);&$module {param($Transaction,$Fence)Set-CcodInstallProductCleanupFence -Transaction $Transaction -Fence $Fence|Out-Null} $product $fence + $bytes=[Text.UTF8Encoding]::new($false).GetBytes('{"schemaVersion":1}');Write-CcodInstallLegacyMigrationPlan -Transaction $product -ReadyTransaction $authority.Ready -Bytes $bytes|Out-Null + $path=Join-Path $fixture.Install ('state\legacy-registration-migrations\00000000000000000001.'+$authority.Ready.transactionId+'.json');$moved=$false;try{[IO.File]::Move($path,$path+'.replacement');$moved=$true}catch [IO.IOException]{}catch [UnauthorizedAccessException]{} + Assert-CcodEqual $false $moved 'live plan handle denies replacement until the authority transaction closes' + Assert-CcodEqual ([Convert]::ToBase64String($bytes)) ([Convert]::ToBase64String((Read-CcodInstallLegacyMigrationPlan -Transaction $product -ReadyTransaction $authority.Ready).Bytes)) 'identity-pinned plan remains readable' + Close-CcodInstallFileTransaction -Transaction $product -Disposition Ready;$product=$null;&$lifecycleModule {param($Fence)Complete-CcodLifecycleProductCleanupFence -Fence $Fence -Outcome Completed|Out-Null} $fence + }finally{if($null-ne$product){try{Close-CcodInstallFileTransaction -Transaction $product -Disposition Failed}catch{}};if($null-ne$outer){try{&$lifecycleModule {param($Context)Exit-CcodLifecycleProductCleanupLease -Context $Context|Out-Null} $outer}catch{}};Remove-CcodInstallFileFixture $fixture} +} + +# Production mutations caught: delayed authority accepts changed ACL, ADS, multilink, or reparse plan objects. +Invoke-CcodTest 'delayed legacy migration plan read rejects hostile file identity and security mutations' { + foreach($mutation in @('Acl','DirectoryAcl','Ads','Multilink','Reparse','DirectoryReparse')){ + $authority=New-CcodProductAuthorityFixture;$fixture=$authority.Fixture;$outer=$null;$product=$null;$lifecycleModule=$null;$fence=$null;$outsidePath=$null + try{ + $lifecycleModule=Import-Module (Join-Path $projectRoot 'src\persistence\modules\InstallLifecycle.psm1') -Force -PassThru -DisableNameChecking;$outer=&$lifecycleModule {Enter-CcodLifecycleProductCleanupLease};$fence=&$lifecycleModule {param($Root,$Ready,$Identity)New-CcodLifecycleProductCleanupFence -InstallRoot $Root -ReadyTransaction $Ready -OwnerIdentity $Identity} $fixture.Install $authority.Ready $outer.OwnerIdentity + $product=Open-CcodInstallProductRegistrationTransaction -InstallRoot $fixture.Install -ReadyTransaction $authority.Ready;$fixture.Transactions.Add($product);&$module {param($Transaction,$Fence)Set-CcodInstallProductCleanupFence -Transaction $Transaction -Fence $Fence|Out-Null} $product $fence + Write-CcodInstallLegacyMigrationPlan -Transaction $product -ReadyTransaction $authority.Ready -Bytes ([Text.UTF8Encoding]::new($false).GetBytes('{"schemaVersion":1}'))|Out-Null;Close-CcodInstallFileTransaction -Transaction $product -Disposition Ready;$product=$null;&$lifecycleModule {param($Fence)Complete-CcodLifecycleProductCleanupFence -Fence $Fence -Outcome Completed|Out-Null} $fence + $path=Join-Path $fixture.Install ('state\legacy-registration-migrations\00000000000000000001.'+$authority.Ready.transactionId+'.json') + if($mutation-ceq'Acl'){$acl=[Security.AccessControl.FileSecurity]::new($path,[Security.AccessControl.AccessControlSections]::Access);$rule=[Security.AccessControl.FileSystemAccessRule]::new([Security.Principal.SecurityIdentifier]::new('S-1-1-0'),[Security.AccessControl.FileSystemRights]::Read,[Security.AccessControl.AccessControlType]::Allow);$acl.AddAccessRule($rule)|Out-Null;[IO.File]::SetAccessControl($path,$acl)} + elseif($mutation-ceq'DirectoryAcl'){$directory=Split-Path $path -Parent;$acl=[Security.AccessControl.DirectorySecurity]::new($directory,[Security.AccessControl.AccessControlSections]::Access);$rule=[Security.AccessControl.FileSystemAccessRule]::new([Security.Principal.SecurityIdentifier]::new('S-1-1-0'),[Security.AccessControl.FileSystemRights]::ReadAndExecute,[Security.AccessControl.InheritanceFlags]::ContainerInherit-bor[Security.AccessControl.InheritanceFlags]::ObjectInherit,[Security.AccessControl.PropagationFlags]::None,[Security.AccessControl.AccessControlType]::Allow);$acl.AddAccessRule($rule)|Out-Null;[IO.Directory]::SetAccessControl($directory,$acl)} + elseif($mutation-ceq'Ads'){$item=Get-Item -LiteralPath $path -Force;$item.IsReadOnly=$false;Set-Content -LiteralPath $path -Stream hostile -Value x -NoNewline;$item.Refresh();$item.IsReadOnly=$true} + elseif($mutation-ceq'Multilink'){$outsidePath=Join-Path $fixture.Outside 'plan-link.json';New-CcodHardLink -Path $outsidePath -Existing $path} + elseif($mutation-ceq'Reparse'){$item=Get-Item -LiteralPath $path -Force;$item.IsReadOnly=$false;Remove-Item -LiteralPath $path -Force;$outsidePath=Join-Path $fixture.Outside 'plan-target';[IO.Directory]::CreateDirectory($outsidePath)|Out-Null;New-CcodJunction -Path $path -Target $outsidePath} + else{$directory=Split-Path $path -Parent;$item=Get-Item -LiteralPath $path -Force;$item.IsReadOnly=$false;Remove-Item -LiteralPath $directory -Recurse -Force;$outsidePath=Join-Path $fixture.Outside 'plan-directory-target';[IO.Directory]::CreateDirectory($outsidePath)|Out-Null;New-CcodJunction -Path $directory -Target $outsidePath} + $fence=&$lifecycleModule {param($Root,$Ready,$Identity)New-CcodLifecycleProductCleanupFence -InstallRoot $Root -ReadyTransaction $Ready -OwnerIdentity $Identity} $fixture.Install $authority.Ready $outer.OwnerIdentity + $product=Open-CcodInstallProductRegistrationTransaction -InstallRoot $fixture.Install -ReadyTransaction $authority.Ready;$fixture.Transactions.Add($product);&$module {param($Transaction,$Fence)Set-CcodInstallProductCleanupFence -Transaction $Transaction -Fence $Fence|Out-Null} $product $fence + Assert-CcodThrows {Read-CcodInstallLegacyMigrationPlan -Transaction $product -ReadyTransaction $authority.Ready|Out-Null} 'CCOD_INSTALL_LEGACY_PLAN_READ_FAILED' + Close-CcodInstallFileTransaction -Transaction $product -Disposition Failed;$product=$null;&$lifecycleModule {param($Fence)Complete-CcodLifecycleProductCleanupFence -Fence $Fence -Outcome Completed|Out-Null} $fence + }finally{if($null-ne$product){try{Close-CcodInstallFileTransaction -Transaction $product -Disposition Failed}catch{}};if($null-ne$outer){try{&$lifecycleModule {param($Context)Exit-CcodLifecycleProductCleanupLease -Context $Context|Out-Null} $outer}catch{}};Remove-CcodInstallFileFixture $fixture} + } +} + +# Production mutation caught: reader-first retry permanently rejects the exact empty directory left between CREATE and ACL initialization. +Invoke-CcodTask2Fix1Test 'reader-recovery' 'reader-first plan retry normalizes only the safe empty CREATE-to-ACL artifact' { + $authority=New-CcodProductAuthorityFixture;$fixture=$authority.Fixture;$outer=$null;$product=$null;$lifecycleModule=$null;$fence=$null + try{ + $statePath=Join-Path $fixture.Install 'state';Set-CcodInstallTestTrustedDirectoryAcl $statePath;$planDirectory=Join-Path $statePath 'legacy-registration-migrations';[IO.Directory]::CreateDirectory($planDirectory)|Out-Null + Assert-CcodEqual $false ([IO.Directory]::GetAccessControl($planDirectory).AreAccessRulesProtected) 'fixture is an inherited safe empty CREATE artifact' + $lifecycleModule=Import-Module (Join-Path $projectRoot 'src\persistence\modules\InstallLifecycle.psm1') -Force -PassThru -DisableNameChecking;$outer=&$lifecycleModule {Enter-CcodLifecycleProductCleanupLease};$fence=&$lifecycleModule {param($Root,$Ready,$Identity)New-CcodLifecycleProductCleanupFence -InstallRoot $Root -ReadyTransaction $Ready -OwnerIdentity $Identity} $fixture.Install $authority.Ready $outer.OwnerIdentity + $product=Open-CcodInstallProductRegistrationTransaction -InstallRoot $fixture.Install -ReadyTransaction $authority.Ready;$fixture.Transactions.Add($product);&$module {param($Transaction,$Fence)Set-CcodInstallProductCleanupFence -Transaction $Transaction -Fence $Fence|Out-Null} $product $fence + Assert-CcodEqual $null (Read-CcodInstallLegacyMigrationPlan -Transaction $product -ReadyTransaction $authority.Ready) 'reader-first recovery returns no plan after normalizing the empty artifact' + Assert-CcodInstallTestExactPlanDirectoryAcl $planDirectory + Close-CcodInstallFileTransaction -Transaction $product -Disposition Ready;$product=$null;&$lifecycleModule {param($Fence)Complete-CcodLifecycleProductCleanupFence -Fence $Fence -Outcome Completed|Out-Null} $fence + }finally{if($null-ne$product){try{Close-CcodInstallFileTransaction -Transaction $product -Disposition Failed}catch{}};if($null-ne$outer){try{&$lifecycleModule {param($Context)Exit-CcodLifecycleProductCleanupLease -Context $Context|Out-Null} $outer}catch{}};Remove-CcodInstallFileFixture $fixture} +} + +# Production mutation caught: Set/Validate failure loses the newly created directory Pin outside transaction cleanup. +Invoke-CcodTask2Fix1Test 'create-handle' 'CREATE security failures retain and then release the exact registered directory handle' { + foreach($mode in @('Set','Validate')){ + $authority=New-CcodProductAuthorityFixture;$fixture=$authority.Fixture;$outer=$null;$product=$null;$lifecycleModule=$null;$fence=$null + try{ + $statePath=Join-Path $fixture.Install 'state';Set-CcodInstallTestTrustedDirectoryAcl $statePath;$planDirectory=Join-Path $statePath 'legacy-registration-migrations' + $lifecycleModule=Import-Module (Join-Path $projectRoot 'src\persistence\modules\InstallLifecycle.psm1') -Force -PassThru -DisableNameChecking;$outer=&$lifecycleModule {Enter-CcodLifecycleProductCleanupLease};$fence=&$lifecycleModule {param($Root,$Ready,$Identity)New-CcodLifecycleProductCleanupFence -InstallRoot $Root -ReadyTransaction $Ready -OwnerIdentity $Identity} $fixture.Install $authority.Ready $outer.OwnerIdentity + $product=Open-CcodInstallProductRegistrationTransaction -InstallRoot $fixture.Install -ReadyTransaction $authority.Ready;$fixture.Transactions.Add($product);&$module {param($Transaction,$Fence)Set-CcodInstallProductCleanupFence -Transaction $Transaction -Fence $Fence|Out-Null} $product $fence + &$module {param($Transaction,$FailureMode)$scope=Get-CcodInstallTransaction $Transaction 'CCOD_INSTALL_PRODUCT_SCOPE';Invoke-CcodRuntimeMethod $scope.State.Runtime SetLegacyPlanDirectoryFailureForTest @($FailureMode)|Out-Null} $product $mode + Assert-CcodThrows {Write-CcodInstallLegacyMigrationPlan -Transaction $product -ReadyTransaction $authority.Ready -Bytes ([Text.UTF8Encoding]::new($false).GetBytes('{"schemaVersion":1}'))|Out-Null} 'CCOD_INSTALL_LEGACY_PLAN_WRITE_FAILED' + Assert-CcodTrue ([IO.Directory]::Exists($planDirectory)) "$mode failure leaves the known empty directory artifact" + $liveMove=$false;try{[IO.Directory]::Move($planDirectory,$planDirectory+'.live');$liveMove=$true}catch [IO.IOException]{}catch [UnauthorizedAccessException]{};Assert-CcodEqual $false $liveMove "$mode failure keeps the directory identity pinned until close" + Close-CcodInstallFileTransaction -Transaction $product -Disposition Failed;$product=$null;&$lifecycleModule {param($Fence)Complete-CcodLifecycleProductCleanupFence -Fence $Fence -Outcome Completed|Out-Null} $fence + $moved=$planDirectory+'.closed';[IO.Directory]::Move($planDirectory,$moved);[IO.Directory]::Move($moved,$planDirectory) + $fence=&$lifecycleModule {param($Root,$Ready,$Identity)New-CcodLifecycleProductCleanupFence -InstallRoot $Root -ReadyTransaction $Ready -OwnerIdentity $Identity} $fixture.Install $authority.Ready $outer.OwnerIdentity;$product=Open-CcodInstallProductRegistrationTransaction -InstallRoot $fixture.Install -ReadyTransaction $authority.Ready;$fixture.Transactions.Add($product);&$module {param($Transaction,$Fence)Set-CcodInstallProductCleanupFence -Transaction $Transaction -Fence $Fence|Out-Null} $product $fence + Assert-CcodEqual $null (Read-CcodInstallLegacyMigrationPlan -Transaction $product -ReadyTransaction $authority.Ready) "$mode artifact is recoverable by a later reader-first transaction" + Close-CcodInstallFileTransaction -Transaction $product -Disposition Ready;$product=$null;&$lifecycleModule {param($Fence)Complete-CcodLifecycleProductCleanupFence -Fence $Fence -Outcome Completed|Out-Null} $fence + }finally{if($null-ne$product){try{Close-CcodInstallFileTransaction -Transaction $product -Disposition Failed}catch{}};if($null-ne$outer){try{&$lifecycleModule {param($Context)Exit-CcodLifecycleProductCleanupLease -Context $Context|Out-Null} $outer}catch{}};Remove-CcodInstallFileFixture $fixture} + } +} + +# Production mutation caught: recovery normalizes a nonempty or untrusted inherited directory merely because the final leaf is absent. +Invoke-CcodTask2Fix1Test 'unsafe-recovery' 'empty-directory recovery rejects nonempty untrusted-ACE and foreign-owner evidence' { + foreach($mutation in @('NonEmpty','UntrustedAce')){ + $authority=New-CcodProductAuthorityFixture;$fixture=$authority.Fixture;$outer=$null;$product=$null;$lifecycleModule=$null;$fence=$null + try{ + $statePath=Join-Path $fixture.Install 'state';Set-CcodInstallTestTrustedDirectoryAcl $statePath;$planDirectory=Join-Path $statePath 'legacy-registration-migrations';[IO.Directory]::CreateDirectory($planDirectory)|Out-Null + if($mutation-ceq'NonEmpty'){[IO.File]::WriteAllText((Join-Path $planDirectory 'foreign.bin'),'foreign',[Text.UTF8Encoding]::new($false))}else{$security=[Security.AccessControl.DirectorySecurity]::new($planDirectory,[Security.AccessControl.AccessControlSections]::Access);[void]$security.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new([Security.Principal.SecurityIdentifier]::new('S-1-1-0'),[Security.AccessControl.FileSystemRights]::FullControl,[Security.AccessControl.InheritanceFlags]::ContainerInherit-bor[Security.AccessControl.InheritanceFlags]::ObjectInherit,[Security.AccessControl.PropagationFlags]::None,[Security.AccessControl.AccessControlType]::Allow));[IO.Directory]::SetAccessControl($planDirectory,$security)} + $before=[IO.Directory]::GetAccessControl($planDirectory).GetSecurityDescriptorSddlForm([Security.AccessControl.AccessControlSections]::Owner-bor[Security.AccessControl.AccessControlSections]::Access) + $lifecycleModule=Import-Module (Join-Path $projectRoot 'src\persistence\modules\InstallLifecycle.psm1') -Force -PassThru -DisableNameChecking;$outer=&$lifecycleModule {Enter-CcodLifecycleProductCleanupLease};$fence=&$lifecycleModule {param($Root,$Ready,$Identity)New-CcodLifecycleProductCleanupFence -InstallRoot $Root -ReadyTransaction $Ready -OwnerIdentity $Identity} $fixture.Install $authority.Ready $outer.OwnerIdentity;$product=Open-CcodInstallProductRegistrationTransaction -InstallRoot $fixture.Install -ReadyTransaction $authority.Ready;$fixture.Transactions.Add($product);&$module {param($Transaction,$Fence)Set-CcodInstallProductCleanupFence -Transaction $Transaction -Fence $Fence|Out-Null} $product $fence + Assert-CcodThrows {Read-CcodInstallLegacyMigrationPlan -Transaction $product -ReadyTransaction $authority.Ready|Out-Null} 'CCOD_INSTALL_LEGACY_PLAN_READ_FAILED' + $after=[IO.Directory]::GetAccessControl($planDirectory).GetSecurityDescriptorSddlForm([Security.AccessControl.AccessControlSections]::Owner-bor[Security.AccessControl.AccessControlSections]::Access);Assert-CcodEqual $before $after "$mutation unsafe directory is never normalized" + Close-CcodInstallFileTransaction -Transaction $product -Disposition Failed;$product=$null;&$lifecycleModule {param($Fence)Complete-CcodLifecycleProductCleanupFence -Fence $Fence -Outcome Completed|Out-Null} $fence + }finally{if($null-ne$product){try{Close-CcodInstallFileTransaction -Transaction $product -Disposition Failed}catch{}};if($null-ne$outer){try{&$lifecycleModule {param($Context)Exit-CcodLifecycleProductCleanupLease -Context $Context|Out-Null} $outer}catch{}};Remove-CcodInstallFileFixture $fixture} + } + # A hosted runner runs the suite as an elevated administrator whose inherited + # directory owner is the Administrators group (S-1-5-32-544) rather than the + # token user, so the recovery predicate must accept the token's default owner + # for inherited evidence while still rejecting a foreign owner. + $identity=[Security.Principal.WindowsIdentity]::GetCurrent() + try{ + $defaultOwner=$identity.Owner + if($null-ne$defaultOwner){ + $sddl='O:{0}D:AI(A;OICIID;FA;;;{1})(A;OICIID;FA;;;SY)(A;OICIID;FA;;;BA)'-f$defaultOwner.Value,$identity.User.Value + $descriptor=[Security.AccessControl.RawSecurityDescriptor]::new($sddl);$bytes=[byte[]]::new($descriptor.BinaryLength);$descriptor.GetBinaryForm($bytes,0) + $sameDefaultOwner=&$module {param([byte[]]$Value)[bool](Invoke-CcodRuntimeStatic IsRecoverableLegacyPlanDirectorySecurity @((,$Value)))} $bytes + Assert-CcodEqual $true $sameDefaultOwner 'a directory owned by the token default owner is recoverable inherited evidence' + } + }finally{$identity.Dispose()} + $identity=[Security.Principal.WindowsIdentity]::GetCurrent();try{$sddl='O:WDG:{0}D:AI(A;OICIID;FA;;;{0})(A;OICIID;FA;;;SY)(A;OICIID;FA;;;BA)'-f$identity.User.Value;$descriptor=[Security.AccessControl.RawSecurityDescriptor]::new($sddl);$bytes=[byte[]]::new($descriptor.BinaryLength);$descriptor.GetBinaryForm($bytes,0);$recoverable=&$module {param([byte[]]$Value)[bool](Invoke-CcodRuntimeStatic IsRecoverableLegacyPlanDirectorySecurity @((,$Value)))} $bytes;Assert-CcodEqual $false $recoverable 'foreign owner is rejected by the same native recovery predicate'}finally{$identity.Dispose()} +} + +# Production mutation caught: a selected Ready chain cannot authorize product access while an unrelated install remains nonterminal. +Invoke-CcodTest 'product authority uses the canonical global lifecycle head and retains no authority after unrelated Prepared rejection' { + $authority=New-CcodProductAuthorityFixture;$fixture=$authority.Fixture + try{ + $unrelated=[pscustomobject][ordered]@{schemaVersion=1;transactionId='44444444-5555-4666-8777-888888888888';oldRuntimeId=$authority.RuntimeId;oldGeneration=[uint64]1;oldManifestSha256=$authority.Manifest.Sha256;newRuntimeId='runtime-unrelated-prepared';newGeneration=[uint64]2;newManifestSha256=('b'*64);sealedPackageSha256=('c'*64);ownedObjectNames=@('runtime-unrelated-prepared');phase='Prepared';errorCode=$null} + $leaf='{0:D20}.00.Prepared.{1}.json'-f[uint64]$unrelated.newGeneration,$unrelated.transactionId;$path=Join-Path $fixture.Install "state\install-transactions\$leaf" + [IO.File]::WriteAllText($path,($unrelated|ConvertTo-Json -Depth 8 -Compress),[Text.UTF8Encoding]::new($false)) + Assert-CcodProductTransactionRejected -Fixture $fixture -Action {Open-CcodInstallProductRegistrationTransaction -InstallRoot $fixture.Install -ReadyTransaction $authority.Ready} + Remove-Item -LiteralPath $path -Force + $product=Open-CcodInstallProductRegistrationTransaction -InstallRoot $fixture.Install -ReadyTransaction $authority.Ready;$fixture.Transactions.Add($product) + Assert-CcodTrue ($null-ne$product) 'rejected global head leaves no product authority or coordination lease behind' + }finally{Remove-CcodInstallFileFixture $fixture} +} + +# Production mutation caught: Failed may not be appended after terminal Ready and must not leave product authority behind. +Invoke-CcodTest 'product authority rejects the canonical Ready to Failed post-terminal continuation without retaining authority' { + $authority=New-CcodProductAuthorityFixture;$fixture=$authority.Fixture + try{ + $failed=$authority.Ready.PSObject.Copy();$failed.phase='Failed';$failed.errorCode='CCOD_TEST_POST_TERMINAL' + $leaf='{0:D20}.99.Failed.{1}.json'-f[uint64]$failed.newGeneration,$failed.transactionId;$path=Join-Path $fixture.Install "state\install-transactions\$leaf" + [IO.File]::WriteAllText($path,($failed|ConvertTo-Json -Depth 8 -Compress),[Text.UTF8Encoding]::new($false)) + Assert-CcodProductTransactionRejected -Fixture $fixture -Action {Open-CcodInstallProductRegistrationTransaction -InstallRoot $fixture.Install -ReadyTransaction $authority.Ready} + Remove-Item -LiteralPath $path -Force + $product=Open-CcodInstallProductRegistrationTransaction -InstallRoot $fixture.Install -ReadyTransaction $authority.Ready;$fixture.Transactions.Add($product) + Assert-CcodTrue ($null-ne$product) 'post-terminal rejection leaves no product authority or coordination lease behind' + }finally{Remove-CcodInstallFileFixture $fixture} +} + +# Production mutation caught: selector proof without the committing install's AccountTransition lease permits N+1 to commit across N proof/use. +Invoke-CcodTest 'product authority holds the real commit coordination lease across proof and retained-file use' { + $authority=New-CcodProductAuthorityFixture;$fixture=$authority.Fixture;$product=$null;$outer=$null;$fence=$null;$lifecycleModule=$null;$commitProcess=$null;$attemptEvent=$null + $attemptEventName='Local\CcodInstallProductAuthorityAttempt.'+[guid]::NewGuid().ToString('N');$eventCreated=$false;$attemptEvent=[Threading.EventWaitHandle]::new($false,[Threading.EventResetMode]::ManualReset,$attemptEventName,[ref]$eventCreated) + Assert-CcodTrue $eventCreated 'atomic attempt event is create-only for this test' + $resultPath=Join-Path $fixture.Base 'commit-result.json';$stdout=Join-Path $fixture.Base 'commit.stdout.txt';$stderr=Join-Path $fixture.Base 'commit.stderr.txt';$childScript=Join-Path $fixture.Base 'commit-next-generation.ps1' + $moduleRoot=Join-Path $projectRoot 'src\persistence\modules';$nextRuntime='runtime-product-canonical-authority-next' + $child=@' +param([string]$ModuleRoot,[string]$InstallRoot,[string]$CurrentRuntime,[string]$NextRuntime,[string]$AttemptEventName,[string]$ResultPath) +$ErrorActionPreference='Stop';$transaction=$null;$ownership=$null;$identity=$null;$process=$null;$attemptEvent=$null +try{ + $fileModule=Import-Module (Join-Path $ModuleRoot 'InstallFileTransaction.psm1') -Force -PassThru -ErrorAction Stop + $runtimeModule=Import-Module (Join-Path $ModuleRoot 'RuntimeManifest.psm1') -Force -PassThru -ErrorAction Stop + $epochModule=Import-Module (Join-Path $ModuleRoot 'LifecycleEpoch.psm1') -Force -PassThru -ErrorAction Stop + $kernelModule=Import-Module (Join-Path $ModuleRoot 'KernelObjects.psm1') -Force -PassThru -ErrorAction Stop + $transaction=&$fileModule {param($Root,$Id)Open-CcodInstallGeneration -InstallRoot $Root -RuntimeId $Id} $InstallRoot $NextRuntime + &$fileModule {param($Generation,$Id)Write-CcodInstallGenerationManifest -Generation $Generation -Manifest ([ordered]@{schemaVersion=1;projectVersion='2.5.22';runtimeId=$Id;commit='0123456789abcdef0123456789abcdef01234567';files=@()})|Out-Null} $transaction $NextRuntime + $attemptEvent=[Threading.EventWaitHandle]::OpenExisting($AttemptEventName) + $enterRealMutex={ + param($UserSid,$SessionId,$TimeoutMilliseconds) + try{ + &$kernelModule { + param($Sid,$Timeout,$EnteredEvent) + $wait={param($MutexHandle,$WaitTimeout)try{[Threading.WaitHandle]::SignalAndWait($EnteredEvent,$MutexHandle,$WaitTimeout,$false)}catch{[Console]::Error.WriteLine('SIGNAL_AND_WAIT_FAILURE: '+($_|Out-String));throw}}.GetNewClosure() + Enter-CcodMutex -Kind AccountTransition -UserSid $Sid -TimeoutMilliseconds $Timeout -Adapters @{WaitMutex=$wait} + } $UserSid $TimeoutMilliseconds $attemptEvent + }catch{[Console]::Error.WriteLine('ATOMIC_WAIT_FAILURE: '+($_|Out-String));throw} + }.GetNewClosure() + $process=[Diagnostics.Process]::GetCurrentProcess();$owner=[pscustomobject][ordered]@{pid=[int]$process.Id;creationTimeUtc=$process.StartTime.ToUniversalTime().ToString('o')} + $identity=[Security.Principal.WindowsIdentity]::GetCurrent();$sid=$identity.User.Value + $ownership=&$epochModule {param($Root,$Runtime,$Owner,$Sid,$Session,$EnterMutex)Enter-CcodLifecycleOwnership -InstallRoot $Root -RuntimeId $Runtime -RuntimeGeneration 1 -OwnerIdentity $Owner -UserSid $Sid -SessionId $Session -TimeoutMilliseconds 15000 -Adapters @{EnterMutex=$EnterMutex}} $InstallRoot $CurrentRuntime $owner $sid $process.SessionId $enterRealMutex + $pointer=&$runtimeModule {param($Root,$Runtime,$Generation,$Transaction,$Owner)Set-CcodActiveRuntime -InstallRoot $Root -NewRuntimeId $Runtime -TargetGeneration $Generation -FileTransaction $Transaction -Ownership $Owner} $InstallRoot $NextRuntime $transaction $transaction $ownership + [IO.File]::WriteAllText($ResultPath,($pointer|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new($false)) + exit 0 +}catch{$message=$_|Out-String;[Console]::Error.WriteLine($message);exit 3} +finally{if($null-ne$ownership){try{&$epochModule {param($Owner)Exit-CcodLifecycleOwnership -Ownership $Owner|Out-Null} $ownership}catch{}};if($null-ne$transaction){try{&$fileModule {param($Transaction)Close-CcodInstallFileTransaction -Transaction $Transaction -Disposition Ready} $transaction}catch{}};if($null-ne$attemptEvent){$attemptEvent.Dispose()};if($null-ne$identity){$identity.Dispose()};if($null-ne$process){$process.Dispose()}} +'@ + [IO.File]::WriteAllText($childScript,$child,[Text.UTF8Encoding]::new($false)) + try{ + $lifecycleModule=Import-Module (Join-Path $projectRoot 'src\persistence\modules\InstallLifecycle.psm1') -Force -PassThru -DisableNameChecking + $outer=&$lifecycleModule {Enter-CcodLifecycleProductCleanupLease} + $owner=&$lifecycleModule {Get-CcodLifecycleCurrentProductCleanupIdentity} + $fence=&$lifecycleModule {param($Root,$Ready,$Owner)New-CcodLifecycleProductCleanupFence -InstallRoot $Root -ReadyTransaction $Ready -OwnerIdentity $Owner} $fixture.Install $authority.Ready $owner + $product=Open-CcodInstallProductRegistrationTransaction -InstallRoot $fixture.Install -ReadyTransaction $authority.Ready;$fixture.Transactions.Add($product) + &$module {param($Transaction,$Fence)Set-CcodInstallProductCleanupFence -Transaction $Transaction -Fence $Fence|Out-Null} $product $fence + $powershell=(Get-Process -Id $PID).Path + $commitProcess=Start-Process -FilePath $powershell -ArgumentList @('-Mta','-NoProfile','-ExecutionPolicy','Bypass','-File',$childScript,'-ModuleRoot',$moduleRoot,'-InstallRoot',$fixture.Install,'-CurrentRuntime',$authority.RuntimeId,'-NextRuntime',$nextRuntime,'-AttemptEventName',$attemptEventName,'-ResultPath',$resultPath) -WindowStyle Hidden -RedirectStandardOutput $stdout -RedirectStandardError $stderr -PassThru + if(-not$attemptEvent.WaitOne(20000)){if(-not$commitProcess.HasExited){Stop-Process -Id $commitProcess.Id -Force};$commitProcess.WaitForExit();throw "real N+1 commit child did not atomically enter the AccountTransition wait: $($(if([IO.File]::Exists($stderr)){[IO.File]::ReadAllText($stderr)}else{'no stderr'}))"} + $commitBlocked=$true;$watch=[Diagnostics.Stopwatch]::StartNew();while($watch.ElapsedMilliseconds-lt5000){if([IO.File]::Exists($resultPath)-or$commitProcess.HasExited){$commitBlocked=$false;break};Start-Sleep -Milliseconds 20};$watch.Stop() + Assert-CcodTrue $commitBlocked 'N+1 commit is blocked after its atomic real AccountTransition wait signal' + $retained=Open-CcodInstallRetainedGeneration -InstallRoot $fixture.Install -RuntimeId $authority.RuntimeId -ExpectedManifestSha256 $authority.Manifest.Sha256 -FileTransaction $product + if($commitBlocked){$opened=Open-CcodInstallRetainedFile -Generation $retained -RelativePath 'registration/StartMenu.CodexRemote-fix.lnk' -ReadyTransaction $authority.Ready;Assert-CcodTrue ($null-ne$opened) 'N retained shortcut opens while its authority lease excludes N+1'} + Close-CcodInstallFileTransaction -Transaction $product -Disposition Ready + &$lifecycleModule {param($Fence)Complete-CcodLifecycleProductCleanupFence -Fence $Fence -Outcome Completed|Out-Null} $fence + $commitBlockedAfterNClose=$true;$watch=[Diagnostics.Stopwatch]::StartNew();while($watch.ElapsedMilliseconds-lt5000){if([IO.File]::Exists($resultPath)-or$commitProcess.HasExited){$commitBlockedAfterNClose=$false;break};Start-Sleep -Milliseconds 20};$watch.Stop() + Assert-CcodTrue $commitBlockedAfterNClose 'N+1 commit remains blocked after N strict authority closes while its durable outer AccountTransition lease is live' + &$lifecycleModule {param($Context)Exit-CcodLifecycleProductCleanupLease -Context $Context|Out-Null} $outer;$outer=$null + if(-not$commitProcess.WaitForExit(20000)){Stop-Process -Id $commitProcess.Id -Force;throw 'real N+1 commit child timed out'} + $commitProcess.Refresh();if(-not[IO.File]::Exists($resultPath)){throw "real N+1 commit child produced no committed pointer exit=$($commitProcess.ExitCode) stdout=$([IO.File]::ReadAllText($stdout)) stderr=$([IO.File]::ReadAllText($stderr))"} + $pointer=[IO.File]::ReadAllText($resultPath)|ConvertFrom-Json;Assert-CcodEqual 2 ([uint64]$pointer.generation) 'real commit advances to N+1 only after N authority closes' + Assert-CcodThrows {$stale=$null;try{$stale=Open-CcodInstallProductRegistrationTransaction -InstallRoot $fixture.Install -ReadyTransaction $authority.Ready}finally{if($null-ne$stale){Close-CcodInstallFileTransaction -Transaction $stale -Disposition Failed|Out-Null}}} 'CCOD_INSTALL_PRODUCT_SCOPE' + Assert-CcodThrows {Open-CcodInstallRetainedFile -Generation $retained -RelativePath 'registration/StartMenu.CodexRemote-fix.lnk' -ReadyTransaction $authority.Ready|Out-Null} 'CCOD_INSTALL_TRANSACTION_CLOSED' + }finally{ + if($null-ne$commitProcess-and-not$commitProcess.HasExited){try{Stop-Process -Id $commitProcess.Id -Force}catch{}};if($null-ne$commitProcess){$commitProcess.Dispose()} + if($null-ne$outer){try{&$lifecycleModule {param($Context)Exit-CcodLifecycleProductCleanupLease -Context $Context|Out-Null} $outer}catch{}} + if($null-ne$attemptEvent){$attemptEvent.Dispose()} + Remove-CcodInstallFileFixture $fixture + } +} + +# Production mutation caught: state-only recovery or an arbitrary absolute source can acquire product shortcut authority. +Invoke-CcodTest 'product registration transaction exposes only a selected retained manifest file capability' { + $fixture=New-CcodInstallFileFixture + try{ + $runtimeId='runtime-product-source';$source=New-CcodSourceFile $fixture 'product-source.lnk' 'sealed shortcut bytes';$generation=Open-CcodFixtureGeneration $fixture $runtimeId;$registration=New-CcodInstallGenerationLeaf -Generation $generation -Leaf 'registration';Copy-CcodInstallSealedSource -Generation $registration -SourcePath $source.Path -Leaf 'StartMenu.CodexRemote-fix.lnk' -ExpectedLength $source.Length -ExpectedSha256 $source.Sha256|Out-Null;$manifest=Write-CcodInstallGenerationManifest -Generation $generation -Manifest (New-CcodGenerationManifest $runtimeId @([ordered]@{path='registration/StartMenu.CodexRemote-fix.lnk';length=$source.Length;sha256=$source.Sha256}));$pointer=Commit-CcodInstallActivePointer -InstallRoot $fixture.Install -TargetGeneration $generation -ExpectedPreviousGeneration 0 -FileTransaction $generation;Close-CcodInstallFileTransaction -Transaction $generation -Disposition Ready + $readyRecord=[pscustomobject][ordered]@{schemaVersion=1;transactionId='11111111-2222-3333-4444-555555555555';oldRuntimeId=$null;oldGeneration=$null;oldManifestSha256=$null;newRuntimeId=$runtimeId;newGeneration=[uint64]1;newManifestSha256=$manifest.Sha256;sealedPackageSha256=('d'*64);ownedObjectNames=@($runtimeId);phase='Ready';errorCode=$null};$readyPath=Write-CcodProductReadyTransactionChain -InstallRoot $fixture.Install -ReadyRecord $readyRecord + foreach($field in @('oldRuntimeId','transactionId','ownedObjectNames')){$changed=$readyRecord.PSObject.Copy();if($field-ceq'oldRuntimeId'){$changed.oldRuntimeId='runtime-old'}elseif($field-ceq'transactionId'){$changed.transactionId='aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'}else{$changed.ownedObjectNames=@($runtimeId,'unexpected-owned-object')};Assert-CcodProductTransactionRejected -Fixture $fixture -Action {Open-CcodInstallProductRegistrationTransaction -InstallRoot $fixture.Install -ReadyTransaction $changed}} + $fabricated=[pscustomobject][ordered]@{phase='Ready';runtimeId=$runtimeId;runtimeGeneration=[uint64]1;manifestSha256=$manifest.Sha256;packageSha256=('d'*64)};Assert-CcodThrows {Open-CcodInstallProductRegistrationTransaction -InstallRoot $fixture.Install -ReadyTransaction $fabricated|Out-Null} 'CCOD_INSTALL_PRODUCT_SCOPE' + $product=Open-CcodInstallProductRegistrationTransaction -InstallRoot $fixture.Install -ReadyTransaction $readyRecord;$fixture.Transactions.Add($product);$retained=Open-CcodInstallRetainedGeneration -InstallRoot $fixture.Install -RuntimeId $runtimeId -ExpectedManifestSha256 $manifest.Sha256 -FileTransaction $product + $heldLease=&$module {param($Capability)$scope=Get-CcodInstallTransaction $Capability;$lease=$scope.State.AuthorityLease;$scope.State.AuthorityLease=$null;return $lease} $product + try{Assert-CcodThrows {Open-CcodInstallRetainedFile -Generation $retained -RelativePath 'registration/StartMenu.CodexRemote-fix.lnk' -ReadyTransaction $readyRecord|Out-Null} 'CCOD_INSTALL_PRODUCT_SCOPE'}finally{&$module {param($Capability,$Lease)$scope=Get-CcodInstallTransaction $Capability;$scope.State.AuthorityLease=$Lease} $product $heldLease} + $file=Open-CcodInstallRetainedFile -Generation $retained -RelativePath 'registration/StartMenu.CodexRemote-fix.lnk' -ReadyTransaction $readyRecord + Assert-CcodTrue ($null-ne$file) 'selected manifest file returns an opaque retained source capability' + Assert-CcodThrows {Open-CcodInstallRetainedFile -Generation $retained -RelativePath 'C:\outside\arbitrary.lnk' -ReadyTransaction $readyRecord|Out-Null} 'CCOD_INSTALL_PRODUCT_SHORTCUT_INVALID' + Assert-CcodThrows {New-CcodInstallDirectory -Transaction $product -Parent $product -Leaf 'state' -CreateIfMissing|Out-Null} 'CCOD_INSTALL_PRODUCT_SCOPE' + $preReady=$readyRecord.PSObject.Copy();$preReady.phase='ProtectionReady';Assert-CcodThrows {Open-CcodInstallProductRegistrationTransaction -InstallRoot $fixture.Install -ReadyTransaction $preReady|Out-Null} 'CCOD_INSTALL_PRODUCT_SCOPE';$changed=$readyRecord.PSObject.Copy();$changed.oldRuntimeId='changed-old';Assert-CcodThrows {Open-CcodInstallRetainedFile -Generation $retained -RelativePath 'registration/StartMenu.CodexRemote-fix.lnk' -ReadyTransaction $changed|Out-Null} 'CCOD_INSTALL_PRODUCT_SCOPE' + [IO.File]::WriteAllText((Join-Path $fixture.Install 'state\active-generation\00000000000000000002.json'),'{'+'"schemaVersion":1,"generation":2,"activeRuntime":"sibling","previousGeneration":1}',[Text.UTF8Encoding]::new($false));Assert-CcodThrows {Open-CcodInstallRetainedFile -Generation $retained -RelativePath 'registration/StartMenu.CodexRemote-fix.lnk' -ReadyTransaction $readyRecord|Out-Null} 'CCOD_INSTALL_PRODUCT_SCOPE' + $state=Open-CcodInstallStateTransaction -InstallRoot $fixture.Install;$fixture.Transactions.Add($state);Assert-CcodThrows {Open-CcodInstallRetainedGeneration -InstallRoot $fixture.Install -RuntimeId $runtimeId -ExpectedManifestSha256 $manifest.Sha256 -FileTransaction $state|Out-Null} 'CCOD_INSTALL_STATE_SCOPE';Assert-CcodThrows {Open-CcodInstallProductSpecialFolder -Generation $state -Kind Desktop|Out-Null} 'CCOD_INSTALL_PRODUCT_FOLDER_INVALID' + }finally{Remove-CcodInstallFileFixture $fixture} +} + +Invoke-CcodTest 'product authority rejects incomplete pointer stores and changed persisted full Ready identity' { + foreach($mutation in @('PointerGap','UnexpectedDirectory','HardlinkPointer','HardlinkTransaction','HardlinkManifest','ScalarOwnedSet','ChangedPersistedReady')){ + $fixture=New-CcodInstallFileFixture + try{ + $runtimeId='runtime-product-authority';$source=New-CcodSourceFile $fixture 'authority.lnk' 'authority shortcut';$generation=Open-CcodFixtureGeneration $fixture $runtimeId;$registration=New-CcodInstallGenerationLeaf -Generation $generation -Leaf 'registration';Copy-CcodInstallSealedSource -Generation $registration -SourcePath $source.Path -Leaf 'StartMenu.CodexRemote-fix.lnk' -ExpectedLength $source.Length -ExpectedSha256 $source.Sha256|Out-Null;$manifest=Write-CcodInstallGenerationManifest -Generation $generation -Manifest (New-CcodGenerationManifest $runtimeId @([ordered]@{path='registration/StartMenu.CodexRemote-fix.lnk';length=$source.Length;sha256=$source.Sha256}));$null=Commit-CcodInstallActivePointer -InstallRoot $fixture.Install -TargetGeneration $generation -ExpectedPreviousGeneration 0 -FileTransaction $generation;Close-CcodInstallFileTransaction -Transaction $generation -Disposition Ready + $ready=[pscustomobject][ordered]@{schemaVersion=1;transactionId='22222222-3333-4444-5555-666666666666';oldRuntimeId=$null;oldGeneration=$null;oldManifestSha256=$null;newRuntimeId=$runtimeId;newGeneration=[uint64]1;newManifestSha256=$manifest.Sha256;sealedPackageSha256=('e'*64);ownedObjectNames=@($runtimeId);phase='Ready';errorCode=$null} + if($mutation-ceq'PointerGap'){$ready.newGeneration=[uint64]3;[IO.File]::WriteAllText((Join-Path $fixture.Install 'state\active-generation\00000000000000000003.json'),([ordered]@{schemaVersion=1;generation=[uint64]3;activeRuntime=$runtimeId;previousGeneration=[uint64]2}|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new($false))} + if($mutation-ceq'UnexpectedDirectory'){[IO.Directory]::CreateDirectory((Join-Path $fixture.Install 'state\active-generation\unexpected'))|Out-Null} + if($mutation-ceq'ScalarOwnedSet'){$ready.ownedObjectNames=$runtimeId} + $readyPath=Write-CcodProductReadyTransactionChain -InstallRoot $fixture.Install -ReadyRecord $ready + if($mutation-ceq'HardlinkPointer'){New-CcodHardLink -Path (Join-Path $fixture.Base 'pointer-hardlink.json') -Existing (Join-Path $fixture.Install 'state\active-generation\00000000000000000001.json')} + elseif($mutation-ceq'HardlinkTransaction'){New-CcodHardLink -Path (Join-Path $fixture.Base 'transaction-hardlink.json') -Existing $readyPath} + elseif($mutation-ceq'HardlinkManifest'){New-CcodHardLink -Path (Join-Path $fixture.Base 'manifest-hardlink.json') -Existing (Join-Path $fixture.Install "runtime\$runtimeId\manifest.json")} + if($mutation-in@('PointerGap','UnexpectedDirectory','HardlinkPointer','HardlinkTransaction','HardlinkManifest','ScalarOwnedSet')){Assert-CcodProductTransactionRejected -Fixture $fixture -Action {Open-CcodInstallProductRegistrationTransaction -InstallRoot $fixture.Install -ReadyTransaction $ready};continue} + $product=Open-CcodInstallProductRegistrationTransaction -InstallRoot $fixture.Install -ReadyTransaction $ready;$fixture.Transactions.Add($product);$retained=Open-CcodInstallRetainedGeneration -InstallRoot $fixture.Install -RuntimeId $runtimeId -ExpectedManifestSha256 $manifest.Sha256 -FileTransaction $product + $changed=$ready.PSObject.Copy();$changed.sealedPackageSha256=('f'*64);[IO.File]::WriteAllText($readyPath,($changed|ConvertTo-Json -Depth 8 -Compress),[Text.UTF8Encoding]::new($false)) + Assert-CcodThrows {Open-CcodInstallRetainedFile -Generation $retained -RelativePath 'registration/StartMenu.CodexRemote-fix.lnk' -ReadyTransaction $ready|Out-Null} 'CCOD_INSTALL_PRODUCT_SCOPE' + }finally{Remove-CcodInstallFileFixture $fixture} + } +} + +Invoke-CcodTest 'V7 state-only transaction writes records but cannot reach generation or pointer operations' { + $fixture=New-CcodInstallFileFixture;$transaction=$null + try{$transaction=Open-CcodInstallStateTransaction -InstallRoot $fixture.Install;$fixture.Transactions.Add($transaction);$state=New-CcodInstallDirectory -Transaction $transaction -Parent $transaction -Leaf 'state' -CreateIfMissing;$records=New-CcodInstallDirectory -Transaction $transaction -Parent $state -Leaf 'install-transactions' -CreateIfMissing;Write-CcodInstallRecord -Transaction $transaction -Parent $records -Leaf 'ready.json' -Record ([ordered]@{schemaVersion=1;phase='Ready'})|Out-Null;Assert-CcodTrue (Test-Path -LiteralPath (Join-Path $fixture.Install 'state\install-transactions\ready.json')) 'state-only transaction writes a create-only state record';Assert-CcodThrows {Write-CcodInstallRecord -Transaction $transaction -Parent $records -Leaf 'ready.json' -Record ([ordered]@{schemaVersion=1;phase='Ready'})|Out-Null} 'CCOD_INSTALL_RECORD_EXISTS';Assert-CcodThrows {New-CcodInstallDirectory -Transaction $transaction -Parent $transaction -Leaf 'runtime' -CreateIfMissing|Out-Null} 'CCOD_INSTALL_STATE_SCOPE';Assert-CcodThrows {New-CcodInstallGenerationLeaf -Generation $transaction -Leaf 'payload'|Out-Null} 'CCOD_INSTALL_STATE_SCOPE';$source=New-CcodSourceFile $fixture 'state-source.txt' 'x';Assert-CcodThrows {Copy-CcodInstallSealedSource -Generation $transaction -SourcePath $source.Path -Leaf 'payload.bin' -ExpectedLength $source.Length -ExpectedSha256 $source.Sha256|Out-Null} 'CCOD_INSTALL_STATE_SCOPE';Assert-CcodThrows {Write-CcodInstallGenerationManifest -Generation $transaction -Manifest (New-CcodGenerationManifest 'state-runtime')|Out-Null} 'CCOD_INSTALL_STATE_SCOPE';Assert-CcodThrows {Open-CcodInstallRetainedGeneration -InstallRoot $fixture.Install -RuntimeId 'state-runtime' -ExpectedManifestSha256 ('0'*64) -FileTransaction $transaction|Out-Null} 'CCOD_INSTALL_STATE_SCOPE';Assert-CcodThrows {Commit-CcodInstallActivePointer -InstallRoot $fixture.Install -TargetGeneration $transaction -ExpectedPreviousGeneration 0 -FileTransaction $transaction|Out-Null} 'CCOD_INSTALL_POINTER_TARGET_INVALID';Assert-CcodThrows {Retire-CcodInstallGeneration -InstallRoot $fixture.Install -RuntimeId 'state-runtime' -FileTransaction $transaction|Out-Null} 'CCOD_INSTALL_GENERATION_NOT_OWNED';Assert-CcodEqual $false (Test-Path -LiteralPath (Join-Path $fixture.Install 'runtime')) 'state-only transaction creates no runtime tree';Assert-CcodEqual $false (Test-Path -LiteralPath (Join-Path $fixture.Install 'state\active-generation')) 'state-only transaction creates no active pointer';Assert-CcodEqual $false (Test-Path -LiteralPath (Join-Path $fixture.Install 'state\retired-generations')) 'state-only transaction creates no retirement record'}finally{Remove-CcodInstallFileFixture $fixture} +} + +Invoke-CcodTest 'migration retry transaction writes only state and targets one retained generation without creating a runtime' { + $fixture=New-CcodInstallFileFixture + try { + $runtimeId='runtime-migration-retry';$source=New-CcodSourceFile $fixture 'migration-retry.bin' 'sealed migration retry bytes' + $original=Open-CcodFixtureGeneration $fixture $runtimeId + Copy-CcodInstallSealedSource -Generation $original -SourcePath $source.Path -Leaf 'payload.bin' -ExpectedLength $source.Length -ExpectedSha256 $source.Sha256|Out-Null + $manifest=Write-CcodInstallGenerationManifest -Generation $original -Manifest (New-CcodGenerationManifest $runtimeId @([ordered]@{path='payload.bin';length=$source.Length;sha256=$source.Sha256})) + Close-CcodInstallFileTransaction -Transaction $original -Disposition Failed + $runtimeCount=@(Get-ChildItem -LiteralPath (Join-Path $fixture.Install 'runtime') -Directory -Force).Count + + $retry=&$module {param($Root)Open-CcodInstallMigrationRetryTransaction -InstallRoot $Root} $fixture.Install;$fixture.Transactions.Add($retry) + $state=New-CcodInstallDirectory -Transaction $retry -Parent $retry -Leaf 'state' -CreateIfMissing + $records=New-CcodInstallDirectory -Transaction $retry -Parent $state -Leaf 'install-transactions' -CreateIfMissing + Write-CcodInstallRecord -Transaction $retry -Parent $records -Leaf 'retry.json' -Record ([ordered]@{schemaVersion=1;phase='Prepared'})|Out-Null + $retained=Open-CcodInstallRetainedGeneration -InstallRoot $fixture.Install -RuntimeId $runtimeId -ExpectedManifestSha256 $manifest.Sha256 -FileTransaction $retry + Assert-CcodThrows {New-CcodInstallGenerationLeaf -Generation $retry -Leaf 'blocked'|Out-Null} 'CCOD_INSTALL_MIGRATION_RETRY_SCOPE' + Assert-CcodThrows {Copy-CcodInstallSealedSource -Generation $retry -SourcePath $source.Path -Leaf 'blocked.bin' -ExpectedLength $source.Length -ExpectedSha256 $source.Sha256|Out-Null} 'CCOD_INSTALL_MIGRATION_RETRY_SCOPE' + Assert-CcodThrows {Write-CcodInstallGenerationManifest -Generation $retry -Manifest (New-CcodGenerationManifest 'blocked')|Out-Null} 'CCOD_INSTALL_MIGRATION_RETRY_SCOPE' + Assert-CcodThrows {Open-CcodInstallProductSpecialFolder -Generation $retained -Kind Desktop|Out-Null} 'CCOD_INSTALL_MIGRATION_RETRY_SCOPE' + $pointer=Commit-CcodInstallActivePointer -InstallRoot $fixture.Install -TargetGeneration $retained -ExpectedPreviousGeneration 0 -FileTransaction $retry + Assert-CcodEqual ([uint64]1) ([uint64]$pointer.Generation) 'retry capability may append only through the retained target' + Assert-CcodEqual $runtimeCount @(Get-ChildItem -LiteralPath (Join-Path $fixture.Install 'runtime') -Directory -Force).Count 'retry capability creates no runtime directory' + Assert-CcodTrue (Test-Path -LiteralPath (Join-Path $fixture.Install 'state\install-transactions\retry.json') -PathType Leaf) 'retry capability publishes bounded state evidence' + } finally {Remove-CcodInstallFileFixture $fixture} +} + +Invoke-CcodTest 'force re-import rebinds the current runtime ABI before real use' { + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + $fixture = New-CcodInstallFileFixture + try { + $generation = Open-CcodFixtureGeneration $fixture 'runtime-reimport' + $child = New-CcodInstallGenerationLeaf -Generation $generation -Leaf 'child' + Assert-CcodEqual '' (@($child.PSObject.Properties.Name) -join ',') 'second import uses the current runtime type binding' + } finally { Remove-CcodInstallFileFixture $fixture } +} + +# Production mutation caught: a long-lived AppDomain already owns the V6 marker/runtime, so force-import silently selects stale CLR code. +Invoke-CcodTest 'stale V6 preload cannot capture the final runtime ABI across force re-imports' { + $escapedModule=$modulePath.Replace("'","''") + $child=@' +$ErrorActionPreference='Stop';$ProgressPreference='SilentlyContinue' +Add-Type -TypeDefinition @" +public sealed class CcodInstallGenerationCapabilityMarkerV6 +{ + private CcodInstallGenerationCapabilityMarkerV6() { } + public static int CapabilityAbi { get { return 6; } } +} +internal sealed class CcodInstallGenerationRuntimeV6 { } +"@ +$root=Join-Path ([IO.Path]::GetTempPath()) ('ccod-v7-preload-'+[guid]::NewGuid().ToString('N'));[IO.Directory]::CreateDirectory($root)|Out-Null;$transaction=$null +try{ + $module=Import-Module '__MODULE__' -Force -PassThru -DisableNameChecking -ErrorAction Stop + $selected=&$module {$script:CcodRuntimeType.FullName} + if($selected-cne'CcodInstallGenerationRuntimeV7'){[Console]::Out.WriteLine('SELECTED_RUNTIME='+$selected);exit 17} + $marker='CcodInstallGenerationCapabilityMarkerV7'-as[type];$staleMarker='CcodInstallGenerationCapabilityMarkerV6'-as[type];$staleRuntime=if($null-eq$staleMarker){$null}else{$staleMarker.Assembly.GetType('CcodInstallGenerationRuntimeV6',$false)} + if($null-eq$marker-or[int]$marker.GetProperty('CapabilityAbi').GetValue($null,$null)-ne7-or$null-eq$staleRuntime){throw 'V7 coexistence contract failed'} + $first=&$module {$script:CcodRuntimeType} + $module=Import-Module '__MODULE__' -Force -PassThru -DisableNameChecking -ErrorAction Stop + $second=&$module {$script:CcodRuntimeType} + if($first-ne$second-or$second.FullName-cne'CcodInstallGenerationRuntimeV7'){throw 'V7 force re-import selected a different runtime'} + $transaction=Open-CcodInstallStateTransaction -InstallRoot $root + $state=New-CcodInstallDirectory -Transaction $transaction -Parent $transaction -Leaf 'state' -CreateIfMissing + $records=New-CcodInstallDirectory -Transaction $transaction -Parent $state -Leaf 'v7-preload-proof' -CreateIfMissing + Write-CcodInstallRecord -Transaction $transaction -Parent $records -Leaf 'ready.json' -Record ([ordered]@{schemaVersion=1;phase='Ready'})|Out-Null + if(-not[IO.File]::Exists((Join-Path $root 'state\v7-preload-proof\ready.json'))){throw 'V7 runtime behavior missing'} + [Console]::Out.WriteLine('STALE_V6_SKIPPED_V7_OK') +}finally{ + if($null-ne$transaction){try{Close-CcodInstallFileTransaction -Transaction $transaction -Disposition Ready}catch{}} + $full=[IO.Path]::GetFullPath($root);$temp=[IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\')+'\';if($full.StartsWith($temp,[StringComparison]::OrdinalIgnoreCase)-and(Test-Path -LiteralPath $full)){Remove-Item -LiteralPath $full -Recurse -Force} +} +'@.Replace('__MODULE__',$escapedModule) + $encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($child));$output=@(& powershell.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand $encoded 2>&1);$exitCode=$LASTEXITCODE + Assert-CcodEqual 0 $exitCode "stale V6 preload must select final V7 output=$($output-join'|')" + Assert-CcodEqual 'STALE_V6_SKIPPED_V7_OK' ($output-join'') 'final V7 remains selected across force re-import and real state use' +} + +Invoke-CcodTest 'creates nested scoped directories and publishes sanitized records create-only' { + $fixture=New-CcodInstallFileFixture + try { + $transaction=Open-CcodFixtureGeneration $fixture 'runtime-scoped-records' + $state=New-CcodInstallDirectory -Transaction $transaction -Parent $transaction -Leaf 'state' -CreateIfMissing + $stateOpened=New-CcodInstallDirectory -Transaction $transaction -Parent $transaction -Leaf 'state';Assert-CcodEqual '' (@($stateOpened.PSObject.Properties.Name)-join ',') 'existing install-root directory opens through the same opaque wrapper' + $receipts=New-CcodInstallDirectory -Transaction $transaction -Parent $state -Leaf 'receipts' -CreateIfMissing + $final=Join-Path $fixture.Install 'state\receipts\activation.json';$observer=[CcodInstallFileRaceProbe]::ObserveFirstVisibleBytes($final) + $record=[ordered]@{schemaVersion=1;phase='Prepared';runtimeId='runtime-scoped-records'} + $written=Write-CcodInstallRecord -Transaction $transaction -Parent $receipts -Leaf 'activation.json' -Record $record + $firstVisible=$observer.GetAwaiter().GetResult();Assert-CcodEqual $written.Length ([int64]$firstVisible.LongLength) 'first visible scoped record has its sealed length' + $observed=Join-Path $fixture.Base 'observed-record.json';[IO.File]::WriteAllBytes($observed,$firstVisible);Assert-CcodEqual $written.Sha256 (Get-CcodTestFileSha256 $observed) 'first visible scoped record has its sealed digest' + $before=Get-CcodTestFileSha256 $final;Assert-CcodThrows {Write-CcodInstallRecord -Transaction $transaction -Parent $receipts -Leaf 'activation.json' -Record $record|Out-Null} 'CCOD_INSTALL_RECORD_EXISTS';Assert-CcodEqual $before (Get-CcodTestFileSha256 $final) 'record collision leaves existing bytes unchanged' + Write-CcodInstallRecord -Transaction $transaction -Parent $receipts -Leaf 'install.log' -Record ([ordered]@{event='phase';code='CCOD_OK'})|Out-Null + Assert-CcodThrows {New-CcodInstallDirectory -Transaction $transaction -Parent $state -Leaf '..\escape' -CreateIfMissing|Out-Null} 'CCOD_INSTALL_LEAF_INVALID' + Assert-CcodThrows {Write-CcodInstallRecord -Transaction $transaction -Parent $receipts -Leaf 'nested\record.json' -Record $record|Out-Null} 'CCOD_INSTALL_LEAF_INVALID' + } finally {Remove-CcodInstallFileFixture $fixture} +} + +Invoke-CcodTest 'abrupt writer exit cannot strand an exact internal create-only temporary leaf' { + $authority=New-CcodProductAuthorityFixture;$fixture=$authority.Fixture + $process=$null;$outer=$null;$lifecycleModule=$null + try { + $lifecycleModule=Import-Module (Join-Path $projectRoot 'src\persistence\modules\InstallLifecycle.psm1') -Force -PassThru -DisableNameChecking + $outer=&$lifecycleModule {Enter-CcodLifecycleProductCleanupLease} + $owner=$outer.OwnerIdentity + $fence=&$lifecycleModule {param($Root,$Ready,$Identity)New-CcodLifecycleProductCleanupFence -InstallRoot $Root -ReadyTransaction $Ready -OwnerIdentity $Identity} $fixture.Install $authority.Ready $owner + &$lifecycleModule {param($Context)Exit-CcodLifecycleProductCleanupLease -Context $Context|Out-Null} $outer;$outer=$null + $leaf=('00000000000000000001.Pending.'+$authority.Ready.transactionId+'.json') + $finalPath=Join-Path $fixture.Install ('state\product-cleanup-fences\'+$leaf);$finalSha=Get-CcodTestFileSha256 $finalPath + $marker=Join-Path $fixture.Base 'collision-reached.txt' + $payload=[ordered]@{module=$modulePath;install=$fixture.Install;leaf=$leaf;marker=$marker} + $payloadBase64=[Convert]::ToBase64String([Text.UTF8Encoding]::new($false).GetBytes(($payload|ConvertTo-Json -Compress))) + $child=@' +$ErrorActionPreference='Stop' +$payload=[Text.UTF8Encoding]::new($false,$true).GetString([Convert]::FromBase64String('__PAYLOAD__'))|ConvertFrom-Json -ErrorAction Stop +Import-Module $payload.module -Force -DisableNameChecking -ErrorAction Stop +$transaction=$null +try{ + $transaction=Open-CcodInstallStateTransaction -InstallRoot ([string]$payload.install) + $state=New-CcodInstallDirectory -Transaction $transaction -Parent $transaction -Leaf 'state' + $fences=New-CcodInstallDirectory -Transaction $transaction -Parent $state -Leaf 'product-cleanup-fences' + try{Write-CcodInstallRecord -Transaction $transaction -Parent $fences -Leaf ([string]$payload.leaf) -Record ([ordered]@{schemaVersion=1;state='replacement'})|Out-Null;throw 'collision unexpectedly published'} + catch{if((([string]$_.FullyQualifiedErrorId-split',')[0])-cne'CCOD_INSTALL_RECORD_EXISTS'){throw}} + [IO.File]::WriteAllText([string]$payload.marker,'collision',[Text.UTF8Encoding]::new($false)) + Start-Sleep -Seconds 30 +}finally{if($null-ne$transaction){try{Close-CcodInstallFileTransaction -Transaction $transaction -Disposition Failed|Out-Null}catch{}}} +'@.Replace('__PAYLOAD__',$payloadBase64) + $encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($child)) + $process=Start-Process -FilePath powershell.exe -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-EncodedCommand',$encoded) -PassThru -WindowStyle Hidden + $deadline=[DateTime]::UtcNow.AddSeconds(15);while(-not[IO.File]::Exists($marker)-and-not$process.HasExited-and[DateTime]::UtcNow-lt$deadline){Start-Sleep -Milliseconds 10} + Assert-CcodTrue ([IO.File]::Exists($marker)) 'child reaches a real create-only collision while its transaction remains live' + $process.Kill();$process.WaitForExit() + $temporaries=@(Get-ChildItem -LiteralPath (Split-Path $finalPath -Parent) -File -Force|Where-Object{$_.Name-cmatch'^\.ccod\.[0-9a-f]{32}\.tmp$'}) + Assert-CcodEqual 1 $temporaries.Count 'abrupt pre-fix writer exit leaves one exact internal crash artifact for recovery' + $outer=&$lifecycleModule {Enter-CcodLifecycleProductCleanupLease} + try{$history=@(&$lifecycleModule {param($Root,$Ready)Read-CcodLifecycleProductCleanupFenceHistory -InstallRoot $Root -ReadyTransaction $Ready} $fixture.Install $authority.Ready)} + finally{&$lifecycleModule {param($Context)Exit-CcodLifecycleProductCleanupLease -Context $Context|Out-Null} $outer;$outer=$null} + Assert-CcodEqual 1 $history.Count 'fence history remains readable after exact orphan recovery' + Assert-CcodEqual 'Pending' $history[0].State 'orphan recovery preserves the canonical Pending record' + Assert-CcodEqual 0 @(Get-ChildItem -LiteralPath (Split-Path $finalPath -Parent) -File -Force|Where-Object{$_.Name-cmatch'^\.ccod\.[0-9a-f]{32}\.tmp$'}).Count 'outer-lease native recovery removes only the exact orphan temporary' + Assert-CcodEqual $finalSha (Get-CcodTestFileSha256 $finalPath) 'crash cleanup leaves the original create-only record byte-for-byte intact' + } finally { + if($null-ne$process){if(-not$process.HasExited){try{$process.Kill();$process.WaitForExit()}catch{}};$process.Dispose()} + if($null-ne$outer){try{&$lifecycleModule {param($Context)Exit-CcodLifecycleProductCleanupLease -Context $Context|Out-Null} $outer}catch{}} + Remove-CcodInstallFileFixture $fixture + } +} + +Invoke-CcodTest 'generation and capabilities are unique create-only opaque references' { + $fixture=New-CcodInstallFileFixture + try { + $runtimeId='runtime-unique-a';$source=New-CcodSourceFile $fixture 'first.bin' 'first-generation-bytes';$generation=Open-CcodFixtureGeneration $fixture $runtimeId + Assert-CcodEqual '' (@($generation.PSObject.Properties.Name)-join ',') 'generation capability exposes no fields' + Copy-CcodInstallSealedSource -Generation $generation -SourcePath $source.Path -Leaf 'payload.bin' -ExpectedLength $source.Length -ExpectedSha256 $source.Sha256|Out-Null + $payload=Join-Path $fixture.Install "runtime\$runtimeId\payload.bin";$before=Get-CcodTestFileSha256 $payload + Assert-CcodThrows {Open-CcodInstallGeneration -InstallRoot $fixture.Install -RuntimeId $runtimeId|Out-Null} 'CCOD_INSTALL_GENERATION_EXISTS' + Assert-CcodEqual $before (Get-CcodTestFileSha256 $payload) 'duplicate generation leaves existing bytes unchanged' + $child=New-CcodInstallGenerationLeaf -Generation $generation -Leaf 'child' + Assert-CcodEqual '' (@($child.PSObject.Properties.Name)-join ',') 'child capability exposes no fields' + Assert-CcodThrows {New-CcodInstallGenerationLeaf -Generation $generation -Leaf 'child'|Out-Null} 'CCOD_INSTALL_LEAF_EXISTS' + Assert-CcodTrue ([IO.Directory]::Exists((Join-Path $fixture.Install "runtime\$runtimeId\child"))) 'duplicate child keeps existing directory' + Assert-CcodOutsideUnchanged $fixture 'duplicate generation and directory' + } finally {Remove-CcodInstallFileFixture $fixture} +} + +Invoke-CcodTest 'destination collisions preserve the first object byte-for-byte' { + $fixture=New-CcodInstallFileFixture + try { + $runtimeId='runtime-destination-collision';$first=New-CcodSourceFile $fixture 'first.bin' 'first-object';$second=New-CcodSourceFile $fixture 'second.bin' 'second-object';$generation=Open-CcodFixtureGeneration $fixture $runtimeId + Copy-CcodInstallSealedSource -Generation $generation -SourcePath $first.Path -Leaf 'payload.bin' -ExpectedLength $first.Length -ExpectedSha256 $first.Sha256|Out-Null + Assert-CcodThrows {Copy-CcodInstallSealedSource -Generation $generation -SourcePath $second.Path -Leaf 'payload.bin' -ExpectedLength $second.Length -ExpectedSha256 $second.Sha256|Out-Null} 'CCOD_INSTALL_LEAF_EXISTS' + Assert-CcodEqual $first.Sha256 (Get-CcodTestFileSha256 (Join-Path $fixture.Install "runtime\$runtimeId\payload.bin")) 'collision never overwrites the first object' + Assert-CcodOutsideUnchanged $fixture 'destination collision' + } finally {Remove-CcodInstallFileFixture $fixture} +} + +Invoke-CcodTest 'requested final leaf is absent until private temporary bytes are sealed' { + $fixture=New-CcodInstallFileFixture + try { + $runtimeId='runtime-private-until-sealed';$source=New-CcodSourceFile $fixture 'large-source.bin' ('private-source-block-'*1048576);$generation=Open-CcodFixtureGeneration $fixture $runtimeId;$final=Join-Path $fixture.Install "runtime\$runtimeId\payload.bin" + $observer=[CcodInstallFileRaceProbe]::ObserveFirstVisibleBytes($final) + Copy-CcodInstallSealedSource -Generation $generation -SourcePath $source.Path -Leaf 'payload.bin' -ExpectedLength $source.Length -ExpectedSha256 $source.Sha256|Out-Null + $firstVisible=$observer.GetAwaiter().GetResult() + Assert-CcodEqual $source.Length ([int64]$firstVisible.LongLength) 'first observable final leaf already has the sealed length' + $observedPath=Join-Path $fixture.Base 'observed.bin';[IO.File]::WriteAllBytes($observedPath,$firstVisible) + Assert-CcodEqual $source.Sha256 (Get-CcodTestFileSha256 $observedPath) 'first observable final leaf already has the sealed digest' + Assert-CcodEqual 0 @(Get-ChildItem -LiteralPath (Split-Path $final -Parent) -File -Filter '.ccod.*.tmp').Count 'successful publication leaves no private temporary name' + } finally {Remove-CcodInstallFileFixture $fixture} +} + +Invoke-CcodTest 'early source verification failure publishes no final leaf and close releases every stream' { + $fixture=New-CcodInstallFileFixture + try { + $runtimeId='runtime-early-copy-failure';$source=New-CcodSourceFile $fixture 'bad-source.bin' 'source-owned-before-hash';$generation=Open-CcodFixtureGeneration $fixture $runtimeId;$wrong=('0'*64) + if($wrong-ceq$source.Sha256){$wrong='1'*64} + Assert-CcodThrows {Copy-CcodInstallSealedSource -Generation $generation -SourcePath $source.Path -Leaf 'payload.bin' -ExpectedLength $source.Length -ExpectedSha256 $wrong|Out-Null} 'CCOD_INSTALL_SOURCE_MISMATCH' + $generationPath=Join-Path $fixture.Install "runtime\$runtimeId";Assert-CcodTrue (-not[IO.File]::Exists((Join-Path $generationPath 'payload.bin'))) 'source verification failure publishes no requested final leaf' + Assert-CcodEqual 'blocked' (Invoke-CcodMoveAttempt $source.Path) 'early source stream is transaction-owned until close' + Close-CcodInstallFileTransaction -Transaction $generation -Disposition Failed|Out-Null + Assert-CcodEqual 'moved' (Invoke-CcodMoveAttempt $source.Path) 'Failed close releases the early source stream' + $temporaries=@(Get-ChildItem -LiteralPath $generationPath -File -Filter '.ccod.*.tmp');foreach($temporary in $temporaries){Assert-CcodEqual 'moved' (Invoke-CcodMoveAttempt $temporary.FullName) 'Failed close releases every private temporary handle'} + } finally {Remove-CcodInstallFileFixture $fixture} +} + +Invoke-CcodTest 'source and destination handles remain private pinned and same-handle verified' { + $fixture=New-CcodInstallFileFixture + try { + $runtimeId='runtime-pinned-copy';$source=New-CcodSourceFile $fixture 'source.bin' ('sealed-source-v1'*65536);$replacement=New-CcodSourceFile $fixture 'replacement.bin' ('attacker-bytes'*65536);$generation=Open-CcodFixtureGeneration $fixture $runtimeId + $destination=Join-Path $fixture.Install "runtime\$runtimeId\payload.bin";$attack=[CcodInstallHandoffAttack]::new($destination,$replacement.Path);Assert-CcodTrue ($attack.WaitReady(5000)) 'handoff attacker is running before destination creation' + $copyFailure=$null;$copy=$null + try{$copy=Copy-CcodInstallSealedSource -Generation $generation -SourcePath $source.Path -Leaf 'payload.bin' -ExpectedLength $source.Length -ExpectedSha256 $source.Sha256}catch{$copyFailure=$_} + $attackOutcome=$attack.Stop();$attack.Dispose() + Assert-CcodTrue ($attackOutcome-in@('blocked','exchanged')) 'handoff attacker completes with a bounded result' + Assert-CcodTrue ($null-eq$copyFailure) "final-name attacker cannot cause a post-commit failure error=$($copyFailure.FullyQualifiedErrorId)" + Assert-CcodEqual $source.Length $copy.Length 'copy returns verified length';Assert-CcodEqual $source.Sha256 $copy.Sha256 'copy returns verified digest' + Assert-CcodEqual 'blocked' (Invoke-CcodMoveAttempt $source.Path) 'source replacement is blocked after its handle opens' + $currentModule = Get-Module InstallFileTransaction + $sealedPin = & $currentModule { + param($Generation) + $scope = Get-CcodInstallTransaction $Generation + $pins = $scope.State.Runtime.GetType().GetField('pins',[Reflection.BindingFlags]'NonPublic,Instance').GetValue($scope.State.Runtime) + $flags = [Reflection.BindingFlags]'NonPublic,Instance' + $pin = $null + foreach ($candidate in $pins.Values) { + $type = $candidate.GetType() + if (-not [bool]$type.GetField('Directory',$flags).GetValue($candidate) -and [string]$type.GetField('Leaf',$flags).GetValue($candidate) -ceq 'payload.bin') { $pin = $candidate; break } + } + $pinType = $pin.GetType() + $stream = $pinType.GetField('Stream',$flags).GetValue($pin) + return [pscustomobject]@{StreamOpen=($null-ne$stream);Length=[int64]($pinType.GetField('SealedLength',$flags).GetValue($pin));Sha256=[string]($pinType.GetField('SealedSha',$flags).GetValue($pin));Published=[bool]($pinType.GetField('Published',$flags).GetValue($pin))} + } $generation + Assert-CcodTrue $sealedPin.Published 'handoff marks the pin published only after final rename' + Assert-CcodTrue (-not $sealedPin.StreamOpen) 'post-commit path performs no potentially failing stream handoff' + Assert-CcodEqual $source.Length $sealedPin.Length 'strict handoff pin retains the same-handle verified length' + Assert-CcodEqual $source.Sha256 $sealedPin.Sha256 'strict handoff pin retains the same-handle verified digest' + $visibleSha=Get-CcodTestFileSha256 $destination + if($visibleSha-cne$source.Sha256){ + Write-CcodInstallGenerationManifest -Generation $generation -Manifest (New-CcodGenerationManifest $runtimeId)|Out-Null + try{Commit-CcodInstallActivePointer -InstallRoot $fixture.Install -TargetGeneration $generation -ExpectedPreviousGeneration ([uint64]0) -FileTransaction $generation|Out-Null;throw 'ASSERT_ATTACKED_GENERATION_COMMITTED'}catch{Assert-CcodTrue ($_.FullyQualifiedErrorId-match'^CCOD_INSTALL_(?:PIN_CHANGED|SEAL_MISMATCH|UNKNOWN_LEAF)') 'post-commit same-user mutation cannot become an eligible generation'} + return + } + Assert-CcodEqual $source.Sha256 $visibleSha 'successful sealed leaf is readable by path before transaction close' + try {[IO.File]::WriteAllText($destination,'attacker');throw 'ASSERT_DESTINATION_MUTABLE'} catch [IO.IOException] {} catch [UnauthorizedAccessException] {} + Assert-CcodEqual $source.Sha256 (Get-CcodTestFileSha256 $destination) 'published destination protection preserves verified bytes' + Assert-CcodOutsideUnchanged $fixture 'pinned copy' + } finally {Remove-CcodInstallFileFixture $fixture} +} + +Invoke-CcodTest 'manifest is create-only pinned and immutable after its same-handle write' { + $fixture=New-CcodInstallFileFixture + try { + $runtimeId='runtime-manifest';$generation=Open-CcodFixtureGeneration $fixture $runtimeId;$result=Write-CcodInstallGenerationManifest -Generation $generation -Manifest (New-CcodGenerationManifest $runtimeId) + Assert-CcodTrue ($result.Length-gt 0) 'manifest returns verified length';Assert-CcodTrue ($result.Sha256-cmatch'^[0-9a-f]{64}$') 'manifest returns lowercase digest' + $path=Join-Path $fixture.Install "runtime\$runtimeId\manifest.json";$before=Get-CcodTestFileSha256 $path + Assert-CcodThrows {Write-CcodInstallGenerationManifest -Generation $generation -Manifest (New-CcodGenerationManifest $runtimeId)|Out-Null} 'CCOD_INSTALL_LEAF_EXISTS' + try {[IO.File]::WriteAllText($path,'{"attacker":true}');throw 'ASSERT_MANIFEST_MUTABLE'} catch [IO.IOException] {} catch [UnauthorizedAccessException] {} + Assert-CcodEqual $before (Get-CcodTestFileSha256 $path) 'manifest mutation changes no bytes';Assert-CcodOutsideUnchanged $fixture 'manifest immutability' + } finally {Remove-CcodInstallFileFixture $fixture} +} + +Invoke-CcodTest 'rejects reparse ADS and multilink inputs before publishing a leaf' { + foreach($case in @('reparse','ads','multilink')){ + $fixture=New-CcodInstallFileFixture + try { + $runtimeId="runtime-invalid-$case" + if($case-ceq'reparse'){ + [IO.Directory]::CreateDirectory((Join-Path $fixture.Install 'runtime'))|Out-Null;New-CcodJunction (Join-Path $fixture.Install "runtime\$runtimeId") $fixture.Outside + Assert-CcodThrows {Open-CcodInstallGeneration -InstallRoot $fixture.Install -RuntimeId $runtimeId|Out-Null} 'CCOD_INSTALL_GENERATION_EXISTS';Assert-CcodOutsideUnchanged $fixture 'reparse collision';continue + } + $source=New-CcodSourceFile $fixture 'source.bin' 'invalid-source';$generation=Open-CcodFixtureGeneration $fixture $runtimeId + if($case-ceq'ads'){ + $previous=$ErrorActionPreference + try {$ErrorActionPreference='Continue';$output=& cmd.exe /d /c "echo attacker>`"$($source.Path):metadata`"" 2>&1;$exitCode=$LASTEXITCODE} finally {$ErrorActionPreference=$previous} + if($exitCode-ne 0){throw "Could not create alternate data stream: $($output -join ' ')"} + $expected='CCOD_INSTALL_ADS_LEAF' + }else{New-CcodHardLink (Join-Path $fixture.Outside 'outside-hardlink.bin') $source.Path;$expected='CCOD_INSTALL_MULTILINK_LEAF'} + Assert-CcodThrows {Copy-CcodInstallSealedSource -Generation $generation -SourcePath $source.Path -Leaf 'payload.bin' -ExpectedLength $source.Length -ExpectedSha256 $source.Sha256|Out-Null} $expected + Assert-CcodTrue (-not [IO.File]::Exists((Join-Path $fixture.Install "runtime\$runtimeId\payload.bin"))) "$case publishes no destination";Assert-CcodOutsideUnchanged $fixture "$case input" + } finally {Remove-CcodInstallFileFixture $fixture} + } +} + +Invoke-CcodTest 'rejects forged cross-transaction and closed capabilities' { + $firstFixture=New-CcodInstallFileFixture;$secondFixture=New-CcodInstallFileFixture + try { + $first=Open-CcodFixtureGeneration $firstFixture 'runtime-first';$second=Open-CcodFixtureGeneration $secondFixture 'runtime-second';$forged=New-Object psobject + Assert-CcodThrows {New-CcodInstallGenerationLeaf -Generation $forged -Leaf 'forged'|Out-Null} 'CCOD_INSTALL_GENERATION_INVALID' + Write-CcodInstallGenerationManifest -Generation $first -Manifest (New-CcodGenerationManifest 'runtime-first')|Out-Null + Assert-CcodThrows {Commit-CcodInstallActivePointer -InstallRoot $secondFixture.Install -TargetGeneration $first -ExpectedPreviousGeneration ([uint64]0) -FileTransaction $first|Out-Null} 'CCOD_INSTALL_TRANSACTION_SCOPE' + Assert-CcodThrows {Retire-CcodInstallGeneration -InstallRoot $firstFixture.Install -RuntimeId 'runtime-first' -FileTransaction $second|Out-Null} 'CCOD_INSTALL_TRANSACTION_SCOPE' + Close-CcodInstallFileTransaction -Transaction $first -Disposition Failed|Out-Null + Assert-CcodThrows {New-CcodInstallGenerationLeaf -Generation $first -Leaf 'after-close'|Out-Null} 'CCOD_INSTALL_TRANSACTION_CLOSED' + Assert-CcodOutsideUnchanged $firstFixture 'capability first';Assert-CcodOutsideUnchanged $secondFixture 'capability second' + } finally {Remove-CcodInstallFileFixture $firstFixture;Remove-CcodInstallFileFixture $secondFixture} +} + +Invoke-CcodTest 'close serializes native handle release against later relative operations' { + $fixture=New-CcodInstallFileFixture + try { + $runtimeId='runtime-close-barrier';$source=New-CcodSourceFile $fixture 'close-source.bin' 'close-source';$generation=Open-CcodFixtureGeneration $fixture $runtimeId;$child=New-CcodInstallGenerationLeaf -Generation $generation -Leaf 'child';Copy-CcodInstallSealedSource -Generation $generation -SourcePath $source.Path -Leaf 'payload.bin' -ExpectedLength $source.Length -ExpectedSha256 $source.Sha256|Out-Null + $currentModule=Get-Module InstallFileTransaction;$native=&$currentModule {param($Generation)$scope=Get-CcodInstallTransaction $Generation;[pscustomobject]@{Runtime=$scope.State.Runtime;Token=$scope.Record.Token}} $generation + $race=[CcodInstallFileRaceProbe]::RaceCreateAndClose($native.Runtime,$native.Token,'racing-child');Assert-CcodTrue ($race-in@('created|closed','closed|closed')) "relative create and close serialize without native handle misuse actual=$race" + Close-CcodInstallFileTransaction -Transaction $generation -Disposition Failed|Out-Null + Assert-CcodThrows {New-CcodInstallGenerationLeaf -Generation $child -Leaf 'late'|Out-Null} 'CCOD_INSTALL_TRANSACTION_CLOSED' + Assert-CcodEqual 'moved' (Invoke-CcodMoveAttempt $source.Path) 'close releases the deliberately retained source handle' + $live=Join-Path $fixture.Install "runtime\$runtimeId";$moved=$live+'.moved';[IO.Directory]::Move($live,$moved);Assert-CcodTrue ([IO.Directory]::Exists($moved)) 'close releases generation and destination handles';Assert-CcodOutsideUnchanged $fixture 'close barrier' + } finally {Remove-CcodInstallFileFixture $fixture} +} + +Invoke-CcodTest 'close retries a failing disposal and releases every other registered resource' { + $fixture=New-CcodInstallFileFixture + try { + $runtimeId='runtime-close-dispose-failure';$source=New-CcodSourceFile $fixture 'close-failure-source.bin' 'close-failure-source';$generation=Open-CcodFixtureGeneration $fixture $runtimeId;Copy-CcodInstallSealedSource -Generation $generation -SourcePath $source.Path -Leaf 'payload.bin' -ExpectedLength $source.Length -ExpectedSha256 $source.Sha256|Out-Null + $throwPath=Join-Path $fixture.Outside 'throwing-stream.bin';[CcodThrowingFileStream]::Reset();$throwing=[CcodThrowingFileStream]::new($throwPath) + $currentModule=Get-Module InstallFileTransaction + & $currentModule {param($Generation,$Throwing)$scope=Get-CcodInstallTransaction $Generation;$streams=$scope.State.Runtime.GetType().GetField('externalStreams',[Reflection.BindingFlags]'NonPublic,Instance').GetValue($scope.State.Runtime);$streams.Insert(0,$Throwing)} $generation $throwing + Assert-CcodThrows {Close-CcodInstallFileTransaction -Transaction $generation -Disposition Failed|Out-Null} 'CCOD_INSTALL_CLOSE_FAILED' + Assert-CcodEqual 1 ([CcodThrowingFileStream]::Attempts) 'first close attempts the injected failing resource once' + Assert-CcodEqual 'moved' (Invoke-CcodMoveAttempt $source.Path) 'first close continues past the injected failure and releases the source stream' + $live=Join-Path $fixture.Install "runtime\$runtimeId";$moved=$live+'.moved';[IO.Directory]::Move($live,$moved);Assert-CcodTrue ([IO.Directory]::Exists($moved)) 'first close continues past the injected failure and releases every generation pin' + Assert-CcodThrows {Close-CcodInstallFileTransaction -Transaction $generation -Disposition Failed|Out-Null} 'CCOD_INSTALL_CLOSE_FAILED' + Assert-CcodEqual 2 ([CcodThrowingFileStream]::Attempts) 'second close retries the retained failing resource instead of short-circuiting' + Assert-CcodOutsideUnchanged $fixture 'exception-safe close' + } finally {Remove-CcodInstallFileFixture $fixture} +} + +# Production mutation caught: a Ready product close without a durably bound Pending fence can release its account lease into an unguarded cross-process window. +Invoke-CcodTest 'Ready product close rejects release before a durable cleanup fence is bound' { + $authority=New-CcodProductAuthorityFixture;$fixture=$authority.Fixture + try { + $product=Open-CcodInstallProductRegistrationTransaction -InstallRoot $fixture.Install -ReadyTransaction $authority.Ready;$fixture.Transactions.Add($product) + Assert-CcodThrows {Close-CcodInstallFileTransaction -Transaction $product -Disposition Ready|Out-Null} 'CCOD_INSTALL_CLOSE_FAILED' + $currentModule=Get-Module InstallFileTransaction -ErrorAction Stop + $leaseLive=&$currentModule {param($Transaction)$state=$null;if(-not$script:CcodTransactions.TryGetValue($Transaction,[ref]$state)){return $false};return $null-ne$state.AuthorityLease-and-not$state.AuthorityLease.Released-and-not$state.Closed} $product + Assert-CcodTrue $leaseLive 'rejected unbound Ready close retains the same-thread product authority lease' + Close-CcodInstallFileTransaction -Transaction $product -Disposition Failed|Out-Null + } finally {Remove-CcodInstallFileFixture $fixture} +} + +# Production mutation caught: bypassing the private lower-close seam prevents lifecycle tests from exercising native-close failure while the real lease-release path still runs. +Invoke-CcodTest 'private lower close seam preserves the real transaction cleanup retry path' { + $fixture=New-CcodInstallFileFixture + $currentModule=Get-Module InstallFileTransaction -ErrorAction Stop + $lowerState=@{Attempts=0;FailuresRemaining=1} + $lowerClose={ + param($TransactionState,$DefaultClose) + $lowerState.Attempts++ + if($lowerState.FailuresRemaining-gt0){$lowerState.FailuresRemaining--;throw [IO.IOException]::new('injected lower native close failure')} + &$DefaultClose $TransactionState + }.GetNewClosure() + & $currentModule {param($Close)$script:CcodInstallFileTransactionLowerCloseForTest=$Close} $lowerClose + try { + $generation=Open-CcodFixtureGeneration $fixture 'runtime-lower-close-seam' + Assert-CcodThrows {Close-CcodInstallFileTransaction -Transaction $generation -Disposition Failed|Out-Null} 'CCOD_INSTALL_CLOSE_FAILED' + Assert-CcodEqual 1 $lowerState.Attempts 'first close reaches the injected lower native failure' + Close-CcodInstallFileTransaction -Transaction $generation -Disposition Failed|Out-Null + Assert-CcodEqual 2 $lowerState.Attempts 'same-thread retry reaches the lower close again and succeeds' + } finally { + & $currentModule {$script:CcodInstallFileTransactionLowerCloseForTest=$null} + Remove-CcodInstallFileFixture $fixture + } +} + +Invoke-CcodTest 'pointer generation records are monotonic create-only and collision safe' { + $fixture=New-CcodInstallFileFixture + try { + $first=Open-CcodFixtureGeneration $fixture 'runtime-pointer-one';Write-CcodInstallGenerationManifest -Generation $first -Manifest (New-CcodGenerationManifest 'runtime-pointer-one')|Out-Null + $committed=Commit-CcodInstallActivePointer -InstallRoot $fixture.Install -TargetGeneration $first -ExpectedPreviousGeneration ([uint64]0) -FileTransaction $first + Assert-CcodEqual ([uint64]1) ([uint64]$committed.Generation) 'first pointer generation is one' + $records=@(Get-ChildItem -LiteralPath (Join-Path $fixture.Install 'state\active-generation') -File -Filter '*.json');Assert-CcodEqual 1 $records.Count 'one pointer record';$before=Get-CcodTestFileSha256 $records[0].FullName + $second=Open-CcodFixtureGeneration $fixture 'runtime-pointer-two';Write-CcodInstallGenerationManifest -Generation $second -Manifest (New-CcodGenerationManifest 'runtime-pointer-two')|Out-Null + $secondCommit=Commit-CcodInstallActivePointer -InstallRoot $fixture.Install -TargetGeneration $second -ExpectedPreviousGeneration ([uint64]1) -FileTransaction $second + Assert-CcodEqual ([uint64]2) ([uint64]$secondCommit.Generation) 'second pointer generation increments to two' + $after=@(Get-ChildItem -LiteralPath (Join-Path $fixture.Install 'state\active-generation') -File -Filter '*.json'|Sort-Object Name);Assert-CcodEqual 2 $after.Count 'successful second commit appends one record';Assert-CcodEqual $before (Get-CcodTestFileSha256 $after[0].FullName) 'generation one remains unchanged' + $third=Open-CcodFixtureGeneration $fixture 'runtime-pointer-three';Write-CcodInstallGenerationManifest -Generation $third -Manifest (New-CcodGenerationManifest 'runtime-pointer-three')|Out-Null + Assert-CcodThrows {Commit-CcodInstallActivePointer -InstallRoot $fixture.Install -TargetGeneration $third -ExpectedPreviousGeneration ([uint64]1) -FileTransaction $third|Out-Null} 'CCOD_INSTALL_POINTER_GENERATION_EXISTS' + Assert-CcodEqual 2 @(Get-ChildItem -LiteralPath (Join-Path $fixture.Install 'state\active-generation') -File -Filter '*.json').Count 'no-replace contender publishes no third record' + Assert-CcodThrows {Commit-CcodInstallActivePointer -InstallRoot $fixture.Install -TargetGeneration $third -ExpectedPreviousGeneration ([uint64]::MaxValue) -FileTransaction $third|Out-Null} 'CCOD_INSTALL_POINTER_GENERATION_OVERFLOW' + Assert-CcodEqual 2 @(Get-ChildItem -LiteralPath (Join-Path $fixture.Install 'state\active-generation') -File -Filter '*.json').Count 'overflow publishes no record' + Assert-CcodOutsideUnchanged $fixture 'pointer monotonicity' + } finally {Remove-CcodInstallFileFixture $fixture} +} + +Invoke-CcodTest 'independent transactions race one pointer generation with exactly one no-replace winner' { + $fixture=New-CcodInstallFileFixture + $processes=@() + try { + $initial=Open-CcodFixtureGeneration $fixture 'runtime-pointer-initial';Write-CcodInstallGenerationManifest -Generation $initial -Manifest (New-CcodGenerationManifest 'runtime-pointer-initial')|Out-Null + Commit-CcodInstallActivePointer -InstallRoot $fixture.Install -TargetGeneration $initial -ExpectedPreviousGeneration ([uint64]0) -FileTransaction $initial|Out-Null + $go=Join-Path $fixture.Base 'go';$escapedModule=$modulePath.Replace("'","''");$escapedRoot=$fixture.Install.Replace("'","''") + foreach($index in 1..2){ + $runtimeId="runtime-pointer-racer-$index";$scriptPath=Join-Path $fixture.Base "racer-$index.ps1";$ready=Join-Path $fixture.Base "ready-$index";$outcome=Join-Path $fixture.Base "outcome-$index" + $scriptText=@" +`$ErrorActionPreference='Stop' +Import-Module '$escapedModule' -Force -DisableNameChecking +`$tx=`$null +try { + `$tx=Open-CcodInstallGeneration -InstallRoot '$escapedRoot' -RuntimeId '$runtimeId' + `$manifest=[ordered]@{schemaVersion=1;projectVersion='2.5.22';runtimeId='$runtimeId';commit='0123456789abcdef0123456789abcdef01234567';files=@()} + Write-CcodInstallGenerationManifest -Generation `$tx -Manifest `$manifest|Out-Null + [IO.File]::WriteAllText('$($ready.Replace("'","''"))','ready') + `$deadline=[DateTime]::UtcNow.AddSeconds(15) + while(-not[IO.File]::Exists('$($go.Replace("'","''"))')){if([DateTime]::UtcNow-gt`$deadline){throw 'barrier timeout'};Start-Sleep -Milliseconds 10} + try{Commit-CcodInstallActivePointer -InstallRoot '$escapedRoot' -TargetGeneration `$tx -ExpectedPreviousGeneration ([uint64]1) -FileTransaction `$tx|Out-Null;[IO.File]::WriteAllText('$($outcome.Replace("'","''"))','success')} + catch{[IO.File]::WriteAllText('$($outcome.Replace("'","''"))',[string]`$_.FullyQualifiedErrorId)} +} finally {if(`$null-ne`$tx){Close-CcodInstallFileTransaction -Transaction `$tx -Disposition Failed|Out-Null}} +"@ + [IO.File]::WriteAllText($scriptPath,$scriptText) + $powershell=Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + $processes+=Start-Process -FilePath $powershell -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File',$scriptPath) -WindowStyle Hidden -PassThru + } + $deadline=[DateTime]::UtcNow.AddSeconds(15) + while(@(1..2|Where-Object{-not[IO.File]::Exists((Join-Path $fixture.Base "ready-$_"))}).Count-ne 0){if([DateTime]::UtcNow-gt$deadline){throw 'pointer racers did not reach the barrier'};Start-Sleep -Milliseconds 20} + [IO.File]::WriteAllText($go,'go') + foreach($process in $processes){if(-not$process.WaitForExit(20000)){Stop-Process -Id $process.Id -Force;throw 'pointer racer timed out'};Assert-CcodEqual 0 $process.ExitCode 'pointer racer handles its no-replace result'} + $outcomes=@(1..2|ForEach-Object{[IO.File]::ReadAllText((Join-Path $fixture.Base "outcome-$_"))}) + Assert-CcodEqual 1 @($outcomes|Where-Object{$_-ceq'success'}).Count 'exactly one pointer racer wins' + Assert-CcodEqual 1 @($outcomes|Where-Object{$_-like'CCOD_INSTALL_POINTER_GENERATION_EXISTS*'}).Count 'losing pointer racer gets the stable collision code' + Assert-CcodEqual 2 @(Get-ChildItem -LiteralPath (Join-Path $fixture.Install 'state\active-generation') -File -Filter '*.json').Count 'race appends exactly one generation-two record' + } finally { + foreach($process in $processes){if(-not$process.HasExited){Stop-Process -Id $process.Id -Force}} + Remove-CcodInstallFileFixture $fixture + } +} + +Invoke-CcodTest 'opens a retained generation read-only and targets it with a generation-three compensation pointer' { + $fixture=New-CcodInstallFileFixture + $otherFixture=New-CcodInstallFileFixture + try { + $source=New-CcodSourceFile $fixture 'retained-source.bin' 'retained-runtime-bytes' + $old=Open-CcodFixtureGeneration $fixture 'runtime-retained-old';Copy-CcodInstallSealedSource -Generation $old -SourcePath $source.Path -Leaf 'payload.bin' -ExpectedLength $source.Length -ExpectedSha256 $source.Sha256|Out-Null + $oldManifest=Write-CcodInstallGenerationManifest -Generation $old -Manifest (New-CcodGenerationManifest 'runtime-retained-old' @([ordered]@{path='payload.bin';length=$source.Length;sha256=$source.Sha256})) + Commit-CcodInstallActivePointer -InstallRoot $fixture.Install -TargetGeneration $old -ExpectedPreviousGeneration ([uint64]0) -FileTransaction $old|Out-Null + Close-CcodInstallFileTransaction -Transaction $old -Disposition Ready|Out-Null + + $current=Open-CcodFixtureGeneration $fixture 'runtime-retained-current';Write-CcodInstallGenerationManifest -Generation $current -Manifest (New-CcodGenerationManifest 'runtime-retained-current')|Out-Null + Commit-CcodInstallActivePointer -InstallRoot $fixture.Install -TargetGeneration $current -ExpectedPreviousGeneration ([uint64]1) -FileTransaction $current|Out-Null + try{$retained=Open-CcodInstallRetainedGeneration -InstallRoot $fixture.Install -RuntimeId 'runtime-retained-old' -ExpectedManifestSha256 $oldManifest.Sha256 -FileTransaction $current}catch{$openFailure=$_;try{Close-CcodInstallFileTransaction -Transaction $current -Disposition Failed|Out-Null}catch{};throw $openFailure} + Assert-CcodEqual '' (@($retained.PSObject.Properties.Name)-join ',') 'retained generation capability is opaque' + Assert-CcodThrows {New-CcodInstallGenerationLeaf -Generation $retained -Leaf 'blocked'|Out-Null} 'CCOD_INSTALL_GENERATION_READ_ONLY' + Assert-CcodThrows {Copy-CcodInstallSealedSource -Generation $retained -SourcePath $source.Path -Leaf 'blocked.bin' -ExpectedLength $source.Length -ExpectedSha256 $source.Sha256|Out-Null} 'CCOD_INSTALL_GENERATION_READ_ONLY' + Assert-CcodThrows {Write-CcodInstallGenerationManifest -Generation $retained -Manifest (New-CcodGenerationManifest 'runtime-retained-old')|Out-Null} 'CCOD_INSTALL_GENERATION_READ_ONLY' + Assert-CcodThrows {New-CcodInstallDirectory -Transaction $current -Parent $retained -Leaf 'blocked-dir' -CreateIfMissing|Out-Null} 'CCOD_INSTALL_GENERATION_READ_ONLY' + Assert-CcodThrows {Write-CcodInstallRecord -Transaction $current -Parent $retained -Leaf 'blocked.json' -Record ([ordered]@{value='blocked'})|Out-Null} 'CCOD_INSTALL_GENERATION_READ_ONLY' + $compensation=Commit-CcodInstallActivePointer -InstallRoot $fixture.Install -TargetGeneration $retained -ExpectedPreviousGeneration ([uint64]2) -FileTransaction $current + Assert-CcodEqual ([uint64]3) ([uint64]$compensation.Generation) 'retained target appends compensation generation three' + $pointer=Get-Content -LiteralPath (Join-Path $fixture.Install 'state\active-generation\00000000000000000003.json') -Raw|ConvertFrom-Json + Assert-CcodEqual 'runtime-retained-old' $pointer.activeRuntime 'compensation pointer targets retained runtime ID' + + Assert-CcodThrows {Open-CcodInstallRetainedGeneration -InstallRoot $fixture.Install -RuntimeId 'runtime-missing' -ExpectedManifestSha256 $oldManifest.Sha256 -FileTransaction $current|Out-Null} 'CCOD_INSTALL_RETAINED_GENERATION_INVALID' + Assert-CcodThrows {Open-CcodInstallRetainedGeneration -InstallRoot $fixture.Install -RuntimeId 'runtime-retained-old' -ExpectedManifestSha256 ('0'*64) -FileTransaction $current|Out-Null} 'CCOD_INSTALL_RETAINED_MANIFEST_MISMATCH' + $sameRootForeign=Open-CcodFixtureGeneration $fixture 'runtime-same-root-foreign';Write-CcodInstallGenerationManifest -Generation $sameRootForeign -Manifest (New-CcodGenerationManifest 'runtime-same-root-foreign')|Out-Null + Assert-CcodThrows {Commit-CcodInstallActivePointer -InstallRoot $fixture.Install -TargetGeneration $retained -ExpectedPreviousGeneration ([uint64]3) -FileTransaction $sameRootForeign|Out-Null} 'CCOD_INSTALL_POINTER_TARGET_INVALID' + $other=Open-CcodFixtureGeneration $otherFixture 'runtime-other-root';Write-CcodInstallGenerationManifest -Generation $other -Manifest (New-CcodGenerationManifest 'runtime-other-root')|Out-Null + Assert-CcodThrows {Commit-CcodInstallActivePointer -InstallRoot $otherFixture.Install -TargetGeneration $retained -ExpectedPreviousGeneration ([uint64]0) -FileTransaction $other|Out-Null} 'CCOD_INSTALL_POINTER_TARGET_INVALID' + } finally {Remove-CcodInstallFileFixture $fixture;Remove-CcodInstallFileFixture $otherFixture} +} + +Invoke-CcodTest 'rejects structurally invalid or unowned retained generations' { + foreach($case in @('reparse','ads','multilink','unowned')){ + $fixture=New-CcodInstallFileFixture + try { + $source=New-CcodSourceFile $fixture 'source.bin' 'retained-invalid-source';$runtimeId="runtime-retained-$case";$old=Open-CcodFixtureGeneration $fixture $runtimeId + if($case-cne'unowned'){Copy-CcodInstallSealedSource -Generation $old -SourcePath $source.Path -Leaf 'payload.bin' -ExpectedLength $source.Length -ExpectedSha256 $source.Sha256|Out-Null} + $manifest=Write-CcodInstallGenerationManifest -Generation $old -Manifest (New-CcodGenerationManifest $runtimeId);Close-CcodInstallFileTransaction -Transaction $old -Disposition Ready|Out-Null + $runtimePath=Join-Path $fixture.Install "runtime\$runtimeId" + switch($case){ + 'reparse'{New-CcodJunction -Path (Join-Path $runtimePath 'linked') -Target $fixture.Outside;$expected='CCOD_INSTALL_REPARSE_LEAF'} + 'ads'{[IO.File]::SetAttributes((Join-Path $runtimePath 'payload.bin'),[IO.FileAttributes]::Normal);$previous=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$output=&cmd.exe /d /c "echo attacker>`"$(Join-Path $runtimePath 'payload.bin'):metadata`"" 2>&1;$exitCode=$LASTEXITCODE}finally{$ErrorActionPreference=$previous};if($exitCode-ne0){throw "ADS fixture failed: $($output-join' ')"};$expected='CCOD_INSTALL_ADS_LEAF'} + 'multilink'{New-CcodHardLink -Path (Join-Path $fixture.Outside 'retained-hardlink.bin') -Existing (Join-Path $runtimePath 'payload.bin');$expected='CCOD_INSTALL_MULTILINK_LEAF'} + 'unowned'{[IO.Directory]::CreateDirectory((Join-Path $fixture.Install 'runtime\runtime-unowned'))|Out-Null;[IO.File]::Copy((Join-Path $runtimePath 'manifest.json'),(Join-Path $fixture.Install 'runtime\runtime-unowned\manifest.json'));$runtimeId='runtime-unowned';$expected='CCOD_INSTALL_RETAINED_RUNTIME_ID_MISMATCH'} + } + $transaction=Open-CcodFixtureGeneration $fixture ("runtime-validator-$case");Write-CcodInstallGenerationManifest -Generation $transaction -Manifest (New-CcodGenerationManifest ("runtime-validator-$case"))|Out-Null + Assert-CcodThrows {Open-CcodInstallRetainedGeneration -InstallRoot $fixture.Install -RuntimeId $runtimeId -ExpectedManifestSha256 $manifest.Sha256 -FileTransaction $transaction|Out-Null} $expected + } finally {Remove-CcodInstallFileFixture $fixture} + } +} + +Invoke-CcodExtensionRedTest 'retained-alias' 'retained read-only propagates through transaction-root alias traversal' { + $fixture=New-CcodInstallFileFixture + try { + $runtimeId='runtime-retained-alias';$old=Open-CcodFixtureGeneration $fixture $runtimeId;$manifest=Write-CcodInstallGenerationManifest -Generation $old -Manifest (New-CcodGenerationManifest $runtimeId);Close-CcodInstallFileTransaction -Transaction $old -Disposition Ready|Out-Null + $transaction=Open-CcodFixtureGeneration $fixture 'runtime-alias-transaction';Write-CcodInstallGenerationManifest -Generation $transaction -Manifest (New-CcodGenerationManifest 'runtime-alias-transaction')|Out-Null + $retained=Open-CcodInstallRetainedGeneration -InstallRoot $fixture.Install -RuntimeId $runtimeId -ExpectedManifestSha256 $manifest.Sha256 -FileTransaction $transaction + $runtimeDirectory=New-CcodInstallDirectory -Transaction $transaction -Parent $transaction -Leaf 'runtime' + $alias=New-CcodInstallDirectory -Transaction $transaction -Parent $runtimeDirectory -Leaf $runtimeId + Assert-CcodThrows {New-CcodInstallDirectory -Transaction $transaction -Parent $alias -Leaf 'blocked-dir' -CreateIfMissing|Out-Null} 'CCOD_INSTALL_GENERATION_READ_ONLY' + Assert-CcodThrows {Write-CcodInstallRecord -Transaction $transaction -Parent $alias -Leaf 'blocked.json' -Record ([ordered]@{value='blocked'})|Out-Null} 'CCOD_INSTALL_GENERATION_READ_ONLY' + Assert-CcodThrows {New-CcodInstallGenerationLeaf -Generation $alias -Leaf 'blocked-leaf'|Out-Null} 'CCOD_INSTALL_GENERATION_READ_ONLY' + Assert-CcodThrows {Write-CcodInstallGenerationManifest -Generation $alias -Manifest (New-CcodGenerationManifest $runtimeId)|Out-Null} 'CCOD_INSTALL_GENERATION_READ_ONLY' + Assert-CcodTrue ($null-ne$retained) 'direct retained capability remains available only as a pointer target' + } finally {Remove-CcodInstallFileFixture $fixture} +} + +Invoke-CcodExtensionRedTest 'nested-retained' 'opens and recursively validates a nested retained generation read-only' { + $fixture=New-CcodInstallFileFixture + try { + $runtimeId='runtime-retained-nested';$source=New-CcodSourceFile $fixture 'nested-source.bin' 'nested-retained-bytes';$old=Open-CcodFixtureGeneration $fixture $runtimeId;$first=New-CcodInstallGenerationLeaf -Generation $old -Leaf 'first';$second=New-CcodInstallGenerationLeaf -Generation $first -Leaf 'second';Copy-CcodInstallSealedSource -Generation $second -SourcePath $source.Path -Leaf 'payload.bin' -ExpectedLength $source.Length -ExpectedSha256 $source.Sha256|Out-Null + $manifest=Write-CcodInstallGenerationManifest -Generation $old -Manifest (New-CcodGenerationManifest $runtimeId @([ordered]@{path='first\second\payload.bin';length=$source.Length;sha256=$source.Sha256}));Close-CcodInstallFileTransaction -Transaction $old -Disposition Ready|Out-Null + $transaction=Open-CcodFixtureGeneration $fixture 'runtime-nested-validator';Write-CcodInstallGenerationManifest -Generation $transaction -Manifest (New-CcodGenerationManifest 'runtime-nested-validator')|Out-Null + $retained=Open-CcodInstallRetainedGeneration -InstallRoot $fixture.Install -RuntimeId $runtimeId -ExpectedManifestSha256 $manifest.Sha256 -FileTransaction $transaction + Assert-CcodEqual '' (@($retained.PSObject.Properties.Name)-join ',') 'nested retained generation opens as an opaque capability' + $result=Commit-CcodInstallActivePointer -InstallRoot $fixture.Install -TargetGeneration $retained -ExpectedPreviousGeneration ([uint64]0) -FileTransaction $transaction + Assert-CcodEqual $runtimeId $result.RuntimeId 'nested retained tree remains a valid read-only pointer target' + } finally {Remove-CcodInstallFileFixture $fixture} +} + +Invoke-CcodExtensionRedTest 'semantic-runtime-id' 'rejects an escaped or nested runtimeId without one matching top-level property' { + $fixture=New-CcodInstallFileFixture + try { + $runtimeId='runtime-semantic-target';$old=Open-CcodFixtureGeneration $fixture $runtimeId + $manifest=Write-CcodInstallGenerationManifest -Generation $old -Manifest ([ordered]@{schemaVersion=1;projectVersion='2.5.22';description='escaped runtimeId marker';metadata=[ordered]@{runtimeId=$runtimeId};commit='0123456789abcdef0123456789abcdef01234567';files=@()});Close-CcodInstallFileTransaction -Transaction $old -Disposition Ready|Out-Null + $transaction=Open-CcodFixtureGeneration $fixture 'runtime-semantic-validator';Write-CcodInstallGenerationManifest -Generation $transaction -Manifest (New-CcodGenerationManifest 'runtime-semantic-validator')|Out-Null + Assert-CcodThrows {Open-CcodInstallRetainedGeneration -InstallRoot $fixture.Install -RuntimeId $runtimeId -ExpectedManifestSha256 $manifest.Sha256 -FileTransaction $transaction|Out-Null} 'CCOD_INSTALL_RETAINED_RUNTIME_ID_MISMATCH' + } finally {Remove-CcodInstallFileFixture $fixture} +} + +Invoke-CcodExtensionRedTest 'duplicate-runtime-id' 'rejects duplicate matching top-level runtimeId members in a retained manifest' { + $fixture=New-CcodInstallFileFixture + try { + $runtimeId='runtime-duplicate-semantic-target';$runtimePath=Join-Path $fixture.Install "runtime\$runtimeId";[IO.Directory]::CreateDirectory($runtimePath)|Out-Null + $manifestPath=Join-Path $runtimePath 'manifest.json';$manifestText='{"schemaVersion":1,"runtimeId":"'+$runtimeId+'","metadata":{"runtimeId":"nested-only"},"description":"escaped \"runtimeId\" marker","runtimeId":"'+$runtimeId+'","files":[]}' + [IO.File]::WriteAllText($manifestPath,$manifestText);$manifestSha=Get-CcodTestFileSha256 $manifestPath + $transaction=Open-CcodFixtureGeneration $fixture 'runtime-duplicate-semantic-validator';Write-CcodInstallGenerationManifest -Generation $transaction -Manifest (New-CcodGenerationManifest 'runtime-duplicate-semantic-validator')|Out-Null + Assert-CcodThrows {Open-CcodInstallRetainedGeneration -InstallRoot $fixture.Install -RuntimeId $runtimeId -ExpectedManifestSha256 $manifestSha -FileTransaction $transaction|Out-Null} 'CCOD_INSTALL_RETAINED_RUNTIME_ID_MISMATCH' + } finally {Remove-CcodInstallFileFixture $fixture} +} + +Invoke-CcodExtensionRedTest 'child-transaction' 'rejects a child directory capability as retained-open FileTransaction' { + $fixture=New-CcodInstallFileFixture + try { + $runtimeId='runtime-child-transaction-old';$old=Open-CcodFixtureGeneration $fixture $runtimeId;$manifest=Write-CcodInstallGenerationManifest -Generation $old -Manifest (New-CcodGenerationManifest $runtimeId);Close-CcodInstallFileTransaction -Transaction $old -Disposition Ready|Out-Null + $transaction=Open-CcodFixtureGeneration $fixture 'runtime-child-transaction-new';Write-CcodInstallGenerationManifest -Generation $transaction -Manifest (New-CcodGenerationManifest 'runtime-child-transaction-new')|Out-Null;$state=New-CcodInstallDirectory -Transaction $transaction -Parent $transaction -Leaf 'state' -CreateIfMissing + Assert-CcodThrows {Open-CcodInstallRetainedGeneration -InstallRoot $fixture.Install -RuntimeId $runtimeId -ExpectedManifestSha256 $manifest.Sha256 -FileTransaction $state|Out-Null} 'CCOD_INSTALL_TRANSACTION_INVALID' + } finally {Remove-CcodInstallFileFixture $fixture} +} + +Invoke-CcodExtensionRedTest 'v4-reimport' 'a child process loads V4 first then proves the V7 state-only export boundary' { + $fixture=New-CcodInstallFileFixture + try { + $source=New-CcodSourceFile $fixture 'v7-state-source.txt' 'state-only-after-v4' + $escapedModule=$modulePath.Replace("'","''");$escapedInstall=$fixture.Install.Replace("'","''");$escapedSource=$source.Path.Replace("'","''") + $child=@" +`$ErrorActionPreference='Stop';`$ProgressPreference='SilentlyContinue' +Add-Type -TypeDefinition 'public sealed class CcodInstallGenerationCapabilityMarkerV4 { private CcodInstallGenerationCapabilityMarkerV4() {} public static int CapabilityAbi { get { return 4; } } }' +if([CcodInstallGenerationCapabilityMarkerV4]::CapabilityAbi-ne4-or`$null-ne('CcodInstallGenerationCapabilityMarkerV7'-as[type])){throw 'V4 was not loaded first'} +Import-Module '$escapedModule' -Force -DisableNameChecking -ErrorAction Stop +if([CcodInstallGenerationCapabilityMarkerV7]::CapabilityAbi-ne7){throw 'V7 ABI missing'} +function Assert-ChildThrows([scriptblock]`$Action,[string]`$ErrorId){try{&`$Action;throw "EXPECTED_`$ErrorId"}catch{if(`$_.FullyQualifiedErrorId-notlike"`$ErrorId*"){throw}}} +`$transaction=Open-CcodInstallStateTransaction -InstallRoot '$escapedInstall' +try{ + `$state=New-CcodInstallDirectory -Transaction `$transaction -Parent `$transaction -Leaf 'state' -CreateIfMissing + `$records=New-CcodInstallDirectory -Transaction `$transaction -Parent `$state -Leaf 'v4-first-recovery' -CreateIfMissing + Write-CcodInstallRecord -Transaction `$transaction -Parent `$records -Leaf 'ready.json' -Record ([ordered]@{schemaVersion=1;phase='Ready'})|Out-Null + if(-not[IO.File]::Exists((Join-Path '$escapedInstall' 'state\v4-first-recovery\ready.json'))){throw 'state record missing'} + Assert-ChildThrows {New-CcodInstallDirectory -Transaction `$transaction -Parent `$transaction -Leaf 'runtime' -CreateIfMissing|Out-Null} 'CCOD_INSTALL_STATE_SCOPE' + Assert-ChildThrows {New-CcodInstallGenerationLeaf -Generation `$transaction -Leaf 'payload'|Out-Null} 'CCOD_INSTALL_STATE_SCOPE' + Assert-ChildThrows {Copy-CcodInstallSealedSource -Generation `$transaction -SourcePath '$escapedSource' -Leaf 'payload.bin' -ExpectedLength $($source.Length) -ExpectedSha256 '$($source.Sha256)'|Out-Null} 'CCOD_INSTALL_STATE_SCOPE' + Assert-ChildThrows {Write-CcodInstallGenerationManifest -Generation `$transaction -Manifest ([ordered]@{schemaVersion=1})|Out-Null} 'CCOD_INSTALL_STATE_SCOPE' + Assert-ChildThrows {Open-CcodInstallRetainedGeneration -InstallRoot '$escapedInstall' -RuntimeId 'runtime-v3-old' -ExpectedManifestSha256 ('0'*64) -FileTransaction `$transaction|Out-Null} 'CCOD_INSTALL_STATE_SCOPE' + Assert-ChildThrows {Commit-CcodInstallActivePointer -InstallRoot '$escapedInstall' -TargetGeneration `$transaction -ExpectedPreviousGeneration 0 -FileTransaction `$transaction|Out-Null} 'CCOD_INSTALL_POINTER_TARGET_INVALID' + Assert-ChildThrows {Retire-CcodInstallGeneration -InstallRoot '$escapedInstall' -RuntimeId 'runtime-v3-old' -FileTransaction `$transaction|Out-Null} 'CCOD_INSTALL_GENERATION_NOT_OWNED' + if([IO.Directory]::Exists((Join-Path '$escapedInstall' 'runtime'))-or[IO.Directory]::Exists((Join-Path '$escapedInstall' 'state\active-generation'))-or[IO.Directory]::Exists((Join-Path '$escapedInstall' 'state\retired-generations'))){throw 'state-only escape observed'} +}finally{Close-CcodInstallFileTransaction -Transaction `$transaction -Disposition Ready} +[Console]::Out.WriteLine('V4_FIRST_V7_STATE_ONLY_OK') +"@ + $encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($child));$output=@(& powershell.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand $encoded 2>&1);$exitCode=$LASTEXITCODE + Assert-CcodEqual 0 $exitCode 'fresh child process accepts V7 after loading only V4 first' + Assert-CcodEqual 'V4_FIRST_V7_STATE_ONLY_OK' ($output -join '') 'child proves only the exported V7 state-only boundary' + } finally {Remove-CcodInstallFileFixture $fixture} +} + +Invoke-CcodTest 'retirement writes one create-only record and keeps the nonempty generation in place' { + $fixture=New-CcodInstallFileFixture + try { + $runtimeId='runtime-retire';$source=New-CcodSourceFile $fixture 'source.bin' 'retained-retirement-bytes';$generation=Open-CcodFixtureGeneration $fixture $runtimeId;$child=New-CcodInstallGenerationLeaf $generation 'child' + Copy-CcodInstallSealedSource -Generation $child -SourcePath $source.Path -Leaf 'payload.bin' -ExpectedLength $source.Length -ExpectedSha256 $source.Sha256|Out-Null + Write-CcodInstallGenerationManifest -Generation $generation -Manifest (New-CcodGenerationManifest $runtimeId @([ordered]@{path='child\payload.bin';length=$source.Length;sha256=$source.Sha256}))|Out-Null + $live=Join-Path $fixture.Install "runtime\$runtimeId";$beforeSddl=(Get-Acl -LiteralPath $live).Sddl;$retirement=Retire-CcodInstallGeneration -InstallRoot $fixture.Install -RuntimeId $runtimeId -FileTransaction $generation + Assert-CcodEqual 'Retired' $retirement.Disposition 'bounded retirement result';Assert-CcodEqual '' (@($retirement.Capability.PSObject.Properties.Name)-join ',') 'retirement capability opaque';Assert-CcodTrue ([IO.Directory]::Exists($live)) 'record-only retirement keeps the generation live name in place' + Assert-CcodThrows {Copy-CcodInstallSealedSource -Generation $retirement.Capability -SourcePath $source.Path -Leaf 'post-retire.bin' -ExpectedLength $source.Length -ExpectedSha256 $source.Sha256|Out-Null} 'CCOD_INSTALL_GENERATION_RETIRED' + Assert-CcodTrue (-not [IO.File]::Exists((Join-Path $live 'post-retire.bin'))) 'retired capability cannot mutate retained generation bytes' + Assert-CcodEqual $source.Sha256 (Get-CcodTestFileSha256 (Join-Path $live 'child\payload.bin')) 'retained generation bytes remain readable and unchanged';Assert-CcodEqual $beforeSddl (Get-Acl -LiteralPath $live).Sddl 'retirement changes no DACL' + $records=@(Get-ChildItem -LiteralPath (Join-Path $fixture.Install 'state\retired-generations') -File -Filter '*.json');Assert-CcodEqual 1 $records.Count 'one retirement record' + $repeated=Retire-CcodInstallGeneration -InstallRoot $fixture.Install -RuntimeId $runtimeId -FileTransaction $generation + Assert-CcodEqual 'Retired' $repeated.Disposition 'repeated retirement is idempotent';Assert-CcodEqual 1 @(Get-ChildItem -LiteralPath (Join-Path $fixture.Install 'state\retired-generations') -File -Filter '*.json').Count 'idempotent retirement writes no second record' + Assert-CcodOutsideUnchanged $fixture 'record-only retirement' + } finally {Remove-CcodInstallFileFixture $fixture} +} + +Invoke-CcodTest 'retirement collision and record I/O failure leave generation eligible and mutable' { + foreach($case in @('collision','io-failure')){ + $fixture=New-CcodInstallFileFixture + try { + $runtimeId="runtime-retire-$case";$generation=Open-CcodFixtureGeneration $fixture $runtimeId;$state=Join-Path $fixture.Install 'state';[IO.Directory]::CreateDirectory($state)|Out-Null + if($case-ceq'collision'){ + $records=Join-Path $state 'retired-generations';[IO.Directory]::CreateDirectory($records)|Out-Null;$record=Join-Path $records ($runtimeId+'.json');[IO.File]::WriteAllText($record,'attacker-record');$recordSha=Get-CcodTestFileSha256 $record;$expected='CCOD_INSTALL_RETIREMENT_RECORD_EXISTS' + }else{ + [IO.File]::WriteAllText((Join-Path $state 'retired-generations'),'not-a-directory');$expected='CCOD_INSTALL_RETIREMENT_FAILED' + } + Assert-CcodThrows {Retire-CcodInstallGeneration -InstallRoot $fixture.Install -RuntimeId $runtimeId -FileTransaction $generation|Out-Null} $expected + $child=New-CcodInstallGenerationLeaf -Generation $generation -Leaf 'still-mutable';Assert-CcodEqual '' (@($child.PSObject.Properties.Name)-join ',') "$case retains the original capability" + Assert-CcodTrue ([IO.Directory]::Exists((Join-Path $fixture.Install "runtime\$runtimeId\still-mutable"))) "$case keeps the generation in place" + if($case-ceq'collision'){ + Assert-CcodEqual $recordSha (Get-CcodTestFileSha256 $record) 'retirement collision preserves the existing record' + $temporary=@(Get-ChildItem -LiteralPath $records -File -Filter '.ccod.*.tmp');Assert-CcodEqual 1 $temporary.Count 'failed no-replace retirement deliberately retains one diagnostic temporary record' + Assert-CcodEqual 'moved' (Invoke-CcodMoveAttempt $temporary[0].FullName) 'failed no-replace rename leaves no temporary stream locked' + Close-CcodInstallFileTransaction -Transaction $generation -Disposition Failed|Out-Null + [IO.File]::SetAttributes($temporary[0].FullName+'.moved',[IO.FileAttributes]::Normal);[IO.File]::Delete($temporary[0].FullName+'.moved');Assert-CcodTrue (-not[IO.File]::Exists($temporary[0].FullName+'.moved')) 'Failed disposition leaves the released diagnostic temporary removable' + } + } finally {Remove-CcodInstallFileFixture $fixture} + } +} + +Invoke-CcodTest 'handle-pinned reclamation deletes an exact readonly immutable generation and nothing outside it' { + $reclamationPath=Join-Path $projectRoot 'src\persistence\modules\GenerationReclamation.psm1' + if(-not(Test-Path -LiteralPath $reclamationPath -PathType Leaf)){throw [Management.Automation.ErrorRecord]::new([InvalidOperationException]::new('generation reclamation module is missing'),'CCOD_RECLAMATION_RED_MODULE_MISSING',[Management.Automation.ErrorCategory]::ObjectNotFound,$reclamationPath)} + Import-Module (Join-Path $projectRoot 'src\persistence\modules\RuntimeManifest.psm1') -Force -DisableNameChecking + Import-Module $reclamationPath -Force -DisableNameChecking + $fixture=New-CcodInstallFileFixture + try{ + $source=New-CcodSourceFile $fixture 'reclaim-source.bin' 'immutable-generation-reclamation';$record=[ordered]@{path='nested/payload.bin';length=$source.Length;sha256=$source.Sha256};$runtimeId=Get-CcodRuntimeId -ProjectVersion '2.5.22' -Files @($record) -Nonce '0123456789abcdef0123456789abcdef';$generation=Open-CcodFixtureGeneration $fixture $runtimeId;$nested=New-CcodInstallGenerationLeaf -Generation $generation -Leaf 'nested';Copy-CcodInstallSealedSource -Generation $nested -SourcePath $source.Path -Leaf 'payload.bin' -ExpectedLength $source.Length -ExpectedSha256 $source.Sha256|Out-Null;$manifest=Write-CcodInstallGenerationManifest -Generation $generation -Manifest ([ordered]@{schemaVersion=1;projectVersion='2.5.22';runtimeId=$runtimeId;files=@($record)});Close-CcodInstallFileTransaction -Transaction $generation -Disposition Ready|Out-Null + $runtimeRoot=Join-Path $fixture.Install "runtime\$runtimeId";Assert-CcodTrue (([IO.File]::GetAttributes((Join-Path $runtimeRoot 'nested\payload.bin'))-band[IO.FileAttributes]::ReadOnly)-ne0) 'fixture consumes the real immutable readonly leaf shape';$result=Remove-CcodVerifiedGenerationTree -InstallRoot $fixture.Install -RuntimeRoot $runtimeRoot -RuntimeId $runtimeId -ExpectedManifestSha256 $manifest.Sha256 + Assert-CcodEqual 'Completed' $result.phase 'immutable reclamation reaches terminal phase';Assert-CcodEqual 'Reclaimed' $result.result 'immutable reclamation reports exact result';Assert-CcodEqual 2 $result.fileCount 'manifest plus one sealed file were reclaimed';Assert-CcodTrue (-not(Test-Path -LiteralPath $runtimeRoot)) 'exact immutable generation is absent';Assert-CcodTrue (Test-Path -LiteralPath $fixture.Install -PathType Container) 'install root remains';Assert-CcodOutsideUnchanged $fixture 'immutable reclamation' + }finally{Remove-CcodInstallFileFixture $fixture} +} + +Write-Host 'Install file transaction self-test passed.' -ForegroundColor Green diff --git a/tests/persistence/InstallLifecycle.SelfTest.ps1 b/tests/persistence/InstallLifecycle.SelfTest.ps1 index d84bd9b..c8e985f 100644 --- a/tests/persistence/InstallLifecycle.SelfTest.ps1 +++ b/tests/persistence/InstallLifecycle.SelfTest.ps1 @@ -12,8 +12,25 @@ Import-Module (Join-Path $repositoryRoot 'src\persistence\modules\UiPreferences. Import-Module (Join-Path $repositoryRoot 'src\persistence\modules\RuntimeManifest.psm1') -Force Import-Module (Join-Path $repositoryRoot 'src\persistence\modules\LifecycleEpoch.psm1') -Force +function Invoke-CcodRuntimeCoreSetForTest { + param([Parameter(Mandatory)][string]$InstallRoot,[string]$NewRuntimeId,$TargetGeneration,$FileTransaction,$Ownership,[hashtable]$Adapters) + $module = Get-Module -Name RuntimeManifest + return & $module { param($Root,$Runtime,$Generation,$Transaction,$Lease,$Injected) Set-CcodActiveRuntimeCore -InstallRoot $Root -NewRuntimeId $Runtime -TargetGeneration $Generation -FileTransaction $Transaction -Ownership $Lease -Adapters $Injected } $InstallRoot $NewRuntimeId $TargetGeneration $FileTransaction $Ownership $Adapters +} + +$v2521LifecycleShortcutNames=@( + 'Programs\CodexRemote-fix\CodexRemote-fix.lnk', + 'Programs\CodexRemote-fix\CodexRemote-fix compatibility check.lnk', + 'Programs\CodexRemote-fix\Uninstall CodexRemote-fix.lnk', + 'Desktop\CodexRemote-fix.lnk' +) + function New-CcodLifecycleTempRoot { - return (Join-Path ([IO.Path]::GetTempPath()) ("ccod-lifecycle-" + [guid]::NewGuid().ToString('N'))) + $tempPath = [IO.Path]::GetFullPath(([IO.Path]::GetTempPath()).TrimEnd('\')) + $tempLeaf = Split-Path $tempPath -Leaf + $parent = Split-Path $tempPath -Parent + $base = if ($tempLeaf.Length -gt 8 -and -not [string]::IsNullOrWhiteSpace($parent) -and [IO.Directory]::Exists($parent)) { $parent } else { $tempPath } + return (Join-Path $base ('c-' + [guid]::NewGuid().ToString('N').Substring(0,8))) } function New-CcodLifecycleSourceFixture { @@ -46,6 +63,7 @@ function New-CcodLifecycleSourceFixture { [IO.File]::WriteAllText((Join-Path $Root 'src\persistence\UninstallBootstrap.ps1'), "# Uninstall bootstrap fixture`r`n", [Text.UTF8Encoding]::new($false)) [IO.File]::WriteAllText((Join-Path $Root 'src\persistence\bootstrap.ps1'), ("# Stable bootstrap fixture" + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) [IO.File]::WriteAllText((Join-Path $Root 'src\persistence\PortableUninstallFinalizer.ps1'), ("# Portable finalizer fixture" + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText((Join-Path $Root 'src\persistence\InstalledUninstallFinalizer.ps1'), ("# Installed finalizer fixture" + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) foreach ($module in @('PersistenceIO.psm1', 'RuntimeManifest.psm1', 'CompatibilityProbe.psm1', 'ProcessControl.psm1', 'StateStore.psm1', 'TransitionJournal.psm1', 'SessionEngine.psm1', 'SupervisorEngine.psm1', 'KernelObjects.psm1', 'TrayUi.psm1', 'UiLocalization.psm1', 'UiPreferences.psm1', 'ScheduledTask.psm1', 'PortableRelease.psm1')) { [IO.File]::WriteAllText((Join-Path $Root "src\persistence\modules\$module"), "# $module`r`n", [Text.UTF8Encoding]::new($false)) } @@ -60,6 +78,171 @@ function New-CcodLifecycleSourceFixture { return $Root } +function New-CcodLifecycleV2521LegacyInstallFixture { + param( + [Parameter(Mandatory)][string]$InstallRoot, + [Parameter(Mandatory)][string]$SourceRoot + ) + + # Source of truth: merge-base 19803a79af3302c49f129e6a6b3ff7ffaf1b930b. + # That release copied this pre-append-only inventory, derived a two-part + # version-digest runtime ID, wrote schema-2 active.json, and kept one + # mutable post-install activation receipt. + $relativeFiles = [Collections.Generic.List[string]]::new() + foreach ($relative in @( + 'src\check-package.mjs', + 'src\persistence\Supervisor.ps1', + 'src\persistence\SessionController.ps1', + 'src\persistence\StaticProbeWorker.ps1', + 'src\persistence\LifecycleWorker.ps1', + 'src\persistence\bootstrap.ps1', + 'src\persistence\UninstallBootstrap.ps1', + 'src\persistence\PortableUninstallFinalizer.ps1', + 'Test-CodexControlOtherDevices.ps1', + 'Start-CodexControlOtherDevices.ps1', + 'Reset-CodexControlOtherDevices.ps1' + )) { $relativeFiles.Add($relative) } + foreach ($file in @(Get-ChildItem -LiteralPath (Join-Path $SourceRoot 'bin') -File -Force)) { + $relativeFiles.Add('bin\' + $file.Name) + } + foreach ($file in @(Get-ChildItem -LiteralPath (Join-Path $SourceRoot 'src\runtime') -File -Force -Recurse)) { + $relativeFiles.Add('src\runtime\' + $file.FullName.Substring((Join-Path $SourceRoot 'src\runtime').TrimEnd('\').Length + 1)) + } + foreach ($file in @(Get-ChildItem -LiteralPath (Join-Path $SourceRoot 'src\persistence\modules') -Filter '*.psm1' -File -Force)) { + $relativeFiles.Add('src\persistence\modules\' + $file.Name) + } + foreach ($file in @(Get-ChildItem -LiteralPath (Join-Path $SourceRoot 'src\persistence\resources') -File -Force)) { + $relativeFiles.Add('src\persistence\resources\' + $file.Name) + } + + $runtimeParent = Join-Path $InstallRoot 'runtime' + $stagingRoot = Join-Path $runtimeParent '.v2521-fixture' + [IO.Directory]::CreateDirectory($stagingRoot) | Out-Null + foreach ($relative in $relativeFiles) { + $source = Join-Path $SourceRoot $relative + $destination = Join-Path $stagingRoot $relative + [IO.Directory]::CreateDirectory((Split-Path $destination -Parent)) | Out-Null + Copy-Item -LiteralPath $source -Destination $destination -Force + } + + $records = [Collections.Generic.List[object]]::new() + foreach ($file in @(Get-ChildItem -LiteralPath $stagingRoot -File -Force -Recurse)) { + $records.Add([pscustomobject][ordered]@{ + path = $file.FullName.Substring($stagingRoot.TrimEnd('\').Length + 1).Replace('\','/') + length = [int64]$file.Length + sha256 = (Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + }) + } + $comparison = [Comparison[object]]{ param($left,$right) [StringComparer]::Ordinal.Compare([string]$left.path,[string]$right.path) } + $records.Sort($comparison) + $canonical = (@($records) | ForEach-Object { '{0}`t{1}`t{2}' -f $_.path,$_.length,$_.sha256 }) -join "`n" + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + $digest = [BitConverter]::ToString($sha256.ComputeHash([Text.Encoding]::UTF8.GetBytes($canonical))).Replace('-','').ToLowerInvariant() + } finally { $sha256.Dispose() } + $runtimeId = '2.5.21-' + $digest.Substring(0,16) + $runtimeRoot = Join-Path $runtimeParent $runtimeId + [IO.Directory]::Move($stagingRoot,$runtimeRoot) + $manifest = [pscustomobject][ordered]@{ + schemaVersion = 1 + projectVersion = '2.5.21' + runtimeId = $runtimeId + files = @($records) + } + [IO.File]::WriteAllText((Join-Path $runtimeRoot 'manifest.json'),($manifest | ConvertTo-Json -Depth 16),[Text.UTF8Encoding]::new($false)) + + $timestamp = '2030-02-03T03:04:05.0000000Z' + $active = [pscustomobject][ordered]@{ + schemaVersion = 2 + activeRuntime = $runtimeId + previousRuntime = $null + generation = [uint64]1 + updatedAtUtc = $timestamp + } + [IO.File]::WriteAllText((Join-Path $InstallRoot 'active.json'),($active | ConvertTo-Json -Depth 4),[Text.UTF8Encoding]::new($false)) + $stateRoot = Join-Path $InstallRoot 'state' + [IO.Directory]::CreateDirectory($stateRoot) | Out-Null + $receipt = [pscustomobject][ordered]@{ + schemaVersion = 1 + activationId = '11111111-2222-4333-8444-555555555555' + phase = 'Ready' + runtimeId = $runtimeId + previousRuntimeId = $null + startedAtUtc = $timestamp + updatedAtUtc = $timestamp + ready = $true + errorCode = $null + } + [IO.File]::WriteAllText((Join-Path $stateRoot 'post-install-activation.json'),($receipt | ConvertTo-Json -Depth 4),[Text.UTF8Encoding]::new($false)) + return [pscustomobject][ordered]@{ RuntimeId=$runtimeId;RuntimeRoot=$runtimeRoot;Manifest=$manifest;Active=$active;Receipt=$receipt } +} + +function New-CcodLifecycleLegacyMigrationRetryFixture { + $legacySource=New-CcodLifecycleTempRoot;$source=New-CcodLifecycleTempRoot;$install=New-CcodLifecycleTempRoot;$nodeRoot=New-CcodLifecycleTempRoot + try { + New-CcodLifecycleSourceFixture -Root $legacySource -Version '2.5.21'|Out-Null + $legacy=New-CcodLifecycleV2521LegacyInstallFixture -InstallRoot $install -SourceRoot $legacySource + New-CcodLifecycleSourceFixture -Root $source -Version '2.5.22'|Out-Null + $node=New-CcodLifecycleFakeNode -Root $nodeRoot;$fake=New-CcodLifecycleFake -NodePath $node + $fake.World.SetActiveFailure={param($InstallRoot,$RuntimeId,$Ownership,$TargetGeneration,$FileTransaction)throw [Management.Automation.ErrorRecord]::new([InvalidOperationException]::new('adapted pointer failure'),'CCOD_RUNTIME_POINTER_COMMIT_FAILED',[Management.Automation.ErrorCategory]::InvalidData,$RuntimeId)} + $failure=$null;try{Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -SealedPackageSha256 ('d'*64) -Adapters $fake.Adapters|Out-Null}catch{$failure=$_} + if($null-eq$failure-or([string]$failure.FullyQualifiedErrorId-split',')[0]-cne'CCOD_RUNTIME_POINTER_COMMIT_FAILED'){throw 'legacy migration retry fixture did not reach the expected first failure'} + $fake.World.SetActiveFailure=$null + $module=Get-Module InstallLifecycle -ErrorAction Stop;$head=&$module {param($Root)Read-CcodInstallTransactionRecord -InstallRoot $Root} $install + return [pscustomobject]@{LegacySource=$legacySource;Source=$source;Install=$install;NodeRoot=$nodeRoot;Node=$node;Legacy=$legacy;Fake=$fake;Head=$head} + } catch { + foreach($path in @($legacySource,$source,$install,$nodeRoot)){if(Test-Path -LiteralPath $path){Remove-Item -LiteralPath $path -Recurse -Force}} + throw + } +} + +function Remove-CcodLifecycleLegacyMigrationRetryFixture { + param([Parameter(Mandatory)]$Fixture) + foreach($path in @($Fixture.LegacySource,$Fixture.Source,$Fixture.Install,$Fixture.NodeRoot)){if(Test-Path -LiteralPath $path){Remove-Item -LiteralPath $path -Recurse -Force}} +} + +function New-CcodLifecyclePayloadManifest { + param( + [Parameter(Mandatory)][string]$Root, + [Parameter(Mandatory)][string]$Version + ) + + $module = Get-Module InstallLifecycle + $sourceFiles = @(& $module { param($SourceRoot) Get-CcodLifecycleSourceFiles -SourceRoot $SourceRoot -RequireTrayHost } $Root) + $recordList = [Collections.Generic.List[object]]::new() + foreach ($sourceFile in $sourceFiles) { + $recordList.Add([pscustomobject][ordered]@{ + path = ([string]$sourceFile.Relative).Replace('\','/') + length = [int64](Get-Item -LiteralPath $sourceFile.Source -Force).Length + sha256 = (Get-FileHash -LiteralPath $sourceFile.Source -Algorithm SHA256).Hash.ToLowerInvariant() + }) + } + $comparison = [System.Comparison[object]]{ param($left,$right) [StringComparer]::Ordinal.Compare([string]$left.path,[string]$right.path) } + $recordList.Sort($comparison) + $records = @($recordList) + $manifestPath = Join-Path $Root 'installer-payload.manifest.json' + [IO.File]::WriteAllText( + $manifestPath, + ([ordered]@{ schemaVersion = 1; projectVersion = $Version; files = $records } | ConvertTo-Json -Depth 8), + [Text.UTF8Encoding]::new($false) + ) + return $manifestPath +} + +function Get-CcodLifecyclePayloadBinding { + param( + [Parameter(Mandatory)][string]$ManifestPath, + [Parameter(Mandatory)][string]$ExpectedVersion + ) + $bytes = [IO.File]::ReadAllBytes($ManifestPath) + return @{ + ExpectedVersion = $ExpectedVersion + PayloadManifestPath = $ManifestPath + ExpectedPayloadManifestSha256 = Get-CcodTestFileSha256 -Path $ManifestPath + PayloadManifestBytesBase64 = [Convert]::ToBase64String($bytes) + } +} + function New-CcodLifecycleFakeNode { param([Parameter(Mandatory)][string]$Root) New-Item -ItemType Directory -Path (Join-Path $Root 'node') -Force | Out-Null @@ -142,6 +325,10 @@ function New-CcodLifecycleFake { LogRecords = [Collections.Generic.List[object]]::new() FailLogCode = $null CopyOverride = $null + TaskRuntimeIds = [Collections.Generic.List[string]]::new() + ProductRegistrationCalls = 0 + ProductRegistrationFailure = $false + ProductRegistrationReadyObserved = $false } $adapters = @{} $adapters.ValidateSource = { param($SourceRoot) $world.Calls.Add("Validate:$([IO.Path]::GetFileName($SourceRoot))"); [bool]$world.ValidateSource }.GetNewClosure() @@ -150,20 +337,25 @@ function New-CcodLifecycleFake { $adapters.ValidateNodeCandidate = { param($Path) $world.Calls.Add("ValidateNode:$([IO.Path]::GetFileName($Path))"); $Path -ceq $world.NodePath }.GetNewClosure() $adapters.GetCurrentIdentity = { $world.Calls.Add('Identity'); $world.Identity }.GetNewClosure() $adapters.UtcNow = { $world.Calls.Add('Now'); $world.NowUtc }.GetNewClosure() - $adapters.NewActivationId = { 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' }.GetNewClosure() + $adapters.NewActivationId = { [guid]::NewGuid().ToString('D') }.GetNewClosure() $adapters.WriteActivationReceipt = { - param($InstallRoot, $Receipt) + param($InstallRoot, $Receipt, $FileTransaction) $world.Phases.Add([string]$Receipt.phase) - $stateRoot = Join-Path $InstallRoot 'state' - [IO.Directory]::CreateDirectory($stateRoot) | Out-Null - [IO.File]::WriteAllText((Join-Path $stateRoot 'post-install-activation.json'), ($Receipt | ConvertTo-Json -Depth 8), [Text.UTF8Encoding]::new($false)) + if ($null -ne $FileTransaction) { + $module = Get-Module -Name InstallLifecycle -ErrorAction Stop + & $module { param($Root,$Value,$Transaction) Write-CcodActivationReceiptFile -InstallRoot $Root -Receipt $Value -FileTransaction $Transaction } $InstallRoot $Receipt $FileTransaction + } else { + $stateRoot = Join-Path $InstallRoot 'state' + [IO.Directory]::CreateDirectory($stateRoot) | Out-Null + [IO.File]::WriteAllText((Join-Path $stateRoot 'post-install-activation.json'), ($Receipt | ConvertTo-Json -Depth 8), [Text.UTF8Encoding]::new($false)) + } }.GetNewClosure() $adapters.ReadActiveLifecycleRequest = { param($StateRoot) $world.Calls.Add('ReadActiveLifecycleRequest');$world.ActiveLifecycleRequest }.GetNewClosure() $adapters.WaitNewRuntimeReady = { param($InstallRoot, $RuntimeId, $RuntimeGeneration, $Identity, $TaskStartedAtUtc, $TimeoutMilliseconds) [pscustomobject][ordered]@{ SupervisorReady = [bool]$world.NewSupervisorReady; TrayReady = [bool]$world.NewTrayReady } }.GetNewClosure() - $adapters.InstallSupervisorTask = { param($InstallRoot, $UserSid) $world.Calls.Add("InstallTask:$([IO.Path]::GetFileName($InstallRoot)):$UserSid"); $world.TaskInstalled++ }.GetNewClosure() + $adapters.InstallSupervisorTask = { param($InstallRoot, $UserSid, $RuntimeId) $world.Calls.Add("InstallTask:$([IO.Path]::GetFileName($InstallRoot)):${UserSid}:$RuntimeId"); $world.TaskRuntimeIds.Add([string]$RuntimeId); $world.TaskInstalled++ }.GetNewClosure() $adapters.RemoveSupervisorTask = { $world.Calls.Add('RemoveTask'); $world.TaskRemoved++ }.GetNewClosure() $adapters.TestSupervisorTaskAbsent = { $world.Calls.Add('TaskAbsent'); [bool]$world.TaskAbsent }.GetNewClosure() $adapters.StartSupervisorTask = { $world.Calls.Add('StartTask');if($world.StartTaskFailure){throw 'PRIVATE_TASK_START_SECRET'};$world.TaskStarted++ }.GetNewClosure() @@ -210,10 +402,10 @@ function New-CcodLifecycleFake { } }.GetNewClosure() $adapters.SetActiveRuntime = { - param($InstallRoot, $RuntimeId, $Ownership) - if ($null -ne $world.SetActiveFailure) { & $world.SetActiveFailure $InstallRoot $RuntimeId $Ownership } - $assertFence = { param($Root, $Receipt, $ExpectActivePointer) if ($Receipt.released) { throw 'released lifecycle owner' }; $true } - Set-CcodActiveRuntime -InstallRoot $InstallRoot -NewRuntimeId $RuntimeId -Ownership $Ownership -Adapters @{ AssertLifecycleFence=$assertFence } + param($InstallRoot, $RuntimeId, $Ownership, $TargetGeneration, $FileTransaction) + if ($null -ne $world.SetActiveFailure) { & $world.SetActiveFailure $InstallRoot $RuntimeId $Ownership $TargetGeneration $FileTransaction } + $assertFence = { param($Root, $Receipt, $ExpectActivePointer,$TargetRuntimeId) if ($Receipt.released) { throw 'released lifecycle owner' }; $true } + Invoke-CcodRuntimeCoreSetForTest -InstallRoot $InstallRoot -NewRuntimeId $RuntimeId -TargetGeneration $TargetGeneration -FileTransaction $FileTransaction -Ownership $Ownership -Adapters @{ AssertLifecycleFence=$assertFence } }.GetNewClosure() $adapters.ExitLifecycleOwnership = { param($Ownership) @@ -258,12 +450,442 @@ function New-CcodLifecycleFake { } $world.LogRecords.Add($Record) }.GetNewClosure() + $adapters.RegisterProduct = { + param($InstallRoot,$RuntimeId,$Version,$PackageSha256,$FileTransaction,$TransactionRecord) + $world.Calls.Add("RegisterProduct:$RuntimeId") + $world.ProductRegistrationCalls++ + $world.ProductRegistrationReadyObserved = $null -ne $TransactionRecord -and $TransactionRecord.phase -ceq 'Ready' + if ($world.ProductRegistrationFailure) { throw [Management.Automation.ErrorRecord]::new([InvalidOperationException]::new('fixture registration failed'),'CCOD_PRODUCT_REGISTRATION_FAILED',[Management.Automation.ErrorCategory]::InvalidData,$RuntimeId) } + [pscustomobject]@{ verified=$true; legacyRemoved=$true } + }.GetNewClosure() + $adapters.AddProductShortcutCandidates = { param($Files) [pscustomobject]@{Files=@($Files);TemporaryRoot=$null} } [pscustomobject]@{ World = $world; Adapters = $adapters } } +function New-CcodLifecycleProductSideEffectAdapters { + param([Parameter(Mandatory)][hashtable]$State) + @{ + WriteProductRegistration={param($Registration,$ReadyEvidence)if($State.FailWrites){throw 'TEST_PRODUCT_WRITE_FAILURE'};$State.Registration=$Registration;$State.ReadyEvidence=$ReadyEvidence;$State.Writes++}.GetNewClosure() + ReadProductRegistration={param($Registration)if($State.ContainsKey('FailReadProduct')-and$State.FailReadProduct){throw 'TEST_PRODUCT_READBACK_FAILURE'};$State.Registration}.GetNewClosure() + WriteShortcut={ + param($Kind,$Shortcut,$FileTransaction,$ReadyEvidence) + $relative='registration/'+$(if($Kind-ceq'StartMenu'){'StartMenu.CodexRemote-fix.lnk'}else{'Desktop.CodexRemote-fix.lnk'}) + try{$fileModule=Get-Module -All|Where-Object{$null-ne$_.Path-and[IO.Path]::GetFileName($_.Path)-ceq'InstallFileTransaction.psm1'}|Select-Object -First 1;if($null-eq$fileModule){throw 'install file transaction module unavailable'};$source=&$fileModule {param($Generation,$Path,$Record)Open-CcodInstallRetainedFile -Generation $Generation -RelativePath $Path -ReadyTransaction $Record} $FileTransaction $relative $ReadyEvidence.transactionRecord}catch{$State.RetainedError=([string]$_.FullyQualifiedErrorId-split',')[0];throw} + if($null-eq$source){throw 'retained shortcut capability missing'} + if($State.ContainsKey('FailShortcutKind')-and[string]$State.FailShortcutKind-ceq$Kind){throw 'TEST_PRODUCT_SHORTCUT_WRITE_FAILURE'} + $State.ProductOnlyObserved=$true;$State.Shortcuts[$Kind]=$Shortcut;$State.ShortcutWrites++ + if($State.ContainsKey('LegacyEntries')){ + $legacyName=if($Kind-ceq'StartMenu'){$v2521LifecycleShortcutNames[0]}else{$v2521LifecycleShortcutNames[3]} + if(-not$State.LegacyEntries.Contains($legacyName)){$State.LegacyEntries.Add($legacyName)} + if($State.ContainsKey('LegacyShortcutProofs')){ + $candidateBytes=[IO.File]::ReadAllBytes([string]$Shortcut.candidatePath) + $State.LegacyShortcutProofs[$legacyName]=[pscustomobject][ordered]@{ + targetPath=[string]$ReadyEvidence.targetPath;arguments=[string]$ReadyEvidence.arguments;workingDirectory='';sha256=$(if($Kind-ceq'StartMenu'){[string]$ReadyEvidence.startMenuSha256}else{[string]$ReadyEvidence.desktopSha256});bytesBase64=[Convert]::ToBase64String($candidateBytes) + } + } + } + }.GetNewClosure() + ReadShortcut={param($Kind,$Shortcut)$State.Shortcuts[$Kind]}.GetNewClosure() + ReadVerifiedRegistration={ + [pscustomobject][ordered]@{ + verified=($null-ne$State.Registration-and$State.Shortcuts.Count-eq2);runtimeId=[string]$State.Registration.runtimeId;version=[string]$State.Registration.version;packageSha256=[string]$State.Registration.packageSha256 + shortcutNames=@($v2521LifecycleShortcutNames[0],$v2521LifecycleShortcutNames[3]);startMenuSha256=[string]$State.ReadyEvidence.startMenuSha256;desktopSha256=[string]$State.ReadyEvidence.desktopSha256 + } + }.GetNewClosure() + ReadLegacyRegistration={ + param($ExpectedAppId) + if(-not$State.ContainsKey('LegacyEntries')-or-not$State.LegacyEntries.Contains('Registry')){return $null} + [pscustomobject][ordered]@{ + appId=[string]$State.LegacyRegistration.appId;displayVersion=[string]$State.LegacyRegistration.displayVersion;installLocation=[string]$State.LegacyRegistration.installLocation + uninstallString=[string]$State.LegacyRegistration.uninstallString;shortcutNames=@($State.LegacyEntries|Where-Object{[string]$_-cne'Registry'});unsafeShortcutNames=@($(if($State.ContainsKey('LegacyUnsafeShortcutNames')){@($State.LegacyUnsafeShortcutNames)}else{@()})) + } + }.GetNewClosure() + ReadLegacySnapshot={ + param($ExpectedAppId,$ExpectedProfile) + $entries=[Collections.Generic.List[object]]::new() + foreach($entry in @($State.LegacyEntries)){ + if([string]$entry-cne'Registry'){ + $base=if(([string]$entry).StartsWith('Programs\',[StringComparison]::Ordinal)){[Environment]::GetFolderPath([Environment+SpecialFolder]::Programs)}else{[Environment]::GetFolderPath([Environment+SpecialFolder]::Desktop)} + $relative=([string]$entry).Substring(([string]$entry).IndexOf('\')+1);$proof=if($State.ContainsKey('LegacyShortcutProofs')){$State.LegacyShortcutProofs[[string]$entry]}else{$null} + $entries.Add([pscustomobject][ordered]@{kind='Shortcut';name=[string]$entry;path=[IO.Path]::GetFullPath((Join-Path $base $relative));sha256=[string]$proof.sha256;bytesBase64=[string]$proof.bytesBase64;targetPath=[string]$proof.targetPath;arguments=[string]$proof.arguments;workingDirectory=[string]$proof.workingDirectory}) + }else{$entries.Add([pscustomobject][ordered]@{kind='Registry';path=('HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\'+$State.LegacyRegistration.appId+'_is1');values=[ordered]@{DisplayVersion=[pscustomobject]@{value=[string]$State.LegacyRegistration.displayVersion;kind='String'};InstallLocation=[pscustomobject]@{value=[string]$State.LegacyRegistration.installLocation;kind='String'};UninstallString=[pscustomobject]@{value=[string]$State.LegacyRegistration.uninstallString;kind='String'}}})} + } + [pscustomobject][ordered]@{appId=$State.LegacyRegistration.appId;entries=@($entries)} + }.GetNewClosure() + GetCurrentShortcutProof={ + param($Kind,$Shortcut,$ReadyEvidence) + $name=if($Kind-ceq'StartMenu'){$v2521LifecycleShortcutNames[0]}else{$v2521LifecycleShortcutNames[3]};$base=if($Kind-ceq'StartMenu'){[Environment]::GetFolderPath([Environment+SpecialFolder]::Programs)}else{[Environment]::GetFolderPath([Environment+SpecialFolder]::Desktop)};$relative=$name.Substring($name.IndexOf('\')+1);$item=Get-Item -LiteralPath ([string]$Shortcut.candidatePath) -Force + [pscustomobject][ordered]@{kind=$Kind;name=$name;path=[IO.Path]::GetFullPath((Join-Path $base $relative));candidatePath=[IO.Path]::GetFullPath([string]$Shortcut.candidatePath);candidateLength=[int64]$item.Length;candidateSha256=$(if($Kind-ceq'StartMenu'){[string]$ReadyEvidence.startMenuSha256}else{[string]$ReadyEvidence.desktopSha256});targetPath=[string]$ReadyEvidence.targetPath;arguments=[string]$ReadyEvidence.arguments;workingDirectory=''} + }.GetNewClosure() + RemoveLegacyEntry={param($Entry);$name=if($Entry-is[string]){[string]$Entry}elseif($Entry.kind-ceq'Registry'){'Registry'}else{[string]$Entry.name};$State.LegacyRemovalAttempts++;[void]$State.LegacyEntries.Remove($name)}.GetNewClosure() + ReadLegacyEntry={param($Entry);$name=if($Entry-is[string]){[string]$Entry}elseif($Entry.kind-ceq'Registry'){'Registry'}else{[string]$Entry.name};if($State.LegacyEntries.Contains($name)){'Exact'}else{$null}}.GetNewClosure() + RestoreLegacyEntry={param($Entry);$name=if($Entry-is[string]){[string]$Entry}elseif($Entry.kind-ceq'Registry'){'Registry'}else{[string]$Entry.name};if(-not$State.LegacyEntries.Contains($name)){$State.LegacyEntries.Add($name)}}.GetNewClosure() + WriteLegacyCompensationFailure={param($Record);$State.LegacyCompensationFailures.Add($Record)}.GetNewClosure() + } +} + +function Set-CcodLifecycleDefaultProductRegistrationFixture { + param([Parameter(Mandatory)]$Fake,[Parameter(Mandatory)][hashtable]$ProductState) + [void]$Fake.Adapters.Remove('RegisterProduct') + $sideEffects=New-CcodLifecycleProductSideEffectAdapters -State $ProductState + $Fake.Adapters.GetProductRegistrationAdapters={$sideEffects}.GetNewClosure() + $Fake.Adapters.AddProductShortcutCandidates={ + param($Files) + $temporaryRoot=Join-Path ([IO.Path]::GetTempPath()) ('ccod-lifecycle-product-'+[guid]::NewGuid().ToString('N'));[IO.Directory]::CreateDirectory($temporaryRoot)|Out-Null + $records=[Collections.Generic.List[object]]::new() + foreach($leaf in @('StartMenu.CodexRemote-fix.lnk','Desktop.CodexRemote-fix.lnk')){$path=Join-Path $temporaryRoot $leaf;[IO.File]::WriteAllText($path,"sealed $leaf",[Text.UTF8Encoding]::new($false));$item=Get-Item -LiteralPath $path;$sha=(Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant();$records.Add([pscustomobject]@{Relative=('registration/'+$leaf);Source=$path;ExpectedLength=[int64]$item.Length;ExpectedSha256=$sha})} + [pscustomobject]@{Files=@($Files)+@($records);TemporaryRoot=$temporaryRoot} + } +} + +function New-CcodLifecycleV2521ShortcutProofs { + param([Parameter(Mandatory)][string]$InstallRoot,[Parameter(Mandatory)][string]$InstallerRoot) + $powershell=[IO.Path]::GetFullPath((Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::System)) 'WindowsPowerShell\v1.0\powershell.exe')) + $bootstrap=[IO.Path]::GetFullPath((Join-Path $InstallRoot 'bootstrap.ps1')) + $testScript=[IO.Path]::GetFullPath((Join-Path $InstallerRoot 'Test-CodexControlOtherDevices.ps1')) + $uninstaller=[IO.Path]::GetFullPath((Join-Path $InstallerRoot 'unins000.exe')) + $mainArguments='-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "{0}" -InstallRoot "{1}" -EntryMode Explicit' -f $bootstrap,[IO.Path]::GetFullPath($InstallRoot) + $specifications=@( + [pscustomobject]@{name=$v2521LifecycleShortcutNames[0];targetPath=$powershell;arguments=$mainArguments;workingDirectory=[IO.Path]::GetFullPath($InstallRoot)}, + [pscustomobject]@{name=$v2521LifecycleShortcutNames[1];targetPath=$powershell;arguments=('-NoProfile -ExecutionPolicy Bypass -File "{0}"' -f $testScript);workingDirectory=[IO.Path]::GetFullPath($InstallerRoot)}, + [pscustomobject]@{name=$v2521LifecycleShortcutNames[2];targetPath=$uninstaller;arguments='';workingDirectory=''}, + [pscustomobject]@{name=$v2521LifecycleShortcutNames[3];targetPath=$powershell;arguments=$mainArguments;workingDirectory=[IO.Path]::GetFullPath($InstallRoot)} + ) + $proofs=@{} + foreach($specification in $specifications){ + $bytes=[Text.UTF8Encoding]::new($false).GetBytes(('legacy-shortcut:'+([string]$specification.name))) + $sha=[Security.Cryptography.SHA256]::Create();try{$hash=[BitConverter]::ToString($sha.ComputeHash($bytes)).Replace('-','').ToLowerInvariant()}finally{$sha.Dispose()} + $proofs[[string]$specification.name]=[pscustomobject][ordered]@{targetPath=[string]$specification.targetPath;arguments=[string]$specification.arguments;workingDirectory=[string]$specification.workingDirectory;sha256=$hash;bytesBase64=[Convert]::ToBase64String($bytes)} + } + return $proofs +} + +function Set-CcodLifecycleProductCloseFailureFixture { + param([Parameter(Mandatory)]$Fake,[Parameter(Mandatory)][hashtable]$State) + $State.CloseAttempts=0 + if(-not$State.ContainsKey('CloseFailuresRemaining')){$State.CloseFailuresRemaining=1} + $Fake.Adapters.CloseProductTransaction={ + param($Transaction,$DefaultClose) + $State.CloseAttempts++ + if($State.CloseAttempts-le[int]$State.CloseFailuresRemaining){throw [Management.Automation.ErrorRecord]::new([InvalidOperationException]::new('fixture owned product close failed'),'CCOD_INSTALL_CLOSE_FAILED',[Management.Automation.ErrorCategory]::CloseError,$Transaction)} + &$DefaultClose $Transaction + }.GetNewClosure() +} + +function Set-CcodLifecycleLowerProductCloseFixture { + param([Parameter(Mandatory)][hashtable]$State) + $modulePath=Join-Path $repositoryRoot 'src\persistence\modules\InstallFileTransaction.psm1' + $fileModule=(Get-Command Close-CcodInstallFileTransaction -ErrorAction SilentlyContinue).Module + if($null-ne$fileModule-and($null-eq$fileModule.Path-or[IO.Path]::GetFullPath($fileModule.Path)-cne[IO.Path]::GetFullPath($modulePath))){$fileModule=$null} + if($null-eq$fileModule){$fileModule=Import-Module $modulePath -PassThru -DisableNameChecking -ErrorAction Stop} + $State.Attempts=0 + $lowerClose={ + param($TransactionState,$DefaultClose) + if(-not[bool]$TransactionState.ProductOnly){&$DefaultClose $TransactionState;return} + $State.Attempts++ + if([int]$State.FailuresRemaining-gt0){$State.FailuresRemaining=[int]$State.FailuresRemaining-1;throw [IO.IOException]::new('injected lower native close failure')} + &$DefaultClose $TransactionState + }.GetNewClosure() + &$fileModule {param($Close)$script:CcodInstallFileTransactionLowerCloseForTest=$Close} $lowerClose + return $fileModule +} + +function Clear-CcodLifecycleLowerProductCloseFixture { + param($FileModule) + if($null-ne$FileModule){&$FileModule {$script:CcodInstallFileTransactionLowerCloseForTest=$null}} +} + +function Set-CcodLifecycleProductOpenFailureFixture { + param([Parameter(Mandatory)][hashtable]$State) + $modulePath=Join-Path $repositoryRoot 'src\persistence\modules\InstallFileTransaction.psm1' + $fileModule=(Get-Command Close-CcodInstallFileTransaction -ErrorAction SilentlyContinue).Module + if($null-ne$fileModule-and($null-eq$fileModule.Path-or[IO.Path]::GetFullPath($fileModule.Path)-cne[IO.Path]::GetFullPath($modulePath))){$fileModule=$null} + if($null-eq$fileModule){$fileModule=Import-Module $modulePath -PassThru -DisableNameChecking -ErrorAction Stop} + $State.Attempts=0 + $original=&$fileModule {(Get-Command Invoke-CcodRuntimeStatic -CommandType Function -ErrorAction Stop).ScriptBlock} + $replacement={ + param([string]$Name,[object[]]$Arguments) + if($Name-ceq'OpenProduct'){ + $State.Attempts++ + if([int]$State.FailuresRemaining-gt0){$State.FailuresRemaining=[int]$State.FailuresRemaining-1;throw [IO.IOException]::new('injected strict product open failure')} + } + &$original $Name $Arguments + }.GetNewClosure() + &$fileModule {param($Invoke)Set-Item -LiteralPath Function:\script:Invoke-CcodRuntimeStatic -Value $Invoke} $replacement + return [pscustomobject]@{Module=$fileModule;Original=$original} +} + +function Clear-CcodLifecycleProductOpenFailureFixture { + param($Fixture) + if($null-ne$Fixture){&$Fixture.Module {param($Invoke)Set-Item -LiteralPath Function:\script:Invoke-CcodRuntimeStatic -Value $Invoke} $Fixture.Original} +} + +function Set-CcodLifecycleProductFenceBindFailureFixture { + param([Parameter(Mandatory)][hashtable]$State) + $modulePath=Join-Path $repositoryRoot 'src\persistence\modules\InstallFileTransaction.psm1' + $fileModule=(Get-Command Close-CcodInstallFileTransaction -ErrorAction SilentlyContinue).Module + if($null-ne$fileModule-and($null-eq$fileModule.Path-or[IO.Path]::GetFullPath($fileModule.Path)-cne[IO.Path]::GetFullPath($modulePath))){$fileModule=$null} + if($null-eq$fileModule){$fileModule=Import-Module $modulePath -PassThru -DisableNameChecking -ErrorAction Stop} + $State.Attempts=0 + $original=&$fileModule {(Get-Command Get-CcodInstallProductCleanupFenceProof -CommandType Function -ErrorAction Stop).ScriptBlock} + $replacement={ + param($TransactionState,$Fence) + $State.Attempts++ + if([int]$State.FailuresRemaining-gt0){$State.FailuresRemaining=[int]$State.FailuresRemaining-1;throw [IO.InvalidDataException]::new('injected durable fence bind failure')} + &$original $TransactionState $Fence + }.GetNewClosure() + &$fileModule {param($GetProof)Set-Item -LiteralPath Function:\script:Get-CcodInstallProductCleanupFenceProof -Value $GetProof} $replacement + return [pscustomobject]@{Module=$fileModule;Original=$original} +} + +function Clear-CcodLifecycleProductFenceBindFailureFixture { + param($Fixture) + if($null-ne$Fixture){&$Fixture.Module {param($GetProof)Set-Item -LiteralPath Function:\script:Get-CcodInstallProductCleanupFenceProof -Value $GetProof} $Fixture.Original} +} + +function Get-CcodLifecycleProductCleanupTestRecords { + param([Parameter(Mandatory)][string]$InstallRoot) + $directory=Join-Path $InstallRoot 'state\product-cleanup-fences' + if(-not[IO.Directory]::Exists($directory)){return @()} + $records=[Collections.Generic.List[object]]::new() + foreach($file in @(Get-ChildItem -LiteralPath $directory -File -Force|Sort-Object Name)){ + if($file.Name-cnotmatch'^(?\d{20})\.(?Pending|Completed)\.(?[0-9a-f-]{36})\.json$'){throw "unexpected cleanup fence leaf $($file.Name)"} + $records.Add([pscustomobject]@{Attempt=[uint64]$Matches.attempt;State=[string]$Matches.state;Leaf=$file.Name;Record=((Get-Content -LiteralPath $file.FullName -Raw)|ConvertFrom-Json)}) + } + return @($records|Sort-Object Attempt,@{Expression={if($_.State-ceq'Pending'){0}else{1}}}) +} + +function Get-CcodLifecycleCurrentProductCleanupTestIdentity { + $process=[Diagnostics.Process]::GetCurrentProcess();$identity=[Security.Principal.WindowsIdentity]::GetCurrent() + try{return [pscustomobject][ordered]@{pid=[int]$process.Id;creationTimeUtc=$process.StartTime.ToUniversalTime().ToString('o',[Globalization.CultureInfo]::InvariantCulture);userSid=[string]$identity.User.Value}} + finally{$process.Dispose();$identity.Dispose()} +} + +function Assert-CcodLifecycleProductCleanupTestRecord { + param([Parameter(Mandatory)]$Actual,[Parameter(Mandatory)]$Ready,[Parameter(Mandatory)]$Owner,[Parameter(Mandatory)][ValidateSet('Pending','Completed')][string]$State) + $fields=@('schemaVersion','transactionId','runtimeId','runtimeGeneration','manifestSha256','packageSha256','ownerPid','ownerCreationTimeUtc','ownerSid','state') + Assert-CcodEqual ($fields-join '|') (@($Actual.PSObject.Properties.Name)-join '|') 'cleanup fence contains only the ordered contract fields' + Assert-CcodEqual 1 $Actual.schemaVersion 'cleanup fence schema is exact' + Assert-CcodEqual $Ready.transactionId $Actual.transactionId 'cleanup fence binds the Ready transaction id' + Assert-CcodEqual $Ready.newRuntimeId $Actual.runtimeId 'cleanup fence binds the selected runtime id' + Assert-CcodEqual ([uint64]$Ready.newGeneration) ([uint64]$Actual.runtimeGeneration) 'cleanup fence binds the selected generation' + Assert-CcodEqual $Ready.newManifestSha256 $Actual.manifestSha256 'cleanup fence binds the manifest hash' + Assert-CcodEqual $Ready.sealedPackageSha256 $Actual.packageSha256 'cleanup fence binds the package hash' + Assert-CcodEqual ([int]$Owner.pid) ([int]$Actual.ownerPid) 'cleanup fence binds the owner pid' + Assert-CcodEqual $Owner.creationTimeUtc $Actual.ownerCreationTimeUtc 'cleanup fence binds the owner creation time' + Assert-CcodEqual $Owner.userSid $Actual.ownerSid 'cleanup fence binds the owner SID' + Assert-CcodEqual $State $Actual.state 'cleanup fence state is exact' +} + +function Invoke-CcodLifecycleOtherProcessProductReconciliation { + param( + [Parameter(Mandatory)][string]$SourceRoot, + [Parameter(Mandatory)][string]$InstallRoot, + [Parameter(Mandatory)][string]$NodePath, + [Parameter(Mandatory)][string]$PackageSha256, + [Parameter(Mandatory)][string]$MarkerRoot + ) + [IO.Directory]::CreateDirectory($MarkerRoot)|Out-Null + $payload=[ordered]@{ + lifecycleModule=$installLifecycleModule + kernelModule=(Join-Path $repositoryRoot 'src\persistence\modules\KernelObjects.psm1') + sourceRoot=$SourceRoot + installRoot=$InstallRoot + nodePath=$NodePath + packageSha256=$PackageSha256 + markerRoot=$MarkerRoot + } + $payloadBase64=[Convert]::ToBase64String([Text.UTF8Encoding]::new($false).GetBytes(($payload|ConvertTo-Json -Compress))) + $childScript=@' +$ErrorActionPreference='Stop' +$payloadJson=[Text.UTF8Encoding]::new($false,$true).GetString([Convert]::FromBase64String('__PAYLOAD__')) +$payload=$payloadJson|ConvertFrom-Json -ErrorAction Stop +Import-Module $payload.lifecycleModule -Force -DisableNameChecking -ErrorAction Stop +$kernelModule=Import-Module $payload.kernelModule -PassThru -DisableNameChecking -ErrorAction Stop +$childState=@{Registration=$null;ReadyEvidence=$null;Shortcuts=@{}} +$productAdapters=@{ + WriteProductRegistration={param($Registration,$ReadyEvidence)$childState.Registration=$Registration;$childState.ReadyEvidence=$ReadyEvidence;[IO.File]::WriteAllText((Join-Path $payload.markerRoot 'product-write.txt'),'registration')}.GetNewClosure() + ReadProductRegistration={param($Registration)$childState.Registration}.GetNewClosure() + WriteShortcut={param($Kind,$Shortcut,$FileTransaction,$ReadyEvidence)$childState.Shortcuts[$Kind]=$Shortcut;[IO.File]::WriteAllText((Join-Path $payload.markerRoot 'shortcut-write.txt'),[string]$Kind)}.GetNewClosure() + ReadShortcut={param($Kind,$Shortcut)$childState.Shortcuts[$Kind]}.GetNewClosure() + ReadVerifiedRegistration={ + [pscustomobject][ordered]@{ + verified=($null-ne$childState.Registration-and$childState.Shortcuts.Count-eq2);runtimeId=[string]$childState.Registration.runtimeId;version=[string]$childState.Registration.version;packageSha256=[string]$childState.Registration.packageSha256 + shortcutNames=@('Programs\CodexRemote-fix\CodexRemote-fix.lnk','Desktop\CodexRemote-fix.lnk');startMenuSha256=[string]$childState.ReadyEvidence.startMenuSha256;desktopSha256=[string]$childState.ReadyEvidence.desktopSha256 + } + }.GetNewClosure() + ReadLegacyRegistration={param($ExpectedAppId)$null} +} +$process=[Diagnostics.Process]::GetCurrentProcess();$windowsIdentity=[Security.Principal.WindowsIdentity]::GetCurrent() +try{$childIdentity=[pscustomobject][ordered]@{UserSid=[string]$windowsIdentity.User.Value;SessionId=[int]$process.SessionId;Pid=[int]$process.Id;CreationTimeUtc=$process.StartTime.ToUniversalTime().ToString('o',[Globalization.CultureInfo]::InvariantCulture)}}finally{$process.Dispose();$windowsIdentity.Dispose()} +$adapters=@{ + ValidateSource={param($Root)$true} + GetProjectVersion={param($Root)'2.5.22'} + DiscoverNodeCandidates={@([string]$payload.nodePath)}.GetNewClosure() + ValidateNodeCandidate={param($Path)$true} + GetCurrentIdentity={$childIdentity}.GetNewClosure() + AddProductShortcutCandidates={param($Files)[pscustomobject]@{Files=@($Files);TemporaryRoot=$null}} + GetProductRegistrationAdapters={$productAdapters}.GetNewClosure() + CloseProductTransaction={param($Transaction,$DefaultClose)[IO.File]::WriteAllText((Join-Path $payload.markerRoot 'authority-opened.txt'),'close reached');&$DefaultClose $Transaction}.GetNewClosure() +} +$lease=$null;$exitCode=24 +try{ + $lease=&$kernelModule {param($Sid)Enter-CcodMutex -Kind AccountTransition -UserSid $Sid -TimeoutMilliseconds 15000} $childIdentity.UserSid + if($null-eq$lease-or$lease.Outcome-cne'Acquired'){throw 'child account mutex was not acquired'} + [IO.File]::WriteAllText((Join-Path $payload.markerRoot 'mutex-acquired.txt'),'acquired') + try{ + $result=Invoke-CcodInstall -SourceRoot ([string]$payload.sourceRoot) -InstallRoot ([string]$payload.installRoot) -SealedPackageSha256 ([string]$payload.packageSha256) -Adapters $adapters + if($null-ne$result-and$result.ProductRegistrationVerified){[IO.File]::WriteAllText((Join-Path $payload.markerRoot 'verified.txt'),'verified')} + $exitCode=0 + }catch{ + $errorId=([string]$_.FullyQualifiedErrorId-split',')[0] + [IO.File]::WriteAllText((Join-Path $payload.markerRoot 'error-id.txt'),$errorId) + $exitCode=if($errorId-ceq'CCOD_PRODUCT_REGISTRATION_FAILED'){23}else{24} + } +}catch{ + [IO.File]::WriteAllText((Join-Path $payload.markerRoot 'child-failure.txt'),[string]$_) + $exitCode=25 +}finally{ + if($null-ne$lease-and$lease.Outcome-ceq'Acquired'-and-not$lease.Released){try{&$kernelModule {param($Value)Exit-CcodMutex -Lease $Value} $lease|Out-Null}catch{$exitCode=26}} +} +exit $exitCode +'@.Replace('__PAYLOAD__',$payloadBase64) + $encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($childScript)) + $process=Start-Process -FilePath powershell.exe -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-EncodedCommand',$encoded) -PassThru -WindowStyle Hidden + try{ + if(-not$process.WaitForExit(45000)){try{$process.Kill()}catch{};$process.WaitForExit();throw 'other-process product reconciliation timed out'} + $exitCode=$process.ExitCode + }finally{$process.Dispose()} + $errorPath=Join-Path $MarkerRoot 'error-id.txt' + return [pscustomobject]@{ + ExitCode=[int]$exitCode + ErrorId=$(if([IO.File]::Exists($errorPath)){[IO.File]::ReadAllText($errorPath)}else{$null}) + MutexAcquired=[IO.File]::Exists((Join-Path $MarkerRoot 'mutex-acquired.txt')) + AuthorityOpened=[IO.File]::Exists((Join-Path $MarkerRoot 'authority-opened.txt')) + ProductWritten=[IO.File]::Exists((Join-Path $MarkerRoot 'product-write.txt'))-or[IO.File]::Exists((Join-Path $MarkerRoot 'shortcut-write.txt')) + Verified=[IO.File]::Exists((Join-Path $MarkerRoot 'verified.txt')) + ChildFailure=$(if([IO.File]::Exists((Join-Path $MarkerRoot 'child-failure.txt'))){[IO.File]::ReadAllText((Join-Path $MarkerRoot 'child-failure.txt'))}else{$null}) + } +} + +function Invoke-CcodLifecycleRollbackBootstrapMutexProbe { + param( + [Parameter(Mandatory)][string]$MarkerRoot, + [int]$MutexTimeoutMilliseconds = 1500, + [int]$ProcessTimeoutMilliseconds = 10000 + ) + + [IO.Directory]::CreateDirectory($MarkerRoot) | Out-Null + $payload = [ordered]@{ + kernelModule = (Join-Path $repositoryRoot 'src\persistence\modules\KernelObjects.psm1') + markerRoot = $MarkerRoot + mutexTimeoutMilliseconds = $MutexTimeoutMilliseconds + } + $payloadBase64 = [Convert]::ToBase64String([Text.UTF8Encoding]::new($false).GetBytes(($payload | ConvertTo-Json -Compress))) + $childScript = @' +$ErrorActionPreference='Stop' +$payloadJson=[Text.UTF8Encoding]::new($false,$true).GetString([Convert]::FromBase64String('__PAYLOAD__')) +$payload=$payloadJson|ConvertFrom-Json -ErrorAction Stop +$kernelModule=Import-Module $payload.kernelModule -PassThru -DisableNameChecking -ErrorAction Stop +$lease=$null;$exitCode=42 +try{ + [IO.File]::WriteAllText((Join-Path $payload.markerRoot 'attempted.txt'),'attempted',[Text.UTF8Encoding]::new($false)) + $identity=[Security.Principal.WindowsIdentity]::GetCurrent() + try{$sid=[string]$identity.User.Value}finally{$identity.Dispose()} + $lease=&$kernelModule {param($UserSid,$Timeout)Enter-CcodMutex -Kind AccountTransition -UserSid $UserSid -TimeoutMilliseconds $Timeout} $sid ([int]$payload.mutexTimeoutMilliseconds) + [IO.File]::WriteAllText((Join-Path $payload.markerRoot 'outcome.txt'),[string]$lease.Outcome,[Text.UTF8Encoding]::new($false)) + if($null-eq$lease-or$lease.Outcome-cne'Acquired'){exit $exitCode} + [IO.File]::WriteAllText((Join-Path $payload.markerRoot 'acquired.txt'),'acquired',[Text.UTF8Encoding]::new($false)) + $exitCode=0 +}catch{ + [IO.File]::WriteAllText((Join-Path $payload.markerRoot 'child-failure.txt'),[string]$_,[Text.UTF8Encoding]::new($false)) + $exitCode=43 +}finally{ + if($null-ne$lease-and$lease.Outcome-ceq'Acquired'-and-not$lease.Released){try{&$kernelModule {param($Value)Exit-CcodMutex -Lease $Value} $lease|Out-Null}catch{$exitCode=44}} +} +exit $exitCode +'@.Replace('__PAYLOAD__',$payloadBase64) + $encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($childScript)) + $process = Start-Process -FilePath powershell.exe -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-EncodedCommand',$encoded) -PassThru -WindowStyle Hidden + try { + if (-not $process.WaitForExit($ProcessTimeoutMilliseconds)) { + try { $process.Kill() } catch { } + $process.WaitForExit() + throw 'rollback bootstrap mutex probe exceeded its bounded process timeout' + } + $exitCode = $process.ExitCode + } finally { + $process.Dispose() + } + $outcomePath = Join-Path $MarkerRoot 'outcome.txt' + $failurePath = Join-Path $MarkerRoot 'child-failure.txt' + return [pscustomobject][ordered]@{ + ExitCode = [int]$exitCode + Attempted = [IO.File]::Exists((Join-Path $MarkerRoot 'attempted.txt')) + MutexAcquired = [IO.File]::Exists((Join-Path $MarkerRoot 'acquired.txt')) + Outcome = $(if ([IO.File]::Exists($outcomePath)) { [IO.File]::ReadAllText($outcomePath) } else { $null }) + ChildFailure = $(if ([IO.File]::Exists($failurePath)) { [IO.File]::ReadAllText($failurePath) } else { $null }) + } +} + +function Invoke-CcodLifecycleDeadOwnerCleanupTest { + $source=New-CcodLifecycleTempRoot;$install=New-CcodLifecycleTempRoot;$nodeRoot=New-CcodLifecycleTempRoot + $fileModule=$null;$fake=$null + try{ + New-CcodLifecycleSourceFixture -Root $source -Version '2.5.22'|Out-Null;$node=New-CcodLifecycleFakeNode -Root $nodeRoot;$fake=New-CcodLifecycleFake -NodePath $node + $productState=@{FailWrites=$false;Registration=$null;ReadyEvidence=$null;RetainedError=$null;Writes=0;ShortcutWrites=0;ProductOnlyObserved=$false;Shortcuts=@{}};Set-CcodLifecycleDefaultProductRegistrationFixture -Fake $fake -ProductState $productState + $fake.Adapters.RegisterProduct={param($InstallRoot,$RuntimeId,$Version,$PackageSha256,$FileTransaction,$TransactionRecord)[pscustomobject]@{verified=$true;legacyRemoved=$true}} + $null=Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -SealedPackageSha256 ('b'*64) -Adapters $fake.Adapters + $module=Get-Module InstallLifecycle -ErrorAction Stop;$ready=&$module {param($Root)Read-CcodInstallTransactionRecord -InstallRoot $Root} $install;$current=Get-CcodLifecycleCurrentProductCleanupTestIdentity + $deadOwner=[pscustomobject][ordered]@{pid=[int]2147483647;creationTimeUtc='2000-01-01T00:00:00.0000000Z';userSid=$current.userSid} + $outer=&$module {Enter-CcodLifecycleProductCleanupLease} + try{&$module {param($Root,$Record,$Owner)[void](New-CcodLifecycleProductCleanupFence -InstallRoot $Root -ReadyTransaction $Record -OwnerIdentity $Owner)} $install $ready $deadOwner} + finally{&$module {param($Context)[void](Exit-CcodLifecycleProductCleanupLease -Context $Context)} $outer} + [void]$fake.Adapters.Remove('RegisterProduct') + $lowerState=@{FailuresRemaining=0};$fileModule=Set-CcodLifecycleLowerProductCloseFixture -State $lowerState + $result=Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -SealedPackageSha256 ('b'*64) -Adapters $fake.Adapters + Assert-CcodEqual 'AlreadyInstalled' $result.Outcome 'dead-owner reconciliation resumes only the exact same-package Ready install' + Assert-CcodTrue $result.ProductRegistrationVerified 'dead-owner reconciliation permits verified success only after cleanup' + Assert-CcodEqual 2 $lowerState.Attempts 'dead owner requires a fresh strict cleanup close before the new verification close' + $records=@(Get-CcodLifecycleProductCleanupTestRecords -InstallRoot $install) + Assert-CcodEqual '1:Pending|1:Completed|2:Pending|2:Completed' (($records|ForEach-Object{"$($_.Attempt):$($_.State)"})-join '|') 'dead-owner recovery completes the old fence before a new verification fence' + Assert-CcodEqual 0 @(Get-ChildItem -LiteralPath (Join-Path $install 'state\install-transactions') -Filter '*.Failed.*.json' -File).Count 'dead-owner cleanup appends no lifecycle Failed snapshot' + }finally{ + Clear-CcodLifecycleLowerProductCloseFixture -FileModule $fileModule + foreach($path in @($source,$install,$nodeRoot)){if(Test-Path $path){Remove-Item $path -Recurse -Force}} + } +} + +function New-CcodReadyFinalizationGapFixture { + $source=New-CcodLifecycleTempRoot + $install=New-CcodLifecycleTempRoot + $nodeRoot=New-CcodLifecycleTempRoot + try { + New-CcodLifecycleSourceFixture -Root $source -Version '2.5.22' | Out-Null + $node=New-CcodLifecycleFakeNode -Root $nodeRoot + $fake=New-CcodLifecycleFake -NodePath $node + $fake.Adapters.CommitReadyTransaction={throw 'PRIVATE_FINAL_SNAPSHOT_FAILURE'} + $failure=$null + try { Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters $fake.Adapters | Out-Null } catch { $failure=$_ } + Assert-CcodTrue ($null-ne$failure-and$failure.FullyQualifiedErrorId-like'CCOD_INSTALL_READY_FINALIZATION_PENDING*') 'fixture reaches the exact Ready-finalization gap' + $module=Get-Module InstallLifecycle -ErrorAction Stop + $head=&$module {param($Root)Read-CcodInstallTransactionRecord -InstallRoot $Root} $install + Assert-CcodEqual 'ProtectionReady' $head.phase 'fixture transaction is recoverable at ProtectionReady' + return [pscustomobject]@{Source=$source;Install=$install;NodeRoot=$nodeRoot;Node=$node;Transaction=$head} + } catch { + foreach($path in @($source,$install,$nodeRoot)){if(Test-Path -LiteralPath $path){Remove-Item -LiteralPath $path -Recurse -Force}} + throw + } +} + +function Remove-CcodReadyFinalizationGapFixture { + param([Parameter(Mandatory)]$Fixture) + foreach($path in @($Fixture.Source,$Fixture.Install,$Fixture.NodeRoot)){if(Test-Path -LiteralPath $path){Remove-Item -LiteralPath $path -Recurse -Force}} +} + function Read-CcodLifecycleActivePointer { param([Parameter(Mandatory)][string]$Root) - return (Get-Content -LiteralPath (Join-Path $Root 'active.json') -Raw | ConvertFrom-Json) + return Read-CcodActiveRuntime -InstallRoot $Root } function Set-CcodLifecycleTestStatus { @@ -351,6 +973,611 @@ function Invoke-CcodLifecycleUninstallCleanupTest { $results = @() +$results += Invoke-CcodTest 'immutable install records advance only through canonical create-only phase snapshots' { + $install = New-CcodLifecycleTempRoot + [IO.Directory]::CreateDirectory($install) | Out-Null + $transactionId = '11111111-2222-3333-4444-555555555555' + $packageSha256 = ('a' * 64) + $module = Get-Module -Name InstallLifecycle -ErrorAction Stop + $fileTransaction = & $module { param($Root) Open-CcodLifecycleInstallGeneration -InstallRoot $Root -RuntimeId 'runtime-new' } $install + try { + $record = & $module { + param($Id,$PackageHash) + New-CcodInstallTransactionRecord -TransactionId $Id -OldRuntimeId 'runtime-old' -OldGeneration ([uint64]7) -OldManifestSha256 ('1'*64) -NewRuntimeId 'runtime-new' -NewGeneration ([uint64]8) -NewManifestSha256 ('2'*64) -SealedPackageSha256 $PackageHash -OwnedObjectNames @('runtime-new','bootstrap.ps1','Uninstall-CodexControlOtherDevices.ps1') + } $transactionId $packageSha256 + Assert-CcodEqual 'Prepared' $record.phase 'new immutable transaction begins Prepared' + Assert-CcodEqual $null $record.errorCode 'nonterminal immutable transaction has no error code' + & $module { param($Root,$Value,$Tx) Write-CcodInstallTransactionRecord -InstallRoot $Root -TransactionRecord $Value -FileTransaction $Tx } $install $record $fileTransaction + foreach ($phase in @('PackageVerified','RuntimeStaged','PreviousProtectionStopped','RuntimePromoted','PointerCommitted','StableShellCommitted','ProtectionReady','Ready')) { + $previous = [string]$record.phase + $record = & $module { param($Root,$Id,$Expected,$Next,$Tx) Set-CcodInstallTransactionPhase -InstallRoot $Root -TransactionId $Id -ExpectedPhase $Expected -NewPhase $Next -FileTransaction $Tx } $install $transactionId $previous $phase $fileTransaction + Assert-CcodEqual $phase $record.phase "immutable transaction advances to $phase" + } + $read = & $module { param($Root,$Id) Read-CcodInstallTransactionRecord -InstallRoot $Root -TransactionId $Id } $install $transactionId + Assert-CcodEqual 'Ready' $read.phase 'reader selects the terminal canonical snapshot' + Assert-CcodEqual $packageSha256 $read.sealedPackageSha256 'phase snapshots retain the sealed package identity' + Assert-CcodThrows { & $module { param($Root,$Id,$Tx) Set-CcodInstallTransactionPhase -InstallRoot $Root -TransactionId $Id -ExpectedPhase 'RuntimeStaged' -NewPhase 'Ready' -FileTransaction $Tx } $install $transactionId $fileTransaction } 'CCOD_INSTALL_TRANSACTION_PHASE_INVALID' + } finally { + & $module { param($Tx) Close-CcodInstallFileTransaction -Transaction $Tx -Disposition Failed } $fileTransaction + if (Test-Path -LiteralPath $install) { Remove-Item -LiteralPath $install -Recurse -Force } + } +} + +$results += Invoke-CcodTest 'same-version immutable install identity is idempotent only for the same sealed package hash' { + $module = Get-Module -Name InstallLifecycle -ErrorAction Stop + $existing = [pscustomobject][ordered]@{ + schemaVersion=1;transactionId='11111111-2222-3333-4444-555555555555';oldRuntimeId='runtime-old';oldGeneration=[uint64]7 + oldManifestSha256=('1'*64);newRuntimeId='runtime-new';newGeneration=[uint64]8;newManifestSha256=('2'*64);sealedPackageSha256=('b'*64);ownedObjectNames=@('runtime-new');phase='Ready';errorCode=$null + } + $same = & $module { param($Record,$Hash) Test-CcodInstallPackageIdentity -TransactionRecord $Record -ProjectVersion '2.5.22' -ActiveProjectVersion '2.5.22' -SealedPackageSha256 $Hash -ActiveRuntimeId 'runtime-new' -ActiveGeneration 8 } $existing ('b'*64) + Assert-CcodEqual $true $same 'same version and same package hash is idempotent' + Assert-CcodThrows { & $module { param($Record,$Hash) Test-CcodInstallPackageIdentity -TransactionRecord $Record -ProjectVersion '2.5.22' -ActiveProjectVersion '2.5.22' -SealedPackageSha256 $Hash -ActiveRuntimeId 'runtime-new' -ActiveGeneration 8 } $existing ('c'*64) } 'CCOD_INSTALL_PACKAGE_CONFLICT' + $drift = & $module { param($Record) Test-CcodInstallPackageIdentity -TransactionRecord $Record -ProjectVersion '2.5.22' -ActiveProjectVersion '2.5.22' -SealedPackageSha256 ('b'*64) -ActiveRuntimeId 'runtime-other' -ActiveGeneration 9 } $existing + Assert-CcodEqual $false $drift 'idempotence is bound to the active runtime and pointer generation' + $legacy = & $module { Test-CcodInstallPackageIdentity -TransactionRecord $null -ProjectVersion '2.5.22' -ActiveProjectVersion '2.5.21' -SealedPackageSha256 ('d'*64) -ActiveRuntimeId 'legacy' -ActiveGeneration 1 } + Assert-CcodEqual $false $legacy 'legacy install without transaction record is treated as non-idempotent Idle' +} + +$results += Invoke-CcodTest 'real v2.5.21 legacy lifecycle state upgrades through the v2.5.22 install entrypoint' { + $legacySource=New-CcodLifecycleTempRoot;$source=New-CcodLifecycleTempRoot;$install=New-CcodLifecycleTempRoot;$nodeRoot=New-CcodLifecycleTempRoot + try { + New-CcodLifecycleSourceFixture -Root $legacySource -Version '2.5.21' | Out-Null + $legacy=New-CcodLifecycleV2521LegacyInstallFixture -InstallRoot $install -SourceRoot $legacySource + Assert-CcodTrue ($legacy.RuntimeId -cmatch '^2\.5\.21-[0-9a-f]{16}$') 'fixture uses the merge-base two-part runtime identity' + Assert-CcodEqual 'schemaVersion,activeRuntime,previousRuntime,generation,updatedAtUtc' (@($legacy.Active.PSObject.Properties.Name)-join ',') 'fixture uses the merge-base schema-two active pointer' + Assert-CcodEqual $false (Test-Path -LiteralPath (Join-Path $install 'state\active-generation')) 'legacy fixture has no append-only selector plane' + Assert-CcodEqual $false (Test-Path -LiteralPath (Join-Path $install 'state\install-transactions')) 'legacy fixture has no append-only transaction plane' + Assert-CcodEqual $false (Test-Path -LiteralPath (Join-Path $install 'state\product-cleanup-fences')) 'legacy fixture has no durable cleanup fence plane' + $legacyActiveHash=Get-CcodTestFileSha256 -Path (Join-Path $install 'active.json') + $legacyManifestHash=Get-CcodTestFileSha256 -Path (Join-Path $legacy.RuntimeRoot 'manifest.json') + $legacyPointer=Read-CcodActiveRuntime -InstallRoot $install + $legacyValidation=Test-CcodRuntimeManifest -RuntimeDirectory $legacy.RuntimeRoot -ExpectedRuntimeId $legacy.RuntimeId -ExpectedManifestSha256 $legacyManifestHash + $module=Get-Module InstallLifecycle -ErrorAction Stop + $compatibility=&$module {param($Root,$Pointer,$Validation)Get-CcodLegacyUpgradeCompatibilityContext -InstallRoot $Root -ExistingPointer $Pointer -ActiveValidation $Validation -GlobalTransaction $null} $install $legacyPointer $legacyValidation + Assert-CcodEqual 'ProvenLegacyWithoutReady' $compatibility.Kind 'compatibility function returns only the proven legacy classification' + Assert-CcodEqual $legacy.RuntimeId $compatibility.ActiveRuntimeId 'compatibility context retains the exact active runtime identity' + Assert-CcodEqual ([uint64]1) $compatibility.ActiveGeneration 'compatibility context retains the exact legacy generation' + Assert-CcodEqual '2.5.21' $compatibility.ProjectVersion 'compatibility context retains the exact supported project version' + Assert-CcodEqual $legacyManifestHash $compatibility.ManifestSha256 'compatibility context retains the exact manifest identity' + New-CcodLifecycleSourceFixture -Root $source -Version '2.5.22' | Out-Null + $node=New-CcodLifecycleFakeNode -Root $nodeRoot + $fake=New-CcodLifecycleFake -NodePath $node + $legacyEntries=[Collections.Generic.List[string]]::new();foreach($entry in @('Registry')+$v2521LifecycleShortcutNames){$legacyEntries.Add($entry)} + $productState=@{ + FailWrites=$false;Registration=$null;ReadyEvidence=$null;RetainedError=$null;Writes=0;ShortcutWrites=0;ProductOnlyObserved=$false;Shortcuts=@{} + LegacyRegistration=[pscustomobject][ordered]@{ + appId='{2B9E9F2E-7A32-4A7E-9C1D-9F5B5C6D7E8F}';displayVersion='2.5.21';installLocation='C:\legacy\CodexControlOtherDevices-installer' + uninstallString='"C:\legacy\CodexControlOtherDevices-installer\unins000.exe"';shortcutNames=@($v2521LifecycleShortcutNames);unsafeShortcutNames=@() + } + LegacyEntries=$legacyEntries;LegacyShortcutProofs=(New-CcodLifecycleV2521ShortcutProofs -InstallRoot $install -InstallerRoot 'C:\legacy\CodexControlOtherDevices-installer');LegacyUnsafeShortcutNames=@() + LegacyRemovalAttempts=0;LegacyCompensationFailures=[Collections.Generic.List[object]]::new() + } + Set-CcodLifecycleDefaultProductRegistrationFixture -Fake $fake -ProductState $productState + $beforeRuntimeCount=@(Get-ChildItem -LiteralPath (Join-Path $install 'runtime') -Directory -Force).Count + + $upgrade=Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -SealedPackageSha256 ('a'*64) -Adapters $fake.Adapters + + Assert-CcodEqual 'Upgraded' $upgrade.Outcome 'proven legacy state converges through the real install entrypoint' + Assert-CcodEqual ($beforeRuntimeCount+1) @(Get-ChildItem -LiteralPath (Join-Path $install 'runtime') -Directory -Force).Count 'upgrade stages exactly one immutable generation' + Assert-CcodTrue (Test-Path -LiteralPath (Join-Path $install 'state\active-generation') -PathType Container) 'upgrade creates the append-only selector plane' + Assert-CcodTrue (Test-Path -LiteralPath (Join-Path $install 'state\install-transactions') -PathType Container) 'upgrade creates the append-only transaction plane' + Assert-CcodTrue (Test-Path -LiteralPath (Join-Path $install 'active.json') -PathType Leaf) 'legacy active pointer remains only as migration evidence' + Assert-CcodEqual $legacyActiveHash (Get-CcodTestFileSha256 -Path (Join-Path $install 'active.json')) 'upgrade never rewrites the legacy active pointer evidence' + $pointer=Read-CcodActiveRuntime -InstallRoot $install + Assert-CcodEqual $upgrade.RuntimeId $pointer.activeRuntime 'append-only selector chooses the new runtime instead of stale active.json' + Assert-CcodEqual ([uint64]2) $pointer.generation 'legacy genesis and new activation form a contiguous two-record selector chain' + Assert-CcodEqual 2 @(Get-ChildItem -LiteralPath (Join-Path $install 'state\active-generation') -File -Force).Count 'upgrade publishes legacy genesis and the new active selector exactly once each' + $ready=&$module {param($Root)Read-CcodInstallTransactionRecord -InstallRoot $Root} $install + Assert-CcodEqual 'Ready' $ready.phase 'upgrade publishes a terminal append-only Ready transaction' + Assert-CcodEqual $legacy.RuntimeId $ready.oldRuntimeId 'Ready transaction is bound to the exact legacy source runtime' + Assert-CcodEqual ([uint64]1) $ready.oldGeneration 'Ready transaction is bound to the exact legacy source generation' + Assert-CcodEqual $upgrade.RuntimeId $ready.newRuntimeId 'Ready transaction is bound to the selected new runtime' + Assert-CcodEqual ([uint64]2) $ready.newGeneration 'Ready transaction is bound to the selected new generation' + $currentRuntimeRoot=Join-Path (Join-Path $install 'runtime') $upgrade.RuntimeId + $currentManifestHash=Get-CcodTestFileSha256 -Path (Join-Path $currentRuntimeRoot 'manifest.json') + $currentValidation=Test-CcodRuntimeManifest -RuntimeDirectory $currentRuntimeRoot -ExpectedRuntimeId $upgrade.RuntimeId -ExpectedManifestSha256 $currentManifestHash + $currentCompatibility=&$module {param($Root,$Pointer,$Validation,$Transaction)Get-CcodLegacyUpgradeCompatibilityContext -InstallRoot $Root -ExistingPointer $Pointer -ActiveValidation $Validation -GlobalTransaction $Transaction} $install $pointer $currentValidation $ready + Assert-CcodEqual 'CurrentReady' $currentCompatibility.Kind 'compatibility function returns only the current Ready classification after migration' + Assert-CcodEqual $upgrade.RuntimeId $currentCompatibility.ActiveRuntimeId 'current compatibility remains bound to the selected runtime' + Assert-CcodEqual ([uint64]2) $currentCompatibility.ActiveGeneration 'current compatibility remains bound to the selected generation' + Assert-CcodEqual $currentManifestHash $currentCompatibility.ManifestSha256 'current compatibility remains bound to the selected manifest' + Assert-CcodTrue $upgrade.ProductRegistrationVerified 'true legacy migration completes the real product-registration boundary' + $migrationPlans=@(Get-ChildItem -LiteralPath (Join-Path $install 'state\legacy-registration-migrations') -File -Force) + Assert-CcodEqual 1 $migrationPlans.Count 'Ready-bound legacy migration plan is durable before any overlapping current registration write' + $migrationPlan=Get-Content -LiteralPath $migrationPlans[0].FullName -Raw|ConvertFrom-Json -ErrorAction Stop + Assert-CcodEqual $ready.transactionId $migrationPlan.transactionId 'durable migration plan binds the exact Ready transaction' + Assert-CcodEqual ([uint64]$ready.newGeneration) ([uint64]$migrationPlan.runtimeGeneration) 'durable migration plan binds the selected generation' + Assert-CcodEqual $ready.newManifestSha256 $migrationPlan.manifestSha256 'durable migration plan binds the selected manifest' + Assert-CcodEqual $ready.sealedPackageSha256 $migrationPlan.packageSha256 'durable migration plan binds the selected package' + Assert-CcodEqual 'version,runtimeId,packageSha256,shortcuts' (($migrationPlan.expectedCurrentProof.PSObject.Properties.Name)-join',') 'durable migration plan carries only the exact current proof contract' + Assert-CcodEqual 3 $productState.LegacyRemovalAttempts 'true v2.5.21 migration removes the registry compatibility link and uninstall link' + Assert-CcodEqual ((@($v2521LifecycleShortcutNames[0],$v2521LifecycleShortcutNames[3])|Sort-Object)-join'|') ((@($productState.LegacyEntries)|Sort-Object)-join'|') 'true v2.5.21 migration preserves both exact current shortcut replacements' + Assert-CcodEqual 0 $productState.LegacyCompensationFailures.Count 'successful true legacy cleanup needs no unresolved compensation record' + + $runtimeCount=@(Get-ChildItem -LiteralPath (Join-Path $install 'runtime') -Directory -Force).Count + $selectorCount=@(Get-ChildItem -LiteralPath (Join-Path $install 'state\active-generation') -File -Force).Count + $sameFake=New-CcodLifecycleFake -NodePath $node;Set-CcodLifecycleDefaultProductRegistrationFixture -Fake $sameFake -ProductState $productState + $same=Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -SealedPackageSha256 ('a'*64) -Adapters $sameFake.Adapters + Assert-CcodEqual 'AlreadyInstalled' $same.Outcome 'second same-package invocation is idempotent after true legacy registration cleanup' + Assert-CcodTrue $same.ProductRegistrationVerified 'idempotent invocation re-verifies the exact current registration' + Assert-CcodEqual $runtimeCount @(Get-ChildItem -LiteralPath (Join-Path $install 'runtime') -Directory -Force).Count 'idempotent true-legacy retry creates no runtime' + Assert-CcodEqual $selectorCount @(Get-ChildItem -LiteralPath (Join-Path $install 'state\active-generation') -File -Force).Count 'idempotent true-legacy retry appends no selector' + Assert-CcodEqual 3 $productState.LegacyRemovalAttempts 'idempotent true-legacy retry performs no second legacy deletion' + Assert-CcodEqual 0 $sameFake.World.TaskInstalled 'idempotent true-legacy retry performs no scheduled-task install' + Assert-CcodEqual 0 $sameFake.World.TaskStarted 'idempotent true-legacy retry starts no process' + } finally { + foreach($path in @($legacySource,$source,$install,$nodeRoot)){if(Test-Path -LiteralPath $path){Remove-Item -LiteralPath $path -Recurse -Force}} + } +} + +# Production mutation caught: a Ready product retry recaptures the washed live profile instead of reopening its disk plan. +$results += Invoke-CcodTest 'new lifecycle invocation replays the disk plan after partial overlapping current writes' { + foreach($failurePoint in @('DesktopWrite','CurrentReadBack')){ + $legacySource=New-CcodLifecycleTempRoot;$source=New-CcodLifecycleTempRoot;$install=New-CcodLifecycleTempRoot;$nodeRoot=New-CcodLifecycleTempRoot + try{ + New-CcodLifecycleSourceFixture -Root $legacySource -Version '2.5.21'|Out-Null;$legacy=New-CcodLifecycleV2521LegacyInstallFixture -InstallRoot $install -SourceRoot $legacySource + New-CcodLifecycleSourceFixture -Root $source -Version '2.5.22'|Out-Null;$node=New-CcodLifecycleFakeNode -Root $nodeRoot + $legacyEntries=[Collections.Generic.List[string]]::new();foreach($entry in @('Registry')+$v2521LifecycleShortcutNames){$legacyEntries.Add($entry)} + $productState=@{FailWrites=$false;FailShortcutKind=$(if($failurePoint-ceq'DesktopWrite'){'Desktop'}else{$null});FailReadProduct=($failurePoint-ceq'CurrentReadBack');Registration=$null;ReadyEvidence=$null;RetainedError=$null;Writes=0;ShortcutWrites=0;ProductOnlyObserved=$false;Shortcuts=@{};LegacyRegistration=[pscustomobject][ordered]@{appId='{2B9E9F2E-7A32-4A7E-9C1D-9F5B5C6D7E8F}';displayVersion='2.5.21';installLocation='C:\legacy\CodexControlOtherDevices-installer';uninstallString='"C:\legacy\CodexControlOtherDevices-installer\unins000.exe"';shortcutNames=@($v2521LifecycleShortcutNames);unsafeShortcutNames=@()};LegacyEntries=$legacyEntries;LegacyShortcutProofs=(New-CcodLifecycleV2521ShortcutProofs -InstallRoot $install -InstallerRoot 'C:\legacy\CodexControlOtherDevices-installer');LegacyUnsafeShortcutNames=@();LegacyRemovalAttempts=0;LegacyCompensationFailures=[Collections.Generic.List[object]]::new()} + $firstFake=New-CcodLifecycleFake -NodePath $node;Set-CcodLifecycleDefaultProductRegistrationFixture -Fake $firstFake -ProductState $productState + $firstFailure=$null;try{Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -SealedPackageSha256 ('a'*64) -Adapters $firstFake.Adapters|Out-Null}catch{$firstFailure=$_} + Assert-CcodEqual 'CCOD_PRODUCT_REGISTRATION_FAILED' (([string]$firstFailure.FullyQualifiedErrorId-split',')[0]) "$failurePoint first invocation fails at the intended post-Ready product boundary" + $runtimeCount=@(Get-ChildItem -LiteralPath (Join-Path $install 'runtime') -Directory -Force).Count;$selectorCount=@(Get-ChildItem -LiteralPath (Join-Path $install 'state\active-generation') -File -Force).Count;$planFiles=@(Get-ChildItem -LiteralPath (Join-Path $install 'state\legacy-registration-migrations') -File -Force) + Assert-CcodEqual 1 $planFiles.Count "$failurePoint first invocation persisted exactly one disk plan" + Assert-CcodEqual 0 $productState.LegacyRemovalAttempts "$failurePoint first invocation never begins legacy-only cleanup" + $productState.FailShortcutKind=$null;$productState.FailReadProduct=$false + $secondFake=New-CcodLifecycleFake -NodePath $node;Set-CcodLifecycleDefaultProductRegistrationFixture -Fake $secondFake -ProductState $productState + $second=Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -SealedPackageSha256 ('a'*64) -Adapters $secondFake.Adapters + Assert-CcodEqual 'AlreadyInstalled' $second.Outcome "$failurePoint new invocation reopens the same Ready installation" + Assert-CcodTrue $second.ProductRegistrationVerified "$failurePoint new invocation converges product registration from the disk plan" + Assert-CcodEqual $runtimeCount @(Get-ChildItem -LiteralPath (Join-Path $install 'runtime') -Directory -Force).Count "$failurePoint retry creates no runtime" + Assert-CcodEqual $selectorCount @(Get-ChildItem -LiteralPath (Join-Path $install 'state\active-generation') -File -Force).Count "$failurePoint retry appends no selector" + Assert-CcodEqual 1 @(Get-ChildItem -LiteralPath (Join-Path $install 'state\legacy-registration-migrations') -File -Force).Count "$failurePoint retry leaves one append-only disk plan" + Assert-CcodEqual 3 $productState.LegacyRemovalAttempts "$failurePoint retry removes the exact registry and two legacy-only shortcuts" + Assert-CcodEqual ((@($v2521LifecycleShortcutNames[0],$v2521LifecycleShortcutNames[3])|Sort-Object)-join'|') ((@($productState.LegacyEntries)|Sort-Object)-join'|') "$failurePoint retry preserves only exact current overlap replacements" + }finally{foreach($path in @($legacySource,$source,$install,$nodeRoot)){if(Test-Path -LiteralPath $path){Remove-Item -LiteralPath $path -Recurse -Force}}} + } +} + +# Production mutation caught: current registration writes the two overlapping names before it +# proves that one complete historical profile existed, washing missing or foreign legacy leaves. +$results += Invoke-CcodTest 'legacy profile is captured before any current product or shortcut write' { + foreach($kind in @('MissingMain','MissingDesktop','ForeignMain')){ + $legacySource=New-CcodLifecycleTempRoot;$source=New-CcodLifecycleTempRoot;$install=New-CcodLifecycleTempRoot;$nodeRoot=New-CcodLifecycleTempRoot + try { + New-CcodLifecycleSourceFixture -Root $legacySource -Version '2.5.21'|Out-Null + $legacy=New-CcodLifecycleV2521LegacyInstallFixture -InstallRoot $install -SourceRoot $legacySource + New-CcodLifecycleSourceFixture -Root $source -Version '2.5.22'|Out-Null + $node=New-CcodLifecycleFakeNode -Root $nodeRoot;$fake=New-CcodLifecycleFake -NodePath $node + $entries=[Collections.Generic.List[string]]::new();foreach($entry in @('Registry')+$v2521LifecycleShortcutNames){$entries.Add($entry)} + $installerRoot='C:\legacy\CodexControlOtherDevices-installer' + $productState=@{ + FailWrites=$false;Registration=$null;ReadyEvidence=$null;RetainedError=$null;Writes=0;ShortcutWrites=0;ProductOnlyObserved=$false;Shortcuts=@{} + LegacyRegistration=[pscustomobject][ordered]@{appId='{2B9E9F2E-7A32-4A7E-9C1D-9F5B5C6D7E8F}';displayVersion='2.5.21';installLocation=$installerRoot;uninstallString=('"{0}\unins000.exe"'-f$installerRoot);shortcutNames=@($v2521LifecycleShortcutNames);unsafeShortcutNames=@()} + LegacyEntries=$entries;LegacyShortcutProofs=(New-CcodLifecycleV2521ShortcutProofs -InstallRoot $install -InstallerRoot $installerRoot);LegacyUnsafeShortcutNames=@() + LegacyRemovalAttempts=0;LegacyCompensationFailures=[Collections.Generic.List[object]]::new() + } + switch($kind){ + 'MissingMain' {[void]$productState.LegacyEntries.Remove($v2521LifecycleShortcutNames[0]);[void]$productState.LegacyShortcutProofs.Remove($v2521LifecycleShortcutNames[0])} + 'MissingDesktop' {[void]$productState.LegacyEntries.Remove($v2521LifecycleShortcutNames[3]);[void]$productState.LegacyShortcutProofs.Remove($v2521LifecycleShortcutNames[3])} + 'ForeignMain' {$foreign=$productState.LegacyShortcutProofs[$v2521LifecycleShortcutNames[0]];$foreign.targetPath='C:\foreign\owned.exe';$foreign.arguments='--foreign';$foreign.workingDirectory='C:\foreign'} + } + $before=@($productState.LegacyEntries|Sort-Object)-join'|' + Set-CcodLifecycleDefaultProductRegistrationFixture -Fake $fake -ProductState $productState + $failure=$null;try{Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -SealedPackageSha256 ('9'*64) -Adapters $fake.Adapters|Out-Null}catch{$failure=$_} + Assert-CcodEqual 0 $productState.Writes "$kind is rejected before the current registry write" + Assert-CcodEqual 0 $productState.ShortcutWrites "$kind is rejected before either current overlap write" + Assert-CcodEqual 0 $productState.LegacyRemovalAttempts "$kind removes no legacy-only entry" + Assert-CcodEqual $before ((@($productState.LegacyEntries|Sort-Object))-join'|') "$kind preserves the exact observed legacy set" + Assert-CcodEqual 'CCOD_PRODUCT_REGISTRATION_FAILED' (([string]$failure.FullyQualifiedErrorId-split',')[0]) "$kind reports the post-Ready product boundary without washing legacy evidence" + } finally {foreach($path in @($legacySource,$source,$install,$nodeRoot)){if(Test-Path -LiteralPath $path){Remove-Item -LiteralPath $path -Recurse -Force}}} + } +} + +foreach($legacyCase in @( + [pscustomobject]@{Name='append-only selector without a transaction chain';Kind='selector';Expected='CCOD_INSTALL_UPGRADE_SOURCE_INVALID'}, + [pscustomobject]@{Name='cleanup fence plane without Ready authority';Kind='fence';Expected='CCOD_INSTALL_UPGRADE_SOURCE_INVALID'}, + [pscustomobject]@{Name='schema-one active pointer normalized by the general reader';Kind='active';Expected='CCOD_INSTALL_UPGRADE_SOURCE_INVALID'}, + [pscustomobject]@{Name='generation-one active pointer with a previous runtime';Kind='previous';Expected='CCOD_INSTALL_UPGRADE_SOURCE_INVALID'}, + [pscustomobject]@{Name='legacy generation without reconstructable append-only history';Kind='generation';Expected='CCOD_INSTALL_UPGRADE_SOURCE_INVALID'}, + [pscustomobject]@{Name='current nonce runtime format disguised as v2.5.21';Kind='current-format';Expected='CCOD_INSTALL_UPGRADE_SOURCE_INVALID'}, + [pscustomobject]@{Name='runtime manifest from an unsupported historical version';Kind='version';Expected='CCOD_INSTALL_UPGRADE_SOURCE_INVALID'}, + [pscustomobject]@{Name='legacy runtime path reparse';Kind='reparse';Expected='CCOD_INSTALL_REPARSE_PATH'} +)) { + $case=$legacyCase + $results += Invoke-CcodTest ("legacy upgrade classification rejects {0} before generation staging" -f $case.Name) { + $legacySource=New-CcodLifecycleTempRoot;$source=New-CcodLifecycleTempRoot;$install=New-CcodLifecycleTempRoot;$nodeRoot=New-CcodLifecycleTempRoot;$outside=$null;$junction=$null + try { + New-CcodLifecycleSourceFixture -Root $legacySource -Version '2.5.21' | Out-Null + $legacy=New-CcodLifecycleV2521LegacyInstallFixture -InstallRoot $install -SourceRoot $legacySource + switch($case.Kind) { + 'selector' { + $selector=Join-Path $install 'state\active-generation';[IO.Directory]::CreateDirectory($selector)|Out-Null + [IO.File]::WriteAllText((Join-Path $selector '00000000000000000001.json'),([ordered]@{schemaVersion=1;generation=[uint64]1;activeRuntime=$legacy.RuntimeId;previousGeneration=[uint64]0}|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new($false)) + } + 'fence' { [IO.Directory]::CreateDirectory((Join-Path $install 'state\product-cleanup-fences'))|Out-Null } + 'active' { + [IO.File]::WriteAllText((Join-Path $install 'active.json'),([ordered]@{schemaVersion=1;activeRuntime=$legacy.RuntimeId;previousRuntime=$null;updatedAtUtc=$legacy.Active.updatedAtUtc}|ConvertTo-Json),[Text.UTF8Encoding]::new($false)) + } + 'previous' { + [IO.File]::WriteAllText((Join-Path $install 'active.json'),([ordered]@{schemaVersion=2;activeRuntime=$legacy.RuntimeId;previousRuntime='2.5.20-0000000000000000';generation=[uint64]1;updatedAtUtc=$legacy.Active.updatedAtUtc}|ConvertTo-Json),[Text.UTF8Encoding]::new($false)) + } + 'generation' { + [IO.File]::WriteAllText((Join-Path $install 'active.json'),([ordered]@{schemaVersion=2;activeRuntime=$legacy.RuntimeId;previousRuntime='2.5.20-0000000000000000';generation=[uint64]2;updatedAtUtc=$legacy.Active.updatedAtUtc}|ConvertTo-Json),[Text.UTF8Encoding]::new($false)) + } + 'current-format' { + $currentId=Get-CcodRuntimeId -ProjectVersion '2.5.21' -Files @($legacy.Manifest.files) -Nonce ('f'*32) + $currentRoot=Join-Path (Split-Path $legacy.RuntimeRoot -Parent) $currentId + $legacy.Manifest.runtimeId=$currentId + [IO.File]::WriteAllText((Join-Path $legacy.RuntimeRoot 'manifest.json'),($legacy.Manifest|ConvertTo-Json -Depth 16),[Text.UTF8Encoding]::new($false)) + [IO.Directory]::Move($legacy.RuntimeRoot,$currentRoot) + [IO.File]::WriteAllText((Join-Path $install 'active.json'),([ordered]@{schemaVersion=2;activeRuntime=$currentId;previousRuntime=$null;generation=[uint64]1;updatedAtUtc=$legacy.Active.updatedAtUtc}|ConvertTo-Json),[Text.UTF8Encoding]::new($false)) + $legacy.RuntimeId=$currentId;$legacy.RuntimeRoot=$currentRoot + } + 'version' { + $manifestPath=Join-Path $legacy.RuntimeRoot 'manifest.json';$manifest=[IO.File]::ReadAllText($manifestPath)|ConvertFrom-Json;$manifest.projectVersion='2.5.20' + [IO.File]::WriteAllText($manifestPath,($manifest|ConvertTo-Json -Depth 16),[Text.UTF8Encoding]::new($false)) + } + 'reparse' { + $outside=New-CcodLifecycleTempRoot + Move-Item -LiteralPath $legacy.RuntimeRoot -Destination $outside + $junction=New-Item -ItemType Junction -Path $legacy.RuntimeRoot -Target $outside + } + } + New-CcodLifecycleSourceFixture -Root $source -Version '2.5.22' | Out-Null + $node=New-CcodLifecycleFakeNode -Root $nodeRoot;$fake=New-CcodLifecycleFake -NodePath $node + $runtimeBefore=@(Get-ChildItem -LiteralPath (Join-Path $install 'runtime') -Force).Count + $selectorPath=Join-Path $install 'state\active-generation';$selectorBefore=if(Test-Path -LiteralPath $selectorPath -PathType Container){@(Get-ChildItem -LiteralPath $selectorPath -File -Force).Count}else{0} + $failure=$null + try{Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -SealedPackageSha256 ('b'*64) -Adapters $fake.Adapters|Out-Null}catch{$failure=$_} + $errorId=if($null-eq$failure){$null}else{([string]$failure.FullyQualifiedErrorId-split',')[0]} + Assert-CcodEqual $case.Expected $errorId "$($case.Name) uses the expected fail-closed boundary" + Assert-CcodEqual $runtimeBefore @(Get-ChildItem -LiteralPath (Join-Path $install 'runtime') -Force).Count "$($case.Name) creates no immutable generation" + $selectorAfter=if(Test-Path -LiteralPath $selectorPath -PathType Container){@(Get-ChildItem -LiteralPath $selectorPath -File -Force).Count}else{0} + Assert-CcodEqual $selectorBefore $selectorAfter "$($case.Name) appends no selector record" + Assert-CcodEqual $false (Test-Path -LiteralPath (Join-Path $install 'state\install-transactions') -PathType Container) "$($case.Name) creates no transaction plane" + Assert-CcodEqual 0 $fake.World.TaskInstalled "$($case.Name) performs no scheduled-task mutation" + } finally { + if($null-ne$junction-and(Test-Path -LiteralPath $legacy.RuntimeRoot)){[IO.Directory]::Delete([IO.Path]::GetFullPath($legacy.RuntimeRoot))} + foreach($path in @($legacySource,$source,$install,$nodeRoot,$outside)){if($null-ne$path-and(Test-Path -LiteralPath $path)){Remove-Item -LiteralPath $path -Recurse -Force}} + } + } +} + +$results += Invoke-CcodTest 'real v2.5.21 upgrade failure before new Ready restarts the retained legacy runtime' { + $legacySource=New-CcodLifecycleTempRoot;$source=New-CcodLifecycleTempRoot;$install=New-CcodLifecycleTempRoot;$nodeRoot=New-CcodLifecycleTempRoot + try { + New-CcodLifecycleSourceFixture -Root $legacySource -Version '2.5.21' | Out-Null + $legacy=New-CcodLifecycleV2521LegacyInstallFixture -InstallRoot $install -SourceRoot $legacySource + New-CcodLifecycleSourceFixture -Root $source -Version '2.5.22' | Out-Null + $node=New-CcodLifecycleFakeNode -Root $nodeRoot;$fake=New-CcodLifecycleFake -NodePath $node + $fake.Adapters.WaitNewRuntimeReady={ + param($InstallRoot,$RuntimeId,$RuntimeGeneration,$Identity,$TaskStartedAtUtc,$TimeoutMilliseconds) + $ready=$RuntimeId-ceq$legacy.RuntimeId + [pscustomobject][ordered]@{SupervisorReady=$ready;TrayReady=$ready} + }.GetNewClosure() + $failure=$null + try{Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -SealedPackageSha256 ('c'*64) -Adapters $fake.Adapters|Out-Null}catch{$failure=$_} + $errorId=if($null-eq$failure){$null}else{([string]$failure.FullyQualifiedErrorId-split',')[0]} + Assert-CcodEqual 'CCOD_INSTALL_NEW_RUNTIME_NOT_READY' $errorId 'new-runtime readiness failure remains the reported cause after successful compensation' + $pointer=Read-CcodActiveRuntime -InstallRoot $install + Assert-CcodEqual $legacy.RuntimeId $pointer.activeRuntime 'compensating append-only pointer reselects the retained legacy runtime' + Assert-CcodEqual ([uint64]3) $pointer.generation 'legacy genesis new activation and compensation form one monotonic selector chain' + Assert-CcodEqual 2 $fake.World.TaskStarted 'failed new runtime and retained legacy runtime are each started once' + Assert-CcodTrue (Test-Path -LiteralPath $legacy.RuntimeRoot -PathType Container) 'retained legacy runtime remains present after compensation' + $module=Get-Module InstallLifecycle -ErrorAction Stop;$head=&$module {param($Root)Read-CcodInstallTransactionRecord -InstallRoot $Root} $install + Assert-CcodEqual 'Failed' $head.phase 'failed new generation has a terminal diagnostic transaction' + Assert-CcodEqual 'CCOD_INSTALL_NEW_RUNTIME_NOT_READY' $head.errorCode 'failed transaction retains the original pre-Ready error' + Assert-CcodTrue (Test-Path -LiteralPath (Join-Path $install 'active.json') -PathType Leaf) 'legacy active pointer remains untouched as evidence after compensation' + $failedRuntimeId=[string]$head.newRuntimeId + $runtimeCount=@(Get-ChildItem -LiteralPath (Join-Path $install 'runtime') -Directory -Force).Count + $fake.Adapters.WaitNewRuntimeReady={param($InstallRoot,$RuntimeId,$RuntimeGeneration,$Identity,$TaskStartedAtUtc,$TimeoutMilliseconds)[pscustomobject][ordered]@{SupervisorReady=$true;TrayReady=$true}} + + $retry=Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -SealedPackageSha256 ('c'*64) -Adapters $fake.Adapters + + Assert-CcodEqual 'Upgraded' $retry.Outcome 'second exact attempt resumes the compensated legacy migration' + Assert-CcodEqual $failedRuntimeId $retry.RuntimeId 'compensated retry reuses the exact sealed generation-two runtime' + Assert-CcodEqual $runtimeCount @(Get-ChildItem -LiteralPath (Join-Path $install 'runtime') -Directory -Force).Count 'compensated retry invents no additional runtime generation' + $retryPointer=Read-CcodActiveRuntime -InstallRoot $install + Assert-CcodEqual $failedRuntimeId $retryPointer.activeRuntime 'compensated retry selects the previously failed sealed runtime' + Assert-CcodEqual ([uint64]4) $retryPointer.generation 'compensated retry appends after the canonical three-record rollback chain' + Assert-CcodEqual 4 @(Get-ChildItem -LiteralPath (Join-Path $install 'state\active-generation') -File -Force).Count 'compensated retry preserves and extends the complete selector chain' + $readyHeads=@(Get-ChildItem -LiteralPath (Join-Path $install 'state\install-transactions') -Filter '*.Ready.*.json' -File -Force) + Assert-CcodEqual 1 $readyHeads.Count 'compensated retry produces exactly one terminal Ready transaction' + $ready=[IO.File]::ReadAllText($readyHeads[0].FullName)|ConvertFrom-Json + Assert-CcodEqual ([uint64]4) ([uint64]$ready.newGeneration) 'compensated retry Ready is bound to selector generation four' + Assert-CcodEqual $failedRuntimeId $ready.newRuntimeId 'compensated retry Ready remains bound to the original failed runtime' + } finally { + foreach($path in @($legacySource,$source,$install,$nodeRoot)){if(Test-Path -LiteralPath $path){Remove-Item -LiteralPath $path -Recurse -Force}} + } +} + +# Production mutation caught: requiring a PointerCommitted journal snapshot even when the +# exact generation-two selector commit and generation-three compensation are both durable. +$results += Invoke-CcodTest 'real v2.5.21 selector commit with a missing PointerCommitted journal resumes the exact retained runtime' { + $legacySource=New-CcodLifecycleTempRoot;$source=New-CcodLifecycleTempRoot;$install=New-CcodLifecycleTempRoot;$nodeRoot=New-CcodLifecycleTempRoot + try { + New-CcodLifecycleSourceFixture -Root $legacySource -Version '2.5.21' | Out-Null + $legacy=New-CcodLifecycleV2521LegacyInstallFixture -InstallRoot $install -SourceRoot $legacySource + New-CcodLifecycleSourceFixture -Root $source -Version '2.5.22' | Out-Null + $node=New-CcodLifecycleFakeNode -Root $nodeRoot;$fake=New-CcodLifecycleFake -NodePath $node + $gate=[pscustomobject]@{Remaining=1} + $fake.World.SetActiveFailure={ + param($Root,$RuntimeId,$Ownership,$TargetGeneration,$FileTransaction) + if($gate.Remaining-gt0){ + $gate.Remaining-- + $fence={param($InstallRoot,$Receipt,$ExpectActivePointer)$true} + Invoke-CcodRuntimeCoreSetForTest -InstallRoot $Root -TargetGeneration $TargetGeneration -FileTransaction $FileTransaction -Ownership $Ownership -Adapters @{AssertLifecycleFence=$fence}|Out-Null + throw 'PRIVATE_AFTER_SELECTOR_COMMIT_BEFORE_JOURNAL' + } + }.GetNewClosure() + + $failure=$null + try{Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -SealedPackageSha256 ('f'*64) -Adapters $fake.Adapters|Out-Null}catch{$failure=$_} + Assert-CcodEqual 'CCOD_INSTALL_RUNTIME_ACTIVATION_UNPROVEN' (([string]$failure.FullyQualifiedErrorId-split',')[0]) 'post-selector journal failure preserves the stable activation boundary' + $selectorFiles=@(Get-ChildItem -LiteralPath (Join-Path $install 'state\active-generation') -File -Force|Sort-Object Name) + Assert-CcodEqual 3 $selectorFiles.Count 'selector commit and compensation leave the canonical three-record chain' + $selectors=@($selectorFiles|ForEach-Object{[IO.File]::ReadAllText($_.FullName)|ConvertFrom-Json}) + Assert-CcodEqual ($legacy.RuntimeId+','+$selectors[1].activeRuntime+','+$legacy.RuntimeId) (($selectors|ForEach-Object activeRuntime)-join',') 'selector evidence is exactly old new old' + $failedRuntimeId=[string]$selectors[1].activeRuntime + $module=Get-Module InstallLifecycle -ErrorAction Stop;$head=&$module {param($Root)Read-CcodInstallTransactionRecord -InstallRoot $Root} $install + Assert-CcodEqual 'Failed' $head.phase 'journal-gap transaction has a terminal Failed head' + Assert-CcodEqual $failedRuntimeId $head.newRuntimeId 'journal-gap transaction remains bound to the committed generation-two runtime' + Assert-CcodEqual 0 @(Get-ChildItem -LiteralPath (Join-Path $install 'state\install-transactions') -Filter '*.PointerCommitted.*.json' -File -Force).Count 'failure happened before the PointerCommitted journal became durable' + Assert-CcodEqual 1 @(Get-ChildItem -LiteralPath (Join-Path $install 'state\install-transactions') -Filter '*.RuntimePromoted.*.json' -File -Force).Count 'immutable RuntimePromoted identity is durable before the selector commit' + $runtimeCount=@(Get-ChildItem -LiteralPath (Join-Path $install 'runtime') -Directory -Force).Count + + $retry=Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -SealedPackageSha256 ('f'*64) -Adapters $fake.Adapters + + Assert-CcodEqual 'Upgraded' $retry.Outcome 'second exact attempt resumes the selector-journal gap' + Assert-CcodEqual $failedRuntimeId $retry.RuntimeId 'selector-journal retry reuses the exact sealed generation-two runtime' + Assert-CcodEqual $runtimeCount @(Get-ChildItem -LiteralPath (Join-Path $install 'runtime') -Directory -Force).Count 'selector-journal retry creates no additional runtime' + $retryPointer=Read-CcodActiveRuntime -InstallRoot $install + Assert-CcodEqual $failedRuntimeId $retryPointer.activeRuntime 'selector-journal retry selects the retained failed runtime' + Assert-CcodEqual ([uint64]4) ([uint64]$retryPointer.generation) 'selector-journal retry appends generation four after compensation' + $readyHeads=@(Get-ChildItem -LiteralPath (Join-Path $install 'state\install-transactions') -Filter '*.Ready.*.json' -File -Force) + Assert-CcodEqual 1 $readyHeads.Count 'selector-journal retry writes one terminal Ready transaction' + } finally { + foreach($path in @($legacySource,$source,$install,$nodeRoot)){if(Test-Path -LiteralPath $path){Remove-Item -LiteralPath $path -Recurse -Force}} + } +} + +$results += Invoke-CcodTest 'real v2.5.21 upgrade failure before new pointer commit keeps the legacy selector active and restarts it' { + $legacySource=New-CcodLifecycleTempRoot;$source=New-CcodLifecycleTempRoot;$install=New-CcodLifecycleTempRoot;$nodeRoot=New-CcodLifecycleTempRoot + try { + New-CcodLifecycleSourceFixture -Root $legacySource -Version '2.5.21' | Out-Null + $legacy=New-CcodLifecycleV2521LegacyInstallFixture -InstallRoot $install -SourceRoot $legacySource + New-CcodLifecycleSourceFixture -Root $source -Version '2.5.22' | Out-Null + $node=New-CcodLifecycleFakeNode -Root $nodeRoot;$fake=New-CcodLifecycleFake -NodePath $node + $fake.World.SetActiveFailure={ + param($InstallRoot,$RuntimeId,$Ownership,$TargetGeneration,$FileTransaction) + throw [Management.Automation.ErrorRecord]::new([InvalidOperationException]::new('adapted pointer failure'),'CCOD_RUNTIME_POINTER_COMMIT_FAILED',[Management.Automation.ErrorCategory]::InvalidData,$RuntimeId) + } + $failure=$null + try{Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -SealedPackageSha256 ('d'*64) -Adapters $fake.Adapters|Out-Null}catch{$failure=$_} + $errorId=if($null-eq$failure){$null}else{([string]$failure.FullyQualifiedErrorId-split',')[0]} + Assert-CcodEqual 'CCOD_RUNTIME_POINTER_COMMIT_FAILED' $errorId 'adapted pre-pointer failure remains the reported cause after old-runtime restart' + $pointer=Read-CcodActiveRuntime -InstallRoot $install + Assert-CcodEqual $legacy.RuntimeId $pointer.activeRuntime 'selector genesis keeps the legacy runtime active when the new pointer never commits' + Assert-CcodEqual ([uint64]1) $pointer.generation 'pre-pointer failure appends no false new or compensating selector' + Assert-CcodEqual 1 $fake.World.TaskStarted 'pre-pointer failure restarts only the retained legacy runtime' + $module=Get-Module InstallLifecycle -ErrorAction Stop;$head=&$module {param($Root)Read-CcodInstallTransactionRecord -InstallRoot $Root} $install + Assert-CcodEqual 'Failed' $head.phase 'pre-pointer failure has a terminal diagnostic transaction' + Assert-CcodEqual 'CCOD_RUNTIME_POINTER_COMMIT_FAILED' $head.errorCode 'diagnostic transaction retains the adapted pointer failure' + Assert-CcodTrue (Test-Path -LiteralPath $legacy.RuntimeRoot -PathType Container) 'pre-pointer failure retains the old runtime tree' + $failedRuntimeId=[string]$head.newRuntimeId + $runtimeCount=@(Get-ChildItem -LiteralPath (Join-Path $install 'runtime') -Directory -Force).Count + $fake.World.SetActiveFailure=$null + + $retry=Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -SealedPackageSha256 ('d'*64) -Adapters $fake.Adapters + + Assert-CcodEqual 'Upgraded' $retry.Outcome 'second exact attempt resumes the failed legacy migration' + Assert-CcodEqual $failedRuntimeId $retry.RuntimeId 'retry reuses the exact sealed generation-two runtime' + Assert-CcodEqual $runtimeCount @(Get-ChildItem -LiteralPath (Join-Path $install 'runtime') -Directory -Force).Count 'retry invents no additional runtime generation' + $retryPointer=Read-CcodActiveRuntime -InstallRoot $install + Assert-CcodEqual $failedRuntimeId $retryPointer.activeRuntime 'retry selects the previously failed sealed runtime' + Assert-CcodEqual ([uint64]2) $retryPointer.generation 'pre-pointer retry completes the missing generation-two selector' + $readyHeads=@(Get-ChildItem -LiteralPath (Join-Path $install 'state\install-transactions') -Filter '*.Ready.*.json' -File -Force) + Assert-CcodEqual 1 $readyHeads.Count 'retry produces exactly one terminal Ready transaction' + } finally { + foreach($path in @($legacySource,$source,$install,$nodeRoot)){if(Test-Path -LiteralPath $path){Remove-Item -LiteralPath $path -Recurse -Force}} + } +} + +foreach($retryCase in @( + [pscustomobject]@{Name='legacy manifest identity drift in the Failed chain';Kind='old-manifest'}, + [pscustomobject]@{Name='Failed transaction generation drift';Kind='generation'}, + [pscustomobject]@{Name='foreign terminal transaction chain';Kind='foreign'}, + [pscustomobject]@{Name='Failed chain without RuntimePromoted evidence';Kind='premature'}, + [pscustomobject]@{Name='cleanup fence plane';Kind='fence'}, + [pscustomobject]@{Name='unexpected selector generation';Kind='selector'}, + [pscustomobject]@{Name='failed runtime manifest byte drift';Kind='new-manifest'}, + [pscustomobject]@{Name='different sealed package identity';Kind='package'} +)) { + $case=$retryCase + $results += Invoke-CcodTest ("legacy migration retry rejects {0} before any new mutation" -f $case.Name) { + $fixture=New-CcodLifecycleLegacyMigrationRetryFixture + try { + $module=Get-Module InstallLifecycle -ErrorAction Stop + $store=Join-Path $fixture.Install 'state\install-transactions' + switch($case.Kind){ + 'old-manifest' { + foreach($file in @(Get-ChildItem -LiteralPath $store -File -Force)){$record=[IO.File]::ReadAllText($file.FullName)|ConvertFrom-Json;$record.oldManifestSha256=('0'*64);[IO.File]::SetAttributes($file.FullName,[IO.FileAttributes]::Normal);[IO.File]::WriteAllText($file.FullName,($record|ConvertTo-Json -Depth 8 -Compress),[Text.UTF8Encoding]::new($false))} + } + 'generation' { + foreach($file in @(Get-ChildItem -LiteralPath $store -File -Force)){$record=[IO.File]::ReadAllText($file.FullName)|ConvertFrom-Json;$record.newGeneration=[uint64]3;$newLeaf=&$module {param($Value)Get-CcodInstallTransactionRecordLeaf -Record $Value} $record;[IO.File]::SetAttributes($file.FullName,[IO.FileAttributes]::Normal);[IO.File]::WriteAllText($file.FullName,($record|ConvertTo-Json -Depth 8 -Compress),[Text.UTF8Encoding]::new($false));[IO.File]::Move($file.FullName,(Join-Path $store $newLeaf))} + } + 'foreign' { + $foreign=&$module {New-CcodInstallTransactionRecord -TransactionId 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' -OldRuntimeId $null -OldGeneration $null -OldManifestSha256 $null -NewRuntimeId 'foreign-runtime' -NewGeneration ([uint64]1) -NewManifestSha256 ('1'*64) -SealedPackageSha256 ('2'*64) -OwnedObjectNames @('foreign-runtime')} + foreach($phase in @('Prepared','Failed')){$record=$foreign.PSObject.Copy();$record.phase=$phase;$record.errorCode=if($phase-ceq'Failed'){'CCOD_INSTALL_FAILED'}else{$null};$leaf=&$module {param($Value)Get-CcodInstallTransactionRecordLeaf -Record $Value} $record;[IO.File]::WriteAllText((Join-Path $store $leaf),($record|ConvertTo-Json -Depth 8 -Compress),[Text.UTF8Encoding]::new($false))} + } + 'premature' { + foreach($file in @(Get-ChildItem -LiteralPath $store -File -Force)){$record=[IO.File]::ReadAllText($file.FullName)|ConvertFrom-Json;if($record.phase-notin@('Prepared','PackageVerified','Failed')){[IO.File]::SetAttributes($file.FullName,[IO.FileAttributes]::Normal);[IO.File]::Delete($file.FullName)}} + } + 'fence' {[IO.Directory]::CreateDirectory((Join-Path $fixture.Install 'state\product-cleanup-fences'))|Out-Null} + 'selector' {[IO.File]::WriteAllText((Join-Path $fixture.Install 'state\active-generation\00000000000000000002.json'),([ordered]@{schemaVersion=1;generation=[uint64]2;activeRuntime=$fixture.Legacy.RuntimeId;previousGeneration=[uint64]1}|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new($false))} + 'new-manifest' {$manifestPath=Join-Path $fixture.Install "runtime\$($fixture.Head.newRuntimeId)\manifest.json";[IO.File]::SetAttributes($manifestPath,[IO.FileAttributes]::Normal);[IO.File]::AppendAllText($manifestPath,"`n",[Text.UTF8Encoding]::new($false))} + } + $runtimeCount=@(Get-ChildItem -LiteralPath (Join-Path $fixture.Install 'runtime') -Directory -Force).Count + $selectorCount=@(Get-ChildItem -LiteralPath (Join-Path $fixture.Install 'state\active-generation') -File -Force).Count + $transactionCount=@(Get-ChildItem -LiteralPath $store -File -Force).Count + $taskInstalled=$fixture.Fake.World.TaskInstalled;$taskStarted=$fixture.Fake.World.TaskStarted + $packageHash=if($case.Kind-ceq'package'){('e'*64)}else{('d'*64)} + $failure=$null;try{Invoke-CcodInstall -SourceRoot $fixture.Source -InstallRoot $fixture.Install -SealedPackageSha256 $packageHash -Adapters $fixture.Fake.Adapters|Out-Null}catch{$failure=$_} + Assert-CcodEqual 'CCOD_INSTALL_UPGRADE_SOURCE_INVALID' (([string]$failure.FullyQualifiedErrorId-split',')[0]) "$($case.Name) fails through the retry compatibility boundary" + Assert-CcodEqual $runtimeCount @(Get-ChildItem -LiteralPath (Join-Path $fixture.Install 'runtime') -Directory -Force).Count "$($case.Name) creates no runtime" + Assert-CcodEqual $selectorCount @(Get-ChildItem -LiteralPath (Join-Path $fixture.Install 'state\active-generation') -File -Force).Count "$($case.Name) appends no selector" + Assert-CcodEqual $transactionCount @(Get-ChildItem -LiteralPath $store -File -Force).Count "$($case.Name) appends no transaction snapshot" + Assert-CcodEqual $taskInstalled $fixture.Fake.World.TaskInstalled "$($case.Name) performs no task install" + Assert-CcodEqual $taskStarted $fixture.Fake.World.TaskStarted "$($case.Name) performs no process start" + } finally {Remove-CcodLifecycleLegacyMigrationRetryFixture $fixture} + } +} + +$results += Invoke-CcodTest 'Invoke-CcodInstall is idempotent only for the active Ready runtime and exact sealed package identity' { + $source=New-CcodLifecycleTempRoot;$install=New-CcodLifecycleTempRoot;$nodeRoot=New-CcodLifecycleTempRoot + try{ + New-CcodLifecycleSourceFixture -Root $source -Version '2.5.22'|Out-Null + $nodePath=New-CcodLifecycleFakeNode -Root $nodeRoot + $firstFake=New-CcodLifecycleFake -NodePath $nodePath + $first=Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -SealedPackageSha256 ('a'*64) -Adapters $firstFake.Adapters + $firstPointer=Read-CcodActiveRuntime -InstallRoot $install + $sameFake=New-CcodLifecycleFake -NodePath $nodePath + $same=Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -SealedPackageSha256 ('a'*64) -Adapters $sameFake.Adapters + $samePointer=Read-CcodActiveRuntime -InstallRoot $install + Assert-CcodEqual 'AlreadyInstalled' $same.Outcome 'same version and package identity returns an idempotent outcome' + Assert-CcodEqual $first.RuntimeId $same.RuntimeId 'idempotence remains bound to the active runtime' + Assert-CcodEqual $firstPointer.generation $samePointer.generation 'idempotence does not append a pointer generation' + Assert-CcodEqual 1 @(Get-ChildItem -LiteralPath (Join-Path $install 'runtime') -Directory).Count 'idempotence creates no generation' + Assert-CcodEqual 0 $sameFake.World.TaskInstalled 'idempotence performs no task mutation' + Assert-CcodEqual 0 $sameFake.World.TaskStarted 'idempotence starts no process' + Assert-CcodEqual 1 $sameFake.World.ProductRegistrationCalls 'AlreadyInstalled reconciles product registration after revalidating exact Ready identity' + Assert-CcodTrue $same.ProductRegistrationVerified 'AlreadyInstalled reports a fresh verified registration reconciliation' + $conflictFake=New-CcodLifecycleFake -NodePath $nodePath + Assert-CcodThrows {Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -SealedPackageSha256 ('b'*64) -Adapters $conflictFake.Adapters|Out-Null} 'CCOD_INSTALL_PACKAGE_CONFLICT' + $conflictPointer=Read-CcodActiveRuntime -InstallRoot $install + Assert-CcodEqual $firstPointer.generation $conflictPointer.generation 'package conflict leaves the active pointer unchanged' + Assert-CcodEqual $first.RuntimeId $conflictPointer.activeRuntime 'package conflict remains bound to the old active runtime' + Assert-CcodEqual 1 @(Get-ChildItem -LiteralPath (Join-Path $install 'runtime') -Directory).Count 'package conflict creates no generation' + Assert-CcodEqual 0 $conflictFake.World.TaskInstalled 'package conflict performs no task mutation' + }finally{foreach($path in @($source,$install,$nodeRoot)){if(Test-Path -LiteralPath $path){Remove-Item -LiteralPath $path -Recurse -Force}}} +} + +$results += Invoke-CcodTest 'missing sealed package identity never enables idempotence or package-conflict decisions' { + $source=New-CcodLifecycleTempRoot;$install=New-CcodLifecycleTempRoot;$nodeRoot=New-CcodLifecycleTempRoot + try{ + New-CcodLifecycleSourceFixture -Root $source -Version '2.5.22-no-identity'|Out-Null;$nodePath=New-CcodLifecycleFakeNode -Root $nodeRoot + $first=Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -SealedPackageSha256 ('a'*64) -Adapters (New-CcodLifecycleFake -NodePath $nodePath).Adapters + $second=Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters (New-CcodLifecycleFake -NodePath $nodePath).Adapters + Assert-CcodEqual 'Upgraded' $second.Outcome 'legacy direct call without canonical package identity is not treated as idempotent' + Assert-CcodTrue ($second.RuntimeId-cne$first.RuntimeId) 'missing package identity creates a fresh unique generation' + Assert-CcodEqual 2 (Read-CcodActiveRuntime -InstallRoot $install).generation 'missing package identity follows the normal monotonic upgrade path' + }finally{foreach($path in @($source,$install,$nodeRoot)){if(Test-Path -LiteralPath $path){Remove-Item -LiteralPath $path -Recurse -Force}}} +} + +$results += Invoke-CcodTest 'unique immutable runtime ids bind project and manifest file identity while preserving per-attempt uniqueness' { + $module=Get-Module InstallLifecycle -ErrorAction Stop + $filesA=@([pscustomobject]@{Relative='a.txt';ExpectedLength=[int64]1;ExpectedSha256=('a'*64);Source='unused'}) + $filesB=@([pscustomobject]@{Relative='a.txt';ExpectedLength=[int64]1;ExpectedSha256=('b'*64);Source='unused'}) + $first=&$module {param($Files)New-CcodUniqueRuntimeId -ProjectVersion '2.5.22' -Files $Files -NewNonce { '1'*32 }} $filesA + $second=&$module {param($Files)New-CcodUniqueRuntimeId -ProjectVersion '2.5.22' -Files $Files -NewNonce { '2'*32 }} $filesA + $differentContent=&$module {param($Files)New-CcodUniqueRuntimeId -ProjectVersion '2.5.22' -Files $Files -NewNonce { '1'*32 }} $filesB + $differentVersion=&$module {param($Files)New-CcodUniqueRuntimeId -ProjectVersion '2.5.23' -Files $Files -NewNonce { '1'*32 }} $filesA + Assert-CcodEqual '2.5.22-4b2146780452e12e-11111111111111111111111111111111' $first 'runtime id contains project version, manifest file digest, and canonical nonce' + Assert-CcodTrue ($first.Length-le96) 'runtime id remains within the manifest identity bound' + Assert-CcodTrue ($first-cne$second) 'identical package content receives a unique generation id per attempt' + Assert-CcodTrue ($first-cne$differentContent) 'manifest file content changes the deterministic runtime identity' + Assert-CcodTrue ($first-cne$differentVersion) 'project version changes the deterministic runtime identity' + Assert-CcodThrows { &$module {param($Files)New-CcodUniqueRuntimeId -ProjectVersion '2.5.22' -Files $Files -NewNonce { 'not-a-guid' }} $filesA } 'CCOD_INSTALL_RUNTIME_ID_INVALID' +} + +$results += Invoke-CcodTest 'immutable record helpers reject a cross-root transaction before creating state' { + $a=New-CcodLifecycleTempRoot;$b=New-CcodLifecycleTempRoot;[IO.Directory]::CreateDirectory($a)|Out-Null;[IO.Directory]::CreateDirectory($b)|Out-Null + $module=Get-Module InstallLifecycle -ErrorAction Stop;$tx=&$module {param($Root)Open-CcodLifecycleInstallGeneration -InstallRoot $Root -RuntimeId 'scope-runtime'} $a + try { + $record=&$module {New-CcodInstallTransactionRecord -TransactionId '22222222-3333-4444-5555-666666666666' -OldRuntimeId $null -OldGeneration $null -OldManifestSha256 $null -NewRuntimeId 'scope-runtime' -NewGeneration 1 -NewManifestSha256 ('2'*64) -SealedPackageSha256 ('e'*64) -OwnedObjectNames @('scope-runtime')} + Assert-CcodThrows { &$module {param($Root,$Record,$Tx)Write-CcodInstallTransactionRecord -InstallRoot $Root -TransactionRecord $Record -FileTransaction $Tx} $b $record $tx } 'CCOD_INSTALL_TRANSACTION_SCOPE' + Assert-CcodEqual $false (Test-Path -LiteralPath (Join-Path $b 'state')) 'cross-root call creates no state' + } finally { &$module {param($Tx)Close-CcodInstallFileTransaction -Transaction $Tx -Disposition Failed} $tx;if(Test-Path $a){Remove-Item $a -Recurse -Force};if(Test-Path $b){Remove-Item $b -Recurse -Force} } +} + +$results += Invoke-CcodTest 'state and UI initialization reject a cross-root transaction before either state plane is written' { + $a=New-CcodLifecycleTempRoot;$b=New-CcodLifecycleTempRoot;[IO.Directory]::CreateDirectory($a)|Out-Null;[IO.Directory]::CreateDirectory($b)|Out-Null + $module=Get-Module InstallLifecycle -ErrorAction Stop;$runtimeId='2.5.22-1111111111111111-22222222222222222222222222222222';$tx=&$module {param($Root,$Id)Open-CcodLifecycleInstallGeneration -InstallRoot $Root -RuntimeId $Id} $a $runtimeId + try{Assert-CcodThrows {&$module {param($Root,$Id,$Transaction)Initialize-CcodInstallStatePlanes -InstallRoot $Root -RuntimeId $Id -FileTransaction $Transaction -NodeCandidates @() -CandidateCompatibleOptIn $false} $b $runtimeId $tx} 'CCOD_INSTALL_TRANSACTION_SCOPE';Assert-CcodEqual $false (Test-Path -LiteralPath (Join-Path $a 'state')) 'root A receives no baseline on cross-root rejection';Assert-CcodEqual $false (Test-Path -LiteralPath (Join-Path $b 'state')) 'root B receives no operational or UI state on cross-root rejection'}finally{&$module {param($Transaction)Close-CcodInstallFileTransaction -Transaction $Transaction -Disposition Failed} $tx;if(Test-Path $a){Remove-Item $a -Recurse -Force};if(Test-Path $b){Remove-Item $b -Recurse -Force}} +} + +$results += Invoke-CcodTest 'partial operational state fails closed without overwrite or activation' { + $source=New-CcodLifecycleTempRoot;$install=New-CcodLifecycleTempRoot;$nodeRoot=New-CcodLifecycleTempRoot + try{ + New-CcodLifecycleSourceFixture -Root $source|Out-Null;$stateRoot=Join-Path $install 'state';[IO.Directory]::CreateDirectory($stateRoot)|Out-Null + $settingsPath=Join-Path $stateRoot 'settings.json';[IO.File]::WriteAllText($settingsPath,'{"schemaVersion":1,"sentinel":"partial"}',[Text.UTF8Encoding]::new($false));$before=Get-CcodTestFileSha256 $settingsPath + $node=New-CcodLifecycleFakeNode -Root $nodeRoot;Assert-CcodThrows {Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters (New-CcodLifecycleFake -NodePath $node).Adapters|Out-Null} 'CCOD_STATE_ALREADY_INITIALIZED' + Assert-CcodEqual $before (Get-CcodTestFileSha256 $settingsPath) 'partial operational state is not overwritten';Assert-CcodEqual 1 @(Get-ChildItem -LiteralPath $stateRoot -File).Count 'no other operational state leaf is materialized';Assert-CcodEqual $false (Test-Path -LiteralPath (Join-Path $stateRoot 'active-generation')) 'partial state cannot activate a generation' + }finally{foreach($p in @($source,$install,$nodeRoot)){if(Test-Path $p){Remove-Item $p -Recurse -Force}}} +} + +$results += Invoke-CcodTest 'transaction reader rejects immutable identity drift across a contiguous snapshot chain' { + $install=New-CcodLifecycleTempRoot;$transactionId='33333333-4444-5555-6666-777777777777' + try{ + $store=Join-Path $install 'state\install-transactions';[IO.Directory]::CreateDirectory($store)|Out-Null + $prepared=[pscustomobject][ordered]@{schemaVersion=1;transactionId=$transactionId;oldRuntimeId='runtime-old-a';oldGeneration=1;oldManifestSha256=('1'*64);newRuntimeId='runtime-new';newGeneration=2;newManifestSha256=('2'*64);sealedPackageSha256=('f'*64);ownedObjectNames=@('runtime-new');phase='Prepared';errorCode=$null} + $verified=[pscustomobject][ordered]@{schemaVersion=1;transactionId=$transactionId;oldRuntimeId='runtime-old-b';oldGeneration=1;oldManifestSha256=('1'*64);newRuntimeId='runtime-new';newGeneration=2;newManifestSha256=('2'*64);sealedPackageSha256=('f'*64);ownedObjectNames=@('runtime-new');phase='PackageVerified';errorCode=$null} + [IO.File]::WriteAllText((Join-Path $store "00000000000000000002.00.Prepared.$transactionId.json"),($prepared|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText((Join-Path $store "00000000000000000002.01.PackageVerified.$transactionId.json"),($verified|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new($false)) + $module=Get-Module InstallLifecycle -ErrorAction Stop + Assert-CcodThrows {&$module {param($Root,$Id)Read-CcodInstallTransactionRecord -InstallRoot $Root -TransactionId $Id|Out-Null} $install $transactionId} 'CCOD_INSTALL_TRANSACTION_INVALID' + }finally{if(Test-Path -LiteralPath $install){Remove-Item -LiteralPath $install -Recurse -Force}} +} + +$results += Invoke-CcodTest 'transaction store rejects unknown reparse ADS multilink and ambiguous chains' { + $module=Get-Module InstallLifecycle -ErrorAction Stop + $newRecord={param($Id,[uint64]$Generation)[pscustomobject][ordered]@{schemaVersion=1;transactionId=$Id;oldRuntimeId='runtime-old';oldGeneration=[uint64]1;oldManifestSha256=('1'*64);newRuntimeId=("runtime-new-$Generation");newGeneration=$Generation;newManifestSha256=('2'*64);sealedPackageSha256=('a'*64);ownedObjectNames=@("runtime-new-$Generation");phase='Prepared';errorCode=$null}} + foreach($kind in @('unknown','reparse','ads','multilink','ambiguous')){ + $install=New-CcodLifecycleTempRoot;$outside=New-CcodLifecycleTempRoot + try{ + $store=Join-Path $install 'state\install-transactions';[IO.Directory]::CreateDirectory($store)|Out-Null;[IO.Directory]::CreateDirectory($outside)|Out-Null + if($kind-ceq'unknown'){[IO.File]::WriteAllText((Join-Path $store 'unknown.bin'),'x',[Text.UTF8Encoding]::new($false))} + elseif($kind-ceq'reparse'){New-Item -ItemType Junction -Path (Join-Path $store '00000000000000000002.00.Prepared.44444444-5555-6666-7777-888888888888.json') -Target $outside|Out-Null} + elseif($kind-ceq'ads'){$id='44444444-5555-6666-7777-888888888888';$path=Join-Path $store "00000000000000000002.00.Prepared.$id.json";[IO.File]::WriteAllText($path,((&$newRecord $id 2)|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new($false));Set-Content -LiteralPath $path -Stream 'evidence' -Value 'x' -NoNewline} + elseif($kind-ceq'multilink'){$id='44444444-5555-6666-7777-888888888888';$outsideFile=Join-Path $outside 'record.json';[IO.File]::WriteAllText($outsideFile,((&$newRecord $id 2)|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new($false));New-Item -ItemType HardLink -Path (Join-Path $store "00000000000000000002.00.Prepared.$id.json") -Target $outsideFile|Out-Null} + else{foreach($pair in @(@('44444444-5555-6666-7777-888888888888',[uint64]2),@('55555555-6666-7777-8888-999999999999',[uint64]3))){$id=[string]$pair[0];$generation=[uint64]$pair[1];[IO.File]::WriteAllText((Join-Path $store ('{0:D20}.00.Prepared.{1}.json'-f$generation,$id)),((&$newRecord $id $generation)|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new($false))}} + $expected=if($kind-ceq'ambiguous'){'CCOD_INSTALL_TRANSACTION_AMBIGUOUS'}else{'CCOD_INSTALL_TRANSACTION_INVALID'} + Assert-CcodThrows {&$module {param($Root)Read-CcodInstallTransactionRecord -InstallRoot $Root|Out-Null} $install} $expected + }finally{if(Test-Path -LiteralPath $install){Remove-Item -LiteralPath $install -Recurse -Force -ErrorAction SilentlyContinue};if(Test-Path -LiteralPath $outside){Remove-Item -LiteralPath $outside -Recurse -Force -ErrorAction SilentlyContinue}} + } +} + +$results += Invoke-CcodTest 'transaction reader rejects ambiguous Ready and latest Failed terminal heads' { + $module=Get-Module InstallLifecycle -ErrorAction Stop + foreach($terminalKind in @('Ready','Failed')){ + $install=New-CcodLifecycleTempRoot + try{ + $store=Join-Path $install 'state\install-transactions';[IO.Directory]::CreateDirectory($store)|Out-Null + $runtimeId='runtime-terminal';$generation=if($terminalKind-ceq'Ready'){[uint64]1}else{[uint64]2} + foreach($id in @('66666666-7777-8888-9999-aaaaaaaaaaaa','77777777-8888-9999-aaaa-bbbbbbbbbbbb')){ + $phases=if($terminalKind-ceq'Ready'){@('Prepared','PackageVerified','RuntimeStaged','PreviousProtectionStopped','RuntimePromoted','PointerCommitted','StableShellCommitted','ProtectionReady','Ready')}else{@('Prepared','Failed')} + foreach($phase in $phases){$index=if($phase-ceq'Failed'){99}else{[Array]::IndexOf(@('Prepared','PackageVerified','RuntimeStaged','PreviousProtectionStopped','RuntimePromoted','PointerCommitted','StableShellCommitted','ProtectionReady','Ready'),$phase)};$record=[pscustomobject][ordered]@{schemaVersion=1;transactionId=$id;oldRuntimeId=$null;oldGeneration=$null;oldManifestSha256=$null;newRuntimeId=$runtimeId;newGeneration=$generation;newManifestSha256=('2'*64);sealedPackageSha256=('b'*64);ownedObjectNames=@($runtimeId);phase=$phase;errorCode=if($phase-ceq'Failed'){'CCOD_INSTALL_FAILED'}else{$null}};[IO.File]::WriteAllText((Join-Path $store ('{0:D20}.{1:D2}.{2}.{3}.json'-f$generation,$index,$phase,$id)),($record|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new($false))} + } + if($terminalKind-ceq'Ready'){$pointerRoot=Join-Path $install 'state\active-generation';[IO.Directory]::CreateDirectory($pointerRoot)|Out-Null;[IO.File]::WriteAllText((Join-Path $pointerRoot '00000000000000000001.json'),('{"schemaVersion":1,"generation":1,"activeRuntime":"'+$runtimeId+'","previousGeneration":0}'),[Text.UTF8Encoding]::new($false))} + Assert-CcodThrows {&$module {param($Root)Read-CcodInstallTransactionRecord -InstallRoot $Root|Out-Null} $install} 'CCOD_INSTALL_TRANSACTION_AMBIGUOUS' + }finally{if(Test-Path -LiteralPath $install){Remove-Item -LiteralPath $install -Recurse -Force}} + } +} + +$results += Invoke-CcodTest 'real install entry rejects pointerless and ambiguous nonterminal transaction state before generation creation' { + foreach($count in @(1,2)){ + $source=New-CcodLifecycleTempRoot;$install=New-CcodLifecycleTempRoot;$nodeRoot=New-CcodLifecycleTempRoot + try{ + New-CcodLifecycleSourceFixture -Root $source|Out-Null;$store=Join-Path $install 'state\install-transactions';[IO.Directory]::CreateDirectory($store)|Out-Null + for($i=1;$i-le$count;$i++){$id=if($i-eq1){'88888888-9999-aaaa-bbbb-cccccccccccc'}else{'99999999-aaaa-bbbb-cccc-dddddddddddd'};$runtime="pending-$i";$record=[pscustomobject][ordered]@{schemaVersion=1;transactionId=$id;oldRuntimeId=$null;oldGeneration=$null;oldManifestSha256=$null;newRuntimeId=$runtime;newGeneration=[uint64]$i;newManifestSha256=('2'*64);sealedPackageSha256=('3'*64);ownedObjectNames=@($runtime);phase='Prepared';errorCode=$null};[IO.File]::WriteAllText((Join-Path $store ('{0:D20}.00.Prepared.{1}.json'-f$i,$id)),($record|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new($false))} + $node=New-CcodLifecycleFakeNode -Root $nodeRoot;$expected=if($count-eq1){'CCOD_INSTALL_TRANSACTION_BUSY'}else{'CCOD_INSTALL_TRANSACTION_AMBIGUOUS'} + Assert-CcodThrows {Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters (New-CcodLifecycleFake -NodePath $node).Adapters|Out-Null} $expected + Assert-CcodEqual $false (Test-Path -LiteralPath (Join-Path $install 'runtime')) "global transaction gate creates no generation for $count head(s)" + }finally{foreach($p in @($source,$install,$nodeRoot)){if(Test-Path $p){Remove-Item $p -Recurse -Force}}} + } +} + $results += Invoke-CcodTest 'waits for the previous IgnoreNew task instance before starting the new supervisor' { $module = Get-Module -Name InstallLifecycle -ErrorAction Stop $calls = [Collections.Generic.List[string]]::new() @@ -387,6 +1614,7 @@ $results += Invoke-CcodTest 'default installer validation uses the structural ru } } +<# Superseded: Setup no longer parses mutable JSON progress or owns an app payload tree. # Production mutation caught: recognizing JSON members only when the colon has zero or one following space. $results += Invoke-CcodTest 'Inno activation progress parser accepts every legal JSON whitespace form' { $installerPath = Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss' @@ -491,17 +1719,27 @@ $results += Invoke-CcodTest 'Inno visibly starts and synchronously owns the boun Assert-CcodTrue ($activationWorker -cmatch '(?s)function Stop-CcodOwnedInstallProcess.*?WaitForExit\(5000\).*?while \(\$true\).*?\.Kill\(\).*?\.WaitForExit\(\).*?\.HasExited.*?return') 'an unproven bounded stop escalates to waiting until exact child exit instead of returning to Setup' } +#> + +$results += Invoke-CcodTest 'sealed Setup delegates strict append-only receipt validation to the locked bootstrap' { + $installerScript = Get-Content -LiteralPath (Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss') -Raw -Encoding UTF8 + $activationWorker = Get-Content -LiteralPath (Join-Path $repositoryRoot 'Activate-CcodRemoteFix.ps1') -Raw -Encoding UTF8 + Assert-CcodTrue ($installerScript -cmatch '(?s)function PrepareToInstall.*ExtractAndLockCcodInputs.*procedure CurStepChanged.*GetArrayLength\(CcodInputHandles\) <> 3.*GetCcodBootstrapParameters.*ewWaitUntilTerminated, ActivationResultCode.*GetCcodBootstrapParameters\(ActivationId, True\).*ewWaitUntilTerminated, ValidationResultCode') 'Setup retains one pre-install locked bootstrap across activation and strict validation' + Assert-CcodTrue ($installerScript -cmatch '-PackagePath' -and $installerScript -cmatch '-ExpectedPackageSha256' -and $installerScript -cnotmatch 'post-install-activation\.json') 'Setup passes the compile-bound sealed package and owns no mutable receipt path' + Assert-CcodTrue ($activationWorker -cmatch 'state\\activation-receipts' -and $activationWorker -cmatch '\$ExpectedActivationId\.\$phase\.json' -and $activationWorker -cnotmatch "Join-Path \`$Root 'state\\post-install-activation\.json'") 'bootstrap validates only append-only activation receipts' +} + $results += Invoke-CcodTest 'activation owner kills and proves exit before a timed-out child can change the receipt' { $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-activation-owned-timeout-' + [guid]::NewGuid().ToString('N')) try { - [IO.Directory]::CreateDirectory((Join-Path $root 'state')) | Out-Null + [IO.Directory]::CreateDirectory((Join-Path $root 'state\activation-receipts')) | Out-Null $lateMarker = Join-Path $root 'late-write.txt' $installScript = Join-Path $root 'Install-CodexControlOtherDevices.ps1' $installSource = @" param([string]`$InstallRoot,[string]`$ActivationId,[switch]`$EnableCandidateCompatibleUpdates) Start-Sleep -Milliseconds 1200 `$receipt=[ordered]@{schemaVersion=1;activationId=`$ActivationId;phase='Ready';runtimeId='late-runtime';previousRuntimeId=`$null;startedAtUtc='2030-02-03T04:05:06.0000000Z';updatedAtUtc='2030-02-03T04:05:07.0000000Z';ready=`$true;errorCode=`$null} -[IO.File]::WriteAllText((Join-Path `$InstallRoot 'state\post-install-activation.json'),(`$receipt|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new(`$false)) +[IO.File]::WriteAllText((Join-Path `$InstallRoot "state\activation-receipts\`$ActivationId.Ready.json"),(`$receipt|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new(`$false)) [IO.File]::WriteAllText('$lateMarker','late',[Text.UTF8Encoding]::new(`$false)) exit 0 "@ @@ -511,131 +1749,381 @@ exit 0 Assert-CcodEqual 1 $LASTEXITCODE 'missing first receipt makes the owned activation fail' Start-Sleep -Milliseconds 1400 Assert-CcodTrue (-not (Test-Path -LiteralPath $lateMarker)) 'the exact install child cannot write after the activation owner returns' - Assert-CcodTrue (-not (Test-Path -LiteralPath (Join-Path $root 'state\post-install-activation.json'))) 'the killed child cannot later publish Ready' + Assert-CcodTrue (-not (Test-Path -LiteralPath (Join-Path $root "state\activation-receipts\$activationId.Ready.json"))) 'the killed child cannot later publish Ready' $log = Get-Content -LiteralPath (Join-Path $root 'logs\post-install-activation.log') -Raw -Encoding UTF8 Assert-CcodTrue ($log -cmatch 'CCOD_ACTIVATION_FIRST_RECEIPT_TIMEOUT') 'the owner records the stable first-receipt timeout code' Assert-CcodTrue (($output -join "`n") -cmatch 'CCOD_ACTIVATION_FIRST_RECEIPT_TIMEOUT') 'the owner reports the bounded timeout without a safe terminal receipt' } finally { - if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } + } +} + +$results += Invoke-CcodTest 'activation owner freezes a correlated progress receipt at the total deadline' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-activation-owned-total-timeout-' + [guid]::NewGuid().ToString('N')) + try { + [IO.Directory]::CreateDirectory((Join-Path $root 'state\activation-receipts')) | Out-Null + $lateMarker = Join-Path $root 'late-total-write.txt' + $installScript = Join-Path $root 'Install-CodexControlOtherDevices.ps1' + $installSource = @" +param([string]`$InstallRoot,[string]`$ActivationId,[switch]`$EnableCandidateCompatibleUpdates) +`$receipt=[ordered]@{schemaVersion=1;activationId=`$ActivationId;phase='StartingProtection';runtimeId='pending-runtime';previousRuntimeId=`$null;startedAtUtc='2030-02-03T04:05:06.0000000Z';updatedAtUtc='2030-02-03T04:05:07.0000000Z';ready=`$false;errorCode=`$null} +[IO.File]::WriteAllText((Join-Path `$InstallRoot "state\activation-receipts\`$ActivationId.StartingProtection.json"),(`$receipt|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new(`$false)) +Start-Sleep -Milliseconds 3000 +`$receipt.phase='Ready';`$receipt.ready=`$true;`$receipt.updatedAtUtc='2030-02-03T04:05:08.0000000Z' +[IO.File]::WriteAllText((Join-Path `$InstallRoot "state\activation-receipts\`$ActivationId.Ready.json"),(`$receipt|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new(`$false)) +[IO.File]::WriteAllText('$lateMarker','late',[Text.UTF8Encoding]::new(`$false)) +exit 0 +"@ + [IO.File]::WriteAllText($installScript,$installSource,[Text.UTF8Encoding]::new($false)) + $activationId = '77777777-6666-5555-4444-333333333333' + $output = @(& (Join-Path $repositoryRoot 'Activate-CcodRemoteFix.ps1') -AppRoot $root -InstallRoot $root -ActivationId $activationId -FirstReceiptTimeoutMilliseconds 1000 -ActivationTimeoutMilliseconds 1200 2>&1) + Assert-CcodEqual 1 $LASTEXITCODE 'total deadline makes the owned activation fail after correlated progress' + Start-Sleep -Milliseconds 3200 + Assert-CcodTrue (-not (Test-Path -LiteralPath $lateMarker)) 'the exact install child cannot write after the total deadline owner return' + $receipt = Get-Content -LiteralPath (Join-Path $root "state\activation-receipts\$activationId.StartingProtection.json") -Raw -Encoding UTF8 | ConvertFrom-Json + Assert-CcodEqual 'StartingProtection' $receipt.phase 'the killed child cannot change progress into Ready after Setup regains control' + $log = Get-Content -LiteralPath (Join-Path $root 'logs\post-install-activation.log') -Raw -Encoding UTF8 + Assert-CcodTrue ($log -cmatch 'CCOD_ACTIVATION_TIMEOUT') 'the owner records the stable total timeout code' + Assert-CcodTrue (($output -join "`n") -cmatch 'CCOD_ACTIVATION_TIMEOUT') 'the owner reports the bounded total timeout without a safe Ready result' + } finally { + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } + } +} + +# Production mutation caught: reducing a strict correlated Failed receipt to the generic activation-worker exit code. +$results += Invoke-CcodTest 'activation worker reports the strict Failed receipt support code without prompting' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-activation-failed-report-' + [guid]::NewGuid().ToString('N')) + try { + [IO.Directory]::CreateDirectory((Join-Path $root 'state\activation-receipts')) | Out-Null + $promptMarker = Join-Path $root 'prompt-marker.txt' + $installScript = Join-Path $root 'Install-CodexControlOtherDevices.ps1' + $promptScript = Join-Path $root 'Prompt-CcodRestart.ps1' + $installSource = @" +param([string]`$InstallRoot,[string]`$ActivationId,[switch]`$EnableCandidateCompatibleUpdates) +`$receipt=[ordered]@{schemaVersion=1;activationId=`$ActivationId;phase='Failed';runtimeId='runtime-new';previousRuntimeId='runtime-old';startedAtUtc='2030-02-03T04:05:06.0000000Z';updatedAtUtc='2030-02-03T04:05:07.0000000Z';ready=`$false;errorCode='CCOD_INSTALL_NEW_RUNTIME_NOT_READY'} +[IO.File]::WriteAllText((Join-Path `$InstallRoot "state\activation-receipts\`$ActivationId.Failed.json"),(`$receipt|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new(`$false)) +exit 1 +"@ + [IO.File]::WriteAllText($installScript,$installSource,[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($promptScript,"[IO.File]::WriteAllText('$promptMarker','prompted');exit 0",[Text.UTF8Encoding]::new($false)) + $activationId = '77777777-6666-5555-4444-333333333333' + $output = @(& (Join-Path $repositoryRoot 'Activate-CcodRemoteFix.ps1') -AppRoot $root -InstallRoot $root -ActivationId $activationId -Prompt -NoUi 2>&1) + + Assert-CcodEqual 1 $LASTEXITCODE 'strict Failed receipt keeps the activation worker unsuccessful' + Assert-CcodTrue (-not (Test-Path -LiteralPath $promptMarker)) 'failed activation never reaches the restart prompt' + $log = Get-Content -LiteralPath (Join-Path $root 'logs\post-install-activation.log') -Raw -Encoding UTF8 + Assert-CcodTrue ($log -cmatch '"code":"CCOD_INSTALL_NEW_RUNTIME_NOT_READY"') 'activation log retains the strict bounded installer support code' + Assert-CcodTrue ($log -cnotmatch '"code":"CCOD_ACTIVATION_RUNTIME_FAILED"') 'strict Failed receipt is not hidden behind the generic child exit code' + } finally { + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } + } +} + +# Production mutation caught: accepting a caller-controlled activation id and persisting it into receipts or logs before canonical validation. +$results += Invoke-CcodTest 'install rejects a noncanonical caller ActivationId before receipt log or task mutation' { + $source=New-CcodLifecycleTempRoot;$install=New-CcodLifecycleTempRoot;$nodeRoot=New-CcodLifecycleTempRoot + try { + New-CcodLifecycleSourceFixture -Root $source -Version '2.5.0-invalid-activation-id'|Out-Null + $nodePath=New-CcodLifecycleFakeNode -Root $nodeRoot + $fake=New-CcodLifecycleFake -NodePath $nodePath + $failure=$null + try { Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -ActivationId 'ATTACKER_LOG_MARKER' -Adapters $fake.Adapters|Out-Null } catch { $failure=$_ } + Assert-CcodTrue ($null-ne$failure) 'noncanonical caller ActivationId fails closed' + Assert-CcodTrue ($failure.FullyQualifiedErrorId-like'CCOD_ACTIVATION_ID_INVALID*') 'invalid ActivationId returns the bounded stable code' + Assert-CcodEqual 0 $fake.World.Phases.Count 'invalid ActivationId writes no activation receipt' + Assert-CcodEqual 0 $fake.World.LogRecords.Count 'invalid ActivationId writes no install log' + Assert-CcodEqual 0 $fake.World.TaskInstalled 'invalid ActivationId performs no scheduled-task mutation' + } finally {foreach($path in @($source,$install,$nodeRoot)){if(Test-Path -LiteralPath $path){Remove-Item -LiteralPath $path -Recurse -Force}}} +} + +$results += Invoke-CcodTest 'first install stages verifies activates task and persists consent' { + $source = New-CcodLifecycleTempRoot + $install = New-CcodLifecycleTempRoot + $nodeRoot = New-CcodLifecycleTempRoot + try { + New-CcodLifecycleSourceFixture -Root $source | Out-Null + $nodePath = New-CcodLifecycleFakeNode -Root $nodeRoot + $fake = New-CcodLifecycleFake -NodePath $nodePath + $receipt = Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -EnableCandidateCompatibleUpdates -Adapters $fake.Adapters + Assert-CcodEqual 'Installed' $receipt.Outcome 'first install outcome' + Assert-CcodEqual $true $receipt.Installed 'first install flag' + $runtimeRoot = Join-Path $install "runtime\$($receipt.RuntimeId)" + Assert-CcodTrue (Test-Path -LiteralPath (Join-Path $runtimeRoot 'src\persistence\bootstrap.ps1') -PathType Leaf) 'generation-specific bootstrap is sealed with the runtime' + Assert-CcodEqual $false (Test-Path -LiteralPath (Join-Path $install 'bootstrap.ps1')) 'first install does not create a mutable root bootstrap' + Assert-CcodEqual $false (Test-Path -LiteralPath (Join-Path $install 'active.json')) 'new install never writes the legacy active pointer' + Assert-CcodEqual $false (Test-Path -LiteralPath (Join-Path $install 'state\post-install-activation.json')) 'new install never writes the mutable legacy activation receipt' + Assert-CcodTrue (Test-Path -LiteralPath (Join-Path $install 'state\active-generation\00000000000000000001.json') -PathType Leaf) 'append-only active generation pointer written' + Assert-CcodTrue (Test-Path -LiteralPath $runtimeRoot -PathType Container) 'runtime staged' + Assert-CcodEqual 1 $fake.World.TaskInstalled 'task installed' + Assert-CcodEqual $receipt.RuntimeId $fake.World.TaskRuntimeIds[0] 'scheduled task selects the committed generation bootstrap' + Assert-CcodEqual 1 $fake.World.TaskStarted 'task started' + Assert-CcodEqual $null $receipt.PreviousRuntimeId 'first install has no previous runtime' + $state = Read-CcodState -StateRoot (Join-Path $install 'state') + Assert-CcodEqual $true $state.Settings.candidateCompatibleOptIn 'explicit consent persisted' + Assert-CcodEqual $true $state.Settings.automationEnabled 'automation enabled on first install' + Assert-CcodEqual $nodePath $state.Settings.nodeCandidates[0] 'verified node candidate persisted' + $stateRoot = Join-Path $install 'state' + $preference = Read-CcodUiPreference -StateRoot $stateRoot + Assert-CcodEqual 'System' $preference.LanguageMode 'first install follows Windows' + Assert-CcodEqual $false $preference.FallbackUsed 'first install persisted preference' + $baselineRoot = Join-Path $stateRoot "install-initializations\$($receipt.RuntimeId)" + foreach ($leaf in @('settings.json','status.json','verified-packages.json','transition.json','ui-preferences.json')) { + $baselinePath = Join-Path $baselineRoot $leaf + Assert-CcodTrue (Test-Path -LiteralPath $baselinePath -PathType Leaf) "immutable initialization baseline contains $leaf" + Assert-CcodTrue (((Get-Item -LiteralPath $baselinePath -Force).Attributes -band [IO.FileAttributes]::ReadOnly) -ne 0) "immutable initialization baseline seals $leaf" + } + foreach ($leaf in @('settings.json','status.json','verified-packages.json','transition.json','ui-preferences.json')) { + Assert-CcodEqual 0 ((Get-Item -LiteralPath (Join-Path $stateRoot $leaf) -Force).Attributes -band [IO.FileAttributes]::ReadOnly) "operational $leaf remains writable" + } + Set-CcodAutomationEnabled -StateRoot $stateRoot -Enabled $false + Assert-CcodEqual $false (Read-CcodSettings -StateRoot $stateRoot).automationEnabled 'StateStore can update and read operational state after immutable initialization' + Set-CcodUiLanguageMode -StateRoot $stateRoot -LanguageMode 'zh-CN' | Out-Null + Assert-CcodEqual 'zh-CN' (Read-CcodUiPreference -StateRoot $stateRoot).LanguageMode 'UI preferences can update and read operational state after immutable initialization' + Assert-CcodTrue (Test-Path -LiteralPath (Join-Path $runtimeRoot 'src\persistence\resources\ui.en-US.json') -PathType Leaf) 'English catalog staged' + Assert-CcodTrue (Test-Path -LiteralPath (Join-Path $runtimeRoot 'src\persistence\resources\ui.zh-CN.json') -PathType Leaf) 'Chinese catalog staged' + $manifest = Test-CcodRuntimeManifest -RuntimeDirectory $runtimeRoot -ExpectedRuntimeId $receipt.RuntimeId + Assert-CcodEqual $true $manifest.Valid 'runtime manifest validates staged resources' + $english = @($manifest.Manifest.files | Where-Object { $_.path -ceq 'src/persistence/resources/ui.en-US.json' }) + $chinese = @($manifest.Manifest.files | Where-Object { $_.path -ceq 'src/persistence/resources/ui.zh-CN.json' }) + Assert-CcodEqual 1 $english.Count 'manifest contains English catalog exactly once' + Assert-CcodEqual 1 $chinese.Count 'manifest contains Chinese catalog exactly once' + Assert-CcodEqual '662b6067a48cfaeb481ae1a35e02f09fa799fa6386d0f4d2c61c19874a152713' $english[0].sha256 'manifest hashes English catalog' + Assert-CcodEqual '5770fe0f20f1623648a185cc7a0a99ff37b6aef6c07426ffc8a984493e0f2a2f' $chinese[0].sha256 'manifest hashes Chinese catalog' + } finally { + foreach ($path in @($source, $install, $nodeRoot)) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force } } + } +} + +# Production mutation caught: accepting an old setup payload for a newer expected setup version and stopping the active runtime before rejecting it. +$results += Invoke-CcodTest 'upgrade rejects a payload version mismatch before active-pointer or protection mutation' { + $source = New-CcodLifecycleTempRoot + $install = New-CcodLifecycleTempRoot + $nodeRoot = New-CcodLifecycleTempRoot + try { + New-CcodLifecycleSourceFixture -Root $source -Version '2.5.13' | Out-Null + $manifestPath = New-CcodLifecyclePayloadManifest -Root $source -Version '2.5.13' + $nodePath = New-CcodLifecycleFakeNode -Root $nodeRoot + $first = Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters (New-CcodLifecycleFake -NodePath $nodePath).Adapters + $oldRuntime = [string]$first.RuntimeId + $before = Read-CcodActiveRuntime -InstallRoot $install + $fake = New-CcodLifecycleFake -NodePath $nodePath + + $payloadBinding = Get-CcodLifecyclePayloadBinding -ManifestPath $manifestPath -ExpectedVersion '2.5.22' + Assert-CcodThrows { + Invoke-CcodInstall -SourceRoot $source -InstallRoot $install @payloadBinding -Adapters $fake.Adapters | Out-Null + } 'CCOD_INSTALL_PAYLOAD_VERSION_MISMATCH' + + $after = Read-CcodActiveRuntime -InstallRoot $install + Assert-CcodEqual $oldRuntime $after.activeRuntime 'old payload cannot replace the active runtime' + Assert-CcodEqual $before.generation $after.generation 'version mismatch cannot advance the active generation' + Assert-CcodEqual 0 $fake.World.ShutdownSignaled 'version mismatch cannot stop the existing protection' + Assert-CcodEqual 0 $fake.World.TaskInstalled 'version mismatch performs no task mutation' + } finally { + foreach ($path in @($source,$install,$nodeRoot)) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force } } + } +} + +# Production mutation caught: rediscovering the payload directory after manifest validation and copying an unlisted leftover into the runtime. +$results += Invoke-CcodTest 'matching payload stages only immutable manifest-listed records' { + $source = New-CcodLifecycleTempRoot + $install = New-CcodLifecycleTempRoot + $nodeRoot = New-CcodLifecycleTempRoot + try { + New-CcodLifecycleSourceFixture -Root $source -Version '2.5.22' | Out-Null + $manifestPath = New-CcodLifecyclePayloadManifest -Root $source -Version '2.5.22' + $stalePath = Join-Path $source 'src\runtime\stale-old-payload.js' + [IO.File]::WriteAllText($stalePath,"module.exports = 'stale';`n",[Text.UTF8Encoding]::new($false)) + $nodePath = New-CcodLifecycleFakeNode -Root $nodeRoot + + $payloadBinding = Get-CcodLifecyclePayloadBinding -ManifestPath $manifestPath -ExpectedVersion '2.5.22' + $receipt = Invoke-CcodInstall -SourceRoot $source -InstallRoot $install @payloadBinding -Adapters (New-CcodLifecycleFake -NodePath $nodePath).Adapters + + $runtimeRoot = Join-Path $install "runtime\$($receipt.RuntimeId)" + Assert-CcodTrue (-not (Test-Path -LiteralPath (Join-Path $runtimeRoot 'src\runtime\stale-old-payload.js'))) 'unlisted stale source file is absent from the runtime' + $runtimeManifest = Test-CcodRuntimeManifest -RuntimeDirectory $runtimeRoot -ExpectedRuntimeId $receipt.RuntimeId + Assert-CcodEqual $true $runtimeManifest.Valid 'manifest-filtered runtime remains valid' + Assert-CcodEqual 0 @($runtimeManifest.Manifest.files | Where-Object { $_.path -ceq 'src/runtime/stale-old-payload.js' }).Count 'runtime manifest contains no unlisted stale record' + } finally { + foreach ($path in @($source,$install,$nodeRoot)) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force } } + } +} + +# Production mutation caught: trusting readiness without rereading the active pointer and selected runtime manifest before the terminal Ready receipt. +$results += Invoke-CcodTest 'payload-bound install rereads the active pointer after readiness before reporting Ready' { + $source = New-CcodLifecycleTempRoot + $install = New-CcodLifecycleTempRoot + $nodeRoot = New-CcodLifecycleTempRoot + try { + New-CcodLifecycleSourceFixture -Root $source -Version '2.5.21' | Out-Null + $nodePath = New-CcodLifecycleFakeNode -Root $nodeRoot + $first = Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -SealedPackageSha256 ('a'*64) -Adapters (New-CcodLifecycleFake -NodePath $nodePath).Adapters + $oldPointer = Read-CcodActiveRuntime -InstallRoot $install + [IO.File]::WriteAllText((Join-Path $source 'package.json'),(@{name='codexremote-fix';version='2.5.22';private=$true}|ConvertTo-Json -Depth 4),[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText((Join-Path $source 'src\runtime\main-payload.js'),"module.exports = 'fixture-2.5.22';`n",[Text.UTF8Encoding]::new($false)) + $manifestPath = New-CcodLifecyclePayloadManifest -Root $source -Version '2.5.22' + $fake = New-CcodLifecycleFake -NodePath $nodePath + $fake.Adapters.NewActivationId = { 'bbbbbbbb-cccc-dddd-eeee-ffffffffffff' } + $fake.Adapters.WaitNewRuntimeReady = { + param($InstallRoot,$RuntimeId,$RuntimeGeneration,$Identity,$TaskStartedAtUtc,$TimeoutMilliseconds) + $pointerRoot = Join-Path $InstallRoot 'state\active-generation' + $drift = [pscustomobject][ordered]@{ schemaVersion=1; generation=[uint64]3; activeRuntime=[string]$oldPointer.activeRuntime; previousGeneration=[uint64]2 } + [IO.File]::WriteAllText((Join-Path $pointerRoot '00000000000000000003.json'),($drift | ConvertTo-Json -Compress),[Text.UTF8Encoding]::new($false)) + [pscustomobject][ordered]@{ SupervisorReady = $true; TrayReady = $true } + }.GetNewClosure() + + $payloadBinding = Get-CcodLifecyclePayloadBinding -ManifestPath $manifestPath -ExpectedVersion '2.5.22' + Assert-CcodThrows { + Invoke-CcodInstall -SourceRoot $source -InstallRoot $install @payloadBinding -Adapters $fake.Adapters | Out-Null + } 'CCOD_INSTALL_RUNTIME_ACTIVATION_UNPROVEN' + Assert-CcodTrue ($fake.World.Phases -notcontains 'Ready') 'pointer drift after readiness cannot produce a Ready receipt' + } finally { + foreach ($path in @($source,$install,$nodeRoot)) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force } } + } +} + +# Production mutation caught: copying stable bootstrap/uninstaller from mutable source paths after their manifest-verified staging copies already exist. +$results += Invoke-CcodTest 'stable bootstrap and uninstaller come from manifest-verified staging bytes' { + $source = New-CcodLifecycleTempRoot + $install = New-CcodLifecycleTempRoot + $nodeRoot = New-CcodLifecycleTempRoot + try { + New-CcodLifecycleSourceFixture -Root $source -Version '2.5.22' | Out-Null + $uninstallerSource = Join-Path $source 'Uninstall-CodexControlOtherDevices.ps1' + [IO.File]::WriteAllText($uninstallerSource,"# verified uninstaller`r`n",[Text.UTF8Encoding]::new($false)) + [IO.Directory]::CreateDirectory($install) | Out-Null + $legacyBootstrap = Join-Path $install 'bootstrap.ps1' + $legacyUninstaller = Join-Path $install 'Uninstall-CodexControlOtherDevices.ps1' + [IO.File]::WriteAllText($legacyBootstrap,"# legacy bootstrap sentinel`r`n",[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($legacyUninstaller,"# legacy uninstaller sentinel`r`n",[Text.UTF8Encoding]::new($false)) + $legacyBootstrapHash = Get-CcodTestFileSha256 -Path $legacyBootstrap + $legacyUninstallerHash = Get-CcodTestFileSha256 -Path $legacyUninstaller + $manifestPath = New-CcodLifecyclePayloadManifest -Root $source -Version '2.5.22' + $nodePath = New-CcodLifecycleFakeNode -Root $nodeRoot + $fake = New-CcodLifecycleFake -NodePath $nodePath + + $payloadBinding = Get-CcodLifecyclePayloadBinding -ManifestPath $manifestPath -ExpectedVersion '2.5.22' + $receipt = Invoke-CcodInstall -SourceRoot $source -InstallRoot $install @payloadBinding -Adapters $fake.Adapters + + $runtimeRoot = Join-Path $install "runtime\$($receipt.RuntimeId)" + Assert-CcodEqual (Get-CcodTestFileSha256 -Path (Join-Path $source 'src\persistence\bootstrap.ps1')) (Get-CcodTestFileSha256 -Path (Join-Path $runtimeRoot 'src\persistence\bootstrap.ps1')) 'generation bootstrap equals the manifest-verified sealed source bytes' + Assert-CcodEqual (Get-CcodTestFileSha256 -Path $uninstallerSource) (Get-CcodTestFileSha256 -Path (Join-Path $runtimeRoot 'Uninstall-CodexControlOtherDevices.ps1')) 'generation uninstaller equals the manifest-verified sealed source bytes' + Assert-CcodEqual $legacyBootstrapHash (Get-CcodTestFileSha256 -Path $legacyBootstrap) 'legacy root bootstrap bytes remain unchanged' + Assert-CcodEqual $legacyUninstallerHash (Get-CcodTestFileSha256 -Path $legacyUninstaller) 'legacy root uninstaller bytes remain unchanged' + } finally { + foreach ($path in @($source,$install,$nodeRoot)) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force } } + } +} + +$results += Invoke-CcodTest 'install root junction is rejected before staging writes' { + $source = New-CcodLifecycleTempRoot + $install = New-CcodLifecycleTempRoot + $target = New-CcodLifecycleTempRoot + $nodeRoot = New-CcodLifecycleTempRoot + try { + New-CcodLifecycleSourceFixture -Root $source | Out-Null + [IO.Directory]::CreateDirectory($target) | Out-Null + New-Item -ItemType Junction -Path $install -Target $target | Out-Null + $nodePath = New-CcodLifecycleFakeNode -Root $nodeRoot + Assert-CcodThrows { Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters (New-CcodLifecycleFake -NodePath $nodePath).Adapters | Out-Null } 'CCOD_INSTALL_REPARSE_PATH' + Assert-CcodTrue (-not (Test-Path -LiteralPath (Join-Path $target 'active.json'))) 'install-root junction target receives no active pointer' + Assert-CcodTrue (-not (Test-Path -LiteralPath (Join-Path $target '.staging'))) 'install-root junction target receives no staging directory' + } finally { + if (Test-Path -LiteralPath $install) { [IO.Directory]::Delete($install) } + foreach ($path in @($source,$target,$nodeRoot)) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force } } } } -$results += Invoke-CcodTest 'activation owner freezes a correlated progress receipt at the total deadline' { - $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-activation-owned-total-timeout-' + [guid]::NewGuid().ToString('N')) +$results += Invoke-CcodTest 'runtime junction is rejected before staging promotion' { + $source = New-CcodLifecycleTempRoot + $install = New-CcodLifecycleTempRoot + $target = New-CcodLifecycleTempRoot + $nodeRoot = New-CcodLifecycleTempRoot try { - [IO.Directory]::CreateDirectory((Join-Path $root 'state')) | Out-Null - $lateMarker = Join-Path $root 'late-total-write.txt' - $installScript = Join-Path $root 'Install-CodexControlOtherDevices.ps1' - $installSource = @" -param([string]`$InstallRoot,[string]`$ActivationId,[switch]`$EnableCandidateCompatibleUpdates) -`$receipt=[ordered]@{schemaVersion=1;activationId=`$ActivationId;phase='StartingProtection';runtimeId='pending-runtime';previousRuntimeId=`$null;startedAtUtc='2030-02-03T04:05:06.0000000Z';updatedAtUtc='2030-02-03T04:05:07.0000000Z';ready=`$false;errorCode=`$null} -[IO.File]::WriteAllText((Join-Path `$InstallRoot 'state\post-install-activation.json'),(`$receipt|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new(`$false)) -Start-Sleep -Milliseconds 3000 -`$receipt.phase='Ready';`$receipt.ready=`$true;`$receipt.updatedAtUtc='2030-02-03T04:05:08.0000000Z' -[IO.File]::WriteAllText((Join-Path `$InstallRoot 'state\post-install-activation.json'),(`$receipt|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new(`$false)) -[IO.File]::WriteAllText('$lateMarker','late',[Text.UTF8Encoding]::new(`$false)) -exit 0 -"@ - [IO.File]::WriteAllText($installScript,$installSource,[Text.UTF8Encoding]::new($false)) - $activationId = '77777777-6666-5555-4444-333333333333' - $output = @(& (Join-Path $repositoryRoot 'Activate-CcodRemoteFix.ps1') -AppRoot $root -InstallRoot $root -ActivationId $activationId -FirstReceiptTimeoutMilliseconds 1000 -ActivationTimeoutMilliseconds 1200 2>&1) - Assert-CcodEqual 1 $LASTEXITCODE 'total deadline makes the owned activation fail after correlated progress' - Start-Sleep -Milliseconds 3200 - Assert-CcodTrue (-not (Test-Path -LiteralPath $lateMarker)) 'the exact install child cannot write after the total deadline owner return' - $receipt = Get-Content -LiteralPath (Join-Path $root 'state\post-install-activation.json') -Raw -Encoding UTF8 | ConvertFrom-Json - Assert-CcodEqual 'StartingProtection' $receipt.phase 'the killed child cannot change progress into Ready after Setup regains control' - $log = Get-Content -LiteralPath (Join-Path $root 'logs\post-install-activation.log') -Raw -Encoding UTF8 - Assert-CcodTrue ($log -cmatch 'CCOD_ACTIVATION_TIMEOUT') 'the owner records the stable total timeout code' - Assert-CcodTrue (($output -join "`n") -cmatch 'CCOD_ACTIVATION_TIMEOUT') 'the owner reports the bounded total timeout without a safe Ready result' + New-CcodLifecycleSourceFixture -Root $source | Out-Null + [IO.Directory]::CreateDirectory($install) | Out-Null + [IO.Directory]::CreateDirectory($target) | Out-Null + New-Item -ItemType Junction -Path (Join-Path $install 'runtime') -Target $target | Out-Null + $nodePath = New-CcodLifecycleFakeNode -Root $nodeRoot + Assert-CcodThrows { Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters (New-CcodLifecycleFake -NodePath $nodePath).Adapters | Out-Null } 'CCOD_INSTALL_FAILED' + Assert-CcodEqual 0 @(Get-ChildItem -LiteralPath $target -Force).Count 'runtime junction target receives no promoted runtime' + Assert-CcodTrue (-not (Test-Path -LiteralPath (Join-Path $install 'active.json'))) 'runtime junction cannot reach active pointer mutation' } finally { - if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } + $junction = Join-Path $install 'runtime' + if (Test-Path -LiteralPath $junction) { [IO.Directory]::Delete($junction) } + foreach ($path in @($source,$install,$target,$nodeRoot)) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force } } } } -# Production mutation caught: reducing a strict correlated Failed receipt to the generic activation-worker exit code. -$results += Invoke-CcodTest 'activation worker reports the strict Failed receipt support code without prompting' { - $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-activation-failed-report-' + [guid]::NewGuid().ToString('N')) +# Production mutation caught: recursive whole-install deletion bypassing the strict per-leaf hard-link contract. +$results += Invoke-CcodTest 'whole-install deletion rejects a nested hard-link before removing any in-tree leaf' { + $install = New-CcodLifecycleTempRoot + $outside = New-CcodLifecycleTempRoot try { - [IO.Directory]::CreateDirectory((Join-Path $root 'state')) | Out-Null - $promptMarker = Join-Path $root 'prompt-marker.txt' - $installScript = Join-Path $root 'Install-CodexControlOtherDevices.ps1' - $promptScript = Join-Path $root 'Prompt-CcodRestart.ps1' - $installSource = @" -param([string]`$InstallRoot,[string]`$ActivationId,[switch]`$EnableCandidateCompatibleUpdates) -`$receipt=[ordered]@{schemaVersion=1;activationId=`$ActivationId;phase='Failed';runtimeId='runtime-new';previousRuntimeId='runtime-old';startedAtUtc='2030-02-03T04:05:06.0000000Z';updatedAtUtc='2030-02-03T04:05:07.0000000Z';ready=`$false;errorCode='CCOD_INSTALL_NEW_RUNTIME_NOT_READY'} -[IO.File]::WriteAllText((Join-Path `$InstallRoot 'state\post-install-activation.json'),(`$receipt|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new(`$false)) -exit 1 -"@ - [IO.File]::WriteAllText($installScript,$installSource,[Text.UTF8Encoding]::new($false)) - [IO.File]::WriteAllText($promptScript,"[IO.File]::WriteAllText('$promptMarker','prompted');exit 0",[Text.UTF8Encoding]::new($false)) - $activationId = '77777777-6666-5555-4444-333333333333' - $output = @(& (Join-Path $repositoryRoot 'Activate-CcodRemoteFix.ps1') -AppRoot $root -InstallRoot $root -ActivationId $activationId -Prompt -NoUi 2>&1) - - Assert-CcodEqual 1 $LASTEXITCODE 'strict Failed receipt keeps the activation worker unsuccessful' - Assert-CcodTrue (-not (Test-Path -LiteralPath $promptMarker)) 'failed activation never reaches the restart prompt' - $log = Get-Content -LiteralPath (Join-Path $root 'logs\post-install-activation.log') -Raw -Encoding UTF8 - Assert-CcodTrue ($log -cmatch '"code":"CCOD_INSTALL_NEW_RUNTIME_NOT_READY"') 'activation log retains the strict bounded installer support code' - Assert-CcodTrue ($log -cnotmatch '"code":"CCOD_ACTIVATION_RUNTIME_FAILED"') 'strict Failed receipt is not hidden behind the generic child exit code' + $nested = Join-Path $install 'runtime\nested' + [IO.Directory]::CreateDirectory($nested) | Out-Null + [IO.Directory]::CreateDirectory($outside) | Out-Null + $sentinel = Join-Path $outside 'outside-sentinel.txt' + $linkedLeaf = Join-Path $nested 'linked-state.json' + $ordinaryLeaf = Join-Path $install 'ordinary-state.json' + [IO.File]::WriteAllText($sentinel,'outside-original',[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($ordinaryLeaf,'ordinary-original',[Text.UTF8Encoding]::new($false)) + New-Item -ItemType HardLink -Path $linkedLeaf -Target $sentinel | Out-Null + + $caughtId = $null + try { + $module = Get-Module InstallLifecycle + & $module { param($Root) Remove-CcodLifecycleInstallTree -InstallRoot $Root -Adapters @{} } $install + } catch { $caughtId = ([string]$_.FullyQualifiedErrorId -split ',')[0] } + + Assert-CcodEqual 'CCOD_INSTALL_UNSAFE_LEAF' $caughtId 'whole-install deletion rejects the nested hard-link before recursive removal' + Assert-CcodTrue ([IO.File]::Exists($linkedLeaf)) 'rejected delete retains the in-tree hard-link' + Assert-CcodEqual 'ordinary-original' ([IO.File]::ReadAllText($ordinaryLeaf,[Text.UTF8Encoding]::new($false))) 'preflight rejection retains other in-tree leaves' + Assert-CcodEqual 'outside-original' ([IO.File]::ReadAllText($sentinel,[Text.UTF8Encoding]::new($false))) 'preflight rejection retains outside sentinel bytes' } finally { - if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } + foreach ($path in @($install,$outside)) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force -ErrorAction SilentlyContinue } } } } -# Production mutation caught: accepting a caller-controlled activation id and persisting it into receipts or logs before canonical validation. -$results += Invoke-CcodTest 'install rejects a noncanonical caller ActivationId before receipt log or task mutation' { - $source=New-CcodLifecycleTempRoot;$install=New-CcodLifecycleTempRoot;$nodeRoot=New-CcodLifecycleTempRoot +$results += Invoke-CcodTest 'payload manifest ancestry treats Windows root equality case-insensitively' { + $source = New-CcodLifecycleTempRoot + $install = New-CcodLifecycleTempRoot + $nodeRoot = New-CcodLifecycleTempRoot try { - New-CcodLifecycleSourceFixture -Root $source -Version '2.5.0-invalid-activation-id'|Out-Null - $nodePath=New-CcodLifecycleFakeNode -Root $nodeRoot - $fake=New-CcodLifecycleFake -NodePath $nodePath - $failure=$null - try { Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -ActivationId 'ATTACKER_LOG_MARKER' -Adapters $fake.Adapters|Out-Null } catch { $failure=$_ } - Assert-CcodTrue ($null-ne$failure) 'noncanonical caller ActivationId fails closed' - Assert-CcodTrue ($failure.FullyQualifiedErrorId-like'CCOD_ACTIVATION_ID_INVALID*') 'invalid ActivationId returns the bounded stable code' - Assert-CcodEqual 0 $fake.World.Phases.Count 'invalid ActivationId writes no activation receipt' - Assert-CcodEqual 0 $fake.World.LogRecords.Count 'invalid ActivationId writes no install log' - Assert-CcodEqual 0 $fake.World.TaskInstalled 'invalid ActivationId performs no scheduled-task mutation' - } finally {foreach($path in @($source,$install,$nodeRoot)){if(Test-Path -LiteralPath $path){Remove-Item -LiteralPath $path -Recurse -Force}}} + New-CcodLifecycleSourceFixture -Root $source -Version '2.5.22' | Out-Null + $manifestPath = New-CcodLifecyclePayloadManifest -Root $source -Version '2.5.22' + $nodePath = New-CcodLifecycleFakeNode -Root $nodeRoot + $payloadBinding = Get-CcodLifecyclePayloadBinding -ManifestPath $manifestPath -ExpectedVersion '2.5.22' + $receipt = Invoke-CcodInstall -SourceRoot $source.ToUpperInvariant() -InstallRoot $install @payloadBinding -Adapters (New-CcodLifecycleFake -NodePath $nodePath).Adapters + Assert-CcodEqual 'Installed' $receipt.Outcome 'case-only source-root difference preserves valid Windows payload ancestry' + } finally { + foreach ($path in @($source,$install,$nodeRoot)) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force } } + } } -$results += Invoke-CcodTest 'first install stages verifies activates task and persists consent' { +$results += Invoke-CcodTest 'payload-bound install rejects a changed runtime manifest after readiness' { $source = New-CcodLifecycleTempRoot $install = New-CcodLifecycleTempRoot $nodeRoot = New-CcodLifecycleTempRoot try { - New-CcodLifecycleSourceFixture -Root $source | Out-Null + New-CcodLifecycleSourceFixture -Root $source -Version '2.5.22' | Out-Null + $manifestPath = New-CcodLifecyclePayloadManifest -Root $source -Version '2.5.22' $nodePath = New-CcodLifecycleFakeNode -Root $nodeRoot $fake = New-CcodLifecycleFake -NodePath $nodePath - $receipt = Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -EnableCandidateCompatibleUpdates -Adapters $fake.Adapters - Assert-CcodEqual 'Installed' $receipt.Outcome 'first install outcome' - Assert-CcodEqual $true $receipt.Installed 'first install flag' - Assert-CcodTrue (Test-Path -LiteralPath (Join-Path $install 'bootstrap.ps1') -PathType Leaf) 'stable bootstrap copied' - Assert-CcodTrue (Test-Path -LiteralPath (Join-Path $install 'active.json') -PathType Leaf) 'active pointer written' - Assert-CcodTrue (Test-Path -LiteralPath (Join-Path $install "runtime\$($receipt.RuntimeId)") -PathType Container) 'runtime staged' - Assert-CcodEqual 1 $fake.World.TaskInstalled 'task installed' - Assert-CcodEqual 1 $fake.World.TaskStarted 'task started' - Assert-CcodEqual $null $receipt.PreviousRuntimeId 'first install has no previous runtime' - $state = Read-CcodState -StateRoot (Join-Path $install 'state') - Assert-CcodEqual $true $state.Settings.candidateCompatibleOptIn 'explicit consent persisted' - Assert-CcodEqual $true $state.Settings.automationEnabled 'automation enabled on first install' - Assert-CcodEqual $nodePath $state.Settings.nodeCandidates[0] 'verified node candidate persisted' - $stateRoot = Join-Path $install 'state' - $preference = Read-CcodUiPreference -StateRoot $stateRoot - Assert-CcodEqual 'System' $preference.LanguageMode 'first install follows Windows' - Assert-CcodEqual $false $preference.FallbackUsed 'first install persisted preference' - $runtimeRoot = Join-Path $install "runtime\$($receipt.RuntimeId)" - Assert-CcodTrue (Test-Path -LiteralPath (Join-Path $runtimeRoot 'src\persistence\resources\ui.en-US.json') -PathType Leaf) 'English catalog staged' - Assert-CcodTrue (Test-Path -LiteralPath (Join-Path $runtimeRoot 'src\persistence\resources\ui.zh-CN.json') -PathType Leaf) 'Chinese catalog staged' - $manifest = Test-CcodRuntimeManifest -RuntimeDirectory $runtimeRoot -ExpectedRuntimeId $receipt.RuntimeId - Assert-CcodEqual $true $manifest.Valid 'runtime manifest validates staged resources' - $english = @($manifest.Manifest.files | Where-Object { $_.path -ceq 'src/persistence/resources/ui.en-US.json' }) - $chinese = @($manifest.Manifest.files | Where-Object { $_.path -ceq 'src/persistence/resources/ui.zh-CN.json' }) - Assert-CcodEqual 1 $english.Count 'manifest contains English catalog exactly once' - Assert-CcodEqual 1 $chinese.Count 'manifest contains Chinese catalog exactly once' - Assert-CcodEqual '662b6067a48cfaeb481ae1a35e02f09fa799fa6386d0f4d2c61c19874a152713' $english[0].sha256 'manifest hashes English catalog' - Assert-CcodEqual '5770fe0f20f1623648a185cc7a0a99ff37b6aef6c07426ffc8a984493e0f2a2f' $chinese[0].sha256 'manifest hashes Chinese catalog' + $fake.Adapters.WaitNewRuntimeReady = { + param($InstallRoot,$RuntimeId,$RuntimeGeneration,$Identity,$TaskStartedAtUtc,$TimeoutMilliseconds) + $runtimeManifestPath = Join-Path $InstallRoot "runtime\$RuntimeId\manifest.json" + $runtimeManifest = Get-Content -LiteralPath $runtimeManifestPath -Raw | ConvertFrom-Json + $runtimeManifest.projectVersion = '2.5.13' + [IO.File]::WriteAllText($runtimeManifestPath,($runtimeManifest|ConvertTo-Json -Depth 16),[Text.UTF8Encoding]::new($false)) + [pscustomobject][ordered]@{SupervisorReady=$true;TrayReady=$true} + }.GetNewClosure() + $payloadBinding = Get-CcodLifecyclePayloadBinding -ManifestPath $manifestPath -ExpectedVersion '2.5.22' + Assert-CcodThrows { + Invoke-CcodInstall -SourceRoot $source -InstallRoot $install @payloadBinding -Adapters $fake.Adapters | Out-Null + } 'CCOD_INSTALL_NEW_RUNTIME_NOT_READY' + Assert-CcodTrue ($fake.World.Phases -notcontains 'Ready') 'changed selected runtime manifest cannot produce Ready' } finally { - foreach ($path in @($source, $install, $nodeRoot)) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force } } + foreach ($path in @($source,$install,$nodeRoot)) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force } } } } @@ -681,7 +2169,7 @@ $results += Invoke-CcodTest 'hidden persistence module is staged and manifest-ha $receipt = Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters (New-CcodLifecycleFake -NodePath $nodePath).Adapters $runtimeRoot = Join-Path $install "runtime\$($receipt.RuntimeId)" $stagedModule = Join-Path $runtimeRoot 'src\persistence\modules\HiddenRuntime.psm1' - Assert-CcodTrue (Test-Path -LiteralPath $stagedModule -PathType Leaf) 'hidden module is staged' + Assert-CcodTrue (Test-Path -LiteralPath $stagedModule -PathType Leaf) 'hidden module is staged into the sealed generation' $manifest = Test-CcodRuntimeManifest -RuntimeDirectory $runtimeRoot -ExpectedRuntimeId $receipt.RuntimeId Assert-CcodEqual $true $manifest.Valid 'hidden module runtime manifest validates' $record = @($manifest.Manifest.files | Where-Object { $_.path -ceq 'src/persistence/modules/HiddenRuntime.psm1' }) @@ -706,6 +2194,7 @@ $results += Invoke-CcodTest 'upgrade retains one previous runtime and starts the Set-CcodUiLanguageMode -StateRoot $stateRoot -LanguageMode 'en-US' -Adapters @{ UtcNow = { [DateTimeOffset]::Parse('2030-02-03T03:04:06.0000000Z') } } | Out-Null $preferencePath = Join-Path $stateRoot 'ui-preferences.json' $preferenceBytes = [IO.File]::ReadAllBytes($preferencePath) + $operationalHashes=@{};foreach($leaf in @('settings.json','status.json','verified-packages.json','transition.json','ui-preferences.json')){$operationalHashes[$leaf]=Get-CcodTestFileSha256 -Path (Join-Path $stateRoot $leaf)} [IO.File]::WriteAllText((Join-Path $source 'src\runtime\main-payload.js'), "module.exports = 'fixture-v2';`n", [Text.UTF8Encoding]::new($false)) $fake2 = New-CcodLifecycleFake -NodePath $nodePath $second = Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters $fake2.Adapters @@ -725,6 +2214,8 @@ $results += Invoke-CcodTest 'upgrade retains one previous runtime and starts the Assert-CcodEqual $first.RuntimeId $pointer.previousRuntime 'previous points at old runtime' Assert-CcodEqual (($preferenceBytes | ForEach-Object { $_.ToString('x2') }) -join '') (([IO.File]::ReadAllBytes($preferencePath) | ForEach-Object { $_.ToString('x2') }) -join '') 'upgrade preserves valid UI preference bytes' Assert-CcodEqual 'en-US' (Read-CcodUiPreference -StateRoot $stateRoot).LanguageMode 'upgrade retains selected UI language' + $upgradeBaseline=Join-Path $stateRoot "install-initializations\$($second.RuntimeId)" + foreach($leaf in @('settings.json','status.json','verified-packages.json','transition.json','ui-preferences.json')){Assert-CcodTrue (Test-Path -LiteralPath (Join-Path $upgradeBaseline $leaf) -PathType Leaf) "upgrade baseline contains $leaf";Assert-CcodEqual $operationalHashes[$leaf] (Get-CcodTestFileSha256 -Path (Join-Path $stateRoot $leaf)) "upgrade does not overwrite operational $leaf"} $runtimeRoot = Join-Path $install 'runtime' $ids = @(Get-ChildItem -LiteralPath $runtimeRoot -Directory | ForEach-Object { $_.Name } | Sort-Object) Assert-CcodEqual (($ids -join '|')) ((@($first.RuntimeId, $second.RuntimeId) | Sort-Object) -join '|') 'only active and previous runtime remain' @@ -758,9 +2249,15 @@ $results += Invoke-CcodTest 'upgrade emits a strict ordered Ready receipt only a Assert-CcodTrue ($fake.World.NewSupervisorReady -and $fake.World.NewTrayReady) 'new Supervisor signal proves the authenticated TrayHost handshake completed' Assert-CcodEqual $expectedKeyHash (Get-FileHash -LiteralPath $deviceKey -Algorithm SHA256).Hash 'upgrade preserves device-key bytes' - $activationPath = Join-Path $install 'state\post-install-activation.json' - Assert-CcodTrue (Test-Path -LiteralPath $activationPath -PathType Leaf) 'activation receipt is durable' - $activation = Get-Content -LiteralPath $activationPath -Raw -Encoding UTF8 | ConvertFrom-Json + $activationRoot = Join-Path $install 'state\activation-receipts' + $matching = @(Get-ChildItem -LiteralPath $activationRoot -Filter '*.Ready.json' -File | ForEach-Object { + $value = Get-Content -LiteralPath $_.FullName -Raw -Encoding UTF8 | ConvertFrom-Json + if ([string]$value.runtimeId -ceq [string]$receipt.RuntimeId) { [pscustomobject]@{ Path=$_.FullName; Value=$value } } + }) + Assert-CcodEqual 1 $matching.Count 'exactly one create-only Ready activation receipt binds the upgraded runtime' + $activationPath = $matching[0].Path + $activation = $matching[0].Value + Assert-CcodTrue ([IO.Path]::GetFileName($activationPath) -ceq ("$($activation.activationId).Ready.json")) 'activation receipt filename binds its canonical activation id and phase' Assert-CcodEqual 'schemaVersion,activationId,phase,runtimeId,previousRuntimeId,startedAtUtc,updatedAtUtc,ready,errorCode' (($activation.PSObject.Properties.Name) -join ',') 'activation receipt has exact properties' Assert-CcodEqual 'Ready' $activation.phase 'terminal activation phase is Ready' Assert-CcodEqual $true $activation.ready 'Ready receipt is the only successful terminal state' @@ -783,7 +2280,7 @@ $results += Invoke-CcodTest 'new-runtime readiness accepts only the exact post-t $installed = Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters (New-CcodLifecycleFake -NodePath $nodePath).Adapters $identity = New-CcodLifecycleIdentity $taskStartedAt = [DateTime]::Parse('2030-02-03T03:04:05Z').ToUniversalTime() - $bootstrapPath = [IO.Path]::GetFullPath((Join-Path $install 'bootstrap.ps1')) + $bootstrapPath = [IO.Path]::GetFullPath((Join-Path $install "runtime\$($installed.RuntimeId)\src\persistence\bootstrap.ps1")) $supervisorPath = [IO.Path]::GetFullPath((Join-Path $install "runtime\$($installed.RuntimeId)\src\persistence\Supervisor.ps1")) $token = 'a' * 64 $snapshots = @( @@ -829,7 +2326,7 @@ $results += Invoke-CcodTest 'new-runtime readiness retries a temporary Ready eve $installed = Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters (New-CcodLifecycleFake -NodePath $nodePath).Adapters $identity = New-CcodLifecycleIdentity $started = [DateTime]::Parse('2030-02-03T03:04:05Z').ToUniversalTime() - $bootstrap = [IO.Path]::GetFullPath((Join-Path $install 'bootstrap.ps1')) + $bootstrap = [IO.Path]::GetFullPath((Join-Path $install "runtime\$($installed.RuntimeId)\src\persistence\bootstrap.ps1")) $supervisor = [IO.Path]::GetFullPath((Join-Path $install "runtime\$($installed.RuntimeId)\src\persistence\Supervisor.ps1")) $token = 'e' * 64 $items = @( @@ -886,7 +2383,7 @@ $results += Invoke-CcodTest 'new-runtime readiness rejects every mismatched or a $nodePath = New-CcodLifecycleFakeNode -Root $nodeRoot $installed = Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters (New-CcodLifecycleFake -NodePath $nodePath).Adapters $identity = New-CcodLifecycleIdentity;$started=[DateTime]::Parse('2030-02-03T03:04:05Z').ToUniversalTime() - $bootstrap=[IO.Path]::GetFullPath((Join-Path $install 'bootstrap.ps1'));$supervisor=[IO.Path]::GetFullPath((Join-Path $install "runtime\$($installed.RuntimeId)\src\persistence\Supervisor.ps1"));$token='b'*64 + $bootstrap=[IO.Path]::GetFullPath((Join-Path $install "runtime\$($installed.RuntimeId)\src\persistence\bootstrap.ps1"));$supervisor=[IO.Path]::GetFullPath((Join-Path $install "runtime\$($installed.RuntimeId)\src\persistence\Supervisor.ps1"));$token='b'*64 $newSnapshots = { $parent=[pscustomobject][ordered]@{ProcessId=600;ParentProcessId=1;SessionId=$identity.SessionId;CreationDate=[DateTime]::Parse('2030-02-03T03:04:05.1000000Z').ToUniversalTime();Name='powershell.exe';CommandLine="powershell.exe -NoProfile -ExecutionPolicy Bypass -STA -WindowStyle Hidden -File `"$bootstrap`" -InstallRoot `"$install`" -EntryMode Task"} $child=[pscustomobject][ordered]@{ProcessId=601;ParentProcessId=600;SessionId=$identity.SessionId;CreationDate=[DateTime]::Parse('2030-02-03T03:04:06.0000000Z').ToUniversalTime();Name='powershell.exe';CommandLine="powershell.exe -NoProfile -ExecutionPolicy Bypass -STA -File `"$supervisor`" -ReadyToken $token"} @@ -926,7 +2423,7 @@ $results += Invoke-CcodTest 'new-runtime readiness requires a live exact process try{ New-CcodLifecycleSourceFixture -Root $source -Version '2.5.0-ready-event'|Out-Null;$nodePath=New-CcodLifecycleFakeNode -Root $nodeRoot $installed=Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters (New-CcodLifecycleFake -NodePath $nodePath).Adapters - $identity=New-CcodLifecycleIdentity;$started=[DateTime]::Parse('2030-02-03T03:04:05Z').ToUniversalTime();$bootstrap=[IO.Path]::GetFullPath((Join-Path $install 'bootstrap.ps1'));$supervisor=[IO.Path]::GetFullPath((Join-Path $install "runtime\$($installed.RuntimeId)\src\persistence\Supervisor.ps1"));$token='d'*64 + $identity=New-CcodLifecycleIdentity;$started=[DateTime]::Parse('2030-02-03T03:04:05Z').ToUniversalTime();$bootstrap=[IO.Path]::GetFullPath((Join-Path $install "runtime\$($installed.RuntimeId)\src\persistence\bootstrap.ps1"));$supervisor=[IO.Path]::GetFullPath((Join-Path $install "runtime\$($installed.RuntimeId)\src\persistence\Supervisor.ps1"));$token='d'*64 $items=@([pscustomobject]@{ProcessId=700;ParentProcessId=1;SessionId=1;CreationDate=[DateTime]::Parse('2030-02-03T03:04:05.1Z').ToUniversalTime();Name='powershell.exe';CommandLine="powershell.exe -NoProfile -ExecutionPolicy Bypass -STA -WindowStyle Hidden -File `"$bootstrap`" -InstallRoot `"$install`" -EntryMode Task"},[pscustomobject]@{ProcessId=701;ParentProcessId=700;SessionId=1;CreationDate=[DateTime]::Parse('2030-02-03T03:04:06Z').ToUniversalTime();Name='powershell.exe';CommandLine="powershell.exe -NoProfile -ExecutionPolicy Bypass -STA -File `"$supervisor`" -ReadyToken $token"}) $cases=@( [pscustomobject]@{Name='TrayHost handshake remains unsignaled';OpenThrows=$false;AliveChecks=99;Signal=$false}, @@ -951,43 +2448,48 @@ $results += Invoke-CcodTest 'upgrade boundaries fail closed with phase receipts [pscustomobject]@{ Name='old task still running';Code='CCOD_INSTALL_SUPERVISOR_TASK_BUSY';Committed=$false;Configure={param($Fake)$Fake.World.TaskIdle=$false} }, [pscustomobject]@{ Name='stale lifecycle generation';Code='CCOD_LIFECYCLE_FENCE_STALE';Committed=$false;Configure={param($Fake)$Fake.World.SetActiveFailure={throw [Management.Automation.ErrorRecord]::new([InvalidOperationException]::new('stale'), 'CCOD_LIFECYCLE_FENCE_STALE', [Management.Automation.ErrorCategory]::InvalidData, $null)}} }, [pscustomobject]@{ Name='active pointer write failure';Code='CCOD_INSTALL_RUNTIME_ACTIVATION_UNPROVEN';Committed=$false;Configure={param($Fake)$Fake.World.SetActiveFailure={throw 'PRIVATE_POINTER_SECRET'}} }, - [pscustomobject]@{ Name='crash after generation commit';Code='CCOD_INSTALL_RUNTIME_ACTIVATION_UNPROVEN';Committed=$true;Configure={param($Fake)$Fake.World.SetActiveFailure={param($Root,$RuntimeId,$Ownership)$fence={param($InstallRoot,$Receipt,$ExpectActivePointer)$true};Set-CcodActiveRuntime -InstallRoot $Root -NewRuntimeId $RuntimeId -Ownership $Ownership -Adapters @{AssertLifecycleFence=$fence}|Out-Null;throw 'PRIVATE_AFTER_COMMIT_SECRET'}} }, - [pscustomobject]@{ Name='new task start failure';Code='CCOD_INSTALL_SUPERVISOR_START_FAILED';Committed=$true;Configure={param($Fake)$Fake.World.StartTaskFailure=$true} }, - [pscustomobject]@{ Name='Supervisor ready timeout';Code='CCOD_INSTALL_NEW_RUNTIME_NOT_READY';Committed=$true;Configure={param($Fake)$Fake.World.NewSupervisorReady=$false} }, - [pscustomobject]@{ Name='TrayHost ready timeout';Code='CCOD_INSTALL_NEW_RUNTIME_NOT_READY';Committed=$true;Configure={param($Fake)$Fake.World.NewTrayReady=$false} } + [pscustomobject]@{ Name='crash after generation commit';Code='CCOD_INSTALL_RUNTIME_ACTIVATION_UNPROVEN';Committed=$true;Configure={param($Fake,$OldRuntime)$gate=[pscustomobject]@{Remaining=1};$Fake.World.SetActiveFailure={param($Root,$RuntimeId,$Ownership,$TargetGeneration,$FileTransaction)if($gate.Remaining-gt0){$gate.Remaining--;$fence={param($InstallRoot,$Receipt,$ExpectActivePointer)$true};Invoke-CcodRuntimeCoreSetForTest -InstallRoot $Root -TargetGeneration $TargetGeneration -FileTransaction $FileTransaction -Ownership $Ownership -Adapters @{AssertLifecycleFence=$fence}|Out-Null;throw 'PRIVATE_AFTER_COMMIT_SECRET'}}.GetNewClosure()} }, + [pscustomobject]@{ Name='new task start failure';Code='CCOD_INSTALL_SUPERVISOR_START_FAILED';Committed=$true;Configure={param($Fake,$OldRuntime)$gate=[pscustomobject]@{Remaining=1};$Fake.Adapters.StartSupervisorTask={if($gate.Remaining-gt0){$gate.Remaining--;throw 'PRIVATE_TASK_START_SECRET'};$Fake.World.TaskStarted++}.GetNewClosure()} }, + [pscustomobject]@{ Name='Supervisor ready timeout';Code='CCOD_INSTALL_NEW_RUNTIME_NOT_READY';Committed=$true;Configure={param($Fake,$OldRuntime)$Fake.Adapters.WaitNewRuntimeReady={param($Root,$RuntimeId,$Generation,$Identity,$Started,$Timeout)[pscustomobject]@{SupervisorReady=([string]$RuntimeId-ceq[string]$OldRuntime);TrayReady=$true}}.GetNewClosure()} }, + [pscustomobject]@{ Name='TrayHost ready timeout';Code='CCOD_INSTALL_NEW_RUNTIME_NOT_READY';Committed=$true;Configure={param($Fake,$OldRuntime)$Fake.Adapters.WaitNewRuntimeReady={param($Root,$RuntimeId,$Generation,$Identity,$Started,$Timeout)[pscustomobject]@{SupervisorReady=$true;TrayReady=([string]$RuntimeId-ceq[string]$OldRuntime)}}.GetNewClosure()} }, + [pscustomobject]@{ Name='rollback readiness failure';Code='CCOD_INSTALL_ROLLBACK_FAILED';Committed=$true;Configure={param($Fake,$OldRuntime)$Fake.World.NewSupervisorReady=$false} } ) foreach ($case in $cases) { $source = New-CcodLifecycleTempRoot $install = New-CcodLifecycleTempRoot $nodeRoot = New-CcodLifecycleTempRoot try { - New-CcodLifecycleSourceFixture -Root $source -Version ('2.5.0-boundary-' + ($case.Name -replace '[^A-Za-z0-9]','-')) | Out-Null + New-CcodLifecycleSourceFixture -Root $source -Version '2.5.0-b' | Out-Null $nodePath = New-CcodLifecycleFakeNode -Root $nodeRoot $first = Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters (New-CcodLifecycleFake -NodePath $nodePath).Adapters Set-CcodLifecycleTestStatus -InstallRoot $install -RuntimeId $first.RuntimeId [IO.File]::WriteAllText((Join-Path $source 'src\runtime\main-payload.js'), "module.exports = 'boundary-$($case.Name)';`n", [Text.UTF8Encoding]::new($false)) $fake = New-CcodLifecycleFake -NodePath $nodePath - & $case.Configure $fake + & $case.Configure $fake $first.RuntimeId $failure = $null try { Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters $fake.Adapters | Out-Null } catch { $failure = $_ } Assert-CcodTrue ($null -ne $failure) "$($case.Name) fails closed" - Assert-CcodTrue ($failure.FullyQualifiedErrorId -like "$($case.Code)*") "$($case.Name) returns its stable support code" - $activation = Get-Content -LiteralPath (Join-Path $install 'state\post-install-activation.json') -Raw -Encoding UTF8 | ConvertFrom-Json + Assert-CcodTrue ($failure.FullyQualifiedErrorId -like "$($case.Code)*") "$($case.Name) returns its stable support code (actual=$($failure.FullyQualifiedErrorId); calls=$($fake.World.Calls -join ','))" + $failedReceipts = @(Get-ChildItem -LiteralPath (Join-Path $install 'state\activation-receipts') -Filter '*.Failed.json' -File | ForEach-Object { Get-Content -LiteralPath $_.FullName -Raw -Encoding UTF8 | ConvertFrom-Json }) + Assert-CcodEqual 1 $failedReceipts.Count "$($case.Name) writes one create-only Failed receipt" + $activation = $failedReceipts[0] Assert-CcodEqual 'Failed' $activation.phase "$($case.Name) writes Failed" Assert-CcodEqual $false $activation.ready "$($case.Name) never writes Ready" Assert-CcodEqual $case.Code $activation.errorCode "$($case.Name) persists the stable code" + $failedTransactions = @(Get-ChildItem -LiteralPath (Join-Path $install 'state\install-transactions') -Filter '*.Failed.*.json' -File | ForEach-Object { Get-Content -LiteralPath $_.FullName -Raw -Encoding UTF8 | ConvertFrom-Json } | Where-Object { [string]$_.newRuntimeId -cne [string]$first.RuntimeId }) + Assert-CcodEqual 1 $failedTransactions.Count "$($case.Name) writes one terminal transaction snapshot" + Assert-CcodEqual $case.Code $failedTransactions[0].errorCode "$($case.Name) binds the transaction head to the stable code" $pointer = Read-CcodLifecycleActivePointer -Root $install $runtimeIds = @(Get-ChildItem -LiteralPath (Join-Path $install 'runtime') -Directory | ForEach-Object Name) + Assert-CcodEqual 2 $runtimeIds.Count "$($case.Name) retains old and failed generations" if ($case.Committed) { - Assert-CcodTrue ($pointer.activeRuntime -cne $first.RuntimeId) "$($case.Name) keeps the committed new generation instead of regressing the pointer" - Assert-CcodEqual $first.RuntimeId $pointer.previousRuntime "$($case.Name) preserves the previous runtime for recovery" - Assert-CcodEqual 2 $runtimeIds.Count "$($case.Name) preserves both committed generations" + Assert-CcodEqual $first.RuntimeId $pointer.activeRuntime "$($case.Name) appends a compensating pointer to the retained old runtime" + Assert-CcodTrue ([uint64]$pointer.generation -ge 3) "$($case.Name) compensation advances rather than regresses the generation" } else { Assert-CcodEqual $first.RuntimeId $pointer.activeRuntime "$($case.Name) leaves the old pointer active before mutation" - Assert-CcodEqual 1 $runtimeIds.Count "$($case.Name) removes any uncommitted staged runtime" - Assert-CcodEqual $first.RuntimeId $runtimeIds[0] "$($case.Name) retains only the previous runtime" + Assert-CcodEqual 1 ([uint64]$pointer.generation) "$($case.Name) does not append a pre-pointer compensation" } } finally { foreach ($path in @($source, $install, $nodeRoot)) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force } } @@ -995,31 +2497,64 @@ $results += Invoke-CcodTest 'upgrade boundaries fail closed with phase receipts } } -# Production mutation caught: reporting a pre-generation upgrade failure after stopping the old runtime without first restarting and proving the still-active generation. -$results += Invoke-CcodTest 'pre-generation upgrade failure restores and proves previous runtime protection before Failed' { - $source=New-CcodLifecycleTempRoot;$install=New-CcodLifecycleTempRoot;$nodeRoot=New-CcodLifecycleTempRoot +# Production mutation caught: retaining the outer AccountTransition cleanup lease while starting the old Supervisor makes its bootstrap deadlock before readiness. +$results += Invoke-CcodTest 'pre-pointer rollback releases the outer cleanup lease before old Supervisor bootstrap' { + $source=New-CcodLifecycleTempRoot;$install=New-CcodLifecycleTempRoot;$nodeRoot=New-CcodLifecycleTempRoot;$markerRoot=New-CcodLifecycleTempRoot try { - New-CcodLifecycleSourceFixture -Root $source -Version '2.5.0-rollback-a'|Out-Null + New-CcodLifecycleSourceFixture -Root $source -Version '2.5.0-rollback-cleanup-lease-a' | Out-Null $nodePath=New-CcodLifecycleFakeNode -Root $nodeRoot $first=Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters (New-CcodLifecycleFake -NodePath $nodePath).Adapters Set-CcodLifecycleTestStatus -InstallRoot $install -RuntimeId $first.RuntimeId - [IO.File]::WriteAllText((Join-Path $source 'src\runtime\main-payload.js'),"module.exports='rollback-b';`n",[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText((Join-Path $source 'src\runtime\main-payload.js'),"module.exports='rollback-cleanup-lease-b';`n",[Text.UTF8Encoding]::new($false)) + $fake=New-CcodLifecycleFake -NodePath $nodePath - $fake.World.CopyOverride=[pscustomobject]@{Match='*Supervisor.ps1';Action={param($SourcePath,$DestinationPath)throw 'PRIVATE_PREGENERATION_COPY_FAILURE'}} - $originalWriter=$fake.Adapters.WriteActivationReceipt - $fake.Adapters.WriteActivationReceipt={param($Root,$Receipt)$fake.World.Calls.Add("Receipt:$($Receipt.phase)");&$originalWriter $Root $Receipt}.GetNewClosure() - $fake.Adapters.WaitNewRuntimeReady={param($Root,$Runtime,$Generation,$Identity,$Started,$Timeout)$fake.World.Calls.Add("WaitRollbackReady:${Runtime}:${Generation}:${Timeout}");[pscustomobject][ordered]@{SupervisorReady=$true;TrayReady=$true}}.GetNewClosure() + $world=[pscustomobject][ordered]@{ + PointerCommitted=$false + PreviousProtectionStopped=$false + BootstrapAttempted=$false + BootstrapMutexAcquired=$false + OldRuntimeOutcome='NotReady' + ChildExitCode=$null + ChildMutexOutcome=$null + ChildFailure=$null + } + $fake.World.SetActiveFailure={throw 'INJECTED_PRE_POINTER_COMMIT_FAILURE'} + $fake.Adapters.StartSupervisorTask={ + $probe=Invoke-CcodLifecycleRollbackBootstrapMutexProbe -MarkerRoot $markerRoot + $world.BootstrapAttempted=[bool]$probe.Attempted + $world.BootstrapMutexAcquired=[bool]$probe.MutexAcquired + $world.ChildExitCode=[int]$probe.ExitCode + $world.ChildMutexOutcome=$probe.Outcome + $world.ChildFailure=$probe.ChildFailure + }.GetNewClosure() + $fake.Adapters.WaitNewRuntimeReady={ + param($Root,$Runtime,$Generation,$Identity,$Started,$Timeout) + $ready=[string]$Runtime-ceq[string]$first.RuntimeId-and[UInt64]$Generation-eq[UInt64]1-and$world.BootstrapMutexAcquired + if($ready){$world.OldRuntimeOutcome='Ready'} + [pscustomobject][ordered]@{SupervisorReady=[bool]$ready;TrayReady=[bool]$ready} + }.GetNewClosure() - Assert-CcodThrows {Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters $fake.Adapters|Out-Null} 'CCOD_INSTALL_STAGING_FAILED' - [string[]]$calls=@($fake.World.Calls) - $close=[Array]::IndexOf($calls,'CloseShutdownGate');$lease=[Array]::IndexOf($calls,'ExitInstallLease');$start=[Array]::IndexOf($calls,'StartTask') - $wait=@($calls|Where-Object{$_-like"WaitRollbackReady:$($first.RuntimeId):1:*"})[0];$waitIndex=[Array]::IndexOf($calls,$wait);$failed=[Array]::IndexOf($calls,'Receipt:Failed') - Assert-CcodTrue ($close-ge0-and$lease-gt$close-and$start-gt$lease-and$waitIndex-gt$start-and$failed-gt$waitIndex) 'shutdown gate and install ownership release before old task restart, readiness proof, and Failed receipt' - Assert-CcodEqual 1 $fake.World.TaskStarted 'previous protection is restarted exactly once' + $failure=$null + try { Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters $fake.Adapters | Out-Null } catch { $failure=$_ } $pointer=Read-CcodLifecycleActivePointer -Root $install - Assert-CcodEqual $first.RuntimeId $pointer.activeRuntime 'rollback proof remains bound to the previous active runtime' - Assert-CcodEqual 1 ([UInt64]$pointer.generation) 'rollback proof remains bound to the previous active generation' - } finally {foreach($path in @($source,$install,$nodeRoot)){if(Test-Path -LiteralPath $path){Remove-Item -LiteralPath $path -Recurse -Force}}} + $world.PointerCommitted=([string]$pointer.activeRuntime-cne[string]$first.RuntimeId-or[UInt64]$pointer.generation-ne[UInt64]1) + $world.PreviousProtectionStopped=[bool]$fake.World.OldSupervisorExitProven + + Assert-CcodTrue ($null-ne$failure) 'injected SetActiveRuntime failure remains fail closed after rollback' + Assert-CcodEqual $false $world.PointerCommitted 'failure occurs before selector commit' + Assert-CcodTrue $world.PreviousProtectionStopped 'old protection was stopped' + Assert-CcodTrue $world.BootstrapAttempted 'rollback attempted old Supervisor bootstrap' + Assert-CcodTrue $world.BootstrapMutexAcquired "old bootstrap acquires AccountTransition before readiness (childExit=$($world.ChildExitCode); outcome=$($world.ChildMutexOutcome); childFailure=$($world.ChildFailure))" + Assert-CcodEqual 'Ready' $world.OldRuntimeOutcome 'old runtime is restored and proven ready' + Assert-CcodTrue ($failure.FullyQualifiedErrorId-like'CCOD_INSTALL_RUNTIME_ACTIVATION_UNPROVEN*') 'successful rollback preserves the original pre-pointer failure code' + } finally { + foreach($path in @($source,$install,$nodeRoot,$markerRoot)){if(Test-Path -LiteralPath $path){Remove-Item -LiteralPath $path -Recurse -Force}} + } +} + +$results += Invoke-CcodTest 'compensation refuses a replaced old manifest using the transaction-recorded hash' { + $source=New-CcodLifecycleTempRoot;$install=New-CcodLifecycleTempRoot;$nodeRoot=New-CcodLifecycleTempRoot + try{New-CcodLifecycleSourceFixture -Root $source -Version '2.5.22-old-manifest'|Out-Null;$node=New-CcodLifecycleFakeNode -Root $nodeRoot;$first=Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters (New-CcodLifecycleFake -NodePath $node).Adapters;Set-CcodLifecycleTestStatus -InstallRoot $install -RuntimeId $first.RuntimeId;[IO.File]::WriteAllText((Join-Path $source 'src\runtime\main-payload.js'),"module.exports='upgrade';`n",[Text.UTF8Encoding]::new($false));$oldManifest=Join-Path $install "runtime\$($first.RuntimeId)\manifest.json";$fake=New-CcodLifecycleFake -NodePath $node;$changed=[pscustomobject]@{Done=$false};$fake.Adapters.WaitNewRuntimeReady={param($Root,$Runtime,$Generation,$Identity,$Started,$Timeout)if(-not$changed.Done){$changed.Done=$true;[IO.File]::SetAttributes($oldManifest,[IO.FileAttributes]::Normal);[IO.File]::AppendAllText($oldManifest,' ',[Text.UTF8Encoding]::new($false))};[pscustomobject]@{SupervisorReady=$false;TrayReady=$false}}.GetNewClosure();Assert-CcodThrows {Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters $fake.Adapters|Out-Null} 'CCOD_INSTALL_ROLLBACK_FAILED';$pointer=Read-CcodActiveRuntime -InstallRoot $install;Assert-CcodTrue ($pointer.activeRuntime-cne$first.RuntimeId) 'replaced old manifest cannot receive a compensating pointer';Assert-CcodEqual 2 $pointer.generation 'failed old-manifest proof leaves the committed new pointer generation';Assert-CcodEqual 2 @(Get-ChildItem -LiteralPath (Join-Path $install 'runtime') -Directory).Count 'both generations remain retained'}finally{foreach($p in @($source,$install,$nodeRoot)){if(Test-Path $p){Remove-Item $p -Recurse -Force}}} } # Production mutation caught: setting previousProtectionStopped only after task-idle proof, which skips rollback when exact Supervisor exit succeeds but IgnoreNew remains busy. @@ -1033,7 +2568,7 @@ $results += Invoke-CcodTest 'task-idle failure restarts and proves the old activ [IO.File]::WriteAllText((Join-Path $source 'src\runtime\main-payload.js'),"module.exports='task-idle-rollback-b';`n",[Text.UTF8Encoding]::new($false)) $fake=New-CcodLifecycleFake -NodePath $nodePath;$fake.World.TaskIdle=$false $originalWriter=$fake.Adapters.WriteActivationReceipt - $fake.Adapters.WriteActivationReceipt={param($Root,$Receipt)$fake.World.Calls.Add("Receipt:$($Receipt.phase)");&$originalWriter $Root $Receipt}.GetNewClosure() + $fake.Adapters.WriteActivationReceipt={param($Root,$Receipt,$FileTransaction)$fake.World.Calls.Add("Receipt:$($Receipt.phase)");&$originalWriter $Root $Receipt $FileTransaction}.GetNewClosure() $fake.Adapters.WaitNewRuntimeReady={param($Root,$Runtime,$Generation,$Identity,$Started,$Timeout)$fake.World.Calls.Add("WaitRollbackReady:${Runtime}:${Generation}:${Timeout}");[pscustomobject][ordered]@{SupervisorReady=$true;TrayReady=$true}}.GetNewClosure() Assert-CcodThrows {Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters $fake.Adapters|Out-Null} 'CCOD_INSTALL_SUPERVISOR_TASK_BUSY' @@ -1048,28 +2583,8 @@ $results += Invoke-CcodTest 'task-idle failure restarts and proves the old activ } finally {foreach($path in @($source,$install,$nodeRoot)){if(Test-Path -LiteralPath $path){Remove-Item -LiteralPath $path -Recurse -Force}}} } -$results += Invoke-CcodTest 'pre-generation rollback proof failure reports a bounded rollback code and preserves the old generation' { - $source=New-CcodLifecycleTempRoot;$install=New-CcodLifecycleTempRoot;$nodeRoot=New-CcodLifecycleTempRoot - try{ - New-CcodLifecycleSourceFixture -Root $source -Version '2.5.0-rollback-proof-a'|Out-Null;$nodePath=New-CcodLifecycleFakeNode -Root $nodeRoot - $first=Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters (New-CcodLifecycleFake -NodePath $nodePath).Adapters - Set-CcodLifecycleTestStatus -InstallRoot $install -RuntimeId $first.RuntimeId - [IO.File]::WriteAllText((Join-Path $source 'src\runtime\main-payload.js'),"module.exports='rollback-proof-b';`n",[Text.UTF8Encoding]::new($false)) - $fake=New-CcodLifecycleFake -NodePath $nodePath;$fake.World.StartTaskFailure=$true - $fake.World.CopyOverride=[pscustomobject]@{Match='*Supervisor.ps1';Action={param($SourcePath,$DestinationPath)throw 'PRIVATE_PREGENERATION_COPY_FAILURE'}} - - Assert-CcodThrows {Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters $fake.Adapters|Out-Null} 'CCOD_INSTALL_ROLLBACK_FAILED' - $activation=Get-Content -LiteralPath (Join-Path $install 'state\post-install-activation.json') -Raw -Encoding UTF8|ConvertFrom-Json - Assert-CcodEqual 'Failed' $activation.phase 'unproven rollback remains terminally Failed' - Assert-CcodEqual 'CCOD_INSTALL_ROLLBACK_FAILED' $activation.errorCode 'unproven rollback exposes only the bounded support code' - $pointer=Read-CcodLifecycleActivePointer -Root $install - Assert-CcodEqual $first.RuntimeId $pointer.activeRuntime 'unproven rollback never mutates the previous active pointer' - Assert-CcodEqual 1 ([UInt64]$pointer.generation) 'unproven rollback never regresses or advances generation' - }finally{foreach($path in @($source,$install,$nodeRoot)){if(Test-Path -LiteralPath $path){Remove-Item -LiteralPath $path -Recurse -Force}}} -} - -# Production mutation caught: removing superseded runtime directories before readiness or retaining them after a verified Ready transition. -$results += Invoke-CcodTest 'old runtime cleanup occurs only after combined readiness succeeds' { +# Production mutation caught: physically deleting retained generations after readiness. +$results += Invoke-CcodTest 'old and unknown runtime generations remain retained after Ready and failure' { $source=New-CcodLifecycleTempRoot;$install=New-CcodLifecycleTempRoot;$nodeRoot=New-CcodLifecycleTempRoot try{ New-CcodLifecycleSourceFixture -Root $source -Version '2.5.0-cleanup-a'|Out-Null;$nodePath=New-CcodLifecycleFakeNode -Root $nodeRoot @@ -1081,42 +2596,18 @@ $results += Invoke-CcodTest 'old runtime cleanup occurs only after combined read $readyFake.Adapters.WaitNewRuntimeReady={param($Root,$Runtime,$Generation,$Identity,$Started,$Timeout)$cleanupWorld.ObservedBeforeReady=Test-Path -LiteralPath $orphan -PathType Container;[pscustomobject][ordered]@{SupervisorReady=$true;TrayReady=$true}}.GetNewClosure() $second=Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters $readyFake.Adapters Assert-CcodTrue $cleanupWorld.ObservedBeforeReady 'superseded runtime still exists while readiness is being proven' - Assert-CcodTrue (-not(Test-Path -LiteralPath $orphan)) 'superseded runtime is cleaned only after Ready' + Assert-CcodTrue (Test-Path -LiteralPath $orphan -PathType Container) 'Ready retains unknown runtime directories for separate maintenance' Set-CcodLifecycleTestStatus -InstallRoot $install -RuntimeId $second.RuntimeId $orphanAfterFailure=Join-Path $install 'runtime\superseded-after-failure';[IO.Directory]::CreateDirectory($orphanAfterFailure)|Out-Null [IO.File]::WriteAllText((Join-Path $source 'src\runtime\main-payload.js'),"module.exports='cleanup-timeout';`n",[Text.UTF8Encoding]::new($false)) $blocked=New-CcodLifecycleFake -NodePath $nodePath;$blocked.World.NewTrayReady=$false - Assert-CcodThrows {Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters $blocked.Adapters|Out-Null} 'CCOD_INSTALL_NEW_RUNTIME_NOT_READY' + Assert-CcodThrows {Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters $blocked.Adapters|Out-Null} 'CCOD_INSTALL_ROLLBACK_FAILED' Assert-CcodTrue (Test-Path -LiteralPath $orphanAfterFailure -PathType Container) 'readiness failure never performs old-runtime cleanup' }finally{foreach($path in @($source,$install,$nodeRoot)){if(Test-Path -LiteralPath $path){Remove-Item -LiteralPath $path -Recurse -Force}}} } # Production mutation caught: allowing a best-effort post-Ready cleanup exception to enter activation failure handling and overwrite the terminal Ready receipt. -$results += Invoke-CcodTest 'post-Ready cleanup failure retains Ready and the undeleted old runtime' { - $source=New-CcodLifecycleTempRoot;$install=New-CcodLifecycleTempRoot;$nodeRoot=New-CcodLifecycleTempRoot;$lock=$null - try{ - New-CcodLifecycleSourceFixture -Root $source -Version '2.5.0-cleanup-failure-a'|Out-Null;$nodePath=New-CcodLifecycleFakeNode -Root $nodeRoot - $first=Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters (New-CcodLifecycleFake -NodePath $nodePath).Adapters - Set-CcodLifecycleTestStatus -InstallRoot $install -RuntimeId $first.RuntimeId - $orphan=Join-Path $install 'runtime\superseded-locked';[IO.Directory]::CreateDirectory($orphan)|Out-Null - $lockedPath=Join-Path $orphan 'locked.bin';$lock=[IO.File]::Open($lockedPath,[IO.FileMode]::CreateNew,[IO.FileAccess]::ReadWrite,[IO.FileShare]::None) - [IO.File]::WriteAllText((Join-Path $source 'src\runtime\main-payload.js'),"module.exports='cleanup-failure-b';`n",[Text.UTF8Encoding]::new($false)) - $fake=New-CcodLifecycleFake -NodePath $nodePath - - $second=Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters $fake.Adapters - Assert-CcodEqual 'Upgraded' $second.Outcome 'cleanup failure does not invalidate the verified upgrade' - $activation=Get-Content -LiteralPath (Join-Path $install 'state\post-install-activation.json') -Raw -Encoding UTF8|ConvertFrom-Json - Assert-CcodEqual 'Ready' $activation.phase 'cleanup failure cannot overwrite Ready with Failed' - Assert-CcodEqual $true $activation.ready 'terminal readiness remains successful' - Assert-CcodTrue (Test-Path -LiteralPath $orphan -PathType Container) 'cleanup failure retains the undeleted old runtime' - Assert-CcodEqual 1 @($fake.World.LogRecords|Where-Object{$_.code-ceq'CCOD_INSTALL_OLD_RUNTIME_CLEANUP_FAILED'}).Count 'cleanup failure leaves one safe diagnostic record' - }finally{ - if($null-ne$lock){$lock.Dispose()} - foreach($path in @($source,$install,$nodeRoot)){if(Test-Path -LiteralPath $path){Remove-Item -LiteralPath $path -Recurse -Force}} - } -} - # Production mutation caught: silently losing a completion-log failure or allowing any post-Ready diagnostic path to overwrite Ready. $results += Invoke-CcodTest 'post-Ready completion-log failure stays Ready retains previous runtime and emits a bounded diagnostic' { $source=New-CcodLifecycleTempRoot;$install=New-CcodLifecycleTempRoot;$nodeRoot=New-CcodLifecycleTempRoot @@ -1129,7 +2620,7 @@ $results += Invoke-CcodTest 'post-Ready completion-log failure stays Ready retai $second=Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters $fake.Adapters Assert-CcodEqual 'Upgraded' $second.Outcome 'post-Ready log failure does not invalidate the verified upgrade' - $activation=Get-Content -LiteralPath (Join-Path $install 'state\post-install-activation.json') -Raw -Encoding UTF8|ConvertFrom-Json + $activation=@(Get-ChildItem -LiteralPath (Join-Path $install 'state\activation-receipts') -Filter '*.Ready.json' -File|ForEach-Object{Get-Content -LiteralPath $_.FullName -Raw -Encoding UTF8|ConvertFrom-Json}|Where-Object{$_.runtimeId-ceq$second.RuntimeId})[0] Assert-CcodEqual 'Ready' $activation.phase 'post-Ready log failure cannot overwrite Ready' Assert-CcodEqual $true $activation.ready 'post-Ready log failure preserves successful readiness' Assert-CcodTrue (Test-Path -LiteralPath (Join-Path $install "runtime\$($first.RuntimeId)") -PathType Container) 'post-Ready log failure retains the previous runtime' @@ -1171,62 +2662,114 @@ $results += Invoke-CcodTest 'upgrade stops the old owner and task before acquiri }finally{foreach($path in @($source,$install,$nodeRoot)){if(Test-Path -LiteralPath $path){Remove-Item -LiteralPath $path -Recurse -Force}}} } -$results += Invoke-CcodTest 'staging copy failure fails closed before activation' { +$results += Invoke-CcodTest 'sealed-source copy failure retains the candidate and leaves pointer and legacy shells unchanged' { $source = New-CcodLifecycleTempRoot $install = New-CcodLifecycleTempRoot $nodeRoot = New-CcodLifecycleTempRoot + $lockHolder = [pscustomobject]@{Stream=$null} try { - New-CcodLifecycleSourceFixture -Root $source | Out-Null + New-CcodLifecycleSourceFixture -Root $source -Version '2.5.22' | Out-Null + $manifestPath=New-CcodLifecyclePayloadManifest -Root $source -Version '2.5.22';$payloadBinding=Get-CcodLifecyclePayloadBinding -ManifestPath $manifestPath -ExpectedVersion '2.5.22' + [IO.Directory]::CreateDirectory($install) | Out-Null + $legacyBootstrap=Join-Path $install 'bootstrap.ps1';$legacyUninstaller=Join-Path $install 'Uninstall-CodexControlOtherDevices.ps1' + [IO.File]::WriteAllText($legacyBootstrap,'legacy-bootstrap',[Text.UTF8Encoding]::new($false));[IO.File]::WriteAllText($legacyUninstaller,'legacy-uninstaller',[Text.UTF8Encoding]::new($false)) + $bootstrapHash=Get-CcodTestFileSha256 $legacyBootstrap;$uninstallerHash=Get-CcodTestFileSha256 $legacyUninstaller $nodePath = New-CcodLifecycleFakeNode -Root $nodeRoot $fake = New-CcodLifecycleFake -NodePath $nodePath - $fake.World.CopyOverride = [pscustomobject]@{ Match = '*Supervisor.ps1'; Action = { param($Source, $Destination) throw 'PRIVATE_COPY_SECRET' } } - Assert-CcodThrows { Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters $fake.Adapters } 'CCOD_INSTALL_STAGING_FAILED' - Assert-CcodTrue (-not (Test-Path -LiteralPath (Join-Path $install 'active.json'))) 'staging failure never writes active pointer' - Assert-CcodTrue (-not (Test-Path -LiteralPath (Join-Path $install 'bootstrap.ps1'))) 'staging failure never writes bootstrap' - Assert-CcodEqual 0 (Get-ChildItem -LiteralPath $install -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -eq '.staging' }).Count 'staging directory cleaned' + $fake.Adapters.ValidateSource={param($Root)$lockHolder.Stream=[IO.File]::Open((Join-Path $Root 'src\persistence\Supervisor.ps1'),[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::None);$true}.GetNewClosure() + $failure=$null;try{Invoke-CcodInstall -SourceRoot $source -InstallRoot $install @payloadBinding -Adapters $fake.Adapters|Out-Null}catch{$failure=$_} + Assert-CcodTrue ($null-ne$failure) 'sealed source lock fails the immutable copy' + Assert-CcodTrue ($failure.FullyQualifiedErrorId-like'CCOD_INSTALL_*') 'sealed source copy failure is bounded' + Assert-CcodEqual $false (Test-Path -LiteralPath (Join-Path $install 'active.json')) 'sealed source failure never writes legacy active pointer' + Assert-CcodEqual $false (Test-Path -LiteralPath (Join-Path $install 'state\active-generation')) 'sealed source failure occurs before append-only pointer commit' + Assert-CcodEqual $bootstrapHash (Get-CcodTestFileSha256 $legacyBootstrap) 'sealed source failure leaves legacy bootstrap bytes unchanged' + Assert-CcodEqual $uninstallerHash (Get-CcodTestFileSha256 $legacyUninstaller) 'sealed source failure leaves legacy uninstaller bytes unchanged' + Assert-CcodEqual 1 @(Get-ChildItem -LiteralPath (Join-Path $install 'runtime') -Directory -ErrorAction SilentlyContinue).Count 'failed immutable candidate is retained for diagnosis' } finally { + if($null-ne$lockHolder.Stream){$lockHolder.Stream.Dispose()} foreach ($path in @($source, $install, $nodeRoot)) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force } } } } -$results += Invoke-CcodTest 'manifest hash mismatch fails closed and cleans staging' { - $source = New-CcodLifecycleTempRoot - $install = New-CcodLifecycleTempRoot - $nodeRoot = New-CcodLifecycleTempRoot +$results += Invoke-CcodTest 'final transaction snapshot failure leaves Ready receipt and recoverable nonterminal without Failed ambiguity' { + $source=New-CcodLifecycleTempRoot;$install=New-CcodLifecycleTempRoot;$nodeRoot=New-CcodLifecycleTempRoot + try{New-CcodLifecycleSourceFixture -Root $source -Version '2.5.22-final-gap'|Out-Null;$node=New-CcodLifecycleFakeNode -Root $nodeRoot;$fake=New-CcodLifecycleFake -NodePath $node;$fake.Adapters.CommitReadyTransaction={throw 'PRIVATE_FINAL_SNAPSHOT_FAILURE'};$failure=$null;try{Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters $fake.Adapters|Out-Null}catch{$failure=$_};Assert-CcodTrue ($failure.FullyQualifiedErrorId-like'CCOD_INSTALL_READY_FINALIZATION_PENDING*') 'final snapshot failure reports recoverable pending status';$ready=@(Get-ChildItem -LiteralPath (Join-Path $install 'state\activation-receipts') -Filter '*.Ready.json' -File);$failed=@(Get-ChildItem -LiteralPath (Join-Path $install 'state\activation-receipts') -Filter '*.Failed.json' -File);Assert-CcodEqual 1 $ready.Count 'Ready activation receipt remains visible exactly once';Assert-CcodEqual 0 $failed.Count 'no contradictory Failed activation receipt is appended';$transactionFailed=@(Get-ChildItem -LiteralPath (Join-Path $install 'state\install-transactions') -Filter '*.Failed.*.json' -File);$transactionReady=@(Get-ChildItem -LiteralPath (Join-Path $install 'state\install-transactions') -Filter '*.Ready.*.json' -File);Assert-CcodEqual 0 $transactionFailed.Count 'no Failed transaction snapshot follows Ready receipt';Assert-CcodEqual 0 $transactionReady.Count 'missing final transaction snapshot remains absent for recovery';$module=Get-Module InstallLifecycle;$head=&$module {param($Root)Read-CcodInstallTransactionRecord -InstallRoot $Root} $install;Assert-CcodEqual 'ProtectionReady' $head.phase 'transaction remains recoverable at ProtectionReady'}finally{foreach($p in @($source,$install,$nodeRoot)){if(Test-Path $p){Remove-Item $p -Recurse -Force}}} +} + +# Production mutation caught: treating every nonterminal transaction as permanently busy instead of finalizing an already-proven Ready activation. +$results += Invoke-CcodTest 'a later invocation recovers one missing Ready transaction snapshot without another install mutation' { + $fixture=New-CcodReadyFinalizationGapFixture try { - New-CcodLifecycleSourceFixture -Root $source | Out-Null - $nodePath = New-CcodLifecycleFakeNode -Root $nodeRoot - $fake = New-CcodLifecycleFake -NodePath $nodePath - $fake.World.CopyOverride = [pscustomobject]@{ Match = '*SessionController.ps1'; Action = { param($Source, $Destination) [IO.Directory]::CreateDirectory((Split-Path $Destination -Parent)) | Out-Null; [IO.File]::WriteAllText($Destination, 'tampered', [Text.UTF8Encoding]::new($false)) } } - Assert-CcodThrows { Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters $fake.Adapters } 'CCOD_INSTALL_FILE_HASH_MISMATCH' - Assert-CcodTrue (-not (Test-Path -LiteralPath (Join-Path $install 'active.json'))) 'hash mismatch never activates' - Assert-CcodEqual 0 (Get-ChildItem -LiteralPath $install -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -eq '.staging' }).Count 'hash mismatch cleans staging' - } finally { - foreach ($path in @($source, $install, $nodeRoot)) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force } } - } + $runtimeCount=@(Get-ChildItem -LiteralPath (Join-Path $fixture.Install 'runtime') -Directory -Force).Count + $pointerCount=@(Get-ChildItem -LiteralPath (Join-Path $fixture.Install 'state\active-generation') -File -Force).Count + Remove-Item -LiteralPath $fixture.Source -Recurse -Force + $recoveryFake=New-CcodLifecycleFake -NodePath $fixture.Node + $result=Invoke-CcodInstall -SourceRoot $fixture.Source -InstallRoot $fixture.Install -Adapters $recoveryFake.Adapters + Assert-CcodEqual 'Recovered' $result.Outcome 'finalization recovery reports a bounded recovered outcome' + Assert-CcodEqual 1 $recoveryFake.World.ProductRegistrationCalls 'Ready-finalization recovery completes post-Ready product registration exactly once' + Assert-CcodEqual $fixture.Transaction.newRuntimeId $result.RuntimeId 'recovery result binds the pending transaction runtime' + Assert-CcodEqual $runtimeCount @(Get-ChildItem -LiteralPath (Join-Path $fixture.Install 'runtime') -Directory -Force).Count 'recovery creates no runtime generation' + Assert-CcodEqual $pointerCount @(Get-ChildItem -LiteralPath (Join-Path $fixture.Install 'state\active-generation') -File -Force).Count 'recovery appends no active pointer' + Assert-CcodEqual 1 @(Get-ChildItem -LiteralPath (Join-Path $fixture.Install 'state\install-transactions') -Filter '*.Ready.*.json' -File -Force).Count 'recovery appends exactly one Ready transaction snapshot' + Assert-CcodEqual 0 @(Get-ChildItem -LiteralPath (Join-Path $fixture.Install 'state\install-transactions') -Filter '*.Failed.*.json' -File -Force).Count 'recovery appends no Failed transaction snapshot' + Assert-CcodEqual 0 @(Get-ChildItem -LiteralPath (Join-Path $fixture.Install 'state\activation-receipts') -Filter '*.Failed.json' -File -Force).Count 'recovery appends no Failed activation receipt' + $module=Get-Module InstallLifecycle -ErrorAction Stop + $head=&$module {param($Root,$Id)Read-CcodInstallTransactionRecord -InstallRoot $Root -TransactionId $Id} $fixture.Install $fixture.Transaction.transactionId + Assert-CcodEqual 'Ready' $head.phase 'the original transaction alone becomes terminal Ready' + Assert-CcodEqual 0 $recoveryFake.World.TaskInstalled 'recovery does not reinstall the scheduled task' + Assert-CcodEqual 0 $recoveryFake.World.TaskStarted 'recovery does not restart the product' + Assert-CcodEqual 0 @($recoveryFake.World.Calls|Where-Object{$_-like'Validate:*'}).Count 'state-only recovery does not depend on the vanished source checkout' + } finally { Remove-CcodReadyFinalizationGapFixture $fixture } } -$results += Invoke-CcodTest 'selected UI catalog mutation after inventory fails hash verification before activation' { - $source = New-CcodLifecycleTempRoot - $install = New-CcodLifecycleTempRoot - $nodeRoot = New-CcodLifecycleTempRoot +$results += Invoke-CcodTest 'registration failure during Ready-finalization recovery is retried by a later same-package invocation' { + $fixture=New-CcodReadyFinalizationGapFixture + try{ + Remove-Item -LiteralPath $fixture.Source -Recurse -Force + $failed=New-CcodLifecycleFake -NodePath $fixture.Node;$failed.World.ProductRegistrationFailure=$true + Assert-CcodThrows {Invoke-CcodInstall -SourceRoot $fixture.Source -InstallRoot $fixture.Install -Adapters $failed.Adapters|Out-Null} 'CCOD_PRODUCT_REGISTRATION_FAILED' + $module=Get-Module InstallLifecycle;$ready=&$module {param($Root,$Id)Read-CcodInstallTransactionRecord -InstallRoot $Root -TransactionId $Id} $fixture.Install $fixture.Transaction.transactionId + Assert-CcodEqual 'Ready' $ready.phase 'recovery registration failure leaves the recovered transaction Ready' + New-CcodLifecycleSourceFixture -Root $fixture.Source -Version '2.5.22'|Out-Null + $retry=New-CcodLifecycleFake -NodePath $fixture.Node + $result=Invoke-CcodInstall -SourceRoot $fixture.Source -InstallRoot $fixture.Install -SealedPackageSha256 ([string]$ready.sealedPackageSha256) -Adapters $retry.Adapters + Assert-CcodEqual 'AlreadyInstalled' $result.Outcome 'same-package invocation reconciles registration after recovery failure' + Assert-CcodEqual 1 $retry.World.ProductRegistrationCalls 'recovery registration retry occurs exactly once' + }finally{foreach($path in @($fixture.Source,$fixture.Install,$fixture.NodeRoot)){if(Test-Path $path){Remove-Item $path -Recurse -Force}}} +} + +# Production mutation caught: finalizing ProtectionReady when any receipt, selected pointer, or recorded manifest proof no longer matches. +$results += Invoke-CcodTest 'invalid Ready receipt pointer or manifest leaves ProtectionReady without runtime pointer or Failed writes' { + $fixture=New-CcodReadyFinalizationGapFixture + $caseRoots=[Collections.Generic.List[string]]::new() try { - New-CcodLifecycleSourceFixture -Root $source | Out-Null - $nodePath = New-CcodLifecycleFakeNode -Root $nodeRoot - $fake = New-CcodLifecycleFake -NodePath $nodePath - $fake.World.CopyOverride = [pscustomobject]@{ - Match = '*ui.en-US.json' - Action = { - param($Source, $Destination) - [IO.Directory]::CreateDirectory((Split-Path $Destination -Parent)) | Out-Null - [IO.File]::Copy($Source, $Destination, $true) - [IO.File]::WriteAllText($Source, '{"schemaVersion":1,"language":"tampered-after-inventory"}', [Text.UTF8Encoding]::new($false)) - } + Remove-Item -LiteralPath $fixture.Source -Recurse -Force + foreach($case in @( + @{Name='receipt';Mutate={param($Root,$Transaction)$path=@(Get-ChildItem -LiteralPath (Join-Path $Root 'state\activation-receipts') -Filter '*.Ready.json' -File)[0].FullName;[IO.File]::SetAttributes($path,[IO.FileAttributes]::Normal);[IO.File]::WriteAllText($path,'{',[Text.UTF8Encoding]::new($false))}}, + @{Name='pointer';Mutate={param($Root,$Transaction)$path=@(Get-ChildItem -LiteralPath (Join-Path $Root 'state\active-generation') -Filter '*.json' -File|Sort-Object Name)[-1].FullName;$record=Get-Content -LiteralPath $path -Raw|ConvertFrom-Json;$replacement=if($record.activeRuntime.EndsWith('0')){'1'}else{'0'};$record.activeRuntime=$record.activeRuntime.Substring(0,$record.activeRuntime.Length-1)+$replacement;[IO.File]::SetAttributes($path,[IO.FileAttributes]::Normal);[IO.File]::WriteAllText($path,($record|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new($false))}}, + @{Name='manifest';Mutate={param($Root,$Transaction)$path=Join-Path $Root "runtime\$($Transaction.newRuntimeId)\manifest.json";[IO.File]::SetAttributes($path,[IO.FileAttributes]::Normal);[IO.File]::AppendAllText($path,"`n",[Text.UTF8Encoding]::new($false))}} + )) { + $caseRoot=New-CcodLifecycleTempRoot + $caseRoots.Add($caseRoot) + Copy-Item -LiteralPath $fixture.Install -Destination $caseRoot -Recurse -Force + $runtimeCount=@(Get-ChildItem -LiteralPath (Join-Path $caseRoot 'runtime') -Directory -Force).Count + $pointerCount=@(Get-ChildItem -LiteralPath (Join-Path $caseRoot 'state\active-generation') -File -Force).Count + & $case.Mutate $caseRoot $fixture.Transaction + $failure=$null + try { Invoke-CcodInstall -SourceRoot $fixture.Source -InstallRoot $caseRoot -Adapters (New-CcodLifecycleFake -NodePath $fixture.Node).Adapters | Out-Null } catch { $failure=$_ } + Assert-CcodTrue ($null-ne$failure-and$failure.FullyQualifiedErrorId-like'CCOD_INSTALL_READY_RECOVERY_INVALID*') "$($case.Name) mismatch fails the bounded recovery contract" + Assert-CcodEqual $runtimeCount @(Get-ChildItem -LiteralPath (Join-Path $caseRoot 'runtime') -Directory -Force).Count "$($case.Name) mismatch creates no runtime generation" + Assert-CcodEqual $pointerCount @(Get-ChildItem -LiteralPath (Join-Path $caseRoot 'state\active-generation') -File -Force).Count "$($case.Name) mismatch appends no active pointer" + Assert-CcodEqual 0 @(Get-ChildItem -LiteralPath (Join-Path $caseRoot 'state\install-transactions') -Filter '*.Ready.*.json' -File -Force).Count "$($case.Name) mismatch appends no Ready transaction snapshot" + Assert-CcodEqual 0 @(Get-ChildItem -LiteralPath (Join-Path $caseRoot 'state\install-transactions') -Filter '*.Failed.*.json' -File -Force).Count "$($case.Name) mismatch appends no Failed transaction snapshot" + Assert-CcodEqual 0 @(Get-ChildItem -LiteralPath (Join-Path $caseRoot 'state\activation-receipts') -Filter '*.Failed.json' -File -Force).Count "$($case.Name) mismatch appends no Failed activation receipt" + $module=Get-Module InstallLifecycle -ErrorAction Stop + $head=&$module {param($Root,$Id)Read-CcodInstallTransactionRecord -InstallRoot $Root -TransactionId $Id} $caseRoot $fixture.Transaction.transactionId + Assert-CcodEqual 'ProtectionReady' $head.phase "$($case.Name) mismatch remains recoverable and nonterminal" } - Assert-CcodThrows { Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters $fake.Adapters } 'CCOD_INSTALL_FILE_HASH_MISMATCH' - Assert-CcodTrue (-not (Test-Path -LiteralPath (Join-Path $install 'active.json'))) 'post-inventory catalog mutation never activates' } finally { - foreach ($path in @($source, $install, $nodeRoot)) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force } } + foreach($caseRoot in $caseRoots){if(Test-Path -LiteralPath $caseRoot){Remove-Item -LiteralPath $caseRoot -Recurse -Force}} + Remove-CcodReadyFinalizationGapFixture $fixture } } @@ -1416,7 +2959,7 @@ $results += Invoke-CcodTest 'invalid active pointer fails closed before upgrade' $nodePath = New-CcodLifecycleFakeNode -Root $nodeRoot $fake = New-CcodLifecycleFake -NodePath $nodePath Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters $fake.Adapters | Out-Null - [IO.File]::WriteAllText((Join-Path $install 'active.json'), '{"schemaVersion":9,"activeRuntime":"x","previousRuntime":null,"updatedAtUtc":"2030-02-03T03:04:05.0000000Z"}', [Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText((Join-Path $install 'state\active-generation\00000000000000000002.json'), '{"schemaVersion":9,"generation":2,"activeRuntime":"x","previousGeneration":1}', [Text.UTF8Encoding]::new($false)) [IO.File]::WriteAllText((Join-Path $source 'src\runtime\main-payload.js'), "module.exports = 'fixture-v2';`n", [Text.UTF8Encoding]::new($false)) Assert-CcodThrows { Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters $fake.Adapters } 'CCOD_SCHEMA_UNSUPPORTED' } finally { @@ -1473,7 +3016,7 @@ $results += Invoke-CcodTest 'repair state quarantines damage and resets consent } } -$results += Invoke-CcodTest 'upgrade and repair preserve malformed UI preference without safety damage' { +$results += Invoke-CcodTest 'upgrade fails closed on malformed UI baseline while repair preserves its bytes' { $source = New-CcodLifecycleTempRoot $install = New-CcodLifecycleTempRoot $nodeRoot = New-CcodLifecycleTempRoot @@ -1488,9 +3031,9 @@ $results += Invoke-CcodTest 'upgrade and repair preserve malformed UI preference [byte[]]$malformed = 0x00,0x7b,0xff,0x13,0x0a [IO.File]::WriteAllBytes($preferencePath, $malformed) [IO.File]::WriteAllText((Join-Path $source 'src\runtime\main-payload.js'), "module.exports = 'fixture-ui-malformed-v2';`n", [Text.UTF8Encoding]::new($false)) - $upgrade = Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters (New-CcodLifecycleFake -NodePath $nodePath).Adapters - Assert-CcodEqual 'Upgraded' $upgrade.Outcome 'malformed preference does not block ordinary upgrade' - Assert-CcodEqual '007bff130a' (([IO.File]::ReadAllBytes($preferencePath) | ForEach-Object { $_.ToString('x2') }) -join '') 'ordinary upgrade preserves malformed UI preference bytes' + Assert-CcodThrows {Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters (New-CcodLifecycleFake -NodePath $nodePath).Adapters|Out-Null} 'CCOD_STATE_MALFORMED' + Assert-CcodEqual $first.RuntimeId (Read-CcodActiveRuntime -InstallRoot $install).activeRuntime 'malformed UI baseline cannot advance the active pointer' + Assert-CcodEqual '007bff130a' (([IO.File]::ReadAllBytes($preferencePath) | ForEach-Object { $_.ToString('x2') }) -join '') 'failed upgrade preserves malformed UI preference bytes' $repairFake = New-CcodLifecycleFake -NodePath $nodePath $repair = Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -RepairState -Adapters $repairFake.Adapters Assert-CcodEqual 'Repaired' $repair.Outcome 'malformed preference does not block repair' @@ -1505,7 +3048,7 @@ $results += Invoke-CcodTest 'upgrade and repair preserve malformed UI preference } } -$results += Invoke-CcodTest 'legacy missing UI preference remains absent across upgrade and follows Windows' { +$results += Invoke-CcodTest 'legacy missing UI preference is safely materialized during upgrade' { $source = New-CcodLifecycleTempRoot $install = New-CcodLifecycleTempRoot $nodeRoot = New-CcodLifecycleTempRoot @@ -1521,10 +3064,10 @@ $results += Invoke-CcodTest 'legacy missing UI preference remains absent across [IO.File]::WriteAllText((Join-Path $source 'src\runtime\main-payload.js'), "module.exports = 'fixture-ui-legacy-v2';`n", [Text.UTF8Encoding]::new($false)) $upgrade = Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters (New-CcodLifecycleFake -NodePath $nodePath).Adapters Assert-CcodEqual 'Upgraded' $upgrade.Outcome 'legacy preference absence does not block upgrade' - Assert-CcodTrue (-not (Test-Path -LiteralPath $preferencePath)) 'legacy missing preference remains absent after upgrade' + Assert-CcodTrue (Test-Path -LiteralPath $preferencePath -PathType Leaf) 'legacy missing preference is materialized from the upgrade baseline' $preference = Read-CcodUiPreference -StateRoot $stateRoot Assert-CcodEqual 'System' $preference.LanguageMode 'legacy missing preference follows Windows' - Assert-CcodEqual $true $preference.FallbackUsed 'legacy missing preference uses safe fallback' + Assert-CcodEqual $false $preference.FallbackUsed 'materialized preference no longer requires fallback' } finally { foreach ($path in @($source, $install, $nodeRoot)) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force } } } @@ -1971,14 +3514,11 @@ $results += Invoke-CcodTest 'portable release keeps the fail-closed bootstrap ha $inno = Get-Content -LiteralPath $innoPath -Raw $wrapper = Get-Content -LiteralPath $wrapperPath -Raw Assert-CcodTrue ($inno -notmatch '(?m)^\s*\[UninstallRun\]') 'Inno has no legacy pre-delete UninstallRun route' - Assert-CcodTrue ($inno -match 'function\s+InitializeUninstall\s*\(\)\s*:\s*Boolean') 'Inno has a pre-delete uninstall gate' - Assert-CcodTrue ($inno -match 'UninstallBootstrap\.ps1' -and $inno -match '-Mode\s+Prepare') 'Inno invokes the staged bootstrap prepare phase' - Assert-CcodTrue ($inno -match 'CurUninstallStepChanged' -and $inno -match '-Mode\s+FinalizeReceipt') 'Inno writes completion only after its file deletion phase' - Assert-CcodTrue ($inno -match 'CCOD_UNINSTALL_FINALIZATION_MISSING' -and $inno -match 'CCOD_UNINSTALL_FINALIZATION_FAILED') 'Inno propagates a missing or failed post-delete completion receipt instead of reporting a false successful uninstall' + Assert-CcodTrue ($inno -match '(?m)^Uninstallable=no\s*$' -and $inno -notmatch 'InitializeUninstall|CurUninstallStepChanged|UninstallBootstrap\.ps1') 'sealed Setup creates no pre-Ready Inno uninstall surface' Assert-CcodTrue ($inno -notmatch 'BackupDeviceKeyStore|RemoveDeviceKeyStore|KeepCurrentSpecialSession') 'Inno exposes no key or special-session uninstall options' Assert-CcodTrue (Test-Path -LiteralPath $finalizerPath -PathType Leaf) 'portable release includes an external finalizer' Assert-CcodTrue (Test-Path -LiteralPath $portableModulePath -PathType Leaf) 'portable release includes a marker-bound removal module' - Assert-CcodTrue ($wrapper -match 'PortableUninstallFinalizer\.ps1' -and $wrapper -match '-Mode\s+Prepare') 'public wrapper prepares protected cleanup before launching the external portable finalizer' + Assert-CcodTrue ($wrapper -match 'PortableUninstallFinalizer\.ps1' -and $wrapper -match 'Invoke-CcodPublicUninstallPrepare[^\r\n]+-PrepareMode\s+Prepare\s+-Identity\s+\$null') 'public wrapper prepares protected cleanup before launching the external portable finalizer' Assert-CcodTrue ($wrapper -match 'Start-Process' -and $wrapper -match 'portable-finalizer\.stderr\.log') 'public wrapper delegates final deletion to a detached external process with auditable output' Assert-CcodTrue ($wrapper -notmatch 'Remove-Item') 'public wrapper cannot delete the portable installer root directly' Assert-CcodTrue ((Get-Content -LiteralPath $finalizerPath -Raw) -match 'Remove-CcodPortableInstallerRoot') 'external finalizer owns the bound portable root deletion' @@ -2122,6 +3662,11 @@ $results += Invoke-CcodTest 'upgrade stops a verified fallback supervisor when s } } +$results += Invoke-CcodTest 'fallback accepts generation parent and rejects root parent when append-only selector exists' { + $install=New-CcodLifecycleTempRoot + try{$identity=New-CcodLifecycleIdentity;[IO.Directory]::CreateDirectory($install)|Out-Null;$runtimeId='2.5.22-1111111111111111-22222222222222222222222222222222';$runtimeRoot=Join-Path $install "runtime\$runtimeId";$supervisor=Join-Path $runtimeRoot 'src\persistence\Supervisor.ps1';$generationBootstrap=Join-Path $runtimeRoot 'src\persistence\bootstrap.ps1';[IO.Directory]::CreateDirectory((Split-Path $supervisor -Parent))|Out-Null;[IO.File]::WriteAllText($generationBootstrap,'# generation',[Text.UTF8Encoding]::new($false));$pointerRoot=Join-Path $install 'state\active-generation';[IO.Directory]::CreateDirectory($pointerRoot)|Out-Null;[IO.File]::WriteAllText((Join-Path $pointerRoot '00000000000000000001.json'),('{"schemaVersion":1,"generation":1,"activeRuntime":"'+$runtimeId+'","previousGeneration":0}'),[Text.UTF8Encoding]::new($false));$parent=[pscustomobject]@{ProcessId=96;ParentProcessId=1;SessionId=$identity.SessionId;CreationDate=[DateTime]::Parse('2030-02-03T02:59:59Z').ToUniversalTime();CommandLine="powershell.exe -File `"$generationBootstrap`" -InstallRoot `"$install`""};$child=[pscustomobject]@{ProcessId=97;ParentProcessId=96;SessionId=$identity.SessionId;CreationDate=[DateTime]::Parse('2030-02-03T03:00:00Z').ToUniversalTime();CommandLine="powershell.exe -File `"$supervisor`" -ReadyToken $('a'*64)"};$module=Get-Module InstallLifecycle;$accepted=&$module {param($Root,$Current,$Items)Get-CcodLifecycleVerifiedSupervisorFallback -InstallRoot $Root -Identity $Current -ProcessEnumerator {param($Ignored)$Items} -OwnerSidResolver {param($P)[pscustomobject]@{ReturnValue=0;Sid=$Current.UserSid}}} $install $identity @($parent,$child);Assert-CcodEqual 97 $accepted.Pid 'append-only task accepts exact same-generation bootstrap parent';$rootBootstrap=Join-Path $install 'bootstrap.ps1';[IO.File]::WriteAllText($rootBootstrap,'# legacy',[Text.UTF8Encoding]::new($false));$parent.CommandLine="powershell.exe -File `"$rootBootstrap`" -InstallRoot `"$install`"";$rejected=&$module {param($Root,$Current,$Items)Get-CcodLifecycleVerifiedSupervisorFallback -InstallRoot $Root -Identity $Current -ProcessEnumerator {param($Ignored)$Items} -OwnerSidResolver {param($P)[pscustomobject]@{ReturnValue=0;Sid=$Current.UserSid}}} $install $identity @($parent,$child);Assert-CcodTrue ($null-eq$rejected) 'append-only selector rejects legacy root bootstrap parent'}finally{if(Test-Path $install){Remove-Item $install -Recurse -Force}} +} + $results += Invoke-CcodTest 'fallback accepts a deleted legacy runtime only through the exact stable bootstrap parent' { $install = New-CcodLifecycleTempRoot try { @@ -2217,18 +3762,11 @@ $results += Invoke-CcodTest 'fallback rejects two verified supervisor children a } } -$results += Invoke-CcodTest 'installer exposes a CodexRemote-fix desktop entry that only starts the stable tray bootstrap' { +$results += Invoke-CcodTest 'sealed installer defers every desktop and Start menu entry until post-Ready registration' { $installerScript = Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss' - $entries = @(Get-Content -LiteralPath $installerScript | Where-Object { $_ -cmatch '^Name: "\{userdesktop\}\\' }) - Assert-CcodEqual 1 $entries.Count 'installer defines exactly one desktop entry' - $entry = [string]$entries[0] - Assert-CcodTrue ($entry -cmatch 'Name: "\{userdesktop\}\\CodexRemote-fix"') 'desktop entry has the public product name' - Assert-CcodTrue ($entry -cmatch 'Filename: "\{sys\}\\WindowsPowerShell\\v1\.0\\powershell\.exe"') 'desktop entry uses the Windows PowerShell host' - Assert-CcodTrue ($entry -cmatch '-WindowStyle Hidden') 'desktop entry hides the bootstrap host window' - Assert-CcodTrue ($entry -cmatch '\{localappdata\}\\CodexControlOtherDevices\\bootstrap\.ps1') 'desktop entry targets the stable bootstrap' - Assert-CcodTrue ($entry -cmatch '-InstallRoot ""\{localappdata\}\\CodexControlOtherDevices""') 'desktop entry supplies the stable install root' - Assert-CcodTrue ($entry -cmatch 'IconFilename: "\{app\}\\assets\\CodexRemote-fix\.ico"') 'desktop entry uses the public product icon' - Assert-CcodTrue ($entry -cnotmatch 'Start-CodexControlOtherDevices\.ps1') 'desktop entry never invokes a direct repair session' + $content = Get-Content -LiteralPath $installerScript -Raw + Assert-CcodEqual 0 @(Get-Content -LiteralPath $installerScript | Where-Object { $_ -cmatch '^Name: "\{(userdesktop|group)\}\\' }).Count 'Task 3 defines no user-visible shortcut entry' + Assert-CcodTrue ($content -cnotmatch '(?m)^\[Icons\]\s*$|\{userdesktop\}|\{group\}') 'no shortcut destination is reachable before strict Ready' } $results += Invoke-CcodTest 'installer exposes CodexRemote-fix as the searchable primary bootstrap entry' { @@ -2239,35 +3777,24 @@ $results += Invoke-CcodTest 'installer exposes CodexRemote-fix as the searchable Assert-CcodTrue ($lines -ccontains 'AppId={{2B9E9F2E-7A32-4A7E-9C1D-9F5B5C6D7E8F}') 'installer retains the v2.1.6 AppId for in-place upgrades' Assert-CcodTrue ($lines -ccontains 'AppName=CodexRemote-fix') 'installed app has the public CodexRemote-fix name' Assert-CcodTrue ($lines -ccontains 'AppVerName=CodexRemote-fix {#ProjectVersion}') 'installed app version has the public CodexRemote-fix name' - Assert-CcodTrue ($lines -ccontains 'DefaultGroupName=CodexRemote-fix') 'current-user Start menu group has the public CodexRemote-fix name' Assert-CcodTrue ($lines -ccontains 'SetupIconFile=..\assets\codexremote-fix\codexremote-fix.ico') 'setup uses the CodexRemote-fix icon' - Assert-CcodTrue ($lines -ccontains 'UninstallDisplayIcon={app}\assets\CodexRemote-fix.ico') 'Apps and Features uses the installed CodexRemote-fix icon' Assert-CcodTrue ($lines -ccontains 'OutputBaseFilename=CodexRemote-fix-{#ProjectVersion}-setup') 'build output uses the public release name' - Assert-CcodEqual 2 @($lines | Where-Object { $_ -match '^Name: "\{(group|userdesktop)\}\\CodexRemote-fix";.*bootstrap\.ps1"".*-EntryMode Explicit' }).Count 'Start menu and desktop shortcuts enter bootstrap Explicit mode' - Assert-CcodTrue ($content -cmatch 'Activate-CcodRemoteFix\.ps1') 'installer delegates upgrades to the durable activation worker instead of relying on uninstall metadata' - - $primaryEntries = @($content -split "`r?`n" | Where-Object { $_ -cmatch '^Name: "\{(group|userdesktop)\}\\CodexRemote-fix";' }) - Assert-CcodEqual 2 $primaryEntries.Count 'Start menu and desktop each expose one primary CodexRemote-fix entry' - foreach ($entry in $primaryEntries) { - Assert-CcodTrue ($entry -cmatch 'Filename: "\{sys\}\\WindowsPowerShell\\v1\.0\\powershell\.exe"') 'primary entry uses the PowerShell host' - Assert-CcodTrue ($entry -cmatch '\{localappdata\}\\CodexControlOtherDevices\\bootstrap\.ps1') 'primary entry invokes the verified stable bootstrap' - Assert-CcodTrue ($entry -cmatch '-InstallRoot ""\{localappdata\}\\CodexControlOtherDevices""') 'primary entry supplies the stable legacy install root' - Assert-CcodTrue ($entry -cmatch 'IconFilename: "\{app\}\\assets\\CodexRemote-fix\.ico"') 'primary entry uses the public product icon' - Assert-CcodTrue ($entry -cnotmatch 'README\.md') 'primary entry never opens documentation' - } + Assert-CcodTrue ($lines -ccontains 'CreateAppDir=no' -and $lines -ccontains 'Uninstallable=no') 'Task 3 Setup has no product registration surface before Ready' + Assert-CcodTrue ($content -cnotmatch '(?m)^\[(Icons|Registry|UninstallRun)\]\s*$|DefaultGroupName=|UninstallDisplayIcon=') 'shortcuts registry and uninstall registration remain deferred to Task 4' + Assert-CcodEqual 4 @($lines | Where-Object { $_ -cmatch '^Source: .*Flags: dontcopy$' }).Count 'Setup carries only four sealed temporary inputs' $buildScript = Get-Content -LiteralPath (Join-Path $repositoryRoot 'build\build.ps1') -Raw - Assert-CcodTrue ($buildScript -cmatch 'CodexRemote-fix-\$Version-windows-x64\.zip') 'build script locates the public portable ZIP filename' - Assert-CcodTrue ($buildScript -cmatch '\$bundle\.sha256\.txt') 'build script writes a hash beside the public portable ZIP filename' + Assert-CcodTrue ($buildScript -cmatch 'ReleaseAssetContract\.psm1' -and $buildScript -cmatch '\$bundle\s*=\s*Join-Path\s+\$dist\s+\$releaseAssetNames\[0\]') 'build script locates the public portable ZIP through the central contract' + Assert-CcodTrue ($buildScript -cmatch '\$checksum\s*=\s*Join-Path\s+\$dist\s+\$releaseAssetNames\[1\]') 'build script locates the public ZIP checksum through the central contract' } -$results += Invoke-CcodTest 'portable builder publishes the exact CodexRemote-fix 2.5.21 release artifact contract' { +$results += Invoke-CcodTest 'portable builder publishes the exact CodexRemote-fix 2.5.22 release artifact contract' { $package = Get-Content -LiteralPath (Join-Path $repositoryRoot 'package.json') -Raw | ConvertFrom-Json - Assert-CcodEqual '2.5.21' ([string]$package.version) 'package version is exactly 2.5.21' + Assert-CcodEqual '2.5.22' ([string]$package.version) 'package version is exactly 2.5.22' $buildScript = Get-Content -LiteralPath (Join-Path $repositoryRoot 'build\build.ps1') -Raw - Assert-CcodTrue ($buildScript -cmatch 'CodexRemote-fix-\$Version-windows-x64\.zip') 'portable build resolves the exact public ZIP filename' - Assert-CcodTrue ($buildScript -cmatch 'CodexRemote-fix-\$Version-payload-manifest\.json') 'portable build publishes a separately bound payload manifest' + Assert-CcodTrue ($buildScript -cmatch '\$bundle\s*=\s*Join-Path\s+\$dist\s+\$releaseAssetNames\[0\]') 'portable build resolves the exact public ZIP through the central contract' + Assert-CcodTrue ($buildScript -cmatch '\$payloadManifestAsset\s*=\s*Join-Path\s+\$dist\s+\$releaseAssetNames\[3\]') 'portable build publishes the separately bound payload manifest through the central contract' Assert-CcodTrue ($buildScript -cmatch 'schemaVersion = 2') 'portable build writes a schema-two release manifest' Assert-CcodTrue ($buildScript -cmatch "distribution = 'portable-zip'") 'portable build labels the release distribution explicitly' } @@ -2277,19 +3804,15 @@ $results += Invoke-CcodTest 'installer accepts success only from the exited owne $installerScript = Get-Content -LiteralPath (Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss') -Raw -Encoding UTF8 $activationWorker = Get-Content -LiteralPath (Join-Path $repositoryRoot 'Activate-CcodRemoteFix.ps1') -Raw -Encoding UTF8 $activationScript = Join-Path $repositoryRoot 'Activate-CcodRemoteFix.ps1' - $promptScript = Join-Path $repositoryRoot 'Prompt-CcodRestart.ps1' Assert-CcodTrue (Test-Path -LiteralPath $activationScript -PathType Leaf) 'post-install activation worker exists' - Assert-CcodTrue (Test-Path -LiteralPath $promptScript -PathType Leaf) 'post-install Codex restart prompt exists' Assert-CcodTrue ($installerScript -cmatch '(?m)^CloseApplications=no\r?$') 'installer never lets Restart Manager close Codex' - Assert-CcodTrue ($installerScript -cmatch 'Activate-CcodRemoteFix\.ps1') 'installer bundles the activation worker' - Assert-CcodTrue ($installerScript -cmatch '(?s)RefuseStaleActivationReceipt\(ReceiptPath\).*UpdateActivationStartingPresentation\(\).*Activate-CcodRemoteFix\.ps1.*ewWaitUntilTerminated, ActivationResultCode') 'installer clears stale state, shows startup, and waits for the exact owner to exit' - Assert-CcodTrue ($installerScript -cmatch '(?s)if ActivationResultCode <> 0 then.*RaiseException.*?-ValidateReceiptWithTimeout.*ewWaitUntilTerminated, ValidationResultCode') 'only a normal owner exit reaches the separate bounded validator' - $readyBranch=[regex]::Match($installerScript,'(?s)if ValidationResultCode = 0 then\s*begin(.*?)\s*end;') - Assert-CcodTrue ($readyBranch.Success -and $readyBranch.Groups[1].Value-cmatch'ProgressGauge\.Position\s*:=\s*100' -and $readyBranch.Groups[1].Value-cmatch'Prompt-CcodRestart\.ps1') 'only strict validator exit zero reaches completion and prompting' - Assert-CcodTrue ($installerScript -cmatch 'if ValidationResultCode <> 0 then' -and $installerScript -cmatch 'RaiseException') 'every nonzero validator result fails closed' + Assert-CcodTrue ($installerScript -cmatch 'ActivationBootstrapPath' -and $installerScript -cmatch 'Flags: dontcopy') 'installer embeds the activation worker only as a temporary sealed input' + Assert-CcodTrue ($installerScript -cmatch '(?s)function PrepareToInstall.*ExtractAndLockCcodInputs\(\).*procedure CurStepChanged.*GetArrayLength\(CcodInputHandles\) <> 3.*GetCcodBootstrapParameters\(ActivationId, False\).*ewWaitUntilTerminated, ActivationResultCode') 'installer locks inputs before waiting for the exact owner to exit' + Assert-CcodTrue ($installerScript -cmatch '(?s)ActivationResultCode <> 0.*RaiseException.*GetCcodBootstrapParameters\(ActivationId, True\).*ewWaitUntilTerminated, ValidationResultCode') 'only a normal owner exit reaches the strict one-shot validator' + Assert-CcodTrue ($installerScript -cmatch 'ProgressGauge\.Position := 100' -and $installerScript -cnotmatch 'Prompt-CcodRestart\.ps1') 'strict validation alone reaches completion while Task 4 registration and prompting stay absent' + Assert-CcodTrue ($installerScript -cmatch '\(ValidationResultCode <> 0\) then RaiseException\(.CCOD_SETUP_READY_VALIDATION_FAILED.' ) 'every nonzero validator result fails closed' Assert-CcodTrue ($installerScript -cnotmatch '(?s)Activate-CcodRemoteFix\.ps1[^;]*-Prompt') 'background activation worker never owns the prompt' Assert-CcodTrue ($activationWorker -cmatch '(?s)if \(\$ValidateReceiptOnly\).*Read-CcodTerminalActivationReceipt.*phase -ceq .Ready.*exit 0.*phase -ceq .Failed.*exit 2' -and $activationWorker -cnotmatch 'ValidationResultPath|ValidationId|Start-Sleep') 'inner validator is a one-shot strict receipt reader with only direct terminal exit codes' - Assert-CcodTrue ($activationWorker -cmatch '(?s)function Invoke-CcodBoundedReceiptValidator.*WaitForExit\(\$TimeoutMilliseconds\).*\.Kill\(\).*return 3' -and $activationWorker -cmatch '(?s)if \(\$ValidateReceiptWithTimeout\).*Invoke-CcodBoundedReceiptValidator.*exit \$validationResult') 'outer validator owns a finite child wait and reduces timeout to a fail-closed direct exit' Assert-CcodTrue ($installerScript -cnotmatch 'ValidationResultPath|ReadValidationResultState|post-install-activation\.validation') 'Inno never accepts a file sidecar as validator authority' Assert-CcodTrue ($installerScript -cnotmatch 'Prepare-CcodRemoteUpgrade\.ps1') 'installer does not pre-stop the supervisor outside the gated runtime activation transaction' Assert-CcodTrue ($installerScript -cnotmatch '(?ms)^\[Run\]\s*\r?\nFilename: "powershell\.exe"; Parameters: ".*Install-CodexControlOtherDevices\.ps1') 'installer does not silently ignore its runtime installer exit code through a Run entry' @@ -2299,15 +3822,14 @@ $results += Invoke-CcodTest 'installer accepts success only from the exited owne $results += Invoke-CcodTest 'installer waits for its process owner and accepts terminal status only from its strict validator child' { $installerScript = Get-Content -LiteralPath (Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss') -Raw -Encoding UTF8 Assert-CcodTrue ($installerScript -cmatch 'CoCreateGuid@ole32\.dll' -and $installerScript -cmatch 'StringFromGUID2@ole32\.dll') 'installer generates a canonical activation GUID before worker launch' - Assert-CcodTrue ($installerScript -cmatch '(?s)ActivationId\s*:=\s*NewActivationId\(\).*ReceiptPath\s*:=.*RefuseStaleActivationReceipt\(ReceiptPath\).*Activate-CcodRemoteFix\.ps1.*-ActivationId\s+"' ) 'installer removes or refuses the stale receipt and passes its own activation id to the worker' - Assert-CcodTrue ($installerScript -cmatch 'ewWaitUntilTerminated, ActivationResultCode' -and $installerScript -cmatch 'ewWaitUntilTerminated, ValidationResultCode' -and $installerScript -cmatch '-ValidateReceiptWithTimeout' -and $installerScript -cmatch 'VALIDATION_TIMEOUT_MILLISECONDS') 'Inno waits for both the activation owner and bounded validator exit codes' + Assert-CcodTrue ($installerScript -cmatch '(?s)ActivationId\s*:=\s*NewActivationId\(\).*GetCcodBootstrapParameters\(ActivationId, False\).*GetCcodBootstrapParameters\(ActivationId, True\)' ) 'installer passes one fresh activation id to owner and validator without deleting append-only receipts' + Assert-CcodTrue ($installerScript -cmatch 'ewWaitUntilTerminated, ActivationResultCode' -and $installerScript -cmatch 'ewWaitUntilTerminated, ValidationResultCode' -and $installerScript -cmatch '-ValidateReceiptOnly') 'Inno waits for both owner and strict validator exit codes' Assert-CcodTrue ($installerScript -cnotmatch 'ewNoWait') 'Inno never releases an unowned activation worker' foreach($forbidden in @('OpenProcess@kernel32.dll','WaitForSingleObject@kernel32.dll','GetExitCodeProcess@kernel32.dll','PROCESS_QUERY_INFORMATION','ProcessHandle := OpenProcess')){Assert-CcodTrue ($installerScript -cnotmatch [regex]::Escape($forbidden)) "installer never uses $forbidden to infer worker identity"} - Assert-CcodTrue ($installerScript -cmatch '(?s)if ActivationResultCode <> 0 then.*RaiseException.*?-ValidateReceiptWithTimeout.*ewWaitUntilTerminated, ValidationResultCode.*if ValidationResultCode <> 0 then.*RaiseException') 'owner failure cannot reach validation and every nonzero validator result fails closed' - Assert-CcodTrue ($installerScript -cmatch '(?s)function IsSafeActivationFile.*ExtractFileDir.*DirectoryAttributes.*RootAttributes.*FILE_ATTRIBUTE_REPARSE_POINT') 'Inno rejects reparse-point state ancestors even for raw presentation reads' + Assert-CcodTrue ($installerScript -cmatch '(?s)ActivationResultCode <> 0.*RaiseException.*GetCcodBootstrapParameters\(ActivationId, True\).*ValidationResultCode <> 0.*RaiseException') 'owner failure cannot reach validation and every nonzero validator result fails closed' + Assert-CcodTrue ($installerScript -cnotmatch 'ReadActivationProgressPhase|LoadBoundedActivationReceipt') 'Inno delegates all receipt authority to the locked bootstrap' Assert-CcodTrue ($installerScript -cnotmatch 'ValidationResultPath|ReadValidationResultState|post-install-activation\.validation') 'no writable sidecar can impersonate the strict validator' - $readyBranch=[regex]::Match($installerScript,'(?s)if ValidationResultCode = 0 then\s*begin(.*?)\s+end;') - Assert-CcodTrue ($readyBranch.Groups[1].Value-cmatch'Prompt-CcodRestart\.ps1') 'only strict Ready exit zero reaches the restart prompt' + Assert-CcodTrue ($installerScript -cnotmatch 'Prompt-CcodRestart\.ps1') 'Task 3 does not create a post-Ready product prompt or registration path' } # Production mutation caught: compiling a script with an unrecognized built-in identifier, or leaving the bounded validator route uncompiled. @@ -2347,8 +3869,8 @@ $results += Invoke-CcodTest 'activation terminal validator enforces the complete $root=Join-Path ([IO.Path]::GetTempPath()) ('ccod-activation-validator-'+[guid]::NewGuid().ToString('N')) $reparseTarget=Join-Path ([IO.Path]::GetTempPath()) ('ccod-activation-validator-target-'+[guid]::NewGuid().ToString('N')) try{ - [IO.Directory]::CreateDirectory((Join-Path $root 'state'))|Out-Null - $activationId='77777777-6666-5555-4444-333333333333';$receiptPath=Join-Path $root 'state\post-install-activation.json' + $receiptDirectory=Join-Path $root 'state\activation-receipts';[IO.Directory]::CreateDirectory($receiptDirectory)|Out-Null + $activationId='77777777-6666-5555-4444-333333333333' $activationScript=Join-Path $repositoryRoot 'Activate-CcodRemoteFix.ps1';$powershellExecutable=Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' $invokeValidator={param([string]$ErrorPath) $outputPath="$ErrorPath.stdout" @@ -2372,6 +3894,8 @@ $results += Invoke-CcodTest 'activation terminal validator enforces the complete ) foreach($case in $cases){ $receipt=[ordered]@{};foreach($key in $valid.Keys){$receipt[$key]=$valid[$key]};&$case.Mutate $receipt + Get-ChildItem -LiteralPath $receiptDirectory -File -Force -ErrorAction SilentlyContinue|Remove-Item -Force + $receiptPath=Join-Path $receiptDirectory ("$activationId.$($receipt.phase).json") [IO.File]::WriteAllText($receiptPath,($receipt|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new($false)) $stderrPath=Join-Path $root (('validator-{0}.err' -f ($case.Name-replace'[^A-Za-z0-9]','-'))) $invocation=&$invokeValidator $stderrPath @@ -2379,6 +3903,7 @@ $results += Invoke-CcodTest 'activation terminal validator enforces the complete Assert-CcodEqual 0 $invocation.Stdout.Count "$($case.Name) emits no receipt data to stdout" if($case.Exit-ne0-and$case.Exit-ne2){Assert-CcodTrue ((Get-Content -LiteralPath $stderrPath -Raw)-cmatch'CCOD_ACTIVATION_RECEIPT_') "$($case.Name) retains a bounded support code on stderr"} } + $receiptPath=Join-Path $receiptDirectory "$activationId.Ready.json" [IO.File]::WriteAllText($receiptPath,'{"schemaVersion":1,"activationId":"77777777-6666-5555-4444-333333333333","phase":"Ready","runtimeId":"runtime-new"',[Text.UTF8Encoding]::new($false)) $stderrPath=Join-Path $root 'validator-truncated.err';$invocation=&$invokeValidator $stderrPath Assert-CcodEqual 3 $invocation.ExitCode 'truncated Ready JSON is rejected by the executable validator' @@ -2388,8 +3913,8 @@ $results += Invoke-CcodTest 'activation terminal validator enforces the complete Assert-CcodEqual 3 $invocation.ExitCode 'oversized receipt is refused before JSON parsing' Assert-CcodEqual 0 $invocation.Stdout.Count 'oversized receipt emits no data to stdout' Remove-Item -LiteralPath (Join-Path $root 'state') -Recurse -Force - [IO.Directory]::CreateDirectory($reparseTarget)|Out-Null - [IO.File]::WriteAllText((Join-Path $reparseTarget 'post-install-activation.json'),($valid|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new($false)) + [IO.Directory]::CreateDirectory((Join-Path $reparseTarget 'activation-receipts'))|Out-Null + [IO.File]::WriteAllText((Join-Path $reparseTarget "activation-receipts\$activationId.Ready.json"),($valid|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new($false)) New-Item -ItemType Junction -Path (Join-Path $root 'state') -Target $reparseTarget|Out-Null $stderrPath=Join-Path $root 'validator-reparse.err';$invocation=&$invokeValidator $stderrPath Assert-CcodEqual 3 $invocation.ExitCode 'receipt beneath a reparse-point parent is refused before parsing' @@ -2407,25 +3932,28 @@ $results += Invoke-CcodTest 'activation terminal validator enforces the complete $results += Invoke-CcodTest 'activation validator watchdog preserves direct terminal exits and bounds a slow child' { $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-activation-watchdog-' + [guid]::NewGuid().ToString('N')) try { - [IO.Directory]::CreateDirectory((Join-Path $root 'state')) | Out-Null + [IO.Directory]::CreateDirectory((Join-Path $root 'state\activation-receipts')) | Out-Null $activationId = '88888888-7777-6666-5555-444444444444' - $receiptPath = Join-Path $root 'state\post-install-activation.json' + $readyPath = Join-Path $root "state\activation-receipts\$activationId.Ready.json" + $failedPath = Join-Path $root "state\activation-receipts\$activationId.Failed.json" $stderrPath = Join-Path $root 'watchdog.err' $activationScript = Join-Path $repositoryRoot 'Activate-CcodRemoteFix.ps1' $ready = [ordered]@{schemaVersion=1;activationId=$activationId;phase='Ready';runtimeId='runtime-watchdog';previousRuntimeId=$null;startedAtUtc='2030-02-03T04:05:06.0000000Z';updatedAtUtc='2030-02-03T04:05:07.0000000Z';ready=$true;errorCode=$null} - [IO.File]::WriteAllText($receiptPath,($ready | ConvertTo-Json -Compress),[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($readyPath,($ready | ConvertTo-Json -Compress),[Text.UTF8Encoding]::new($false)) $stdout = @(& $activationScript -AppRoot $repositoryRoot -InstallRoot $root -ActivationId $activationId -ValidateReceiptWithTimeout -ValidationTimeoutMilliseconds 10000 2>$stderrPath) Assert-CcodEqual 0 $LASTEXITCODE 'watchdog returns the strict Ready child exit directly' Assert-CcodEqual 0 $stdout.Count 'watchdog Ready path emits no receipt data to stdout' $failed = [ordered]@{}; foreach ($key in $ready.Keys) { $failed[$key] = $ready[$key] }; $failed.phase = 'Failed'; $failed.ready = $false; $failed.errorCode = 'CCOD_INSTALL_FAILED' - [IO.File]::WriteAllText($receiptPath,($failed | ConvertTo-Json -Compress),[Text.UTF8Encoding]::new($false)) + Remove-Item -LiteralPath $readyPath -Force + [IO.File]::WriteAllText($failedPath,($failed | ConvertTo-Json -Compress),[Text.UTF8Encoding]::new($false)) $stdout = @(& $activationScript -AppRoot $repositoryRoot -InstallRoot $root -ActivationId $activationId -ValidateReceiptWithTimeout -ValidationTimeoutMilliseconds 10000 2>$stderrPath) Assert-CcodEqual 2 $LASTEXITCODE 'watchdog returns the strict Failed child exit directly' Assert-CcodEqual 0 $stdout.Count 'watchdog Failed path emits no receipt data to stdout' - [IO.File]::WriteAllText($receiptPath,($ready | ConvertTo-Json -Compress),[Text.UTF8Encoding]::new($false)) + Remove-Item -LiteralPath $failedPath -Force + [IO.File]::WriteAllText($readyPath,($ready | ConvertTo-Json -Compress),[Text.UTF8Encoding]::new($false)) $stopwatch = [Diagnostics.Stopwatch]::StartNew() try { $stdout = @(& $activationScript -AppRoot $repositoryRoot -InstallRoot $root -ActivationId $activationId -ValidateReceiptWithTimeout -ValidationTimeoutMilliseconds 1 2>$stderrPath) @@ -2466,11 +3994,11 @@ $results += Invoke-CcodTest 'post-install restart prompt does nothing when the u $results += Invoke-CcodTest 'activation worker installs first and prompts only after a successful runtime activation' { $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-activation-worker-' + [guid]::NewGuid().ToString('N')) try { - [IO.Directory]::CreateDirectory((Join-Path $root 'state')) | Out-Null + [IO.Directory]::CreateDirectory((Join-Path $root 'state\activation-receipts')) | Out-Null $marker = Join-Path $root 'marker.txt' $installScript = Join-Path $root 'Install-CodexControlOtherDevices.ps1' $promptScript = Join-Path $root 'Prompt-CcodRestart.ps1' - $installSource = "param([string]`$InstallRoot,[string]`$ActivationId,[switch]`$EnableCandidateCompatibleUpdates);[IO.File]::AppendAllText('$marker','install,',[Text.UTF8Encoding]::new(`$false));`$r=[ordered]@{schemaVersion=1;activationId=`$ActivationId;phase='Ready';runtimeId='runtime-new';previousRuntimeId=`$null;startedAtUtc='2030-02-03T04:05:06.0000000Z';updatedAtUtc='2030-02-03T04:05:07.0000000Z';ready=`$true;errorCode=`$null};[IO.File]::WriteAllText((Join-Path `$InstallRoot 'state\post-install-activation.json'),(`$r|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new(`$false));exit 0" + $installSource = "param([string]`$InstallRoot,[string]`$ActivationId,[switch]`$EnableCandidateCompatibleUpdates);[IO.File]::AppendAllText('$marker','install,',[Text.UTF8Encoding]::new(`$false));`$r=[ordered]@{schemaVersion=1;activationId=`$ActivationId;phase='Ready';runtimeId='runtime-new';previousRuntimeId=`$null;startedAtUtc='2030-02-03T04:05:06.0000000Z';updatedAtUtc='2030-02-03T04:05:07.0000000Z';ready=`$true;errorCode=`$null};[IO.File]::WriteAllText((Join-Path `$InstallRoot `"state\activation-receipts\`$ActivationId.Ready.json`"),(`$r|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new(`$false));exit 0" [IO.File]::WriteAllText($installScript, $installSource, [Text.UTF8Encoding]::new($false)) [IO.File]::WriteAllText($promptScript, "param([string]`$AppRoot,[string]`$InstallRoot,[string]`$ActivationId,[switch]`$NoUi);[IO.File]::AppendAllText('$marker','prompt',[Text.UTF8Encoding]::new(`$false)); exit 0", [Text.UTF8Encoding]::new($false)) $output = @(& (Join-Path $repositoryRoot 'Activate-CcodRemoteFix.ps1') -AppRoot $root -InstallRoot $root -Prompt 2>&1) @@ -2485,11 +4013,11 @@ $results += Invoke-CcodTest 'activation worker installs first and prompts only a $results += Invoke-CcodTest 'activation worker preserves an activated runtime when optional restart confirmation fails' { $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-activation-restart-warning-' + [guid]::NewGuid().ToString('N')) try { - [IO.Directory]::CreateDirectory((Join-Path $root 'state')) | Out-Null + [IO.Directory]::CreateDirectory((Join-Path $root 'state\activation-receipts')) | Out-Null $marker = Join-Path $root 'marker.txt' $installScript = Join-Path $root 'Install-CodexControlOtherDevices.ps1' $promptScript = Join-Path $root 'Prompt-CcodRestart.ps1' - $installSource = "param([string]`$InstallRoot,[string]`$ActivationId,[switch]`$EnableCandidateCompatibleUpdates);[IO.File]::AppendAllText('$marker','install,',[Text.UTF8Encoding]::new(`$false));`$r=[ordered]@{schemaVersion=1;activationId=`$ActivationId;phase='Ready';runtimeId='runtime-new';previousRuntimeId=`$null;startedAtUtc='2030-02-03T04:05:06.0000000Z';updatedAtUtc='2030-02-03T04:05:07.0000000Z';ready=`$true;errorCode=`$null};[IO.File]::WriteAllText((Join-Path `$InstallRoot 'state\post-install-activation.json'),(`$r|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new(`$false));exit 0" + $installSource = "param([string]`$InstallRoot,[string]`$ActivationId,[switch]`$EnableCandidateCompatibleUpdates);[IO.File]::AppendAllText('$marker','install,',[Text.UTF8Encoding]::new(`$false));`$r=[ordered]@{schemaVersion=1;activationId=`$ActivationId;phase='Ready';runtimeId='runtime-new';previousRuntimeId=`$null;startedAtUtc='2030-02-03T04:05:06.0000000Z';updatedAtUtc='2030-02-03T04:05:07.0000000Z';ready=`$true;errorCode=`$null};[IO.File]::WriteAllText((Join-Path `$InstallRoot `"state\activation-receipts\`$ActivationId.Ready.json`"),(`$r|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new(`$false));exit 0" [IO.File]::WriteAllText($installScript, $installSource, [Text.UTF8Encoding]::new($false)) [IO.File]::WriteAllText($promptScript, "param([string]`$AppRoot,[string]`$InstallRoot,[string]`$ActivationId,[switch]`$NoUi);[IO.File]::AppendAllText('$marker','prompt',[Text.UTF8Encoding]::new(`$false)); exit 1", [Text.UTF8Encoding]::new($false)) $output = @(& (Join-Path $repositoryRoot 'Activate-CcodRemoteFix.ps1') -AppRoot $root -InstallRoot $root -Prompt -NoUi 2>&1) @@ -2508,14 +4036,14 @@ $results += Invoke-CcodTest 'activation worker preserves an activated runtime wh $results += Invoke-CcodTest 'activation worker requires a strict Ready receipt before prompting' { $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-activation-nonterminal-' + [guid]::NewGuid().ToString('N')) try { - [IO.Directory]::CreateDirectory((Join-Path $root 'state')) | Out-Null + [IO.Directory]::CreateDirectory((Join-Path $root 'state\activation-receipts')) | Out-Null $marker = Join-Path $root 'prompt-marker.txt' $installScript = Join-Path $root 'Install-CodexControlOtherDevices.ps1' $promptScript = Join-Path $root 'Prompt-CcodRestart.ps1' $installSource = @" param([string]`$InstallRoot,[string]`$ActivationId,[switch]`$EnableCandidateCompatibleUpdates) `$receipt=[ordered]@{schemaVersion=1;activationId=`$ActivationId;phase='StartingProtection';runtimeId='runtime-new';previousRuntimeId='runtime-old';startedAtUtc='2030-02-03T04:05:06.0000000Z';updatedAtUtc='2030-02-03T04:05:07.0000000Z';ready=`$false;errorCode=`$null} -[IO.File]::WriteAllText((Join-Path `$InstallRoot 'state\post-install-activation.json'),(`$receipt|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new(`$false)) +[IO.File]::WriteAllText((Join-Path `$InstallRoot "state\activation-receipts\`$ActivationId.StartingProtection.json"),(`$receipt|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new(`$false)) exit 0 "@ [IO.File]::WriteAllText($installScript,$installSource,[Text.UTF8Encoding]::new($false)) @@ -2598,21 +4126,11 @@ $results += Invoke-CcodTest 'CodexRemote-fix icon is a bounded multi-resolution } } -$results += Invoke-CcodTest 'setup uninstall Start menu and desktop use one installed CodexRemote-fix icon' { +$results += Invoke-CcodTest 'sealed Setup uses its wizard icon without installing product icons or shortcuts' { $installerScript = Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss' $lines = @(Get-Content -LiteralPath $installerScript) - $installedIcon = '{app}\assets\CodexRemote-fix.ico' Assert-CcodTrue ($lines -ccontains 'SetupIconFile=..\assets\codexremote-fix\codexremote-fix.ico') 'setup uses the source product icon' - Assert-CcodTrue ($lines -ccontains "UninstallDisplayIcon=$installedIcon") 'uninstall registration uses the installed product icon' - Assert-CcodTrue ($lines -ccontains 'Source: "..\assets\codexremote-fix\codexremote-fix.ico"; DestDir: "{app}\assets"; DestName: "CodexRemote-fix.ico"; Flags: ignoreversion') 'installer carries the source icon to the common installed icon path' - Assert-CcodTrue ($lines -ccontains 'Source: "..\assets\codexremote-fix\codexremote-fix.ico"; DestDir: "{app}\assets\codexremote-fix"; Flags: ignoreversion') 'installer preserves the hermetic source icon path for installed validation' - Assert-CcodTrue ($lines -ccontains 'Source: "..\.github\workflows\release.yml"; DestDir: "{app}\.github\workflows"; Flags: ignoreversion') 'installer carries the release workflow required by installed validation' - - $shortcutEntries = @($lines | Where-Object { $_ -cmatch '^Name: "\{(group|userdesktop)\}\\' }) - Assert-CcodTrue ($shortcutEntries.Count -ge 3) 'installer exposes Start menu and desktop shortcuts' - foreach ($entry in $shortcutEntries) { - Assert-CcodTrue ($entry -cmatch ('IconFilename: "' + [regex]::Escape($installedIcon) + '"')) 'every Start menu and desktop shortcut uses the common installed icon' - } + Assert-CcodTrue (($lines -join "`n") -cnotmatch 'UninstallDisplayIcon=|DestDir:\s*"\{app\}|^Name:\s*"\{(group|userdesktop)\}') 'Task 3 installs no product icon registration workflow or shortcut before Ready' } $results += Invoke-CcodTest 'README and release workflow publish current portable-release branding' { @@ -2622,16 +4140,16 @@ $results += Invoke-CcodTest 'README and release workflow publish current portabl Assert-CcodTrue ($readmeChinese -cmatch '\A(?s:
.*?

CodexRemote-fix

)') 'Chinese README uses the centered public product heading' $quickStart = [regex]::Match($readme, '(?ms)^## Quick start[^\r\n]*\r?\n(.*?)(?=^## )').Groups[1].Value - Assert-CcodTrue ($quickStart -cmatch 'CodexRemote-fix-2\.5\.21-setup\.exe') 'English Quick Start names the setup installer' - Assert-CcodTrue ($quickStart -cmatch 'CodexRemote-fix-2\.5\.21-windows-x64\.zip') 'English Quick Start names the exact portable artifact' + Assert-CcodTrue ($quickStart -cmatch 'CodexRemote-fix-2\.5\.22-setup\.exe') 'English Quick Start names the setup installer' + Assert-CcodTrue ($quickStart -cmatch 'CodexRemote-fix-2\.5\.22-windows-x64\.zip') 'English Quick Start names the exact portable artifact' Assert-CcodTrue ($quickStart -cmatch 'CodexRemote-fix\.exe') 'English Quick Start names the portable double-click entrypoint' Assert-CcodTrue ($quickStart -cmatch '\.sha256\.txt') 'English Quick Start names the checksum artifact' $quickStartChineseMatch = [regex]::Match($readmeChinese, '(?ms)^## [^\r\n]+\r?\n(?:\r?\n)?(?=1\.[^\r\n]*\[Releases\])(.*?)(?=^## |\z)') Assert-CcodTrue $quickStartChineseMatch.Success 'Chinese README exposes a Quick Start section' $quickStartChinese = $quickStartChineseMatch.Groups[1].Value - Assert-CcodTrue ($quickStartChinese -cmatch 'CodexRemote-fix-2\.5\.21-setup\.exe') 'Chinese Quick Start names the setup installer' - Assert-CcodTrue ($quickStartChinese -cmatch 'CodexRemote-fix-2\.5\.21-windows-x64\.zip') 'Chinese Quick Start names the exact portable artifact' + Assert-CcodTrue ($quickStartChinese -cmatch 'CodexRemote-fix-2\.5\.22-setup\.exe') 'Chinese Quick Start names the setup installer' + Assert-CcodTrue ($quickStartChinese -cmatch 'CodexRemote-fix-2\.5\.22-windows-x64\.zip') 'Chinese Quick Start names the exact portable artifact' Assert-CcodTrue ($quickStartChinese -cmatch 'CodexRemote-fix\.exe') 'Chinese Quick Start names the portable double-click entrypoint' Assert-CcodTrue ($quickStartChinese -cmatch '\.sha256\.txt') 'Chinese Quick Start names the checksum artifact' @@ -2643,11 +4161,28 @@ $results += Invoke-CcodTest 'README and release workflow publish current portabl $workflow = Get-Content -LiteralPath (Join-Path $repositoryRoot '.github\workflows\release.yml') -Raw -Encoding UTF8 Assert-CcodTrue ($workflow -cmatch '(?m)^name: CodexRemote-fix release\r?$') 'release workflow uses public product branding' Assert-CcodTrue ($workflow -cmatch '(?m)^\s+name: CodexRemote-fix portable bundle\r?$') 'uploaded artifact uses public portable bundle branding' - Assert-CcodTrue ($workflow -cmatch '--title "CodexRemote-fix \$version"') 'GitHub release title uses public product branding' - Assert-CcodTrue ($workflow -cmatch 'englishSection = \[regex\]::Match') 'GitHub release notes extract the English changelog section only' - Assert-CcodTrue ($workflow -cmatch 'has no English release section') 'release fails clearly when the English changelog section is missing' + $draftTool = Join-Path $repositoryRoot 'tools\Invoke-GitHubDraftRelease.ps1' + $draftToolContent = Get-Content -LiteralPath $draftTool -Raw -Encoding UTF8 + Assert-CcodTrue ($draftToolContent -cmatch '(?s)release.*create.*--draft' -and $draftToolContent -cmatch 'CodexRemote-fix \$version') 'draft tool creates a private release with public product branding' + Assert-CcodTrue ($workflow -cmatch 'Invoke-CcodGitHubDraftRelease -Mode Stage') 'release workflow stages through the draft-only production tool' + Assert-CcodTrue ($workflow -cmatch 'tools\\New-GitHubReleaseNotes\.ps1''\) -ChangelogPath .*? -Tag \$tag -OutputPath \$notesPath') 'GitHub release notes use the behavior-tested target-English extractor' + Assert-CcodTrue ($workflow -cnotmatch 'englishSection\s*=\s*\[regex\]::Match') 'release workflow does not retain a second inline English extractor' + $notesTool = Join-Path $repositoryRoot 'tools\New-GitHubReleaseNotes.ps1' + Assert-CcodTrue (Test-Path -LiteralPath $notesTool -PathType Leaf) 'behavior-tested GitHub release notes extractor exists' + $notesFixtureRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-install-release-notes-' + [guid]::NewGuid().ToString('N')) + try { + [IO.Directory]::CreateDirectory($notesFixtureRoot) | Out-Null + $missingEnglishChangelog = Join-Path $notesFixtureRoot 'CHANGELOG.md' + $notesOutput = Join-Path $notesFixtureRoot 'notes.md' + [IO.File]::WriteAllText($missingEnglishChangelog,"## v2.5.22`r`n`r`n### Simplified Chinese`r`n`r`n- fixture`r`n",[Text.UTF8Encoding]::new($false)) + Assert-CcodThrows { & $notesTool -ChangelogPath $missingEnglishChangelog -Tag 'v2.5.22' -OutputPath $notesOutput | Out-Null } 'CCOD_RELEASE_NOTES_ENGLISH_SECTION_INVALID' + Assert-CcodTrue (-not (Test-Path -LiteralPath $notesOutput)) 'missing English notes cannot create a GitHub release body' + } finally { + if (Test-Path -LiteralPath $notesFixtureRoot) { Remove-Item -LiteralPath $notesFixtureRoot -Recurse -Force -ErrorAction SilentlyContinue } + } } +<# Superseded by Task 3 sealed package and Task 4 post-Ready registration. $results += Invoke-CcodTest 'installer carries the Inno contract needed by its self-validation' { $installerScript = Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss' $sourceEntries = @(Get-Content -LiteralPath $installerScript | Where-Object { $_ -cmatch '^Source: "\.\.\\build\\CodexControlOtherDevices\.iss"; DestDir: "\{app\}\\build";' }) @@ -2688,5 +4223,409 @@ $results += Invoke-CcodTest 'installer carries build.ps1 so installed self-valid $sourceEntries = @(Get-Content -LiteralPath $installerScript | Where-Object { $_ -cmatch '^Source: "\.\.\\build\\build\.ps1"; DestDir: "\{app\}\\build";' }) Assert-CcodEqual 1 $sourceEntries.Count 'installer carries build.ps1 required by Validate.ps1' } +#> + +$results += Invoke-CcodTest 'sealed Setup carries no mutable validation or legacy shortcut migration payload' { + $installerScript = Get-Content -LiteralPath (Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss') -Raw -Encoding UTF8 + Assert-CcodTrue ($installerScript -cnotmatch 'DestDir:\s*"\{app\}|\[InstallDelete\]|CodexControlOtherDevices\.iss"; DestDir|build\.ps1"; DestDir') 'Task 3 leaves installed validation and legacy registration migration to immutable payload and Task 4' + Assert-CcodTrue ($installerScript -cmatch '(?m)^CreateAppDir=no\s*$' -and $installerScript -cmatch '(?m)^Uninstallable=no\s*$') 'Setup cannot create the old mutable validation root' +} + +# Production mutation caught: product state written from ProtectionReady or before the terminal transaction snapshot. +$results += Invoke-CcodTest 'product registration runs exactly once only after the Task 2 Ready transaction is durable' { + $source=New-CcodLifecycleTempRoot;$install=New-CcodLifecycleTempRoot;$nodeRoot=New-CcodLifecycleTempRoot + try{ + New-CcodLifecycleSourceFixture -Root $source -Version '2.5.22'|Out-Null + $node=New-CcodLifecycleFakeNode -Root $nodeRoot;$fake=New-CcodLifecycleFake -NodePath $node + $result=Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -SealedPackageSha256 ('3'*64) -Adapters $fake.Adapters + Assert-CcodEqual 1 $fake.World.ProductRegistrationCalls 'successful install commits product registration exactly once' + Assert-CcodTrue $fake.World.ProductRegistrationReadyObserved 'registration adapter receives only the durable Ready transaction' + Assert-CcodTrue $result.ProductRegistrationVerified 'install result reports verified post-Ready registration' + }finally{foreach($path in @($source,$install,$nodeRoot)){if(Test-Path $path){Remove-Item $path -Recurse -Force}}} +} + +# Production mutation caught: an AlreadyInstalled return must not strand the +# temporary shortcut candidates prepared before the idempotence check. +$results += Invoke-CcodTest 'AlreadyInstalled product reconciliation removes temporary shortcut candidates before returning' { + $source=New-CcodLifecycleTempRoot;$install=New-CcodLifecycleTempRoot;$nodeRoot=New-CcodLifecycleTempRoot;$temporaryRoot=$null + try{ + New-CcodLifecycleSourceFixture -Root $source -Version '2.5.22'|Out-Null + $node=New-CcodLifecycleFakeNode -Root $nodeRoot;$first=New-CcodLifecycleFake -NodePath $node + $firstResult=Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -SealedPackageSha256 ('a'*64) -Adapters $first.Adapters + Assert-CcodEqual 'Installed' $firstResult.Outcome 'fixture reaches a Ready install before idempotent reconciliation' + $second=New-CcodLifecycleFake -NodePath $node + $preparedState=@{Calls=0;TemporaryRoot=$null} + $second.Adapters.AddProductShortcutCandidates={ + param($Files) + $preparedState.Calls++ + $temporaryRoot=Join-Path ([IO.Path]::GetTempPath()) ('ccod-lifecycle-same-identity-'+[guid]::NewGuid().ToString('N')) + [IO.Directory]::CreateDirectory($temporaryRoot)|Out-Null + [IO.File]::WriteAllText((Join-Path $temporaryRoot 'candidate.tmp'),'fixture',[Text.UTF8Encoding]::new($false)) + $preparedState.TemporaryRoot=$temporaryRoot + [pscustomobject]@{Files=@($Files);TemporaryRoot=$temporaryRoot} + }.GetNewClosure() + $secondResult=Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -SealedPackageSha256 ('a'*64) -Adapters $second.Adapters + Assert-CcodEqual 'AlreadyInstalled' $secondResult.Outcome 'same package takes the idempotent reconciliation path' + Assert-CcodEqual 0 $preparedState.Calls 'idempotent reconciliation checks package identity before preparing product candidates' + Assert-CcodTrue ([string]::IsNullOrWhiteSpace([string]$preparedState.TemporaryRoot)) 'idempotent reconciliation creates no temporary product candidates' + }finally{ + foreach($path in @($source,$install,$nodeRoot,$temporaryRoot)){if(-not[string]::IsNullOrWhiteSpace([string]$path)-and(Test-Path -LiteralPath $path)){Remove-Item -LiteralPath $path -Recurse -Force -ErrorAction SilentlyContinue}} + } +} + +# Production mutation caught: treating post-Ready registration failure as an install rollback or Failed lifecycle snapshot. +$results += Invoke-CcodTest 'registration failure preserves durable Ready without rollback or Failed transaction' { + $source=New-CcodLifecycleTempRoot;$install=New-CcodLifecycleTempRoot;$nodeRoot=New-CcodLifecycleTempRoot + try{ + New-CcodLifecycleSourceFixture -Root $source -Version '2.5.22'|Out-Null + $node=New-CcodLifecycleFakeNode -Root $nodeRoot;$fake=New-CcodLifecycleFake -NodePath $node;$fake.World.ProductRegistrationFailure=$true + Assert-CcodThrows { Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -SealedPackageSha256 ('3'*64) -Adapters $fake.Adapters|Out-Null } 'CCOD_PRODUCT_REGISTRATION_FAILED' + $module=Get-Module InstallLifecycle;$head=&$module {param($Root)Read-CcodInstallTransactionRecord -InstallRoot $Root} $install + Assert-CcodEqual 'Ready' $head.phase 'post-Ready registration failure cannot downgrade the lifecycle transaction' + Assert-CcodEqual 0 @(Get-ChildItem -LiteralPath (Join-Path $install 'state\install-transactions') -Filter '*.Failed.*.json' -File).Count 'registration failure appends no Failed transaction snapshot' + $retry=New-CcodLifecycleFake -NodePath $node + $result=Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -SealedPackageSha256 ('3'*64) -Adapters $retry.Adapters + Assert-CcodEqual 'AlreadyInstalled' $result.Outcome 'later same-package invocation retries after post-Ready registration failure' + Assert-CcodEqual 1 $retry.World.ProductRegistrationCalls 'normal Ready registration retry occurs exactly once' + }finally{foreach($path in @($source,$install,$nodeRoot)){if(Test-Path $path){Remove-Item $path -Recurse -Force}}} +} + +# Production mutation caught: normal first install passed its writable generation transaction to the default product path instead of reopening strict Ready authority. +$results += Invoke-CcodTest 'normal first install reaches the default registration path with only a strict product capability' { + $source=New-CcodLifecycleTempRoot;$install=New-CcodLifecycleTempRoot;$nodeRoot=New-CcodLifecycleTempRoot + try{ + New-CcodLifecycleSourceFixture -Root $source -Version '2.5.22'|Out-Null;$node=New-CcodLifecycleFakeNode -Root $nodeRoot;$fake=New-CcodLifecycleFake -NodePath $node + $productState=@{FailWrites=$false;Registration=$null;ReadyEvidence=$null;RetainedError=$null;Writes=0;ShortcutWrites=0;ProductOnlyObserved=$false;Shortcuts=@{}} + Set-CcodLifecycleDefaultProductRegistrationFixture -Fake $fake -ProductState $productState + $result=Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -SealedPackageSha256 ('6'*64) -Adapters $fake.Adapters + Assert-CcodEqual 'Installed' $result.Outcome 'normal first install completes through the real default registration adapter' + Assert-CcodTrue $result.ProductRegistrationVerified 'normal first install reports verified product registration' + Assert-CcodTrue $productState.ProductOnlyObserved 'lower shortcut side effect receives a product-only retained capability' + Assert-CcodEqual 1 $productState.Writes 'registry side effect boundary is invoked once' + Assert-CcodEqual 2 $productState.ShortcutWrites 'both shortcut side effect boundaries are invoked once' + }finally{foreach($path in @($source,$install,$nodeRoot)){if(Test-Path $path){Remove-Item $path -Recurse -Force}}} +} + +# Production mutation caught: default registration failure after durable Ready must remain outside lifecycle failure and reconcile through AlreadyInstalled. +$results += Invoke-CcodTest 'default first-install registration failure leaves Ready and retries with strict product authority' { + $source=New-CcodLifecycleTempRoot;$install=New-CcodLifecycleTempRoot;$nodeRoot=New-CcodLifecycleTempRoot + try{ + New-CcodLifecycleSourceFixture -Root $source -Version '2.5.22'|Out-Null;$node=New-CcodLifecycleFakeNode -Root $nodeRoot;$first=New-CcodLifecycleFake -NodePath $node + $failedState=@{FailWrites=$true;Registration=$null;ReadyEvidence=$null;RetainedError=$null;Writes=0;ShortcutWrites=0;ProductOnlyObserved=$false;Shortcuts=@{}};Set-CcodLifecycleDefaultProductRegistrationFixture -Fake $first -ProductState $failedState + Assert-CcodThrows {Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -SealedPackageSha256 ('7'*64) -Adapters $first.Adapters|Out-Null} 'CCOD_PRODUCT_REGISTRATION_FAILED' + $module=Get-Module InstallLifecycle -ErrorAction Stop;$head=&$module {param($Root)Read-CcodInstallTransactionRecord -InstallRoot $Root} $install + Assert-CcodEqual 'Ready' $head.phase 'default registration failure leaves the lifecycle transaction durably Ready' + Assert-CcodEqual 0 @(Get-ChildItem -LiteralPath (Join-Path $install 'state\install-transactions') -Filter '*.Failed.*.json' -File).Count 'default registration failure appends no Failed lifecycle snapshot' + $retry=New-CcodLifecycleFake -NodePath $node;$retryState=@{FailWrites=$false;Registration=$null;ReadyEvidence=$null;RetainedError=$null;Writes=0;ShortcutWrites=0;ProductOnlyObserved=$false;Shortcuts=@{}};Set-CcodLifecycleDefaultProductRegistrationFixture -Fake $retry -ProductState $retryState + $result=Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -SealedPackageSha256 ('7'*64) -Adapters $retry.Adapters + Assert-CcodEqual 'AlreadyInstalled' $result.Outcome 'later same-package invocation retries default product registration' + Assert-CcodTrue $retryState.ProductOnlyObserved 'recovery retry receives only strict product authority' + Assert-CcodEqual 2 $retryState.ShortcutWrites 'recovery retry publishes both shortcut records through lower side effects' + }finally{foreach($path in @($source,$install,$nodeRoot)){if(Test-Path $path){Remove-Item $path -Recurse -Force}}} +} + +# Production mutation caught: failure to close the writable generation at the post-Ready handoff must not enter lifecycle rollback or prevent reconciliation. +$results += Invoke-CcodTest 'post-Ready writable close failure preserves Ready and retries before product side effects' { + $source=New-CcodLifecycleTempRoot;$install=New-CcodLifecycleTempRoot;$nodeRoot=New-CcodLifecycleTempRoot + try{ + New-CcodLifecycleSourceFixture -Root $source -Version '2.5.22'|Out-Null;$node=New-CcodLifecycleFakeNode -Root $nodeRoot;$first=New-CcodLifecycleFake -NodePath $node + $failedState=@{FailWrites=$false;Registration=$null;ReadyEvidence=$null;RetainedError=$null;Writes=0;ShortcutWrites=0;ProductOnlyObserved=$false;Shortcuts=@{}};Set-CcodLifecycleDefaultProductRegistrationFixture -Fake $first -ProductState $failedState + $first.Adapters.CloseReadyGeneration={param($Transaction)throw [Management.Automation.ErrorRecord]::new([InvalidOperationException]::new('fixture Ready close failed'),'CCOD_INSTALL_CLOSE_FAILED',[Management.Automation.ErrorCategory]::InvalidData,$null)} + Assert-CcodThrows {Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -SealedPackageSha256 ('8'*64) -Adapters $first.Adapters|Out-Null} 'CCOD_PRODUCT_REGISTRATION_FAILED' + $module=Get-Module InstallLifecycle -ErrorAction Stop;$head=&$module {param($Root)Read-CcodInstallTransactionRecord -InstallRoot $Root} $install + Assert-CcodEqual 'Ready' $head.phase 'post-Ready writable close failure leaves the lifecycle transaction Ready' + Assert-CcodEqual 0 @(Get-ChildItem -LiteralPath (Join-Path $install 'state\install-transactions') -Filter '*.Failed.*.json' -File).Count 'post-Ready writable close failure appends no Failed snapshot' + Assert-CcodEqual 0 $failedState.Writes 'no product side effect begins before the writable generation closes' + $retry=New-CcodLifecycleFake -NodePath $node;$retryState=@{FailWrites=$false;Registration=$null;ReadyEvidence=$null;RetainedError=$null;Writes=0;ShortcutWrites=0;ProductOnlyObserved=$false;Shortcuts=@{}};Set-CcodLifecycleDefaultProductRegistrationFixture -Fake $retry -ProductState $retryState + $result=Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -SealedPackageSha256 ('8'*64) -Adapters $retry.Adapters + Assert-CcodEqual 'AlreadyInstalled' $result.Outcome 'same-package retry reconciles after post-Ready writable close failure' + Assert-CcodTrue $retryState.ProductOnlyObserved 'retry uses strict product authority after cleanup succeeds' + Assert-CcodEqual 2 $retryState.ShortcutWrites 'retry completes both lower shortcut side effects' + }finally{foreach($path in @($source,$install,$nodeRoot)){if(Test-Path $path){Remove-Item $path -Recurse -Force}}} +} + +# Production mutation caught: marking a dead owner's Pending fence Completed without opening and closing fresh strict authority skips the required cleanup proof. +$results += Invoke-CcodTest 'dead-owner Pending fence requires fresh strict cleanup before registration' { + Invoke-CcodLifecycleDeadOwnerCleanupTest +} + +# Production mutation caught: initializing cleanup as complete before strict OpenProduct lets an open failure append a false Completed fence. +$results += Invoke-CcodTest 'strict product open failure retains Pending and later reconciles before verified success' { + $source=New-CcodLifecycleTempRoot;$install=New-CcodLifecycleTempRoot;$nodeRoot=New-CcodLifecycleTempRoot + $openFixture=$null + try{ + New-CcodLifecycleSourceFixture -Root $source -Version '2.5.22'|Out-Null;$node=New-CcodLifecycleFakeNode -Root $nodeRoot;$fake=New-CcodLifecycleFake -NodePath $node + $productState=@{FailWrites=$false;Registration=$null;ReadyEvidence=$null;RetainedError=$null;Writes=0;ShortcutWrites=0;ProductOnlyObserved=$false;Shortcuts=@{}};Set-CcodLifecycleDefaultProductRegistrationFixture -Fake $fake -ProductState $productState + $fake.Adapters.RegisterProduct={param($InstallRoot,$RuntimeId,$Version,$PackageSha256,$FileTransaction,$TransactionRecord)[pscustomobject]@{verified=$true;legacyRemoved=$true}} + $null=Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -SealedPackageSha256 ('c'*64) -Adapters $fake.Adapters + [void]$fake.Adapters.Remove('RegisterProduct') + $openState=@{FailuresRemaining=1};$openFixture=Set-CcodLifecycleProductOpenFailureFixture -State $openState + $unexpectedReceipt=$null;$failure=$null + try{$unexpectedReceipt=Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -SealedPackageSha256 ('c'*64) -Adapters $fake.Adapters}catch{$failure=$_} + Assert-CcodEqual $null $unexpectedReceipt 'strict product open failure emits no verified registration receipt' + $records=@(Get-CcodLifecycleProductCleanupTestRecords -InstallRoot $install) + Assert-CcodEqual 1 $records.Count 'strict product open failure leaves only its durable Pending record' + Assert-CcodEqual 'Pending' $records[0].State 'strict product open failure never appends Completed without an opened transaction' + Assert-CcodEqual 'CCOD_PRODUCT_REGISTRATION_FAILED' (([string]$failure.FullyQualifiedErrorId-split',')[0]) 'strict product open failure is normalized to the stable product registration error' + Assert-CcodEqual 1 $openState.Attempts 'failed registration makes exactly one strict product open attempt' + Assert-CcodEqual 0 $productState.Writes 'strict product open failure performs no product write' + Assert-CcodEqual 0 $productState.ShortcutWrites 'strict product open failure performs no shortcut write' + Assert-CcodEqual 0 @(Get-ChildItem -LiteralPath (Join-Path $install 'state\install-transactions') -Filter '*.Failed.*.json' -File).Count 'strict product open failure appends no lifecycle Failed snapshot' + + $retry=Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -SealedPackageSha256 ('c'*64) -Adapters $fake.Adapters + Assert-CcodEqual 'AlreadyInstalled' $retry.Outcome 'same-package retry reconciles the open-failure Pending fence' + Assert-CcodTrue $retry.ProductRegistrationVerified 'same-package retry verifies only after exact strict cleanup and new registration close' + Assert-CcodEqual 3 $openState.Attempts 'retry opens once to reconcile the old Pending fence and once for new verification' + $records=@(Get-CcodLifecycleProductCleanupTestRecords -InstallRoot $install) + Assert-CcodEqual '1:Pending|1:Completed|2:Pending|2:Completed' (($records|ForEach-Object{"$($_.Attempt):$($_.State)"})-join '|') 'open-failure recovery preserves append-only exact cleanup history' + }finally{ + Clear-CcodLifecycleProductOpenFailureFixture -Fixture $openFixture + foreach($path in @($source,$install,$nodeRoot)){if(Test-Path $path){Remove-Item $path -Recurse -Force}} + } +} + +# Production mutation caught: a Ready-close fence assertion must not skip native abort cleanup after strict OpenProduct succeeds but binding fails. +$results += Invoke-CcodTest 'fence bind failure aborts the real strict transaction before exact retry recovery' { + $source=New-CcodLifecycleTempRoot;$install=New-CcodLifecycleTempRoot;$nodeRoot=New-CcodLifecycleTempRoot;$markerRoot=New-CcodLifecycleTempRoot + $bindFixture=$null;$fileModule=$null + try{ + New-CcodLifecycleSourceFixture -Root $source -Version '2.5.22'|Out-Null;$node=New-CcodLifecycleFakeNode -Root $nodeRoot;$fake=New-CcodLifecycleFake -NodePath $node + $productState=@{FailWrites=$false;Registration=$null;ReadyEvidence=$null;RetainedError=$null;Writes=0;ShortcutWrites=0;ProductOnlyObserved=$false;Shortcuts=@{}};Set-CcodLifecycleDefaultProductRegistrationFixture -Fake $fake -ProductState $productState + $fake.Adapters.RegisterProduct={param($InstallRoot,$RuntimeId,$Version,$PackageSha256,$FileTransaction,$TransactionRecord)[pscustomobject]@{verified=$true;legacyRemoved=$true}} + $null=Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -SealedPackageSha256 ('d'*64) -Adapters $fake.Adapters + [void]$fake.Adapters.Remove('RegisterProduct') + $lowerState=@{FailuresRemaining=0};$fileModule=Set-CcodLifecycleLowerProductCloseFixture -State $lowerState + $bindState=@{FailuresRemaining=1};$bindFixture=Set-CcodLifecycleProductFenceBindFailureFixture -State $bindState + + $unexpectedReceipt=$null;$failure=$null + try{$unexpectedReceipt=Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -SealedPackageSha256 ('d'*64) -Adapters $fake.Adapters}catch{$failure=$_} + Assert-CcodEqual $null $unexpectedReceipt 'bind failure emits no verified registration receipt' + Assert-CcodEqual 'CCOD_PRODUCT_REGISTRATION_FAILED' (([string]$failure.FullyQualifiedErrorId-split',')[0]) 'bind failure is normalized to the stable product registration error' + $records=@(Get-CcodLifecycleProductCleanupTestRecords -InstallRoot $install) + Assert-CcodEqual '1:Pending' (($records|ForEach-Object{"$($_.Attempt):$($_.State)"})-join '|') 'bind failure retains Pending without false completion' + Assert-CcodEqual 0 $productState.Writes 'bind failure performs no product write' + Assert-CcodEqual 0 $productState.ShortcutWrites 'bind failure performs no shortcut write' + + $child=Invoke-CcodLifecycleOtherProcessProductReconciliation -SourceRoot $source -InstallRoot $install -NodePath $node -PackageSha256 ('d'*64) -MarkerRoot $markerRoot + Assert-CcodTrue $child.MutexAcquired 'Failed abort releases the inner real AccountTransition mutex for another process' + Assert-CcodEqual 23 $child.ExitCode 'another process observes live Pending after acquiring the released mutex' + Assert-CcodEqual $false $child.AuthorityOpened 'live Pending blocks the other process before strict product authority' + Assert-CcodEqual 1 $lowerState.Attempts 'unbound strict transaction executes one real lower Failed close' + + $bindState.FailuresRemaining=0 + $retry=$null;$retryFailure=$null + try{$retry=Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -SealedPackageSha256 ('d'*64) -Adapters $fake.Adapters}catch{$retryFailure=$_} + Assert-CcodEqual $null $retryFailure "same-package retry reaches fresh recovery bind=$($bindState.Attempts) close=$($lowerState.Attempts) writes=$($productState.Writes) shortcuts=$($productState.ShortcutWrites) retained=$($productState.RetainedError)" + Assert-CcodEqual 'AlreadyInstalled' $retry.Outcome 'same-package retry recovers the exact Pending only through fresh bound authority' + Assert-CcodTrue $retry.ProductRegistrationVerified 'retry verifies only after fresh bound Ready close succeeds' + Assert-CcodEqual 3 $bindState.Attempts 'retry binds one fresh recovery transaction and one new verification transaction' + Assert-CcodEqual 3 $lowerState.Attempts 'Failed abort and two bound Ready closes all reach real lower cleanup' + $records=@(Get-CcodLifecycleProductCleanupTestRecords -InstallRoot $install) + Assert-CcodEqual '1:Pending|1:Completed|2:Pending|2:Completed' (($records|ForEach-Object{"$($_.Attempt):$($_.State)"})-join '|') 'bind failure recovery preserves exact append-only cleanup history' + Assert-CcodEqual 0 @(Get-ChildItem -LiteralPath (Join-Path $install 'state\install-transactions') -Filter '*.Failed.*.json' -File).Count 'post-Ready bind failure appends no lifecycle Failed snapshot' + }finally{ + Clear-CcodLifecycleProductFenceBindFailureFixture -Fixture $bindFixture + Clear-CcodLifecycleLowerProductCloseFixture -FileModule $fileModule + foreach($path in @($source,$install,$nodeRoot,$markerRoot)){if(Test-Path $path){try{Remove-Item $path -Recurse -Force}catch{}}} + } +} + +# Production mutation caught: validating every historical record against the latest Ready transaction makes any completed prior version poison an upgrade. +$results += Invoke-CcodTest 'completed cleanup history from an older Ready transaction permits new upgrade registration' { + $source=New-CcodLifecycleTempRoot;$install=New-CcodLifecycleTempRoot;$nodeRoot=New-CcodLifecycleTempRoot + try{ + New-CcodLifecycleSourceFixture -Root $source -Version '2.5.22'|Out-Null;$node=New-CcodLifecycleFakeNode -Root $nodeRoot;$firstFake=New-CcodLifecycleFake -NodePath $node + $firstProduct=@{FailWrites=$false;Registration=$null;ReadyEvidence=$null;RetainedError=$null;Writes=0;ShortcutWrites=0;ProductOnlyObserved=$false;Shortcuts=@{}};Set-CcodLifecycleDefaultProductRegistrationFixture -Fake $firstFake -ProductState $firstProduct + $first=Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters $firstFake.Adapters + $module=Get-Module InstallLifecycle -ErrorAction Stop;$oldReady=&$module {param($Root)Read-CcodInstallTransactionRecord -InstallRoot $Root} $install + Assert-CcodEqual 'Installed' $first.Outcome 'first product version reaches Ready registration' + Assert-CcodEqual '1:Pending|1:Completed' ((@(Get-CcodLifecycleProductCleanupTestRecords -InstallRoot $install)|ForEach-Object{"$($_.Attempt):$($_.State)"})-join '|') 'first Ready transaction owns one complete cleanup attempt' + Set-CcodLifecycleTestStatus -InstallRoot $install -RuntimeId $first.RuntimeId + [IO.File]::WriteAllText((Join-Path $source 'src\runtime\main-payload.js'),"module.exports = 'fixture-upgrade-cleanup-history';`n",[Text.UTF8Encoding]::new($false)) + $upgradeFake=New-CcodLifecycleFake -NodePath $node;$upgradeProduct=@{FailWrites=$false;Registration=$null;ReadyEvidence=$null;RetainedError=$null;Writes=0;ShortcutWrites=0;ProductOnlyObserved=$false;Shortcuts=@{}};Set-CcodLifecycleDefaultProductRegistrationFixture -Fake $upgradeFake -ProductState $upgradeProduct + $upgrade=$null;$upgradeFailure=$null + try{$upgrade=Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters $upgradeFake.Adapters}catch{$upgradeFailure=$_} + $upgradeErrorId=if($null-eq$upgradeFailure){$null}else{([string]$upgradeFailure.FullyQualifiedErrorId-split',')[0]} + Assert-CcodEqual $null $upgradeErrorId 'older completed cleanup history is ignored for the new Ready attempt sequence' + Assert-CcodEqual 'Upgraded' $upgrade.Outcome 'new Ready transaction completes upgrade registration' + Assert-CcodTrue $upgrade.ProductRegistrationVerified 'new upgrade reports verified only after its own cleanup completion' + $newReady=&$module {param($Root)Read-CcodInstallTransactionRecord -InstallRoot $Root} $install + Assert-CcodTrue ($newReady.transactionId-cne$oldReady.transactionId) 'upgrade commits a distinct Ready transaction identity' + $records=@(Get-CcodLifecycleProductCleanupTestRecords -InstallRoot $install) + Assert-CcodEqual 4 $records.Count 'append-only history retains both transaction cleanup pairs' + $oldRecords=@($records|Where-Object{$_.Record.transactionId-ceq$oldReady.transactionId});$newRecords=@($records|Where-Object{$_.Record.transactionId-ceq$newReady.transactionId}) + Assert-CcodEqual '1:Pending|1:Completed' (($oldRecords|ForEach-Object{"$($_.Attempt):$($_.State)"})-join '|') 'older transaction retains its independent attempt sequence' + Assert-CcodEqual '1:Pending|1:Completed' (($newRecords|ForEach-Object{"$($_.Attempt):$($_.State)"})-join '|') 'new transaction starts and completes its own attempt sequence' + Assert-CcodEqual 1 $upgradeProduct.Writes 'upgrade reaches the product write boundary once' + Assert-CcodEqual 2 $upgradeProduct.ShortcutWrites 'upgrade reaches both shortcut write boundaries' + }finally{foreach($path in @($source,$install,$nodeRoot)){if(Test-Path $path){Remove-Item $path -Recurse -Force}}} +} + +# Production mutation caught: resolving old cleanup only after the selector advances makes the old Ready authority permanently stale. +$results += Invoke-CcodTest 'upgrade resolves a dead-owner old Pending while the old Ready is still selected' { + $source=New-CcodLifecycleTempRoot;$install=New-CcodLifecycleTempRoot;$nodeRoot=New-CcodLifecycleTempRoot + try{ + New-CcodLifecycleSourceFixture -Root $source -Version '2.5.22'|Out-Null;$node=New-CcodLifecycleFakeNode -Root $nodeRoot;$firstFake=New-CcodLifecycleFake -NodePath $node + $first=Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters $firstFake.Adapters + $module=Get-Module InstallLifecycle -ErrorAction Stop;$oldReady=&$module {param($Root)Read-CcodInstallTransactionRecord -InstallRoot $Root} $install;$currentOwner=Get-CcodLifecycleCurrentProductCleanupTestIdentity + $owner=[pscustomobject][ordered]@{pid=[int]2147483647;creationTimeUtc='2000-01-01T00:00:00.0000000Z';userSid=$currentOwner.userSid} + $outer=&$module {Enter-CcodLifecycleProductCleanupLease} + try{&$module {param($Root,$Ready,$Identity)[void](New-CcodLifecycleProductCleanupFence -InstallRoot $Root -ReadyTransaction $Ready -OwnerIdentity $Identity)} $install $oldReady $owner} + finally{&$module {param($Context)[void](Exit-CcodLifecycleProductCleanupLease -Context $Context)} $outer} + Set-CcodLifecycleTestStatus -InstallRoot $install -RuntimeId $first.RuntimeId + [IO.File]::WriteAllText((Join-Path $source 'src\runtime\main-payload.js'),"module.exports = 'fixture-upgrade-prior-pending';`n",[Text.UTF8Encoding]::new($false)) + $upgradeFake=New-CcodLifecycleFake -NodePath $node;$upgradeProduct=@{FailWrites=$false;Registration=$null;ReadyEvidence=$null;RetainedError=$null;Writes=0;ShortcutWrites=0;ProductOnlyObserved=$false;Shortcuts=@{}};Set-CcodLifecycleDefaultProductRegistrationFixture -Fake $upgradeFake -ProductState $upgradeProduct + $upgrade=$null;$failure=$null + try{$upgrade=Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters $upgradeFake.Adapters}catch{$failure=$_} + Assert-CcodEqual $null $failure 'dead-owner old Pending is reconciled before selector advance' + Assert-CcodEqual 'Upgraded' $upgrade.Outcome 'upgrade proceeds only after old cleanup completion' + Assert-CcodTrue $upgrade.ProductRegistrationVerified 'upgrade verifies only after old and new strict cleanup complete' + $newReady=&$module {param($Root)Read-CcodInstallTransactionRecord -InstallRoot $Root} $install + Assert-CcodTrue ($newReady.transactionId-cne$oldReady.transactionId) 'upgrade commits a distinct Ready only after old cleanup completion' + $records=@(Get-CcodLifecycleProductCleanupTestRecords -InstallRoot $install) + $oldRecords=@($records|Where-Object{$_.Record.transactionId-ceq$oldReady.transactionId});$newRecords=@($records|Where-Object{$_.Record.transactionId-ceq$newReady.transactionId}) + Assert-CcodEqual '1:Pending|1:Completed' (($oldRecords|ForEach-Object{"$($_.Attempt):$($_.State)"})-join '|') 'old Pending completes against the still-selected old Ready' + Assert-CcodEqual '1:Pending|1:Completed' (($newRecords|ForEach-Object{"$($_.Attempt):$($_.State)"})-join '|') 'new Ready owns an independent completed cleanup attempt' + Assert-CcodEqual 1 $upgradeProduct.Writes 'upgrade reaches the product write boundary once after old cleanup' + Assert-CcodEqual 2 $upgradeProduct.ShortcutWrites 'upgrade reaches both shortcut writes after old cleanup' + Assert-CcodEqual 0 @(Get-ChildItem -LiteralPath (Join-Path $install 'state\install-transactions') -Filter '*.Failed.*.json' -File).Count 'old cleanup recovery fabricates no lifecycle Failed snapshot' + }finally{foreach($path in @($source,$install,$nodeRoot)){if(Test-Path $path){Remove-Item $path -Recurse -Force}}} +} + +# Production mutation caught: accepting an extra cleanup member weakens the exact ten-field durable authority contract. +$results += Invoke-CcodTest 'cleanup history rejects an unknown record field before product authority opens' { + $source=New-CcodLifecycleTempRoot;$install=New-CcodLifecycleTempRoot;$nodeRoot=New-CcodLifecycleTempRoot + try{ + New-CcodLifecycleSourceFixture -Root $source -Version '2.5.22'|Out-Null;$node=New-CcodLifecycleFakeNode -Root $nodeRoot;$fake=New-CcodLifecycleFake -NodePath $node + $productState=@{FailWrites=$false;Registration=$null;ReadyEvidence=$null;RetainedError=$null;Writes=0;ShortcutWrites=0;ProductOnlyObserved=$false;Shortcuts=@{}};Set-CcodLifecycleDefaultProductRegistrationFixture -Fake $fake -ProductState $productState + $null=Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -SealedPackageSha256 ('2'*64) -Adapters $fake.Adapters + $completed=@(Get-ChildItem -LiteralPath (Join-Path $install 'state\product-cleanup-fences') -Filter '*.Completed.*.json' -File) + Assert-CcodEqual 1 $completed.Count 'fixture starts from one valid completed cleanup record' + $record=(Get-Content -LiteralPath $completed[0].FullName -Raw)|ConvertFrom-Json + $mutated=[ordered]@{};foreach($property in $record.PSObject.Properties){$mutated[$property.Name]=$property.Value};$mutated['unknownFenceField']='rejected' + $completed[0].IsReadOnly=$false + [IO.File]::WriteAllText($completed[0].FullName,(($mutated|ConvertTo-Json -Depth 8 -Compress)+"`n"),[Text.UTF8Encoding]::new($false)) + $completed[0].Refresh();$completed[0].IsReadOnly=$true + $productState.Writes=0;$productState.ShortcutWrites=0 + $unexpectedReceipt=$null;$failure=$null + try{$unexpectedReceipt=Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -SealedPackageSha256 ('2'*64) -Adapters $fake.Adapters}catch{$failure=$_} + Assert-CcodEqual $null $unexpectedReceipt 'unknown cleanup field emits no verified receipt' + Assert-CcodEqual 'CCOD_PRODUCT_REGISTRATION_FAILED' (([string]$failure.FullyQualifiedErrorId-split',')[0]) 'unknown cleanup field fails closed with the stable error' + Assert-CcodEqual 0 $productState.Writes 'unknown cleanup field blocks before product writes' + Assert-CcodEqual 0 $productState.ShortcutWrites 'unknown cleanup field blocks before shortcut writes' + }finally{foreach($path in @($source,$install,$nodeRoot)){if(Test-Path $path){Remove-Item $path -Recurse -Force}}} +} + +# Production mutation caught: broad temp-name deletion could remove attacker-controlled reparse, ADS, hardlink, or live objects from the authority plane. +$results += Invoke-CcodTest 'cleanup fence orphan recovery rejects every hostile temporary lookalike' { + foreach($case in @('unexpected-name','reparse','ads','hardlink','open')){ + $source=New-CcodLifecycleTempRoot;$install=New-CcodLifecycleTempRoot;$nodeRoot=New-CcodLifecycleTempRoot;$outside=New-CcodLifecycleTempRoot;$held=$null + try{ + New-CcodLifecycleSourceFixture -Root $source -Version '2.5.22'|Out-Null;$node=New-CcodLifecycleFakeNode -Root $nodeRoot;$fake=New-CcodLifecycleFake -NodePath $node + $null=Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -SealedPackageSha256 ('e'*64) -Adapters $fake.Adapters + $plane=Join-Path $install 'state\product-cleanup-fences';[IO.Directory]::CreateDirectory($plane)|Out-Null + $exactName='.ccod.'+('a'*32)+'.tmp';$name=if($case-ceq'unexpected-name'){'.ccod.not-a-canonical-guid.tmp'}else{$exactName};$artifact=Join-Path $plane $name + switch($case){ + 'unexpected-name'{[IO.File]::WriteAllText($artifact,'hostile unexpected name',[Text.UTF8Encoding]::new($false))} + 'reparse'{[IO.Directory]::CreateDirectory($outside)|Out-Null;New-Item -ItemType Junction -Path $artifact -Target $outside|Out-Null} + 'ads'{[IO.File]::WriteAllText($artifact,'hostile alternate stream',[Text.UTF8Encoding]::new($false));$previous=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$output=&cmd.exe /d /c "echo ads>`"$($artifact):metadata`"" 2>&1;$exitCode=$LASTEXITCODE}finally{$ErrorActionPreference=$previous};if($exitCode-ne0){throw "ADS fixture failed: $($output-join' ')"}} + 'hardlink'{[IO.Directory]::CreateDirectory($outside)|Out-Null;$outsideFile=Join-Path $outside 'hostile.tmp';[IO.File]::WriteAllText($outsideFile,'hostile hardlink',[Text.UTF8Encoding]::new($false));New-Item -ItemType HardLink -Path $artifact -Target $outsideFile|Out-Null} + 'open'{[IO.File]::WriteAllText($artifact,'hostile live handle',[Text.UTF8Encoding]::new($false));$held=[IO.File]::Open($artifact,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete)} + } + [void]$fake.Adapters.Remove('RegisterProduct');$productState=@{FailWrites=$false;Registration=$null;ReadyEvidence=$null;RetainedError=$null;Writes=0;ShortcutWrites=0;ProductOnlyObserved=$false;Shortcuts=@{}};Set-CcodLifecycleDefaultProductRegistrationFixture -Fake $fake -ProductState $productState + $unexpectedReceipt=$null;$failure=$null + try{$unexpectedReceipt=Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -SealedPackageSha256 ('e'*64) -Adapters $fake.Adapters}catch{$failure=$_} + Assert-CcodEqual $null $unexpectedReceipt "$case hostile temporary emits no verified receipt" + Assert-CcodEqual 'CCOD_PRODUCT_REGISTRATION_FAILED' (([string]$failure.FullyQualifiedErrorId-split',')[0]) "$case hostile temporary fails closed" + Assert-CcodTrue (Test-Path -LiteralPath $artifact) "$case hostile object is never deleted or ignored" + Assert-CcodEqual 0 $productState.Writes "$case blocks before product writes" + Assert-CcodEqual 0 $productState.ShortcutWrites "$case blocks before shortcut writes" + }finally{ + if($null-ne$held){$held.Dispose()} + foreach($path in @($source,$install,$nodeRoot,$outside)){if(Test-Path $path){Remove-Item $path -Recurse -Force}} + } + } +} + +# Production mutation caught: releasing strict product authority after a lower close failure without a durable fence lets another process report verified registration. +$results += Invoke-CcodTest 'live-owner durable product cleanup blocks another real process after mutex acquisition' { + $source=New-CcodLifecycleTempRoot;$install=New-CcodLifecycleTempRoot;$nodeRoot=New-CcodLifecycleTempRoot;$markerRoot=New-CcodLifecycleTempRoot + $fileModule=$null;$first=$null + try{ + New-CcodLifecycleSourceFixture -Root $source -Version '2.5.22'|Out-Null;$node=New-CcodLifecycleFakeNode -Root $nodeRoot;$first=New-CcodLifecycleFake -NodePath $node + $productState=@{FailWrites=$false;Registration=$null;ReadyEvidence=$null;RetainedError=$null;Writes=0;ShortcutWrites=0;ProductOnlyObserved=$false;Shortcuts=@{}};Set-CcodLifecycleDefaultProductRegistrationFixture -Fake $first -ProductState $productState + $lowerState=@{FailuresRemaining=1};$fileModule=Set-CcodLifecycleLowerProductCloseFixture -State $lowerState + $unexpectedReceipt=$null;$failure=$null + try{$unexpectedReceipt=Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -SealedPackageSha256 ('a'*64) -Adapters $first.Adapters}catch{$failure=$_} + Assert-CcodEqual $null $unexpectedReceipt 'lower native close failure returns no verified receipt to the first process' + Assert-CcodEqual 'CCOD_PRODUCT_REGISTRATION_FAILED' (([string]$failure.FullyQualifiedErrorId-split',')[0]) 'lower native close failure surfaces as product registration failure' + $module=Get-Module InstallLifecycle -ErrorAction Stop;$ready=&$module {param($Root)Read-CcodInstallTransactionRecord -InstallRoot $Root} $install + Assert-CcodEqual 'Ready' $ready.phase 'first process lower close failure leaves lifecycle Ready' + Assert-CcodEqual 0 @(Get-ChildItem -LiteralPath (Join-Path $install 'state\install-transactions') -Filter '*.Failed.*.json' -File).Count 'first process lower close failure appends no Failed lifecycle snapshot' + + $child=Invoke-CcodLifecycleOtherProcessProductReconciliation -SourceRoot $source -InstallRoot $install -NodePath $node -PackageSha256 ('a'*64) -MarkerRoot $markerRoot + [Console]::Error.WriteLine(('CCOD_CROSS_PROCESS_OBSERVED exit={0} mutex={1} authority={2} product={3} verified={4} error={5}'-f$child.ExitCode,$child.MutexAcquired,$child.AuthorityOpened,$child.ProductWritten,$child.Verified,$child.ErrorId)) + Assert-CcodTrue $child.MutexAcquired 'fresh process acquires the real AccountTransition mutex after the first failure' + Assert-CcodEqual 23 $child.ExitCode 'fresh process fails closed on the durable pending fence' + Assert-CcodEqual 'CCOD_PRODUCT_REGISTRATION_FAILED' $child.ErrorId 'fresh process reports the stable product registration failure' + Assert-CcodEqual $false $child.AuthorityOpened 'fresh process opens no strict product authority while the live owner is pending' + Assert-CcodEqual $false $child.ProductWritten 'fresh process writes no product registration or shortcut' + Assert-CcodEqual $false $child.Verified 'fresh process emits no verified success' + Assert-CcodEqual $null $child.ChildFailure 'fresh process has no setup or adapter failure' + $records=@(Get-CcodLifecycleProductCleanupTestRecords -InstallRoot $install);$owner=Get-CcodLifecycleCurrentProductCleanupTestIdentity + Assert-CcodEqual 1 $records.Count 'other process observes one on-disk Pending fence rather than parent memory' + Assert-CcodLifecycleProductCleanupTestRecord -Actual $records[0].Record -Ready $ready -Owner $owner -State Pending + Assert-CcodEqual 1 $lowerState.Attempts 'first process performs exactly one lower product close attempt' + }finally{ + Clear-CcodLifecycleLowerProductCloseFixture -FileModule $fileModule + if($null-ne$first-and(Test-Path -LiteralPath $install)){try{Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -SealedPackageSha256 ('a'*64) -Adapters $first.Adapters|Out-Null}catch{}} + foreach($path in @($source,$install,$nodeRoot,$markerRoot)){if(Test-Path $path){Remove-Item $path -Recurse -Force}} + } +} + +# Production mutation caught: dropping the retained same-thread capability or completing its fence before exact close permits false verified success after Ready. +$results += Invoke-CcodTest 'same-thread AlreadyInstalled drains durable product cleanup before verified success' { + $source=New-CcodLifecycleTempRoot;$install=New-CcodLifecycleTempRoot;$nodeRoot=New-CcodLifecycleTempRoot + $fileModule=$null;$cleanupNeeded=$true + try{ + New-CcodLifecycleSourceFixture -Root $source -Version '2.5.22'|Out-Null;$node=New-CcodLifecycleFakeNode -Root $nodeRoot;$first=New-CcodLifecycleFake -NodePath $node + $productState=@{FailWrites=$false;Registration=$null;ReadyEvidence=$null;RetainedError=$null;Writes=0;ShortcutWrites=0;ProductOnlyObserved=$false;Shortcuts=@{}};Set-CcodLifecycleDefaultProductRegistrationFixture -Fake $first -ProductState $productState + $lowerState=@{FailuresRemaining=2};$fileModule=Set-CcodLifecycleLowerProductCloseFixture -State $lowerState + $firstReceipt=$null;$firstFailure=$null + try{$firstReceipt=Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -SealedPackageSha256 ('9'*64) -Adapters $first.Adapters}catch{$firstFailure=$_} + Assert-CcodEqual $null $firstReceipt 'first lower close failure emits no successful install or registration receipt' + Assert-CcodEqual 'CCOD_PRODUCT_REGISTRATION_FAILED' (([string]$firstFailure.FullyQualifiedErrorId-split',')[0]) 'first lower close failure is a stable product registration failure' + $module=Get-Module InstallLifecycle -ErrorAction Stop;$ready=&$module {param($Root)Read-CcodInstallTransactionRecord -InstallRoot $Root} $install;$owner=Get-CcodLifecycleCurrentProductCleanupTestIdentity + Assert-CcodEqual 'Ready' $ready.phase 'first lower close failure retains lifecycle Ready' + Assert-CcodEqual 0 @(Get-ChildItem -LiteralPath (Join-Path $install 'state\install-transactions') -Filter '*.Failed.*.json' -File).Count 'first lower close failure appends no lifecycle Failed snapshot' + $records=@(Get-CcodLifecycleProductCleanupTestRecords -InstallRoot $install) + Assert-CcodEqual 1 $records.Count 'first lower close failure leaves exactly one durable Pending record' + Assert-CcodLifecycleProductCleanupTestRecord -Actual $records[0].Record -Ready $ready -Owner $owner -State Pending + Assert-CcodEqual 1 $lowerState.Attempts 'one Invoke call performs one lower close attempt' + + $secondReceipt=$null;$secondFailure=$null + try{$secondReceipt=Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -SealedPackageSha256 ('9'*64) -Adapters $first.Adapters}catch{$secondFailure=$_} + Assert-CcodEqual $null $secondReceipt 'second retained-capability close failure emits no verified receipt' + Assert-CcodEqual 'CCOD_PRODUCT_REGISTRATION_FAILED' (([string]$secondFailure.FullyQualifiedErrorId-split',')[0]) 'second retained-capability close failure remains retryable' + Assert-CcodEqual 2 $lowerState.Attempts 'second same-thread invocation retries the original lower close exactly once' + Assert-CcodEqual 'Ready' (&$module {param($Root)(Read-CcodInstallTransactionRecord -InstallRoot $Root).phase} $install) 'two close failures leave lifecycle Ready' + Assert-CcodEqual 0 @(Get-ChildItem -LiteralPath (Join-Path $install 'state\install-transactions') -Filter '*.Failed.*.json' -File).Count 'two close failures append no lifecycle Failed snapshot' + Assert-CcodEqual 1 @(Get-CcodLifecycleProductCleanupTestRecords -InstallRoot $install).Count 'failed retained retry appends no false Completed record' + + $result=Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -SealedPackageSha256 ('9'*64) -Adapters $first.Adapters + Assert-CcodEqual 'AlreadyInstalled' $result.Outcome 'exact same-package invocation returns only after original cleanup drains' + Assert-CcodTrue $result.ProductRegistrationVerified 'exact same-package invocation verifies only after durable completion' + Assert-CcodEqual 4 $lowerState.Attempts 'successful retry closes the retained original and the new strict verification transaction' + $records=@(Get-CcodLifecycleProductCleanupTestRecords -InstallRoot $install) + Assert-CcodEqual 4 $records.Count 'successful retry appends original completion and a complete verification pair' + Assert-CcodEqual '1:Pending|1:Completed|2:Pending|2:Completed' (($records|ForEach-Object{"$($_.Attempt):$($_.State)"})-join '|') 'cleanup history is append-only and ordered' + Assert-CcodLifecycleProductCleanupTestRecord -Actual $records[1].Record -Ready $ready -Owner $owner -State Completed + $cleanupNeeded=$false + }finally{ + Clear-CcodLifecycleLowerProductCloseFixture -FileModule $fileModule + if($cleanupNeeded-and(Test-Path -LiteralPath $install)){try{Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -SealedPackageSha256 ('9'*64) -Adapters $first.Adapters|Out-Null}catch{}} + foreach($path in @($source,$install,$nodeRoot)){if(Test-Path $path){Remove-Item $path -Recurse -Force}} + } +} Write-Output "Install lifecycle self-tests passed: $($results.Count)" diff --git a/tests/persistence/InstalledLifecycleHarness.SelfTest.ps1 b/tests/persistence/InstalledLifecycleHarness.SelfTest.ps1 index ef0e4ed..04b33c4 100644 --- a/tests/persistence/InstalledLifecycleHarness.SelfTest.ps1 +++ b/tests/persistence/InstalledLifecycleHarness.SelfTest.ps1 @@ -1,17 +1,20 @@ $ErrorActionPreference = 'Stop' . (Join-Path $PSScriptRoot 'TestSupport.ps1') +Restore-CcodTestDesktopModulePath $repositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent $harnessPath = Join-Path $repositoryRoot 'tests\installed\Invoke-InstalledLifecycleIntegration.ps1' function New-CcodHarnessFixture { - $root = Join-Path $env:TEMP ('ccod-installed-harness-' + [guid]::NewGuid().ToString('N')) + $root = Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-installed-harness-' + [guid]::NewGuid().ToString('N')) $null = [IO.Directory]::CreateDirectory($root) $installer = Join-Path $root 'CodexRemote-fix-2.5.0-setup.exe' [IO.File]::WriteAllBytes($installer, [byte[]](1,2,3,4,5,6,7,8)) $hash = Get-CcodTestFileSha256 -Path $installer $checksum = "$installer.sha256.txt" [IO.File]::WriteAllText($checksum, ("{0} *{1}`r`n" -f $hash, [IO.Path]::GetFileName($installer)), [Text.UTF8Encoding]::new($false)) + $payload = [ordered]@{ schemaVersion = 1; projectVersion = '2.5.0'; files = @([ordered]@{ path = 'package.json'; length = [int64]1; sha256 = ('e' * 64) }) } + [IO.File]::WriteAllText((Join-Path $root 'CodexRemote-fix-2.5.0-setup-payload-manifest.json'), ($payload | ConvertTo-Json -Depth 8), [Text.UTF8Encoding]::new($false)) return [pscustomobject]@{ Root = $root Installer = $installer @@ -20,12 +23,30 @@ function New-CcodHarnessFixture { } } +function New-CcodHarnessSealedFixture { + $source=Join-Path $repositoryRoot 'tests/persistence/ReleaseWorkflow.SelfTest.ps1' + $tokens=$null;$errors=$null;$ast=[Management.Automation.Language.Parser]::ParseFile($source,[ref]$tokens,[ref]$errors) + Assert-CcodEqual 0 @($errors).Count 'shared sealed fixture source parses' + foreach($name in @('Get-CcodTask5ExpectedAssetNames','Write-CcodTask5Json','New-CcodTask5ExactAssetFixture')){ + $definitions=@($ast.EndBlock.Statements|Where-Object {$_-is[Management.Automation.Language.FunctionDefinitionAst]-and$_.Name-ceq$name}) + Assert-CcodEqual 1 $definitions.Count 'one shared fixture definition is loaded without executing release tests' + . ([scriptblock]::Create($definitions[0].Extent.Text)) + } + return New-CcodTask5ExactAssetFixture +} + function New-CcodHarnessCandidate { - param([Parameter(Mandatory)]$Fixture, [Parameter(Mandatory)][string]$Version) + param( + [Parameter(Mandatory)]$Fixture, + [Parameter(Mandatory)][string]$Version, + [byte[]]$Bytes = [byte[]](8,7,6,5,4,3,2,1) + ) $installer = Join-Path $Fixture.Root ("CodexRemote-fix-$Version-setup.exe") - [IO.File]::WriteAllBytes($installer, [byte[]](8,7,6,5,4,3,2,1)) + [IO.File]::WriteAllBytes($installer, $Bytes) $hash = Get-CcodTestFileSha256 -Path $installer [IO.File]::WriteAllText("$installer.sha256.txt", ("{0} *{1}`r`n" -f $hash, [IO.Path]::GetFileName($installer)), [Text.UTF8Encoding]::new($false)) + $payload = [ordered]@{ schemaVersion = 1; projectVersion = $Version; files = @([ordered]@{ path = 'package.json'; length = [int64]1; sha256 = ('e' * 64) }) } + [IO.File]::WriteAllText((Join-Path $Fixture.Root ("CodexRemote-fix-$Version-setup-payload-manifest.json")), ($payload | ConvertTo-Json -Depth 8), [Text.UTF8Encoding]::new($false)) return $installer } @@ -41,18 +62,26 @@ function New-CcodHarnessFacts { [string]$ReceiptPhase = 'Completed' ) return [pscustomobject][ordered]@{ + installRootPresent = $true appPresent = $true + runtimeRootPresent = $true + activePointerPresent = $true + installReady = $true activeRuntimeId = $RuntimeId activeGeneration = [UInt64]1 runtimeManifestSha256 = ('a' * 64) + runtimeManifestFiles = @([pscustomobject][ordered]@{ path = 'package.json'; length = [int64]1; sha256 = ('e' * 64) }) supervisor = @([pscustomobject]@{ pid = 100; creationTimeUtc = '2026-08-24T00:00:00.0000000Z' }) trayHost = @([pscustomobject]@{ pid = 101; creationTimeUtc = '2026-08-24T00:00:01.0000000Z' }) + trayHostIdentity = [pscustomobject][ordered]@{ pid = 101; creationTimeUtc = '2026-08-24T00:00:01.0000000Z' } + trayAuthenticated = $true codex = @([pscustomobject]@{ pid = $CodexPid; creationTimeUtc = $CodexCreationTimeUtc }) taskState = 'Ready' statusPhase = 'Active' statusRuntimeId = $StatusRuntimeId statusCodex = [pscustomobject][ordered]@{ pid = $StatusCodexPid; creationTimeUtc = $StatusCodexCreationTimeUtc } transitionStage = 'Idle' + protectionReady = $true lifecycleReceipt = [pscustomobject][ordered]@{ kind = 'RestartAndRepair' origin = 'Installer' @@ -64,13 +93,30 @@ function New-CcodHarnessFacts { deviceKeyPresent = $true deviceKeySha256 = ('b' * 64) shortcuts = [pscustomobject]@{ startMenu = $true; desktop = $true } - privatePath = 'C:\\Users\\Alice\\.codex\\remote-control-device-keys.windows.json' + debugEndpoints = @( + [pscustomobject][ordered]@{ localAddress = '127.0.0.1'; localPort = 9229; owningProcess = $CodexPid; owningProcessCreationTimeUtc = $CodexCreationTimeUtc } + [pscustomobject][ordered]@{ localAddress = '127.0.0.1'; localPort = 9230; owningProcess = $CodexPid; owningProcessCreationTimeUtc = $CodexCreationTimeUtc } + ) + debugPorts = @(9229,9230) + privatePath = 'fixture-private-key.json' token = 'do-not-store-this' userName = 'Alice' conversation = 'private conversation content must never enter evidence' } } +function New-CcodHarnessAbsentFacts { + param([switch]$WithoutDeviceKey) + $facts = New-CcodHarnessFacts + foreach ($name in @('installRootPresent','appPresent','runtimeRootPresent','activePointerPresent','installReady','trayAuthenticated','protectionReady')) { $facts.$name = $false } + foreach ($name in @('activeRuntimeId','activeGeneration','runtimeManifestSha256','trayHostIdentity','statusRuntimeId','statusCodex','lifecycleReceipt','aboutVersion')) { $facts.$name = $null } + foreach ($name in @('runtimeManifestFiles','supervisor','trayHost','codex','debugPorts','debugEndpoints')) { $facts.$name = @() } + $facts.taskState = 'Absent'; $facts.statusPhase = 'Unavailable'; $facts.transitionStage = 'Unavailable' + $facts.shortcuts = [pscustomobject][ordered]@{ startMenu = $false; desktop = $false } + if ($WithoutDeviceKey) { $facts.deviceKeyPresent = $false; $facts.deviceKeySha256 = $null } + return $facts +} + function Write-CcodHarnessJson { param([Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)]$Value) $null = [IO.Directory]::CreateDirectory((Split-Path $Path -Parent)) @@ -113,7 +159,9 @@ function New-CcodHarnessInstalledStateFixture { [Parameter(Mandatory)][string]$StatusRuntimeId, [Parameter(Mandatory)][int]$StatusCodexPid, [Parameter(Mandatory)][string]$StatusCodexCreationTimeUtc, - [Parameter(Mandatory)][string]$ReceiptPhase + [Parameter(Mandatory)][string]$ReceiptPhase, + [int]$StatusMainPort = 41001, + [int]$StatusRendererPort = 41002 ) $active = [ordered]@{ schemaVersion = 2 @@ -136,8 +184,8 @@ function New-CcodHarnessInstalledStateFixture { packageFullName = 'OpenAI.Codex_1.0.0.0_x64__test' packageVersion = '1.0.0.0' appAsarSha256 = ('c' * 64) - mainPort = 41001 - rendererPort = 41002 + mainPort = $StatusMainPort + rendererPort = $StatusRendererPort mainProbe = 'Closed' rendererProbe = 'BridgeValid' } @@ -147,7 +195,7 @@ function New-CcodHarnessInstalledStateFixture { $receipt = New-CcodHarnessLifecycleReceipt -RuntimeId $ActiveRuntimeId -RuntimeGeneration $ActiveGeneration -Phase $ReceiptPhase Write-CcodHarnessJson -Path (Join-Path $Root 'active.json') -Value $active $manifestPath = Join-Path (Join-Path (Join-Path $Root 'runtime') $ActiveRuntimeId) 'manifest.json' - Write-CcodHarnessJson -Path $manifestPath -Value ([ordered]@{ schemaVersion = 1; runtimeId = $ActiveRuntimeId }) + Write-CcodHarnessJson -Path $manifestPath -Value ([ordered]@{ schemaVersion = 1; projectVersion = '2.5.21'; runtimeId = $ActiveRuntimeId; files = @() }) Write-CcodHarnessJson -Path (Join-Path $Root 'state\status.json') -Value $status Write-CcodHarnessJson -Path (Join-Path $Root 'state\transition.json') -Value $transition Write-CcodHarnessJson -Path (Join-Path $Root ("state\lifecycle\receipts\{0}.json" -f $receipt.transactionId)) -Value $receipt @@ -156,6 +204,12 @@ function New-CcodHarnessInstalledStateFixture { function Set-CcodHarnessProcessFixture { param([object[]]$ChatGPT, [object[]]$Codex = @()) $script:CcodHarnessProcessFixture = [pscustomobject]@{ ChatGPT = @($ChatGPT); Codex = @($Codex) } + function global:Get-Process { + param($Id,$ErrorAction) + if(-not$script:CcodHarnessNativeCreation.ContainsKey([int]$Id)){throw 'unknown fixture native process'} + $value=[pscustomobject]@{Id=[int]$Id;StartTime=[datetime]::ParseExact($script:CcodHarnessNativeCreation[[int]$Id],'o',[Globalization.CultureInfo]::InvariantCulture,[Globalization.DateTimeStyles]::RoundtripKind);HasExited=$false} + $value|Add-Member ScriptMethod Dispose {};return $value + } function global:Get-CimInstance { param($ClassName, $Filter, $ErrorAction) if ([string]$Filter -cmatch "Name = 'ChatGPT.exe'") { return @($script:CcodHarnessProcessFixture.ChatGPT) } @@ -169,6 +223,7 @@ function Set-CcodHarnessProcessFixture { } function Clear-CcodHarnessProcessFixture { + Remove-Item -LiteralPath Function:\Get-Process -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath Function:\Get-CimInstance -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath Function:\Get-ScheduledTask -Force -ErrorAction SilentlyContinue $script:CcodHarnessProcessFixture = $null @@ -183,6 +238,8 @@ function New-CcodHarnessCimProcess { [int]$ParentProcessId = 0 ) $created = [datetime]::ParseExact($CreationTimeUtc, 'o', [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::RoundtripKind) + if($null-eq(Get-Variable CcodHarnessNativeCreation -Scope Script -ErrorAction SilentlyContinue)){$script:CcodHarnessNativeCreation=@{}} + $script:CcodHarnessNativeCreation[$ProcessId]=$CreationTimeUtc return [pscustomobject][ordered]@{ Name = $Name ProcessId = $ProcessId @@ -194,13 +251,41 @@ function New-CcodHarnessCimProcess { } function Invoke-CcodHarnessWithCapturedFacts { - param([Parameter(Mandatory)]$Context, [Parameter(Mandatory)]$BeforeFacts, [Parameter(Mandatory)]$AfterFacts) + param([Parameter(Mandatory)]$Context, [Parameter(Mandatory)]$BeforeFacts, [Parameter(Mandatory)]$AfterFacts, [ref]$ObservedExpectedDebugPorts, $RunResult, + [object[]]$PostUninstallListeners = @(), [switch]$FailUninstallEnumeration, [ref]$ObservedUninstallEnumeration) + if ($null -eq $Context.PSObject.Properties['transactionId']) { $Context | Add-Member -NotePropertyName transactionId -NotePropertyValue '11111111-2222-3333-4444-555555555555' } + if ($null -eq $Context.PSObject.Properties['installerSha256']) { $Context | Add-Member -NotePropertyName installerSha256 -NotePropertyValue ('a' * 64) } + if ($null -eq $Context.PSObject.Properties['candidatePayloadFiles']) { $Context | Add-Member -NotePropertyName candidatePayloadFiles -NotePropertyValue @($AfterFacts.runtimeManifestFiles) } $original = ${function:Get-CcodInstalledLifecycleFacts} + $originalNetTcp = ${function:Get-NetTCPConnection} + $uninstallFixture = $Context.scenario -in @('SettingsUninstall','DirectUninstall') try { - Set-Item -LiteralPath Function:\Get-CcodInstalledLifecycleFacts -Value ({ param($InstallRoot) return $AfterFacts }.GetNewClosure()) - return Test-CcodInstalledLifecycleScenario -Context $Context -BeforeFacts $BeforeFacts -RunResult ([pscustomobject]@{ code = 'CCOD_INTEGRATION_OPERATOR_COMPLETED' }) + if ($uninstallFixture) { + Set-Item -LiteralPath Function:\global:Get-NetTCPConnection -Value ({ + param($State,$ErrorAction) + if ($null -ne $ObservedUninstallEnumeration) { $ObservedUninstallEnumeration.Value = [int]$ObservedUninstallEnumeration.Value + 1 } + if ($FailUninstallEnumeration) { throw [IO.IOException]::new('fixture listener enumeration unavailable') } + return @($PostUninstallListeners) + }.GetNewClosure()) + } + Set-Item -LiteralPath Function:\Get-CcodInstalledLifecycleFacts -Value ({ + param($InstallRoot,$ExpectedVersion,$ExpectedDebugPorts) + if ($null -ne $ObservedExpectedDebugPorts) { + $ObservedExpectedDebugPorts.Value = if ($null -eq $ExpectedDebugPorts) { 'none' } else { (@($ExpectedDebugPorts) -join ',') } + } + return $AfterFacts + }.GetNewClosure()) + $effectiveRunResult = if ($null -eq $RunResult) { + $runtimeManifest = if ($Context.scenario -in @('FreshInstall','FreshLater','FreshRestart','Upgrade','SlowLaunch','ManualLaunchResume','LanguageStress','RepairStates')) { [string]$AfterFacts.runtimeManifestSha256 } else { $null } + [pscustomobject][ordered]@{ code = 'CCOD_INTEGRATION_OPERATOR_COMPLETED'; scenario = [string]$Context.scenario; transactionId = [string]$Context.transactionId; completed = $true; operatorAttestation = 'OperatorConfirmed'; installerSha256 = [string]$Context.installerSha256; runtimeManifestSha256 = $runtimeManifest } + } else { $RunResult } + return Test-CcodInstalledLifecycleScenario -Context $Context -BeforeFacts $BeforeFacts -RunResult $effectiveRunResult } finally { Set-Item -LiteralPath Function:\Get-CcodInstalledLifecycleFacts -Value $original + if ($uninstallFixture) { + if ($null -eq $originalNetTcp) { Remove-Item -LiteralPath Function:\global:Get-NetTCPConnection -Force -ErrorAction SilentlyContinue } + else { Set-Item -LiteralPath Function:\global:Get-NetTCPConnection -Value $originalNetTcp } + } } } @@ -210,11 +295,16 @@ function New-CcodHarnessAdapters { [Parameter(Mandatory)][AllowEmptyCollection()][Collections.Generic.List[string]]$Calls, [switch]$FailScenario, [switch]$DirtyCheckout, + [string]$ReplacePreviousAfterValidation, + [switch]$RecordRollbackIdentity, + [string]$BeforeVersion = '2.5.0', + [string]$AfterVersion, [ref]$CapturedReceipt ) - $before = New-CcodHarnessFacts - $after = New-CcodHarnessFacts + $resolvedAfterVersion = if ([string]::IsNullOrWhiteSpace($AfterVersion)) { $BeforeVersion } else { $AfterVersion } + $before = New-CcodHarnessFacts -Version $BeforeVersion + $after = New-CcodHarnessFacts -Version $resolvedAfterVersion return @{ GetGitStatus = { param($RepositoryRoot) @@ -240,12 +330,25 @@ function New-CcodHarnessAdapters { WriteEvidence = { param($EvidenceDirectory, $Receipt) $Calls.Add('WriteEvidence') - $CapturedReceipt.Value = $Receipt - return [IO.Path]::GetFullPath((Join-Path $EvidenceDirectory 'receipt.json')) + $directory = [IO.Directory]::CreateDirectory($EvidenceDirectory).FullName + $path = [IO.Path]::GetFullPath((Join-Path $directory 'receipt.json')) + $json = ($Receipt | ConvertTo-Json -Depth 16) + $stream = [IO.File]::Open($path, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) + try { $bytes = [Text.UTF8Encoding]::new($false).GetBytes($json); $stream.Write($bytes,0,$bytes.Length); $stream.Flush($true) } finally { $stream.Dispose() } + $readback = [IO.File]::ReadAllText($path, [Text.UTF8Encoding]::new($false)) | ConvertFrom-Json -ErrorAction Stop + if ($null -eq $readback) { throw 'fixture evidence readback failed' } + $CapturedReceipt.Value = $readback + return $path }.GetNewClosure() CaptureFacts = { - param($InstallRoot) - $Calls.Add('CaptureFacts') + param($InstallRoot,$ExpectedVersion) + $Calls.Add('CaptureFacts:' + [string]$ExpectedVersion) + if (-not [string]::IsNullOrWhiteSpace($ReplacePreviousAfterValidation) -and -not ($Calls -contains 'ReplacePreviousAfterValidation')) { + $Calls.Add('ReplacePreviousAfterValidation') + [IO.File]::WriteAllBytes($ReplacePreviousAfterValidation, [byte[]](31,29,27,25,23,21,19,17)) + $replacementHash = Get-CcodTestFileSha256 -Path $ReplacePreviousAfterValidation + [IO.File]::WriteAllText("$ReplacePreviousAfterValidation.sha256.txt", ("{0} *{1}`r`n" -f $replacementHash, [IO.Path]::GetFileName($ReplacePreviousAfterValidation)), [Text.UTF8Encoding]::new($false)) + } if (($Calls | Where-Object { $_ -eq 'RunScenario' }).Count -eq 0) { return $before } return $after }.GetNewClosure() @@ -258,7 +361,8 @@ function New-CcodHarnessAdapters { param($Context) $Calls.Add('RunScenario') if ($FailScenario) { throw [InvalidOperationException]::new('operator scenario failed') } - return [pscustomobject]@{ code = 'CCOD_OPERATOR_COMPLETED'; operatorAttestation = 'accepted'; privatePath = 'C:\\private\\operator' } + $runtimeManifest = if ($Context.scenario -in @('FreshInstall','FreshLater','FreshRestart','Upgrade','SlowLaunch','ManualLaunchResume','LanguageStress','RepairStates')) { [string]$after.runtimeManifestSha256 } else { $null } + return [pscustomobject][ordered]@{ code = 'CCOD_INTEGRATION_OPERATOR_COMPLETED'; scenario = [string]$Context.scenario; transactionId = [string]$Context.transactionId; completed = $true; operatorAttestation = 'OperatorConfirmed'; installerSha256 = [string]$Context.installerSha256; runtimeManifestSha256 = $runtimeManifest } }.GetNewClosure() VerifyScenario = { param($Context, $BeforeFacts, $RunResult) @@ -268,6 +372,10 @@ function New-CcodHarnessAdapters { Rollback = { param($Context, $Snapshot) $Calls.Add('Rollback') + if ($RecordRollbackIdentity) { + $Calls.Add('RollbackPath:' + [string]$Context.previousInstallerPath) + $Calls.Add('RollbackHash:' + (Get-CcodTestFileSha256 -Path ([string]$Context.previousInstallerPath))) + } return [pscustomobject]@{ restored = $true; code = 'CCOD_INTEGRATION_ROLLBACK_COMPLETED' } }.GetNewClosure() CleanupRollback = { @@ -282,17 +390,110 @@ function New-CcodHarnessAdapters { } } +Invoke-CcodTest 'installed lifecycle active facts prefer the append-only selector over legacy active json' { + . $harnessPath -Library + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-selector-authority-' + [guid]::NewGuid().ToString('N')) + try { + $null = [IO.Directory]::CreateDirectory($root) + $selector = Join-Path (Join-Path $root 'state') 'active-generation' + $null = [IO.Directory]::CreateDirectory($selector) + $id = '2.5.22-e3b0c44298fc1c14-22222222222222222222222222222222' + $runtime = Join-Path (Join-Path $root 'runtime') $id + $null = [IO.Directory]::CreateDirectory($runtime) + [IO.File]::WriteAllText((Join-Path $runtime 'manifest.json'), (([ordered]@{ schemaVersion = 1; projectVersion = '2.5.22'; runtimeId = $id; files = @() } | ConvertTo-Json -Compress)), [Text.UTF8Encoding]::new($false)) + $record = [ordered]@{ schemaVersion = 1; generation = [UInt64]1; activeRuntime = $id; previousGeneration = [UInt64]0 } + [IO.File]::WriteAllText((Join-Path $selector '00000000000000000001.json'), ($record | ConvertTo-Json -Compress), [Text.UTF8Encoding]::new($false)) + $legacy = [ordered]@{ schemaVersion = 2; activeRuntime = 'legacy-runtime'; previousRuntime = $null; generation = [UInt64]99; updatedAtUtc = '2030-02-03T04:05:06.0000000Z' } + [IO.File]::WriteAllText((Join-Path $root 'active.json'), ($legacy | ConvertTo-Json -Compress), [Text.UTF8Encoding]::new($false)) + $actual = Read-CcodInstalledLifecycleActiveFact -InstallRoot $root + Assert-CcodEqual $id ([string]$actual.activeRuntime) 'append-only selector is authoritative over legacy active json' + Assert-CcodEqual ([UInt64]1) ([UInt64]$actual.generation) 'selector generation is returned' + Add-Content -LiteralPath (Join-Path $selector '00000000000000000001.json') -Stream 'tampered' -Value 'alternate' -NoNewline + Assert-CcodThrows { Read-CcodInstalledLifecycleActiveFact -InstallRoot $root | Out-Null } 'CCOD_INTEGRATION_FACTS_INVALID' + } finally { if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } } +} + + Invoke-CcodTest 'installed lifecycle selector rejects a file state ancestor before legacy fallback' { + . $harnessPath -Library + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-selector-state-file-' + [guid]::NewGuid().ToString('N')) + try { + $null = [IO.Directory]::CreateDirectory($root) + [IO.File]::WriteAllText((Join-Path $root 'state'), 'not-a-directory', [Text.UTF8Encoding]::new($false)) + $legacy = [ordered]@{ schemaVersion = 2; activeRuntime = 'legacy-runtime'; previousRuntime = $null; generation = [UInt64]1; updatedAtUtc = '2030-02-03T04:05:06.0000000Z' } + [IO.File]::WriteAllText((Join-Path $root 'active.json'), ($legacy | ConvertTo-Json -Compress), [Text.UTF8Encoding]::new($false)) + Assert-CcodThrows { Read-CcodInstalledLifecycleActiveFact -InstallRoot $root | Out-Null } 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' + } finally { if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } } + } + +Invoke-CcodTest 'installed lifecycle selector rejects a dot-leading active runtime before path construction' { + . $harnessPath -Library + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-selector-dot-leading-' + [guid]::NewGuid().ToString('N')) + try { + $selector = Join-Path (Join-Path $root 'state') 'active-generation' + [IO.Directory]::CreateDirectory($selector) | Out-Null + $record = [ordered]@{ schemaVersion = 1; generation = [UInt64]1; activeRuntime = '..'; previousGeneration = [UInt64]0 } + [IO.File]::WriteAllText((Join-Path $selector '00000000000000000001.json'), ($record | ConvertTo-Json -Compress), [Text.UTF8Encoding]::new($false)) + Assert-CcodThrows { Read-CcodInstalledLifecycleActiveFact -InstallRoot $root | Out-Null } 'CCOD_INTEGRATION_FACTS_INVALID' + } finally { + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } +} + Invoke-CcodTest 'installed lifecycle harness exposes the guarded library interface' { Assert-CcodTrue (Test-Path -LiteralPath $harnessPath -PathType Leaf) 'installed lifecycle harness exists' . $harnessPath -Library Assert-CcodTrue ($null -ne (Get-Command Invoke-CcodInstalledLifecycleIntegration -ErrorAction SilentlyContinue)) 'harness exports its invocation function when loaded as a library' } +Invoke-CcodTest 'FreshInstall is a distinct guarded integration scenario' { + . $harnessPath -Library + Assert-CcodThrows { + Invoke-CcodInstalledLifecycleIntegration -InstallerPath 'C:\fixture\CodexRemote-fix-2.5.22-setup.exe' -ExpectedVersion '2.5.22' -EvidenceRoot 'C:\fixture\evidence' -Scenario FreshInstall + } 'CCOD_INTEGRATION_MUTATION_NOT_ALLOWED' +} + # Production mutation caught: FreshRestart ignores stale status and a current-runtime CloseFailed receipt. +Invoke-CcodTest 'direct uninstall verification rejects partial owned-state residue' { + . $harnessPath -Library + $before = New-CcodHarnessFacts + $after = New-CcodHarnessFacts + $after.installRootPresent = $false; $after.appPresent = $false; $after.runtimeRootPresent = $true; $after.activePointerPresent = $true; $after.taskState = 'Absent' + $context = [pscustomobject]@{ scenario = 'DirectUninstall'; expectedVersion = '2.5.0'; installRoot = 'C:\fixture' } + $result = Invoke-CcodHarnessWithCapturedFacts -Context $context -BeforeFacts $before -AfterFacts $after + Assert-CcodTrue (-not $result.verified -and [string]$result.code -eq 'CCOD_INTEGRATION_OBSERVATION_UNPROVEN') 'direct uninstall rejects runtime and pointer residue' +} + +Invoke-CcodTest 'direct uninstall rejects a newly created device key when none existed before' { + . $harnessPath -Library + $before = New-CcodHarnessFacts + $before.deviceKeyPresent = $false + $before.deviceKeySha256 = $null + $after = New-CcodHarnessAbsentFacts -WithoutDeviceKey + $context = [pscustomobject]@{ scenario = 'DirectUninstall'; expectedVersion = '2.5.0'; installRoot = 'C:\fixture' } + $positive = Invoke-CcodHarnessWithCapturedFacts -Context $context -BeforeFacts $before -AfterFacts $after + Assert-CcodTrue $positive.verified 'the absent-key uninstall baseline is independently valid' + $after.deviceKeyPresent = $true; $after.deviceKeySha256 = ('b' * 64) + $result = Invoke-CcodHarnessWithCapturedFacts -Context $context -BeforeFacts $before -AfterFacts $after + Assert-CcodTrue (-not $result.verified -and [string]$result.code -eq 'CCOD_INTEGRATION_OBSERVATION_UNPROVEN') 'uninstall rejects device-key creation after an absent baseline' +} + +Invoke-CcodTest 'uninstall rejects persisted runtime status residue after owned state is gone' { + . $harnessPath -Library + $before = New-CcodHarnessFacts + $before.deviceKeyPresent = $false; $before.deviceKeySha256 = $null + $after = New-CcodHarnessFacts + $after.installRootPresent = $false; $after.appPresent = $false; $after.runtimeRootPresent = $false; $after.activePointerPresent = $false; $after.taskState = 'Absent' + $after.supervisor = @(); $after.trayHost = @(); $after.trayHostIdentity = $null; $after.trayAuthenticated = $false; $after.installReady = $false; $after.protectionReady = $false; $after.codex = @(); $after.deviceKeyPresent = $false; $after.deviceKeySha256 = $null + $after.shortcuts = [pscustomobject]@{ startMenu = $false; desktop = $false }; $after.debugEndpoints = @(); $after.lifecycleReceipt = $null + $context = [pscustomobject]@{ scenario = 'DirectUninstall'; expectedVersion = '2.5.0'; installRoot = 'C:\fixture' } + $result = Invoke-CcodHarnessWithCapturedFacts -Context $context -BeforeFacts $before -AfterFacts $after + Assert-CcodTrue (-not $result.verified -and [string]$result.code -eq 'CCOD_INTEGRATION_OBSERVATION_UNPROVEN') 'uninstall rejects persisted status and transition residue' +} + Invoke-CcodTest 'rejects FreshRestart when current active runtime has stale status and a CloseFailed installer receipt' { . $harnessPath -Library $before = New-CcodHarnessFacts -RuntimeId '2.5.19-old' -CodexPid 10664 - $after = New-CcodHarnessFacts -RuntimeId '2.5.21-new' -CodexPid 13948 ` + $after = New-CcodHarnessFacts -RuntimeId '2.5.21-e3b0c44298fc1c14-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' -CodexPid 13948 ` -StatusRuntimeId '2.5.19-old' -StatusCodexPid 10664 -ReceiptPhase 'CloseFailed' $context = [pscustomobject]@{ scenario = 'FreshRestart'; expectedVersion = '2.5.0'; installRoot = 'C:\fixture' } $verification = Invoke-CcodHarnessWithCapturedFacts -Context $context -BeforeFacts $before -AfterFacts $after @@ -300,13 +501,37 @@ Invoke-CcodTest 'rejects FreshRestart when current active runtime has stale stat Assert-CcodEqual 'CCOD_INTEGRATION_OBSERVATION_UNPROVEN' $verification.code 'rejection uses the stable observation code' } +Invoke-CcodTest 'FreshInstall requires the same current-runtime Ready and Complete proof as later phases' { + . $harnessPath -Library + $before = New-CcodHarnessAbsentFacts + $after = New-CcodHarnessFacts + $context = [pscustomobject]@{ scenario = 'FreshInstall'; expectedVersion = '2.5.0'; expectedDebugPorts = @(9229,9230); installRoot = 'C:\fixture' } + $positive = Invoke-CcodHarnessWithCapturedFacts -Context $context -BeforeFacts $before -AfterFacts $after + Assert-CcodTrue $positive.verified 'clean pre-install state and complete candidate proof form a valid FreshInstall baseline' + $after.lifecycleReceipt = $null + $verification = Invoke-CcodHarnessWithCapturedFacts -Context $context -BeforeFacts $before -AfterFacts $after + Assert-CcodEqual $false $verification.verified 'FreshInstall cannot use the generic installed success branch' + Assert-CcodEqual 'CCOD_INTEGRATION_OBSERVATION_UNPROVEN' $verification.code 'FreshInstall missing proof uses the stable observation code' +} + +Invoke-CcodTest 'rejects FreshRestart without current Active status Idle transition or Completed receipt' { + . $harnessPath -Library + $before = New-CcodHarnessFacts -RuntimeId '2.5.21-before' -CodexPid 10664 + $after = New-CcodHarnessFacts -RuntimeId '2.5.22-after' -CodexPid 13948 + $after.statusPhase = 'Unknown'; $after.statusRuntimeId = $null; $after.statusCodex = $null; $after.transitionStage = 'Unknown'; $after.lifecycleReceipt = $null + $context = [pscustomobject]@{ scenario = 'FreshRestart'; expectedVersion = '2.5.0'; installRoot = 'C:\fixture' } + $verification = Invoke-CcodHarnessWithCapturedFacts -Context $context -BeforeFacts $before -AfterFacts $after + Assert-CcodEqual $false $verification.verified 'FreshRestart requires current-runtime Ready/Complete evidence' + Assert-CcodEqual 'CCOD_INTEGRATION_OBSERVATION_UNPROVEN' $verification.code 'missing Ready/Complete evidence uses the stable observation code' +} + # Production mutation caught: querying Codex.exe instead of the Windows app root ChatGPT.exe. Invoke-CcodTest 'captures exactly the top-level ChatGPT root and excludes Electron type children' { . $harnessPath -Library - $root = Join-Path $env:TEMP ('ccod-installed-capture-' + [guid]::NewGuid().ToString('N')) + $root = Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-installed-capture-' + [guid]::NewGuid().ToString('N')) try { $null = [IO.Directory]::CreateDirectory($root) - $created = '2026-08-24T00:00:04.0000000Z' + $created = '2026-08-24T00:00:04.0000006Z' Set-CcodHarnessProcessFixture -ChatGPT @( (New-CcodHarnessCimProcess -Name 'ChatGPT.exe' -ProcessId 13948 -CreationTimeUtc $created -CommandLine '"C:\Program Files\WindowsApps\OpenAI.Codex\ChatGPT.exe"'), (New-CcodHarnessCimProcess -Name 'ChatGPT.exe' -ProcessId 13949 -CreationTimeUtc '2026-08-24T00:00:04.1000000Z' -CommandLine '"C:\Program Files\WindowsApps\OpenAI.Codex\ChatGPT.exe" --type=renderer' -ParentProcessId 13948), @@ -325,7 +550,7 @@ Invoke-CcodTest 'captures exactly the top-level ChatGPT root and excludes Electr Invoke-CcodTest 'normalizes only supported CIM creation-time evidence for complete ChatGPT roots' { . $harnessPath -Library - $root = Join-Path $env:TEMP ('ccod-installed-cim-creation-' + [guid]::NewGuid().ToString('N')) + $root = Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-installed-cim-creation-' + [guid]::NewGuid().ToString('N')) try { $null = [IO.Directory]::CreateDirectory($root) $commandLine = '"C:\Program Files\WindowsApps\OpenAI.Codex\ChatGPT.exe"' @@ -340,6 +565,7 @@ Invoke-CcodTest 'normalizes only supported CIM creation-time evidence for comple foreach ($case in $successCases) { $process = New-CcodHarnessCimProcess -Name 'ChatGPT.exe' -ProcessId 13948 -CreationTimeUtc '2026-08-24T00:00:04.0000000Z' -CommandLine $commandLine $process.CreationDate = $case.Value + $script:CcodHarnessNativeCreation[13948]=$case.Expected Set-CcodHarnessProcessFixture -ChatGPT @($process) $facts = Get-CcodInstalledLifecycleFacts -InstallRoot $root Assert-CcodEqual 1 $facts.codex.Count ("{0} keeps one root" -f $case.Name) @@ -368,7 +594,7 @@ Invoke-CcodTest 'normalizes only supported CIM creation-time evidence for comple Invoke-CcodTest 'fails closed when any enumerated ChatGPT process cannot be proven root or Electron child' { . $harnessPath -Library foreach($case in @('null command line','empty command line','unparsable command line','invalid creation time','zero PID','string PID','nonstring command line','missing command line')){ - $root=Join-Path $env:TEMP ("ccod-installed-indeterminate-$($case.Replace(' ','-'))-"+[guid]::NewGuid().ToString('N')) + $root=Join-Path (Get-CcodTestCanonicalTempRoot) ("ccod-installed-indeterminate-$($case.Replace(' ','-'))-"+[guid]::NewGuid().ToString('N')) try{ $null=[IO.Directory]::CreateDirectory($root) $valid=New-CcodHarnessCimProcess -Name 'ChatGPT.exe' -ProcessId 13948 -CreationTimeUtc '2026-08-24T00:00:04.0000000Z' -CommandLine '"C:\Program Files\WindowsApps\OpenAI.Codex\ChatGPT.exe"' @@ -394,17 +620,17 @@ Invoke-CcodTest 'fails closed when any enumerated ChatGPT process cannot be prov Invoke-CcodTest 'captures bounded current-runtime status transition and latest installer receipt facts from complete schemas' { . $harnessPath -Library - $root = Join-Path $env:TEMP ('ccod-installed-state-' + [guid]::NewGuid().ToString('N')) + $root = Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-installed-state-' + [guid]::NewGuid().ToString('N')) try { $null = [IO.Directory]::CreateDirectory($root) - New-CcodHarnessInstalledStateFixture -Root $root -ActiveRuntimeId '2.5.21-new' -ActiveGeneration ([UInt64]8) ` + New-CcodHarnessInstalledStateFixture -Root $root -ActiveRuntimeId '2.5.21-e3b0c44298fc1c14-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' -ActiveGeneration ([UInt64]8) ` -StatusRuntimeId '2.5.19-old' -StatusCodexPid 10664 -StatusCodexCreationTimeUtc '2026-08-24T00:00:02.0000000Z' -ReceiptPhase 'CloseFailed' Set-CcodHarnessProcessFixture -ChatGPT @( (New-CcodHarnessCimProcess -Name 'ChatGPT.exe' -ProcessId 13948 -CreationTimeUtc '2026-08-24T00:00:04.0000000Z' -CommandLine '"C:\Program Files\WindowsApps\OpenAI.Codex\ChatGPT.exe"'), (New-CcodHarnessCimProcess -Name 'ChatGPT.exe' -ProcessId 13949 -CreationTimeUtc '2026-08-24T00:00:04.1000000Z' -CommandLine '"C:\Program Files\WindowsApps\OpenAI.Codex\ChatGPT.exe" --type=renderer' -ParentProcessId 13948) ) $facts = Get-CcodInstalledLifecycleFacts -InstallRoot $root - Assert-CcodEqual '2.5.21-new' $facts.activeRuntimeId 'active schema 2 supplies the current runtime' + Assert-CcodEqual '2.5.21-e3b0c44298fc1c14-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' $facts.activeRuntimeId 'active schema 2 supplies the current runtime' Assert-CcodEqual 8 ([UInt64]$facts.activeGeneration) 'active schema 2 supplies the current generation' Assert-CcodEqual 'Active' $facts.statusPhase 'complete status schema 1 supplies the session state' Assert-CcodEqual '2.5.19-old' $facts.statusRuntimeId 'stale status runtime remains visible for correlation rejection' @@ -412,7 +638,7 @@ Invoke-CcodTest 'captures bounded current-runtime status transition and latest i Assert-CcodEqual 'Idle' $facts.transitionStage 'null active transaction is captured as idle' Assert-CcodEqual 'RestartAndRepair' $facts.lifecycleReceipt.kind 'receipt kind is bounded and retained' Assert-CcodEqual 'Installer' $facts.lifecycleReceipt.origin 'receipt origin is bounded and retained' - Assert-CcodEqual '2.5.21-new' $facts.lifecycleReceipt.runtimeId 'latest receipt is bound to the active runtime' + Assert-CcodEqual '2.5.21-e3b0c44298fc1c14-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' $facts.lifecycleReceipt.runtimeId 'latest receipt is bound to the active runtime' Assert-CcodEqual 'CloseFailed' $facts.lifecycleReceipt.phase 'terminal receipt failure remains visible' $serialized = $facts | ConvertTo-Json -Depth 16 -Compress foreach ($forbidden in @('00000000:000003E7','S-1-5-21-1-2-3-1001','OpenAI.Codex_1.0.0.0_x64__test','41001','--type=renderer','C:\Program Files\WindowsApps')) { @@ -426,12 +652,12 @@ Invoke-CcodTest 'captures bounded current-runtime status transition and latest i Invoke-CcodTest 'selects only an unambiguous latest current-runtime installer restart receipt' { . $harnessPath -Library - $root = Join-Path $env:TEMP ('ccod-installed-receipts-' + [guid]::NewGuid().ToString('N')) + $root = Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-installed-receipts-' + [guid]::NewGuid().ToString('N')) try { $null = [IO.Directory]::CreateDirectory($root) - New-CcodHarnessInstalledStateFixture -Root $root -ActiveRuntimeId '2.5.21-new' -ActiveGeneration ([UInt64]8) ` - -StatusRuntimeId '2.5.21-new' -StatusCodexPid 13948 -StatusCodexCreationTimeUtc '2026-08-24T00:00:04.0000000Z' -ReceiptPhase 'CloseFailed' - $completed = New-CcodHarnessLifecycleReceipt -RuntimeId '2.5.21-new' -RuntimeGeneration ([UInt64]8) -Phase 'Completed' ` + New-CcodHarnessInstalledStateFixture -Root $root -ActiveRuntimeId '2.5.21-e3b0c44298fc1c14-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' -ActiveGeneration ([UInt64]8) ` + -StatusRuntimeId '2.5.21-e3b0c44298fc1c14-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' -StatusCodexPid 13948 -StatusCodexCreationTimeUtc '2026-08-24T00:00:04.0000000Z' -ReceiptPhase 'CloseFailed' + $completed = New-CcodHarnessLifecycleReceipt -RuntimeId '2.5.21-e3b0c44298fc1c14-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' -RuntimeGeneration ([UInt64]8) -Phase 'Completed' ` -TransactionId '22222222-3333-4444-5555-666666666666' -UpdatedAtUtc '2026-08-24T00:00:06.0000000Z' Write-CcodHarnessJson -Path (Join-Path $root ("state\lifecycle\receipts\{0}.json" -f $completed.transactionId)) -Value $completed Set-CcodHarnessProcessFixture -ChatGPT @( @@ -440,7 +666,7 @@ Invoke-CcodTest 'selects only an unambiguous latest current-runtime installer re $facts = Get-CcodInstalledLifecycleFacts -InstallRoot $root Assert-CcodEqual 'Completed' $facts.lifecycleReceipt.phase 'strict receipt selection uses the unique latest updatedAtUtc' - $ambiguous = New-CcodHarnessLifecycleReceipt -RuntimeId '2.5.21-new' -RuntimeGeneration ([UInt64]8) -Phase 'CloseFailed' ` + $ambiguous = New-CcodHarnessLifecycleReceipt -RuntimeId '2.5.21-e3b0c44298fc1c14-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' -RuntimeGeneration ([UInt64]8) -Phase 'CloseFailed' ` -TransactionId '33333333-4444-5555-6666-777777777777' -UpdatedAtUtc '2026-08-24T00:00:06.0000000Z' Write-CcodHarnessJson -Path (Join-Path $root ("state\lifecycle\receipts\{0}.json" -f $ambiguous.transactionId)) -Value $ambiguous Assert-CcodThrows { Get-CcodInstalledLifecycleFacts -InstallRoot $root } 'CCOD_INTEGRATION_FACTS_INVALID' @@ -453,12 +679,12 @@ Invoke-CcodTest 'selects only an unambiguous latest current-runtime installer re # Production mutation caught: choosing latest by runtime before filtering the active generation. Invoke-CcodTest 'ignores a newer wrong-generation receipt when selecting the current-generation terminal result' { . $harnessPath -Library - $root = Join-Path $env:TEMP ('ccod-installed-receipt-generation-' + [guid]::NewGuid().ToString('N')) + $root = Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-installed-receipt-generation-' + [guid]::NewGuid().ToString('N')) try { $null = [IO.Directory]::CreateDirectory($root) - New-CcodHarnessInstalledStateFixture -Root $root -ActiveRuntimeId '2.5.21-new' -ActiveGeneration ([UInt64]8) ` - -StatusRuntimeId '2.5.21-new' -StatusCodexPid 13948 -StatusCodexCreationTimeUtc '2026-08-24T00:00:04.0000000Z' -ReceiptPhase 'Completed' - $wrongGeneration = New-CcodHarnessLifecycleReceipt -RuntimeId '2.5.21-new' -RuntimeGeneration ([UInt64]7) -Phase 'CloseFailed' ` + New-CcodHarnessInstalledStateFixture -Root $root -ActiveRuntimeId '2.5.21-e3b0c44298fc1c14-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' -ActiveGeneration ([UInt64]8) ` + -StatusRuntimeId '2.5.21-e3b0c44298fc1c14-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' -StatusCodexPid 13948 -StatusCodexCreationTimeUtc '2026-08-24T00:00:04.0000000Z' -ReceiptPhase 'Completed' + $wrongGeneration = New-CcodHarnessLifecycleReceipt -RuntimeId '2.5.21-e3b0c44298fc1c14-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' -RuntimeGeneration ([UInt64]7) -Phase 'CloseFailed' ` -TransactionId '44444444-5555-6666-7777-888888888888' -UpdatedAtUtc '2026-08-24T00:00:07.0000000Z' Write-CcodHarnessJson -Path (Join-Path $root ("state\lifecycle\receipts\{0}.json" -f $wrongGeneration.transactionId)) -Value $wrongGeneration Set-CcodHarnessProcessFixture -ChatGPT @() @@ -473,14 +699,14 @@ Invoke-CcodTest 'ignores a newer wrong-generation receipt when selecting the cur Invoke-CcodTest 'does not treat tied wrong-generation receipts as current-generation ambiguity' { . $harnessPath -Library - $root = Join-Path $env:TEMP ('ccod-installed-receipt-generation-tie-' + [guid]::NewGuid().ToString('N')) + $root = Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-installed-receipt-generation-tie-' + [guid]::NewGuid().ToString('N')) try { $null = [IO.Directory]::CreateDirectory($root) - New-CcodHarnessInstalledStateFixture -Root $root -ActiveRuntimeId '2.5.21-new' -ActiveGeneration ([UInt64]8) ` - -StatusRuntimeId '2.5.21-new' -StatusCodexPid 13948 -StatusCodexCreationTimeUtc '2026-08-24T00:00:04.0000000Z' -ReceiptPhase 'Completed' + New-CcodHarnessInstalledStateFixture -Root $root -ActiveRuntimeId '2.5.21-e3b0c44298fc1c14-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' -ActiveGeneration ([UInt64]8) ` + -StatusRuntimeId '2.5.21-e3b0c44298fc1c14-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' -StatusCodexPid 13948 -StatusCodexCreationTimeUtc '2026-08-24T00:00:04.0000000Z' -ReceiptPhase 'Completed' foreach ($record in @( - (New-CcodHarnessLifecycleReceipt -RuntimeId '2.5.21-new' -RuntimeGeneration ([UInt64]7) -Phase 'CloseFailed' -TransactionId '55555555-6666-7777-8888-999999999999' -UpdatedAtUtc '2026-08-24T00:00:07.0000000Z'), - (New-CcodHarnessLifecycleReceipt -RuntimeId '2.5.21-new' -RuntimeGeneration ([UInt64]7) -Phase 'RepairFailed' -TransactionId '66666666-7777-8888-9999-aaaaaaaaaaaa' -UpdatedAtUtc '2026-08-24T00:00:07.0000000Z') + (New-CcodHarnessLifecycleReceipt -RuntimeId '2.5.21-e3b0c44298fc1c14-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' -RuntimeGeneration ([UInt64]7) -Phase 'CloseFailed' -TransactionId '55555555-6666-7777-8888-999999999999' -UpdatedAtUtc '2026-08-24T00:00:07.0000000Z'), + (New-CcodHarnessLifecycleReceipt -RuntimeId '2.5.21-e3b0c44298fc1c14-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' -RuntimeGeneration ([UInt64]7) -Phase 'RepairFailed' -TransactionId '66666666-7777-8888-9999-aaaaaaaaaaaa' -UpdatedAtUtc '2026-08-24T00:00:07.0000000Z') )) { Write-CcodHarnessJson -Path (Join-Path $root ("state\lifecycle\receipts\{0}.json" -f $record.transactionId)) -Value $record } @@ -496,14 +722,14 @@ Invoke-CcodTest 'does not treat tied wrong-generation receipts as current-genera Invoke-CcodTest 'fails closed on receipt filename duplicate-property and nonterminal boundaries' { . $harnessPath -Library foreach ($case in @('filename','duplicate-json','nonterminal')) { - $root = Join-Path $env:TEMP ("ccod-installed-receipt-$case-" + [guid]::NewGuid().ToString('N')) + $root = Join-Path (Get-CcodTestCanonicalTempRoot) ("ccod-installed-receipt-$case-" + [guid]::NewGuid().ToString('N')) try { $null = [IO.Directory]::CreateDirectory($root) - New-CcodHarnessInstalledStateFixture -Root $root -ActiveRuntimeId '2.5.21-new' -ActiveGeneration ([UInt64]8) ` - -StatusRuntimeId '2.5.21-new' -StatusCodexPid 13948 -StatusCodexCreationTimeUtc '2026-08-24T00:00:04.0000000Z' -ReceiptPhase 'Completed' + New-CcodHarnessInstalledStateFixture -Root $root -ActiveRuntimeId '2.5.21-e3b0c44298fc1c14-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' -ActiveGeneration ([UInt64]8) ` + -StatusRuntimeId '2.5.21-e3b0c44298fc1c14-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' -StatusCodexPid 13948 -StatusCodexCreationTimeUtc '2026-08-24T00:00:04.0000000Z' -ReceiptPhase 'Completed' switch ($case) { 'filename' { - $receipt = New-CcodHarnessLifecycleReceipt -RuntimeId '2.5.21-new' -RuntimeGeneration ([UInt64]8) -Phase 'Completed' ` + $receipt = New-CcodHarnessLifecycleReceipt -RuntimeId '2.5.21-e3b0c44298fc1c14-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' -RuntimeGeneration ([UInt64]8) -Phase 'Completed' ` -TransactionId '77777777-8888-9999-aaaa-bbbbbbbbbbbb' Write-CcodHarnessJson -Path (Join-Path $root 'state\lifecycle\receipts\88888888-9999-aaaa-bbbb-cccccccccccc.json') -Value $receipt Assert-CcodThrows { Get-CcodInstalledLifecycleFacts -InstallRoot $root } 'CCOD_INTEGRATION_FACTS_INVALID' @@ -515,7 +741,7 @@ Invoke-CcodTest 'fails closed on receipt filename duplicate-property and nonterm Assert-CcodThrows { Get-CcodInstalledLifecycleFacts -InstallRoot $root } 'CCOD_STATE_MALFORMED' } 'nonterminal' { - $receipt = New-CcodHarnessLifecycleReceipt -RuntimeId '2.5.21-new' -RuntimeGeneration ([UInt64]8) -Phase 'Completed' ` + $receipt = New-CcodHarnessLifecycleReceipt -RuntimeId '2.5.21-e3b0c44298fc1c14-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' -RuntimeGeneration ([UInt64]8) -Phase 'Completed' ` -TransactionId '99999999-aaaa-bbbb-cccc-dddddddddddd' $receipt.phase = 'Requested' Write-CcodHarnessJson -Path (Join-Path $root ("state\lifecycle\receipts\{0}.json" -f $receipt.transactionId)) -Value $receipt @@ -528,14 +754,38 @@ Invoke-CcodTest 'fails closed on receipt filename duplicate-property and nonterm } } +Invoke-CcodTest 'lifecycle receipt inspection rejects non-json residue and non-plain receipt links' { + . $harnessPath -Library + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-receipt-authority-' + [guid]::NewGuid().ToString('N')) + $outside = Join-Path ([IO.Path]::GetTempPath()) ('ccod-receipt-hardlink-' + [guid]::NewGuid().ToString('N') + '.json') + try { + [IO.Directory]::CreateDirectory($root) | Out-Null + New-CcodHarnessInstalledStateFixture -Root $root -ActiveRuntimeId '2.5.21-e3b0c44298fc1c14' -ActiveGeneration ([UInt64]8) ` + -StatusRuntimeId '2.5.21-e3b0c44298fc1c14' -StatusCodexPid 13948 -StatusCodexCreationTimeUtc '2026-08-24T00:00:04.0000000Z' -ReceiptPhase 'Completed' + Set-CcodHarnessProcessFixture -ChatGPT @( + (New-CcodHarnessCimProcess -Name 'ChatGPT.exe' -ProcessId 13948 -CreationTimeUtc '2026-08-24T00:00:04.0000000Z' -CommandLine '"C:\Program Files\WindowsApps\OpenAI.Codex\ChatGPT.exe"') + ) + $receiptDirectory = Join-Path $root 'state/lifecycle/receipts' + [IO.File]::WriteAllText((Join-Path $receiptDirectory 'unexpected.tmp'), 'residue', [Text.UTF8Encoding]::new($false)) + Assert-CcodThrows { Get-CcodInstalledLifecycleFacts -InstallRoot $root | Out-Null } 'CCOD_INTEGRATION_FACTS_INVALID' + Remove-Item -LiteralPath (Join-Path $receiptDirectory 'unexpected.tmp') -Force + New-Item -ItemType HardLink -Path $outside -Target (Get-ChildItem -LiteralPath $receiptDirectory -Filter '*.json' | Select-Object -First 1 -ExpandProperty FullName) | Out-Null + Assert-CcodThrows { Get-CcodInstalledLifecycleFacts -InstallRoot $root | Out-Null } 'CCOD_INTEGRATION_FACTS_INVALID' + } finally { + Clear-CcodHarnessProcessFixture + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + if (Test-Path -LiteralPath $outside) { Remove-Item -LiteralPath $outside -Force } + } +} + Invoke-CcodTest 'fails closed on malformed active status and transition schemas' { . $harnessPath -Library foreach ($case in @('active','status','transition')) { - $root = Join-Path $env:TEMP ("ccod-installed-state-$case-" + [guid]::NewGuid().ToString('N')) + $root = Join-Path (Get-CcodTestCanonicalTempRoot) ("ccod-installed-state-$case-" + [guid]::NewGuid().ToString('N')) try { $null = [IO.Directory]::CreateDirectory($root) - New-CcodHarnessInstalledStateFixture -Root $root -ActiveRuntimeId '2.5.21-new' -ActiveGeneration ([UInt64]8) ` - -StatusRuntimeId '2.5.21-new' -StatusCodexPid 13948 -StatusCodexCreationTimeUtc '2026-08-24T00:00:04.0000000Z' -ReceiptPhase 'Completed' + New-CcodHarnessInstalledStateFixture -Root $root -ActiveRuntimeId '2.5.21-e3b0c44298fc1c14-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' -ActiveGeneration ([UInt64]8) ` + -StatusRuntimeId '2.5.21-e3b0c44298fc1c14-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' -StatusCodexPid 13948 -StatusCodexCreationTimeUtc '2026-08-24T00:00:04.0000000Z' -ReceiptPhase 'Completed' switch ($case) { 'active' { $path = Join-Path $root 'active.json' @@ -566,7 +816,7 @@ Invoke-CcodTest 'fails closed on malformed active status and transition schemas' # Production mutation caught: treating a Codex.exe-only observation as installed app process evidence. Invoke-CcodTest 'does not accept a Codex.exe-only fixture as Codex process evidence' { . $harnessPath -Library - $root = Join-Path $env:TEMP ('ccod-installed-capture-' + [guid]::NewGuid().ToString('N')) + $root = Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-installed-capture-' + [guid]::NewGuid().ToString('N')) try { $null = [IO.Directory]::CreateDirectory($root) Set-CcodHarnessProcessFixture -ChatGPT @() -Codex @( @@ -580,22 +830,1122 @@ Invoke-CcodTest 'does not accept a Codex.exe-only fixture as Codex process evide } } +Invoke-CcodTest 'sealed direct uninstall launches the active public uninstaller rather than removed Inno executable' { + . $harnessPath -Library + $root=Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-modern-uninstall-'+[guid]::NewGuid().ToString('N')) + $originalStart=${function:Start-Process};$originalAck=${function:Read-CcodInstalledLifecycleOperatorAck} + $originalCommand=${function:Get-CcodInstalledLifecycleUninstallCommand} + $originalLaunch=${function:Start-CcodInstalledLifecycleVerifiedUninstall} + $state=[pscustomobject]@{Launch=$null;Ack=0;Resolved=0} + try { + [IO.Directory]::CreateDirectory($root)|Out-Null + $runtimeId='2.5.22-'+('a'*16)+'-'+('b'*32);$runtime=Join-Path $root ('runtime\'+$runtimeId) + [IO.Directory]::CreateDirectory($runtime)|Out-Null + $uninstaller=Join-Path $runtime 'Uninstall-CodexControlOtherDevices.ps1';[IO.File]::WriteAllText($uninstaller,'# inert uninstall test') + $installer=Join-Path $root 'candidate.exe';[IO.File]::WriteAllText($installer,'inert candidate') + $context=[pscustomobject]@{scenario='DirectUninstall';expectedVersion='2.5.22';installRoot=$root;installerApplicationRoot=(Join-Path $root 'obsolete-inno');installerPath=$installer;candidatePayloadFiles=@([pscustomobject]@{path='payload';length=1;sha256=('a'*64)});transactionId='11111111-2222-3333-4444-555555555555'} + function Get-CcodInstalledLifecycleUninstallCommand {param($InstallRoot,$ExpectedVersion,$ExpectedPayloadFiles)$state.Resolved++;Assert-CcodEqual '2.5.22' $ExpectedVersion 'resolver gets the bound version';Assert-CcodEqual 1 @($ExpectedPayloadFiles).Count 'resolver gets the actual candidate records';[pscustomobject]@{Spec=[pscustomobject]@{installRoot=$InstallRoot;runtimeId=$runtimeId;candidatePayloadFiles=$ExpectedPayloadFiles}}} + function Start-Process {throw 'unverified direct native launch must not occur'} + function Start-CcodInstalledLifecycleVerifiedUninstall {param($Spec)$state.Launch=$Spec;[pscustomobject]@{ExitCode=0}} + function Read-CcodInstalledLifecycleOperatorAck {param($Scenario,$Instructions)$state.Ack++} + $result=Invoke-CcodInstalledLifecycleOperatorScenario -Context $context + Assert-CcodEqual 1 $state.Resolved 'actual scenario resolves the sealed uninstaller contract' + Assert-CcodEqual $root $state.Launch.installRoot 'verified launcher receives the selected install root' + Assert-CcodEqual $runtimeId $state.Launch.runtimeId 'launcher receives the original runtime identity rather than command text' + Assert-CcodEqual 1 @($state.Launch.candidatePayloadFiles).Count 'launcher retains original candidate records' + Assert-CcodEqual 1 $state.Ack 'operator acknowledgement remains mandatory' + Assert-CcodTrue $result.completed 'only scenario initiation is reported; final absence is verified separately' + } finally { + foreach($pair in @(@('Start-Process',$originalStart),@('Read-CcodInstalledLifecycleOperatorAck',$originalAck),@('Get-CcodInstalledLifecycleUninstallCommand',$originalCommand),@('Start-CcodInstalledLifecycleVerifiedUninstall',$originalLaunch))){if($null-ne$pair[1]){Set-Item ('Function:'+$pair[0]) $pair[1]}else{Remove-Item ('Function:'+$pair[0]) -ErrorAction SilentlyContinue}} + if(Test-Path -LiteralPath $root){Remove-Item -LiteralPath $root -Recurse -Force} + } +} + +Invoke-CcodTest 'public finalizer launch arguments round trip through native parser and inert child' { + . $harnessPath -Library + Initialize-CcodInstalledLifecycleCommandLineParser + $publicPath=Join-Path $repositoryRoot 'Uninstall-CodexControlOtherDevices.ps1' + $tokens=$null;$errors=$null;$ast=[Management.Automation.Language.Parser]::ParseFile($publicPath,[ref]$tokens,[ref]$errors) + Assert-CcodEqual 0 @($errors).Count 'public wrapper parses before extracting inert argument boundary' + $converters=@($ast.FindAll({param($node)$node-is[Management.Automation.Language.FunctionDefinitionAst]-and$node.Name-in@('ConvertTo-CcodInstalledUninstallLiteral','ConvertTo-CcodPublicUninstallPowerShellLiteral')},$true)) + Assert-CcodEqual 2 $converters.Count 'both current native launch converters are covered' + foreach($converter in $converters){. ([scriptblock]::Create($converter.Extent.Text))} + $launchArguments=@($ast.FindAll({param($node)$node-is[Management.Automation.Language.AssignmentStatementAst]-and$node.Left-is[Management.Automation.Language.VariableExpressionAst]-and$node.Left.VariablePath.UserPath-ceq'arguments'},$true)) + Assert-CcodEqual 2 $launchArguments.Count 'installed and portable native argument builders are uniquely identified' + $root=Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod native args '+[char]0x6d4b+[char]0x8bd5+" O'Brien "+[guid]::NewGuid().ToString('N')) + $powershellPath=Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::System)) 'WindowsPowerShell/v1.0/powershell.exe' + $oldPolicy=[Environment]::GetEnvironmentVariable('PSExecutionPolicyPreference','Process') + try { + [IO.Directory]::CreateDirectory($root)|Out-Null + $finalizer=Join-Path $root 'inert finalizer.ps1';$finalizerPath=$finalizer + $probe=@' +param([switch]$WrapperResume,[string]$TransactionId,[string]$RuntimeRoot,[string]$InstallerRoot,[string]$InstallRoot,[int]$WrapperProcessId,[string]$WrapperCreationTimeUtc) +$ErrorActionPreference='Stop' +[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false) +[Console]::Out.WriteLine(([pscustomobject][ordered]@{transactionId=$TransactionId;runtimeRoot=$RuntimeRoot;installerRoot=$InstallerRoot;installRoot=$InstallRoot;wrapperPid=$WrapperProcessId;wrapperCreated=$WrapperCreationTimeUtc;resume=[bool]$WrapperResume}|ConvertTo-Json -Compress)) +'@ + [IO.File]::WriteAllText($finalizer,$probe,[Text.UTF8Encoding]::new($true)) + $installerRoot=Join-Path $root 'runtime selected';$expectedInstallRoot=(Join-Path $root 'install root')+'\' + $wrapperId=[int]417;$wrapperCreated='2030-02-03T04:05:01.0000006Z' + $prepared=[pscustomobject]@{transactionId='11111111-2222-3333-4444-555555555555'} + foreach($mode in @('Installed','Resume','Portable')){ + $resumeExisting=$mode-ceq'Resume';$arguments=$null + $builder=$launchArguments[$(if($mode-ceq'Portable'){1}else{0})] + . ([scriptblock]::Create($builder.Extent.Text)) + $argv=@([CcodInstalledLifecycleCommandLine]::Parse(('"'+$powershellPath+'" '+$arguments))) + $fileIndex=[Array]::IndexOf($argv,'-File') + Assert-CcodTrue ($fileIndex-gt0) 'actual builder supplies native File switch' + Assert-CcodEqual $finalizer $argv[$fileIndex+1] ($mode+' preserves finalizer filename before any process starts') + $expected=@('-TransactionId',$prepared.transactionId) + if($mode-ceq'Portable'){$expected+=@('-InstallerRoot',$installerRoot,'-InstallRoot',$expectedInstallRoot)} + else{$expected+=@('-RuntimeRoot',$installerRoot,'-InstallRoot',$expectedInstallRoot,'-WrapperProcessId',[string]$wrapperId,'-WrapperCreationTimeUtc',$wrapperCreated)} + if($resumeExisting){$expected=@('-WrapperResume')+$expected} + $actual=@($argv|Select-Object -Skip ($fileIndex+2)) + Assert-CcodEqual ($expected-join'|') ($actual-join'|') ($mode+' exact native arguments have no quotes or split paths') + # Argument fidelity probe only: omit the existing production policy override. + # The unmodified argv above is tested separately; no new Bypass child is run. + $normalArguments=$arguments.Replace('-ExecutionPolicy Bypass ','') + Assert-CcodTrue ($normalArguments-notmatch'(?i)-ExecutionPolicy') 'normal-policy smoke has no override argument' + [Environment]::SetEnvironmentVariable('PSExecutionPolicyPreference',$null,'Process') + $stdout=Join-Path $root ($mode+'.stdout');$stderr=Join-Path $root ($mode+'.stderr') + $child=Start-Process -FilePath $powershellPath -ArgumentList $normalArguments -RedirectStandardOutput $stdout -RedirectStandardError $stderr -NoNewWindow -PassThru -Wait -ErrorAction Stop + try{Assert-CcodEqual 0 $child.ExitCode ($mode+' inert child actually executes')}finally{$child.Dispose()} + $stderrText=[IO.File]::ReadAllText($stderr) + Assert-CcodEqual '' $stderrText ($mode+' inert child produces no error; stderr=[' + $stderrText.Replace("`r",'').Replace("`n",'') + ']') + $value=[IO.File]::ReadAllText($stdout)|ConvertFrom-Json + Assert-CcodEqual $prepared.transactionId $value.transactionId 'actual child retains transaction' + Assert-CcodEqual $expectedInstallRoot $value.installRoot 'actual child retains spaced trailing-slash path' + Assert-CcodEqual $resumeExisting $value.resume 'actual child retains resume distinction' + if($mode-ceq'Portable'){Assert-CcodEqual $installerRoot $value.installerRoot 'portable child receives its root'}else{Assert-CcodEqual $installerRoot $value.runtimeRoot 'installed child receives its root';Assert-CcodEqual $wrapperCreated $value.wrapperCreated 'installed child preserves precise wrapper identity';Assert-CcodEqual $wrapperId $value.wrapperPid 'installed child preserves wrapper PID'} + } + } finally { + [Environment]::SetEnvironmentVariable('PSExecutionPolicyPreference',$oldPolicy,'Process') + if(Test-Path -LiteralPath $root){Remove-Item -LiteralPath $root -Recurse -Force} + } +} + +Invoke-CcodTest 'public uninstall bootstrap invocation returns the actual function receipt instead of CLI exit' { + $publicPath=Join-Path $repositoryRoot 'Uninstall-CodexControlOtherDevices.ps1' + $bootstrapSource=Join-Path $repositoryRoot 'src/persistence/UninstallBootstrap.ps1' + $tokens=$null;$errors=$null;$publicAst=[Management.Automation.Language.Parser]::ParseFile($publicPath,[ref]$tokens,[ref]$errors) + Assert-CcodEqual 0 @($errors).Count 'public wrapper parses' + $bootstrapAst=[Management.Automation.Language.Parser]::ParseFile($bootstrapSource,[ref]$tokens,[ref]$errors) + Assert-CcodEqual 0 @($errors).Count 'bootstrap source parses' + $tail=@($bootstrapAst.EndBlock.Statements|Where-Object {$_-is[Management.Automation.Language.IfStatementAst]-and$_.Extent.Text.StartsWith('if ($MyInvocation.InvocationName')}) + Assert-CcodEqual 1 $tail.Count 'actual CLI-only entry is unambiguous' + $calls=@($publicAst.FindAll({param($Node)$Node-is[Management.Automation.Language.AssignmentStatementAst]-and$Node.Left-is[Management.Automation.Language.VariableExpressionAst]-and$Node.Left.VariablePath.UserPath-ceq'prepared'},$true)) + Assert-CcodEqual 2 $calls.Count 'installed and portable actual caller expressions are exercised' + $root=Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-bootstrap-receipt-'+[guid]::NewGuid().ToString('N')) + [IO.Directory]::CreateDirectory($root)|Out-Null + try { + $bootstrapPath=Join-Path $root 'bootstrap.ps1';$installerRoot=$root;$expectedInstallRoot=Join-Path $root 'install' + $wrapperIdentity=[pscustomobject]@{pid=7} + $stub=@' +function Invoke-CcodUninstallBootstrap { + param($InstallerRoot,$InstallRoot,$Mode,$WrapperIdentity) + [pscustomobject]@{transactionId='11111111-2222-3333-4444-555555555555';phase=$(if($Mode-ceq'PrepareInstalled'){'TaskRemoved'}else{'ReadyForInno'});installerRoot=$InstallerRoot;installRoot=$InstallRoot;mode=$Mode;wrapper=$WrapperIdentity} +} +'@ + [IO.File]::WriteAllText($bootstrapPath,$bootstrapAst.ParamBlock.Extent.Text+"`n"+$stub+"`n"+$tail[0].Extent.Text,[Text.UTF8Encoding]::new($false)) + foreach($definition in @($publicAst.EndBlock.Statements|Where-Object {$_-is[Management.Automation.Language.FunctionDefinitionAst]})){. ([scriptblock]::Create($definition.Extent.Text))} + foreach($index in @(0,1)) { + $prepared=$null + . ([scriptblock]::Create($calls[$index].Extent.Text)) + Assert-CcodTrue ($null-ne$prepared) 'the real public caller obtains the bootstrap function receipt' + Assert-CcodEqual '11111111-2222-3333-4444-555555555555' $prepared.transactionId 'producer receipt is not synthesized by the wrapper' + Assert-CcodEqual $root $prepared.installerRoot 'caller runtime root reaches the function unchanged' + Assert-CcodEqual $expectedInstallRoot $prepared.installRoot 'target root reaches the function unchanged' + Assert-CcodEqual @('TaskRemoved','ReadyForInno')[$index] $prepared.phase 'both public modes preserve their terminal boundary' + } + } finally {if(Test-Path $root){Remove-Item $root -Recurse -Force}} +} + +Invoke-CcodTest 'direct uninstall never consumes a substituted dependency after resolution' { + . $harnessPath -Library + $root=Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-uninstall-consumption-'+[guid]::NewGuid().ToString('N')) + $originalFacts=${function:Get-CcodInstalledLifecycleFacts};$originalActive=${function:Read-CcodInstalledLifecycleActiveFact} + $originalStart=${function:Start-Process};$originalAck=${function:Read-CcodInstalledLifecycleOperatorAck} + $originalLaunch=${function:Start-CcodInstalledLifecycleVerifiedUninstall} + try { + $runtimeId='2.5.22-'+('a'*16)+'-'+('b'*32);$runtime=Join-Path $root ('runtime\'+$runtimeId) + [IO.Directory]::CreateDirectory((Join-Path $runtime 'src/persistence'))|Out-Null + $uninstaller=Join-Path $runtime 'Uninstall-CodexControlOtherDevices.ps1';$dependency=Join-Path $runtime 'src/persistence/UninstallBootstrap.ps1' + $body='[CmdletBinding(SupportsShouldProcess=$true)]param(); $global:CcodUninstallRaceConsumed++; . (Join-Path $PSScriptRoot ''src/persistence/UninstallBootstrap.ps1''); [pscustomobject]@{Outcome=''InstalledFinalizationStarted'';TransactionId=''11111111-2222-3333-4444-555555555555'';FinalizerProcessId=1;KeptDeviceKeyStore=$true}' + [IO.File]::WriteAllText($uninstaller,$body);[IO.File]::WriteAllText($dependency,'$global:CcodUninstallRaceValue=''original''') + $records=@('Uninstall-CodexControlOtherDevices.ps1','src/persistence/UninstallBootstrap.ps1'|ForEach-Object {$path=Join-Path $runtime $_;[pscustomobject][ordered]@{path=$_;length=[long](Get-Item $path).Length;sha256=Get-CcodTestFileSha256 $path}}) + $manifestPath=Join-Path $runtime 'manifest.json';[IO.File]::WriteAllText($manifestPath,'inert manifest captured by independent facts seam') + $facts=New-CcodHarnessFacts -Version '2.5.22' -RuntimeId $runtimeId;$facts.runtimeManifestFiles=$records;$facts.runtimeManifestSha256=Get-CcodTestFileSha256 $manifestPath + $installer=Join-Path $root 'candidate.exe';[IO.File]::WriteAllText($installer,'inert candidate') + $context=[pscustomobject]@{scenario='DirectUninstall';expectedVersion='2.5.22';installRoot=$root;installerApplicationRoot=(Join-Path $root 'obsolete');installerPath=$installer;candidatePayloadFiles=$records;transactionId='11111111-2222-3333-4444-555555555555'} + $probe=[pscustomobject]@{Attack=$false;Seam=0;Ack=0;Facts=$facts} + function Get-CcodInstalledLifecycleFacts {param($InstallRoot,$ExpectedVersion,$ExpectedPayloadFiles)return $probe.Facts} + function Read-CcodInstalledLifecycleActiveFact {param($InstallRoot)[pscustomobject]@{activeRuntime=$runtimeId;generation=[uint64]1}} + function Start-Process {param($FilePath,$ArgumentList,[switch]$PassThru,[switch]$Wait,$ErrorAction) + $probe.Seam++;if($probe.Attack){[IO.File]::WriteAllText($dependency,'$global:CcodUninstallRaceValue=''substituted''')} + & $uninstaller -Confirm:$false|Out-Null + [pscustomobject]@{ExitCode=0} + } + function Start-CcodInstalledLifecycleVerifiedUninstall {param($Spec) + $probe.Seam++;if($probe.Attack){[IO.File]::WriteAllText($dependency,'$global:CcodUninstallRaceValue=''substituted''')} + Invoke-CcodInstalledLifecycleVerifiedUninstall -Spec $Spec|Out-Null + [pscustomobject]@{ExitCode=0} + } + function Read-CcodInstalledLifecycleOperatorAck {param($Scenario,$Instructions)$probe.Ack++} + $global:CcodUninstallRaceConsumed=0;$global:CcodUninstallRaceValue=$null + $control=Invoke-CcodInstalledLifecycleOperatorScenario -Context $context + Assert-CcodTrue $control.completed 'unchanged actual resolver and consumer path succeeds' + Assert-CcodEqual 1 $global:CcodUninstallRaceConsumed 'positive control actually loads inert wrapper and dependency' + Assert-CcodEqual 'original' $global:CcodUninstallRaceValue 'positive control observes original dependency bytes' + $probe.Attack=$true;$probe.Seam=0;$probe.Ack=0;$global:CcodUninstallRaceConsumed=0;$global:CcodUninstallRaceValue=$null + $failure=$null;try{Invoke-CcodInstalledLifecycleOperatorScenario -Context $context|Out-Null}catch{$failure=$_.FullyQualifiedErrorId} + Assert-CcodEqual 1 $probe.Seam 'replacement occurs at the real parent-to-consumer boundary' + Assert-CcodEqual 0 $global:CcodUninstallRaceConsumed 'substitution cannot execute even the inert wrapper' + Assert-CcodTrue ($failure-like'CCOD_INTEGRATION_*') 'changed dependency fails closed before operation success' + Assert-CcodEqual 0 $probe.Ack 'no success acknowledgement occurs after rejected consumption' + } finally { + foreach($pair in @(@('Get-CcodInstalledLifecycleFacts',$originalFacts),@('Read-CcodInstalledLifecycleActiveFact',$originalActive),@('Start-Process',$originalStart),@('Read-CcodInstalledLifecycleOperatorAck',$originalAck),@('Start-CcodInstalledLifecycleVerifiedUninstall',$originalLaunch))){if($null-ne$pair[1]){Set-Item ('Function:'+$pair[0]) $pair[1]}else{Remove-Item ('Function:'+$pair[0]) -ErrorAction SilentlyContinue}} + Remove-Variable CcodUninstallRaceConsumed,CcodUninstallRaceValue -Scope Global -ErrorAction SilentlyContinue + if(Test-Path $root){Remove-Item $root -Recurse -Force} + } +} + +Invoke-CcodTest 'verified uninstall launcher uses normal-policy native process and exact completion framing' { + . $harnessPath -Library + $root=Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod child '+[char]0x6d4b+[char]0x8bd5+" O'Brien "+[guid]::NewGuid().ToString('N')) + $oldRoot=$script:CcodInstalledLifecycleRepositoryRoot + try { + foreach($relative in @('tests/installed','tools','src/persistence/modules')){[IO.Directory]::CreateDirectory((Join-Path $root $relative))|Out-Null} + Copy-Item (Join-Path $repositoryRoot 'tools/ReleaseAssetContract.psm1') (Join-Path $root 'tools/ReleaseAssetContract.psm1') + foreach($leaf in @('PersistenceIO.psm1','StateStore.psm1','TrustedLogonIdentity.psm1','LifecycleTransaction.psm1','KernelObjects.psm1','ProductRegistration.psm1')){[IO.File]::WriteAllText((Join-Path $root ('src/persistence/modules/'+$leaf)),'# inert source held by real launcher')} + $entry=Join-Path $root 'tests/installed/Invoke-InstalledLifecycleIntegration.ps1' + $child=@' +param([switch]$VerifiedUninstallChild,[switch]$AllowMachineMutation,[switch]$AllowCodexRestart) +$ErrorActionPreference='Stop' +[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false) +$reader=[IO.StreamReader]::new([Console]::OpenStandardInput(),[Text.UTF8Encoding]::new($false),$true) +$raw=$reader.ReadToEnd();$reader.Dispose() +$spec=$raw|ConvertFrom-Json +if(-not$VerifiedUninstallChild){throw 'child mode missing'} +if(-not[string]::IsNullOrEmpty($env:PSExecutionPolicyPreference)){throw 'inherited policy override'} +$root=Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +$blocked=0 +foreach($path in @($PSCommandPath,(Join-Path $root 'src/persistence/modules/KernelObjects.psm1'))){try{[IO.File]::WriteAllText($path,'wrong')}catch [IO.IOException]{$blocked++}} +try{[IO.Directory]::Move($root,$root+'-moved')}catch [IO.IOException]{$blocked++} +if($blocked-ne3){throw 'parent source authority absent'} +if($spec.text-cne('inert-'+[char]0x6d4b+[char]0x8bd5+" O'Brien")){throw 'stdin unicode drift'} +if($spec.mode-ceq'Error'){[Console]::Error.WriteLine('inert child failure');exit 1} +if($spec.mode-ceq'Empty'){exit 0} +if($spec.mode-ceq'Polluted'){[Console]::Out.WriteLine('unexpected output')} +[Console]::Out.WriteLine('CCOD_UNINSTALL_WRAPPER_COMPLETED') +'@ + [IO.File]::WriteAllText($entry,$child,[Text.UTF8Encoding]::new($true)) + $script:CcodInstalledLifecycleRepositoryRoot=$root + $spec=[pscustomobject]@{mode='Normal';text=('inert-'+[char]0x6d4b+[char]0x8bd5+" O'Brien")} + $control=Start-CcodInstalledLifecycleVerifiedUninstall -Spec $spec + Assert-CcodEqual 0 $control.ExitCode 'normal-policy child consumes original source and exact Unicode input' + foreach($mode in @('Error','Empty','Polluted')){$spec.mode=$mode;Assert-CcodThrows {Start-CcodInstalledLifecycleVerifiedUninstall -Spec $spec|Out-Null} 'CCOD_INTEGRATION_INSTALLER_FAILED'} + $spec.mode='Normal';Assert-CcodEqual 0 (Start-CcodInstalledLifecycleVerifiedUninstall -Spec $spec).ExitCode 'failed child runs release all source holds for retry' + [IO.File]::WriteAllText($entry,'# writable after child completion') + } finally {$script:CcodInstalledLifecycleRepositoryRoot=$oldRoot;if(Test-Path $root){Remove-Item $root -Recurse -Force}} +} + +Invoke-CcodTest 'native uninstall child requires both machine and host restart authorizations before consumption' { + $tokens=$null;$errors=$null;$ast=[Management.Automation.Language.Parser]::ParseFile($harnessPath,[ref]$tokens,[ref]$errors) + Assert-CcodEqual 0 @($errors).Count 'real child entry parses' + $tail=@($ast.EndBlock.Statements|Where-Object {$_-is[Management.Automation.Language.IfStatementAst]-and$_.Extent.Text.StartsWith('if ($VerifiedUninstallChild)')}) + Assert-CcodEqual 1 $tail.Count 'one actual native child dispatch is selected' + $root=Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-child-consent-'+[guid]::NewGuid().ToString('N')) + [IO.Directory]::CreateDirectory($root)|Out-Null + try { + $probe=Join-Path $root 'child.ps1' + $stub=@' +$ErrorActionPreference='Stop' +$script:CcodInstalledLifecyclePersistenceIoModule=New-Module {function Test-CcodJsonHasNoDuplicateProperties {param($Json)return $true}} +function Throw-CcodInstalledLifecycleError {param($Id,$Message,$Target)throw $Id} +function Invoke-CcodInstalledLifecycleVerifiedUninstall {param($Spec)[IO.File]::WriteAllText((Join-Path $PSScriptRoot 'consumed.txt'),'inert consumer reached')} +'@ + [IO.File]::WriteAllText($probe,$ast.ParamBlock.Extent.Text+"`n"+$stub+"`n"+$tail[0].Extent.Text,[Text.UTF8Encoding]::new($true)) + $spec=[pscustomobject]@{installRoot=[IO.Path]::GetFullPath((Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)) 'CodexControlOtherDevices'))} + foreach($arguments in @('',' -AllowMachineMutation',' -AllowCodexRestart',' -AllowMachineMutation -AllowCodexRestart')) { + $start=[Diagnostics.ProcessStartInfo]::new();$start.FileName=Join-Path $PSHOME 'powershell.exe' + $start.Arguments='-NoLogo -NoProfile -NonInteractive -File "'+$probe+'" -VerifiedUninstallChild'+$arguments + $start.UseShellExecute=$false;$start.CreateNoWindow=$true;$start.RedirectStandardInput=$true;$start.RedirectStandardOutput=$true;$start.RedirectStandardError=$true + [void]$start.EnvironmentVariables.Remove('PSExecutionPolicyPreference') + $child=[Diagnostics.Process]::Start($start) + try { + $output=$child.StandardOutput.ReadToEndAsync();$errorOutput=$child.StandardError.ReadToEndAsync() + Write-CcodTestProcessInput -Process $child -Text ($spec|ConvertTo-Json -Compress);$child.StandardInput.Close() + Assert-CcodTrue ($child.WaitForExit(15000)) 'inert child gate completes' + $authorized=$arguments-ceq' -AllowMachineMutation -AllowCodexRestart' + Assert-CcodEqual $authorized ([IO.File]::Exists((Join-Path $root 'consumed.txt'))) 'only both explicit consents allow reaching the consumer' + if($authorized){Assert-CcodEqual 0 $child.ExitCode 'authorized inert child reaches valid dispatch';Assert-CcodEqual ('CCOD_UNINSTALL_WRAPPER_COMPLETED'+[Environment]::NewLine) $output.Result 'authorized dispatch returns exact marker'} + else{Assert-CcodTrue ($child.ExitCode-ne0) 'missing consent cannot report success'} + } finally {if(-not$child.HasExited){$child.Kill();$child.WaitForExit()};$child.Dispose()} + } + } finally {if(Test-Path $root){Remove-Item $root -Recurse -Force}} +} + +Invoke-CcodTest 'native verified consumer retains dependencies until wrapper return and releases before process exit' { + . $harnessPath -Library + $root=Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-consumer-native-'+[guid]::NewGuid().ToString('N')) + $child=$null + try { + $runtimeId='2.5.22-'+('a'*16)+'-'+('b'*32);$runtime=Join-Path $root ('runtime\'+$runtimeId) + [IO.Directory]::CreateDirectory((Join-Path $runtime 'src/persistence'))|Out-Null + $wrapper=Join-Path $runtime 'Uninstall-CodexControlOtherDevices.ps1';$dependency=Join-Path $runtime 'src/persistence/UninstallBootstrap.ps1' + $body=@' +[CmdletBinding(SupportsShouldProcess=$true)]param() +$blocked=0 +foreach($path in @($PSCommandPath,(Join-Path $PSScriptRoot 'src/persistence/UninstallBootstrap.ps1'))){try{[IO.File]::WriteAllText($path,'not allowed')}catch [IO.IOException]{$blocked++}} +try{[IO.Directory]::Move($PSScriptRoot,$PSScriptRoot+'-moved')}catch [IO.IOException]{$blocked++} +. (Join-Path $PSScriptRoot 'src/persistence/UninstallBootstrap.ps1') +if($bootstrapProof-cne'original-bootstrap'){throw 'changed bootstrap consumed'} +[Console]::Out.WriteLine('CCOD_HELD='+$blocked);[Console]::Out.Flush() +$reader=[IO.StreamReader]::new([Console]::OpenStandardInput(),[Text.UTF8Encoding]::new($false),$true) +if($reader.ReadLine()-cne'continue'){throw 'handshake failed'} +[pscustomobject][ordered]@{Outcome='InstalledFinalizationStarted';TransactionId='11111111-2222-3333-4444-555555555555';FinalizerProcessId=[int]$PID;KeptDeviceKeyStore=$true} +'@ + [IO.File]::WriteAllText($wrapper,$body,[Text.UTF8Encoding]::new($false));[IO.File]::WriteAllText($dependency,'$bootstrapProof=''original-bootstrap''') + $manifest=Join-Path $runtime 'manifest.json';[IO.File]::WriteAllText($manifest,'inert manifest bound by captured facts') + $records=@('Uninstall-CodexControlOtherDevices.ps1','src/persistence/UninstallBootstrap.ps1'|ForEach-Object {$path=Join-Path $runtime $_;[pscustomobject][ordered]@{path=$_;length=[long](Get-Item $path).Length;sha256=Get-CcodTestFileSha256 $path}}) + $facts=New-CcodHarnessFacts -Version '2.5.22' -RuntimeId $runtimeId;$facts.runtimeManifestFiles=$records;$facts.runtimeManifestSha256=Get-CcodTestFileSha256 $manifest + $spec=[pscustomobject][ordered]@{schemaVersion=1;installRoot=$root;expectedVersion='2.5.22';runtimeId=$runtimeId;generation=1;manifestSha256=$facts.runtimeManifestSha256;runtimeFiles=$records;candidatePayloadFiles=$records} + [IO.File]::WriteAllText((Join-Path $root 'facts.json'),($facts|ConvertTo-Json -Depth 12),[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText((Join-Path $root 'spec.json'),($spec|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + $driver=Join-Path $root 'driver.ps1' + $text=@' +param([string]$IntegrationPath) +$ErrorActionPreference='Stop';$WarningPreference='SilentlyContinue' +# The parent asserts this child's stderr is byte-empty, so non-error streams must be +# silent: a progress record would otherwise be serialized to stderr as CLIXML. +$ProgressPreference='SilentlyContinue' +. $IntegrationPath -Library +$script:FixtureFacts=[IO.File]::ReadAllText((Join-Path $PSScriptRoot 'facts.json'))|ConvertFrom-Json +$spec=[IO.File]::ReadAllText((Join-Path $PSScriptRoot 'spec.json'))|ConvertFrom-Json +function Get-CcodInstalledLifecycleFacts {param($InstallRoot,$ExpectedVersion,$ExpectedPayloadFiles)return $script:FixtureFacts} +function Read-CcodInstalledLifecycleActiveFact {param($InstallRoot)[pscustomobject]@{activeRuntime=$script:FixtureFacts.activeRuntimeId;generation=1}} +$result=Invoke-CcodInstalledLifecycleVerifiedUninstall -Spec $spec +$runtime=Join-Path (Join-Path $spec.installRoot 'runtime') $spec.runtimeId +[IO.Directory]::Move($runtime,$runtime+'-released') +[IO.Directory]::Move($runtime+'-released',$runtime) +if($result.Outcome-cne'InstalledFinalizationStarted'){throw 'wrong completion'} +[Console]::Out.WriteLine('CCOD_RELEASED_BEFORE_EXIT') +'@ + [IO.File]::WriteAllText($driver,$text,[Text.UTF8Encoding]::new($false)) + $start=[Diagnostics.ProcessStartInfo]::new();$start.FileName=Join-Path $PSHOME 'powershell.exe';$start.Arguments='-NoLogo -NoProfile -NonInteractive -File "'+$driver+'" -IntegrationPath "'+$harnessPath+'"' + $start.UseShellExecute=$false;$start.CreateNoWindow=$true;$start.RedirectStandardInput=$true;$start.RedirectStandardOutput=$true;$start.RedirectStandardError=$true + [void]$start.EnvironmentVariables.Remove('PSExecutionPolicyPreference') + $child=[Diagnostics.Process]::Start($start);$errors=$child.StandardError.ReadToEndAsync();$line=$child.StandardOutput.ReadLineAsync() + Assert-CcodTrue ($line.Wait(20000)) 'real consumer reaches the explicit held checkpoint' + Assert-CcodEqual 'CCOD_HELD=3' $line.Result 'actual child cannot rewrite wrapper dependency or ancestor while executing' + $blocked=$false;try{[IO.File]::WriteAllText($dependency,'outside writer')}catch [IO.IOException]{$blocked=$true};Assert-CcodTrue $blocked 'parent also cannot alter held dependency' + Write-CcodTestProcessInput -Process $child -Text 'continue' -AddNewLine;$child.StandardInput.Close();$rest=$child.StandardOutput.ReadToEndAsync() + Assert-CcodTrue ($child.WaitForExit(20000)) 'actual consumer exits after dependency handoff' + Assert-CcodEqual 0 $child.ExitCode ('real consumer failure: '+$errors.Result) + Assert-CcodEqual ('CCOD_RELEASED_BEFORE_EXIT'+[Environment]::NewLine) $rest.Result 'directory authority is released before wrapper process exit' + Assert-CcodEqual '' $errors.Result ('child has no hidden errors; stderr=[' + $errors.Result.Replace("`r",'').Replace("`n",'') + ']') + [IO.Directory]::Move($runtime,$runtime+'-reclaimed') + } finally {if($null-ne$child){if(-not$child.HasExited){$child.StandardInput.Close();if(-not$child.WaitForExit(15000)){$child.Kill();$child.WaitForExit()}};$child.Dispose()};if(Test-Path $root){Remove-Item $root -Recurse -Force}} +} + +Invoke-CcodTest 'staged uninstall dependency cannot change at its real module import boundary' { + $root=Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-stage-load-'+[guid]::NewGuid().ToString('N')) + $module=$null;$loaded=$null + try { + $payload=Join-Path $root 'payload';$modules=Join-Path $payload 'src/persistence/modules' + [IO.Directory]::CreateDirectory($modules)|Out-Null + $target=Join-Path $modules 'InstallLifecycle.psm1' + [IO.File]::WriteAllText($target,'function Invoke-CcodUninstallCleanup { param($InstallRoot,$Transaction,$WriteTransaction,$StopAfterTaskRemoval) $Transaction }; Export-ModuleMember -Function Invoke-CcodUninstallCleanup') + $records=@([pscustomobject][ordered]@{path='src/persistence/modules/InstallLifecycle.psm1';length=[long](Get-Item $target).Length;sha256=Get-CcodTestFileSha256 $target}) + $transaction=[pscustomobject]@{installedBinding=[pscustomobject]@{payloadRecords=$records}} + $bootstrap=Join-Path $repositoryRoot 'src/persistence/UninstallBootstrap.ps1' + $module=New-Module -ArgumentList $bootstrap -ScriptBlock {param($Source). $Source} + $probe=[pscustomobject]@{Target=$target;Attack=$false;Reached=0;Denied=0;Loaded=$null} + &$module { + param($State) + $script:BoundaryProbe=$State + function script:Import-Module { + [CmdletBinding()]param([string]$Name,[switch]$Force,[switch]$PassThru,[switch]$DisableNameChecking) + $state=$script:BoundaryProbe + if($Name-ceq$state.Target){$state.Reached++;if($state.Attack){try{[IO.File]::AppendAllText($Name,"`nthrow 'substituted module initializer'")}catch [IO.IOException]{$state.Denied++}}} + $state.Loaded=Microsoft.PowerShell.Core\Import-Module -Name $Name -Force -PassThru -DisableNameChecking + if($PassThru){return $state.Loaded} + } + } $probe + $run=&$module {(Get-CcodUninstallBootstrapAdapters).RunCleanup} + $control=&$module {param($Run,$Root,$Transaction)&$Run $Root $Root $Root $Transaction {param($Value)} 'PrepareInstalled'} $run $root $transaction + Assert-CcodTrue ($null-ne$control) 'valid staged module reaches its actual cleanup function' + $probe.Attack=$true;$probe.Reached=0;$failure=$null + try{&$module {param($Run,$Root,$Transaction)&$Run $Root $Root $Root $Transaction {param($Value)} 'PrepareInstalled'} $run $root $transaction|Out-Null}catch{$failure=$_} + Assert-CcodEqual 1 $probe.Reached 'attack reaches the real import seam' + Assert-CcodEqual 1 $probe.Denied 'held staged dependency refuses modification through actual loader' + Assert-CcodEqual $null $failure 'valid original cleanup is not poisoned by a denied writer' + [IO.File]::AppendAllText($target,"`n# writable after consumption") + } finally {if($null-ne$probe-and$null-ne$probe.Loaded){Remove-Module $probe.Loaded -Force -ErrorAction SilentlyContinue};if($null-ne$module){Remove-Module $module -Force -ErrorAction SilentlyContinue};if(Test-Path $root){Remove-Item $root -Recurse -Force}} +} + +Invoke-CcodTest 'public installed finalizer retains staged bytes through native child acquisition' { + $root=Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-finalizer-launch-'+[guid]::NewGuid().ToString('N')) + $originalStart=${function:Start-Process};$process=$null + $tokens=$null;$errors=$null;$ast=[Management.Automation.Language.Parser]::ParseFile((Join-Path $repositoryRoot 'Uninstall-CodexControlOtherDevices.ps1'),[ref]$tokens,[ref]$errors) + Assert-CcodEqual 0 @($errors).Count 'public wrapper parses' + foreach($definition in @($ast.EndBlock.Statements|Where-Object {$_-is[Management.Automation.Language.FunctionDefinitionAst]})){. ([scriptblock]::Create($definition.Extent.Text))} + $launch=@($ast.FindAll({param($Node)$Node-is[Management.Automation.Language.TryStatementAst]-and$Node.Body.Extent.Text.StartsWith('{$process=')},$true)) + Assert-CcodEqual 1 $launch.Count 'actual installed finalizer launch is unique' + try { + $transactionDirectory=$root;$payloadRoot=Join-Path $root 'payload';$bootstrapPath=Join-Path $repositoryRoot 'src/persistence/UninstallBootstrap.ps1' + [IO.Directory]::CreateDirectory((Join-Path $payloadRoot 'src/persistence'))|Out-Null + $finalizer=Join-Path $payloadRoot 'src/persistence/InstalledUninstallFinalizer.ps1' + $child=@' +param([string]$AuthorityReadyHandle) +$ErrorActionPreference='Stop' +. '__BOOTSTRAP__' +$records=[IO.File]::ReadAllText('__RECORDS__')|ConvertFrom-Json +$lease=Open-CcodUninstallBootstrapPayloadAuthority -PayloadRoot '__PAYLOAD__' -Records @($records) +try { + if(-not[string]::IsNullOrEmpty($AuthorityReadyHandle)){ + $pipe=[IO.Pipes.AnonymousPipeClientStream]::new([IO.Pipes.PipeDirection]::Out,$AuthorityReadyHandle) + try{$bytes=[BitConverter]::GetBytes([int]$PID);$pipe.Write($bytes,0,$bytes.Length);$pipe.Flush()}finally{$pipe.Dispose()} + } +}finally{$lease.Dispose()} +'@ + $recordsPath=Join-Path $root 'records.json' + $child=$child.Replace('__BOOTSTRAP__',$bootstrapPath.Replace("'","''")).Replace('__RECORDS__',$recordsPath.Replace("'","''")).Replace('__PAYLOAD__',$payloadRoot.Replace("'","''")) + [IO.File]::WriteAllText($finalizer,$child,[Text.UTF8Encoding]::new($false)) + $records=@([pscustomobject][ordered]@{path='src/persistence/InstalledUninstallFinalizer.ps1';length=[long](Get-Item $finalizer).Length;sha256=Get-CcodTestFileSha256 $finalizer}) + [IO.File]::WriteAllText($recordsPath,(ConvertTo-Json -InputObject $records -Depth 4),[Text.UTF8Encoding]::new($false)) + $prepared=[pscustomobject]@{installedBinding=[pscustomobject]@{payloadRecords=$records}} + $powershellPath=Join-Path $PSHOME 'powershell.exe';$arguments='-NoLogo -NoProfile -NonInteractive -File "'+$finalizer+'"' + $probe=[pscustomobject]@{Attack=$false;Reached=0;Denied=0} + function Start-Process { + [CmdletBinding()]param($FilePath,$ArgumentList,$WindowStyle,$RedirectStandardOutput,$RedirectStandardError,[switch]$PassThru) + $probe.Reached++ + if($probe.Attack){try{[IO.File]::AppendAllText($finalizer,"`nthrow 'substituted finalizer initializer'")}catch [IO.IOException]{$probe.Denied++}} + $child=Microsoft.PowerShell.Management\Start-Process @PSBoundParameters + [void]$child.Handle + return $child + } + foreach($attack in @($false,$true)) { + $probe.Attack=$attack;$probe.Reached=0;$probe.Denied=0;$failure=$null + try{. ([scriptblock]::Create($launch[0].Extent.Text))}catch{$failure=$_} + Assert-CcodEqual 1 $probe.Reached 'real parent launch boundary is exercised' + if($attack){Assert-CcodEqual 1 $probe.Denied 'staged script replacement is denied before the native child reads it'} + Assert-CcodEqual $null $failure 'original finalizer acquisition is not poisoned by a denied replacement' + Assert-CcodTrue ($process.WaitForExit(15000)) 'owned inert finalizer finishes' + Assert-CcodEqual 0 $process.ExitCode ('inert child failed: '+[IO.File]::ReadAllText((Join-Path $root 'installed-finalizer.stderr.log'))) + $process.Dispose();$process=$null + } + [IO.File]::AppendAllText($finalizer,"`n# released after native acquisition") + } finally {if($null-ne$process){if(-not$process.HasExited){$process.Kill();$process.WaitForExit()};$process.Dispose()};if($null-ne$originalStart){Set-Item Function:Start-Process $originalStart}else{Remove-Item Function:Start-Process -ErrorAction SilentlyContinue};if(Test-Path $root){Remove-Item $root -Recurse -Force}} +} + +Invoke-CcodTest 'default finalizer pins its staged closure before validating the envelope' { + $root=Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-finalizer-c-'+[guid]::NewGuid().ToString('N')) + $previous=[Environment]::GetEnvironmentVariable('LOCALAPPDATA','Process');$module=$null + try { + [Environment]::SetEnvironmentVariable('LOCALAPPDATA',$root,'Process') + $id='11111111-2222-3333-4444-555555555555';$payload=Join-Path $root ('CodexRemote-fix-uninstall/'+$id+'/payload') + $source=Join-Path $repositoryRoot 'src/persistence/InstalledUninstallFinalizer.ps1' + $tokens=$null;$errors=$null;$ast=[Management.Automation.Language.Parser]::ParseFile($source,[ref]$tokens,[ref]$errors) + Assert-CcodEqual 0 @($errors).Count 'actual finalizer parses' + $entries=@($ast.FindAll({param($Node)$Node-is[Management.Automation.Language.AssignmentStatementAst]-and$Node.Left.Extent.Text-ceq'$script:CcodInstalledFinalizerPayloadEntries'},$true)) + Assert-CcodEqual 1 $entries.Count 'one complete production payload allowlist' + $leaves=@($entries[0].Right.FindAll({param($Node)$Node-is[Management.Automation.Language.StringConstantExpressionAst]},$true)|ForEach-Object Value) + foreach($relative in $leaves){$path=Join-Path $payload $relative;[IO.Directory]::CreateDirectory((Split-Path $path -Parent))|Out-Null;[IO.File]::WriteAllText($path,'# inert staged payload')} + $entry=Join-Path $payload 'src/persistence/InstalledUninstallFinalizer.ps1';Copy-Item $source $entry -Force + $module=New-Module -ArgumentList $entry -ScriptBlock {param($Path). $Path} + $probe=[pscustomobject]@{Target=(Join-Path $payload 'src/persistence/UninstallBootstrap.ps1');Root=$payload;Reached=0;Denied=0} + &$module { + param($Probe) + $script:AuthorityProbe=$Probe + function script:Read-CcodInstalledFinalizerEnvelope { + param($TransactionRoot,$TransactionId,$PayloadRoot,$RuntimeRoot,$InstallRoot) + $script:AuthorityProbe.Reached++ + try{[IO.File]::AppendAllText($script:AuthorityProbe.Target,'# must not change')}catch [IO.IOException]{$script:AuthorityProbe.Denied++} + try{[IO.Directory]::Move($script:AuthorityProbe.Root,$script:AuthorityProbe.Root+'-moved');[IO.Directory]::Move($script:AuthorityProbe.Root+'-moved',$script:AuthorityProbe.Root)}catch [IO.IOException]{$script:AuthorityProbe.Denied++} + return [pscustomobject]@{validated=$true} + } + function script:Get-CcodInstalledFinalizerAdapters {param($Adapters,$TransactionRoot,$PayloadRoot,$RuntimeRoot,$InstallRoot)throw 'INERT_STOP_BEFORE_ANY_MACHINE_OPERATION'} + } $probe + $install=Join-Path $root 'CodexControlOtherDevices';$runtime=Join-Path $install ('runtime/2.5.22-'+('a'*16)+'-'+('b'*32)) + $failure=$null;try{&$module {param($Id,$Runtime,$Install)Invoke-CcodInstalledUninstallFinalizer -TransactionId $Id -RuntimeRoot $Runtime -InstallRoot $Install -WrapperIdentity $null -Resume} $id $runtime $install|Out-Null}catch{$failure=$_} + Assert-CcodEqual 1 $probe.Reached 'actual default path reaches envelope validation' + Assert-CcodEqual 2 $probe.Denied 'complete staged closure and ancestry remain immutable before envelope checks' + Assert-CcodTrue ($failure.Exception.Message-like'*INERT_STOP_BEFORE_ANY_MACHINE_OPERATION*') 'test stops before any machine adapter can operate' + [IO.File]::WriteAllText($probe.Target,'released after rejected operation') + [IO.Directory]::Move($payload,$payload+'-released') + } finally {if($null-ne$module){Remove-Module $module -Force -ErrorAction SilentlyContinue};[Environment]::SetEnvironmentVariable('LOCALAPPDATA',$previous,'Process');if(Test-Path $root){Remove-Item $root -Recurse -Force}} +} + +Invoke-CcodTest 'default finalizer never treats an unavailable wrapper observation as process exit' { + $source=Join-Path $repositoryRoot 'src/persistence/InstalledUninstallFinalizer.ps1' + $module=New-Module -ArgumentList $source -ScriptBlock {param($Path). $Path} + $current=[Diagnostics.Process]::GetCurrentProcess();$windowsIdentity=[Security.Principal.WindowsIdentity]::GetCurrent();$child=$null + try { + $identity=[pscustomobject]@{pid=[int]$current.Id;creationTimeUtc=$current.StartTime.ToUniversalTime().ToString('o',[Globalization.CultureInfo]::InvariantCulture);sessionId=[int]$current.SessionId;userSid=[string]$windowsIdentity.User.Value} + $control=&$module { + param($Identity) + $adapters=Get-CcodInstalledFinalizerAdapters -TransactionRoot (Get-CcodTestCanonicalTempRoot) -PayloadRoot (Get-CcodTestCanonicalTempRoot) -RuntimeRoot (Get-CcodTestCanonicalTempRoot) -InstallRoot (Get-CcodTestCanonicalTempRoot) + &$adapters.WaitWrapperExit $Identity 0 + } $identity + Assert-CcodTrue $control.verifiedAtStart 'real current wrapper identity is observable' + Assert-CcodTrue (-not $control.exited) 'a live wrapper does not authorize deletion' + $start=[Diagnostics.ProcessStartInfo]::new() + $start.FileName=Join-Path $PSHOME 'powershell.exe';$start.Arguments='-NoLogo -NoProfile -NonInteractive -Command "Start-Sleep -Milliseconds 800"' + $start.UseShellExecute=$false;$start.CreateNoWindow=$true;$start.EnvironmentVariables.Remove('PSExecutionPolicyPreference') + $child=[Diagnostics.Process]::Start($start) + $exitedIdentity=[pscustomobject]@{pid=[int]$child.Id;creationTimeUtc=$child.StartTime.ToUniversalTime().ToString('o',[Globalization.CultureInfo]::InvariantCulture);sessionId=[int]$child.SessionId;userSid=$identity.userSid} + Assert-CcodTrue ($child.WaitForExit(10000)) 'owned inert wrapper actually exits' + Assert-CcodEqual 0 $child.ExitCode 'owned inert wrapper exits successfully' + $child.Dispose();$child=$null + $exited=&$module { + param($Identity) + $adapters=Get-CcodInstalledFinalizerAdapters -TransactionRoot (Get-CcodTestCanonicalTempRoot) -PayloadRoot (Get-CcodTestCanonicalTempRoot) -RuntimeRoot (Get-CcodTestCanonicalTempRoot) -InstallRoot (Get-CcodTestCanonicalTempRoot) + &$adapters.WaitWrapperExit $Identity 1000 + } $exitedIdentity + Assert-CcodTrue ($exited.verifiedAtStart-and$exited.exited) 'exact native process-not-found proves the already exited wrapper is absent' + foreach($failurePoint in @('Initial','Polling')) { + foreach($category in @('PermissionDenied','ReadError','ObjectNotFound','Empty')) { + $probe=[pscustomobject]@{Calls=0;FailurePoint=$failurePoint;Category=$category} + &$module { + param($Probe) + $script:WrapperObservationProbe=$Probe + function script:Get-Process { + [CmdletBinding()]param([int]$Id) + $script:WrapperObservationProbe.Calls++ + if($script:WrapperObservationProbe.FailurePoint-ceq'Polling'-and$script:WrapperObservationProbe.Calls-eq1){return Microsoft.PowerShell.Management\Get-Process -Id $Id -ErrorAction Stop} + if($script:WrapperObservationProbe.Category-ceq'Empty'){return} + Write-Error -Message 'INERT_WRAPPER_OBSERVATION_FAILURE' -Category $script:WrapperObservationProbe.Category -ErrorId 'CcodWrapperObservationFailure' + } + } $probe + $result=$null;$failure=$null + try { + $result=&$module { + param($Identity) + $adapters=Get-CcodInstalledFinalizerAdapters -TransactionRoot (Get-CcodTestCanonicalTempRoot) -PayloadRoot (Get-CcodTestCanonicalTempRoot) -RuntimeRoot (Get-CcodTestCanonicalTempRoot) -InstallRoot (Get-CcodTestCanonicalTempRoot) + &$adapters.WaitWrapperExit $Identity 1000 + } $identity + } catch {$failure=$_} + Assert-CcodTrue ($probe.Calls-gt0) "$failurePoint/$category reaches the real process observation seam" + Assert-CcodTrue ($null-ne$failure) "$failurePoint/$category must fail closed rather than authorize deletion" + Assert-CcodEqual $null $result "$failurePoint/$category returns no successful exit proof" + } + } + } finally {if($null-ne$child){if(-not$child.HasExited){$child.Kill();$child.WaitForExit()};$child.Dispose()};$current.Dispose();$windowsIdentity.Dispose();Remove-Module $module -Force -ErrorAction SilentlyContinue} +} + +Invoke-CcodTest 'uninstall and finalizer independently embed the same native payload lease' { + $definitions=@() + foreach($relative in @('src/persistence/UninstallBootstrap.ps1','src/persistence/InstalledUninstallFinalizer.ps1')) { + $tokens=$null;$errors=$null;$ast=[Management.Automation.Language.Parser]::ParseFile((Join-Path $repositoryRoot $relative),[ref]$tokens,[ref]$errors) + Assert-CcodEqual 0 @($errors).Count 'native lease entry parses' + $function=@($ast.EndBlock.Statements|Where-Object {$_-is[Management.Automation.Language.FunctionDefinitionAst]-and$_.Name-ceq'Initialize-CcodUninstallPayloadAuthority'}) + Assert-CcodEqual 1 $function.Count 'each independent entry has its own bootstrap before any unheld import' + $definitions+=($function[0].Extent.Text-replace"`r`n","`n") + } + Assert-CcodEqual $definitions[0] $definitions[1] 'the shared native type cannot drift depending on who initializes first' +} + +Invoke-CcodTest 'actual product module naming warning does not pollute the verified uninstall result' { + . $harnessPath -Library + $originalImport=${function:Import-Module};$probe=[pscustomobject]@{Module=$null;Calls=0} + try { + function Import-Module { + [CmdletBinding()]param([Parameter(Position=0)][string]$Name,[switch]$PassThru,[switch]$DisableNameChecking) + $probe.Calls++ + $probe.Module=Microsoft.PowerShell.Core\Import-Module -Name $Name -Force -PassThru -DisableNameChecking:$DisableNameChecking + &$probe.Module {function script:Read-CcodCurrentProductState {param($ExpectedRuntimeId)[pscustomobject]@{valid=$true;runtimeId=$ExpectedRuntimeId}}} + if($PassThru){return $probe.Module} + } + $values=@(& {Get-CcodInstalledLifecycleProductState -RuntimeId ('2.5.22-'+('a'*16)+'-'+('b'*32))} 3>&1) + Assert-CcodEqual 1 $probe.Calls 'actual production import is reached once' + Assert-CcodEqual 0 @($values|Where-Object {$_-is[Management.Automation.WarningRecord]}).Count 'actual ProductRegistration import must not add name-check prose to native stdout' + Assert-CcodEqual 1 $values.Count 'the product observation remains exactly one object' + Assert-CcodTrue $values[0].valid 'positive observation is consumed without registry mutation' + } finally {if($null-ne$probe.Module){Remove-Module $probe.Module -Force -ErrorAction SilentlyContinue};if($null-ne$originalImport){Set-Item Function:Import-Module $originalImport}else{Remove-Item Function:Import-Module -ErrorAction SilentlyContinue}} +} + +Invoke-CcodTest 'sealed uninstall resolver requires active manifest file identity' { + . $harnessPath -Library + $root=Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-uninstall-resolver-'+[guid]::NewGuid().ToString('N')) + $originalFacts=${function:Get-CcodInstalledLifecycleFacts};$originalActive=${function:Read-CcodInstalledLifecycleActiveFact} + try { + $runtimeId='2.5.22-'+('a'*16)+'-'+('b'*32);$runtime=Join-Path $root ('runtime\'+$runtimeId) + [IO.Directory]::CreateDirectory($runtime)|Out-Null + $path=Join-Path $runtime 'Uninstall-CodexControlOtherDevices.ps1';[IO.File]::WriteAllText($path,'# inert test uninstaller') + $records=@([pscustomobject][ordered]@{path='Uninstall-CodexControlOtherDevices.ps1';length=[long](Get-Item $path).Length;sha256=Get-CcodTestFileSha256 $path}) + $facts=New-CcodHarnessFacts -Version '2.5.22' -RuntimeId $runtimeId;$facts.runtimeManifestFiles=$records + $state=[pscustomobject]@{Facts=$facts;Reads=0;Drift=$false} + function Get-CcodInstalledLifecycleFacts {param($InstallRoot,$ExpectedVersion,$ExpectedPayloadFiles)$state.Reads++;return $state.Facts} + function Read-CcodInstalledLifecycleActiveFact {param($InstallRoot)[pscustomobject]@{activeRuntime=$(if($state.Drift){'wrong'}else{$state.Facts.activeRuntimeId});generation=[uint64]1}} + $result=Get-CcodInstalledLifecycleUninstallCommand -InstallRoot $root -ExpectedVersion '2.5.22' -ExpectedPayloadFiles $records + Assert-CcodEqual $root $result.Spec.installRoot 'typed specification binds the current root' + Assert-CcodEqual $runtimeId $result.Spec.runtimeId 'typed specification binds the selected runtime' + Assert-CcodEqual $records[0].sha256 $result.Spec.runtimeFiles[0].sha256 'typed specification retains the validated script hash' + Assert-CcodEqual $null $result.PSObject.Properties['Arguments'] 'resolver does not expose a mutable executable command' + [IO.File]::WriteAllText($path,'# changed uninstaller') + Assert-CcodThrows {Get-CcodInstalledLifecycleUninstallCommand -InstallRoot $root -ExpectedVersion '2.5.22' -ExpectedPayloadFiles $records|Out-Null} 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' + [IO.File]::WriteAllText($path,'# inert test uninstaller');$state.Drift=$true + Assert-CcodThrows {Get-CcodInstalledLifecycleUninstallCommand -InstallRoot $root -ExpectedVersion '2.5.22' -ExpectedPayloadFiles $records|Out-Null} 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' + $state.Drift=$false;$state.Facts.protectionReady=$false + Assert-CcodThrows {Get-CcodInstalledLifecycleUninstallCommand -InstallRoot $root -ExpectedVersion '2.5.22' -ExpectedPayloadFiles $records|Out-Null} 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' + } finally { + Set-Item Function:Get-CcodInstalledLifecycleFacts $originalFacts;Set-Item Function:Read-CcodInstalledLifecycleActiveFact $originalActive + if(Test-Path -LiteralPath $root){Remove-Item -LiteralPath $root -Recurse -Force} + } +} + +Invoke-CcodTest 'sealed payload comparison validates generated shortcuts rather than ignoring extras' { + . $harnessPath -Library + $root=Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-product-proof-'+[guid]::NewGuid().ToString('N')) + $original=${function:Get-CcodInstalledLifecycleProductState} + try { + $payload=@([pscustomobject][ordered]@{path='package.json';length=[long]25;sha256=('a'*64)}) + $records=@($payload)+@([pscustomobject][ordered]@{path='registration/Desktop.CodexRemote-fix.lnk';length=[long]40;sha256=('b'*64)},[pscustomobject][ordered]@{path='registration/StartMenu.CodexRemote-fix.lnk';length=[long]41;sha256=('c'*64)}) + $runtimeId='2.5.22-'+('d'*16)+'-'+('e'*32) + $proof=[pscustomobject][ordered]@{phase='Ready';installRoot=$root;runtimeId=$runtimeId;runtimeGeneration=[uint64]8;packageSha256=('f'*64);manifestSha256=('9'*64);startMenuSha256=('c'*64);desktopSha256=('b'*64);targetPath=[IO.Path]::GetFullPath((Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::System)) 'schtasks.exe'));arguments='/Run /TN "Codex Control Other Devices Supervisor"'} + $state=[pscustomobject]@{valid=$true;readyEvidence=$proof;entries=@([pscustomobject]@{kind='StartMenu';path='test';sha256=('c'*64)},[pscustomobject]@{kind='Desktop';path='test';sha256=('b'*64)},[pscustomobject]@{kind='Registry';path='test'})} + function Get-CcodInstalledLifecycleProductState {param($RuntimeId)return $state} + Assert-CcodTrue (Test-CcodInstalledLifecycleCandidateFileSet -Actual $records -Expected $payload -InstallRoot $root -RuntimeId $runtimeId -Generation 8 -ManifestSha256 ('9'*64)) 'candidate comparison accepts shipped payload plus independently bound generated shortcuts' + $state.readyEvidence.desktopSha256='f'*64 + Assert-CcodEqual $false (Test-CcodInstalledLifecycleCandidateFileSet -Actual $records -Expected $payload -InstallRoot $root -RuntimeId $runtimeId -Generation 8 -ManifestSha256 ('9'*64)) 'mismatched registered shortcut hash is rejected' + $state.readyEvidence.desktopSha256='b'*64 + $records+=@([pscustomobject][ordered]@{path='unexpected.txt';length=[long]1;sha256=('1'*64)}) + Assert-CcodEqual $false (Test-CcodInstalledLifecycleCandidateFileSet -Actual $records -Expected $payload -InstallRoot $root -RuntimeId $runtimeId -Generation 8 -ManifestSha256 ('9'*64)) 'unknown extra payload file is never ignored' + } finally {if($null-ne$original){Set-Item Function:Get-CcodInstalledLifecycleProductState $original}else{Remove-Item Function:Get-CcodInstalledLifecycleProductState -ErrorAction SilentlyContinue}} +} + +Invoke-CcodTest 'installed runtime validator agrees with the actual manifest producer digest' { + . $harnessPath -Library + $module=Import-Module (Join-Path $repositoryRoot 'src/persistence/modules/RuntimeManifest.psm1') -PassThru -Force + $root=Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-producer-digest-'+[guid]::NewGuid().ToString('N')) + try { + [IO.Directory]::CreateDirectory($root)|Out-Null + [IO.File]::WriteAllText((Join-Path $root 'payload.txt'),'actual producer fixture') + $manifest=&$module {param($Root)New-CcodRuntimeManifest -RuntimeDirectory $Root -ProjectVersion '2.5.22'} $root + $actual=Assert-CcodInstalledLifecycleRuntimeManifest -Manifest $manifest -ExpectedVersion '2.5.22' -ExpectedRuntimeId $manifest.runtimeId -RuntimeRoot $root + Assert-CcodEqual $manifest.runtimeId $actual.runtimeId 'real producer and installed validator agree on nonempty records' + $legacyCanonical=@($manifest.files|ForEach-Object {'{0}`t{1}`t{2}' -f [string]$_.path,[int64]$_.length,[string]$_.sha256})-join "`n" + $legacySha=[Security.Cryptography.SHA256]::Create();try{$legacyDigest=[BitConverter]::ToString($legacySha.ComputeHash([Text.Encoding]::UTF8.GetBytes($legacyCanonical))).Replace('-','').ToLowerInvariant()}finally{$legacySha.Dispose()} + $legacy=[pscustomobject][ordered]@{schemaVersion=1;projectVersion='2.5.21';runtimeId=('2.5.21-'+$legacyDigest.Substring(0,16));files=@($manifest.files)} + Assert-CcodEqual $legacy.runtimeId (Assert-CcodInstalledLifecycleRuntimeManifest -Manifest $legacy -ExpectedVersion '2.5.21' -ExpectedRuntimeId $legacy.runtimeId -RuntimeRoot $root).runtimeId 'historical digest grammar stays identical without the nonce' + [IO.File]::WriteAllText((Join-Path $root 'payload.txt'),'changed producer fixture') + Assert-CcodThrows {Assert-CcodInstalledLifecycleRuntimeManifest -Manifest $manifest -ExpectedVersion '2.5.22' -ExpectedRuntimeId $manifest.runtimeId -RuntimeRoot $root|Out-Null} 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' + } finally {if(Test-Path -LiteralPath $root){Remove-Item -LiteralPath $root -Recurse -Force}} +} + +Invoke-CcodTest 'installed runtime ordering matches mixed-case producer paths across cultures' { + . $harnessPath -Library + $module=Import-Module (Join-Path $repositoryRoot 'src/persistence/modules/RuntimeManifest.psm1') -PassThru -Force + $root=Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-producer-order-'+[guid]::NewGuid().ToString('N')) + $originalCulture=[Threading.Thread]::CurrentThread.CurrentCulture + try { + [IO.Directory]::CreateDirectory((Join-Path $root 'bin'))|Out-Null + foreach($relative in @('Uninstall-CodexControlOtherDevices.ps1','package.json','bin/CodexRemote.txt')){ + [IO.File]::WriteAllText((Join-Path $root $relative),'inert producer ordering fixture') + } + foreach($culture in @('en-US','zh-CN','tr-TR')){ + [Threading.Thread]::CurrentThread.CurrentCulture=[Globalization.CultureInfo]::GetCultureInfo($culture) + $manifest=&$module {param($Root)New-CcodRuntimeManifest -RuntimeDirectory $Root -ProjectVersion '2.5.22'} $root + Assert-CcodEqual 'Uninstall-CodexControlOtherDevices.ps1|bin/CodexRemote.txt|package.json' (@($manifest.files|ForEach-Object{$_.path})-join'|') 'producer uses ordinal path order' + $actual=Assert-CcodInstalledLifecycleRuntimeManifest -Manifest $manifest -ExpectedVersion '2.5.22' -ExpectedRuntimeId $manifest.runtimeId -RuntimeRoot $root + Assert-CcodEqual $manifest.runtimeId $actual.runtimeId ('current mixed-case producer accepts under '+$culture) + $legacyCanonical=@($manifest.files|ForEach-Object {'{0}`t{1}`t{2}' -f [string]$_.path,[int64]$_.length,[string]$_.sha256})-join "`n" + $legacySha=[Security.Cryptography.SHA256]::Create();try{$legacyDigest=[BitConverter]::ToString($legacySha.ComputeHash([Text.Encoding]::UTF8.GetBytes($legacyCanonical))).Replace('-','').ToLowerInvariant()}finally{$legacySha.Dispose()} + $legacy=[pscustomobject][ordered]@{schemaVersion=1;projectVersion='2.5.21';runtimeId=('2.5.21-'+$legacyDigest.Substring(0,16));files=@($manifest.files)} + Assert-CcodEqual $legacy.runtimeId (Assert-CcodInstalledLifecycleRuntimeManifest -Manifest $legacy -ExpectedVersion '2.5.21' -ExpectedRuntimeId $legacy.runtimeId -RuntimeRoot $root).runtimeId ('legacy mixed-case producer accepts under '+$culture) + } + $reordered=$manifest.PSObject.Copy();$reordered.files=@($manifest.files[2],$manifest.files[1],$manifest.files[0]) + Assert-CcodThrows {Assert-CcodInstalledLifecycleRuntimeManifest -Manifest $reordered -ExpectedVersion '2.5.22' -ExpectedRuntimeId $manifest.runtimeId -RuntimeRoot $root|Out-Null} 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' + [IO.File]::WriteAllText((Join-Path $root 'extra.txt'),'undeclared') + Assert-CcodThrows {Assert-CcodInstalledLifecycleRuntimeManifest -Manifest $manifest -ExpectedVersion '2.5.22' -ExpectedRuntimeId $manifest.runtimeId -RuntimeRoot $root|Out-Null} 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' + } finally { + [Threading.Thread]::CurrentThread.CurrentCulture=$originalCulture + if(Test-Path -LiteralPath $root){Remove-Item -LiteralPath $root -Recurse -Force} + } +} + +Invoke-CcodTest 'sealed runtime metadata does not depend on the obsolete Inno directory' { + . $harnessPath -Library + $root=Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-modern-app-'+[guid]::NewGuid().ToString('N')) + $originalManifest=${function:Assert-CcodInstalledLifecycleRuntimeManifest} + $originalOptional=${function:Get-CcodInstalledLifecycleOptionalDirectoryState};$previousHome=$env:CODEX_HOME + try { + [IO.Directory]::CreateDirectory($root)|Out-Null;$env:CODEX_HOME=Join-Path $root 'keys' + $runtimeId='2.5.22-e3b0c44298fc1c14-'+('a'*32) + New-CcodHarnessInstalledStateFixture -Root $root -ActiveRuntimeId $runtimeId -ActiveGeneration 8 -StatusRuntimeId $runtimeId -StatusCodexPid 702 -StatusCodexCreationTimeUtc '2026-08-24T00:00:02.0000000Z' -ReceiptPhase Completed + $runtime=Join-Path $root ('runtime\'+$runtimeId) + [IO.File]::WriteAllText((Join-Path $runtime 'package.json'),'{"version":"2.5.22"}') + $manifest=[IO.File]::ReadAllText((Join-Path $runtime 'manifest.json'))|ConvertFrom-Json;$manifest.projectVersion='2.5.22' + $manifest.files=@([pscustomobject][ordered]@{path='package.json';length=[long](Get-Item (Join-Path $runtime 'package.json')).Length;sha256=Get-CcodTestFileSha256 (Join-Path $runtime 'package.json')}) + Write-CcodHarnessJson (Join-Path $runtime 'manifest.json') $manifest + Set-CcodHarnessProcessFixture -ChatGPT @() + function Get-CcodInstalledLifecycleOptionalDirectoryState {param($Path,$GetItem)if($Path.EndsWith('CodexControlOtherDevices-installer')){return $false};return &$originalOptional -Path $Path -GetItem $GetItem} + function Assert-CcodInstalledLifecycleRuntimeManifest {param($Manifest,$ExpectedVersion,$ExpectedRuntimeId,$RuntimeRoot)return $Manifest} + $facts=Get-CcodInstalledLifecycleFacts -InstallRoot $root -ExpectedVersion '2.5.22' + Assert-CcodTrue $facts.appPresent 'sealed runtime is the installed application' + Assert-CcodEqual '2.5.22' $facts.aboutVersion 'version comes from the active runtime package' + Assert-CcodTrue $facts.installReady 'absence of the obsolete Inno directory does not reject sealed metadata' + Assert-CcodTrue (-not$facts.protectionReady) 'metadata alone does not prove protection readiness' + } finally { + Set-Item Function:Assert-CcodInstalledLifecycleRuntimeManifest $originalManifest + Set-Item Function:Get-CcodInstalledLifecycleOptionalDirectoryState $originalOptional + Clear-CcodHarnessProcessFixture + if($null-eq$previousHome){Remove-Item Env:CODEX_HOME -ErrorAction SilentlyContinue}else{$env:CODEX_HOME=$previousHome} + if(Test-Path -LiteralPath $root){Remove-Item -LiteralPath $root -Recurse -Force} + } +} + +Invoke-CcodTest 'native CIM timestamps are preserved for installed supervisor and tray' { + . $harnessPath -Library + $root = Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-installed-native-cim-' + [guid]::NewGuid().ToString('N')) + $hadCodexHome = Test-Path -LiteralPath Env:CODEX_HOME + $previousCodexHome = $env:CODEX_HOME + try { + [IO.Directory]::CreateDirectory($root) | Out-Null + $env:CODEX_HOME = Join-Path $root 'keys' + $created = [datetime]::ParseExact('2026-08-24T00:00:00.0000000Z', 'o', [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::RoundtripKind) + $supervisorProcess = New-CcodHarnessCimProcess -Name 'powershell.exe' -ProcessId 700 -CreationTimeUtc $created.ToString('o') -CommandLine ('powershell.exe -File "{0}"' -f (Join-Path $root 'Supervisor.ps1')) + $trayProcess = New-CcodHarnessCimProcess -Name 'CodexRemote.TrayHost.exe' -ProcessId 701 -CreationTimeUtc $created.AddSeconds(1).ToString('o') -CommandLine 'CodexRemote.TrayHost.exe' + $trayProcess.ExecutablePath = Join-Path $root 'bin\CodexRemote.TrayHost.exe' + Set-CcodHarnessProcessFixture -ChatGPT @() + $global:CcodInstalledLifecycleNativeCimFixture = @($supervisorProcess,$trayProcess) + function global:Get-CimInstance { + param($ClassName,$Filter,$ErrorAction) + return @($global:CcodInstalledLifecycleNativeCimFixture | Where-Object { $Filter -ceq ("Name = '{0}'" -f $_.Name) }) + } + $legacy = Get-CcodInstalledLifecycleFacts -InstallRoot $root + Assert-CcodEqual 1 $legacy.supervisor.Count 'DMTF supervisor baseline is observed' + Assert-CcodEqual 1 $legacy.trayHost.Count 'DMTF tray baseline is observed' + Assert-CcodEqual $created.ToString('o') $legacy.supervisor[0].CreationTimeUtc 'DMTF supervisor time is canonical UTC' + Assert-CcodEqual $created.AddSeconds(1).ToString('o') $legacy.trayHost[0].CreationTimeUtc 'DMTF tray time is canonical UTC' + $supervisorProcess.CreationDate = $created + $trayProcess.CreationDate = $created.AddSeconds(1) + Assert-CcodTrue ($supervisorProcess.CreationDate -is [datetime] -and $trayProcess.CreationDate -is [datetime]) 'native CIM DateTime values reach the real collector' + $native = Get-CcodInstalledLifecycleFacts -InstallRoot $root + Assert-CcodEqual 1 $native.supervisor.Count 'native supervisor is observed' + Assert-CcodEqual 1 $native.trayHost.Count 'native tray is observed' + Assert-CcodEqual $created.ToString('o') $native.supervisor[0].CreationTimeUtc 'native supervisor creation identity is preserved' + Assert-CcodEqual $created.AddSeconds(1).ToString('o') $native.trayHost[0].CreationTimeUtc 'native tray creation identity is preserved' + $supervisorProcess.CreationDate = 'not-a-cim-timestamp' + Assert-CcodThrows { Get-CcodInstalledLifecycleFacts -InstallRoot $root } 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' + } finally { + Clear-CcodHarnessProcessFixture + Remove-Variable -Name CcodInstalledLifecycleNativeCimFixture -Scope Global -ErrorAction SilentlyContinue + if ($hadCodexHome) { $env:CODEX_HOME = $previousCodexHome } else { Remove-Item -LiteralPath Env:CODEX_HOME -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } +} + +Invoke-CcodTest 'process precision correlation retains exact native time and rejects cross-microsecond identity' { + . $harnessPath -Library + $original=${function:Get-CcodInstalledLifecycleNativeProcessCreationTimeUtc} + try { + $cim=[datetime]::ParseExact('2026-08-24T00:00:04.0000000Z','o',[Globalization.CultureInfo]::InvariantCulture,[Globalization.DateTimeStyles]::RoundtripKind) + $process=[pscustomobject]@{ProcessId=[uint32]13948;CreationDate=$cim} + $state=[pscustomobject]@{Native=$cim.ToString('o');Fail=$false;Calls=0} + function Get-CcodInstalledLifecycleNativeProcessCreationTimeUtc {param($ProcessId)$state.Calls++;if($state.Fail){throw 'native query denied'};return $state.Native} + foreach($delta in @(0,6,9)){ + $state.Native=$cim.AddTicks($delta).ToString('o') + Assert-CcodEqual $state.Native (Get-CcodInstalledLifecycleCorrelatedCreationTimeUtc -Process $process) 'only CIM precision is correlated; full native ticks remain exact' + } + foreach($delta in @(-1,10)){ + $state.Native=$cim.AddTicks($delta).ToString('o') + Assert-CcodThrows {Get-CcodInstalledLifecycleCorrelatedCreationTimeUtc -Process $process|Out-Null} 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' + } + $process.CreationDate=$cim.AddTicks(6);$state.Native=$process.CreationDate.ToString('o') + Assert-CcodEqual $state.Native (Get-CcodInstalledLifecycleCorrelatedCreationTimeUtc -Process $process) 'already precise observation must equal native identity' + $state.Native=$cim.AddTicks(7).ToString('o') + Assert-CcodThrows {Get-CcodInstalledLifecycleCorrelatedCreationTimeUtc -Process $process|Out-Null} 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' + $state.Fail=$true + Assert-CcodThrows {Get-CcodInstalledLifecycleCorrelatedCreationTimeUtc -Process $process|Out-Null} 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' + $state.Fail=$false;$state.Native=$null + Assert-CcodThrows {Get-CcodInstalledLifecycleCorrelatedCreationTimeUtc -Process $process|Out-Null} 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' + Assert-CcodTrue ($state.Calls-ge9) 'each boundary reaches native observation' + } finally {Set-Item Function:Get-CcodInstalledLifecycleNativeProcessCreationTimeUtc $original} +} + +Invoke-CcodTest 'actual local process retains native precision through CIM correlation' { + . $harnessPath -Library + $current=[Diagnostics.Process]::GetCurrentProcess() + try { + $cim=CimCmdlets\Get-CimInstance -ClassName Win32_Process -Filter ('ProcessId = '+$current.Id) -ErrorAction Stop + $exact=$current.StartTime.ToUniversalTime().ToString('o',[Globalization.CultureInfo]::InvariantCulture) + Assert-CcodEqual $exact (Get-CcodInstalledLifecycleNativeProcessCreationTimeUtc -ProcessId $current.Id) 'real native read retains all ticks' + Assert-CcodEqual $exact (Get-CcodInstalledLifecycleCorrelatedCreationTimeUtc -Process $cim) 'real current process correlates without replacing exact native identity' + } finally {$current.Dispose()} +} + +Invoke-CcodTest 'installed tray observation selects active runtime bin rather than top-level decoy' { + . $harnessPath -Library + $root=Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-tray-runtime-path-'+[guid]::NewGuid().ToString('N')) + $previousHome=$env:CODEX_HOME + try { + [IO.Directory]::CreateDirectory($root)|Out-Null + $env:CODEX_HOME=Join-Path $root 'keys' + $runtimeId='2.5.21-e3b0c44298fc1c14' + New-CcodHarnessInstalledStateFixture -Root $root -ActiveRuntimeId $runtimeId -ActiveGeneration 8 -StatusRuntimeId $runtimeId -StatusCodexPid 702 -StatusCodexCreationTimeUtc '2026-08-24T00:00:02.0000000Z' -ReceiptPhase Completed + $actual=New-CcodHarnessCimProcess -Name 'CodexRemote.TrayHost.exe' -ProcessId 701 -CreationTimeUtc '2026-08-24T00:00:01.0000000Z' -CommandLine 'CodexRemote.TrayHost.exe' + $actual.ExecutablePath=Join-Path $root ('runtime\'+$runtimeId+'\bin\CodexRemote.TrayHost.exe') + $decoy=New-CcodHarnessCimProcess -Name 'CodexRemote.TrayHost.exe' -ProcessId 799 -CreationTimeUtc '2026-08-24T00:00:01.0000000Z' -CommandLine 'CodexRemote.TrayHost.exe' + $decoy.ExecutablePath=Join-Path $root 'bin\CodexRemote.TrayHost.exe' + Set-CcodHarnessProcessFixture -ChatGPT @() + $global:CcodRuntimePathProcesses=@($actual,$decoy) + function global:Get-CimInstance {param($ClassName,$Filter,$ErrorAction)return @($global:CcodRuntimePathProcesses|Where-Object {$Filter-ceq("Name = '{0}'"-f$_.Name)})} + $originalReady=${function:Get-CcodInstalledLifecycleTrayHostReadyProof} + function Get-CcodInstalledLifecycleTrayHostReadyProof {param($InstallRoot,$RuntimeId,$TrayHost)return $false} + $facts=Get-CcodInstalledLifecycleFacts -InstallRoot $root + Assert-CcodEqual 1 $facts.trayHost.Count 'only the current runtime tray is selected' + Assert-CcodEqual 701 $facts.trayHost[0].Pid 'top-level executable is not the active TrayHost' + } finally { + if($null-ne$originalReady){Set-Item Function:Get-CcodInstalledLifecycleTrayHostReadyProof $originalReady} + Clear-CcodHarnessProcessFixture + Remove-Variable CcodRuntimePathProcesses -Scope Global -ErrorAction SilentlyContinue + if($null-eq$previousHome){Remove-Item Env:CODEX_HOME -ErrorAction SilentlyContinue}else{$env:CODEX_HOME=$previousHome} + if(Test-Path -LiteralPath $root){Remove-Item -LiteralPath $root -Recurse -Force} + } +} + +Invoke-CcodTest 'legacy readiness uses protected event and exact current process identities without new logs' { + . $harnessPath -Library + $root=Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-legacy-ready-'+[guid]::NewGuid().ToString('N')) + $kernel=Import-Module (Join-Path $repositoryRoot 'src/persistence/modules/KernelObjects.psm1') -PassThru -Force + $event=$null + $originalNative=${function:Get-CcodInstalledLifecycleNativeProcessCreationTimeUtc} + try { + function Get-CcodInstalledLifecycleNativeProcessCreationTimeUtc {param($ProcessId)return $script:CcodHarnessNativeCreation[[int]$ProcessId]} + $utf8=[Text.UTF8Encoding]::new($false) + $records=[Collections.Generic.List[object]]::new() + foreach($relative in @('bin/CodexRemote.TrayHost.exe','src/persistence/Supervisor.ps1')){ + $bytes=$utf8.GetBytes('inert legacy '+$relative);$sha=[Security.Cryptography.SHA256]::Create() + try{$hash=[BitConverter]::ToString($sha.ComputeHash($bytes)).Replace('-','').ToLowerInvariant()}finally{$sha.Dispose()} + $records.Add([pscustomobject][ordered]@{path=$relative;length=[long]$bytes.Length;sha256=$hash}) + } + $canonical=(@($records|ForEach-Object {'{0}`t{1}`t{2}' -f $_.path,$_.length,$_.sha256})-join"`n") + $sha=[Security.Cryptography.SHA256]::Create();try{$digest=[BitConverter]::ToString($sha.ComputeHash($utf8.GetBytes($canonical))).Replace('-','').ToLowerInvariant()}finally{$sha.Dispose()} + $runtimeId='2.5.21-'+$digest.Substring(0,16) + New-CcodHarnessInstalledStateFixture -Root $root -ActiveRuntimeId $runtimeId -ActiveGeneration 8 -StatusRuntimeId $runtimeId -StatusCodexPid 702 -StatusCodexCreationTimeUtc '2026-08-24T00:00:02.0000000Z' -ReceiptPhase Completed + $runtime=Join-Path $root ('runtime\'+$runtimeId) + foreach($record in $records){$path=Join-Path $runtime $record.path;[IO.Directory]::CreateDirectory((Split-Path $path -Parent))|Out-Null;[IO.File]::WriteAllText($path,('inert legacy '+$record.path),$utf8)} + Write-CcodHarnessJson -Path (Join-Path $runtime 'manifest.json') -Value ([ordered]@{schemaVersion=1;projectVersion='2.5.21';runtimeId=$runtimeId;files=@($records)}) + $current=[Diagnostics.Process]::GetCurrentProcess();try{$session=$current.SessionId}finally{$current.Dispose()} + $windowsIdentity=[Security.Principal.WindowsIdentity]::GetCurrent();try{$sid=$windowsIdentity.User.Value}finally{$windowsIdentity.Dispose()} + $token=[guid]::NewGuid().ToString('N')+[guid]::NewGuid().ToString('N') + $event=&$kernel {param($Sid,$Session,$Token)New-CcodEvent -Kind Ready -UserSid $Sid -SessionId $Session -ReadyToken $Token} $sid $session $token + $powershell=Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + $created=[datetime]::ParseExact('2026-08-24T00:00:00.0000006Z','o',[Globalization.CultureInfo]::InvariantCulture,[Globalization.DateTimeStyles]::RoundtripKind) + $supervisor=New-CcodHarnessCimProcess -Name 'powershell.exe' -ProcessId 700 -CreationTimeUtc $created.ToString('o') -CommandLine ('"{0}" -NoProfile -ExecutionPolicy Bypass -STA -File "{1}" -ReadyToken {2}'-f$powershell,(Join-Path $runtime 'src/persistence/Supervisor.ps1'),$token) + $supervisor.ExecutablePath=$powershell;$supervisor|Add-Member SessionId $session + $trayPath=Join-Path $runtime 'bin/CodexRemote.TrayHost.exe' + $tray=New-CcodHarnessCimProcess -Name 'CodexRemote.TrayHost.exe' -ProcessId 701 -ParentProcessId 700 -CreationTimeUtc $created.AddSeconds(1).ToString('o') -CommandLine ('"{0}" --child --parent-pid 700 --parent-created {1} --runtime-id {2}'-f$trayPath,$created.ToFileTimeUtc(),$runtimeId) + $tray.ExecutablePath=$trayPath;$tray|Add-Member SessionId $session + $statusPath=Join-Path $root 'state/status.json';$status=[IO.File]::ReadAllText($statusPath)|ConvertFrom-Json;$status.session.supervisorCreationTimeUtc=$created.ToString('o');$status.session.sessionId=$session.ToString([Globalization.CultureInfo]::InvariantCulture);Write-CcodHarnessJson $statusPath $status + $supervisorIdentity=@([pscustomobject]@{Pid=700;CreationTimeUtc=$created.ToString('o')});$trayIdentity=@([pscustomobject]@{Pid=701;CreationTimeUtc=$created.AddSeconds(1).ToString('o')}) + $global:CcodLegacyReadyFixture=[pscustomobject]@{Processes=@($supervisor,$tray);Calls=0;Drift=$false} + function global:Get-CimInstance {param($ClassName,$Filter,$ErrorAction)$global:CcodLegacyReadyFixture.Calls++;$items=@($global:CcodLegacyReadyFixture.Processes|Where-Object {$Filter-ceq('ProcessId = '+$_.ProcessId)});if($global:CcodLegacyReadyFixture.Drift-and$global:CcodLegacyReadyFixture.Calls-ge3){return @()};return $items} + Assert-CcodEqual $false (Get-CcodInstalledLifecycleTrayHostReadyProof -InstallRoot $root -RuntimeId $runtimeId -TrayHost $trayIdentity) 'absent newer log does not prove old readiness' + [void]$event.Handle.Set() + Assert-CcodTrue (Get-CcodInstalledLifecycleTrayHostReadyProof -InstallRoot $root -RuntimeId $runtimeId -TrayHost $trayIdentity) 'protected legacy startup event proves the current parent and host without a log' + [void]$event.Handle.Reset() + Assert-CcodEqual $false (Get-CcodInstalledLifecycleLegacyTrayReadyProof -InstallRoot $root -RuntimeId $runtimeId -Supervisor $supervisorIdentity -TrayHost $trayIdentity) 'unsignaled event does not prove readiness' + [void]$event.Handle.Set() + Assert-CcodTrue (-not(Test-Path -LiteralPath (Join-Path $root 'logs/supervisor.log'))) 'no fabricated newer Ready log is written' + $tray.ParentProcessId=799 + Assert-CcodThrows {Get-CcodInstalledLifecycleLegacyTrayReadyProof -InstallRoot $root -RuntimeId $runtimeId -Supervisor $supervisorIdentity -TrayHost $trayIdentity|Out-Null} 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' + $tray.ParentProcessId=700 + $status.session.supervisorCreationTimeUtc=$created.AddSeconds(-1).ToString('o');Write-CcodHarnessJson $statusPath $status + Assert-CcodThrows {Get-CcodInstalledLifecycleLegacyTrayReadyProof -InstallRoot $root -RuntimeId $runtimeId -Supervisor $supervisorIdentity -TrayHost $trayIdentity|Out-Null} 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' + $status.session.supervisorCreationTimeUtc=$created.ToString('o');Write-CcodHarnessJson $statusPath $status + $global:CcodLegacyReadyFixture.Calls=0;$global:CcodLegacyReadyFixture.Drift=$true + Assert-CcodThrows {Get-CcodInstalledLifecycleLegacyTrayReadyProof -InstallRoot $root -RuntimeId $runtimeId -Supervisor $supervisorIdentity -TrayHost $trayIdentity|Out-Null} 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' + $global:CcodLegacyReadyFixture.Drift=$false + Assert-CcodThrows {Get-CcodInstalledLifecycleLegacyTrayReadyProof -InstallRoot $root -RuntimeId ($runtimeId+'-'+('a'*32)) -Supervisor $supervisorIdentity -TrayHost $trayIdentity|Out-Null} 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' + Assert-CcodTrue (Get-CcodInstalledLifecycleLegacyTrayReadyProof -InstallRoot $root -RuntimeId $runtimeId -Supervisor $supervisorIdentity -TrayHost $trayIdentity) 'failure releases event and module leases for retry' + } finally { + if($null-ne$event){$event.Handle.Dispose()} + Set-Item Function:Get-CcodInstalledLifecycleNativeProcessCreationTimeUtc $originalNative + Remove-Item Function:\Get-CimInstance -ErrorAction SilentlyContinue + Remove-Variable CcodLegacyReadyFixture -Scope Global -ErrorAction SilentlyContinue + if(Test-Path -LiteralPath $root){Remove-Item -LiteralPath $root -Recurse -Force} + } +} + +Invoke-CcodTest 'installed readiness selects current Ready across valid supervisor history' { + . $harnessPath -Library + $root = Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-installed-ready-history-' + [guid]::NewGuid().ToString('N')) + try { + [IO.Directory]::CreateDirectory((Join-Path $root 'logs')) | Out-Null + $log = Join-Path $root 'logs\supervisor.log' + $current = [ordered]@{ schemaVersion = 1; timestampUtc = '2030-02-03T04:05:07.0000000Z'; component = 'Supervisor'; stage = 'TrayHostReady'; code = 'CCOD_TRAYHOST_READY'; outcome = 'Completed'; runtimeId = 'runtime-2.5.22'; hostPid = 201; hostCreationTimeUtc = '2030-02-03T04:05:01.0000000Z'; protocolMajor = 2; capabilities = 7 } + $hosts = @([pscustomobject][ordered]@{ Pid = 201; CreationTimeUtc = $current.hostCreationTimeUtc }) + [IO.File]::WriteAllText($log, (($current | ConvertTo-Json -Compress) + [Environment]::NewLine)) + Assert-CcodTrue (Get-CcodInstalledLifecycleTrayHostReadyProof -InstallRoot $root -RuntimeId 'runtime-2.5.22' -TrayHost $hosts) 'a current Ready record proves the baseline' + $historical = [ordered]@{} + foreach ($key in $current.Keys) { $historical[$key] = $current[$key] } + $historical.runtimeId = 'runtime-2.5.21' + $action = [ordered]@{ schemaVersion = 1; timestampUtc = $current.timestampUtc; component = 'Supervisor'; stage = 'TrayAction'; code = 'CCOD_TRAY_ACTION_COMPLETED'; outcome = 'Completed'; command = 'ShowAbout'; revision = 1; status = 'Completed' } + $warning = [ordered]@{ schemaVersion = 1; timestampUtc = $current.timestampUtc; component = 'Supervisor'; stage = 'LeaseAcquire'; code = 'CCOD_SUPERVISOR_LEASE_ABANDONED'; outcome = 'Warning' } + $history = @($action,$historical,$warning) + [IO.File]::WriteAllText($log, ((@($history + @($current) | ForEach-Object { $_ | ConvertTo-Json -Compress }) -join [Environment]::NewLine) + [Environment]::NewLine)) + Assert-CcodTrue (Get-CcodInstalledLifecycleTrayHostReadyProof -InstallRoot $root -RuntimeId 'runtime-2.5.22' -TrayHost $hosts) 'well-formed action and old runtime records do not invalidate current Ready' + [IO.File]::WriteAllText($log, ((@($history | ForEach-Object { $_ | ConvertTo-Json -Compress }) -join [Environment]::NewLine) + [Environment]::NewLine)) + Assert-CcodEqual $false (Get-CcodInstalledLifecycleTrayHostReadyProof -InstallRoot $root -RuntimeId 'runtime-2.5.22' -TrayHost $hosts) 'historical runtime cannot prove readiness even with matching process identity' + $current.hostPid = '201' + [IO.File]::WriteAllText($log, ((@($history + @($current) | ForEach-Object { $_ | ConvertTo-Json -Compress }) -join [Environment]::NewLine) + [Environment]::NewLine)) + Assert-CcodThrows { Get-CcodInstalledLifecycleTrayHostReadyProof -InstallRoot $root -RuntimeId 'runtime-2.5.22' -TrayHost $hosts } 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' + $current.hostPid = 201 + $action.status = @('Completed') + [IO.File]::WriteAllText($log, ((@($action,$current | ForEach-Object { $_ | ConvertTo-Json -Depth 4 -Compress }) -join [Environment]::NewLine) + [Environment]::NewLine)) + Assert-CcodThrows { Get-CcodInstalledLifecycleTrayHostReadyProof -InstallRoot $root -RuntimeId 'runtime-2.5.22' -TrayHost $hosts } 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' + } finally { + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } +} + +Invoke-CcodTest 'expected debug ports survive the installed lifecycle facts boundary' { + . $harnessPath -Library + $root = Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-installed-expected-ports-' + [guid]::NewGuid().ToString('N')) + $originalNetTcp = ${function:Get-NetTCPConnection} + $originalDirectoryProbe = ${function:Get-CcodInstalledLifecycleOptionalDirectoryState} + $originalFileProbe = ${function:Get-CcodInstalledLifecycleOptionalRegularFileState} + $fixtureAbsentApplicationRoot = Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)) 'CodexControlOtherDevices-installer' + $fixtureApplicationProbes = [pscustomobject]@{ Directory = 0; Metadata = 0 } + try { + function Get-CcodInstalledLifecycleOptionalDirectoryState { + param($Path,$GetItem) + if ($Path -ieq $fixtureAbsentApplicationRoot) { $fixtureApplicationProbes.Directory++; return $false } + & $originalDirectoryProbe -Path $Path -GetItem $GetItem + } + function Get-CcodInstalledLifecycleOptionalRegularFileState { + param($Path,$GetItem) + if ($Path -ieq (Join-Path $fixtureAbsentApplicationRoot 'package.json')) { $fixtureApplicationProbes.Metadata++; return $null } + & $originalFileProbe -Path $Path -GetItem $GetItem + } + [IO.Directory]::CreateDirectory($root) | Out-Null + New-CcodHarnessInstalledStateFixture -Root $root -ActiveRuntimeId '2.5.21-e3b0c44298fc1c14' -ActiveGeneration ([UInt64]8) ` + -StatusRuntimeId '2.5.21-e3b0c44298fc1c14' -StatusCodexPid 13948 -StatusCodexCreationTimeUtc '2026-08-24T00:00:02.0000000Z' -ReceiptPhase 'Completed' -StatusMainPort 9229 -StatusRendererPort 9230 + Set-CcodHarnessProcessFixture -ChatGPT @( + (New-CcodHarnessCimProcess -Name 'ChatGPT.exe' -ProcessId 13948 -CreationTimeUtc '2026-08-24T00:00:02.0000000Z' -CommandLine '"C:\Program Files\WindowsApps\OpenAI.Codex\ChatGPT.exe"') + ) + function global:Get-NetTCPConnection { + param($State,$ErrorAction) + return @( + [pscustomobject]@{ LocalAddress = '127.0.0.1'; LocalPort = 9229; OwningProcess = 13948 } + [pscustomobject]@{ LocalAddress = '127.0.0.1'; LocalPort = 9230; OwningProcess = 13948 } + ) + } + $facts = Get-CcodInstalledLifecycleFacts -InstallRoot $root -ExpectedDebugPorts @(9229,9230) + Assert-CcodEqual '9229,9230' ($facts.debugPorts -join ',') 'expected debug ports are preserved for listener verification' + Assert-CcodEqual $false $facts.appPresent 'port observation does not depend on a machine installation' + Assert-CcodEqual $null $facts.aboutVersion 'port fixture has no installed application metadata' + Assert-CcodTrue ($fixtureApplicationProbes.Directory -gt 0 -and $fixtureApplicationProbes.Metadata -gt 0) 'real application path lookups are isolated by the fixture' + $legacyFacts = Get-CcodInstalledLifecycleFacts -InstallRoot $root + Assert-CcodTrue ($null -ne $legacyFacts -and $legacyFacts.activeRuntimeId -is [string]) 'legacy two-part runtime identity can be observed without an expected version' + } finally { + Set-Item -LiteralPath Function:\Get-CcodInstalledLifecycleOptionalDirectoryState -Value $originalDirectoryProbe + Set-Item -LiteralPath Function:\Get-CcodInstalledLifecycleOptionalRegularFileState -Value $originalFileProbe + Clear-CcodHarnessProcessFixture + if ($null -eq $originalNetTcp) { Remove-Item -LiteralPath Function:\Get-NetTCPConnection -Force -ErrorAction SilentlyContinue } + else { Set-Item -LiteralPath Function:\Get-NetTCPConnection -Value $originalNetTcp } + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } +} + +Invoke-CcodTest 'rejects non-integer debug port facts before endpoint observation' { + . $harnessPath -Library + $root = Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-installed-noninteger-port-' + [guid]::NewGuid().ToString('N')) + $originalNetTcp = ${function:Get-NetTCPConnection} + $originalStatusReader = ${function:Read-CcodInstalledLifecycleStatusFact} + try { + [IO.Directory]::CreateDirectory($root) | Out-Null + New-CcodHarnessInstalledStateFixture -Root $root -ActiveRuntimeId '2.5.21-e3b0c44298fc1c14' -ActiveGeneration ([UInt64]8) ` + -StatusRuntimeId '2.5.21-e3b0c44298fc1c14' -StatusCodexPid 13948 -StatusCodexCreationTimeUtc '2026-08-24T00:00:02.0000000Z' -ReceiptPhase 'Completed' -StatusMainPort 9229 -StatusRendererPort 9230 + $statusOverride = Get-Content -LiteralPath (Join-Path $root 'state\status.json') -Raw | ConvertFrom-Json + $global:CcodInstalledLifecycleNonIntegerStatusOverride = $statusOverride + Set-Item -LiteralPath Function:\Read-CcodInstalledLifecycleStatusFact -Value { param($StateRoot) return $global:CcodInstalledLifecycleNonIntegerStatusOverride } + Set-CcodHarnessProcessFixture -ChatGPT @( + (New-CcodHarnessCimProcess -Name 'ChatGPT.exe' -ProcessId 13948 -CreationTimeUtc '2026-08-24T00:00:02.0000000Z' -CommandLine '"C:\Program Files\WindowsApps\OpenAI.Codex\ChatGPT.exe"') + ) + function global:Get-NetTCPConnection { + param($State,$ErrorAction) + return @( + [pscustomobject]@{ LocalAddress = '127.0.0.1'; LocalPort = 9229; OwningProcess = 13948 } + [pscustomobject]@{ LocalAddress = '127.0.0.1'; LocalPort = 9230; OwningProcess = 13948 } + ) + } + $positive = Get-CcodInstalledLifecycleFacts -InstallRoot $root -ExpectedDebugPorts @(9229,9230) + Assert-CcodEqual 2 $positive.debugEndpoints.Count 'complete integer status fixture reaches endpoint observation' + $statusOverride.session.codex.mainPort = [decimal]9229.25 + Assert-CcodTrue ($statusOverride.session.codex.mainPort -is [decimal]) 'fixture retains a non-integer status port value' + Assert-CcodEqual 9229 ([int]$statusOverride.session.codex.mainPort) 'rounding would otherwise preserve the declared port binding' + Assert-CcodThrows { + Get-CcodInstalledLifecycleFacts -InstallRoot $root -ExpectedDebugPorts @(9229,9230) + } 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' + } finally { + Clear-CcodHarnessProcessFixture + if ($null -eq $originalNetTcp) { Remove-Item -LiteralPath Function:\Get-NetTCPConnection -Force -ErrorAction SilentlyContinue } + else { Set-Item -LiteralPath Function:\Get-NetTCPConnection -Value $originalNetTcp } + Set-Item -LiteralPath Function:\Read-CcodInstalledLifecycleStatusFact -Value $originalStatusReader + Remove-Variable -Name CcodInstalledLifecycleNonIntegerStatusOverride -Scope Global -ErrorAction SilentlyContinue + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } +} + +Invoke-CcodTest 'uses absolute end anchors for lifecycle candidate and version validation' { + . $harnessPath -Library + $source = [IO.File]::ReadAllText($harnessPath, [Text.UTF8Encoding]::new($false)) + Assert-CcodTrue ($source.Contains('[regex]::Match($leaf, ''^CodexRemote-fix-(\d+\.\d+\.\d+)-setup\.exe\z''')) 'candidate filename validation uses an absolute end anchor' + Assert-CcodTrue ($source.Contains('[Parameter(Mandatory)][ValidatePattern(''^\d+\.\d+\.\d+\z'')][string]$ExpectedVersion,')) 'public expected-version validation uses an absolute end anchor' + Assert-CcodTrue (-not $source.Contains("'^[0-9a-f]{64}$'")) 'lifecycle hash validation uses an absolute end anchor' +} + +Invoke-CcodTest 'rejects expected debug ports detached from the current runtime declaration' { + . $harnessPath -Library + $root = Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-installed-port-binding-' + [guid]::NewGuid().ToString('N')) + $originalStatusReader = ${function:Read-CcodInstalledLifecycleStatusFact} + $originalNetTcp = ${function:Get-NetTCPConnection} + try { + $null = [IO.Directory]::CreateDirectory($root) + New-CcodHarnessInstalledStateFixture -Root $root -ActiveRuntimeId '2.5.21-e3b0c44298fc1c14-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' -ActiveGeneration ([UInt64]8) ` + -StatusRuntimeId '2.5.21-e3b0c44298fc1c14-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' -StatusCodexPid 13948 -StatusCodexCreationTimeUtc '2026-08-24T00:00:04.0000000Z' -ReceiptPhase 'Completed' + Set-CcodHarnessProcessFixture -ChatGPT @( + (New-CcodHarnessCimProcess -Name 'ChatGPT.exe' -ProcessId 13948 -CreationTimeUtc '2026-08-24T00:00:04.0000000Z' -CommandLine '"C:\Program Files\WindowsApps\OpenAI.Codex\ChatGPT.exe"') + ) + $status = Get-Content -LiteralPath (Join-Path $root 'state\status.json') -Raw | ConvertFrom-Json + $status.session.codex.mainPort = 9229 + $status.session.codex.rendererPort = 9230 + $global:InstalledLifecycleHarnessStatusOverride = $status + Set-Item -LiteralPath Function:\Read-CcodInstalledLifecycleStatusFact -Value { param($StateRoot) return $global:InstalledLifecycleHarnessStatusOverride } + function global:Get-NetTCPConnection { param($State,$ErrorAction) return @() } + $positive = Get-CcodInstalledLifecycleFacts -InstallRoot $root -ExpectedDebugPorts @(9229,9230) + Assert-CcodEqual '9229,9230' ($positive.debugPorts -join ',') 'matching declared port baseline succeeds independently of application version' + Assert-CcodThrows { + Get-CcodInstalledLifecycleFacts -InstallRoot $root -ExpectedDebugPorts @(9331,9332) + } 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' + } finally { + if ($null -eq $originalNetTcp) { Remove-Item -LiteralPath Function:\Get-NetTCPConnection -Force -ErrorAction SilentlyContinue } + else { Set-Item -LiteralPath Function:\Get-NetTCPConnection -Value $originalNetTcp } + Set-Item -LiteralPath Function:\Read-CcodInstalledLifecycleStatusFact -Value $originalStatusReader + Remove-Variable -Name InstalledLifecycleHarnessStatusOverride -Scope Global -ErrorAction SilentlyContinue + Clear-CcodHarnessProcessFixture + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } +} + +Invoke-CcodTest 'rejects fractional expected debug ports before endpoint observation' { + . $harnessPath -Library + $root = Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-installed-port-type-' + [guid]::NewGuid().ToString('N')) + $originalNetTcp = ${function:Get-NetTCPConnection} + try { + $null = [IO.Directory]::CreateDirectory($root) + New-CcodHarnessInstalledStateFixture -Root $root -ActiveRuntimeId '2.5.21-e3b0c44298fc1c14-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' -ActiveGeneration ([UInt64]8) ` + -StatusRuntimeId '2.5.21-e3b0c44298fc1c14-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' -StatusCodexPid 13948 -StatusCodexCreationTimeUtc '2026-08-24T00:00:04.0000000Z' -ReceiptPhase 'Completed' -StatusMainPort 9229 -StatusRendererPort 9230 + Set-CcodHarnessProcessFixture -ChatGPT @() + function global:Get-NetTCPConnection { param($State,$ErrorAction) return @() } + $positive = Get-CcodInstalledLifecycleFacts -InstallRoot $root -ExpectedDebugPorts @(9229,9230) + Assert-CcodEqual '9229,9230' ($positive.debugPorts -join ',') 'integer expected-port baseline succeeds before the scalar mutation' + Assert-CcodEqual 9229 ([int][decimal]9229.25) 'fractional fixture cannot be rejected only by downstream port mismatch' + Assert-CcodThrows { + Get-CcodInstalledLifecycleFacts -InstallRoot $root -ExpectedDebugPorts @([decimal]9229.25, 9230) + } 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' + } finally { + if ($null -eq $originalNetTcp) { Remove-Item -LiteralPath Function:\Get-NetTCPConnection -Force -ErrorAction SilentlyContinue } + else { Set-Item -LiteralPath Function:\Get-NetTCPConnection -Value $originalNetTcp } + Clear-CcodHarnessProcessFixture + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } +} + +Invoke-CcodTest 'rejects listener endpoint fields whose scalar types are not exact integers' { + . $harnessPath -Library + $root = Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-installed-listener-type-' + [guid]::NewGuid().ToString('N')) + $originalNetTcp = ${function:Get-NetTCPConnection} + $global:InstalledLifecycleListenerObserved = 0 + try { + [IO.Directory]::CreateDirectory($root) | Out-Null + New-CcodHarnessInstalledStateFixture -Root $root -ActiveRuntimeId '2.5.21-e3b0c44298fc1c14-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' -ActiveGeneration ([UInt64]8) ` + -StatusRuntimeId '2.5.21-e3b0c44298fc1c14-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' -StatusCodexPid 13948 -StatusCodexCreationTimeUtc '2026-08-24T00:00:04.0000000Z' -ReceiptPhase 'Completed' -StatusMainPort 9229 -StatusRendererPort 9230 + Set-CcodHarnessProcessFixture -ChatGPT @( + (New-CcodHarnessCimProcess -Name 'ChatGPT.exe' -ProcessId 13948 -CreationTimeUtc '2026-08-24T00:00:04.0000000Z' -CommandLine '"C:\Program Files\WindowsApps\OpenAI.Codex\ChatGPT.exe"') + ) + function global:Get-NetTCPConnection { + param($State,$ErrorAction) + $global:InstalledLifecycleListenerObserved = [int]$global:InstalledLifecycleListenerObserved + 1 + return @( + [pscustomobject]@{ LocalAddress = '127.0.0.1'; LocalPort = 9229; OwningProcess = 13948 } + [pscustomobject]@{ LocalAddress = '127.0.0.1'; LocalPort = 9230; OwningProcess = 13948 } + ) + } + $valid = Get-CcodInstalledLifecycleFacts -InstallRoot $root -ExpectedDebugPorts @(9229,9230) + Assert-CcodEqual '9229,9230' ($valid.debugPorts -join ',') 'integer listener fixture reaches a valid endpoint observation' + $global:CcodInstalledLifecycleMixedListeners = @( + [pscustomobject]@{ LocalAddress = '127.0.0.1'; LocalPort = [uint16]9229; OwningProcess = [uint32]13948 } + [pscustomobject]@{ LocalAddress = '127.0.0.1'; LocalPort = [uint16]9230; OwningProcess = [uint32]13948 } + [pscustomobject]@{ LocalAddress = '0.0.0.0'; LocalPort = [uint16]80; OwningProcess = [uint32]4 } + [pscustomobject]@{ LocalAddress = '::'; LocalPort = [uint16]135; OwningProcess = [uint32]4 } + [pscustomobject]@{ LocalAddress = '192.0.2.1'; LocalPort = [uint16]445; OwningProcess = [uint32]4 } + ) + function global:Get-NetTCPConnection { + param($State,$ErrorAction) + $global:InstalledLifecycleListenerObserved = [int]$global:InstalledLifecycleListenerObserved + 1 + return @($global:CcodInstalledLifecycleMixedListeners) + } + $mixed = Get-CcodInstalledLifecycleFacts -InstallRoot $root -ExpectedDebugPorts @(9229,9230) + Assert-CcodEqual 2 $mixed.debugEndpoints.Count 'unrelated wildcard IPv6 and LAN listeners are excluded from debug evidence' + Assert-CcodEqual '9229,9230' (($mixed.debugEndpoints | ForEach-Object { $_.localPort }) -join ',') 'only declared debug listeners are retained' + foreach ($unsafeAddress in @('0.0.0.0','::')) { + $global:CcodInstalledLifecycleMixedListeners[0].LocalAddress = $unsafeAddress + Assert-CcodThrows { Get-CcodInstalledLifecycleFacts -InstallRoot $root -ExpectedDebugPorts @(9229,9230) } 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' + } + $global:CcodInstalledLifecycleMixedListeners[0].LocalAddress = '127.0.0.1' + $global:CcodInstalledLifecycleMixedListeners[0].OwningProcess = [uint32]13949 + Assert-CcodThrows { Get-CcodInstalledLifecycleFacts -InstallRoot $root -ExpectedDebugPorts @(9229,9230) } 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' + Remove-Item -LiteralPath Function:\Get-NetTCPConnection -Force + function global:Get-NetTCPConnection { + param($State,$ErrorAction) + $global:InstalledLifecycleListenerObserved = [int]$global:InstalledLifecycleListenerObserved + 1 + return @( + [pscustomobject]@{ LocalAddress = '127.0.0.1'; LocalPort = '9229'; OwningProcess = '13948' } + [pscustomobject]@{ LocalAddress = '127.0.0.1'; LocalPort = '9230'; OwningProcess = '13948' } + ) + } + Assert-CcodThrows { + Get-CcodInstalledLifecycleFacts -InstallRoot $root -ExpectedDebugPorts @(9229,9230) + } 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' + Assert-CcodTrue ($global:InstalledLifecycleListenerObserved -ge 2) 'both valid and malformed listener fixtures were observed' + } finally { + Remove-Variable -Name CcodInstalledLifecycleMixedListeners -Scope Global -ErrorAction SilentlyContinue + Clear-CcodHarnessProcessFixture + if ($null -eq $originalNetTcp) { Remove-Item -LiteralPath Function:\Get-NetTCPConnection -Force -ErrorAction SilentlyContinue } + else { Set-Item -LiteralPath Function:\Get-NetTCPConnection -Value $originalNetTcp } + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } +} + Invoke-CcodTest 'verifies FreshRestart only with one correlated ChatGPT root and Completed current-runtime installer receipt' { . $harnessPath -Library - $root = Join-Path $env:TEMP ('ccod-installed-success-' + [guid]::NewGuid().ToString('N')) + $root = Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-installed-success-' + [guid]::NewGuid().ToString('N')) try { $null = [IO.Directory]::CreateDirectory($root) - New-CcodHarnessInstalledStateFixture -Root $root -ActiveRuntimeId '2.5.21-new' -ActiveGeneration ([UInt64]8) ` - -StatusRuntimeId '2.5.21-new' -StatusCodexPid 13948 -StatusCodexCreationTimeUtc '2026-08-24T00:00:04.0000000Z' -ReceiptPhase 'Completed' + New-CcodHarnessInstalledStateFixture -Root $root -ActiveRuntimeId '2.5.21-e3b0c44298fc1c14-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' -ActiveGeneration ([UInt64]8) ` + -StatusRuntimeId '2.5.21-e3b0c44298fc1c14-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' -StatusCodexPid 13948 -StatusCodexCreationTimeUtc '2026-08-24T00:00:04.0000000Z' -ReceiptPhase 'Completed' Set-CcodHarnessProcessFixture -ChatGPT @( (New-CcodHarnessCimProcess -Name 'ChatGPT.exe' -ProcessId 13948 -CreationTimeUtc '2026-08-24T00:00:04.0000000Z' -CommandLine '"C:\Program Files\WindowsApps\OpenAI.Codex\ChatGPT.exe"'), (New-CcodHarnessCimProcess -Name 'ChatGPT.exe' -ProcessId 13949 -CreationTimeUtc '2026-08-24T00:00:04.1000000Z' -CommandLine '"C:\Program Files\WindowsApps\OpenAI.Codex\ChatGPT.exe" --type=renderer' -ParentProcessId 13948) ) $after = Get-CcodInstalledLifecycleFacts -InstallRoot $root - $after.appPresent = $true + $after.installRootPresent = $true; $after.appPresent = $true + $after.installReady = $true; $after.protectionReady = $true; $after.trayAuthenticated = $true + $after.trayHost = @([pscustomobject][ordered]@{ Pid = 101; CreationTimeUtc = '2026-08-24T00:00:01.0000000Z' }) + $after.trayHostIdentity = [pscustomobject][ordered]@{ pid = 101; creationTimeUtc = '2026-08-24T00:00:01.0000000Z' } $after.aboutVersion = '2.5.0' $after.deviceKeyPresent = $true $after.deviceKeySha256 = ('b' * 64) + $after.debugPorts = @(9229,9230) + $after.debugEndpoints = @( + [pscustomobject][ordered]@{ localAddress = '127.0.0.1'; localPort = 9229; owningProcess = $after.codex[0].Pid; owningProcessCreationTimeUtc = $after.codex[0].CreationTimeUtc } + [pscustomobject][ordered]@{ localAddress = '127.0.0.1'; localPort = 9230; owningProcess = $after.codex[0].Pid; owningProcessCreationTimeUtc = $after.codex[0].CreationTimeUtc } + ) $before = New-CcodHarnessFacts -RuntimeId '2.5.19-old' -CodexPid 10664 $context = [pscustomobject]@{ scenario = 'FreshRestart'; expectedVersion = '2.5.0'; installRoot = $root } $verification = Invoke-CcodHarnessWithCapturedFacts -Context $context -BeforeFacts $before -AfterFacts $after @@ -607,28 +1957,189 @@ Invoke-CcodTest 'verifies FreshRestart only with one correlated ChatGPT root and } } +Invoke-CcodTest 'rejects installed verification without an authenticated ready-state proof' { + . $harnessPath -Library + $context = [pscustomobject]@{ scenario = 'FreshLater'; expectedVersion = '2.5.0'; installRoot = 'C:\fixture' } + $before = New-CcodHarnessFacts + $after = New-CcodHarnessFacts + $after.installReady = $false + $after.protectionReady = $false + $after.trayAuthenticated = $false + $verification = Invoke-CcodHarnessWithCapturedFacts -Context $context -BeforeFacts $before -AfterFacts $after + Assert-CcodEqual $false $verification.verified 'installed verification requires durable readiness and authenticated TrayHost identity' + Assert-CcodEqual 'CCOD_INTEGRATION_OBSERVATION_UNPROVEN' $verification.code 'missing readiness proof uses the stable observation code' +} + +Invoke-CcodTest 'rejects an incomplete installed operator run result before verification' { + . $harnessPath -Library + $context = [pscustomobject]@{ scenario = 'FreshLater'; expectedVersion = '2.5.0'; installRoot = 'C:\fixture' } + $facts = New-CcodHarnessFacts + $verification = Invoke-CcodHarnessWithCapturedFacts -Context $context -BeforeFacts $facts -AfterFacts $facts -RunResult ([pscustomobject]@{ code = 'CCOD_INTEGRATION_OPERATOR_COMPLETED' }) + Assert-CcodEqual $false $verification.verified 'an incomplete operator run result cannot report success' + Assert-CcodEqual 'CCOD_INTEGRATION_OBSERVATION_UNPROVEN' $verification.code 'incomplete operator attestation uses the stable observation code' +} + +Invoke-CcodTest 'rejects a FreshLater verification without complete owner-bound debug endpoints' { + . $harnessPath -Library + $before = New-CcodHarnessFacts + $after = New-CcodHarnessFacts + $after.debugEndpoints = @() + $context = [pscustomobject]@{ scenario = 'FreshLater'; expectedVersion = '2.5.0'; expectedDebugPorts = @(9229,9230); installRoot = 'C:\Fake\Install' } + $verification = Invoke-CcodHarnessWithCapturedFacts -Context $context -BeforeFacts $before -AfterFacts $after + Assert-CcodEqual $false $verification.verified 'FreshLater requires both expected debug endpoints' + Assert-CcodEqual 'CCOD_INTEGRATION_OBSERVATION_UNPROVEN' $verification.code 'missing debug endpoint proof uses the stable observation code' +} + +Invoke-CcodTest 'rejects FreshLater verification without current status and terminal lifecycle identity' { + . $harnessPath -Library + $before = New-CcodHarnessFacts + $after = New-CcodHarnessFacts + $after.statusPhase = 'Unavailable' + $after.statusRuntimeId = $null + $after.statusCodex = $null + $after.transitionStage = 'Unknown' + $after.trayAuthenticated = $false + $after.lifecycleReceipt = $null + $context = [pscustomobject]@{ scenario = 'FreshLater'; expectedVersion = '2.5.0'; expectedDebugPorts = @(9229,9230); installRoot = 'C:\Fake\Install' } + $verification = Invoke-CcodHarnessWithCapturedFacts -Context $context -BeforeFacts $before -AfterFacts $after + Assert-CcodEqual $false $verification.verified 'FreshLater requires current status, transition, tray, and lifecycle receipt identity' + Assert-CcodEqual 'CCOD_INTEGRATION_OBSERVATION_UNPROVEN' $verification.code 'detached FreshLater status uses the stable observation code' +} + +Invoke-CcodTest 'FreshInstall requires a clean pre-install product baseline beyond zero Codex roots' { + . $harnessPath -Library + $before = New-CcodHarnessFacts + $before.codex = @() + $after = New-CcodHarnessFacts + $context = [pscustomobject]@{ scenario = 'FreshInstall'; expectedVersion = '2.5.0'; expectedDebugPorts = @(9229,9230); installRoot = 'C:\Fake\Install' } + $verification = Invoke-CcodHarnessWithCapturedFacts -Context $context -BeforeFacts $before -AfterFacts $after + Assert-CcodEqual $false $verification.verified 'FreshInstall rejects an already-present install baseline even when Codex is absent' +} + +Invoke-CcodTest 'FreshLater requires a pre-existing Codex identity even when the baseline is empty' { + . $harnessPath -Library + $before = New-CcodHarnessFacts + $before.codex = @() + $after = New-CcodHarnessFacts + $context = [pscustomobject]@{ scenario = 'FreshLater'; expectedVersion = '2.5.0'; expectedDebugPorts = @(9229,9230); installRoot = 'C:\Fake\Install' } + $verification = Invoke-CcodHarnessWithCapturedFacts -Context $context -BeforeFacts $before -AfterFacts $after + Assert-CcodEqual $false $verification.verified 'FreshLater cannot claim a preserved pre-existing session from an empty baseline' +} + +Invoke-CcodTest 'uninstall verification probes captured ports independently of deleted status' { + . $harnessPath -Library + $before = New-CcodHarnessFacts + $after = New-CcodHarnessFacts + $after.installRootPresent = $false + $after.appPresent = $false + $after.runtimeRootPresent = $false + $after.activePointerPresent = $false + $after.activeRuntimeId = $null + $after.activeGeneration = $null + $after.runtimeManifestSha256 = $null + $after.runtimeManifestFiles = @() + $after.supervisor = @() + $after.trayHost = @() + $after.trayHostIdentity = $null + $after.trayAuthenticated = $false + $after.installReady = $false + $after.protectionReady = $false + $after.codex = @() + $after.taskState = 'Absent' + $after.statusPhase = 'Unavailable' + $after.statusRuntimeId = $null + $after.statusCodex = $null + $after.transitionStage = 'Unavailable' + $after.lifecycleReceipt = $null + $after.aboutVersion = $null + $after.shortcuts = [pscustomobject]@{ startMenu = $false; desktop = $false } + $after.debugPorts = @() + $after.debugEndpoints = @() + $afterKey = $after.deviceKeySha256 + $observedArgument = 'unset' + $listenerProbes = 0 + $context = [pscustomobject]@{ scenario = 'DirectUninstall'; expectedVersion = '2.5.0'; expectedDebugPorts = @(9229,9230); installRoot = 'C:\Fake\Install' } + $verification = Invoke-CcodHarnessWithCapturedFacts -Context $context -BeforeFacts $before -AfterFacts $after -ObservedExpectedDebugPorts ([ref]$observedArgument) -ObservedUninstallEnumeration ([ref]$listenerProbes) + Assert-CcodEqual 'none' $observedArgument 'removed status is not treated as a current runtime port declaration' + Assert-CcodTrue $verification.verified 'empty uninstall state is a valid terminal observation' + Assert-CcodEqual $afterKey $verification.facts.deviceKeySha256 'uninstall preserves the existing device-key identity' + Assert-CcodEqual 1 $listenerProbes 'terminal verification independently enumerates the captured ports' + foreach ($port in @(9229,9230)) { foreach ($address in @('127.0.0.1','0.0.0.0','::','192.0.2.1')) { + $listener = [pscustomobject]@{ LocalAddress = $address; LocalPort = [uint16]$port; OwningProcess = [uint32]4 } + $residual = Invoke-CcodHarnessWithCapturedFacts -Context $context -BeforeFacts $before -AfterFacts $after -PostUninstallListeners @($listener) + Assert-CcodEqual $false $residual.verified 'any remaining listener on a captured port blocks uninstall verification' + Assert-CcodEqual 'CCOD_INTEGRATION_OBSERVATION_UNPROVEN' $residual.code 'remaining listener is not a completed uninstall' + } } + $context.expectedDebugPorts = @() + $cannotSuppress = Invoke-CcodHarnessWithCapturedFacts -Context $context -BeforeFacts $before -AfterFacts $after -PostUninstallListeners @([pscustomobject]@{ LocalPort = [uint16]9230 }) + Assert-CcodEqual $false $cannotSuppress.verified 'empty caller hints cannot suppress the pre-uninstall observation' + $context.expectedDebugPorts = @(9229,9230) + foreach ($invalidPorts in @(@('9229',9230),@([double]9229,9230),@(9229,9229))) { + $invalidBefore = $before.PSObject.Copy() + $invalidBefore.debugPorts = $invalidPorts + Assert-CcodThrows { Invoke-CcodHarnessWithCapturedFacts -Context $context -BeforeFacts $invalidBefore -AfterFacts $after } 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' + } + $unrelated = Invoke-CcodHarnessWithCapturedFacts -Context $context -BeforeFacts $before -AfterFacts $after -PostUninstallListeners @([pscustomobject]@{ LocalAddress = '::'; LocalPort = [uint16]443; OwningProcess = [uint32]4 }) + Assert-CcodTrue $unrelated.verified 'unrelated listeners do not prevent proof that captured ports are closed' + Assert-CcodThrows { Invoke-CcodHarnessWithCapturedFacts -Context $context -BeforeFacts $before -AfterFacts $after -FailUninstallEnumeration } 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' +} + +Invoke-CcodTest 'accepts a FreshRestart verification with ports discovered only after a clean uninstall' { + . $harnessPath -Library + $before = New-CcodHarnessFacts -CodexPid 10664 + $before.debugPorts = @() + $before.debugEndpoints = @() + $after = New-CcodHarnessFacts + $context = [pscustomobject]@{ scenario = 'FreshRestart'; expectedVersion = '2.5.0'; installRoot = 'C:\Fake\Install' } + $verification = Invoke-CcodHarnessWithCapturedFacts -Context $context -BeforeFacts $before -AfterFacts $after + Assert-CcodEqual $true $verification.verified 'FreshInstall can establish its new debug-port set after uninstall' +} + +Invoke-CcodTest 'rejects an active runtime with a zero generation' { + . $harnessPath -Library + $facts = New-CcodHarnessFacts + $facts.activeGeneration = [UInt64]0 + Assert-CcodThrows { ConvertTo-CcodInstalledLifecycleFacts -Facts $facts } 'CCOD_INTEGRATION_FACTS_INVALID' +} + +Invoke-CcodTest 'rejects non-boolean runtime-root and active-pointer presence facts' { + . $harnessPath -Library + $facts = New-CcodHarnessFacts + $facts.runtimeRootPresent = 'true' + Assert-CcodThrows { ConvertTo-CcodInstalledLifecycleFacts -Facts $facts } 'CCOD_INTEGRATION_FACTS_INVALID' + $facts = New-CcodHarnessFacts + $facts.activePointerPresent = 1 + Assert-CcodThrows { ConvertTo-CcodInstalledLifecycleFacts -Facts $facts } 'CCOD_INTEGRATION_FACTS_INVALID' +} + +Invoke-CcodTest 'rejects dot-only active runtime IDs at the facts boundary' { + . $harnessPath -Library + $facts = New-CcodHarnessFacts + $facts.activeRuntimeId = '..' + Assert-CcodThrows { ConvertTo-CcodInstalledLifecycleFacts -Facts $facts } 'CCOD_INTEGRATION_FACTS_INVALID' +} + Invoke-CcodTest 'rejects FreshRestart when the fully correlated after root is unchanged from before' { . $harnessPath -Library - $root = Join-Path $env:TEMP ('ccod-installed-unchanged-root-' + [guid]::NewGuid().ToString('N')) + $after=New-CcodHarnessFacts -RuntimeId '2.5.22-after' -CodexPid 13948 -CodexCreationTimeUtc '2026-08-24T00:00:04.0000006Z' + $before=New-CcodHarnessFacts -RuntimeId '2.5.21-before' -CodexPid 10664 -CodexCreationTimeUtc '2026-08-24T00:00:00.0000000Z' + $context=[pscustomobject]@{scenario='FreshRestart';expectedVersion='2.5.0';installRoot='C:\fixture'} + $positive=Invoke-CcodHarnessWithCapturedFacts -Context $context -BeforeFacts $before -AfterFacts $after + Assert-CcodTrue $positive.verified 'fully valid new-root baseline passes before the single identity mutation' + $before.codex=@($after.codex) + $negative=Invoke-CcodHarnessWithCapturedFacts -Context $context -BeforeFacts $before -AfterFacts $after + Assert-CcodEqual $false $negative.verified 'only unchanged root makes the complete baseline invalid' + Assert-CcodEqual 'CCOD_INTEGRATION_OBSERVATION_UNPROVEN' $negative.code 'unchanged root uses stable rejection' + $original=${function:Test-CcodInstalledLifecycleScenario} try { - $null = [IO.Directory]::CreateDirectory($root) - $unchangedPid=13948;$unchangedCreation='2026-08-24T00:00:04.0000000Z' - New-CcodHarnessInstalledStateFixture -Root $root -ActiveRuntimeId '2.5.21-new' -ActiveGeneration ([UInt64]8) ` - -StatusRuntimeId '2.5.21-new' -StatusCodexPid $unchangedPid -StatusCodexCreationTimeUtc $unchangedCreation -ReceiptPhase 'Completed' - Set-CcodHarnessProcessFixture -ChatGPT @( - (New-CcodHarnessCimProcess -Name 'ChatGPT.exe' -ProcessId $unchangedPid -CreationTimeUtc $unchangedCreation -CommandLine '"C:\Program Files\WindowsApps\OpenAI.Codex\ChatGPT.exe"') - ) - $after=Get-CcodInstalledLifecycleFacts -InstallRoot $root - $after.appPresent=$true;$after.aboutVersion='2.5.0';$after.deviceKeyPresent=$true;$after.deviceKeySha256=('b'*64) - $before=New-CcodHarnessFacts -RuntimeId '2.5.19-old' -CodexPid $unchangedPid -CodexCreationTimeUtc $unchangedCreation - $context=[pscustomobject]@{scenario='FreshRestart';expectedVersion='2.5.0';installRoot=$root} - $verification=Invoke-CcodHarnessWithCapturedFacts -Context $context -BeforeFacts $before -AfterFacts $after - Assert-CcodEqual $false $verification.verified 'FreshRestart requires the root identity to change even when status and receipt fully correlate' - Assert-CcodEqual 'CCOD_INTEGRATION_OBSERVATION_UNPROVEN' $verification.code 'unchanged root rejection uses the stable observation code' - } finally { - Clear-CcodHarnessProcessFixture - if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } - } + $source=$original.ToString();$needle='$rootIdentityReplaced -and' + Assert-CcodEqual 1 ([regex]::Matches($source,[regex]::Escape($needle))).Count 'one identity guard is mutated in memory only' + Set-Item Function:Test-CcodInstalledLifecycleScenario ([scriptblock]::Create($source.Replace($needle,'$true -and'))) + $mutant=Invoke-CcodHarnessWithCapturedFacts -Context $context -BeforeFacts $before -AfterFacts $after + Assert-CcodTrue $mutant.verified 'removing only root replacement enforcement admits the unchanged-root negative' + } finally {Set-Item Function:Test-CcodInstalledLifecycleScenario $original} + $before.codex=@([pscustomobject]@{pid=13948;creationTimeUtc='2026-08-24T00:00:04.0000005Z'}) + Assert-CcodTrue ((Invoke-CcodHarnessWithCapturedFacts -Context $context -BeforeFacts $before -AfterFacts $after).verified) 'reused PID with different exact creation time is a replacement root' } Invoke-CcodTest 'requires mutation and Codex-restart consent before any adapter or filesystem action' { @@ -646,11 +2157,82 @@ Invoke-CcodTest 'requires mutation and Codex-restart consent before any adapter Invoke-CcodInstalledLifecycleIntegration -InstallerPath $fixture.Installer -ExpectedVersion '2.5.0' -EvidenceRoot $fixture.EvidenceRoot -Scenario FreshRestart -AllowMachineMutation -Adapters $adapters } 'CCOD_INTEGRATION_CODEX_RESTART_NOT_ALLOWED' Assert-CcodEqual 0 $calls.Count 'restart rejection also precedes every adapter action' + Assert-CcodThrows { + Invoke-CcodInstalledLifecycleIntegration -InstallerPath $fixture.Installer -ExpectedVersion '2.5.0' -EvidenceRoot $fixture.EvidenceRoot -Scenario FreshInstall -AllowMachineMutation -Adapters $adapters + } 'CCOD_INTEGRATION_CODEX_RESTART_NOT_ALLOWED' + Assert-CcodEqual 0 $calls.Count 'FreshInstall restart rejection also precedes every adapter action' } finally { if (Test-Path -LiteralPath $fixture.Root) { Remove-Item -LiteralPath $fixture.Root -Recurse -Force } } } +Invoke-CcodTest 'integration keeps candidate bytes immutable through scenario execution' { + . $harnessPath -Library + $fixture=New-CcodHarnessFixture + $calls=[Collections.Generic.List[string]]::new();$captured=$null + try { + $adapters=New-CcodHarnessAdapters -Fixture $fixture -Calls $calls -CapturedReceipt ([ref]$captured) + $original=$adapters.RunScenario + $probe=[pscustomobject]@{Attempted=0;Blocked=0;Consumed=$null} + $adapters.RunScenario={param($Context) + $probe.Attempted++ + try{[IO.File]::WriteAllText($Context.installerPath,'changed-at-execution')}catch [IO.IOException]{$probe.Blocked++} + $probe.Consumed=Get-CcodTestFileSha256 $Context.installerPath + & $original $Context + } + $result=Invoke-CcodInstalledLifecycleIntegration -InstallerPath $fixture.Installer -ExpectedVersion '2.5.0' -EvidenceRoot $fixture.EvidenceRoot -Scenario FreshLater -AllowMachineMutation -Adapters $adapters + Assert-CcodEqual 1 $probe.Attempted 'test reaches real scenario execution after validation' + Assert-CcodEqual $fixture.InstallerHash $probe.Consumed 'scenario consumes the original candidate bytes' + Assert-CcodEqual 1 $probe.Blocked 'candidate overwrite is denied throughout consumption' + Assert-CcodEqual 'Completed' $result.outcome 'unchanged consumption remains valid' + [IO.File]::WriteAllText($fixture.Installer,'writable-after-execution') + } finally {if(Test-Path $fixture.Root){Remove-Item -LiteralPath $fixture.Root -Recurse -Force}} +} + +Invoke-CcodTest 'default official phase binds its original candidate before actual integration mutation' { + $fixture=New-CcodHarnessFixture;$sealed=$null;$official=$null;$integration=$null + try { + $sealed=New-CcodHarnessSealedFixture + $official=Import-Module (Join-Path $repositoryRoot 'tests/installed/OfficialDraftAcceptance.psm1') -Force -PassThru -DisableNameChecking + $integration=&$official {Get-CcodOfficialDraftIntegrationModule} + $calls=[Collections.Generic.List[string]]::new();$captured=$null + $adapters=New-CcodHarnessAdapters -Fixture $fixture -Calls $calls -BeforeVersion '2.5.22' -AfterVersion '2.5.22' -CapturedReceipt ([ref]$captured) + $after=New-CcodHarnessFacts -Version '2.5.22' + $after.runtimeManifestFiles=@(([IO.File]::ReadAllText((Join-Path $sealed.Root $sealed.Names[8]))|ConvertFrom-Json).files) + $adapters.VerifyScenario={param($Context,$BeforeFacts,$RunResult)$calls.Add('VerifyScenario');[pscustomobject]@{verified=$true;code='CCOD_INTEGRATION_VERIFIED';facts=$after}} + &$integration {param($SafeAdapters)$script:CcodBoundaryAdapters=$SafeAdapters;function script:Resolve-CcodInstalledLifecycleAdapters {param($Adapters)return $script:CcodBoundaryAdapters}} $adapters + $context=&$official { + param($Root,$Evidence) + $assetModule=Get-CcodOfficialDraftAssetModule + $contract=&$assetModule {param($Root)Test-CcodExactReleaseAssetSet -AssetDirectory $Root -Version '2.5.22'} $Root + $candidate=ConvertTo-CcodOfficialDraftCandidate $contract + New-CcodOfficialDraftContext -Phase FreshInstall -AssetDirectory $Root -PreviousAssetDirectory $Root -EvidenceRoot $Evidence -Candidate $candidate -Draft ([pscustomobject]@{tag='v2.5.22';id='123'}) -PreviousSetup ([pscustomobject]@{version='2.5.21';assetSha256=('a'*64);manifestSha256=('b'*64)}) -AllowMachineMutation -AllowCodexRestart + } $sealed.Root $fixture.EvidenceRoot + $run=&$official {(Get-CcodOfficialDraftDefaultAdapters).RunPhase} + $control=&$run $context + Assert-CcodTrue $control.completed 'real default phase and real integration have a valid sealed candidate control' + $original=$context.candidate|ConvertTo-Json -Depth 8 -Compress + foreach($mode in @('Installer','Commit','OtherAsset')) { + $context.candidate=$original|ConvertFrom-Json + if($mode-ceq'Installer'){$context.candidate.assetHashes[5].sha256='1'*64} + elseif($mode-ceq'Commit'){$context.candidate.gitCommit='1'*40} + else{$context.candidate.assetHashes[2].sha256='1'*64} + $calls.Clear() + Assert-CcodThrows {&$run $context|Out-Null} 'CCOD_ACCEPTANCE_MACHINE_OPERATION_FAILED' + Assert-CcodTrue (-not$calls.Contains('CreateRollbackSnapshot')) ($mode+' rejects before snapshot') + Assert-CcodTrue (-not$calls.Contains('RunScenario')) ($mode+' rejects before machine action rather than post-operation') + Assert-CcodTrue (-not$calls.Contains('NewEvidenceDirectory')) ($mode+' rejects before creating success evidence') + } + $context.candidate=$original|ConvertFrom-Json + Assert-CcodTrue (&$run $context).completed 'failed binding releases authority for a legitimate retry' + } finally { + if($null-ne$integration){Remove-Module $integration.Name -Force -ErrorAction SilentlyContinue} + if($null-ne$official){Remove-Module $official.Name -Force -ErrorAction SilentlyContinue} + if($null-ne$sealed){foreach($root in @($sealed.Root,$sealed.Outside)){if(Test-Path $root){Remove-Item $root -Recurse -Force}}} + if(Test-Path $fixture.Root){Remove-Item $fixture.Root -Recurse -Force} + } +} + Invoke-CcodTest 'blocks a dirty checkout before snapshot or scenario execution' { . $harnessPath -Library $fixture = New-CcodHarnessFixture @@ -716,22 +2298,187 @@ Invoke-CcodTest 'rolls back, records a redacted failure receipt, and raises a st } } +Invoke-CcodTest 'rollback launches the canonical frozen installer path directly' { + $source = [IO.File]::ReadAllText($harnessPath) + Assert-CcodTrue (-not $source.Contains('Start-Process -FilePath $installer.FullName')) 'rollback uses the canonical frozen installer path returned by validation' +} + +Invoke-CcodTest 'rollback validates the frozen temporary root before copying prior artifacts' { + $source = [IO.File]::ReadAllText($harnessPath, [Text.UTF8Encoding]::new($false)) + $copy = $source.IndexOf('[IO.File]::Copy($previousCandidate.Path', [StringComparison]::Ordinal) + $safe = $source.IndexOf('Assert-CcodInstalledLifecycleSafeDirectory -Path $frozenPreviousRoot', [StringComparison]::Ordinal) + Assert-CcodTrue ($safe -ge 0 -and $copy -ge 0 -and $safe -lt $copy) 'frozen rollback root is checked for canonical non-reparse ancestry before copying' +} + +Invoke-CcodTest 'rollback cleanup rejects a descendant reparse point before recursive deletion' { + . $harnessPath -Library + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-frozen-descendant-reparse-' + [guid]::NewGuid().ToString('N')) + $outside = Join-Path ([IO.Path]::GetTempPath()) ('ccod-frozen-descendant-outside-' + [guid]::NewGuid().ToString('N')) + $junction = Join-Path $root 'escape' + [IO.Directory]::CreateDirectory($root) | Out-Null + [IO.Directory]::CreateDirectory($outside) | Out-Null + $outsideFile = Join-Path $outside 'keep.txt' + [IO.File]::WriteAllText($outsideFile, 'must remain', [Text.UTF8Encoding]::new($false)) + try { + New-Item -ItemType Junction -Path $junction -Target $outside | Out-Null + Assert-CcodThrows { Remove-CcodInstalledLifecycleFrozenRoot -Path $root } 'CCOD_INTEGRATION_ROLLBACK_CLEANUP_FAILED' + Assert-CcodTrue (Test-Path -LiteralPath $outsideFile -PathType Leaf) 'unsafe frozen cleanup does not touch the reparse target' + } finally { + try { if ([IO.Directory]::Exists($junction)) { [IO.Directory]::Delete($junction, $false) } } catch { } + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $outside) { Remove-Item -LiteralPath $outside -Recurse -Force -ErrorAction SilentlyContinue } + } +} + +Invoke-CcodTest 'default installed verifier preserves converter fields until the integration boundary' { + $source = [IO.File]::ReadAllText($harnessPath) + $removeDebug = $source.IndexOf("if (`$afterFacts.PSObject.Properties['debugPorts']) { `$afterFacts.PSObject.Properties.Remove('debugPorts') }") + $callerConvert = $source.IndexOf('$verification = [pscustomobject][ordered]@{') + Assert-CcodTrue ($removeDebug -lt 0 -or $callerConvert -lt $removeDebug) 'default verifier does not remove mandatory debug fields before the caller converter' +} + +Invoke-CcodTest 'accepts an exact checksum-bound v2.5.21 installer as the source of a v2.5.22 upgrade run' { + . $harnessPath -Library + $fixture = New-CcodHarnessFixture + $sealed=$null + try { + $sealed=New-CcodHarnessSealedFixture + $current=Join-Path $sealed.Root $sealed.Names[5] + $package=[IO.File]::ReadAllText((Join-Path $sealed.Root $sealed.Names[8]))|ConvertFrom-Json + $legacy = New-CcodHarnessCandidate -Fixture $fixture -Version '2.5.21' -Bytes ([byte[]](2,3,5,7,11,13,17,19)) + $legacyManifest = Join-Path $fixture.Root 'CodexRemote-fix-2.5.21-setup-release-manifest.json' + [IO.File]::WriteAllText($legacyManifest, 'legacy release manifest', [Text.UTF8Encoding]::new($false)) + $legacyInstallerHash = Get-CcodTestFileSha256 -Path $legacy + $legacyManifestHash = Get-CcodTestFileSha256 -Path $legacyManifest + $calls = [Collections.Generic.List[string]]::new() + $captured = $null + $adapters = New-CcodHarnessAdapters -Fixture $fixture -Calls $calls -BeforeVersion '2.5.21' -AfterVersion '2.5.22' -CapturedReceipt ([ref]$captured) + $after=New-CcodHarnessFacts -Version '2.5.22';$after.runtimeManifestFiles=@($package.files) + $adapters.VerifyScenario={param($Context,$BeforeFacts,$RunResult)$calls.Add('VerifyScenario');[pscustomobject]@{verified=$true;code='CCOD_INTEGRATION_VERIFIED';facts=$after}} + $receipt = Invoke-CcodInstalledLifecycleIntegration -InstallerPath $current -PreviousInstallerPath $legacy -PreviousExpectedVersion '2.5.21' -PreviousInstallerSha256 $legacyInstallerHash -PreviousManifestSha256 $legacyManifestHash -ExpectedVersion '2.5.22' -EvidenceRoot $fixture.EvidenceRoot -Scenario Upgrade -AllowMachineMutation -AllowCodexRestart -Adapters $adapters + Assert-CcodEqual 'Completed' ([string]$receipt.outcome) 'v2.5.21 to v2.5.22 reaches the fake installed verification boundary' + Assert-CcodTrue ($receipt.beforeFacts.PSObject.Properties['debugPorts'] -and $receipt.beforeFacts.PSObject.Properties['debugEndpoints']) 'upgrade evidence retains the observed pre-operation debug endpoints' + Assert-CcodTrue ($receipt.verification.facts.PSObject.Properties['debugPorts'] -and $receipt.verification.facts.PSObject.Properties['debugEndpoints']) 'upgrade evidence retains the observed post-operation debug endpoints' + Assert-CcodEqual (Get-CcodTestFileSha256 -Path $legacy) ([string]$receipt.previousInstallerSha256) 'upgrade evidence binds the exact v2.5.21 installer bytes' + [IO.File]::WriteAllBytes($legacy, [byte[]](31,29,27,25,23,21,19,17)) + $replacementHash = Get-CcodTestFileSha256 -Path $legacy + [IO.File]::WriteAllText("$legacy.sha256.txt", ("{0} *{1}`r`n" -f $replacementHash, [IO.Path]::GetFileName($legacy)), [Text.UTF8Encoding]::new($false)) + Assert-CcodThrows { + Invoke-CcodInstalledLifecycleIntegration -InstallerPath $current -PreviousInstallerPath $legacy -PreviousExpectedVersion '2.5.21' -PreviousInstallerSha256 $legacyInstallerHash -PreviousManifestSha256 $legacyManifestHash -ExpectedVersion '2.5.22' -EvidenceRoot $fixture.EvidenceRoot -Scenario Upgrade -AllowMachineMutation -AllowCodexRestart -Adapters $adapters + } 'CCOD_INTEGRATION_PREVIOUS_INSTALLER_INVALID' + Assert-CcodTrue ($replacementHash -cne $legacyInstallerHash) 'replacement fixture changes the prior installer bytes' + Assert-CcodTrue ($calls -contains 'CreateRollbackSnapshot') 'legacy upgrade captures rollback state before the adapted scenario' + Assert-CcodTrue ($calls -contains 'VerifyScenario') 'legacy upgrade still requires independent installed verification' + Assert-CcodEqual 'CaptureFacts:2.5.21' ((@($calls | Where-Object { [string]$_ -like 'CaptureFacts:*' }) -join '|')) 'upgrade observes the retained v2.5.21 state before invoking the v2.5.22 operation' + } finally { + if($null-ne$sealed){foreach($root in @($sealed.Root,$sealed.Outside)){if(Test-Path -LiteralPath $root){Remove-Item -LiteralPath $root -Recurse -Force}}} + if (Test-Path -LiteralPath $fixture.Root) { Remove-Item -LiteralPath $fixture.Root -Recurse -Force } + } +} + +Invoke-CcodTest 'rejects a self-consistent active runtime whose payload file set is detached from the candidate' { + . $harnessPath -Library + $fixture = New-CcodHarnessFixture + $sealed=$null + try { + $sealed=New-CcodHarnessSealedFixture + $current=Join-Path $sealed.Root $sealed.Names[5] + $package=[IO.File]::ReadAllText((Join-Path $sealed.Root $sealed.Names[8]))|ConvertFrom-Json + $calls = [Collections.Generic.List[string]]::new() + $adapters = New-CcodHarnessAdapters -Fixture $fixture -Calls $calls -BeforeVersion '2.5.21' -AfterVersion '2.5.22' -CapturedReceipt ([ref]$null) + $state=[pscustomobject]@{After=(New-CcodHarnessFacts -Version '2.5.22')} + $state.After.runtimeManifestFiles=@($package.files) + $adapters.VerifyScenario={param($Context,$BeforeFacts,$RunResult)$calls.Add('VerifyScenario');[pscustomobject]@{verified=$true;code='CCOD_INTEGRATION_VERIFIED';facts=$state.After}} + $control=Invoke-CcodInstalledLifecycleIntegration -InstallerPath $current -ExpectedVersion '2.5.22' -EvidenceRoot $fixture.EvidenceRoot -Scenario FreshLater -AllowMachineMutation -AllowCodexRestart -Adapters $adapters + Assert-CcodEqual 'Completed' $control.outcome 'sealed candidate and matching runtime records pass before one file changes' + $state.After.runtimeManifestFiles[0].sha256='f'*64 + Assert-CcodThrows { + Invoke-CcodInstalledLifecycleIntegration -InstallerPath $current -ExpectedVersion '2.5.22' -EvidenceRoot $fixture.EvidenceRoot -Scenario FreshLater -AllowMachineMutation -AllowCodexRestart -Adapters $adapters + } 'CCOD_INTEGRATION_VERIFICATION_FAILED' + } finally { + if($null-ne$sealed){foreach($root in @($sealed.Root,$sealed.Outside)){if(Test-Path -LiteralPath $root){Remove-Item -LiteralPath $root -Recurse -Force}}} + if (Test-Path -LiteralPath $fixture.Root) { Remove-Item -LiteralPath $fixture.Root -Recurse -Force } + } +} + +Invoke-CcodTest 'upgrade rollback routes through a frozen previous installer identity' { + . $harnessPath -Library + $fixture = New-CcodHarnessFixture + try { + $current = New-CcodHarnessCandidate -Fixture $fixture -Version '2.5.0' -Bytes ([byte[]](9,8,7,6,5,4,3,2)) + $previous = New-CcodHarnessCandidate -Fixture $fixture -Version '2.4.24' -Bytes ([byte[]](2,3,5,7,11,13,17,19)) + $previousManifest = Join-Path $fixture.Root 'CodexRemote-fix-2.4.24-setup-release-manifest.json' + [IO.File]::WriteAllText($previousManifest, 'previous release manifest', [Text.UTF8Encoding]::new($false)) + $previousInstallerHash = Get-CcodTestFileSha256 -Path $previous + $previousManifestHash = Get-CcodTestFileSha256 -Path $previousManifest + $calls = [Collections.Generic.List[string]]::new() + $captured = $null + $adapters = New-CcodHarnessAdapters -Fixture $fixture -Calls $calls -FailScenario -ReplacePreviousAfterValidation $previous -RecordRollbackIdentity -CapturedReceipt ([ref]$captured) + Assert-CcodThrows { + Invoke-CcodInstalledLifecycleIntegration -InstallerPath $current -PreviousInstallerPath $previous -PreviousExpectedVersion '2.4.24' -PreviousInstallerSha256 $previousInstallerHash -PreviousManifestSha256 $previousManifestHash -ExpectedVersion '2.5.0' -EvidenceRoot $fixture.EvidenceRoot -Scenario Upgrade -AllowMachineMutation -AllowCodexRestart -Adapters $adapters + } 'CCOD_INTEGRATION_SCENARIO_FAILED' + $rollbackPath = @($calls | Where-Object { [string]$_ -like 'RollbackPath:*' })[0].Substring(13) + $rollbackHash = @($calls | Where-Object { [string]$_ -like 'RollbackHash:*' })[0].Substring(13) + Assert-CcodTrue (-not [string]::IsNullOrWhiteSpace($rollbackPath)) 'failed upgrade enters rollback with an explicit previous installer' + Assert-CcodEqual $previousInstallerHash $rollbackHash 'rollback uses the byte identity validated before the scenario' + Assert-CcodTrue ($rollbackPath -cne $previous) 'rollback does not launch the mutable source path' + } finally { + if (Test-Path -LiteralPath $fixture.Root) { Remove-Item -LiteralPath $fixture.Root -Recurse -Force } + } +} + +Invoke-CcodTest 'frozen rollback bytes remain held until their actual consumer returns' { + . $harnessPath -Library + $fixture=New-CcodHarnessFixture + $originalStart=${function:Start-Process};$originalAck=${function:Read-CcodInstalledLifecycleOperatorAck} + try { + $previous=New-CcodHarnessCandidate -Fixture $fixture -Version '2.4.24' + $manifest=Join-Path $fixture.Root 'CodexRemote-fix-2.4.24-setup-release-manifest.json' + [IO.File]::WriteAllText($manifest,'original previous manifest') + $expected=Get-CcodTestFileSha256 $previous;$manifestHash=Get-CcodTestFileSha256 $manifest + $calls=[Collections.Generic.List[string]]::new();$captured=$null + $adapters=New-CcodHarnessAdapters -Fixture $fixture -Calls $calls -FailScenario -CapturedReceipt ([ref]$captured) + $probe=[pscustomobject]@{Attempts=0;Blocked=0;Consumed=$null;Path=$null;Manifest=$null} + $adapters.Rollback={param($Context,$Snapshot)$probe.Manifest=$Context.previousManifestPath;Invoke-CcodInstalledLifecycleRollback -Context $Context -Snapshot $Snapshot} + function Start-Process {param($FilePath,[switch]$PassThru,[switch]$Wait,$ErrorAction) + $probe.Attempts++;$probe.Path=$FilePath + foreach($path in @($FilePath,$probe.Manifest)){try{[IO.File]::WriteAllText($path,'changed before rollback launch')}catch [IO.IOException]{$probe.Blocked++}} + $probe.Consumed=Get-CcodTestFileSha256 $FilePath + [pscustomobject]@{ExitCode=0} + } + function Read-CcodInstalledLifecycleOperatorAck {param($Scenario,$Instructions)} + Assert-CcodThrows {Invoke-CcodInstalledLifecycleIntegration -InstallerPath $fixture.Installer -PreviousInstallerPath $previous -PreviousExpectedVersion '2.4.24' -PreviousInstallerSha256 $expected -PreviousManifestSha256 $manifestHash -ExpectedVersion '2.5.0' -EvidenceRoot $fixture.EvidenceRoot -Scenario Upgrade -AllowMachineMutation -AllowCodexRestart -Adapters $adapters|Out-Null} 'CCOD_INTEGRATION_SCENARIO_FAILED' + Assert-CcodEqual 1 $probe.Attempts 'real rollback reaches the native launch seam' + Assert-CcodEqual $expected $probe.Consumed 'rollback consumes only originally frozen executable bytes' + Assert-CcodEqual 2 $probe.Blocked 'both executable and manifest remain held through launch' + Assert-CcodTrue $captured.rollback.completed 'rollback itself completes while the original scenario stays failed' + Assert-CcodTrue (-not(Test-Path -LiteralPath (Split-Path $probe.Path -Parent))) 'all holds release before frozen directory cleanup' + } finally { + foreach($pair in @(@('Start-Process',$originalStart),@('Read-CcodInstalledLifecycleOperatorAck',$originalAck))){if($null-ne$pair[1]){Set-Item ('Function:'+$pair[0]) $pair[1]}else{Remove-Item ('Function:'+$pair[0]) -ErrorAction SilentlyContinue}} + if(Test-Path $fixture.Root){Remove-Item $fixture.Root -Recurse -Force} + } +} + Invoke-CcodTest 'accepts only a distinct checksum-bound prior installer for an upgrade rollback path' { . $harnessPath -Library $fixture = New-CcodHarnessFixture try { $previous = New-CcodHarnessCandidate -Fixture $fixture -Version '2.4.24' + $previousManifest = Join-Path $fixture.Root 'CodexRemote-fix-2.4.24-setup-release-manifest.json' + [IO.File]::WriteAllText($previousManifest, 'previous release manifest', [Text.UTF8Encoding]::new($false)) + $previousInstallerHash = Get-CcodTestFileSha256 -Path $previous + $previousManifestHash = Get-CcodTestFileSha256 -Path $previousManifest $calls = [Collections.Generic.List[string]]::new() $captured = $null $adapters = New-CcodHarnessAdapters -Fixture $fixture -Calls $calls -CapturedReceipt ([ref]$captured) - $receipt = Invoke-CcodInstalledLifecycleIntegration -InstallerPath $fixture.Installer -PreviousInstallerPath $previous -ExpectedVersion '2.5.0' -EvidenceRoot $fixture.EvidenceRoot -Scenario Upgrade -AllowMachineMutation -AllowCodexRestart -Adapters $adapters + $receipt = Invoke-CcodInstalledLifecycleIntegration -InstallerPath $fixture.Installer -PreviousInstallerPath $previous -PreviousExpectedVersion '2.4.24' -PreviousInstallerSha256 $previousInstallerHash -PreviousManifestSha256 $previousManifestHash -ExpectedVersion '2.5.0' -EvidenceRoot $fixture.EvidenceRoot -Scenario Upgrade -AllowMachineMutation -AllowCodexRestart -Adapters $adapters Assert-CcodEqual 'Completed' ([string]$receipt.outcome) 'distinct prior installer can support a checked upgrade run' Assert-CcodThrows { - Invoke-CcodInstalledLifecycleIntegration -InstallerPath $fixture.Installer -PreviousInstallerPath $fixture.Installer -ExpectedVersion '2.5.0' -EvidenceRoot $fixture.EvidenceRoot -Scenario Upgrade -AllowMachineMutation -AllowCodexRestart -Adapters $adapters + Invoke-CcodInstalledLifecycleIntegration -InstallerPath $fixture.Installer -PreviousInstallerPath $fixture.Installer -PreviousExpectedVersion '2.4.24' -PreviousInstallerSha256 $previousInstallerHash -PreviousManifestSha256 $previousManifestHash -ExpectedVersion '2.5.0' -EvidenceRoot $fixture.EvidenceRoot -Scenario Upgrade -AllowMachineMutation -AllowCodexRestart -Adapters $adapters } 'CCOD_INTEGRATION_PREVIOUS_INSTALLER_INVALID' $future = New-CcodHarnessCandidate -Fixture $fixture -Version '2.5.1' Assert-CcodThrows { - Invoke-CcodInstalledLifecycleIntegration -InstallerPath $fixture.Installer -PreviousInstallerPath $future -ExpectedVersion '2.5.0' -EvidenceRoot $fixture.EvidenceRoot -Scenario Upgrade -AllowMachineMutation -AllowCodexRestart -Adapters $adapters + Invoke-CcodInstalledLifecycleIntegration -InstallerPath $fixture.Installer -PreviousInstallerPath $future -PreviousExpectedVersion '2.4.24' -PreviousInstallerSha256 $previousInstallerHash -PreviousManifestSha256 $previousManifestHash -ExpectedVersion '2.5.0' -EvidenceRoot $fixture.EvidenceRoot -Scenario Upgrade -AllowMachineMutation -AllowCodexRestart -Adapters $adapters } 'CCOD_INTEGRATION_PREVIOUS_INSTALLER_INVALID' } finally { if (Test-Path -LiteralPath $fixture.Root) { Remove-Item -LiteralPath $fixture.Root -Recurse -Force } @@ -756,4 +2503,180 @@ Invoke-CcodTest 'rejects an installer checksum mismatch before snapshot or scena } } +Invoke-CcodTest 'rejects a noncanonical installer path before candidate validation' { + . $harnessPath -Library + $fixture = New-CcodHarnessFixture + try { + $calls = [Collections.Generic.List[string]]::new() + $captured = $null + $adapters = New-CcodHarnessAdapters -Fixture $fixture -Calls $calls -CapturedReceipt ([ref]$captured) + $nonCanonical = Join-Path $fixture.Root 'nested\..\CodexRemote-fix-2.5.0-setup.exe' + Assert-CcodThrows { + Get-CcodInstalledLifecycleCandidate -Path $nonCanonical -ExpectedVersion '2.5.0' -Adapters $adapters -Kind 'Installer' | Out-Null + } 'CCOD_INTEGRATION_CANDIDATE_INVALID' + } finally { + if (Test-Path -LiteralPath $fixture.Root) { Remove-Item -LiteralPath $fixture.Root -Recurse -Force } + } +} + +Invoke-CcodTest 'rejects noncanonical installed observation paths instead of normalizing them' { + . $harnessPath -Library + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-integration-canonical-' + [guid]::NewGuid().ToString('N')) + try { + [IO.Directory]::CreateDirectory($root) | Out-Null + Set-CcodHarnessProcessFixture -ChatGPT @() + $probe = { param($Path) throw [IO.FileNotFoundException]::new('fixture missing',$Path) } + Assert-CcodThrows { + Get-CcodInstalledLifecycleOptionalDirectoryState -Path (Join-Path $root '.\missing') -GetItem $probe | Out-Null + } 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' + Assert-CcodThrows { + Get-CcodInstalledLifecycleFacts -InstallRoot (Join-Path $root '.') + } 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' + } finally { + Clear-CcodHarnessProcessFixture + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } +} + +Invoke-CcodTest 'installed lifecycle optional directory probe fails closed on access errors' { + . $harnessPath -Library + $probe = { param($Path) throw [UnauthorizedAccessException]::new('fixture access denied') } + Assert-CcodThrows { Get-CcodInstalledLifecycleOptionalDirectoryState -Path 'C:\fixture\blocked' -GetItem $probe | Out-Null } 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' +} + +Invoke-CcodTest 'installed lifecycle optional missing leaves reject a reparse parent' { + . $harnessPath -Library + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-integration-reparse-' + [guid]::NewGuid().ToString('N')) + $outside = Join-Path ([IO.Path]::GetTempPath()) ('ccod-integration-reparse-target-' + [guid]::NewGuid().ToString('N')) + $junction = Join-Path $root 'state-link' + try { + [IO.Directory]::CreateDirectory($root) | Out-Null + [IO.Directory]::CreateDirectory($outside) | Out-Null + New-Item -ItemType Junction -Path $junction -Target $outside | Out-Null + $probe = { param($Path) Get-Item -LiteralPath $Path -Force -ErrorAction Stop } + Assert-CcodThrows { Get-CcodInstalledLifecycleOptionalRegularFileState -Path (Join-Path $junction 'missing.json') -GetItem $probe | Out-Null } 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' + } finally { + if ([IO.Directory]::Exists($junction)) { [IO.Directory]::Delete($junction) } + if ([IO.Directory]::Exists($root)) { [IO.Directory]::Delete($root, $true) } + if ([IO.Directory]::Exists($outside)) { [IO.Directory]::Delete($outside, $true) } + } +} + +Invoke-CcodTest 'installed lifecycle default evidence writer cleans a failed readback' { + . $harnessPath -Library + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-evidence-readback-' + [guid]::NewGuid().ToString('N')) + $receipt = [pscustomobject][ordered]@{ schemaVersion = 1; transactionId = 'fixture-readback'; outcome = 'Completed' } + try { + [IO.Directory]::CreateDirectory($root) | Out-Null + $defaults = Get-CcodInstalledLifecycleDefaultAdapters + $directory = & $defaults.NewEvidenceDirectory $root 'fixture-readback' + $verifier = { param($Path) [IO.File]::WriteAllText($Path, 'tampered', [Text.UTF8Encoding]::new($false)); return $false } + Assert-CcodThrows { & $defaults.WriteEvidence $directory $receipt $verifier | Out-Null } 'CCOD_INTEGRATION_EVIDENCE_CLEANUP_FAILED' + Assert-CcodTrue ([IO.File]::Exists((Join-Path $directory 'receipt.json'))) 'unknown replacement evidence remains for manual cleanup' + } finally { + if ([IO.Directory]::Exists($root)) { [IO.Directory]::Delete($root, $true) } + } +} + +Invoke-CcodTest 'installed lifecycle rejects unreadable Supervisor and TrayHost identity fields' { + . $harnessPath -Library + Assert-CcodThrows { Assert-CcodInstalledLifecycleReadableProcessText -Value $null -Kind 'Supervisor CommandLine' } 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' + Assert-CcodThrows { Assert-CcodInstalledLifecycleReadableProcessText -Value ([pscustomobject]@{}) -Kind 'TrayHost ExecutablePath' } 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' +} + +Invoke-CcodTest 'installed lifecycle clean checkout includes ignored contamination' { + $raw = [IO.File]::ReadAllText($harnessPath, [Text.UTF8Encoding]::new($false)) + Assert-CcodTrue ($raw.Contains('--ignored=matching')) 'default Git status includes ignored contamination' +} + +Invoke-CcodTest 'installed lifecycle accepts schema-one legacy active json' { + . $harnessPath -Library + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-legacy-active-json-' + [guid]::NewGuid().ToString('N')) + try { + [IO.Directory]::CreateDirectory($root) | Out-Null + $legacyRuntime = '2.5.21-e3b0c44298fc1c14' + [IO.File]::WriteAllText((Join-Path $root 'active.json'), ([ordered]@{ schemaVersion = 1; activeRuntime = $legacyRuntime; previousRuntime = $null; updatedAtUtc = '2030-02-03T04:05:06.0000000Z' } | ConvertTo-Json -Compress), [Text.UTF8Encoding]::new($false)) + $active = Read-CcodInstalledLifecycleActiveFact -InstallRoot $root + Assert-CcodEqual $legacyRuntime $active.activeRuntime 'legacy active runtime id is retained' + Assert-CcodEqual ([UInt64]1) ([UInt64]$active.generation) 'schema-one active pointer maps to generation one' + } finally { if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } } +} + +Invoke-CcodTest 'installed lifecycle rejects an invalid UTC start clock before mutation' { + . $harnessPath -Library + $fixture = New-CcodHarnessFixture + try { + $calls = [Collections.Generic.List[string]]::new() + $adapters = New-CcodHarnessAdapters -Fixture $fixture -Calls $calls + $adapters.GetUtcNow = { [datetime]::SpecifyKind([datetime]::MinValue,[DateTimeKind]::Utc) } + Assert-CcodThrows { + Invoke-CcodInstalledLifecycleIntegration -InstallerPath $fixture.Installer -ExpectedVersion '2.5.0' -EvidenceRoot $fixture.EvidenceRoot -Scenario FreshRestart -AllowMachineMutation -AllowCodexRestart -Adapters $adapters + } 'CCOD_INTEGRATION_CLOCK_INVALID' + Assert-CcodTrue (-not ($calls -contains 'RunScenario')) 'invalid start clock cannot run a machine scenario' + } finally { + if (Test-Path -LiteralPath $fixture.Root) { Remove-Item -LiteralPath $fixture.Root -Recurse -Force } + } +} + +Invoke-CcodTest 'installed lifecycle rejects an invalid completion clock instead of substituting the current time' { + . $harnessPath -Library + $fixture = New-CcodHarnessFixture + try { + $calls = [Collections.Generic.List[string]]::new() + $adapters = New-CcodHarnessAdapters -Fixture $fixture -Calls $calls + $global:CcodLifecycleCompletionClockCalls = 0 + $adapters.GetUtcNow = { + $global:CcodLifecycleCompletionClockCalls++ + if ($global:CcodLifecycleCompletionClockCalls -eq 1) { return [datetime]::UtcNow } + return 'not-a-date' + } + Assert-CcodThrows { + Invoke-CcodInstalledLifecycleIntegration -InstallerPath $fixture.Installer -ExpectedVersion '2.5.0' -EvidenceRoot $fixture.EvidenceRoot -Scenario FreshRestart -AllowMachineMutation -AllowCodexRestart -Adapters $adapters + } 'CCOD_INTEGRATION_CLOCK_INVALID' + } finally { + Remove-Variable -Name CcodLifecycleCompletionClockCalls -Scope Global -ErrorAction SilentlyContinue + if (Test-Path -LiteralPath $fixture.Root) { Remove-Item -LiteralPath $fixture.Root -Recurse -Force } + } +} + +Invoke-CcodTest 'installed lifecycle rejects an unsafe schema-one active runtime before path use' { + . $harnessPath -Library + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-legacy-unsafe-active-json-' + [guid]::NewGuid().ToString('N')) + try { + [IO.Directory]::CreateDirectory($root) | Out-Null + $legacy = [ordered]@{ schemaVersion = 1; activeRuntime = '..'; previousRuntime = $null; updatedAtUtc = '2030-02-03T04:05:06.0000000Z' } + [IO.File]::WriteAllText((Join-Path $root 'active.json'), ($legacy | ConvertTo-Json -Compress), [Text.UTF8Encoding]::new($false)) + Assert-CcodThrows { Read-CcodInstalledLifecycleActiveFact -InstallRoot $root | Out-Null } 'CCOD_INTEGRATION_FACTS_INVALID' + } finally { if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } } +} + +Invoke-CcodTest 'installed lifecycle accepts the documented two-part v2.5.21 runtime identity' { + . $harnessPath -Library + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-legacy-runtime-' + [guid]::NewGuid().ToString('N')) + try { + $runtime = Join-Path $root 'runtime' + [IO.Directory]::CreateDirectory($runtime) | Out-Null + $payload = Join-Path $runtime 'payload.txt' + [IO.File]::WriteAllText($payload, 'legacy payload', [Text.UTF8Encoding]::new($false)) + $length = [int64](Get-Item -LiteralPath $payload).Length + $sha = Get-CcodInstalledLifecycleHash -Path $payload + $canonical = 'payload.txt`t{0}`t{1}' -f $length,$sha + $digestAlgorithm = [Security.Cryptography.SHA256]::Create() + try { $digest = ([BitConverter]::ToString($digestAlgorithm.ComputeHash([Text.Encoding]::UTF8.GetBytes($canonical)))).Replace('-','').ToLowerInvariant() } finally { $digestAlgorithm.Dispose() } + $runtimeId = '2.5.21-' + $digest.Substring(0,16) + $manifest = [pscustomobject][ordered]@{ schemaVersion = 1; projectVersion = '2.5.21'; runtimeId = $runtimeId; files = @([pscustomobject][ordered]@{ path = 'payload.txt'; length = $length; sha256 = $sha }) } + [IO.File]::WriteAllText((Join-Path $runtime 'manifest.json'), ($manifest | ConvertTo-Json -Depth 8), [Text.UTF8Encoding]::new($false)) + $result = Assert-CcodInstalledLifecycleRuntimeManifest -Manifest $manifest -ExpectedVersion '2.5.21' -ExpectedRuntimeId $runtimeId -RuntimeRoot $runtime + Assert-CcodEqual $runtimeId $result.runtimeId 'legacy two-part runtime identity is accepted only for v2.5.21' + } finally { if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } } +} + +Invoke-CcodTest 'installed lifecycle runtime manifest binds schema version project version and runtime id' { + . $harnessPath -Library + $manifest = [pscustomobject][ordered]@{ schemaVersion = 1; projectVersion = '2.5.21'; runtimeId = '2.5.22-aaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; files = @() } + Assert-CcodThrows { Assert-CcodInstalledLifecycleRuntimeManifest -Manifest $manifest -ExpectedVersion '2.5.22' -ExpectedRuntimeId $manifest.runtimeId } 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' + $mismatch = [pscustomobject][ordered]@{ schemaVersion = 1; projectVersion = '2.5.22'; runtimeId = '2.5.21-aaaaaaaaaaaaaaaa'; files = @() } + Assert-CcodThrows { Assert-CcodInstalledLifecycleRuntimeManifest -Manifest $mismatch -ExpectedVersion '2.5.22' -ExpectedRuntimeId $mismatch.runtimeId } 'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE' +} + Write-Host 'Installed lifecycle harness self-tests passed.' diff --git a/tests/persistence/LegacyReleaseFixture.ps1 b/tests/persistence/LegacyReleaseFixture.ps1 new file mode 100644 index 0000000..0de3a7a --- /dev/null +++ b/tests/persistence/LegacyReleaseFixture.ps1 @@ -0,0 +1,35 @@ +# Offline contract data only. Nothing in this fixture may be installed or executed. +function New-CcodLegacyReleaseFixture { + param([Parameter(Mandatory)][string]$Directory) + if (@(Get-ChildItem -LiteralPath $Directory -Force -ErrorAction Stop).Count -ne 0) { throw 'legacy fixture directory must be empty' } + $version='2.5.21';$commit='b'*40;$timestamp='2030-02-03T04:05:06.0000000Z' + $names=@('CodexRemote-fix-2.5.21-windows-x64.zip','CodexRemote-fix-2.5.21-windows-x64.zip.sha256.txt','CodexRemote-fix-2.5.21-trayhost-provenance.json','CodexRemote-fix-2.5.21-payload-manifest.json','CodexRemote-fix-2.5.21-release-manifest.json','CodexRemote-fix-2.5.21-setup.exe','CodexRemote-fix-2.5.21-setup.exe.sha256.txt','CodexRemote-fix-2.5.21-setup-release-manifest.json') + $work=Join-Path ([IO.Path]::GetTempPath()) ('ccod-legacy-contract-'+[guid]::NewGuid().ToString('N')) + $utf8=[Text.UTF8Encoding]::new($false) + try { + [IO.Directory]::CreateDirectory((Join-Path $work 'payload'))|Out-Null + [IO.File]::WriteAllText((Join-Path $work 'CodexRemote-fix.exe'),'inert legacy launcher fixture',$utf8) + [IO.File]::WriteAllText((Join-Path $work 'CodexRemote-fix.exe.config'),'',$utf8) + [IO.File]::WriteAllText((Join-Path $work 'Install-CodexRemote-fix.ps1'),'# inert legacy installer fixture',$utf8) + $payloadFile=Join-Path $work 'payload/fixture.txt' + [IO.File]::WriteAllText($payloadFile,'legacy payload fixture',$utf8) + $payload=[ordered]@{schemaVersion=1;product='CodexRemote-fix';version=$version;gitCommit=$commit;buildTimestampUtc=$timestamp;files=@([ordered]@{path='fixture.txt';length=[long](Get-Item $payloadFile).Length;sha256=Get-CcodTestFileSha256 $payloadFile})} + $payloadJson=$payload|ConvertTo-Json -Depth 8 + [IO.File]::WriteAllText((Join-Path $work 'payload-manifest.json'),$payloadJson,$utf8) + [IO.File]::WriteAllText((Join-Path $Directory $names[3]),$payloadJson,$utf8) + Add-Type -AssemblyName System.IO.Compression.FileSystem + [IO.Compression.ZipFile]::CreateFromDirectory($work,(Join-Path $Directory $names[0])) + [IO.File]::WriteAllText((Join-Path $Directory $names[1]),((Get-CcodTestFileSha256 (Join-Path $Directory $names[0]))+' *'+$names[0]),$utf8) + $tray=[ordered]@{schemaVersion=1;product='CodexRemote-fix';version=$version;gitCommit=$commit;buildTimestampUtc=$timestamp;targetFramework='net48';compiler=[ordered]@{name='csc.exe';sha256=('1'*64)};referenceRoot='locked-net48';sourceFiles=@([ordered]@{name='AssemblyInfo.cs';sha256=('2'*64)});iconSha256=('3'*64);manifestSha256=('4'*64);configSha256=('5'*64);artifactSha256=Get-CcodTestFileSha256 (Join-Path $work 'CodexRemote-fix.exe');configArtifactSha256=Get-CcodTestFileSha256 (Join-Path $work 'CodexRemote-fix.exe.config')} + [IO.File]::WriteAllText((Join-Path $Directory $names[2]),($tray|ConvertTo-Json -Depth 8),$utf8) + $portableAssets=@(foreach($name in $names[0..3]){[ordered]@{name=$name;sha256=Get-CcodTestFileSha256 (Join-Path $Directory $name)}}) + $portableAssets+=@(foreach($name in @('CodexRemote-fix.exe','CodexRemote-fix.exe.config')){[ordered]@{name=$name;sha256=Get-CcodTestFileSha256 (Join-Path $work $name)}}) + $portable=[ordered]@{schemaVersion=2;product='CodexRemote-fix';version=$version;gitCommit=$commit;buildTimestampUtc=$timestamp;distribution='portable-zip';assets=$portableAssets} + [IO.File]::WriteAllText((Join-Path $Directory $names[4]),($portable|ConvertTo-Json -Depth 8),$utf8) + [IO.File]::WriteAllText((Join-Path $Directory $names[5]),'inert legacy setup fixture',$utf8) + [IO.File]::WriteAllText((Join-Path $Directory $names[6]),((Get-CcodTestFileSha256 (Join-Path $Directory $names[5]))+' *'+$names[5]),$utf8) + $setup=[ordered]@{schemaVersion=1;product='CodexRemote-fix';version=$version;gitCommit=$commit;buildTimestampUtc=$timestamp;assets=@(foreach($name in @($names[5],$names[6],$names[2])){[ordered]@{name=$name;sha256=Get-CcodTestFileSha256 (Join-Path $Directory $name)}})} + [IO.File]::WriteAllText((Join-Path $Directory $names[7]),($setup|ConvertTo-Json -Depth 8),$utf8) + return [pscustomobject]@{Names=$names;GitCommit=$commit;SetupSha256=Get-CcodTestFileSha256 (Join-Path $Directory $names[5]);ManifestSha256=Get-CcodTestFileSha256 (Join-Path $Directory $names[7])} + } finally {if(Test-Path -LiteralPath $work){Remove-Item -LiteralPath $work -Recurse -Force}} +} diff --git a/tests/persistence/ManualWrappers.SelfTest.ps1 b/tests/persistence/ManualWrappers.SelfTest.ps1 index 2eab7da..2ea5537 100644 --- a/tests/persistence/ManualWrappers.SelfTest.ps1 +++ b/tests/persistence/ManualWrappers.SelfTest.ps1 @@ -257,7 +257,7 @@ Invoke-CcodTest 'public uninstall wrapper rejects retired direct options and del Assert-CcodTrue ($commands -ccontains 'Import-Module') 'Uninstall wrapper verifies the installed portable marker through its manifest-bound module' foreach($forbidden in @('Remove-Item','Move-Item','Copy-Item','Invoke-CcodUninstall')){Assert-CcodTrue ($commands -cnotcontains $forbidden) "Uninstall wrapper has no direct $forbidden path"} $source=Get-Content -LiteralPath $uninstallPath -Raw -Encoding UTF8 - foreach($required in @('portable-release.json','Assert-CcodPortableInstalledMarker','-Mode Prepare','PortableUninstallFinalizer.ps1')){ + foreach($required in @('portable-release.json','Assert-CcodPortableInstalledMarker','Invoke-CcodPublicUninstallPrepare','-PrepareMode Prepare -Identity $null','Invoke-CcodUninstallBootstrap -InstallerRoot $Runtime -InstallRoot $Root -Mode $PrepareMode','PortableUninstallFinalizer.ps1')){ Assert-CcodTrue ($source -cmatch [regex]::Escape($required)) "Uninstall wrapper requires $required for the portable finalization boundary" } foreach($argument in @(@{Name='KeepCurrentSpecialSession';Value=$true},@{Name='BackupDeviceKeyStore';Value=$true},@{Name='RemoveDeviceKeyStore';Value=$true})){ diff --git a/tests/persistence/OfficialDraftAcceptanceHarness.SelfTest.ps1 b/tests/persistence/OfficialDraftAcceptanceHarness.SelfTest.ps1 new file mode 100644 index 0000000..6d3d898 --- /dev/null +++ b/tests/persistence/OfficialDraftAcceptanceHarness.SelfTest.ps1 @@ -0,0 +1,3492 @@ +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'TestSupport.ps1') +Restore-CcodTestDesktopModulePath +. (Join-Path $PSScriptRoot 'LegacyReleaseFixture.ps1') + +$repositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +$modulePath = Join-Path $repositoryRoot 'tests\installed\OfficialDraftAcceptance.psm1' +$wrapperPath = Join-Path $repositoryRoot 'tests\installed\Invoke-OfficialDraftAcceptance.ps1' + +Invoke-CcodTest 'validation entrypoint includes the official draft acceptance harness' { + $validationPath = Join-Path $repositoryRoot 'tests\Validate.ps1' + Assert-CcodTrue (Test-Path -LiteralPath $validationPath -PathType Leaf) 'validation entrypoint exists' + $validation = [IO.File]::ReadAllText($validationPath, [Text.UTF8Encoding]::new($false)) + Assert-CcodTrue ($validation.Contains('OfficialDraftAcceptanceHarness.SelfTest.ps1')) 'validation runs the official draft acceptance harness' + Assert-CcodTrue ($validation.Contains('PSExecutionPolicyPreference')) 'acceptance validation clears inherited process policy for the child' + Assert-CcodTrue ($validation.Contains('tests\installed\OfficialDraftAcceptance.psm1')) 'validation requires the official draft acceptance module' +} + +Invoke-CcodTest 'legacy official manifest initial validation and persisted reload agree' { + $root=Join-Path ([IO.Path]::GetTempPath()) ('ccod-legacy-binding-'+[guid]::NewGuid().ToString('N')) + $module=Import-Module $modulePath -Force -PassThru -DisableNameChecking + try { + [IO.Directory]::CreateDirectory($root)|Out-Null + $fixture=New-CcodLegacyReleaseFixture -Directory $root + Assert-CcodEqual 8 @(Get-ChildItem -LiteralPath $root -File).Count 'fixture has the published legacy eight-file layout' + $assetModule=&$module {Get-CcodOfficialDraftAssetModule} + &$assetModule {param($Root)$path=Join-Path $Root 'CodexRemote-fix-2.5.21-release-manifest.json';$loaded=Read-CcodReleaseContractJson -Path $path -ErrorId 'LEGACY_FIXTURE';Test-CcodReleasePortableManifestDeep -Manifest $loaded.Value -ManifestRaw $loaded.Raw -ManifestPath $path -Directory $Root -Version '2.5.21' -ErrorId 'LEGACY_FIXTURE'|Out-Null} $root + $validate=&$module {(Get-CcodOfficialDraftDefaultAdapters).ValidatePreviousSetup} + $initial=&$validate $root '2.5.21' + Assert-CcodTrue $initial.Valid 'real default adapter accepts the actual legacy manifest schema' + Assert-CcodEqual $fixture.SetupSha256 $initial.InstallerSha256 'initial validation binds actual setup bytes' + $previous=[pscustomobject][ordered]@{version='2.5.21';gitCommit=$initial.GitCommit;assetSha256=$initial.InstallerSha256;manifestSha256=$initial.ManifestSha256} + Assert-CcodTrue (&$module {param($Value,$Root)Assert-CcodOfficialDraftPreviousSetupBinding -PreviousSetup $Value -PreviousAssetDirectory $Root} $previous $root) 'persisted binding accepts the same legacy contract' + foreach($name in $fixture.Names){ + $path=Join-Path $root $name;$bytes=[IO.File]::ReadAllBytes($path) + try { + [IO.File]::WriteAllText($path,'tampered legacy asset',[Text.UTF8Encoding]::new($false)) + Assert-CcodThrows {&$validate $root '2.5.21'|Out-Null} 'CCOD_ACCEPTANCE_PREVIOUS_ASSET_INVALID' + Assert-CcodThrows {&$module {param($Value,$Root)Assert-CcodOfficialDraftPreviousSetupBinding -PreviousSetup $Value -PreviousAssetDirectory $Root} $previous $root|Out-Null} 'CCOD_ACCEPTANCE_RECEIPT_INVALID' + } finally {[IO.File]::WriteAllBytes($path,$bytes)} + } + $extra=Join-Path $root 'extra.json' + try {[IO.File]::WriteAllText($extra,'{}');Assert-CcodThrows {&$validate $root '2.5.21'|Out-Null} 'CCOD_ACCEPTANCE_PREVIOUS_ASSET_INVALID'}finally{Remove-Item -LiteralPath $extra -Force} + Assert-CcodTrue (&$validate $root '2.5.21').Valid 'all failed validations release handles and allow a valid retry' + } finally {Remove-Module $module.Name -Force -ErrorAction SilentlyContinue;if(Test-Path -LiteralPath $root){Remove-Item -LiteralPath $root -Recurse -Force}} +} + +Invoke-CcodTest 'validation acceptance child preserves the normal effective execution policy' { + $validationPath = Join-Path $repositoryRoot 'tests\Validate.ps1' + $tokens = $null + $parseErrors = $null + $ast = [Management.Automation.Language.Parser]::ParseFile($validationPath, [ref]$tokens, [ref]$parseErrors) + Assert-CcodEqual 0 @($parseErrors).Count 'validation source parses before the real child invocation probe' + $blocks = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.IfStatementAst] -and + $node.Extent.Text.StartsWith('if (-not (Test-Path -LiteralPath $officialDraftAcceptanceSelfTest') + }, $true)) + Assert-CcodEqual 1 $blocks.Count 'exactly one acceptance validation block is exercised' + $markerFunctions = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq 'Get-CcodValidationSafeChildMarkers' + }, $true)) + Assert-CcodEqual 1 $markerFunctions.Count 'the real validation marker classifier is available' + $powershellExecutable = (Get-Command powershell.exe -ErrorAction Stop).Source + $hadPolicy = Test-Path -LiteralPath 'Env:PSExecutionPolicyPreference' + $previousPolicy = $env:PSExecutionPolicyPreference + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-acceptance-policy-' + [guid]::NewGuid().ToString('N')) + try { + [IO.Directory]::CreateDirectory($root) | Out-Null + $probe = Join-Path $root 'policy-probe.ps1' + [IO.File]::WriteAllText($probe, '[Console]::WriteLine("CCOD_POLICY_PROBE effective=" + (Get-ExecutionPolicy).ToString() + " process=" + (Get-ExecutionPolicy -Scope Process).ToString())', [Text.UTF8Encoding]::new($false)) + Remove-Item -LiteralPath 'Env:PSExecutionPolicyPreference' -ErrorAction SilentlyContinue + $baseline = @(& $powershellExecutable -NoProfile -Command 'Get-ExecutionPolicy; Get-ExecutionPolicy -Scope Process') + Assert-CcodEqual 0 $LASTEXITCODE 'normal child policy query succeeds without executing a script' + Assert-CcodEqual 2 $baseline.Count 'normal child reports effective and process policies' + Assert-CcodTrue ($baseline[0] -cmatch '^(Restricted|AllSigned|RemoteSigned|Unrestricted|Bypass)\z') 'baseline reports a supported effective policy' + Assert-CcodEqual 'Undefined' $baseline[1] 'baseline has no explicit or inherited process policy' + $normalPolicy = $baseline[0] + $expectedMarker = 'CCOD_POLICY_PROBE effective={0} process={1}' -f $normalPolicy,$baseline[1] + if ($hadPolicy) { $env:PSExecutionPolicyPreference = $previousPolicy } + $observed = & { + param($Probe, $ValidationBlock, $MarkerDefinition) + . $MarkerDefinition + $officialDraftAcceptanceSelfTest = $Probe + $failures = [Collections.Generic.List[string]]::new() + . $ValidationBlock + [pscustomobject]@{ + ExitCode = $officialDraftAcceptanceExitCode + Output = @($officialDraftAcceptanceOutput) + Failures = @($failures.ToArray()) + } + } $probe ([scriptblock]::Create($blocks[0].Extent.Text)) ([scriptblock]::Create($markerFunctions[0].Extent.Text)) + Assert-CcodEqual $hadPolicy (Test-Path -LiteralPath 'Env:PSExecutionPolicyPreference') 'validation restores parent process-policy presence' + if ($hadPolicy) { Assert-CcodEqual $previousPolicy $env:PSExecutionPolicyPreference 'validation restores parent process-policy value' } + if ($normalPolicy -in @('Restricted', 'AllSigned')) { + Assert-CcodTrue ($observed.ExitCode -ne 0) 'normal policy still rejects the unsigned local probe' + Assert-CcodEqual 1 $observed.Failures.Count 'normal policy rejection remains a validation failure' + Assert-CcodTrue (-not (@($observed.Output | ForEach-Object { [string]$_ }) -contains $expectedMarker)) 'blocked probe never executes' + } else { + Assert-CcodEqual 0 $observed.ExitCode 'actual acceptance runner loads a local script allowed by the normal policy' + Assert-CcodEqual 0 $observed.Failures.Count 'normal allowed execution has no validation failures' + Assert-CcodEqual $expectedMarker ($observed.Output -join '') 'actual runner preserves normal effective and process policy' + } + } finally { + if ($hadPolicy) { $env:PSExecutionPolicyPreference = $previousPolicy } + else { Remove-Item -LiteralPath 'Env:PSExecutionPolicyPreference' -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } +} + +function Invoke-CcodAcceptancePrivate { + param( + [Parameter(Mandatory)][string]$Phase, + [Parameter(Mandatory)][string]$AssetDirectory, + [Parameter(Mandatory)][string]$PreviousAssetDirectory, + [Parameter(Mandatory)][string]$EvidenceRoot, + [string]$DraftId = '123', + [hashtable]$Adapters, + [string]$TrayOperation, + [string]$RemoteOperation, + [string]$ScreenshotPath, + [string]$RedactedLogPath, + [string]$ReviewState, + [switch]$AllowMachineMutation, + [switch]$AllowCodexRestart, + [switch]$AllowWindowsReboot + ) + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + try { + return & $module { + param($P, $A, $Previous, $Evidence, $Draft, $Tray, $Remote, $Screenshot, $Log, $Review, $Mutation, $Restart, $Reboot, $PrivateAdapters) + Invoke-CcodOfficialDraftAcceptanceCore ` + -Phase $P ` + -AssetDirectory $A ` + -PreviousAssetDirectory $Previous ` + -EvidenceRoot $Evidence ` + -DraftId $Draft ` + -TrayOperation $Tray ` + -RemoteOperation $Remote ` + -ScreenshotPath $Screenshot ` + -RedactedLogPath $Log ` + -ReviewState $Review ` + -AllowMachineMutation:$Mutation ` + -AllowCodexRestart:$Restart ` + -AllowWindowsReboot:$Reboot ` + -Adapters $PrivateAdapters + } $Phase $AssetDirectory $PreviousAssetDirectory $EvidenceRoot $DraftId $TrayOperation $RemoteOperation $ScreenshotPath $RedactedLogPath $ReviewState $AllowMachineMutation $AllowCodexRestart $AllowWindowsReboot $Adapters + } finally { + if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + } +} + +function New-CcodAcceptanceCandidateFixture { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-acceptance-candidate-' + [guid]::NewGuid().ToString('N')) + [IO.Directory]::CreateDirectory($root) | Out-Null + $version = '2.5.22' + $commit = 'c' * 40 + $names = @( + "CodexRemote-fix-$version-windows-x64.zip", + "CodexRemote-fix-$version-windows-x64.zip.sha256.txt", + "CodexRemote-fix-$version-trayhost-provenance.json", + "CodexRemote-fix-$version-payload-manifest.json", + "CodexRemote-fix-$version-release-manifest.json", + "CodexRemote-fix-$version-setup.exe", + "CodexRemote-fix-$version-setup.exe.sha256.txt", + "CodexRemote-fix-$version-setup-provenance.json", + "CodexRemote-fix-$version-setup-payload-manifest.json", + "CodexRemote-fix-$version-setup-destination-inventory.iss", + "CodexRemote-fix-$version-setup-release-manifest.json" + ) + foreach ($name in $names) { + [IO.File]::WriteAllBytes((Join-Path $root $name), [Text.Encoding]::UTF8.GetBytes("fixture:$name")) + } + $assets = @($names | ForEach-Object { + [pscustomobject][ordered]@{ name = $_; sha256 = Get-CcodTestFileSha256 -Path (Join-Path $root $_) } + }) + return [pscustomobject][ordered]@{ + Root = $root + Version = $version + GitCommit = $commit + Names = $names + Contract = [pscustomobject][ordered]@{ + Valid = $true + Version = $version + GitCommit = $commit + Assets = $assets + SetupManifestSha256 = $assets[10].sha256 + PortableManifestSha256 = $assets[4].sha256 + } + } +} + +function New-CcodAcceptanceDefenderReceipt { + param( + [Parameter(Mandatory)]$Fixture, + [Parameter(Mandatory)][ValidateSet('Setup','PortableZip')][string]$AssetType + ) + $setup = $AssetType -ceq 'Setup' + $assetName = if ($setup) { $Fixture.Names[5] } else { $Fixture.Names[0] } + $checksumName = if ($setup) { $Fixture.Names[6] } else { $Fixture.Names[1] } + $manifestName = if ($setup) { $Fixture.Names[10] } else { $Fixture.Names[4] } + $assetHash = [string](($Fixture.Contract.Assets | Where-Object { $_.name -ceq $assetName }).sha256) + $checksumHash = [string](($Fixture.Contract.Assets | Where-Object { $_.name -ceq $checksumName }).sha256) + $manifestHash = [string](($Fixture.Contract.Assets | Where-Object { $_.name -ceq $manifestName }).sha256) + return [pscustomobject][ordered]@{ + schemaVersion = 2 + assetType = $AssetType + assetName = $assetName + assetSha256 = $assetHash + checksumName = $checksumName + checksumSha256 = $checksumHash + manifestName = $manifestName + manifestSha256 = $manifestHash + version = $Fixture.Version + gitCommit = $Fixture.GitCommit + origin = 'InternetDownload' + workflowArtifactIdentity = $null + zoneId = 3 + defenderServiceEnabled = $true + antivirusEnabled = $true + realTimeProtectionEnabled = $true + defenderPlatformVersion = '4.18.26070.1' + defenderEngineVersion = '1.1.26070.1' + signatureVersion = '1.999.1.0' + signatureUpdatedAtUtc = '2030-02-03T02:05:06.0000000Z' + scanStartedAtUtc = '2030-02-03T04:05:06.0000000Z' + scanCompletedAtUtc = '2030-02-03T04:05:07.0000000Z' + detectionCount = 0 + outcome = 'Completed' + errorCode = $null + } +} + +function New-CcodAcceptanceFacts { + param( + [switch]$Absent, + [string]$KeyHash = ('b' * 64), + [string]$BootId = 'boot-1', + [string]$RuntimeId = 'runtime-2.5.22', + [UInt64]$Generation = 2, + [string]$Version = '2.5.22' + ) + if ($Absent) { + return [pscustomobject][ordered]@{ + installRootPresent = $false; appRootPresent = $false; runtimeRootPresent = $false; activePointerPresent = $false + activeRuntimeId = $null; activeGeneration = $null; runtimeManifestSha256 = $null + supervisor = @(); trayHost = @(); trayHostIdentity = $null; trayAuthenticated = $false; codex = @() + taskState = 'Absent'; statusPhase = 'Unavailable'; statusRuntimeId = $null; statusCodex = $null; codexCount = 0 + transitionStage = 'Idle'; lifecycleReceipt = $null + debugPorts = @() + shortcuts = [pscustomobject][ordered]@{ startMenu = $false; desktop = $false } + debugEndpoints = @(); protectionReady = $false; protectionRecovered = $false + deviceKeyPresent = $true; deviceKeySha256 = $KeyHash; bootId = $BootId + privatePath = 'C:\\Users\\fixture\\private'; token = 'fixture-token'; rawLog = 'fixture raw log' + } + } + return [pscustomobject][ordered]@{ + installRootPresent = $true; appRootPresent = $true; runtimeRootPresent = $true; activePointerPresent = $true; installReady = $true + activeRuntimeId = $RuntimeId; activeGeneration = $Generation; runtimeManifestSha256 = ('d' * 64) + supervisor = @([pscustomobject]@{ pid = 200; creationTimeUtc = '2030-02-03T04:05:00.0000000Z' }) + trayHost = @([pscustomobject]@{ pid = 201; creationTimeUtc = '2030-02-03T04:05:01.0000000Z' }) + trayHostIdentity = [pscustomobject][ordered]@{ pid = 201; creationTimeUtc = '2030-02-03T04:05:01.0000000Z' } + trayAuthenticated = $true; codex = @([pscustomobject]@{ pid = 202; creationTimeUtc = '2030-02-03T04:05:02.0000000Z' }); codexCount = 1 + taskState = 'Ready'; statusPhase = 'Active'; statusRuntimeId = $RuntimeId; aboutVersion = $Version + statusCodex = [pscustomobject][ordered]@{ pid = 202; creationTimeUtc = '2030-02-03T04:05:02.0000000Z' } + transitionStage = 'Idle' + lifecycleReceipt = [pscustomobject][ordered]@{ kind = 'RestartAndRepair'; origin = 'Installer'; runtimeId = $RuntimeId; runtimeGeneration = $Generation; phase = 'Completed' } + shortcuts = [pscustomobject][ordered]@{ startMenu = $true; desktop = $true } + debugEndpoints = @( + [pscustomobject][ordered]@{ localAddress = '127.0.0.1'; localPort = 9229; owningProcess = 202; owningProcessCreationTimeUtc = '2030-02-03T04:05:02.0000000Z' } + [pscustomobject][ordered]@{ localAddress = '127.0.0.1'; localPort = 9230; owningProcess = 202; owningProcessCreationTimeUtc = '2030-02-03T04:05:02.0000000Z' } + ) + debugPorts = @(9229,9230) + protectionReady = $true; protectionRecovered = $true + deviceKeyPresent = $true; deviceKeySha256 = $KeyHash; bootId = $BootId + privatePath = 'C:\\Users\\fixture\\private'; token = 'fixture-token'; rawLog = 'fixture raw log' + } +} + +function New-CcodAcceptanceRawFacts { + param([switch]$Absent) + $facts = New-CcodAcceptanceFacts -Absent:$Absent + $facts | Add-Member -NotePropertyName appPresent -NotePropertyValue ([bool]$facts.appRootPresent) + if ($Absent) { $facts | Add-Member -NotePropertyName aboutVersion -NotePropertyValue $null } + $facts.PSObject.Properties.Remove('appRootPresent') + $facts.PSObject.Properties.Remove('codexCount') + foreach ($name in @('supervisor','trayHost','codex')) { + $facts.$name = @($facts.$name | ForEach-Object { [pscustomobject]@{ Pid = [uint32]$_.pid; CreationTimeUtc = [string]$_.creationTimeUtc } }) + } + if ($null -ne $facts.statusCodex) { $facts.statusCodex = [pscustomobject]@{ Pid = [uint32]$facts.statusCodex.pid; CreationTimeUtc = [string]$facts.statusCodex.creationTimeUtc } } + return $facts +} + +function New-CcodAcceptancePhaseAdapters { + param( + [Parameter(Mandatory)]$Fixture, + [Parameter(Mandatory)]$State, + [switch]$ReturnAbsentAfterUninstall, + [switch]$LeaveInstallRootAfterUninstall + ) + $previousContractPath=Join-Path $repositoryRoot 'tests/installed/OfficialDraftAcceptance.psm1' + $previousFixtureWriter=${function:New-CcodLegacyReleaseFixture} + return @{ + ValidateAssetSet = { + param($Path, $Version) + return $Fixture.Contract + }.GetNewClosure() + ValidatePreviousSetup = { + param($Path, $Version) + if ($null -ne $State.PreviousCalls) { $State.PreviousCalls++ } + try { + if ($Version -cne '2.5.21') { throw 'previous version request' } + $directory = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if ($directory -isnot [IO.DirectoryInfo] -or ($directory.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'previous directory' } + $setupPath = Join-Path $directory.FullName 'CodexRemote-fix-2.5.21-setup.exe' + $manifestPath = Join-Path $directory.FullName 'CodexRemote-fix-2.5.21-setup-release-manifest.json' + $seed = $true + if ($null -ne $State.PSObject.Properties['SeedPreviousSetup']) { + if ($State.SeedPreviousSetup -isnot [bool]) { throw 'seed flag' } + $seed = [bool]$State.SeedPreviousSetup + } + if ($seed -and -not [IO.File]::Exists($setupPath) -and -not [IO.File]::Exists($manifestPath)) { + &$previousFixtureWriter -Directory $directory.FullName|Out-Null + } + $previousModule=Import-Module $previousContractPath -PassThru -DisableNameChecking + return &$previousModule {param($Root,$Version)Get-CcodOfficialDraftPreviousSetupContract -Directory $Root -Version $Version} $directory.FullName $Version + } catch { throw 'previous setup fixture validation failed' } + }.GetNewClosure() + GetDraftIdentity = { + param($Path, $Candidate, $ExpectedDraftId) + return [pscustomobject][ordered]@{ tag = 'v2.5.22'; id = [string]$ExpectedDraftId } + }.GetNewClosure() + InvokeDefenderCheck = { + param($Path, $AssetType, $Origin, $Version, $GitCommit, $EvidencePath) + $State.DefenderCalls.Add(('{0}:{1}' -f $AssetType, $Origin)) + return New-CcodAcceptanceDefenderReceipt -Fixture $Fixture -AssetType $AssetType + }.GetNewClosure() + CaptureFacts = { + param($Context, $Point) + $State.CaptureCalls.Add(('{0}:{1}' -f $Context.phase, $Point)) + if ($null -ne $State.PSObject.Properties['Events']) { $State.Events.Add(('Capture:{0}:{1}' -f $Context.phase, $Point)) } + if ($Context.phase -ceq 'Uninstall' -and $Point -ceq 'After' -and $ReturnAbsentAfterUninstall) { + $absentFacts = New-CcodAcceptanceFacts -Absent -KeyHash $State.KeyHash -BootId $State.BootId + if ($LeaveInstallRootAfterUninstall) { $absentFacts.installRootPresent = $true } + return $absentFacts + } + if ($Context.phase -ceq 'LegacyUpgrade' -and $Point -ceq 'Before') { + return New-CcodAcceptanceFacts -RuntimeId '2.5.21-legacy-runtime' -Generation 1 -Version '2.5.21' -KeyHash $State.KeyHash -BootId $State.BootId + } + return $State.Facts + }.GetNewClosure() + RunPhase = { + param($Context) + $State.RunCalls.Add([string]$Context.phase) + if ($null -ne $State.PSObject.Properties['Events']) { $State.Events.Add(('Run:{0}' -f $Context.phase)) } + $setupName = [IO.Path]::GetFileName([string]$Context.setupPath) + $setupAsset = @($Fixture.Contract.Assets | Where-Object { $_.name -ceq $setupName }) + [pscustomobject][ordered]@{ completed = $true; outcome = 'Completed'; installerSha256 = [string]$setupAsset[0].sha256; runtimeManifestSha256 = [string]$State.Facts.runtimeManifestSha256 } + }.GetNewClosure() + RunManualOperation = { + param($Context,$Operation) + $states = @{ About = 'AboutVisible'; Language = 'LanguageChanged'; OpenLogs = 'LogsOpened'; Repair = 'RepairCompleted'; SecondDeviceControl = 'SecondDeviceControlled' } + $codes = @{ About = 'CCOD_TRAYABOUT_COMPLETED'; Language = 'CCOD_TRAYLANGUAGE_COMPLETED'; OpenLogs = 'CCOD_TRAYOPENLOGS_COMPLETED'; Repair = 'CCOD_TRAYREPAIR_COMPLETED'; SecondDeviceControl = 'CCOD_SECONDDEVICE_COMPLETED' } + $timestamp = '2030-02-03T04:05:06.0000000Z' + $proof = $null + if ([string]$Operation -ceq 'SecondDeviceControl') { + $proof = [pscustomobject][ordered]@{ schemaVersion = 1; timestampUtc = $timestamp; kind = 'remote-control-manual-proof'; operation = 'SecondDeviceControl'; deviceRole = 'SecondDevice'; challenge = [string]$Context.manualChallenge; candidateVersion = [string]$Context.expectedVersion; runtimeId = [string]$State.Facts.activeRuntimeId; runtimeGeneration = [UInt64]$State.Facts.activeGeneration; runtimeManifestSha256 = [string]$State.Facts.runtimeManifestSha256; attestation = 'HumanReviewedStructuredAttestation'; connection = 'Connected'; control = 'Completed'; outcome = 'Completed'; code = 'CCOD_REMOTE_ACTION_COMPLETED' } + } else { + $commands = @{ About = 'ShowAbout'; Language = 'SetLanguageEnglish'; OpenLogs = 'OpenLogs'; Repair = 'CheckAndRepair' } + $proof = [pscustomobject][ordered]@{ timestampUtc = $timestamp; command = [string]$commands[$Operation]; revision = [UInt64]1; code = 'CCOD_TRAY_ACTION_COMPLETED'; status = 'Completed' } + } + return [pscustomobject][ordered]@{ verified = $true; operation = [string]$Operation; terminalState = [string]$states[$Operation]; code = [string]$codes[$Operation]; proof = $proof; screenshotSha256 = Get-CcodTestFileSha256 -Path $Context.screenshotPath; redactedLogSha256 = Get-CcodTestFileSha256 -Path $Context.redactedLogPath } + }.GetNewClosure() + GetBootIdentity = { + $State.BootCalls++ + return [string]$State.BootId + }.GetNewClosure() + Reboot = { + $State.RebootCalls++ + return $true + }.GetNewClosure() + GetUtcNow = { + return [datetime]::Parse('2030-02-03T04:05:06Z').ToUniversalTime() + }.GetNewClosure() + } +} + +function Invoke-CcodAcceptanceThroughReady { + param( + [Parameter(Mandatory)]$Fixture, + [Parameter(Mandatory)]$PreviousRoot, + [Parameter(Mandatory)]$EvidenceRoot, + [Parameter(Mandatory)]$Adapters, + [Parameter(Mandatory)]$State + ) + Invoke-CcodAcceptancePrivate -Phase Preflight -AssetDirectory $Fixture.Root -PreviousAssetDirectory $PreviousRoot -EvidenceRoot $EvidenceRoot -Adapters $Adapters | Out-Null + Invoke-CcodAcceptancePrivate -Phase LegacyUpgrade -AssetDirectory $Fixture.Root -PreviousAssetDirectory $PreviousRoot -EvidenceRoot $EvidenceRoot -Adapters $Adapters -AllowMachineMutation -AllowCodexRestart | Out-Null + Invoke-CcodAcceptancePrivate -Phase Uninstall -AssetDirectory $Fixture.Root -PreviousAssetDirectory $PreviousRoot -EvidenceRoot $EvidenceRoot -Adapters $Adapters -AllowMachineMutation -AllowCodexRestart | Out-Null + Invoke-CcodAcceptancePrivate -Phase FreshInstall -AssetDirectory $Fixture.Root -PreviousAssetDirectory $PreviousRoot -EvidenceRoot $EvidenceRoot -Adapters $Adapters -AllowMachineMutation -AllowCodexRestart | Out-Null + Invoke-CcodAcceptancePrivate -Phase PreReboot -AssetDirectory $Fixture.Root -PreviousAssetDirectory $PreviousRoot -EvidenceRoot $EvidenceRoot -Adapters $Adapters -AllowMachineMutation -AllowCodexRestart -AllowWindowsReboot | Out-Null + $State.BootId = 'boot-2' + $State.Facts = New-CcodAcceptanceFacts -BootId 'boot-2' + Invoke-CcodAcceptancePrivate -Phase PostReboot -AssetDirectory $Fixture.Root -PreviousAssetDirectory $PreviousRoot -EvidenceRoot $EvidenceRoot -Adapters $Adapters -AllowMachineMutation -AllowCodexRestart -AllowWindowsReboot | Out-Null + Invoke-CcodAcceptancePrivate -Phase ReadyForManualEvidence -AssetDirectory $Fixture.Root -PreviousAssetDirectory $PreviousRoot -EvidenceRoot $EvidenceRoot -Adapters $Adapters | Out-Null +} + +function New-CcodAcceptanceManualInputFiles { + param([Parameter(Mandatory)][string]$Root) + [IO.Directory]::CreateDirectory($Root) | Out-Null + $paths = @{} + foreach ($operation in @('About','Language','OpenLogs','Repair','SecondDeviceControl')) { + $paths[$operation] = [pscustomobject][ordered]@{ + Screenshot = Join-Path $Root ($operation + '-screenshot.png') + Log = Join-Path $Root ($operation + '-redacted.log') + } + [IO.File]::WriteAllText($paths[$operation].Screenshot, 'screenshot-' + $operation, [Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($paths[$operation].Log, 'redacted-log-' + $operation, [Text.UTF8Encoding]::new($false)) + } + return $paths +} + +function Invoke-CcodAcceptanceManualRecords { + param( + [Parameter(Mandatory)]$Fixture, + [Parameter(Mandatory)]$PreviousRoot, + [Parameter(Mandatory)]$EvidenceRoot, + [Parameter(Mandatory)]$Adapters, + [Parameter(Mandatory)]$Paths + ) + foreach ($operation in @('About','Language','OpenLogs','Repair')) { + Invoke-CcodAcceptancePrivate -Phase TrayEvidence -TrayOperation $operation -ScreenshotPath $Paths[$operation].Screenshot -RedactedLogPath $Paths[$operation].Log -ReviewState Reviewed -AssetDirectory $Fixture.Root -PreviousAssetDirectory $PreviousRoot -EvidenceRoot $EvidenceRoot -Adapters $Adapters | Out-Null + } + Invoke-CcodAcceptancePrivate -Phase RemoteEvidence -RemoteOperation SecondDeviceControl -ScreenshotPath $Paths.SecondDeviceControl.Screenshot -RedactedLogPath $Paths.SecondDeviceControl.Log -ReviewState Reviewed -AssetDirectory $Fixture.Root -PreviousAssetDirectory $PreviousRoot -EvidenceRoot $EvidenceRoot -Adapters $Adapters | Out-Null +} + +try { + Invoke-CcodTest 'official draft acceptance exposes the required public module and wrapper boundary' { + Assert-CcodTrue (Test-Path -LiteralPath $modulePath -PathType Leaf) 'official-draft acceptance module exists' + Assert-CcodTrue (Test-Path -LiteralPath $wrapperPath -PathType Leaf) 'official-draft acceptance wrapper exists' + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + try { + Assert-CcodEqual 'Invoke-CcodOfficialDraftAcceptance' ((@($module.ExportedCommands.Keys | Sort-Object) -join ',')) 'module exports only the official-draft acceptance command' + $command = Get-Command Invoke-CcodOfficialDraftAcceptance -Module $module.Name -ErrorAction Stop + Assert-CcodTrue (-not $command.Parameters.ContainsKey('Adapters')) 'public command does not expose test adapters' + Assert-CcodTrue ($command.Parameters.ContainsKey('Phase')) 'public command exposes phase' + Assert-CcodTrue ($command.Parameters.ContainsKey('AssetDirectory')) 'public command exposes candidate asset directory' + Assert-CcodTrue ($command.Parameters.ContainsKey('PreviousAssetDirectory')) 'public command exposes previous asset directory' + Assert-CcodTrue ($command.Parameters.ContainsKey('EvidenceRoot')) 'public command exposes evidence root' + Assert-CcodTrue ($command.Parameters.ContainsKey('DraftId')) 'public command exposes the official draft identifier' + Assert-CcodTrue ($command.Parameters.ContainsKey('AllowMachineMutation')) 'public command exposes machine mutation authorization' + Assert-CcodTrue ($command.Parameters.ContainsKey('AllowCodexRestart')) 'public command exposes Codex restart authorization' + Assert-CcodTrue ($command.Parameters.ContainsKey('AllowWindowsReboot')) 'public command exposes Windows reboot authorization' + Assert-CcodTrue ($command.Parameters.ContainsKey('TrayOperation')) 'public command exposes bounded tray operation' + Assert-CcodTrue ($command.Parameters.ContainsKey('RemoteOperation')) 'public command exposes bounded remote operation' + Assert-CcodTrue ($command.Parameters.ContainsKey('ScreenshotPath')) 'public command exposes screenshot evidence path' + Assert-CcodTrue ($command.Parameters.ContainsKey('RedactedLogPath')) 'public command exposes redacted log evidence path' + Assert-CcodTrue ($command.Parameters.ContainsKey('ReviewState')) 'public command exposes manual evidence review state' + } finally { + if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + } + } + + Invoke-CcodTest 'official draft acceptance rejects a missing evidence root before adapter work' { + $assetRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-acceptance-assets-' + [guid]::NewGuid().ToString('N')) + $previousRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-acceptance-previous-' + [guid]::NewGuid().ToString('N')) + $evidenceRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-acceptance-missing-' + [guid]::NewGuid().ToString('N')) + try { + [IO.Directory]::CreateDirectory($assetRoot) | Out-Null + [IO.Directory]::CreateDirectory($previousRoot) | Out-Null + Assert-CcodThrows { + Invoke-CcodAcceptancePrivate -Phase Preflight -AssetDirectory $assetRoot -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot + } 'CCOD_ACCEPTANCE_EVIDENCE_ROOT_INVALID' + } finally { + foreach ($path in @($assetRoot, $previousRoot, $evidenceRoot)) { + if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force } + } + } + } + + Invoke-CcodTest 'official draft public wrapper runs in a fresh process and fails closed' { + $assetRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-acceptance-wrapper-assets-' + [guid]::NewGuid().ToString('N')) + $previousRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-acceptance-wrapper-previous-' + [guid]::NewGuid().ToString('N')) + $evidenceRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-acceptance-wrapper-missing-' + [guid]::NewGuid().ToString('N')) + [IO.Directory]::CreateDirectory($assetRoot) | Out-Null + [IO.Directory]::CreateDirectory($previousRoot) | Out-Null + try { + $stderrPath = Join-Path $assetRoot 'wrapper.stderr.log' + $stdoutPath = Join-Path $assetRoot 'wrapper.stdout.log' + $child = Start-Process -FilePath 'powershell.exe' -ArgumentList @('-NoLogo','-NoProfile','-File',$wrapperPath,'-Phase','Preflight','-AssetDirectory',$assetRoot,'-PreviousAssetDirectory',$previousRoot,'-EvidenceRoot',$evidenceRoot) -RedirectStandardOutput $stdoutPath -RedirectStandardError $stderrPath -Wait -PassThru -WindowStyle Hidden + $exitCode = $child.ExitCode + $stdout = if (Test-Path -LiteralPath $stdoutPath -PathType Leaf) { [IO.File]::ReadAllText($stdoutPath, [Text.UTF8Encoding]::new($false, $true)) } else { '' } + $stderrBytes = if (Test-Path -LiteralPath $stderrPath -PathType Leaf) { [IO.File]::ReadAllBytes($stderrPath) } else { [byte[]]@() } + $stderr = ([Text.Encoding]::ASCII.GetString($stderrBytes) + "`n" + [Text.Encoding]::Unicode.GetString($stderrBytes)) + $joined = $stdout + "`n" + $stderr + Assert-CcodEqual 1 $exitCode 'public wrapper exits nonzero for a missing evidence root' + Assert-CcodTrue ($joined.Contains('CCOD_ACCEPTANCE_EVIDENCE_ROOT_INVALID')) 'public wrapper preserves the stable fail-closed error code' + $invalidStderrPath = Join-Path $assetRoot 'wrapper-invalid-phase.stderr.log' + $invalidStdoutPath = Join-Path $assetRoot 'wrapper-invalid-phase.stdout.log' + $invalidChild = Start-Process -FilePath 'powershell.exe' -ArgumentList @('-NoLogo','-NoProfile','-File',$wrapperPath,'-Phase','NotARealPhase','-AssetDirectory',$assetRoot,'-PreviousAssetDirectory',$previousRoot,'-EvidenceRoot',$evidenceRoot) -RedirectStandardOutput $invalidStdoutPath -RedirectStandardError $invalidStderrPath -Wait -PassThru -WindowStyle Hidden + $invalidBytes = if (Test-Path -LiteralPath $invalidStderrPath -PathType Leaf) { [IO.File]::ReadAllBytes($invalidStderrPath) } else { [byte[]]@() } + $invalidOutput = ([Text.Encoding]::ASCII.GetString($invalidBytes) + "`n" + [Text.Encoding]::Unicode.GetString($invalidBytes)) + Assert-CcodEqual 1 $invalidChild.ExitCode 'public wrapper exits nonzero for an invalid phase' + Assert-CcodTrue ($invalidOutput.Contains('CCOD_ACCEPTANCE_PHASE_INVALID')) 'public wrapper converts binder-level phase rejection into a stable error code' + } finally { + foreach ($path in @($assetRoot, $previousRoot, $evidenceRoot)) { + if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force -ErrorAction SilentlyContinue } + } + } + } + + Invoke-CcodTest 'official draft preflight scans exactly two downloaded assets and persists a bound receipt' { + $fixture = New-CcodAcceptanceCandidateFixture + $previousRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-acceptance-previous-' + [guid]::NewGuid().ToString('N')) + $evidenceRoot = Join-Path $fixture.Root 'evidence' + [IO.Directory]::CreateDirectory($previousRoot) | Out-Null + [IO.Directory]::CreateDirectory($evidenceRoot) | Out-Null + $calls = [Collections.Generic.List[string]]::new() + $defenderArguments = [Collections.Generic.List[object]]::new() + try { + $adapters = @{ + ValidateAssetSet = { + param($Path, $Version) + $calls.Add(('ValidateAssetSet:{0}:{1}' -f $Path, $Version)) + return $fixture.Contract + }.GetNewClosure() + GetDraftIdentity = { + param($Path, $Candidate) + $calls.Add('GetDraftIdentity') + return [pscustomobject][ordered]@{ tag = 'v2.5.22'; id = '123' } + }.GetNewClosure() + InvokeDefenderCheck = { + param($Path, $AssetType, $Origin, $Version, $GitCommit, $EvidencePath) + $calls.Add(('Defender:{0}:{1}' -f $AssetType, $Origin)) + $assetIndex = if ($AssetType -ceq 'Setup') { 5 } else { 0 } + $defenderArguments.Add([pscustomobject][ordered]@{ + AssetType = $AssetType + Origin = $Origin + Version = $Version + GitCommit = $GitCommit + CandidatePath = [IO.Path]::GetFullPath((Join-Path $Path $fixture.Names[$assetIndex])) + EvidencePath = [IO.Path]::GetFullPath($EvidencePath) + }) + return New-CcodAcceptanceDefenderReceipt -Fixture $fixture -AssetType $AssetType + }.GetNewClosure() + GetUtcNow = { + return [datetime]::Parse('2030-02-03T04:05:06Z').ToUniversalTime() + }.GetNewClosure() + } + $result = Invoke-CcodAcceptancePrivate -Phase Preflight -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters + Assert-CcodEqual 'Preflight' ([string]$result.phase) 'preflight returns its canonical phase' + Assert-CcodEqual 'Completed' ([string]$result.outcome) 'preflight returns a completed outcome' + $defenderCalls = @($calls | Where-Object { $_ -like 'Defender:*' }) + Assert-CcodEqual 2 $defenderCalls.Count 'preflight invokes Defender exactly twice' + Assert-CcodEqual 'Defender:Setup:InternetDownload' $defenderCalls[0] 'Setup is scanned first as an InternetDownload' + Assert-CcodEqual 'Defender:PortableZip:InternetDownload' $defenderCalls[1] 'portable ZIP is scanned second as an InternetDownload' + $stateDirectory = Join-Path $evidenceRoot 'official-draft-acceptance' + $expectedAssetIndexes = @(5,0) + for ($index = 0; $index -lt 2; $index++) { + $arguments = $defenderArguments[$index] + Assert-CcodEqual @('Setup','PortableZip')[$index] ([string]$arguments.AssetType) 'Defender callback receives the ordered asset type' + Assert-CcodEqual 'InternetDownload' ([string]$arguments.Origin) 'Defender callback receives the download origin' + Assert-CcodEqual $fixture.Version ([string]$arguments.Version) 'Defender callback receives the candidate version' + Assert-CcodEqual $fixture.Contract.GitCommit ([string]$arguments.GitCommit) 'Defender callback receives the candidate commit' + Assert-CcodEqual ([IO.Path]::GetFullPath((Join-Path $fixture.Root $fixture.Names[$expectedAssetIndexes[$index]]))) ([string]$arguments.CandidatePath) 'Defender callback receives the expected candidate asset' + Assert-CcodTrue (([string]$arguments.EvidencePath).StartsWith([IO.Path]::GetFullPath($stateDirectory) + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) 'Defender callback evidence is inside the acceptance state directory' + Assert-CcodTrue ([IO.Path]::GetFileName([string]$arguments.EvidencePath) -cmatch '^\.defender-(Setup|PortableZip)-[0-9a-f]{32}\.json$') 'Defender callback evidence uses a bounded pending receipt name' + } + $preflightPath = Join-Path $stateDirectory '01-Preflight.json' + Assert-CcodTrue (Test-Path -LiteralPath $preflightPath -PathType Leaf) 'preflight writes one durable state receipt' + $serialized = [IO.File]::ReadAllText($preflightPath) + foreach ($forbidden in @('C:\\Users', 'private', 'token', 'account', 'raw log')) { + Assert-CcodTrue (-not $serialized.Contains($forbidden)) "preflight receipt omits $forbidden data" + } + $receipt = $serialized | ConvertFrom-Json + Assert-CcodEqual 'Preflight' ([string]$receipt.phase) 'durable receipt records the exact phase' + Assert-CcodEqual 'v2.5.22' ([string]$receipt.draft.tag) 'durable receipt records the draft tag' + Assert-CcodEqual '123' ([string]$receipt.draft.id) 'durable receipt records the bounded draft id' + Assert-CcodEqual 2 @($receipt.facts.defenderReceipts).Count 'durable receipt retains both Defender receipts' + for ($index = 0; $index -lt 2; $index++) { + $assetName = $fixture.Names[$expectedAssetIndexes[$index]] + $expectedHash = [string](($fixture.Contract.Assets | Where-Object { $_.name -ceq $assetName }).sha256) + Assert-CcodEqual $assetName ([string]$receipt.facts.defenderReceipts[$index].assetName) 'durable Defender receipts preserve asset order' + Assert-CcodEqual $expectedHash ([string]$receipt.facts.defenderReceipts[$index].assetSha256) 'durable Defender receipts bind exact asset hashes' + } + } finally { + foreach ($path in @($fixture.Root, $previousRoot)) { + if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force } + } + } + } + + Invoke-CcodTest 'official draft rejects a missing draft identifier before Defender work' { + $fixture = New-CcodAcceptanceCandidateFixture + $previousRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-acceptance-previous-' + [guid]::NewGuid().ToString('N')) + $evidenceRoot = Join-Path $fixture.Root 'evidence' + $calls = [Collections.Generic.List[string]]::new() + [IO.Directory]::CreateDirectory($previousRoot) | Out-Null + [IO.Directory]::CreateDirectory($evidenceRoot) | Out-Null + try { + $adapters = @{ + ValidateAssetSet = { param($Path,$Version) $fixture.Contract }.GetNewClosure() + GetDraftIdentity = { param($Path,$Candidate) [pscustomobject][ordered]@{ tag = 'v2.5.22'; id = $null } }.GetNewClosure() + InvokeDefenderCheck = { param($Path,$AssetType,$Origin,$Version,$GitCommit,$EvidencePath) $calls.Add($AssetType); New-CcodAcceptanceDefenderReceipt -Fixture $fixture -AssetType $AssetType }.GetNewClosure() + } + Assert-CcodThrows { Invoke-CcodAcceptancePrivate -Phase Preflight -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters | Out-Null } 'CCOD_ACCEPTANCE_DRAFT_IDENTITY_INVALID' + Assert-CcodEqual 0 $calls.Count 'missing draft ID blocks Defender work' + $adapters.GetDraftIdentity = { param($Path,$Candidate,$Expected) [pscustomobject][ordered]@{ tag = 'v2.5.22'; id = 'draft-123' } }.GetNewClosure() + Assert-CcodThrows { Invoke-CcodAcceptancePrivate -Phase Preflight -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters | Out-Null } 'CCOD_ACCEPTANCE_DRAFT_IDENTITY_INVALID' + Assert-CcodEqual 0 $calls.Count 'nonnumeric draft ID also blocks Defender work' + } finally { + foreach ($path in @($fixture.Root,$previousRoot)) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force } } + } + } + + Invoke-CcodTest 'official draft acceptance enforces authorization before machine mutation and rejects skipped phases' { + $fixture = New-CcodAcceptanceCandidateFixture + $previousRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-acceptance-previous-' + [guid]::NewGuid().ToString('N')) + $evidenceRoot = Join-Path $fixture.Root 'evidence' + [IO.Directory]::CreateDirectory($previousRoot) | Out-Null + [IO.Directory]::CreateDirectory($evidenceRoot) | Out-Null + $state = [pscustomobject]@{ + DefenderCalls = [Collections.Generic.List[string]]::new() + RunCalls = [Collections.Generic.List[string]]::new() + CaptureCalls = [Collections.Generic.List[string]]::new() + BootCalls = 0 + RebootCalls = 0 + PreviousCalls = 0 + BootId = 'boot-1' + KeyHash = ('b' * 64) + Facts = (New-CcodAcceptanceFacts) + } + try { + $adapters = New-CcodAcceptancePhaseAdapters -Fixture $fixture -State $state + Invoke-CcodAcceptancePrivate -Phase Preflight -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters | Out-Null + Assert-CcodThrows { + Invoke-CcodAcceptancePrivate -Phase LegacyUpgrade -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters + } 'CCOD_ACCEPTANCE_MACHINE_MUTATION_NOT_ALLOWED' + Assert-CcodEqual 0 $state.RunCalls.Count 'missing machine authorization precedes the operation adapter' + Assert-CcodThrows { + Invoke-CcodAcceptancePrivate -Phase LegacyUpgrade -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation + } 'CCOD_ACCEPTANCE_CODEX_RESTART_NOT_ALLOWED' + Assert-CcodEqual 0 $state.RunCalls.Count 'missing restart authorization also precedes the operation adapter' + Assert-CcodThrows { + Invoke-CcodAcceptancePrivate -Phase Uninstall -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart + } 'CCOD_ACCEPTANCE_PHASE_ORDER_INVALID' + Assert-CcodEqual 0 $state.RunCalls.Count 'a skipped phase cannot invoke machine mutation' + } finally { + foreach ($path in @($fixture.Root, $previousRoot)) { + if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force } + } + } + } + + Invoke-CcodTest 'official draft legacy upgrade requires the public v2.5.21 Setup and preserves the device key hash' { + $fixture = New-CcodAcceptanceCandidateFixture + $previousRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-acceptance-previous-' + [guid]::NewGuid().ToString('N')) + $evidenceRoot = Join-Path $fixture.Root 'evidence' + [IO.Directory]::CreateDirectory($previousRoot) | Out-Null + [IO.Directory]::CreateDirectory($evidenceRoot) | Out-Null + $state = [pscustomobject]@{ + DefenderCalls = [Collections.Generic.List[string]]::new() + RunCalls = [Collections.Generic.List[string]]::new() + CaptureCalls = [Collections.Generic.List[string]]::new() + Events = [Collections.Generic.List[string]]::new() + BootCalls = 0; RebootCalls = 0; PreviousCalls = 0 + BootId = 'boot-1'; KeyHash = ('b' * 64); Facts = (New-CcodAcceptanceFacts) + } + try { + $adapters = New-CcodAcceptancePhaseAdapters -Fixture $fixture -State $state + Invoke-CcodAcceptancePrivate -Phase Preflight -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters | Out-Null + $previous = @(& $adapters.ValidatePreviousSetup $previousRoot '2.5.21') + Assert-CcodEqual 1 $previous.Count 'legacy setup validator returns one real previous setup record' + $previousSetupPath = Join-Path $previousRoot 'CodexRemote-fix-2.5.21-setup.exe' + $previousManifestPath = Join-Path $previousRoot 'CodexRemote-fix-2.5.21-setup-release-manifest.json' + Assert-CcodTrue ([IO.File]::Exists($previousSetupPath) -and [IO.File]::Exists($previousManifestPath)) 'legacy setup validator binds both previous files on disk' + Assert-CcodEqual (Get-CcodTestFileSha256 -Path $previousSetupPath) ([string]$previous[0].InstallerSha256) 'legacy setup metadata binds the actual installer hash' + Assert-CcodEqual (Get-CcodTestFileSha256 -Path $previousManifestPath) ([string]$previous[0].ManifestSha256) 'legacy setup metadata binds the actual manifest hash' + $originalPreviousManifest = [IO.File]::ReadAllBytes($previousManifestPath) + $tamperedPreviousManifest = [IO.File]::ReadAllText($previousManifestPath) | ConvertFrom-Json + $tamperedPreviousManifest.version = '2.5.22' + [IO.File]::WriteAllText($previousManifestPath, ($tamperedPreviousManifest | ConvertTo-Json -Depth 8), [Text.UTF8Encoding]::new($false)) + $rejectedVersion = $false + try { & $adapters.ValidatePreviousSetup $previousRoot '2.5.21' | Out-Null } catch { $rejectedVersion = $true } + Assert-CcodTrue $rejectedVersion 'legacy setup validator rejects a manifest version detached from the requested v2.5.21' + [IO.File]::WriteAllBytes($previousManifestPath, $originalPreviousManifest) + $wrongPreviousRoot = Join-Path $fixture.Root 'wrong-previous-root' + [IO.Directory]::CreateDirectory($wrongPreviousRoot) | Out-Null + $state | Add-Member -NotePropertyName SeedPreviousSetup -NotePropertyValue $false + $rejectedPath = $false + try { & $adapters.ValidatePreviousSetup $wrongPreviousRoot '2.5.21' | Out-Null } catch { $rejectedPath = $true } + Assert-CcodTrue $rejectedPath 'legacy setup validator rejects an empty alternate previous-asset path' + $state.PSObject.Properties.Remove('SeedPreviousSetup') + $state.PreviousCalls = 0 + $result = Invoke-CcodAcceptancePrivate -Phase LegacyUpgrade -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart + Assert-CcodEqual 'LegacyUpgrade' ([string]$result.phase) 'legacy upgrade returns its canonical phase' + Assert-CcodEqual 'Completed' ([string]$result.outcome) 'legacy upgrade returns a completed outcome' + Assert-CcodEqual 1 $state.PreviousCalls 'legacy upgrade validates the supplied previous installer once' + Assert-CcodEqual 'LegacyUpgrade' $state.RunCalls[0] 'legacy upgrade invokes only its injected operation' + Assert-CcodTrue ($state.CaptureCalls -contains 'LegacyUpgrade:Before') 'legacy upgrade captures the key before mutation' + Assert-CcodTrue ($state.CaptureCalls -contains 'LegacyUpgrade:After') 'legacy upgrade captures the key after mutation' + Assert-CcodEqual 'Capture:LegacyUpgrade:Before,Run:LegacyUpgrade,Capture:LegacyUpgrade:After' (@($state.Events) -join ',') 'legacy upgrade enforces before-operation-after ordering' + $receiptPath = Join-Path (Join-Path $evidenceRoot 'official-draft-acceptance') '02-LegacyUpgrade.json' + $serialized = [IO.File]::ReadAllText($receiptPath) + foreach ($forbidden in @('C:\\Users', 'private', 'token', 'raw log')) { + Assert-CcodTrue (-not $serialized.Contains($forbidden)) "legacy receipt omits $forbidden data" + } + $receipt = $serialized | ConvertFrom-Json + Assert-CcodTrue ([bool]$receipt.facts.keyHashPreserved) 'legacy receipt records unchanged key-hash proof' + Assert-CcodTrue ($null -ne $receipt.facts.before -and $null -ne $receipt.facts.after) 'legacy receipt persists before and after readiness observations' + Assert-CcodEqual '2.5.21' ([string]$receipt.facts.before.version) 'legacy receipt binds the pre-upgrade observation to v2.5.21' + Assert-CcodEqual '2.5.22' ([string]$receipt.facts.after.version) 'legacy receipt binds the post-upgrade observation to v2.5.22' + Assert-CcodEqual '2.5.21-legacy-runtime' ([string]$receipt.facts.before.activeRuntimeId) 'legacy receipt binds the previous active runtime identity' + Assert-CcodEqual 1 ([int]$receipt.facts.before.activeGeneration) 'legacy receipt binds the previous active generation' + Assert-CcodEqual ('d' * 64) ([string]$receipt.facts.before.runtimeManifestSha256) 'legacy receipt binds the previous runtime manifest hash' + Assert-CcodEqual 'runtime-2.5.22' ([string]$receipt.facts.after.activeRuntimeId) 'legacy receipt binds the candidate active runtime identity' + Assert-CcodEqual 2 ([int]$receipt.facts.after.activeGeneration) 'legacy receipt binds the candidate active generation' + Assert-CcodEqual ('d' * 64) ([string]$receipt.facts.after.runtimeManifestSha256) 'legacy receipt binds the candidate runtime manifest hash' + Assert-CcodEqual ('b' * 64) ([string]$receipt.facts.before.deviceKeySha256) 'legacy receipt binds the before device-key hash' + Assert-CcodEqual ('b' * 64) ([string]$receipt.facts.after.deviceKeySha256) 'legacy receipt binds the after device-key hash' + Assert-CcodEqual '2.5.21' ([string]$receipt.facts.previousSetup.version) 'legacy receipt records the manifest-bound prior version' + } finally { + if (Test-Path -LiteralPath $fixture.Root) { Remove-Item -LiteralPath $fixture.Root -Recurse -Force } + if (Test-Path -LiteralPath $previousRoot) { Remove-Item -LiteralPath $previousRoot -Recurse -Force } + } + } + + Invoke-CcodTest 'official draft legacy upgrade rejects an unready v2.5.21 runtime before mutation' { + $fixture = New-CcodAcceptanceCandidateFixture + $previousRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-acceptance-previous-' + [guid]::NewGuid().ToString('N')) + $evidenceRoot = Join-Path $fixture.Root 'evidence' + [IO.Directory]::CreateDirectory($previousRoot) | Out-Null + [IO.Directory]::CreateDirectory($evidenceRoot) | Out-Null + $state = [pscustomobject]@{ + DefenderCalls = [Collections.Generic.List[string]]::new() + RunCalls = [Collections.Generic.List[string]]::new() + CaptureCalls = [Collections.Generic.List[string]]::new() + BootCalls = 0; RebootCalls = 0; PreviousCalls = 0 + BootId = 'boot-1'; KeyHash = ('b' * 64); Facts = (New-CcodAcceptanceFacts) + } + try { + $adapters = New-CcodAcceptancePhaseAdapters -Fixture $fixture -State $state + Invoke-CcodAcceptancePrivate -Phase Preflight -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters | Out-Null + $adapters.CaptureFacts = { + param($Context, $Point) + if ($Context.phase -ceq 'LegacyUpgrade' -and $Point -ceq 'Before') { + $facts = New-CcodAcceptanceFacts -RuntimeId '2.5.21-legacy-runtime' -Generation 1 -Version '2.5.21' -KeyHash $State.KeyHash -BootId $State.BootId + $facts.taskState = 'Unknown' + return $facts + } + return $State.Facts + }.GetNewClosure() + Assert-CcodThrows { + Invoke-CcodAcceptancePrivate -Phase LegacyUpgrade -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart | Out-Null + } 'CCOD_ACCEPTANCE_OBSERVATION_UNAVAILABLE' + Assert-CcodEqual 0 $state.RunCalls.Count 'an unready previous runtime cannot reach the upgrade operation' + } finally { + foreach ($path in @($fixture.Root, $previousRoot)) { + if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force } + } + } + } + + Invoke-CcodTest 'official draft uninstall proves owned state is gone while preserving the device key hash' { + $fixture = New-CcodAcceptanceCandidateFixture + $previousRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-acceptance-previous-' + [guid]::NewGuid().ToString('N')) + $evidenceRoot = Join-Path $fixture.Root 'evidence' + [IO.Directory]::CreateDirectory($previousRoot) | Out-Null + [IO.Directory]::CreateDirectory($evidenceRoot) | Out-Null + $state = [pscustomobject]@{ + DefenderCalls = [Collections.Generic.List[string]]::new() + RunCalls = [Collections.Generic.List[string]]::new() + CaptureCalls = [Collections.Generic.List[string]]::new() + BootCalls = 0; RebootCalls = 0; PreviousCalls = 0 + BootId = 'boot-1'; KeyHash = ('b' * 64); Facts = (New-CcodAcceptanceFacts) + } + try { + $adapters = New-CcodAcceptancePhaseAdapters -Fixture $fixture -State $state -ReturnAbsentAfterUninstall + Invoke-CcodAcceptancePrivate -Phase Preflight -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters | Out-Null + Invoke-CcodAcceptancePrivate -Phase LegacyUpgrade -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart | Out-Null + $result = Invoke-CcodAcceptancePrivate -Phase Uninstall -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart + Assert-CcodEqual 'Uninstall' ([string]$result.phase) 'uninstall returns its canonical phase' + Assert-CcodEqual 'Completed' ([string]$result.outcome) 'uninstall returns a completed outcome' + Assert-CcodEqual 'Uninstall' $state.RunCalls[1] 'uninstall invokes its injected operation after upgrade' + $receiptPath = Join-Path (Join-Path $evidenceRoot 'official-draft-acceptance') '03-Uninstall.json' + $serialized = [IO.File]::ReadAllText($receiptPath) + foreach ($forbidden in @('C:\\Users', 'private', 'token', 'raw log')) { + Assert-CcodTrue (-not $serialized.Contains($forbidden)) "uninstall receipt omits $forbidden data" + } + $receipt = $serialized | ConvertFrom-Json + Assert-CcodTrue ([bool]$receipt.facts.stateRemoved) 'uninstall receipt records complete removal proof' + Assert-CcodTrue ([bool]$receipt.facts.keyHashPreserved) 'uninstall receipt records unchanged key-hash proof' + Assert-CcodEqual 'Absent' ([string]$receipt.facts.taskState) 'uninstall receipt records the absent scheduled task' + Assert-CcodEqual 0 ([int]$receipt.facts.supervisorCount) 'uninstall receipt records no Supervisor' + Assert-CcodEqual 0 ([int]$receipt.facts.trayHostCount) 'uninstall receipt records no TrayHost' + Assert-CcodTrue ($null -ne $receipt.facts.PSObject.Properties['codexCount']) 'uninstall receipt persists the Codex absence fact' + Assert-CcodEqual 0 ([int]$receipt.facts.codexCount) 'uninstall receipt records no Codex process' + } finally { + if (Test-Path -LiteralPath $fixture.Root) { Remove-Item -LiteralPath $fixture.Root -Recurse -Force } + if (Test-Path -LiteralPath $previousRoot) { Remove-Item -LiteralPath $previousRoot -Recurse -Force } + } + } + + Invoke-CcodTest 'official draft uninstall rejects a lingering Codex process even without debug listeners' { + $fixture = New-CcodAcceptanceCandidateFixture + $previousRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-acceptance-previous-' + [guid]::NewGuid().ToString('N')) + $evidenceRoot = Join-Path $fixture.Root 'evidence' + [IO.Directory]::CreateDirectory($previousRoot) | Out-Null + [IO.Directory]::CreateDirectory($evidenceRoot) | Out-Null + $state = [pscustomobject]@{ DefenderCalls = [Collections.Generic.List[string]]::new(); RunCalls = [Collections.Generic.List[string]]::new(); CaptureCalls = [Collections.Generic.List[string]]::new(); BootCalls = 0; RebootCalls = 0; PreviousCalls = 0; BootId = 'boot-1'; KeyHash = ('b' * 64); Facts = New-CcodAcceptanceFacts } + try { + $adapters = New-CcodAcceptancePhaseAdapters -Fixture $fixture -State $state -ReturnAbsentAfterUninstall + $capture = $adapters.CaptureFacts + $adapters.CaptureFacts = { + param($Context, $Point) + $facts = & $capture $Context $Point + if ($Context.phase -ceq 'Uninstall' -and $Point -ceq 'After') { + $facts.codex = @([pscustomobject]@{ pid = 202; creationTimeUtc = '2030-02-03T04:05:02.0000000Z' }); $facts.codexCount = 1 + } + return $facts + }.GetNewClosure() + Invoke-CcodAcceptancePrivate -Phase Preflight -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters | Out-Null + Invoke-CcodAcceptancePrivate -Phase LegacyUpgrade -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart | Out-Null + Assert-CcodThrows { Invoke-CcodAcceptancePrivate -Phase Uninstall -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart | Out-Null } 'CCOD_ACCEPTANCE_UNINSTALL_OBSERVATION_INVALID' + } finally { + if (Test-Path -LiteralPath $fixture.Root) { Remove-Item -LiteralPath $fixture.Root -Recurse -Force } + if (Test-Path -LiteralPath $previousRoot) { Remove-Item -LiteralPath $previousRoot -Recurse -Force } + } + } + + Invoke-CcodTest 'official draft uninstall rejects a lingering top-level install root' { + $fixture = New-CcodAcceptanceCandidateFixture + $previousRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-acceptance-previous-' + [guid]::NewGuid().ToString('N')) + $evidenceRoot = Join-Path $fixture.Root 'evidence' + [IO.Directory]::CreateDirectory($previousRoot) | Out-Null + [IO.Directory]::CreateDirectory($evidenceRoot) | Out-Null + $state = [pscustomobject]@{ DefenderCalls = [Collections.Generic.List[string]]::new(); RunCalls = [Collections.Generic.List[string]]::new(); CaptureCalls = [Collections.Generic.List[string]]::new(); BootCalls = 0; RebootCalls = 0; PreviousCalls = 0; BootId = 'boot-1'; KeyHash = ('b' * 64); Facts = New-CcodAcceptanceFacts } + try { + $adapters = New-CcodAcceptancePhaseAdapters -Fixture $fixture -State $state -ReturnAbsentAfterUninstall -LeaveInstallRootAfterUninstall + Invoke-CcodAcceptancePrivate -Phase Preflight -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters | Out-Null + Invoke-CcodAcceptancePrivate -Phase LegacyUpgrade -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart | Out-Null + Assert-CcodThrows { Invoke-CcodAcceptancePrivate -Phase Uninstall -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart | Out-Null } 'CCOD_ACCEPTANCE_UNINSTALL_OBSERVATION_INVALID' + Assert-CcodTrue (-not (Test-Path -LiteralPath (Join-Path (Join-Path $evidenceRoot 'official-draft-acceptance') '03-Uninstall.json'))) 'uninstall does not persist a receipt when the top-level root remains' + } finally { + if (Test-Path -LiteralPath $fixture.Root) { Remove-Item -LiteralPath $fixture.Root -Recurse -Force } + if (Test-Path -LiteralPath $previousRoot) { Remove-Item -LiteralPath $previousRoot -Recurse -Force } + } + } + + Invoke-CcodTest 'official draft fresh install proves one active generation and authenticated protection' { + $fixture = New-CcodAcceptanceCandidateFixture + $previousRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-acceptance-previous-' + [guid]::NewGuid().ToString('N')) + $evidenceRoot = Join-Path $fixture.Root 'evidence' + [IO.Directory]::CreateDirectory($previousRoot) | Out-Null + [IO.Directory]::CreateDirectory($evidenceRoot) | Out-Null + $state = [pscustomobject]@{ + DefenderCalls = [Collections.Generic.List[string]]::new() + RunCalls = [Collections.Generic.List[string]]::new() + CaptureCalls = [Collections.Generic.List[string]]::new() + BootCalls = 0; RebootCalls = 0; PreviousCalls = 0 + BootId = 'boot-1'; KeyHash = ('b' * 64); Facts = (New-CcodAcceptanceFacts) + } + try { + $adapters = New-CcodAcceptancePhaseAdapters -Fixture $fixture -State $state -ReturnAbsentAfterUninstall + Invoke-CcodAcceptancePrivate -Phase Preflight -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters | Out-Null + Invoke-CcodAcceptancePrivate -Phase LegacyUpgrade -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart | Out-Null + Invoke-CcodAcceptancePrivate -Phase Uninstall -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart | Out-Null + $result = Invoke-CcodAcceptancePrivate -Phase FreshInstall -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart + Assert-CcodEqual 'FreshInstall' ([string]$result.phase) 'fresh install returns its canonical phase' + Assert-CcodEqual 'Completed' ([string]$result.outcome) 'fresh install returns a completed outcome' + Assert-CcodEqual 'FreshInstall' $state.RunCalls[2] 'fresh install invokes its injected operation after uninstall' + $receiptPath = Join-Path (Join-Path $evidenceRoot 'official-draft-acceptance') '04-FreshInstall.json' + $serialized = [IO.File]::ReadAllText($receiptPath) + foreach ($forbidden in @('C:\\Users', 'private', 'token', 'raw log')) { + Assert-CcodTrue (-not $serialized.Contains($forbidden)) "fresh-install receipt omits $forbidden data" + } + $receipt = $serialized | ConvertFrom-Json + Assert-CcodTrue ([bool]$receipt.facts.installReady) 'fresh-install receipt records the complete readiness proof' + Assert-CcodEqual 1 ([int]$receipt.facts.supervisorCount) 'fresh-install receipt records one Supervisor' + Assert-CcodEqual 1 ([int]$receipt.facts.trayHostCount) 'fresh-install receipt records one TrayHost' + Assert-CcodTrue ([bool]$receipt.facts.trayAuthenticated) 'fresh-install receipt records authenticated TrayHost evidence' + Assert-CcodEqual 'Completed' ([string]$receipt.facts.terminalReceipt.phase) 'fresh-install receipt records a terminal lifecycle receipt' + Assert-CcodEqual 1 ([int]$receipt.facts.codexCount) 'fresh-install receipt records exactly one Codex process' + } finally { + if (Test-Path -LiteralPath $fixture.Root) { Remove-Item -LiteralPath $fixture.Root -Recurse -Force } + if (Test-Path -LiteralPath $previousRoot) { Remove-Item -LiteralPath $previousRoot -Recurse -Force } + } + } + + Invoke-CcodTest 'official draft pre-reboot persists a fresh-install receipt hash and boot identity before reboot' { + $fixture = New-CcodAcceptanceCandidateFixture + $previousRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-acceptance-previous-' + [guid]::NewGuid().ToString('N')) + $evidenceRoot = Join-Path $fixture.Root 'evidence' + [IO.Directory]::CreateDirectory($previousRoot) | Out-Null + [IO.Directory]::CreateDirectory($evidenceRoot) | Out-Null + $state = [pscustomobject]@{ + DefenderCalls = [Collections.Generic.List[string]]::new() + RunCalls = [Collections.Generic.List[string]]::new() + CaptureCalls = [Collections.Generic.List[string]]::new() + BootCalls = 0; RebootCalls = 0; PreviousCalls = 0 + BootId = 'boot-1'; KeyHash = ('b' * 64); Facts = (New-CcodAcceptanceFacts) + } + try { + $adapters = New-CcodAcceptancePhaseAdapters -Fixture $fixture -State $state -ReturnAbsentAfterUninstall + Invoke-CcodAcceptancePrivate -Phase Preflight -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters | Out-Null + Invoke-CcodAcceptancePrivate -Phase LegacyUpgrade -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart | Out-Null + Invoke-CcodAcceptancePrivate -Phase Uninstall -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart | Out-Null + Invoke-CcodAcceptancePrivate -Phase FreshInstall -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart | Out-Null + $adapters.Reboot = { + $State.RebootCalls++ + $stateDirectory = Join-Path $evidenceRoot 'official-draft-acceptance' + $preRebootPath = Join-Path $stateDirectory '05-PreReboot.json' + $freshInstallPath = Join-Path $stateDirectory '04-FreshInstall.json' + Assert-CcodTrue (Test-Path -LiteralPath $preRebootPath -PathType Leaf) 'pre-reboot receipt is durable before reboot is requested' + $preReboot = [IO.File]::ReadAllText($preRebootPath, [Text.UTF8Encoding]::new($false, $true)) | ConvertFrom-Json + $freshInstallHash = Get-CcodTestFileSha256 -Path $freshInstallPath + Assert-CcodEqual $freshInstallHash ([string]$preReboot.facts.freshInstallReceiptSha256) 'pre-reboot receipt carries the exact fresh-install receipt hash' + return $true + }.GetNewClosure() + $result = Invoke-CcodAcceptancePrivate -Phase PreReboot -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart -AllowWindowsReboot + Assert-CcodEqual 'PreReboot' ([string]$result.phase) 'pre-reboot returns its canonical phase' + Assert-CcodEqual 'Completed' ([string]$result.outcome) 'pre-reboot returns a completed outcome' + Assert-CcodEqual 1 $state.BootCalls 'pre-reboot captures one boot identity' + Assert-CcodEqual 1 $state.RebootCalls 'pre-reboot invokes exactly one injected reboot' + $receiptPath = Join-Path (Join-Path $evidenceRoot 'official-draft-acceptance') '05-PreReboot.json' + $receipt = ([IO.File]::ReadAllText($receiptPath) | ConvertFrom-Json) + Assert-CcodTrue ([bool]$receipt.facts.rebootRequested) 'pre-reboot receipt records the reboot boundary' + Assert-CcodEqual 'boot-1' ([string]$receipt.facts.bootIdBefore) 'pre-reboot receipt records the old boot identity' + Assert-CcodEqual (Get-CcodTestFileSha256 -Path (Join-Path (Join-Path $evidenceRoot 'official-draft-acceptance') '04-FreshInstall.json')) ([string]$receipt.facts.freshInstallReceiptSha256) 'pre-reboot receipt binds the exact fresh-install receipt hash' + Assert-CcodTrue ($null -ne $receipt.facts.observation) 'pre-reboot receipt persists the complete bounded lifecycle observation' + foreach ($field in @('installRootPresent','installReady','activePointerPresent','activeRuntimeId','activeGeneration','runtimeManifestSha256','supervisor','trayHost','trayHostIdentity','statusPhase','statusRuntimeId','statusCodex','lifecycleReceipt','debugPorts','debugEndpoints','deviceKeySha256')) { + Assert-CcodTrue ($null -ne $receipt.facts.observation.PSObject.Properties[$field]) "pre-reboot observation persists $field" + } + } finally { + if (Test-Path -LiteralPath $fixture.Root) { Remove-Item -LiteralPath $fixture.Root -Recurse -Force } + if (Test-Path -LiteralPath $previousRoot) { Remove-Item -LiteralPath $previousRoot -Recurse -Force } + } + } + + Invoke-CcodTest 'official draft pre-reboot rejects current runtime identity drift before reboot' { + $fixture = New-CcodAcceptanceCandidateFixture + $previousRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-acceptance-previous-' + [guid]::NewGuid().ToString('N')) + $evidenceRoot = Join-Path $fixture.Root 'evidence' + [IO.Directory]::CreateDirectory($previousRoot) | Out-Null + [IO.Directory]::CreateDirectory($evidenceRoot) | Out-Null + $state = [pscustomobject]@{ + DefenderCalls = [Collections.Generic.List[string]]::new() + RunCalls = [Collections.Generic.List[string]]::new() + CaptureCalls = [Collections.Generic.List[string]]::new() + BootCalls = 0; RebootCalls = 0; PreviousCalls = 0 + BootId = 'boot-1'; KeyHash = ('b' * 64); Facts = (New-CcodAcceptanceFacts) + } + try { + $adapters = New-CcodAcceptancePhaseAdapters -Fixture $fixture -State $state -ReturnAbsentAfterUninstall + Invoke-CcodAcceptancePrivate -Phase Preflight -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters | Out-Null + Invoke-CcodAcceptancePrivate -Phase LegacyUpgrade -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart | Out-Null + Invoke-CcodAcceptancePrivate -Phase Uninstall -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart | Out-Null + Invoke-CcodAcceptancePrivate -Phase FreshInstall -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart | Out-Null + $state.Facts.activeGeneration = [UInt64]3 + Assert-CcodThrows { + Invoke-CcodAcceptancePrivate -Phase PreReboot -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart -AllowWindowsReboot | Out-Null + } 'CCOD_ACCEPTANCE_OBSERVATION_UNAVAILABLE' + Assert-CcodEqual 0 $state.RebootCalls 'runtime identity drift cannot reach the reboot adapter' + } finally { + foreach ($path in @($fixture.Root, $previousRoot)) { + if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force } + } + } + } + + Invoke-CcodTest 'official draft post-reboot proves a new boot and recovered protected runtime' { + $fixture = New-CcodAcceptanceCandidateFixture + $previousRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-acceptance-previous-' + [guid]::NewGuid().ToString('N')) + $evidenceRoot = Join-Path $fixture.Root 'evidence' + [IO.Directory]::CreateDirectory($previousRoot) | Out-Null + [IO.Directory]::CreateDirectory($evidenceRoot) | Out-Null + $state = [pscustomobject]@{ + DefenderCalls = [Collections.Generic.List[string]]::new() + RunCalls = [Collections.Generic.List[string]]::new() + CaptureCalls = [Collections.Generic.List[string]]::new() + BootCalls = 0; RebootCalls = 0; PreviousCalls = 0 + BootId = 'boot-1'; KeyHash = ('b' * 64); Facts = (New-CcodAcceptanceFacts -BootId 'boot-1') + } + try { + $adapters = New-CcodAcceptancePhaseAdapters -Fixture $fixture -State $state -ReturnAbsentAfterUninstall + Invoke-CcodAcceptancePrivate -Phase Preflight -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters | Out-Null + Invoke-CcodAcceptancePrivate -Phase LegacyUpgrade -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart | Out-Null + Invoke-CcodAcceptancePrivate -Phase Uninstall -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart | Out-Null + Invoke-CcodAcceptancePrivate -Phase FreshInstall -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart | Out-Null + Invoke-CcodAcceptancePrivate -Phase PreReboot -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart -AllowWindowsReboot | Out-Null + $state.BootId = 'boot-2' + $state.Facts = New-CcodAcceptanceFacts -BootId 'boot-2' + $result = Invoke-CcodAcceptancePrivate -Phase PostReboot -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart -AllowWindowsReboot + Assert-CcodEqual 'PostReboot' ([string]$result.phase) 'post-reboot returns its canonical phase' + Assert-CcodEqual 'Completed' ([string]$result.outcome) 'post-reboot returns a completed outcome' + Assert-CcodEqual 2 $state.BootCalls 'post-reboot captures one additional boot identity' + Assert-CcodEqual 1 $state.RebootCalls 'post-reboot does not request another reboot' + Assert-CcodEqual 2 $state.DefenderCalls.Count 'post-reboot never invokes Defender' + $receiptPath = Join-Path (Join-Path $evidenceRoot 'official-draft-acceptance') '06-PostReboot.json' + $receipt = ([IO.File]::ReadAllText($receiptPath) | ConvertFrom-Json) + Assert-CcodTrue ([bool]$receipt.facts.bootChanged) 'post-reboot receipt records a new boot identity' + Assert-CcodEqual 'boot-1' ([string]$receipt.facts.bootIdBefore) 'post-reboot receipt binds the pre-reboot boot' + Assert-CcodEqual 'boot-2' ([string]$receipt.facts.bootIdAfter) 'post-reboot receipt binds the new boot' + Assert-CcodTrue ([bool]$receipt.facts.receiptContinuity) 'post-reboot receipt records lifecycle receipt continuity' + Assert-CcodTrue ([bool]$receipt.facts.protectionRecovered) 'post-reboot receipt records recovered protection' + Assert-CcodEqual 'Idle' ([string]$receipt.facts.transitionStage) 'post-reboot receipt records the Idle transition' + Assert-CcodTrue ([bool]$receipt.facts.keyHashPreserved) 'post-reboot receipt records unchanged key-hash proof' + Assert-CcodEqual 1 ([int]$receipt.facts.codexCount) 'post-reboot receipt records exactly one Codex process' + Assert-CcodTrue ($null -ne $receipt.facts.observation) 'post-reboot receipt persists the complete bounded lifecycle observation' + foreach ($field in @('installRootPresent','installReady','activePointerPresent','activeRuntimeId','activeGeneration','runtimeManifestSha256','supervisor','trayHost','trayHostIdentity','statusPhase','statusRuntimeId','statusCodex','lifecycleReceipt','debugPorts','debugEndpoints','deviceKeySha256')) { + Assert-CcodTrue ($null -ne $receipt.facts.observation.PSObject.Properties[$field]) "post-reboot observation persists $field" + } + } finally { + if (Test-Path -LiteralPath $fixture.Root) { Remove-Item -LiteralPath $fixture.Root -Recurse -Force } + if (Test-Path -LiteralPath $previousRoot) { Remove-Item -LiteralPath $previousRoot -Recurse -Force } + } + } + + Invoke-CcodTest 'official draft ready-for-manual-evidence requires every automated phase but never claims complete' { + $fixture = New-CcodAcceptanceCandidateFixture + $previousRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-acceptance-previous-' + [guid]::NewGuid().ToString('N')) + $evidenceRoot = Join-Path $fixture.Root 'evidence' + [IO.Directory]::CreateDirectory($previousRoot) | Out-Null + [IO.Directory]::CreateDirectory($evidenceRoot) | Out-Null + $state = [pscustomobject]@{ + DefenderCalls = [Collections.Generic.List[string]]::new() + RunCalls = [Collections.Generic.List[string]]::new() + CaptureCalls = [Collections.Generic.List[string]]::new() + BootCalls = 0; RebootCalls = 0; PreviousCalls = 0 + BootId = 'boot-1'; KeyHash = ('b' * 64); Facts = (New-CcodAcceptanceFacts -BootId 'boot-1') + } + try { + $adapters = New-CcodAcceptancePhaseAdapters -Fixture $fixture -State $state -ReturnAbsentAfterUninstall + Invoke-CcodAcceptancePrivate -Phase Preflight -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters | Out-Null + Invoke-CcodAcceptancePrivate -Phase LegacyUpgrade -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart | Out-Null + Invoke-CcodAcceptancePrivate -Phase Uninstall -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart | Out-Null + Invoke-CcodAcceptancePrivate -Phase FreshInstall -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart | Out-Null + Invoke-CcodAcceptancePrivate -Phase PreReboot -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart -AllowWindowsReboot | Out-Null + $state.BootId = 'boot-2'; $state.Facts = New-CcodAcceptanceFacts -BootId 'boot-2' + Invoke-CcodAcceptancePrivate -Phase PostReboot -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart -AllowWindowsReboot | Out-Null + $result = Invoke-CcodAcceptancePrivate -Phase ReadyForManualEvidence -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters + Assert-CcodEqual 'ReadyForManualEvidence' ([string]$result.phase) 'ready phase returns its canonical phase' + Assert-CcodEqual 'ReadyForManualEvidence' ([string]$result.outcome) 'ready phase does not use a free-text completion result' + Assert-CcodEqual $false ([bool]$result.complete) 'ready phase never claims Complete' + $receiptPath = Join-Path (Join-Path $evidenceRoot 'official-draft-acceptance') '07-ReadyForManualEvidence.json' + $serialized = [IO.File]::ReadAllText($receiptPath) + Assert-CcodTrue ($serialized -notmatch '"outcome"\s*:\s*"Complete"') 'ready receipt contains no Complete claim' + $receipt = $serialized | ConvertFrom-Json + Assert-CcodEqual 6 @($receipt.facts.automatedPhases).Count 'ready receipt requires all six automated phases' + Assert-CcodEqual $false ([bool]$receipt.facts.complete) 'ready receipt records that manual evidence is still pending' + } finally { + if (Test-Path -LiteralPath $fixture.Root) { Remove-Item -LiteralPath $fixture.Root -Recurse -Force } + if (Test-Path -LiteralPath $previousRoot) { Remove-Item -LiteralPath $previousRoot -Recurse -Force } + } + } + Invoke-CcodTest 'official draft rejects a tampered legacy receipt before uninstall mutation' { + $fixture = New-CcodAcceptanceCandidateFixture + $previousRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-acceptance-previous-' + [guid]::NewGuid().ToString('N')) + $evidenceRoot = Join-Path $fixture.Root 'evidence' + [IO.Directory]::CreateDirectory($previousRoot) | Out-Null + [IO.Directory]::CreateDirectory($evidenceRoot) | Out-Null + $state = [pscustomobject]@{ + DefenderCalls = [Collections.Generic.List[string]]::new() + RunCalls = [Collections.Generic.List[string]]::new() + CaptureCalls = [Collections.Generic.List[string]]::new() + BootCalls = 0; RebootCalls = 0; PreviousCalls = 0 + BootId = 'boot-1'; KeyHash = ('b' * 64); Facts = (New-CcodAcceptanceFacts) + } + try { + $adapters = New-CcodAcceptancePhaseAdapters -Fixture $fixture -State $state -ReturnAbsentAfterUninstall + Invoke-CcodAcceptancePrivate -Phase Preflight -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters | Out-Null + Invoke-CcodAcceptancePrivate -Phase LegacyUpgrade -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart | Out-Null + $legacyPath = Join-Path (Join-Path $evidenceRoot 'official-draft-acceptance') '02-LegacyUpgrade.json' + $legacy = [IO.File]::ReadAllText($legacyPath) | ConvertFrom-Json + $legacy.draft.id = '999' + [IO.File]::WriteAllText($legacyPath, ($legacy | ConvertTo-Json -Depth 16), [Text.UTF8Encoding]::new($false)) + Assert-CcodThrows { + Invoke-CcodAcceptancePrivate -Phase Uninstall -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart + } 'CCOD_ACCEPTANCE_DRAFT_IDENTITY_CHANGED' + Assert-CcodEqual 1 $state.RunCalls.Count 'draft identity mismatch is rejected before uninstall mutation' + $legacy.draft.id = '123' + [IO.File]::WriteAllText($legacyPath, ($legacy | ConvertTo-Json -Depth 16), [Text.UTF8Encoding]::new($false)) + $legacy = [IO.File]::ReadAllText($legacyPath) | ConvertFrom-Json + $legacy.facts.keyHashPreserved = $false + [IO.File]::WriteAllText($legacyPath, ($legacy | ConvertTo-Json -Depth 16), [Text.UTF8Encoding]::new($false)) + Assert-CcodThrows { + Invoke-CcodAcceptancePrivate -Phase Uninstall -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart + } 'CCOD_ACCEPTANCE_RECEIPT_INVALID' + Assert-CcodEqual 1 $state.RunCalls.Count 'tampered receipt is rejected before uninstall mutation' + } finally { + if (Test-Path -LiteralPath $fixture.Root) { Remove-Item -LiteralPath $fixture.Root -Recurse -Force } + if (Test-Path -LiteralPath $previousRoot) { Remove-Item -LiteralPath $previousRoot -Recurse -Force } + } + } + + Invoke-CcodTest 'official draft default Defender adapter binds each downloaded asset to its checksum and manifest' { + $fixture = New-CcodAcceptanceCandidateFixture + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + $fakeState = [pscustomobject]@{ Calls = [Collections.Generic.List[object]]::new() } + $fakeModule = New-Module -ArgumentList $fakeState -ScriptBlock { + param($State) + function Invoke-CcodReleaseDefenderCheck { + param($CandidatePath,$ChecksumPath,$ManifestPath,$Origin,$ExpectedVersion,$ExpectedGitCommit,$EvidencePath) + $State.Calls.Add([pscustomobject][ordered]@{ + CandidatePath = $CandidatePath + ChecksumPath = $ChecksumPath + ManifestPath = $ManifestPath + Origin = $Origin + ExpectedVersion = $ExpectedVersion + ExpectedGitCommit = $ExpectedGitCommit + EvidencePath = $EvidencePath + }) + [pscustomobject]@{ fixture = $true } + } + Export-ModuleMember -Function Invoke-CcodReleaseDefenderCheck + } + try { + &$module { + param($Fake,$Root,$Commit,$SetupReceipt,$PortableReceipt) + $script:CcodOfficialDraftDefenderModule = $Fake + $defaults = Get-CcodOfficialDraftDefaultAdapters + & $defaults.InvokeDefenderCheck $Root 'Setup' 'InternetDownload' '2.5.22' $Commit $SetupReceipt | Out-Null + & $defaults.InvokeDefenderCheck $Root 'PortableZip' 'InternetDownload' '2.5.22' $Commit $PortableReceipt | Out-Null + } $fakeModule $fixture.Root $fixture.GitCommit (Join-Path $fixture.Root 'setup-receipt.json') (Join-Path $fixture.Root 'portable-receipt.json') + Assert-CcodEqual 2 $fakeState.Calls.Count 'default Defender adapter invokes the checker once per downloaded asset' + $setup = $fakeState.Calls[0] + Assert-CcodEqual (Join-Path $fixture.Root $fixture.Names[5]) ([string]$setup.CandidatePath) 'Setup scan uses the Setup executable' + Assert-CcodEqual (Join-Path $fixture.Root $fixture.Names[6]) ([string]$setup.ChecksumPath) 'Setup scan uses its checksum' + Assert-CcodEqual (Join-Path $fixture.Root $fixture.Names[10]) ([string]$setup.ManifestPath) 'Setup scan uses its release manifest' + $portable = $fakeState.Calls[1] + Assert-CcodEqual (Join-Path $fixture.Root $fixture.Names[0]) ([string]$portable.CandidatePath) 'portable scan uses the ZIP' + Assert-CcodEqual (Join-Path $fixture.Root $fixture.Names[1]) ([string]$portable.ChecksumPath) 'portable scan uses its checksum' + Assert-CcodEqual (Join-Path $fixture.Root $fixture.Names[4]) ([string]$portable.ManifestPath) 'portable scan uses its release manifest' + } finally { + if ($null -ne $fakeModule) { Remove-Module -Name $fakeModule.Name -Force -ErrorAction SilentlyContinue } + if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $fixture.Root) { Remove-Item -LiteralPath $fixture.Root -Recurse -Force } + } + } + + Invoke-CcodTest 'official draft schemas reject non-integer numbers and scalar arrays' { + $fixture = New-CcodAcceptanceCandidateFixture + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + try { + Assert-CcodTrue (-not (& $module { param($Value) Test-CcodOfficialDraftPositiveInteger $Value } ([double]2.0))) 'floating point pseudo-integer is rejected' + Assert-CcodTrue (-not (& $module { param($Value) Test-CcodOfficialDraftPositiveInteger $Value } ([decimal]2))) 'decimal pseudo-integer is rejected' + $freshFacts = New-CcodAcceptanceFacts + $freshFacts.activeGeneration = [double]2 + Assert-CcodThrows { + &$module { param($Facts,$KeyHash) Assert-CcodOfficialDraftFreshFacts -Facts $Facts -ExpectedKeyHash $KeyHash } $freshFacts ('b' * 64) | Out-Null + } 'CCOD_ACCEPTANCE_FRESH_INSTALL_OBSERVATION_INVALID' + $receipt = New-CcodAcceptanceDefenderReceipt -Fixture $fixture -AssetType Setup + $receipt.assetName = @([string]$receipt.assetName) + $candidate = [pscustomobject][ordered]@{ + version = $fixture.Version; gitCommit = $fixture.GitCommit + assetHashes = @($fixture.Contract.Assets) + manifestHashes = [pscustomobject][ordered]@{ portable = $fixture.Contract.PortableManifestSha256; setup = $fixture.Contract.SetupManifestSha256 } + } + Assert-CcodThrows { + &$module { param($Receipt,$Candidate) Assert-CcodOfficialDraftDefenderReceipt -Receipt $Receipt -Candidate $Candidate -AssetType Setup } $receipt $candidate | Out-Null + } 'CCOD_ACCEPTANCE_DEFENDER_RECEIPT_INVALID' + } finally { + if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + if ($null -ne $fixture -and (Test-Path -LiteralPath $fixture.Root)) { Remove-Item -LiteralPath $fixture.Root -Recurse -Force -ErrorAction SilentlyContinue } + } + } + + Invoke-CcodTest 'official draft phase operation requires completed and outcome to agree' { + $fixture = New-CcodAcceptanceCandidateFixture + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + try { + $state = [pscustomobject]@{ RunCalls = [Collections.Generic.List[string]]::new() } + $adapters = New-CcodAcceptancePhaseAdapters -Fixture $fixture -State $state + $adapters.RunPhase = { param($Context) [pscustomobject][ordered]@{ completed = $false; outcome = 'Completed'; installerSha256 = [string]$fixture.Contract.Assets[5].sha256; runtimeManifestSha256 = ('1' * 64) } }.GetNewClosure() + $context = [pscustomobject][ordered]@{ + setupPath = Join-Path $fixture.Root $fixture.Names[5] + candidate = [pscustomobject][ordered]@{ assetHashes = @($fixture.Contract.Assets) } + } + Assert-CcodThrows { + &$module { param($Adapters,$Context) Invoke-CcodOfficialDraftPhaseOperation -Adapters $Adapters -Context $Context } $adapters $context | Out-Null + } 'CCOD_ACCEPTANCE_MACHINE_OPERATION_FAILED' + } finally { + if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $fixture.Root) { Remove-Item -LiteralPath $fixture.Root -Recurse -Force } + } + } + + Invoke-CcodTest 'official draft persisted hash validators use absolute end anchors' { + $source = [IO.File]::ReadAllText($modulePath, [Text.UTF8Encoding]::new($false)) + Assert-CcodTrue (-not $source.Contains("'^[0-9a-f]{40}$'")) 'acceptance commit validators reject trailing newline variants' + Assert-CcodTrue (-not $source.Contains("'^[0-9a-f]{64}$'")) 'acceptance hash validators reject trailing newline variants' + } + + Invoke-CcodTest 'official draft JSONL rejects duplicate object properties' { + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + try { + Assert-CcodThrows { &$module { param($Raw) Assert-CcodOfficialDraftJsonNoDuplicateProperties -Raw $Raw } '{"code":"first","code":"second"}' } 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID' + Assert-CcodThrows { &$module { param($Raw) Assert-CcodOfficialDraftJsonNoDuplicateProperties -Raw $Raw } '{"code":"first","\u0063ode":"second"}' } 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID' + } finally { if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } } + } + + Invoke-CcodTest 'official draft debug endpoints require an owning verified process' { + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + try { + Assert-CcodThrows { &$module { param($Endpoints) Assert-CcodOfficialDraftDebugEndpointOwnership -Endpoints $Endpoints -ExpectedPorts @(9229) -ExpectedPid 13948 -ExpectedCreationTimeUtc '2030-02-03T04:05:02.0000000Z' } @([pscustomobject]@{ localAddress = '127.0.0.1'; localPort = 9229 }) } 'CCOD_ACCEPTANCE_OBSERVATION_UNAVAILABLE' + } finally { if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } } + } + + Invoke-CcodTest 'official draft debug endpoint owning process rejects Int32 wrapping values' { + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + try { + $endpoint = [pscustomobject][ordered]@{ localAddress = '127.0.0.1'; localPort = 9229; owningProcess = [uint64]4294981244; owningProcessCreationTimeUtc = '2030-02-03T04:05:02.0000000Z' } + Assert-CcodThrows { &$module { param($Endpoints) Assert-CcodOfficialDraftDebugEndpointOwnership -Endpoints $Endpoints -ExpectedPorts @(9229) -ExpectedPid 13948 -ExpectedCreationTimeUtc '2030-02-03T04:05:02.0000000Z' } @($endpoint) } 'CCOD_ACCEPTANCE_OBSERVATION_UNAVAILABLE' + } finally { if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } } + } + + Invoke-CcodTest 'official draft tray and remote proofs reject noncanonical timestamps' { + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + try { + $tray = [pscustomobject][ordered]@{ timestampUtc = '2030-02-03T04:05:06+00:00'; command = 'ShowAbout'; revision = [UInt64]1; code = 'CCOD_TRAY_ACTION_COMPLETED'; status = 'Completed' } + Assert-CcodThrows { &$module { param($Proof) ConvertTo-CcodOfficialDraftTrayProof -Proof $Proof -Operation About } $tray } 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID' + $remote = [pscustomobject][ordered]@{ + schemaVersion = 1; timestampUtc = '2030-02-03T04:05:06+00:00'; kind = 'remote-control-manual-proof'; operation = 'SecondDeviceControl'; deviceRole = 'SecondDevice'; challenge = 'run-1'; candidateVersion = '2.5.22'; runtimeId = 'runtime-2.5.22'; runtimeGeneration = [UInt64]2; runtimeManifestSha256 = ('d' * 64); attestation = 'HumanReviewedStructuredAttestation'; connection = 'Connected'; control = 'Completed'; outcome = 'Completed'; code = 'CCOD_REMOTE_ACTION_COMPLETED' + } + Assert-CcodThrows { &$module { param($Proof) ConvertTo-CcodOfficialDraftRemoteProof -Proof $Proof } $remote } 'CCOD_ACCEPTANCE_REMOTE_UNPROVEN' + } finally { if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } } + } + + Invoke-CcodTest 'official draft tray proof requires a real terminal action record' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-tray-proof-' + [guid]::NewGuid().ToString('N')) + $log = Join-Path $root 'supervisor.log' + [IO.Directory]::CreateDirectory($root) | Out-Null + $record = [ordered]@{ schemaVersion = 1; timestampUtc = '2030-02-03T04:05:07.0000000Z'; component = 'Supervisor'; stage = 'TrayAction'; code = 'CCOD_TRAY_ACTION_COMPLETED'; outcome = 'Completed'; command = 'ShowAbout'; revision = [UInt64]1; status = 'Completed' } + [IO.File]::WriteAllText($log, (($record | ConvertTo-Json -Compress) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + try { + $proof = &$module { param($Path) Get-CcodOfficialDraftTrayActionProof -Path $Path -Operation About -NotBefore ([DateTimeOffset]::Parse('2030-02-03T04:05:06.0000000Z')) } $log + Assert-CcodEqual 'ShowAbout' ([string]$proof.command) 'tray proof binds the actual command' + [IO.File]::WriteAllText($log, (($record | ConvertTo-Json -Compress) + [Environment]::NewLine + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + Assert-CcodThrows { &$module { param($Path) Get-CcodOfficialDraftTrayActionProof -Path $Path -Operation About } $log | Out-Null } 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID' + $arrayRecord = [ordered]@{} + foreach ($key in $record.Keys) { $arrayRecord[$key] = $record[$key] } + $arrayRecord.stage = @('TrayAction') + [IO.File]::WriteAllText($log, (($arrayRecord | ConvertTo-Json -Compress) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + Assert-CcodThrows { &$module { param($Path) Get-CcodOfficialDraftTrayActionProof -Path $Path -Operation About } $log | Out-Null } 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID' + [IO.File]::WriteAllText($log, (($record | ConvertTo-Json -Compress) + [Environment]::NewLine + '{"stage":"TrayAction"}' + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + Assert-CcodThrows { &$module { param($Path) Get-CcodOfficialDraftTrayActionProof -Path $Path -Operation About -NotBefore ([DateTimeOffset]::Parse('2030-02-03T04:05:06.0000000Z')) } $log | Out-Null } 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID' + [IO.File]::WriteAllText($log, 'completed', [Text.UTF8Encoding]::new($false)) + Assert-CcodThrows { &$module { param($Path) Get-CcodOfficialDraftTrayActionProof -Path $Path -Operation About } $log | Out-Null } 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID' + } finally { + if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } + } + } + + Invoke-CcodTest 'official draft tray ready proof selects the current host from historical records' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-tray-ready-history-' + [guid]::NewGuid().ToString('N')) + $logDirectory = Join-Path $root 'logs' + $log = Join-Path $logDirectory 'supervisor.log' + $module = $null + try { + [IO.Directory]::CreateDirectory($logDirectory) | Out-Null + $base = [ordered]@{ schemaVersion = 1; timestampUtc = '2030-02-03T04:05:07.0000000Z'; component = 'Supervisor'; stage = 'TrayHostReady'; code = 'CCOD_TRAYHOST_READY'; outcome = 'Completed'; runtimeId = 'runtime-2.5.21'; hostPid = 101; hostCreationTimeUtc = '2030-02-03T04:04:01.0000000Z'; protocolMajor = 2; capabilities = [UInt64]2 } + $current = [ordered]@{ schemaVersion = 1; timestampUtc = $base.timestampUtc; component = 'Supervisor'; stage = 'TrayHostReady'; code = 'CCOD_TRAYHOST_READY'; outcome = 'Completed'; runtimeId = 'runtime-2.5.22'; hostPid = 201; hostCreationTimeUtc = '2030-02-03T04:05:01.0000000Z'; protocolMajor = 2; capabilities = [UInt64]2 } + [IO.File]::WriteAllText($log, (($base | ConvertTo-Json -Compress) + [Environment]::NewLine + ($current | ConvertTo-Json -Compress) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + $proof = &$module { param($InstallRoot,$Hosts) Get-CcodOfficialDraftTrayHostReadyProof -InstallRoot $InstallRoot -RuntimeId 'runtime-2.5.22' -TrayHosts $Hosts } $root @([pscustomobject]@{ pid = 201; creationTimeUtc = '2030-02-03T04:05:01.0000000Z' }) + Assert-CcodTrue ($null -ne $proof -and [int]$proof.hostPid -eq 201) 'current TrayHost identity is selected from historical ready records' + $oldRuntimeCurrentHost = [ordered]@{} + foreach ($key in $current.Keys) { $oldRuntimeCurrentHost[$key] = $current[$key] } + $oldRuntimeCurrentHost.runtimeId = 'runtime-2.5.21' + [IO.File]::WriteAllText($log, (($oldRuntimeCurrentHost | ConvertTo-Json -Compress) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + $oldRuntimeProof = &$module { param($InstallRoot,$Hosts) Get-CcodOfficialDraftTrayHostReadyProof -InstallRoot $InstallRoot -RuntimeId 'runtime-2.5.22' -TrayHosts $Hosts } $root @([pscustomobject]@{ pid = 201; creationTimeUtc = '2030-02-03T04:05:01.0000000Z' }) + Assert-CcodTrue ($null -eq $oldRuntimeProof) 'matching process identity from another runtime cannot prove current readiness' + $offsetReady = [ordered]@{} + foreach ($key in $current.Keys) { $offsetReady[$key] = $current[$key] } + $offsetReady.hostCreationTimeUtc = '2030-02-03T04:05:01.0000000+00:00' + [IO.File]::WriteAllText($log, (($base | ConvertTo-Json -Compress) + [Environment]::NewLine + ($offsetReady | ConvertTo-Json -Compress) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + Assert-CcodThrows { &$module { param($InstallRoot,$Hosts) Get-CcodOfficialDraftTrayHostReadyProof -InstallRoot $InstallRoot -RuntimeId 'runtime-2.5.22' -TrayHosts $Hosts } $root @([pscustomobject]@{ pid = 201; creationTimeUtc = '2030-02-03T04:05:01.0000000Z' }) } 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID' + $arrayReady = [ordered]@{} + foreach ($key in $current.Keys) { $arrayReady[$key] = $current[$key] } + $arrayReady.stage = @('TrayHostReady') + [IO.File]::WriteAllText($log, (($base | ConvertTo-Json -Compress) + [Environment]::NewLine + ($arrayReady | ConvertTo-Json -Compress) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + Assert-CcodThrows { &$module { param($InstallRoot,$Hosts) Get-CcodOfficialDraftTrayHostReadyProof -InstallRoot $InstallRoot -RuntimeId 'runtime-2.5.22' -TrayHosts $Hosts } $root @([pscustomobject]@{ pid = 201; creationTimeUtc = '2030-02-03T04:05:01.0000000Z' }) } 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID' + $withinCompatibility = [ordered]@{ schemaVersion = 1; timestampUtc = $base.timestampUtc; component = 'Supervisor'; stage = 'TrayHostReady'; code = 'CCOD_TRAYHOST_READY'; outcome = 'Completed'; runtimeId = 'runtime-2.5.22'; hostPid = 201; hostCreationTimeUtc = '2030-02-03T04:05:01.0000001Z'; protocolMajor = 2; capabilities = 7 } + [IO.File]::WriteAllText($log, (($base | ConvertTo-Json -Compress) + [Environment]::NewLine + ($withinCompatibility | ConvertTo-Json -Compress) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + $withinProof = &$module { param($InstallRoot,$Hosts) Get-CcodOfficialDraftTrayHostReadyProof -InstallRoot $InstallRoot -RuntimeId 'runtime-2.5.22' -TrayHosts $Hosts } $root @([pscustomobject]@{ pid = 201; creationTimeUtc = '2030-02-03T04:05:01.0000000Z' }) + Assert-CcodTrue ($null -eq $withinProof) 'native creation identity requires exact equality even within one CIM microsecond' + $near = [ordered]@{ schemaVersion = 1; timestampUtc = $base.timestampUtc; component = 'Supervisor'; stage = 'TrayHostReady'; code = 'CCOD_TRAYHOST_READY'; outcome = 'Completed'; runtimeId = 'runtime-2.5.22'; hostPid = 201; hostCreationTimeUtc = '2030-02-03T04:05:01.0000010Z'; protocolMajor = 2; capabilities = 7 } + [IO.File]::WriteAllText($log, (($base | ConvertTo-Json -Compress) + [Environment]::NewLine + ($near | ConvertTo-Json -Compress) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + $nearProof = &$module { param($InstallRoot,$Hosts) Get-CcodOfficialDraftTrayHostReadyProof -InstallRoot $InstallRoot -RuntimeId 'runtime-2.5.22' -TrayHosts $Hosts } $root @([pscustomobject]@{ pid = 201; creationTimeUtc = '2030-02-03T04:05:01.0000000Z' }) + Assert-CcodTrue ($null -eq $nearProof) 'creation identity outside the observed native instant is rejected' + } finally { + if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } + } + } + + Invoke-CcodTest 'default capture preserves exact native tray readiness and collector rejection' { + $root=Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-native-ready-capture-'+[guid]::NewGuid().ToString('N')) + $logs=Join-Path $root 'logs';$log=Join-Path $logs 'supervisor.log';$module=$null;$integration=$null + try { + [IO.Directory]::CreateDirectory($logs)|Out-Null + $facts=New-CcodAcceptanceRawFacts + $facts.trayHost[0].CreationTimeUtc='2030-02-03T04:05:01.0000006Z' + $facts.trayHostIdentity.creationTimeUtc=$facts.trayHost[0].CreationTimeUtc + $state=[pscustomobject]@{Facts=$facts;Calls=0;ProofCalls=0;OverrideAuth=$null} + $record=[ordered]@{schemaVersion=1;timestampUtc='2030-02-03T04:05:07.0000000Z';component='Supervisor';stage='TrayHostReady';code='CCOD_TRAYHOST_READY';outcome='Completed';runtimeId=$facts.activeRuntimeId;hostPid=201;hostCreationTimeUtc=$facts.trayHost[0].CreationTimeUtc;protocolMajor=2;capabilities=2} + [IO.File]::WriteAllText($log,(($record|ConvertTo-Json -Compress)+"`n"),[Text.UTF8Encoding]::new($false)) + $module=Import-Module $modulePath -Force -PassThru -DisableNameChecking + $integration=&$module {Get-CcodOfficialDraftIntegrationModule} + &$integration { + param($State) + $script:CcodNativeReadyState=$State + function script:Get-CcodInstalledLifecycleFacts { + param($InstallRoot,$ExpectedVersion) + $s=$script:CcodNativeReadyState;$s.Calls++;$value=$s.Facts.PSObject.Copy();$s.ProofCalls++ + $value.trayAuthenticated=Get-CcodInstalledLifecycleTrayHostReadyProof -InstallRoot $InstallRoot -RuntimeId $value.activeRuntimeId -TrayHost $value.trayHost + if($null-ne$s.OverrideAuth){$value.trayAuthenticated=$s.OverrideAuth} + return $value + } + } $state + $capture=&$module {param($Root,$Integration)$script:CcodOfficialDraftInstallRoot=$Root;$script:CcodOfficialDraftIntegrationModule=$Integration;(Get-CcodOfficialDraftDefaultAdapters).CaptureFacts} $root $integration + $context=[pscustomobject]@{phase='PostReboot';expectedVersion='2.5.22'} + $positive=&$capture $context 'After' + Assert-CcodTrue $positive.trayAuthenticated 'exact nonzero native tick identity passes both real matchers' + Assert-CcodTrue $positive.protectionReady 'positive default capture proves readiness' + $record.hostCreationTimeUtc='2030-02-03T04:05:01.0000007Z' + [IO.File]::WriteAllText($log,(($record|ConvertTo-Json -Compress)+"`n"),[Text.UTF8Encoding]::new($false)) + $changed=&$capture $context 'After' + Assert-CcodEqual 2 $state.ProofCalls 'changed identity reaches real integration matcher' + Assert-CcodEqual $false $changed.trayAuthenticated 'one changed native tick cannot regain authentication in default capture' + Assert-CcodEqual $false $changed.protectionReady 'mismatched native identity is not protection ready' + $record.hostCreationTimeUtc=$facts.trayHost[0].CreationTimeUtc + [IO.File]::WriteAllText($log,(($record|ConvertTo-Json -Compress)+"`n"),[Text.UTF8Encoding]::new($false)) + $state.OverrideAuth=$false + $rejected=&$capture $context 'After' + Assert-CcodEqual $false $rejected.trayAuthenticated 'exact log cannot override collector authentication rejection' + foreach($invalid in @('true',1,@($true))){$state.OverrideAuth=$invalid;Assert-CcodThrows {&$capture $context 'After'|Out-Null} 'CCOD_ACCEPTANCE_OBSERVATION_UNAVAILABLE'} + $state.OverrideAuth=$null + Assert-CcodTrue ((&$capture $context 'After').protectionReady) 'clean retry is possible after rejected observations' + } finally { + if($null-ne$integration){Remove-Module $integration -Force -ErrorAction SilentlyContinue} + if($null-ne$module){Remove-Module $module -Force -ErrorAction SilentlyContinue} + if(Test-Path -LiteralPath $root){Remove-Item -LiteralPath $root -Recurse -Force} + } + } + + Invoke-CcodTest 'official draft tray ready proof rejects noncanonical timestamps' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-tray-ready-timestamp-' + [guid]::NewGuid().ToString('N')) + $logDirectory = Join-Path $root 'logs' + $log = Join-Path $logDirectory 'supervisor.log' + $module = $null + try { + [IO.Directory]::CreateDirectory($logDirectory) | Out-Null + $record = [ordered]@{ schemaVersion = 1; timestampUtc = 'not-a-canonical-utc'; component = 'Supervisor'; stage = 'TrayHostReady'; code = 'CCOD_TRAYHOST_READY'; outcome = 'Completed'; runtimeId = 'runtime-2.5.22'; hostPid = 201; hostCreationTimeUtc = '2030-02-03T04:05:01.0000000Z'; protocolMajor = 2; capabilities = [UInt64]2 } + [IO.File]::WriteAllText($log, (($record | ConvertTo-Json -Compress) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + Assert-CcodThrows { &$module { param($InstallRoot) Get-CcodOfficialDraftTrayHostReadyProof -InstallRoot $InstallRoot -RuntimeId 'runtime-2.5.22' -TrayHosts @([pscustomobject]@{ pid = 201; creationTimeUtc = '2030-02-03T04:05:01.0000000Z' }) } $root } 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID' + } finally { + if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } + } + } + + Invoke-CcodTest 'official draft default tray operation verifies live and redacted terminal records' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-default-tray-operation-' + [guid]::NewGuid().ToString('N')) + $logDirectory = Join-Path $root 'logs' + $liveLog = Join-Path $logDirectory 'supervisor.log' + $redactedLog = Join-Path $root 'redacted.log' + $screenshot = Join-Path $root 'screenshot.png' + [IO.Directory]::CreateDirectory((Join-Path $root 'runtime')) | Out-Null + [IO.Directory]::CreateDirectory($logDirectory) | Out-Null + [IO.File]::WriteAllText((Join-Path $root 'active.json'), '{}', [Text.UTF8Encoding]::new($false)) + $future = [DateTimeOffset]::UtcNow.AddMinutes(5).ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss.fffffff'Z'",[Globalization.CultureInfo]::InvariantCulture) + $readyRecord = [ordered]@{ schemaVersion = 1; timestampUtc = $future; component = 'Supervisor'; stage = 'TrayHostReady'; code = 'CCOD_TRAYHOST_READY'; outcome = 'Completed'; runtimeId = 'runtime-2.5.22'; hostPid = 201; hostCreationTimeUtc = '2030-02-03T04:05:01.0000000Z'; protocolMajor = 2; capabilities = [UInt64]2 } + $actionRecord = [ordered]@{ schemaVersion = 1; timestampUtc = $future; component = 'Supervisor'; stage = 'TrayAction'; code = 'CCOD_TRAY_ACTION_COMPLETED'; outcome = 'Completed'; command = 'ShowAbout'; revision = [UInt64]1; status = 'Completed' } + $serialized = (($readyRecord | ConvertTo-Json -Compress) + "`n" + ($actionRecord | ConvertTo-Json -Compress) + [Environment]::NewLine) + [IO.File]::WriteAllText($liveLog, $serialized, [Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($redactedLog, (($actionRecord | ConvertTo-Json -Compress) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($screenshot, 'fixture screenshot', [Text.UTF8Encoding]::new($false)) + $facts = [pscustomobject][ordered]@{ + installRootPresent = $true; appPresent = $true; runtimeRootPresent = $true; activePointerPresent = $true; activeRuntimeId = 'runtime-2.5.22'; activeGeneration = [UInt64]2; runtimeManifestSha256 = ('d' * 64) + supervisor = @([pscustomobject]@{ Pid = 200; CreationTimeUtc = '2030-02-03T04:05:00.0000000Z' }) + trayHost = @([pscustomobject]@{ Pid = 201; CreationTimeUtc = '2030-02-03T04:05:01.0000000Z' }) + trayHostIdentity = [pscustomobject][ordered]@{ pid = 201; creationTimeUtc = '2030-02-03T04:05:01.0000000Z' } + codex = @([pscustomobject]@{ Pid = 202; CreationTimeUtc = '2030-02-03T04:05:02.0000000Z' }) + trayAuthenticated = $true + taskState = 'Ready'; statusPhase = 'Active'; statusRuntimeId = 'runtime-2.5.22' + statusCodex = [pscustomobject]@{ pid = 202; creationTimeUtc = '2030-02-03T04:05:02.0000000Z' } + transitionStage = 'Idle'; lifecycleReceipt = [pscustomobject][ordered]@{ kind = 'RestartAndRepair'; origin = 'Installer'; runtimeId = 'runtime-2.5.22'; runtimeGeneration = [UInt64]2; phase = 'Completed' } + aboutVersion = '2.5.22'; deviceKeyPresent = $true; deviceKeySha256 = ('b' * 64) + shortcuts = [pscustomobject][ordered]@{ startMenu = $true; desktop = $true } + debugEndpoints = @([pscustomobject][ordered]@{ localAddress = '127.0.0.1'; localPort = 9229; owningProcess = 202; owningProcessCreationTimeUtc = '2030-02-03T04:05:02.0000000Z' },[pscustomobject][ordered]@{ localAddress = '127.0.0.1'; localPort = 9230; owningProcess = 202; owningProcessCreationTimeUtc = '2030-02-03T04:05:02.0000000Z' }) + debugPorts = @(9229,9230) + } + $fakeIntegration = New-Module -ArgumentList $facts -ScriptBlock { + param($Facts) + function Get-CcodInstalledLifecycleFacts { param([string]$InstallRoot,[string]$ExpectedVersion); return $Facts } + Export-ModuleMember -Function Get-CcodInstalledLifecycleFacts + } + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + try { + Assert-CcodEqual $null $facts.PSObject.Properties['codexCount'] 'raw integration output has identities rather than a precomputed codex count' + $observation = &$module { + param($InstallRoot,$EvidencePath,$FakeIntegration,$ScreenshotPath) + $script:CcodOfficialDraftManualAcknowledgement = { param([string]$Prompt) return 'CCOD_MANUAL_ABOUT_COMPLETED' } + $script:CcodOfficialDraftInstallRoot = $InstallRoot + $script:CcodOfficialDraftIntegrationModule = $FakeIntegration + $adapters = Get-CcodOfficialDraftDefaultAdapters + $context = [pscustomobject][ordered]@{ screenshotPath = $ScreenshotPath; redactedLogPath = $EvidencePath; expectedVersion = '2.5.22'; manualAcknowledgement = 'CCOD_MANUAL_ABOUT_COMPLETED' } + $captured = & $adapters.CaptureFacts ([pscustomobject]@{ phase = 'PostReboot'; expectedVersion = '2.5.22' }) 'After' + $result = & $adapters.RunManualOperation $context 'About' + [pscustomobject]@{ Captured = $captured; Result = $result; Capture = $adapters.CaptureFacts } + } $root $redactedLog $fakeIntegration $screenshot + $result = $observation.Result + Assert-CcodEqual 1 $observation.Captured.codexCount 'default capture derives the count from the observed identities' + Assert-CcodEqual 'Idle' $observation.Captured.transitionStage 'default capture preserves transition state for core validation' + $coreObservation = & $module { param($Captured) ConvertTo-CcodOfficialDraftLegacyObservation -Facts $Captured -ExpectedVersion '2.5.22' } $observation.Captured + Assert-CcodEqual '2.5.22' $coreObservation.version 'actual default capture output passes the strict core observation contract' + Assert-CcodEqual 1 $coreObservation.codex.Count 'strict core observes the same single Codex identity' + foreach ($kind in @('supervisor','trayHost','codex')) { + Assert-CcodEqual 'pid,creationTimeUtc' ($observation.Captured.$kind[0].PSObject.Properties.Name -join ',') 'native producer identity fields become the canonical core schema' + } + $captureContext = [pscustomobject]@{ phase = 'PostReboot'; expectedVersion = '2.5.22' } + $facts.transitionStage = 'NotIdle' + $busy = & $observation.Capture $captureContext 'After' + Assert-CcodEqual 'NotIdle' $busy.transitionStage 'nonterminal transition state is retained' + Assert-CcodEqual $false $busy.protectionReady 'busy transition cannot be promoted to ready by the adapter' + $facts.transitionStage = 'Idle' + $facts.activeGeneration = @([UInt64]2) + Assert-CcodThrows { & $observation.Capture $captureContext 'After' | Out-Null } 'CCOD_ACCEPTANCE_OBSERVATION_UNAVAILABLE' + $facts.activeGeneration = [UInt64]2 + $facts.transitionStage = @('Idle') + Assert-CcodThrows { & $observation.Capture $captureContext 'After' | Out-Null } 'CCOD_ACCEPTANCE_OBSERVATION_UNAVAILABLE' + $facts.transitionStage = 'Idle' + $facts.codex[0].Pid = '202' + Assert-CcodThrows { & $observation.Capture $captureContext 'After' | Out-Null } 'CCOD_ACCEPTANCE_OBSERVATION_UNAVAILABLE' + $facts.codex[0].Pid = 202 + $facts | Add-Member -NotePropertyName codexCount -NotePropertyValue 2 + Assert-CcodThrows { & $observation.Capture $captureContext 'After' | Out-Null } 'CCOD_ACCEPTANCE_OBSERVATION_UNAVAILABLE' + $facts.PSObject.Properties.Remove('codexCount') + Assert-CcodTrue ([bool]$result.verified) 'default tray adapter returns verified only after both terminal records match' + Assert-CcodEqual 'CCOD_TRAYABOUT_COMPLETED' ([string]$result.code) 'default tray adapter returns the canonical operation code' + Assert-CcodTrue ($result.screenshotSha256 -is [string] -and $result.screenshotSha256 -match '^[0-9a-f]{64}\z') 'default tray adapter captures the screenshot hash' + Assert-CcodTrue ($result.redactedLogSha256 -is [string] -and $result.redactedLogSha256 -match '^[0-9a-f]{64}\z') 'default tray adapter captures the redacted log hash' + $bad = &$module { + param($InstallRoot,$EvidencePath,$FakeIntegration,$ScreenshotPath) + $script:CcodOfficialDraftManualAcknowledgement = { param([string]$Prompt) return 'CCOD_MANUAL_ABOUT_COMPLETED' } + $script:CcodOfficialDraftInstallRoot = $InstallRoot + $script:CcodOfficialDraftIntegrationModule = $FakeIntegration + $adapters = Get-CcodOfficialDraftDefaultAdapters + $context = [pscustomobject][ordered]@{ screenshotPath = $ScreenshotPath; redactedLogPath = $EvidencePath; expectedVersion = '2.5.22'; expectedRuntimeId = 'runtime-other'; manualAcknowledgement = 'CCOD_MANUAL_ABOUT_COMPLETED' } + try { & $adapters.RunManualOperation $context 'About' | Out-Null; return $null } catch { return $_ } + } $root $redactedLog $fakeIntegration $screenshot + Assert-CcodTrue ($null -ne $bad -and [string]$bad.FullyQualifiedErrorId -like 'CCOD_ACCEPTANCE_MANUAL_OPERATION_UNPROVEN*') 'default tray adapter binds manual proof to the persisted runtime identity' + } finally { + if ($null -ne $module) { & $module { $script:CcodOfficialDraftManualAcknowledgement = $null }; Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + if ($null -ne $fakeIntegration) { Remove-Module -Name $fakeIntegration.Name -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } + } + } + + Invoke-CcodTest 'official draft default absent observation preserves empty arrays and null state' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-absent-capture-' + [guid]::NewGuid().ToString('N')) + $facts = [pscustomobject][ordered]@{ + installRootPresent = $false; appPresent = $false; runtimeRootPresent = $false; activePointerPresent = $false + activeRuntimeId = $null; activeGeneration = $null; runtimeManifestSha256 = $null + supervisor = @(); trayHost = @(); codex = @(); taskState = 'Absent'; statusPhase = 'Unavailable' + statusRuntimeId = $null; statusCodex = $null; transitionStage = 'Unavailable'; lifecycleReceipt = $null; aboutVersion = $null + deviceKeyPresent = $true; deviceKeySha256 = ('b' * 64) + shortcuts = [pscustomobject][ordered]@{ startMenu = $false; desktop = $false } + debugPorts = @(); debugEndpoints = @() + } + $fakeIntegration = New-Module -ArgumentList $facts -ScriptBlock { + param($Facts) + function Get-CcodInstalledLifecycleFacts { param($InstallRoot,$ExpectedVersion) return $Facts } + Export-ModuleMember -Function Get-CcodInstalledLifecycleFacts + } + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + try { + $captured = & $module { + param($InstallRoot,$Integration) + $script:CcodOfficialDraftInstallRoot = $InstallRoot + $script:CcodOfficialDraftIntegrationModule = $Integration + $defaults = Get-CcodOfficialDraftDefaultAdapters + & $defaults.CaptureFacts ([pscustomobject]@{ phase = 'Uninstall'; expectedVersion = '2.5.22' }) 'After' + } $root $fakeIntegration + Assert-CcodEqual $false $captured.installRootPresent 'the read-only capture preserves an actually absent fixture root' + Assert-CcodEqual $false $captured.protectionReady 'absence is never turned into protection readiness' + Assert-CcodEqual 0 $captured.codexCount 'empty producer identities yield zero observed Codex roots' + foreach ($kind in @('supervisor','trayHost','codex')) { + Assert-CcodTrue ($captured.$kind -is [array] -and $captured.$kind.Count -eq 0) 'empty native identity arrays remain arrays' + } + Assert-CcodEqual $null $captured.activeGeneration 'null generation remains null rather than a synthetic zero' + Assert-CcodEqual 'Unavailable' $captured.transitionStage 'absent transition observation is retained' + } finally { + if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + if ($null -ne $fakeIntegration) { Remove-Module -Name $fakeIntegration.Name -Force -ErrorAction SilentlyContinue } + } + } + + Invoke-CcodTest 'official draft uninstall reuses captured ports and rejects every residual binding' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-uninstall-census-' + [guid]::NewGuid().ToString('N')) + $originalNetTcp = ${function:Get-NetTCPConnection} + $module = $null; $fakeIntegration = $null + try { + [IO.Directory]::CreateDirectory($root) | Out-Null + $before = New-CcodAcceptanceFacts + $before | Add-Member -NotePropertyName appPresent -NotePropertyValue $true + $before.PSObject.Properties.Remove('codexCount') + $after = New-CcodAcceptanceFacts -Absent + $after | Add-Member -NotePropertyName appPresent -NotePropertyValue $false + $after | Add-Member -NotePropertyName aboutVersion -NotePropertyValue $null + $after.PSObject.Properties.Remove('codexCount') + $state = [pscustomobject]@{ Facts = $before; Listeners = @(); StatusReads = 0; CensusCalls = 0; FailEnumeration = $false } + # Capture a mutable fixture reference without shadowing the cmdlet's State parameter. + $censusState = $state + Set-Item -LiteralPath Function:\global:Get-NetTCPConnection -Value ({ + param($State,$ErrorAction) + if ($censusState.FailEnumeration) { throw [IO.IOException]::new('fixture census unavailable') } + return @($censusState.Listeners) + }.GetNewClosure()) + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + $realIntegration = & $module { Get-CcodOfficialDraftIntegrationModule } + $fakeIntegration = New-Module -ArgumentList $state,$realIntegration -ScriptBlock { + param($State,$RealIntegration) + function Get-CcodInstalledLifecycleFacts { param($InstallRoot,$ExpectedVersion) return $State.Facts } + function Read-CcodInstalledLifecycleStatusFact { + param($StateRoot) + $State.StatusReads++ + [pscustomobject]@{ session = [pscustomobject]@{ codex = [pscustomobject]@{ mainPort = 9229; rendererPort = 9230 } } } + } + function Test-CcodInstalledLifecycleDebugPortsClosed { + param($Ports) + $State.CensusCalls++ + & $RealIntegration { param($CapturedPorts) Test-CcodInstalledLifecycleDebugPortsClosed -Ports $CapturedPorts } $Ports + } + Export-ModuleMember -Function Get-CcodInstalledLifecycleFacts,Read-CcodInstalledLifecycleStatusFact,Test-CcodInstalledLifecycleDebugPortsClosed + } + $capture = & $module { + param($InstallRoot,$Integration) + $script:CcodOfficialDraftInstallRoot = $InstallRoot + $script:CcodOfficialDraftIntegrationModule = $Integration + (Get-CcodOfficialDraftDefaultAdapters).CaptureFacts + } $root $fakeIntegration + $context = [pscustomobject]@{ phase = 'Uninstall'; expectedVersion = '2.5.22' } + $capturedBefore = & $capture $context 'Before' + Assert-CcodEqual '9229,9230' ($capturedBefore.debugPorts -join ',') 'before capture retains the observed port pair' + Assert-CcodEqual 0 $state.StatusReads 'port capture does not reopen mutable status after taking the observation' + $state.Facts = $after + Remove-Item -LiteralPath $root -Recurse -Force + $capturedAfter = & $capture $context 'After' + Assert-CcodEqual 1 $state.CensusCalls 'after capture uses the real closed-port census despite removed status' + $key = & $module { param($Facts) Assert-CcodOfficialDraftUninstallFacts -Facts $Facts -ExpectedKeyHash ('b' * 64) } $capturedAfter + Assert-CcodEqual ('b' * 64) $key 'complete absence retains the expected key hash' + foreach ($port in @(9229,9230)) { foreach ($address in @('127.0.0.1','0.0.0.0','::','192.0.2.1')) { + $state.Listeners = @([pscustomobject]@{ LocalAddress = $address; LocalPort = [uint16]$port; OwningProcess = [uint32]4 }) + Assert-CcodThrows { & $capture $context 'After' | Out-Null } 'CCOD_ACCEPTANCE_UNINSTALL_OBSERVATION_INVALID' + } } + $state.Listeners = @([pscustomobject]@{ LocalAddress = '::'; LocalPort = [uint16]443; OwningProcess = [uint32]4 }) + $unrelated = & $capture $context 'After' + Assert-CcodEqual 0 $unrelated.debugEndpoints.Count 'unrelated listeners are not evidence of a retained product endpoint' + $state.FailEnumeration = $true + Assert-CcodThrows { & $capture $context 'After' | Out-Null } 'CCOD_ACCEPTANCE_OBSERVATION_UNAVAILABLE' + } finally { + if ($null -eq $originalNetTcp) { Remove-Item -LiteralPath Function:\global:Get-NetTCPConnection -Force -ErrorAction SilentlyContinue } + else { Set-Item -LiteralPath Function:\global:Get-NetTCPConnection -Value $originalNetTcp } + if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + if ($null -ne $fakeIntegration) { Remove-Module -Name $fakeIntegration.Name -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } + } + + Invoke-CcodTest 'manual proof rejects ABA file and ancestor replacement between hashing and parsing' { + foreach ($operation in @('About','SecondDeviceControl')) { foreach ($attack in @('File','Directory')) { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-proof-aba-' + [guid]::NewGuid().ToString('N')) + $module = $null; $fakeIntegration = $null + try { + $evidence = Join-Path $root 'evidence' + [IO.Directory]::CreateDirectory($evidence) | Out-Null + [IO.Directory]::CreateDirectory((Join-Path $root 'logs')) | Out-Null + $target = Join-Path $evidence 'redacted.log' + $screenshot = Join-Path $root 'screenshot.bin' + [IO.File]::WriteAllText($screenshot,'fixture image',[Text.UTF8Encoding]::new($false)) + $freshTime = [DateTimeOffset]::UtcNow.AddMinutes(5).ToString('o') + $freshTime = [DateTimeOffset]::Parse($freshTime).UtcDateTime.ToString('o') + $ready = [ordered]@{ schemaVersion = 1; timestampUtc = $freshTime; component = 'Supervisor'; stage = 'TrayHostReady'; code = 'CCOD_TRAYHOST_READY'; outcome = 'Completed'; runtimeId = 'runtime-2.5.22'; hostPid = 201; hostCreationTimeUtc = '2030-02-03T04:05:01.0000000Z'; protocolMajor = 2; capabilities = 7 } + $action = [ordered]@{ schemaVersion = 1; timestampUtc = $freshTime; component = 'Supervisor'; stage = 'TrayAction'; code = 'CCOD_TRAY_ACTION_COMPLETED'; outcome = 'Completed'; command = 'ShowAbout'; revision = 1; status = 'Completed' } + [IO.File]::WriteAllText((Join-Path $root 'logs\supervisor.log'), (($ready | ConvertTo-Json -Compress) + "`n" + ($action | ConvertTo-Json -Compress) + "`n"), [Text.UTF8Encoding]::new($false)) + $proof = if ($operation -ceq 'About') { $action } else { + [ordered]@{ schemaVersion = 1; timestampUtc = $freshTime; kind = 'remote-control-manual-proof'; operation = 'SecondDeviceControl'; deviceRole = 'SecondDevice'; challenge = 'fixture-challenge'; candidateVersion = '2.5.22'; runtimeId = 'runtime-2.5.22'; runtimeGeneration = 2; runtimeManifestSha256 = ('d' * 64); attestation = 'HumanReviewedStructuredAttestation'; connection = 'Connected'; control = 'Completed'; outcome = 'Completed'; code = 'CCOD_REMOTE_ACTION_COMPLETED' } + } + $goodBytes = [Text.UTF8Encoding]::new($false).GetBytes(($proof | ConvertTo-Json -Compress) + "`n") + $oldProof = [ordered]@{} + foreach ($key in $proof.Keys) { $oldProof[$key] = $proof[$key] } + $oldProof.timestampUtc = [DateTime]::UtcNow.AddDays(-1).ToString('o') + $oldBytes = [Text.UTF8Encoding]::new($false).GetBytes(($oldProof | ConvertTo-Json -Compress) + "`n") + [IO.File]::WriteAllBytes($target,$goodBytes) + $facts = New-CcodAcceptanceFacts + $facts | Add-Member -NotePropertyName appPresent -NotePropertyValue $true + $fakeIntegration = New-Module -ArgumentList $facts -ScriptBlock { + param($Facts) + function Get-CcodInstalledLifecycleFacts { param($InstallRoot,$ExpectedVersion,$ExpectedDebugPorts) return $Facts } + Export-ModuleMember -Function Get-CcodInstalledLifecycleFacts + } + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + $run = & $module { + param($Root,$Integration) + $script:CcodOfficialDraftInstallRoot = $Root + $script:CcodOfficialDraftIntegrationModule = $Integration + (Get-CcodOfficialDraftDefaultAdapters).RunManualOperation + } $root $fakeIntegration + $context = [pscustomobject]@{ screenshotPath = $screenshot; redactedLogPath = $target; expectedVersion = '2.5.22'; manualChallenge = 'fixture-challenge'; manualAcknowledgement = ('CCOD_MANUAL_' + $operation.ToUpperInvariant() + '_COMPLETED') } + $control = & $run $context $operation + Assert-CcodTrue $control.verified 'the real manual operation reaches a valid proof before the attack' + [IO.File]::WriteAllBytes($target,$oldBytes) + $oldHash = Get-CcodTestFileSha256 $target + $state = [pscustomobject]@{ Target = $target; Parent = $evidence; Backup = ($evidence + '-moved'); OldBytes = $oldBytes; GoodBytes = $goodBytes; Mode = $attack; Attempts = 0; Replaced = $false } + & $module { + param($State) + $script:CcodProofRace = $State + $script:CcodProofRaceOriginal = ${function:Get-CcodOfficialDraftJsonLines} + function script:Get-CcodOfficialDraftJsonLines { + param([string]$Path,[AllowNull()][Nullable[DateTimeOffset]]$NotBefore) + $race = $script:CcodProofRace + if ($Path -cne $race.Target) { return & $script:CcodProofRaceOriginal -Path $Path -NotBefore $NotBefore } + $race.Attempts++ + $changed = $false + try { + if ($race.Mode -ceq 'Directory') { + [IO.Directory]::Move($race.Parent,$race.Backup) + $changed = $true + [IO.Directory]::CreateDirectory($race.Parent) | Out-Null + } + [IO.File]::WriteAllBytes($race.Target,$race.GoodBytes) + $changed = $true; $race.Replaced = $true + return & $script:CcodProofRaceOriginal -Path $Path -NotBefore $NotBefore + } finally { + if ($changed) { + if ($race.Mode -ceq 'Directory') { [IO.Directory]::Delete($race.Parent,$true); [IO.Directory]::Move($race.Backup,$race.Parent) } + else { [IO.File]::WriteAllBytes($race.Target,$race.OldBytes) } + } + } + } + } $state + $accepted = $false + try { $result = & $run $context $operation; $accepted = [bool]$result.verified } catch { } + Assert-CcodEqual 1 $state.Attempts 'the attack reaches the actual semantic-read boundary' + Assert-CcodEqual $oldHash (Get-CcodTestFileSha256 $target) 'the ABA sequence retains the original hashed bytes' + Assert-CcodEqual $false $accepted 'an ABA-swapped proof cannot be accepted under the original file hash' + Assert-CcodEqual $false $state.Replaced 'held file and ancestor authority blocks replacement' + [IO.File]::WriteAllBytes($target,$goodBytes) + Assert-CcodTrue ((Get-CcodTestFileSha256 $target) -cne $oldHash) 'failure releases evidence handles for a later legitimate retry' + } finally { + if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + if ($null -ne $fakeIntegration) { Remove-Module -Name $fakeIntegration.Name -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } + } } + } + + Invoke-CcodTest 'manual read authority rejects existing writers and releases partial acquisition' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-evidence-read-lease-' + [guid]::NewGuid().ToString('N')) + $module = $null; $writer = $null; $lease = $null + try { + [IO.Directory]::CreateDirectory($root) | Out-Null + $first = Join-Path $root 'first evidence.bin' + $second = Join-Path $root 'second.log' + [IO.File]::WriteAllText($first,'first original') + [IO.File]::WriteAllText($second,'second original') + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + $writer = [IO.File]::Open($second,[IO.FileMode]::Open,[IO.FileAccess]::ReadWrite,([IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete)) + Assert-CcodThrows { & $module { param($Paths) Open-CcodOfficialDraftEvidenceReadLease -Paths $Paths } @($first,$second) | Out-Null } 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID' + [IO.File]::WriteAllText($first,'first after rejected acquisition') + Assert-CcodEqual 'first after rejected acquisition' ([IO.File]::ReadAllText($first)) 'a failed second-file acquisition releases the first file' + $writer.Dispose(); $writer = $null + $lease = & $module { param($Paths) Open-CcodOfficialDraftEvidenceReadLease -Paths $Paths } @($first,$second) + [IO.File]::WriteAllText((Join-Path $root 'unrelated-sibling.txt'),'allowed sibling mutation') + & $module { param($Lease) Assert-CcodOfficialDraftEvidenceReadLease -Lease $Lease } $lease + $blocked = $false + try { [IO.File]::WriteAllText($first,'must not replace held bytes') } catch [IO.IOException] { $blocked = $true } + Assert-CcodTrue $blocked 'successful read authority excludes concurrent data writers' + & $module { param($Lease) Close-CcodOfficialDraftEvidenceReadLease -Lease $Lease } $lease + Assert-CcodTrue $lease.Closed 'read lease records closure' + [IO.File]::WriteAllText($first,'first after release') + [IO.File]::WriteAllText($second,'second after release') + Assert-CcodEqual 'second after release' ([IO.File]::ReadAllText($second)) 'all evidence files become writable after release' + } finally { + if ($null -ne $writer) { $writer.Dispose() } + if ($null -ne $lease -and $null -ne $module) { & $module { param($Lease) Close-CcodOfficialDraftEvidenceReadLease -Lease $Lease } $lease } + if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } + } + + Invoke-CcodTest 'manual artifact copy rejects changed bytes before publication and removes its own residue' { + foreach ($changedKind in @('Screenshot','RedactedLog')) { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-artifact-copy-change-' + [guid]::NewGuid().ToString('N')) + $module = $null + try { + [IO.Directory]::CreateDirectory($root) | Out-Null + $screenshot = Join-Path $root 'source-image.bin' + $log = Join-Path $root 'source.log' + [IO.File]::WriteAllText($screenshot,'original screenshot bytes',[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($log,'original redacted log bytes',[Text.UTF8Encoding]::new($false)) + $screenshotHash = Get-CcodTestFileSha256 $screenshot + $logHash = Get-CcodTestFileSha256 $log + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + $write = { + param($Destination) + & $module { + param($Directory,$Image,$Log,$ImageHash,$LogHash) + Write-CcodOfficialDraftManualArtifacts -ArtifactDirectory $Directory -Operation About -ScreenshotPath $Image -RedactedLogPath $Log -ExpectedScreenshotSha256 $ImageHash -ExpectedRedactedLogSha256 $LogHash + } $Destination $screenshot $log $screenshotHash $logHash + } + $control = & $write (Join-Path $root 'control') + Assert-CcodEqual $screenshotHash (Get-CcodTestFileSha256 $control.Screenshot) 'unmodified copy control publishes the checked screenshot' + Assert-CcodEqual $logHash (Get-CcodTestFileSha256 $control.RedactedLog) 'unmodified copy control publishes the checked log' + $changedPath = if ($changedKind -ceq 'Screenshot') { $screenshot } else { $log } + [IO.File]::WriteAllText($changedPath,'bytes changed after caller hashing',[Text.UTF8Encoding]::new($false)) + $destination = Join-Path $root 'changed' + Assert-CcodThrows { & $write $destination | Out-Null } 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID' + Assert-CcodEqual 0 @(Get-ChildItem -LiteralPath $destination -Recurse -File).Count 'hash mismatch leaves no temporary or published file owned by this attempt' + [IO.File]::WriteAllText($screenshot,'original screenshot bytes',[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($log,'original redacted log bytes',[Text.UTF8Encoding]::new($false)) + $retry = & $write $destination + Assert-CcodEqual $screenshotHash (Get-CcodTestFileSha256 $retry.Screenshot) 'a clean retry is not blocked by failed-copy residue' + Assert-CcodEqual $logHash (Get-CcodTestFileSha256 $retry.RedactedLog) 'retry log retains the originally checked bytes' + } finally { + if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } + } + } + + Invoke-CcodTest 'official draft remote proof binds the current acceptance challenge' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-remote-proof-' + [guid]::NewGuid().ToString('N')) + $proofPath = Join-Path $root 'remote-proof.json' + [IO.Directory]::CreateDirectory($root) | Out-Null + $proofTimestamp = [DateTimeOffset]::UtcNow.AddMinutes(5).ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss.fffffff'Z'",[Globalization.CultureInfo]::InvariantCulture) + $proof = [ordered]@{ schemaVersion = 1; timestampUtc = $proofTimestamp; kind = 'remote-control-manual-proof'; operation = 'SecondDeviceControl'; deviceRole = 'SecondDevice'; challenge = 'run-1'; candidateVersion = '2.5.22'; runtimeId = 'runtime-2.5.22'; runtimeGeneration = [UInt64]2; runtimeManifestSha256 = ('d' * 64); attestation = 'HumanReviewedStructuredAttestation'; connection = 'Connected'; control = 'Completed'; outcome = 'Completed'; code = 'CCOD_REMOTE_ACTION_COMPLETED' } + [IO.File]::WriteAllText($proofPath, (($proof | ConvertTo-Json -Compress) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + try { + $parsed = &$module { param($Path) Get-CcodOfficialDraftRemoteProof -Path $Path -ExpectedChallenge 'run-1' -NotBefore ([DateTimeOffset]::UtcNow) -ExpectedVersion '2.5.22' -ExpectedRuntimeId 'runtime-2.5.22' -ExpectedGeneration ([UInt64]2) -ExpectedManifestSha256 ('d' * 64) } $proofPath + Assert-CcodEqual 'run-1' ([string]$parsed.challenge) 'remote proof binds the current challenge' + $proof.challenge = 'run-2' + [IO.File]::WriteAllText($proofPath, (($proof | ConvertTo-Json -Compress) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + Assert-CcodThrows { &$module { param($Path) Get-CcodOfficialDraftRemoteProof -Path $Path -ExpectedChallenge 'run-1' -NotBefore ([DateTimeOffset]::UtcNow) -ExpectedVersion '2.5.22' -ExpectedRuntimeId 'runtime-2.5.22' -ExpectedGeneration ([UInt64]2) -ExpectedManifestSha256 ('d' * 64) } $proofPath | Out-Null } 'CCOD_ACCEPTANCE_REMOTE_UNPROVEN' + } finally { + if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } + } + } + + Invoke-CcodTest 'official draft default adapters use the isolated installed-lifecycle integration contract' { + $assetNames = @( + 'CodexRemote-fix-2.5.21-windows-x64.zip', + 'CodexRemote-fix-2.5.21-windows-x64.zip.sha256.txt', + 'CodexRemote-fix-2.5.21-trayhost-provenance.json', + 'CodexRemote-fix-2.5.21-payload-manifest.json', + 'CodexRemote-fix-2.5.21-release-manifest.json', + 'CodexRemote-fix-2.5.21-setup.exe', + 'CodexRemote-fix-2.5.21-setup.exe.sha256.txt', + 'CodexRemote-fix-2.5.21-setup-provenance.json', + 'CodexRemote-fix-2.5.21-setup-payload-manifest.json', + 'CodexRemote-fix-2.5.21-setup-destination-inventory.iss', + 'CodexRemote-fix-2.5.21-setup-release-manifest.json' + ) + $assetState = [pscustomobject]@{ Contract = [pscustomobject][ordered]@{ + Valid = $true; Version = '2.5.21'; GitCommit = ('b' * 40) + Assets = @($assetNames | ForEach-Object { [pscustomobject][ordered]@{ name = $_; sha256 = ('a' * 64) } }) + } } + $fakeAssetModule = New-Module -ArgumentList $assetState, $assetNames -ScriptBlock { + param($State,$Names) + function Get-CcodExpectedReleaseAssetNames { param($Version); return @($Names | ForEach-Object { $_ -replace '2\.5\.21', [string]$Version }) } + function Test-CcodExactReleaseAssetSet { param($AssetDirectory,$Version); return $State.Contract } + Export-ModuleMember -Function Get-CcodExpectedReleaseAssetNames,Test-CcodExactReleaseAssetSet + } + $integrationState = [pscustomobject]@{ + PreviousCalls = 0 + FactsCalls = [Collections.Generic.List[string]]::new() + RunCalls = [Collections.Generic.List[object]]::new() + Facts = [pscustomobject][ordered]@{ + installRootPresent = $true + appPresent = $true + runtimeRootPresent = $true + activePointerPresent = $true + activeRuntimeId = 'runtime-2.5.22' + activeGeneration = [UInt64]2 + runtimeManifestSha256 = ('d' * 64) + supervisor = @([pscustomobject]@{ pid = 200; creationTimeUtc = '2030-02-03T04:05:00.0000000Z' }) + trayHost = @([pscustomobject]@{ pid = 201; creationTimeUtc = '2030-02-03T04:05:01.0000000Z' }) + codex = @([pscustomobject]@{ pid = 202; creationTimeUtc = '2030-02-03T04:05:02.0000000Z' }); codexCount = 1 + taskState = 'Ready' + statusPhase = 'Active' + statusRuntimeId = 'runtime-2.5.22' + statusCodex = [pscustomobject]@{ pid = 202; creationTimeUtc = '2030-02-03T04:05:02.0000000Z' } + transitionStage = 'Idle' + lifecycleReceipt = [pscustomobject][ordered]@{ kind = 'RestartAndRepair'; origin = 'Installer'; runtimeId = 'runtime-2.5.22'; runtimeGeneration = [UInt64]2; phase = 'Completed' } + shortcuts = [pscustomobject][ordered]@{ startMenu = $true; desktop = $true } + debugEndpoints = @([pscustomobject][ordered]@{ localAddress = '127.0.0.1'; localPort = 9229; owningProcess = 202; owningProcessCreationTimeUtc = '2030-02-03T04:05:02.0000000Z' },[pscustomobject][ordered]@{ localAddress = '127.0.0.1'; localPort = 9230; owningProcess = 202; owningProcessCreationTimeUtc = '2030-02-03T04:05:02.0000000Z' }) + debugPorts = @(9229,9230) + aboutVersion = $null + deviceKeyPresent = $true + deviceKeySha256 = ('b' * 64) + } + } + $fakeIntegrationModule = New-Module -ArgumentList $integrationState -ScriptBlock { + param($State) + function Get-CcodInstalledLifecycleFacts { + param($InstallRoot,$ExpectedVersion) + $State.FactsCalls.Add([string]$InstallRoot) + return $State.Facts + } + function Invoke-CcodInstalledLifecycleIntegration { + param($InstallerPath,$PreviousInstallerPath,$PreviousExpectedVersion,$PreviousInstallerSha256,$PreviousManifestSha256,$ExpectedVersion,$EvidenceRoot,$AllowMachineMutation,$AllowCodexRestart,$Scenario) + $State.RunCalls.Add([pscustomobject][ordered]@{ + InstallerPath = $InstallerPath; PreviousInstallerPath = $PreviousInstallerPath; PreviousExpectedVersion = $PreviousExpectedVersion; PreviousInstallerSha256 = $PreviousInstallerSha256; PreviousManifestSha256 = $PreviousManifestSha256; ExpectedVersion = $ExpectedVersion + EvidenceRoot = $EvidenceRoot; AllowMachineMutation = [bool]$AllowMachineMutation; AllowCodexRestart = [bool]$AllowCodexRestart; Scenario = $Scenario + }) + return [pscustomobject][ordered]@{ outcome = 'Completed'; installerSha256 = ('c' * 64); verification = [pscustomobject][ordered]@{ verified = $true; facts = [pscustomobject][ordered]@{ runtimeManifestSha256 = ('d' * 64) } } } + } + Export-ModuleMember -Function Get-CcodInstalledLifecycleFacts,Invoke-CcodInstalledLifecycleIntegration + } + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + try { + $observed = &$module { + param($FakeAsset,$FakeIntegration) + $script:CcodOfficialDraftAssetModule = $FakeAsset + $script:CcodOfficialDraftIntegrationModule = $FakeIntegration + $candidate = [pscustomobject][ordered]@{ version = '2.5.22'; assetHashes = @([pscustomobject][ordered]@{ name = 'CodexRemote-fix-2.5.22-setup.exe'; sha256 = ('c' * 64) }) } + $draft = [pscustomobject][ordered]@{ tag = 'v2.5.22'; id = '123' } + $previousSetup = [pscustomobject][ordered]@{ version = '2.5.21'; gitCommit = ('b' * 40); assetSha256 = ('a' * 64); manifestSha256 = ('a' * 64) } + $context = New-CcodOfficialDraftContext -Phase 'LegacyUpgrade' -AssetDirectory 'C:\candidate' -PreviousAssetDirectory 'C:\previous' -EvidenceRoot 'C:\evidence' -Candidate $candidate -Draft $draft -PreviousSetup $previousSetup -AllowMachineMutation -AllowCodexRestart + $defaults = Get-CcodOfficialDraftDefaultAdapters + $run = & $defaults.RunPhase $context + $facts = & $defaults.CaptureFacts $context 'After' + [pscustomobject][ordered]@{ Context = $context; Run = $run; Facts = $facts } + } $fakeAssetModule $fakeIntegrationModule + Assert-CcodEqual 'Upgrade' ([string]$integrationState.RunCalls[0].Scenario) 'default LegacyUpgrade maps to the integration Upgrade scenario' + Assert-CcodEqual ([string]$observed.Context.setupPath) ([string]$integrationState.RunCalls[0].InstallerPath) 'default phase passes the candidate Setup path' + Assert-CcodEqual ([string]$observed.Context.previousSetupPath) ([string]$integrationState.RunCalls[0].PreviousInstallerPath) 'default upgrade passes the previous Setup path' + Assert-CcodEqual '2.5.21' ([string]$integrationState.RunCalls[0].PreviousExpectedVersion) 'default upgrade passes the validated previous version' + Assert-CcodEqual ('a' * 64) ([string]$integrationState.RunCalls[0].PreviousInstallerSha256) 'default upgrade passes the validated previous Setup hash' + Assert-CcodEqual ('a' * 64) ([string]$integrationState.RunCalls[0].PreviousManifestSha256) 'default upgrade passes the validated previous manifest hash' + Assert-CcodEqual '2.5.22' ([string]$observed.Context.expectedVersion) 'core context binds the candidate version' + Assert-CcodEqual '2.5.22' ([string]$integrationState.RunCalls[0].ExpectedVersion) 'default phase passes the candidate version' + Assert-CcodEqual 1 $integrationState.FactsCalls.Count 'default facts adapter captures through the integration module' + Assert-CcodTrue ([bool]$observed.Facts.appRootPresent) 'default facts preserve the installed app observation' + } finally { + if ($null -ne $fakeIntegrationModule) { Remove-Module -Name $fakeIntegrationModule.Name -Force -ErrorAction SilentlyContinue } + if ($null -ne $fakeAssetModule) { Remove-Module -Name $fakeAssetModule.Name -Force -ErrorAction SilentlyContinue } + if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + } + } + + Invoke-CcodTest 'official draft default remote adapter passes candidate-bound lifecycle observation arguments' { + $source = [IO.File]::ReadAllText($modulePath) + Assert-CcodTrue ($source.Contains('Get-CcodInstalledLifecycleFacts -InstallRoot $Root -ExpectedVersion $Version -ExpectedDebugPorts ([int[]]$DebugPorts)')) 'default remote adapter binds the candidate version and complete expected debug-port set' + Assert-CcodTrue ($source.Contains('$facts.activeRuntimeId -cne [string]$readyAfter.activeRuntimeId')) 'default remote adapter binds the active runtime to the ready-after identity' + Assert-CcodTrue ($source.Contains('[UInt64]$facts.activeGeneration -ne [UInt64]$readyAfter.activeGeneration')) 'default remote adapter binds the active generation to the ready-after identity' + Assert-CcodTrue ($source.Contains('ExpectedDeviceKeySha256')) 'manual readiness accepts the persisted device-key expectation' + Assert-CcodTrue ($source.Contains('$facts.deviceKeySha256 -cne $ExpectedDeviceKeySha256')) 'manual readiness binds the device key to the persisted acceptance state' + Assert-CcodTrue ($source.Contains('$readyAfterCodex')) 'remote evidence binds endpoints to the ready-after Codex identity' + } + + Invoke-CcodTest 'official draft Complete rejects missing bounded manual evidence' { + $fixture = New-CcodAcceptanceCandidateFixture + $previousRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-acceptance-previous-' + [guid]::NewGuid().ToString('N')) + $evidenceRoot = Join-Path $fixture.Root 'evidence' + [IO.Directory]::CreateDirectory($previousRoot) | Out-Null + [IO.Directory]::CreateDirectory($evidenceRoot) | Out-Null + $state = [pscustomobject]@{ + DefenderCalls = [Collections.Generic.List[string]]::new() + RunCalls = [Collections.Generic.List[string]]::new() + CaptureCalls = [Collections.Generic.List[string]]::new() + BootCalls = 0; RebootCalls = 0; PreviousCalls = 0 + BootId = 'boot-1'; KeyHash = ('b' * 64); Facts = (New-CcodAcceptanceFacts -BootId 'boot-1') + } + try { + $adapters = New-CcodAcceptancePhaseAdapters -Fixture $fixture -State $state -ReturnAbsentAfterUninstall + Invoke-CcodAcceptancePrivate -Phase Preflight -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters | Out-Null + Invoke-CcodAcceptancePrivate -Phase LegacyUpgrade -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart | Out-Null + Invoke-CcodAcceptancePrivate -Phase Uninstall -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart | Out-Null + Invoke-CcodAcceptancePrivate -Phase FreshInstall -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart | Out-Null + Invoke-CcodAcceptancePrivate -Phase PreReboot -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart -AllowWindowsReboot | Out-Null + $state.BootId = 'boot-2'; $state.Facts = New-CcodAcceptanceFacts -BootId 'boot-2' + Invoke-CcodAcceptancePrivate -Phase PostReboot -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart -AllowWindowsReboot | Out-Null + Invoke-CcodAcceptancePrivate -Phase ReadyForManualEvidence -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters | Out-Null + Assert-CcodThrows { + Invoke-CcodAcceptancePrivate -Phase Complete -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters + } 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INCOMPLETE' + } finally { + if (Test-Path -LiteralPath $fixture.Root) { Remove-Item -LiteralPath $fixture.Root -Recurse -Force } + if (Test-Path -LiteralPath $previousRoot) { Remove-Item -LiteralPath $previousRoot -Recurse -Force } + } + } + + Invoke-CcodTest 'official draft manual proof rejects a stale redacted action log' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-stale-redacted-log-' + [guid]::NewGuid().ToString('N')) + $logs = Join-Path $root 'logs' + $redacted = Join-Path $root 'redacted.log' + $screenshot = Join-Path $root 'screenshot.bin' + [IO.Directory]::CreateDirectory((Join-Path $root 'runtime')) | Out-Null + [IO.Directory]::CreateDirectory($logs) | Out-Null + [IO.File]::WriteAllText((Join-Path $root 'active.json'), '{}', [Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($screenshot,'fixture screenshot',[Text.UTF8Encoding]::new($false)) + $future = [DateTimeOffset]::UtcNow.AddMinutes(1).ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss.fffffff'Z'", [Globalization.CultureInfo]::InvariantCulture) + $ready = [ordered]@{ schemaVersion = 1; timestampUtc = $future; component = 'Supervisor'; stage = 'TrayHostReady'; code = 'CCOD_TRAYHOST_READY'; outcome = 'Completed'; runtimeId = 'runtime-2.5.22'; hostPid = 201; hostCreationTimeUtc = '2030-02-03T04:05:01.0000000Z'; protocolMajor = 2; capabilities = [UInt64]2 } + $base = [ordered]@{ schemaVersion = 1; timestampUtc = $future; component = 'Supervisor'; stage = 'TrayAction'; code = 'CCOD_TRAY_ACTION_COMPLETED'; outcome = 'Completed'; command = 'ShowAbout'; revision = 7; status = 'Completed' } + [IO.File]::WriteAllText((Join-Path $logs 'supervisor.log'), (($ready | ConvertTo-Json -Compress) + "`n" + ($base | ConvertTo-Json -Compress) + "`n"), [Text.UTF8Encoding]::new($false)) + $stale = [ordered]@{} + foreach ($entry in $base.GetEnumerator()) { $stale[$entry.Key] = $entry.Value } + $stale.timestampUtc = '2000-01-01T00:00:00.0000000Z' + [IO.File]::WriteAllText($redacted, (($base | ConvertTo-Json -Compress) + "`n"), [Text.UTF8Encoding]::new($false)) + $facts = [pscustomobject][ordered]@{ + installRootPresent = $true; appPresent = $true; runtimeRootPresent = $true; activePointerPresent = $true; activeRuntimeId = 'runtime-2.5.22'; activeGeneration = [UInt64]2; runtimeManifestSha256 = ('d' * 64) + supervisor = @([pscustomobject]@{ pid = 200; creationTimeUtc = '2030-02-03T04:05:00.0000000Z' }) + trayHost = @([pscustomobject]@{ pid = 201; creationTimeUtc = '2030-02-03T04:05:01.0000000Z' }) + trayHostIdentity = [pscustomobject][ordered]@{ pid = 201; creationTimeUtc = '2030-02-03T04:05:01.0000000Z' } + codex = @([pscustomobject]@{ pid = 202; creationTimeUtc = '2030-02-03T04:05:02.0000000Z' }); codexCount = 1 + trayAuthenticated = $true + taskState = 'Ready'; statusPhase = 'Active'; statusRuntimeId = 'runtime-2.5.22' + statusCodex = [pscustomobject]@{ pid = 202; creationTimeUtc = '2030-02-03T04:05:02.0000000Z' } + transitionStage = 'Idle'; lifecycleReceipt = [pscustomobject][ordered]@{ kind = 'RestartAndRepair'; origin = 'Installer'; runtimeId = 'runtime-2.5.22'; runtimeGeneration = [UInt64]2; phase = 'Completed' } + aboutVersion = '2.5.22'; deviceKeyPresent = $true; deviceKeySha256 = ('b' * 64) + shortcuts = [pscustomobject][ordered]@{ startMenu = $true; desktop = $true } + debugEndpoints = @([pscustomobject][ordered]@{ localAddress = '127.0.0.1'; localPort = 9229; owningProcess = 202; owningProcessCreationTimeUtc = '2030-02-03T04:05:02.0000000Z' },[pscustomobject][ordered]@{ localAddress = '127.0.0.1'; localPort = 9230; owningProcess = 202; owningProcessCreationTimeUtc = '2030-02-03T04:05:02.0000000Z' }) + debugPorts = @(9229,9230) + } + $state = [pscustomobject]@{ Facts = $facts; Calls = 0; ProofReads = 0; LiveProofReads = 0; Target = $redacted; LivePath = (Join-Path $logs 'supervisor.log') } + $fakeIntegration = New-Module -ArgumentList $state -ScriptBlock { + param($State) + function Get-CcodInstalledLifecycleFacts { param($InstallRoot,$ExpectedVersion) $State.Calls++; return $State.Facts } + Export-ModuleMember -Function Get-CcodInstalledLifecycleFacts + } + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + try { + $run = & $module { + param($Fake,$InstallRoot,$State) + $script:CcodOfficialDraftIntegrationModule = $Fake + $script:CcodOfficialDraftInstallRoot = $InstallRoot + $script:CcodStaleProofState = $State + $script:CcodStaleProofOriginal = ${function:Get-CcodOfficialDraftJsonLines} + function script:Get-CcodOfficialDraftJsonLines { + param([string]$Path,[AllowNull()][Nullable[DateTimeOffset]]$NotBefore) + if ($Path -ceq $script:CcodStaleProofState.Target) { $script:CcodStaleProofState.ProofReads++ } + if ($Path -ceq $script:CcodStaleProofState.LivePath -and $null -ne $NotBefore) { $script:CcodStaleProofState.LiveProofReads++ } + & $script:CcodStaleProofOriginal -Path $Path -NotBefore $NotBefore + } + (Get-CcodOfficialDraftDefaultAdapters).RunManualOperation + } $fakeIntegration $root $state + $context = [pscustomobject][ordered]@{ screenshotPath = $screenshot; redactedLogPath = $redacted; expectedVersion = '2.5.22'; manualAcknowledgement = 'CCOD_MANUAL_ABOUT_COMPLETED' } + $positive = & $run $context 'About' + Assert-CcodTrue $positive.verified 'complete fresh manual proof is accepted before the timestamp-only mutation' + $previousReads = $state.ProofReads + $previousFacts = $state.Calls + [IO.File]::WriteAllText($redacted, (($stale | ConvertTo-Json -Compress) + "`n"), [Text.UTF8Encoding]::new($false)) + Assert-CcodThrows { & $run $context 'About' | Out-Null } 'CCOD_ACCEPTANCE_MANUAL_OPERATION_UNPROVEN' + Assert-CcodEqual ($previousReads + 1) $state.ProofReads 'stale case reaches the real redacted proof parser' + Assert-CcodEqual ($previousFacts + 2) $state.Calls 'stale case has passed both readiness observations' + # Matching old live/redacted records must still fail freshness, not only tuple equality. + [IO.File]::WriteAllText($state.LivePath, (($ready | ConvertTo-Json -Compress) + "`n" + ($stale | ConvertTo-Json -Compress) + "`n"), [Text.UTF8Encoding]::new($false)) + $previousLiveReads = $state.LiveProofReads + $previousFacts = $state.Calls + Assert-CcodThrows { & $run $context 'About' | Out-Null } 'CCOD_ACCEPTANCE_MANUAL_OPERATION_UNPROVEN' + Assert-CcodEqual ($previousLiveReads + 1) $state.LiveProofReads 'matching stale records reach freshness validation in the live proof parser' + Assert-CcodEqual ($previousFacts + 2) $state.Calls 'matching stale action records do not invalidate current host readiness' + } finally { + if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + if ($null -ne $fakeIntegration) { Remove-Module -Name $fakeIntegration.Name -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } + } + } + + Invoke-CcodTest 'official draft manual proof rechecks current ready runtime state' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-manual-readiness-' + [guid]::NewGuid().ToString('N')) + $logs = Join-Path $root 'logs' + $redacted = Join-Path $root 'redacted.log' + $screenshot = Join-Path $root 'screenshot.bin' + [IO.Directory]::CreateDirectory($logs) | Out-Null + [IO.File]::WriteAllText($screenshot,'fixture screenshot',[Text.UTF8Encoding]::new($false)) + $future = [DateTimeOffset]::UtcNow.AddMinutes(1).ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss.fffffff'Z'", [Globalization.CultureInfo]::InvariantCulture) + $record = [ordered]@{ + schemaVersion = 1; timestampUtc = $future; component = 'Supervisor'; stage = 'TrayAction' + code = 'CCOD_TRAY_ACTION_COMPLETED'; outcome = 'Completed'; command = 'ShowAbout'; revision = 7; status = 'Completed' + } + $json = (($record | ConvertTo-Json -Compress) + "`n") + $ready = [ordered]@{ schemaVersion = 1; timestampUtc = $future; component = 'Supervisor'; stage = 'TrayHostReady'; code = 'CCOD_TRAYHOST_READY'; outcome = 'Completed'; runtimeId = 'runtime-2.5.22'; hostPid = 201; hostCreationTimeUtc = '2030-02-03T04:05:01.0000000Z'; protocolMajor = 2; capabilities = 7 } + [IO.File]::WriteAllText((Join-Path $logs 'supervisor.log'), (($ready | ConvertTo-Json -Compress) + "`n" + $json), [Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($redacted, $json, [Text.UTF8Encoding]::new($false)) + $facts = New-CcodAcceptanceFacts + $facts | Add-Member -NotePropertyName appPresent -NotePropertyValue $true + $state = [pscustomobject]@{ Facts = $facts; Calls = 0; DriftAt = 0 } + $fakeIntegration = New-Module -ArgumentList $state -ScriptBlock { + param($State) + function Get-CcodInstalledLifecycleFacts { + param($InstallRoot,$ExpectedVersion) + $State.Calls++ + $observed = $State.Facts.PSObject.Copy() + if ($State.DriftAt -gt 0 -and $State.Calls -ge $State.DriftAt) { $observed.aboutVersion = '2.5.21' } + return $observed + } + Export-ModuleMember -Function Get-CcodInstalledLifecycleFacts + } + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + try { + $run = & $module { + param($Fake,$InstallRoot) + $script:CcodOfficialDraftIntegrationModule = $Fake + $script:CcodOfficialDraftInstallRoot = $InstallRoot + (Get-CcodOfficialDraftDefaultAdapters).RunManualOperation + } $fakeIntegration $root + $context = [pscustomobject][ordered]@{ screenshotPath = $screenshot; redactedLogPath = $redacted; expectedVersion = '2.5.22'; manualAcknowledgement = 'CCOD_MANUAL_ABOUT_COMPLETED' } + $positive = & $run $context 'About' + Assert-CcodTrue $positive.verified 'complete ready baseline succeeds before observation drift' + $previousCalls = $state.Calls + $state.DriftAt = $previousCalls + 2 + Assert-CcodThrows { & $run $context 'About' | Out-Null } 'CCOD_ACCEPTANCE_MANUAL_OPERATION_UNPROVEN' + Assert-CcodEqual ($previousCalls + 2) $state.Calls 'readiness is reobserved after acknowledgement, not rejected at context validation' + Assert-CcodEqual '2.5.22' $state.Facts.aboutVersion 'the initial healthy fixture remains unchanged' + } finally { + if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + if ($null -ne $fakeIntegration) { Remove-Module -Name $fakeIntegration.Name -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } + } + } + + Invoke-CcodTest 'official draft preserves an inner acceptance error from post-manual observation' { + $fixture = New-CcodAcceptanceCandidateFixture + $previousRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-acceptance-previous-' + [guid]::NewGuid().ToString('N')) + $evidenceRoot = Join-Path $fixture.Root 'evidence' + $manualRoot = Join-Path $fixture.Root 'manual-input' + [IO.Directory]::CreateDirectory($previousRoot) | Out-Null + [IO.Directory]::CreateDirectory($evidenceRoot) | Out-Null + [IO.Directory]::CreateDirectory($manualRoot) | Out-Null + $state = [pscustomobject]@{ DefenderCalls = [Collections.Generic.List[string]]::new(); RunCalls = [Collections.Generic.List[string]]::new(); CaptureCalls = [Collections.Generic.List[string]]::new(); BootCalls = 0; RebootCalls = 0; PreviousCalls = 0; BootId = 'boot-1'; KeyHash = ('b' * 64); Facts = New-CcodAcceptanceFacts } + try { + $adapters = New-CcodAcceptancePhaseAdapters -Fixture $fixture -State $state -ReturnAbsentAfterUninstall + Invoke-CcodAcceptanceThroughReady -Fixture $fixture -PreviousRoot $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -State $state + $paths = New-CcodAcceptanceManualInputFiles -Root $manualRoot + $originalCapture = $adapters.CaptureFacts + $adapters.CaptureFacts = { + param($Context,$Point) + try { + if ([string]$Point -ceq 'AfterManualOperation') { + $exception = New-Object -TypeName System.InvalidOperationException -ArgumentList 'inner acceptance fixture error' + Write-Error -Exception $exception -ErrorId 'CCOD_ACCEPTANCE_TEST_INNER' -Category InvalidData -ErrorAction Stop + } + & $originalCapture $Context $Point + } catch { $State.InnerErrorId = [string]$_.FullyQualifiedErrorId; throw } + }.GetNewClosure() + $state | Add-Member -NotePropertyName InnerErrorId -NotePropertyValue $null -Force + $observedErrorId = $null + try { + Invoke-CcodAcceptancePrivate -Phase TrayEvidence -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -TrayOperation About -ScreenshotPath $paths.About.Screenshot -RedactedLogPath $paths.About.Log -ReviewState Reviewed | Out-Null + } catch { $observedErrorId = [string]$_.FullyQualifiedErrorId } + if ($state.InnerErrorId -notlike 'CCOD_ACCEPTANCE_TEST_INNER*') { throw "adapter inner error id was [$($state.InnerErrorId)]" } + Assert-CcodEqual 'CCOD_ACCEPTANCE_TEST_INNER' $observedErrorId 'post-manual observation preserves the inner acceptance error id' + } finally { + if (Test-Path -LiteralPath $fixture.Root) { Remove-Item -LiteralPath $fixture.Root -Recurse -Force } + if (Test-Path -LiteralPath $previousRoot) { Remove-Item -LiteralPath $previousRoot -Recurse -Force } + } + } + + Invoke-CcodTest 'official draft records four tray operations and second-device evidence before Complete' { + $fixture = New-CcodAcceptanceCandidateFixture + $previousRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-acceptance-previous-' + [guid]::NewGuid().ToString('N')) + $evidenceRoot = Join-Path $fixture.Root 'evidence' + $manualRoot = Join-Path $fixture.Root 'manual-input' + [IO.Directory]::CreateDirectory($previousRoot) | Out-Null + [IO.Directory]::CreateDirectory($evidenceRoot) | Out-Null + [IO.Directory]::CreateDirectory($manualRoot) | Out-Null + $state = [pscustomobject]@{ + DefenderCalls = [Collections.Generic.List[string]]::new() + RunCalls = [Collections.Generic.List[string]]::new() + CaptureCalls = [Collections.Generic.List[string]]::new() + BootCalls = 0; RebootCalls = 0; PreviousCalls = 0 + BootId = 'boot-1'; KeyHash = ('b' * 64); Facts = (New-CcodAcceptanceFacts -BootId 'boot-1') + } + $operations = @('About','Language','OpenLogs','Repair','SecondDeviceControl') + $paths = @{} + foreach ($operation in $operations) { + $paths[$operation] = [pscustomobject][ordered]@{ + Screenshot = Join-Path $manualRoot ($operation + '-screenshot.png') + Log = Join-Path $manualRoot ($operation + '-redacted.log') + } + [IO.File]::WriteAllText($paths[$operation].Screenshot, 'screenshot-' + $operation, [Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($paths[$operation].Log, 'redacted-log-' + $operation, [Text.UTF8Encoding]::new($false)) + } + try { + $adapters = New-CcodAcceptancePhaseAdapters -Fixture $fixture -State $state -ReturnAbsentAfterUninstall + Invoke-CcodAcceptancePrivate -Phase Preflight -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters | Out-Null + Invoke-CcodAcceptancePrivate -Phase LegacyUpgrade -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart | Out-Null + Invoke-CcodAcceptancePrivate -Phase Uninstall -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart | Out-Null + Invoke-CcodAcceptancePrivate -Phase FreshInstall -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart | Out-Null + Invoke-CcodAcceptancePrivate -Phase PreReboot -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart -AllowWindowsReboot | Out-Null + $state.BootId = 'boot-2'; $state.Facts = New-CcodAcceptanceFacts -BootId 'boot-2' + Invoke-CcodAcceptancePrivate -Phase PostReboot -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart -AllowWindowsReboot | Out-Null + Invoke-CcodAcceptancePrivate -Phase ReadyForManualEvidence -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters | Out-Null + $normalManualOperation = $adapters.RunManualOperation + $adapters.RunManualOperation = { + param($Context,$Operation) + $State.Facts.bootId = 'boot-detached' + return & $normalManualOperation $Context $Operation + }.GetNewClosure() + Assert-CcodThrows { + Invoke-CcodAcceptancePrivate -Phase TrayEvidence -TrayOperation 'About' -ScreenshotPath $paths.About.Screenshot -RedactedLogPath $paths.About.Log -ReviewState Reviewed -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters | Out-Null + } 'CCOD_ACCEPTANCE_MANUAL_OPERATION_UNPROVEN' + $adapters.RunManualOperation = $normalManualOperation + $state.Facts = New-CcodAcceptanceFacts -BootId 'boot-2' + foreach ($operation in @('About','Language','OpenLogs','Repair')) { + Invoke-CcodAcceptancePrivate -Phase TrayEvidence -TrayOperation $operation -ScreenshotPath $paths[$operation].Screenshot -RedactedLogPath $paths[$operation].Log -ReviewState Reviewed -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters | Out-Null + } + Invoke-CcodAcceptancePrivate -Phase RemoteEvidence -RemoteOperation SecondDeviceControl -ScreenshotPath $paths.SecondDeviceControl.Screenshot -RedactedLogPath $paths.SecondDeviceControl.Log -ReviewState Reviewed -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters | Out-Null + $aboutArtifact = Join-Path (Join-Path (Join-Path $evidenceRoot 'official-draft-artifacts') 'About') 'screenshot.bin' + [IO.File]::WriteAllText($aboutArtifact, 'tampered-artifact', [Text.UTF8Encoding]::new($false)) + Assert-CcodThrows { Invoke-CcodAcceptancePrivate -Phase Complete -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters | Out-Null } 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID' + $restoreBytes = [IO.File]::ReadAllBytes($paths['About'].Screenshot) + [IO.File]::Delete($aboutArtifact) + [IO.File]::WriteAllBytes($aboutArtifact, $restoreBytes) + $normalClock = $adapters.GetUtcNow + $adapters.GetUtcNow = { return [datetime]::SpecifyKind([datetime]::Parse('2030-02-03T04:05:06'), [DateTimeKind]::Unspecified) } + Assert-CcodThrows { + Invoke-CcodAcceptancePrivate -Phase Complete -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters | Out-Null + } 'CCOD_ACCEPTANCE_COMPLETE_WRITE_FAILED' + $adapters.GetUtcNow = $normalClock + $complete = Invoke-CcodAcceptancePrivate -Phase Complete -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters + Assert-CcodEqual 'Complete' ([string]$complete.phase) 'complete returns its canonical phase' + Assert-CcodTrue ([bool]$complete.complete) 'complete reports the complete gate only after all five records' + $manualDirectory = Join-Path (Join-Path $evidenceRoot 'official-draft-acceptance') 'manual-evidence' + Assert-CcodEqual 5 (@(Get-ChildItem -LiteralPath $manualDirectory -File).Count) 'complete retains exactly five bounded manual records' + $completePath = Join-Path (Join-Path $evidenceRoot 'acceptance') 'CodexRemote-fix-2.5.22-official-draft.complete.json' + Assert-CcodTrue (Test-Path -LiteralPath $completePath -PathType Leaf) 'complete writes the Promote acceptance receipt' + $serialized = [IO.File]::ReadAllText($completePath) + $completeReceipt = $serialized | ConvertFrom-Json + Assert-CcodEqual 'v2.5.22' ([string]$completeReceipt.draft.tag) 'complete receipt records the draft tag' + Assert-CcodEqual '123' ([string]$completeReceipt.draft.id) 'complete receipt records the draft identifier' + $remoteRecord = @($completeReceipt.manualEvidence | Where-Object { $_.operation -ceq 'SecondDeviceControl' }) + Assert-CcodTrue ($remoteRecord.Count -eq 1 -and $null -ne $remoteRecord[0].proof -and [string]$remoteRecord[0].proof.candidateVersion -ceq '2.5.22' -and -not [string]::IsNullOrWhiteSpace([string]$remoteRecord[0].proof.challenge)) 'complete receipt preserves the structured second-device proof' + $freshReceipt = ([IO.File]::ReadAllText((Join-Path (Join-Path $evidenceRoot 'official-draft-acceptance') '04-FreshInstall.json')) | ConvertFrom-Json) + $postReceipt = ([IO.File]::ReadAllText((Join-Path (Join-Path $evidenceRoot 'official-draft-acceptance') '06-PostReboot.json')) | ConvertFrom-Json) + Assert-CcodTrue ($null -ne $freshReceipt.facts.trayHostIdentity -and $null -ne $postReceipt.facts.trayHostIdentity) 'automated receipts persist the exact TrayHost identity' + foreach ($path in @($paths.About.Screenshot,$paths.About.Log,$paths.SecondDeviceControl.Screenshot,$paths.SecondDeviceControl.Log)) { + Assert-CcodTrue (-not $serialized.Contains($path)) 'complete receipt does not persist private evidence paths' + } + Assert-CcodEqual 5 (@($complete.facts.manualEvidence).Count) 'complete result contains all five normalized records' + Assert-CcodThrows { + Invoke-CcodAcceptancePrivate -Phase Complete -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters + } 'CCOD_ACCEPTANCE_PHASE_REUSED' + $stateDirectory = Join-Path $evidenceRoot 'official-draft-acceptance' + $completeStatePath = Join-Path $stateDirectory '08-Complete.json' + $manualStatePath = Join-Path (Join-Path $stateDirectory 'manual-evidence') 'RemoteEvidence-SecondDeviceControl.json' + $completeState = [IO.File]::ReadAllText($completeStatePath) | ConvertFrom-Json + $manualState = [IO.File]::ReadAllText($manualStatePath) | ConvertFrom-Json + $completeRemote = @($completeState.facts.manualEvidence | Where-Object { $_.operation -ceq 'SecondDeviceControl' })[0] + $completeRemote.proof.candidateVersion = '2.5.21' + $completeRemote.proof.challenge = 'tampered-challenge' + $manualState.proof.candidateVersion = '2.5.21' + $manualState.proof.challenge = 'tampered-challenge' + [IO.File]::WriteAllText($completeStatePath, ($completeState | ConvertTo-Json -Depth 16), [Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($manualStatePath, ($manualState | ConvertTo-Json -Depth 16), [Text.UTF8Encoding]::new($false)) + $stateModule = Import-Module $modulePath -Force -PassThru -DisableNameChecking + try { + Assert-CcodThrows { + &$stateModule { + param($Directory) + $records = Read-CcodOfficialDraftState -StateDirectory $Directory + [void](Assert-CcodOfficialDraftAutomatedReceiptChain -Records $records -StateDirectory $Directory) + } $stateDirectory + } 'CCOD_ACCEPTANCE_RECEIPT_INVALID' + } finally { + if ($null -ne $stateModule) { Remove-Module -Name $stateModule.Name -Force -ErrorAction SilentlyContinue } + } + } finally { + if (Test-Path -LiteralPath $fixture.Root) { Remove-Item -LiteralPath $fixture.Root -Recurse -Force } + if (Test-Path -LiteralPath $previousRoot) { Remove-Item -LiteralPath $previousRoot -Recurse -Force } + } + } + + Invoke-CcodTest 'official draft rejects a candidate change after ReadyForManualEvidence' { + $fixture = New-CcodAcceptanceCandidateFixture + $previousRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-acceptance-previous-' + [guid]::NewGuid().ToString('N')) + $evidenceRoot = Join-Path $fixture.Root 'evidence' + $manualRoot = Join-Path $fixture.Root 'manual-input' + [IO.Directory]::CreateDirectory($previousRoot) | Out-Null + [IO.Directory]::CreateDirectory($evidenceRoot) | Out-Null + $state = [pscustomobject]@{ DefenderCalls = [Collections.Generic.List[string]]::new(); RunCalls = [Collections.Generic.List[string]]::new(); CaptureCalls = [Collections.Generic.List[string]]::new(); BootCalls = 0; RebootCalls = 0; PreviousCalls = 0; BootId = 'boot-1'; KeyHash = ('b' * 64); Facts = New-CcodAcceptanceFacts } + try { + $adapters = New-CcodAcceptancePhaseAdapters -Fixture $fixture -State $state -ReturnAbsentAfterUninstall + Invoke-CcodAcceptanceThroughReady -Fixture $fixture -PreviousRoot $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -State $state + $fixture.Contract.GitCommit = 'd' * 40 + $paths = New-CcodAcceptanceManualInputFiles -Root $manualRoot + Assert-CcodThrows { + Invoke-CcodAcceptancePrivate -Phase TrayEvidence -TrayOperation About -ScreenshotPath $paths.About.Screenshot -RedactedLogPath $paths.About.Log -ReviewState Reviewed -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters | Out-Null + } 'CCOD_ACCEPTANCE_CANDIDATE_CHANGED' + } finally { + if (Test-Path -LiteralPath $fixture.Root) { Remove-Item -LiteralPath $fixture.Root -Recurse -Force } + if (Test-Path -LiteralPath $previousRoot) { Remove-Item -LiteralPath $previousRoot -Recurse -Force } + } + } + + Invoke-CcodTest 'official draft manual evidence requires every automated receipt after Ready' { + $fixture = New-CcodAcceptanceCandidateFixture + $previousRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-acceptance-previous-' + [guid]::NewGuid().ToString('N')) + $evidenceRoot = Join-Path $fixture.Root 'evidence' + $manualRoot = Join-Path $fixture.Root 'manual-input' + [IO.Directory]::CreateDirectory($previousRoot) | Out-Null + [IO.Directory]::CreateDirectory($evidenceRoot) | Out-Null + $state = [pscustomobject]@{ DefenderCalls = [Collections.Generic.List[string]]::new(); RunCalls = [Collections.Generic.List[string]]::new(); CaptureCalls = [Collections.Generic.List[string]]::new(); BootCalls = 0; RebootCalls = 0; PreviousCalls = 0; BootId = 'boot-1'; KeyHash = ('b' * 64); Facts = New-CcodAcceptanceFacts } + try { + $adapters = New-CcodAcceptancePhaseAdapters -Fixture $fixture -State $state -ReturnAbsentAfterUninstall + Invoke-CcodAcceptanceThroughReady -Fixture $fixture -PreviousRoot $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -State $state + Remove-Item -LiteralPath (Join-Path (Join-Path $evidenceRoot 'official-draft-acceptance') '06-PostReboot.json') -Force + $paths = New-CcodAcceptanceManualInputFiles -Root $manualRoot + Assert-CcodThrows { + Invoke-CcodAcceptancePrivate -Phase TrayEvidence -TrayOperation About -ScreenshotPath $paths.About.Screenshot -RedactedLogPath $paths.About.Log -ReviewState Reviewed -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters | Out-Null + } 'CCOD_ACCEPTANCE_PHASE_ORDER_INVALID' + } finally { + if (Test-Path -LiteralPath $fixture.Root) { Remove-Item -LiteralPath $fixture.Root -Recurse -Force } + if (Test-Path -LiteralPath $previousRoot) { Remove-Item -LiteralPath $previousRoot -Recurse -Force } + } + } + + Invoke-CcodTest 'official draft rejects manual evidence seeded before ReadyForManualEvidence' { + $fixture = New-CcodAcceptanceCandidateFixture + $previousRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-acceptance-previous-' + [guid]::NewGuid().ToString('N')) + $evidenceRoot = Join-Path $fixture.Root 'evidence' + [IO.Directory]::CreateDirectory($previousRoot) | Out-Null + [IO.Directory]::CreateDirectory($evidenceRoot) | Out-Null + $stateDirectory = Join-Path $evidenceRoot 'official-draft-acceptance' + $manualDirectory = Join-Path $stateDirectory 'manual-evidence' + [IO.Directory]::CreateDirectory($manualDirectory) | Out-Null + $record = [ordered]@{ schemaVersion = 1; kind = 'manual-evidence'; phase = 'TrayEvidence'; operation = 'About'; terminalState = 'AboutVisible'; result = 'CCOD_TRAYABOUT_COMPLETED'; proof = [ordered]@{ timestampUtc = '2030-02-03T04:05:06.0000000Z'; command = 'ShowAbout'; revision = 1; code = 'CCOD_TRAY_ACTION_COMPLETED'; status = 'Completed' }; reviewState = 'Reviewed'; version = '2.5.22'; gitCommit = $fixture.GitCommit; candidateManifestSha256 = $fixture.Contract.PortableManifestSha256; screenshotSha256 = ('1' * 64); redactedLogSha256 = ('2' * 64) } + [IO.File]::WriteAllText((Join-Path $manualDirectory 'TrayEvidence-About.json'), (($record | ConvertTo-Json -Depth 8) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + $state = [pscustomobject]@{ DefenderCalls = [Collections.Generic.List[string]]::new(); RunCalls = [Collections.Generic.List[string]]::new(); CaptureCalls = [Collections.Generic.List[string]]::new(); BootCalls = 0; RebootCalls = 0; PreviousCalls = 0; BootId = 'boot-1'; KeyHash = ('b' * 64); Facts = New-CcodAcceptanceFacts } + try { + $adapters = New-CcodAcceptancePhaseAdapters -Fixture $fixture -State $state + Assert-CcodThrows { + Invoke-CcodAcceptancePrivate -Phase Preflight -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters | Out-Null + } 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_EARLY' + Assert-CcodEqual 0 $state.DefenderCalls.Count 'pre-seeded manual evidence blocks Defender work before automation' + } finally { + if (Test-Path -LiteralPath $fixture.Root) { Remove-Item -LiteralPath $fixture.Root -Recurse -Force } + if (Test-Path -LiteralPath $previousRoot) { Remove-Item -LiteralPath $previousRoot -Recurse -Force } + } + } + + Invoke-CcodTest 'official draft Complete does not leave a terminal state when acceptance target already exists' { + $fixture = New-CcodAcceptanceCandidateFixture + $previousRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-acceptance-previous-' + [guid]::NewGuid().ToString('N')) + $evidenceRoot = Join-Path $fixture.Root 'evidence' + $manualRoot = Join-Path $fixture.Root 'manual-input' + [IO.Directory]::CreateDirectory($previousRoot) | Out-Null + [IO.Directory]::CreateDirectory($evidenceRoot) | Out-Null + $state = [pscustomobject]@{ DefenderCalls = [Collections.Generic.List[string]]::new(); RunCalls = [Collections.Generic.List[string]]::new(); CaptureCalls = [Collections.Generic.List[string]]::new(); BootCalls = 0; RebootCalls = 0; PreviousCalls = 0; BootId = 'boot-1'; KeyHash = ('b' * 64); Facts = New-CcodAcceptanceFacts } + try { + $adapters = New-CcodAcceptancePhaseAdapters -Fixture $fixture -State $state -ReturnAbsentAfterUninstall + Invoke-CcodAcceptanceThroughReady -Fixture $fixture -PreviousRoot $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -State $state + $paths = New-CcodAcceptanceManualInputFiles -Root $manualRoot + Invoke-CcodAcceptanceManualRecords -Fixture $fixture -PreviousRoot $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -Paths $paths + $acceptanceDirectory = Join-Path $evidenceRoot 'acceptance' + [IO.Directory]::CreateDirectory($acceptanceDirectory) | Out-Null + $acceptancePath = Join-Path $acceptanceDirectory 'CodexRemote-fix-2.5.22-official-draft.complete.json' + [IO.File]::WriteAllText($acceptancePath, 'existing', [Text.UTF8Encoding]::new($false)) + Assert-CcodThrows { + Invoke-CcodAcceptancePrivate -Phase Complete -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters | Out-Null + } 'CCOD_ACCEPTANCE_PHASE_REUSED' + Assert-CcodTrue (-not (Test-Path -LiteralPath (Join-Path (Join-Path $evidenceRoot 'official-draft-acceptance') '08-Complete.json') -PathType Leaf)) 'failed Complete does not leave an unusable terminal state' + } finally { + if (Test-Path -LiteralPath $fixture.Root) { Remove-Item -LiteralPath $fixture.Root -Recurse -Force } + if (Test-Path -LiteralPath $previousRoot) { Remove-Item -LiteralPath $previousRoot -Recurse -Force } + } + } + + Invoke-CcodTest 'Complete rejects an interleaved contender without invalidating its successful receipt chain' { + $fixture=New-CcodAcceptanceCandidateFixture + $previousRoot=Join-Path ([IO.Path]::GetTempPath()) ('ccod-complete-interleave-'+[guid]::NewGuid().ToString('N')) + $evidenceRoot=Join-Path $fixture.Root 'evidence';$manualRoot=Join-Path $fixture.Root 'manual-input' + [IO.Directory]::CreateDirectory($previousRoot)|Out-Null;[IO.Directory]::CreateDirectory($evidenceRoot)|Out-Null + $state=[pscustomobject]@{DefenderCalls=[Collections.Generic.List[string]]::new();RunCalls=[Collections.Generic.List[string]]::new();CaptureCalls=[Collections.Generic.List[string]]::new();BootCalls=0;RebootCalls=0;PreviousCalls=0;BootId='boot-1';KeyHash=('b'*64);Facts=New-CcodAcceptanceFacts} + $module=$null + try { + $adapters=New-CcodAcceptancePhaseAdapters -Fixture $fixture -State $state -ReturnAbsentAfterUninstall + Invoke-CcodAcceptanceThroughReady -Fixture $fixture -PreviousRoot $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -State $state + $paths=New-CcodAcceptanceManualInputFiles -Root $manualRoot + Invoke-CcodAcceptanceManualRecords -Fixture $fixture -PreviousRoot $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -Paths $paths + $module=Import-Module $modulePath -Force -PassThru -DisableNameChecking + $probe=[pscustomobject]@{Attempts=0;InnerError=$null;InnerResult=$null;OuterError=$null;OuterResult=$null;InnerValidation=0} + $contender=$adapters.Clone();$validate=$contender.ValidateAssetSet + $contender.ValidateAssetSet={param($Path,$Version)$probe.InnerValidation++;&$validate $Path $Version}.GetNewClosure() + $clock=$adapters.GetUtcNow + $adapters.GetUtcNow={ + $probe.Attempts++ + try{$probe.InnerResult=&$module {param($A,$P,$E,$Adapters)Invoke-CcodOfficialDraftAcceptanceCore -Phase Complete -AssetDirectory $A -PreviousAssetDirectory $P -EvidenceRoot $E -DraftId '123' -Adapters $Adapters} $fixture.Root $previousRoot $evidenceRoot $contender}catch{$probe.InnerError=$_.FullyQualifiedErrorId} + return &$clock + }.GetNewClosure() + try{$probe.OuterResult=&$module {param($A,$P,$E,$Adapters)Invoke-CcodOfficialDraftAcceptanceCore -Phase Complete -AssetDirectory $A -PreviousAssetDirectory $P -EvidenceRoot $E -DraftId '123' -Adapters $Adapters} $fixture.Root $previousRoot $evidenceRoot $adapters}catch{$probe.OuterError=$_.FullyQualifiedErrorId} + Assert-CcodEqual 1 $probe.Attempts 'contender runs after the outer call validated its initial state' + Assert-CcodTrue ($probe.InnerError-like'CCOD_ACCEPTANCE_OPERATION_BUSY*') 'same evidence root rejects the contender before any operation' + Assert-CcodEqual 0 $probe.InnerValidation 'busy rejection precedes candidate/default adapter side effects' + Assert-CcodEqual $null $probe.OuterError 'the original operation is not poisoned by its rejected contender' + Assert-CcodTrue $probe.OuterResult.complete 'original Complete returns success' + $statePath=Join-Path $evidenceRoot 'official-draft-acceptance\08-Complete.json' + $acceptancePath=Join-Path $evidenceRoot 'acceptance\CodexRemote-fix-2.5.22-official-draft.complete.json' + $persisted=[IO.File]::ReadAllText($acceptancePath)|ConvertFrom-Json + Assert-CcodEqual (Get-CcodTestFileSha256 $statePath) (@($persisted.automatedReceiptSha256|Where-Object {$_.phase-ceq'Complete'})[0].sha256) 'successful aggregate remains linked to its durable Complete state' + & $module {param($Root) $records=Read-CcodOfficialDraftState -StateDirectory (Join-Path $Root 'official-draft-acceptance');Assert-CcodOfficialDraftAutomatedReceiptChain -Records $records -StateDirectory (Join-Path $Root 'official-draft-acceptance')|Out-Null} $evidenceRoot + } finally {if($null-ne$module){Remove-Module $module.Name -Force -ErrorAction SilentlyContinue};if(Test-Path $fixture.Root){Remove-Item $fixture.Root -Recurse -Force};if(Test-Path $previousRoot){Remove-Item $previousRoot -Recurse -Force}} + } + + Invoke-CcodTest 'acceptance operation authority is exclusive across imports and native child processes' { + $root=Join-Path ([IO.Path]::GetTempPath()) ('ccod-op-lock-'+[guid]::NewGuid().ToString('N')) + $module=$null;$first=$null;$second=$null;$process=$null + [IO.Directory]::CreateDirectory($root)|Out-Null + $evidence=Join-Path $root 'evidence';$other=Join-Path $root 'other';[IO.Directory]::CreateDirectory($evidence)|Out-Null;[IO.Directory]::CreateDirectory($other)|Out-Null + try { + $module=Import-Module $modulePath -Force -PassThru -DisableNameChecking + $first=&$module {param($Root) Open-CcodOfficialDraftOperationLease -EvidenceRoot $Root} $evidence + Assert-CcodThrows {&$module {param($Root) Open-CcodOfficialDraftOperationLease -EvidenceRoot $Root} $evidence|Out-Null} 'CCOD_ACCEPTANCE_OPERATION_BUSY' + $second=&$module {param($Root) Open-CcodOfficialDraftOperationLease -EvidenceRoot $Root} $other + &$module {param($Lease) Close-CcodOfficialDraftOperationLease $Lease} $second;$second=$null + &$module {param($Lease) Close-CcodOfficialDraftOperationLease $Lease} $first;$first=$null + $child=Join-Path $root 'hold-lock.ps1' + $text=@' +param([string]$ModulePath,[string]$Root) +$ErrorActionPreference='Stop';$ProgressPreference='SilentlyContinue' +$module=Import-Module $ModulePath -Force -PassThru -DisableNameChecking +$lease=$null +try { + $lease=&$module {param($Root)Open-CcodOfficialDraftOperationLease -EvidenceRoot $Root} $Root + [Console]::WriteLine('CCOD_OPERATION_LEASE_HELD');[Console]::Out.Flush() + [void][Console]::ReadLine() +} finally {if($null-ne$lease){&$module {param($Lease)Close-CcodOfficialDraftOperationLease $Lease} $lease}} +'@ + [IO.File]::WriteAllText($child,$text,[Text.UTF8Encoding]::new($false)) + $start=[Diagnostics.ProcessStartInfo]::new();$start.FileName=Join-Path $PSHOME 'powershell.exe' + $start.Arguments='-NoLogo -NoProfile -NonInteractive -File "'+$child+'" -ModulePath "'+$modulePath+'" -Root "'+$evidence+'"' + $start.UseShellExecute=$false;$start.RedirectStandardOutput=$true;$start.RedirectStandardError=$true;$start.RedirectStandardInput=$true;$start.CreateNoWindow=$true + [void]$start.EnvironmentVariables.Remove('PSExecutionPolicyPreference') + $process=[Diagnostics.Process]::Start($start) + $errors=$process.StandardError.ReadToEndAsync();$line=$process.StandardOutput.ReadLineAsync() + Assert-CcodTrue ($line.Wait(15000)) 'normal-policy child reports the real acquired lock without a blind delay' + Assert-CcodEqual 'CCOD_OPERATION_LEASE_HELD' $line.Result 'child reached actual native lease' + Remove-Module $module.Name -Force;$module=Import-Module $modulePath -Force -PassThru -DisableNameChecking + Assert-CcodThrows {&$module {param($Root)Open-CcodOfficialDraftOperationLease -EvidenceRoot $Root} $evidence|Out-Null} 'CCOD_ACCEPTANCE_OPERATION_BUSY' + Write-CcodTestProcessInput -Process $process -Text 'release' -AddNewLine + Assert-CcodTrue ($process.WaitForExit(15000)) 'owned inert child exits after explicit release' + Assert-CcodEqual 0 $process.ExitCode 'child lease release succeeds' + Assert-CcodEqual '' $errors.Result ('native child has no hidden lock error; stderr=[' + $errors.Result.Replace("`r",'').Replace("`n",'') + ']') + $first=&$module {param($Root)Open-CcodOfficialDraftOperationLease -EvidenceRoot $Root} $evidence + Assert-CcodTrue ($null-ne$first) 'new acquisition after child exit proves release' + $blocked=$false;try{[IO.Directory]::Move($evidence,$evidence+'.moved')}catch [IO.IOException]{$blocked=$true} + Assert-CcodTrue $blocked 'held evidence-root authority denies ancestor replacement' + &$module {param($Lease)Close-CcodOfficialDraftOperationLease $Lease} $first;$first=$null + $lockPath=Join-Path $evidence '.ccod-official-draft-operation.lock' + [IO.File]::WriteAllText($lockPath,'foreign lock contents') + Assert-CcodThrows {&$module {param($Root)Open-CcodOfficialDraftOperationLease -EvidenceRoot $Root} $evidence|Out-Null} 'CCOD_ACCEPTANCE_OPERATION_LOCK_FAILED' + Assert-CcodEqual 'foreign lock contents' ([IO.File]::ReadAllText($lockPath)) 'unsafe lock content remains untouched' + [IO.File]::WriteAllBytes($lockPath,[byte[]]@()) + $first=&$module {param($Root)Open-CcodOfficialDraftOperationLease -EvidenceRoot $Root} $evidence + } finally { + if($null-ne$process){if(-not$process.HasExited){$process.StandardInput.Close();if(-not$process.WaitForExit(15000)){$process.Kill();[void]$process.WaitForExit(10000)}};$process.Dispose()} + foreach($lease in @($second,$first)){if($null-ne$lease){&$module {param($Lease)Close-CcodOfficialDraftOperationLease $Lease} $lease}} + if($null-ne$module){Remove-Module $module.Name -Force -ErrorAction SilentlyContinue} + if(Test-Path $root){Remove-Item $root -Recurse -Force} + } + } + + Invoke-CcodTest 'failed Complete recovery never adopts deletion ownership of a preexisting state receipt' { + $fixture=New-CcodAcceptanceCandidateFixture + $previousRoot=Join-Path ([IO.Path]::GetTempPath()) ('ccod-complete-reuse-'+[guid]::NewGuid().ToString('N')) + $evidenceRoot=Join-Path $fixture.Root 'evidence';$manualRoot=Join-Path $fixture.Root 'manual-input' + [IO.Directory]::CreateDirectory($previousRoot)|Out-Null;[IO.Directory]::CreateDirectory($evidenceRoot)|Out-Null + $state=[pscustomobject]@{DefenderCalls=[Collections.Generic.List[string]]::new();RunCalls=[Collections.Generic.List[string]]::new();CaptureCalls=[Collections.Generic.List[string]]::new();BootCalls=0;RebootCalls=0;PreviousCalls=0;BootId='boot-1';KeyHash=('b'*64);Facts=New-CcodAcceptanceFacts} + try { + $adapters=New-CcodAcceptancePhaseAdapters -Fixture $fixture -State $state -ReturnAbsentAfterUninstall + Invoke-CcodAcceptanceThroughReady -Fixture $fixture -PreviousRoot $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -State $state + $paths=New-CcodAcceptanceManualInputFiles -Root $manualRoot + Invoke-CcodAcceptanceManualRecords -Fixture $fixture -PreviousRoot $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -Paths $paths + $invokeArgs=@{Phase='Complete';AssetDirectory=$fixture.Root;PreviousAssetDirectory=$previousRoot;EvidenceRoot=$evidenceRoot;Adapters=$adapters} + $control=Invoke-CcodAcceptancePrivate @invokeArgs + Assert-CcodTrue $control.complete 'full successful control precedes recovery failure' + $statePath=Join-Path $evidenceRoot 'official-draft-acceptance\08-Complete.json' + $acceptancePath=Join-Path $evidenceRoot 'acceptance\CodexRemote-fix-2.5.22-official-draft.complete.json' + $stateHash=Get-CcodTestFileSha256 $statePath + [IO.File]::Delete($acceptancePath) + $artifact=Join-Path $evidenceRoot 'official-draft-artifacts\About\screenshot.bin' + $bytes=[IO.File]::ReadAllBytes($artifact) + [IO.File]::WriteAllText($artifact,'independent artifact drift') + Assert-CcodThrows {Invoke-CcodAcceptancePrivate @invokeArgs|Out-Null} 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID' + Assert-CcodTrue ([IO.File]::Exists($statePath)) 'reused Complete state is not owned by the failed invocation' + Assert-CcodEqual $stateHash (Get-CcodTestFileSha256 $statePath) 'failed recovery preserves prior terminal evidence bytes' + Assert-CcodTrue (-not[IO.File]::Exists($acceptancePath)) 'failed recovery does not create a successful aggregate' + [IO.File]::WriteAllBytes($artifact,$bytes) + $retry=Invoke-CcodAcceptancePrivate @invokeArgs + Assert-CcodTrue $retry.complete 'safe existing-state recovery remains retryable after the root cause is corrected' + } finally {if(Test-Path $fixture.Root){Remove-Item $fixture.Root -Recurse -Force};if(Test-Path $previousRoot){Remove-Item $previousRoot -Recurse -Force}} + } + + Invoke-CcodTest 'official draft Complete resumes when the terminal state receipt survived without acceptance' { + $fixture = New-CcodAcceptanceCandidateFixture + $previousRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-acceptance-previous-' + [guid]::NewGuid().ToString('N')) + $evidenceRoot = Join-Path $fixture.Root 'evidence' + $manualRoot = Join-Path $fixture.Root 'manual-input' + [IO.Directory]::CreateDirectory($previousRoot) | Out-Null + [IO.Directory]::CreateDirectory($evidenceRoot) | Out-Null + [IO.Directory]::CreateDirectory($manualRoot) | Out-Null + $state = [pscustomobject]@{ DefenderCalls = [Collections.Generic.List[string]]::new(); RunCalls = [Collections.Generic.List[string]]::new(); CaptureCalls = [Collections.Generic.List[string]]::new(); BootCalls = 0; RebootCalls = 0; PreviousCalls = 0; BootId = 'boot-1'; KeyHash = ('b' * 64); Facts = New-CcodAcceptanceFacts } + try { + $adapters = New-CcodAcceptancePhaseAdapters -Fixture $fixture -State $state -ReturnAbsentAfterUninstall + Invoke-CcodAcceptanceThroughReady -Fixture $fixture -PreviousRoot $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -State $state + $paths = New-CcodAcceptanceManualInputFiles -Root $manualRoot + Invoke-CcodAcceptanceManualRecords -Fixture $fixture -PreviousRoot $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -Paths $paths + $stateDirectory = Join-Path $evidenceRoot 'official-draft-acceptance' + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + try { + &$module { + param($Directory,$Previous) + $records = Read-CcodOfficialDraftState -StateDirectory $Directory -PreviousAssetDirectory $Previous + $facts = [pscustomobject][ordered]@{ manualEvidence = @($records.ManualEvidence.Values); status = 'Complete'; complete = $true } + Write-CcodOfficialDraftReceipt -StateDirectory $Directory -Phase Complete -Candidate $records.Preflight.candidate -Draft $records.Preflight.draft -Facts $facts | Out-Null + } $stateDirectory $previousRoot + } finally { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + $completeState = Join-Path $stateDirectory '08-Complete.json' + Assert-CcodTrue (Test-Path -LiteralPath $completeState -PathType Leaf) 'terminal state receipt exists before simulated recovery' + $result = Invoke-CcodAcceptancePrivate -Phase Complete -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters + Assert-CcodEqual 'Complete' ([string]$result.outcome) 'Complete resumes from an existing terminal state receipt' + Assert-CcodTrue (Test-Path -LiteralPath (Join-Path $evidenceRoot 'acceptance/CodexRemote-fix-2.5.22-official-draft.complete.json') -PathType Leaf) 'recovery writes the missing acceptance artifact' + } finally { + if (Test-Path -LiteralPath $fixture.Root) { Remove-Item -LiteralPath $fixture.Root -Recurse -Force } + if (Test-Path -LiteralPath $previousRoot) { Remove-Item -LiteralPath $previousRoot -Recurse -Force } + } + } + + Invoke-CcodTest 'official draft accepts persisted Int64 receipt numbers from PowerShell Core' { + $fixture = New-CcodAcceptanceCandidateFixture + $previousRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-acceptance-previous-' + [guid]::NewGuid().ToString('N')) + $evidenceRoot = Join-Path $fixture.Root 'evidence' + [IO.Directory]::CreateDirectory($previousRoot) | Out-Null + [IO.Directory]::CreateDirectory($evidenceRoot) | Out-Null + $state = [pscustomobject]@{ DefenderCalls = [Collections.Generic.List[string]]::new(); RunCalls = [Collections.Generic.List[string]]::new(); CaptureCalls = [Collections.Generic.List[string]]::new(); BootCalls = 0; RebootCalls = 0; PreviousCalls = 0; BootId = 'boot-1'; KeyHash = ('b' * 64); Facts = New-CcodAcceptanceFacts } + try { + $adapters = New-CcodAcceptancePhaseAdapters -Fixture $fixture -State $state + $preflight = Invoke-CcodAcceptancePrivate -Phase Preflight -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters + $preflightPath = Join-Path (Join-Path $evidenceRoot 'official-draft-acceptance') '01-Preflight.json' + $receipt = [IO.File]::ReadAllText($preflightPath) | ConvertFrom-Json + $receipt.schemaVersion = [int64]$receipt.schemaVersion + foreach ($defender in @($receipt.facts.defenderReceipts)) { + $defender.schemaVersion = [int64]$defender.schemaVersion + $defender.zoneId = [int64]$defender.zoneId + $defender.detectionCount = [int64]$defender.detectionCount + } + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + try { + $normalized = &$module { + param($Receipt,$Candidate) + Assert-CcodOfficialDraftReceiptShape -Receipt $Receipt -ExpectedPhase 'Preflight' + } $receipt $preflight.candidate + Assert-CcodEqual 'Preflight' ([string]$normalized.phase) 'Int64 receipt numbers remain readable across PowerShell runtimes' + } finally { + if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + } + } finally { + if (Test-Path -LiteralPath $fixture.Root) { Remove-Item -LiteralPath $fixture.Root -Recurse -Force } + if (Test-Path -LiteralPath $previousRoot) { Remove-Item -LiteralPath $previousRoot -Recurse -Force } + } + } + + Invoke-CcodTest 'official draft optional state probes fail closed on access errors' { + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + try { + Assert-CcodThrows { &$module { Get-CcodOfficialDraftOptionalPathState -Path 'C:\\fixture\\missing.json' -ExpectDirectory $false -GetItem { param($Path) throw [UnauthorizedAccessException]::new('fixture denied') } -ErrorId 'CCOD_ACCEPTANCE_RECEIPT_INVALID' } } 'CCOD_ACCEPTANCE_RECEIPT_INVALID' + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + } + } + + Invoke-CcodTest 'official draft default observations reject wrong-type install residue' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-default-observation-' + [guid]::NewGuid().ToString('N')) + $runtime = Join-Path $root 'runtime' + $pointer = Join-Path $root 'active.json' + [IO.Directory]::CreateDirectory($root) | Out-Null + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + $realIntegration = & $module { Get-CcodOfficialDraftIntegrationModule } + $state = [pscustomobject]@{ Facts = (New-CcodAcceptanceRawFacts -Absent); Probes = [Collections.Generic.List[string]]::new() } + $fakeIntegration = New-Module -ArgumentList $state,$realIntegration -ScriptBlock { + param($State,$Real) + function Get-CcodInstalledLifecycleFacts { + param([string]$InstallRoot,[string]$ExpectedVersion) + $observed = $State.Facts.PSObject.Copy() + & $Real { + param($Root,$Facts,$State) + $probe = { param($Path) Get-Item -LiteralPath $Path -Force -ErrorAction Stop } + $runtimePath = Join-Path $Root 'runtime' + $pointerPath = Join-Path $Root 'active.json' + $State.Probes.Add($runtimePath) + $Facts.runtimeRootPresent = Get-CcodInstalledLifecycleOptionalDirectoryState -Path $runtimePath -GetItem $probe + $State.Probes.Add($pointerPath) + $Facts.activePointerPresent = $null -ne (Get-CcodInstalledLifecycleOptionalRegularFileState -Path $pointerPath -GetItem $probe) + } $InstallRoot $observed $State + return $observed + } + Export-ModuleMember -Function Get-CcodInstalledLifecycleFacts + } + try { + $capture = & $module { + param($Fake,$InstallRoot) + $script:CcodOfficialDraftIntegrationModule = $Fake + $script:CcodOfficialDraftInstallRoot = $InstallRoot + (Get-CcodOfficialDraftDefaultAdapters).CaptureFacts + } $fakeIntegration $root + foreach ($kind in @('Runtime','Pointer')) { + [IO.Directory]::CreateDirectory($runtime) | Out-Null + [IO.File]::WriteAllText($pointer,'{}',[Text.UTF8Encoding]::new($false)) + $control = & $capture ([pscustomobject]@{ phase = 'Preflight' }) 'Before' + Assert-CcodTrue ($control.runtimeRootPresent -and $control.activePointerPresent) 'real path probes accept the valid directory/file pair' + $badPath = if ($kind -ceq 'Runtime') { $runtime } else { $pointer } + Remove-Item -LiteralPath $badPath -Recurse -Force + if ($kind -ceq 'Runtime') { [IO.File]::WriteAllText($runtime,'wrong-type') } + else { [IO.Directory]::CreateDirectory($pointer) | Out-Null } + $previousProbes = $state.Probes.Count + Assert-CcodThrows { & $capture ([pscustomobject]@{ phase = 'Preflight' }) 'After' | Out-Null } 'CCOD_ACCEPTANCE_OBSERVATION_UNAVAILABLE' + Assert-CcodTrue ($state.Probes.Count -gt $previousProbes) 'the invalid target reaches the production path probe' + Assert-CcodEqual $badPath $state.Probes[$state.Probes.Count - 1] 'the intended wrong-type path, not an earlier fixture defect, rejects' + Remove-Item -LiteralPath $runtime -Recurse -Force + Remove-Item -LiteralPath $pointer -Recurse -Force + } + } finally { + if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + if ($null -ne $fakeIntegration) { Remove-Module -Name $fakeIntegration.Name -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } + } + + Invoke-CcodTest 'official draft default observations reject incomplete integration facts' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-default-incomplete-facts-' + [guid]::NewGuid().ToString('N')) + [IO.Directory]::CreateDirectory($root) | Out-Null + $fakeIntegration = New-Module -ScriptBlock { + function Get-CcodInstalledLifecycleFacts { + param([string]$InstallRoot,[string]$ExpectedVersion) + return [pscustomobject][ordered]@{ + taskState = 'Absent'; supervisor = @(); trayHost = @() + deviceKeyPresent = $true; deviceKeySha256 = ('b' * 64) + } + } + function Read-CcodInstalledLifecycleStatusFact { param([string]$StateRoot); return $null } + Export-ModuleMember -Function Get-CcodInstalledLifecycleFacts,Read-CcodInstalledLifecycleStatusFact + } + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + try { + Assert-CcodThrows { + &$module { + param($Fake,$InstallRoot) + $script:CcodOfficialDraftIntegrationModule = $Fake + $script:CcodOfficialDraftInstallRoot = $InstallRoot + $adapters = Get-CcodOfficialDraftDefaultAdapters + & $adapters.CaptureFacts ([pscustomobject]@{ phase = 'Uninstall' }) 'After' | Out-Null + } $fakeIntegration $root + } 'CCOD_ACCEPTANCE_OBSERVATION_UNAVAILABLE' + } finally { + if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + if ($null -ne $fakeIntegration) { Remove-Module -Name $fakeIntegration.Name -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } + } + + Invoke-CcodTest 'official draft optional path probe fails closed on access errors' { + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + try { + Assert-CcodThrows { + &$module { + $probe = { param($Path) throw [UnauthorizedAccessException]::new('fixture access denied') } + Get-CcodOfficialDraftOptionalPathState -Path 'C:\fixture\blocked' -ExpectDirectory $true -GetItem $probe -ErrorId 'CCOD_ACCEPTANCE_OBSERVATION_UNAVAILABLE' + } + } 'CCOD_ACCEPTANCE_OBSERVATION_UNAVAILABLE' + } finally { + if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + } + } + + Invoke-CcodTest 'official draft optional missing path rejects a reparse ancestor' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-optional-reparse-' + [guid]::NewGuid().ToString('N')) + $outside = Join-Path ([IO.Path]::GetTempPath()) ('ccod-optional-reparse-outside-' + [guid]::NewGuid().ToString('N')) + $module = $null + try { + [IO.Directory]::CreateDirectory($root) | Out-Null + [IO.Directory]::CreateDirectory($outside) | Out-Null + $ancestor = Join-Path $root 'ancestor' + New-Item -ItemType Junction -Path $ancestor -Target $outside | Out-Null + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + Assert-CcodThrows { + &$module { + param($Path) + $probe = { param($CandidatePath) Get-Item -LiteralPath $CandidatePath -Force -ErrorAction Stop } + Get-CcodOfficialDraftOptionalPathState -Path $Path -ExpectDirectory $false -GetItem $probe -ErrorId 'CCOD_ACCEPTANCE_OBSERVATION_UNAVAILABLE' + } (Join-Path $ancestor 'missing.txt') + } 'CCOD_ACCEPTANCE_OBSERVATION_UNAVAILABLE' + } finally { + if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $outside) { Remove-Item -LiteralPath $outside -Recurse -Force -ErrorAction SilentlyContinue } + } + } + + Invoke-CcodTest 'official draft cleanup validates ancestors before accepting a missing target' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-cleanup-missing-reparse-' + [guid]::NewGuid().ToString('N')) + $outside = Join-Path ([IO.Path]::GetTempPath()) ('ccod-cleanup-missing-reparse-outside-' + [guid]::NewGuid().ToString('N')) + $junction = Join-Path $root 'unsafe' + $target = Join-Path $junction 'missing.json' + [IO.Directory]::CreateDirectory($root) | Out-Null + [IO.Directory]::CreateDirectory($outside) | Out-Null + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + try { + New-Item -ItemType Junction -Path $junction -Target $outside | Out-Null + Assert-CcodThrows { + &$module { param($Path) Remove-CcodOfficialDraftCompleteArtifacts -Paths @($Path) -ExpectedHashes @{} } $target | Out-Null + } 'CCOD_ACCEPTANCE_COMPLETE_CLEANUP_FAILED' + } finally { + if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $junction) { Remove-Item -LiteralPath $junction -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $outside) { Remove-Item -LiteralPath $outside -Recurse -Force -ErrorAction SilentlyContinue } + } + } + + Invoke-CcodTest 'official draft default observations recheck the install root after mutation' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-default-root-recheck-' + [guid]::NewGuid().ToString('N')) + [IO.Directory]::CreateDirectory($root) | Out-Null + $facts = New-CcodAcceptanceRawFacts -Absent + $fakeIntegration = New-Module -ArgumentList $facts -ScriptBlock { + param($Facts) + function Get-CcodInstalledLifecycleFacts { param([string]$InstallRoot,[string]$ExpectedVersion); return $Facts } + Export-ModuleMember -Function Get-CcodInstalledLifecycleFacts + } + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + try { + $capture = & $module { + param($Fake,$InstallRoot) + $script:CcodOfficialDraftIntegrationModule = $Fake + $script:CcodOfficialDraftInstallRoot = $InstallRoot + (Get-CcodOfficialDraftDefaultAdapters).CaptureFacts + } $fakeIntegration $root + $context = [pscustomobject]@{ phase = 'Preflight' } + $control = & $capture $context 'After' + Assert-CcodTrue $control.installRootPresent 'valid directory control succeeds before changing only the root type' + Remove-Item -LiteralPath $root -Recurse -Force + [IO.File]::WriteAllText($root,'root residue',[Text.UTF8Encoding]::new($false)) + Assert-CcodThrows { & $capture $context 'After' | Out-Null } 'CCOD_ACCEPTANCE_OBSERVATION_UNAVAILABLE' + } finally { + if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + if ($null -ne $fakeIntegration) { Remove-Module -Name $fakeIntegration.Name -Force -ErrorAction SilentlyContinue } + if ([IO.File]::Exists($root)) { Remove-Item -LiteralPath $root -Force -ErrorAction SilentlyContinue } + if ([IO.Directory]::Exists($root)) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } + } + } + + Invoke-CcodTest 'official draft default observations reject pseudo-integer generations' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-default-generation-type-' + [guid]::NewGuid().ToString('N')) + [IO.Directory]::CreateDirectory($root) | Out-Null + $facts = New-CcodAcceptanceRawFacts + $fakeIntegration = New-Module -ArgumentList $facts -ScriptBlock { + param($Facts) + function Get-CcodInstalledLifecycleFacts { param([string]$InstallRoot,[string]$ExpectedVersion); return $Facts } + Export-ModuleMember -Function Get-CcodInstalledLifecycleFacts + } + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + try { + $capture = & $module { + param($Fake,$InstallRoot) + $script:CcodOfficialDraftIntegrationModule = $Fake + $script:CcodOfficialDraftInstallRoot = $InstallRoot + (Get-CcodOfficialDraftDefaultAdapters).CaptureFacts + } $fakeIntegration $root + $context = [pscustomobject]@{ phase = 'Preflight'; expectedVersion = '2.5.22' } + foreach ($bad in @([double]2,[bool]$true,'2',([object[]]@(2)))) { + $facts.activeGeneration = [uint64]2 + $control = & $capture $context 'After' + Assert-CcodEqual ([uint64]2) $control.activeGeneration 'the complete raw fixture exposes its valid generation' + $facts.activeGeneration = $bad + Assert-CcodThrows { & $capture $context 'After' | Out-Null } 'CCOD_ACCEPTANCE_OBSERVATION_UNAVAILABLE' + } + } finally { + if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + if ($null -ne $fakeIntegration) { Remove-Module -Name $fakeIntegration.Name -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } + } + } + + Invoke-CcodTest 'official draft default observations accept Int64 process identities' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-default-pid-type-' + [guid]::NewGuid().ToString('N')) + $logs = Join-Path $root 'logs' + [IO.Directory]::CreateDirectory((Join-Path $root 'runtime')) | Out-Null + [IO.Directory]::CreateDirectory($logs) | Out-Null + [IO.File]::WriteAllText((Join-Path $root 'active.json'), '{}', [Text.UTF8Encoding]::new($false)) + $trayReady = [ordered]@{ + schemaVersion = 1; timestampUtc = '2030-02-03T04:05:03.0000000Z'; component = 'Supervisor'; stage = 'TrayHostReady' + code = 'CCOD_TRAYHOST_READY'; outcome = 'Completed'; runtimeId = 'runtime-2.5.22'; hostPid = 201 + hostCreationTimeUtc = '2030-02-03T04:05:01.0000000Z'; protocolMajor = 2; capabilities = [UInt64]2 + } + [IO.File]::WriteAllText((Join-Path $logs 'supervisor.log'), (($trayReady | ConvertTo-Json -Compress) + "`n"), [Text.UTF8Encoding]::new($false)) + $facts = [pscustomobject][ordered]@{ + installRootPresent = $true; appPresent = $true; runtimeRootPresent = $true; activePointerPresent = $true; activeRuntimeId = 'runtime-2.5.22'; activeGeneration = [UInt64]2; runtimeManifestSha256 = ('d' * 64) + supervisor = @([pscustomobject]@{ pid = 200; creationTimeUtc = '2030-02-03T04:05:00.0000000Z' }) + trayHost = @([pscustomobject]@{ pid = 201; creationTimeUtc = '2030-02-03T04:05:01.0000000Z' }) + trayHostIdentity = [pscustomobject][ordered]@{ pid = 201; creationTimeUtc = '2030-02-03T04:05:01.0000000Z' } + codex = @([pscustomobject]@{ pid = [int64]202; creationTimeUtc = '2030-02-03T04:05:02.0000000Z' }); codexCount = [int64]1 + trayAuthenticated = $true + taskState = 'Ready'; statusPhase = 'Active'; statusRuntimeId = 'runtime-2.5.22' + statusCodex = [pscustomobject]@{ pid = [int64]202; creationTimeUtc = '2030-02-03T04:05:02.0000000Z' } + transitionStage = 'Idle'; lifecycleReceipt = [pscustomobject][ordered]@{ kind = 'RestartAndRepair'; origin = 'Installer'; runtimeId = 'runtime-2.5.22'; runtimeGeneration = [UInt64]2; phase = 'Completed' } + aboutVersion = '2.5.22'; deviceKeyPresent = $true; deviceKeySha256 = ('b' * 64) + shortcuts = [pscustomobject][ordered]@{ startMenu = $true; desktop = $true } + debugEndpoints = @([pscustomobject][ordered]@{ localAddress = '127.0.0.1'; localPort = 9229; owningProcess = 202; owningProcessCreationTimeUtc = '2030-02-03T04:05:02.0000000Z' },[pscustomobject][ordered]@{ localAddress = '127.0.0.1'; localPort = 9230; owningProcess = 202; owningProcessCreationTimeUtc = '2030-02-03T04:05:02.0000000Z' }) + debugPorts = @(9229,9230) + } + $fakeIntegration = New-Module -ArgumentList $facts -ScriptBlock { + param($Facts) + function Get-CcodInstalledLifecycleFacts { param([string]$InstallRoot,[string]$ExpectedVersion); return $Facts } + Export-ModuleMember -Function Get-CcodInstalledLifecycleFacts + } + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + try { + $observed = &$module { + param($Fake,$InstallRoot) + $script:CcodOfficialDraftIntegrationModule = $Fake + $script:CcodOfficialDraftInstallRoot = $InstallRoot + $adapters = Get-CcodOfficialDraftDefaultAdapters + & $adapters.CaptureFacts ([pscustomobject]@{ phase = 'LegacyUpgrade'; expectedVersion = '2.5.22' }) 'After' + } $fakeIntegration $root + Assert-CcodTrue ([bool]$observed.trayAuthenticated) 'Int64 process identities preserve authenticated readiness' + } finally { + if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + if ($null -ne $fakeIntegration) { Remove-Module -Name $fakeIntegration.Name -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } + } + } + + Invoke-CcodTest 'official draft default legacy observations reject a missing installed baseline' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-default-legacy-baseline-' + [guid]::NewGuid().ToString('N')) + [IO.Directory]::CreateDirectory($root) | Out-Null + # The integration helper's protected event and CIM behavior have separate native tests. + $state = [pscustomobject]@{ Facts = (New-CcodAcceptanceRawFacts); Calls = 0; ReadyCalls = 0; Ready = $true } + $state.Facts.aboutVersion = '2.5.21' + $state.Facts.activeRuntimeId='2.5.21-e3b0c44298fc1c14' + $state.Facts.statusRuntimeId=$state.Facts.activeRuntimeId + $state.Facts.lifecycleReceipt.runtimeId=$state.Facts.activeRuntimeId + $fakeIntegration = New-Module -ArgumentList $state -ScriptBlock { + param($State) + function Get-CcodInstalledLifecycleFacts { param($InstallRoot,$ExpectedVersion) $State.Calls++; return $State.Facts } + function Get-CcodInstalledLifecycleLegacyTrayReadyProof { + param($InstallRoot,$RuntimeId,$Supervisor,$TrayHost) + $State.ReadyCalls++ + if($RuntimeId-cne$State.Facts.activeRuntimeId-or@($Supervisor).Count-ne1-or$Supervisor[0].pid-ne200-or@($TrayHost).Count-ne1-or$TrayHost[0].pid-ne201){throw 'legacy helper arguments'} + return $State.Ready + } + Export-ModuleMember -Function Get-CcodInstalledLifecycleFacts,Get-CcodInstalledLifecycleLegacyTrayReadyProof + } + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + try { + $capture = &$module { + param($Fake,$InstallRoot) + $script:CcodOfficialDraftIntegrationModule = $Fake + $script:CcodOfficialDraftInstallRoot = $InstallRoot + (Get-CcodOfficialDraftDefaultAdapters).CaptureFacts + } $fakeIntegration $root + $context = [pscustomobject][ordered]@{ phase='LegacyUpgrade'; expectedVersion='2.5.22'; previousSetup=[pscustomobject]@{version='2.5.21'} } + $control = & $capture $context 'Before' + Assert-CcodTrue $control.protectionReady 'legacy observation uses protected event proof without a newer log' + Assert-CcodEqual 1 $state.ReadyCalls 'default capture invokes the legacy helper once' + $state.Ready=$false + $notReady=&$capture $context 'Before' + Assert-CcodTrue (-not$notReady.protectionReady-and-not$notReady.trayAuthenticated) 'negative protected-event proof cannot become readiness' + $state.Ready=$true + $previousCalls = $state.Calls + $state.Facts = New-CcodAcceptanceRawFacts -Absent + Assert-CcodThrows { & $capture $context 'Before' | Out-Null } 'CCOD_ACCEPTANCE_OBSERVATION_UNAVAILABLE' + Assert-CcodEqual ($previousCalls + 1) $state.Calls 'the absent installed observation reaches the same adapter boundary' + } finally { + if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + if ($null -ne $fakeIntegration) { Remove-Module -Name $fakeIntegration.Name -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } + } +} + +Invoke-CcodTest 'official draft default uninstall observations require two debug ports' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-default-uninstall-ports-' + [guid]::NewGuid().ToString('N')) + [IO.Directory]::CreateDirectory($root) | Out-Null + $facts = New-CcodAcceptanceRawFacts + $fakeIntegration = New-Module -ArgumentList $facts -ScriptBlock { + param($Facts) + function Get-CcodInstalledLifecycleFacts { param([string]$InstallRoot,[string]$ExpectedVersion); return $Facts } + function Read-CcodInstalledLifecycleStatusFact { param($StateRoot) throw 'status must not be reread after the fact snapshot' } + Export-ModuleMember -Function Get-CcodInstalledLifecycleFacts,Read-CcodInstalledLifecycleStatusFact + } + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + try { + $capture = & $module { + param($Fake,$InstallRoot) + $script:CcodOfficialDraftIntegrationModule=$Fake + $script:CcodOfficialDraftInstallRoot=$InstallRoot + (Get-CcodOfficialDraftDefaultAdapters).CaptureFacts + } $fakeIntegration $root + $context = [pscustomobject]@{ phase='Uninstall' } + foreach ($bad in @(@(),@(9229),@(9229,9229),@([double]9229.25,9230),'9229')) { + $facts.debugPorts = @(9229,9230) + $control = & $capture $context 'Before' + Assert-CcodEqual '9229,9230' ($control.debugPorts -join ',') 'two captured ports are accepted before a single port-shape mutation' + $facts.debugPorts = $bad + Assert-CcodThrows { & $capture $context 'Before' | Out-Null } 'CCOD_ACCEPTANCE_OBSERVATION_UNAVAILABLE' + } + } finally { + if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + if ($null -ne $fakeIntegration) { Remove-Module -Name $fakeIntegration.Name -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } + } + } + + Invoke-CcodTest 'official draft default observations include the current boot identity' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-default-boot-' + [guid]::NewGuid().ToString('N')) + [IO.Directory]::CreateDirectory($root) | Out-Null + [IO.Directory]::CreateDirectory((Join-Path $root 'runtime')) | Out-Null + [IO.Directory]::CreateDirectory((Join-Path $root 'logs')) | Out-Null + [IO.File]::WriteAllText((Join-Path $root 'active.json'), '{}', [Text.UTF8Encoding]::new($false)) + $trayReady = [ordered]@{ schemaVersion = 1; timestampUtc = '2030-02-03T04:05:03.0000000Z'; component = 'Supervisor'; stage = 'TrayHostReady'; code = 'CCOD_TRAYHOST_READY'; outcome = 'Completed'; runtimeId = 'runtime-2.5.22'; hostPid = 201; hostCreationTimeUtc = '2030-02-03T04:05:01.0000000Z'; protocolMajor = 2; capabilities = [UInt64]2 } + [IO.File]::WriteAllText((Join-Path $root 'logs\supervisor.log'), (($trayReady | ConvertTo-Json -Compress) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + $facts = New-CcodAcceptanceFacts -BootId 'fixture-ignored' + $facts | Add-Member -NotePropertyName appPresent -NotePropertyValue $true + $facts.aboutVersion = '2.5.22' + $fakeIntegration = New-Module -ArgumentList $facts -ScriptBlock { + param($Facts) + function Get-CcodInstalledLifecycleFacts { param([string]$InstallRoot,[string]$ExpectedVersion); return $Facts } + Export-ModuleMember -Function Get-CcodInstalledLifecycleFacts + } + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + try { + $observed = &$module { + param($Fake,$InstallRoot) + $script:CcodOfficialDraftIntegrationModule = $Fake + $script:CcodOfficialDraftInstallRoot = $InstallRoot + $adapters = Get-CcodOfficialDraftDefaultAdapters + & $adapters.CaptureFacts ([pscustomobject]@{ phase = 'PostReboot'; expectedVersion = '2.5.22' }) 'After' + } $fakeIntegration $root + Assert-CcodTrue ($observed.bootId -is [string] -and $observed.bootId.Length -gt 0) 'default observations include a sanitized boot identity' + Assert-CcodTrue ($observed.trayAuthenticated -and $observed.protectionReady) 'default observations require the authenticated tray proof and matching runtime status' + } finally { + if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + if ($null -ne $fakeIntegration) { Remove-Module -Name $fakeIntegration.Name -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } + } + + Invoke-CcodTest 'official draft post-reboot rejects an observed active generation that differs from the terminal receipt' { + $fixture = New-CcodAcceptanceCandidateFixture + $previousRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-acceptance-previous-' + [guid]::NewGuid().ToString('N')) + $evidenceRoot = Join-Path $fixture.Root 'evidence' + [IO.Directory]::CreateDirectory($previousRoot) | Out-Null + [IO.Directory]::CreateDirectory($evidenceRoot) | Out-Null + $state = [pscustomobject]@{ DefenderCalls = [Collections.Generic.List[string]]::new(); RunCalls = [Collections.Generic.List[string]]::new(); CaptureCalls = [Collections.Generic.List[string]]::new(); BootCalls = 0; RebootCalls = 0; PreviousCalls = 0; BootId = 'boot-1'; KeyHash = ('b' * 64); Facts = New-CcodAcceptanceFacts } + try { + $adapters = New-CcodAcceptancePhaseAdapters -Fixture $fixture -State $state -ReturnAbsentAfterUninstall + Invoke-CcodAcceptancePrivate -Phase Preflight -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters | Out-Null + Invoke-CcodAcceptancePrivate -Phase LegacyUpgrade -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart | Out-Null + Invoke-CcodAcceptancePrivate -Phase Uninstall -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart | Out-Null + Invoke-CcodAcceptancePrivate -Phase FreshInstall -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart | Out-Null + Invoke-CcodAcceptancePrivate -Phase PreReboot -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart -AllowWindowsReboot | Out-Null + $state.BootId = 'boot-2' + $state.Facts = New-CcodAcceptanceFacts -BootId 'boot-2' + $state.Facts.activeRuntimeId = 'runtime-attacker' + $state.Facts.activeGeneration = [UInt64]99 + Assert-CcodThrows { + Invoke-CcodAcceptancePrivate -Phase PostReboot -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart -AllowWindowsReboot | Out-Null + } 'CCOD_ACCEPTANCE_POST_REBOOT_OBSERVATION_INVALID' + } finally { + if (Test-Path -LiteralPath $fixture.Root) { Remove-Item -LiteralPath $fixture.Root -Recurse -Force } + if (Test-Path -LiteralPath $previousRoot) { Remove-Item -LiteralPath $previousRoot -Recurse -Force } + } + } + + Invoke-CcodTest 'official draft replay rejects a tampered PreReboot continuity identity' { + $fixture = New-CcodAcceptanceCandidateFixture + $previousRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-acceptance-previous-' + [guid]::NewGuid().ToString('N')) + $evidenceRoot = Join-Path $fixture.Root 'evidence' + [IO.Directory]::CreateDirectory($previousRoot) | Out-Null + [IO.Directory]::CreateDirectory($evidenceRoot) | Out-Null + $state = [pscustomobject]@{ DefenderCalls = [Collections.Generic.List[string]]::new(); RunCalls = [Collections.Generic.List[string]]::new(); CaptureCalls = [Collections.Generic.List[string]]::new(); BootCalls = 0; RebootCalls = 0; PreviousCalls = 0; BootId = 'boot-1'; KeyHash = ('b' * 64); Facts = New-CcodAcceptanceFacts } + try { + $adapters = New-CcodAcceptancePhaseAdapters -Fixture $fixture -State $state -ReturnAbsentAfterUninstall + Invoke-CcodAcceptanceThroughReady -Fixture $fixture -PreviousRoot $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -State $state + $path = Join-Path (Join-Path $evidenceRoot 'official-draft-acceptance') '05-PreReboot.json' + $receipt = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json + $receipt.facts.activeRuntimeId = 'runtime-tampered' + [IO.File]::WriteAllText($path, ($receipt | ConvertTo-Json -Depth 12), [Text.UTF8Encoding]::new($false)) + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + try { Assert-CcodThrows { &$module { param($Directory) Read-CcodOfficialDraftState -StateDirectory $Directory } (Join-Path $evidenceRoot 'official-draft-acceptance') } 'CCOD_ACCEPTANCE_RECEIPT_INVALID' } finally { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + } finally { + if (Test-Path -LiteralPath $fixture.Root) { Remove-Item -LiteralPath $fixture.Root -Recurse -Force } + if (Test-Path -LiteralPath $previousRoot) { Remove-Item -LiteralPath $previousRoot -Recurse -Force } + } + } + + Invoke-CcodTest 'official draft replay revalidates the fixed v2.5.21 setup and manifest bytes' { + $fixture = New-CcodAcceptanceCandidateFixture + $previousRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-acceptance-previous-' + [guid]::NewGuid().ToString('N')) + $evidenceRoot = Join-Path $fixture.Root 'evidence' + [IO.Directory]::CreateDirectory($previousRoot) | Out-Null + [IO.Directory]::CreateDirectory($evidenceRoot) | Out-Null + $state = [pscustomobject]@{ DefenderCalls = [Collections.Generic.List[string]]::new(); RunCalls = [Collections.Generic.List[string]]::new(); CaptureCalls = [Collections.Generic.List[string]]::new(); BootCalls = 0; RebootCalls = 0; PreviousCalls = 0; BootId = 'boot-1'; KeyHash = ('b' * 64); Facts = New-CcodAcceptanceFacts } + try { + $adapters = New-CcodAcceptancePhaseAdapters -Fixture $fixture -State $state -ReturnAbsentAfterUninstall + Invoke-CcodAcceptanceThroughReady -Fixture $fixture -PreviousRoot $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -State $state + $path = Join-Path (Join-Path $evidenceRoot 'official-draft-acceptance') '02-LegacyUpgrade.json' + $receipt = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json + $receipt.facts.previousSetup.assetSha256 = ('e' * 64) + [IO.File]::WriteAllText($path, ($receipt | ConvertTo-Json -Depth 12), [Text.UTF8Encoding]::new($false)) + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + try { + Assert-CcodThrows { &$module { param($Directory,$Previous) Read-CcodOfficialDraftState -StateDirectory $Directory -PreviousAssetDirectory $Previous } (Join-Path $evidenceRoot 'official-draft-acceptance') $previousRoot } 'CCOD_ACCEPTANCE_RECEIPT_INVALID' + } finally { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + } finally { + if (Test-Path -LiteralPath $fixture.Root) { Remove-Item -LiteralPath $fixture.Root -Recurse -Force } + if (Test-Path -LiteralPath $previousRoot) { Remove-Item -LiteralPath $previousRoot -Recurse -Force } + } + } + + Invoke-CcodTest 'official draft replay rejects a detached PreReboot runtime manifest hash' { + $fixture = New-CcodAcceptanceCandidateFixture + $previousRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-acceptance-previous-' + [guid]::NewGuid().ToString('N')) + $evidenceRoot = Join-Path $fixture.Root 'evidence' + [IO.Directory]::CreateDirectory($previousRoot) | Out-Null + [IO.Directory]::CreateDirectory($evidenceRoot) | Out-Null + $state = [pscustomobject]@{ DefenderCalls = [Collections.Generic.List[string]]::new(); RunCalls = [Collections.Generic.List[string]]::new(); CaptureCalls = [Collections.Generic.List[string]]::new(); BootCalls = 0; RebootCalls = 0; PreviousCalls = 0; BootId = 'boot-1'; KeyHash = ('b' * 64); Facts = New-CcodAcceptanceFacts } + try { + $adapters = New-CcodAcceptancePhaseAdapters -Fixture $fixture -State $state -ReturnAbsentAfterUninstall + Invoke-CcodAcceptanceThroughReady -Fixture $fixture -PreviousRoot $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -State $state + $path = Join-Path (Join-Path $evidenceRoot 'official-draft-acceptance') '05-PreReboot.json' + $receipt = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json + $receipt.facts.observation.runtimeManifestSha256 = ('e' * 64) + [IO.File]::WriteAllText($path, ($receipt | ConvertTo-Json -Depth 12), [Text.UTF8Encoding]::new($false)) + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + try { Assert-CcodThrows { &$module { param($Directory) Read-CcodOfficialDraftState -StateDirectory $Directory } (Join-Path $evidenceRoot 'official-draft-acceptance') } 'CCOD_ACCEPTANCE_RECEIPT_INVALID' } finally { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + } finally { + if (Test-Path -LiteralPath $fixture.Root) { Remove-Item -LiteralPath $fixture.Root -Recurse -Force } + if (Test-Path -LiteralPath $previousRoot) { Remove-Item -LiteralPath $previousRoot -Recurse -Force } + } + } + + Invoke-CcodTest 'official draft replay rejects a PostReboot TrayHost identity detached from its observation' { + $fixture = New-CcodAcceptanceCandidateFixture + $previousRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-acceptance-previous-' + [guid]::NewGuid().ToString('N')) + $evidenceRoot = Join-Path $fixture.Root 'evidence' + [IO.Directory]::CreateDirectory($previousRoot) | Out-Null + [IO.Directory]::CreateDirectory($evidenceRoot) | Out-Null + $state = [pscustomobject]@{ DefenderCalls = [Collections.Generic.List[string]]::new(); RunCalls = [Collections.Generic.List[string]]::new(); CaptureCalls = [Collections.Generic.List[string]]::new(); BootCalls = 0; RebootCalls = 0; PreviousCalls = 0; BootId = 'boot-1'; KeyHash = ('b' * 64); Facts = New-CcodAcceptanceFacts } + try { + $adapters = New-CcodAcceptancePhaseAdapters -Fixture $fixture -State $state -ReturnAbsentAfterUninstall + Invoke-CcodAcceptanceThroughReady -Fixture $fixture -PreviousRoot $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -State $state + $path = Join-Path (Join-Path $evidenceRoot 'official-draft-acceptance') '06-PostReboot.json' + $receipt = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json + $receipt.facts.trayHostIdentity.pid = 999 + [IO.File]::WriteAllText($path, ($receipt | ConvertTo-Json -Depth 12), [Text.UTF8Encoding]::new($false)) + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + try { Assert-CcodThrows { &$module { param($Directory) Read-CcodOfficialDraftState -StateDirectory $Directory } (Join-Path $evidenceRoot 'official-draft-acceptance') } 'CCOD_ACCEPTANCE_RECEIPT_INVALID' } finally { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + } finally { + if (Test-Path -LiteralPath $fixture.Root) { Remove-Item -LiteralPath $fixture.Root -Recurse -Force } + if (Test-Path -LiteralPath $previousRoot) { Remove-Item -LiteralPath $previousRoot -Recurse -Force } + } + } + + Invoke-CcodTest 'official draft replay rejects a broken device-key chain across uninstall' { + $fixture = New-CcodAcceptanceCandidateFixture + $previousRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-acceptance-previous-' + [guid]::NewGuid().ToString('N')) + $evidenceRoot = Join-Path $fixture.Root 'evidence' + [IO.Directory]::CreateDirectory($previousRoot) | Out-Null + [IO.Directory]::CreateDirectory($evidenceRoot) | Out-Null + $state = [pscustomobject]@{ DefenderCalls = [Collections.Generic.List[string]]::new(); RunCalls = [Collections.Generic.List[string]]::new(); CaptureCalls = [Collections.Generic.List[string]]::new(); BootCalls = 0; RebootCalls = 0; PreviousCalls = 0; BootId = 'boot-1'; KeyHash = ('b' * 64); Facts = New-CcodAcceptanceFacts } + try { + $adapters = New-CcodAcceptancePhaseAdapters -Fixture $fixture -State $state -ReturnAbsentAfterUninstall + Invoke-CcodAcceptanceThroughReady -Fixture $fixture -PreviousRoot $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -State $state + $path = Join-Path (Join-Path $evidenceRoot 'official-draft-acceptance') '03-Uninstall.json' + $receipt = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json + $receipt.facts.deviceKeySha256Before = ('c' * 64) + $receipt.facts.deviceKeySha256After = ('c' * 64) + [IO.File]::WriteAllText($path, ($receipt | ConvertTo-Json -Depth 12), [Text.UTF8Encoding]::new($false)) + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + try { + Assert-CcodThrows { &$module { param($Directory) Read-CcodOfficialDraftState -StateDirectory $Directory } (Join-Path $evidenceRoot 'official-draft-acceptance') } 'CCOD_ACCEPTANCE_RECEIPT_INVALID' + } finally { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + } finally { + if (Test-Path -LiteralPath $fixture.Root) { Remove-Item -LiteralPath $fixture.Root -Recurse -Force } + if (Test-Path -LiteralPath $previousRoot) { Remove-Item -LiteralPath $previousRoot -Recurse -Force } + } + } + + Invoke-CcodTest 'official draft state reader rejects a reparse-point state root' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-state-reparse-' + [guid]::NewGuid().ToString('N')) + $outside = Join-Path ([IO.Path]::GetTempPath()) ('ccod-state-reparse-outside-' + [guid]::NewGuid().ToString('N')) + [IO.Directory]::CreateDirectory($outside) | Out-Null + try { + New-Item -ItemType Junction -Path $root -Target $outside | Out-Null + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + try { Assert-CcodThrows { &$module { param($Directory) Read-CcodOfficialDraftState -StateDirectory $Directory } $root } 'CCOD_ACCEPTANCE_RECEIPT_INVALID' } finally { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + } finally { + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $outside) { Remove-Item -LiteralPath $outside -Recurse -Force -ErrorAction SilentlyContinue } + } + } + + Invoke-CcodTest 'official draft state reader recovers closed orphan create-only temporaries' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-state-orphan-temp-' + [guid]::NewGuid().ToString('N')) + [IO.Directory]::CreateDirectory($root) | Out-Null + $acceptanceTemp = Join-Path $root ('.acceptance-' + [guid]::NewGuid().ToString('N') + '.tmp') + $manualTemp = Join-Path $root ('.manual-' + [guid]::NewGuid().ToString('N') + '.tmp') + [IO.File]::WriteAllText($acceptanceTemp, 'orphan-acceptance', [Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($manualTemp, 'orphan-manual', [Text.UTF8Encoding]::new($false)) + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + try { + $records = &$module { param($Directory) Read-CcodOfficialDraftState -StateDirectory $Directory } $root + Assert-CcodTrue ($records.ContainsKey('ManualEvidence')) 'state reader continues after orphan temporary recovery' + Assert-CcodTrue (-not (Test-Path -LiteralPath $acceptanceTemp) -and -not (Test-Path -LiteralPath $manualTemp)) 'state reader removes only recognized closed orphan temporaries' + } finally { + if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } + } + } + + Invoke-CcodTest 'official draft JSONL proofs reject a reparse parent outside the evidence root' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-jsonl-reparse-' + [guid]::NewGuid().ToString('N')) + $outside = Join-Path ([IO.Path]::GetTempPath()) ('ccod-jsonl-reparse-outside-' + [guid]::NewGuid().ToString('N')) + $junction = Join-Path $root 'logs' + [IO.Directory]::CreateDirectory($outside) | Out-Null + [IO.File]::WriteAllText((Join-Path $outside 'proof.jsonl'), '{"kind":"fixture"}', [Text.UTF8Encoding]::new($false)) + try { + [IO.Directory]::CreateDirectory($root) | Out-Null + New-Item -ItemType Junction -Path $junction -Target $outside | Out-Null + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + try { + Assert-CcodThrows { + &$module { param($Path) Get-CcodOfficialDraftJsonLines -Path $Path } (Join-Path $junction 'proof.jsonl') + } 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID' + } finally { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + } finally { + try { if ([IO.Directory]::Exists($junction)) { [IO.Directory]::Delete($junction, $false) } } catch { } + try { if ([IO.Directory]::Exists($root)) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } } catch { } + try { if ([IO.Directory]::Exists($outside)) { Remove-Item -LiteralPath $outside -Recurse -Force -ErrorAction SilentlyContinue } } catch { } + } + } + + Invoke-CcodTest 'official draft terminal receipts reject floating and boolean generations' { + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + try { + foreach ($bad in @([double]2,[bool]$true)) { + $receipt = [pscustomobject][ordered]@{ kind='RestartAndRepair'; origin='Installer'; runtimeId='runtime-2.5.22'; runtimeGeneration=$bad; phase='Completed' } + Assert-CcodThrows { &$module { param($Value) ConvertTo-CcodOfficialDraftTerminalReceipt -Receipt $Value } $receipt } 'CCOD_ACCEPTANCE_FRESH_INSTALL_OBSERVATION_INVALID' + } + } finally { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + } + + Invoke-CcodTest 'official draft manual records reject lowercase phase variants' { + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + $record = [pscustomobject][ordered]@{ + schemaVersion = 1; kind = 'manual-evidence'; phase = 'TrayEvidence'; operation = 'About' + terminalState = 'AboutVisible'; result = 'CCOD_TRAYABOUT_COMPLETED' + proof = [pscustomobject][ordered]@{ timestampUtc = [DateTime]::UtcNow.ToString('o'); command = 'ShowAbout'; revision = 7; code = 'CCOD_TRAY_ACTION_COMPLETED'; status = 'Completed' } + reviewState = 'Reviewed' + version = '2.5.22'; gitCommit = ('c' * 40); candidateManifestSha256 = ('d' * 64) + screenshotSha256 = ('1' * 64); redactedLogSha256 = ('2' * 64) + } + try { + $control = & $module { param($Value) Assert-CcodOfficialDraftManualRecordShape -Record $Value } $record + Assert-CcodEqual 'TrayEvidence' $control.phase 'complete proof with canonical phase succeeds before changing only case' + $record.phase = 'trayevidence' + Assert-CcodThrows { + &$module { param($Value) Assert-CcodOfficialDraftManualRecordShape -Record $Value } $record + } 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID' + } finally { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + } + + Invoke-CcodTest 'official draft removes PreReboot receipt when the authorized reboot fails' { + $fixture = New-CcodAcceptanceCandidateFixture + $previousRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-acceptance-previous-' + [guid]::NewGuid().ToString('N')) + $evidenceRoot = Join-Path $fixture.Root 'evidence' + [IO.Directory]::CreateDirectory($previousRoot) | Out-Null + [IO.Directory]::CreateDirectory($evidenceRoot) | Out-Null + $state = [pscustomobject]@{ DefenderCalls = [Collections.Generic.List[string]]::new(); RunCalls = [Collections.Generic.List[string]]::new(); CaptureCalls = [Collections.Generic.List[string]]::new(); BootCalls = 0; RebootCalls = 0; PreviousCalls = 0; BootId = 'boot-1'; KeyHash = ('b' * 64); Facts = New-CcodAcceptanceFacts } + try { + $adapters = New-CcodAcceptancePhaseAdapters -Fixture $fixture -State $state -ReturnAbsentAfterUninstall + Invoke-CcodAcceptancePrivate -Phase Preflight -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters | Out-Null + Invoke-CcodAcceptancePrivate -Phase LegacyUpgrade -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart | Out-Null + Invoke-CcodAcceptancePrivate -Phase Uninstall -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart | Out-Null + Invoke-CcodAcceptancePrivate -Phase FreshInstall -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart | Out-Null + $adapters.Reboot = { $State.RebootCalls++; return $false }.GetNewClosure() + Assert-CcodThrows { + Invoke-CcodAcceptancePrivate -Phase PreReboot -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart -AllowWindowsReboot | Out-Null + } 'CCOD_ACCEPTANCE_REBOOT_FAILED' + Assert-CcodTrue (-not (Test-Path -LiteralPath (Join-Path (Join-Path $evidenceRoot 'official-draft-acceptance') '05-PreReboot.json') -PathType Leaf)) 'failed reboot does not strand the PreReboot receipt' + } finally { + if (Test-Path -LiteralPath $fixture.Root) { Remove-Item -LiteralPath $fixture.Root -Recurse -Force } + if (Test-Path -LiteralPath $previousRoot) { Remove-Item -LiteralPath $previousRoot -Recurse -Force } + } + } + + Invoke-CcodTest 'official draft receipt writer never deletes a colliding temporary file' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-receipt-collision-' + [guid]::NewGuid().ToString('N')) + $target = Join-Path $root 'receipt.json' + $collision = [hashtable]@{ Path = $null } + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + try { + [IO.Directory]::CreateDirectory($root) | Out-Null + $beforeOpen = { + param($Path) + $collision.Path = $Path + [IO.File]::WriteAllText($Path, 'foreign temporary content', [Text.UTF8Encoding]::new($false)) + }.GetNewClosure() + Assert-CcodThrows { + &$module { param($Path,$Callback) Write-CcodOfficialDraftJsonCreateOnly -Path $Path -Record ([ordered]@{ value = 'fixture' }) -ErrorId 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_WRITE_FAILED' -BeforeTemporaryOpen $Callback | Out-Null } $target $beforeOpen + } 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_CLEANUP_FAILED' + Assert-CcodTrue ($null -ne $collision.Path -and [IO.File]::Exists($collision.Path)) 'foreign temporary file remains untouched after collision' + Assert-CcodEqual 'foreign temporary content' ([IO.File]::ReadAllText($collision.Path)) 'foreign temporary content is preserved' + } finally { + if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } + } + } + + Invoke-CcodTest 'official draft Complete reports cleanup failure instead of hiding a terminal residue' { + $path = Join-Path ([IO.Path]::GetTempPath()) ('ccod-complete-cleanup-' + [guid]::NewGuid().ToString('N') + '.json') + [IO.File]::WriteAllText($path, 'fixture', [Text.UTF8Encoding]::new($false)) + $expectedHash = Get-CcodTestFileSha256 -Path $path + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + try { + & $module { param($Target,$Hash) Remove-CcodOfficialDraftCompleteArtifacts -Paths @($Target) -ExpectedHashes @{ ([IO.Path]::GetFullPath($Target)) = $Hash } | Out-Null } $path $expectedHash + Assert-CcodTrue (-not (Test-Path -LiteralPath $path)) 'valid owned-file cleanup succeeds before the failing callback' + foreach ($mode in @('Throw','LeaveResidue')) { + [IO.File]::WriteAllText($path,'fixture',[Text.UTF8Encoding]::new($false)) + $state = [pscustomobject]@{ Calls = 0; Mode = $mode } + $remove = { param($Path) $state.Calls++; if ($state.Mode -ceq 'Throw') { throw 'fixture cleanup failure' } }.GetNewClosure() + Assert-CcodThrows { + &$module { + param($Target,$Hash,$Remove) + Remove-CcodOfficialDraftCompleteArtifacts -Paths @($Target) -ExpectedHashes @{ ([IO.Path]::GetFullPath($Target)) = $Hash } -RemovePath $Remove + } $path $expectedHash $remove + } 'CCOD_ACCEPTANCE_COMPLETE_CLEANUP_FAILED' + Assert-CcodEqual 1 $state.Calls 'the valid hash reaches the actual deletion callback' + Assert-CcodTrue (Test-Path -LiteralPath $path -PathType Leaf) 'failed deletion leaves an explicitly reported residue' + Assert-CcodEqual $expectedHash (Get-CcodTestFileSha256 -Path $path) 'failed deletion does not change the owned bytes' + } + } finally { + if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue } + } + } + + Invoke-CcodTest 'official draft receipt chain rejects a detached Complete candidate' { + $fixture = New-CcodAcceptanceCandidateFixture + $previousRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-acceptance-previous-' + [guid]::NewGuid().ToString('N')) + $evidenceRoot = Join-Path $fixture.Root 'evidence' + $manualRoot = Join-Path $fixture.Root 'manual-input' + [IO.Directory]::CreateDirectory($previousRoot) | Out-Null + [IO.Directory]::CreateDirectory($evidenceRoot) | Out-Null + [IO.Directory]::CreateDirectory($manualRoot) | Out-Null + $state = [pscustomobject]@{ DefenderCalls = [Collections.Generic.List[string]]::new(); RunCalls = [Collections.Generic.List[string]]::new(); CaptureCalls = [Collections.Generic.List[string]]::new(); BootCalls = 0; RebootCalls = 0; PreviousCalls = 0; BootId = 'boot-1'; KeyHash = ('b' * 64); Facts = New-CcodAcceptanceFacts } + try { + $adapters = New-CcodAcceptancePhaseAdapters -Fixture $fixture -State $state -ReturnAbsentAfterUninstall + Invoke-CcodAcceptanceThroughReady -Fixture $fixture -PreviousRoot $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -State $state + $paths = New-CcodAcceptanceManualInputFiles -Root $manualRoot + Invoke-CcodAcceptanceManualRecords -Fixture $fixture -PreviousRoot $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -Paths $paths + Invoke-CcodAcceptancePrivate -Phase Complete -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters | Out-Null + $stateDirectory = Join-Path $evidenceRoot 'official-draft-acceptance' + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + try { + $records = &$module { param($Directory) Read-CcodOfficialDraftState -StateDirectory $Directory } $stateDirectory + $completeRemote = @($records.Complete.facts.manualEvidence | Where-Object { $_.operation -ceq 'SecondDeviceControl' })[0] + $fileRemote = $records.ManualEvidence['SecondDeviceControl'] + $completeRemote.proof.runtimeId = 'runtime-other' + $fileRemote.proof.runtimeId = 'runtime-other' + Assert-CcodThrows { + &$module { param($Value,$Directory) Assert-CcodOfficialDraftAutomatedReceiptChain -Records $Value -StateDirectory $Directory } $records $stateDirectory + } 'CCOD_ACCEPTANCE_RECEIPT_INVALID' + $completeRemote.proof.runtimeId = 'runtime-2.5.22' + $fileRemote.proof.runtimeId = 'runtime-2.5.22' + $records.Complete.draft.id = 'different-draft' + Assert-CcodThrows { + &$module { param($Value,$Directory) Assert-CcodOfficialDraftAutomatedReceiptChain -Records $Value -StateDirectory $Directory } $records $stateDirectory + } 'CCOD_ACCEPTANCE_RECEIPT_INVALID' + $records.Complete.draft.id = '123' + $detached = [pscustomobject][ordered]@{ + version = [string]$records.Complete.candidate.version + gitCommit = ('d' * 40) + assetHashes = @($records.Complete.candidate.assetHashes) + manifestHashes = [pscustomobject][ordered]@{ portable = [string]$records.Complete.candidate.manifestHashes.portable; setup = [string]$records.Complete.candidate.manifestHashes.setup } + } + $records.Complete.candidate = $detached + Assert-CcodThrows { + &$module { param($Value,$Directory) Assert-CcodOfficialDraftAutomatedReceiptChain -Records $Value -StateDirectory $Directory } $records $stateDirectory + } 'CCOD_ACCEPTANCE_RECEIPT_INVALID' + } finally { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + } finally { + if (Test-Path -LiteralPath $fixture.Root) { Remove-Item -LiteralPath $fixture.Root -Recurse -Force } + if (Test-Path -LiteralPath $previousRoot) { Remove-Item -LiteralPath $previousRoot -Recurse -Force } + } + } + + Invoke-CcodTest 'official draft fresh install rejects a runtime binding detached from the candidate' { + $fixture = New-CcodAcceptanceCandidateFixture + $previousRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-acceptance-previous-' + [guid]::NewGuid().ToString('N')) + $evidenceRoot = Join-Path $fixture.Root 'evidence' + [IO.Directory]::CreateDirectory($previousRoot) | Out-Null + [IO.Directory]::CreateDirectory($evidenceRoot) | Out-Null + $state = [pscustomobject]@{ DefenderCalls = [Collections.Generic.List[string]]::new(); RunCalls = [Collections.Generic.List[string]]::new(); CaptureCalls = [Collections.Generic.List[string]]::new(); BootCalls = 0; RebootCalls = 0; PreviousCalls = 0; BootId = 'boot-1'; KeyHash = ('b' * 64); Facts = New-CcodAcceptanceFacts } + try { + $adapters = New-CcodAcceptancePhaseAdapters -Fixture $fixture -State $state -ReturnAbsentAfterUninstall + Invoke-CcodAcceptancePrivate -Phase Preflight -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters | Out-Null + Invoke-CcodAcceptancePrivate -Phase LegacyUpgrade -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart | Out-Null + Invoke-CcodAcceptancePrivate -Phase Uninstall -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart | Out-Null + $adapters.RunPhase = { param($Context) [pscustomobject][ordered]@{ completed = $true; outcome = 'Completed'; installerSha256 = [string](@($Fixture.Contract.Assets | Where-Object { $_.name -like '*-setup.exe' })[0].sha256); runtimeManifestSha256 = ('1' * 64) } }.GetNewClosure() + Assert-CcodThrows { + Invoke-CcodAcceptancePrivate -Phase FreshInstall -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart | Out-Null + } 'CCOD_ACCEPTANCE_FRESH_INSTALL_OBSERVATION_INVALID' + } finally { + if (Test-Path -LiteralPath $fixture.Root) { Remove-Item -LiteralPath $fixture.Root -Recurse -Force } + if (Test-Path -LiteralPath $previousRoot) { Remove-Item -LiteralPath $previousRoot -Recurse -Force } + } + } + + Invoke-CcodTest 'official draft manual evidence rejects files changed after proof capture' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-manual-hash-stability-' + [guid]::NewGuid().ToString('N')) + [IO.Directory]::CreateDirectory($root) | Out-Null + $screenshot = Join-Path $root 'screenshot.png' + $log = Join-Path $root 'redacted.log' + [IO.File]::WriteAllText($screenshot, 'screenshot-before', [Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($log, 'log-before', [Text.UTF8Encoding]::new($false)) + $screenshotHash = Get-CcodTestFileSha256 -Path $screenshot + $logHash = Get-CcodTestFileSha256 -Path $log + [IO.File]::WriteAllText($log, 'log-after', [Text.UTF8Encoding]::new($false)) + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + try { + Assert-CcodThrows { + &$module { + param($Screenshot,$Log,$ExpectedScreenshot,$ExpectedLog) + Assert-CcodOfficialDraftEvidenceHashesStable -ScreenshotPath $Screenshot -RedactedLogPath $Log -ExpectedScreenshotSha256 $ExpectedScreenshot -ExpectedRedactedLogSha256 $ExpectedLog + } $screenshot $log $screenshotHash $logHash + } 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID' + } finally { + if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } + } + } + + Invoke-CcodTest 'official draft manual artifacts reject files larger than two MiB' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-manual-size-bound-' + [guid]::NewGuid().ToString('N')) + $artifactRoot = Join-Path $root 'artifacts' + $screenshot = Join-Path $root 'screenshot.png' + $log = Join-Path $root 'redacted.log' + [IO.Directory]::CreateDirectory($root) | Out-Null + [IO.File]::WriteAllBytes($screenshot, [byte[]]::new((2MB) + 1)) + [IO.File]::WriteAllText($log, 'bounded-log', [Text.UTF8Encoding]::new($false)) + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + try { + Assert-CcodThrows { + &$module { + param($Artifact,$Shot,$Log,$ShotHash,$LogHash) + Write-CcodOfficialDraftManualArtifacts -ArtifactDirectory $Artifact -Operation 'About' -ScreenshotPath $Shot -RedactedLogPath $Log -ExpectedScreenshotSha256 $ShotHash -ExpectedRedactedLogSha256 $LogHash | Out-Null + } $artifactRoot $screenshot $log (Get-CcodTestFileSha256 -Path $screenshot) (Get-CcodTestFileSha256 -Path $log) + } 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID' + } finally { + if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } + } + } + + Invoke-CcodTest 'official draft regular file validation rejects noncanonical paths' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-noncanonical-file-' + [guid]::NewGuid().ToString('N')) + $nested = Join-Path $root 'nested' + $file = Join-Path $root 'evidence.log' + [IO.Directory]::CreateDirectory($nested) | Out-Null + [IO.File]::WriteAllText($file, 'fixture', [Text.UTF8Encoding]::new($false)) + $noncanonical = Join-Path $nested '..\evidence.log' + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + try { + Assert-CcodThrows { + &$module { param($Path) Get-CcodOfficialDraftFileSha256 -Path $Path } $noncanonical + } 'CCOD_ACCEPTANCE_RECEIPT_INVALID' + } finally { + if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } + } + } + Invoke-CcodTest 'official draft atomic receipt writer removes a destination that fails readback' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-receipt-readback-' + [guid]::NewGuid().ToString('N')) + $target = Join-Path $root 'receipt.json' + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + try { + [IO.Directory]::CreateDirectory($root) | Out-Null + $state = [pscustomobject]@{ Calls = 0; PublishedPresent = $false } + $readback = { param($Path) $state.Calls++; $state.PublishedPresent = ((Get-Item -LiteralPath $Path -Force -ErrorAction Stop) -is [IO.FileInfo]); return $false }.GetNewClosure() + $failure = &$module { + param($Path,$Callback) + $hash = $null + try { Write-CcodOfficialDraftJsonCreateOnly -Path $Path -Record ([ordered]@{ value = 'fixture' }) -ErrorId 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_WRITE_FAILED' -Readback $Callback -PublishedHash ([ref]$hash) | Out-Null } + catch { return [pscustomobject][ordered]@{ ErrorId = $_.FullyQualifiedErrorId; PublishedHash = $hash } } + } $target $readback + Assert-CcodEqual 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_WRITE_FAILED' $failure.ErrorId 'receipt writer preserves the original write failure after readback cleanup' + Assert-CcodTrue ($failure.PublishedHash -is [string] -and $failure.PublishedHash -cmatch '^[0-9a-f]{64}\z') 'receipt writer registers the published hash before readback' + Assert-CcodEqual 1 $state.Calls 'the actual readback callback reported failure' + Assert-CcodTrue $state.PublishedPresent 'readback left the published file for production rollback' + Assert-CcodTrue (-not [IO.File]::Exists($target)) 'receipt writer cleans a published destination after failed readback' + $successTarget = Join-Path $root 'success.json' + $publishedHash = &$module { param($Path) $hash = $null; Write-CcodOfficialDraftJsonCreateOnly -Path $Path -Record ([ordered]@{ value = 'fixture' }) -ErrorId 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_WRITE_FAILED' -PublishedHash ([ref]$hash) | Out-Null; return $hash } $successTarget + Assert-CcodTrue ($publishedHash -match '^[0-9a-f]{64}\z') 'receipt writer returns the intended hash before any caller cleanup' + } finally { + if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } + } + } + Invoke-CcodTest 'official draft receipt writer removes a destination that fails readback' { + $fixture = New-CcodAcceptanceCandidateFixture + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-receipt-readback-' + [guid]::NewGuid().ToString('N')) + [IO.Directory]::CreateDirectory($root) | Out-Null + $candidate = [pscustomobject][ordered]@{ version = $fixture.Version; gitCommit = $fixture.GitCommit; assetHashes = @($fixture.Contract.Assets); manifestHashes = [pscustomobject][ordered]@{ portable = $fixture.Contract.PortableManifestSha256; setup = $fixture.Contract.SetupManifestSha256 } } + $draft = [pscustomobject][ordered]@{ tag = 'v2.5.22'; id = '123' } + $setupReceipt = New-CcodAcceptanceDefenderReceipt -Fixture $fixture -AssetType Setup + $portableReceipt = New-CcodAcceptanceDefenderReceipt -Fixture $fixture -AssetType PortableZip + $facts = [pscustomobject][ordered]@{ defenderReceipts = @($setupReceipt,$portableReceipt) } + try { + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + try { + $state = [pscustomobject]@{ Calls = 0; PublishedPresent = $false } + $readback = { param($Path) $state.Calls++; $state.PublishedPresent = ((Get-Item -LiteralPath $Path -Force -ErrorAction Stop) -is [IO.FileInfo]); return $false }.GetNewClosure() + Assert-CcodThrows { + &$module { param($Root,$Candidate,$Draft,$Facts,$Callback) Write-CcodOfficialDraftReceipt -StateDirectory $Root -Phase Preflight -Candidate $Candidate -Draft $Draft -Facts $Facts -ReadbackVerifier $Callback | Out-Null } $root $candidate $draft $facts $readback + } 'CCOD_ACCEPTANCE_RECEIPT_WRITE_FAILED' + Assert-CcodEqual 1 $state.Calls 'the phase receipt reaches actual readback' + Assert-CcodTrue $state.PublishedPresent 'the callback does not pre-delete the published phase receipt' + Assert-CcodTrue (-not [IO.File]::Exists((Join-Path $root '01-Preflight.json'))) 'receipt writer cleans a published destination after failed readback' + } finally { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + } finally { + if (Test-Path -LiteralPath $fixture.Root) { Remove-Item -LiteralPath $fixture.Root -Recurse -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } + } + } + + Invoke-CcodTest 'official draft receipt writer registers its hash before readback failure' { + $fixture = New-CcodAcceptanceCandidateFixture + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-receipt-published-hash-' + [guid]::NewGuid().ToString('N')) + [IO.Directory]::CreateDirectory($root) | Out-Null + $candidate = [pscustomobject][ordered]@{ version = $fixture.Version; gitCommit = $fixture.GitCommit; assetHashes = @($fixture.Contract.Assets); manifestHashes = [pscustomobject][ordered]@{ portable = $fixture.Contract.PortableManifestSha256; setup = $fixture.Contract.SetupManifestSha256 } } + $draft = [pscustomobject][ordered]@{ tag = 'v2.5.22'; id = '123' } + $facts = [pscustomobject][ordered]@{ defenderReceipts = @((New-CcodAcceptanceDefenderReceipt -Fixture $fixture -AssetType Setup),(New-CcodAcceptanceDefenderReceipt -Fixture $fixture -AssetType PortableZip)) } + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + try { + $hash = $null + $failure = &$module { + param($Root,$Candidate,$Draft,$Facts,$HashRef) + $readback = { param($Path) throw 'forced readback failure' } + try { Write-CcodOfficialDraftReceipt -StateDirectory $Root -Phase Preflight -Candidate $Candidate -Draft $Draft -Facts $Facts -ReadbackVerifier $readback -PublishedHash $HashRef | Out-Null } + catch { [pscustomobject][ordered]@{ ErrorId = $_.FullyQualifiedErrorId; Hash = $HashRef.Value } } + } $root $candidate $draft $facts ([ref]$hash) + Assert-CcodEqual 'CCOD_ACCEPTANCE_RECEIPT_WRITE_FAILED' $failure.ErrorId 'receipt writer retains write error identity after readback failure' + Assert-CcodTrue ($failure.Hash -is [string] -and $failure.Hash -cmatch '^[0-9a-f]{64}\z') 'receipt writer exposes the published hash before readback' + Assert-CcodTrue (-not (Test-Path -LiteralPath (Join-Path $root '01-Preflight.json'))) 'throwing readback still rolls back the destination' + } finally { + if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $fixture.Root) { Remove-Item -LiteralPath $fixture.Root -Recurse -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } + } + } + + Invoke-CcodTest 'official draft Complete rereads the automated chain before publishing terminal acceptance' { + $fixture = New-CcodAcceptanceCandidateFixture + $previousRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-acceptance-previous-' + [guid]::NewGuid().ToString('N')) + $evidenceRoot = Join-Path $fixture.Root 'evidence' + $manualRoot = Join-Path $fixture.Root 'manual-input' + [IO.Directory]::CreateDirectory($previousRoot) | Out-Null + [IO.Directory]::CreateDirectory($evidenceRoot) | Out-Null + [IO.Directory]::CreateDirectory($manualRoot) | Out-Null + $state = [pscustomobject]@{ DefenderCalls = [Collections.Generic.List[string]]::new(); RunCalls = [Collections.Generic.List[string]]::new(); CaptureCalls = [Collections.Generic.List[string]]::new(); BootCalls = 0; RebootCalls = 0; PreviousCalls = 0; BootId = 'boot-1'; KeyHash = ('b' * 64); Facts = New-CcodAcceptanceFacts } + try { + $adapters = New-CcodAcceptancePhaseAdapters -Fixture $fixture -State $state -ReturnAbsentAfterUninstall + Invoke-CcodAcceptanceThroughReady -Fixture $fixture -PreviousRoot $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -State $state + $paths = New-CcodAcceptanceManualInputFiles -Root $manualRoot + Invoke-CcodAcceptanceManualRecords -Fixture $fixture -PreviousRoot $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -Paths $paths + $adapters.GetUtcNow = { + $postPath = Join-Path (Join-Path $evidenceRoot 'official-draft-acceptance') '06-PostReboot.json' + $post = [IO.File]::ReadAllText($postPath) | ConvertFrom-Json + $post.facts.installRootPresent = $false + [IO.File]::WriteAllText($postPath, ($post | ConvertTo-Json -Depth 16), [Text.UTF8Encoding]::new($false)) + return [datetime]::Parse('2030-02-03T04:05:06Z').ToUniversalTime() + }.GetNewClosure() + Assert-CcodThrows { + Invoke-CcodAcceptancePrivate -Phase Complete -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters | Out-Null + } 'CCOD_ACCEPTANCE_RECEIPT_INVALID' + Assert-CcodTrue (-not (Test-Path -LiteralPath (Join-Path (Join-Path $evidenceRoot 'official-draft-acceptance') '08-Complete.json') -PathType Leaf)) 'tampered automated chain does not leave a Complete receipt' + } finally { + if (Test-Path -LiteralPath $fixture.Root) { Remove-Item -LiteralPath $fixture.Root -Recurse -Force } + if (Test-Path -LiteralPath $previousRoot) { Remove-Item -LiteralPath $previousRoot -Recurse -Force } + } + } + + Invoke-CcodTest 'persisted PostReboot facts require an installed root and active pointer' { + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + $facts = [pscustomobject][ordered]@{ + observation = [pscustomobject][ordered]@{ + version = '2.5.22'; bootId = 'boot-after'; installRootPresent = $true; installReady = $true; appRootPresent = $true; runtimeRootPresent = $true; activePointerPresent = $true + activeRuntimeId = 'runtime-2.5.22'; activeGeneration = [UInt64]2; runtimeManifestSha256 = ('d' * 64) + supervisor = @([pscustomobject][ordered]@{ pid = 200; creationTimeUtc = '2030-02-03T04:05:00.0000000Z' }) + trayHost = @([pscustomobject][ordered]@{ pid = 201; creationTimeUtc = '2030-02-03T04:05:01.0000000Z' }) + codex = @([pscustomobject][ordered]@{ pid = 202; creationTimeUtc = '2030-02-03T04:05:02.0000000Z' }); codexCount = 1 + trayHostIdentity = [pscustomobject][ordered]@{ pid = 201; creationTimeUtc = '2030-02-03T04:05:01.0000000Z' }; trayAuthenticated = $true + taskState = 'Ready'; statusPhase = 'Active'; statusRuntimeId = 'runtime-2.5.22'; statusCodex = [pscustomobject][ordered]@{ pid = 202; creationTimeUtc = '2030-02-03T04:05:02.0000000Z' } + transitionStage = 'Idle'; lifecycleReceipt = [pscustomobject][ordered]@{ kind = 'RestartAndRepair'; origin = 'Installer'; runtimeId = 'runtime-2.5.22'; runtimeGeneration = [UInt64]2; phase = 'Completed' }; aboutVersion = '2.5.22' + deviceKeyPresent = $true; deviceKeySha256 = ('b' * 64); shortcuts = [pscustomobject][ordered]@{ startMenu = $true; desktop = $true } + debugPorts = @(9229,9230); debugEndpoints = @( + [pscustomobject][ordered]@{ localAddress = '127.0.0.1'; localPort = 9229; owningProcess = 202; owningProcessCreationTimeUtc = '2030-02-03T04:05:02.0000000Z' } + [pscustomobject][ordered]@{ localAddress = '127.0.0.1'; localPort = 9230; owningProcess = 202; owningProcessCreationTimeUtc = '2030-02-03T04:05:02.0000000Z' } + ); protectionReady = $true + } + installRootPresent = $true; activePointerPresent = $true + bootIdBefore = 'boot-before'; bootIdAfter = 'boot-after'; bootChanged = $true; receiptContinuity = $true + activeRuntimeId = 'runtime-2.5.22'; activeGeneration = [UInt64]2; runtimeManifestSha256 = ('d' * 64) + transitionStage = 'Idle'; protectionRecovered = $true; protectionReady = $true; taskState = 'Ready' + supervisorCount = 1; trayHostCount = 1; codexCount = 1 + trayHostIdentity = [pscustomobject][ordered]@{ pid = 201; creationTimeUtc = '2030-02-03T04:05:01.0000000Z' } + trayAuthenticated = $true + terminalReceipt = [pscustomobject][ordered]@{ kind = 'RestartAndRepair'; origin = 'Installer'; runtimeId = 'runtime-2.5.22'; runtimeGeneration = [UInt64]2; phase = 'Completed' } + deviceKeySha256 = ('b' * 64); keyHashPreserved = $true; operationOutcome = 'Completed' + } + try { + $valid = &$module { param($Value) Assert-CcodOfficialDraftPersistedPhaseFacts -Phase 'PostReboot' -Facts $Value } $facts + Assert-CcodTrue ($null -ne $valid) 'persisted PostReboot facts accept both required presence flags' + foreach ($name in @('installRootPresent','activePointerPresent')) { + $tampered = [pscustomobject][ordered]@{} + foreach ($property in $facts.PSObject.Properties) { $tampered | Add-Member -NotePropertyName $property.Name -NotePropertyValue $property.Value } + $tampered.$name = $false + Assert-CcodThrows { &$module { param($Value) Assert-CcodOfficialDraftPersistedPhaseFacts -Phase 'PostReboot' -Facts $Value } $tampered | Out-Null } 'CCOD_ACCEPTANCE_RECEIPT_INVALID' + } + } finally { if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } } + } + + Invoke-CcodTest 'official draft preserves an inner terminal receipt error identity' { + $source = [IO.File]::ReadAllText($modulePath, [Text.UTF8Encoding]::new($false)) + $start = $source.IndexOf('function ConvertTo-CcodOfficialDraftTerminalReceipt') + $end = $source.IndexOf('function Assert-CcodOfficialDraftFreshFacts', $start) + Assert-CcodTrue ($start -ge 0 -and $end -gt $start) 'terminal receipt converter source is present' + $block = $source.Substring($start, $end - $start) + Assert-CcodTrue ($block.Contains("if (`$_.FullyQualifiedErrorId -like 'CCOD_ACCEPTANCE_*') { throw }")) 'terminal receipt conversion preserves an existing acceptance error id' + } + + Invoke-CcodTest 'official draft hashes manual artifacts with their own error context' { + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking + $missing = Join-Path ([IO.Path]::GetTempPath()) ('ccod-missing-manual-' + [guid]::NewGuid().ToString('N') + '.bin') + try { + $observed = & $module { + param($Path) + try { Get-CcodOfficialDraftFileSha256 -Path $Path -ErrorId 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID' -Message 'manual artifact unavailable' | Out-Null; 'NO_ERROR' } catch { [string]$_.FullyQualifiedErrorId } + } $missing + Assert-CcodEqual 'CCOD_ACCEPTANCE_MANUAL_EVIDENCE_INVALID' $observed 'manual artifact hash failures retain the manual evidence error context' + } finally { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + } + + Invoke-CcodTest 'official draft pre-reboot binds the captured boot identity' { + $fixture = New-CcodAcceptanceCandidateFixture + $previousRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-acceptance-previous-' + [guid]::NewGuid().ToString('N')) + $evidenceRoot = Join-Path $fixture.Root 'evidence' + [IO.Directory]::CreateDirectory($previousRoot) | Out-Null + [IO.Directory]::CreateDirectory($evidenceRoot) | Out-Null + $state = [pscustomobject]@{ DefenderCalls = [Collections.Generic.List[string]]::new(); RunCalls = [Collections.Generic.List[string]]::new(); CaptureCalls = [Collections.Generic.List[string]]::new(); BootCalls = 0; RebootCalls = 0; PreviousCalls = 0; BootId = 'boot-1'; KeyHash = ('b' * 64); Facts = New-CcodAcceptanceFacts } + try { + $adapters = New-CcodAcceptancePhaseAdapters -Fixture $fixture -State $state -ReturnAbsentAfterUninstall + Invoke-CcodAcceptancePrivate -Phase Preflight -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters | Out-Null + Invoke-CcodAcceptancePrivate -Phase LegacyUpgrade -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart | Out-Null + Invoke-CcodAcceptancePrivate -Phase Uninstall -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart | Out-Null + Invoke-CcodAcceptancePrivate -Phase FreshInstall -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart | Out-Null + $adapters.GetBootIdentity = { return 'boot-external' } + Assert-CcodThrows { + Invoke-CcodAcceptancePrivate -Phase PreReboot -AssetDirectory $fixture.Root -PreviousAssetDirectory $previousRoot -EvidenceRoot $evidenceRoot -Adapters $adapters -AllowMachineMutation -AllowCodexRestart -AllowWindowsReboot | Out-Null + } 'CCOD_ACCEPTANCE_OBSERVATION_UNAVAILABLE' + Assert-CcodEqual 0 $state.RebootCalls 'boot identity mismatch cannot reach the reboot adapter' + } finally { + if (Test-Path -LiteralPath $fixture.Root) { Remove-Item -LiteralPath $fixture.Root -Recurse -Force } + if (Test-Path -LiteralPath $previousRoot) { Remove-Item -LiteralPath $previousRoot -Recurse -Force } + } + } +} catch { + Write-Error $_ + exit 1 +} + +Write-Host 'Official-draft acceptance harness self-tests passed.' diff --git a/tests/persistence/PersistenceIO.SelfTest.ps1 b/tests/persistence/PersistenceIO.SelfTest.ps1 index bd585eb..528add4 100644 --- a/tests/persistence/PersistenceIO.SelfTest.ps1 +++ b/tests/persistence/PersistenceIO.SelfTest.ps1 @@ -49,6 +49,12 @@ try { Assert-CcodEqual 1 (Read-CcodStrictJson -Path $path -ExpectedSchema 1 -Kind 'settings').schemaVersion 'schema round-trip' } + Invoke-CcodTest 'bounded strict JSON reader rejects oversized complete records before parsing' { + $path = Join-Path $root 'state\oversized-record.json' + [IO.File]::WriteAllText($path,('{"schemaVersion":1,"padding":"'+('x'*4096)+'"}'),[Text.UTF8Encoding]::new($false)) + Assert-CcodThrows { Read-CcodStrictJson -Path $path -ExpectedSchema 1 -Kind 'bounded record' -MaxBytes 1024 | Out-Null } 'CCOD_STATE_TOO_LARGE' + } + Invoke-CcodTest 'preserves ISO timestamp fields as JSON strings under Windows PowerShell' { $path = Join-Path $root 'state\timestamp-string.json' $timestamp = '2030-02-03T04:05:06.0000000Z' diff --git a/tests/persistence/ProcessControl.SelfTest.ps1 b/tests/persistence/ProcessControl.SelfTest.ps1 index 20564a9..008dc43 100644 --- a/tests/persistence/ProcessControl.SelfTest.ps1 +++ b/tests/persistence/ProcessControl.SelfTest.ps1 @@ -892,6 +892,53 @@ try { Assert-CcodTrue ($seen.RootEvidenceCount -ge 2) 'Special root proof is supplied on initial and final rereads' } + Invoke-CcodTest 'retries one transient verified tree miss while the exact root identity remains stable' { + $status = New-CcodSpecialStatus + $root = New-CcodSnapshot -Mode Special -RendererPort 41001 -MainPort 41002 -CommandLine 'special' + $child = New-CcodSnapshot -ProcessId 101 -CreationTimeUtc '2026-08-02T00:00:01.0000000Z' -ParentPid 100 -IsTopLevel $false -Mode Unrelated + $calls = [pscustomobject]@{ Tree = 0; Root = 0; Delay = 0 } + $tree = @(Get-CcodStableVerifiedProcessTree -Root $root -StatusEvidence $status -RetryBudget 1 -Adapters @{ + GetVerifiedTree = { + param($ExpectedRoot, $StatusEvidence) + $calls.Tree++ + if ($calls.Tree -eq 1) { return @() } + return @($root, $child) + }.GetNewClosure() + GetProcess = { + param($ProcessId, $StatusEvidence) + $calls.Root++ + Assert-CcodEqual $status $StatusEvidence 'stable retry preserves root status evidence' + return $root + }.GetNewClosure() + Delay = { param($Milliseconds) $calls.Delay++ }.GetNewClosure() + }) + Assert-CcodEqual 2 $tree.Count 'one transient empty tree is retried without relaxing root identity' + Assert-CcodEqual '100,101' (($tree.Pid | Sort-Object) -join ',') 'successful retry returns the complete verified tree' + Assert-CcodEqual 2 $calls.Tree 'the fixed one-retry budget performs exactly two tree attempts' + Assert-CcodEqual 1 $calls.Root 'the root is reread exactly once before the retry' + Assert-CcodEqual 1 $calls.Delay 'delay is entered only after an empty tree and a stable root reread' + } + + Invoke-CcodTest 'rejects a retry when the exact root creation time changes' { + $root = New-CcodSnapshot + $changed = New-CcodSnapshot -CreationTimeUtc '2026-08-02T00:00:05.0000000Z' + $calls = [pscustomobject]@{ Tree = 0; Root = 0; Delay = 0 } + $tree = @(Get-CcodStableVerifiedProcessTree -Root $root -RetryBudget 1 -Adapters @{ + GetVerifiedTree = { + param($ExpectedRoot, $StatusEvidence) + $calls.Tree++ + if ($calls.Tree -eq 1) { return @() } + return @($changed) + }.GetNewClosure() + GetProcess = { param($ProcessId, $StatusEvidence) $calls.Root++; $changed }.GetNewClosure() + Delay = { param($Milliseconds) $calls.Delay++ }.GetNewClosure() + }) + Assert-CcodEqual 0 $tree.Count 'creation-time drift returns no verified tree' + Assert-CcodEqual 1 $calls.Tree 'root drift is not retried' + Assert-CcodEqual 1 $calls.Root 'drift is detected by the mandatory root reread' + Assert-CcodEqual 0 $calls.Delay 'root drift never enters the retry delay' + } + Invoke-CcodTest 'adopts exactly one special transaction candidate' { $command = '"C:\Codex\ChatGPT.exe" --remote-debugging-address=127.0.0.1 --remote-debugging-port=41001 --inspect=127.0.0.1:41002' $validArgv = @('C:\Codex\ChatGPT.exe','--remote-debugging-address=127.0.0.1','--remote-debugging-port=41001','--inspect=127.0.0.1:41002') diff --git a/tests/persistence/ProductRegistration.SelfTest.ps1 b/tests/persistence/ProductRegistration.SelfTest.ps1 new file mode 100644 index 0000000..db0e9aa --- /dev/null +++ b/tests/persistence/ProductRegistration.SelfTest.ps1 @@ -0,0 +1,846 @@ +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'TestSupport.ps1') + +$repositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +$modulePath = Join-Path $repositoryRoot 'src\persistence\modules\ProductRegistration.psm1' +if (-not (Test-Path -LiteralPath $modulePath -PathType Leaf)) { + throw "ProductRegistration module is missing: $modulePath" +} +$module=Import-Module $modulePath -Force -PassThru + +$runtimeId = '2.5.22-1111111111111111-22222222222222222222222222222222' +$packageSha256 = '3' * 64 +$appId = '{2B9E9F2E-7A32-4A7E-9C1D-9F5B5C6D7E8F}' +$canonicalTaskTarget=[IO.Path]::GetFullPath((Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::System)) 'schtasks.exe')) +$startCurrentBytes=[Text.UTF8Encoding]::new($false).GetBytes('current-shortcut:Programs\CodexRemote-fix\CodexRemote-fix.lnk') +$desktopCurrentBytes=[Text.UTF8Encoding]::new($false).GetBytes('current-shortcut:Desktop\CodexRemote-fix.lnk') +$testSha=[Security.Cryptography.SHA256]::Create();try{$startCurrentSha=[BitConverter]::ToString($testSha.ComputeHash($startCurrentBytes)).Replace('-','').ToLowerInvariant();$desktopCurrentSha=[BitConverter]::ToString($testSha.ComputeHash($desktopCurrentBytes)).Replace('-','').ToLowerInvariant()}finally{$testSha.Dispose()} +$cleanupReady=[pscustomobject][ordered]@{phase='Ready';installRoot='C:\fixture\CodexControlOtherDevices';runtimeId=$runtimeId;runtimeGeneration=[uint64]7;packageSha256=$packageSha256;manifestSha256=('a'*64);startMenuSha256=$startCurrentSha;desktopSha256=$desktopCurrentSha;targetPath=$canonicalTaskTarget;arguments='/Run /TN "Codex Control Other Devices Supervisor"'} +$expectedVerifiedRegistration=[pscustomobject][ordered]@{verified=$true;runtimeId=$runtimeId;version='2.5.22';packageSha256=$packageSha256;shortcutNames=@('Programs\CodexRemote-fix\CodexRemote-fix.lnk','Desktop\CodexRemote-fix.lnk');startMenuSha256=$startCurrentSha;desktopSha256=$desktopCurrentSha} +$fullReadyRecord=[pscustomobject][ordered]@{schemaVersion=1;transactionId='11111111-2222-3333-4444-555555555555';oldRuntimeId=$null;oldGeneration=$null;oldManifestSha256=$null;newRuntimeId=$runtimeId;newGeneration=[uint64]7;newManifestSha256=('a'*64);sealedPackageSha256=$packageSha256;ownedObjectNames=@($runtimeId);phase='Ready';errorCode=$null} +$v210ShortcutNames = @( + 'Programs\Codex Control other devices\Codex Control other devices for Windows.lnk', + 'Programs\Codex Control other devices\Open the tray supervisor.lnk', + 'Programs\Codex Control other devices\Compatibility check.lnk', + 'Programs\Codex Control other devices\Uninstall Codex Control other devices.lnk' +) +$v211ShortcutNames = @($v210ShortcutNames) + @( + ('Desktop\Codex ' + [char]0x8BBE + [char]0x5907 + [char]0x8FDE + [char]0x63A5 + ' (Device Connection).lnk') +) +$v2521ShortcutNames = @( + 'Programs\CodexRemote-fix\CodexRemote-fix.lnk', + 'Programs\CodexRemote-fix\CodexRemote-fix compatibility check.lnk', + 'Programs\CodexRemote-fix\Uninstall CodexRemote-fix.lnk', + 'Desktop\CodexRemote-fix.lnk' +) +$currentShortcutNames=@($v2521ShortcutNames[0],$v2521ShortcutNames[3]) +$legacyInstallLocation = 'C:\legacy\CodexControlOtherDevices-installer' + +function Get-CcodTestBytesSha256([byte[]]$Bytes){$sha=[Security.Cryptography.SHA256]::Create();try{[BitConverter]::ToString($sha.ComputeHash($Bytes)).Replace('-','').ToLowerInvariant()}finally{$sha.Dispose()}} +function Invoke-CcodTask2Fix1ProductTest([string]$Id,[string]$Name,[scriptblock]$Action){if(-not[string]::IsNullOrWhiteSpace($env:CCOD_TASK2_FIX1_RED_CASE)-and$env:CCOD_TASK2_FIX1_RED_CASE-cne$Id){return};Invoke-CcodTest $Name $Action} + +function New-CcodLegacyRegistrationFixture { + param( + [Parameter(Mandatory)][string]$Version, + [Parameter(Mandatory)][string[]]$ShortcutNames, + [string]$AppId = $appId, + [string]$InstallLocation = $legacyInstallLocation, + [string]$UninstallString = ('"{0}\unins000.exe"' -f $legacyInstallLocation), + [string[]]$UnsafeShortcutNames = @() + ) + [pscustomobject][ordered]@{ + appId = $AppId + displayVersion = $Version + installLocation = $InstallLocation + uninstallString = $UninstallString + shortcutNames = @($ShortcutNames) + unsafeShortcutNames = @($UnsafeShortcutNames) + } +} + +function New-CcodProductLegacyShortcutProofs { + param([Parameter(Mandatory)]$Legacy,[Parameter(Mandatory)][string]$ExpectedInstallRoot) + $powershell=[IO.Path]::GetFullPath((Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::System)) 'WindowsPowerShell\v1.0\powershell.exe')) + $install=[IO.Path]::GetFullPath($ExpectedInstallRoot);$installer=[IO.Path]::GetFullPath([string]$Legacy.installLocation);$version=[version]::Parse([string]$Legacy.displayVersion) + $bootstrap=Join-Path $install 'bootstrap.ps1';$explicit=if($version-ge[version]'2.5.0'){' -EntryMode Explicit'}else{''};$main='-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "{0}" -InstallRoot "{1}"{2}'-f$bootstrap,$install,$explicit + $proofs=@{} + foreach($name in @($Legacy.shortcutNames)){ + $target=$null;$arguments='';$working='' + $oldDesktop='Desktop\Codex '+[char]0x8BBE+[char]0x5907+[char]0x8FDE+[char]0x63A5+' (Device Connection).lnk' + if([string]$name-ceq$oldDesktop){$target=$powershell;$arguments=$main;$working=$install} + else{ + switch -CaseSensitive ([string]$name){ + 'Programs\Codex Control other devices\Codex Control other devices for Windows.lnk' {$target=Join-Path $installer 'README.md'} + 'Programs\Codex Control other devices\Open the tray supervisor.lnk' {$target=$powershell;$arguments='-NoProfile -ExecutionPolicy Bypass -File "{0}"'-f(Join-Path $installer 'Start-CodexControlOtherDevices.ps1');$working=$installer} + 'Programs\Codex Control other devices\Compatibility check.lnk' {$target=$powershell;$arguments='-NoProfile -ExecutionPolicy Bypass -File "{0}"'-f(Join-Path $installer 'Test-CodexControlOtherDevices.ps1');$working=$installer} + 'Programs\Codex Control other devices\Uninstall Codex Control other devices.lnk' {$target=Join-Path $installer 'unins000.exe'} + 'Programs\CodexRemote-fix\CodexRemote-fix.lnk' {$target=$powershell;$arguments=$main;$working=$install} + 'Programs\CodexRemote-fix\CodexRemote-fix compatibility check.lnk' {$target=$powershell;$arguments='-NoProfile -ExecutionPolicy Bypass -File "{0}"'-f(Join-Path $installer 'Test-CodexControlOtherDevices.ps1');$working=$installer} + 'Programs\CodexRemote-fix\Uninstall CodexRemote-fix.lnk' {$target=Join-Path $installer 'unins000.exe'} + 'Desktop\CodexRemote-fix.lnk' {$target=$powershell;$arguments=$main;$working=$install} + default {throw "unknown test legacy shortcut $name"} + } + } + $bytes=[Text.UTF8Encoding]::new($false).GetBytes(('legacy-shortcut:'+([string]$name)));$sha=[Security.Cryptography.SHA256]::Create();try{$hash=[BitConverter]::ToString($sha.ComputeHash($bytes)).Replace('-','').ToLowerInvariant()}finally{$sha.Dispose()} + $proofs[[string]$name]=[pscustomobject][ordered]@{targetPath=[IO.Path]::GetFullPath($target);arguments=$arguments;workingDirectory=$(if([string]::IsNullOrEmpty($working)){''}else{[IO.Path]::GetFullPath($working).TrimEnd('\')});sha256=$hash;bytesBase64=[Convert]::ToBase64String($bytes)} + } + $proofs +} + +function New-CcodProductCurrentShortcutProofs { + $proofs=@{};foreach($name in $currentShortcutNames){$bytes=[Text.UTF8Encoding]::new($false).GetBytes(('current-shortcut:'+([string]$name)));$sha=[Security.Cryptography.SHA256]::Create();try{$hash=[BitConverter]::ToString($sha.ComputeHash($bytes)).Replace('-','').ToLowerInvariant()}finally{$sha.Dispose()};$proofs[[string]$name]=[pscustomobject][ordered]@{targetPath=$canonicalTaskTarget;arguments='/Run /TN "Codex Control Other Devices Supervisor"';workingDirectory='';sha256=$hash;bytesBase64=[Convert]::ToBase64String($bytes)}};$proofs +} + +function New-CcodRegistrationWorld { + param([string]$InstallRoot = 'C:\fixture\CodexControlOtherDevices') + + $registration = New-CcodProductRegistration -InstallRoot $InstallRoot -RuntimeId $runtimeId -Version '2.5.22' -PackageSha256 $packageSha256 -FileTransaction ([pscustomobject]@{}) + $legacyEntries=[Collections.Generic.List[string]]::new();foreach($entry in @('Registry')+$v2521ShortcutNames){$legacyEntries.Add($entry)} + $world = [pscustomobject]@{ + Registration = $registration + Ready = [pscustomobject][ordered]@{ + phase = 'Ready' + runtimeId = $runtimeId + version = '2.5.22' + packageSha256 = $packageSha256 + runtimeGeneration = [uint64]7 + manifestSha256 = 'a'*64 + startMenuSha256 = $startCurrentSha + desktopSha256 = $desktopCurrentSha + targetPath = $canonicalTaskTarget + arguments = '/Run /TN "Codex Control Other Devices Supervisor"' + transactionRecord = $fullReadyRecord + bootstrapPath = $registration.bootstrapPath + uninstallerPath = $registration.uninstallerPath + } + Product = $null + Shortcuts = @{} + WriteProductFailure = $false + WriteShortcutFailure = $null + ReadProductFailure = $false + ReadShortcutFailure = $null + Legacy = New-CcodLegacyRegistrationFixture -Version '2.5.21' -ShortcutNames $v2521ShortcutNames + LegacyRemoved = $false + LegacyRemovalCalls = 0 + CurrentProductRemovals = 0 + CurrentProductState = [pscustomobject]@{valid=$true;reason=$null;readyEvidence=$cleanupReady;entries=@('Registry','StartMenu','Desktop')} + LegacyEntries = $legacyEntries + LegacyRemoveFailureAt = 0 + LegacyRemoveAttempts = 0 + LegacyRestores = 0 + LegacyReplacementEntry = $null + LegacyRestoreFailureEntry = $null + LegacyUnresolvedRecords = [Collections.Generic.List[object]]::new() + LegacyRestoreOrder = [Collections.Generic.List[string]]::new() + VerifiedRegistrationReads = 0 + VerifiedRegistrationMutationAt = 0 + LegacySnapshotHashOverrides = @{} + LegacyShortcutProofs = $null + CurrentShortcutProofs = (New-CcodProductCurrentShortcutProofs) + SimulateOverlapWrites = $false + LegacyMigrationPlanBytes = $null + LegacyMigrationPlanWrites = 0 + LegacyMigrationPlanReads = 0 + LegacyMigrationPlanWriteMode = $null + LegacyMigrationPlanReadFailureAt = 0 + Calls = [Collections.Generic.List[string]]::new() + } + $world.LegacyShortcutProofs=New-CcodProductLegacyShortcutProofs -Legacy $world.Legacy -ExpectedInstallRoot $registration.installRoot + $world | Add-Member -NotePropertyName Adapters -NotePropertyValue @{ + GetReadyProof = { param($Registration) $world.Calls.Add('Ready'); $world.Ready }.GetNewClosure() + WriteProductRegistration = { + param($Registration) + $world.Calls.Add('WriteProduct') + if ($world.WriteProductFailure) { throw 'fixture product write failed' } + $world.Product = $Registration + }.GetNewClosure() + ReadProductRegistration = { + param($Registration) + $world.Calls.Add('ReadProduct') + if ($world.ReadProductFailure) { throw 'fixture product read failed' } + $world.Product + }.GetNewClosure() + WriteShortcut = { + param($Kind,$Shortcut,$FileTransaction) + $world.Calls.Add("WriteShortcut:$Kind") + if ($world.WriteShortcutFailure -ceq $Kind) { throw 'fixture shortcut write failed' } + $world.Shortcuts[$Kind] = $Shortcut + $legacyName=if($Kind-ceq'StartMenu'){$currentShortcutNames[0]}else{$currentShortcutNames[1]} + if(-not$world.LegacyEntries.Contains($legacyName)){$world.LegacyEntries.Add($legacyName)} + if($world.SimulateOverlapWrites){$world.LegacyShortcutProofs[$legacyName]=$world.CurrentShortcutProofs[$legacyName]} + }.GetNewClosure() + ReadShortcut = { + param($Kind,$Shortcut) + $world.Calls.Add("ReadShortcut:$Kind") + if ($world.ReadShortcutFailure -ceq $Kind) { throw 'fixture shortcut read failed' } + $world.Shortcuts[$Kind] + }.GetNewClosure() + ReadLegacyRegistration = {param($ExpectedAppId)$world.Calls.Add('ReadLegacy');if(-not$world.LegacyEntries.Contains('Registry')){return $null};[pscustomobject][ordered]@{appId=[string]$world.Legacy.appId;displayVersion=[string]$world.Legacy.displayVersion;installLocation=[string]$world.Legacy.installLocation;uninstallString=[string]$world.Legacy.uninstallString;shortcutNames=@($world.LegacyEntries|Where-Object{[string]$_-cne'Registry'});unsafeShortcutNames=@($world.Legacy.unsafeShortcutNames)}}.GetNewClosure() + ReadVerifiedRegistration = { + $world.VerifiedRegistrationReads++ + $startMenuSha256=if($world.VerifiedRegistrationMutationAt-eq$world.VerifiedRegistrationReads){'d'*64}else{[string]$world.Ready.startMenuSha256} + [pscustomobject][ordered]@{ + verified=($null-ne$world.Product-and$world.Shortcuts.Count-eq2);runtimeId=$runtimeId;version='2.5.22';packageSha256=$packageSha256 + shortcutNames=@($currentShortcutNames);startMenuSha256=$startMenuSha256;desktopSha256=[string]$world.Ready.desktopSha256 + } + }.GetNewClosure() + RemoveLegacyRegistration = { param($ExpectedAppId,$ExpectedShortcutNames) $world.Calls.Add('RemoveLegacy'); $world.LegacyRemovalCalls++; $world.LegacyRemoved = $true }.GetNewClosure() + ReadCurrentProductState = { param($ExpectedRuntimeId) $world.CurrentProductState }.GetNewClosure() + RemoveCurrentProductEntry = { param($Entry) $world.CurrentProductRemovals++ }.GetNewClosure() + ReadLegacySnapshot = { + param($ExpectedAppId,$ExpectedProfile) + $entries=[Collections.Generic.List[object]]::new() + foreach($entry in @($world.LegacyEntries)){ + if([string]$entry-cne'Registry'){ + $base=if(([string]$entry).StartsWith('Programs\',[StringComparison]::Ordinal)){[Environment]::GetFolderPath([Environment+SpecialFolder]::Programs)}else{[Environment]::GetFolderPath([Environment+SpecialFolder]::Desktop)} + $relative=([string]$entry).Substring(([string]$entry).IndexOf('\')+1);$proof=$world.LegacyShortcutProofs[[string]$entry];$sha=[string]$proof.sha256 + if($world.LegacySnapshotHashOverrides.ContainsKey([string]$entry)){$sha=[string]$world.LegacySnapshotHashOverrides[[string]$entry]} + $entries.Add([pscustomobject][ordered]@{kind='Shortcut';name=[string]$entry;path=[IO.Path]::GetFullPath((Join-Path $base $relative));sha256=$sha;bytesBase64=[string]$proof.bytesBase64;targetPath=[string]$proof.targetPath;arguments=[string]$proof.arguments;workingDirectory=[string]$proof.workingDirectory}) + }else{$entries.Add([pscustomobject][ordered]@{kind='Registry';path=('HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\'+$world.Legacy.appId+'_is1');values=[ordered]@{DisplayVersion=[pscustomobject]@{value=[string]$world.Legacy.displayVersion;kind='String'};InstallLocation=[pscustomobject]@{value=[string]$world.Legacy.installLocation;kind='String'};UninstallString=[pscustomobject]@{value=[string]$world.Legacy.uninstallString;kind='String'}}})} + } + [pscustomobject][ordered]@{appId=$world.Legacy.appId;entries=@($entries)} + }.GetNewClosure() + GetCurrentShortcutProof = { + param($Kind,$Shortcut,$ReadyEvidence) + $name=if($Kind-ceq'StartMenu'){$currentShortcutNames[0]}else{$currentShortcutNames[1]};$proof=$world.CurrentShortcutProofs[$name] + $base=if($Kind-ceq'StartMenu'){[Environment]::GetFolderPath([Environment+SpecialFolder]::Programs)}else{[Environment]::GetFolderPath([Environment+SpecialFolder]::Desktop)};$relative=$name.Substring($name.IndexOf('\')+1) + [pscustomobject][ordered]@{kind=$Kind;name=$name;path=[IO.Path]::GetFullPath((Join-Path $base $relative));candidatePath=[IO.Path]::GetFullPath([string]$Shortcut.candidatePath);candidateLength=[int64]([Convert]::FromBase64String([string]$proof.bytesBase64).LongLength);candidateSha256=[string]$proof.sha256;targetPath=[string]$proof.targetPath;arguments=[string]$proof.arguments;workingDirectory=[string]$proof.workingDirectory} + }.GetNewClosure() + ReadLegacyMigrationPlan = {param($ReadyEvidence)$world.LegacyMigrationPlanReads++;if($world.LegacyMigrationPlanReadFailureAt-eq$world.LegacyMigrationPlanReads){throw 'fixture durable plan read-back failure'};if($null-eq$world.LegacyMigrationPlanBytes){return $null};$bytes=[byte[]]$world.LegacyMigrationPlanBytes.Clone();[pscustomobject]@{Bytes=$bytes;Length=[int64]$bytes.LongLength;Sha256=(Get-CcodTestBytesSha256 $bytes)}}.GetNewClosure() + WriteLegacyMigrationPlan = {param($ReadyEvidence,[byte[]]$Bytes)if($null-ne$world.LegacyMigrationPlanBytes){throw 'fixture durable plan collision'};$world.LegacyMigrationPlanWrites++;$world.LegacyMigrationPlanBytes=[byte[]]$Bytes.Clone();if($world.LegacyMigrationPlanWriteMode-ceq'PublishedThenThrow'){throw 'fixture published durable plan then threw'};[pscustomobject]@{Length=[int64]$Bytes.LongLength;Sha256=(Get-CcodTestBytesSha256 $Bytes)}}.GetNewClosure() + RemoveLegacyEntry = {param($Entry);$name=if($Entry-is[string]){[string]$Entry}elseif($Entry.kind-ceq'Registry'){'Registry'}else{[string]$Entry.name};$world.LegacyRemoveAttempts++;if($world.LegacyRemoveFailureAt-eq$world.LegacyRemoveAttempts){throw 'fixture legacy delete failure'};[void]$world.LegacyEntries.Remove($name)}.GetNewClosure() + ReadLegacyEntry = {param($Entry);$name=if($Entry-is[string]){[string]$Entry}elseif($Entry.kind-ceq'Registry'){'Registry'}else{[string]$Entry.name};if($world.LegacyReplacementEntry-ceq$name-and$world.LegacyRemoveFailureAt-gt0-and$world.LegacyRemoveAttempts-ge$world.LegacyRemoveFailureAt){return 'Mismatch'};if(-not$world.LegacyEntries.Contains($name)){return $null};if($Entry-isnot[string]-and$Entry.kind-ceq'Shortcut' -and [string]$Entry.sha256-cne[string]$world.LegacyShortcutProofs[$name].sha256){return 'Mismatch'};'Exact'}.GetNewClosure() + RestoreLegacyEntry = {param($Entry);$name=if($Entry-is[string]){[string]$Entry}elseif($Entry.kind-ceq'Registry'){'Registry'}else{[string]$Entry.name};if($world.LegacyRestoreFailureEntry-ceq$name){throw 'fixture restore failed'};if(-not$world.LegacyEntries.Contains($name)){$world.LegacyEntries.Add($name)};$world.LegacyRestoreOrder.Add($name);$world.LegacyRestores++}.GetNewClosure() + WriteLegacyCompensationFailure = {param($Record)$world.LegacyUnresolvedRecords.Add($Record)}.GetNewClosure() + } + return $world +} + +function Set-CcodRegistrationLegacyFixture { + param( + [Parameter(Mandatory)]$World, + [Parameter(Mandatory)][string]$Version, + [Parameter(Mandatory)][string[]]$ShortcutNames, + [string]$AppId = $appId, + [string]$InstallLocation = $legacyInstallLocation, + [string]$UninstallString = ('"{0}\unins000.exe"' -f $legacyInstallLocation), + [string[]]$UnsafeShortcutNames = @() + ) + $World.Legacy = New-CcodLegacyRegistrationFixture -Version $Version -ShortcutNames $ShortcutNames -AppId $AppId -InstallLocation $InstallLocation -UninstallString $UninstallString -UnsafeShortcutNames $UnsafeShortcutNames + $World.LegacyShortcutProofs=New-CcodProductLegacyShortcutProofs -Legacy $World.Legacy -ExpectedInstallRoot $World.Registration.installRoot + $World.LegacyEntries.Clear() + foreach ($entry in @('Registry') + $ShortcutNames) { $World.LegacyEntries.Add($entry) } +} + +function Get-CcodProductTestLegacyMigrationPlan { + param([Parameter(Mandatory)]$World) + Get-CcodDurableLegacyProductRegistrationMigrationPlan -ExpectedAppId $appId -Registration $World.Registration -ReadyEvidence $World.Ready -Adapters $World.Adapters +} + +function New-CcodFix1RegistryKey { + param([Parameter(Mandatory)][string]$Id,[Parameter(Mandatory)]$Values,[Parameter(Mandatory)]$Kinds,[Parameter(Mandatory)]$World) + $key=[pscustomobject]@{Id=$Id;Values=$Values;Kinds=$Kinds;World=$World;DeleteCalls=0;DisposeCalls=0;Deleted=$false} + $key|Add-Member ScriptMethod GetValueNames { @($this.Values.Keys) } + $key|Add-Member ScriptMethod GetSubKeyNames { ,([string[]]@()) } + $key|Add-Member ScriptMethod GetValue {param($Name,$DefaultValue,$Options)if($this.Values.Contains([string]$Name)){$this.Values[[string]$Name]}else{$DefaultValue}} + $key|Add-Member ScriptMethod GetValueKind {param($Name)[Enum]::Parse([Microsoft.Win32.RegistryValueKind],[string]$this.Kinds[[string]$Name])} + $key|Add-Member ScriptMethod DeleteValue { + param($Name,$ThrowOnMissing) + $this.DeleteCalls++ + if($this.World.Mode-ceq'PartialReplacement'-and$this.Id-ceq'Original'-and$this.DeleteCalls-eq2){$this.World.CurrentKey=$this.World.Replacement;throw 'TEST_PARTIAL_REGISTRY_DELETE_FAILURE'} + if(-not$this.Values.Contains([string]$Name)){if($ThrowOnMissing){throw 'TEST_REGISTRY_VALUE_MISSING'};return} + $this.Values.Remove([string]$Name) + } + $key|Add-Member ScriptMethod Dispose {$this.DisposeCalls++} + return $key +} + +function New-CcodFix1RegistryWorld { + param([Parameter(Mandatory)][ValidateSet('PartialReplacement','CreateOpened','CreateNew')][string]$Mode) + $world=[pscustomobject]@{ + Mode=$Mode;CurrentKey=$null;Original=$null;Replacement=$null;Created=$null;NativeCreateCalls=0;ProviderNewItemCalls=0 + ReplacementWrites=0;CompensationRecords=[Collections.Generic.List[object]]::new();RegistryAdapters=$null;LowerFailure=$null + } + $world.Original=New-CcodFix1RegistryKey -Id Original -Values ([ordered]@{DisplayName='captured-product';NoModify=1}) -Kinds ([ordered]@{DisplayName='String';NoModify='DWord'}) -World $world + $world.Replacement=New-CcodFix1RegistryKey -Id Replacement -Values ([ordered]@{DisplayName='replacement-product';ReplacementSentinel=[byte[]]@(0xde,0xad,0xbe,0xef)}) -Kinds ([ordered]@{DisplayName='String';ReplacementSentinel='Binary'}) -World $world + $world.CurrentKey=$world.Original + $world.RegistryAdapters=@{ + OpenExisting={param($Path)if($null-eq$world.CurrentKey){return $null};[pscustomobject][ordered]@{key=$world.CurrentKey;resource=$world.CurrentKey;disposition='OpenedExisting'}}.GetNewClosure() + CloseKey={param($Handle)$Handle.key.Dispose()}.GetNewClosure() + GetValueNames={param($Handle)@($Handle.key.Values.Keys)}.GetNewClosure() + GetSubKeyNames={param($Handle)@()}.GetNewClosure() + GetValue={param($Handle,$Name)if($Handle.key.Values.Contains([string]$Name)){$Handle.key.Values[[string]$Name]}else{$null}}.GetNewClosure() + GetValueKind={param($Handle,$Name)[string]$Handle.key.Kinds[[string]$Name]}.GetNewClosure() + DeleteValue={param($Handle,$Name)$Handle.key.DeleteValue([string]$Name,$true)}.GetNewClosure() + SetValue={param($Handle,$Name,$Value,$Kind)if($Handle.key.Id-ceq'Replacement'){$world.ReplacementWrites++};$Handle.key.Values[[string]$Name]=$Value;$Handle.key.Kinds[[string]$Name]=[string]$Kind}.GetNewClosure() + DeleteKey={param($Handle)$Handle.key.Deleted=$true;if([object]::ReferenceEquals($world.CurrentKey,$Handle.key)){$world.CurrentKey=$null}}.GetNewClosure() + TestKeyAtPath={param($Path,$Handle)[object]::ReferenceEquals($world.CurrentKey,$Handle.key)}.GetNewClosure() + CreateKey={ + param($Path) + $world.NativeCreateCalls++ + if($world.Mode-ceq'CreateOpened'){$world.CurrentKey=$world.Replacement;return [pscustomobject][ordered]@{key=$world.Replacement;resource=$world.Replacement;disposition='OpenedExisting'}} + if($null-ne$world.CurrentKey){return [pscustomobject][ordered]@{key=$world.CurrentKey;resource=$world.CurrentKey;disposition='OpenedExisting'}} + $world.Created=New-CcodFix1RegistryKey -Id Created -Values ([ordered]@{}) -Kinds ([ordered]@{}) -World $world;$world.CurrentKey=$world.Created + [pscustomobject][ordered]@{key=$world.Created;resource=$world.Created;disposition='CreatedNew'} + }.GetNewClosure() + } + return $world +} + +function New-CcodFix1RegistrySnapshotEntry { + [pscustomobject][ordered]@{ + kind='Registry';path='HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\{2B9E9F2E-7A32-4A7E-9C1D-9F5B5C6D7E8F}_is1' + values=[ordered]@{DisplayName=[pscustomobject]@{value='captured-product';kind='String'};NoModify=[pscustomobject]@{value=1;kind='DWord'}} + } +} + +function Invoke-CcodFix1RegistryCompensationScenario { + param([Parameter(Mandatory)][ValidateSet('PartialReplacement','CreateOpened','CreateNew')][string]$Mode) + $root=Join-Path ([IO.Path]::GetTempPath()) ('ccod-product-fix1-'+[guid]::NewGuid().ToString('N'));[IO.Directory]::CreateDirectory($root)|Out-Null + $copy=Join-Path $root 'ProductRegistrationFix1.psm1';Copy-Item -LiteralPath $modulePath -Destination $copy + $isolated=Import-Module $copy -Force -PassThru -DisableNameChecking -Prefix Fix1 + $world=New-CcodFix1RegistryWorld -Mode $Mode;$replacementBefore=($world.Replacement.Values|ConvertTo-Json -Compress);$replacementKindsBefore=($world.Replacement.Kinds|ConvertTo-Json -Compress) + try{ + &$isolated { + param($State) + $script:CcodFix1RegistryWorld=$State + function script:Get-CcodLegacyRegistryMutationAdapters {param($Adapters)$script:CcodFix1RegistryWorld.RegistryAdapters} + function script:Get-Item {param($LiteralPath,$ErrorAction)$script:CcodFix1RegistryWorld.CurrentKey} + function script:Test-Path {param($LiteralPath,$PathType)[bool]($null-ne$script:CcodFix1RegistryWorld.CurrentKey)} + function script:Get-ItemProperty {param($LiteralPath,$Name,$ErrorAction)$key=$script:CcodFix1RegistryWorld.CurrentKey;if($null-ne$key-and$key.Values.Contains([string]$Name)){[pscustomobject]@{value=$key.Values[[string]$Name]}}else{$null}} + function script:New-ItemProperty {param($LiteralPath,$Name,$Value,$PropertyType,[switch]$Force)$key=$script:CcodFix1RegistryWorld.CurrentKey;if($key.Id-ceq'Replacement'){$script:CcodFix1RegistryWorld.ReplacementWrites++};$key.Values[[string]$Name]=$Value;$key.Kinds[[string]$Name]=[string]$PropertyType;[pscustomobject]@{value=$Value}} + function script:Remove-Item {param($LiteralPath,[switch]$Force,$ErrorAction)$key=$script:CcodFix1RegistryWorld.CurrentKey;if($null-ne$key){$key.Deleted=$true};$script:CcodFix1RegistryWorld.CurrentKey=$null} + function script:New-Item { + param($Path,[switch]$Force) + $script:CcodFix1RegistryWorld.ProviderNewItemCalls++ + if($script:CcodFix1RegistryWorld.Mode-in@('PartialReplacement','CreateOpened')){$script:CcodFix1RegistryWorld.CurrentKey=$script:CcodFix1RegistryWorld.Replacement;return $script:CcodFix1RegistryWorld.Replacement} + $created=New-CcodFix1RegistryKey -Id Created -Values ([ordered]@{}) -Kinds ([ordered]@{}) -World $script:CcodFix1RegistryWorld;$script:CcodFix1RegistryWorld.Created=$created;$script:CcodFix1RegistryWorld.CurrentKey=$created;$created + } + } $world + $registryEntry=New-CcodFix1RegistrySnapshotEntry + $legacy=New-CcodLegacyRegistrationFixture -Version '2.5.21' -ShortcutNames $v2521ShortcutNames + $proofs=New-CcodProductLegacyShortcutProofs -Legacy $legacy -ExpectedInstallRoot 'C:\fixture\CodexControlOtherDevices' + $programs=[Environment]::GetFolderPath([Environment+SpecialFolder]::Programs);$desktop=[Environment]::GetFolderPath([Environment+SpecialFolder]::Desktop) + $snapshotEntries=[Collections.Generic.List[object]]::new();$snapshotEntries.Add($registryEntry) + foreach($name in $v2521ShortcutNames){$base=if($name.StartsWith('Programs\',[StringComparison]::Ordinal)){$programs}else{$desktop};$relative=$name.Substring($name.IndexOf('\')+1);$shortcut=$proofs[$name];$snapshotEntries.Add([pscustomobject][ordered]@{kind='Shortcut';name=$name;path=[IO.Path]::GetFullPath((Join-Path $base $relative));sha256=[string]$shortcut.sha256;bytesBase64=[string]$shortcut.bytesBase64;targetPath=[string]$shortcut.targetPath;arguments=[string]$shortcut.arguments;workingDirectory=[string]$shortcut.workingDirectory})} + $snapshot=[pscustomobject][ordered]@{appId=$appId;entries=@($snapshotEntries)} + $proof=[pscustomobject][ordered]@{verified=$true;runtimeId=$runtimeId;version='2.5.22';packageSha256=$packageSha256;shortcutNames=@($currentShortcutNames);startMenuSha256=$startCurrentSha;desktopSha256=$desktopCurrentSha} + $removeRegistry={param($Entry)&$isolated {param($Value)Remove-CcodLegacySnapshotEntry -Entry $Value} $Entry}.GetNewClosure() + $readRegistry={param($Entry)&$isolated {param($Value)Read-CcodLegacySnapshotEntry -Entry $Value} $Entry}.GetNewClosure() + $restoreRegistry={param($Entry)&$isolated {param($Value)Restore-CcodLegacySnapshotEntry -Entry $Value} $Entry}.GetNewClosure() + $adapters=@{ + ReadVerifiedRegistration={$proof}.GetNewClosure();ReadLegacyRegistration={param($ExpectedAppId)$legacy}.GetNewClosure();ReadLegacySnapshot={param($ExpectedAppId,$ExpectedProfile)$snapshot}.GetNewClosure() + RemoveLegacyEntry={param($Entry)if($Entry-isnot[string]-and$Entry.kind-ceq'Registry'){try{&$removeRegistry $Entry}catch{$world.LowerFailure=$_;throw};return};$name=if($Entry-is[string]){[string]$Entry}else{[string]$Entry.name};if($Mode-cne'PartialReplacement'-and$name-ceq$v2521ShortcutNames[1]){throw 'TEST_LATER_SHORTCUT_DELETE_FAILURE'}}.GetNewClosure() + ReadLegacyEntry={param($Entry)if($Entry-isnot[string]-and$Entry.kind-ceq'Registry'){&$readRegistry $Entry}else{'Exact'}}.GetNewClosure() + RestoreLegacyEntry={param($Entry)if($Entry-isnot[string]-and$Entry.kind-ceq'Registry'){&$restoreRegistry $Entry}}.GetNewClosure() + WriteLegacyCompensationFailure={param($Record)$world.CompensationRecords.Add($Record)}.GetNewClosure() + } + $failure=$null;try{&$isolated {param($ExpectedAppId,$ProductAdapters,$ExpectedInstallRoot,$ExpectedCurrentProof)$plan=Get-CcodLegacyProductRegistrationMigrationPlan -ExpectedAppId $ExpectedAppId -ExpectedInstallRoot $ExpectedInstallRoot -Adapters $ProductAdapters;Remove-CcodLegacyProductRegistration -ExpectedAppId $ExpectedAppId -MigrationPlan $plan -ExpectedCurrentProof $ExpectedCurrentProof -Adapters $ProductAdapters} $appId $adapters 'C:\fixture\CodexControlOtherDevices' $proof}catch{$failure=$_} + return [pscustomobject]@{World=$world;Failure=$failure;ReplacementBefore=$replacementBefore;ReplacementKindsBefore=$replacementKindsBefore;RegistryEntry=$registryEntry} + }finally{Remove-Module $isolated -Force -ErrorAction SilentlyContinue;if(Test-Path -LiteralPath $root){Remove-Item -LiteralPath $root -Recurse -Force}} +} + +$results = @() + +# Production mutation caught: accepting a caller-crafted path/runtime tuple as a registration contract. +$results += Invoke-CcodTest 'registration rejects noncanonical runtime version hash and install targets' { + Assert-CcodThrows { New-CcodProductRegistration -InstallRoot 'relative' -RuntimeId $runtimeId -Version '2.5.22' -PackageSha256 $packageSha256 -FileTransaction ([pscustomobject]@{}) | Out-Null } 'CCOD_PRODUCT_REGISTRATION_INVALID' + Assert-CcodThrows { New-CcodProductRegistration -InstallRoot 'C:\fixture\root' -RuntimeId 'runtime' -Version '2.5.22' -PackageSha256 $packageSha256 -FileTransaction ([pscustomobject]@{}) | Out-Null } 'CCOD_PRODUCT_REGISTRATION_INVALID' + Assert-CcodThrows { New-CcodProductRegistration -InstallRoot 'C:\fixture\root' -RuntimeId $runtimeId -Version '2.5.21' -PackageSha256 $packageSha256 -FileTransaction ([pscustomobject]@{}) | Out-Null } 'CCOD_PRODUCT_REGISTRATION_INVALID' + Assert-CcodThrows { New-CcodProductRegistration -InstallRoot 'C:\fixture\root' -RuntimeId $runtimeId -Version '2.5.22' -PackageSha256 ('A' * 64) -FileTransaction ([pscustomobject]@{}) | Out-Null } 'CCOD_PRODUCT_REGISTRATION_INVALID' +} + +# Production mutation caught: allowing product writes from ProtectionReady or a mismatched Ready proof. +$results += Invoke-CcodTest 'registration performs no writes before an exact Ready proof' { + foreach ($mutation in @( + { param($ready) $ready.phase = 'ProtectionReady' }, + { param($ready) $ready.runtimeId = '2.5.22-aaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' }, + { param($ready) $ready.version = '2.5.21' }, + { param($ready) $ready.packageSha256 = '4' * 64 }, + { param($ready) $ready.targetPath = 'C:\outside\task.exe' }, + { param($ready) $ready.bootstrapPath = 'C:\outside\bootstrap.ps1' }, + { param($ready) $ready.uninstallerPath = 'C:\outside\uninstall.ps1' } + )) { + $world = New-CcodRegistrationWorld + & $mutation $world.Ready + Assert-CcodThrows { Commit-CcodProductRegistration -Registration $world.Registration -FileTransaction ([pscustomobject]@{}) -Adapters $world.Adapters | Out-Null } 'CCOD_PRODUCT_REGISTRATION_NOT_READY' + Assert-CcodEqual 0 @($world.Calls | Where-Object { $_ -clike 'Write*' }).Count 'a non-Ready proof cannot reach product writes' + Assert-CcodEqual 0 $world.LegacyRemovalCalls 'a non-Ready proof cannot reach legacy removal' + } +} + +# Production mutation caught: deleting compatibility state after a partial new registration. +$results += Invoke-CcodTest 'write and read-back failures preserve every legacy entry and shortcut name' { + foreach ($failure in @('WriteProduct','WriteStartMenu','WriteDesktop','ReadProduct','ReadStartMenu','ReadDesktop')) { + $world = New-CcodRegistrationWorld + switch ($failure) { + 'WriteProduct' { $world.WriteProductFailure = $true } + 'WriteStartMenu' { $world.WriteShortcutFailure = 'StartMenu' } + 'WriteDesktop' { $world.WriteShortcutFailure = 'Desktop' } + 'ReadProduct' { $world.ReadProductFailure = $true } + 'ReadStartMenu' { $world.ReadShortcutFailure = 'StartMenu' } + 'ReadDesktop' { $world.ReadShortcutFailure = 'Desktop' } + } + $legacyUninstallString = $world.Legacy.uninstallString + $legacyShortcutNames = @($world.Legacy.shortcutNames) + Assert-CcodThrows { Commit-CcodProductRegistration -Registration $world.Registration -FileTransaction ([pscustomobject]@{}) -Adapters $world.Adapters | Out-Null } 'CCOD_PRODUCT_REGISTRATION_FAILED' + Assert-CcodEqual $legacyUninstallString $world.Legacy.uninstallString 'failure before read-back retains the legacy entry' + Assert-CcodEqual ($legacyShortcutNames -join '|') (@($world.Legacy.shortcutNames) -join '|') 'failure before read-back retains exact legacy shortcut names' + Assert-CcodEqual 0 $world.LegacyRemovalCalls 'failed registration never removes legacy state' + } +} + +# Production mutation caught: the pre-capture plan exists only in memory, so a failed current +# overlap write permanently destroys the historical proof needed by the next process. +$results += Invoke-CcodTest 'second registration recovers from partial current overlap writes only through a durable pre-capture plan' { + $outcomes=[Collections.Generic.List[string]]::new() + foreach($failurePoint in @('DesktopWrite','CurrentReadBack')){ + $world=New-CcodRegistrationWorld;$world.SimulateOverlapWrites=$true;$world.Ready.startMenuSha256=[string]$world.CurrentShortcutProofs[$currentShortcutNames[0]].sha256;$world.Ready.desktopSha256=[string]$world.CurrentShortcutProofs[$currentShortcutNames[1]].sha256 + if($failurePoint-ceq'DesktopWrite'){$world.WriteShortcutFailure='Desktop'}else{$world.ReadProductFailure=$true} + $firstPlan=Get-CcodProductTestLegacyMigrationPlan $world;$firstFailure=$null;try{Commit-CcodProductRegistration -Registration $world.Registration -FileTransaction ([pscustomobject]@{}) -Adapters $world.Adapters|Out-Null}catch{$firstFailure=$_} + Assert-CcodEqual 'CCOD_PRODUCT_REGISTRATION_FAILED' (([string]$firstFailure.FullyQualifiedErrorId-split',')[0]) "$failurePoint first attempt fails after its intended overlap boundary" + $firstPlan=$null;$world.WriteShortcutFailure=$null;$world.ReadProductFailure=$false + $secondFailure=$null;$secondReceipt=$null;try{$secondPlan=Get-CcodProductTestLegacyMigrationPlan $world;$secondReceipt=Commit-CcodProductRegistration -Registration $world.Registration -FileTransaction ([pscustomobject]@{}) -Adapters $world.Adapters}catch{$secondFailure=$_} + $outcome=if($null-ne$secondReceipt-and$secondReceipt.verified){'Recovered'}else{([string]$secondFailure.FullyQualifiedErrorId-split',')[0]};$outcomes.Add(('{0}:{1}'-f$failurePoint,$outcome)) + } + Assert-CcodEqual 'DesktopWrite:Recovered|CurrentReadBack:Recovered' (@($outcomes)-join'|') 'a new process reuses durable historical evidence after either overlap failure' +} + +# Production mutation caught: durable replay assumes Start-menu is always replaced before Desktop and rejects another valid crash ordering. +$results += Invoke-CcodTest 'durable replay converges all four independent historical and current overlap states' { + foreach($state in @('HistoricalHistorical','CurrentHistorical','HistoricalCurrent','CurrentCurrent')){ + $world=New-CcodRegistrationWorld;$null=Get-CcodProductTestLegacyMigrationPlan $world + if($state-in@('CurrentHistorical','CurrentCurrent')){$world.LegacyShortcutProofs[$currentShortcutNames[0]]=$world.CurrentShortcutProofs[$currentShortcutNames[0]]} + if($state-in@('HistoricalCurrent','CurrentCurrent')){$world.LegacyShortcutProofs[$currentShortcutNames[1]]=$world.CurrentShortcutProofs[$currentShortcutNames[1]]} + $plan=Get-CcodProductTestLegacyMigrationPlan $world;$world.SimulateOverlapWrites=$true;$null=Commit-CcodProductRegistration -Registration $world.Registration -FileTransaction ([pscustomobject]@{}) -Adapters $world.Adapters + Remove-CcodLegacyProductRegistration -ExpectedAppId $appId -MigrationPlan $plan -ExpectedCurrentProof $expectedVerifiedRegistration -Adapters $world.Adapters + Assert-CcodEqual (($currentShortcutNames|Sort-Object)-join'|') ((@($world.LegacyEntries)|Sort-Object)-join'|') "$state converges to only the two exact current overlaps" + Assert-CcodEqual 1 $world.LegacyMigrationPlanWrites "$state reuses one append-only plan" + } +} + +# Production mutation caught: a later idempotent registration mistakes already-completed exact legacy cleanup for evidence loss. +$results += Invoke-CcodTest 'durable migration replay continues every registry-first legacy cleanup crash boundary' { + $legacyOnly=@($v2521ShortcutNames[1],$v2521ShortcutNames[2]) + foreach($removedCount in 0..2){ + $world=New-CcodRegistrationWorld;$world.SimulateOverlapWrites=$true;$null=Get-CcodProductTestLegacyMigrationPlan $world;$null=Commit-CcodProductRegistration -Registration $world.Registration -FileTransaction ([pscustomobject]@{}) -Adapters $world.Adapters + [void]$world.LegacyEntries.Remove('Registry');for($index=0;$index-lt$removedCount;$index++){[void]$world.LegacyEntries.Remove($legacyOnly[$index])} + $second=Get-CcodProductTestLegacyMigrationPlan $world + Assert-CcodEqual $true $second.legacyPresent "registry-first crash boundary $removedCount retains the durable cleanup plan" + $before=$world.LegacyRemoveAttempts;$null=Commit-CcodProductRegistration -Registration $world.Registration -FileTransaction ([pscustomobject]@{}) -Adapters $world.Adapters;Remove-CcodLegacyProductRegistration -ExpectedAppId $appId -MigrationPlan $second -ExpectedCurrentProof $expectedVerifiedRegistration -Adapters $world.Adapters + Assert-CcodEqual (2-$removedCount) ($world.LegacyRemoveAttempts-$before) "registry-first crash boundary $removedCount removes only remaining exact legacy-only shortcuts" + Assert-CcodEqual (($currentShortcutNames|Sort-Object)-join'|') ((@($world.LegacyEntries)|Sort-Object)-join'|') "registry-first crash boundary $removedCount converges without restoring removed entries" + Assert-CcodEqual 1 $world.LegacyMigrationPlanWrites "registry-first crash boundary $removedCount preserves one append-only plan" + } + $partial=New-CcodRegistrationWorld;$partial.SimulateOverlapWrites=$true;$null=Get-CcodProductTestLegacyMigrationPlan $partial;$null=Commit-CcodProductRegistration -Registration $partial.Registration -FileTransaction ([pscustomobject]@{}) -Adapters $partial.Adapters;[void]$partial.LegacyEntries.Remove($legacyOnly[0]);$plan=Get-CcodProductTestLegacyMigrationPlan $partial;$null=Commit-CcodProductRegistration -Registration $partial.Registration -FileTransaction ([pscustomobject]@{}) -Adapters $partial.Adapters;Remove-CcodLegacyProductRegistration -ExpectedAppId $appId -MigrationPlan $plan -ExpectedCurrentProof $expectedVerifiedRegistration -Adapters $partial.Adapters + Assert-CcodEqual (($currentShortcutNames|Sort-Object)-join'|') ((@($partial.LegacyEntries)|Sort-Object)-join'|') 'registry-present partial legacy-only cleanup also converges absent-or-exact' + $foreign=New-CcodRegistrationWorld;$foreign.SimulateOverlapWrites=$true;$null=Get-CcodProductTestLegacyMigrationPlan $foreign;$null=Commit-CcodProductRegistration -Registration $foreign.Registration -FileTransaction ([pscustomobject]@{}) -Adapters $foreign.Adapters;[void]$foreign.LegacyEntries.Remove('Registry');$bytes=[Text.UTF8Encoding]::new($false).GetBytes('foreign-legacy-only');$foreign.LegacyShortcutProofs[$legacyOnly[0]]=[pscustomobject][ordered]@{targetPath=$canonicalTaskTarget;arguments='foreign';workingDirectory='';sha256=(Get-CcodTestBytesSha256 $bytes);bytesBase64=[Convert]::ToBase64String($bytes)} + Assert-CcodThrows {Get-CcodProductTestLegacyMigrationPlan $foreign|Out-Null} 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' +} + +# Production mutations caught: accepting a washed hybrid without a plan, or trusting changed durable identity/evidence. +$results += Invoke-CcodTest 'durable migration plan rejects absent tampered foreign and wrong Ready identities before current writes' { + $absent=New-CcodRegistrationWorld;$foreignBytes=[Text.UTF8Encoding]::new($false).GetBytes('foreign-current-overlap');$foreignSha=Get-CcodTestBytesSha256 $foreignBytes + $absent.LegacyShortcutProofs[$currentShortcutNames[0]]=[pscustomobject][ordered]@{targetPath=$canonicalTaskTarget;arguments='/Run /TN "foreign"';workingDirectory='';sha256=$foreignSha;bytesBase64=[Convert]::ToBase64String($foreignBytes)} + Assert-CcodThrows {Get-CcodProductTestLegacyMigrationPlan $absent|Out-Null} 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' + Assert-CcodEqual 0 $absent.LegacyMigrationPlanWrites 'washed hybrid without a prior plan publishes no replacement authority' + Assert-CcodEqual 0 $absent.Shortcuts.Count 'washed hybrid without a prior plan performs zero current shortcut writes' + + $oversized=New-CcodRegistrationWorld;$oversizedBytes=[byte[]]::new(262145);$oversizedName=$v2521ShortcutNames[1];$oversizedProof=$oversized.LegacyShortcutProofs[$oversizedName];$oversizedProof.bytesBase64=[Convert]::ToBase64String($oversizedBytes);$oversizedProof.sha256=Get-CcodTestBytesSha256 $oversizedBytes + Assert-CcodThrows {Get-CcodProductTestLegacyMigrationPlan $oversized|Out-Null} 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' + Assert-CcodEqual 0 $oversized.LegacyMigrationPlanWrites 'oversized historical shortcut is rejected before durable publication' + + foreach($inconsistency in @('Generation','Manifest','TransactionPackage')){$world=New-CcodRegistrationWorld;$world.Ready.transactionRecord=(($world.Ready.transactionRecord|ConvertTo-Json -Depth 8 -Compress)|ConvertFrom-Json);if($inconsistency-ceq'Generation'){$world.Ready.runtimeGeneration=[uint64]8}elseif($inconsistency-ceq'Manifest'){$world.Ready.manifestSha256='e'*64}else{$world.Ready.transactionRecord.sealedPackageSha256='e'*64};Assert-CcodThrows {Get-CcodProductTestLegacyMigrationPlan $world|Out-Null} 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID';Assert-CcodEqual 0 $world.LegacyMigrationPlanWrites "$inconsistency inconsistent Ready proof publishes no plan"} + + foreach($mutation in @('TamperedCurrentProof','WrongReady','WrongPackage','WrongGeneration','Duplicate','UnsafeShortcut','ForeignHybrid')){ + $world=New-CcodRegistrationWorld;$null=Get-CcodProductTestLegacyMigrationPlan $world + if($mutation-ceq'ForeignHybrid'){ + $world.LegacyShortcutProofs[$currentShortcutNames[0]]=[pscustomobject][ordered]@{targetPath=$canonicalTaskTarget;arguments='/Run /TN "foreign"';workingDirectory='';sha256=$foreignSha;bytesBase64=[Convert]::ToBase64String($foreignBytes)} + }else{ + $json=[Text.UTF8Encoding]::new($false,$true).GetString($world.LegacyMigrationPlanBytes) + if($mutation-ceq'Duplicate'){$json=$json.Replace('{"schemaVersion":1','{"schemaVersion":1,"schemaVersion":1')} + else{ + $record=$json|ConvertFrom-Json -ErrorAction Stop + switch($mutation){ + 'TamperedCurrentProof' {$record.expectedCurrentProof.shortcuts[0].candidateSha256='f'*64} + 'WrongReady' {$record.readyTransaction.transactionId='aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'} + 'WrongPackage' {$record.packageSha256='e'*64} + 'WrongGeneration' {$record.runtimeGeneration=[uint64]8} + 'UnsafeShortcut' {$record.snapshot.entries[1].path='C:\outside\foreign.lnk'} + } + $json=($record|ConvertTo-Json -Depth 32 -Compress)+"`n" + } + $world.LegacyMigrationPlanBytes=[Text.UTF8Encoding]::new($false).GetBytes($json) + } + Assert-CcodThrows {Get-CcodProductTestLegacyMigrationPlan $world|Out-Null} 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' + Assert-CcodEqual 1 $world.LegacyMigrationPlanWrites "$mutation never republishes or overwrites the first plan" + Assert-CcodEqual 0 $world.Shortcuts.Count "$mutation fails before a current shortcut write" + Assert-CcodEqual $null $world.Product "$mutation fails before a current registry write" + } +} + +# Production mutation caught: persisted nested evidence is accepted by normalized meaning instead of exact canonical bytes and order. +$results += Invoke-CcodTask2Fix1ProductTest 'schema' 'durable plan requires exact six-field profile and raw canonical identity paths' { + foreach($mutation in @('ProfileExtra','ProfileMissing','ProfileReordered','ProfileValue','ExpectedRoot','ShortcutPath','ShortcutTarget','ShortcutWorking')){ + $world=New-CcodRegistrationWorld;$null=Get-CcodProductTestLegacyMigrationPlan $world;$json=[Text.UTF8Encoding]::new($false,$true).GetString($world.LegacyMigrationPlanBytes);$record=$json|ConvertFrom-Json -ErrorAction Stop + switch($mutation){ + 'ProfileExtra' {$record.profile|Add-Member -NotePropertyName unexpected -NotePropertyValue 'foreign'} + 'ProfileMissing' {$record.profile.PSObject.Properties.Remove('minimumVersion')} + 'ProfileReordered' {$names=@($record.profile.PSObject.Properties.Name);$profile=[ordered]@{};for($index=$names.Count-1;$index-ge0;$index--){$profile[$names[$index]]=$record.profile.($names[$index])};$record.profile=[pscustomobject]$profile} + 'ProfileValue' {$record.profile.minimumVersion='2.2.1'} + 'ExpectedRoot' {$record.expectedInstallRoot=[string]$record.expectedInstallRoot+'\.'} + 'ShortcutPath' {$entry=@($record.snapshot.entries|Where-Object{$_.kind-ceq'Shortcut'})[0];$entry.path=(Split-Path ([string]$entry.path) -Parent)+'\.\'+[IO.Path]::GetFileName([string]$entry.path)} + 'ShortcutTarget' {$entry=@($record.snapshot.entries|Where-Object{$_.kind-ceq'Shortcut'})[0];$entry.targetPath=(Split-Path ([string]$entry.targetPath) -Parent)+'\.\'+[IO.Path]::GetFileName([string]$entry.targetPath)} + 'ShortcutWorking' {$entry=@($record.snapshot.entries|Where-Object{$_.kind-ceq'Shortcut'-and-not[string]::IsNullOrEmpty([string]$_.workingDirectory)})[0];$entry.workingDirectory=[string]$entry.workingDirectory+'\.'} + } + $world.LegacyMigrationPlanBytes=[Text.UTF8Encoding]::new($false).GetBytes(($record|ConvertTo-Json -Depth 32 -Compress)+"`n") + Assert-CcodThrows {Get-CcodProductTestLegacyMigrationPlan $world|Out-Null} 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' + Assert-CcodEqual 1 $world.LegacyMigrationPlanWrites "$mutation never republishes the changed durable plan" + Assert-CcodEqual 0 $world.Shortcuts.Count "$mutation fails before current shortcut writes" + } + $exact=New-CcodRegistrationWorld;$null=Get-CcodProductTestLegacyMigrationPlan $exact;$persisted=[Text.UTF8Encoding]::new($false,$true).GetString($exact.LegacyMigrationPlanBytes)|ConvertFrom-Json -ErrorAction Stop + Assert-CcodEqual 'profileId,appId,minimumVersion,maximumVersion,uninstallCommandShape,shortcutNames' (($persisted.profile.PSObject.Properties.Name)-join',') 'persisted profile has the six canonical fields in canonical order' +} + +# Production mutation caught: a post-publish exception or read-back error causes the later process to republish instead of consuming the exact existing plan. +$results += Invoke-CcodTask2Fix1ProductTest 'replay' 'published writer and read-back failures converge without duplicate plan publication' { + foreach($failure in @('PublishedThenThrow','PostWriteReadBack')){ + $world=New-CcodRegistrationWorld;if($failure-ceq'PublishedThenThrow'){$world.LegacyMigrationPlanWriteMode='PublishedThenThrow'}else{$world.LegacyMigrationPlanReadFailureAt=2} + Assert-CcodThrows {Get-CcodProductTestLegacyMigrationPlan $world|Out-Null} 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' + Assert-CcodEqual 1 $world.LegacyMigrationPlanWrites "$failure first invocation published exactly once" + Assert-CcodTrue ($null-ne$world.LegacyMigrationPlanBytes) "$failure first invocation left the exact published bytes" + Assert-CcodEqual 0 $world.Shortcuts.Count "$failure first invocation performs zero current shortcut writes" + Assert-CcodEqual $null $world.Product "$failure first invocation performs zero current registry writes" + $plan=Get-CcodProductTestLegacyMigrationPlan $world;$world.SimulateOverlapWrites=$true;$null=Commit-CcodProductRegistration -Registration $world.Registration -FileTransaction ([pscustomobject]@{}) -Adapters $world.Adapters;Remove-CcodLegacyProductRegistration -ExpectedAppId $appId -MigrationPlan $plan -ExpectedCurrentProof $expectedVerifiedRegistration -Adapters $world.Adapters + Assert-CcodEqual 1 $world.LegacyMigrationPlanWrites "$failure later invocation never republishes" + Assert-CcodEqual (($currentShortcutNames|Sort-Object)-join'|') ((@($world.LegacyEntries)|Sort-Object)-join'|') "$failure later invocation converges from the existing plan" + } + + $world=New-CcodRegistrationWorld;$null=Get-CcodProductTestLegacyMigrationPlan $world;$world.LegacyShortcutProofs[$currentShortcutNames[0]]=$world.CurrentShortcutProofs[$currentShortcutNames[0]] + $profile=Resolve-CcodLegacyRegistrationProfile -LegacyRegistration $world.Legacy;$liveRegistration=&$world.Adapters.ReadLegacyRegistration $appId;$liveSnapshot=&$world.Adapters.ReadLegacySnapshot $appId $profile $null + $payload=[ordered]@{module=$modulePath;appId=$appId;registration=$world.Registration;ready=$world.Ready;planBase64=[Convert]::ToBase64String($world.LegacyMigrationPlanBytes);legacyRegistration=$liveRegistration;legacySnapshot=$liveSnapshot} + $child=@' +param([Parameter(Mandatory)][string]$PayloadPath) +$ErrorActionPreference='Stop' +$payload=[IO.File]::ReadAllText([IO.Path]::GetFullPath($PayloadPath),[Text.UTF8Encoding]::new($false,$true))|ConvertFrom-Json -ErrorAction Stop +Import-Module ([string]$payload.module) -Force -DisableNameChecking -ErrorAction Stop +function Get-BytesSha([byte[]]$Bytes){$sha=[Security.Cryptography.SHA256]::Create();try{[BitConverter]::ToString($sha.ComputeHash($Bytes)).Replace('-','').ToLowerInvariant()}finally{$sha.Dispose()}} +$bytes=[Convert]::FromBase64String([string]$payload.planBase64);$writes=0 +$adapters=@{ + ReadLegacyMigrationPlan={param($Ready)$copy=[byte[]]$bytes.Clone();[pscustomobject]@{Bytes=$copy;Length=[int64]$copy.LongLength;Sha256=(Get-BytesSha $copy)}}.GetNewClosure() + WriteLegacyMigrationPlan={param($Ready,[byte[]]$Value)$writes++;throw 'CHILD_UNEXPECTED_REPUBLISH'}.GetNewClosure() + ReadLegacyRegistration={param($ExpectedAppId)(($payload.legacyRegistration|ConvertTo-Json -Depth 16 -Compress)|ConvertFrom-Json)}.GetNewClosure() + ReadLegacySnapshot={param($ExpectedAppId,$ExpectedProfile,$CapturedPlan)(($payload.legacySnapshot|ConvertTo-Json -Depth 32 -Compress)|ConvertFrom-Json)}.GetNewClosure() +} +$registration=(($payload.registration|ConvertTo-Json -Depth 16 -Compress)|ConvertFrom-Json);$ready=(($payload.ready|ConvertTo-Json -Depth 16 -Compress)|ConvertFrom-Json) +$plan=Get-CcodDurableLegacyProductRegistrationMigrationPlan -ExpectedAppId ([string]$payload.appId) -Registration $registration -ReadyEvidence $ready -Adapters $adapters +if($null-eq$plan-or-not$plan.legacyPresent-or$writes-ne0){throw 'CHILD_REPLAY_FAILED'} +[Console]::Out.WriteLine('CHILD_DURABLE_REPLAY_OK') +'@ + $childRoot=Join-Path ([IO.Path]::GetTempPath()) ('ccod-product-replay-'+[guid]::NewGuid().ToString('N'));[IO.Directory]::CreateDirectory($childRoot)|Out-Null;$payloadPath=Join-Path $childRoot 'payload.json';$childPath=Join-Path $childRoot 'replay.ps1' + try{[IO.File]::WriteAllText($payloadPath,($payload|ConvertTo-Json -Depth 32 -Compress),[Text.UTF8Encoding]::new($false));[IO.File]::WriteAllText($childPath,$child,[Text.UTF8Encoding]::new($false));$output=@(& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $childPath -PayloadPath $payloadPath 2>&1);$exitCode=$LASTEXITCODE}finally{$full=[IO.Path]::GetFullPath($childRoot);$temp=[IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\')+'\';if(-not$full.StartsWith($temp,[StringComparison]::OrdinalIgnoreCase)){throw 'refusing non-temp child cleanup'};if(Test-Path -LiteralPath $full){Remove-Item -LiteralPath $full -Recurse -Force}} + Assert-CcodEqual 0 $exitCode 'fresh powershell process replays serialized durable/live evidence' + Assert-CcodEqual 'CHILD_DURABLE_REPLAY_OK' ($output -join'') 'fresh powershell process consumes existing plan without parent object identity' +} + +# Production mutation caught: trusting a shortcut read-back that resolves outside the selected sealed generation. +$results += Invoke-CcodTest 'shortcut path and target mismatches fail the three-record read-back gate' { + foreach ($kind in @('StartMenu','Desktop')) { + $world = New-CcodRegistrationWorld + $originalRead = $world.Adapters.ReadShortcut + $world.Adapters.ReadShortcut = { + param($candidateKind,$expected) + $value = & $originalRead $candidateKind $expected + if ($candidateKind -ceq $kind) { + $value = $value.PSObject.Copy() + $value.bootstrapPath = 'C:\outside\bootstrap.ps1' + } + $value + }.GetNewClosure() + Assert-CcodThrows { Commit-CcodProductRegistration -Registration $world.Registration -FileTransaction ([pscustomobject]@{}) -Adapters $world.Adapters | Out-Null } 'CCOD_PRODUCT_REGISTRATION_FAILED' + Assert-CcodEqual 0 $world.LegacyRemovalCalls 'mismatched shortcut target retains legacy state' + } +} + +# Production mutation caught: considering adapter write success equivalent to durable read-back. +$results += Invoke-CcodTest 'valid Ready registration reads all three new records before exact legacy removal' { + $world = New-CcodRegistrationWorld + $plan = Get-CcodProductTestLegacyMigrationPlan $world + $receipt = Commit-CcodProductRegistration -Registration $world.Registration -FileTransaction ([pscustomobject]@{}) -Adapters $world.Adapters + Assert-CcodTrue $receipt.verified 'registration returns a verified three-record receipt' + Assert-CcodEqual 'ReadLegacy,Ready,WriteProduct,WriteShortcut:StartMenu,WriteShortcut:Desktop,ReadProduct,ReadShortcut:StartMenu,ReadShortcut:Desktop' ($world.Calls -join ',') 'legacy profile capture precedes all current writes and read-backs' + Remove-CcodLegacyProductRegistration -ExpectedAppId $appId -MigrationPlan $plan -ExpectedCurrentProof $expectedVerifiedRegistration -Adapters $world.Adapters + Assert-CcodEqual 3 $world.LegacyRemoveAttempts 'exact migration removes only the three non-current legacy entries' + Assert-CcodEqual (($currentShortcutNames|Sort-Object)-join'|') ((@($world.LegacyEntries)|Sort-Object)-join'|') 'exact migration retains both verified current shortcut replacements' +} + +# Production mutation caught: requiring the union of every historical shortcut name instead of +# selecting the one complete installer profile that produced the legacy registration. +$results += Invoke-CcodTest 'real v2.5.21 registration removes its exact four-shortcut profile' { + $world = New-CcodRegistrationWorld + $plan = Get-CcodProductTestLegacyMigrationPlan $world + $null = Commit-CcodProductRegistration -Registration $world.Registration -FileTransaction ([pscustomobject]@{}) -Adapters $world.Adapters + + Remove-CcodLegacyProductRegistration -ExpectedAppId $appId -MigrationPlan $plan -ExpectedCurrentProof $expectedVerifiedRegistration -Adapters $world.Adapters + + Assert-CcodEqual 3 $world.LegacyRemoveAttempts 'v2.5.21 cleanup removes the registry compatibility link and uninstall link' + Assert-CcodEqual (($currentShortcutNames|Sort-Object)-join'|') ((@($world.LegacyEntries)|Sort-Object)-join'|') 'v2.5.21 cleanup retains the two verified current shortcut replacements' + Assert-CcodEqual 2 $world.VerifiedRegistrationReads 'v2.5.21 cleanup re-verifies the exact current three-record registration after deletion' +} + +# Production mutation caught: widening, narrowing, or crossing a checked-in installer profile's +# version/name boundary can select the wrong legacy objects for deletion. +$results += Invoke-CcodTest 'historical installer profiles resolve at their exact version boundaries' { + $cases = @( + [pscustomobject]@{Version='2.1.0';Names=$v210ShortcutNames;ProfileId='CodexControlOtherDevicesInitial';Minimum='2.1.0';Maximum='2.1.0'}, + [pscustomobject]@{Version='2.1.1';Names=$v211ShortcutNames;ProfileId='CodexControlOtherDevicesDesktop';Minimum='2.1.1';Maximum='2.1.6'}, + [pscustomobject]@{Version='2.1.6';Names=$v211ShortcutNames;ProfileId='CodexControlOtherDevicesDesktop';Minimum='2.1.1';Maximum='2.1.6'}, + [pscustomobject]@{Version='2.2.0';Names=$v2521ShortcutNames;ProfileId='CodexRemoteFix';Minimum='2.2.0';Maximum='2.5.21'}, + [pscustomobject]@{Version='2.5.21';Names=@($v2521ShortcutNames[3..0]);ProfileId='CodexRemoteFix';Minimum='2.2.0';Maximum='2.5.21'} + ) + foreach ($case in $cases) { + $legacy = New-CcodLegacyRegistrationFixture -Version $case.Version -ShortcutNames $case.Names + $profile = Resolve-CcodLegacyRegistrationProfile -LegacyRegistration $legacy + Assert-CcodEqual $case.ProfileId $profile.profileId "$($case.Version) selects the installer profile that emitted its exact shortcut set" + Assert-CcodEqual $appId $profile.appId "$($case.Version) remains bound to the canonical Inno AppId" + Assert-CcodEqual $case.Minimum $profile.minimumVersion "$($case.Version) reports the checked-in lower version boundary" + Assert-CcodEqual $case.Maximum $profile.maximumVersion "$($case.Version) reports the checked-in upper version boundary" + Assert-CcodEqual '"{installLocation}\unins000.exe"' $profile.uninstallCommandShape "$($case.Version) requires the quoted Inno uninstaller under InstallLocation" + } + Assert-CcodEqual 3 @(Get-CcodLegacyRegistrationProfiles).Count 'only the three checked-in historical layouts are supported' +} + +# Production mutation caught: validating only a shortcut count, a case-insensitive set, or an +# unbound registry command permits a foreign/replaced entry to be deleted as legacy state. +$results += Invoke-CcodTest 'profile resolution rejects ambiguous registry and shortcut evidence before deletion' { + foreach ($kind in @('AppId','Missing','Extra','CrossProfile','Case','Reparse','VersionBinding','VersionGap','VersionTooNew','UninstallOutside','UninstallArguments','UninstallUnquoted','InstallLocationRelative')) { + $world = New-CcodRegistrationWorld + $legacy = New-CcodLegacyRegistrationFixture -Version '2.5.21' -ShortcutNames $v2521ShortcutNames + switch ($kind) { + 'AppId' { $legacy.appId = '{AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE}' } + 'Missing' { $legacy.shortcutNames = @($v2521ShortcutNames[0..2]) } + 'Extra' { $legacy.shortcutNames = @($v2521ShortcutNames) + 'Programs\CodexRemote-fix\unexpected.lnk' } + 'CrossProfile' { $legacy.shortcutNames = @($v2521ShortcutNames[0..2]) + $v211ShortcutNames[4] } + 'Case' { $legacy.shortcutNames = @($v2521ShortcutNames[0..2]) + 'desktop\CodexRemote-fix.lnk' } + 'Reparse' { $legacy.unsafeShortcutNames = @($v2521ShortcutNames[0]) } + 'VersionBinding' { $legacy.displayVersion = '2.1.6' } + 'VersionGap' { $legacy.displayVersion = '2.1.7' } + 'VersionTooNew' { $legacy.displayVersion = '2.5.22' } + 'UninstallOutside' { $legacy.uninstallString = '"C:\outside\unins000.exe"' } + 'UninstallArguments' { $legacy.uninstallString += ' /SILENT' } + 'UninstallUnquoted' { $legacy.uninstallString = 'C:\legacy\CodexControlOtherDevices-installer\unins000.exe' } + 'InstallLocationRelative' { $legacy.installLocation = 'relative\installer' } + } + $world.Legacy = $legacy + $world.LegacyEntries.Clear();$world.LegacyEntries.Add('Registry');foreach($name in @($legacy.shortcutNames)){$world.LegacyEntries.Add([string]$name)} + $before=@($world.LegacyEntries|Sort-Object)-join'|' + Assert-CcodThrows { Resolve-CcodLegacyRegistrationProfile -LegacyRegistration $world.Legacy | Out-Null } 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' + Assert-CcodThrows { Get-CcodProductTestLegacyMigrationPlan $world | Out-Null } 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' + Assert-CcodEqual 0 $world.LegacyRemoveAttempts "$kind mismatch is rejected before any legacy deletion" + Assert-CcodEqual $null $world.Product "$kind mismatch is rejected before the current registry write" + Assert-CcodEqual 0 $world.Shortcuts.Count "$kind mismatch is rejected before either current shortcut write" + Assert-CcodEqual $before ((@($world.LegacyEntries|Sort-Object))-join'|') "$kind mismatch preserves the complete exact legacy state" + } +} + +# Production mutation caught: keeping the historical fixed count of nine records either rejects a +# complete older profile or authorizes a same-count snapshot containing an unrelated entry. +$results += Invoke-CcodTest 'legacy snapshot count and names are derived from the selected historical profile' { + foreach ($case in @( + [pscustomobject]@{Version='2.1.0';Names=$v210ShortcutNames;RemovalCount=5;Remaining=$currentShortcutNames}, + [pscustomobject]@{Version='2.1.6';Names=$v211ShortcutNames;RemovalCount=6;Remaining=$currentShortcutNames}, + [pscustomobject]@{Version='2.5.21';Names=$v2521ShortcutNames;RemovalCount=3;Remaining=$currentShortcutNames} + )) { + $world = New-CcodRegistrationWorld + Set-CcodRegistrationLegacyFixture -World $world -Version $case.Version -ShortcutNames $case.Names + $plan = Get-CcodProductTestLegacyMigrationPlan $world + $null = Commit-CcodProductRegistration -Registration $world.Registration -FileTransaction ([pscustomobject]@{}) -Adapters $world.Adapters + Remove-CcodLegacyProductRegistration -ExpectedAppId $appId -MigrationPlan $plan -ExpectedCurrentProof $expectedVerifiedRegistration -Adapters $world.Adapters + Assert-CcodEqual $case.RemovalCount $world.LegacyRemoveAttempts "$($case.Version) removes only legacy entries not replaced by exact current shortcuts" + Assert-CcodEqual ((@($case.Remaining)|Sort-Object)-join'|') ((@($world.LegacyEntries)|Sort-Object)-join'|') "$($case.Version) leaves exactly its verified current replacement set" + } + + $world = New-CcodRegistrationWorld + $plan = Get-CcodProductTestLegacyMigrationPlan $world + $null = Commit-CcodProductRegistration -Registration $world.Registration -FileTransaction ([pscustomobject]@{}) -Adapters $world.Adapters + [void]$world.LegacyEntries.Remove($v2521ShortcutNames[3]) + $world.LegacyEntries.Add('Programs\CodexRemote-fix\unexpected.lnk') + Assert-CcodThrows { Remove-CcodLegacyProductRegistration -ExpectedAppId $appId -MigrationPlan $plan -ExpectedCurrentProof $expectedVerifiedRegistration -Adapters $world.Adapters } 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' + Assert-CcodEqual 0 $world.LegacyRemoveAttempts 'same-count foreign snapshot is rejected before the first deletion' +} + +$results += Invoke-CcodTest 'overlapping legacy names require exact current shortcut hashes before deletion' { + $world=New-CcodRegistrationWorld + $world.LegacySnapshotHashOverrides[$currentShortcutNames[0]]='d'*64 + Assert-CcodThrows {Get-CcodProductTestLegacyMigrationPlan $world|Out-Null} 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' + Assert-CcodEqual 0 $world.LegacyRemoveAttempts 'mismatched replacement hash is rejected before any legacy deletion' + Assert-CcodEqual $null $world.Product 'mismatched captured bytes are rejected before current product writes' + Assert-CcodEqual 5 $world.LegacyEntries.Count 'mismatched replacement hash preserves every observed entry' +} + +$results += Invoke-CcodTest 'current registration drift after cleanup restores every removed legacy-only entry' { + $world=New-CcodRegistrationWorld + $world.VerifiedRegistrationMutationAt=2 + $before=@($world.LegacyEntries)|Sort-Object + $plan=Get-CcodProductTestLegacyMigrationPlan $world + $null=Commit-CcodProductRegistration -Registration $world.Registration -FileTransaction ([pscustomobject]@{}) -Adapters $world.Adapters + Assert-CcodThrows {Remove-CcodLegacyProductRegistration -ExpectedAppId $appId -MigrationPlan $plan -ExpectedCurrentProof $expectedVerifiedRegistration -Adapters $world.Adapters} 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' + Assert-CcodEqual 3 $world.LegacyRemoveAttempts 'post-cleanup proof drift occurs only after the three legacy-only entries were removed' + Assert-CcodEqual ($before-join'|') ((@($world.LegacyEntries)|Sort-Object)-join'|') 'post-cleanup proof drift compensates the complete removed legacy-only set' + Assert-CcodEqual (($v2521ShortcutNames[2],$v2521ShortcutNames[1],'Registry')-join'|') (@($world.LegacyRestoreOrder)-join'|') 'post-cleanup proof drift restores legacy-only entries in reverse order' +} + +# Production mutation caught: accepting an unexpected AppId/name set or an unverified registration receipt. +$results += Invoke-CcodTest 'legacy migration rejects AppId shortcut and current-proof mismatches without deletion' { + foreach ($mismatch in @('AppId','Shortcut','Receipt','CurrentProofHash','CurrentProofNames')) { + $world = New-CcodRegistrationWorld + $plan = Get-CcodProductTestLegacyMigrationPlan $world + $receipt = Commit-CcodProductRegistration -Registration $world.Registration -FileTransaction ([pscustomobject]@{}) -Adapters $world.Adapters + if ($mismatch -ceq 'AppId') { $plan.legacyRegistration.appId = '{AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE}' } + if ($mismatch -ceq 'Shortcut') { $plan.legacyRegistration.shortcutNames = @('Programs\unexpected.lnk') } + if ($mismatch -ceq 'Receipt') { $world.Product = $null } + if ($mismatch -in @('CurrentProofHash','CurrentProofNames')) { + $readVerified=$world.Adapters.ReadVerifiedRegistration + $world.Adapters.ReadVerifiedRegistration={ + $proof=&$readVerified + if($mismatch-ceq'CurrentProofHash'){$proof.startMenuSha256='d'*64}else{$proof.shortcutNames=@($currentShortcutNames[0])} + $proof + }.GetNewClosure() + } + Assert-CcodThrows { Remove-CcodLegacyProductRegistration -ExpectedAppId $appId -MigrationPlan $plan -ExpectedCurrentProof $expectedVerifiedRegistration -Adapters $world.Adapters } 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' + Assert-CcodEqual 0 $world.LegacyRemoveAttempts 'legacy mismatch is rejected before any deletion' + Assert-CcodEqual 5 $world.LegacyEntries.Count 'legacy state remains intact on mismatch' + } +} + +# Production mutation caught: deleting a whole product key despite unknown values or mismatched shortcut evidence. +$results += Invoke-CcodTest 'current product cleanup requires exact registry values and shortcut bytes targets and file identity' { + foreach($mutation in @('UnknownRegistryValue','RegistrySubkey','ShortcutHash','ShortcutTarget','ShortcutReparse','Ambiguous','CoherentReplacement')){ + $world=New-CcodRegistrationWorld;$world.CurrentProductState.valid=$false;$world.CurrentProductState.reason=$mutation + Assert-CcodThrows {Remove-CcodProductRegistration -ReadyEvidence $cleanupReady -Adapters $world.Adapters} 'CCOD_PRODUCT_CLEANUP_INVALID' + Assert-CcodEqual 0 $world.CurrentProductRemovals "$mutation mismatch preserves every current product entry" + } + $world=New-CcodRegistrationWorld + Remove-CcodProductRegistration -ReadyEvidence $cleanupReady -Adapters $world.Adapters + Assert-CcodEqual 3 $world.CurrentProductRemovals 'matched cleanup removes only the exact registry and two shortcut entries' + $wrong=$cleanupReady.PSObject.Copy();$wrong.targetPath='C:\outside\task.exe';$world=New-CcodRegistrationWorld;$world.CurrentProductState.readyEvidence=$wrong + Assert-CcodThrows {Remove-CcodProductRegistration -ReadyEvidence $wrong -Adapters $world.Adapters} 'CCOD_PRODUCT_CLEANUP_INVALID' + Assert-CcodEqual 0 $world.CurrentProductRemovals 'coherent arbitrary target evidence cannot authorize cleanup' +} + +# Production mutation caught: the registry reader starts reading shortcut evidence even though an unknown +# product subkey already makes the entire current registration ambiguous. +$results += Invoke-CcodTest 'current product registry preflight rejects a subkey before returning shortcut entries' { + $allowed=@('CcodRuntimeId','DisplayVersion') + $accepted=&$module {param($ValueNames,$SubKeyNames,$Allowed)Test-CcodCurrentProductRegistryPreflight -ValueNames $ValueNames -SubKeyNames $SubKeyNames -AllowedValueNames $Allowed} $allowed @('unexpected-child') $allowed + Assert-CcodEqual $false $accepted 'an unknown subkey invalidates the complete state before shortcut evidence is read' + $world=New-CcodRegistrationWorld;$world.CurrentProductState.valid=$accepted;$world.CurrentProductState.entries=@() + Assert-CcodThrows {Remove-CcodProductRegistration -ReadyEvidence $cleanupReady -Adapters $world.Adapters} 'CCOD_PRODUCT_CLEANUP_INVALID' + Assert-CcodEqual 0 $world.CurrentProductRemovals 'subkey preflight failure performs zero removals' +} + +# Production mutation caught: a mid-sequence legacy deletion failure leaves earlier exact entries missing. +$results += Invoke-CcodTest 'legacy migration restores earlier exact deletions after a later deletion failure' { + $world=New-CcodRegistrationWorld + $plan=Get-CcodProductTestLegacyMigrationPlan $world + $receipt=Commit-CcodProductRegistration -Registration $world.Registration -FileTransaction ([pscustomobject]@{}) -Adapters $world.Adapters + $before=@($world.LegacyEntries)|Sort-Object + $world.LegacyRemoveFailureAt=3 + Assert-CcodThrows {Remove-CcodLegacyProductRegistration -ExpectedAppId $appId -MigrationPlan $plan -ExpectedCurrentProof $expectedVerifiedRegistration -Adapters $world.Adapters} 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' + Assert-CcodEqual ($before-join'|') ((@($world.LegacyEntries)|Sort-Object)-join'|') 'failed migration restores the complete exact legacy set' + Assert-CcodEqual 2 $world.LegacyRestores 'only successfully removed legacy-only entries are restored' + Assert-CcodEqual (($v2521ShortcutNames[1],'Registry')-join'|') (@($world.LegacyRestoreOrder)-join'|') 'successful legacy-only deletions are compensated in exact reverse order' +} + +$results += Invoke-CcodTest 'native registry create-only boundary loads without opening or changing a key' { + $native=&$module {Initialize-CcodLegacyRegistryNative;[pscustomobject]@{Create=$null-ne[CcodLegacyRegistryNativeV1].GetMethod('CreateCurrentUserKey');Compare=$null-ne[CcodLegacyRegistryNativeV1].GetMethod('IsSameKey');Delete=$null-ne[CcodLegacyRegistryNativeV1].GetMethod('DeleteKey')}} + Assert-CcodTrue ($native.Create-and$native.Compare-and$native.Delete) 'native boundary exposes only create-with-disposition compare-handle and delete-by-handle operations used by compensation' +} + +$results += Invoke-CcodTest 'installed resume registry publish converges only exact old-new partial transitions' { + $original=[ordered]@{DisplayName='CodexRemote-fix';UninstallString='old-command';QuietUninstallString='old-quiet'} + $expected=[ordered]@{DisplayName='CodexRemote-fix';UninstallString='resume-command';QuietUninstallString='resume-command';CcodUninstallTransactionId='11111111-2222-3333-4444-555555555555';CcodUninstallResumeScriptSha256=('a'*64)} + $kinds=[ordered]@{DisplayName='String';UninstallString='String';QuietUninstallString='String';CcodUninstallTransactionId='String';CcodUninstallResumeScriptSha256='String'} + $world=[pscustomobject]@{ReplacementWrites=0} + $partial=New-CcodFix1RegistryKey -Id Partial -Values ([ordered]@{DisplayName='CodexRemote-fix';UninstallString='resume-command';QuietUninstallString='old-quiet';CcodUninstallTransactionId='11111111-2222-3333-4444-555555555555'}) -Kinds ([ordered]@{DisplayName='String';UninstallString='String';QuietUninstallString='String';CcodUninstallTransactionId='String'}) -World $world + $accepted=&$module {param($Key,$Old,$New,$Kinds)Test-CcodInstalledResumeRegistryTransition -Key $Key -Original $Old -Expected $New -ExpectedKinds $Kinds} $partial $original $expected $kinds + Assert-CcodTrue $accepted 'crash after exact metadata and one command write remains safely convergent' + $partial.Values.CcodUninstallTransactionId='aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + $foreign=&$module {param($Key,$Old,$New,$Kinds)Test-CcodInstalledResumeRegistryTransition -Key $Key -Original $Old -Expected $New -ExpectedKinds $Kinds} $partial $original $expected $kinds + Assert-CcodEqual $false $foreign 'foreign partial recovery metadata cannot be overwritten or accepted' + $partial.Values.CcodUninstallTransactionId='11111111-2222-3333-4444-555555555555';$partial.Values.Unexpected='foreign';$partial.Kinds.Unexpected='String' + $unknown=&$module {param($Key,$Old,$New,$Kinds)Test-CcodInstalledResumeRegistryTransition -Key $Key -Original $Old -Expected $New -ExpectedKinds $Kinds} $partial $original $expected $kinds + Assert-CcodEqual $false $unknown 'unknown recovery values fail closed instead of being normalized' +} + +$results += Invoke-CcodTest 'create-only registry compensation accepts only a newly created exact key' { + $result=Invoke-CcodFix1RegistryCompensationScenario -Mode CreateNew + Assert-CcodEqual 1 $result.World.NativeCreateCalls 'fully absent registry compensation performs exactly one native create-only open' + Assert-CcodEqual 0 $result.World.ProviderNewItemCalls 'new registry restoration never uses provider New-Item -Force' + Assert-CcodEqual '{"DisplayName":"captured-product","NoModify":1}' ($result.World.Created.Values|ConvertTo-Json -Compress) 'newly created key is restored to the exact captured values' + Assert-CcodEqual '{"DisplayName":"String","NoModify":"DWord"}' ($result.World.Created.Kinds|ConvertTo-Json -Compress) 'newly created key is restored to the exact captured value kinds' + Assert-CcodEqual 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' (([string]$result.Failure.FullyQualifiedErrorId-split',')[0]) 'later failure reports ordinary restored rollback only after exact create-only compensation' + Assert-CcodEqual 0 $result.World.CompensationRecords.Count 'exact newly created restoration needs no unresolved record' +} + +$results += Invoke-CcodTest 'create-only registry compensation preserves a replacement that wins the restore race' { + $result=Invoke-CcodFix1RegistryCompensationScenario -Mode CreateOpened + Assert-CcodEqual $result.ReplacementBefore ($result.World.Replacement.Values|ConvertTo-Json -Compress) 'opened-existing restore disposition leaves replacement values unchanged' + Assert-CcodEqual $result.ReplacementKindsBefore ($result.World.Replacement.Kinds|ConvertTo-Json -Compress) 'opened-existing restore disposition leaves replacement kinds unchanged' + Assert-CcodEqual 0 $result.World.ReplacementWrites 'opened-existing restore disposition performs zero value writes' + Assert-CcodEqual 1 $result.World.NativeCreateCalls 'fully deleted registry compensation attempts exactly one native create-only open' + Assert-CcodEqual 0 $result.World.ProviderNewItemCalls 'registry compensation never falls back to provider New-Item -Force' + Assert-CcodEqual 'CCOD_LEGACY_PRODUCT_COMPENSATION_FAILED' (([string]$result.Failure.FullyQualifiedErrorId-split',')[0]) 'opened-existing restore disposition remains explicitly unresolved' + Assert-CcodEqual 'Registry' (@($result.World.CompensationRecords[0].entries)-join'|') 'replacement race records only Registry as unresolved' +} + +# Production mutation caught: disposing the partially deleted original registry handle and then +# filling missing values by path writes captured data into a replacement key at the same name. +$results += Invoke-CcodTest 'partial registry compensation never writes captured values into a replacement key' { + $result=Invoke-CcodFix1RegistryCompensationScenario -Mode PartialReplacement + Assert-CcodEqual 2 $result.World.Original.DeleteCalls "production registry remover reaches the injected mid-delete failure after one value deletion; lower=$($result.World.LowerFailure.Exception.Message)" + Assert-CcodEqual $result.ReplacementBefore ($result.World.Replacement.Values|ConvertTo-Json -Compress) 'replacement registry values remain byte-for-byte unchanged' + Assert-CcodEqual $result.ReplacementKindsBefore ($result.World.Replacement.Kinds|ConvertTo-Json -Compress) 'replacement registry value kinds remain unchanged' + Assert-CcodEqual 0 $result.World.ReplacementWrites 'captured values are written only through the original open registry handle' + Assert-CcodEqual 2 $result.World.Original.Values.Count 'partial deletion restores the complete captured value set through the still-open original handle' + Assert-CcodEqual 'captured-product' $result.World.Original.Values.DisplayName 'same-handle restoration restores the captured string value' + Assert-CcodEqual 1 $result.World.Original.Values.NoModify 'same-handle restoration restores the captured DWORD value' + Assert-CcodEqual 0 $result.World.NativeCreateCalls 'unproven partial deletion never falls back to path-level create' + Assert-CcodEqual 0 $result.World.ProviderNewItemCalls 'unproven partial deletion never uses provider New-Item -Force' + Assert-CcodEqual 'CCOD_LEGACY_PRODUCT_COMPENSATION_FAILED' (([string]$result.Failure.FullyQualifiedErrorId-split',')[0]) 'replacement during partial registry compensation remains explicitly unresolved' + Assert-CcodEqual 1 $result.World.CompensationRecords.Count 'one create-only unresolved compensation record is persisted' + Assert-CcodEqual 'Registry' (@($result.World.CompensationRecords[0].entries)-join'|') 'the unresolved record names only the displaced Registry entry' +} + +$results += Invoke-CcodTest 'legacy compensation never overwrites a replacement that appeared after deletion' { + $world=New-CcodRegistrationWorld;$plan=Get-CcodProductTestLegacyMigrationPlan $world;$null=Commit-CcodProductRegistration -Registration $world.Registration -FileTransaction ([pscustomobject]@{}) -Adapters $world.Adapters;$world.LegacyRemoveFailureAt=3;$world.LegacyReplacementEntry=$v2521ShortcutNames[1] + Assert-CcodThrows {Remove-CcodLegacyProductRegistration -ExpectedAppId $appId -MigrationPlan $plan -ExpectedCurrentProof $expectedVerifiedRegistration -Adapters $world.Adapters} 'CCOD_LEGACY_PRODUCT_COMPENSATION_FAILED' + Assert-CcodEqual 1 $world.LegacyRestores 'compensation restores only entries that remain absent' + Assert-CcodTrue (-not$world.LegacyEntries.Contains($v2521ShortcutNames[1])) 'replacement occupies the removed name and is not overwritten with captured bytes' + Assert-CcodEqual 1 $world.LegacyUnresolvedRecords.Count 'replacement-blocked restoration is recorded explicitly' +} + +$results += Invoke-CcodTest 'legacy restore failure is explicit and records the unresolved exact entries' { + $world=New-CcodRegistrationWorld;$plan=Get-CcodProductTestLegacyMigrationPlan $world;$null=Commit-CcodProductRegistration -Registration $world.Registration -FileTransaction ([pscustomobject]@{}) -Adapters $world.Adapters;$world.LegacyRemoveFailureAt=3;$world.LegacyRestoreFailureEntry=$v2521ShortcutNames[1] + Assert-CcodThrows {Remove-CcodLegacyProductRegistration -ExpectedAppId $appId -MigrationPlan $plan -ExpectedCurrentProof $expectedVerifiedRegistration -Adapters $world.Adapters} 'CCOD_LEGACY_PRODUCT_COMPENSATION_FAILED' + Assert-CcodEqual 1 $world.LegacyUnresolvedRecords.Count 'failed compensation writes one explicit unresolved record' + Assert-CcodTrue (@($world.LegacyUnresolvedRecords[0].entries)-ccontains$v2521ShortcutNames[1]) 'unresolved record names the exact entry whose restoration failed' +} + +$results += Invoke-CcodTest 'partially removed current registry entry is included in unresolved compensation' { + $world=New-CcodRegistrationWorld;$plan=Get-CcodProductTestLegacyMigrationPlan $world;$null=Commit-CcodProductRegistration -Registration $world.Registration -FileTransaction ([pscustomobject]@{}) -Adapters $world.Adapters;$world.LegacyRemoveFailureAt=1;$world.LegacyRestoreFailureEntry='Registry';$originalRemove=$world.Adapters.RemoveLegacyEntry;$world.Adapters.RemoveLegacyEntry={param($Entry)$name=if($Entry-is[string]){[string]$Entry}elseif($Entry.kind-ceq'Registry'){'Registry'}else{[string]$Entry.name};if($name-ceq'Registry'){[void]$world.LegacyEntries.Remove('Registry');throw 'partial registry failure'};&$originalRemove $Entry}.GetNewClosure() + Assert-CcodThrows {Remove-CcodLegacyProductRegistration -ExpectedAppId $appId -MigrationPlan $plan -ExpectedCurrentProof $expectedVerifiedRegistration -Adapters $world.Adapters} 'CCOD_LEGACY_PRODUCT_COMPENSATION_FAILED' + Assert-CcodTrue (@($world.LegacyUnresolvedRecords[0].entries)-ccontains'Registry') 'current partially removed registry entry is recorded unresolved' +} + +$results += Invoke-CcodTest 'legacy registry snapshot comparison treats a present partial key as unresolved compensation' { + $captured=[pscustomobject]@{kind='Registry';path='HKCU:\fixture';values=[ordered]@{DisplayName=[pscustomobject]@{value='Codex';kind='String'};NoModify=[pscustomobject]@{value=1;kind='DWord'}}} + $partial=[pscustomobject]@{kind='Registry';path='HKCU:\fixture';values=[ordered]@{DisplayName=[pscustomobject]@{value='Codex';kind='String'}}} + $state=&$module {param($Expected,$Current)Compare-CcodLegacySnapshotEntry -Expected $Expected -Current $Current} $captured $partial + Assert-CcodEqual 'Mismatch' $state 'a still-present key missing one captured value is not restored' + $world=New-CcodRegistrationWorld;$plan=Get-CcodProductTestLegacyMigrationPlan $world;$null=Commit-CcodProductRegistration -Registration $world.Registration -FileTransaction ([pscustomobject]@{}) -Adapters $world.Adapters;$world.LegacyRemoveFailureAt=1;$world.Adapters.RemoveLegacyEntry={param($Entry)$name=if($Entry-is[string]){[string]$Entry}elseif($Entry.kind-ceq'Registry'){'Registry'}else{[string]$Entry.name};if($name-ceq'Registry'){$world.LegacyRemoveAttempts++;throw 'partial registry removal and internal restore failed'}}.GetNewClosure();$world.Adapters.ReadLegacyEntry={param($Entry)$name=if($Entry-is[string]){[string]$Entry}elseif($Entry.kind-ceq'Registry'){'Registry'}else{[string]$Entry.name};if($name-ceq'Registry'-and$world.LegacyRemoveAttempts-gt0){return $state};if($world.LegacyEntries.Contains($name)){return 'Exact'};$null}.GetNewClosure() + Assert-CcodThrows {Remove-CcodLegacyProductRegistration -ExpectedAppId $appId -MigrationPlan $plan -ExpectedCurrentProof $expectedVerifiedRegistration -Adapters $world.Adapters} 'CCOD_LEGACY_PRODUCT_COMPENSATION_FAILED' + Assert-CcodTrue (@($world.LegacyUnresolvedRecords[0].entries)-ccontains'Registry') 'partial registry key is persisted in the unresolved create-only record' +} + +Write-Output "Product registration self-tests passed: $($results.Count)" diff --git a/tests/persistence/ProductResidueCleanup.SelfTest.ps1 b/tests/persistence/ProductResidueCleanup.SelfTest.ps1 new file mode 100644 index 0000000..04cb93f --- /dev/null +++ b/tests/persistence/ProductResidueCleanup.SelfTest.ps1 @@ -0,0 +1,348 @@ +$ErrorActionPreference='Stop' +. (Join-Path $PSScriptRoot 'TestSupport.ps1') +$repositoryRoot=Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +$module=Import-Module (Join-Path $repositoryRoot 'src/persistence/modules/GenerationReclamation.psm1') -Force -PassThru +$fixtureTokens=$null;$fixtureErrors=$null +$fixtureAst=[Management.Automation.Language.Parser]::ParseFile((Join-Path $PSScriptRoot 'InstallLifecycle.SelfTest.ps1'),[ref]$fixtureTokens,[ref]$fixtureErrors) +if(@($fixtureErrors).Count-ne0){throw 'installer fixture definitions invalid'} +foreach($definition in @($fixtureAst.EndBlock.Statements|Where-Object {$_-is[Management.Automation.Language.FunctionDefinitionAst]})){. ([scriptblock]::Create($definition.Extent.Text))} +$v2521LifecycleShortcutNames=@('Programs\CodexRemote-fix\CodexRemote-fix.lnk','Programs\CodexRemote-fix\CodexRemote-fix compatibility check.lnk','Programs\CodexRemote-fix\Uninstall CodexRemote-fix.lnk','Desktop\CodexRemote-fix.lnk') +$results=@() + +$results+=Invoke-CcodTest 'generation reclamation runtime IDs use canonical TAB delimiters' { + $records=@( + [pscustomobject]@{path='a.txt';length=[int64]5;sha256=('a'*64)} + [pscustomobject]@{path='b.txt';length=[int64]4;sha256=('b'*64)} + ) + $expected='2.5.22-e71f4818a0f8e98f-0123456789abcdef0123456789abcdef' + Assert-CcodEqual $expected (&$module {param($ProjectVersion,$Records,$Nonce) Get-CcodGenerationReclamationRuntimeId -ProjectVersion $ProjectVersion -Records $Records -Nonce $Nonce} '2.5.22' $records '0123456789abcdef0123456789abcdef') 'generation reclamation digest input must use literal TAB delimiters' +} + +$results+=Invoke-CcodTest 'bounded product residue cleanup removes only the verified disposable product root' { + $root=Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-product-tail-'+[guid]::NewGuid().ToString('N')) + $install=Join-Path $root 'CodexControlOtherDevices' + try { + [IO.Directory]::CreateDirectory((Join-Path $install 'state/active-generation'))|Out-Null + [IO.Directory]::CreateDirectory((Join-Path $install 'runtime'))|Out-Null + $runtimeId='2.5.22-'+('a'*16)+'-'+('b'*32) + [IO.File]::WriteAllText((Join-Path $install 'state/lifecycle-epoch.json'),'{"schemaVersion":1,"epoch":11}') + [IO.File]::WriteAllText((Join-Path $install 'state/active-generation/00000000000000000001.json'),('{"schemaVersion":1,"generation":1,"activeRuntime":"'+$runtimeId+'","previousGeneration":0}')) + $outside=Join-Path $root 'project.txt';[IO.File]::WriteAllText($outside,'user project retained') + $outsideHash=Get-CcodTestFileSha256 $outside + $result=&$module { + param($Install,$Runtime) + Remove-CcodVerifiedProductResidue -InstallRoot $Install -SelectedRuntimeId $Runtime -ExpectedEpoch ([uint64]11) + } $install $runtimeId + Assert-CcodEqual 'Removed' $result.result 'product cleanup returns exact terminal result' + Assert-CcodTrue (-not(Test-Path -LiteralPath $install)) 'complete uninstall removes the product root' + Assert-CcodEqual $outsideHash (Get-CcodTestFileSha256 $outside) 'sibling project is unchanged' + } finally {if(Test-Path $root){Remove-CcodTestOwnedTree -Path $root}} +} +$results+=Invoke-CcodTest 'bounded product residue cleanup accepts real initialized state and retained manifest files' { + $root=Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-product-state-'+[guid]::NewGuid().ToString('N')) + $install=Join-Path $root 'CodexControlOtherDevices' + $modules=@() + try { + $state=Join-Path $install 'state' + [IO.Directory]::CreateDirectory((Join-Path $state 'active-generation'))|Out-Null + $modules+=Import-Module (Join-Path $repositoryRoot 'src/persistence/modules/StateStore.psm1') -Force -PassThru -DisableNameChecking + $modules+=Import-Module (Join-Path $repositoryRoot 'src/persistence/modules/UiPreferences.psm1') -Force -PassThru -DisableNameChecking + $modules+=Import-Module (Join-Path $repositoryRoot 'src/persistence/modules/RuntimeManifest.psm1') -Force -PassThru -DisableNameChecking + $initialized=Initialize-CcodState -StateRoot $state -NodeCandidates @() -CandidateCompatibleOptIn $false + $initialized.settings.automationEnabled=$false + Write-CcodSettings -StateRoot $state -Settings $initialized.settings + Initialize-CcodUiPreference -StateRoot $state|Out-Null + $runtimeId='2.5.22-'+('a'*16)+'-'+('b'*32) + [IO.File]::WriteAllText((Join-Path $state 'lifecycle-epoch.json'),'{"schemaVersion":1,"epoch":11}') + [IO.File]::WriteAllText((Join-Path $state 'active-generation/00000000000000000001.json'),('{"schemaVersion":1,"generation":1,"activeRuntime":"'+$runtimeId+'","previousGeneration":0}')) + $retained=Join-Path $install 'runtime/pending';[IO.Directory]::CreateDirectory($retained)|Out-Null + [IO.File]::WriteAllText((Join-Path $retained 'payload.txt'),'verified retained product payload') + $manifest=New-CcodRuntimeManifest -RuntimeDirectory $retained -ProjectVersion '2.5.22' + $sealed=Join-Path $install ('runtime/'+$manifest.runtimeId);[IO.Directory]::Move($retained,$sealed) + [IO.File]::WriteAllText((Join-Path $sealed 'manifest.json'),($manifest|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + $result=&$module {param($Install,$Runtime)Remove-CcodVerifiedProductResidue -InstallRoot $Install -SelectedRuntimeId $Runtime -ExpectedEpoch ([uint64]11)} $install $runtimeId + Assert-CcodEqual 'Removed' $result.result 'actual producers are accepted without loosening unknown-file handling' + Assert-CcodTrue (-not(Test-Path -LiteralPath $install)) 'known state and verified retained product files are removed' + } finally {foreach($imported in $modules){Remove-Module $imported -Force -ErrorAction SilentlyContinue};if(Test-Path $root){Remove-CcodTestOwnedTree -Path $root}} +} + +foreach($mutation in @('UnknownFile','UnknownDirectory','DeviceKey','Reparse','Hardlink','Ads','OpenFile','EpochDrift')){ + $results+=Invoke-CcodTest "bounded product residue rejects $mutation without deleting any file" { + $root=Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-product-hostile-'+[guid]::NewGuid().ToString('N')) + $install=Join-Path $root 'CodexControlOtherDevices';$held=$null + try { + [IO.Directory]::CreateDirectory((Join-Path $install 'state/active-generation'))|Out-Null + [IO.Directory]::CreateDirectory((Join-Path $install 'runtime'))|Out-Null + $runtimeId='2.5.22-'+('a'*16)+'-'+('b'*32) + $epoch=Join-Path $install 'state/lifecycle-epoch.json' + [IO.File]::WriteAllText($epoch,'{"schemaVersion":1,"epoch":11}') + $pointer=Join-Path $install 'state/active-generation/00000000000000000001.json' + [IO.File]::WriteAllText($pointer,('{"schemaVersion":1,"generation":1,"activeRuntime":"'+$runtimeId+'","previousGeneration":0}')) + $outside=Join-Path $root 'outside.txt';[IO.File]::WriteAllText($outside,'outside user data') + $outsideHash=Get-CcodTestFileSha256 $outside + switch($mutation){ + 'UnknownFile' {[IO.File]::WriteAllText((Join-Path $install 'my-project.txt'),'user-owned')} + 'UnknownDirectory' {[IO.Directory]::CreateDirectory((Join-Path $install 'projects'))|Out-Null} + 'DeviceKey' {[IO.Directory]::CreateDirectory((Join-Path $install 'state/device-key'))|Out-Null;[IO.File]::WriteAllText((Join-Path $install 'state/device-key/sentinel.bin'),'synthetic protected data')} + 'Reparse' {New-Item -ItemType Junction -Path (Join-Path $install 'projects') -Target $root|Out-Null} + 'Hardlink' {Remove-Item $epoch;New-Item -ItemType HardLink -Path $epoch -Target $outside|Out-Null} + 'Ads' {Set-Content -LiteralPath $epoch -Stream 'untrusted' -Value 'ads' -NoNewline} + 'OpenFile' {$held=[IO.File]::Open($epoch,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read)} + 'EpochDrift' {[IO.File]::WriteAllText($epoch,'{"schemaVersion":1,"epoch":12}')} + } + Assert-CcodThrows {&$module {param($Install,$Runtime)Remove-CcodVerifiedProductResidue -InstallRoot $Install -SelectedRuntimeId $Runtime -ExpectedEpoch ([uint64]11)} $install $runtimeId|Out-Null} 'CCOD_PRODUCT_RESIDUE_INVALID' + Assert-CcodTrue (Test-Path $pointer) "$mutation leaves the product selector untouched" + Assert-CcodTrue (Test-Path $epoch) "$mutation leaves the epoch untouched" + Assert-CcodEqual $outsideHash (Get-CcodTestFileSha256 $outside) "$mutation leaves outside data untouched" + } finally {if($null-ne$held){$held.Dispose()};if(Test-Path (Join-Path $install 'projects') -PathType Container){$item=Get-Item (Join-Path $install 'projects') -Force;if($item.Attributes-band[IO.FileAttributes]::ReparsePoint){[IO.Directory]::Delete($item.FullName)}};if(Test-Path $root){Remove-CcodTestOwnedTree -Path $root}} + } +} + +$results+=Invoke-CcodTest 'bounded cleanup accepts the actual installer state-plane writer output' { + $root=Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-product-install-plane-'+[guid]::NewGuid().ToString('N')) + $install=Join-Path $root 'CodexControlOtherDevices';$fileTransaction=$null;$installModule=$null;$fileModule=$null + try { + [IO.Directory]::CreateDirectory((Join-Path $install 'state/active-generation'))|Out-Null + [IO.Directory]::CreateDirectory((Join-Path $install 'runtime'))|Out-Null + $runtimeId='2.5.22-'+('a'*16)+'-'+('b'*32) + $fileModule=Import-Module (Join-Path $repositoryRoot 'src/persistence/modules/InstallFileTransaction.psm1') -Force -PassThru -DisableNameChecking + $installModule=Import-Module (Join-Path $repositoryRoot 'src/persistence/modules/InstallLifecycle.psm1') -Force -PassThru -DisableNameChecking + $fileTransaction=&$installModule {param($Root)Open-CcodLifecycleInstallStateTransaction -InstallRoot $Root} $install + &$installModule {param($Root,$Runtime,$Transaction)Initialize-CcodInstallStatePlanes -InstallRoot $Root -RuntimeId $Runtime -FileTransaction $Transaction -NodeCandidates @() -CandidateCompatibleOptIn $false} $install $runtimeId $fileTransaction + Close-CcodInstallFileTransaction -Transaction $fileTransaction -Disposition Ready|Out-Null;$fileTransaction=$null + &$installModule {param($Root)$settings=Read-CcodSettings -StateRoot (Join-Path $Root 'state');$settings.automationEnabled=$false;Write-CcodSettings -StateRoot (Join-Path $Root 'state') -Settings $settings} $install + [IO.File]::WriteAllText((Join-Path $install 'state/lifecycle-epoch.json'),'{"schemaVersion":1,"epoch":11}') + [IO.File]::WriteAllText((Join-Path $install 'state/active-generation/00000000000000000001.json'),('{"schemaVersion":1,"generation":1,"activeRuntime":"'+$runtimeId+'","previousGeneration":0}')) + $result=&$module {param($Install,$Runtime)Remove-CcodVerifiedProductResidue -InstallRoot $Install -SelectedRuntimeId $Runtime -ExpectedEpoch ([uint64]11)} $install $runtimeId + Assert-CcodEqual 'Removed' $result.result 'actual installer initialization records are recognized' + } finally {if($null-ne$fileTransaction){Close-CcodInstallFileTransaction -Transaction $fileTransaction -Disposition Failed|Out-Null};foreach($m in @($installModule,$fileModule)){if($null-ne$m){Remove-Module $m -Force -ErrorAction SilentlyContinue}};if(Test-Path $root){Remove-CcodTestOwnedTree -Path $root}} +} + +function Invoke-CcodFullInstallerResidueCase { + param([switch]$LegacyUpgrade) + $root=Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-product-full-'+[guid]::NewGuid().ToString('N').Substring(0,8)) + $install=Join-Path $root 'CodexControlOtherDevices';$source=Join-Path $root 'source';$nodeRoot=Join-Path $root 'node' + try { + Import-Module (Join-Path $repositoryRoot 'src/persistence/modules/InstallLifecycle.psm1') -Force -DisableNameChecking + Import-Module (Join-Path $repositoryRoot 'src/persistence/modules/StateStore.psm1') -Force -DisableNameChecking + Import-Module (Join-Path $repositoryRoot 'src/persistence/modules/UiPreferences.psm1') -Force -DisableNameChecking + Import-Module (Join-Path $repositoryRoot 'src/persistence/modules/RuntimeManifest.psm1') -Force -DisableNameChecking + Import-Module (Join-Path $repositoryRoot 'src/persistence/modules/LifecycleEpoch.psm1') -Force -DisableNameChecking + Import-Module (Join-Path $repositoryRoot 'src/persistence/modules/InstallFileTransaction.psm1') -DisableNameChecking + New-CcodLifecycleSourceFixture -Root $source -Version '2.5.22'|Out-Null + $fake=New-CcodLifecycleFake -NodePath (New-CcodLifecycleFakeNode -Root $nodeRoot) + $productState=@{FailWrites=$false;Registration=$null;ReadyEvidence=$null;RetainedError=$null;Writes=0;ShortcutWrites=0;ProductOnlyObserved=$false;Shortcuts=@{}} + Set-CcodLifecycleDefaultProductRegistrationFixture -Fake $fake -ProductState $productState + [void]$fake.Adapters.Remove('WriteLog') + if($LegacyUpgrade){ + $legacySource=Join-Path $root 'legacy-source' + New-CcodLifecycleSourceFixture -Root $legacySource -Version '2.5.21'|Out-Null + $legacy=New-CcodLifecycleV2521LegacyInstallFixture -InstallRoot $install -SourceRoot $legacySource + } + $receipt=Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -SealedPackageSha256 ('c'*64) -Adapters $fake.Adapters + Assert-CcodTrue $receipt.ProductRegistrationVerified 'real installer wrote a verified product state' + $active=Read-CcodActiveRuntime -InstallRoot $install + $runtime=Join-Path $install ('runtime/'+$active.activeRuntime) + $manifestHash=Get-CcodTestFileSha256 (Join-Path $runtime 'manifest.json') + &$module {param($Root,$Runtime,$Id,$Hash)Remove-CcodVerifiedGenerationTree -InstallRoot $Root -RuntimeRoot $Runtime -RuntimeId $Id -ExpectedManifestSha256 $Hash} $install $runtime $active.activeRuntime $manifestHash|Out-Null + $lifecycle=Get-Module InstallLifecycle + &$lifecycle {param($Root)$settings=Read-CcodSettings -StateRoot (Join-Path $Root 'state');$settings.automationEnabled=$false;Write-CcodSettings -StateRoot (Join-Path $Root 'state') -Settings $settings} $install + [IO.File]::WriteAllText((Join-Path $install 'state/lifecycle-epoch.json'),'{"schemaVersion":1,"epoch":11}') + $result=&$module {param($Root,$Id)Remove-CcodVerifiedProductResidue -InstallRoot $Root -SelectedRuntimeId $Id -ExpectedEpoch ([uint64]11)} $install $active.activeRuntime + Assert-CcodEqual 'Removed' $result.result 'full installer output is recognized without a fabricated state layout' + } finally {if(Test-Path $root){Remove-CcodTestOwnedTree -Path $root}} +} + +$results+=Invoke-CcodTest 'bounded cleanup consumes the full actual installer output with isolated machine adapters' {Invoke-CcodFullInstallerResidueCase} +$results+=Invoke-CcodTest 'bounded cleanup consumes the historical v2521 upgrade output without retaining product files' {Invoke-CcodFullInstallerResidueCase -LegacyUpgrade} + +$results+=Invoke-CcodTest 'an existing cleanup journal cannot authorize an unknown user file' { + $root=Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-product-journal-forgery-'+[guid]::NewGuid().ToString('N')) + $install=Join-Path $root 'CodexControlOtherDevices';$native=$null + try { + [IO.Directory]::CreateDirectory((Join-Path $install 'state/active-generation'))|Out-Null + $userFile=Join-Path $install 'my-project.txt';[IO.File]::WriteAllText($userFile,'user project must survive') + $originalHash=Get-CcodTestFileSha256 $userFile + $runtimeId='2.5.22-'+('a'*16)+'-'+('b'*32);$id='11111111-2222-3333-4444-555555555555' + [IO.File]::WriteAllText((Join-Path $install 'state/lifecycle-epoch.json'),'{"schemaVersion":1,"epoch":11}') + [IO.File]::WriteAllText((Join-Path $install 'state/active-generation/00000000000000000001.json'),('{"schemaVersion":1,"generation":1,"activeRuntime":"'+$runtimeId+'","previousGeneration":0}')) + $directory=Join-Path $root ('CodexRemote-fix-uninstall/'+$id);[IO.Directory]::CreateDirectory($directory)|Out-Null + $native=&$module {param([string]$Root)Initialize-CcodGenerationReclamationRuntime;Invoke-CcodGenerationReclamationStatic -Name OpenResidue -Arguments @($Root)} $install + $identity=&$module {param($Native)Invoke-CcodGenerationReclamationMethod -Runtime $Native -Name RootIdentity -Arguments @()} $native + $snapshot=&$module {param($Native)Invoke-CcodGenerationReclamationMethod -Runtime $Native -Name Snapshot -Arguments @()} $native + $evidence=@($snapshot.Files|Where-Object {$_.Path.StartsWith('state/')}|ForEach-Object {[pscustomobject][ordered]@{path=$_.Path;text=(&$module {param($Native,[string]$Path)Invoke-CcodGenerationReclamationMethod -Runtime $Native -Name ReadFileText -Arguments @($Path)} $native $_.Path)}}) + $native.Dispose();$native=$null + $plan=[pscustomobject][ordered]@{schemaVersion=1;transactionId=$id;installRoot=$install;runtimeId=$runtimeId;epoch=11;rootIdentity=$identity;files=@($snapshot.Files|ForEach-Object {[pscustomobject][ordered]@{path=$_.Path;length=$_.Length;sha256=$_.Sha256}});directories=@($snapshot.Directories);evidence=$evidence} + [IO.File]::WriteAllText((Join-Path $directory 'product-residue-plan.json'),($plan|ConvertTo-Json -Depth 8 -Compress)) + Assert-CcodThrows {&$module {param($Root,$Runtime,$Directory,$Id)Remove-CcodVerifiedProductResidue -InstallRoot $Root -SelectedRuntimeId $Runtime -ExpectedEpoch ([uint64]11) -TransactionDirectory $Directory -TransactionId $Id} $install $runtimeId $directory $id|Out-Null} 'CCOD_PRODUCT_RESIDUE_INVALID' + Assert-CcodEqual $originalHash (Get-CcodTestFileSha256 $userFile) 'forged journal cannot expand the approved deletion inventory' + } finally {if($null-ne$native){$native.Dispose()};if(Test-Path $root){Remove-CcodTestOwnedTree -Path $root}} +} + +function Invoke-CcodResiduePartialRetryCase { + param([ValidateSet('None','AddedFile','ReplacementRoot','JournalMutation')][string]$Attack='None') + $root=Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-product-partial-'+[guid]::NewGuid().ToString('N')) + $install=Join-Path $root 'CodexControlOtherDevices' + try { + [IO.Directory]::CreateDirectory((Join-Path $install 'state/active-generation'))|Out-Null + [IO.Directory]::CreateDirectory((Join-Path $install 'runtime'))|Out-Null + $runtimeId='2.5.22-'+('a'*16)+'-'+('b'*32);$id='11111111-2222-3333-4444-555555555555' + [IO.File]::WriteAllText((Join-Path $install 'state/lifecycle-epoch.json'),'{"schemaVersion":1,"epoch":11}') + [IO.File]::WriteAllText((Join-Path $install 'state/active-generation/00000000000000000001.json'),('{"schemaVersion":1,"generation":1,"activeRuntime":"'+$runtimeId+'","previousGeneration":0}')) + $directory=Join-Path $root ('CodexRemote-fix-uninstall/'+$id);[IO.Directory]::CreateDirectory($directory)|Out-Null + &$module {$script:CcodProductResidueFailCommitAtForTest=3} + Assert-CcodThrows {&$module {param($Root,$Runtime,$Directory,$Id)Remove-CcodVerifiedProductResidue -InstallRoot $Root -SelectedRuntimeId $Runtime -ExpectedEpoch ([uint64]11) -TransactionDirectory $Directory -TransactionId $Id} $install $runtimeId $directory $id|Out-Null} 'CCOD_PRODUCT_RESIDUE_INVALID' + Assert-CcodTrue (Test-Path $install) 'partial commit retains the same native root' + Assert-CcodTrue (-not(Test-Path (Join-Path $install 'state/lifecycle-epoch.json'))) 'failure happens after native files were actually deleted' + Assert-CcodTrue (Test-Path (Join-Path $directory 'product-residue-plan.json')) 'write-ahead plan survives actual partial deletion' + &$module {$script:CcodProductResidueFailCommitAtForTest=-1} + if($Attack-cne'None'){ + switch($Attack){ + 'AddedFile' {[IO.File]::WriteAllText((Join-Path $install 'my-project.txt'),'added after partial cleanup')} + 'ReplacementRoot' {$parked=Join-Path $root 'original-product-root';[IO.Directory]::Move($install,$parked);[IO.Directory]::CreateDirectory($install)|Out-Null} + 'JournalMutation' {$planPath=Join-Path $directory 'product-residue-plan.json';$plan=[IO.File]::ReadAllText($planPath)|ConvertFrom-Json;$plan.runtimeId='2.5.22-'+('c'*16)+'-'+('d'*32);[IO.File]::WriteAllText($planPath,($plan|ConvertTo-Json -Depth 12 -Compress))} + } + Assert-CcodThrows {&$module {param($Root,$Runtime,$Directory,$Id)Remove-CcodVerifiedProductResidue -InstallRoot $Root -SelectedRuntimeId $Runtime -ExpectedEpoch ([uint64]11) -TransactionDirectory $Directory -TransactionId $Id} $install $runtimeId $directory $id|Out-Null} 'CCOD_PRODUCT_RESIDUE_INVALID' + Assert-CcodTrue (Test-Path $install) 'changed retry target is not removed' + if($Attack-ceq'AddedFile'){Assert-CcodEqual 'added after partial cleanup' ([IO.File]::ReadAllText((Join-Path $install 'my-project.txt'))) 'new user file is untouched'} + return + } + $result=&$module {param($Root,$Runtime,$Directory,$Id)Remove-CcodVerifiedProductResidue -InstallRoot $Root -SelectedRuntimeId $Runtime -ExpectedEpoch ([uint64]11) -TransactionDirectory $Directory -TransactionId $Id} $install $runtimeId $directory $id + Assert-CcodEqual 'Removed' $result.result 'same-root retry validates the saved inventory and completes' + Assert-CcodTrue (-not(Test-Path $install)) 'retried cleanup removes the exact residual root' + } finally {&$module {$script:CcodProductResidueFailCommitAtForTest=-1};if(Test-Path $root){Remove-CcodTestOwnedTree -Path $root}} +} + +$results+=Invoke-CcodTest 'bounded cleanup resumes the exact residual tree after a native partial commit failure' {Invoke-CcodResiduePartialRetryCase} +foreach($retryAttack in @('AddedFile','ReplacementRoot','JournalMutation')){ + $results+=Invoke-CcodTest "bounded cleanup refuses $retryAttack on a real partial-commit retry" {Invoke-CcodResiduePartialRetryCase -Attack $retryAttack} +} + +$results+=Invoke-CcodTest 'bounded cleanup accepts real epoch initialization and supervisor log writers' { + $root=Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-product-observation-'+[guid]::NewGuid().ToString('N')) + $install=Join-Path $root 'CodexControlOtherDevices' + try { + [IO.Directory]::CreateDirectory((Join-Path $install 'state/active-generation'))|Out-Null + [IO.Directory]::CreateDirectory((Join-Path $install 'state/workers'))|Out-Null + $runtimeId='2.5.22-'+('a'*16)+'-'+('b'*32) + [IO.File]::WriteAllText((Join-Path $install 'state/active-generation/00000000000000000001.json'),('{"schemaVersion":1,"generation":1,"activeRuntime":"'+$runtimeId+'","previousGeneration":0}')) + $epochModule=Import-Module (Join-Path $repositoryRoot 'src/persistence/modules/LifecycleEpoch.psm1') -Force -PassThru -DisableNameChecking + &$epochModule {param($Root)Write-CcodLifecycleEpoch -InstallRoot $Root -Epoch ([uint64]11)} $install + . (Join-Path $repositoryRoot 'src/persistence/Supervisor.ps1') -ReadyToken ('a'*64) + $io=Import-Module (Join-Path $repositoryRoot 'src/persistence/modules/PersistenceIO.psm1') -PassThru -DisableNameChecking + $hostState=[pscustomobject]@{RuntimeCleanupCodes=[Collections.Generic.List[string]]::new()} + $log=Join-Path $install 'logs/supervisor.log' + $adapters=@{GetUtcNow={[DateTime]::UtcNow};WriteLog={param($Record)&$io {param($Path,$Value)Write-CcodRotatingLog -Path $Path -Message ($Value|ConvertTo-Json -Depth 8 -Compress)} $log $Record}.GetNewClosure()} + Write-CcodSupervisorUiFailure -HostState $hostState -Adapters $adapters -Stage LanguageChange -Code 'CCOD_LANGUAGE_CHANGE_FAILED' + $action=[pscustomobject]@{Command='ShowAbout';Revision=[uint64]1} + Assert-CcodTrue (Write-CcodSupervisorTrayActionTerminal -HostState $hostState -Adapters $adapters -Action $action -Status Completed -ErrorCode $null) 'actual Supervisor log producer succeeded' + Assert-CcodEqual 0 $hostState.RuntimeCleanupCodes.Count 'log writing itself succeeded' + $result=&$module {param($Root,$Id)Remove-CcodVerifiedProductResidue -InstallRoot $Root -SelectedRuntimeId $Id -ExpectedEpoch ([uint64]11)} $install $runtimeId + Assert-CcodEqual 'Removed' $result.result 'product diagnostics and epoch initializer are recognized' + } finally {if(Test-Path $root){Remove-CcodTestOwnedTree -Path $root}} +} + +$results+=Invoke-CcodTest 'protected user data is rejected before opening or hashing its contents' { + $root=Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-product-protected-'+[guid]::NewGuid().ToString('N')) + $install=Join-Path $root 'CodexControlOtherDevices';$held=$null + try { + [IO.Directory]::CreateDirectory((Join-Path $install 'state/device-key'))|Out-Null + $file=Join-Path $install 'state/device-key/sentinel.bin';[IO.File]::WriteAllText($file,'synthetic protected sentinel') + $held=[IO.File]::Open($file,[IO.FileMode]::Open,[IO.FileAccess]::ReadWrite,[IO.FileShare]::None) + $failure=$null + try {&$module {param($Root)Remove-CcodVerifiedProductResidue -InstallRoot $Root -SelectedRuntimeId ('2.5.22-'+('a'*16)+'-'+('b'*32)) -ExpectedEpoch ([uint64]11)} $install|Out-Null}catch{$failure=$_} + Assert-CcodTrue ($null-ne$failure-and$failure.Exception.Message.Contains('protected user data')) 'policy rejects the directory name before the exclusive file can cause a read error' + } finally {if($null-ne$held){$held.Dispose()};if(Test-Path $root){Remove-CcodTestOwnedTree -Path $root}} +} + +$results+=Invoke-CcodTest 'bounded cleanup accepts actual terminal lifecycle receipts and transaction archive output' { + $root=Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-product-terminal-'+[guid]::NewGuid().ToString('N')) + $install=Join-Path $root 'CodexControlOtherDevices';$state=Join-Path $install 'state' + try { + [IO.Directory]::CreateDirectory((Join-Path $state 'active-generation'))|Out-Null + $runtimeId='2.5.22-'+('a'*16)+'-'+('b'*32);$id='11111111-2222-3333-4444-555555555555' + [IO.File]::WriteAllText((Join-Path $state 'lifecycle-epoch.json'),'{"schemaVersion":1,"epoch":11}') + [IO.File]::WriteAllText((Join-Path $state 'active-generation/00000000000000000001.json'),('{"schemaVersion":1,"generation":1,"activeRuntime":"'+$runtimeId+'","previousGeneration":0}')) + $lifecycle=Import-Module (Join-Path $repositoryRoot 'src/persistence/modules/LifecycleTransaction.psm1') -Force -PassThru -DisableNameChecking + $logon=Import-Module (Join-Path $repositoryRoot 'src/persistence/modules/TrustedLogonIdentity.psm1') -Force -PassThru -DisableNameChecking + $inbox=Import-Module (Join-Path $repositoryRoot 'src/persistence/modules/LifecycleRequest.psm1') -Force -PassThru -DisableNameChecking + $journal=Import-Module (Join-Path $repositoryRoot 'src/persistence/modules/TransitionJournal.psm1') -Force -PassThru -DisableNameChecking + $io=Import-Module (Join-Path $repositoryRoot 'src/persistence/modules/PersistenceIO.psm1') -PassThru -DisableNameChecking + $identity=&$logon {Get-CcodTrustedLogonIdentity} + $now=[DateTime]::UtcNow.ToString('o',[Globalization.CultureInfo]::InvariantCulture) + $request=&$lifecycle {param($Root,$Id,$Runtime,$Identity,$Now) + $owner=[pscustomobject]@{pid=[int]$PID;creationTimeUtc=[Diagnostics.Process]::GetCurrentProcess().StartTime.ToUniversalTime().ToString('o')} + $value=New-CcodLifecycleRequest -Kind SafeExit -Origin Tray -RuntimeId $Runtime -RuntimeGeneration 1 -LeaseEpoch 11 -OwnerIdentity $owner -LogonIdentity $Identity -NowUtc $Now -TransactionId $Id + $value.phase='CancelledBeforeClose' + Write-CcodLifecycleRequest -StateRoot $Root -Request $value + Complete-CcodLifecycleRequest -StateRoot $Root -Request $value + $value + } $state $id $runtimeId $identity $now + &$logon {param($Root,$Identity,$Runtime,$Id,$Now)Write-CcodSafeExitIntent -StateRoot $Root -LogonIdentity $Identity -RuntimeId $Runtime -RecoveryTransactionId $Id -NowUtc $Now} $state $identity $runtimeId $id $now|Out-Null + &$inbox {param($Root,$Id)Write-CcodLifecycleSubmissionReceipt -StateRoot $Root -SubmissionId $Id -Accepted $true -TransactionId $Id -ErrorCode $null} $state $id|Out-Null + $transition=[pscustomobject]@{transactionId=$id;stage='Closed';sourcePid=$null;sourceCreationTimeUtc=$null;specialPid=$null;specialCreationTimeUtc=$null;recoveryPid=$null;recoveryCreationTimeUtc=$null;appAsarSha256=('a'*64);runtimeId=$runtimeId} + $archive=&$journal {param($Transition,$Now)New-CcodArchiveRecord -Transition $Transition -Disposition Closed -CompletedAtUtc $Now} $transition $now + $completion=&$journal {param($Id,$Now)New-CcodCompletionReceipt -TransactionId $Id -Disposition Closed -TerminalStage Closed -CompletedAtUtc $Now -State Archived -ArchiveErrorId $null} $id $now + &$io {param($Root,$Archive,$Completion)Write-CcodRotatingLog -Path (Join-Path $Root 'logs/transactions.log') -Message ($Archive|ConvertTo-Json -Depth 8 -Compress);Write-CcodAtomicJson -Path (Join-Path $Root 'state/transaction-completion.receipt.json') -Value $Completion -Compress} $install $archive $completion + $result=&$module {param($Root,$Runtime)Remove-CcodVerifiedProductResidue -InstallRoot $Root -SelectedRuntimeId $Runtime -ExpectedEpoch ([uint64]11)} $install $runtimeId + Assert-CcodEqual 'Removed' $result.result 'actual completed lifecycle evidence is disposable product state' + } finally {if(Test-Path $root){Remove-CcodTestOwnedTree -Path $root}} +} + +$results+=Invoke-CcodTest 'bounded cleanup accepts real session and legacy install diagnostic writers' { + $root=Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-product-session-log-'+[guid]::NewGuid().ToString('N')) + $install=Join-Path $root 'CodexControlOtherDevices' + try { + [IO.Directory]::CreateDirectory((Join-Path $install 'state/active-generation'))|Out-Null + $runtimeId='2.5.22-'+('a'*16)+'-'+('b'*32);$id='11111111-2222-3333-4444-555555555555' + [IO.File]::WriteAllText((Join-Path $install 'state/lifecycle-epoch.json'),'{"schemaVersion":1,"epoch":11}') + [IO.File]::WriteAllText((Join-Path $install 'state/active-generation/00000000000000000001.json'),('{"schemaVersion":1,"generation":1,"activeRuntime":"'+$runtimeId+'","previousGeneration":0}')) + . (Join-Path $repositoryRoot 'src/persistence/SessionController.ps1') -Action Inspect + $engine=Import-Module (Join-Path $repositoryRoot 'src/persistence/modules/SessionEngine.psm1') -PassThru -DisableNameChecking + $io=Import-Module (Join-Path $repositoryRoot 'src/persistence/modules/PersistenceIO.psm1') -PassThru -DisableNameChecking + $paths=[pscustomobject]@{SessionLogPath=(Join-Path $install 'logs/session.log')} + $adapter=@{UtcNow={[DateTime]::UtcNow};WriteLog={param($Path,$Message)&$io {param($P,$M)Write-CcodRotatingLog -Path $P -Message $M} $Path $Message}.GetNewClosure()} + $result=[pscustomobject]@{action='Inspect';transactionId=$id;stage='Inspect';error=[pscustomobject]@{code='CCOD_SESSION_FAILED'};logFile=$null} + Assert-CcodTrue (Write-CcodControllerDiagnostic -Result $result -Request $null -Paths $paths -Adapter $adapter) 'actual controller diagnostic is written' + &$engine {param($Result,$Id,$Paths,$Adapter)Write-CcodSessionDiagnostic -Result $Result -Action Inspect -TransactionId $Id -Stage Inspect -Code CCOD_SESSION_FAILED -Record $null -Paths $Paths -Adapter $Adapter} $result $id $paths $adapter + $lifecycle=Import-Module (Join-Path $repositoryRoot 'src/persistence/modules/InstallLifecycle.psm1') -Force -PassThru -DisableNameChecking + &$lifecycle {param($Root)$adapter=Get-CcodLifecycleAdapters;Write-CcodLifecycleLog -InstallRoot $Root -Adapters $adapter -Stage Install -Code CCOD_INSTALL_COMPLETED -Outcome Installed -ThrowOnFailure} $install + $result=&$module {param($Root,$Id)Remove-CcodVerifiedProductResidue -InstallRoot $Root -SelectedRuntimeId $Id -ExpectedEpoch ([uint64]11)} $install $runtimeId + Assert-CcodEqual 'Removed' $result.result 'actual session and legacy install diagnostics are recognized' + } finally {if(Test-Path $root){Remove-CcodTestOwnedTree -Path $root}} +} + +$results+=Invoke-CcodTest 'bounded cleanup accepts the exact abandoned-lease warning produced by Supervisor' { + $root=Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-product-warning-'+[guid]::NewGuid().ToString('N')) + $install=Join-Path $root 'CodexControlOtherDevices' + try { + [IO.Directory]::CreateDirectory((Join-Path $install 'state/active-generation'))|Out-Null + $runtimeId='2.5.22-'+('a'*16)+'-'+('b'*32) + [IO.File]::WriteAllText((Join-Path $install 'state/lifecycle-epoch.json'),'{"schemaVersion":1,"epoch":11}') + [IO.File]::WriteAllText((Join-Path $install 'state/active-generation/00000000000000000001.json'),('{"schemaVersion":1,"generation":1,"activeRuntime":"'+$runtimeId+'","previousGeneration":0}')) + $supervisorPath=Join-Path $repositoryRoot 'src/persistence/Supervisor.ps1' + . $supervisorPath -ReadyToken ('a'*64) + $tokens=$null;$errors=$null;$ast=[Management.Automation.Language.Parser]::ParseFile($supervisorPath,[ref]$tokens,[ref]$errors) + Assert-CcodEqual 0 @($errors).Count 'real supervisor source parses' + $writer=@($ast.FindAll({param($n)$n-is[Management.Automation.Language.AssignmentStatementAst]-and$n.Left.Extent.Text-ceq'$record'-and$n.Right.Extent.Text.Contains("code='CCOD_SUPERVISOR_LEASE_ABANDONED'")},$true)) + Assert-CcodEqual 1 $writer.Count 'one exact production warning producer' + $adapter=@{GetUtcNow={[DateTime]::UtcNow}} + $record=&([scriptblock]::Create($writer[0].Right.Extent.Text)) + Assert-CcodEqual 'Warning' $record.outcome 'production outcome is Warning' + $io=Import-Module (Join-Path $repositoryRoot 'src/persistence/modules/PersistenceIO.psm1') -PassThru -DisableNameChecking + foreach($changedField in @('stage','code')){ + $bad=$record|ConvertTo-Json -Compress|ConvertFrom-Json + if($changedField-ceq'stage'){$bad.stage='OtherStage'}else{$bad.code='CCOD_OTHER_WARNING'} + &$io {param($Root,$Value)Write-CcodRotatingLog -Path (Join-Path $Root 'logs/supervisor.log') -Message ($Value|ConvertTo-Json -Compress)} $install $bad + Assert-CcodThrows {&$module {param($Root,$Runtime)Remove-CcodVerifiedProductResidue -InstallRoot $Root -SelectedRuntimeId $Runtime -ExpectedEpoch ([uint64]11)} $install $runtimeId|Out-Null} 'CCOD_PRODUCT_RESIDUE_INVALID' + Assert-CcodTrue (Test-Path (Join-Path $install 'state/lifecycle-epoch.json')) 'unsupported Warning remains rejected before deletion' + [IO.File]::Delete((Join-Path $install 'logs/supervisor.log')) + } + &$io {param($Root,$Value)Write-CcodRotatingLog -Path (Join-Path $Root 'logs/supervisor.log') -Message ($Value|ConvertTo-Json -Compress)} $install $record + $result=&$module {param($Root,$Runtime)Remove-CcodVerifiedProductResidue -InstallRoot $Root -SelectedRuntimeId $Runtime -ExpectedEpoch ([uint64]11)} $install $runtimeId + Assert-CcodEqual 'Removed' $result.result 'real abandoned-lease diagnostic does not block uninstall' + } finally {if(Test-Path $root){Remove-CcodTestOwnedTree -Path $root}} +} + +$results|ForEach-Object {"PASS $($_.Name)"} +Write-Output "Product residue cleanup self-tests passed: $($results.Count)" diff --git a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 index 2fc3b87..cc3afaf 100644 --- a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 +++ b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 @@ -1,39 +1,209 @@ +[CmdletBinding()] +param([string]$FocusedCase) + $ErrorActionPreference = 'Stop' . (Join-Path $PSScriptRoot 'TestSupport.ps1') +Restore-CcodTestDesktopModulePath $repositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent $defenderPath = Join-Path $repositoryRoot 'tools\Test-ReleaseDefender.ps1' +$releaseDefenderModulePath = Join-Path $repositoryRoot 'tools\ReleaseDefender.psm1' +$assetContractPath = Join-Path $repositoryRoot 'tools\ReleaseAssetContract.psm1' -function New-CcodReleaseFixture { - $root = Join-Path $env:TEMP ('ccod-release-workflow-' + [guid]::NewGuid().ToString('N')) - $null = [IO.Directory]::CreateDirectory($root) - $installer = Join-Path $root 'CodexRemote-fix-2.5.0-setup.exe' - [IO.File]::WriteAllBytes($installer, [byte[]](9,8,7,6,5,4,3,2,1)) - $checksum = "$installer.sha256.txt" - $installerHash = Get-CcodTestFileSha256 -Path $installer - [IO.File]::WriteAllText($checksum, ("{0} *{1}`r`n" -f $installerHash, [IO.Path]::GetFileName($installer)), [Text.UTF8Encoding]::new($false)) - $trayHost = Join-Path $root 'CodexRemote-fix-2.5.0-trayhost-provenance.json' - [IO.File]::WriteAllText($trayHost, ('{"schemaVersion":1,"product":"CodexRemote-fix","version":"2.5.0","gitCommit":"' + ('a' * 40) + '","buildTimestampUtc":"2026-08-24T00:00:00.0000000Z"}'), [Text.UTF8Encoding]::new($false)) - $manifest = Join-Path $root 'CodexRemote-fix-2.5.0-setup-release-manifest.json' - $assets = @( - [ordered]@{ name = [IO.Path]::GetFileName($installer); sha256 = $installerHash }, - [ordered]@{ name = [IO.Path]::GetFileName($checksum); sha256 = Get-CcodTestFileSha256 -Path $checksum }, - [ordered]@{ name = [IO.Path]::GetFileName($trayHost); sha256 = Get-CcodTestFileSha256 -Path $trayHost } +foreach ($selector in @('CCOD_TASK5_RED_CASE','CCOD_TASK6_RED_CASE')) { + if (-not [string]::IsNullOrEmpty([Environment]::GetEnvironmentVariable($selector, 'Process'))) { + throw 'CCOD_RELEASE_TEST_SELECTION_INVALID: inherited selectors are forbidden; use -FocusedCase explicitly.' + } +} +$script:CcodReleaseFocus = $FocusedCase +$script:CcodReleaseExecuted = 0 +$script:CcodReleaseSkipped = 0 +if ($PSBoundParameters.ContainsKey('FocusedCase')) { + $tokens = $null; $parseErrors = $null + $ast = [Management.Automation.Language.Parser]::ParseFile($PSCommandPath, [ref]$tokens, [ref]$parseErrors) + if (@($parseErrors).Count -ne 0) { throw 'CCOD_RELEASE_TEST_SELECTION_INVALID: test source does not parse.' } + $known = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + foreach ($command in @($ast.FindAll({ param($node) $node -is [Management.Automation.Language.CommandAst] -and $node.GetCommandName() -cin @('Invoke-CcodTask5Test','Invoke-CcodTask6Test') }, $true))) { + if ($command.CommandElements[1] -is [Management.Automation.Language.StringConstantExpressionAst]) { + $group = if ($command.GetCommandName() -ceq 'Invoke-CcodTask5Test') { 'Task5' } else { 'Task6' } + [void]$known.Add($group + ':' + $command.CommandElements[1].Value) + } + } + if ([string]::IsNullOrWhiteSpace($FocusedCase) -or -not $known.Contains($FocusedCase)) { + throw 'CCOD_RELEASE_TEST_SELECTION_INVALID: unknown explicit case.' + } +} +$script:CcodReleaseBaseInvokeTest = ${function:Invoke-CcodTest} +function Invoke-CcodReleaseSelectedTest([string]$Id,[string]$Name,[scriptblock]$Action) { + if ($script:CcodReleaseFocus -and $script:CcodReleaseFocus -cne $Id) { $script:CcodReleaseSkipped++; return } + $script:CcodReleaseExecuted++ + & $script:CcodReleaseBaseInvokeTest $Name $Action +} +function Invoke-CcodTest([string]$Name,[scriptblock]$Action) { Invoke-CcodReleaseSelectedTest '' $Name $Action } +function Invoke-CcodTask5Test([string]$Id,[string]$Name,[scriptblock]$Action) { Invoke-CcodReleaseSelectedTest ('Task5:' + $Id) $Name $Action } +function Invoke-CcodTask6Test([string]$Id,[string]$Name,[scriptblock]$Action) { Invoke-CcodReleaseSelectedTest ('Task6:' + $Id) $Name $Action } + +function ConvertFrom-CcodWorkflowScalar { + param([Parameter(Mandatory)][AllowEmptyString()][string]$Value) + $scalar = $Value.Trim() + if ($scalar.Length -ge 2 -and (($scalar[0] -ceq '"' -and $scalar[$scalar.Length - 1] -ceq '"') -or ($scalar[0] -ceq "'" -and $scalar[$scalar.Length - 1] -ceq "'"))) { + return $scalar.Substring(1, $scalar.Length - 2) + } + return $scalar +} + +function Get-CcodWorkflowStructure { + param([Parameter(Mandatory)][string]$Path) + $jobs = [Collections.Generic.List[object]]::new() + $currentJob = $null + $currentStep = $null + $inJobs = $false + $inSteps = $false + $runBlock = $false + $runLines = [Collections.Generic.List[string]]::new() + $lines = [IO.File]::ReadAllLines($Path, [Text.UTF8Encoding]::new($false)) + for ($index = 0; $index -lt $lines.Length; $index++) { + $line = $lines[$index] + if ($runBlock) { + if ([string]::IsNullOrWhiteSpace($line)) { + $runLines.Add('') + continue + } + if ($line -cmatch '^ {10,}') { + $runLines.Add($line.Substring(10)) + continue + } + $currentStep.Run = $runLines -join "`n" + $runLines.Clear() + $runBlock = $false + } + if (-not $inJobs) { + if ($line -cmatch '^jobs:\s*(?:#.*)?$') { $inJobs = $true } + continue + } + if ($line -cmatch '^ (?[A-Za-z0-9_-]+):\s*(?:#.*)?$') { + $currentJob = [pscustomobject]@{ Name = $Matches.name; Steps = [Collections.Generic.List[object]]::new() } + $jobs.Add($currentJob) + $currentStep = $null + $inSteps = $false + continue + } + if ($null -eq $currentJob) { continue } + if ($line -cmatch '^ steps:\s*(?:#.*)?$') { + $inSteps = $true + continue + } + if (-not $inSteps) { continue } + if ($line -cmatch '^ -(?:\s+(?[A-Za-z][A-Za-z0-9_-]*):\s*(?.*))?\s*$') { + $currentStep = [pscustomobject]@{ Name = ''; Shell = ''; Run = ''; If = ''; ContinueOnError = ''; Uses = '' } + $currentJob.Steps.Add($currentStep) + $key = [string]$Matches.key + $value = [string]$Matches.value + if ($key -in @('name', 'shell', 'run', 'if', 'continue-on-error', 'uses')) { + if ($key -ceq 'run' -and $value.Trim() -in @('|', '|-', '|+')) { + $runBlock = $true + $runLines.Clear() + } else { + $property = switch ($key) { + 'name' { 'Name' } + 'shell' { 'Shell' } + 'run' { 'Run' } + 'if' { 'If' } + 'continue-on-error' { 'ContinueOnError' } + 'uses' { 'Uses' } + } + $currentStep.$property = ConvertFrom-CcodWorkflowScalar $value + } + } + continue + } + if ($null -eq $currentStep) { continue } + if ($line -cmatch '^ (?name|shell|run|if|continue-on-error|uses):\s*(?.*)$') { + $key = $Matches.key + $value = $Matches.value.Trim() + if ($key -ceq 'run' -and $value -in @('|', '|-', '|+')) { + $runBlock = $true + $runLines.Clear() + } else { + $property = switch ($key) { + 'name' { 'Name' } + 'shell' { 'Shell' } + 'run' { 'Run' } + 'if' { 'If' } + 'continue-on-error' { 'ContinueOnError' } + 'uses' { 'Uses' } + } + $currentStep.$property = ConvertFrom-CcodWorkflowScalar $value + } + } + } + if ($runBlock) { $currentStep.Run = $runLines -join "`n" } + return [pscustomobject]@{ Jobs = @($jobs) } +} + +function Test-CcodWorkflowStepInvokesBuild { + param([Parameter(Mandatory)]$Step) + $tokens = $null + $parseErrors = $null + $ast = [Management.Automation.Language.Parser]::ParseInput([string]$Step.Run, [ref]$tokens, [ref]$parseErrors) + if (@($parseErrors).Count -ne 0) { throw 'CCOD_RELEASE_TRACE_GATE_INVALID' } + $commands = @($ast.FindAll({ param($node) $node -is [Management.Automation.Language.CommandAst] }, $true)) + foreach ($command in $commands) { + $commandName = [string]$command.GetCommandName() + $elements = @($command.CommandElements | ForEach-Object { $_.Extent.Text }) + if ($commandName.Replace('\', '/') -ceq './build/build.ps1' -and $elements -ccontains '-Version') { return $true } + } + return $false +} + +function Test-CcodWorkflowStepInvokesProductionTrace { + param([Parameter(Mandatory)]$Step) + $tokens = $null + $parseErrors = $null + $ast = [Management.Automation.Language.Parser]::ParseInput([string]$Step.Run, [ref]$tokens, [ref]$parseErrors) + if (@($parseErrors).Count -ne 0) { throw 'CCOD_RELEASE_TRACE_GATE_INVALID' } + $commands = @($ast.FindAll({ param($node) $node -is [Management.Automation.Language.CommandAst] }, $true)) + foreach ($command in $commands) { + $commandName = [string]$command.GetCommandName() + $elements = @($command.CommandElements | ForEach-Object { $_.Extent.Text }) + if ($commandName.Replace('\', '/') -ceq './tests/trayhost/Invoke-TrayHostSelfTest.ps1' -and $elements -ccontains '-ProductionTraceOnly') { return $true } + } + return $false +} + +function Assert-CcodAuthenticatedTraceWorkflowContract { + param( + [Parameter(Mandatory)][string]$CiPath, + [Parameter(Mandatory)][string]$ReleasePath ) - $record = [ordered]@{ - schemaVersion = 1 - product = 'CodexRemote-fix' - version = '2.5.0' - gitCommit = ('a' * 40) - buildTimestampUtc = '2026-08-24T00:00:00.0000000Z' - assets = $assets + $traceName = 'Run authenticated TrayHost production trace' + $traceRun = './tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly' + foreach ($target in @( + [pscustomobject]@{ Path = $CiPath; Job = 'validate'; RequireBuildOrder = $false }, + [pscustomobject]@{ Path = $ReleasePath; Job = 'build'; RequireBuildOrder = $true } + )) { + $workflow = Get-CcodWorkflowStructure -Path $target.Path + $jobs = @($workflow.Jobs | Where-Object { $_.Name -ceq $target.Job }) + if ($jobs.Count -ne 1) { throw 'CCOD_RELEASE_TRACE_GATE_INVALID' } + $allTraceSteps = @($workflow.Jobs | ForEach-Object { @($_.Steps) } | Where-Object { $_.Name -ceq $traceName }) + $allTraceInvocationSteps = @($workflow.Jobs | ForEach-Object { @($_.Steps) } | Where-Object { Test-CcodWorkflowStepInvokesProductionTrace -Step $_ }) + $traceSteps = @($jobs[0].Steps | Where-Object { $_.Name -ceq $traceName }) + if ($allTraceSteps.Count -ne 1 -or $allTraceInvocationSteps.Count -ne 1 -or $traceSteps.Count -ne 1 -or $traceSteps[0].Shell -cne 'pwsh' -or $traceSteps[0].Run -cne $traceRun -or -not [string]::IsNullOrEmpty([string]$traceSteps[0].If) -or -not [string]::IsNullOrEmpty([string]$traceSteps[0].ContinueOnError)) { + throw 'CCOD_RELEASE_TRACE_GATE_INVALID' + } + if ($target.RequireBuildOrder) { + $buildIndexes = [Collections.Generic.List[int]]::new() + for ($stepIndex = 0; $stepIndex -lt $jobs[0].Steps.Count; $stepIndex++) { + if (Test-CcodWorkflowStepInvokesBuild -Step $jobs[0].Steps[$stepIndex]) { $buildIndexes.Add($stepIndex) } + } + $traceIndex = $jobs[0].Steps.IndexOf($traceSteps[0]) + if ($buildIndexes.Count -ne 1 -or $traceIndex -lt 0 -or $traceIndex -ge $buildIndexes[0]) { throw 'CCOD_RELEASE_TRACE_GATE_INVALID' } + } } - [IO.File]::WriteAllText($manifest, ($record | ConvertTo-Json -Depth 8), [Text.UTF8Encoding]::new($false)) - return [pscustomobject]@{ Root = $root; Installer = $installer; Checksum = $checksum; TrayHost = $trayHost; Manifest = $manifest } } function New-CcodPortableReleaseFixture { - $root = Join-Path $env:TEMP ('ccod-portable-release-workflow-' + [guid]::NewGuid().ToString('N')) + $root = Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-portable-release-workflow-' + [guid]::NewGuid().ToString('N')) $stage = Join-Path $root 'stage' $payload = Join-Path $stage 'payload' [IO.Directory]::CreateDirectory($payload) | Out-Null @@ -56,7 +226,8 @@ function New-CcodPortableReleaseFixture { $bundleHash = Get-CcodTestFileSha256 -Path $bundle [IO.File]::WriteAllText($checksum,("$bundleHash *$([IO.Path]::GetFileName($bundle))"),[Text.UTF8Encoding]::new($false)) $provenance = Join-Path $root 'CodexRemote-fix-2.5.6-trayhost-provenance.json' - [IO.File]::WriteAllText($provenance,([ordered]@{schemaVersion=1;product='CodexRemote-fix';version='2.5.6';gitCommit=$commit;buildTimestampUtc=$timestamp}|ConvertTo-Json),[Text.UTF8Encoding]::new($false)) + $tray=[ordered]@{schemaVersion=1;product='CodexRemote-fix';version='2.5.6';gitCommit=$commit;buildTimestampUtc=$timestamp;targetFramework='net48';compiler=[ordered]@{name='csc.exe';sha256=('1'*64)};referenceRoot='locked-net48';sourceFiles=@([ordered]@{name='AssemblyInfo.cs';sha256=('2'*64)},[ordered]@{name='WindowsTrayHostRuntime.cs';sha256=('3'*64)});iconSha256=('4'*64);manifestSha256=('5'*64);configSha256=('6'*64);artifactSha256=('7'*64);configArtifactSha256=('8'*64)} + [IO.File]::WriteAllText($provenance,($tray|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) $payloadAsset = Join-Path $root 'CodexRemote-fix-2.5.6-payload-manifest.json' [IO.File]::Copy((Join-Path $stage 'payload-manifest.json'),$payloadAsset,$false) $manifest = Join-Path $root 'CodexRemote-fix-2.5.6-release-manifest.json' @@ -73,274 +244,6442 @@ function New-CcodPortableReleaseFixture { return [pscustomobject]@{Root=$root;Bundle=$bundle;Checksum=$checksum;PayloadManifest=$payloadAsset;Manifest=$manifest} } -Invoke-CcodTest 'release defender tool exposes manifest and scan functions without a live scan' { - Assert-CcodTrue (Test-Path -LiteralPath $defenderPath -PathType Leaf) 'Defender release gate exists' - . $defenderPath -Library - Assert-CcodTrue ($null -ne (Get-Command Test-CcodReleaseAssetManifest -ErrorAction SilentlyContinue)) 'release manifest validator is exported for deterministic tests' - Assert-CcodTrue ($null -ne (Get-Command Invoke-CcodReleaseDefenderCheck -ErrorAction SilentlyContinue)) 'Defender invocation is available' +function Get-CcodTask5ExpectedAssetNames([string]$Version='2.5.22'){ + @( + "CodexRemote-fix-$Version-windows-x64.zip", + "CodexRemote-fix-$Version-windows-x64.zip.sha256.txt", + "CodexRemote-fix-$Version-trayhost-provenance.json", + "CodexRemote-fix-$Version-payload-manifest.json", + "CodexRemote-fix-$Version-release-manifest.json", + "CodexRemote-fix-$Version-setup.exe", + "CodexRemote-fix-$Version-setup.exe.sha256.txt", + "CodexRemote-fix-$Version-setup-provenance.json", + "CodexRemote-fix-$Version-setup-payload-manifest.json", + "CodexRemote-fix-$Version-setup-destination-inventory.iss", + "CodexRemote-fix-$Version-setup-release-manifest.json" + ) } -Invoke-CcodTest 'release manifest binds the final asset names hashes version commit and timestamp' { - . $defenderPath -Library - $fixture = New-CcodReleaseFixture - try { - $validated = Test-CcodReleaseAssetManifest -ManifestPath $fixture.Manifest -AssetDirectory $fixture.Root -ExpectedVersion '2.5.0' - Assert-CcodEqual $true ([bool]$validated.Valid) 'valid fixture passes the release manifest contract' - Assert-CcodEqual (Get-CcodTestFileSha256 -Path $fixture.Installer) ([string]$validated.InstallerSha256) 'validator returns the exact installer hash' - [IO.File]::WriteAllBytes($fixture.Installer, [byte[]](1,1,1)) - Assert-CcodThrows { - Test-CcodReleaseAssetManifest -ManifestPath $fixture.Manifest -AssetDirectory $fixture.Root -ExpectedVersion '2.5.0' - } 'CCOD_RELEASE_ASSET_HASH_MISMATCH' - } finally { - if (Test-Path -LiteralPath $fixture.Root) { Remove-Item -LiteralPath $fixture.Root -Recurse -Force } +function New-CcodTask5ExactAssetFixture { + param([switch]$SetupHashAsArray) + $root=Join-Path ([IO.Path]::GetTempPath()) ('ccod-task5-assets-'+[guid]::NewGuid().ToString('N'));$outside=Join-Path ([IO.Path]::GetTempPath()) ('ccod-task5-assets-outside-'+[guid]::NewGuid().ToString('N'));$work=Join-Path ([IO.Path]::GetTempPath()) ('ccod-task5-assets-work-'+[guid]::NewGuid().ToString('N'));foreach($directory in @($root,$outside,$work)){[IO.Directory]::CreateDirectory($directory)|Out-Null} + $version='2.5.22';$commit='c'*40;$timestamp='2030-02-03T04:05:06.0000000Z';$names=Get-CcodTask5ExpectedAssetNames $version + try{ + $stage=Join-Path $work 'portable-stage';$payload=Join-Path $stage 'payload';[IO.Directory]::CreateDirectory($payload)|Out-Null + $launcher=Join-Path $stage 'CodexRemote-fix.exe';$launcherConfig=Join-Path $stage 'CodexRemote-fix.exe.config';[IO.File]::WriteAllText((Join-Path $stage 'Install-CodexRemote-fix.ps1'),'Write-Output portable',[Text.UTF8Encoding]::new($false));[IO.File]::WriteAllBytes($launcher,[byte[]](1,2,3,4,5));[IO.File]::WriteAllText($launcherConfig,'',[Text.UTF8Encoding]::new($false));[IO.File]::WriteAllText((Join-Path $payload 'hello.txt'),'payload',[Text.UTF8Encoding]::new($false)) + $payloadFile=Join-Path $payload 'hello.txt';$payloadRecord=[ordered]@{path='hello.txt';length=[int64](Get-Item $payloadFile).Length;sha256=Get-CcodTestFileSha256 $payloadFile};$payloadManifest=[ordered]@{schemaVersion=1;product='CodexRemote-fix';version=$version;gitCommit=$commit;buildTimestampUtc=$timestamp;files=@($payloadRecord)} + [IO.File]::WriteAllText((Join-Path $stage 'payload-manifest.json'),($payloadManifest|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false));Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction Stop;[IO.Compression.ZipFile]::CreateFromDirectory($stage,(Join-Path $root $names[0]),[IO.Compression.CompressionLevel]::Optimal,$false);[IO.File]::WriteAllText((Join-Path $root $names[1]),((Get-CcodTestFileSha256 (Join-Path $root $names[0]))+' *'+$names[0]),[Text.UTF8Encoding]::new($false));[IO.File]::WriteAllText((Join-Path $root $names[3]),($payloadManifest|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + $tray=[ordered]@{schemaVersion=1;product='CodexRemote-fix';version=$version;gitCommit=$commit;buildTimestampUtc=$timestamp;targetFramework='net48';compiler=[ordered]@{name='csc.exe';sha256=('1'*64)};referenceRoot='locked-net48';sourceFiles=@([ordered]@{name='AssemblyInfo.cs';sha256=('2'*64)},[ordered]@{name='WindowsTrayHostRuntime.cs';sha256=('3'*64)});iconSha256=('4'*64);manifestSha256=('5'*64);configSha256=('6'*64);artifactSha256=('7'*64);configArtifactSha256=('8'*64)};Write-CcodTask5Json -Path (Join-Path $root $names[2]) -Value $tray + $portableAssets=@([ordered]@{name=$names[0];sha256=Get-CcodTestFileSha256 (Join-Path $root $names[0])},[ordered]@{name=$names[1];sha256=Get-CcodTestFileSha256 (Join-Path $root $names[1])},[ordered]@{name=$names[2];sha256=Get-CcodTestFileSha256 (Join-Path $root $names[2])},[ordered]@{name=$names[3];sha256=Get-CcodTestFileSha256 (Join-Path $root $names[3])},[ordered]@{name='CodexRemote-fix.exe';sha256=Get-CcodTestFileSha256 $launcher},[ordered]@{name='CodexRemote-fix.exe.config';sha256=Get-CcodTestFileSha256 $launcherConfig});Write-CcodTask5Json -Path (Join-Path $root $names[4]) -Value ([ordered]@{schemaVersion=2;product='CodexRemote-fix';version=$version;gitCommit=$commit;buildTimestampUtc=$timestamp;distribution='portable-zip';assets=$portableAssets}) + $packageManifest=[ordered]@{schemaVersion=1;product='CodexRemote-fix';version=$version;gitCommit=$commit;payloadManifest=[ordered]@{name='installer-payload.manifest.json';length=[int64]123;sha256=('9'*64)};files=@([ordered]@{path='Install-CodexControlOtherDevices.ps1';length=[int64]321;sha256=('a'*64)},[ordered]@{path='package.json';length=[int64]42;sha256=('b'*64)})} + if ($SetupHashAsArray) { $packageManifest.files[0].sha256 = @($packageManifest.files[0].sha256) } + Write-CcodTask5Json -Path (Join-Path $root $names[8]) -Value $packageManifest + $inventory="procedure AddCcodExpectedSetupDirectories(Directories: TStrings);`r`nbegin`r`n Directories.Add('runtime');`r`nend;`r`n";[IO.File]::WriteAllText((Join-Path $root $names[9]),$inventory,[Text.UTF8Encoding]::new($false));$packageManifestHash=Get-CcodTestFileSha256 (Join-Path $root $names[8]);$packageHash='d'*64;$bootstrapHash='e'*64 + $provenance=[ordered]@{schemaVersion=2;product='CodexRemote-fix';version=$version;gitCommit=$commit;buildTimestampUtc=$timestamp;installerPackage=[ordered]@{name='installer-package.zip';length=[int64]456;sha256=$packageHash};installerPackageManifest=[ordered]@{name='installer-package.manifest.json';length=[int64](Get-Item (Join-Path $root $names[8])).Length;sha256=$packageManifestHash;fileCount=[int]2;payloadManifestSha256=('9'*64)};activationBootstrap=[ordered]@{name='Activate-CcodRemoteFix.ps1';length=[int64]789;sha256=$bootstrapHash};buildInputs=[ordered]@{innoTemplateSha256=('f'*64);destinationInventorySha256=Get-CcodTestFileSha256 (Join-Path $root $names[9]);compilerSha256=('1'*64);compilerFileVersion='6.7.3'};peContract=[ordered]@{fileVersion="$version.0";packageManifestFirst=$packageManifestHash.Substring(0,32);packageManifestLast=$packageManifestHash.Substring(32,32);bootstrapFirst=$bootstrapHash.Substring(0,32);bootstrapLast=$bootstrapHash.Substring(32,32);companyName=$commit;legalCopyright=$packageHash}};Write-CcodTask5Json -Path (Join-Path $root $names[7]) -Value $provenance + $iscc=@((Join-Path $env:LOCALAPPDATA 'Programs\Inno Setup 6\ISCC.exe'),(Join-Path ${env:ProgramFiles(x86)} 'Inno Setup 6\ISCC.exe'),(Join-Path $env:ProgramFiles 'Inno Setup 6\ISCC.exe'))|Where-Object{$_-and[IO.File]::Exists($_)}|Select-Object -First 1;if(-not$iscc){throw 'Inno Setup 6 required for Task5 exact fixture'};$iss=Join-Path $work 'fixture.iss';$out=Join-Path $work 'setup-output';[IO.Directory]::CreateDirectory($out)|Out-Null + $issText="[Setup]`r`nAppId={{11111111-2222-4333-8444-555555555555}`r`nAppName=CodexRemote-fix`r`nAppVersion=$version`r`nDefaultDirName={tmp}\CcodFixture`r`nCreateAppDir=no`r`nUninstallable=no`r`nPrivilegesRequired=lowest`r`nVersionInfoVersion=$version.0`r`nVersionInfoTextVersion=$version.0`r`nVersionInfoProductName=$($bootstrapHash.Substring(32,32))`r`nVersionInfoProductTextVersion=$($packageManifestHash.Substring(0,32))`r`nVersionInfoDescription=$($packageManifestHash.Substring(32,32))`r`nVersionInfoCompany=$commit`r`nVersionInfoCopyright=$packageHash`r`nVersionInfoOriginalFileName=$($bootstrapHash.Substring(0,32))`r`nOutputBaseFilename=$([IO.Path]::GetFileNameWithoutExtension($names[5]))`r`nCompression=none`r`nDisableProgramGroupPage=yes`r`n";[IO.File]::WriteAllText($iss,$issText,[Text.UTF8Encoding]::new($false));$compile=@(&$iscc "/O$out" $iss 2>&1);if($LASTEXITCODE-ne0){throw "Task5 fixture ISCC failed: $($compile-join' ')"};[IO.File]::Copy((Join-Path $out $names[5]),(Join-Path $root $names[5]),$false) + [IO.File]::WriteAllText((Join-Path $root $names[6]),((Get-CcodTestFileSha256 (Join-Path $root $names[5]))+' *'+$names[5]),[Text.UTF8Encoding]::new($false));$setupAssets=@([ordered]@{name=$names[5];sha256=Get-CcodTestFileSha256 (Join-Path $root $names[5])},[ordered]@{name=$names[6];sha256=Get-CcodTestFileSha256 (Join-Path $root $names[6])},[ordered]@{name=$names[2];sha256=Get-CcodTestFileSha256 (Join-Path $root $names[2])},[ordered]@{name=$names[7];sha256=Get-CcodTestFileSha256 (Join-Path $root $names[7])},[ordered]@{name=$names[8];sha256=Get-CcodTestFileSha256 (Join-Path $root $names[8])},[ordered]@{name=$names[9];sha256=Get-CcodTestFileSha256 (Join-Path $root $names[9])});Write-CcodTask5Json -Path (Join-Path $root $names[10]) -Value ([ordered]@{schemaVersion=1;product='CodexRemote-fix';version=$version;gitCommit=$commit;buildTimestampUtc=$timestamp;assets=$setupAssets}) + return [pscustomobject]@{Root=$root;OriginalRoot=$root;Outside=$outside;Names=$names;Version=$version;GitCommit=$commit;Timestamp=$timestamp} + }catch{foreach($path in @($root,$outside)){if(Test-Path $path){Remove-Item -LiteralPath $path -Recurse -Force -ErrorAction SilentlyContinue}};throw}finally{if(Test-Path $work){Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue}} +} + +function Copy-CcodTask5ExactAssetFixture { + param([Parameter(Mandatory)]$Source) + $root=Join-Path ([IO.Path]::GetTempPath()) ('ccod-task5-assets-copy-'+[guid]::NewGuid().ToString('N'));$outside=Join-Path ([IO.Path]::GetTempPath()) ('ccod-task5-assets-outside-'+[guid]::NewGuid().ToString('N'));[IO.Directory]::CreateDirectory($root)|Out-Null;[IO.Directory]::CreateDirectory($outside)|Out-Null;foreach($name in $Source.Names){[IO.File]::Copy((Join-Path $Source.Root $name),(Join-Path $root $name),$false)};[pscustomobject]@{Root=$root;OriginalRoot=$root;Outside=$outside;Names=$Source.Names;Version=$Source.Version;GitCommit=$Source.GitCommit;Timestamp=$Source.Timestamp} +} + +function Remove-CcodTask5ExactAssetFixture($Fixture){foreach($path in @($Fixture.Root,$Fixture.OriginalRoot,$Fixture.Outside)){if([string]::IsNullOrWhiteSpace([string]$path)){continue};$full=[IO.Path]::GetFullPath([string]$path);$temp=[IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\')+'\';if(-not$full.StartsWith($temp,[StringComparison]::OrdinalIgnoreCase)){throw 'refusing non-temp asset fixture cleanup'};if(Test-Path -LiteralPath $full){Remove-Item -LiteralPath $full -Recurse -Force}}} + +function New-CcodTask5DefenderStatus { + [pscustomobject][ordered]@{AMServiceEnabled=$true;AntivirusEnabled=$true;RealTimeProtectionEnabled=$true;AMProductVersion='4.18.26070.1';AMEngineVersion='1.1.26070.1';AntivirusSignatureVersion='1.999.1.0';AntivirusSignatureLastUpdated=[datetime]::Parse('2030-02-03T02:05:06Z').ToUniversalTime()} +} + +function New-CcodTask5DefenderAdapterFixture { + param($Status=(New-CcodTask5DefenderStatus),[datetime]$Started=([datetime]::Parse('2030-02-03T04:05:06Z').ToUniversalTime()),$Completed=([datetime]::Parse('2030-02-03T04:05:07Z').ToUniversalTime()),[switch]$ScanThrows,[switch]$Detects,[switch]$WriteThrows) + $state=[pscustomobject]@{Clock=0;Threat=0;Calls=[Collections.Generic.List[string]]::new()} + $adapters=@{ + GetDefenderStatus={ $state.Calls.Add('Status');$Status }.GetNewClosure() + StartCustomScan={param($Path)$state.Calls.Add('Scan');if($ScanThrows){throw 'fixture scan failure'}}.GetNewClosure() + GetThreatDetections={$state.Calls.Add('Threat');$state.Threat++;if($Detects-and$state.Threat-gt1){@([pscustomobject]@{ThreatID=99;InitialDetectionTime='2030-02-03T04:05:06.0000000Z';Resources=@('redacted')})}else{@()}}.GetNewClosure() + GetUtcNow={$state.Calls.Add('Clock');$state.Clock++;if($state.Clock-eq1){$Started}else{$Completed}}.GetNewClosure() } + if($WriteThrows){$adapters.PublishReceiptBytes={param($Directory,$Leaf,$Bytes)$state.Calls.Add('Write');throw 'fixture evidence write failure'}.GetNewClosure()} + [pscustomobject]@{Adapters=$adapters;State=$state} } -Invoke-CcodTest 'release timestamp validation reads the raw JSON string representation' { - . $defenderPath -Library - $canonical = '2026-08-24T00:00:00.0000000Z' - Assert-CcodEqual $canonical (Get-CcodReleaseDefenderRawJsonString -Json ('{"buildTimestampUtc":"' + $canonical + '"}') -PropertyName 'buildTimestampUtc') 'canonical raw timestamp is retained as text' - Assert-CcodEqual $null (Get-CcodReleaseDefenderRawJsonString -Json '{"buildTimestampUtc":123}' -PropertyName 'buildTimestampUtc') 'nonstring timestamp JSON is rejected' - Assert-CcodEqual $null (Get-CcodReleaseDefenderRawJsonString -Json ('{"buildTimestampUtc":"' + $canonical + '","buildTimestampUtc":"' + $canonical + '"}') -PropertyName 'buildTimestampUtc') 'duplicate timestamp JSON is rejected' - Assert-CcodEqual 'not-canonical' (Get-CcodReleaseDefenderRawJsonString -Json ('{"nested":{"buildTimestampUtc":"' + $canonical + '"},"buildTimestampUtc":"not-canonical"}') -PropertyName 'buildTimestampUtc') 'only the root timestamp property is selected' +function Get-CcodTask5ReleaseDefenderModule { + $module=@(Get-Module|Where-Object{$_.Path-ceq[IO.Path]::GetFullPath($releaseDefenderModulePath)})|Select-Object -First 1 + if($null-eq$module){$module=Import-Module $releaseDefenderModulePath -Force -PassThru -DisableNameChecking} + return $module } -Invoke-CcodTest 'release manifest rejects a numeric top-level timestamp hidden by a nested canonical timestamp' { - . $defenderPath -Library - $fixture = New-CcodReleaseFixture +function Invoke-CcodTask5DefenderCore { + param([string]$CandidatePath,[string]$ChecksumPath,[string]$ManifestPath,[string]$Origin,$WorkflowArtifactIdentity,[string]$ExpectedVersion,[string]$ExpectedGitCommit,[string]$EvidencePath,[hashtable]$Adapters) + $module=Get-CcodTask5ReleaseDefenderModule + &$module {param($CandidatePath,$ChecksumPath,$ManifestPath,$Origin,$WorkflowArtifactIdentity,$ExpectedVersion,$ExpectedGitCommit,$EvidencePath,$Adapters)Invoke-CcodReleaseDefenderCheckCore -CandidatePath $CandidatePath -ChecksumPath $ChecksumPath -ManifestPath $ManifestPath -Origin $Origin -WorkflowArtifactIdentity $WorkflowArtifactIdentity -ExpectedVersion $ExpectedVersion -ExpectedGitCommit $ExpectedGitCommit -EvidencePath $EvidencePath -Adapters $Adapters} $CandidatePath $ChecksumPath $ManifestPath $Origin $WorkflowArtifactIdentity $ExpectedVersion $ExpectedGitCommit $EvidencePath $Adapters +} + +function Get-CcodTask5DefaultDefenderAdapters {$module=Get-CcodTask5ReleaseDefenderModule;&$module {Get-CcodReleaseDefenderDefaultAdapters}} + +function New-CcodTask5WorkflowIdentity([string]$Commit=('a'*40)){ + [pscustomobject][ordered]@{provider='GitHubActions';repository='naipi11/CodexRemote-fix';runId=[uint64]123;runAttempt=[uint64]2;artifactId=[uint64]456;artifactName='CodexRemote-fix portable bundle';artifactDigest=('sha256:'+('9'*64));gitCommit=$Commit} +} + +function New-CcodTask5DefenderReceipt { + param([ValidateSet('Setup','PortableZip')][string]$AssetType='Setup',[string]$Version='2.5.22',[string]$Commit=('c'*40),[ValidateSet('InternetDownload','TrustedWorkflowArtifact')][string]$Origin='InternetDownload') + $names=Get-CcodTask5ExpectedAssetNames $Version;$setup=$AssetType-ceq'Setup' + $scanStarted=[datetime]::UtcNow.AddMinutes(-1);$scanCompleted=$scanStarted.AddSeconds(1);$signature=$scanStarted.AddHours(-1) + [pscustomobject][ordered]@{schemaVersion=2;assetType=$AssetType;assetName=$(if($setup){$names[5]}else{$names[0]});assetSha256=$(if($setup){'5'*64}else{'0'*64});checksumName=$(if($setup){$names[6]}else{$names[1]});checksumSha256=$(if($setup){'6'*64}else{'1'*64});manifestName=$(if($setup){$names[10]}else{$names[4]});manifestSha256=$(if($setup){'a'*64}else{'4'*64});version=$Version;gitCommit=$Commit;origin=$Origin;workflowArtifactIdentity=$(if($Origin-ceq'TrustedWorkflowArtifact'){New-CcodTask5WorkflowIdentity $Commit}else{$null});zoneId=$(if($Origin-ceq'InternetDownload'){3}else{$null});defenderServiceEnabled=$true;antivirusEnabled=$true;realTimeProtectionEnabled=$true;defenderPlatformVersion='4.18.26070.1';defenderEngineVersion='1.1.26070.1';signatureVersion='1.999.1.0';signatureUpdatedAtUtc=$signature.ToString('o');scanStartedAtUtc=$scanStarted.ToString('o');scanCompletedAtUtc=$scanCompleted.ToString('o');detectionCount=0;outcome='Completed';errorCode=$null} +} + +function Get-CcodTask5PromotionReceiptNames([string]$Version='2.5.22'){ + @("CodexRemote-fix-$Version-setup.internet-download.defender.json","CodexRemote-fix-$Version-windows-x64.internet-download.defender.json") +} + +function Write-CcodTask5Json { + param([Parameter(Mandatory)][string]$Path,[Parameter(Mandatory)]$Value) + [IO.File]::WriteAllText($Path,(($Value|ConvertTo-Json -Depth 12)+[Environment]::NewLine),[Text.UTF8Encoding]::new($false)) +} + +function New-CcodTask5PromotionFixture { + param([Parameter(Mandatory)]$AssetFixture) + $root=Join-Path ([IO.Path]::GetTempPath()) ('ccod-task5-promotion-'+[guid]::NewGuid().ToString('N')) + [IO.Directory]::CreateDirectory($root)|Out-Null + $names=Get-CcodTask5PromotionReceiptNames + $setup=New-CcodTask5DefenderReceipt -AssetType Setup + $portable=New-CcodTask5DefenderReceipt -AssetType PortableZip + $setup.assetSha256=Get-CcodTestFileSha256 (Join-Path $AssetFixture.Root $AssetFixture.Names[5]);$setup.checksumSha256=Get-CcodTestFileSha256 (Join-Path $AssetFixture.Root $AssetFixture.Names[6]);$setup.manifestSha256=Get-CcodTestFileSha256 (Join-Path $AssetFixture.Root $AssetFixture.Names[10]) + $portable.assetSha256=Get-CcodTestFileSha256 (Join-Path $AssetFixture.Root $AssetFixture.Names[0]);$portable.checksumSha256=Get-CcodTestFileSha256 (Join-Path $AssetFixture.Root $AssetFixture.Names[1]);$portable.manifestSha256=Get-CcodTestFileSha256 (Join-Path $AssetFixture.Root $AssetFixture.Names[4]) + Write-CcodTask5Json -Path (Join-Path $root $names[0]) -Value $setup + Write-CcodTask5Json -Path (Join-Path $root $names[1]) -Value $portable + [pscustomobject]@{Root=$root;Names=$names;Setup=$setup;Portable=$portable;Commit=('c'*40);Version='2.5.22'} +} + +function New-CcodTask9ManualEvidenceFixture { + param([Parameter(Mandatory)][string]$ManifestHash,[string]$Version='2.5.22',[string]$Commit=('c'*40),[string]$ArtifactRoot) + $operations = @('About','Language','OpenLogs','Repair','SecondDeviceControl') + $states = @('AboutVisible','LanguageChanged','LogsOpened','RepairCompleted','SecondDeviceControlled') + $results = @('CCOD_TRAYABOUT_COMPLETED','CCOD_TRAYLANGUAGE_COMPLETED','CCOD_TRAYOPENLOGS_COMPLETED','CCOD_TRAYREPAIR_COMPLETED','CCOD_SECONDDEVICE_COMPLETED') + $hashes = @(('1' * 64),('2' * 64),('3' * 64),('4' * 64),('5' * 64),('6' * 64),('7' * 64),('8' * 64),('9' * 64),('a' * 64)) + $records = [Collections.Generic.List[object]]::new() + for ($index = 0; $index -lt $operations.Count; $index++) { + $proof = $null + if ($index -lt 4) { + $commands = @('ShowAbout','SetLanguageEnglish','OpenLogs','CheckAndRepair') + $proof = [pscustomobject][ordered]@{ timestampUtc = '2030-02-03T04:05:07.0000000Z'; command = $commands[$index]; revision = [UInt64]1; code = 'CCOD_TRAY_ACTION_COMPLETED'; status = 'Completed' } + } else { + $proof = [pscustomobject][ordered]@{ schemaVersion = 1; timestampUtc = '2030-02-03T04:05:07.0000000Z'; kind = 'remote-control-manual-proof'; operation = 'SecondDeviceControl'; deviceRole = 'SecondDevice'; challenge = 'ffffffffffffffffffffffffffffffff'; candidateVersion = $Version; runtimeId = 'runtime-2.5.22'; runtimeGeneration = [UInt64]2; runtimeManifestSha256 = ('d' * 64); attestation = 'HumanReviewedStructuredAttestation'; connection = 'Connected'; control = 'Completed'; outcome = 'Completed'; code = 'CCOD_REMOTE_ACTION_COMPLETED' } + } + $record = [pscustomobject][ordered]@{ + schemaVersion = 1 + kind = 'manual-evidence' + phase = if ($index -lt 4) { 'TrayEvidence' } else { 'RemoteEvidence' } + operation = $operations[$index] + terminalState = $states[$index] + result = $results[$index] + proof = $proof + reviewState = 'Reviewed' + version = $Version + gitCommit = $Commit + candidateManifestSha256 = $ManifestHash + screenshotSha256 = $hashes[$index * 2] + redactedLogSha256 = $hashes[$index * 2 + 1] + } + if (-not [string]::IsNullOrWhiteSpace($ArtifactRoot)) { + $operationDirectory = Join-Path ([IO.Path]::GetFullPath($ArtifactRoot)) $operations[$index] + [IO.Directory]::CreateDirectory($operationDirectory) | Out-Null + $screenshotPath = Join-Path $operationDirectory 'screenshot.bin' + $redactedLogPath = Join-Path $operationDirectory 'redacted.log' + [IO.File]::WriteAllText($screenshotPath, "fixture screenshot $Version $Commit $($operations[$index])", [Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($redactedLogPath, "fixture redacted log $Version $Commit $($operations[$index])", [Text.UTF8Encoding]::new($false)) + $record.screenshotSha256 = Get-CcodTestFileSha256 $screenshotPath + $record.redactedLogSha256 = Get-CcodTestFileSha256 $redactedLogPath + } + $records.Add($record) + } + return @($records) +} + +function New-CcodTask9AcceptanceRecord { + param([Parameter(Mandatory)]$AssetFixture,[string]$EvidenceRoot) + $manifestHash = Get-CcodTestFileSha256 (Join-Path $AssetFixture.Root $AssetFixture.Names[4]) + $automated = @() + $manual = @() + if (-not [string]::IsNullOrWhiteSpace($EvidenceRoot)) { + $state = New-CcodTask9AutomatedAcceptanceState -AssetFixture $AssetFixture -EvidenceRoot $EvidenceRoot + $automated = @($state.AutomatedReceiptSha256) + $manual = @($state.ManualEvidence) + } else { + $manual = @(New-CcodTask9ManualEvidenceFixture -ManifestHash $manifestHash -Commit ([string]$AssetFixture.GitCommit)) + } + [ordered]@{ + schemaVersion = 1 + kind = 'official-draft-acceptance' + version = '2.5.22' + gitCommit = [string]$AssetFixture.GitCommit + draft = [pscustomobject][ordered]@{ tag = 'v2.5.22'; id = '123' } + candidateManifestSha256 = $manifestHash + phase = 'Complete' + completedAtUtc = '2030-02-03T04:05:07.0000000Z' + automatedReceiptSha256 = @($automated) + manualEvidence = $manual + } +} + +function Get-CcodTask9EvidenceRoot { + for ($scope = 0; $scope -le 3; $scope++) { + try { $promotionVariable = Get-Variable -Name promotion -Scope $scope -ValueOnly -ErrorAction Stop } catch { continue } + if ($null -ne $promotionVariable -and $null -ne $promotionVariable.Root) { return [string]$promotionVariable.Root } + } + for ($scope = 0; $scope -le 3; $scope++) { + try { $evidenceVariable = Get-Variable -Name evidence -Scope $scope -ValueOnly -ErrorAction Stop } catch { continue } + if ($null -ne $evidenceVariable) { return [string]$evidenceVariable } + } + throw 'Task 9 fixture evidence root is unavailable.' +} + +function New-CcodTask9AutomatedAcceptanceState { + param([Parameter(Mandatory)]$AssetFixture,[Parameter(Mandatory)][string]$EvidenceRoot) + $stateRoot = Join-Path ([IO.Path]::GetFullPath($EvidenceRoot)) 'official-draft-acceptance' + $manualRoot = Join-Path $stateRoot 'manual-evidence' + [IO.Directory]::CreateDirectory($manualRoot) | Out-Null + $names = [string[]]$AssetFixture.Names + $assetHashes = @($names | ForEach-Object { [pscustomobject][ordered]@{ name = [string]$_; sha256 = Get-CcodTestFileSha256 (Join-Path $AssetFixture.Root $_) } }) + $candidate = [pscustomobject][ordered]@{ + version = '2.5.22' + gitCommit = [string]$AssetFixture.GitCommit + assetHashes = @($assetHashes) + manifestHashes = [pscustomobject][ordered]@{ portable = [string]$assetHashes[4].sha256; setup = [string]$assetHashes[10].sha256 } + } + $draft = [pscustomobject][ordered]@{ tag = 'v2.5.22'; id = '123' } + $defenderScanStarted=[datetime]::UtcNow.AddMinutes(-1);$defenderScanCompleted=$defenderScanStarted.AddSeconds(1);$defenderSignature=$defenderScanStarted.AddHours(-1) + $newDefender = { + param([string]$AssetType) + $assetIndex = if ($AssetType -ceq 'Setup') { 5 } else { 0 } + $checksumIndex = if ($AssetType -ceq 'Setup') { 6 } else { 1 } + $manifestIndex = if ($AssetType -ceq 'Setup') { 10 } else { 4 } + [pscustomobject][ordered]@{ + schemaVersion = 2; assetType = $AssetType; assetName = $names[$assetIndex]; assetSha256 = $assetHashes[$assetIndex].sha256 + checksumName = $names[$checksumIndex]; checksumSha256 = $assetHashes[$checksumIndex].sha256 + manifestName = $names[$manifestIndex]; manifestSha256 = $assetHashes[$manifestIndex].sha256 + version = '2.5.22'; gitCommit = [string]$AssetFixture.GitCommit; origin = 'InternetDownload'; workflowArtifactIdentity = $null; zoneId = 3 + defenderServiceEnabled = $true; antivirusEnabled = $true; realTimeProtectionEnabled = $true + defenderPlatformVersion = '4.18.26070.1'; defenderEngineVersion = '1.1.26070.1'; signatureVersion = '1.999.1.0' + signatureUpdatedAtUtc = $defenderSignature.ToString('o'); scanStartedAtUtc = $defenderScanStarted.ToString('o'); scanCompletedAtUtc = $defenderScanCompleted.ToString('o') + detectionCount = 0; outcome = 'Completed'; errorCode = $null + } + }.GetNewClosure() + $keyHash = ('b' * 64); $runtimeId = 'runtime-2.5.22'; [UInt64]$generation = 2 + $terminal = [pscustomobject][ordered]@{ kind = 'RestartAndRepair'; origin = 'Installer'; runtimeId = $runtimeId; runtimeGeneration = $generation; phase = 'Completed' } + $makeLegacyObservation = { + param([string]$Version,[string]$ObservedRuntimeId,[UInt64]$ObservedGeneration,[string]$ObservedManifestHash,[string]$ObservedBootId) + $observedTerminal = [pscustomobject][ordered]@{ kind = 'RestartAndRepair'; origin = 'Installer'; runtimeId = $ObservedRuntimeId; runtimeGeneration = $ObservedGeneration; phase = 'Completed' } + return [pscustomobject][ordered]@{ + version = $Version + bootId = $ObservedBootId + installRootPresent = $true + installReady = $true + appRootPresent = $true + runtimeRootPresent = $true + activePointerPresent = $true + activeRuntimeId = $ObservedRuntimeId + activeGeneration = $ObservedGeneration + runtimeManifestSha256 = $ObservedManifestHash + supervisor = @([pscustomobject][ordered]@{ pid = 200; creationTimeUtc = '2030-02-03T04:05:00.0000000Z' }) + trayHost = @([pscustomobject][ordered]@{ pid = 201; creationTimeUtc = '2030-02-03T04:05:01.0000000Z' }) + codex = @([pscustomobject][ordered]@{ pid = 202; creationTimeUtc = '2030-02-03T04:05:02.0000000Z' }) + trayHostIdentity = [pscustomobject][ordered]@{ pid = 201; creationTimeUtc = '2030-02-03T04:05:01.0000000Z' } + trayAuthenticated = $true + taskState = 'Ready' + statusPhase = 'Active' + statusRuntimeId = $ObservedRuntimeId + statusCodex = [pscustomobject][ordered]@{ pid = 202; creationTimeUtc = '2030-02-03T04:05:02.0000000Z' } + transitionStage = 'Idle' + lifecycleReceipt = $observedTerminal + aboutVersion = $Version + deviceKeyPresent = $true + deviceKeySha256 = $keyHash + shortcuts = [pscustomobject][ordered]@{ startMenu = $true; desktop = $true } + debugPorts = [int[]]@(9229,9230) + debugEndpoints = @( + [pscustomobject][ordered]@{ localAddress = '127.0.0.1'; localPort = 9229; owningProcess = 202; owningProcessCreationTimeUtc = '2030-02-03T04:05:02.0000000Z' } + [pscustomobject][ordered]@{ localAddress = '127.0.0.1'; localPort = 9230; owningProcess = 202; owningProcessCreationTimeUtc = '2030-02-03T04:05:02.0000000Z' } + ) + protectionReady = $true + } + }.GetNewClosure() + $legacyBeforeObservation = & $makeLegacyObservation '2.5.21' 'runtime-2.5.21' ([UInt64]1) ('2' * 64) 'boot-legacy-before' + $legacyAfterObservation = & $makeLegacyObservation '2.5.22' $runtimeId $generation ('d' * 64) 'boot-legacy-after' + $facts = [ordered]@{} + $defenderReceipts = @(& $newDefender 'Setup') + $defenderReceipts += @(& $newDefender 'PortableZip') + $facts.Preflight = [pscustomobject][ordered]@{ defenderReceipts = @($defenderReceipts) } + $facts.LegacyUpgrade = [pscustomobject][ordered]@{ + previousSetup = [pscustomobject][ordered]@{ version = '2.5.21'; gitCommit = ('a' * 40); assetSha256 = ('1' * 64); manifestSha256 = ('2' * 64) } + before = $legacyBeforeObservation; after = $legacyAfterObservation + deviceKeySha256Before = $keyHash; deviceKeySha256After = $keyHash; keyHashPreserved = $true; operationOutcome = 'Completed' + } + $facts.Uninstall = [pscustomobject][ordered]@{ + stateRemoved = $true; installRootPresent = $false; appRootPresent = $false; runtimeRootPresent = $false; activePointerPresent = $false; taskState = 'Absent'; supervisorCount = 0; trayHostCount = 0; codexCount = 0 + shortcuts = [pscustomobject][ordered]@{ startMenu = $false; desktop = $false }; debugEndpointsGone = $true + deviceKeySha256Before = $keyHash; deviceKeySha256After = $keyHash; keyHashPreserved = $true; operationOutcome = 'Completed' + } + $facts.FreshInstall = [pscustomobject][ordered]@{ + installRootPresent = $true; installReady = $true; activePointerPresent = $true; activeRuntimeId = $runtimeId; activeGeneration = $generation; runtimeManifestSha256 = ('d' * 64) + supervisorCount = 1; trayHostCount = 1; codexCount = 1; trayHostIdentity = [pscustomobject][ordered]@{ pid = 201; creationTimeUtc = '2030-02-03T04:05:01.0000000Z' }; trayAuthenticated = $true; taskState = 'Ready'; terminalReceipt = $terminal; protectionReady = $true; deviceKeySha256 = $keyHash; operationOutcome = 'Completed' + } + $phaseFiles = [ordered]@{ Preflight = '01-Preflight.json'; LegacyUpgrade = '02-LegacyUpgrade.json'; Uninstall = '03-Uninstall.json'; FreshInstall = '04-FreshInstall.json'; PreReboot = '05-PreReboot.json'; PostReboot = '06-PostReboot.json'; ReadyForManualEvidence = '07-ReadyForManualEvidence.json'; Complete = '08-Complete.json' } + $writeReceipt = { + param([string]$Phase,$Value) + $receipt = [ordered]@{ schemaVersion = 1; phase = $Phase; candidate = $candidate; draft = $draft; facts = $Value } + [IO.File]::WriteAllText((Join-Path $stateRoot $phaseFiles[$Phase]), (($receipt | ConvertTo-Json -Depth 20) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + }.GetNewClosure() + foreach ($phase in @('Preflight','LegacyUpgrade','Uninstall','FreshInstall')) { & $writeReceipt $phase $facts[$phase] } + $preObservation = & $makeLegacyObservation '2.5.22' $runtimeId $generation ('d' * 64) 'boot-1' + $facts.PreReboot = [pscustomobject][ordered]@{ rebootRequested = $true; bootIdBefore = 'boot-1'; freshInstallReceiptSha256 = Get-CcodTestFileSha256 (Join-Path $stateRoot $phaseFiles.FreshInstall); activeRuntimeId = $runtimeId; activeGeneration = $generation; runtimeManifestSha256 = $preObservation.runtimeManifestSha256; deviceKeySha256 = $keyHash; operationOutcome = 'Completed'; observation = $preObservation } + $postObservation = & $makeLegacyObservation '2.5.22' $runtimeId $generation ('d' * 64) 'boot-2' + $facts.PostReboot = [pscustomobject][ordered]@{ + observation = $postObservation + installRootPresent = $true; activePointerPresent = $true; bootIdBefore = 'boot-1'; bootIdAfter = 'boot-2'; bootChanged = $true; receiptContinuity = $true; activeRuntimeId = $runtimeId; activeGeneration = $generation; runtimeManifestSha256 = ('d' * 64); transitionStage = 'Idle' + protectionRecovered = $true; protectionReady = $true; taskState = 'Ready'; supervisorCount = 1; trayHostCount = 1; codexCount = 1; trayHostIdentity = [pscustomobject][ordered]@{ pid = 201; creationTimeUtc = '2030-02-03T04:05:01.0000000Z' }; trayAuthenticated = $true; terminalReceipt = $terminal + deviceKeySha256 = $keyHash; keyHashPreserved = $true; operationOutcome = 'Completed' + } + $facts.ReadyForManualEvidence = [pscustomobject][ordered]@{ automatedPhases = @('Preflight','LegacyUpgrade','Uninstall','FreshInstall','PreReboot','PostReboot'); status = 'ReadyForManualEvidence'; manualEvidencePending = $true; complete = $false; manualChallenge = 'ffffffffffffffffffffffffffffffff' } + & $writeReceipt 'PreReboot' $facts.PreReboot; & $writeReceipt 'PostReboot' $facts.PostReboot; & $writeReceipt 'ReadyForManualEvidence' $facts.ReadyForManualEvidence + $manual = @(New-CcodTask9ManualEvidenceFixture -ManifestHash $candidate.manifestHashes.portable -Commit $candidate.gitCommit -ArtifactRoot (Join-Path $EvidenceRoot 'official-draft-artifacts')) + $manualNames = @('TrayEvidence-About.json','TrayEvidence-Language.json','TrayEvidence-OpenLogs.json','TrayEvidence-Repair.json','RemoteEvidence-SecondDeviceControl.json') + for ($index = 0; $index -lt $manual.Count; $index++) { [IO.File]::WriteAllText((Join-Path $manualRoot $manualNames[$index]), (($manual[$index] | ConvertTo-Json -Depth 12) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) } + $facts.Complete = [pscustomobject][ordered]@{ manualEvidence = $manual; status = 'Complete'; complete = $true } + & $writeReceipt 'Complete' $facts.Complete + $hashes = [Collections.Generic.List[object]]::new() + foreach ($phase in @('Preflight','LegacyUpgrade','Uninstall','FreshInstall','PreReboot','PostReboot','ReadyForManualEvidence','Complete')) { $hashes.Add([pscustomobject][ordered]@{ phase = $phase; sha256 = Get-CcodTestFileSha256 (Join-Path $stateRoot $phaseFiles[$phase]) }) } + return [pscustomobject][ordered]@{ StateDirectory = $stateRoot; AutomatedReceiptSha256 = @($hashes); ManualEvidence = @($manual) } +} + +function Sync-CcodTask5OuterAssetHash { + param([Parameter(Mandatory)]$Fixture,[Parameter(Mandatory)][ValidateSet('Portable','Setup')][string]$Distribution,[Parameter(Mandatory)][string]$AssetName) + $manifestName=if($Distribution-ceq'Portable'){$Fixture.Names[4]}else{$Fixture.Names[10]} + $manifestPath=Join-Path $Fixture.Root $manifestName;$manifest=[IO.File]::ReadAllText($manifestPath,[Text.UTF8Encoding]::new($false,$true))|ConvertFrom-Json + $record=@($manifest.assets|Where-Object{$_.name-ceq$AssetName});if($record.Count-ne1){throw "fixture outer asset missing: $AssetName"} + $record[0].sha256=Get-CcodTestFileSha256 (Join-Path $Fixture.Root $AssetName) + [IO.File]::WriteAllText($manifestPath,($manifest|ConvertTo-Json -Depth 12),[Text.UTF8Encoding]::new($false)) +} + +function Get-CcodReadOnlyProductTreeSnapshot { + param( + [Parameter(Mandatory)][string]$Root, + [int]$MaximumEntries=8192, + [long]$MaximumFileBytes=268435456, + [long]$MaximumTotalBytes=1073741824 + ) + $full=[IO.Path]::GetFullPath($Root).TrimEnd('\') + if([IO.File]::Exists($full)){throw 'CCOD_TEST_PRODUCT_SNAPSHOT_UNSAFE'} + if(-not[IO.Directory]::Exists($full)){ + return [pscustomobject][ordered]@{root=$full;exists=$false;rootAttributes=$null;rootCreationTimeUtc=$null;rootLastWriteTimeUtc=$null;entryCount=0;totalBytes=[long]0;records=@()} + } + $rootItem=Get-Item -LiteralPath $full -Force -ErrorAction Stop + if(-not$rootItem.PSIsContainer-or($rootItem.Attributes-band[IO.FileAttributes]::ReparsePoint)-ne0){throw 'CCOD_TEST_PRODUCT_SNAPSHOT_UNSAFE'} + $prefix=$full+'\';$pending=[Collections.Generic.Stack[string]]::new();$pending.Push($full);$records=[Collections.Generic.List[object]]::new();$seen=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase);$total=[long]0 + while($pending.Count-gt0){ + $directory=$pending.Pop() + foreach($item in @(Get-ChildItem -LiteralPath $directory -Force -ErrorAction Stop)){ + if($records.Count-ge$MaximumEntries){throw 'CCOD_TEST_PRODUCT_SNAPSHOT_UNSAFE'} + $path=[IO.Path]::GetFullPath($item.FullName) + if(-not$path.StartsWith($prefix,[StringComparison]::OrdinalIgnoreCase)-or($item.Attributes-band[IO.FileAttributes]::ReparsePoint)-ne0){throw 'CCOD_TEST_PRODUCT_SNAPSHOT_UNSAFE'} + $relative=$path.Substring($prefix.Length).Replace('\','/') + if([string]::IsNullOrWhiteSpace($relative)-or-not$seen.Add($relative)){throw 'CCOD_TEST_PRODUCT_SNAPSHOT_UNSAFE'} + if($item.PSIsContainer){ + $records.Add([pscustomobject][ordered]@{path=$relative;kind='directory';attributes=[int]$item.Attributes;creationTimeUtc=$item.CreationTimeUtc.ToString('o',[Globalization.CultureInfo]::InvariantCulture);lastWriteTimeUtc=$item.LastWriteTimeUtc.ToString('o',[Globalization.CultureInfo]::InvariantCulture);length=$null;sha256=$null}) + $pending.Push($path) + }else{ + $length=[long]$item.Length + if($length-lt0-or$length-gt$MaximumFileBytes-or$total-gt($MaximumTotalBytes-$length)){throw 'CCOD_TEST_PRODUCT_SNAPSHOT_UNSAFE'} + $total+=$length + try{$streams=@(Get-Item -LiteralPath $path -Stream * -ErrorAction Stop|Where-Object{[string]$_.Stream-cnotin@(':$DATA','::$DATA','$DATA')})}catch{throw 'CCOD_TEST_PRODUCT_SNAPSHOT_UNSAFE'} + if($streams.Count-ne0){throw 'CCOD_TEST_PRODUCT_SNAPSHOT_UNSAFE'} + $records.Add([pscustomobject][ordered]@{path=$relative;kind='file';attributes=[int]$item.Attributes;creationTimeUtc=$item.CreationTimeUtc.ToString('o',[Globalization.CultureInfo]::InvariantCulture);lastWriteTimeUtc=$item.LastWriteTimeUtc.ToString('o',[Globalization.CultureInfo]::InvariantCulture);length=$length;sha256=Get-CcodTestFileSha256 -Path $path}) + } + } + } + $comparison=[System.Comparison[object]]{param($left,$right)[StringComparer]::Ordinal.Compare([string]$left.path,[string]$right.path)};$records.Sort($comparison) + return [pscustomobject][ordered]@{root=$full;exists=$true;rootAttributes=[int]$rootItem.Attributes;rootCreationTimeUtc=$rootItem.CreationTimeUtc.ToString('o',[Globalization.CultureInfo]::InvariantCulture);rootLastWriteTimeUtc=$rootItem.LastWriteTimeUtc.ToString('o',[Globalization.CultureInfo]::InvariantCulture);entryCount=$records.Count;totalBytes=$total;records=@($records)} +} + +Invoke-CcodTask5Test 'review-installed-package-contract' 'installed candidate reader accepts the actual sealed release package manifest' { + $fixture=$null;$integration=$null try { - $canonical = '2026-08-24T00:00:00.0000000Z' - $maliciousProvenance = ('{"schemaVersion":1,"product":"CodexRemote-fix","version":"2.5.0","gitCommit":"' + ('a' * 40) + '","buildTimestampUtc":123,"nested":{"buildTimestampUtc":"' + $canonical + '"}}') - [IO.File]::WriteAllText($fixture.TrayHost, $maliciousProvenance, [Text.UTF8Encoding]::new($false)) - $record = [IO.File]::ReadAllText($fixture.Manifest) | ConvertFrom-Json - $boundAsset = @($record.assets | Where-Object { $_.name -ceq [IO.Path]::GetFileName($fixture.TrayHost) }) - Assert-CcodEqual 1 $boundAsset.Count 'fixture manifest binds the TrayHost provenance asset once' - $boundAsset[0].sha256 = Get-CcodTestFileSha256 -Path $fixture.TrayHost - [IO.File]::WriteAllText($fixture.Manifest, ($record | ConvertTo-Json -Depth 8), [Text.UTF8Encoding]::new($false)) - Assert-CcodThrows { - Test-CcodReleaseAssetManifest -ManifestPath $fixture.Manifest -AssetDirectory $fixture.Root -ExpectedVersion '2.5.0' - } 'CCOD_RELEASE_MANIFEST_INVALID' + $fixture=New-CcodTask5ExactAssetFixture + $asset=Import-Module $assetContractPath -Force -PassThru + $contract=Test-CcodExactReleaseAssetSet -AssetDirectory $fixture.Root -Version '2.5.22' + Assert-CcodTrue $contract.Valid 'the real release validator accepts the fixture before the integration reader' + $integration=New-Module -ArgumentList (Join-Path $repositoryRoot 'tests/installed/Invoke-InstalledLifecycleIntegration.ps1') -ScriptBlock {param($Path). $Path -Library} + $path=Join-Path $fixture.Root $fixture.Names[5] + $result=&$integration {param($Path)Get-CcodInstalledLifecycleCandidate -Path $Path -ExpectedVersion '2.5.22' -Kind Installer -Adapters (Get-CcodInstalledLifecycleDefaultAdapters)} $path + Assert-CcodEqual (Get-CcodTestFileSha256 $path) $result.Sha256 'integration binds the same installer bytes' + $package=[IO.File]::ReadAllText((Join-Path $fixture.Root $fixture.Names[8]))|ConvertFrom-Json + Assert-CcodEqual ($package.files|ConvertTo-Json -Depth 8 -Compress) ($result.PayloadFiles|ConvertTo-Json -Depth 8 -Compress) 'runtime expectations use real package file records' + [IO.File]::WriteAllText((Join-Path $fixture.Root $fixture.Names[8]),'{}') + Assert-CcodThrows {&$integration {param($Path)Get-CcodInstalledLifecycleCandidate -Path $Path -ExpectedVersion '2.5.22' -Kind Installer -Adapters (Get-CcodInstalledLifecycleDefaultAdapters)} $path|Out-Null} 'CCOD_INTEGRATION_CANDIDATE_INVALID' } finally { - if (Test-Path -LiteralPath $fixture.Root) { Remove-Item -LiteralPath $fixture.Root -Recurse -Force } + if($null-ne$integration){Remove-Module $integration.Name -Force -ErrorAction SilentlyContinue} + if($null-ne$fixture){Remove-CcodTask5ExactAssetFixture $fixture} } } -Invoke-CcodTest 'release manifest binds the TrayHost provenance timestamp to its own timestamp' { - . $defenderPath -Library - $fixture = New-CcodReleaseFixture - try { - $mismatchedProvenance = ('{"schemaVersion":1,"product":"CodexRemote-fix","version":"2.5.0","gitCommit":"' + ('a' * 40) + '","buildTimestampUtc":"2026-08-24T00:00:01.0000000Z"}') - [IO.File]::WriteAllText($fixture.TrayHost, $mismatchedProvenance, [Text.UTF8Encoding]::new($false)) - $record = [IO.File]::ReadAllText($fixture.Manifest) | ConvertFrom-Json - $boundAsset = @($record.assets | Where-Object { $_.name -ceq [IO.Path]::GetFileName($fixture.TrayHost) }) - Assert-CcodEqual 1 $boundAsset.Count 'fixture manifest binds the TrayHost provenance asset once' - $boundAsset[0].sha256 = Get-CcodTestFileSha256 -Path $fixture.TrayHost - [IO.File]::WriteAllText($fixture.Manifest, ($record | ConvertTo-Json -Depth 8), [Text.UTF8Encoding]::new($false)) - Assert-CcodThrows { - Test-CcodReleaseAssetManifest -ManifestPath $fixture.Manifest -AssetDirectory $fixture.Root -ExpectedVersion '2.5.0' +Invoke-CcodTask5Test 'fix2-setup-file-hash-type' 'Setup file hashes reject singleton arrays with all dependent hashes rebound' { + $fixture = $null + $module = Import-Module $assetContractPath -Force -PassThru -DisableNameChecking + try { + $fixture = New-CcodTask5ExactAssetFixture -SetupHashAsArray + $package = [IO.File]::ReadAllText((Join-Path $fixture.Root $fixture.Names[8])) | ConvertFrom-Json + Assert-CcodTrue ($package.files[0].sha256 -is [array]) 'malformed hash remains an array after JSON round-trip' + Assert-CcodThrows { + Test-CcodReleaseAssetManifest -ManifestPath (Join-Path $fixture.Root $fixture.Names[10]) -AssetDirectory $fixture.Root -ExpectedVersion $fixture.Version | Out-Null } 'CCOD_RELEASE_MANIFEST_INVALID' } finally { - if (Test-Path -LiteralPath $fixture.Root) { Remove-Item -LiteralPath $fixture.Root -Recurse -Force } + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + if ($null -ne $fixture) { Remove-CcodTask5ExactAssetFixture $fixture } } } -Invoke-CcodTest 'Defender gate requires Internet Zone before a custom scan and writes a redacted receipt after a clean scan' { - . $defenderPath -Library - $fixture = New-CcodReleaseFixture - try { - $calls = [Collections.Generic.List[string]]::new() - $capture = [pscustomobject]@{ Value = $null } - $base = @{ - GetFileSha256 = { param($Path) $calls.Add('GetFileSha256'); Get-CcodTestFileSha256 -Path $Path }.GetNewClosure() - GetDefenderStatus = { $calls.Add('GetDefenderStatus'); [pscustomobject]@{ AMProductVersion = '4.18.26070.1'; AntivirusSignatureVersion = '1.999.1.0' } }.GetNewClosure() - StartCustomScan = { param($Path) $calls.Add('StartCustomScan') }.GetNewClosure() - GetThreatDetections = { $calls.Add('GetThreatDetections'); @() }.GetNewClosure() - GetUtcNow = { $calls.Add('GetUtcNow'); [datetime]::Parse('2026-08-24T00:00:00Z').ToUniversalTime() }.GetNewClosure() - WriteReceipt = { param($Path, $Receipt) $calls.Add('WriteReceipt'); $capture.Value = $Receipt; $Path }.GetNewClosure() - } - $blocked = @{} + $base - $blocked.GetZoneId = { param($Path) $calls.Add('GetZoneId'); $null }.GetNewClosure() - Assert-CcodThrows { - Invoke-CcodReleaseDefenderCheck -InstallerPath $fixture.Installer -ChecksumPath $fixture.Checksum -EvidencePath (Join-Path $fixture.Root 'receipt.json') -Adapters $blocked - } 'CCOD_DEFENDER_ZONE_REQUIRED' - Assert-CcodTrue (-not ($calls -contains 'StartCustomScan')) 'missing Internet Zone blocks Defender scan' - - $calls.Clear() - $capture.Value = $null - $clean = @{} + $base - $clean.GetZoneId = { param($Path) $calls.Add('GetZoneId'); 3 }.GetNewClosure() - $receipt = Invoke-CcodReleaseDefenderCheck -InstallerPath $fixture.Installer -ChecksumPath $fixture.Checksum -EvidencePath (Join-Path $fixture.Root 'receipt.json') -Adapters $clean - Assert-CcodEqual 0 ([int]$receipt.detectionCount) 'clean scan records no detections' - Assert-CcodTrue ($calls -contains 'StartCustomScan') 'clean verified asset invokes the custom scan' - $serialized = $capture.Value | ConvertTo-Json -Depth 8 -Compress - Assert-CcodTrue (-not $serialized.Contains($fixture.Root)) 'Defender receipt does not persist the private artifact path' - Assert-CcodTrue ($serialized.Contains($fixture.Installer.Substring(0,0))) 'receipt is serializable' - - $calls.Clear() - $capture.Value = $null - $threatCalls = [pscustomobject]@{ Count = 0 } - $detected = @{} + $base - $detected.GetZoneId = { param($Path) $calls.Add('GetZoneId'); 3 }.GetNewClosure() - $detected.GetThreatDetections = { - $calls.Add('GetThreatDetections') - $threatCalls.Count++ - if ($threatCalls.Count -eq 1) { return @() } - return @([pscustomobject]@{ ThreatID = 99; InitialDetectionTime = '2026-08-24T00:00:00.0000000Z'; Resources = 'redacted-by-gate' }) - }.GetNewClosure() - Assert-CcodThrows { - Invoke-CcodReleaseDefenderCheck -InstallerPath $fixture.Installer -ChecksumPath $fixture.Checksum -EvidencePath (Join-Path $fixture.Root 'detection.json') -Adapters $detected - } 'CCOD_DEFENDER_DETECTIONS_FOUND' - Assert-CcodEqual 'Failed' ([string]$capture.Value.outcome) 'detection receipt records failure' - Assert-CcodEqual 1 ([int]$capture.Value.detectionCount) 'detection receipt counts only the newly observed detection' - } finally { - if (Test-Path -LiteralPath $fixture.Root) { Remove-Item -LiteralPath $fixture.Root -Recurse -Force } +Invoke-CcodTask6Test 'fix2-selector' 'release test selection rejects inherited filters and never reports a partial run as full success' { + $selfTest = Join-Path $repositoryRoot 'tests\persistence\ReleaseWorkflow.SelfTest.ps1' + $runChild = { + param([hashtable]$Environment,[string]$Focus) + $start = [Diagnostics.ProcessStartInfo]::new() + $start.FileName = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + $start.Arguments = '-NoLogo -NoProfile -File "' + $selfTest + '"' + if ($Focus) { $start.Arguments += ' -FocusedCase "' + $Focus + '"' } + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + foreach ($name in @('CCOD_TASK5_RED_CASE','CCOD_TASK6_RED_CASE')) { $start.EnvironmentVariables.Remove($name) } + foreach ($name in $Environment.Keys) { $start.EnvironmentVariables[$name] = $Environment[$name] } + $start.EnvironmentVariables.Remove('PSExecutionPolicyPreference') + Assert-CcodTrue ($start.Arguments -notmatch '-ExecutionPolicy') 'selector child does not override normal execution policy' + Assert-CcodTrue (-not $start.EnvironmentVariables.ContainsKey('PSExecutionPolicyPreference')) 'selector child does not inherit parent process policy' + $child = [Diagnostics.Process]::new() + $child.StartInfo = $start + try { + [void]$child.Start() + $stdout = $child.StandardOutput.ReadToEndAsync() + $stderr = $child.StandardError.ReadToEndAsync() + if (-not $child.WaitForExit(60000)) { $child.Kill(); $child.WaitForExit(); throw 'selector child timed out' } + [pscustomobject]@{ ExitCode = $child.ExitCode; Output = $stdout.GetAwaiter().GetResult() + $stderr.GetAwaiter().GetResult() } + } finally { $child.Dispose() } + }.GetNewClosure() + foreach ($environment in @( + @{ CCOD_TASK5_RED_CASE = 'unknown-case' }, + @{ CCOD_TASK6_RED_CASE = 'fix1-absolute-tag-anchor' }, + @{ CCOD_TASK5_RED_CASE = ' '; CCOD_TASK6_RED_CASE = 'unknown-case' } + )) { + $result = & $runChild $environment '' + Assert-CcodTrue ($result.ExitCode -ne 0) 'inherited selectors fail rather than silently skipping release gates' + Assert-CcodTrue ($result.Output.Contains('CCOD_RELEASE_TEST_SELECTION_INVALID')) 'inherited selector failure has a stable marker' + Assert-CcodTrue (-not $result.Output.Contains('Release workflow self-tests passed')) 'rejected run never reports full success' } + $unknown = & $runChild @{} 'Task6:unknown-case' + Assert-CcodTrue ($unknown.ExitCode -ne 0 -and $unknown.Output.Contains('CCOD_RELEASE_TEST_SELECTION_INVALID')) 'unknown explicit selection is rejected before running tests' + $focused = & $runChild @{} 'Task6:fix1-absolute-tag-anchor' + Assert-CcodEqual 0 $focused.ExitCode 'known explicit focused case executes successfully' + Assert-CcodTrue ($focused.Output.Contains('CCOD_RELEASE_FOCUSED_TESTS_PASSED')) 'focused mode identifies itself separately' + Assert-CcodTrue (-not $focused.Output.Contains('Release workflow self-tests passed')) 'focused mode cannot masquerade as a full release suite' } -Invoke-CcodTest 'package scripts build provenance and workflows retain the release-contract gates' { - $package = Get-Content -LiteralPath (Join-Path $repositoryRoot 'package.json') -Raw | ConvertFrom-Json - Assert-CcodTrue ($null -ne $package.scripts.'test:installed-lifecycle') 'package exposes installed-lifecycle deterministic tests' - Assert-CcodTrue ($null -ne $package.scripts.'test:release-contract') 'package exposes release contract tests' - $build = Get-Content -LiteralPath (Join-Path $repositoryRoot 'build\build.ps1') -Raw - Assert-CcodTrue ($build -match 'release-manifest\.json') 'build emits a release manifest' - Assert-CcodTrue ($build -match 'gitCommit') 'build records the source commit' - Assert-CcodTrue ($build -match 'buildTimestampUtc') 'build records a canonical build timestamp' - Assert-CcodTrue ($build -match 'does not match package\.json version') 'build refuses a requested version that differs from package metadata' - Assert-CcodTrue ($build -match 'status --porcelain --untracked-files=all') 'build refuses a dirty candidate checkout' - Assert-CcodTrue ($build -match 'Test-CcodReleaseAssetManifest') 'build verifies its own immutable release manifest before success' - $trayHost = Get-Content -LiteralPath (Join-Path $repositoryRoot 'build\TrayHostBuild.psm1') -Raw - Assert-CcodTrue ($trayHost -match 'gitCommit') 'TrayHost provenance binds its source commit' - Assert-CcodTrue ($trayHost -match 'buildTimestampUtc') 'TrayHost provenance binds its build timestamp' - $ci = Get-Content -LiteralPath (Join-Path $repositoryRoot '.github\workflows\ci.yml') -Raw - Assert-CcodTrue ($ci -match 'UninstallBootstrap\.SelfTest\.ps1') 'CI runs the external uninstall bootstrap self-test before aggregate validation' - Assert-CcodTrue ($ci -match 'test:installed-lifecycle') 'CI runs deterministic installed lifecycle tests' - Assert-CcodTrue ($ci -match 'test:release-contract') 'CI runs release contract tests' - $release = Get-Content -LiteralPath (Join-Path $repositoryRoot '.github\workflows\release.yml') -Raw - Assert-CcodTrue ($release -cmatch '(?ms)^\s*workflow_dispatch:\r?\n\s+inputs:\r?\n\s+tag:') 'manual release dispatch requires an explicit tag input' - Assert-CcodTrue ($release -match 'CCOD_RELEASE_TAG') 'release jobs derive their version from the validated release tag' - Assert-CcodTrue ($release -match 'build\.ps1 -Version') 'release build binds the candidate version to the validated tag' - Assert-CcodTrue ($release -match 'download-artifact') 'release promotion downloads a previously built candidate' - Assert-CcodTrue ($release -match 'test:release-contract') 'release promotion checks the release contract' - Assert-CcodTrue ($release -match 'release-manifest') 'release promotion uploads the bound release manifest' - Assert-CcodTrue ($release -match 'gh release download') 'existing release assets are downloaded before any publication decision' - Assert-CcodTrue (-not ($release -match 'gh release upload[^\r\n]*--clobber')) 'release publication never overwrites an existing asset' - Assert-CcodTrue ($release -match 'Read back published GitHub release assets') 'release publication re-downloads every uploaded asset for hash read-back' - Assert-CcodTrue ($release -cmatch '(?ms)^permissions:\r?\n\s+contents: read\s*$') 'candidate build starts with read-only repository permission' - Assert-CcodTrue ($release -cmatch '(?ms)^ publish:\r?\n needs: build\r?\n runs-on: windows-latest\r?\n permissions:\r?\n contents: write\s*$') 'only the publish job receives release-write permission' +Invoke-CcodTask5Test 'fix1-public' 'public Defender surfaces cannot accept adapters or enter library mode' { + $cli=Get-Command $defenderPath -ErrorAction Stop + Assert-CcodTrue (-not$cli.Parameters.ContainsKey('Library')) 'Defender CLI exposes no library bypass' + Assert-CcodTrue (-not$cli.Parameters.ContainsKey('Adapters')) 'Defender CLI exposes no adapter injection parameter' + foreach($name in @('CandidatePath','ChecksumPath','ManifestPath','Origin','WorkflowArtifactIdentity','ExpectedVersion','ExpectedGitCommit','EvidencePath')){Assert-CcodTrue $cli.Parameters.ContainsKey($name) "Defender CLI retains required public parameter $name"} + Assert-CcodTrue (Test-Path -LiteralPath $releaseDefenderModulePath -PathType Leaf) 'release Defender module exists' + $module=Import-Module $releaseDefenderModulePath -Force -PassThru -DisableNameChecking + try{ + Assert-CcodEqual 'Invoke-CcodReleaseDefenderCheck' ((@($module.ExportedCommands.Keys|Sort-Object))-join',') 'release Defender module exports only the public checker' + $public=Get-Command Invoke-CcodReleaseDefenderCheck -Module $module.Name -ErrorAction Stop + Assert-CcodTrue (-not$public.Parameters.ContainsKey('Adapters')) 'public Defender function exposes no adapter injection parameter' + Assert-CcodThrows {&$defenderPath -Library} 'NamedParameterNotFound' + Assert-CcodThrows {Invoke-CcodReleaseDefenderCheck -CandidatePath 'x' -ChecksumPath 'x' -ManifestPath 'x' -Origin InternetDownload -ExpectedVersion '2.5.22' -ExpectedGitCommit ('a'*40) -EvidencePath 'x' -Adapters @{}} 'NamedParameterNotFound' + }finally{Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue} } -$iss = Get-Content -LiteralPath (Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss') -Raw; Assert-CcodTrue ($iss -match 'PortableArtifactDirectory' -and $iss -match 'CodexRemote.Portable.exe' -and $iss -match 'portable-launcher-provenance.json') 'Inno installer packages the portable launcher into the verified bin set'; -Invoke-CcodTest '2.5.21 documentation matches the stable portable release, Defender gate, and protected uninstall contract' { - $package = Get-Content -LiteralPath (Join-Path $repositoryRoot 'package.json') -Raw | ConvertFrom-Json - Assert-CcodEqual '2.5.21' ([string]$package.version) 'package metadata is the 2.5.21 release' - $changelog = Get-Content -LiteralPath (Join-Path $repositoryRoot 'CHANGELOG.md') -Raw - $releaseSection = [regex]::Match($changelog, '(?ms)^## v2\.5\.21\s*\r?\n(?.*?)(?=^## |\z)') - Assert-CcodTrue $releaseSection.Success 'v2.5.21 release section exists' - Assert-CcodTrue ($releaseSection.Groups['body'].Value -match '(?m)^### English\s*$') 'v2.5.21 changelog has concise English release notes' - Assert-CcodTrue ($releaseSection.Groups['body'].Value.Contains('stale-status recovery')) 'v2.5.21 changelog records stale-status repair' - Assert-CcodTrue ($releaseSection.Groups['body'].Value.Contains('safe lifecycle diagnostics')) 'v2.5.21 changelog records exact safe diagnostics' - Assert-CcodTrue (-not $releaseSection.Groups['body'].Value.Contains('installed Supervisor and TrayHost lifecycle evidence')) 'v2.5.21 changelog omits the unsupported Supervisor and TrayHost validation claim' - $readme = Get-Content -LiteralPath (Join-Path $repositoryRoot 'README.md') -Raw - $quickStart = [regex]::Match($readme, '(?ms)^## Quick start\s*\r?\n(?.*?)(?=^## |\z)').Groups['body'].Value - Assert-CcodTrue ($readme.Contains('v2.5.21 is the current stable release')) 'English README marks v2.5.21 stable without a version-by-version What''s new block' - Assert-CcodTrue (-not $readme.Contains("## What's new")) 'English README keeps release details off the home page' - Assert-CcodTrue ($quickStart.Contains('CodexRemote-fix-2.5.21-setup.exe')) 'English Quick Start names the setup installer' - Assert-CcodTrue ($quickStart.Contains('CodexRemote-fix-2.5.21-windows-x64.zip')) 'English Quick Start names the portable ZIP' - Assert-CcodTrue ($quickStart.Contains('CodexRemote-fix.exe')) 'English Quick Start names the portable double-click entrypoint' - Assert-CcodTrue ($quickStart.Contains('Microsoft Defender')) 'English Quick Start documents the local Defender gate' - $readmeZh = Get-Content -LiteralPath (Join-Path $repositoryRoot 'README.zh-CN.md') -Raw -Encoding UTF8 - Assert-CcodTrue ($readmeZh -match 'v2\.5\.21 \u662F\u5F53\u524D\u7A33\u5B9A\u7248') 'Chinese README marks v2.5.21 stable' - Assert-CcodTrue (-not $readmeZh.Contains("## What's new")) 'Chinese README keeps release details off the home page' - Assert-CcodTrue ($readmeZh.Contains('CodexRemote-fix-2.5.21-setup.exe')) 'Chinese Quick Start names the setup installer' - Assert-CcodTrue ($readmeZh.Contains('CodexRemote-fix-2.5.21-windows-x64.zip')) 'Chinese Quick Start names the portable ZIP' - Assert-CcodTrue ($readmeZh.Contains('CodexRemote-fix.exe')) 'Chinese Quick Start names the portable double-click entrypoint' - Assert-CcodTrue ($readmeZh.Contains('Microsoft Defender')) 'Chinese Quick Start documents the local Defender gate' - $technical = Get-Content -LiteralPath (Join-Path $repositoryRoot 'docs\TECHNICAL.md') -Raw - Assert-CcodTrue ($technical.Contains('PortableUninstallFinalizer.ps1')) 'technical documentation records the staged portable finalizer' - $security = Get-Content -LiteralPath (Join-Path $repositoryRoot 'SECURITY.md') -Raw - Assert-CcodTrue ($security.Contains('does not disable Defender or add exclusions')) 'security documentation forbids Defender weakening' -<# - $package = Get-Content -LiteralPath (Join-Path $repositoryRoot 'package.json') -Raw | ConvertFrom-Json - Assert-CcodEqual '2.5.5' ([string]$package.version) 'package metadata is the 2.5.5 release' +Invoke-CcodTask5Test 'assets' 'release asset contract owns the exact ordered eleven-name set and rejects every mutation' { + Assert-CcodTrue (Test-Path -LiteralPath $assetContractPath -PathType Leaf) 'central release asset contract module exists' + $assetModule=Import-Module $assetContractPath -Force -PassThru -DisableNameChecking + $template=$null + try{ + $expected=Get-CcodTask5ExpectedAssetNames '2.5.22';$actual=@(Get-CcodExpectedReleaseAssetNames -Version '2.5.22') + Assert-CcodEqual ($expected-join'|') ($actual-join'|') 'asset contract returns the literal ordered eleven-name set' + $template=New-CcodTask5ExactAssetFixture;$validated=Test-CcodExactReleaseAssetSet -AssetDirectory $template.Root -Version $template.Version;Assert-CcodEqual $template.GitCommit ([string]$validated.GitCommit) 'exact set returns the common 40-hex commit';Assert-CcodEqual $template.Timestamp ([string]$validated.BuildTimestampUtc) 'exact set returns the common timestamp';Assert-CcodEqual ($expected-join'|') ((@($validated.Assets.name))-join'|') 'exact set returns all public hashes in contract order' + foreach($missing in $expected){$fixture=Copy-CcodTask5ExactAssetFixture $template;try{Remove-Item -LiteralPath (Join-Path $fixture.Root $missing) -Force;Assert-CcodThrows {Test-CcodExactReleaseAssetSet -AssetDirectory $fixture.Root -Version $fixture.Version|Out-Null} 'CCOD_RELEASE_ASSET_SET_INVALID'}finally{Remove-CcodTask5ExactAssetFixture $fixture}} + foreach($mutation in @('Extra','CaseVaried','Directory','Reparse','UnsafeAncestry','NoncanonicalRoot','DuplicateTopProperty','DuplicateNestedProperty','DuplicateEscapedProperty','DuplicateManifest','ManifestOrder','CommitMismatch','TimestampMismatch','SharedProvenanceMismatch')){ + $fixture=Copy-CcodTask5ExactAssetFixture $template + try{ + switch($mutation){ + 'Extra'{[IO.File]::WriteAllText((Join-Path $fixture.Root 'unexpected.txt'),'x',[Text.UTF8Encoding]::new($false))} + 'CaseVaried'{$source=Join-Path $fixture.Root $fixture.Names[0];$temporary=$source+'.case';Move-Item -LiteralPath $source -Destination $temporary;Move-Item -LiteralPath $temporary -Destination (Join-Path $fixture.Root $fixture.Names[0].ToUpperInvariant())} + 'Directory'{Remove-Item -LiteralPath (Join-Path $fixture.Root $fixture.Names[3]) -Force;[IO.Directory]::CreateDirectory((Join-Path $fixture.Root $fixture.Names[3]))|Out-Null} + 'Reparse'{Remove-Item -LiteralPath (Join-Path $fixture.Root $fixture.Names[3]) -Force;$target=Join-Path $fixture.Outside 'target';[IO.Directory]::CreateDirectory($target)|Out-Null;$previous=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$out=&cmd.exe /d /c mklink /J (Join-Path $fixture.Root $fixture.Names[3]) $target 2>&1;$code=$LASTEXITCODE}finally{$ErrorActionPreference=$previous};if($code-ne0){throw "junction fixture failed: $($out-join' ')"}} + 'UnsafeAncestry'{$parent=Join-Path $fixture.Outside 'parent';[IO.Directory]::CreateDirectory($parent)|Out-Null;$link=Join-Path $parent 'assets';$previous=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$out=&cmd.exe /d /c mklink /J $link $fixture.Root 2>&1;$code=$LASTEXITCODE}finally{$ErrorActionPreference=$previous};if($code-ne0){throw "ancestry fixture failed: $($out-join' ')"};$fixture.Root=$link} + 'NoncanonicalRoot'{$fixture.Root=[string]$fixture.Root+'\.'} + 'DuplicateTopProperty'{$manifestPath=Join-Path $fixture.Root $fixture.Names[4];$raw=[IO.File]::ReadAllText($manifestPath);$raw=[regex]::Replace($raw,'"schemaVersion"\s*:\s*2','"schemaVersion": 2, "schemaVersion": 2',1);[IO.File]::WriteAllText($manifestPath,$raw,[Text.UTF8Encoding]::new($false))} + 'DuplicateNestedProperty'{$manifestPath=Join-Path $fixture.Root $fixture.Names[4];$raw=[IO.File]::ReadAllText($manifestPath);$raw=[regex]::Replace($raw,'"name"\s*:\s*"([^"]+)"','"name": "$1", "name": "$1"',1);[IO.File]::WriteAllText($manifestPath,$raw,[Text.UTF8Encoding]::new($false))} + 'DuplicateEscapedProperty'{$manifestPath=Join-Path $fixture.Root $fixture.Names[4];$raw=[IO.File]::ReadAllText($manifestPath);$raw=[regex]::Replace($raw,'"gitCommit"\s*:\s*"([^"]+)"','"gitCommit": "$1", "\u0067itCommit": "$1"',1);[IO.File]::WriteAllText($manifestPath,$raw,[Text.UTF8Encoding]::new($false))} + default{$manifestPath=Join-Path $fixture.Root $fixture.Names[4];$manifest=[IO.File]::ReadAllText($manifestPath)|ConvertFrom-Json;if($mutation-ceq'DuplicateManifest'){$manifest.assets=@($manifest.assets)+@($manifest.assets[0])}elseif($mutation-ceq'ManifestOrder'){$manifest.assets=@($manifest.assets[1],$manifest.assets[0])+@($manifest.assets|Select-Object -Skip 2)}elseif($mutation-ceq'CommitMismatch'){$manifest.gitCommit='d'*40}elseif($mutation-ceq'TimestampMismatch'){$manifest.buildTimestampUtc='2030-02-03T04:05:07.0000000Z'}else{$entry=@($manifest.assets|Where-Object{$_.name-ceq$fixture.Names[2]})[0];$entry.sha256='f'*64};[IO.File]::WriteAllText($manifestPath,($manifest|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false))} + } + Assert-CcodThrows {Test-CcodExactReleaseAssetSet -AssetDirectory $fixture.Root -Version $fixture.Version|Out-Null} 'CCOD_RELEASE_ASSET_SET_INVALID' + }finally{Remove-CcodTask5ExactAssetFixture $fixture} + } + }finally{if($null-ne$template){Remove-CcodTask5ExactAssetFixture $template};if($null-ne$assetModule){Remove-Module -Name $assetModule.Name -Force -ErrorAction SilentlyContinue}} +} - $changelog = Get-Content -LiteralPath (Join-Path $repositoryRoot 'CHANGELOG.md') -Raw - $releaseSection = [regex]::Match($changelog, '(?ms)^## v2\.5\.5\s*\r?\n(?.*?)(?=^## |\z)') - Assert-CcodTrue $releaseSection.Success 'v2.5.5 release section exists' - $releaseBody = $releaseSection.Groups['body'].Value - Assert-CcodTrue ($releaseBody -match '(?m)^### English\s*$') 'v2.5.5 release section is English' - Assert-CcodTrue ($releaseBody.Contains('Added a tightly scoped compatibility inspection for an older manifest-sealed controller that omitted its `ProcessControl` import. It accepts only the exact correlated legacy failure and requires a manifest-verified read-only ordinary-session recheck immediately before each protected uninstall deletion boundary.')) 'v2.5.5 legacy controller compatibility release bullet is exact' - Assert-CcodTrue ($releaseBody.Contains('New `SessionController` runtimes now load `ProcessControl` globally, and regression coverage rejects every other controller failure or changed compatibility proof.')) 'v2.5.5 regression release bullet is exact' +Invoke-CcodTask5Test 'fix1-deep' 'exact asset authority rejects self-consistent nested portable and Setup corruption' { + $module=Import-Module $assetContractPath -Force -PassThru -DisableNameChecking + $template=$null + try{ + $template=New-CcodTask5ExactAssetFixture + foreach($mutation in @('PortablePayloadLength','PortablePayloadHash','PortableZipRootLauncher','PortableZipExtra','TrayProvenanceBogusNested','SetupPackagePayloadMismatch','SetupProvenanceBogusNested','SetupProvenanceLength','SetupInventoryDirective')){ + $fixture=Copy-CcodTask5ExactAssetFixture $template + try{ + switch($mutation){ + 'PortablePayloadLength'{$path=Join-Path $fixture.Root $fixture.Names[3];$record=[IO.File]::ReadAllText($path)|ConvertFrom-Json;$record.files[0].length=[int64]$record.files[0].length+1;Write-CcodTask5Json -Path $path -Value $record;Sync-CcodTask5OuterAssetHash -Fixture $fixture -Distribution Portable -AssetName $fixture.Names[3]} + 'PortablePayloadHash'{$path=Join-Path $fixture.Root $fixture.Names[3];$record=[IO.File]::ReadAllText($path)|ConvertFrom-Json;$record.files[0].sha256='0'*64;Write-CcodTask5Json -Path $path -Value $record;Sync-CcodTask5OuterAssetHash -Fixture $fixture -Distribution Portable -AssetName $fixture.Names[3]} + 'PortableZipRootLauncher'{$zipPath=Join-Path $fixture.Root $fixture.Names[0];$archive=[IO.Compression.ZipFile]::Open($zipPath,[IO.Compression.ZipArchiveMode]::Update);try{$archive.GetEntry('CodexRemote-fix.exe').Delete();$entry=$archive.CreateEntry('CodexRemote-fix.exe');$stream=$entry.Open();try{$bytes=[byte[]](9,9,9);$stream.Write($bytes,0,$bytes.Length)}finally{$stream.Dispose()}}finally{$archive.Dispose()};[IO.File]::WriteAllText((Join-Path $fixture.Root $fixture.Names[1]),((Get-CcodTestFileSha256 $zipPath)+' *'+$fixture.Names[0]),[Text.UTF8Encoding]::new($false));Sync-CcodTask5OuterAssetHash $fixture Portable $fixture.Names[0];Sync-CcodTask5OuterAssetHash $fixture Portable $fixture.Names[1]} + 'PortableZipExtra'{$zipPath=Join-Path $fixture.Root $fixture.Names[0];$archive=[IO.Compression.ZipFile]::Open($zipPath,[IO.Compression.ZipArchiveMode]::Update);try{$entry=$archive.CreateEntry('unexpected.txt');$stream=$entry.Open();try{$stream.WriteByte(1)}finally{$stream.Dispose()}}finally{$archive.Dispose()};[IO.File]::WriteAllText((Join-Path $fixture.Root $fixture.Names[1]),((Get-CcodTestFileSha256 $zipPath)+' *'+$fixture.Names[0]),[Text.UTF8Encoding]::new($false));Sync-CcodTask5OuterAssetHash $fixture Portable $fixture.Names[0];Sync-CcodTask5OuterAssetHash $fixture Portable $fixture.Names[1]} + 'TrayProvenanceBogusNested'{$path=Join-Path $fixture.Root $fixture.Names[2];$record=[IO.File]::ReadAllText($path)|ConvertFrom-Json;$record.compiler|Add-Member -NotePropertyName bogus -NotePropertyValue 'accepted';Write-CcodTask5Json -Path $path -Value $record;Sync-CcodTask5OuterAssetHash $fixture Portable $fixture.Names[2];Sync-CcodTask5OuterAssetHash $fixture Setup $fixture.Names[2]} + 'SetupPackagePayloadMismatch'{$path=Join-Path $fixture.Root $fixture.Names[8];$record=[IO.File]::ReadAllText($path)|ConvertFrom-Json;$record.payloadManifest.sha256='0'*64;Write-CcodTask5Json -Path $path -Value $record;Sync-CcodTask5OuterAssetHash $fixture Setup $fixture.Names[8]} + 'SetupProvenanceBogusNested'{$path=Join-Path $fixture.Root $fixture.Names[7];$record=[IO.File]::ReadAllText($path)|ConvertFrom-Json;$record.installerPackageManifest|Add-Member -NotePropertyName bogus -NotePropertyValue 'accepted';Write-CcodTask5Json -Path $path -Value $record;Sync-CcodTask5OuterAssetHash -Fixture $fixture -Distribution Setup -AssetName $fixture.Names[7]} + 'SetupProvenanceLength'{$path=Join-Path $fixture.Root $fixture.Names[7];$record=[IO.File]::ReadAllText($path)|ConvertFrom-Json;$record.installerPackageManifest.length=[int64]$record.installerPackageManifest.length+1;Write-CcodTask5Json -Path $path -Value $record;Sync-CcodTask5OuterAssetHash $fixture Setup $fixture.Names[7]} + 'SetupInventoryDirective'{$path=Join-Path $fixture.Root $fixture.Names[9];[IO.File]::AppendAllText($path,"#include 'foreign.iss'`r`n",[Text.UTF8Encoding]::new($false));Sync-CcodTask5OuterAssetHash -Fixture $fixture -Distribution Setup -AssetName $fixture.Names[9]} + } + Assert-CcodThrows {Test-CcodExactReleaseAssetSet -AssetDirectory $fixture.Root -Version $fixture.Version|Out-Null} 'CCOD_RELEASE_ASSET_SET_INVALID' + }finally{Remove-CcodTask5ExactAssetFixture $fixture} + } + }finally{if($null-ne$template){Remove-CcodTask5ExactAssetFixture $template};Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue} +} - $readme = Get-Content -LiteralPath (Join-Path $repositoryRoot 'README.md') -Raw - $quickStart = [regex]::Match($readme, '(?ms)^## Quick start\s*\r?\n(?.*?)(?=^## |\z)').Groups['body'].Value - Assert-CcodTrue ($quickStart.Contains('CodexRemote-fix-2.5.5-setup.exe')) 'English Quick Start names the 2.5.5 installer' - Assert-CcodTrue ($readme.Contains('## Connection and protection status')) 'English README documents the current status contract' - foreach ($state in @('Waiting for Codex', 'Checking', 'Connected', 'Repair needed', 'Error', 'Running', 'Reconnecting', 'Stopping')) { - Assert-CcodTrue ($readme.Contains($state)) "English README documents state: $state" +Invoke-CcodTask5Test 'fix1-handle-scan' 'Defender scan holds candidate identity against same-byte ABA writes' { + $fixture=New-CcodTask5ExactAssetFixture + try{ + $candidate=Join-Path $fixture.Root $fixture.Names[5];$checksum=Join-Path $fixture.Root $fixture.Names[6];$manifest=Join-Path $fixture.Root $fixture.Names[10];Set-Content -LiteralPath $candidate -Stream Zone.Identifier -Value "[ZoneTransfer]`r`nZoneId=3`r`n" -NoNewline + $probe=[pscustomobject]@{Blocked=$false;Mutated=$false};$case=New-CcodTask5DefenderAdapterFixture + $case.Adapters.StartCustomScan={param($Path)$case.State.Calls.Add('Scan');$stream=$null;try{$stream=[IO.File]::Open($Path,[IO.FileMode]::Open,[IO.FileAccess]::ReadWrite,[IO.FileShare]::ReadWrite);$first=$stream.ReadByte();$stream.Position=0;$stream.WriteByte((($first+1)-band255));$stream.Flush($true);$stream.Position=0;$stream.WriteByte($first);$stream.Flush($true);$probe.Mutated=$true}catch [IO.IOException] {$probe.Blocked=$true}finally{if($null-ne$stream){$stream.Dispose()}}}.GetNewClosure() + $receipt=Invoke-CcodTask5DefenderCore -CandidatePath $candidate -ChecksumPath $checksum -ManifestPath $manifest -Origin InternetDownload -ExpectedVersion $fixture.Version -ExpectedGitCommit $fixture.GitCommit -EvidencePath (Join-Path $fixture.Outside 'scan-aba.json') -Adapters $case.Adapters + Assert-CcodEqual 'Completed' ([string]$receipt.outcome) 'clean fake scan reaches a completed receipt' + Assert-CcodTrue $probe.Blocked 'candidate write is denied for the entire scan and receipt decision' + Assert-CcodTrue (-not$probe.Mutated) 'same-byte ABA mutation never obtains a writable handle' + }finally{Remove-CcodTask5ExactAssetFixture $fixture} +} + +Invoke-CcodTask5Test 'fix1-handle-post' 'Defender holds candidate identity through receipt publication and readback' { + $fixture=New-CcodTask5ExactAssetFixture + try{ + $candidate=Join-Path $fixture.Root $fixture.Names[5];$checksum=Join-Path $fixture.Root $fixture.Names[6];$manifest=Join-Path $fixture.Root $fixture.Names[10];$probe=[pscustomobject]@{Blocked=$false;Replaced=$false};$backup=$candidate+'.held-original';$case=New-CcodTask5DefenderAdapterFixture;$realPublish=(Get-CcodTask5DefaultDefenderAdapters).PublishReceiptBytes + $case.Adapters.PublishReceiptBytes={param($Directory,$Leaf,$Bytes)$case.State.Calls.Add('Write');try{[IO.File]::Move($candidate,$backup);[IO.File]::Copy($backup,$candidate,$false);$probe.Replaced=$true}catch [IO.IOException] {$probe.Blocked=$true};&$realPublish $Directory $Leaf $Bytes}.GetNewClosure() + $receipt=Invoke-CcodTask5DefenderCore -CandidatePath $candidate -ChecksumPath $checksum -ManifestPath $manifest -Origin TrustedWorkflowArtifact -WorkflowArtifactIdentity (New-CcodTask5WorkflowIdentity $fixture.GitCommit) -ExpectedVersion $fixture.Version -ExpectedGitCommit $fixture.GitCommit -EvidencePath (Join-Path $fixture.Outside 'post-revalidate.json') -Adapters $case.Adapters + Assert-CcodEqual 'Completed' ([string]$receipt.outcome) 'clean fake scan reaches receipt publication' + Assert-CcodTrue $probe.Blocked 'candidate replacement is denied through durable receipt readback' + Assert-CcodTrue (-not$probe.Replaced) 'post-revalidation same-byte replacement never changes file identity' + }finally{ + if(Test-Path -LiteralPath $backup){if(Test-Path -LiteralPath $candidate){Remove-Item -LiteralPath $candidate -Force};Move-Item -LiteralPath $backup -Destination $candidate} + Remove-CcodTask5ExactAssetFixture $fixture } - Assert-CcodTrue ($readme.Contains('Restart now')) 'English README explains Restart now' - Assert-CcodTrue ($readme.Contains('Later')) 'English README explains Later' - Assert-CcodTrue ($readme.Contains('Safe Exit')) 'English README explains safe Exit' - Assert-CcodTrue ($readme.Contains('The tray has no uninstall command.')) 'English README excludes tray uninstall' - Assert-CcodTrue ($readme.Contains('Windows Settings')) 'English README documents Windows Settings uninstall' - Assert-CcodTrue ($readme.Contains('unins000.exe')) 'English README documents direct uninstaller' - Assert-CcodTrue (-not $readme.Contains('Automation, Candidate-compatible trial, Logs, and Uninstall')) 'English README no longer describes removed tray toggles' +} - $readmeZh = Get-Content -LiteralPath (Join-Path $repositoryRoot 'README.zh-CN.md') -Raw -Encoding UTF8 - $quickStartZh = [regex]::Match($readmeZh, '(?ms)^## \u5FEB\u901F\u5F00\u59CB\s*\r?\n(?.*?)(?=^## |\z)').Groups['body'].Value - Assert-CcodTrue ($quickStartZh.Contains('CodexRemote-fix-2.5.5-setup.exe')) 'Chinese Quick Start names the 2.5.5 installer' - Assert-CcodTrue ($readmeZh -match '## \u8FDE\u63A5\u4E0E\u5B88\u62A4\u72B6\u6001') 'Chinese README documents the current status contract' - foreach ($statePattern in @('\u7B49\u5F85 Codex', '\u6B63\u5728\u68C0\u67E5', '\u5DF2\u8FDE\u63A5', '\u9700\u8981\u4FEE\u590D', '\u9519\u8BEF', '\u8FD0\u884C\u4E2D', '\u6B63\u5728\u91CD\u8FDE', '\u6B63\u5728\u505C\u6B62')) { - Assert-CcodTrue ($readmeZh -match $statePattern) "Chinese README documents state pattern: $statePattern" +Invoke-CcodTask5Test 'fix1-handle-promotion' 'promotion holds all asset and receipt identities through its final decision' { + $module=Import-Module $assetContractPath -Force -PassThru -DisableNameChecking + $assetFixture=$null;$evidence=$null + try{ + Assert-CcodTrue ($null-ne(&$module {Get-Command Test-CcodReleasePromotionEvidenceCore -ErrorAction SilentlyContinue})) 'asset module has a private promotion core for controlled replacement tests' + $source=[IO.File]::ReadAllText($assetContractPath,[Text.UTF8Encoding]::new($false)) + Assert-CcodTrue (([regex]::Matches($source,'foreach\(\$directoryAuthority in @\(\$heldFileDirectories\)\)')).Count -ge 2) 'promotion rechecks every held parent directory before and after the visibility callback' + $assetFixture=New-CcodTask5ExactAssetFixture;$evidence=New-CcodTask5PromotionFixture $assetFixture;$probe=[pscustomobject]@{AssetBlocked=$false;AssetReplaced=$false;ReceiptBlocked=$false;ReceiptReplaced=$false} + $assetBackup=(Join-Path $assetFixture.Root $assetFixture.Names[0])+'.original';$receiptPath=Join-Path $evidence.Root $evidence.Names[0];$receiptBackup=$receiptPath+'.original' + $callback={param($Assets,$Evidence,$Receipts)try{[IO.File]::Move($Assets.Files[0].Path,$assetBackup);[IO.File]::Copy($assetBackup,$Assets.Files[0].Path,$false);$probe.AssetReplaced=$true}catch [IO.IOException] {$probe.AssetBlocked=$true};try{[IO.File]::Move($Receipts[0].Path,$receiptBackup);[IO.File]::Copy($receiptBackup,$Receipts[0].Path,$false);$probe.ReceiptReplaced=$true}catch [IO.IOException] {$probe.ReceiptBlocked=$true}}.GetNewClosure() + $result=&$module {param($EvidenceRoot,$AssetRoot,$Version,$Commit,$Callback)Test-CcodReleasePromotionEvidenceCore -EvidenceDirectory $EvidenceRoot -AssetDirectory $AssetRoot -Version $Version -ExpectedGitCommit $Commit -BeforeReturn $Callback} $evidence.Root $assetFixture.Root $assetFixture.Version $assetFixture.GitCommit $callback + Assert-CcodTrue ([bool]$result.Valid) 'promotion core returns a valid held decision' + Assert-CcodTrue ($probe.AssetBlocked-and$probe.ReceiptBlocked) 'promotion asset and receipt replacements are denied while the decision is live' + Assert-CcodTrue (-not$probe.AssetReplaced-and-not$probe.ReceiptReplaced) 'promotion callback cannot change any held identity' + }finally{ + if($null-ne$assetFixture-and(Test-Path -LiteralPath $assetBackup)){if(Test-Path -LiteralPath (Join-Path $assetFixture.Root $assetFixture.Names[0])){Remove-Item -LiteralPath (Join-Path $assetFixture.Root $assetFixture.Names[0]) -Force};Move-Item -LiteralPath $assetBackup -Destination (Join-Path $assetFixture.Root $assetFixture.Names[0])} + if($null-ne$evidence-and(Test-Path -LiteralPath $receiptBackup)){if(Test-Path -LiteralPath $receiptPath){Remove-Item -LiteralPath $receiptPath -Force};Move-Item -LiteralPath $receiptBackup -Destination $receiptPath} + if($null-ne$evidence-and(Test-Path $evidence.Root)){Remove-Item -LiteralPath $evidence.Root -Recurse -Force};if($null-ne$assetFixture){Remove-CcodTask5ExactAssetFixture $assetFixture};Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } - Assert-CcodTrue ($readmeZh -match '\u7ACB\u5373\u91CD\u542F') 'Chinese README explains Restart now' - Assert-CcodTrue ($readmeZh -match '\u7A0D\u540E') 'Chinese README explains Later' - Assert-CcodTrue ($readmeZh -match '\u5B89\u5168\u9000\u51FA') 'Chinese README explains safe Exit' - Assert-CcodTrue ($readmeZh -match '\u6258\u76D8\u4E2D\u6CA1\u6709\u5378\u8F7D\u547D\u4EE4\u3002') 'Chinese README excludes tray uninstall' - Assert-CcodTrue ($readmeZh -match 'Windows \u8BBE\u7F6E') 'Chinese README documents Windows Settings uninstall' - Assert-CcodTrue ($readmeZh.Contains('unins000.exe')) 'Chinese README documents direct uninstaller' - Assert-CcodTrue (-not ($readmeZh -match '\u7ACB\u5373\u5E94\u7528\u3001\u91CD\u8BD5\u3001\u81EA\u52A8\u5316\u5F00\u5173\u3001\u517C\u5BB9\u66F4\u65B0\u8BD5\u7528\u3001\u65E5\u5FD7\u3001\u5378\u8F7D')) 'Chinese README no longer describes removed tray toggles' +} - $technical = Get-Content -LiteralPath (Join-Path $repositoryRoot 'docs\TECHNICAL.md') -Raw - foreach ($term in @('lifecycle epoch/generation fence', 'trusted LUID marker', 'protocol v2', 'external uninstaller receipt', 'remote-control-device-keys.windows.json', 'The tray has no uninstall command.')) { - Assert-CcodTrue ($technical.Contains($term)) "technical documentation records: $term" +Invoke-CcodTask5Test 'fix1-handle-promotion-post-callback' 'promotion rechecks every held authority after the visibility callback' { + $module=Import-Module $assetContractPath -Force -PassThru -DisableNameChecking + $assetFixture=$null;$evidence=$null + try{ + $assetFixture=New-CcodTask5ExactAssetFixture;$evidence=New-CcodTask5PromotionFixture $assetFixture + $callback={param($Assets,$Evidence,$Receipts) + &$module {param($Authority)Close-CcodExactReleaseAssetAuthority $Authority} $Assets + &$module {param($Authority)Close-CcodReleaseAuthority $Authority} $Evidence + foreach($receipt in @($Receipts)){&$module {param($Authority)Close-CcodReleaseAuthority $Authority} $receipt} + }.GetNewClosure() + Assert-CcodThrows { + &$module {param($EvidenceRoot,$AssetRoot,$Version,$Commit,$Callback)Test-CcodReleasePromotionEvidenceCore -EvidenceDirectory $EvidenceRoot -AssetDirectory $AssetRoot -Version $Version -ExpectedGitCommit $Commit -BeforeReturn $Callback} $evidence.Root $assetFixture.Root $assetFixture.Version $assetFixture.GitCommit $callback + } 'CCOD_RELEASE_PROMOTION_EVIDENCE_INVALID' + }finally{ + if($null-ne$evidence-and(Test-Path $evidence.Root)){Remove-Item -LiteralPath $evidence.Root -Recurse -Force} + if($null-ne$assetFixture){Remove-CcodTask5ExactAssetFixture $assetFixture};Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } - foreach ($obsolete in @('Menu.Uninstall', 'UninstallEnabled', 'AutomationToggleEnabled', 'CandidateOptInToggleEnabled', 'BackupDeviceKeyStore', 'RemoveDeviceKeyStore')) { - Assert-CcodTrue (-not $technical.Contains($obsolete)) "technical documentation excludes obsolete contract: $obsolete" +} + +foreach($receiptShape in @('NoOp','Corrupt','WrongTarget','StaleObject','ReplacedReadback','FailedNoOp')){ + Invoke-CcodTask5Test ("fix1-receipt-$receiptShape") ("Defender receipt readback rejects $receiptShape publication") { + $fixture=New-CcodTask5ExactAssetFixture + try{ + $candidate=Join-Path $fixture.Root $fixture.Names[5];$checksum=Join-Path $fixture.Root $fixture.Names[6];$manifest=Join-Path $fixture.Root $fixture.Names[10];$evidence=Join-Path $fixture.Outside ("receipt-$receiptShape.json");$case=if($receiptShape-ceq'FailedNoOp'){New-CcodTask5DefenderAdapterFixture -ScanThrows}else{New-CcodTask5DefenderAdapterFixture};$realPublish=(Get-CcodTask5DefaultDefenderAdapters).PublishReceiptBytes + $controlPath=Join-Path $fixture.Outside 'control.json' + $control=Invoke-CcodTask5DefenderCore -CandidatePath $candidate -ChecksumPath $checksum -ManifestPath $manifest -Origin TrustedWorkflowArtifact -WorkflowArtifactIdentity (New-CcodTask5WorkflowIdentity $fixture.GitCommit) -ExpectedVersion $fixture.Version -ExpectedGitCommit $fixture.GitCommit -EvidencePath $controlPath -Adapters (New-CcodTask5DefenderAdapterFixture).Adapters + Assert-CcodEqual 'Completed' $control.outcome 'the same candidate has a passing real publication control' + $controlHash=Get-CcodTestFileSha256 $controlPath + switch($receiptShape){ + 'NoOp'{$case.Adapters.PublishReceiptBytes={param($Directory,$Leaf,$Bytes)$null}} + 'Corrupt'{$case.Adapters.PublishReceiptBytes={param($Directory,$Leaf,$Bytes)&$realPublish $Directory $Leaf ([Text.UTF8Encoding]::new($false).GetBytes('{}'))}.GetNewClosure()} + 'WrongTarget'{$case.Adapters.PublishReceiptBytes={param($Directory,$Leaf,$Bytes)&$realPublish $Directory ($Leaf+'.wrong.json') $Bytes}.GetNewClosure()} + 'StaleObject'{$case.Adapters.PublishReceiptBytes={param($Directory,$Leaf,$Bytes)$pin=&$realPublish $Directory $Leaf $Bytes;$pin.Stream.Dispose();$pin.Closed=$true;$pin}.GetNewClosure()} + 'ReplacedReadback'{$case.Adapters.PublishReceiptBytes={param($Directory,$Leaf,$Bytes)$pin=&$realPublish $Directory $Leaf $Bytes;$pin.Stream.Dispose();$pin.Closed=$true;$target=Join-Path $Directory.Path $Leaf;$original=$target+'.original';[IO.File]::Move($target,$original);[IO.File]::Copy($original,$target,$false);$pin}.GetNewClosure()} + 'FailedNoOp'{$case.Adapters.PublishReceiptBytes={param($Directory,$Leaf,$Bytes)$null}} + } + $faultPublish=$case.Adapters.PublishReceiptBytes + $state=[pscustomobject]@{Calls=0;Authority=$null} + $case.Adapters.PublishReceiptBytes={param($Directory,$Leaf,$Bytes)$state.Calls++;$value=&$faultPublish $Directory $Leaf $Bytes;$state.Authority=$value;return $value}.GetNewClosure() + $expected=if($receiptShape-in@('WrongTarget','StaleObject','ReplacedReadback')){'CCOD_DEFENDER_EVIDENCE_CLEANUP_FAILED'}else{'CCOD_DEFENDER_EVIDENCE_WRITE_FAILED'} + Assert-CcodThrows {Invoke-CcodTask5DefenderCore -CandidatePath $candidate -ChecksumPath $checksum -ManifestPath $manifest -Origin TrustedWorkflowArtifact -WorkflowArtifactIdentity (New-CcodTask5WorkflowIdentity $fixture.GitCommit) -ExpectedVersion $fixture.Version -ExpectedGitCommit $fixture.GitCommit -EvidencePath $evidence -Adapters $case.Adapters|Out-Null} $expected + Assert-CcodEqual 1 $state.Calls 'the failing publisher is actually invoked once' + Assert-CcodEqual $controlHash (Get-CcodTestFileSha256 $controlPath) 'failure preserves the unrelated valid receipt' + if($receiptShape-in@('NoOp','Corrupt','FailedNoOp')){Assert-CcodTrue (-not[IO.File]::Exists($evidence)) 'no-op or rejected owned publication leaves no target'} + if($receiptShape-ceq'WrongTarget') { + Assert-CcodTrue (-not[IO.File]::Exists($evidence)) 'wrong-target callback never created the requested receipt' + Assert-CcodEqual $state.Authority.Sha256 (Get-CcodTestFileSha256 ($evidence+'.wrong.json')) 'cleanup does not guess ownership of the other target' + } + if($receiptShape-in@('StaleObject','ReplacedReadback')){Assert-CcodEqual $state.Authority.Sha256 (Get-CcodTestFileSha256 $evidence) 'unverifiable authority is rejected without pathname deletion'} + if($receiptShape-ceq'ReplacedReadback'){Assert-CcodEqual $state.Authority.Sha256 (Get-CcodTestFileSha256 ($evidence+'.original')) 'detached original is not guessed from its pathname'} + if($null-ne$state.Authority){Assert-CcodTrue $state.Authority.Closed 'every retained publisher handle is released'} + }finally{Remove-CcodTask5ExactAssetFixture $fixture} } +} - $cleanroom = Get-Content -LiteralPath (Join-Path $repositoryRoot 'docs\CLEANROOM.md') -Raw - Assert-CcodTrue ($cleanroom.Contains('protocol v2')) 'clean-room boundary records the current tray protocol' +Invoke-CcodTask5Test 'promotion' 'promotion requires exactly distinct Setup and ZIP Internet-download Defender receipts' { + Assert-CcodTrue (Test-Path -LiteralPath $assetContractPath -PathType Leaf) 'central release asset contract module exists for promotion validation' + $assetModule=Import-Module $assetContractPath -Force -PassThru -DisableNameChecking + $assetFixture=$null + try{ + $assetFixture=New-CcodTask5ExactAssetFixture + $baseline=New-CcodTask5PromotionFixture -AssetFixture $assetFixture + try{ + $validated=Test-CcodReleasePromotionEvidence -EvidenceDirectory $baseline.Root -AssetDirectory $assetFixture.Root -Version $baseline.Version -ExpectedGitCommit $baseline.Commit + Assert-CcodEqual 'Setup,PortableZip' ((@($validated.Receipts.assetType))-join',') 'promotion returns the two distinct official asset types in contract order' + Assert-CcodTrue (@($validated.Receipts|Where-Object{$_.origin-cne'InternetDownload'-or$_.zoneId-ne3}).Count-eq0) 'promotion returns only actual Internet-download receipts' + }finally{if(Test-Path $baseline.Root){Remove-Item -LiteralPath $baseline.Root -Recurse -Force}} - $security = Get-Content -LiteralPath (Join-Path $repositoryRoot 'SECURITY.md') -Raw - Assert-CcodTrue ($security.Contains('remote-control-device-keys.windows.json')) 'security documentation retains the unchanged DPAPI key location' - Assert-CcodTrue ($security.Contains('Windows Settings')) 'security documentation identifies the external uninstall route' - Assert-CcodTrue ($security.Contains('unins000.exe')) 'security documentation identifies the direct uninstaller route' - Assert-CcodTrue (-not $security.Contains('-BackupDeviceKeyStore')) 'security documentation excludes legacy key backup switch' - Assert-CcodTrue (-not $security.Contains('-RemoveDeviceKeyStore')) 'security documentation excludes legacy key removal switch' -#> + foreach($shape in @('MissingSetup','MissingPortable','Extra','DuplicateSetup','DuplicatePortable','Swapped','ReusedHashes','ForgedTriplet','TrustedSubstitution')){ + $fixture=New-CcodTask5PromotionFixture -AssetFixture $assetFixture + try{ + $setupPath=Join-Path $fixture.Root $fixture.Names[0];$portablePath=Join-Path $fixture.Root $fixture.Names[1] + switch($shape){ + 'MissingSetup'{Remove-Item -LiteralPath $setupPath -Force} + 'MissingPortable'{Remove-Item -LiteralPath $portablePath -Force} + 'Extra'{Write-CcodTask5Json -Path (Join-Path $fixture.Root 'unexpected.defender.json') -Value $fixture.Setup} + 'DuplicateSetup'{Write-CcodTask5Json -Path $portablePath -Value $fixture.Setup} + 'DuplicatePortable'{Write-CcodTask5Json -Path $setupPath -Value $fixture.Portable} + 'Swapped'{Write-CcodTask5Json -Path $setupPath -Value $fixture.Portable;Write-CcodTask5Json -Path $portablePath -Value $fixture.Setup} + 'ReusedHashes'{$fixture.Portable.assetSha256=$fixture.Setup.assetSha256;$fixture.Portable.checksumSha256=$fixture.Setup.checksumSha256;$fixture.Portable.manifestSha256=$fixture.Setup.manifestSha256;Write-CcodTask5Json -Path $portablePath -Value $fixture.Portable} + 'ForgedTriplet'{$fixture.Setup.assetSha256='b'*64;$fixture.Setup.checksumSha256='c'*64;$fixture.Setup.manifestSha256='d'*64;Write-CcodTask5Json -Path $setupPath -Value $fixture.Setup} + 'TrustedSubstitution'{$fixture.Setup.origin='TrustedWorkflowArtifact';$fixture.Setup.workflowArtifactIdentity=New-CcodTask5WorkflowIdentity $fixture.Commit;$fixture.Setup.zoneId=$null;Write-CcodTask5Json -Path $setupPath -Value $fixture.Setup} + } + Assert-CcodThrows {Test-CcodReleasePromotionEvidence -EvidenceDirectory $fixture.Root -AssetDirectory $assetFixture.Root -Version $fixture.Version -ExpectedGitCommit $fixture.Commit|Out-Null} 'CCOD_RELEASE_PROMOTION_EVIDENCE_INVALID' + }finally{if(Test-Path $fixture.Root){Remove-Item -LiteralPath $fixture.Root -Recurse -Force}} + } + + foreach($field in @('schemaVersion','assetType','assetName','assetSha256','checksumName','checksumSha256','manifestName','manifestSha256','version','gitCommit','origin','workflowArtifactIdentity','zoneId','defenderServiceEnabled','antivirusEnabled','realTimeProtectionEnabled','defenderPlatformVersion','defenderEngineVersion','signatureVersion','signatureUpdatedAtUtc','scanStartedAtUtc','scanCompletedAtUtc','detectionCount','outcome','errorCode')){ + $fixture=New-CcodTask5PromotionFixture -AssetFixture $assetFixture + try{ + $receipt=$fixture.Setup + switch($field){ + 'schemaVersion'{$receipt.schemaVersion=1} + 'assetType'{$receipt.assetType='PortableZip'} + 'assetName'{$receipt.assetName='foreign.exe'} + 'assetSha256'{$receipt.assetSha256='z'*64} + 'checksumName'{$receipt.checksumName='foreign.sha256.txt'} + 'checksumSha256'{$receipt.checksumSha256='z'*64} + 'manifestName'{$receipt.manifestName='foreign.json'} + 'manifestSha256'{$receipt.manifestSha256='z'*64} + 'version'{$receipt.version='2.5.21'} + 'gitCommit'{$receipt.gitCommit='b'*40} + 'origin'{$receipt.origin='TrustedWorkflowArtifact'} + 'workflowArtifactIdentity'{$receipt.workflowArtifactIdentity=New-CcodTask5WorkflowIdentity $fixture.Commit} + 'zoneId'{$receipt.zoneId=2} + 'defenderServiceEnabled'{$receipt.defenderServiceEnabled=$false} + 'antivirusEnabled'{$receipt.antivirusEnabled=$false} + 'realTimeProtectionEnabled'{$receipt.realTimeProtectionEnabled=$false} + 'defenderPlatformVersion'{$receipt.defenderPlatformVersion=''} + 'defenderEngineVersion'{$receipt.defenderEngineVersion=''} + 'signatureVersion'{$receipt.signatureVersion=''} + 'signatureUpdatedAtUtc'{$receipt.signatureUpdatedAtUtc='2030-01-01T00:00:00.0000000Z'} + 'scanStartedAtUtc'{$receipt.scanStartedAtUtc='not-a-clock'} + 'scanCompletedAtUtc'{$receipt.scanCompletedAtUtc='2030-02-03T04:05:05.0000000Z'} + 'detectionCount'{$receipt.detectionCount=1} + 'outcome'{$receipt.outcome='Failed'} + 'errorCode'{$receipt.errorCode='CCOD_DEFENDER_SCAN_FAILED'} + } + Write-CcodTask5Json -Path (Join-Path $fixture.Root $fixture.Names[0]) -Value $receipt + Assert-CcodThrows {Test-CcodReleasePromotionEvidence -EvidenceDirectory $fixture.Root -AssetDirectory $assetFixture.Root -Version $fixture.Version -ExpectedGitCommit $fixture.Commit|Out-Null} 'CCOD_RELEASE_PROMOTION_EVIDENCE_INVALID' + }finally{if(Test-Path $fixture.Root){Remove-Item -LiteralPath $fixture.Root -Recurse -Force}} + } + }finally{if($null-ne$assetFixture){Remove-CcodTask5ExactAssetFixture $assetFixture};if($null-ne$assetModule){Remove-Module -Name $assetModule.Name -Force -ErrorAction SilentlyContinue}} } -Invoke-CcodTest 'portable release manifest binds the ZIP payload manifest and each archived payload file' { - . $defenderPath -Library - $fixture = New-CcodPortableReleaseFixture +Invoke-CcodTest 'release defender tool exposes manifest and scan functions without a live scan' { + Assert-CcodTrue (Test-Path -LiteralPath $defenderPath -PathType Leaf) 'Defender release gate exists' + $defenderModule=Import-Module $releaseDefenderModulePath -Force -PassThru -DisableNameChecking;$assetModule=Import-Module $assetContractPath -Force -PassThru -DisableNameChecking + Assert-CcodTrue $assetModule.ExportedCommands.ContainsKey('Test-CcodReleaseAssetManifest') 'release manifest validator is exported for deterministic tests' + Assert-CcodTrue $defenderModule.ExportedCommands.ContainsKey('Invoke-CcodReleaseDefenderCheck') 'Defender invocation is available' +} + +Invoke-CcodTest 'production installer payload generator writes ordered version-bound file records' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-installer-manifest-generator-' + [guid]::NewGuid().ToString('N')) try { - $validated = Test-CcodReleaseAssetManifest -ManifestPath $fixture.Manifest -AssetDirectory $fixture.Root -ExpectedVersion '2.5.6' - Assert-CcodEqual $true ([bool]$validated.Valid) 'valid portable fixture passes the release manifest contract' - Assert-CcodEqual 'portable-zip' ([string]$validated.Distribution) 'validator reports the portable release distribution' - $payload = Get-Content -LiteralPath $fixture.PayloadManifest -Raw | ConvertFrom-Json - $payload.files[0].sha256 = '0' * 64 - [IO.File]::WriteAllText($fixture.PayloadManifest,($payload | ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) - $release = Get-Content -LiteralPath $fixture.Manifest -Raw | ConvertFrom-Json - $asset = @($release.assets | Where-Object { $_.name -ceq [IO.Path]::GetFileName($fixture.PayloadManifest) })[0] - $asset.sha256 = Get-CcodTestFileSha256 -Path $fixture.PayloadManifest - [IO.File]::WriteAllText($fixture.Manifest,($release | ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + $payload = Join-Path $root 'payload' + [IO.Directory]::CreateDirectory((Join-Path $payload 'nested')) | Out-Null + [IO.File]::WriteAllText((Join-Path $payload 'z-last.txt'),'z',[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText((Join-Path $payload 'nested\a-first.txt'),'alpha',[Text.UTF8Encoding]::new($false)) + $manifestPath = Join-Path $root 'installer-payload.manifest.json' + & (Join-Path $repositoryRoot 'tools\New-InstallerPayloadManifest.ps1') -PayloadRoot $payload -ProjectVersion '2.5.22' -OutputPath $manifestPath | Out-Null + Assert-CcodTrue (Test-Path -LiteralPath $manifestPath -PathType Leaf) 'production manifest generator writes its requested output' + $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json + Assert-CcodEqual 'schemaVersion,projectVersion,files' (@($manifest.PSObject.Properties.Name) -join ',') 'generator writes the canonical manifest field order' + Assert-CcodEqual '2.5.22' $manifest.projectVersion 'generator binds the requested project version' + Assert-CcodEqual 'nested/a-first.txt,z-last.txt' (@($manifest.files.path) -join ',') 'generator sorts file records ordinally' + foreach ($record in $manifest.files) { + $file = Join-Path $payload ([string]$record.path).Replace('/','\') + Assert-CcodEqual ([int64](Get-Item -LiteralPath $file).Length) ([int64]$record.length) 'record length binds the file bytes' + Assert-CcodEqual (Get-CcodTestFileSha256 -Path $file) ([string]$record.sha256) 'record hash binds the file bytes' + } + } finally { + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } +} + +# Production mutation caught: accepting a ZIP whose entry set or identity no longer matches the manifest bound by Setup. +Invoke-CcodTest 'sealed installer package rejects entry and identity changes before product state exists' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-sealed-installer-package-' + [guid]::NewGuid().ToString('N')) + $module = $null + try { + $payload = Join-Path $root 'payload' + foreach ($directory in @('src\persistence\modules')) { [IO.Directory]::CreateDirectory((Join-Path $payload $directory)) | Out-Null } + [IO.File]::WriteAllText((Join-Path $payload 'package.json'),'{"version":"2.5.22"}',[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText((Join-Path $payload 'Install-CodexControlOtherDevices.ps1'),'param()',[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText((Join-Path $payload 'src\persistence\modules\InstallLifecycle.psm1'),'# lifecycle',[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText((Join-Path $payload 'src\persistence\modules\RuntimeManifest.psm1'),'# manifest',[Text.UTF8Encoding]::new($false)) + $payloadManifest = Join-Path $root 'installer-payload.manifest.json' + & (Join-Path $repositoryRoot 'tools\New-InstallerPayloadManifest.ps1') -PayloadRoot $payload -ProjectVersion '2.5.22' -OutputPath $payloadManifest | Out-Null + $package = Join-Path $root 'installer-package.zip' + $manifest = Join-Path $root 'installer-package.manifest.json' + $module = Import-Module (Join-Path $repositoryRoot 'build\InstallerPackage.psm1') -Force -PassThru + $created = New-CcodInstallerPackage -PayloadRoot $payload -PayloadManifestPath $payloadManifest -Version '2.5.22' -GitCommit ('a' * 40) -OutputPath $package -ManifestOutputPath $manifest + $validated = Test-CcodInstallerPackage -PackagePath $package -ManifestPath $manifest -ExpectedPackageSha256 $created.PackageSha256 -ExpectedManifestSha256 $created.ManifestSha256 -ExpectedVersion '2.5.22' -ExpectedGitCommit ('a' * 40) + Assert-CcodEqual $true ([bool]$validated.Valid) 'canonical sealed installer package validates' + Assert-CcodThrows { - Test-CcodReleaseAssetManifest -ManifestPath $fixture.Manifest -AssetDirectory $fixture.Root -ExpectedVersion '2.5.6' - } 'CCOD_RELEASE_ASSET_HASH_MISMATCH' + Test-CcodInstallerPackage -PackagePath $package -ManifestPath $manifest -ExpectedPackageSha256 ('0' * 64) -ExpectedManifestSha256 $created.ManifestSha256 -ExpectedVersion '2.5.22' -ExpectedGitCommit ('a' * 40) | Out-Null + } 'CCOD_INSTALLER_PACKAGE_HASH_MISMATCH' + Assert-CcodThrows { + Test-CcodInstallerPackage -PackagePath $package -ManifestPath $manifest -ExpectedPackageSha256 $created.PackageSha256 -ExpectedManifestSha256 ('0' * 64) -ExpectedVersion '2.5.22' -ExpectedGitCommit ('a' * 40) | Out-Null + } 'CCOD_INSTALLER_PACKAGE_MANIFEST_HASH_MISMATCH' + Assert-CcodThrows { + Test-CcodInstallerPackage -PackagePath $package -ManifestPath $manifest -ExpectedPackageSha256 $created.PackageSha256 -ExpectedManifestSha256 $created.ManifestSha256 -ExpectedVersion '9.9.9' -ExpectedGitCommit ('a' * 40) | Out-Null + } 'CCOD_INSTALLER_PACKAGE_INVALID' + Assert-CcodThrows { + Test-CcodInstallerPackage -PackagePath $package -ManifestPath $manifest -ExpectedPackageSha256 $created.PackageSha256 -ExpectedManifestSha256 $created.ManifestSha256 -ExpectedVersion '2.5.22' -ExpectedGitCommit ('b' * 40) | Out-Null + } 'CCOD_INSTALLER_PACKAGE_INVALID' + + Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction Stop + foreach ($variant in @( + [pscustomobject]@{Name='missing';Mutate={param($archive)$archive.GetEntry('package.json').Delete()}}, + [pscustomobject]@{Name='extra';Mutate={param($archive)$entry=$archive.CreateEntry('extra.txt');$writer=[IO.StreamWriter]::new($entry.Open());try{$writer.Write('extra')}finally{$writer.Dispose()}}}, + [pscustomobject]@{Name='duplicate';Mutate={param($archive)$entry=$archive.CreateEntry('package.json');$writer=[IO.StreamWriter]::new($entry.Open());try{$writer.Write('{}')}finally{$writer.Dispose()}}}, + [pscustomobject]@{Name='unsafe';Mutate={param($archive)$entry=$archive.CreateEntry('../escape.txt');$writer=[IO.StreamWriter]::new($entry.Open());try{$writer.Write('escape')}finally{$writer.Dispose()}}}, + [pscustomobject]@{Name='changed';Mutate={param($archive)$archive.GetEntry('package.json').Delete();$entry=$archive.CreateEntry('package.json');$writer=[IO.StreamWriter]::new($entry.Open());try{$writer.Write('{"version":"9.9.9"}')}finally{$writer.Dispose()}}} + )) { + $variantPath = Join-Path $root ("$($variant.Name).zip") + [IO.File]::Copy($package,$variantPath,$false) + $archive = [IO.Compression.ZipFile]::Open($variantPath,[IO.Compression.ZipArchiveMode]::Update) + try { & $variant.Mutate $archive } finally { $archive.Dispose() } + $variantHash = Get-CcodTestFileSha256 -Path $variantPath + Assert-CcodThrows { + Test-CcodInstallerPackage -PackagePath $variantPath -ManifestPath $manifest -ExpectedPackageSha256 $variantHash -ExpectedManifestSha256 $created.ManifestSha256 -ExpectedVersion '2.5.22' -ExpectedGitCommit ('a' * 40) | Out-Null + } 'CCOD_INSTALLER_PACKAGE_INVALID' + } + Assert-CcodTrue (-not (Test-Path -LiteralPath (Join-Path $root 'product-state'))) 'all package failures precede product state creation' } finally { - if (Test-Path -LiteralPath $fixture.Root) { Remove-Item -LiteralPath $fixture.Root -Recurse -Force } + if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } +} + +# Production mutation caught: restoring any legacy recursive {app} copy or executing a writable app bootstrap before Ready. +Invoke-CcodTest 'Setup contains only sealed temporary inputs and no pre-Ready app product write' { + $source = Get-Content -LiteralPath (Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss') -Raw -Encoding UTF8 + # This text asset is not line-ending pinned, so a Windows checkout with + # core.autocrlf produces CRLF while a Unix checkout produces LF. Normalize so + # the line-anchored assertions below assert content rather than line endings. + $source = $source -replace "`r`n", "`n" + Assert-CcodTrue ($source -cmatch '(?m)^CreateAppDir=no\s*$') 'Setup never creates app before Ready' + Assert-CcodTrue ($source -cmatch '(?m)^Uninstallable=no\s*$') 'Setup never creates an Inno uninstaller before Ready' + Assert-CcodTrue ($source -cmatch 'InstallerPackageSha256' -and $source -cmatch 'InstallerPackageManifestSha256' -and $source -cmatch 'ActivationBootstrapSha256') 'Setup binds package manifest and bootstrap hashes' + Assert-CcodTrue ($source -cnotmatch '(?im)^\s*Source:.*DestDir:\s*"\{app\}' -and $source -cnotmatch '(?im)^\[(Icons|Registry|UninstallRun)\]\s*$') 'Setup has no pre-Ready app Files Icons Registry or UninstallRun writes' + Assert-CcodTrue ($source -cnotmatch 'recursesubdirs|createallsubdirs|Source:\s*"[^"]*\\\*"') 'Setup never recursively extracts an arbitrary payload tree' + Assert-CcodTrue ($source -cnotmatch '-File\s+"\{app\}\\Activate-CcodRemoteFix\.ps1"') 'Setup never executes activation from writable app' + $fileLines = @([regex]::Matches($source,'(?im)^\s*Source:\s*"[^\r\n]+$') | ForEach-Object { $_.Value }) + Assert-CcodEqual 4 $fileLines.Count 'Setup embeds exactly package manifest bootstrap and provenance inputs' + foreach ($line in $fileLines) { Assert-CcodTrue ($line -cmatch 'Flags:\s*dontcopy\s*$') 'every Setup input is private temporary dontcopy data' } +} + +Invoke-CcodTest 'production setup template has one inventory marker no external includes and no product destination inventory' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-installer-directory-inventory-' + [guid]::NewGuid().ToString('N')) + try { + $payload = Join-Path $root 'payload' + [IO.Directory]::CreateDirectory((Join-Path $payload 'src\persistence\modules')) | Out-Null + [IO.File]::WriteAllText((Join-Path $payload 'src\persistence\modules\InstallLifecycle.psm1'),'fixture',[Text.UTF8Encoding]::new($false)) + $output = Join-Path $root 'InstallerDestinationInventory.iss' + $innoPath = Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss' + $templateLines = @([IO.File]::ReadAllLines($innoPath,[Text.UTF8Encoding]::new($false))) + $inventoryMarkers = @($templateLines | Where-Object { [string]$_ -ceq '// CCOD_INSTALLER_DESTINATION_INVENTORY' }) + $externalIncludes = @($templateLines | Where-Object { [string]$_ -match '^\s*#\s*(?:include\b|\+)' }) + Assert-CcodEqual 1 $inventoryMarkers.Count 'production template has exactly one destination inventory marker comment' + Assert-CcodEqual 0 $externalIncludes.Count 'production template has no external include directive' + & (Join-Path $repositoryRoot 'tools\New-InstallerDestinationInventory.ps1') -RepositoryRoot $repositoryRoot -PayloadRoot $payload -ProjectVersion '2.5.22' -InnoScriptPath $innoPath -OutputPath $output | Out-Null + $inventory = [IO.File]::ReadAllText($output,[Text.UTF8Encoding]::new($false)) + Assert-CcodTrue ($inventory -cmatch '(?s)^procedure AddCcodExpectedSetupDirectories\(Directories: TStrings\);\s*begin\s*end;\s*$') 'sealed Setup produces an explicit empty product destination inventory' + Assert-CcodTrue ($inventory -cnotmatch 'Directories\.Add') 'no payload directory can be created before Ready' + } finally { + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } +} + +Invoke-CcodTest 'production setup destination inventory rejects multiple Files sections' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-installer-multiple-files-sections-' + [guid]::NewGuid().ToString('N')) + try { + $payload = Join-Path $root 'payload' + [IO.Directory]::CreateDirectory($payload) | Out-Null + [IO.File]::WriteAllText((Join-Path $payload 'package.json'),'{}',[Text.UTF8Encoding]::new($false)) + $innoPath = Join-Path $root 'MultipleFilesSections.iss' + $innoSource = @' +[Setup] +AppName=Fixture +AppVersion=1.0.0 +DefaultDirName={app} +[Files] +Source: "fixture-a.txt"; DestDir: "{app}\first"; Flags: ignoreversion +[Code] +procedure Fixture(); +begin +end; +[Files] +Source: "fixture-b.txt"; DestDir: "{app}\second"; Flags: ignoreversion +'@ + [IO.File]::WriteAllText($innoPath,$innoSource,[Text.UTF8Encoding]::new($false)) + $output = Join-Path $root 'Inventory.iss' + $failure = $null + try { + & (Join-Path $repositoryRoot 'tools\New-InstallerDestinationInventory.ps1') -RepositoryRoot $repositoryRoot -PayloadRoot $payload -ProjectVersion '2.5.22' -InnoScriptPath $innoPath -OutputPath $output | Out-Null + } catch { $failure = $_ } + Assert-CcodTrue ($null -ne $failure) 'generator fails closed when the Inno source contains multiple Files sections' + Assert-CcodTrue ($failure.Exception.Message -cmatch 'exactly one \[Files\] section') 'multiple-section failure explains the structural contract' + Assert-CcodTrue (-not (Test-Path -LiteralPath $output)) 'multiple Files sections produce no partial inventory artifact' + } finally { + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } } } -Write-Host 'Release workflow self-tests passed.' +Invoke-CcodTest 'production setup destination inventory rejects every external include spelling before output' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-installer-external-include-' + [guid]::NewGuid().ToString('N')) + try { + $payload = Join-Path $root 'payload' + [IO.Directory]::CreateDirectory($payload) | Out-Null + $firstSource = Join-Path $root 'fixture-a.txt' + [IO.File]::WriteAllText($firstSource,'first',[Text.UTF8Encoding]::new($false)) + $generator = Join-Path $repositoryRoot 'tools\New-InstallerDestinationInventory.ps1' + foreach ($fixture in @( + [pscustomobject]@{Name='plus alias';Directive='#+ "extra.iss"'}, + [pscustomobject]@{Name='include keyword';Directive='#include "extra.iss"'} + )) { + $innoPath = Join-Path $root (($fixture.Name -replace '[^A-Za-z]','') + '.iss') + $innoSource = @" +[Setup] +AppName=ExternalIncludeFixture +AppVersion=1.0.0 +DefaultDirName={app} +[Files] +Source: "$firstSource"; DestDir: "{app}\first"; Flags: ignoreversion +[Code] +procedure Fixture(); +begin +end; +$($fixture.Directive) +"@ + [IO.File]::WriteAllText($innoPath,$innoSource,[Text.UTF8Encoding]::new($false)) + $inventoryPath = Join-Path $root (($fixture.Name -replace '[^A-Za-z]','') + '-Inventory.iss') + $generatorFailure = $null + try { + & $generator -RepositoryRoot $repositoryRoot -PayloadRoot $payload -ProjectVersion '2.5.22' -InnoScriptPath $innoPath -OutputPath $inventoryPath | Out-Null + } catch { $generatorFailure = $_ } + Assert-CcodTrue ($null -ne $generatorFailure) "generator rejects the $($fixture.Name) before writing inventory" + Assert-CcodTrue ($generatorFailure.Exception.Message -cmatch 'include') "$($fixture.Name) failure identifies the include-free template boundary" + Assert-CcodTrue (-not [IO.File]::Exists($inventoryPath)) "$($fixture.Name) produces no partial destination inventory" + } + } finally { + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } +} + +Invoke-CcodTest 'line-spanned include forms are rejected before inventory generation or real ISCC' { + $iscc = @( + (Join-Path $env:LOCALAPPDATA 'Programs\Inno Setup 6\ISCC.exe'), + (Join-Path ${env:ProgramFiles(x86)} 'Inno Setup 6\ISCC.exe'), + (Join-Path $env:ProgramFiles 'Inno Setup 6\ISCC.exe') + ) | Where-Object { $_ -and [IO.File]::Exists($_) } | Select-Object -First 1 + if (-not $iscc) { throw 'Inno Setup 6 is required for the line-spanned include fixtures' } + . (Join-Path $repositoryRoot 'build\build.ps1') -Library + $results = [Collections.Generic.List[object]]::new() + foreach ($fixture in @( + [pscustomobject]@{Name='line-spanned include keyword';Lines=@('# \','include "extra.iss"')}, + [pscustomobject]@{Name='line-spanned plus alias';Lines=@('# \','+ "extra.iss"')} + )) { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-line-spanned-include-' + [guid]::NewGuid().ToString('N')) + try { + $payload = Join-Path $root 'payload' + $output = Join-Path $root 'output' + [IO.Directory]::CreateDirectory($payload) | Out-Null + [IO.Directory]::CreateDirectory($output) | Out-Null + $firstSource = Join-Path $root 'fixture-a.txt' + $secondSource = Join-Path $root 'fixture-b.txt' + [IO.File]::WriteAllText($firstSource,'first',[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($secondSource,'second',[Text.UTF8Encoding]::new($false)) + $extraPath = Join-Path $root 'extra.iss' + $extraSource = @" +[Files] +Source: "$secondSource"; DestDir: "{app}\second"; Flags: ignoreversion +"@ + [IO.File]::WriteAllText($extraPath,$extraSource,[Text.UTF8Encoding]::new($false)) + $templatePath = Join-Path $root 'Template.iss' + $directive = $fixture.Lines -join "`r`n" + $templateSource = @" +[Setup] +AppName=LineSpannedInclude +AppVersion=1.0.0 +DefaultDirName={tmp}\LineSpannedInclude +OutputDir=$output +OutputBaseFilename=LineSpannedInclude +Uninstallable=no +$directive +[Files] +Source: "$firstSource"; DestDir: "{app}\first"; Flags: ignoreversion +[Code] +// CCOD_INSTALLER_DESTINATION_INVENTORY +"@ + [IO.File]::WriteAllText($templatePath,$templateSource,[Text.UTF8Encoding]::new($false)) + $inventoryPath = Join-Path $root 'Inventory.iss' + $setupPath = Join-Path $output 'LineSpannedInclude.exe' + $failure = $null + $compilerAttempted = $false + try { + & (Join-Path $repositoryRoot 'tools\New-InstallerDestinationInventory.ps1') -RepositoryRoot $repositoryRoot -PayloadRoot $payload -ProjectVersion '2.5.22' -InnoScriptPath $templatePath -OutputPath $inventoryPath | Out-Null + $compilerAttempted = $true + Invoke-CcodBuildInnoCompiler -TemplatePath $templatePath -InventoryPath $inventoryPath -IsccPath $iscc -Arguments @() -SetupPath $setupPath + } catch { $failure = $_ } + $results.Add([pscustomobject]@{ + Name = $fixture.Name + Failure = $failure + CompilerAttempted = $compilerAttempted + InventoryExists = [IO.File]::Exists($inventoryPath) + SetupExists = [IO.File]::Exists($setupPath) + GeneratedCount = @(Get-ChildItem -LiteralPath $root -Filter '.ccod-generated-setup-*.iss' -File -Force).Count + }) + } finally { + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } + } + foreach ($result in $results) { + Assert-CcodTrue ($null -ne $result.Failure) "$($result.Name) is rejected before the compiler boundary" + Assert-CcodTrue ($result.Failure.Exception.Message -cmatch 'preprocessor|continuation') "$($result.Name) failure identifies the strict preprocessor boundary" + Assert-CcodTrue (-not $result.CompilerAttempted) "$($result.Name) never reaches the real ISCC boundary" + Assert-CcodTrue (-not $result.InventoryExists) "$($result.Name) leaves no inventory output" + Assert-CcodTrue (-not $result.SetupExists) "$($result.Name) leaves no setup artifact" + Assert-CcodEqual 0 $result.GeneratedCount "$($result.Name) leaves no generated compiler input" + } +} + +Invoke-CcodTest 'inventory generation rejects pragma, emit, and unknown simple preprocessor directives' { + $results = [Collections.Generic.List[object]]::new() + foreach ($fixture in @( + [pscustomobject]@{Name='pragma';Directive='#pragma parseroption -u+'}, + [pscustomobject]@{Name='emit';Directive='#emit "[Files]"'}, + [pscustomobject]@{Name='unknown';Directive='#futuredirective "extra.iss"'} + )) { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-unsafe-inno-directive-' + [guid]::NewGuid().ToString('N')) + try { + $payload = Join-Path $root 'payload' + [IO.Directory]::CreateDirectory($payload) | Out-Null + $sourcePath = Join-Path $root 'fixture.txt' + [IO.File]::WriteAllText($sourcePath,'fixture',[Text.UTF8Encoding]::new($false)) + $templatePath = Join-Path $root 'Template.iss' + $templateSource = @" +[Setup] +AppName=UnsafeDirective +AppVersion=1.0.0 +DefaultDirName={app} +[Files] +Source: "$sourcePath"; DestDir: "{app}\first"; Flags: ignoreversion +[Code] +// CCOD_INSTALLER_DESTINATION_INVENTORY +$($fixture.Directive) +"@ + [IO.File]::WriteAllText($templatePath,$templateSource,[Text.UTF8Encoding]::new($false)) + $inventoryPath = Join-Path $root 'Inventory.iss' + $failure = $null + try { + & (Join-Path $repositoryRoot 'tools\New-InstallerDestinationInventory.ps1') -RepositoryRoot $repositoryRoot -PayloadRoot $payload -ProjectVersion '2.5.22' -InnoScriptPath $templatePath -OutputPath $inventoryPath | Out-Null + } catch { $failure = $_ } + $results.Add([pscustomobject]@{ + Name = $fixture.Name + Failure = $failure + InventoryExists = [IO.File]::Exists($inventoryPath) + }) + } finally { + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } + } + foreach ($result in $results) { + Assert-CcodTrue ($null -ne $result.Failure) "$($result.Name) directive is rejected before inventory generation" + Assert-CcodTrue ($result.Failure.Exception.Message -cmatch 'preprocessor|directive') "$($result.Name) failure identifies the strict directive boundary" + Assert-CcodTrue (-not $result.InventoryExists) "$($result.Name) directive leaves no inventory output" + } +} + +Invoke-CcodTest 'VT and FF prefixed directives are rejected by inventory and build boundaries before ISCC' { + $iscc = @( + (Join-Path $env:LOCALAPPDATA 'Programs\Inno Setup 6\ISCC.exe'), + (Join-Path ${env:ProgramFiles(x86)} 'Inno Setup 6\ISCC.exe'), + (Join-Path $env:ProgramFiles 'Inno Setup 6\ISCC.exe') + ) | Where-Object { $_ -and [IO.File]::Exists($_) } | Select-Object -First 1 + if (-not $iscc) { throw 'Inno Setup 6 is required for the preprocessor whitespace fixtures' } + . (Join-Path $repositoryRoot 'build\build.ps1') -Library + $results = [Collections.Generic.List[object]]::new() + foreach ($fixture in @( + [pscustomobject]@{Name='vertical tab';Prefix=[char]0x0B}, + [pscustomobject]@{Name='form feed';Prefix=[char]0x0C} + )) { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-preprocessor-whitespace-' + [guid]::NewGuid().ToString('N')) + try { + $payload = Join-Path $root 'payload' + $realOutput = Join-Path $root 'real-output' + [IO.Directory]::CreateDirectory($payload) | Out-Null + [IO.Directory]::CreateDirectory($realOutput) | Out-Null + $sourcePath = Join-Path $root 'fixture.txt' + [IO.File]::WriteAllText($sourcePath,'fixture',[Text.UTF8Encoding]::new($false)) + $unsafeDirective = [string]$fixture.Prefix + '#emit ''AppPublisher=InjectedByWhitespace''' + $templatePath = Join-Path $root 'Template.iss' + $templateSource = @" +[Setup] +AppName=WhitespaceDirective +AppVersion=1.0.0 +DefaultDirName={tmp}\WhitespaceDirective +OutputDir=$realOutput +OutputBaseFilename=WhitespaceDirective +Uninstallable=no +$unsafeDirective +[Files] +Source: "$sourcePath"; DestDir: "{app}\first"; Flags: ignoreversion +[Code] +// CCOD_INSTALLER_DESTINATION_INVENTORY +"@ + [IO.File]::WriteAllText($templatePath,$templateSource,[Text.UTF8Encoding]::new($false)) + $inventoryPath = Join-Path $root 'Inventory.iss' + $realSetupPath = Join-Path $realOutput 'WhitespaceDirective.exe' + $generatorFailure = $null + $realCompilerAttempted = $false + try { + & (Join-Path $repositoryRoot 'tools\New-InstallerDestinationInventory.ps1') -RepositoryRoot $repositoryRoot -PayloadRoot $payload -ProjectVersion '2.5.22' -InnoScriptPath $templatePath -OutputPath $inventoryPath | Out-Null + $realCompilerAttempted = $true + Invoke-CcodBuildInnoCompiler -TemplatePath $templatePath -InventoryPath $inventoryPath -IsccPath $iscc -Arguments @() -SetupPath $realSetupPath + } catch { $generatorFailure = $_ } + + $buildTemplatePath = Join-Path $root 'BuildTemplate.iss' + [IO.File]::WriteAllText($buildTemplatePath,$templateSource,[Text.UTF8Encoding]::new($false)) + $buildInventoryPath = Join-Path $root 'BuildInventory.iss' + $buildInventorySource = @' +procedure AddCcodExpectedSetupDirectories(Directories: TStrings); +begin +end; +'@ + [IO.File]::WriteAllText($buildInventoryPath,$buildInventorySource,[Text.UTF8Encoding]::new($false)) + $compilerMarker = Join-Path $root 'fake-iscc-invoked.txt' + $compilerPath = Join-Path $root 'fake-iscc.cmd' + $buildSetupPath = Join-Path $root 'build-setup.exe' + $compilerSource = "@echo off`r`n> `"$compilerMarker`" echo invoked`r`n> `"$buildSetupPath`" echo setup`r`nexit /b 0`r`n" + [IO.File]::WriteAllText($compilerPath,$compilerSource,[Text.ASCIIEncoding]::new()) + $buildFailure = $null + try { + Invoke-CcodBuildInnoCompiler -TemplatePath $buildTemplatePath -InventoryPath $buildInventoryPath -IsccPath $compilerPath -Arguments @() -SetupPath $buildSetupPath + } catch { $buildFailure = $_ } + + $results.Add([pscustomobject]@{ + Name = $fixture.Name + GeneratorFailure = $generatorFailure + InventoryExists = [IO.File]::Exists($inventoryPath) + RealCompilerAttempted = $realCompilerAttempted + RealSetupExists = [IO.File]::Exists($realSetupPath) + BuildFailure = $buildFailure + MarkerCompilerInvoked = [IO.File]::Exists($compilerMarker) + BuildSetupExists = [IO.File]::Exists($buildSetupPath) + GeneratedCount = @(Get-ChildItem -LiteralPath $root -Filter '.ccod-generated-setup-*.iss' -File -Force).Count + }) + } finally { + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } + } + foreach ($result in $results) { + Assert-CcodTrue ($null -ne $result.GeneratorFailure) "$($result.Name) directive is rejected before inventory generation" + Assert-CcodTrue ($result.GeneratorFailure.Exception.Message -cmatch 'preprocessor|directive') "$($result.Name) generator failure identifies the preprocessor boundary" + Assert-CcodTrue (-not $result.InventoryExists) "$($result.Name) leaves no inventory output" + Assert-CcodTrue (-not $result.RealCompilerAttempted) "$($result.Name) never reaches real ISCC" + Assert-CcodTrue (-not $result.RealSetupExists) "$($result.Name) leaves no real setup artifact" + Assert-CcodTrue ($null -ne $result.BuildFailure) "$($result.Name) directive is rejected by generated setup creation" + Assert-CcodTrue ($result.BuildFailure.Exception.Message -cmatch 'preprocessor|directive') "$($result.Name) build failure identifies the preprocessor boundary" + Assert-CcodTrue (-not $result.MarkerCompilerInvoked) "$($result.Name) never reaches the marker compiler" + Assert-CcodTrue (-not $result.BuildSetupExists) "$($result.Name) leaves no marker setup artifact" + Assert-CcodEqual 0 $result.GeneratedCount "$($result.Name) leaves no generated compiler script" + } +} + +Invoke-CcodTest 'inline file and unsafe define expansion are rejected before inventory or ISCC' { + $iscc = @( + (Join-Path $env:LOCALAPPDATA 'Programs\Inno Setup 6\ISCC.exe'), + (Join-Path ${env:ProgramFiles(x86)} 'Inno Setup 6\ISCC.exe'), + (Join-Path $env:ProgramFiles 'Inno Setup 6\ISCC.exe') + ) | Where-Object { $_ -and [IO.File]::Exists($_) } | Select-Object -First 1 + if (-not $iscc) { throw 'Inno Setup 6 is required for the inline preprocessor fixtures' } + . (Join-Path $repositoryRoot 'build\build.ps1') -Library + $results = [Collections.Generic.List[object]]::new() + foreach ($fixtureName in @('inline file','unsafe define expansion')) { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-inline-preprocessor-' + [guid]::NewGuid().ToString('N')) + try { + $payload = Join-Path $root 'payload' + $realOutput = Join-Path $root 'real-output' + [IO.Directory]::CreateDirectory($payload) | Out-Null + [IO.Directory]::CreateDirectory($realOutput) | Out-Null + $mainSource = Join-Path $root 'main.txt' + $injectedSource = Join-Path $root 'injected.txt' + $externalText = Join-Path $root 'external.txt' + [IO.File]::WriteAllText($mainSource,'main',[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($injectedSource,'injected',[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($externalText,'external text',[Text.UTF8Encoding]::new($false)) + $templatePath = Join-Path $root 'Template.iss' + if ($fixtureName -ceq 'inline file') { + $templateSource = @" +[Setup] +AppName=InlineFileDirective +AppVersion=1.0.0 +DefaultDirName={tmp}\InlineFileDirective +OutputDir=$realOutput +OutputBaseFilename=InlineFileDirective +Uninstallable=no +LicenseFile={#file "external.txt"} +[Files] +Source: "$mainSource"; DestDir: "{app}\main"; Flags: ignoreversion +[Code] +// CCOD_INSTALLER_DESTINATION_INVENTORY +"@ + $setupName = 'InlineFileDirective.exe' + } else { + $templateSource = @" +#define TrayHostArtifactDirectory "[Files]" + NewLine + "Source: ""$injectedSource""; DestDir: ""{app}\injected""; Flags: ignoreversion" +[Setup] +AppName=UnsafeDefineExpansion +AppVersion=1.0.0 +DefaultDirName={tmp}\UnsafeDefineExpansion +OutputDir=$realOutput +OutputBaseFilename=UnsafeDefineExpansion +Uninstallable=no +{#TrayHostArtifactDirectory} +[Files] +Source: "$mainSource"; DestDir: "{app}\main"; Flags: ignoreversion +[Code] +// CCOD_INSTALLER_DESTINATION_INVENTORY +"@ + $setupName = 'UnsafeDefineExpansion.exe' + } + [IO.File]::WriteAllText($templatePath,$templateSource,[Text.UTF8Encoding]::new($false)) + $inventoryPath = Join-Path $root 'Inventory.iss' + $realSetupPath = Join-Path $realOutput $setupName + $generatorFailure = $null + $realCompilerAttempted = $false + try { + & (Join-Path $repositoryRoot 'tools\New-InstallerDestinationInventory.ps1') -RepositoryRoot $repositoryRoot -PayloadRoot $payload -ProjectVersion '2.5.22' -InnoScriptPath $templatePath -OutputPath $inventoryPath | Out-Null + $realCompilerAttempted = $true + Invoke-CcodBuildInnoCompiler -TemplatePath $templatePath -InventoryPath $inventoryPath -IsccPath $iscc -Arguments @() -SetupPath $realSetupPath + } catch { $generatorFailure = $_ } + + $buildTemplatePath = Join-Path $root 'BuildTemplate.iss' + [IO.File]::WriteAllText($buildTemplatePath,$templateSource,[Text.UTF8Encoding]::new($false)) + $buildInventoryPath = Join-Path $root 'BuildInventory.iss' + $buildInventorySource = @' +procedure AddCcodExpectedSetupDirectories(Directories: TStrings); +begin +end; +'@ + [IO.File]::WriteAllText($buildInventoryPath,$buildInventorySource,[Text.UTF8Encoding]::new($false)) + $compilerMarker = Join-Path $root 'fake-iscc-invoked.txt' + $compilerPath = Join-Path $root 'fake-iscc.cmd' + $buildSetupPath = Join-Path $root 'build-setup.exe' + $compilerSource = "@echo off`r`n> `"$compilerMarker`" echo invoked`r`n> `"$buildSetupPath`" echo setup`r`nexit /b 0`r`n" + [IO.File]::WriteAllText($compilerPath,$compilerSource,[Text.ASCIIEncoding]::new()) + $buildFailure = $null + try { + Invoke-CcodBuildInnoCompiler -TemplatePath $buildTemplatePath -InventoryPath $buildInventoryPath -IsccPath $compilerPath -Arguments @() -SetupPath $buildSetupPath + } catch { $buildFailure = $_ } + + $results.Add([pscustomobject]@{ + Name = $fixtureName + GeneratorFailure = $generatorFailure + InventoryExists = [IO.File]::Exists($inventoryPath) + RealCompilerAttempted = $realCompilerAttempted + RealSetupExists = [IO.File]::Exists($realSetupPath) + BuildFailure = $buildFailure + MarkerCompilerInvoked = [IO.File]::Exists($compilerMarker) + BuildSetupExists = [IO.File]::Exists($buildSetupPath) + GeneratedCount = @(Get-ChildItem -LiteralPath $root -Filter '.ccod-generated-setup-*.iss' -File -Force).Count + }) + } finally { + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } + } + foreach ($result in $results) { + Assert-CcodTrue ($null -ne $result.GeneratorFailure) "$($result.Name) is rejected before inventory generation" + Assert-CcodTrue ($result.GeneratorFailure.Exception.Message -cmatch 'preprocessor|inline|construct|directive') "$($result.Name) generator failure identifies the strict construct boundary" + Assert-CcodTrue (-not $result.InventoryExists) "$($result.Name) leaves no inventory output" + Assert-CcodTrue (-not $result.RealCompilerAttempted) "$($result.Name) never reaches real ISCC" + Assert-CcodTrue (-not $result.RealSetupExists) "$($result.Name) leaves no real setup artifact" + Assert-CcodTrue ($null -ne $result.BuildFailure) "$($result.Name) is rejected by generated setup creation" + Assert-CcodTrue ($result.BuildFailure.Exception.Message -cmatch 'preprocessor|inline|construct|directive') "$($result.Name) build failure identifies the strict construct boundary" + Assert-CcodTrue (-not $result.MarkerCompilerInvoked) "$($result.Name) never reaches the marker compiler" + Assert-CcodTrue (-not $result.BuildSetupExists) "$($result.Name) leaves no marker setup artifact" + Assert-CcodEqual 0 $result.GeneratedCount "$($result.Name) leaves no generated compiler script" + } +} + +Invoke-CcodTest 'generated setup creation applies the same strict preprocessor boundary before ISCC' { + . (Join-Path $repositoryRoot 'build\build.ps1') -Library + $results = [Collections.Generic.List[object]]::new() + foreach ($fixture in @( + [pscustomobject]@{Name='line-spanned include keyword';Directive=(@('# \','include "extra.iss"') -join "`r`n")}, + [pscustomobject]@{Name='line-spanned plus alias';Directive=(@('# \','+ "extra.iss"') -join "`r`n")}, + [pscustomobject]@{Name='pragma';Directive='#pragma parseroption -u+'}, + [pscustomobject]@{Name='emit';Directive='#emit "[Files]"'}, + [pscustomobject]@{Name='unknown';Directive='#futuredirective "extra.iss"'} + )) { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-generated-unsafe-directive-' + [guid]::NewGuid().ToString('N')) + try { + [IO.Directory]::CreateDirectory($root) | Out-Null + $templatePath = Join-Path $root 'Template.iss' + $templateSource = @" +[Setup] +AppName=GeneratedUnsafeDirective +AppVersion=1.0.0 +DefaultDirName={tmp}\GeneratedUnsafeDirective +OutputBaseFilename=GeneratedUnsafeDirective +Uninstallable=no +[Code] +// CCOD_INSTALLER_DESTINATION_INVENTORY +$($fixture.Directive) +"@ + [IO.File]::WriteAllText($templatePath,$templateSource,[Text.UTF8Encoding]::new($false)) + $inventoryPath = Join-Path $root 'Inventory.iss' + $inventorySource = @' +procedure AddCcodExpectedSetupDirectories(Directories: TStrings); +begin +end; +'@ + [IO.File]::WriteAllText($inventoryPath,$inventorySource,[Text.UTF8Encoding]::new($false)) + $compilerMarker = Join-Path $root 'iscc-invoked.txt' + $compilerPath = Join-Path $root 'fake-iscc.cmd' + $setupPath = Join-Path $root 'setup.exe' + $compilerSource = "@echo off`r`n> `"$compilerMarker`" echo invoked`r`n> `"$setupPath`" echo setup`r`nexit /b 0`r`n" + [IO.File]::WriteAllText($compilerPath,$compilerSource,[Text.ASCIIEncoding]::new()) + $failure = $null + try { + Invoke-CcodBuildInnoCompiler -TemplatePath $templatePath -InventoryPath $inventoryPath -IsccPath $compilerPath -Arguments @() -SetupPath $setupPath + } catch { $failure = $_ } + $results.Add([pscustomobject]@{ + Name = $fixture.Name + Failure = $failure + CompilerInvoked = [IO.File]::Exists($compilerMarker) + SetupExists = [IO.File]::Exists($setupPath) + GeneratedCount = @(Get-ChildItem -LiteralPath $root -Filter '.ccod-generated-setup-*.iss' -File -Force).Count + }) + } finally { + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } + } + foreach ($result in $results) { + Assert-CcodTrue ($null -ne $result.Failure) "$($result.Name) is rejected before generated setup compilation" + Assert-CcodTrue ($result.Failure.Exception.Message -cmatch 'preprocessor|continuation|directive') "$($result.Name) failure identifies the strict generated-source boundary" + Assert-CcodTrue (-not $result.CompilerInvoked) "$($result.Name) does not invoke ISCC" + Assert-CcodTrue (-not $result.SetupExists) "$($result.Name) leaves no setup artifact" + Assert-CcodEqual 0 $result.GeneratedCount "$($result.Name) leaves no generated compiler input" + } +} + +Invoke-CcodTest 'generated setup rejects an unsafe directive introduced by inventory before ISCC' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-generated-unsafe-inventory-' + [guid]::NewGuid().ToString('N')) + try { + [IO.Directory]::CreateDirectory($root) | Out-Null + $templatePath = Join-Path $root 'Template.iss' + $templateSource = @' +[Setup] +AppName=GeneratedUnsafeInventory +AppVersion=1.0.0 +DefaultDirName={tmp}\GeneratedUnsafeInventory +OutputBaseFilename=GeneratedUnsafeInventory +Uninstallable=no +[Code] +// CCOD_INSTALLER_DESTINATION_INVENTORY +'@ + [IO.File]::WriteAllText($templatePath,$templateSource,[Text.UTF8Encoding]::new($false)) + $inventoryPath = Join-Path $root 'Inventory.iss' + $inventorySource = @' +procedure AddCcodExpectedSetupDirectories(Directories: TStrings); +begin +end; +#emit "[Files]" +'@ + [IO.File]::WriteAllText($inventoryPath,$inventorySource,[Text.UTF8Encoding]::new($false)) + $compilerMarker = Join-Path $root 'iscc-invoked.txt' + $compilerPath = Join-Path $root 'fake-iscc.cmd' + $setupPath = Join-Path $root 'setup.exe' + $compilerSource = "@echo off`r`n> `"$compilerMarker`" echo invoked`r`n> `"$setupPath`" echo setup`r`nexit /b 0`r`n" + [IO.File]::WriteAllText($compilerPath,$compilerSource,[Text.ASCIIEncoding]::new()) + . (Join-Path $repositoryRoot 'build\build.ps1') -Library + $failure = $null + try { + Invoke-CcodBuildInnoCompiler -TemplatePath $templatePath -InventoryPath $inventoryPath -IsccPath $compilerPath -Arguments @() -SetupPath $setupPath + } catch { $failure = $_ } + + Assert-CcodTrue ($null -ne $failure) 'unsafe inventory directive is rejected after marker replacement' + Assert-CcodTrue ($failure.Exception.Message -cmatch 'preprocessor|directive') 'unsafe inventory failure identifies the final generated-source boundary' + Assert-CcodTrue (-not [IO.File]::Exists($compilerMarker)) 'unsafe inventory does not invoke ISCC' + Assert-CcodTrue (-not [IO.File]::Exists($setupPath)) 'unsafe inventory leaves no setup artifact' + Assert-CcodEqual 0 @(Get-ChildItem -LiteralPath $root -Filter '.ccod-generated-setup-*.iss' -File -Force).Count 'unsafe inventory leaves no generated setup script' + } finally { + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } +} + +Invoke-CcodTest 'build rejects invalid generated setup inputs before invoking ISCC' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-generated-setup-invalid-' + [guid]::NewGuid().ToString('N')) + try { + [IO.Directory]::CreateDirectory($root) | Out-Null + $templatePath = Join-Path $root 'Template.iss' + $inventoryPath = Join-Path $root 'Inventory.iss' + $validInventorySource = @' +procedure AddCcodExpectedSetupDirectories(Directories: TStrings); +begin +end; +'@ + $compilerMarker = Join-Path $root 'iscc-invoked.txt' + $compilerPath = Join-Path $root 'fake-iscc.cmd' + $setupPath = Join-Path $root 'setup.exe' + $compilerSource = "@echo off`r`n> `"$compilerMarker`" echo invoked`r`n> `"$setupPath`" echo setup`r`nexit /b 0`r`n" + [IO.File]::WriteAllText($compilerPath,$compilerSource,[Text.ASCIIEncoding]::new()) + . (Join-Path $repositoryRoot 'build\build.ps1') -Library + foreach ($fixture in @( + [pscustomobject]@{Name='include keyword';Directive='#include "extra.iss"'}, + [pscustomobject]@{Name='plus alias';Directive='#+ "extra.iss"'} + )) { + $templateSource = @" +[Setup] +AppName=InvalidGeneratedSetup +AppVersion=1.0.0 +DefaultDirName={tmp}\InvalidGeneratedSetup +OutputBaseFilename=InvalidGeneratedSetup +Uninstallable=no +[Code] +// CCOD_INSTALLER_DESTINATION_INVENTORY +$($fixture.Directive) +"@ + [IO.File]::WriteAllText($templatePath,$templateSource,[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($inventoryPath,$validInventorySource,[Text.UTF8Encoding]::new($false)) + $failure = $null + try { + Invoke-CcodBuildInnoCompiler -TemplatePath $templatePath -InventoryPath $inventoryPath -IsccPath $compilerPath -Arguments @() -SetupPath $setupPath + } catch { $failure = $_ } + Assert-CcodTrue ($null -ne $failure) "build rejects the $($fixture.Name) before ISCC" + Assert-CcodTrue ($failure.Exception.Message -cmatch 'include') "$($fixture.Name) failure identifies the include-free compiler boundary" + Assert-CcodTrue (-not [IO.File]::Exists($compilerMarker)) "$($fixture.Name) does not launch ISCC" + Assert-CcodTrue (-not [IO.File]::Exists($setupPath)) "$($fixture.Name) produces no setup artifact" + Assert-CcodEqual 0 @(Get-ChildItem -LiteralPath $root -Filter '.ccod-generated-setup-*.iss' -File -Force).Count "$($fixture.Name) leaves no generated setup script" + } + + $validTemplateSource = @' +[Setup] +AppName=InvalidInventory +AppVersion=1.0.0 +DefaultDirName={tmp}\InvalidInventory +OutputBaseFilename=InvalidInventory +Uninstallable=no +[Code] +// CCOD_INSTALLER_DESTINATION_INVENTORY +'@ + $invalidInventorySource = $validInventorySource + "`r`n[Files]`r`n" + [IO.File]::WriteAllText($templatePath,$validTemplateSource,[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($inventoryPath,$invalidInventorySource,[Text.UTF8Encoding]::new($false)) + $inventoryFailure = $null + try { + Invoke-CcodBuildInnoCompiler -TemplatePath $templatePath -InventoryPath $inventoryPath -IsccPath $compilerPath -Arguments @() -SetupPath $setupPath + } catch { $inventoryFailure = $_ } + Assert-CcodTrue ($null -ne $inventoryFailure) 'build rejects a generated inventory carrying an Inno section header' + Assert-CcodTrue ($inventoryFailure.Exception.Message -cmatch 'section header') 'inventory validation explains the forbidden section header' + Assert-CcodTrue (-not [IO.File]::Exists($compilerMarker)) 'invalid inventory does not launch ISCC' + Assert-CcodTrue (-not [IO.File]::Exists($setupPath)) 'invalid inventory produces no setup artifact' + Assert-CcodEqual 0 @(Get-ChildItem -LiteralPath $root -Filter '.ccod-generated-setup-*.iss' -File -Force).Count 'invalid inventory leaves no generated setup script' + } finally { + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } +} + +Invoke-CcodTest 'build passes only a GUID generated include-free setup to ISCC and cleans it' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-generated-setup-valid-' + [guid]::NewGuid().ToString('N')) + try { + [IO.Directory]::CreateDirectory($root) | Out-Null + $templatePath = Join-Path $root 'Template.iss' + $templateSource = @' +[Setup] +AppName=GeneratedSetup +AppVersion=1.0.0 +DefaultDirName={tmp}\GeneratedSetup +OutputBaseFilename=GeneratedSetup +Uninstallable=no +[Code] +// CCOD_INSTALLER_DESTINATION_INVENTORY +'@ + [IO.File]::WriteAllText($templatePath,$templateSource,[Text.UTF8Encoding]::new($false)) + $inventoryPath = Join-Path $root 'Inventory.iss' + $inventorySource = @' +procedure AddCcodExpectedSetupDirectories(Directories: TStrings); +begin + Directories.Add('payload\2.5.22'); +end; +'@ + [IO.File]::WriteAllText($inventoryPath,$inventorySource,[Text.UTF8Encoding]::new($false)) + $compilerArgument = Join-Path $root 'iscc-argument.txt' + $capturedSource = Join-Path $root 'compiled-source.iss' + $compilerPath = Join-Path $root 'fake-iscc.cmd' + $setupPath = Join-Path $root 'setup.exe' + $compilerSource = "@echo off`r`n> `"$compilerArgument`" echo %~f1`r`ncopy /y `"%~1`" `"$capturedSource`" >nul`r`n> `"$setupPath`" echo setup`r`nexit /b 0`r`n" + [IO.File]::WriteAllText($compilerPath,$compilerSource,[Text.ASCIIEncoding]::new()) + . (Join-Path $repositoryRoot 'build\build.ps1') -Library + + Invoke-CcodBuildInnoCompiler -TemplatePath $templatePath -InventoryPath $inventoryPath -IsccPath $compilerPath -Arguments @() -SetupPath $setupPath + + Assert-CcodTrue ([IO.File]::Exists($setupPath)) 'validated generated source accepts the compiler setup artifact' + Assert-CcodTrue ([IO.File]::Exists($capturedSource)) 'compiler receives a readable generated setup source' + $compiledPath = [IO.File]::ReadAllText($compilerArgument).Trim() + Assert-CcodTrue (-not $compiledPath.Equals([IO.Path]::GetFullPath($templatePath),[StringComparison]::OrdinalIgnoreCase)) 'ISCC never receives the checked-in template path' + Assert-CcodTrue ([IO.Path]::GetFileName($compiledPath) -cmatch '^\.ccod-generated-setup-[0-9a-f]{32}\.iss$') 'ISCC receives the GUID-named generated setup path' + Assert-CcodTrue (-not [IO.File]::Exists($compiledPath)) 'generated compiler input is cleaned after ISCC returns' + $compiledSource = [IO.File]::ReadAllText($capturedSource,[Text.UTF8Encoding]::new($false)) + Assert-CcodTrue ($compiledSource.Contains("Directories.Add('payload\2.5.22');")) 'compiled source contains the verified injected inventory procedure' + Assert-CcodTrue (-not $compiledSource.Contains('// CCOD_INSTALLER_DESTINATION_INVENTORY')) 'compiled source contains no unresolved inventory marker' + Assert-CcodTrue ($compiledSource -cnotmatch '(?m)^\s*#\s*(?:include\b|\+)') 'compiled source contains no external include directive' + Assert-CcodEqual 0 @(Get-ChildItem -LiteralPath $root -Filter '.ccod-generated-setup-*.iss' -File -Force).Count 'successful compile leaves no generated setup script' + } finally { + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } +} + +Invoke-CcodTest 'build cleans the GUID generated setup when ISCC returns nonzero' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-generated-setup-compiler-failure-' + [guid]::NewGuid().ToString('N')) + try { + [IO.Directory]::CreateDirectory($root) | Out-Null + $templatePath = Join-Path $root 'Template.iss' + $templateSource = @' +[Setup] +AppName=GeneratedSetupCompilerFailure +AppVersion=1.0.0 +DefaultDirName={tmp}\GeneratedSetupCompilerFailure +OutputBaseFilename=GeneratedSetupCompilerFailure +Uninstallable=no +[Code] +// CCOD_INSTALLER_DESTINATION_INVENTORY +'@ + [IO.File]::WriteAllText($templatePath,$templateSource,[Text.UTF8Encoding]::new($false)) + $inventoryPath = Join-Path $root 'Inventory.iss' + $inventorySource = @' +procedure AddCcodExpectedSetupDirectories(Directories: TStrings); +begin +end; +'@ + [IO.File]::WriteAllText($inventoryPath,$inventorySource,[Text.UTF8Encoding]::new($false)) + $compilerArgument = Join-Path $root 'iscc-argument.txt' + $compilerPath = Join-Path $root 'fake-iscc.cmd' + $setupPath = Join-Path $root 'setup.exe' + $compilerSource = "@echo off`r`n> `"$compilerArgument`" echo %~f1`r`nexit /b 23`r`n" + [IO.File]::WriteAllText($compilerPath,$compilerSource,[Text.ASCIIEncoding]::new()) + . (Join-Path $repositoryRoot 'build\build.ps1') -Library + $failure = $null + try { + Invoke-CcodBuildInnoCompiler -TemplatePath $templatePath -InventoryPath $inventoryPath -IsccPath $compilerPath -Arguments @() -SetupPath $setupPath + } catch { $failure = $_ } + + Assert-CcodTrue ($null -ne $failure) 'nonzero ISCC exit fails the build boundary' + Assert-CcodTrue ($failure.Exception.Message -cmatch 'exit code 23') 'compiler failure retains the exact nonzero exit code' + Assert-CcodTrue ([IO.File]::Exists($compilerArgument)) 'nonzero compiler records the generated source argument' + $compiledPath = [IO.File]::ReadAllText($compilerArgument).Trim() + Assert-CcodTrue ([IO.Path]::GetFileName($compiledPath) -cmatch '^\.ccod-generated-setup-[0-9a-f]{32}\.iss$') 'nonzero compiler receives the GUID generated source' + Assert-CcodTrue (-not [IO.File]::Exists($compiledPath)) 'nonzero compiler path is removed by the build finally boundary' + Assert-CcodTrue (-not [IO.File]::Exists($setupPath)) 'nonzero compiler produces no accepted setup artifact' + Assert-CcodEqual 0 @(Get-ChildItem -LiteralPath $root -Filter '.ccod-generated-setup-*.iss' -File -Force).Count 'nonzero compiler leaves no generated setup script' + } finally { + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } +} + +Invoke-CcodTest 'real ISCC compiles the generated setup with its injected inventory procedure' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-generated-setup-iscc-' + [guid]::NewGuid().ToString('N')) + try { + $output = Join-Path $root 'output' + [IO.Directory]::CreateDirectory($output) | Out-Null + $templatePath = Join-Path $root 'Template.iss' + $templateSource = @" +[Setup] +AppName=GeneratedSetupRealCompiler +AppVersion=1.0.0 +DefaultDirName={tmp}\GeneratedSetupRealCompiler +OutputDir=$output +OutputBaseFilename=GeneratedSetupRealCompiler +Uninstallable=no +[Code] +// CCOD_INSTALLER_DESTINATION_INVENTORY +function InitializeSetup(): Boolean; +var + Directories: TStringList; +begin + Directories := TStringList.Create; + try + AddCcodExpectedSetupDirectories(Directories); + Result := Directories.Count = 1; + finally + Directories.Free; + end; +end; +"@ + [IO.File]::WriteAllText($templatePath,$templateSource,[Text.UTF8Encoding]::new($false)) + $inventoryPath = Join-Path $root 'Inventory.iss' + $inventorySource = @' +procedure AddCcodExpectedSetupDirectories(Directories: TStrings); +begin + Directories.Add('payload\2.5.22'); +end; +'@ + [IO.File]::WriteAllText($inventoryPath,$inventorySource,[Text.UTF8Encoding]::new($false)) + $iscc = @( + (Join-Path $env:LOCALAPPDATA 'Programs\Inno Setup 6\ISCC.exe'), + (Join-Path ${env:ProgramFiles(x86)} 'Inno Setup 6\ISCC.exe'), + (Join-Path $env:ProgramFiles 'Inno Setup 6\ISCC.exe') + ) | Where-Object { $_ -and [IO.File]::Exists($_) } | Select-Object -First 1 + if (-not $iscc) { throw 'Inno Setup 6 is required for the generated setup fixture' } + $setupPath = Join-Path $output 'GeneratedSetupRealCompiler.exe' + . (Join-Path $repositoryRoot 'build\build.ps1') -Library + + Invoke-CcodBuildInnoCompiler -TemplatePath $templatePath -InventoryPath $inventoryPath -IsccPath $iscc -Arguments @() -SetupPath $setupPath + + Assert-CcodTrue ([IO.File]::Exists($setupPath)) 'real ISCC compiles the injected inventory procedure and its call site' + Assert-CcodEqual 0 @(Get-ChildItem -LiteralPath $root -Filter '.ccod-generated-setup-*.iss' -File -Force).Count 'real ISCC compile leaves no generated setup script' + } finally { + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } +} + +function New-CcodActivationPayloadFixture { + param([string]$Version = '2.5.22') + + $appRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-activation-payload-' + [guid]::NewGuid().ToString('N')) + $payloadRoot = Join-Path $appRoot "payload\$Version" + [IO.Directory]::CreateDirectory((Join-Path $payloadRoot 'src\persistence\modules')) | Out-Null + [IO.File]::WriteAllText((Join-Path $payloadRoot 'package.json'),([ordered]@{name='fixture';version=$Version}|ConvertTo-Json),[Text.UTF8Encoding]::new($false)) + $installSource = @' +param( + [string]$InstallRoot, + [switch]$EnableCandidateCompatibleUpdates, + [string]$ActivationId, + [string]$ExpectedVersion, + [string]$PayloadManifestPath, + [string]$ExpectedPayloadManifestSha256, + [string]$SealedPackageSha256 +) +$null = [IO.Directory]::CreateDirectory($InstallRoot) +[IO.File]::WriteAllText((Join-Path $InstallRoot 'sealed-package-sha256.txt'),[string]$SealedPackageSha256,[Text.UTF8Encoding]::new($false)) +$manifest = [IO.File]::ReadAllText($PayloadManifestPath,[Text.Encoding]::UTF8) | ConvertFrom-Json +Import-Module (Join-Path $PSScriptRoot 'src\persistence\modules\InstallLifecycle.psm1') -Force +Invoke-CcodFixtureInstall -InstallRoot $InstallRoot -ActivationId $ActivationId -ManifestVersion ([string]$manifest.projectVersion) +exit 0 +'@ + [IO.File]::WriteAllText((Join-Path $payloadRoot 'Install-CodexControlOtherDevices.ps1'),$installSource,[Text.UTF8Encoding]::new($false)) + $lifecycleSource = @' +function Invoke-CcodFixtureInstall { + param([string]$InstallRoot,[string]$ActivationId,[string]$ManifestVersion) + [IO.Directory]::CreateDirectory((Join-Path $InstallRoot 'state\activation-receipts')) | Out-Null + [IO.File]::WriteAllText((Join-Path $InstallRoot 'activation-byte-marker.txt'),('original:' + $ManifestVersion),[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText((Join-Path $InstallRoot 'active.json'),'{"activeRuntime":"runtime-fixture"}',[Text.UTF8Encoding]::new($false)) + $receipt=[ordered]@{schemaVersion=1;activationId=$ActivationId;phase='Ready';runtimeId='runtime-fixture';previousRuntimeId=$null;startedAtUtc='2030-02-03T04:05:06.0000000Z';updatedAtUtc='2030-02-03T04:05:07.0000000Z';ready=$true;errorCode=$null} + [IO.File]::WriteAllText((Join-Path $InstallRoot "state\activation-receipts\$ActivationId.Ready.json"),($receipt|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new($false)) +} +Export-ModuleMember -Function Invoke-CcodFixtureInstall +'@ + [IO.File]::WriteAllText((Join-Path $payloadRoot 'src\persistence\modules\InstallLifecycle.psm1'),$lifecycleSource,[Text.UTF8Encoding]::new($false)) + $runtimeManifestSource = @" +function Read-CcodActiveRuntime { param([string]`$InstallRoot) Get-Content -LiteralPath (Join-Path `$InstallRoot 'active.json') -Raw | ConvertFrom-Json } +function Test-CcodRuntimeManifest { param([string]`$RuntimeDirectory,[string]`$ExpectedRuntimeId) [pscustomobject]@{Valid=`$true;Manifest=[pscustomobject]@{projectVersion='$Version'}} } +Export-ModuleMember -Function Read-CcodActiveRuntime,Test-CcodRuntimeManifest +"@ + [IO.File]::WriteAllText((Join-Path $payloadRoot 'src\persistence\modules\RuntimeManifest.psm1'),$runtimeManifestSource,[Text.UTF8Encoding]::new($false)) + $records = [Collections.Generic.List[object]]::new() + foreach ($file in @(Get-ChildItem -LiteralPath $payloadRoot -File -Force -Recurse)) { + $relative = $file.FullName.Substring($payloadRoot.TrimEnd('\').Length + 1).Replace('\','/') + $records.Add([pscustomobject][ordered]@{path=$relative;length=[int64]$file.Length;sha256=Get-CcodTestFileSha256 -Path $file.FullName}) + } + $comparison = [System.Comparison[object]]{param($left,$right)[StringComparer]::Ordinal.Compare([string]$left.path,[string]$right.path)} + $records.Sort($comparison) + $manifestPath = Join-Path $payloadRoot 'installer-payload.manifest.json' + [IO.File]::WriteAllText($manifestPath,([ordered]@{schemaVersion=1;projectVersion=$Version;files=@($records)}|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + return [pscustomobject]@{AppRoot=$appRoot;PayloadRoot=$payloadRoot;ManifestPath=$manifestPath;ManifestSha256=Get-CcodTestFileSha256 -Path $manifestPath} +} + +function New-CcodActivationBarrierHost { + param( + [Parameter(Mandatory)][string]$OutputDirectory, + [Parameter(Mandatory)][string]$ReadyPath, + [Parameter(Mandatory)][string]$ContinuePath, + [Parameter(Mandatory)][string]$RealPowerShellPath + ) + + [IO.Directory]::CreateDirectory($OutputDirectory) | Out-Null + $outputPath = Join-Path $OutputDirectory 'powershell.exe' + $source = @" +using System; +using System.Diagnostics; +using System.IO; +using System.Text; +using System.Threading; +public static class Program { + private static string Quote(string value) { + var result = new StringBuilder(); result.Append((char)34); int slashes = 0; + foreach (char character in value) { + if (character == (char)92) { slashes++; continue; } + if (character == (char)34) { result.Append(new string((char)92, (slashes * 2) + 1)); result.Append((char)34); slashes = 0; continue; } + if (slashes > 0) { result.Append(new string((char)92, slashes)); slashes = 0; } + result.Append(character); + } + if (slashes > 0) result.Append(new string((char)92, slashes * 2)); + result.Append((char)34); return result.ToString(); + } + public static int Main(string[] args) { + File.WriteAllText(@"$($ReadyPath.Replace('"','""'))", "ready", new UTF8Encoding(false)); + var deadline = DateTime.UtcNow.AddSeconds(30); + while (!File.Exists(@"$($ContinuePath.Replace('"','""'))")) { if (DateTime.UtcNow >= deadline) return 124; Thread.Sleep(10); } + var info = new ProcessStartInfo(@"$($RealPowerShellPath.Replace('"','""'))", String.Join(" ", Array.ConvertAll(args, Quote))); + info.UseShellExecute = false; info.CreateNoWindow = true; + using (var process = Process.Start(info)) { process.WaitForExit(); return process.ExitCode; } + } +} +"@ + Add-Type -TypeDefinition $source -Language CSharp -OutputAssembly $outputPath -OutputType ConsoleApplication + return $outputPath +} + +function Invoke-CcodActivationVerifierFixture { + param([Parameter(Mandatory)]$Fixture,[Parameter(Mandatory)][string]$ExpectedManifestSha256,[string]$PayloadRoot) + + $stdoutPath = Join-Path $Fixture.AppRoot ('stdout-' + [guid]::NewGuid().ToString('N') + '.txt') + $stderrPath = Join-Path $Fixture.AppRoot ('stderr-' + [guid]::NewGuid().ToString('N') + '.txt') + if ([string]::IsNullOrWhiteSpace($PayloadRoot)) { $PayloadRoot = $Fixture.PayloadRoot } + $argumentLine = '-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "{0}" -AppRoot "{1}" -InstallRoot "{1}" -PayloadRoot "{2}" -ExpectedVersion "2.5.22" -ExpectedPayloadManifestSha256 "{3}" -ActivationId "77777777-6666-5555-4444-333333333333" -ValidateReceiptOnly' -f (Join-Path $repositoryRoot 'Activate-CcodRemoteFix.ps1'),$Fixture.AppRoot,$PayloadRoot,$ExpectedManifestSha256 + $process = Start-Process -FilePath (Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe') -ArgumentList $argumentLine -WindowStyle Hidden -RedirectStandardOutput $stdoutPath -RedirectStandardError $stderrPath -Wait -PassThru + try { $exitCode = [int]$process.ExitCode } finally { $process.Dispose() } + return [pscustomobject]@{ExitCode=$exitCode;Output=([IO.File]::ReadAllText($stdoutPath)+[IO.File]::ReadAllText($stderrPath))} +} + +# Production mutation caught: verifying package bytes but invoking a legacy source root without propagating package identity. +Invoke-CcodTest 'temporary bootstrap verifies the sealed package and propagates its exact hash to the installer child' { + $fixture = New-CcodActivationPayloadFixture + $module = $null + try { + $packagePath = Join-Path $fixture.AppRoot 'sealed-installer.zip' + $packageManifestPath = Join-Path $fixture.AppRoot 'sealed-installer.manifest.json' + $module = Import-Module (Join-Path $repositoryRoot 'build\InstallerPackage.psm1') -Force -PassThru + $created = New-CcodInstallerPackage -PayloadRoot $fixture.PayloadRoot -PayloadManifestPath $fixture.ManifestPath -Version '2.5.22' -GitCommit ('d' * 40) -OutputPath $packagePath -ManifestOutputPath $packageManifestPath + $installRoot = Join-Path $fixture.AppRoot 'installed-state' + $activationId = '77777777-6666-5555-4444-333333333333' + $setupScanObservation=[pscustomobject]@{Path=$null} + function Get-MpComputerStatus {[pscustomobject]@{AMServiceEnabled=$true;AntivirusEnabled=$true;RealTimeProtectionEnabled=$true;AMProductVersion='fixture-platform';AMEngineVersion='fixture-engine';AntivirusSignatureVersion='fixture-signature';AntivirusSignatureLastUpdated=[datetime]::UtcNow.AddHours(-1)}} + function Get-MpThreatDetection {param($ErrorAction)@()} + function Start-MpScan {param($ScanType,$ScanPath,$ErrorAction)$setupScanObservation.Path=[string]$ScanPath} + $output = @(& (Join-Path $repositoryRoot 'Activate-CcodRemoteFix.ps1') -InstallRoot $installRoot -PackagePath $packagePath -PackageManifestPath $packageManifestPath -ExpectedPackageSha256 $created.PackageSha256 -ExpectedPackageManifestSha256 $created.ManifestSha256 -ExpectedVersion '2.5.22' -ExpectedGitCommit ('d' * 40) -ActivationId $activationId 2>&1) + Assert-CcodEqual 0 $LASTEXITCODE "sealed package bootstrap completes: $($output -join ' ')" + Assert-CcodEqual ([IO.Path]::GetFullPath($packagePath)) ([string]$setupScanObservation.Path) 'Setup scans the canonical package path returned by the held seal' + Assert-CcodEqual $created.PackageSha256 ([IO.File]::ReadAllText((Join-Path $installRoot 'sealed-package-sha256.txt'),[Text.UTF8Encoding]::new($false))) 'child receives the exact verified package identity' + Assert-CcodTrue (Test-Path -LiteralPath (Join-Path $installRoot "state\activation-receipts\$activationId.Ready.json") -PathType Leaf) 'bootstrap accepts only the append-only Ready receipt' + Assert-CcodTrue (-not (Test-Path -LiteralPath (Join-Path $installRoot 'state\post-install-activation.json'))) 'bootstrap never creates or consumes the mutable legacy receipt' + } finally { + if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $fixture.AppRoot) { Remove-Item -LiteralPath $fixture.AppRoot -Recurse -Force -ErrorAction SilentlyContinue } + } +} + +Invoke-CcodTask5Test 'entrypoints' 'Setup Defender failure occurs after sealing and before temp expansion or activation mutation' { + $productionPath=Join-Path $repositoryRoot 'Activate-CcodRemoteFix.ps1' + $productionSource=[IO.File]::ReadAllText($productionPath,[Text.UTF8Encoding]::new($false,$true)) + $needle='$packageDefender = Invoke-CcodSetupPackageDefenderGate -PackagePath ([string]$packageSeal.PackagePath)' + Assert-CcodEqual 1 ([regex]::Matches($productionSource,[regex]::Escape($needle)).Count) 'Setup invokes the package Defender gate exactly once from the sealed-package branch' + $sealIndex=$productionSource.IndexOf('$packageSeal = Open-CcodInstallerPackageSeal',[StringComparison]::Ordinal) + $scanIndex=$productionSource.IndexOf($needle,[StringComparison]::Ordinal) + $tempIndex=$productionSource.IndexOf('$packageAppRoot = Join-Path',[StringComparison]::Ordinal) + Assert-CcodTrue ($sealIndex-ge0-and$scanIndex-gt$sealIndex-and$tempIndex-gt$scanIndex) 'Setup scan is ordered after exact seal and before the first extraction temp path' + + $fixture=New-CcodActivationPayloadFixture + $module=$null + try{ + $packagePath=Join-Path $fixture.AppRoot 'sealed-installer.zip';$packageManifestPath=Join-Path $fixture.AppRoot 'sealed-installer.manifest.json' + $module=Import-Module (Join-Path $repositoryRoot 'build\InstallerPackage.psm1') -Force -PassThru + $created=New-CcodInstallerPackage -PayloadRoot $fixture.PayloadRoot -PayloadManifestPath $fixture.ManifestPath -Version '2.5.22' -GitCommit ('d'*40) -OutputPath $packagePath -ManifestOutputPath $packageManifestPath + $injectedPath=Join-Path $fixture.AppRoot 'Activate-CcodRemoteFix-defender-failure.ps1' + $injectedSource=$productionSource.Replace($needle,"throw 'CCOD_SETUP_DEFENDER_SCAN_FAILED'") + Assert-CcodTrue ($injectedSource-cne$productionSource) 'test injection replaces only the real Setup Defender call boundary' + [IO.File]::WriteAllText($injectedPath,$injectedSource,[Text.UTF8Encoding]::new($false)) + $installRoot=Join-Path $fixture.AppRoot 'must-remain-absent';$before=Get-CcodReadOnlyProductTreeSnapshot -Root $installRoot + $tempRoot=[IO.Path]::GetFullPath([IO.Path]::GetTempPath());$beforeTemps=@(Get-ChildItem -LiteralPath $tempRoot -Directory -Filter 'ccod-activation-package-*' -Force -ErrorAction Stop|ForEach-Object{$_.FullName}|Sort-Object) + $output=@(&$injectedPath -InstallRoot $installRoot -PackagePath $packagePath -PackageManifestPath $packageManifestPath -ExpectedPackageSha256 $created.PackageSha256 -ExpectedPackageManifestSha256 $created.ManifestSha256 -ExpectedVersion '2.5.22' -ExpectedGitCommit ('d'*40) -ActivationId '77777777-6666-5555-4444-333333333333' 2>&1) + Assert-CcodEqual 3 $LASTEXITCODE "injected sealed-package Defender failure is rejected: $($output-join' ')" + Assert-CcodTrue (($output-join"`n")-cmatch'CCOD_SETUP_DEFENDER_SCAN_FAILED') 'Setup preserves the stable scan failure code' + $after=Get-CcodReadOnlyProductTreeSnapshot -Root $installRoot + Assert-CcodEqual ($before|ConvertTo-Json -Depth 8 -Compress) ($after|ConvertTo-Json -Depth 8 -Compress) 'Setup scan failure creates no Prepared receipt transaction product or worker state' + $afterTemps=@(Get-ChildItem -LiteralPath $tempRoot -Directory -Filter 'ccod-activation-package-*' -Force -ErrorAction Stop|ForEach-Object{$_.FullName}|Sort-Object) + Assert-CcodEqual ($beforeTemps-join'|') ($afterTemps-join'|') 'Setup scan failure creates no package expansion directory' + }finally{ + if($null-ne$module){Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue} + if(Test-Path $fixture.AppRoot){Remove-Item -LiteralPath $fixture.AppRoot -Recurse -Force -ErrorAction SilentlyContinue} + } +} + +function Invoke-CcodInnoPayloadCompileFixture { + param([switch]$IncludePayloadDefines,[switch]$OmitDestinationInventory,[switch]$BindWrongPackageHash,[switch]$UseInertActivationMarker) + + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-inno-payload-contract-' + [guid]::NewGuid().ToString('N')) + $tray = Join-Path $root 'tray' + $portable = Join-Path $root 'portable' + $payload = Join-Path $root 'payload' + $output = Join-Path $root 'output' + foreach ($directory in @($tray,$portable,$payload,$output)) { [IO.Directory]::CreateDirectory($directory) | Out-Null } + foreach ($leaf in @('CodexRemote.TrayHost.exe','CodexRemote.TrayHost.exe.config','trayhost-build-provenance.json')) { + [IO.File]::WriteAllText((Join-Path $tray $leaf),"fixture $leaf",[Text.UTF8Encoding]::new($false)) + } + foreach ($leaf in @('CodexRemote.Portable.exe','CodexRemote.Portable.exe.config','portable-launcher-provenance.json')) { + [IO.File]::WriteAllText((Join-Path $portable $leaf),"fixture $leaf",[Text.UTF8Encoding]::new($false)) + } + [IO.File]::WriteAllText((Join-Path $payload 'package.json'),'{"version":"2.5.21"}',[Text.UTF8Encoding]::new($false)) + $packageHash = Get-CcodTestFileSha256 -Path (Join-Path $payload 'package.json') + $manifest = [ordered]@{schemaVersion=1;projectVersion='2.5.21';files=@([ordered]@{path='package.json';length=[int64](Get-Item -LiteralPath (Join-Path $payload 'package.json')).Length;sha256=$packageHash})} + $manifestPath = Join-Path $payload 'installer-payload.manifest.json' + [IO.File]::WriteAllText($manifestPath,($manifest|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + $inventoryPath = Join-Path $root 'InstallerDestinationInventory.iss' + $templatePath = Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss' + if (-not $OmitDestinationInventory) { + & (Join-Path $repositoryRoot 'tools\New-InstallerDestinationInventory.ps1') -RepositoryRoot $repositoryRoot -PayloadRoot $payload -ProjectVersion '2.5.21' -InnoScriptPath $templatePath -OutputPath $inventoryPath | Out-Null + } + $iscc = @( + (Join-Path $env:LOCALAPPDATA 'Programs\Inno Setup 6\ISCC.exe'), + (Join-Path ${env:ProgramFiles(x86)} 'Inno Setup 6\ISCC.exe'), + (Join-Path $env:ProgramFiles 'Inno Setup 6\ISCC.exe') + ) | Where-Object { $_ -and [IO.File]::Exists($_) } | Select-Object -First 1 + if (-not $iscc) { throw 'Inno Setup 6 is required for the setup payload contract' } + $payloadManifestSha256 = Get-CcodTestFileSha256 -Path $manifestPath + $setupGitCommit = 'c' * 40 + Import-Module (Join-Path $repositoryRoot 'build\InstallerPackage.psm1') -Force + $installerPackagePath = Join-Path $root 'installer-package.zip' + $installerPackageManifestPath = Join-Path $root 'installer-package.manifest.json' + $installerPackage = New-CcodInstallerPackage -PayloadRoot $payload -PayloadManifestPath $manifestPath -Version '2.5.21' -GitCommit $setupGitCommit -OutputPath $installerPackagePath -ManifestOutputPath $installerPackageManifestPath + $activationMarker = Join-Path $root 'activation-executed.txt' + $activationBootstrapPath = if($UseInertActivationMarker){ + $path=Join-Path $root 'inert-activation-bootstrap.ps1' + [IO.File]::WriteAllText($path,"[IO.File]::WriteAllText('$($activationMarker.Replace("'","''"))','executed',[Text.UTF8Encoding]::new(`$false));exit 97",[Text.UTF8Encoding]::new($false)) + $path + }else{Join-Path $repositoryRoot 'Activate-CcodRemoteFix.ps1'} + $activationBootstrapSha256 = Get-CcodTestFileSha256 -Path $activationBootstrapPath + $setupProvenancePath = Join-Path $root 'setup-provenance.json' + [IO.File]::WriteAllText($setupProvenancePath,'{}',[Text.UTF8Encoding]::new($false)) + $boundPackageHash=$null + $arguments = @('/DProjectVersion=2.5.21',"/DSetupGitCommit=$setupGitCommit","/DSetupProvenancePath=$setupProvenancePath","/O$output\") + if ($IncludePayloadDefines) { + $boundPackageHash=if($BindWrongPackageHash){'0'*64}else{[string]$installerPackage.PackageSha256} + $arguments = @('/DProjectVersion=2.5.21',"/DInstallerPackagePath=$installerPackagePath","/DInstallerPackageManifestPath=$installerPackageManifestPath","/DInstallerPackageSha256=$boundPackageHash","/DInstallerPackageManifestSha256=$($installerPackage.ManifestSha256)","/DActivationBootstrapPath=$activationBootstrapPath","/DActivationBootstrapSha256=$activationBootstrapSha256","/DSetupGitCommit=$setupGitCommit","/DSetupProvenancePath=$setupProvenancePath") + $arguments += "/O$output\" + } + . (Join-Path $repositoryRoot 'build\build.ps1') -Library + $generatedPath = Join-Path (Split-Path $templatePath -Parent) ('.ccod-generated-setup-' + [guid]::NewGuid().ToString('N') + '.iss') + $generatedSource = '' + $compileOutput = @() + $exitCode = 1 + try { + $generated = New-CcodBuildGeneratedInnoScript -TemplatePath $templatePath -InventoryPath $inventoryPath -OutputPath $generatedPath + $generatedSource = [IO.File]::ReadAllText($generated,[Text.UTF8Encoding]::new($false)) + $arguments += $generated + $previousPreference = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + $compileOutput = @(& $iscc @arguments 2>&1) + $exitCode = $LASTEXITCODE + } finally { $ErrorActionPreference = $previousPreference } + } catch { + $compileOutput = @($_.Exception.Message) + $exitCode = 1 + } finally { + if ([IO.File]::Exists($generatedPath)) { Remove-Item -LiteralPath $generatedPath -Force } + } + return [pscustomobject]@{ + Root = $root + ExitCode = $exitCode + Output = ($compileOutput -join "`n") + SetupPath = (Join-Path $output 'CodexRemote-fix-2.5.21-setup.exe') + GeneratedPath = $generatedPath + GeneratedSource = $generatedSource + PayloadManifestSha256 = $payloadManifestSha256 + PackageSha256 = $installerPackage.PackageSha256 + PackageManifestSha256 = $installerPackage.ManifestSha256 + ActivationBootstrapSha256 = $activationBootstrapSha256 + SetupGitCommit = $setupGitCommit + PackagePath = $installerPackagePath + PackageManifestPath = $installerPackageManifestPath + ActivationBootstrapPath = $activationBootstrapPath + InventoryPath = $inventoryPath + TemplatePath = $templatePath + CompilerPath = $iscc + ActivationMarker = $activationMarker + BoundPackageSha256 = $boundPackageHash + } +} + +Invoke-CcodTest 'setup build and activation bind one immutable versioned payload end to end' { + $build = Get-Content -LiteralPath (Join-Path $repositoryRoot 'build\build.ps1') -Raw -Encoding UTF8 + $inno = Get-Content -LiteralPath (Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss') -Raw -Encoding UTF8 + $activation = Get-Content -LiteralPath (Join-Path $repositoryRoot 'Activate-CcodRemoteFix.ps1') -Raw -Encoding UTF8 + $installer = Get-Content -LiteralPath (Join-Path $repositoryRoot 'Install-CodexControlOtherDevices.ps1') -Raw -Encoding UTF8 + $setupArtifact = Get-Content -LiteralPath (Join-Path $repositoryRoot 'build\SetupArtifact.psm1') -Raw -Encoding UTF8 + + Assert-CcodTrue ($build -cmatch 'New-InstallerPayloadManifest\.ps1' -and $build -cmatch 'InstallerPackage\.psm1' -and $build -cmatch 'New-CcodInstallerPackage' -and $build -cmatch 'New-CcodBuildGeneratedInnoScript') 'build creates the sealed package and binds it into generated setup compilation' + Assert-CcodTrue ($inno -cmatch 'InstallerPackagePath' -and $inno -cmatch 'ExtractTemporaryFile\(.ccod-installer-package\.zip.' -and $inno -cnotmatch 'DestDir:\s*"\{app\}') 'Inno extracts only the sealed package to private temporary storage' + Assert-CcodTrue ($inno -cmatch '-ExpectedPackageSha256\s+"\{#InstallerPackageSha256' -and $inno -cmatch '-ExpectedVersion\s+"\{#ProjectVersion') 'Inno binds activation to its compiled package identity and version' + Assert-CcodTrue ($activation -cmatch '\[string\]\$PackagePath' -and $activation -cmatch '\[string\]\$ExpectedPackageSha256' -and $activation -cmatch 'Open-CcodInstallerPackageSeal') 'activation accepts and opens the sealed package contract' + Assert-CcodTrue ($installer -cmatch '\[string\]\$SealedPackageSha256' -and $installer -cmatch '\$invoke\.SealedPackageSha256\s*=\s*\$SealedPackageSha256') 'installer explicitly forwards the exact sealed package identity to lifecycle activation' + Assert-CcodTrue ($setupArtifact -cmatch 'New-CcodSealedSetupBuildProvenance' -and $setupArtifact -cmatch 'Test-CcodSealedSetupBuildProvenance' -and $build -cmatch 'New-CcodSealedSetupBuildProvenance' -and $build -cmatch 'Test-CcodSealedSetupBuildProvenance') 'Setup provenance creates and verifies package manifest and bootstrap bindings' +} + +# Production mutation caught: reopening mutable payload paths after the parent accepted their manifest bytes. +Invoke-CcodTest 'activation executes only the verified manifest installer and module bytes across the child-launch barrier' { + $fixture = New-CcodActivationPayloadFixture + $barrierRoot = Join-Path $fixture.AppRoot 'barrier-host' + $readyPath = Join-Path $fixture.AppRoot 'child-launch.ready' + $continuePath = Join-Path $fixture.AppRoot 'child-launch.continue' + $stdoutPath = Join-Path $fixture.AppRoot 'activation.stdout' + $stderrPath = Join-Path $fixture.AppRoot 'activation.stderr' + $process = $null + try { + $realPowerShell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + $null = New-CcodActivationBarrierHost -OutputDirectory $barrierRoot -ReadyPath $readyPath -ContinuePath $continuePath -RealPowerShellPath $realPowerShell + $activationId = '77777777-6666-5555-4444-333333333333' + $arguments = @( + '-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-File',(Join-Path $repositoryRoot 'Activate-CcodRemoteFix.ps1'), + '-AppRoot',$fixture.AppRoot,'-InstallRoot',$fixture.AppRoot,'-PayloadRoot',$fixture.PayloadRoot, + '-ExpectedVersion','2.5.22','-ExpectedPayloadManifestSha256',$fixture.ManifestSha256,'-ActivationId',$activationId, + '-FirstReceiptTimeoutMilliseconds','30000','-ActivationTimeoutMilliseconds','30000' + ) + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $realPowerShell + $startInfo.Arguments = (($arguments | ForEach-Object { '"' + ([string]$_).Replace('\','\').Replace('"','\"') + '"' }) -join ' ') + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + $startInfo.EnvironmentVariables['PATH'] = $barrierRoot + ';' + $env:PATH + $process = [Diagnostics.Process]::new() + $process.StartInfo = $startInfo + Assert-CcodTrue $process.Start() 'activation fixture process starts' + + $deadline = [DateTime]::UtcNow.AddSeconds(30) + while (-not [IO.File]::Exists($readyPath) -and [DateTime]::UtcNow -lt $deadline -and -not $process.HasExited) { + [Threading.Thread]::Sleep(10) + } + $barrierFailure = if ($process.HasExited) { $process.StandardOutput.ReadToEnd() + ' ' + $process.StandardError.ReadToEnd() } else { '' } + Assert-CcodTrue ([IO.File]::Exists($readyPath)) "barrier proves parent payload verification completed before child execution: $barrierFailure" + + $replacementInstallerMarker = Join-Path $fixture.AppRoot 'replacement-installer-executed.txt' + $replacementInstaller = @" +param([string]`$InstallRoot,[switch]`$EnableCandidateCompatibleUpdates,[string]`$ActivationId,[string]`$ExpectedVersion,[string]`$PayloadManifestPath,[string]`$ExpectedPayloadManifestSha256) +[IO.File]::WriteAllText('$($replacementInstallerMarker.Replace("'","''"))','executed',[Text.UTF8Encoding]::new(`$false)) +`$manifest=[IO.File]::ReadAllText(`$PayloadManifestPath,[Text.Encoding]::UTF8)|ConvertFrom-Json +Import-Module (Join-Path `$PSScriptRoot 'src\persistence\modules\InstallLifecycle.psm1') -Force +Invoke-CcodFixtureInstall -InstallRoot `$InstallRoot -ActivationId `$ActivationId -ManifestVersion ([string]`$manifest.projectVersion) +exit 0 +"@ + [IO.File]::WriteAllText((Join-Path $fixture.PayloadRoot 'Install-CodexControlOtherDevices.ps1'),$replacementInstaller,[Text.UTF8Encoding]::new($false)) + $replacementModule = @' +function Invoke-CcodFixtureInstall { + param([string]$InstallRoot,[string]$ActivationId,[string]$ManifestVersion) + [IO.Directory]::CreateDirectory((Join-Path $InstallRoot 'state\activation-receipts')) | Out-Null + [IO.File]::WriteAllText((Join-Path $InstallRoot 'activation-byte-marker.txt'),('replacement:' + $ManifestVersion),[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText((Join-Path $InstallRoot 'active.json'),'{"activeRuntime":"runtime-fixture"}',[Text.UTF8Encoding]::new($false)) + $receipt=[ordered]@{schemaVersion=1;activationId=$ActivationId;phase='Ready';runtimeId='runtime-fixture';previousRuntimeId=$null;startedAtUtc='2030-02-03T04:05:06.0000000Z';updatedAtUtc='2030-02-03T04:05:07.0000000Z';ready=$true;errorCode=$null} + [IO.File]::WriteAllText((Join-Path $InstallRoot "state\activation-receipts\$ActivationId.Ready.json"),($receipt|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new($false)) +} +Export-ModuleMember -Function Invoke-CcodFixtureInstall +'@ + [IO.File]::WriteAllText((Join-Path $fixture.PayloadRoot 'src\persistence\modules\InstallLifecycle.psm1'),$replacementModule,[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($fixture.ManifestPath,'{"schemaVersion":1,"projectVersion":"9.9.9","files":[]}',[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($continuePath,'continue',[Text.UTF8Encoding]::new($false)) + + Assert-CcodTrue $process.WaitForExit(30000) 'activation fixture finishes within the bounded child deadline' + $stdout = $process.StandardOutput.ReadToEnd() + $stderr = $process.StandardError.ReadToEnd() + Assert-CcodEqual 0 ([int]$process.ExitCode) "verified fixture activation succeeds: $stdout $stderr" + Assert-CcodEqual 'original:2.5.22' ([IO.File]::ReadAllText((Join-Path $fixture.AppRoot 'activation-byte-marker.txt'),[Text.UTF8Encoding]::new($false))) 'child executes and parses only the original verified bytes' + Assert-CcodTrue (-not [IO.File]::Exists($replacementInstallerMarker)) 'post-verification installer replacement never executes' + Assert-CcodTrue (-not [IO.File]::Exists((Join-Path $fixture.AppRoot 'active.json.tmp'))) 'rejection or success leaves no uncommitted pointer sidecar' + } finally { + if ($null -ne $process) { + try { if (-not $process.HasExited) { $process.Kill(); [void]$process.WaitForExit(5000) } } catch { } + $process.Dispose() + } + if (Test-Path -LiteralPath $fixture.AppRoot) { Remove-Item -LiteralPath $fixture.AppRoot -Recurse -Force -ErrorAction SilentlyContinue } + } +} + +# Production mutation caught: creating/checking the stage by pathname and allowing directory substitution before the first staged leaf write. +Invoke-CcodTest 'activation pins the stage directory before any verified leaf write' { + $fixture = New-CcodActivationPayloadFixture + $libraryRoot = Join-Path $fixture.AppRoot 'activation-library' + $outside = Join-Path $fixture.AppRoot 'outside-stage-target' + $libraryPath = Join-Path $libraryRoot 'ActivationFunctions.psm1' + $module = $null + $breakpoint = $null + $seal = $null + $attack = [pscustomobject]@{ Attempted=$false; Outcome='not-run'; Stage=$null; Captured=$null } + try { + [IO.Directory]::CreateDirectory($libraryRoot) | Out-Null + [IO.Directory]::CreateDirectory($outside) | Out-Null + [IO.File]::WriteAllText((Join-Path $outside 'outside-sentinel.txt'),'outside-original',[Text.UTF8Encoding]::new($false)) + $tokens = $null; $parseErrors = $null + $activationAst = [Management.Automation.Language.Parser]::ParseFile((Join-Path $repositoryRoot 'Activate-CcodRemoteFix.ps1'),[ref]$tokens,[ref]$parseErrors) + Assert-CcodEqual 0 @($parseErrors).Count 'activation function library source parses before race fixture extraction' + $definitions = @($activationAst.EndBlock.Statements | Where-Object { $_ -is [Management.Automation.Language.FunctionDefinitionAst] } | ForEach-Object { $_.Extent.Text }) + Assert-CcodTrue ($definitions.Count -gt 10) 'activation race fixture extracts the real production function set' + [IO.File]::WriteAllText($libraryPath,(($definitions -join "`r`n`r`n")+"`r`n"),[Text.UTF8Encoding]::new($false)) + $module = Import-Module $libraryPath -Force -PassThru + $libraryLines = [IO.File]::ReadAllLines($libraryPath,[Text.UTF8Encoding]::new($false)) + $barrierLines = @() + for ($index=0;$index-lt$libraryLines.Length;$index++) { + if ($libraryLines[$index] -cmatch '^\s*foreach \(\$snapshot in @\(\$snapshots\)') { $barrierLines += ($index + 1) } + } + Assert-CcodEqual 1 $barrierLines.Count 'activation stage write barrier is unique' + $appRoot = $fixture.AppRoot + $attackAction = { + if ($attack.Attempted) { return } + $attack.Attempted = $true + try { + $stages = @(Get-ChildItem -LiteralPath $appRoot -Directory -Force | Where-Object { $_.Name -cmatch '^\.activation-payload-stage-[0-9a-f]{32}$' }) + if ($stages.Count -ne 1) { throw "expected one stage; found $($stages.Count)" } + $attack.Stage = [IO.Path]::GetFullPath($stages[0].FullName) + $expectedPrefix = [IO.Path]::GetFullPath($appRoot).TrimEnd('\') + '\' + if (-not $attack.Stage.StartsWith($expectedPrefix,[StringComparison]::OrdinalIgnoreCase)) { throw 'stage escaped fixture app root' } + $attack.Captured = $attack.Stage + '.captured' + [IO.Directory]::Move($attack.Stage,$attack.Captured) + New-Item -ItemType Junction -Path $attack.Stage -Target $outside | Out-Null + $attack.Outcome = 'substituted' + } catch { $attack.Outcome = 'blocked' } + }.GetNewClosure() + $breakpoint = Set-PSBreakpoint -Script $libraryPath -Line $barrierLines[0] -Action $attackAction + $caught = $null + try { + $seal = & $module { + param($AppRoot,$PayloadRoot,$ManifestSha) + New-CcodActivationPayloadSeal -AppRoot $AppRoot -Root $PayloadRoot -Version '2.5.22' -ExpectedManifestSha256 $ManifestSha + } $fixture.AppRoot $fixture.PayloadRoot $fixture.ManifestSha256 + } catch { $caught = $_ } + + Assert-CcodEqual 'blocked' $attack.Outcome 'stage directory substitution is blocked while the write boundary is pinned' + Assert-CcodTrue ($null -eq $caught -and $null -ne $seal) 'blocked substitution leaves the original verified stage usable' + Assert-CcodEqual 'outside-original' ([IO.File]::ReadAllText((Join-Path $outside 'outside-sentinel.txt'),[Text.UTF8Encoding]::new($false))) 'stage race cannot alter the outside sentinel' + Assert-CcodEqual 1 @(Get-ChildItem -LiteralPath $outside -File -Force -Recurse).Count 'stage race cannot redirect any verified leaf outside' + } finally { + if ($null -ne $breakpoint) { Remove-PSBreakpoint -Breakpoint $breakpoint -ErrorAction SilentlyContinue } + if ($null -ne $seal -and $null -ne $module) { try { & $module { param($Value) Close-CcodActivationPayloadSeal -Seal $Value } $seal } catch { } } + if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + if ($null -ne $attack.Stage -and (Test-Path -LiteralPath $attack.Stage)) { + $stageItem = Get-Item -LiteralPath $attack.Stage -Force + if (($stageItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { [IO.Directory]::Delete($attack.Stage) } + } + foreach ($path in @($attack.Captured,$fixture.AppRoot)) { if ($null -ne $path -and (Test-Path -LiteralPath $path)) { Remove-Item -LiteralPath $path -Recurse -Force -ErrorAction SilentlyContinue } } + } +} + +Invoke-CcodTest 'activation accepts a compile-bound installer manifest hash before payload verification' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-activation-hash-interface-' + [guid]::NewGuid().ToString('N')) + try { + [IO.Directory]::CreateDirectory($root) | Out-Null + $activationId = '77777777-6666-5555-4444-333333333333' + $stdoutPath = Join-Path $root 'stdout.txt' + $stderrPath = Join-Path $root 'stderr.txt' + $argumentLine = '-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "{0}" -AppRoot "{1}" -InstallRoot "{1}" -PayloadRoot "{1}\payload\2.5.22" -ExpectedVersion "2.5.22" -ExpectedPayloadManifestSha256 "{2}" -ActivationId "{3}" -ValidateReceiptOnly' -f (Join-Path $repositoryRoot 'Activate-CcodRemoteFix.ps1'),$root,('0' * 64),$activationId + $process = Start-Process -FilePath (Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe') -ArgumentList $argumentLine -WindowStyle Hidden -RedirectStandardOutput $stdoutPath -RedirectStandardError $stderrPath -Wait -PassThru + try { $exitCode = [int]$process.ExitCode } finally { $process.Dispose() } + $output = [IO.File]::ReadAllText($stdoutPath) + [IO.File]::ReadAllText($stderrPath) + Assert-CcodEqual 3 $exitCode 'missing payload reaches the bounded verifier contract after accepting the manifest hash parameter' + Assert-CcodTrue ($output -cnotmatch 'parameter name .ExpectedPayloadManifestSha256') 'compile-bound hash is a real activation parameter' + } finally { + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } +} + +Invoke-CcodTest 'activation rejects a changed payload module before any payload code executes' { + $fixture = New-CcodActivationPayloadFixture + try { + $marker = Join-Path $fixture.AppRoot 'payload-module-executed.txt' + $modulePath = Join-Path $fixture.PayloadRoot 'src\persistence\modules\InstallLifecycle.psm1' + $markerLiteral = $marker.Replace("'","''") + [IO.File]::WriteAllText($modulePath,"[IO.File]::WriteAllText('$markerLiteral','executed'); function Get-CcodLifecyclePayloadManifestFiles { @() }",[Text.UTF8Encoding]::new($false)) + + $result = Invoke-CcodActivationVerifierFixture -Fixture $fixture -ExpectedManifestSha256 $fixture.ManifestSha256 + + Assert-CcodEqual 3 $result.ExitCode 'payload hash mismatch is a bounded verification failure' + Assert-CcodTrue (-not (Test-Path -LiteralPath $marker)) 'unverified payload module code never executes' + Assert-CcodTrue ($result.Output -cmatch 'CCOD_INSTALL_FILE_HASH_MISMATCH|CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID') 'failure retains a stable payload verification code' + } finally { + if (Test-Path -LiteralPath $fixture.AppRoot) { Remove-Item -LiteralPath $fixture.AppRoot -Recurse -Force } + } +} + +Invoke-CcodTest 'activation distinguishes a valid payload from a bad compile-bound manifest hash' { + $fixture = New-CcodActivationPayloadFixture + try { + $valid = Invoke-CcodActivationVerifierFixture -Fixture $fixture -ExpectedManifestSha256 $fixture.ManifestSha256 + Assert-CcodEqual 3 $valid.ExitCode 'valid payload advances to bounded receipt verification' + Assert-CcodTrue ($valid.Output -cmatch 'CCOD_ACTIVATION_RECEIPT_MISSING') 'valid payload reaches the receipt boundary after independent verification' + $invalid = Invoke-CcodActivationVerifierFixture -Fixture $fixture -ExpectedManifestSha256 ('0' * 64) + Assert-CcodEqual 3 $invalid.ExitCode 'bad compile-bound manifest hash fails verification' + Assert-CcodTrue ($invalid.Output -cmatch 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID') 'bad manifest hash retains the stable payload code' + } finally { + if (Test-Path -LiteralPath $fixture.AppRoot) { Remove-Item -LiteralPath $fixture.AppRoot -Recurse -Force } + } +} + +Invoke-CcodTest 'activation requires the exact versioned payload root' { + $fixture = New-CcodActivationPayloadFixture + try { + $wrongRoot = Join-Path $fixture.AppRoot 'payload\other' + [IO.Directory]::CreateDirectory($wrongRoot) | Out-Null + $result = Invoke-CcodActivationVerifierFixture -Fixture $fixture -ExpectedManifestSha256 $fixture.ManifestSha256 -PayloadRoot $wrongRoot + Assert-CcodEqual 3 $result.ExitCode 'wrong version directory fails before receipt processing' + Assert-CcodTrue ($result.Output -cmatch 'CCOD_INSTALL_PAYLOAD_PATH_INVALID') 'wrong payload root retains a stable path code' + } finally { + if (Test-Path -LiteralPath $fixture.AppRoot) { Remove-Item -LiteralPath $fixture.AppRoot -Recurse -Force } + } +} + +Invoke-CcodTest 'activation rejects a reparse payload ancestor' { + $fixture = New-CcodActivationPayloadFixture + $targetRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-activation-payload-target-' + [guid]::NewGuid().ToString('N')) + try { + $sourceVersion = $fixture.PayloadRoot + [IO.Directory]::CreateDirectory($targetRoot) | Out-Null + Move-Item -LiteralPath $sourceVersion -Destination (Join-Path $targetRoot '2.5.22') + Remove-Item -LiteralPath (Join-Path $fixture.AppRoot 'payload') -Force + New-Item -ItemType Junction -Path (Join-Path $fixture.AppRoot 'payload') -Target $targetRoot | Out-Null + $result = Invoke-CcodActivationVerifierFixture -Fixture $fixture -ExpectedManifestSha256 $fixture.ManifestSha256 + Assert-CcodEqual 3 $result.ExitCode 'payload junction fails before receipt processing' + Assert-CcodTrue ($result.Output -cmatch 'CCOD_INSTALL_SOURCE_REPARSE') 'payload junction retains the reparse support code' + } finally { + $junction = Join-Path $fixture.AppRoot 'payload' + if (Test-Path -LiteralPath $junction) { [IO.Directory]::Delete($junction) } + if (Test-Path -LiteralPath $fixture.AppRoot) { Remove-Item -LiteralPath $fixture.AppRoot -Recurse -Force } + if (Test-Path -LiteralPath $targetRoot) { Remove-Item -LiteralPath $targetRoot -Recurse -Force } + } +} + +Invoke-CcodTest 'activation rejects an unlisted nested payload junction' { + $fixture = New-CcodActivationPayloadFixture + $targetRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-activation-nested-target-' + [guid]::NewGuid().ToString('N')) + try { + [IO.Directory]::CreateDirectory($targetRoot) | Out-Null + $junction = Join-Path $fixture.PayloadRoot 'unlisted-junction' + New-Item -ItemType Junction -Path $junction -Target $targetRoot | Out-Null + $result = Invoke-CcodActivationVerifierFixture -Fixture $fixture -ExpectedManifestSha256 $fixture.ManifestSha256 + Assert-CcodEqual 3 $result.ExitCode 'nested payload junction fails before receipt processing' + Assert-CcodTrue ($result.Output -cmatch 'CCOD_INSTALL_SOURCE_REPARSE') 'nested payload junction retains the reparse support code' + } finally { + $junction = Join-Path $fixture.PayloadRoot 'unlisted-junction' + if (Test-Path -LiteralPath $junction) { [IO.Directory]::Delete($junction) } + if (Test-Path -LiteralPath $fixture.AppRoot) { Remove-Item -LiteralPath $fixture.AppRoot -Recurse -Force } + if (Test-Path -LiteralPath $targetRoot) { Remove-Item -LiteralPath $targetRoot -Recurse -Force } + } +} + +Invoke-CcodTest 'Inno compile refuses an implicit installer payload source' { + $compile = Invoke-CcodInnoPayloadCompileFixture + try { + Assert-CcodTrue ($compile.ExitCode -ne 0) 'setup compilation fails without an explicit sealed installer package contract' + Assert-CcodTrue ($compile.Output -cmatch 'InstallerPackagePath') 'compiler identifies the missing sealed package define' + Assert-CcodTrue (-not (Test-Path -LiteralPath $compile.SetupPath)) 'missing payload define produces no setup artifact' + } finally { + if (Test-Path -LiteralPath $compile.Root) { Remove-Item -LiteralPath $compile.Root -Recurse -Force } + } +} + +Invoke-CcodTest 'Inno compile packages the explicit manifest-bound installer payload' { + $compile = Invoke-CcodInnoPayloadCompileFixture -IncludePayloadDefines + try { + Assert-CcodEqual 0 $compile.ExitCode "explicit setup payload contract compiles: $($compile.Output)" + Assert-CcodTrue (Test-Path -LiteralPath $compile.SetupPath -PathType Leaf) 'explicit payload compile produces the setup artifact' + Assert-CcodTrue ($compile.GeneratedSource -cmatch 'procedure AddCcodExpectedSetupDirectories' -and $compile.GeneratedSource -cnotmatch 'Directories\.Add') 'compiler input contains an explicit empty destination inventory procedure' + Assert-CcodTrue ($compile.GeneratedSource -cnotmatch 'CCOD_INSTALLER_DESTINATION_INVENTORY' -and $compile.GeneratedSource -cnotmatch '(?m)^\s*#\s*(?:include\b|\+)') 'compiler input contains neither the marker nor an external include' + Assert-CcodTrue (-not [IO.File]::Exists($compile.GeneratedPath)) 'payload compile cleans its generated compiler input' + Assert-CcodTrue ($compile.Output -cmatch 'installer-package\.zip' -and $compile.Output -cmatch 'installer-package\.manifest\.json' -and $compile.Output -cmatch 'Activate-CcodRemoteFix\.ps1') 'compiler input trace contains only the sealed package manifest and bootstrap inputs' + } finally { + if (Test-Path -LiteralPath $compile.Root) { Remove-Item -LiteralPath $compile.Root -Recurse -Force } + } +} + +# Production mutation caught: trusting ISCC exit zero and a newly computed sidecar without inspecting the final PE contract. +Invoke-CcodTest 'compiled Setup independently binds PE version commit package manifest and bootstrap hashes' { + $modulePath = Join-Path $repositoryRoot 'build\SetupArtifact.psm1' + Assert-CcodTrue (Test-Path -LiteralPath $modulePath -PathType Leaf) 'independent Setup artifact validator exists' + Import-Module $modulePath -Force + $compile = Invoke-CcodInnoPayloadCompileFixture -IncludePayloadDefines + try { + $validated = Test-CcodSetupArtifact -SetupPath $compile.SetupPath -ExpectedVersion '2.5.21' -ExpectedGitCommit $compile.SetupGitCommit -ExpectedPackageSha256 $compile.PackageSha256 -ExpectedPackageManifestSha256 $compile.PackageManifestSha256 -ExpectedActivationBootstrapSha256 $compile.ActivationBootstrapSha256 + Assert-CcodEqual $true ([bool]$validated.Valid) 'real ISCC Setup PE satisfies the independent version and payload contract' + Assert-CcodEqual '2.5.21.0' ([string]$validated.FileVersion) 'Setup PE FileVersion is exact' + Assert-CcodEqual $compile.PackageManifestSha256 ([string]$validated.PackageManifestSha256) 'Setup PE ProductVersion and description bind the full package manifest hash' + Assert-CcodThrows { + Test-CcodSetupArtifact -SetupPath $compile.SetupPath -ExpectedVersion '2.5.21' -ExpectedGitCommit $compile.SetupGitCommit -ExpectedPackageSha256 ('0' * 64) -ExpectedPackageManifestSha256 $compile.PackageManifestSha256 -ExpectedActivationBootstrapSha256 $compile.ActivationBootstrapSha256 | Out-Null + } 'CCOD_SETUP_PAYLOAD_BINDING_INVALID' + } finally { + if (Test-Path -LiteralPath $compile.Root) { Remove-Item -LiteralPath $compile.Root -Recurse -Force } + } +} + +# Production mutation caught: compiled Setup metadata looks safe while CurStepChanged skips its runtime package hash/lock barrier. +Invoke-CcodTest 'compiled production Setup rejects a wrong package hash before activation and product state' { + $fixture=Invoke-CcodInnoPayloadCompileFixture -IncludePayloadDefines -BindWrongPackageHash -UseInertActivationMarker + $process=$null + try { + Assert-CcodEqual 0 $fixture.ExitCode "wrong-hash production Setup fixture compiles: $($fixture.Output)" + Assert-CcodTrue (Test-Path -LiteralPath $fixture.SetupPath -PathType Leaf) 'wrong-hash compiled Setup exists' + Assert-CcodEqual ('0'*64) ([string]$fixture.BoundPackageSha256) 'fixture binds the deliberate wrong package hash' + Assert-CcodEqual ('0'*64) ([string][Diagnostics.FileVersionInfo]::GetVersionInfo($fixture.SetupPath).LegalCopyright).Trim() 'compiled PE independently retains the wrong expected package hash' + $realProductRoot=Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)) 'CodexControlOtherDevices' + $productStateBefore=Get-CcodReadOnlyProductTreeSnapshot -Root $realProductRoot + $appRoot=Join-Path $fixture.Root 'forbidden-app-output' + $logPath=Join-Path $fixture.Root 'executed-setup.log' + $arguments=@('/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART','/SP-',("/DIR=$appRoot"),("/LOG=$logPath")) + $startInfo=[Diagnostics.ProcessStartInfo]::new();$startInfo.FileName=$fixture.SetupPath;$startInfo.Arguments=(($arguments|ForEach-Object{'"'+([string]$_).Replace('"','\"')+'"'})-join' ');$startInfo.UseShellExecute=$false;$startInfo.CreateNoWindow=$true + $process=[Diagnostics.Process]::new();$process.StartInfo=$startInfo + Assert-CcodTrue $process.Start() 'wrong-hash compiled Setup starts' + Assert-CcodTrue $process.WaitForExit(20000) 'wrong-hash compiled Setup exits within the pre-activation bound' + $productStateAfter=Get-CcodReadOnlyProductTreeSnapshot -Root $realProductRoot + Assert-CcodTrue (Test-Path -LiteralPath $logPath -PathType Leaf) 'executed Setup emits its isolated runtime log' + $log=[IO.File]::ReadAllText($logPath,[Text.UTF8Encoding]::new($false)) + Assert-CcodTrue ([int]$process.ExitCode-ne0) "wrong-hash compiled Setup fails closed: exit=$([int]$process.ExitCode) log=$($log.Substring([Math]::Max(0,$log.Length-1000)))" + Assert-CcodTrue ($log-cmatch 'CCOD_SETUP_INPUT_BINDING_INVALID') 'runtime CurStepChanged reports the package hash/lock barrier failure' + Assert-CcodTrue (-not(Test-Path -LiteralPath $fixture.ActivationMarker)) 'temporary activation bootstrap never executes' + Assert-CcodTrue (-not(Test-Path -LiteralPath $appRoot)) 'CreateAppDir=no leaves no app product output' + Assert-CcodTrue ($log.IndexOf($realProductRoot,[StringComparison]::OrdinalIgnoreCase)-lt0) 'failure log never reaches the real LocalAppData product root' + Assert-CcodEqual ($productStateBefore|ConvertTo-Json -Depth 8 -Compress) ($productStateAfter|ConvertTo-Json -Depth 8 -Compress) 'actual LocalAppData product tree is byte-and-metadata unchanged' + } finally { + if($null-ne$process){try{if(-not$process.HasExited){$process.Kill();[void]$process.WaitForExit(5000)}}catch{};$process.Dispose()} + if(Test-Path -LiteralPath $fixture.Root){Remove-Item -LiteralPath $fixture.Root -Recurse -Force -ErrorAction SilentlyContinue} + } +} + +# Production mutation caught: accepting self-consistent but false Setup build-input hashes without comparing canonical files. +Invoke-CcodTest 'Setup provenance rejects self-consistent wrong canonical build input hashes' { + Import-Module (Join-Path $repositoryRoot 'build\SetupArtifact.psm1') -Force + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-setup-provenance-inputs-' + [guid]::NewGuid().ToString('N')) + try { + [IO.Directory]::CreateDirectory($root) | Out-Null + $template = Join-Path $root 'CodexControlOtherDevices.iss' + $inventory = Join-Path $root 'InstallerDestinationInventory.iss' + $compiler = Join-Path $root 'ISCC.exe' + $payload = Join-Path $root 'installer-payload.manifest.json' + $provenance = Join-Path $root 'setup-provenance.json' + [IO.File]::WriteAllText($template,'canonical-template',[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($inventory,'canonical-inventory',[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllBytes($compiler,[byte[]](1,2,3,4,5,6,7,8)) + $payloadRecord = [ordered]@{schemaVersion=1;projectVersion='2.5.22';files=@([ordered]@{path='package.json';length=[int64]1;sha256=('a'*64)})} + [IO.File]::WriteAllText($payload,($payloadRecord|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + $payloadHash = Get-CcodTestFileSha256 -Path $payload + $commit = 'b' * 40 + $timestamp = '2026-08-28T00:00:00.0000000Z' + $record = [ordered]@{ + schemaVersion=1;product='CodexRemote-fix';version='2.5.22';gitCommit=$commit;buildTimestampUtc=$timestamp + payloadManifest=[ordered]@{name='installer-payload.manifest.json';length=[int64](Get-Item -LiteralPath $payload).Length;sha256=$payloadHash;fileCount=1} + buildInputs=[ordered]@{innoTemplateSha256=('1'*64);destinationInventorySha256=('2'*64);compilerSha256=('3'*64);compilerFileVersion='0.0.0.0'} + peContract=[ordered]@{fileVersion='2.5.22.0';productVersion='2.5.22.0';productName='CodexRemote-fix';fileDescription='CCODSETUP 2.5.22';companyName=$commit;legalCopyright=$payloadHash} + } + [IO.File]::WriteAllText($provenance,(($record|ConvertTo-Json -Depth 8)+"`n"),[Text.UTF8Encoding]::new($false)) + + Assert-CcodThrows { + $validator = Get-Command Test-CcodSetupBuildProvenance + if ($validator.Parameters.ContainsKey('InnoTemplatePath')) { + Test-CcodSetupBuildProvenance -ProvenancePath $provenance -ExpectedVersion '2.5.22' -ExpectedGitCommit $commit -ExpectedPayloadManifestSha256 $payloadHash -ExpectedBuildTimestampUtc $timestamp -InnoTemplatePath $template -DestinationInventoryPath $inventory -CompilerPath $compiler -PayloadManifestPath $payload | Out-Null + } else { + Test-CcodSetupBuildProvenance -ProvenancePath $provenance -ExpectedVersion '2.5.22' -ExpectedGitCommit $commit -ExpectedPayloadManifestSha256 $payloadHash -ExpectedBuildTimestampUtc $timestamp | Out-Null + } + } 'CCOD_SETUP_PROVENANCE_INVALID' + } finally { + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } +} + +# Production mutation caught: schema-two provenance accepts cosmetic nested evidence not bound to the actual inputs. +Invoke-CcodTest 'sealed Setup provenance rejects every field type name length count hash and property-order mutation' { + Import-Module (Join-Path $repositoryRoot 'build\SetupArtifact.psm1') -Force + $fixture=Invoke-CcodInnoPayloadCompileFixture -IncludePayloadDefines + try { + Assert-CcodEqual 0 $fixture.ExitCode "sealed provenance fixture compiles: $($fixture.Output)" + $provenance=Join-Path $fixture.Root 'sealed-setup-provenance.json' + $timestamp='2030-02-03T04:05:06.0000000Z' + $created=New-CcodSealedSetupBuildProvenance -Version '2.5.21' -GitCommit $fixture.SetupGitCommit -BuildTimestampUtc $timestamp -PackagePath $fixture.PackagePath -PackageManifestPath $fixture.PackageManifestPath -ActivationBootstrapPath $fixture.ActivationBootstrapPath -InnoTemplatePath $fixture.TemplatePath -DestinationInventoryPath $fixture.InventoryPath -CompilerPath $fixture.CompilerPath -OutputPath $provenance + $arguments=@{ExpectedVersion='2.5.21';ExpectedGitCommit=$fixture.SetupGitCommit;ExpectedPackageSha256=$fixture.PackageSha256;ExpectedPackageManifestSha256=$fixture.PackageManifestSha256;ExpectedActivationBootstrapSha256=$fixture.ActivationBootstrapSha256;ExpectedBuildTimestampUtc=$timestamp;PackagePath=$fixture.PackagePath;PackageManifestPath=$fixture.PackageManifestPath;ActivationBootstrapPath=$fixture.ActivationBootstrapPath;InnoTemplatePath=$fixture.TemplatePath;DestinationInventoryPath=$fixture.InventoryPath;CompilerPath=$fixture.CompilerPath} + $valid=Test-CcodSealedSetupBuildProvenance -ProvenancePath $provenance @arguments + Assert-CcodEqual 2 ([int]$valid.schemaVersion) 'canonical schema-two provenance validates' + $mutations=@( + [pscustomobject]@{Name='top-level-order';Apply={param($r)$copy=[ordered]@{product=$r.product;schemaVersion=$r.schemaVersion;version=$r.version;gitCommit=$r.gitCommit;buildTimestampUtc=$r.buildTimestampUtc;installerPackage=$r.installerPackage;installerPackageManifest=$r.installerPackageManifest;activationBootstrap=$r.activationBootstrap;buildInputs=$r.buildInputs;peContract=$r.peContract};return [pscustomobject]$copy}}, + [pscustomobject]@{Name='schema-type';Apply={param($r)$r.schemaVersion='2';$r}}, + [pscustomobject]@{Name='version-type';Apply={param($r)$r.version=2521;$r}}, + [pscustomobject]@{Name='commit';Apply={param($r)$r.gitCommit='e'*40;$r}}, + [pscustomobject]@{Name='package-order';Apply={param($r)$r.installerPackage=[pscustomobject][ordered]@{sha256=$r.installerPackage.sha256;name=$r.installerPackage.name;length=$r.installerPackage.length};$r}}, + [pscustomobject]@{Name='package-name';Apply={param($r)$r.installerPackage.name='renamed.zip';$r}}, + [pscustomobject]@{Name='package-length';Apply={param($r)$r.installerPackage.length=[long]$r.installerPackage.length+1;$r}}, + [pscustomobject]@{Name='package-length-type';Apply={param($r)$r.installerPackage.length=[string]$r.installerPackage.length;$r}}, + [pscustomobject]@{Name='manifest-order';Apply={param($r)$r.installerPackageManifest=[pscustomobject][ordered]@{name=$r.installerPackageManifest.name;sha256=$r.installerPackageManifest.sha256;length=$r.installerPackageManifest.length;fileCount=$r.installerPackageManifest.fileCount;payloadManifestSha256=$r.installerPackageManifest.payloadManifestSha256};$r}}, + [pscustomobject]@{Name='manifest-name';Apply={param($r)$r.installerPackageManifest.name='renamed.json';$r}}, + [pscustomobject]@{Name='manifest-length';Apply={param($r)$r.installerPackageManifest.length=[long]$r.installerPackageManifest.length+1;$r}}, + [pscustomobject]@{Name='file-count';Apply={param($r)$r.installerPackageManifest.fileCount=[int]$r.installerPackageManifest.fileCount+1;$r}}, + [pscustomobject]@{Name='file-count-type';Apply={param($r)$r.installerPackageManifest.fileCount=[string]$r.installerPackageManifest.fileCount;$r}}, + [pscustomobject]@{Name='payload-manifest-sha';Apply={param($r)$r.installerPackageManifest.payloadManifestSha256='0'*64;$r}}, + [pscustomobject]@{Name='bootstrap-order';Apply={param($r)$r.activationBootstrap=[pscustomobject][ordered]@{length=$r.activationBootstrap.length;name=$r.activationBootstrap.name;sha256=$r.activationBootstrap.sha256};$r}}, + [pscustomobject]@{Name='bootstrap-name';Apply={param($r)$r.activationBootstrap.name='stale.ps1';$r}}, + [pscustomobject]@{Name='bootstrap-length';Apply={param($r)$r.activationBootstrap.length=[long]$r.activationBootstrap.length+1;$r}}, + [pscustomobject]@{Name='build-input-order';Apply={param($r)$r.buildInputs=[pscustomobject][ordered]@{compilerSha256=$r.buildInputs.compilerSha256;innoTemplateSha256=$r.buildInputs.innoTemplateSha256;destinationInventorySha256=$r.buildInputs.destinationInventorySha256;compilerFileVersion=$r.buildInputs.compilerFileVersion};$r}}, + [pscustomobject]@{Name='template-hash';Apply={param($r)$r.buildInputs.innoTemplateSha256='1'*64;$r}}, + [pscustomobject]@{Name='inventory-hash';Apply={param($r)$r.buildInputs.destinationInventorySha256='2'*64;$r}}, + [pscustomobject]@{Name='compiler-hash';Apply={param($r)$r.buildInputs.compilerSha256='3'*64;$r}}, + [pscustomobject]@{Name='compiler-version';Apply={param($r)$r.buildInputs.compilerFileVersion='9.9.9.9';$r}}, + [pscustomobject]@{Name='pe-order';Apply={param($r)$r.peContract=[pscustomobject][ordered]@{legalCopyright=$r.peContract.legalCopyright;fileVersion=$r.peContract.fileVersion;packageManifestFirst=$r.peContract.packageManifestFirst;packageManifestLast=$r.peContract.packageManifestLast;bootstrapFirst=$r.peContract.bootstrapFirst;bootstrapLast=$r.peContract.bootstrapLast;companyName=$r.peContract.companyName};$r}}, + [pscustomobject]@{Name='nested-extra';Apply={param($r)$r.activationBootstrap|Add-Member attacker 'x';$r}} + ) + foreach($mutation in $mutations){ + $record=([IO.File]::ReadAllText($provenance,[Text.UTF8Encoding]::new($false))|ConvertFrom-Json) + $record=&$mutation.Apply $record + $mutated=Join-Path $fixture.Root ("provenance-$($mutation.Name).json") + [IO.File]::WriteAllText($mutated,(($record|ConvertTo-Json -Depth 8)+[Environment]::NewLine),[Text.UTF8Encoding]::new($false)) + $failure=$null + try{Test-CcodSealedSetupBuildProvenance -ProvenancePath $mutated @arguments|Out-Null}catch{$failure=$_} + Assert-CcodTrue ($null-ne$failure) "provenance mutation is rejected: $($mutation.Name)" + Assert-CcodTrue ($failure.FullyQualifiedErrorId-like'CCOD_SETUP_PROVENANCE_INVALID*') "provenance mutation has the bounded code: $($mutation.Name)" + } + $rawProvenance=[IO.File]::ReadAllText($provenance,[Text.UTF8Encoding]::new($false)) + $duplicatePatterns=@( + [pscustomobject]@{Name='top';Pattern='(?s)^\s*\{\s*(?"schemaVersion"\s*:\s*2)'}, + [pscustomobject]@{Name='installerPackage';Pattern='(?s)"installerPackage"\s*:\s*\{\s*(?"name"\s*:\s*"[^"]+")'}, + [pscustomobject]@{Name='installerPackageManifest';Pattern='(?s)"installerPackageManifest"\s*:\s*\{\s*(?"name"\s*:\s*"[^"]+")'}, + [pscustomobject]@{Name='activationBootstrap';Pattern='(?s)"activationBootstrap"\s*:\s*\{\s*(?"name"\s*:\s*"[^"]+")'}, + [pscustomobject]@{Name='buildInputs';Pattern='(?s)"buildInputs"\s*:\s*\{\s*(?"innoTemplateSha256"\s*:\s*"[^"]+")'}, + [pscustomobject]@{Name='peContract';Pattern='(?s)"peContract"\s*:\s*\{\s*(?"fileVersion"\s*:\s*"[^"]+")'} + ) + foreach($duplicate in $duplicatePatterns){ + $match=[regex]::Match($rawProvenance,$duplicate.Pattern) + Assert-CcodTrue ($match.Success-and$match.Groups['member'].Success) "duplicate mutation locates object: $($duplicate.Name)" + $member=$match.Groups['member'].Value + $duplicateRaw=$rawProvenance.Insert($match.Groups['member'].Index+$match.Groups['member'].Length,(','+$member)) + $mutated=Join-Path $fixture.Root ("provenance-duplicate-$($duplicate.Name).json") + [IO.File]::WriteAllText($mutated,$duplicateRaw,[Text.UTF8Encoding]::new($false)) + $failure=$null + try{Test-CcodSealedSetupBuildProvenance -ProvenancePath $mutated @arguments|Out-Null}catch{$failure=$_} + Assert-CcodTrue ($null-ne$failure) "raw duplicate provenance member is rejected: $($duplicate.Name)" + Assert-CcodTrue ($failure.FullyQualifiedErrorId-like'CCOD_SETUP_PROVENANCE_INVALID*') "raw duplicate provenance member has bounded code: $($duplicate.Name)" + } + } finally { + if(Test-Path -LiteralPath $fixture.Root){Remove-Item -LiteralPath $fixture.Root -Recurse -Force -ErrorAction SilentlyContinue} + } +} + +# Production mutation caught: cleaning payload stage/inventory only after a successful Setup build. +Invoke-CcodTest 'build temporary Setup inputs are cleaned from the exact finally boundary after failure' { + . (Join-Path $repositoryRoot 'build\build.ps1') -Library + Assert-CcodTrue ($null -ne (Get-Command Invoke-CcodBuildTemporarySetupScope -ErrorAction SilentlyContinue)) 'build exposes its production temporary Setup scope' + $buildRoot = Join-Path $repositoryRoot 'build' + $payloadStage = Join-Path $buildRoot ('.installer-payload-stage-' + [guid]::NewGuid().ToString('N')) + $inventory = Join-Path $buildRoot ('.installer-destination-inventory-' + [guid]::NewGuid().ToString('N') + '.iss') + $fixturePayloadStage = $payloadStage + $fixtureInventory = $inventory + Assert-CcodThrows { + Invoke-CcodBuildTemporarySetupScope -BuildRoot $buildRoot -InstallerPayloadDirectory $fixturePayloadStage -DestinationInventoryPath $fixtureInventory -Action { + param($PayloadStagePath,$InventoryPath) + [IO.Directory]::CreateDirectory($PayloadStagePath) | Out-Null + [IO.File]::WriteAllText((Join-Path $PayloadStagePath 'fixture.txt'),'fixture',[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($InventoryPath,'fixture',[Text.UTF8Encoding]::new($false)) + throw [Management.Automation.ErrorRecord]::new([InvalidOperationException]::new('fixture failure'),'CCOD_BUILD_FIXTURE_FAILURE',[Management.Automation.ErrorCategory]::OperationStopped,$null) + } + } 'CCOD_BUILD_FIXTURE_FAILURE' + Assert-CcodTrue (-not [IO.Directory]::Exists($payloadStage)) 'failed build leaves no installer payload stage' + Assert-CcodTrue (-not [IO.File]::Exists($inventory)) 'failed build leaves no destination inventory' +} + +Invoke-CcodTest 'Inno compile refuses a missing generated destination inventory' { + $compile = Invoke-CcodInnoPayloadCompileFixture -IncludePayloadDefines -OmitDestinationInventory + try { + Assert-CcodTrue ($compile.ExitCode -ne 0) 'setup compilation fails before ISCC when the generated inventory is missing' + Assert-CcodTrue ($compile.Output -cmatch 'destination inventory.*missing') 'generation identifies the missing destination inventory artifact' + Assert-CcodTrue (-not (Test-Path -LiteralPath $compile.SetupPath)) 'missing inventory define produces no setup artifact' + Assert-CcodTrue (-not [IO.File]::Exists($compile.GeneratedPath)) 'missing inventory leaves no generated compiler input' + } finally { + if (Test-Path -LiteralPath $compile.Root) { Remove-Item -LiteralPath $compile.Root -Recurse -Force } + } +} + +<# Superseded by the sealed no-product-write Setup boundary in Task 3. +Invoke-CcodTest 'Inno exposes a pre-write payload-directory reparse gate' { + $inno = Get-Content -LiteralPath (Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss') -Raw -Encoding UTF8 + $helper = [regex]::Match($inno,'(?ms)^function IsSafeExistingPayloadDirectory\(.*?^end;') + Assert-CcodTrue $helper.Success 'production Inno script exposes the directory predicate used before payload writes' + $streamHelper = [regex]::Match($inno,'(?ms)^function HasOnlyDefaultDataStream\(.*?^end;') + $leafHelper = [regex]::Match($inno,'(?ms)^function IsSafeExistingSetupLeaf\(.*?^end;') + Assert-CcodTrue ($streamHelper.Success -and $leafHelper.Success) 'production Inno script exposes native stream and link-count predicates for existing leaves' + $treeHelper = [regex]::Match($inno,'(?ms)^function IsSafeExistingSetupTree\(.*?^end;') + Assert-CcodTrue $treeHelper.Success 'production Inno script exposes a recursive destination-tree predicate' + $inventoryValidator = [regex]::Match($inno,'(?ms)^function AreCcodExpectedSetupDirectoriesSafe\(.*?^end;') + Assert-CcodTrue $inventoryValidator.Success 'production Inno script exposes a generated destination-inventory validator' + $pinBlockMatch = [regex]::Match($inno,'(?ms)^// CCOD_SETUP_PIN_BEGIN\s*$\r?\n(?.*?)^// CCOD_SETUP_PIN_END\s*$') + $pinBlock = if ($pinBlockMatch.Success) { $pinBlockMatch.Groups['body'].Value } else { @' +function PinCcodExistingSetupTree(const DirectoryName: String): Boolean; +begin + Result := IsSafeExistingSetupTree(DirectoryName); +end; +procedure CloseCcodSetupPins(); +begin +end; +'@ } + Assert-CcodTrue ($inno -cmatch '(?m)^// CCOD_INSTALLER_DESTINATION_INVENTORY\s*$' -and $inno -cmatch '(?ms)^function PrepareToInstall\(var NeedsRestart: Boolean\): String;.*?AreCcodExpectedSetupDirectoriesSafe') 'PrepareToInstall consumes the generated inventory injected at the unique marker before file copy' + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-inno-reparse-harness-' + [guid]::NewGuid().ToString('N')) + try { + $normal = Join-Path $root 'normal' + $target = Join-Path $root 'target' + $junction = Join-Path $root 'junction' + $missing = Join-Path $root 'missing' + $tree = Join-Path $root 'tree' + $nested = Join-Path $tree 'src\escape' + $fileDirectory = Join-Path $root 'file-as-directory' + $expectedFileRoot = Join-Path $root 'expected-file-root' + $expectedJunctionRoot = Join-Path $root 'expected-junction-root' + $normalExpectedRoot = Join-Path $root 'normal-expected-root' + $inventoryPayload = Join-Path $root 'inventory-payload' + $hardLinkTree = Join-Path $root 'hardlink-tree' + $outsideHardLink = Join-Path $root 'outside-hardlink-sentinel.txt' + $hardLinkLeaf = Join-Path $hardLinkTree 'existing-leaf.txt' + $fileWriteMarker = Join-Path $expectedFileRoot 'payload-write-marker.txt' + $junctionWriteMarker = Join-Path $expectedJunctionRoot 'payload-write-marker.txt' + $resultPath = Join-Path $root 'result.txt' + $concurrentTarget = Join-Path $root 'concurrent-target' + $concurrentMoved = Join-Path $root 'concurrent-target-moved' + $concurrentOutside = Join-Path $root 'concurrent-outside' + $attackScript = Join-Path $root 'Invoke-ConcurrentSubstitution.ps1' + $attackResult = Join-Path $root 'concurrent-attack.txt' + $pinWriteResult = Join-Path $root 'concurrent-write.txt' + [IO.Directory]::CreateDirectory($normal) | Out-Null + [IO.Directory]::CreateDirectory($target) | Out-Null + [IO.Directory]::CreateDirectory((Split-Path $nested -Parent)) | Out-Null + New-Item -ItemType Junction -Path $junction -Target $target | Out-Null + New-Item -ItemType Junction -Path $nested -Target $target | Out-Null + [IO.File]::WriteAllText($fileDirectory,'not a directory',[Text.UTF8Encoding]::new($false)) + [IO.Directory]::CreateDirectory((Join-Path $expectedFileRoot 'src')) | Out-Null + [IO.File]::WriteAllText((Join-Path $expectedFileRoot 'src\persistence'),'not a directory',[Text.UTF8Encoding]::new($false)) + [IO.Directory]::CreateDirectory((Join-Path $expectedJunctionRoot 'payload\2.5.22\src')) | Out-Null + New-Item -ItemType Junction -Path (Join-Path $expectedJunctionRoot 'payload\2.5.22\src\persistence') -Target $target | Out-Null + [IO.Directory]::CreateDirectory($normalExpectedRoot) | Out-Null + [IO.Directory]::CreateDirectory($hardLinkTree) | Out-Null + [IO.File]::WriteAllText($outsideHardLink,'outside-original',[Text.UTF8Encoding]::new($false)) + New-Item -ItemType HardLink -Path $hardLinkLeaf -Target $outsideHardLink | Out-Null + [IO.Directory]::CreateDirectory($concurrentTarget) | Out-Null + [IO.Directory]::CreateDirectory($concurrentOutside) | Out-Null + [IO.File]::WriteAllText((Join-Path $concurrentTarget 'payload.txt'),'target-original',[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText((Join-Path $concurrentOutside 'payload.txt'),'outside-original',[Text.UTF8Encoding]::new($false)) + $attackSource = @' +[CmdletBinding()] +param([Parameter(Mandatory)][string]$Target,[Parameter(Mandatory)][string]$Moved,[Parameter(Mandatory)][string]$Outside,[Parameter(Mandatory)][string]$ResultPath) +$ErrorActionPreference='Stop' +try { + Move-Item -LiteralPath $Target -Destination $Moved -ErrorAction Stop + New-Item -ItemType Junction -Path $Target -Target $Outside -ErrorAction Stop | Out-Null + [IO.File]::WriteAllText($ResultPath,'substituted',[Text.UTF8Encoding]::new($false)) +} catch { + [IO.File]::WriteAllText($ResultPath,'blocked',[Text.UTF8Encoding]::new($false)) +} +'@ + [IO.File]::WriteAllText($attackScript,$attackSource,[Text.UTF8Encoding]::new($false)) + [IO.Directory]::CreateDirectory((Join-Path $inventoryPayload 'src\persistence\modules')) | Out-Null + [IO.File]::WriteAllText((Join-Path $inventoryPayload 'src\persistence\modules\InstallLifecycle.psm1'),'fixture',[Text.UTF8Encoding]::new($false)) + $inventoryPath = Join-Path $root 'InventoryFixture.iss' + & (Join-Path $repositoryRoot 'tools\New-InstallerDestinationInventory.ps1') -RepositoryRoot $repositoryRoot -PayloadRoot $inventoryPayload -ProjectVersion '2.5.22' -InnoScriptPath (Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss') -OutputPath $inventoryPath | Out-Null + $inventorySource = [IO.File]::ReadAllText($inventoryPath,[Text.UTF8Encoding]::new($false)) + $harnessPath = Join-Path $root 'ReparseGate.iss' + $harness = @" +[Setup] +AppName=ReparseGate +AppVersion=1.0.0 +DefaultDirName={tmp}\ReparseGate +PrivilegesRequired=lowest +OutputDir=$($root.Replace('\','\\')) +OutputBaseFilename=ReparseGate +Uninstallable=no +[Code] +const + CCOD_FILE_ATTRIBUTE_DIRECTORY = `$00000010; + CCOD_FILE_ATTRIBUTE_REPARSE_POINT = `$00000400; + CCOD_FILE_READ_ATTRIBUTES = `$00000080; + CCOD_DELETE_ACCESS = `$00010000; + CCOD_FILE_SHARE_READ = `$00000001; + CCOD_FILE_SHARE_WRITE = `$00000002; + CCOD_FILE_SHARE_DELETE = `$00000004; + CCOD_OPEN_EXISTING = 3; + CCOD_FILE_FLAG_OPEN_REPARSE_POINT = `$00200000; + CCOD_FILE_FLAG_BACKUP_SEMANTICS = `$02000000; + CCOD_INVALID_FILE_ATTRIBUTES = `$FFFFFFFF; + CCOD_INVALID_HANDLE_VALUE = -1; + CCOD_ERROR_HANDLE_EOF = 38; +type + TCcodFileTime = record + LowDateTime: Cardinal; + HighDateTime: Cardinal; + end; + TCcodByHandleFileInformation = record + FileAttributes: Cardinal; + CreationTime: TCcodFileTime; + LastAccessTime: TCcodFileTime; + LastWriteTime: TCcodFileTime; + VolumeSerialNumber: Cardinal; + FileSizeHigh: Cardinal; + FileSizeLow: Cardinal; + NumberOfLinks: Cardinal; + FileIndexHigh: Cardinal; + FileIndexLow: Cardinal; + end; + TCcodFindStreamData = record + StreamSize: Int64; + StreamNameBuffer: array[0..591] of Byte; + end; +function GetFileAttributesW(const FileName: String): Cardinal; + external 'GetFileAttributesW@kernel32.dll stdcall'; +function CreateFileW(const FileName: String; DesiredAccess, ShareMode, + SecurityAttributes, CreationDisposition, FlagsAndAttributes, + TemplateFile: Cardinal): Integer; + external 'CreateFileW@kernel32.dll stdcall'; +function GetFileInformationByHandle(FileHandle: Integer; + var Information: TCcodByHandleFileInformation): Boolean; + external 'GetFileInformationByHandle@kernel32.dll stdcall'; +function CloseHandle(Handle: Integer): Boolean; + external 'CloseHandle@kernel32.dll stdcall'; +function FindFirstStreamW(const FileName: String; InfoLevel: Integer; + var StreamData: TCcodFindStreamData; Flags: Cardinal): Integer; + external 'FindFirstStreamW@kernel32.dll stdcall'; +function FindNextStreamW(FindHandle: Integer; + var StreamData: TCcodFindStreamData): Boolean; + external 'FindNextStreamW@kernel32.dll stdcall'; +function CcodFindClose(FindHandle: Integer): Boolean; + external 'FindClose@kernel32.dll stdcall'; +function GetLastError(): Cardinal; + external 'GetLastError@kernel32.dll stdcall'; +$($helper.Value) +$($streamHelper.Value) +$($leafHelper.Value) +$($treeHelper.Value) +$inventorySource +$($inventoryValidator.Value) +$pinBlock +function InitializeSetup(): Boolean; +var + AttackResultCode: Integer; + AttackParameters: String; +begin + if AreCcodExpectedSetupDirectoriesSafe('$($expectedFileRoot.Replace("'","''"))') then + SaveStringToFile('$($fileWriteMarker.Replace("'","''"))','unsafe write',False); + if AreCcodExpectedSetupDirectoriesSafe('$($expectedJunctionRoot.Replace("'","''"))') then + SaveStringToFile('$($junctionWriteMarker.Replace("'","''"))','unsafe write',False); + if IsSafeExistingSetupTree('$($hardLinkTree.Replace("'","''"))') then + SaveStringToFile('$($hardLinkLeaf.Replace("'","''"))','unsafe overwrite',False); + if PinCcodExistingSetupTree('$($concurrentTarget.Replace("'","''"))') then + begin + AttackParameters := '-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "' + + '$($attackScript.Replace("'","''"))' + '" -Target "' + '$($concurrentTarget.Replace("'","''"))' + + '" -Moved "' + '$($concurrentMoved.Replace("'","''"))' + '" -Outside "' + + '$($concurrentOutside.Replace("'","''"))' + '" -ResultPath "' + '$($attackResult.Replace("'","''"))' + '"'; + if (not Exec(ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe'), AttackParameters, + '', SW_HIDE, ewWaitUntilTerminated, AttackResultCode)) or (AttackResultCode <> 0) then + SaveStringToFile('$($attackResult.Replace("'","''"))','attacker-failed',False); + if SaveStringToFile('$((Join-Path $concurrentTarget 'payload.txt').Replace("'","''"))','setup-write',False) then + SaveStringToFile('$($pinWriteResult.Replace("'","''"))','write-ok',False) + else + SaveStringToFile('$($pinWriteResult.Replace("'","''"))','write-failed',False); + CloseCcodSetupPins(); + end + else + SaveStringToFile('$($pinWriteResult.Replace("'","''"))','pin-failed',False); + if IsSafeExistingPayloadDirectory('$($normal.Replace("'","''"))') and + IsSafeExistingPayloadDirectory('$($missing.Replace("'","''"))') and + (not IsSafeExistingPayloadDirectory('$($junction.Replace("'","''"))')) and + (not IsSafeExistingSetupTree('$($tree.Replace("'","''"))')) and + (not IsSafeExistingSetupTree('$($fileDirectory.Replace("'","''"))')) and + (not IsSafeExistingSetupTree('$($hardLinkTree.Replace("'","''"))')) and + AreCcodExpectedSetupDirectoriesSafe('$($normalExpectedRoot.Replace("'","''"))') and + (not AreCcodExpectedSetupDirectoriesSafe('$($expectedFileRoot.Replace("'","''"))')) and + (not AreCcodExpectedSetupDirectoriesSafe('$($expectedJunctionRoot.Replace("'","''"))')) then + SaveStringToFile('$($resultPath.Replace("'","''"))','pass',False); + Result := False; +end; +procedure DeinitializeSetup(); +begin + CloseCcodSetupPins(); +end; +"@ + [IO.File]::WriteAllText($harnessPath,$harness,[Text.UTF8Encoding]::new($false)) + $iscc = Join-Path $env:LOCALAPPDATA 'Programs\Inno Setup 6\ISCC.exe' + $compileOutput = @(& $iscc $harnessPath 2>&1) + Assert-CcodEqual 0 $LASTEXITCODE "reparse predicate harness compiles: $($compileOutput -join ' ')" + $process = Start-Process -FilePath (Join-Path $root 'ReparseGate.exe') -ArgumentList @('/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART','/SP-') -WindowStyle Hidden -Wait -PassThru + try { $null = $process.ExitCode } finally { $process.Dispose() } + Assert-CcodEqual 'pass' ([IO.File]::ReadAllText($resultPath,[Text.UTF8Encoding]::new($false))) 'production predicate rejects root/nested junctions and file-valued directory paths' + Assert-CcodTrue (-not (Test-Path -LiteralPath $fileWriteMarker)) 'nested file-as-directory is rejected before simulated payload writes' + Assert-CcodTrue (-not (Test-Path -LiteralPath $junctionWriteMarker)) 'nested junction is rejected before simulated payload writes' + Assert-CcodEqual 'outside-original' ([IO.File]::ReadAllText($outsideHardLink,[Text.UTF8Encoding]::new($false))) 'hard-linked setup leaf is rejected before simulated outside overwrite' + Assert-CcodEqual 'blocked' ([IO.File]::ReadAllText($attackResult,[Text.UTF8Encoding]::new($false))) 'concurrent directory substitution is blocked after Setup preflight' + Assert-CcodEqual 'write-ok' ([IO.File]::ReadAllText($pinWriteResult,[Text.UTF8Encoding]::new($false))) 'retained pins still allow the legitimate Setup overwrite' + Assert-CcodEqual 'outside-original' ([IO.File]::ReadAllText((Join-Path $concurrentOutside 'payload.txt'),[Text.UTF8Encoding]::new($false))) 'concurrent substitution cannot redirect Setup bytes to the outside sentinel' + Assert-CcodEqual 'setup-write' ([IO.File]::ReadAllText((Join-Path $concurrentTarget 'payload.txt'),[Text.UTF8Encoding]::new($false))) 'simulated Setup write reaches the pinned destination identity' + } finally { + if (Test-Path -LiteralPath $concurrentTarget) { + $concurrentItem = Get-Item -LiteralPath $concurrentTarget -Force + if (($concurrentItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { [IO.Directory]::Delete($concurrentTarget) } + } + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } +} + +#> + +# Production mutation caught: hashing one temporary bootstrap and later executing replacement bytes from the same path. +Invoke-CcodTest 'temporary bootstrap lock makes the verified source bytes the executed bytes' { + $inno = Get-Content -LiteralPath (Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss') -Raw -Encoding UTF8 + Assert-CcodTrue ($inno -cmatch '(?s)function LockCcodInput.*GetSHA256OfFile\(Path\).*CreateFileW\(Path, CCOD_GENERIC_READ, CCOD_FILE_SHARE_READ.*GetSHA256OfFile\(Path\)') 'Setup hashes locks and rehashes each temporary input' + Assert-CcodTrue ($inno -cmatch '(?s)function GetCcodBootstrapParameters.*-File.*CcodBootstrapPath' -and $inno -cmatch '(?s)procedure CurStepChanged.*GetCcodBootstrapParameters.*Exec\(') 'Setup executes the exact locked temporary bootstrap path' + Assert-CcodTrue ($inno -cmatch '(?s)procedure DeinitializeSetup\(\);.*CloseCcodInputHandles') 'Setup retains input locks through terminal validation' + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-bootstrap-lock-' + [guid]::NewGuid().ToString('N')) + $lock = $null + try { + [IO.Directory]::CreateDirectory($root) | Out-Null + $scriptPath = Join-Path $root 'ccod-activation-bootstrap.ps1' + $marker = Join-Path $root 'executed.txt' + [IO.File]::WriteAllText($scriptPath,"[IO.File]::WriteAllText('$($marker.Replace("'","''"))','original',[Text.UTF8Encoding]::new(`$false))",[Text.UTF8Encoding]::new($false)) + $expected = Get-CcodTestFileSha256 -Path $scriptPath + $lock = [IO.File]::Open($scriptPath,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read) + Assert-CcodEqual $expected (Get-CcodTestFileSha256 -Path $scriptPath) 'locked bootstrap bytes retain the verified hash' + $replacementBlocked = $false + try { [IO.File]::WriteAllText($scriptPath,"[IO.File]::WriteAllText('$($marker.Replace("'","''"))','replacement')",[Text.UTF8Encoding]::new($false)) } catch [IO.IOException] { $replacementBlocked = $true } + Assert-CcodTrue $replacementBlocked 'replacement is blocked while the verified bootstrap handle is retained' + & (Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe') -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $scriptPath + Assert-CcodEqual 0 $LASTEXITCODE 'locked verified bootstrap executes successfully' + Assert-CcodEqual 'original' ([IO.File]::ReadAllText($marker,[Text.UTF8Encoding]::new($false))) 'the executed bytes are the original verified bytes' + } finally { + if ($null -ne $lock) { $lock.Dispose() } + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } +} + +Invoke-CcodTest 'release manifest binds the final asset names hashes version commit and timestamp' { + Import-Module $assetContractPath -Force -DisableNameChecking + $fixture = New-CcodTask5ExactAssetFixture + try { + $manifest=Join-Path $fixture.Root $fixture.Names[10];$installer=Join-Path $fixture.Root $fixture.Names[5];$validated = Test-CcodReleaseAssetManifest -ManifestPath $manifest -AssetDirectory $fixture.Root -ExpectedVersion $fixture.Version + Assert-CcodEqual $true ([bool]$validated.Valid) 'valid fixture passes the release manifest contract' + Assert-CcodEqual (Get-CcodTestFileSha256 -Path $installer) ([string]$validated.InstallerSha256) 'validator returns the exact installer hash' + [IO.File]::WriteAllBytes($installer, [byte[]](1,1,1)) + Assert-CcodThrows { + Test-CcodReleaseAssetManifest -ManifestPath $manifest -AssetDirectory $fixture.Root -ExpectedVersion $fixture.Version + } 'CCOD_RELEASE_ASSET_HASH_MISMATCH' + } finally { + Remove-CcodTask5ExactAssetFixture $fixture + } +} + +Invoke-CcodTest 'release timestamp validation reads the raw JSON string representation' { + $module=Import-Module $assetContractPath -Force -PassThru -DisableNameChecking + $canonical = '2026-08-24T00:00:00.0000000Z' + try{Assert-CcodEqual $true (&$module {param($Value)Test-CcodReleaseContractCanonicalUtc $Value} $canonical) 'canonical raw timestamp is retained as exact UTC';Assert-CcodEqual $false (&$module {param($Value)Test-CcodReleaseContractCanonicalUtc $Value} 'not-canonical') 'noncanonical timestamp is rejected'}finally{Remove-Module $module.Name -Force} +} + +Invoke-CcodTest 'release manifest rejects a numeric top-level timestamp hidden by a nested canonical timestamp' { + Import-Module $assetContractPath -Force -DisableNameChecking + $fixture = New-CcodTask5ExactAssetFixture + try { + $canonical = '2026-08-24T00:00:00.0000000Z' + $trayPath=Join-Path $fixture.Root $fixture.Names[2];$tray=[IO.File]::ReadAllText($trayPath)|ConvertFrom-Json;$tray.buildTimestampUtc=123;$tray|Add-Member -NotePropertyName nested -NotePropertyValue ([pscustomobject]@{buildTimestampUtc=$canonical});Write-CcodTask5Json -Path $trayPath -Value $tray;Sync-CcodTask5OuterAssetHash $fixture Setup $fixture.Names[2] + Assert-CcodThrows { + Test-CcodReleaseAssetManifest -ManifestPath (Join-Path $fixture.Root $fixture.Names[10]) -AssetDirectory $fixture.Root -ExpectedVersion $fixture.Version + } 'CCOD_RELEASE_MANIFEST_INVALID' + } finally { + Remove-CcodTask5ExactAssetFixture $fixture + } +} + +Invoke-CcodTest 'release manifest binds the TrayHost provenance timestamp to its own timestamp' { + Import-Module $assetContractPath -Force -DisableNameChecking + $fixture = New-CcodTask5ExactAssetFixture + try { + $trayPath=Join-Path $fixture.Root $fixture.Names[2];$tray=[IO.File]::ReadAllText($trayPath)|ConvertFrom-Json;$tray.buildTimestampUtc='2030-02-03T04:05:07.0000000Z';Write-CcodTask5Json -Path $trayPath -Value $tray;Sync-CcodTask5OuterAssetHash $fixture Setup $fixture.Names[2] + Assert-CcodThrows { + Test-CcodReleaseAssetManifest -ManifestPath (Join-Path $fixture.Root $fixture.Names[10]) -AssetDirectory $fixture.Root -ExpectedVersion $fixture.Version + } 'CCOD_RELEASE_MANIFEST_INVALID' + } finally { + Remove-CcodTask5ExactAssetFixture $fixture + } +} + +Invoke-CcodTask5Test 'defender' 'Defender gate binds exact origins status clocks manifests and a redacted receipt' { + $fixture=New-CcodTask5ExactAssetFixture + try{ + $candidate=Join-Path $fixture.Root $fixture.Names[5];$checksum=Join-Path $fixture.Root $fixture.Names[6];$manifest=Join-Path $fixture.Root $fixture.Names[10];Set-Content -LiteralPath $candidate -Stream Zone.Identifier -Value "[ZoneTransfer]`r`nZoneId=3`r`n" -NoNewline + $clean=New-CcodTask5DefenderAdapterFixture;$receipt=Invoke-CcodTask5DefenderCore -CandidatePath $candidate -ChecksumPath $checksum -ManifestPath $manifest -Origin InternetDownload -ExpectedVersion $fixture.Version -ExpectedGitCommit $fixture.GitCommit -EvidencePath (Join-Path $fixture.Outside 'internet.json') -Adapters $clean.Adapters + $fields='schemaVersion,assetType,assetName,assetSha256,checksumName,checksumSha256,manifestName,manifestSha256,version,gitCommit,origin,workflowArtifactIdentity,zoneId,defenderServiceEnabled,antivirusEnabled,realTimeProtectionEnabled,defenderPlatformVersion,defenderEngineVersion,signatureVersion,signatureUpdatedAtUtc,scanStartedAtUtc,scanCompletedAtUtc,detectionCount,outcome,errorCode' + Assert-CcodEqual $fields (($receipt.PSObject.Properties.Name)-join',') 'receipt exposes only the exact ordered schema';Assert-CcodEqual 'InternetDownload' $receipt.origin 'official download receipt is origin-bound';Assert-CcodEqual 3 $receipt.zoneId 'official download receipt binds actual ZoneId 3';Assert-CcodEqual $null $receipt.workflowArtifactIdentity 'official download receipt has no workflow identity';Assert-CcodEqual (Get-CcodTestFileSha256 $candidate) $receipt.assetSha256 'receipt binds exact candidate hash';Assert-CcodEqual (Get-CcodTestFileSha256 $manifest) $receipt.manifestSha256 'receipt binds exact matching manifest hash';Assert-CcodTrue (-not(($receipt|ConvertTo-Json -Depth 12 -Compress).Contains($fixture.Root))) 'receipt contains no source path or raw output' + Remove-Item -LiteralPath $candidate -Stream Zone.Identifier + foreach($zoneShape in @([pscustomobject]@{Name='absent';Text=$null},[pscustomobject]@{Name='other';Text="[ZoneTransfer]`r`nZoneId=2`r`n"},[pscustomobject]@{Name='duplicate';Text="[ZoneTransfer]`r`nZoneId=3`r`nZoneId=3`r`n"},[pscustomobject]@{Name='mixed';Text="[ZoneTransfer]`r`nZoneId=3`r`nZoneId=2`r`n"})){if($null-ne$zoneShape.Text){Set-Content -LiteralPath $candidate -Stream Zone.Identifier -Value $zoneShape.Text -NoNewline};$case=New-CcodTask5DefenderAdapterFixture;Assert-CcodThrows {Invoke-CcodTask5DefenderCore -CandidatePath $candidate -ChecksumPath $checksum -ManifestPath $manifest -Origin InternetDownload -ExpectedVersion $fixture.Version -ExpectedGitCommit $fixture.GitCommit -EvidencePath (Join-Path $fixture.Outside "zone-$($zoneShape.Name).json") -Adapters $case.Adapters|Out-Null} 'CCOD_DEFENDER_ZONE_REQUIRED';Assert-CcodTrue (-not($case.State.Calls-contains'Scan')) "$($zoneShape.Name) ZoneId metadata blocks scan";if($null-ne$zoneShape.Text){Remove-Item -LiteralPath $candidate -Stream Zone.Identifier}} + $identity=New-CcodTask5WorkflowIdentity $fixture.GitCommit;$trusted=New-CcodTask5DefenderAdapterFixture;$trustedReceipt=Invoke-CcodTask5DefenderCore -CandidatePath $candidate -ChecksumPath $checksum -ManifestPath $manifest -Origin TrustedWorkflowArtifact -WorkflowArtifactIdentity $identity -ExpectedVersion $fixture.Version -ExpectedGitCommit $fixture.GitCommit -EvidencePath (Join-Path $fixture.Outside 'trusted.json') -Adapters $trusted.Adapters;Assert-CcodEqual $null $trustedReceipt.zoneId 'trusted workflow receipt never claims Internet Zone';Assert-CcodEqual ($identity|ConvertTo-Json -Compress) ($trustedReceipt.workflowArtifactIdentity|ConvertTo-Json -Compress) 'trusted receipt binds exact workflow artifact identity' + Assert-CcodThrows {Invoke-CcodTask5DefenderCore -CandidatePath $candidate -ChecksumPath $checksum -ManifestPath $manifest -Origin TrustedWorkflowArtifact -ExpectedVersion $fixture.Version -ExpectedGitCommit $fixture.GitCommit -EvidencePath (Join-Path $fixture.Outside 'trusted-missing.json') -Adapters (New-CcodTask5DefenderAdapterFixture).Adapters|Out-Null} 'CCOD_DEFENDER_ORIGIN_INVALID' + Assert-CcodThrows {Invoke-CcodTask5DefenderCore -CandidatePath $candidate -ChecksumPath $checksum -ManifestPath $manifest -Origin InternetDownload -WorkflowArtifactIdentity $identity -ExpectedVersion $fixture.Version -ExpectedGitCommit $fixture.GitCommit -EvidencePath (Join-Path $fixture.Outside 'internet-identity.json') -Adapters (New-CcodTask5DefenderAdapterFixture).Adapters|Out-Null} 'CCOD_DEFENDER_ORIGIN_INVALID' + foreach($field in @('provider','repository','runId','runAttempt','artifactId','artifactName','artifactDigest','gitCommit')){$bad=(($identity|ConvertTo-Json -Compress)|ConvertFrom-Json);if($field-in@('runId','runAttempt','artifactId')){$bad.$field=0}elseif($field-ceq'artifactDigest'){$bad.$field='sha256:bad'}elseif($field-ceq'gitCommit'){$bad.$field='b'*40}else{$bad.$field='foreign'};Assert-CcodThrows {Invoke-CcodTask5DefenderCore -CandidatePath $candidate -ChecksumPath $checksum -ManifestPath $manifest -Origin TrustedWorkflowArtifact -WorkflowArtifactIdentity $bad -ExpectedVersion $fixture.Version -ExpectedGitCommit $fixture.GitCommit -EvidencePath (Join-Path $fixture.Outside "trusted-$field.json") -Adapters (New-CcodTask5DefenderAdapterFixture).Adapters|Out-Null} 'CCOD_DEFENDER_ORIGIN_INVALID'} + foreach($mutation in @('Service','Antivirus','Realtime','Platform','Engine','Signature','SignatureMissing','SignatureStale','SignatureFuture')){$status=New-CcodTask5DefenderStatus;switch($mutation){'Service'{$status.AMServiceEnabled=$false};'Antivirus'{$status.AntivirusEnabled=$false};'Realtime'{$status.RealTimeProtectionEnabled=$false};'Platform'{$status.AMProductVersion=''};'Engine'{$status.AMEngineVersion=''};'Signature'{$status.AntivirusSignatureVersion=''};'SignatureMissing'{$status.AntivirusSignatureLastUpdated=$null};'SignatureStale'{$status.AntivirusSignatureLastUpdated=[datetime]::Parse('2030-01-30T04:05:05Z')};'SignatureFuture'{$status.AntivirusSignatureLastUpdated=[datetime]::Parse('2030-02-03T04:10:07Z')}};$case=New-CcodTask5DefenderAdapterFixture -Status $status;Assert-CcodThrows {Invoke-CcodTask5DefenderCore -CandidatePath $candidate -ChecksumPath $checksum -ManifestPath $manifest -Origin TrustedWorkflowArtifact -WorkflowArtifactIdentity $identity -ExpectedVersion $fixture.Version -ExpectedGitCommit $fixture.GitCommit -EvidencePath (Join-Path $fixture.Outside "status-$mutation.json") -Adapters $case.Adapters|Out-Null} 'CCOD_DEFENDER_STATUS_INVALID';Assert-CcodTrue (-not($case.State.Calls-contains'Scan')) "$mutation blocks scan"} + foreach($clock in @('Reverse','TooLong','Invalid')){$completed=if($clock-ceq'Reverse'){[datetime]::Parse('2030-02-03T04:05:05Z')}elseif($clock-ceq'TooLong'){[datetime]::Parse('2030-02-03T06:05:07Z')}else{'not-a-clock'};$case=New-CcodTask5DefenderAdapterFixture -Completed $completed;Assert-CcodThrows {Invoke-CcodTask5DefenderCore -CandidatePath $candidate -ChecksumPath $checksum -ManifestPath $manifest -Origin TrustedWorkflowArtifact -WorkflowArtifactIdentity $identity -ExpectedVersion $fixture.Version -ExpectedGitCommit $fixture.GitCommit -EvidencePath (Join-Path $fixture.Outside "clock-$clock.json") -Adapters $case.Adapters|Out-Null} 'CCOD_DEFENDER_CLOCK_INVALID'} + foreach($failure in @('Scan','Detection','Write')){$case=if($failure-ceq'Scan'){New-CcodTask5DefenderAdapterFixture -ScanThrows}elseif($failure-ceq'Detection'){New-CcodTask5DefenderAdapterFixture -Detects}else{New-CcodTask5DefenderAdapterFixture -WriteThrows};$error=if($failure-ceq'Scan'){'CCOD_DEFENDER_SCAN_FAILED'}elseif($failure-ceq'Detection'){'CCOD_DEFENDER_DETECTIONS_FOUND'}else{'CCOD_DEFENDER_EVIDENCE_WRITE_FAILED'};Assert-CcodThrows {Invoke-CcodTask5DefenderCore -CandidatePath $candidate -ChecksumPath $checksum -ManifestPath $manifest -Origin TrustedWorkflowArtifact -WorkflowArtifactIdentity $identity -ExpectedVersion $fixture.Version -ExpectedGitCommit $fixture.GitCommit -EvidencePath (Join-Path $fixture.Outside "failure-$failure.json") -Adapters $case.Adapters|Out-Null} $error} + $command=Get-Command $defenderPath;Assert-CcodTrue (-not$command.Parameters.ContainsKey('ZoneId')-and-not$command.Parameters.ContainsKey('Adapters')-and-not$command.Parameters.ContainsKey('Library')) 'public tool exposes no Zone synthesis adapter or library bypass parameter' + }finally{Remove-CcodTask5ExactAssetFixture $fixture} +} + +Invoke-CcodTask5Test 'review-defender-existing-threat' 'Defender rejects the same unresolved candidate detection before and after a scan' { + $fixture=New-CcodTask5ExactAssetFixture + try { + $candidate=Join-Path $fixture.Root $fixture.Names[5] + $invoke=@{CandidatePath=$candidate;ChecksumPath=(Join-Path $fixture.Root $fixture.Names[6]);ManifestPath=(Join-Path $fixture.Root $fixture.Names[10]);Origin='TrustedWorkflowArtifact';WorkflowArtifactIdentity=(New-CcodTask5WorkflowIdentity $fixture.GitCommit);ExpectedVersion=$fixture.Version;ExpectedGitCommit=$fixture.GitCommit} + $control=New-CcodTask5DefenderAdapterFixture + $clean=Invoke-CcodTask5DefenderCore @invoke -EvidencePath (Join-Path $fixture.Outside 'clean.json') -Adapters $control.Adapters + Assert-CcodEqual 'Completed' $clean.outcome 'otherwise-valid unchanged candidate passes before introducing the old detection' + Assert-CcodEqual 2 $control.State.Threat 'passing control observes both sides of the scan' + [Console]::WriteLine('CCOD_DEFENDER_EXISTING_THREAT_CONTROL_PASSED') + $record=[pscustomobject]@{ThreatID=[long]99;InitialDetectionTime=([datetime]::Parse('2030-02-02T04:05:06Z').ToUniversalTime());Resources=[string[]]@('file:_'+$candidate);ThreatStatusID=[byte]1;ActionSuccess=$false;ThreatStatusErrorCode=[int]0;AdditionalActionsBitMask=[uint32]0;CurrentThreatExecutionStatusID=[byte]0;LastThreatStatusChangeTime=([datetime]::Parse('2030-02-02T04:05:06Z').ToUniversalTime());RemediationTime=$null} + $case=New-CcodTask5DefenderAdapterFixture + $state=$case.State + $case.Adapters.GetThreatDetections={$state.Calls.Add('Threat');$state.Threat++;return @($record)}.GetNewClosure() + $path=Join-Path $fixture.Outside 'unresolved.json' + Assert-CcodThrows {Invoke-CcodTask5DefenderCore @invoke -EvidencePath $path -Adapters $case.Adapters | Out-Null} 'CCOD_DEFENDER_DETECTIONS_FOUND' + Assert-CcodEqual 2 $state.Threat 'unchanged unresolved detection is observed before and after scanning' + Assert-CcodEqual 1 @($state.Calls | Where-Object {$_ -ceq 'Scan'}).Count 'the isolated scan adapter actually ran' + $failed=[IO.File]::ReadAllText($path)|ConvertFrom-Json + Assert-CcodEqual 'Failed' $failed.outcome 'unresolved candidate never leaves a clean receipt' + Assert-CcodEqual 1 $failed.detectionCount 'the same detection is counted once rather than omitted or counted twice' + Assert-CcodTrue (-not([IO.File]::ReadAllText($path).Contains($candidate))) 'receipt does not leak threat resource paths' + } finally {Remove-CcodTask5ExactAssetFixture $fixture} +} + +Invoke-CcodTask5Test 'review-defender-status-drift' 'Defender does not ignore remediation changes under an existing detection key' { + $fixture=New-CcodTask5ExactAssetFixture + try { + $candidate=Join-Path $fixture.Root $fixture.Names[5] + $invoke=@{CandidatePath=$candidate;ChecksumPath=(Join-Path $fixture.Root $fixture.Names[6]);ManifestPath=(Join-Path $fixture.Root $fixture.Names[10]);Origin='TrustedWorkflowArtifact';WorkflowArtifactIdentity=(New-CcodTask5WorkflowIdentity $fixture.GitCommit);ExpectedVersion=$fixture.Version;ExpectedGitCommit=$fixture.GitCommit} + $initial=[datetime]::Parse('2030-02-02T04:05:06Z').ToUniversalTime() + $record=[pscustomobject]@{ThreatID=[long]99;InitialDetectionTime=$initial;Resources=[string[]]@('file:_'+$candidate);ThreatStatusID=[byte]2;ActionSuccess=$true;ThreatStatusErrorCode=[int]0;AdditionalActionsBitMask=[uint32]0;CurrentThreatExecutionStatusID=[byte]4;LastThreatStatusChangeTime=$initial.AddMinutes(1);RemediationTime=$initial.AddMinutes(1)} + foreach($mode in @('Stable','RemediationTime','LastThreatStatusChangeTime','ThreatStatusID','CurrentThreatExecutionStatusID','ThreatStatusErrorCode','AdditionalActionsBitMask','ActionSuccess','MissingStatus','StatusArray','StatusString','BeforeUnresolved','DisappearingUnresolved','Unrelated','SamePrefixOtherFile','ContainerFile','Allowed','UnknownResource')) { + $case=New-CcodTask5DefenderAdapterFixture;$state=$case.State + $before=$record.PSObject.Copy() + $after=$record.PSObject.Copy() + switch($mode) { + 'RemediationTime'{$after.RemediationTime=$initial.AddMinutes(2)} + 'LastThreatStatusChangeTime'{$after.LastThreatStatusChangeTime=$initial.AddMinutes(2)} + 'ThreatStatusID'{$after.ThreatStatusID=[byte]3} + 'CurrentThreatExecutionStatusID'{$after.CurrentThreatExecutionStatusID=[byte]3} + 'ThreatStatusErrorCode'{$after.ThreatStatusErrorCode=[int]-1} + 'AdditionalActionsBitMask'{$after.AdditionalActionsBitMask=[uint32]8} + 'ActionSuccess'{$after.ActionSuccess=$false} + 'MissingStatus'{$before.PSObject.Properties.Remove('ThreatStatusID');$after.PSObject.Properties.Remove('ThreatStatusID')} + 'StatusArray'{$before.ThreatStatusID=@([byte]2);$after.ThreatStatusID=@([byte]2)} + 'StatusString'{$before.ThreatStatusID='2';$after.ThreatStatusID='2'} + 'BeforeUnresolved'{$before.ThreatStatusID=[byte]1;$before.ActionSuccess=$false} + 'DisappearingUnresolved'{$before.ThreatStatusID=[byte]1;$before.ActionSuccess=$false;$after=$null} + 'Unrelated'{$before.Resources=[string[]]@('file:_' + (Join-Path $fixture.Outside 'other.exe'));$after.Resources=$before.Resources;$before.ActionSuccess=$false;$after.ActionSuccess=$false} + 'SamePrefixOtherFile'{$before.Resources=[string[]]@('file:_' + $candidate + '.other');$after.Resources=$before.Resources;$before.ActionSuccess=$false;$after.ActionSuccess=$false} + 'ContainerFile'{$before.Resources=[string[]]@('containerfile:_' + $candidate + ';file:_inside.exe');$after.Resources=$before.Resources;$before.ActionSuccess=$false;$after.ActionSuccess=$false} + 'Allowed'{$before.ThreatStatusID=[byte]5;$after.ThreatStatusID=[byte]5} + 'UnknownResource'{$before.Resources=[string[]]@('unrecognized-resource');$after.Resources=$before.Resources;$before.ActionSuccess=$false;$after.ActionSuccess=$false} + } + $case.Adapters.GetThreatDetections={$state.Calls.Add('Threat');$state.Threat++;if($state.Threat-eq1){$before}else{$after}}.GetNewClosure() + $path=Join-Path $fixture.Outside ($mode+'.json') + if($mode-in@('Stable','Unrelated','SamePrefixOtherFile')) { + $result=Invoke-CcodTask5DefenderCore @invoke -EvidencePath $path -Adapters $case.Adapters + Assert-CcodEqual 'Completed' $result.outcome 'unchanged successfully remediated historical detection is a passing control' + Assert-CcodEqual 0 $result.detectionCount 'stable clean history is not counted as a new threat' + [Console]::WriteLine('CCOD_DEFENDER_RESOLVED_HISTORY_CONTROL_PASSED '+$mode) + } else { + Assert-CcodThrows {Invoke-CcodTask5DefenderCore @invoke -EvidencePath $path -Adapters $case.Adapters|Out-Null} 'CCOD_DEFENDER_DETECTIONS_FOUND' + $failed=[IO.File]::ReadAllText($path)|ConvertFrom-Json + Assert-CcodEqual 'Failed' $failed.outcome 'status changes cannot produce Completed' + Assert-CcodEqual 1 $failed.detectionCount 'changed existing record is counted once' + } + Assert-CcodEqual 2 $state.Threat 'both snapshots are actually observed' + } + } finally {Remove-CcodTask5ExactAssetFixture $fixture} +} + +Invoke-CcodTask5Test 'review-defender-malformed-observation' 'Defender cannot interpret an unidentified record as empty clean history' { + $fixture=New-CcodTask5ExactAssetFixture + try { + $invoke=@{CandidatePath=(Join-Path $fixture.Root $fixture.Names[5]);ChecksumPath=(Join-Path $fixture.Root $fixture.Names[6]);ManifestPath=(Join-Path $fixture.Root $fixture.Names[10]);Origin='TrustedWorkflowArtifact';WorkflowArtifactIdentity=(New-CcodTask5WorkflowIdentity $fixture.GitCommit);ExpectedVersion=$fixture.Version;ExpectedGitCommit=$fixture.GitCommit} + $control=New-CcodTask5DefenderAdapterFixture + $clean=Invoke-CcodTask5DefenderCore @invoke -EvidencePath (Join-Path $fixture.Outside 'clean.json') -Adapters $control.Adapters + Assert-CcodEqual 'Completed' $clean.outcome 'legitimate empty observation passes' + $case=New-CcodTask5DefenderAdapterFixture;$state=$case.State + $case.Adapters.GetThreatDetections={$state.Threat++;return @([pscustomobject]@{})}.GetNewClosure() + $path=Join-Path $fixture.Outside 'invalid.json' + Assert-CcodThrows {Invoke-CcodTask5DefenderCore @invoke -EvidencePath $path -Adapters $case.Adapters|Out-Null} 'CCOD_DEFENDER_DETECTIONS_INVALID' + Assert-CcodTrue (-not[IO.File]::Exists($path)) 'unidentified observation cannot create a clean receipt' + Assert-CcodEqual 1 $state.Threat 'malformed first observation is reached' + Assert-CcodTrue (-not($state.Calls-contains'Scan')) 'malformed baseline blocks the scan' + } finally {Remove-CcodTask5ExactAssetFixture $fixture} +} + +Invoke-CcodTask5Test 'review2-defender-receipt-rename' 'Defender cannot leave success evidence after an attempted held-receipt rename' { + $fixture=New-CcodTask5ExactAssetFixture + try { + $invoke=@{CandidatePath=(Join-Path $fixture.Root $fixture.Names[5]);ChecksumPath=(Join-Path $fixture.Root $fixture.Names[6]);ManifestPath=(Join-Path $fixture.Root $fixture.Names[10]);Origin='TrustedWorkflowArtifact';WorkflowArtifactIdentity=(New-CcodTask5WorkflowIdentity $fixture.GitCommit);ExpectedVersion=$fixture.Version;ExpectedGitCommit=$fixture.GitCommit} + $controlPath=Join-Path $fixture.Outside 'rename-control.json' + $control=Invoke-CcodTask5DefenderCore @invoke -EvidencePath $controlPath -Adapters (New-CcodTask5DefenderAdapterFixture).Adapters + Assert-CcodEqual 'Completed' $control.outcome 'real publisher/readback control passes' + $controlHash=Get-CcodTestFileSha256 $controlPath + $path=Join-Path $fixture.Outside 'rename-target.json';$renamed=Join-Path $fixture.Outside 'renamed-success.json';$interference=Join-Path $fixture.Outside 'rename-interference.txt' + $state=[pscustomobject]@{Attempts=0;Moved=$false;Authority=$null} + $realPublish=(Get-CcodTask5DefaultDefenderAdapters).PublishReceiptBytes + $case=New-CcodTask5DefenderAdapterFixture + $case.Adapters.PublishReceiptBytes={ + param($Directory,$Leaf,$Bytes) + $pin=&$realPublish $Directory $Leaf $Bytes;$state.Authority=$pin;$state.Attempts++ + try{[IO.File]::Move($pin.Path,$renamed);$state.Moved=$true}catch [IO.IOException]{} + [IO.File]::WriteAllText($interference,'post-publication membership failure') + return $pin + }.GetNewClosure() + $failure=$null + try{Invoke-CcodTask5DefenderCore @invoke -EvidencePath $path -Adapters $case.Adapters|Out-Null}catch{$failure=$_.FullyQualifiedErrorId} + Assert-CcodEqual 1 $state.Attempts 'actual publication occurs before the native rename attempt' + Assert-CcodTrue ($failure-like'CCOD_DEFENDER_*') 'post-publication failure reaches owned rollback' + Assert-CcodTrue (-not[IO.File]::Exists($path)-and-not[IO.File]::Exists($renamed)) 'failed invocation leaves no owned Completed receipt under either name' + Assert-CcodEqual $false $state.Moved 'publisher denies another opener rename while retaining ownership' + Assert-CcodTrue $state.Authority.Closed 'failure closes the real owned authority' + Assert-CcodEqual $controlHash (Get-CcodTestFileSha256 $controlPath) 'earlier successful evidence remains untouched' + Assert-CcodTrue ([IO.File]::Exists($interference)) 'unowned sibling is never deleted by rollback' + [IO.File]::Delete($interference) + $retry=Invoke-CcodTask5DefenderCore @invoke -EvidencePath $path -Adapters (New-CcodTask5DefenderAdapterFixture).Adapters + Assert-CcodEqual 'Completed' $retry.outcome 'released authority permits legitimate retry' + } finally {Remove-CcodTask5ExactAssetFixture $fixture} +} + +Invoke-CcodTask5Test 'review-defender-published-rollback' 'Defender removes its published receipt when evidence membership validation fails' { + $fixture=New-CcodTask5ExactAssetFixture + try { + $invoke=@{CandidatePath=(Join-Path $fixture.Root $fixture.Names[5]);ChecksumPath=(Join-Path $fixture.Root $fixture.Names[6]);ManifestPath=(Join-Path $fixture.Root $fixture.Names[10]);Origin='TrustedWorkflowArtifact';WorkflowArtifactIdentity=(New-CcodTask5WorkflowIdentity $fixture.GitCommit);ExpectedVersion=$fixture.Version;ExpectedGitCommit=$fixture.GitCommit} + $control=New-CcodTask5DefenderAdapterFixture + $controlPath=Join-Path $fixture.Outside 'control.json' + $clean=Invoke-CcodTask5DefenderCore @invoke -EvidencePath $controlPath -Adapters $control.Adapters + Assert-CcodEqual 'Completed' $clean.outcome 'unchanged publication and readback pass before injecting failure' + $controlHash=Get-CcodTestFileSha256 $controlPath + $case=New-CcodTask5DefenderAdapterFixture + $publish=(Get-CcodTask5DefaultDefenderAdapters).PublishReceiptBytes + $state=[pscustomobject]@{Published=$false;Authority=$null;Interference=$null} + $case.Adapters.PublishReceiptBytes={ + param($Directory,$Leaf,$Bytes) + $authority=&$publish $Directory $Leaf $Bytes + $state.Authority=$authority + $state.Published=[IO.File]::Exists($authority.Path) + $state.Interference=Join-Path $Directory.Path 'unrelated.txt' + [IO.File]::WriteAllText($state.Interference,'unrelated-data',[Text.UTF8Encoding]::new($false)) + return $authority + }.GetNewClosure() + $path=Join-Path $fixture.Outside 'rejected.json' + Assert-CcodThrows {Invoke-CcodTask5DefenderCore @invoke -EvidencePath $path -Adapters $case.Adapters|Out-Null} 'CCOD_DEFENDER_EVIDENCE_WRITE_FAILED' + Assert-CcodTrue $state.Published 'failure occurs after actual native receipt publication' + Assert-CcodTrue (-not[IO.File]::Exists($path)) 'failed Defender invocation leaves no published success-shaped receipt' + Assert-CcodTrue $state.Authority.Closed 'owned receipt authority is released after rollback' + Assert-CcodEqual $controlHash (Get-CcodTestFileSha256 $controlPath) 'rollback preserves the existing valid receipt' + Assert-CcodEqual 'unrelated-data' ([IO.File]::ReadAllText($state.Interference)) 'rollback does not delete the interfering sibling' + $retry=Invoke-CcodTask5DefenderCore @invoke -EvidencePath $path -Adapters (New-CcodTask5DefenderAdapterFixture).Adapters + Assert-CcodEqual 'Completed' $retry.outcome 'clean retry proves receipt and directory handles were released' + } finally {Remove-CcodTask5ExactAssetFixture $fixture} +} + +Invoke-CcodTask5Test 'review-defender-readback-final-rollback' 'Defender rolls back readback failures and input drift after successful readback' { + $fixture=New-CcodTask5ExactAssetFixture + $module=Import-Module $releaseDefenderModulePath -Force -PassThru -DisableNameChecking + $original=&$module {${function:Confirm-CcodReleaseDefenderReceiptReadback}} + try { + &$module { + param($Original) + $script:CcodReviewOriginalReadback=$Original + function script:Confirm-CcodReleaseDefenderReceiptReadback { + param($PublishedAuthority,$ReadbackAuthority,[string]$ExpectedPath,[byte[]]$ExpectedBytes,$ExpectedReceipt) + $script:CcodReviewReadbackState.Readbacks++ + $result=&$script:CcodReviewOriginalReadback @PSBoundParameters + $script:CcodReviewReadbackState.Confirmed=$true + if($script:CcodReviewReadbackState.Mode-ceq'FinalInputs') { + [IO.File]::WriteAllText($script:CcodReviewReadbackState.Interference,'fixture-input-drift',[Text.UTF8Encoding]::new($false)) + $script:CcodReviewReadbackState.Injected=$true + } + return $result + } + } $original + $invoke=@{CandidatePath=(Join-Path $fixture.Root $fixture.Names[5]);ChecksumPath=(Join-Path $fixture.Root $fixture.Names[6]);ManifestPath=(Join-Path $fixture.Root $fixture.Names[10]);Origin='TrustedWorkflowArtifact';WorkflowArtifactIdentity=(New-CcodTask5WorkflowIdentity $fixture.GitCommit);ExpectedVersion=$fixture.Version;ExpectedGitCommit=$fixture.GitCommit} + $publish=(Get-CcodTask5DefaultDefenderAdapters).PublishReceiptBytes + foreach($mode in @('Clean','ReadbackBytes','ReadbackLength','FinalInputs')) { + $state=[pscustomobject]@{Mode=$mode;Readbacks=0;Confirmed=$false;Injected=$false;Authority=$null;Interference=(Join-Path $fixture.Root 'after-confirm.txt')} + &$module {param($State)$script:CcodReviewReadbackState=$State} $state + $case=New-CcodTask5DefenderAdapterFixture + $case.Adapters.PublishReceiptBytes={ + param($Directory,$Leaf,$Bytes) + $authority=&$publish $Directory $Leaf $Bytes + $state.Authority=$authority + if($state.Mode-in@('ReadbackBytes','ReadbackLength')) { + $text=[Text.UTF8Encoding]::new($false).GetString($Bytes) + $text=if($state.Mode-ceq'ReadbackBytes'){$text.Replace('Completed','CompleteX')}else{$text+' '} + $changed=[Text.UTF8Encoding]::new($false).GetBytes($text) + $authority.Stream.Position=0;$authority.Stream.Write($changed,0,$changed.Length);$authority.Stream.SetLength($changed.Length);$authority.Stream.Flush($true) + $state.Injected=$true + } + return $authority + }.GetNewClosure() + $path=Join-Path $fixture.Outside ($mode+'.json') + if($mode-ceq'Clean') { + $result=Invoke-CcodTask5DefenderCore @invoke -EvidencePath $path -Adapters $case.Adapters + Assert-CcodEqual 'Completed' $result.outcome 'the instrumented actual readback succeeds without interference' + Assert-CcodTrue $state.Confirmed 'passing control reaches real readback confirmation' + } else { + $expected=if($mode-ceq'FinalInputs'){'CCOD_RELEASE_ASSET_HASH_MISMATCH'}else{'CCOD_DEFENDER_EVIDENCE_WRITE_FAILED'} + Assert-CcodThrows {Invoke-CcodTask5DefenderCore @invoke -EvidencePath $path -Adapters $case.Adapters|Out-Null} $expected + Assert-CcodTrue $state.Injected "$mode failure is injected at the actual boundary" + Assert-CcodEqual 1 $state.Readbacks "$mode reaches the real readback helper" + if($mode-ceq'FinalInputs') { + Assert-CcodTrue $state.Confirmed 'final input drift occurs strictly after successful readback' + Assert-CcodEqual 'fixture-input-drift' ([IO.File]::ReadAllText($state.Interference)) 'production rollback preserves the unrelated candidate sibling' + [IO.File]::Delete($state.Interference) + } + Assert-CcodTrue (-not[IO.File]::Exists($path)) "$mode leaves no usable published receipt" + Assert-CcodTrue $state.Authority.Closed "$mode releases the published file handle" + $state.Mode='Retry' + $retry=Invoke-CcodTask5DefenderCore @invoke -EvidencePath $path -Adapters (New-CcodTask5DefenderAdapterFixture).Adapters + Assert-CcodEqual 'Completed' $retry.outcome "$mode allows a new valid operation after cleanup" + } + } + } finally { + &$module {param($Original)Set-Item -LiteralPath Function:script:Confirm-CcodReleaseDefenderReceiptReadback -Value $Original;Remove-Variable -Name CcodReviewOriginalReadback,CcodReviewReadbackState -Scope Script -ErrorAction SilentlyContinue} $original + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + Remove-CcodTask5ExactAssetFixture $fixture + } +} + +Invoke-CcodTask5Test 'review-defender-rollback-lost-authority' 'Defender cleanup fails closed without deleting a pathname replacement after authority loss' { + $fixture=New-CcodTask5ExactAssetFixture + try { + $invoke=@{CandidatePath=(Join-Path $fixture.Root $fixture.Names[5]);ChecksumPath=(Join-Path $fixture.Root $fixture.Names[6]);ManifestPath=(Join-Path $fixture.Root $fixture.Names[10]);Origin='TrustedWorkflowArtifact';WorkflowArtifactIdentity=(New-CcodTask5WorkflowIdentity $fixture.GitCommit);ExpectedVersion=$fixture.Version;ExpectedGitCommit=$fixture.GitCommit} + $clean=Invoke-CcodTask5DefenderCore @invoke -EvidencePath (Join-Path $fixture.Outside 'control.json') -Adapters (New-CcodTask5DefenderAdapterFixture).Adapters + Assert-CcodEqual 'Completed' $clean.outcome 'valid publication succeeds with retained authority' + $publish=(Get-CcodTask5DefaultDefenderAdapters).PublishReceiptBytes + $state=[pscustomobject]@{Replaced=$false;OwnedPath=$null;Authority=$null} + $case=New-CcodTask5DefenderAdapterFixture + $case.Adapters.PublishReceiptBytes={ + param($Directory,$Leaf,$Bytes) + $authority=&$publish $Directory $Leaf $Bytes + $state.Authority=$authority + $authority.Stream.Dispose();$authority.Closed=$true + $state.OwnedPath=Join-Path $Directory.Path 'detached.json' + [IO.File]::Move($authority.Path,$state.OwnedPath) + [IO.File]::WriteAllText($authority.Path,'unrelated-replacement',[Text.UTF8Encoding]::new($false)) + $state.Replaced=$true + return $authority + }.GetNewClosure() + $path=Join-Path $fixture.Outside 'replaced.json' + Assert-CcodThrows {Invoke-CcodTask5DefenderCore @invoke -EvidencePath $path -Adapters $case.Adapters|Out-Null} 'CCOD_DEFENDER_EVIDENCE_CLEANUP_FAILED' + Assert-CcodTrue $state.Replaced 'fixture actually replaces the published pathname after closing its authority' + Assert-CcodEqual 'unrelated-replacement' ([IO.File]::ReadAllText($path)) 'failure never falls back to deleting the replacement by pathname' + Assert-CcodTrue ([IO.File]::Exists($state.OwnedPath)) 'lost ownership is reported rather than guessed from another path' + Assert-CcodTrue $state.Authority.Closed 'the lost authority remains closed' + } finally {Remove-CcodTask5ExactAssetFixture $fixture} +} + +Invoke-CcodTask5Test 'evidence' 'Defender default evidence writer is create-only and rejects unsafe targets before scan' { + $fixture=New-CcodTask5ExactAssetFixture + $unsafeOutside=$null;$unsafeLink=$null + try{ + $candidate=Join-Path $fixture.Root $fixture.Names[5];$checksum=Join-Path $fixture.Root $fixture.Names[6];$manifest=Join-Path $fixture.Root $fixture.Names[10];$identity=New-CcodTask5WorkflowIdentity $fixture.GitCommit + $clean=New-CcodTask5DefenderAdapterFixture;$evidence=Join-Path $fixture.Outside 'create-only-receipt.json' + $receipt=Invoke-CcodTask5DefenderCore -CandidatePath $candidate -ChecksumPath $checksum -ManifestPath $manifest -Origin TrustedWorkflowArtifact -WorkflowArtifactIdentity $identity -ExpectedVersion $fixture.Version -ExpectedGitCommit $fixture.GitCommit -EvidencePath $evidence -Adapters $clean.Adapters + Assert-CcodTrue (Test-Path -LiteralPath $evidence -PathType Leaf) 'default writer creates the requested regular evidence leaf' + $persisted=[IO.File]::ReadAllText($evidence,[Text.UTF8Encoding]::new($false,$true))|ConvertFrom-Json + Assert-CcodEqual ($receipt|ConvertTo-Json -Depth 12 -Compress) ($persisted|ConvertTo-Json -Depth 12 -Compress) 'default writer persists only the returned canonical receipt' + Assert-CcodEqual 0 @(Get-ChildItem -LiteralPath $fixture.Outside -Force|Where-Object{$_.Name-like'.ccod-defender-receipt-*'}).Count 'default writer leaves no temporary evidence leaf' + + foreach($shape in @('ExistingFile','Directory','ReparseLeaf','UnsafeAncestry','Noncanonical','AlternateStream')){ + $case=New-CcodTask5DefenderAdapterFixture;$target=Join-Path $fixture.Outside ("invalid-$shape.json") + $existingText=$null;$cleanupLink=$null + switch($shape){ + 'ExistingFile'{$existingText='do-not-overwrite';[IO.File]::WriteAllText($target,$existingText,[Text.UTF8Encoding]::new($false))} + 'Directory'{[IO.Directory]::CreateDirectory($target)|Out-Null} + 'ReparseLeaf'{$outside=Join-Path ([IO.Path]::GetTempPath()) ('ccod-task5-evidence-leaf-'+[guid]::NewGuid().ToString('N'));[IO.Directory]::CreateDirectory($outside)|Out-Null;$cleanupLink=$target;New-Item -ItemType Junction -Path $target -Target $outside|Out-Null} + 'UnsafeAncestry'{$unsafeOutside=Join-Path ([IO.Path]::GetTempPath()) ('ccod-task5-evidence-parent-'+[guid]::NewGuid().ToString('N'));[IO.Directory]::CreateDirectory($unsafeOutside)|Out-Null;$unsafeLink=Join-Path $fixture.Outside 'unsafe-evidence-parent';New-Item -ItemType Junction -Path $unsafeLink -Target $unsafeOutside|Out-Null;$target=Join-Path $unsafeLink 'receipt.json';$cleanupLink=$unsafeLink} + 'Noncanonical'{$target=$fixture.Outside+'\.\noncanonical-receipt.json'} + 'AlternateStream'{$target=(Join-Path $fixture.Outside 'alternate-receipt.json:stream')} + } + try{ + Assert-CcodThrows {Invoke-CcodTask5DefenderCore -CandidatePath $candidate -ChecksumPath $checksum -ManifestPath $manifest -Origin TrustedWorkflowArtifact -WorkflowArtifactIdentity $identity -ExpectedVersion $fixture.Version -ExpectedGitCommit $fixture.GitCommit -EvidencePath $target -Adapters $case.Adapters|Out-Null} 'CCOD_DEFENDER_EVIDENCE_INVALID' + Assert-CcodTrue (-not($case.State.Calls-contains'Scan')) "$shape evidence target is rejected before Defender scan" + if($shape-ceq'ExistingFile'){Assert-CcodEqual $existingText ([IO.File]::ReadAllText($target,[Text.UTF8Encoding]::new($false,$true))) 'existing evidence remains byte-for-byte unchanged'} + }finally{ + if($null-ne$cleanupLink-and(Test-Path -LiteralPath $cleanupLink)){[IO.Directory]::Delete($cleanupLink)} + if($shape-ceq'ReparseLeaf'-and$null-ne$outside-and(Test-Path -LiteralPath $outside)){Remove-Item -LiteralPath $outside -Recurse -Force} + if($shape-ceq'UnsafeAncestry'-and$null-ne$unsafeOutside-and(Test-Path -LiteralPath $unsafeOutside)){Remove-Item -LiteralPath $unsafeOutside -Recurse -Force;$unsafeOutside=$null;$unsafeLink=$null} + } + } + }finally{ + if($null-ne$unsafeLink-and(Test-Path -LiteralPath $unsafeLink)){[IO.Directory]::Delete($unsafeLink)} + if($null-ne$unsafeOutside-and(Test-Path -LiteralPath $unsafeOutside)){Remove-Item -LiteralPath $unsafeOutside -Recurse -Force} + Remove-CcodTask5ExactAssetFixture $fixture + } +} + +Invoke-CcodTest 'TrayHost artifact validation requires the exact compiled source name and hash set' { + Import-Module (Join-Path $repositoryRoot 'build\TrayHostBuild.psm1') -Force + $artifact = Join-Path ([IO.Path]::GetTempPath()) ('ccod-trayhost-provenance-fixture-' + [guid]::NewGuid().ToString('N')) + try { + $commit = 'c' * 40 + Invoke-CcodTrayHostBuild -RepositoryRoot $repositoryRoot -Version '2.5.22' -OutputDirectory $artifact -GitCommit $commit -BuildTimestampUtc '2026-08-28T00:00:00.0000000Z' | Out-Null + $provenancePath = Join-Path $artifact 'trayhost-build-provenance.json' + $baselineJson = [IO.File]::ReadAllText($provenancePath,[Text.UTF8Encoding]::new($false)) + $baseline = $baselineJson | ConvertFrom-Json + $sourceRecords = @($baseline.sourceFiles) + Assert-CcodTrue (@($sourceRecords.name) -ccontains 'TrayHostChildSession.cs') 'release provenance fixture includes the shared production child session' + Assert-CcodTrue (@($sourceRecords.name) -ccontains 'WindowsTrayHostRuntime.cs') 'release provenance fixture includes the production Windows runtime adapter' + Test-CcodTrayHostArtifact -RepositoryRoot $repositoryRoot -Version '2.5.22' -ArtifactDirectory $artifact -ExpectedGitCommit $commit | Out-Null + $mutations = @( + [pscustomobject]@{ Name = 'missing shared child session'; Apply = { param($record) $record.sourceFiles = @($record.sourceFiles | Where-Object { $_.name -cne 'TrayHostChildSession.cs' }) } }, + [pscustomobject]@{ Name = 'duplicate shared child session'; Apply = { param($record) $child = @($record.sourceFiles | Where-Object { $_.name -ceq 'TrayHostChildSession.cs' })[0]; $record.sourceFiles = @($record.sourceFiles | Where-Object { $_.name -cne 'WindowsTrayHostRuntime.cs' }) + @([pscustomobject]@{ name = $child.name; sha256 = $child.sha256 }) } }, + [pscustomobject]@{ Name = 'different source name set'; Apply = { param($record) $child = @($record.sourceFiles | Where-Object { $_.name -ceq 'TrayHostChildSession.cs' })[0]; $child.name = 'TrayHostChildSession-copy.cs' } }, + [pscustomobject]@{ Name = 'Windows runtime source hash mismatch'; Apply = { param($record) $runtime = @($record.sourceFiles | Where-Object { $_.name -ceq 'WindowsTrayHostRuntime.cs' })[0]; $runtime.sha256 = '0' * 64 } } + ) + foreach ($mutationCase in $mutations) { + $mutated = $baselineJson | ConvertFrom-Json + $applyMutation = [scriptblock]$mutationCase.Apply + & $applyMutation $mutated + [IO.File]::WriteAllText($provenancePath, ($mutated | ConvertTo-Json -Depth 8), [Text.UTF8Encoding]::new($false)) + Assert-CcodThrows { + Test-CcodTrayHostArtifact -RepositoryRoot $repositoryRoot -Version '2.5.22' -ArtifactDirectory $artifact -ExpectedGitCommit $commit | Out-Null + } 'CCOD_TRAYHOST_SOURCE_TAMPERED' + } + } finally { + if (Test-Path -LiteralPath $artifact) { Remove-Item -LiteralPath $artifact -Recurse -Force -ErrorAction SilentlyContinue } + } +} + +Invoke-CcodTest 'CI and release build jobs uniquely gate asset production on the authenticated TrayHost trace' { + Assert-CcodAuthenticatedTraceWorkflowContract ` + -CiPath (Join-Path $repositoryRoot '.github\workflows\ci.yml') ` + -ReleasePath (Join-Path $repositoryRoot '.github\workflows\release.yml') +} + +Invoke-CcodTest 'authenticated trace workflow gate rejects bypass modifiers comments wrong jobs duplicates and post-build placement' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-trace-workflow-fixtures-' + [guid]::NewGuid().ToString('N')) + try { + [IO.Directory]::CreateDirectory($root) | Out-Null + $ciPath = Join-Path $root 'ci.yml' + $releasePath = Join-Path $root 'release.yml' + $validCi = @' +name: fixture CI +jobs: + validate: + steps: + - name: Run authenticated TrayHost production trace + shell: pwsh + run: ./tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly +'@ + $validRelease = @' +name: fixture release +jobs: + build: + steps: + - name: Run authenticated TrayHost production trace + shell: pwsh + run: ./tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly + - name: Build assets + shell: pwsh + run: | + $version = '2.5.21' + ./build/build.ps1 -Version $version + publish: + steps: + - name: Publish fixture + shell: pwsh + run: Write-Output publish +'@ + [IO.File]::WriteAllText($ciPath, $validCi, [Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($releasePath, $validRelease, [Text.UTF8Encoding]::new($false)) + Assert-CcodAuthenticatedTraceWorkflowContract -CiPath $ciPath -ReleasePath $releasePath + $emptyModifierCi = @' +name: fixture CI +jobs: + validate: + steps: + - name: Run authenticated TrayHost production trace + if: + continue-on-error: + shell: pwsh + run: ./tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly +'@ + [IO.File]::WriteAllText($ciPath, $emptyModifierCi, [Text.UTF8Encoding]::new($false)) + Assert-CcodAuthenticatedTraceWorkflowContract -CiPath $ciPath -ReleasePath $releasePath + $invalidFixtures = @( + [pscustomobject]@{ Name = 'conditional CI trace step'; Ci = @' +name: fixture CI +jobs: + validate: + steps: + - name: Run authenticated TrayHost production trace + if: ${{ false }} + shell: pwsh + run: ./tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly +'@; Release = $validRelease }, + [pscustomobject]@{ Name = 'continue-on-error release trace step'; Ci = $validCi; Release = @' +name: fixture release +jobs: + build: + steps: + - name: Run authenticated TrayHost production trace + continue-on-error: true + shell: pwsh + run: ./tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly + - name: Build assets + shell: pwsh + run: ./build/build.ps1 -Version 2.5.21 +'@ }, + [pscustomobject]@{ Name = 'comment-only CI decoy'; Ci = @' +name: fixture CI +jobs: + validate: + steps: + # - name: Run authenticated TrayHost production trace + # shell: pwsh + # run: ./tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly + - name: Validate + shell: pwsh + run: Write-Output validate +'@; Release = $validRelease }, + [pscustomobject]@{ Name = 'release publish-job decoy'; Ci = $validCi; Release = @' +name: fixture release +jobs: + build: + steps: + - name: Build assets + shell: pwsh + run: ./build/build.ps1 -Version 2.5.21 + publish: + steps: + - name: Run authenticated TrayHost production trace + shell: pwsh + run: ./tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly +'@ }, + [pscustomobject]@{ Name = 'post-build release gate'; Ci = $validCi; Release = @' +name: fixture release +jobs: + build: + steps: + - name: Build assets + shell: pwsh + run: ./build/build.ps1 -Version 2.5.21 + - name: Run authenticated TrayHost production trace + shell: pwsh + run: ./tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly +'@ }, + [pscustomobject]@{ Name = 'anonymous pre-build release step'; Ci = $validCi; Release = @' +name: fixture release +jobs: + build: + steps: + - run: ./build/build.ps1 -Version 2.5.21 + - name: Run authenticated TrayHost production trace + shell: pwsh + run: ./tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly +'@ }, + [pscustomobject]@{ Name = 'duplicate CI gate'; Ci = $validCi + "`n" + @' + - name: Run authenticated TrayHost production trace + shell: pwsh + run: ./tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly +'@; Release = $validRelease }, + [pscustomobject]@{ Name = 'different-name duplicate CI trace invocation'; Ci = $validCi + "`n" + @' + - name: Trace alias + shell: pwsh + run: ./tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly +'@; Release = $validRelease } + ) + foreach ($fixture in $invalidFixtures) { + [IO.File]::WriteAllText($ciPath, [string]$fixture.Ci, [Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($releasePath, [string]$fixture.Release, [Text.UTF8Encoding]::new($false)) + Assert-CcodThrows { + Assert-CcodAuthenticatedTraceWorkflowContract -CiPath $ciPath -ReleasePath $releasePath + } 'CCOD_RELEASE_TRACE_GATE_INVALID' + } + } finally { + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } + } +} + +Invoke-CcodTest 'package scripts build provenance and workflows retain the release-contract gates' { + $package = Get-Content -LiteralPath (Join-Path $repositoryRoot 'package.json') -Raw | ConvertFrom-Json + Assert-CcodTrue ($null -ne $package.scripts.'test:installed-lifecycle') 'package exposes installed-lifecycle deterministic tests' + Assert-CcodTrue ($null -ne $package.scripts.'test:release-contract') 'package exposes release contract tests' + $build = Get-Content -LiteralPath (Join-Path $repositoryRoot 'build\build.ps1') -Raw + Assert-CcodTrue ($build -match '\$releaseManifest\s*=\s*Join-Path\s+\$dist\s+\$releaseAssetNames\[4\]') 'build emits the central-contract portable release manifest' + Assert-CcodTrue ($build -match 'gitCommit') 'build records the source commit' + Assert-CcodTrue ($build -match 'buildTimestampUtc') 'build records a canonical build timestamp' + Assert-CcodTrue ($build -match 'does not match package\.json version') 'build refuses a requested version that differs from package metadata' + Assert-CcodTrue ($build -match 'status --porcelain --untracked-files=all') 'build refuses a dirty candidate checkout' + Assert-CcodTrue ($build -match 'Test-CcodReleaseAssetManifest') 'build verifies its own immutable release manifest before success' + $trayHost = Get-Content -LiteralPath (Join-Path $repositoryRoot 'build\TrayHostBuild.psm1') -Raw + Assert-CcodTrue ($trayHost -match 'gitCommit') 'TrayHost provenance binds its source commit' + Assert-CcodTrue ($trayHost -match 'buildTimestampUtc') 'TrayHost provenance binds its build timestamp' + $ci = Get-Content -LiteralPath (Join-Path $repositoryRoot '.github\workflows\ci.yml') -Raw + Assert-CcodTrue ($ci -match 'UninstallBootstrap\.SelfTest\.ps1') 'CI runs the external uninstall bootstrap self-test before aggregate validation' + Assert-CcodTrue ($ci -match 'test:installed-lifecycle') 'CI runs deterministic installed lifecycle tests' + Assert-CcodTrue ($ci -match 'test:release-contract') 'CI runs release contract tests' + $release = Get-Content -LiteralPath (Join-Path $repositoryRoot '.github\workflows\release.yml') -Raw + Assert-CcodTrue ($release -cmatch '(?ms)^\s*workflow_dispatch:\r?\n\s+inputs:\r?\n\s+tag:') 'manual release dispatch requires an explicit tag input' + Assert-CcodTrue ($release -match 'CCOD_RELEASE_TAG') 'release jobs derive their version from the validated release tag' + Assert-CcodTrue ($release -match 'build\.ps1 -Version') 'release build binds the candidate version to the validated tag' + Assert-CcodTrue ($release -match 'download-artifact') 'release promotion downloads a previously built candidate' + Assert-CcodTrue ($release -match 'test:release-contract') 'release promotion checks the release contract' + Assert-CcodTrue ($release -match 'release-manifest') 'release promotion uploads the bound release manifest' + Assert-CcodTrue ($release -match 'New-GitHubReleaseNotes\.ps1') 'release publication uses the behavior-tested English notes extractor' + Assert-CcodTrue ((Get-Content -LiteralPath (Join-Path $repositoryRoot 'tools\Invoke-GitHubDraftRelease.ps1') -Raw) -match 'gh release download') 'draft promoter reads back staged assets through gh adapters' + Assert-CcodTrue (-not ($release -match 'gh release upload[^\r\n]*--clobber')) 'release publication never overwrites an existing asset' + Assert-CcodTrue ($release -match 'Invoke-CcodGitHubDraftRelease -Mode Verify') 'release publication re-downloads every uploaded asset for hash read-back' + Assert-CcodTrue ($release -cmatch '(?ms)^permissions:\r?\n\s+contents: read\s*$') 'candidate build starts with read-only repository permission' + Assert-CcodTrue ($release -cmatch '(?ms)^ stage:\r?\n needs: build\r?\n runs-on: windows-latest\r?\n permissions:\r?\n contents: write\s*$') 'only the stage job receives release-write permission' +} + +$iss = Get-Content -LiteralPath (Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss') -Raw +$buildSource = Get-Content -LiteralPath (Join-Path $repositoryRoot 'build\build.ps1') -Raw +Assert-CcodTrue ($buildSource -match 'CodexRemote\.Portable\.exe' -and $buildSource -match 'Copy-CcodBuildPayloadFile' -and $buildSource -match 'New-CcodInstallerPackage') 'build places the portable launcher only in the manifest-listed sealed package' +Assert-CcodTrue ($buildSource -cnotmatch '\.GetNewClosure\(\)') 'the temporary Setup action stays a plain scriptblock so script-local functions stay resolvable' +Assert-CcodTrue ($iss -cnotmatch 'CodexRemote\.Portable\.exe|PortableArtifactDirectory') 'Inno never copies a product launcher outside the sealed package transaction' +Invoke-CcodTest 'temporary setup scope resolves script-local functions under PowerShell 7' { + # The release workflow runs build/build.ps1 from pwsh. PowerShell 7 gives a + # GetNewClosure() scriptblock a fresh dynamic module that cannot resolve the + # script's own functions, while Windows PowerShell 5.1 resolves them. Exercise + # the real action through pwsh so the defect cannot return unnoticed. + $pwsh = (Get-Command pwsh.exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1) + if ($null -eq $pwsh) { return } + $buildPath = Join-Path $repositoryRoot 'build\build.ps1' + $probe = @' +$ErrorActionPreference = 'Stop' +$build = $env:CCOD_BUILD_PROBE_PATH +. $build -Library +$root = $env:CCOD_BUILD_PROBE_ROOT +$id = [guid]::NewGuid().ToString('N') +$payloadStage = Join-Path $root ('.installer-payload-stage-' + $id) +$inventory = Join-Path $root ('.installer-destination-inventory-' + $id + '.iss') +[IO.Directory]::CreateDirectory($payloadStage) | Out-Null +$result = Invoke-CcodBuildTemporarySetupScope -BuildRoot $root -InstallerPayloadDirectory $payloadStage -DestinationInventoryPath $inventory -Action ({ ,([string](Get-Command Copy-CcodBuildPayloadFile -ErrorAction SilentlyContinue).Name) }) +if ([string]::IsNullOrWhiteSpace([string]$result)) { [Console]::Error.WriteLine('CCOD_BUILD_ACTION_UNRESOLVED'); exit 41 } +if ([IO.Directory]::Exists($payloadStage)) { [Console]::Error.WriteLine('CCOD_BUILD_ACTION_LEAK'); exit 42 } +[Console]::WriteLine('CCOD_BUILD_ACTION_OK=' + $result) +'@ + $probeRoot = Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-action-probe-' + [guid]::NewGuid().ToString('N')) + $probePath = Join-Path $probeRoot 'action-probe.ps1' + try { + [IO.Directory]::CreateDirectory($probeRoot) | Out-Null + [IO.File]::WriteAllText($probePath,$probe,[Text.UTF8Encoding]::new($false)) + $start = [Diagnostics.ProcessStartInfo]::new() + $start.FileName = $pwsh.Source + $start.Arguments = '-NoLogo -NoProfile -NonInteractive -File "' + $probePath + '"' + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + $start.StandardOutputEncoding = [Text.UTF8Encoding]::new($false) + $start.StandardErrorEncoding = [Text.UTF8Encoding]::new($false) + $start.EnvironmentVariables['CCOD_BUILD_PROBE_PATH'] = $buildPath + $start.EnvironmentVariables['CCOD_BUILD_PROBE_ROOT'] = $probeRoot + $child = [Diagnostics.Process]::Start($start) + try { + $stdout = $child.StandardOutput.ReadToEndAsync() + $stderr = $child.StandardError.ReadToEndAsync() + if (-not $child.WaitForExit(120000)) { throw 'pwsh action probe did not complete' } + Assert-CcodEqual 0 $child.ExitCode ('pwsh action probe failed: ' + $stderr.Result.Replace("`r",' ').Replace("`n",' | ')) + Assert-CcodTrue ($stdout.Result -match 'CCOD_BUILD_ACTION_OK=Copy-CcodBuildPayloadFile') 'pwsh resolves a script-local build function from the temporary setup action' + } finally { + if (-not $child.HasExited) { $child.Kill(); $child.WaitForExit() } + $child.Dispose() + } + } finally { + if ([IO.File]::Exists($probePath)) { [IO.File]::Delete($probePath) } + if ([IO.Directory]::Exists($probeRoot)) { Remove-CcodTestOwnedTree -Path $probeRoot } + } +} + +Invoke-CcodTest 'release notes extraction emits only the target release English section' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-release-notes-fixture-' + [guid]::NewGuid().ToString('N')) + try { + [IO.Directory]::CreateDirectory($root) | Out-Null + $changelogPath = Join-Path $root 'CHANGELOG.md' + $notesPath = Join-Path $root 'notes.md' + $fixture = @' +# Fixture release notes + +## Unreleased + +### English + +- UNRELEASED_DECOY + +## v2.5.23 + +### English + +- NEWER_DECOY + +## v2.5.22 + +### English + +- Target English note. +- Second target English note. + +### 简体中文 + +- CHINESE_DECOY + +## v2.5.21 + +### English + +- OLDER_DECOY +'@ + $fixtureCrlf = $fixture.Replace("`r`n","`n").Replace("`r","`n").Replace("`n","`r`n") + [IO.File]::WriteAllText($changelogPath,$fixtureCrlf,[Text.UTF8Encoding]::new($false)) + & (Join-Path $repositoryRoot 'tools\New-GitHubReleaseNotes.ps1') -ChangelogPath $changelogPath -Tag 'v2.5.22' -OutputPath $notesPath | Out-Null + $actual = [IO.File]::ReadAllText($notesPath,[Text.UTF8Encoding]::new($false)) + Assert-CcodEqual "- Target English note.`n- Second target English note.`n" $actual 'release body contains exactly the current target English notes' + foreach ($decoy in @('UNRELEASED_DECOY','NEWER_DECOY','CHINESE_DECOY','OLDER_DECOY')) { + Assert-CcodTrue (-not $actual.Contains($decoy)) "release body excludes $decoy" + } + } finally { + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } + } +} + +Invoke-CcodTest 'release build validates the portable launcher artifact before payload copy' { + $build = Get-Content -LiteralPath (Join-Path $repositoryRoot 'build\build.ps1') -Raw + Assert-CcodTrue ($build -cmatch '(?s)Invoke-CcodPortableLauncherBuild.*?Test-CcodPortableLauncherArtifact.*?foreach \(\$portableFile in') 'portable build validates the generated artifact before copying it into the payload' +} + +Invoke-CcodTask5Test 'docs' 'candidate documentation and build consume the exact non-receipt eleven-asset contract' { + $expected=Get-CcodTask5ExpectedAssetNames '2.5.22' + $readme=[IO.File]::ReadAllText((Join-Path $repositoryRoot 'README.md'),[Text.UTF8Encoding]::new($false,$true)) + $readmeZh=[IO.File]::ReadAllText((Join-Path $repositoryRoot 'README.zh-CN.md'),[Text.UTF8Encoding]::new($false,$true)) + Assert-CcodTrue (-not($readme-cmatch'(?i)always published|every tagged release ships|verified and available')) 'English README makes no unconditional publication or verification claim' + Assert-CcodTrue (-not($readmeZh-cmatch'\u5DF2\u9A8C\u8BC1|\u5747\u53EF\u7528|\u59CB\u7EC8\u53D1\u5E03')) 'Chinese README makes no prior verified or unconditional publication claim' + $englishRelease=[regex]::Match($readme,'(?ms)^## Releases\s*\r?\n(?.*?)(?=^## |\z)').Groups['body'].Value + $chineseRelease=[regex]::Match($readmeZh,'(?ms)^## \u53D1\u5E03\uFF08Releases\uFF09\s*\r?\n(?.*?)(?=^## |\z)').Groups['body'].Value + $englishContract=[regex]::Match($englishRelease,'(?ms)
\s*Exact 11-file candidate asset contract(?.*?)
') + $chineseContract=[regex]::Match($chineseRelease,'(?ms)
\s*\u7CBE\u786E\u7684 11 \u6587\u4EF6\u5019\u9009\u8D44\u4EA7\u5951\u7EA6(?.*?)
') + Assert-CcodTrue ($englishContract.Success-and$chineseContract.Success) 'both README asset lists are collapsed by default in an exact details block' + foreach($name in $expected){$literal='`'+$name+'`';Assert-CcodEqual 1 ([regex]::Matches($englishContract.Groups['body'].Value,[regex]::Escape($literal)).Count) "English candidate list names $name exactly once";Assert-CcodEqual 1 ([regex]::Matches($chineseContract.Groups['body'].Value,[regex]::Escape($literal)).Count) "Chinese candidate list names $name exactly once"} + Assert-CcodTrue ($englishRelease-cmatch'(?i)candidate'-and$chineseRelease-cmatch'\u5019\u9009') 'both release sections scope the eleven files to the v2.5.22 candidate' + Assert-CcodTrue ($englishRelease-cnotmatch'(?i)defender[^\r\n]*\.json'-and$chineseRelease-cnotmatch'(?i)defender[^\r\n]*\.json') 'Defender receipts are not listed as public assets' + $build=[IO.File]::ReadAllText((Join-Path $repositoryRoot 'build\build.ps1'),[Text.UTF8Encoding]::new($false,$true)) + Assert-CcodTrue ($build-cmatch'ReleaseAssetContract\.psm1'-and$build-cmatch'Get-CcodExpectedReleaseAssetNames') 'build consumes the central release asset contract' + foreach($suffix in @('windows-x64.zip','windows-x64.zip.sha256.txt','trayhost-provenance.json','payload-manifest.json','release-manifest.json','setup.exe','setup.exe.sha256.txt','setup-provenance.json','setup-payload-manifest.json','setup-destination-inventory.iss','setup-release-manifest.json')){Assert-CcodTrue ($build-cnotmatch[regex]::Escape('CodexRemote-fix-$Version-'+$suffix)) "build no longer reconstructs $suffix outside the central contract"} + $changelog=[IO.File]::ReadAllText((Join-Path $repositoryRoot 'CHANGELOG.md'),[Text.UTF8Encoding]::new($false,$true));$section=[regex]::Match($changelog,'(?ms)^## v2\.5\.22\s*\r?\n(?.*?)(?=^## |\z)').Groups['body'].Value + Assert-CcodTrue ($section-cmatch'(?i)candidate.*two distinct.*InternetDownload.*Defender receipts'-and$section-cmatch'\u5019\u9009.*InternetDownload.*Defender') 'changelog describes only the enforced candidate evidence contract' +} + +Invoke-CcodTest '2.5.22 source metadata and documentation match the release contract' { + $package = Get-Content -LiteralPath (Join-Path $repositoryRoot 'package.json') -Raw | ConvertFrom-Json + Assert-CcodEqual '2.5.22' ([string]$package.version) 'package metadata is the 2.5.22 release' + foreach ($nativeComponent in @('trayhost','portable')) { + $assemblyInfo = Get-Content -LiteralPath (Join-Path $repositoryRoot ("src\{0}\AssemblyInfo.cs" -f $nativeComponent)) -Raw + Assert-CcodTrue ($assemblyInfo -cmatch 'AssemblyVersion\("2\.5\.22\.0"\)') "$nativeComponent assembly version is 2.5.22.0" + Assert-CcodTrue ($assemblyInfo -cmatch 'AssemblyFileVersion\("2\.5\.22\.0"\)') "$nativeComponent file version is 2.5.22.0" + } + $trayHostManifest = Get-Content -LiteralPath (Join-Path $repositoryRoot 'src\trayhost\CodexRemote.TrayHost.manifest') -Raw + Assert-CcodTrue ($trayHostManifest -cmatch '.*?)(?=^## |\z)') + Assert-CcodTrue $releaseSection.Success 'v2.5.22 release section exists' + Assert-CcodTrue ($releaseSection.Groups['body'].Value -match '(?m)^### English\s*$') 'v2.5.22 changelog has concise English release notes' + $englishSection = [regex]::Match($releaseSection.Groups['body'].Value,'(?ms)^### English\s*\r?\n(?.*?)(?=^### |\z)') + $englishBullets = @($englishSection.Groups['body'].Value -split '\r?\n' | Where-Object { $_ -cmatch '^- ' }) + Assert-CcodTrue ($englishSection.Success -and $englishBullets.Count -ge 3 -and $englishBullets.Count -le 5) 'v2.5.22 English release notes contain three to five bullets' + $readme = Get-Content -LiteralPath (Join-Path $repositoryRoot 'README.md') -Raw + $quickStart = [regex]::Match($readme, '(?ms)^## Quick start\s*\r?\n(?.*?)(?=^## |\z)').Groups['body'].Value + Assert-CcodTrue ($readme.Contains('v2.5.22 is the current release candidate') -and $readme.Contains('stable Windows acceptance still requires recorded install, upgrade, reboot, repair, UI, and Defender verification')) 'English README does not claim stable Windows acceptance before real-machine evidence' + Assert-CcodTrue (-not $readme.Contains("## What's new")) 'English README keeps release details off the home page' + Assert-CcodTrue ($quickStart.Contains('CodexRemote-fix-2.5.22-setup.exe')) 'English Quick Start names the setup installer' + Assert-CcodTrue ($quickStart.Contains('CodexRemote-fix-2.5.22-windows-x64.zip')) 'English Quick Start names the portable ZIP' + Assert-CcodTrue ($quickStart.Contains('CodexRemote-fix.exe')) 'English Quick Start names the portable double-click entrypoint' + Assert-CcodTrue ($quickStart.Contains('Microsoft Defender')) 'English Quick Start documents the local Defender gate' + Assert-CcodTrue (-not $readme.Contains('\r\n')) 'English README contains no literal CRLF escape text' + Assert-CcodTrue ($readme.Contains('The portable distribution publishes its payload manifest as an asset')) 'English README identifies the externally published portable payload manifest' + Assert-CcodTrue ($readme.Contains('Setup embeds and hash-binds its versioned `installer-payload.manifest.json`')) 'English README identifies the embedded hash-bound setup payload manifest' + Assert-CcodTrue (-not $readme.Contains('shared payload manifest')) 'English README does not claim setup and portable share one payload manifest' + $readmeZh = Get-Content -LiteralPath (Join-Path $repositoryRoot 'README.zh-CN.md') -Raw -Encoding UTF8 + Assert-CcodTrue ($readmeZh -match 'v2\.5\.22 \u662F\u5F53\u524D\u5019\u9009\u53D1\u5E03\u7248' -and $readmeZh -match '\u7A33\u5B9A\u7248\u9A8C\u6536\u4ECD\u9700\u8BB0\u5F55') 'Chinese README does not claim stable Windows acceptance before real-machine evidence' + Assert-CcodTrue (-not $readmeZh.Contains("## What's new")) 'Chinese README keeps release details off the home page' + Assert-CcodTrue ($readmeZh.Contains('CodexRemote-fix-2.5.22-setup.exe')) 'Chinese Quick Start names the setup installer' + Assert-CcodTrue ($readmeZh.Contains('CodexRemote-fix-2.5.22-windows-x64.zip')) 'Chinese Quick Start names the portable ZIP' + Assert-CcodTrue ($readmeZh.Contains('CodexRemote-fix.exe')) 'Chinese Quick Start names the portable double-click entrypoint' + Assert-CcodTrue ($readmeZh.Contains('Microsoft Defender')) 'Chinese Quick Start documents the local Defender gate' + Assert-CcodTrue ($readmeZh -match '\u4FBF\u643A\u53D1\u884C\u7248\u4F1A\u5916\u53D1\u81EA\u5DF1\u7684 payload manifest') 'Chinese README identifies the externally published portable payload manifest' + Assert-CcodTrue ($readmeZh -match 'Setup \u5219\u5185\u5D4C\u5E76\u4EE5\u54C8\u5E0C\u7ED1\u5B9A\u7248\u672C\u5316\u7684 `installer-payload\.manifest\.json`') 'Chinese README identifies the embedded hash-bound setup payload manifest' + $technical = Get-Content -LiteralPath (Join-Path $repositoryRoot 'docs\TECHNICAL.md') -Raw + Assert-CcodTrue ($technical.Contains('PortableUninstallFinalizer.ps1')) 'technical documentation records the staged portable finalizer' + $security = Get-Content -LiteralPath (Join-Path $repositoryRoot 'SECURITY.md') -Raw + Assert-CcodTrue ($security.Contains('does not disable Defender or add exclusions')) 'security documentation forbids Defender weakening' +<# + $package = Get-Content -LiteralPath (Join-Path $repositoryRoot 'package.json') -Raw | ConvertFrom-Json + Assert-CcodEqual '2.5.5' ([string]$package.version) 'package metadata is the 2.5.5 release' + + $changelog = Get-Content -LiteralPath (Join-Path $repositoryRoot 'CHANGELOG.md') -Raw + $releaseSection = [regex]::Match($changelog, '(?ms)^## v2\.5\.5\s*\r?\n(?.*?)(?=^## |\z)') + Assert-CcodTrue $releaseSection.Success 'v2.5.5 release section exists' + $releaseBody = $releaseSection.Groups['body'].Value + Assert-CcodTrue ($releaseBody -match '(?m)^### English\s*$') 'v2.5.5 release section is English' + Assert-CcodTrue ($releaseBody.Contains('Added a tightly scoped compatibility inspection for an older manifest-sealed controller that omitted its `ProcessControl` import. It accepts only the exact correlated legacy failure and requires a manifest-verified read-only ordinary-session recheck immediately before each protected uninstall deletion boundary.')) 'v2.5.5 legacy controller compatibility release bullet is exact' + Assert-CcodTrue ($releaseBody.Contains('New `SessionController` runtimes now load `ProcessControl` globally, and regression coverage rejects every other controller failure or changed compatibility proof.')) 'v2.5.5 regression release bullet is exact' + + $readme = Get-Content -LiteralPath (Join-Path $repositoryRoot 'README.md') -Raw + $quickStart = [regex]::Match($readme, '(?ms)^## Quick start\s*\r?\n(?.*?)(?=^## |\z)').Groups['body'].Value + Assert-CcodTrue ($quickStart.Contains('CodexRemote-fix-2.5.5-setup.exe')) 'English Quick Start names the 2.5.5 installer' + Assert-CcodTrue ($readme.Contains('## Connection and protection status')) 'English README documents the current status contract' + foreach ($state in @('Waiting for Codex', 'Checking', 'Connected', 'Repair needed', 'Error', 'Running', 'Reconnecting', 'Stopping')) { + Assert-CcodTrue ($readme.Contains($state)) "English README documents state: $state" + } + Assert-CcodTrue ($readme.Contains('Restart now')) 'English README explains Restart now' + Assert-CcodTrue ($readme.Contains('Later')) 'English README explains Later' + Assert-CcodTrue ($readme.Contains('Safe Exit')) 'English README explains safe Exit' + Assert-CcodTrue ($readme.Contains('The tray has no uninstall command.')) 'English README excludes tray uninstall' + Assert-CcodTrue ($readme.Contains('Windows Settings')) 'English README documents Windows Settings uninstall' + Assert-CcodTrue ($readme.Contains('unins000.exe')) 'English README documents direct uninstaller' + Assert-CcodTrue (-not $readme.Contains('Automation, Candidate-compatible trial, Logs, and Uninstall')) 'English README no longer describes removed tray toggles' + + $readmeZh = Get-Content -LiteralPath (Join-Path $repositoryRoot 'README.zh-CN.md') -Raw -Encoding UTF8 + $quickStartZh = [regex]::Match($readmeZh, '(?ms)^## \u5FEB\u901F\u5F00\u59CB\s*\r?\n(?.*?)(?=^## |\z)').Groups['body'].Value + Assert-CcodTrue ($quickStartZh.Contains('CodexRemote-fix-2.5.5-setup.exe')) 'Chinese Quick Start names the 2.5.5 installer' + Assert-CcodTrue ($readmeZh -match '## \u8FDE\u63A5\u4E0E\u5B88\u62A4\u72B6\u6001') 'Chinese README documents the current status contract' + foreach ($statePattern in @('\u7B49\u5F85 Codex', '\u6B63\u5728\u68C0\u67E5', '\u5DF2\u8FDE\u63A5', '\u9700\u8981\u4FEE\u590D', '\u9519\u8BEF', '\u8FD0\u884C\u4E2D', '\u6B63\u5728\u91CD\u8FDE', '\u6B63\u5728\u505C\u6B62')) { + Assert-CcodTrue ($readmeZh -match $statePattern) "Chinese README documents state pattern: $statePattern" + } + Assert-CcodTrue ($readmeZh -match '\u7ACB\u5373\u91CD\u542F') 'Chinese README explains Restart now' + Assert-CcodTrue ($readmeZh -match '\u7A0D\u540E') 'Chinese README explains Later' + Assert-CcodTrue ($readmeZh -match '\u5B89\u5168\u9000\u51FA') 'Chinese README explains safe Exit' + Assert-CcodTrue ($readmeZh -match '\u6258\u76D8\u4E2D\u6CA1\u6709\u5378\u8F7D\u547D\u4EE4\u3002') 'Chinese README excludes tray uninstall' + Assert-CcodTrue ($readmeZh -match 'Windows \u8BBE\u7F6E') 'Chinese README documents Windows Settings uninstall' + Assert-CcodTrue ($readmeZh.Contains('unins000.exe')) 'Chinese README documents direct uninstaller' + Assert-CcodTrue (-not ($readmeZh -match '\u7ACB\u5373\u5E94\u7528\u3001\u91CD\u8BD5\u3001\u81EA\u52A8\u5316\u5F00\u5173\u3001\u517C\u5BB9\u66F4\u65B0\u8BD5\u7528\u3001\u65E5\u5FD7\u3001\u5378\u8F7D')) 'Chinese README no longer describes removed tray toggles' + + $technical = Get-Content -LiteralPath (Join-Path $repositoryRoot 'docs\TECHNICAL.md') -Raw + foreach ($term in @('lifecycle epoch/generation fence', 'trusted LUID marker', 'protocol v2', 'external uninstaller receipt', 'remote-control-device-keys.windows.json', 'The tray has no uninstall command.')) { + Assert-CcodTrue ($technical.Contains($term)) "technical documentation records: $term" + } + foreach ($obsolete in @('Menu.Uninstall', 'UninstallEnabled', 'AutomationToggleEnabled', 'CandidateOptInToggleEnabled', 'BackupDeviceKeyStore', 'RemoveDeviceKeyStore')) { + Assert-CcodTrue (-not $technical.Contains($obsolete)) "technical documentation excludes obsolete contract: $obsolete" + } + + $cleanroom = Get-Content -LiteralPath (Join-Path $repositoryRoot 'docs\CLEANROOM.md') -Raw + Assert-CcodTrue ($cleanroom.Contains('protocol v2')) 'clean-room boundary records the current tray protocol' + + $security = Get-Content -LiteralPath (Join-Path $repositoryRoot 'SECURITY.md') -Raw + Assert-CcodTrue ($security.Contains('remote-control-device-keys.windows.json')) 'security documentation retains the unchanged DPAPI key location' + Assert-CcodTrue ($security.Contains('Windows Settings')) 'security documentation identifies the external uninstall route' + Assert-CcodTrue ($security.Contains('unins000.exe')) 'security documentation identifies the direct uninstaller route' + Assert-CcodTrue (-not $security.Contains('-BackupDeviceKeyStore')) 'security documentation excludes legacy key backup switch' + Assert-CcodTrue (-not $security.Contains('-RemoveDeviceKeyStore')) 'security documentation excludes legacy key removal switch' +#> +} + +Invoke-CcodTest 'portable release manifest binds the ZIP payload manifest and each archived payload file' { + Import-Module $assetContractPath -Force -DisableNameChecking + $fixture = New-CcodPortableReleaseFixture + try { + $validated = Test-CcodReleaseAssetManifest -ManifestPath $fixture.Manifest -AssetDirectory $fixture.Root -ExpectedVersion '2.5.6' + Assert-CcodEqual $true ([bool]$validated.Valid) 'valid portable fixture passes the release manifest contract' + Assert-CcodEqual 'portable-zip' ([string]$validated.Distribution) 'validator reports the portable release distribution' + $payload = Get-Content -LiteralPath $fixture.PayloadManifest -Raw | ConvertFrom-Json + $payload.files[0].sha256 = '0' * 64 + [IO.File]::WriteAllText($fixture.PayloadManifest,($payload | ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + $release = Get-Content -LiteralPath $fixture.Manifest -Raw | ConvertFrom-Json + $asset = @($release.assets | Where-Object { $_.name -ceq [IO.Path]::GetFileName($fixture.PayloadManifest) })[0] + $asset.sha256 = Get-CcodTestFileSha256 -Path $fixture.PayloadManifest + [IO.File]::WriteAllText($fixture.Manifest,($release | ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + Assert-CcodThrows { + Test-CcodReleaseAssetManifest -ManifestPath $fixture.Manifest -AssetDirectory $fixture.Root -ExpectedVersion '2.5.6' + } 'CCOD_RELEASE_ASSET_HASH_MISMATCH' + } finally { + if (Test-Path -LiteralPath $fixture.Root) { Remove-Item -LiteralPath $fixture.Root -Recurse -Force } + } +} + +# Production mutation caught: portable validates a payload identity but omits or changes it at the lifecycle child boundary. +Invoke-CcodTest 'portable installer propagates only the exact revalidated sealed identity to its lifecycle child' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-portable-sealed-identity-' + [guid]::NewGuid().ToString('N')) + $module = $null + try { + [IO.Directory]::CreateDirectory($root) | Out-Null + $scriptPath = Join-Path $repositoryRoot 'Install-CodexRemote-fix.ps1' + $tokens=$null;$parseErrors=$null + $ast=[Management.Automation.Language.Parser]::ParseFile($scriptPath,[ref]$tokens,[ref]$parseErrors) + Assert-CcodEqual 0 @($parseErrors).Count 'portable installer parses before production function extraction' + $definitions=@($ast.EndBlock.Statements|Where-Object{$_-is[Management.Automation.Language.FunctionDefinitionAst]}|ForEach-Object{$_.Extent.Text}) + $libraryPath=Join-Path $root 'PortableInstallerFunctions.psm1' + [IO.File]::WriteAllText($libraryPath,(($definitions-join"`r`n`r`n")+"`r`n"),[Text.UTF8Encoding]::new($false)) + $module=Import-Module $libraryPath -Force -PassThru + $marker=Join-Path $root 'child-identity.txt' + $child=Join-Path $root 'Install-CodexControlOtherDevices.ps1' + $childSource=@" +param([string]`$InstallRoot,[switch]`$EnableCandidateCompatibleUpdates,[switch]`$DoNotStart,[string]`$SealedPackageSha256) +[IO.File]::WriteAllText('$($marker.Replace("'","''"))',[string]`$SealedPackageSha256,[Text.UTF8Encoding]::new(`$false)) +[pscustomobject]@{Outcome='Installed';RuntimeId='runtime-fixture'} +"@ + [IO.File]::WriteAllText($child,$childSource,[Text.UTF8Encoding]::new($false)) + $verified='a'*64 + $receipt=&$module {param($Path,$Root,$Identity)Invoke-CcodPortableLifecycleInstaller -InstallerPath $Path -InstallRoot $Root -CopiedSealedPackageSha256 $Identity -InitialSealedPackageSha256 $Identity -RevalidatedSourceSealedPackageSha256 $Identity} $child $root $verified + Assert-CcodEqual 'runtime-fixture' ([string]$receipt.RuntimeId) 'portable helper returns the exact child receipt' + Assert-CcodEqual $verified ([IO.File]::ReadAllText($marker,[Text.UTF8Encoding]::new($false))) 'exact verified identity reaches the lifecycle child' + Remove-Item -LiteralPath $marker -Force + Assert-CcodThrows { + &$module {param($Path,$Root,$Copied,$Expected)Invoke-CcodPortableLifecycleInstaller -InstallerPath $Path -InstallRoot $Root -CopiedSealedPackageSha256 $Copied -InitialSealedPackageSha256 $Expected -RevalidatedSourceSealedPackageSha256 $Expected} $child $root ('b'*64) $verified | Out-Null + } 'CCOD_PORTABLE_PACKAGE_IDENTITY_INVALID' + Assert-CcodTrue (-not(Test-Path -LiteralPath $marker)) 'changed identity fails before lifecycle child mutation' + } finally { + if($null-ne$module){Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue} + if(Test-Path -LiteralPath $root){Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue} + } +} + +# Production mutation caught: the real portable entrypoint revalidates source bytes after Defender before copying them. +Invoke-CcodTest 'actual portable entrypoint rejects a post-Defender source mutation before child or lifecycle state' { + $root=Join-Path ([IO.Path]::GetTempPath()) ('ccod-portable-entry-race-'+[guid]::NewGuid().ToString('N')) + try { + $payload=Join-Path $root 'payload';$modules=Join-Path $payload 'src\persistence\modules';[IO.Directory]::CreateDirectory($modules)|Out-Null + $entrypoint=Join-Path $root 'Install-CodexRemote-fix.ps1' + [IO.File]::Copy((Join-Path $repositoryRoot 'Install-CodexRemote-fix.ps1'),$entrypoint,$false) + Assert-CcodEqual (Get-CcodTestFileSha256 -Path (Join-Path $repositoryRoot 'Install-CodexRemote-fix.ps1')) (Get-CcodTestFileSha256 -Path $entrypoint) 'fixture invokes an exact copy of the production portable entrypoint' + [IO.File]::Copy((Join-Path $repositoryRoot 'src\persistence\modules\PortableRelease.psm1'),(Join-Path $modules 'PortableRelease.psm1'),$false) + [IO.File]::WriteAllText((Join-Path $payload 'package.json'),'{"version":"2.5.22"}',[Text.UTF8Encoding]::new($false)) + $childMarker=Join-Path $root 'child-executed.txt';$lifecycleRoot=Join-Path $root 'lifecycle-state';$child=Join-Path $payload 'Install-CodexControlOtherDevices.ps1' + $childSource="param([string]`$InstallRoot,[string]`$SealedPackageSha256);[IO.Directory]::CreateDirectory('$($lifecycleRoot.Replace("'","''"))')|Out-Null;[IO.File]::WriteAllText('$($childMarker.Replace("'","''"))',[string]`$SealedPackageSha256,[Text.UTF8Encoding]::new(`$false));[pscustomobject]@{Outcome='Installed';RuntimeId='should-not-run'}" + [IO.File]::WriteAllText($child,$childSource,[Text.UTF8Encoding]::new($false)) + $records=[Collections.Generic.List[object]]::new() + foreach($file in @(Get-ChildItem -LiteralPath $payload -File -Force -Recurse)){$relative=$file.FullName.Substring($payload.TrimEnd('\').Length+1).Replace('\','/');$records.Add([pscustomobject][ordered]@{path=$relative;length=[int64]$file.Length;sha256=Get-CcodTestFileSha256 -Path $file.FullName})} + $comparison=[System.Comparison[object]]{param($left,$right)[StringComparer]::Ordinal.Compare([string]$left.path,[string]$right.path)};$records.Sort($comparison) + $manifest=[ordered]@{schemaVersion=1;product='CodexRemote-fix';version='2.5.22';gitCommit=('a'*40);buildTimestampUtc='2030-02-03T04:05:06.0000000Z';files=@($records)} + [IO.File]::WriteAllText((Join-Path $root 'payload-manifest.json'),(($manifest|ConvertTo-Json -Depth 8)+[Environment]::NewLine),[Text.UTF8Encoding]::new($false)) + $attack=[pscustomobject]@{Ran=$false} + function Get-MpComputerStatus {[pscustomobject]@{AMServiceEnabled=$true;AntivirusEnabled=$true;RealTimeProtectionEnabled=$true;AMProductVersion='fixture-platform';AMEngineVersion='fixture-engine';AntivirusSignatureVersion='fixture-signature';AntivirusSignatureLastUpdated=[datetime]::UtcNow.AddHours(-1)}} + function Get-MpThreatDetection {param($ErrorAction)@()} + function Start-MpScan {param($ScanType,$ScanPath,$ErrorAction)throw 'fixture portable scan failure'} + $scanFailure=$null + try{&$entrypoint -DoNotStart|Out-Null}catch{$scanFailure=$_} + Assert-CcodTrue ($null-ne$scanFailure-and$scanFailure.FullyQualifiedErrorId-like'CCOD_PORTABLE_DEFENDER_SCAN_FAILED*') 'actual entrypoint preserves the portable scan failure code' + Assert-CcodTrue (-not(Test-Path -LiteralPath $childMarker)-and-not(Test-Path -LiteralPath $lifecycleRoot)) 'portable scan failure creates no child or lifecycle state' + function Start-MpScan {param($ScanType,$ScanPath,$ErrorAction)$attack.Ran=$true;[IO.File]::WriteAllText($child,'raced-child-source',[Text.UTF8Encoding]::new($false))} + $failure=$null + try{&$entrypoint -DoNotStart|Out-Null}catch{$failure=$_} + Assert-CcodTrue $attack.Ran 'controlled source mutation runs after initial entrypoint validation during its Defender gate' + Assert-CcodTrue ($null-ne$failure-and$failure.FullyQualifiedErrorId-like'CCOD_PORTABLE_MANIFEST_INVALID*') 'actual entrypoint rejects the raced source at revalidation' + Assert-CcodTrue (-not(Test-Path -LiteralPath $childMarker)) 'actual entrypoint never executes the marker child' + Assert-CcodTrue (-not(Test-Path -LiteralPath $lifecycleRoot)) 'actual entrypoint creates no fixture lifecycle state' + } finally { + if(Test-Path -LiteralPath $root){Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue} + } +} + +# Production mutation caught: the real portable entrypoint ignores a child-source race at the final File.Copy barrier. +Invoke-CcodTest 'actual portable entrypoint rejects the final File.Copy barrier race before child or lifecycle state' { + $root=Join-Path ([IO.Path]::GetTempPath()) ('ccod-portable-entry-copy-race-'+[guid]::NewGuid().ToString('N')) + $redirectBreakpoint=$null;$copyBreakpoint=$null + try { + $payload=Join-Path $root 'payload';$modules=Join-Path $payload 'src\persistence\modules';[IO.Directory]::CreateDirectory($modules)|Out-Null + $entrypoint=Join-Path $root 'Install-CodexRemote-fix.ps1' + $productionEntrypoint=Join-Path $repositoryRoot 'Install-CodexRemote-fix.ps1' + [IO.File]::Copy($productionEntrypoint,$entrypoint,$false) + Assert-CcodEqual (Get-CcodTestFileSha256 -Path $productionEntrypoint) (Get-CcodTestFileSha256 -Path $entrypoint) 'copy-barrier fixture invokes an exact copy of the production portable entrypoint' + $portableModulePath=Join-Path $modules 'PortableRelease.psm1';$productionPortableModule=Join-Path $repositoryRoot 'src\persistence\modules\PortableRelease.psm1' + [IO.File]::Copy($productionPortableModule,$portableModulePath,$false) + Assert-CcodEqual (Get-CcodTestFileSha256 -Path $productionPortableModule) (Get-CcodTestFileSha256 -Path $portableModulePath) 'copy-barrier fixture uses an exact copy of the production portable module' + [IO.File]::WriteAllText((Join-Path $payload 'package.json'),'{'+'"version":"2.5.22"}',[Text.UTF8Encoding]::new($false)) + $childMarker=Join-Path $root 'child-executed.txt';$fixtureInstallRoot=Join-Path $root 'fixture-install-root';$installerRoot=Join-Path $root 'copied-installer';$child=Join-Path $payload 'Install-CodexControlOtherDevices.ps1' + $childSource="param([string]`$InstallRoot,[switch]`$EnableCandidateCompatibleUpdates,[switch]`$DoNotStart,[string]`$SealedPackageSha256);[IO.Directory]::CreateDirectory('$($fixtureInstallRoot.Replace("'","''"))')|Out-Null;[IO.File]::WriteAllText('$($childMarker.Replace("'","''"))',[string]`$SealedPackageSha256,[Text.UTF8Encoding]::new(`$false));[pscustomobject]@{Outcome='Installed';RuntimeId='should-not-run'}" + [IO.File]::WriteAllText($child,$childSource,[Text.UTF8Encoding]::new($false)) + $records=[Collections.Generic.List[object]]::new() + foreach($file in @(Get-ChildItem -LiteralPath $payload -File -Force -Recurse)){$relative=$file.FullName.Substring($payload.TrimEnd('\').Length+1).Replace('\','/');$records.Add([pscustomobject][ordered]@{path=$relative;length=[int64]$file.Length;sha256=Get-CcodTestFileSha256 -Path $file.FullName})} + $comparison=[System.Comparison[object]]{param($left,$right)[StringComparer]::Ordinal.Compare([string]$left.path,[string]$right.path)};$records.Sort($comparison) + Assert-CcodEqual 'Install-CodexControlOtherDevices.ps1' ([string]$records[0].path) 'fixture child reaches the first production payload File.Copy barrier' + $manifest=[ordered]@{schemaVersion=1;product='CodexRemote-fix';version='2.5.22';gitCommit=('a'*40);buildTimestampUtc='2030-02-03T04:05:06.0000000Z';files=@($records)} + [IO.File]::WriteAllText((Join-Path $root 'payload-manifest.json'),(($manifest|ConvertTo-Json -Depth 8)+[Environment]::NewLine),[Text.UTF8Encoding]::new($false)) + function Get-MpComputerStatus {[pscustomobject]@{AMServiceEnabled=$true;AntivirusEnabled=$true;RealTimeProtectionEnabled=$true;AMProductVersion='fixture-platform';AMEngineVersion='fixture-engine';AntivirusSignatureVersion='fixture-signature';AntivirusSignatureLastUpdated=[datetime]::UtcNow.AddHours(-1)}} + function Get-MpThreatDetection {param($ErrorAction)@()} + function Start-MpScan {param($ScanType,$ScanPath,$ErrorAction)} + $barrier=[pscustomobject]@{InstallerRootRedirected=$false;SourceMutated=$false} + $entryCopyLine=@(Select-String -Path $entrypoint -Pattern '^\s*\$copied = Copy-CcodPortablePayload\b'|ForEach-Object{$_.LineNumber}) + Assert-CcodEqual 1 $entryCopyLine.Count 'production entrypoint copy call is unique' + $redirectAction={ + $module=@(Get-Module|Where-Object{$_.Path -ceq $portableModulePath}) + if($module.Count-ne 1){throw 'CCOD_TEST_PORTABLE_MODULE_NOT_LOADED'} + &$module[0] {param($ExpectedRoot)Set-Item -Path Function:Get-CcodPortableReleaseExpectedInstallerRoot -Value { $ExpectedRoot }.GetNewClosure()} $installerRoot + $barrier.InstallerRootRedirected=$true + }.GetNewClosure() + $redirectBreakpoint=Set-PSBreakpoint -Script $entrypoint -Line $entryCopyLine[0] -Action $redirectAction + $moduleCopyLine=@(Select-String -Path $portableModulePath -Pattern '^\s*\[IO\.File\]::Copy\(\$sourceFile,\$destination,\$false\)'|ForEach-Object{$_.LineNumber}) + Assert-CcodEqual 1 $moduleCopyLine.Count 'production portable payload File.Copy barrier is unique' + $copyAction={ + [IO.File]::WriteAllText($child,'raced-child-source-at-final-copy-boundary',[Text.UTF8Encoding]::new($false)) + $barrier.SourceMutated=$true + }.GetNewClosure() + $copyBreakpoint=Set-PSBreakpoint -Script $portableModulePath -Line $moduleCopyLine[0] -Action $copyAction + $failure=$null + try{&$entrypoint -DoNotStart|Out-Null}catch{$failure=$_} + Assert-CcodTrue $barrier.InstallerRootRedirected 'fixture redirects only the private installer-root dependency before the production copy call' + Assert-CcodTrue ($null-ne$failure-and$failure.FullyQualifiedErrorId-like'CCOD_PORTABLE_COPY_HASH_MISMATCH*') 'actual entrypoint rejects the source child changed at the production File.Copy barrier' + Assert-CcodTrue $barrier.SourceMutated 'source child changes only at the production File.Copy barrier' + Assert-CcodTrue (-not(Test-Path -LiteralPath $childMarker)) 'copy-barrier failure never executes the marker child' + Assert-CcodTrue (-not(Test-Path -LiteralPath $fixtureInstallRoot)) 'copy-barrier failure creates no fixture lifecycle install root' + Assert-CcodTrue (-not(Test-Path -LiteralPath $installerRoot)) 'copy-barrier failure publishes no fixture installer root' + } finally { + if($null-ne$copyBreakpoint){Remove-PSBreakpoint -Breakpoint $copyBreakpoint -ErrorAction SilentlyContinue} + if($null-ne$redirectBreakpoint){Remove-PSBreakpoint -Breakpoint $redirectBreakpoint -ErrorAction SilentlyContinue} + if(Test-Path -LiteralPath $root){Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue} + } +} + +function Import-CcodTask6ToolModule { + param([Parameter(Mandatory)][string]$Path,[Parameter(Mandatory)][string]$Name) + $existing = Get-Module -Name $Name -ErrorAction SilentlyContinue + if ($null -ne $existing) { Remove-Module -Name $Name -Force } + $text = [IO.File]::ReadAllText([IO.Path]::GetFullPath($Path), [Text.UTF8Encoding]::new($false)) + $created = New-Module -Name $Name -ScriptBlock ([scriptblock]::Create($text)) + Import-Module $created -Force -DisableNameChecking | Out-Null + $loaded = Get-Module -Name $Name + if ($null -eq $loaded) { throw "CCOD_TASK6_TOOL_MODULE_MISSING $Name" } + & $loaded { param($Root) $script:CcodGitHubDraftModuleRoot = [IO.Path]::GetFullPath($Root) } (Split-Path (Get-Item -LiteralPath $Path).FullName -Parent) + return $loaded +} + +function Assert-CcodDraftReleaseWorkflowContract { + param( + [Parameter(Mandatory)][string]$CiPath, + [Parameter(Mandatory)][string]$ReleasePath + ) + $errorId = 'CCOD_RELEASE_WORKFLOW_INVALID' + $pins = [ordered]@{ + 'actions/checkout' = '11d5960a326750d5838078e36cf38b85af677262' + 'actions/setup-node' = '49933ea5288caeca8642d1e84afbd3f7d6820020' + 'actions/upload-artifact' = 'ea165f8d65b6e75b540449e92b4886f43607fa02' + 'actions/download-artifact' = 'd3f86a106a0bac45b974a628896c90dbdf5c8093' + } + foreach ($target in @($CiPath, $ReleasePath)) { + $raw = [IO.File]::ReadAllText($target, [Text.UTF8Encoding]::new($false)) + if ($raw -cmatch 'uses:\s*actions/[A-Za-z0-9_.-]+@v\d') { throw $errorId } + if ($raw -cmatch '(?m)^\s*continue-on-error:') { throw $errorId } + foreach ($use in @([regex]::Matches($raw, '(?m)^\s+uses:\s*(?\S+)\s*$'))) { + if ($use.Groups['ref'].Value -cnotmatch '^actions/[A-Za-z0-9_.-]+@[0-9a-f]{40}$') { throw $errorId } + $action = ($use.Groups['ref'].Value -split '@')[0] + $sha = ($use.Groups['ref'].Value -split '@')[1] + if (-not $pins.Contains($action) -or $pins[$action] -cne $sha) { throw $errorId } + } + } + $ci = [IO.File]::ReadAllText($CiPath, [Text.UTF8Encoding]::new($false)) + $release = [IO.File]::ReadAllText($ReleasePath, [Text.UTF8Encoding]::new($false)) + foreach ($action in @('actions/checkout','actions/setup-node')) { + if (-not $ci.Contains($action + '@' + $pins[$action])) { throw $errorId } + } + foreach ($action in @($pins.Keys)) { + if (-not $release.Contains($action + '@' + $pins[$action])) { throw $errorId } + } + if ($release -cmatch 'build/dist/\*' -or $release -cmatch '\.Extension\s+-in' -or $release -cmatch 'Test-ReleaseDefender\.ps1[^\r\n]*-Library') { throw $errorId } + if ($release -cnotmatch 'ReleaseAssetContract\.psm1' -or $release -cnotmatch 'Get-CcodExpectedReleaseAssetNames' -or $release -cnotmatch 'Test-CcodCleanReleaseRunner' -or $release -cnotmatch 'Invoke-CcodGitHubDraftRelease') { throw $errorId } + if ($release -cmatch '(?m)gh release create\b(?![^\r\n]*--draft)') { throw $errorId } + if ($release -cnotmatch '(?m)^concurrency:' -or $release -cnotmatch 'cancel-in-progress:\s*false') { throw $errorId } + $workflow = Get-CcodWorkflowStructure -Path $ReleasePath + $jobNames = @($workflow.Jobs | ForEach-Object { $_.Name }) + $preflightIndex = [array]::IndexOf($jobNames, 'preflight') + $buildIndex = [array]::IndexOf($jobNames, 'build') + if ($preflightIndex -lt 0 -or $buildIndex -lt 0 -or $preflightIndex -ge $buildIndex) { throw $errorId } + $cleanSteps = @($workflow.Jobs[$preflightIndex].Steps | Where-Object { [string]$_.Run -cmatch 'Test-CcodCleanReleaseRunner' }) + if ($cleanSteps.Count -lt 1) { throw $errorId } + $normalized = $release.Replace("`r`n", "`n") + $stageBlocks = @([regex]::Matches($normalized, '(?ms)^ stage:\n(?.*?)(?=^ [A-Za-z0-9_-]+:\n|\z)')) + if ($stageBlocks.Count -ne 1) { throw $errorId } + $stageBody = $stageBlocks[0].Groups['body'].Value + if ($normalized -cnotmatch '(?m)^permissions:\n contents: read$' -or + @([regex]::Matches($normalized, '(?m)^[ ]*contents: write[ ]*$')).Count -ne 1 -or + $stageBody -cnotmatch '(?m)^ permissions:\n contents: write$' -or + $stageBody -cnotmatch '(?m)^ needs: build$' -or + $stageBody -cnotmatch '(?m)^ ref: \$\{\{ needs\.build\.outputs\.commit \}\}$') { throw $errorId } + foreach ($transfer in @( + [pscustomobject]@{ Job = 'preflight'; Step = 'Upload immutable clean preflight evidence' }, + [pscustomobject]@{ Job = 'stage'; Step = 'Download immutable clean preflight evidence' }, + [pscustomobject]@{ Job = 'stage'; Step = 'Materialize transferred clean preflight evidence' } + )) { + $owner = @($workflow.Jobs | Where-Object { $_.Name -ceq $transfer.Job }) + if ($owner.Count -ne 1) { throw $errorId } + $steps = @($owner[0].Steps | Where-Object { $_.Name -ceq $transfer.Step }) + if ($steps.Count -ne 1 -or -not [string]::IsNullOrEmpty([string]$steps[0].If)) { throw $errorId } + } + foreach ($job in @($workflow.Jobs)) { + foreach ($step in @($job.Steps)) { + if (-not [string]::IsNullOrEmpty([string]$step.ContinueOnError)) { throw $errorId } + if ($job.Name -cne 'build' -and (Test-CcodWorkflowStepInvokesBuild $step)) { throw $errorId } + if (([string]$step.Run -cmatch 'Test-CcodCleanReleaseRunner|Invoke-CcodGitHubDraftRelease|test:release-contract|build\.ps1') -and -not [string]::IsNullOrEmpty([string]$step.If)) { throw $errorId } + } + } +} + +function New-CcodTask6CleanAdapterFixture { + param( + [string]$Version = '2.5.22', + [string]$Commit = ('c' * 40), + [string]$Porcelain = '', + [switch]$Installed, + [switch]$MutexOccupied, + [switch]$TaskPresent, + [switch]$SupervisorPresent, + [switch]$TrayHostPresent + ) + $state = [pscustomobject]@{ Calls = [Collections.Generic.List[string]]::new(); Preflight = $null; Cleanup = [Collections.Generic.List[string]]::new() } + $adapters = @{ + GetPackageVersion = { param($Root) $state.Calls.Add('GetPackageVersion'); $Version }.GetNewClosure() + GetGitPorcelain = { param($Root) $state.Calls.Add('GetGitPorcelain'); $Porcelain }.GetNewClosure() + GetGitCommit = { param($Root) $state.Calls.Add('GetGitCommit'); $Commit }.GetNewClosure() + GetProductState = { param($Root) $state.Calls.Add('GetProductState'); [pscustomobject]@{ InstallRootPresent = [bool]$Installed; ScheduledTaskPresent = [bool]$TaskPresent; SupervisorPresent = [bool]$SupervisorPresent; TrayHostPresent = [bool]$TrayHostPresent } }.GetNewClosure() + ProbeMutex = { param($Kind) $state.Calls.Add('ProbeMutex:' + $Kind); [bool]$MutexOccupied }.GetNewClosure() + WritePreflightEvidence = { param($Path, $Record) $state.Calls.Add('WritePreflightEvidence'); $state.Preflight = [pscustomobject]@{ Path = $Path; Record = $Record }; $Path }.GetNewClosure() + CleanupProduct = { param($Root) $state.Cleanup.Add('CleanupProduct'); throw 'cleanup forbidden' }.GetNewClosure() + } + [pscustomobject]@{ Adapters = $adapters; State = $state } +} + +function New-CcodTask6DraftAdapterFixture { + param([string]$AssetDirectory, [switch]$UploadFails, [switch]$UploadFailurePublishes, [switch]$ConcurrentStage, [switch]$ReadbackFails, [switch]$ReadbackFailurePublishes) + $state = [pscustomobject]@{ + Calls = [Collections.Generic.List[string]]::new() + DraftPrivate = $true + DraftId = '123' + Created = $false + Uploaded = [Collections.Generic.List[string]]::new() + Promoted = $false + Rebuilt = $false + Assets = @{} + SourceDirectory = $AssetDirectory + } + $hashFile = { + param($Path) + $sha = [Security.Cryptography.SHA256]::Create() + try { + $stream = [IO.File]::OpenRead([IO.Path]::GetFullPath($Path)) + try { return [BitConverter]::ToString($sha.ComputeHash($stream)).Replace('-', '').ToLowerInvariant() } finally { $stream.Dispose() } + } finally { $sha.Dispose() } + }.GetNewClosure() + $adapters = @{ + TryStageLock = { param($Tag) $state.Calls.Add('TryStageLock'); if ($ConcurrentStage) { $false } else { $true } }.GetNewClosure() + CreateDraft = { param($Tag, $Title, $Notes) $state.Calls.Add('CreateDraft'); $state.Created = $true; $state.DraftPrivate = $true; [pscustomobject][ordered]@{ Tag = $Tag; Id = [string]$state.DraftId; Draft = $true; AssetNames = @() } }.GetNewClosure() + GetTagCommit = { param($Tag, [bool]$ActionsOnly) $state.Calls.Add('GetTagCommit'); 'c' * 40 }.GetNewClosure() + UploadAsset = { param($Tag, $Name, $Path) + $state.Calls.Add('UploadAsset:' + $Name) + if ($UploadFails) { if ($UploadFailurePublishes) { $state.DraftPrivate = $false }; throw 'upload failed' } + $state.Uploaded.Add($Name) + $state.Assets[$Name] = & $hashFile $Path + }.GetNewClosure() + DownloadAsset = { param($Tag, $Name, $Destination) + $state.Calls.Add('DownloadAsset:' + $Name) + if ($ReadbackFails) { if ($ReadbackFailurePublishes) { $state.DraftPrivate = $false }; throw 'readback failed' } + $source = Join-Path $state.SourceDirectory $Name + [IO.File]::Copy($source, $Destination, $true) + }.GetNewClosure() + ViewRelease = { param($Tag) $state.Calls.Add('ViewRelease'); [pscustomobject][ordered]@{ Tag = $Tag; Id = [string]$state.DraftId; Draft = [bool]$state.DraftPrivate; AssetNames = @($state.Uploaded) } }.GetNewClosure() + ProbeRelease = { param($Tag) $state.Calls.Add('ProbeRelease'); $null }.GetNewClosure() + SetReleaseDraftState = { param($Tag, [bool]$Draft) $state.Calls.Add('SetReleaseDraftState:' + $Draft); $state.DraftPrivate = [bool]$Draft; if (-not $Draft) { $state.Promoted = $true } }.GetNewClosure() + InvokeBuild = { param($Version) $state.Calls.Add('InvokeBuild'); $state.Rebuilt = $true }.GetNewClosure() + InvokeGh = { param($Arguments) $state.Calls.Add('InvokeGh'); throw 'raw gh forbidden' }.GetNewClosure() + } + [pscustomobject]@{ Adapters = $adapters; State = $state } +} + +function Invoke-CcodTask6CleanCore { + param([Parameter(Mandatory)]$Module,[Parameter(Mandatory)][string]$RepositoryRoot,[Parameter(Mandatory)][string]$ExpectedVersion,[Parameter(Mandatory)][hashtable]$Adapters) + &$Module { param($RepositoryRoot,$ExpectedVersion,$Adapters) Test-CcodCleanReleaseRunnerCore -RepositoryRoot $RepositoryRoot -ExpectedVersion $ExpectedVersion -Adapters $Adapters } $RepositoryRoot $ExpectedVersion $Adapters +} + +function Invoke-CcodTask6DraftCore { + param([Parameter(Mandatory)]$Module,[Parameter(Mandatory)][string]$Mode,[Parameter(Mandatory)][string]$Tag,[Parameter(Mandatory)][string]$AssetDirectory,[Parameter(Mandatory)][string]$EvidenceDirectory,[Parameter(Mandatory)][hashtable]$Adapters,[string]$NotesPath) + &$Module { param($Mode,$Tag,$AssetDirectory,$EvidenceDirectory,$Adapters,$NotesPath) Invoke-CcodGitHubDraftReleaseCore -Mode $Mode -Tag $Tag -AssetDirectory $AssetDirectory -EvidenceDirectory $EvidenceDirectory -Adapters $Adapters -NotesPath $NotesPath } $Mode $Tag $AssetDirectory $EvidenceDirectory $Adapters $NotesPath +} + +Invoke-CcodTask6Test 'runner' 'clean release runner is exported from the production tool' { + $path = Join-Path $repositoryRoot 'tools\Test-CleanReleaseRunner.ps1' + Assert-CcodTrue (Test-Path -LiteralPath $path -PathType Leaf) 'clean release runner script exists' + $module = Import-CcodTask6ToolModule -Path $path -Name 'CcodCleanReleaseRunnerPublic' + try { + $command = Get-Command Test-CcodCleanReleaseRunner -Module $module.Name -ErrorAction Stop + Assert-CcodTrue ($command.Parameters.ContainsKey('RepositoryRoot')) 'clean runner requires RepositoryRoot' + Assert-CcodTrue ($command.Parameters.ContainsKey('ExpectedVersion')) 'clean runner requires ExpectedVersion' + Assert-CcodTrue (-not $command.Parameters.ContainsKey('Adapters')) 'public clean runner exposes no adapter injection parameter' + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + } +} + +Invoke-CcodTask6Test 'draft' 'draft release promoter is exported from the production tool' { + $path = Join-Path $repositoryRoot 'tools\Invoke-GitHubDraftRelease.ps1' + Assert-CcodTrue (Test-Path -LiteralPath $path -PathType Leaf) 'draft release promoter script exists' + $module = Import-CcodTask6ToolModule -Path $path -Name 'CcodGitHubDraftReleasePublic' + try { + $command = Get-Command Invoke-CcodGitHubDraftRelease -Module $module.Name -ErrorAction Stop + foreach ($name in @('Mode','Tag','AssetDirectory','EvidenceDirectory')) { + Assert-CcodTrue ($command.Parameters.ContainsKey($name)) "draft promoter requires $name" + } + Assert-CcodTrue (-not $command.Parameters.ContainsKey('Adapters')) 'public draft promoter exposes no adapter injection parameter' + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + } +} + +Invoke-CcodTask6Test 'pin' 'CI and release workflows pin checkout to the reviewed commit SHA' { + $expected = 'actions/checkout@11d5960a326750d5838078e36cf38b85af677262' + foreach ($name in @('ci.yml','release.yml')) { + $raw = [IO.File]::ReadAllText((Join-Path $repositoryRoot ('.github\workflows\' + $name)), [Text.UTF8Encoding]::new($false)) + Assert-CcodTrue ($raw.Contains($expected)) "$name pins actions/checkout to the reviewed SHA" + Assert-CcodTrue ($raw -cnotmatch 'actions/checkout@v') "$name does not use a floating checkout tag" + } +} + +Invoke-CcodTask6Test 'glob' 'release workflow never uploads by dist glob or dotsources Defender library mode' { + $raw = [IO.File]::ReadAllText((Join-Path $repositoryRoot '.github\workflows\release.yml'), [Text.UTF8Encoding]::new($false)) + Assert-CcodTrue ($raw -cnotmatch 'build/dist/\*') 'release workflow does not upload by dist glob' + Assert-CcodTrue ($raw -cnotmatch 'Test-ReleaseDefender\.ps1[^\r\n]*-Library') 'release workflow does not restore Defender library mode' + Assert-CcodTrue ($raw.Contains('ReleaseAssetContract.psm1')) 'release workflow imports the central asset contract' +} + +Invoke-CcodTask6Test 'draft-create' 'release workflow never public-first creates a GitHub release' { + $raw = [IO.File]::ReadAllText((Join-Path $repositoryRoot '.github\workflows\release.yml'), [Text.UTF8Encoding]::new($false)) + Assert-CcodTrue ($raw -cnotmatch '(?m)gh release create\b(?![^\r\n]*--draft)') 'gh release create is never invoked without --draft' +} + +Invoke-CcodTask6Test 'workflow' 'CI and release workflows satisfy the draft-only pinned contract' { + Assert-CcodDraftReleaseWorkflowContract ` + -CiPath (Join-Path $repositoryRoot '.github\workflows\ci.yml') ` + -ReleasePath (Join-Path $repositoryRoot '.github\workflows\release.yml') +} + +Invoke-CcodTask6Test 'fix2-workflow-candidate-commit' 'Stage candidate gate rejects commits different from the immutable build output' { + $path = Join-Path $repositoryRoot '.github/workflows/release.yml' + $raw = [IO.File]::ReadAllText($path, [Text.UTF8Encoding]::new($false)) + $workflow = Get-CcodWorkflowStructure $path + $stage = @($workflow.Jobs | Where-Object { $_.Name -ceq 'stage' }) + $step = @($stage[0].Steps | Where-Object { $_.Name -ceq 'Verify downloaded release candidate contract' }) + $tokens = $null; $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseInput($step[0].Run, [ref]$tokens, [ref]$errors) + $guards = @($ast.FindAll({ param($node) $node -is [Management.Automation.Language.IfStatementAst] -and $node.Extent.Text.Contains('$candidate.GitCommit') }, $true)) + Assert-CcodEqual 1 $guards.Count 'downloaded candidate has one executable commit guard' + Assert-CcodTrue ($raw -cmatch '(?m)^ CCOD_RELEASE_COMMIT: \$\{\{ needs\.build\.outputs\.commit \}\}\r?$') 'commit guard consumes the immutable build output' + $guard = [scriptblock]::Create($guards[0].Extent.Text) + $saved = [Environment]::GetEnvironmentVariable('CCOD_RELEASE_COMMIT', 'Process') + try { + $candidate = [pscustomobject]@{ GitCommit = 'c' * 40 } + $env:CCOD_RELEASE_COMMIT = 'c' * 40 + & $guard + foreach ($invalid in @(('d' * 40), (('c' * 40) + "`n"), '')) { + $env:CCOD_RELEASE_COMMIT = $invalid + Assert-CcodThrows { & $guard } 'CCOD_RELEASE_BUILD_COMMIT_MISMATCH' + } + } finally { [Environment]::SetEnvironmentVariable('CCOD_RELEASE_COMMIT', $saved, 'Process') } +} + +Invoke-CcodTask6Test 'fix2-workflow-authority' 'workflow contract rejects detached Stage checkout authority and skipped preflight transfer' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-workflow-authority-' + [guid]::NewGuid().ToString('N')) + try { + [IO.Directory]::CreateDirectory($root) | Out-Null + $ci = Join-Path $repositoryRoot '.github\workflows\ci.yml' + $releasePath = Join-Path $root 'release.yml' + $raw = [IO.File]::ReadAllText((Join-Path $repositoryRoot '.github\workflows\release.yml'), [Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($releasePath, $raw, [Text.UTF8Encoding]::new($false)) + Assert-CcodDraftReleaseWorkflowContract -CiPath $ci -ReleasePath $releasePath + $mutations = @( + [pscustomobject]@{ Name = 'Stage dependency'; From = 'needs: build'; To = 'needs: preflight' } + [pscustomobject]@{ Name = 'Stage immutable checkout'; From = 'ref: ${{ needs.build.outputs.commit }}'; To = 'ref: ${{ github.ref }}' } + [pscustomobject]@{ Name = 'global write authority'; From = "permissions:`n contents: read"; To = "permissions:`n contents: write" } + [pscustomobject]@{ Name = 'build write authority'; From = " build:`n needs: preflight"; To = " build:`n permissions:`n contents: write`n needs: preflight" } + [pscustomobject]@{ Name = 'Stage write authority absent'; From = ' contents: write'; To = ' contents: read' } + [pscustomobject]@{ Name = 'skipped preflight upload'; From = ' - name: Upload immutable clean preflight evidence'; To = " - name: Upload immutable clean preflight evidence`n if: false" } + [pscustomobject]@{ Name = 'skipped preflight download'; From = ' - name: Download immutable clean preflight evidence'; To = " - name: Download immutable clean preflight evidence`n if: false" } + [pscustomobject]@{ Name = 'skipped preflight materialization'; From = ' - name: Materialize transferred clean preflight evidence'; To = " - name: Materialize transferred clean preflight evidence`n if: false" } + ) + $normalized = $raw.Replace("`r`n", "`n") + foreach ($mutation in $mutations) { + $changed = $normalized.Replace($mutation.From, $mutation.To) + Assert-CcodTrue ($changed -cne $normalized) "$($mutation.Name) mutation reaches the real workflow" + [IO.File]::WriteAllText($releasePath, $changed, [Text.UTF8Encoding]::new($false)) + Assert-CcodThrows { Assert-CcodDraftReleaseWorkflowContract -CiPath $ci -ReleasePath $releasePath } 'CCOD_RELEASE_WORKFLOW_INVALID' + } + } finally { if ([IO.Directory]::Exists($root)) { [IO.Directory]::Delete($root, $true) } } +} + +Invoke-CcodTask6Test 'workflow-bypass' 'draft workflow contract rejects unpinned actions continue-on-error if-bypass rebuild and missing concurrency' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-task6-workflow-' + [guid]::NewGuid().ToString('N')) + try { + [IO.Directory]::CreateDirectory($root) | Out-Null + $ciPath = Join-Path $root 'ci.yml' + $releasePath = Join-Path $root 'release.yml' + $pinnedCi = @' +name: fixture CI +jobs: + validate: + steps: + - name: Check out repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 +'@ + $pinnedRelease = [IO.File]::ReadAllText((Join-Path $repositoryRoot '.github/workflows/release.yml'), [Text.UTF8Encoding]::new($false)).Replace("`r`n", "`n") + [IO.File]::WriteAllText($ciPath, $pinnedCi, [Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($releasePath, $pinnedRelease, [Text.UTF8Encoding]::new($false)) + Assert-CcodDraftReleaseWorkflowContract -CiPath $ciPath -ReleasePath $releasePath + $invalid = @( + [pscustomobject]@{ Name = 'unpinned setup-node'; Release = $pinnedRelease.Replace('actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020','actions/setup-node@v4') }, + [pscustomobject]@{ Name = 'continue-on-error'; Release = $pinnedRelease.Replace(' - name: Run clean release runner'," - name: Run clean release runner`n continue-on-error: true") }, + [pscustomobject]@{ Name = 'if bypass'; Release = $pinnedRelease.Replace(' - name: Run clean release runner', ' - name: Run clean release runner' + "`n" + ' if: ${{ false }}') }, + [pscustomobject]@{ Name = 'missing concurrency'; Release = [regex]::Replace($pinnedRelease, '(?m)^concurrency:\n group:[^\n]+\n cancel-in-progress: false\n', '') }, + [pscustomobject]@{ Name = 'rebuild in stage'; Release = $pinnedRelease + [Environment]::NewLine + " run: ./build/build.ps1 -Version 2.5.22`n" }, + [pscustomobject]@{ Name = 'extension glob'; Release = $pinnedRelease.Replace('Get-CcodExpectedReleaseAssetNames -Version $version','Get-ChildItem -File | Where-Object { $_.Extension -in @(''.exe'') }') } + ) + foreach ($fixture in $invalid) { + Assert-CcodTrue ($fixture.Release -cne $pinnedRelease) "$($fixture.Name) mutation changes the workflow" + [IO.File]::WriteAllText($releasePath, [string]$fixture.Release, [Text.UTF8Encoding]::new($false)) + Assert-CcodThrows { Assert-CcodDraftReleaseWorkflowContract -CiPath $ciPath -ReleasePath $releasePath } 'CCOD_RELEASE_WORKFLOW_INVALID' + } + } finally { + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } + } +} + +Invoke-CcodTask6Test 'runner-core' 'clean runner core accepts module-scope adapters and public surface does not' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools\Test-CleanReleaseRunner.ps1') -Name 'CcodCleanReleaseRunner' + try { + $public = Get-Command Test-CcodCleanReleaseRunner -Module $module.Name -ErrorAction Stop + Assert-CcodTrue (-not $public.Parameters.ContainsKey('Adapters')) 'public clean runner exposes no adapter injection parameter' + $core = &$module { Get-Command Test-CcodCleanReleaseRunnerCore -ErrorAction SilentlyContinue } + Assert-CcodTrue ($null -ne $core) 'clean runner core exists for tests' + Assert-CcodTrue ($core.Parameters.ContainsKey('Adapters')) 'clean runner core accepts adapters' + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + } +} + +Invoke-CcodTask6Test 'runner-reject' 'clean runner fail-closes contaminated dirty missing-preflight and version mismatches without cleanup' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools\Test-CleanReleaseRunner.ps1') -Name 'CcodCleanReleaseRunner' + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-task6-clean-' + [guid]::NewGuid().ToString('N')) + try { + [IO.Directory]::CreateDirectory($root) | Out-Null + foreach ($case in @( + [pscustomobject]@{ Id = 'installed'; Error = 'CCOD_CLEAN_RUNNER_CONTAMINATED'; Fixture = { New-CcodTask6CleanAdapterFixture -Installed } }, + [pscustomobject]@{ Id = 'mutex'; Error = 'CCOD_CLEAN_RUNNER_CONTAMINATED'; Fixture = { New-CcodTask6CleanAdapterFixture -MutexOccupied } }, + [pscustomobject]@{ Id = 'task'; Error = 'CCOD_CLEAN_RUNNER_CONTAMINATED'; Fixture = { New-CcodTask6CleanAdapterFixture -TaskPresent } }, + [pscustomobject]@{ Id = 'supervisor'; Error = 'CCOD_CLEAN_RUNNER_CONTAMINATED'; Fixture = { New-CcodTask6CleanAdapterFixture -SupervisorPresent } }, + [pscustomobject]@{ Id = 'trayhost'; Error = 'CCOD_CLEAN_RUNNER_CONTAMINATED'; Fixture = { New-CcodTask6CleanAdapterFixture -TrayHostPresent } }, + [pscustomobject]@{ Id = 'dirty'; Error = 'CCOD_CLEAN_RUNNER_DIRTY'; Fixture = { New-CcodTask6CleanAdapterFixture -Porcelain ' M README.md' } }, + [pscustomobject]@{ Id = 'version'; Error = 'CCOD_CLEAN_RUNNER_VERSION_INVALID'; Fixture = { New-CcodTask6CleanAdapterFixture -Version '2.5.21' } } + )) { + $fixture = & $case.Fixture + Assert-CcodThrows { Invoke-CcodTask6CleanCore -Module $module -RepositoryRoot $root -ExpectedVersion '2.5.22' -Adapters $fixture.Adapters | Out-Null } $case.Error + Assert-CcodEqual 0 $fixture.State.Cleanup.Count "clean runner does not cleanup after $($case.Id)" + } + $ok = New-CcodTask6CleanAdapterFixture + $result = Invoke-CcodTask6CleanCore -Module $module -RepositoryRoot $root -ExpectedVersion '2.5.22' -Adapters $ok.Adapters + Assert-CcodTrue ([bool]$result.Valid) 'clean runner accepts a clean checkout' + Assert-CcodEqual '2.5.22' ([string]$result.Version) 'clean runner returns the expected version' + Assert-CcodTrue ($ok.State.Calls -contains 'WritePreflightEvidence') 'clean runner writes preflight evidence' + Assert-CcodEqual 0 $ok.State.Cleanup.Count 'successful clean runner still performs no cleanup' + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } + } +} + +Invoke-CcodTask6Test 'draft-core' 'draft promoter core accepts module-scope adapters and public surface does not' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools\Invoke-GitHubDraftRelease.ps1') -Name 'CcodGitHubDraftRelease' + try { + $public = Get-Command Invoke-CcodGitHubDraftRelease -Module $module.Name -ErrorAction Stop + Assert-CcodTrue (-not $public.Parameters.ContainsKey('Adapters')) 'public draft promoter exposes no adapter injection parameter' + $core = &$module { Get-Command Invoke-CcodGitHubDraftReleaseCore -ErrorAction SilentlyContinue } + Assert-CcodTrue ($null -ne $core) 'draft promoter core exists for tests' + Assert-CcodTrue ($core.Parameters.ContainsKey('Adapters')) 'draft promoter core accepts adapters' + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + } +} + +Invoke-CcodTask6Test 'fix1-public-module-boundary' 'release workflow imports only the public draft command and does not dot-source injectable internals' { + $releasePath = Join-Path $repositoryRoot '.github/workflows/release.yml' + $workflow = [IO.File]::ReadAllText($releasePath, [Text.UTF8Encoding]::new($false)) + foreach ($spec in @( + [pscustomobject]@{ Name = 'draft'; Script = 'Invoke-GitHubDraftRelease.psm1'; Public = 'Invoke-CcodGitHubDraftRelease'; Private = 'Invoke-CcodGitHubDraftReleaseCore' }, + [pscustomobject]@{ Name = 'clean runner'; Script = 'Test-CleanReleaseRunner.psm1'; Public = 'Test-CcodCleanReleaseRunner'; Private = 'Test-CcodCleanReleaseRunnerCore' } + )) { + $scriptPath = Join-Path $repositoryRoot ('tools/' + $spec.Script) + Assert-CcodTrue (Test-Path -LiteralPath $scriptPath -PathType Leaf) "$($spec.Name) tool has a real module wrapper" + Assert-CcodTrue ($workflow -cmatch ('Import-Module.*' + [regex]::Escape($spec.Script))) "$($spec.Name) workflow import uses the module wrapper" + Assert-CcodTrue ($workflow -cnotmatch ('(?m)^\s*\.\s+\.?/?\.?[/\\]tools[/\\]' + [regex]::Escape($spec.Script))) "$($spec.Name) workflow does not dot-source the module wrapper" + $module = Import-Module $scriptPath -Force -PassThru -DisableNameChecking + try { + Assert-CcodTrue ($null -ne (Get-Command $spec.Public -Module $module.Name -ErrorAction SilentlyContinue)) "$($spec.Name) public command is exported" + Assert-CcodTrue ($null -eq (Get-Command $spec.Private -Module $module.Name -ErrorAction SilentlyContinue)) "$($spec.Name) private core is not exported" + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + } + } +} + +Invoke-CcodTask6Test 'fix1-fresh-process-module-boundary' 'draft release wrapper exports only its public command in a fresh PowerShell process' { + $wrapper = Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.psm1' + Assert-CcodTrue (Test-Path -LiteralPath $wrapper -PathType Leaf) 'draft release wrapper exists as a regular file' + $escaped = $wrapper.Replace("'", "''") + $probe = "`$ErrorActionPreference = 'Stop'; `$ProgressPreference = 'SilentlyContinue'; if ((Get-ExecutionPolicy -Scope Process) -cne 'Undefined' -or -not [string]::IsNullOrEmpty([Environment]::GetEnvironmentVariable('PSExecutionPolicyPreference','Process'))) { exit 24 }; Import-Module '$escaped' -Force; `$public = Get-Command Invoke-CcodGitHubDraftRelease -ErrorAction SilentlyContinue; `$private = Get-Command Invoke-CcodGitHubDraftReleaseCore -ErrorAction SilentlyContinue; if (`$null -eq `$public -or `$null -ne `$private) { exit 23 }; [Console]::WriteLine('CCOD_DRAFT_WRAPPER_POLICY=' + (Get-ExecutionPolicy)); [Console]::WriteLine('CCOD_DRAFT_WRAPPER_OK')" + $previous=[Environment]::GetEnvironmentVariable('PSExecutionPolicyPreference','Process') + $normalPolicy=$null + try { + foreach($inherited in @($null,'Bypass','Restricted')) { + [Environment]::SetEnvironmentVariable('PSExecutionPolicyPreference',$inherited,'Process') + $info=[Diagnostics.ProcessStartInfo]::new() + $info.FileName=(Get-Command powershell.exe -CommandType Application -ErrorAction Stop).Source + $encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($probe)) + $info.Arguments='-NoLogo -NoProfile -NonInteractive -EncodedCommand '+$encoded + $info.UseShellExecute=$false;$info.CreateNoWindow=$true + $info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true + $info.EnvironmentVariables.Remove('PSExecutionPolicyPreference') + Assert-CcodTrue (-not$info.EnvironmentVariables.ContainsKey('PSExecutionPolicyPreference')) 'actual child environment excludes the parent policy override' + Assert-CcodEqual ('-NoLogo -NoProfile -NonInteractive -EncodedCommand '+$encoded) $info.Arguments 'actual child arguments contain no policy override' + $child=[Diagnostics.Process]::new();$child.StartInfo=$info;$started=$false + try { + $started=$child.Start() + Assert-CcodTrue $started 'fresh wrapper process actually starts' + $stdout=$child.StandardOutput.ReadToEndAsync();$stderr=$child.StandardError.ReadToEndAsync() + Assert-CcodTrue ($child.WaitForExit(60000)) 'fresh wrapper process completes within the timeout' + $output=$stdout.GetAwaiter().GetResult();$errorOutput=$stderr.GetAwaiter().GetResult() + Assert-CcodEqual 0 $child.ExitCode "fresh PowerShell import uses normal process policy with parent override '$inherited'" + Assert-CcodEqual '' $errorOutput ('normal-policy module import has no stderr failure; stderr=[' + $errorOutput.Replace("`r",'').Replace("`n",'') + ']') + Assert-CcodTrue $output.Contains('CCOD_DRAFT_WRAPPER_OK') 'fresh PowerShell import exports the expected public command' + $policyLines=@($output.Split([char]10)|Where-Object {$_.StartsWith('CCOD_DRAFT_WRAPPER_POLICY=',[StringComparison]::Ordinal)}) + Assert-CcodEqual 1 $policyLines.Count 'the real child reports exactly one effective policy' + $policyValue=$policyLines[0].TrimEnd([char]13).Substring('CCOD_DRAFT_WRAPPER_POLICY='.Length) + Assert-CcodTrue (-not[string]::IsNullOrWhiteSpace($policyValue)) 'effective policy is not empty' + if($null-eq$normalPolicy){$normalPolicy=$policyValue}else{Assert-CcodEqual $normalPolicy $policyValue 'parent override does not change the actual normal child policy'} + Assert-CcodEqual $inherited ([Environment]::GetEnvironmentVariable('PSExecutionPolicyPreference','Process')) 'launch configuration does not mutate the parent environment' + } finally {if($started-and-not$child.HasExited){$child.Kill();$child.WaitForExit()};$child.Dispose()} + } + } finally {[Environment]::SetEnvironmentVariable('PSExecutionPolicyPreference',$previous,'Process')} + Assert-CcodEqual $previous ([Environment]::GetEnvironmentVariable('PSExecutionPolicyPreference','Process')) 'test restores its original parent environment' +} + +Invoke-CcodTask6Test 'draft-stage' 'Stage fail-closes missing preflight extra assets upload failure concurrent stage and never promotes' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools\Invoke-GitHubDraftRelease.ps1') -Name 'CcodGitHubDraftRelease' + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-task6-draft-' + [guid]::NewGuid().ToString('N')) + $assetFixture = $null + try { + [IO.Directory]::CreateDirectory($root) | Out-Null + $evidence = Join-Path $root 'evidence' + [IO.Directory]::CreateDirectory($evidence) | Out-Null + $emptyAssets = Join-Path $root 'assets' + [IO.Directory]::CreateDirectory($emptyAssets) | Out-Null + $fixture = New-CcodTask6DraftAdapterFixture -AssetDirectory $emptyAssets + Assert-CcodThrows { Invoke-CcodTask6DraftCore -Module $module -Mode Stage -Tag 'v2.5.22' -AssetDirectory $emptyAssets -EvidenceDirectory $evidence -Adapters $fixture.Adapters | Out-Null } 'CCOD_GITHUB_DRAFT_PREFLIGHT_MISSING' + Assert-CcodTrue (-not $fixture.State.Promoted) 'missing preflight never promotes' + Assert-CcodTrue (-not $fixture.State.Rebuilt) 'missing preflight never rebuilds' + [IO.File]::WriteAllText((Join-Path $evidence 'CodexRemote-fix-2.5.22-clean-preflight.json'), '{"valid":true}', [Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText((Join-Path $emptyAssets 'unexpected.txt'), 'x', [Text.UTF8Encoding]::new($false)) + $extra = New-CcodTask6DraftAdapterFixture -AssetDirectory $emptyAssets + Assert-CcodThrows { Invoke-CcodTask6DraftCore -Module $module -Mode Stage -Tag 'v2.5.22' -AssetDirectory $emptyAssets -EvidenceDirectory $evidence -Adapters $extra.Adapters | Out-Null } 'CCOD_RELEASE_ASSET_SET_INVALID' + Assert-CcodTrue (-not $extra.State.Promoted) 'extra asset never promotes' + $assetFixture = New-CcodTask5ExactAssetFixture + $preflight = Join-Path $assetFixture.Outside 'CodexRemote-fix-2.5.22-clean-preflight.json' + $preflightRecord = [ordered]@{ schemaVersion = 1; valid = $true; version = '2.5.22'; gitCommit = [string]$assetFixture.GitCommit; repositoryRoot = [IO.Path]::GetFullPath($repositoryRoot) } + [IO.File]::WriteAllText($preflight, (($preflightRecord | ConvertTo-Json -Depth 8) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + $upload = New-CcodTask6DraftAdapterFixture -AssetDirectory $assetFixture.Root -UploadFails -UploadFailurePublishes + Assert-CcodThrows { Invoke-CcodTask6DraftCore -Module $module -Mode Stage -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory $assetFixture.Outside -Adapters $upload.Adapters | Out-Null } 'CCOD_GITHUB_DRAFT_UPLOAD_FAILED' + Assert-CcodThrows { Invoke-CcodTask6DraftCore -Module $module -Mode Stage -Tag 'v2.5.22' -AssetDirectory 'relative-assets' -EvidenceDirectory $assetFixture.Outside -Adapters $upload.Adapters | Out-Null } 'CCOD_GITHUB_DRAFT_ASSET_PATH_INVALID' + Assert-CcodThrows { Invoke-CcodTask6DraftCore -Module $module -Mode Stage -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory 'relative-evidence' -Adapters $upload.Adapters | Out-Null } 'CCOD_GITHUB_DRAFT_EVIDENCE_PATH_INVALID' + Assert-CcodThrows { Invoke-CcodTask6DraftCore -Module $module -Mode Stage -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory $assetFixture.Outside -NotesPath 'tests/persistence/ReleaseWorkflow.SelfTest.ps1' -Adapters $upload.Adapters | Out-Null } 'CCOD_GITHUB_DRAFT_NOTES_INVALID' + Assert-CcodTrue $upload.State.Created 'upload failure still created a draft' + Assert-CcodTrue $upload.State.DraftPrivate 'upload failure leaves the draft private' + Assert-CcodTrue (-not $upload.State.Promoted) 'upload failure never promotes' + $busy = New-CcodTask6DraftAdapterFixture -AssetDirectory $assetFixture.Root -ConcurrentStage + Assert-CcodThrows { Invoke-CcodTask6DraftCore -Module $module -Mode Stage -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory $assetFixture.Outside -Adapters $busy.Adapters | Out-Null } 'CCOD_GITHUB_DRAFT_CONCURRENT' + Assert-CcodTrue (-not $busy.State.Created) 'concurrent stage does not create a second draft' + Assert-CcodTrue (-not $busy.State.Promoted) 'concurrent stage never promotes' + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + if ($null -ne $assetFixture) { Remove-CcodTask5ExactAssetFixture $assetFixture } + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } + } +} + +Invoke-CcodTask6Test 'draft-verify-promote' 'Verify and Promote cannot rebuild and Promote fail-closes missing dual receipt or acceptance' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools\Invoke-GitHubDraftRelease.ps1') -Name 'CcodGitHubDraftRelease' + $assetFixture = $null + $promotion = $null + try { + $assetFixture = New-CcodTask5ExactAssetFixture + $evidence = $assetFixture.Outside + $preflightRecord = [ordered]@{ schemaVersion = 1; valid = $true; version = '2.5.22'; gitCommit = [string]$assetFixture.GitCommit; repositoryRoot = [IO.Path]::GetFullPath($repositoryRoot) } + [IO.File]::WriteAllText((Join-Path $evidence 'CodexRemote-fix-2.5.22-clean-preflight.json'), (($preflightRecord | ConvertTo-Json -Depth 8) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + $verify = New-CcodTask6DraftAdapterFixture -AssetDirectory $assetFixture.Root + Assert-CcodThrows { Invoke-CcodTask6DraftCore -Module $module -Mode Verify -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory $evidence -Adapters $verify.Adapters | Out-Null } 'CCOD_GITHUB_DRAFT_NOT_STAGED' + Assert-CcodTrue (-not $verify.State.Rebuilt) 'Verify never rebuilds' + Assert-CcodTrue (-not $verify.State.Created) 'Verify never creates a release' + Assert-CcodTrue (-not $verify.State.Promoted) 'Verify never promotes' + $promote = New-CcodTask6DraftAdapterFixture -AssetDirectory $assetFixture.Root + [IO.Directory]::CreateDirectory((Join-Path $evidence 'defender')) | Out-Null + Assert-CcodThrows { Invoke-CcodTask6DraftCore -Module $module -Mode Promote -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory $evidence -Adapters $promote.Adapters | Out-Null } 'CCOD_RELEASE_PROMOTION_EVIDENCE_INVALID' + Assert-CcodTrue (-not $promote.State.Promoted) 'missing dual receipt never promotes' + Assert-CcodTrue (-not $promote.State.Rebuilt) 'Promote never rebuilds' + $promotion = New-CcodTask5PromotionFixture $assetFixture + $promoteEvidence = $promotion.Root + $defender = Join-Path $promoteEvidence 'defender' + [IO.Directory]::CreateDirectory($defender) | Out-Null + foreach ($name in @($promotion.Names)) { Move-Item -LiteralPath (Join-Path $promoteEvidence $name) -Destination (Join-Path $defender $name) } + $preflightRecord = [ordered]@{ schemaVersion = 1; valid = $true; version = '2.5.22'; gitCommit = [string]$assetFixture.GitCommit; repositoryRoot = [IO.Path]::GetFullPath($repositoryRoot) } + [IO.File]::WriteAllText((Join-Path $promoteEvidence 'CodexRemote-fix-2.5.22-clean-preflight.json'), (($preflightRecord | ConvertTo-Json -Depth 8) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + $missingAcceptance = New-CcodTask6DraftAdapterFixture -AssetDirectory $assetFixture.Root + Assert-CcodThrows { Invoke-CcodTask6DraftCore -Module $module -Mode Promote -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory $promoteEvidence -Adapters $missingAcceptance.Adapters | Out-Null } 'CCOD_GITHUB_DRAFT_ACCEPTANCE_MISSING' + Assert-CcodTrue (-not $missingAcceptance.State.Promoted) 'missing acceptance never promotes' + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + if ($null -ne $promotion -and (Test-Path -LiteralPath $promotion.Root)) { Remove-Item -LiteralPath $promotion.Root -Recurse -Force } + if ($null -ne $assetFixture) { Remove-CcodTask5ExactAssetFixture $assetFixture } + } +} + +Invoke-CcodTask6Test 'task9-promote-incomplete' 'Promote rejects the legacy acceptance envelope before changing draft visibility' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools\Invoke-GitHubDraftRelease.ps1') -Name 'CcodGitHubDraftReleaseTask9PromoteIncomplete' + $assetFixture = $null + $promotion = $null + try { + $assetFixture = New-CcodTask5ExactAssetFixture + $promotion = New-CcodTask5PromotionFixture $assetFixture + $defender = Join-Path $promotion.Root 'defender' + [IO.Directory]::CreateDirectory($defender) | Out-Null + foreach ($name in @($promotion.Names)) { + Move-Item -LiteralPath (Join-Path $promotion.Root $name) -Destination (Join-Path $defender $name) + } + $preflightRecord = [ordered]@{ + schemaVersion = 1 + valid = $true + version = '2.5.22' + gitCommit = [string]$assetFixture.GitCommit + repositoryRoot = [IO.Path]::GetFullPath($repositoryRoot) + } + [IO.File]::WriteAllText( + (Join-Path $promotion.Root 'CodexRemote-fix-2.5.22-clean-preflight.json'), + (($preflightRecord | ConvertTo-Json -Depth 8) + [Environment]::NewLine), + [Text.UTF8Encoding]::new($false)) + $expected = [string[]]$assetFixture.Names + $fixture = New-CcodTask6DraftAdapterFixture -AssetDirectory $assetFixture.Root + $fixture.State.Uploaded.AddRange($expected) + Invoke-CcodTask6DraftCore -Module $module -Mode Verify -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory $promotion.Root -Adapters $fixture.Adapters | Out-Null + $acceptanceDirectory = Join-Path $promotion.Root 'acceptance' + [IO.Directory]::CreateDirectory($acceptanceDirectory) | Out-Null + $legacyAcceptance = [ordered]@{ + schemaVersion = 1 + kind = 'official-draft-acceptance' + version = '2.5.22' + gitCommit = [string]$assetFixture.GitCommit + candidateManifestSha256 = Get-CcodTestFileSha256 (Join-Path $assetFixture.Root $assetFixture.Names[4]) + phase = 'Complete' + completedAtUtc = '2030-02-03T04:05:07.0000000Z' + } + [IO.File]::WriteAllText( + (Join-Path $acceptanceDirectory 'CodexRemote-fix-2.5.22-official-draft.complete.json'), + (($legacyAcceptance | ConvertTo-Json -Depth 8) + [Environment]::NewLine), + [Text.UTF8Encoding]::new($false)) + Assert-CcodThrows { + Invoke-CcodTask6DraftCore -Module $module -Mode Promote -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory $promotion.Root -Adapters $fixture.Adapters | Out-Null + } 'CCOD_GITHUB_DRAFT_ACCEPTANCE_INCOMPLETE' + Assert-CcodTrue (-not $fixture.State.Promoted) 'incomplete acceptance never changes draft visibility' + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + if ($null -ne $promotion -and (Test-Path -LiteralPath $promotion.Root)) { Remove-Item -LiteralPath $promotion.Root -Recurse -Force -ErrorAction SilentlyContinue } + if ($null -ne $assetFixture) { Remove-CcodTask5ExactAssetFixture $assetFixture } + } +} + +Invoke-CcodTask6Test 'fix1-runner-inspection' 'clean runner converts inspection failures into stable fail-closed errors' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Test-CleanReleaseRunner.ps1') -Name 'CcodCleanReleaseRunnerFix1Inspection' + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-task6-runner-inspection-' + [guid]::NewGuid().ToString('N')) + try { + [IO.Directory]::CreateDirectory($root) | Out-Null + $fixture = New-CcodTask6CleanAdapterFixture + $fixture.Adapters.GetGitPorcelain = { param($Root) throw 'git inspection unavailable' }.GetNewClosure() + Assert-CcodThrows { Invoke-CcodTask6CleanCore -Module $module -RepositoryRoot $root -ExpectedVersion '2.5.22' -Adapters $fixture.Adapters | Out-Null } 'CCOD_CLEAN_RUNNER_INSPECTION_FAILED' + Assert-CcodEqual 0 $fixture.State.Cleanup.Count 'git inspection failure does not invoke cleanup' + $fixture = New-CcodTask6CleanAdapterFixture + $fixture.Adapters.GetProductState = { param($Root) throw 'product inspection denied' }.GetNewClosure() + Assert-CcodThrows { Invoke-CcodTask6CleanCore -Module $module -RepositoryRoot $root -ExpectedVersion '2.5.22' -Adapters $fixture.Adapters | Out-Null } 'CCOD_CLEAN_RUNNER_INSPECTION_FAILED' + Assert-CcodEqual 0 $fixture.State.Cleanup.Count 'product inspection failure does not invoke cleanup' + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } + } +} + +Invoke-CcodTask6Test 'fix1-preflight-binding' 'Stage rejects an unbound or malformed transferred clean preflight before creating a draft' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name 'CcodGitHubDraftReleaseFix1Preflight' + $assetFixture = $null + try { + $assetFixture = New-CcodTask5ExactAssetFixture + $evidence = $assetFixture.Outside + $preflight = Join-Path $evidence 'CodexRemote-fix-2.5.22-clean-preflight.json' + $record = [ordered]@{ schemaVersion = 1; valid = $true; version = '2.5.21'; gitCommit = [string]$assetFixture.GitCommit; repositoryRoot = [IO.Path]::GetFullPath($repositoryRoot) } + [IO.File]::WriteAllText($preflight, (($record | ConvertTo-Json -Depth 8) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + $fixture = New-CcodTask6DraftAdapterFixture -AssetDirectory $assetFixture.Root + Assert-CcodThrows { Invoke-CcodTask6DraftCore -Module $module -Mode Stage -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory $evidence -Adapters $fixture.Adapters | Out-Null } 'CCOD_GITHUB_DRAFT_PREFLIGHT_INVALID' + Assert-CcodTrue (-not $fixture.State.Created) 'invalid preflight does not create a draft' + Assert-CcodTrue (-not $fixture.State.Promoted) 'invalid preflight does not promote' + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + if ($null -ne $assetFixture) { Remove-CcodTask5ExactAssetFixture $assetFixture } + } +} + +Invoke-CcodTask6Test 'fix1-preflight-all-modes' 'Verify and Promote require the transferred clean preflight before accepting or publishing evidence' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name 'CcodGitHubDraftReleaseFix1PreflightAllModes' + $assetFixture = $null + $promotion = $null + try { + $assetFixture = New-CcodTask5ExactAssetFixture + $evidence = Join-Path $assetFixture.Outside 'verify-without-preflight' + [IO.Directory]::CreateDirectory($evidence) | Out-Null + $expected = [string[]]$assetFixture.Names + $verify = New-CcodTask6DraftAdapterFixture -AssetDirectory $assetFixture.Root + $verify.State.Uploaded.AddRange($expected) + Assert-CcodThrows { + Invoke-CcodTask6DraftCore -Module $module -Mode Verify -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory $evidence -Adapters $verify.Adapters | Out-Null + } 'CCOD_GITHUB_DRAFT_PREFLIGHT_MISSING' + Assert-CcodTrue (-not (Test-Path -LiteralPath (Join-Path $evidence 'verification') -PathType Container)) 'Verify without preflight writes no verification evidence' + + $promotion = New-CcodTask5PromotionFixture $assetFixture + $defender = Join-Path $promotion.Root 'defender' + $verificationDirectory = Join-Path $promotion.Root 'verification' + $acceptanceDirectory = Join-Path $promotion.Root 'acceptance' + [IO.Directory]::CreateDirectory($defender) | Out-Null + foreach ($name in @($promotion.Names)) { Move-Item -LiteralPath (Join-Path $promotion.Root $name) -Destination (Join-Path $defender $name) } + $preflightPath = Join-Path $promotion.Root 'CodexRemote-fix-2.5.22-clean-preflight.json' + $preflightRecord = [ordered]@{ schemaVersion = 1; valid = $true; version = '2.5.22'; gitCommit = [string]$assetFixture.GitCommit; repositoryRoot = [IO.Path]::GetFullPath($repositoryRoot) } + [IO.File]::WriteAllText($preflightPath, (($preflightRecord | ConvertTo-Json -Depth 8) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + $promote = New-CcodTask6DraftAdapterFixture -AssetDirectory $assetFixture.Root + $promote.State.Uploaded.AddRange($expected) + Invoke-CcodTask6DraftCore -Module $module -Mode Verify -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory $promotion.Root -Adapters $promote.Adapters | Out-Null + Remove-Item -LiteralPath $preflightPath -Force + $acceptanceRecord = New-CcodTask9AcceptanceRecord -AssetFixture $assetFixture -EvidenceRoot (Get-CcodTask9EvidenceRoot) + [IO.Directory]::CreateDirectory($acceptanceDirectory) | Out-Null + [IO.File]::WriteAllText((Join-Path $acceptanceDirectory 'CodexRemote-fix-2.5.22-official-draft.complete.json'), (($acceptanceRecord | ConvertTo-Json -Depth 8) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + Assert-CcodThrows { + Invoke-CcodTask6DraftCore -Module $module -Mode Promote -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory $promotion.Root -Adapters $promote.Adapters | Out-Null + } 'CCOD_GITHUB_DRAFT_PREFLIGHT_MISSING' + Assert-CcodTrue (-not $promote.State.Promoted) 'Promote without preflight never changes visibility' + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + if ($null -ne $promotion -and (Test-Path -LiteralPath $promotion.Root)) { Remove-Item -LiteralPath $promotion.Root -Recurse -Force -ErrorAction SilentlyContinue } + if ($null -ne $assetFixture) { Remove-CcodTask5ExactAssetFixture $assetFixture } + } +} + +Invoke-CcodTask6Test 'fix1-remote-contract' 'Verify rejects null, public, and non-exact remote draft responses' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name 'CcodGitHubDraftReleaseFix1Remote' + $assetFixture = $null + try { + $assetFixture = New-CcodTask5ExactAssetFixture + $evidence = $assetFixture.Outside + $preflightRecord = [ordered]@{ schemaVersion = 1; valid = $true; version = '2.5.22'; gitCommit = [string]$assetFixture.GitCommit; repositoryRoot = [IO.Path]::GetFullPath($repositoryRoot) } + [IO.File]::WriteAllText((Join-Path $evidence 'CodexRemote-fix-2.5.22-clean-preflight.json'), (($preflightRecord | ConvertTo-Json -Depth 8) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + $expected = @(Get-CcodTask5ExpectedAssetNames '2.5.22') + $nullView = New-CcodTask6DraftAdapterFixture -AssetDirectory $assetFixture.Root + $nullView.Adapters.ViewRelease = { param($Tag) $null }.GetNewClosure() + Assert-CcodThrows { Invoke-CcodTask6DraftCore -Module $module -Mode Verify -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory $evidence -Adapters $nullView.Adapters | Out-Null } 'CCOD_GITHUB_DRAFT_NOT_STAGED' + $publicView = New-CcodTask6DraftAdapterFixture -AssetDirectory $assetFixture.Root + $publicView.State.Uploaded.AddRange([string[]]$expected) + $publicView.State.DraftPrivate = $false + $publicView.Adapters.ViewRelease = { param($Tag) [pscustomobject][ordered]@{ Tag = $Tag; Id = '123'; Draft = $false; AssetNames = @($expected) } }.GetNewClosure() + Assert-CcodThrows { Invoke-CcodTask6DraftCore -Module $module -Mode Verify -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory $evidence -Adapters $publicView.Adapters | Out-Null } 'CCOD_GITHUB_DRAFT_NOT_STAGED' + $wrongNames = New-CcodTask6DraftAdapterFixture -AssetDirectory $assetFixture.Root + $wrong = @($expected) + $wrong[0] = 'unexpected-release-asset.zip' + $wrongNames.State.Uploaded.AddRange([string[]]$wrong) + $wrongNames.Adapters.ViewRelease = { param($Tag) [pscustomobject][ordered]@{ Tag = $Tag; Id = '123'; Draft = $true; AssetNames = @($wrong) } }.GetNewClosure() + Assert-CcodThrows { Invoke-CcodTask6DraftCore -Module $module -Mode Verify -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory $evidence -Adapters $wrongNames.Adapters | Out-Null } 'CCOD_GITHUB_DRAFT_ASSET_SET_INVALID' + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + if ($null -ne $assetFixture) { Remove-CcodTask5ExactAssetFixture $assetFixture } + } +} + +Invoke-CcodTask6Test 'fix1-verify-final-remote-lineage' 'Verify rechecks private draft identity and tag commit after the last asset download' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name 'CcodGitHubDraftReleaseFix1VerifyFinalLineage' + $assetFixture = $null;$evidence = $null + try { + $assetFixture = New-CcodTask5ExactAssetFixture;$evidence = $assetFixture.Outside + $preflightRecord = [ordered]@{ schemaVersion = 1; valid = $true; version = '2.5.22'; gitCommit = [string]$assetFixture.GitCommit; repositoryRoot = [IO.Path]::GetFullPath($repositoryRoot) } + [IO.File]::WriteAllText((Join-Path $evidence 'CodexRemote-fix-2.5.22-clean-preflight.json'), (($preflightRecord | ConvertTo-Json -Depth 8) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + $expected = [string[]]$assetFixture.Names;$remoteRoot = Join-Path $assetFixture.Outside 'verify-remote-assets';[IO.Directory]::CreateDirectory($remoteRoot) | Out-Null + $draft = New-CcodTask6DraftAdapterFixture -AssetDirectory $assetFixture.Root;$state = $draft.State;$state.Uploaded.AddRange($expected);$state | Add-Member -NotePropertyName DownloadCount -NotePropertyValue 0 + $draft.Adapters.ViewRelease = { param($Tag) [pscustomobject][ordered]@{ Tag = $Tag; Id = [string]$state.DraftId; Draft = [bool]$state.DraftPrivate; AssetNames = @($state.Uploaded) } }.GetNewClosure() + $draft.Adapters.DownloadAsset = { param($Tag,$Name,$Destination) [IO.File]::Copy((Join-Path $assetFixture.Root $Name),$Destination,$true);$state.DownloadCount++ }.GetNewClosure() + $verificationPath = & $module {param($Evidence) Get-CcodGitHubDraftVerificationPath -EvidenceDirectory $Evidence -Version '2.5.22'} $evidence + $control=Invoke-CcodTask6DraftCore -Module $module -Mode Verify -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory $evidence -Adapters $draft.Adapters + Assert-CcodEqual $verificationPath $control.VerificationPath 'absence probe points at the proven real publisher destination' + Assert-CcodTrue ([IO.File]::Exists($verificationPath)) 'success control materializes the exact target before absence checks' + [IO.File]::Delete($verificationPath);$state.DownloadCount=0 + $draft.Adapters.DownloadAsset = { param($Tag,$Name,$Destination) [IO.File]::Copy((Join-Path $assetFixture.Root $Name),$Destination,$true);$state.DownloadCount++;if($state.DownloadCount -eq $expected.Count){$state.DraftPrivate=$false} }.GetNewClosure() + Assert-CcodThrows { Invoke-CcodTask6DraftCore -Module $module -Mode Verify -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory $evidence -Adapters $draft.Adapters | Out-Null } 'CCOD_GITHUB_DRAFT_VERIFY_EVIDENCE_INVALID' + Assert-CcodEqual $expected.Count $state.DownloadCount 'Verify downloaded every asset before the final remote recheck' + Assert-CcodTrue (-not [IO.File]::Exists($verificationPath)) 'Verify does not publish evidence after a final remote identity drift' + } finally { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue;if($null -ne $assetFixture){Remove-CcodTask5ExactAssetFixture $assetFixture} } +} + +Invoke-CcodTask6Test 'fix1-github-boundary' 'draft release operations are bound to the official Actions repository without an environment bypass' { + $draftPath = Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1' + $releasePath = Join-Path $repositoryRoot '.github/workflows/release.yml' + $draftRaw = [IO.File]::ReadAllText($draftPath, [Text.UTF8Encoding]::new($false)) + $releaseRaw = [IO.File]::ReadAllText($releasePath, [Text.UTF8Encoding]::new($false)) + Assert-CcodTrue (-not ($draftRaw + $releaseRaw).Contains('CCOD_ALLOW_GITHUB_RELEASE')) 'live GitHub operations have no caller-controlled override' + Assert-CcodTrue ($draftRaw.Contains('naipi11/CodexRemote-fix')) 'GitHub target repository is explicit' + Assert-CcodTrue ($draftRaw.Contains('GITHUB_SERVER_URL') -and $draftRaw.Contains('GITHUB_RUN_ID') -and $draftRaw.Contains('GH_TOKEN')) 'default GitHub adapter checks the Actions context' + Assert-CcodTrue ($draftRaw.Contains("GITHUB_RUN_ID -notmatch '^[1-9][0-9]*\z'")) 'Actions run IDs use an absolute end anchor' + Assert-CcodTrue ($draftRaw.Contains('$value.isDraft -isnot [bool]')) 'default GitHub adapter rejects non-boolean draft state' + Assert-CcodTrue ($draftRaw.Contains("'tagName,isDraft,databaseId,assets'")) 'default GitHub adapter requests a stable draft database identity' + Assert-CcodTrue ($draftRaw.Contains('$json -join [Environment]::NewLine')) 'default GitHub adapter parses the complete multi-line JSON response' + $ghLines = @($draftRaw -split "`r?`n" | Where-Object { $_ -match '& gh ' }) + Assert-CcodTrue ($ghLines.Count -gt 0) 'default adapter has explicit GitHub operations' + foreach ($line in $ghLines) { + if ($line -match '& gh api') { + Assert-CcodTrue ($draftRaw.Contains('repos/naipi11/CodexRemote-fix/')) 'GitHub API operation pins the repository in its endpoint' + } elseif ($line -notmatch '& gh auth status') { + Assert-CcodTrue ($line.Contains('--repo')) 'every GitHub release operation pins the repository' + } + if ($line -notmatch '\$json\s*=\s*& gh ' -and $line -notmatch '& gh api ') { Assert-CcodTrue ($line.Contains('| Out-Null')) 'non-query GitHub operations do not leak CLI output into the state result' } + } +} + +Invoke-CcodTask6Test 'fix1-local-promote-context' 'authenticated local draft readback does not require the GitHub Actions environment' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name 'CcodGitHubDraftReleaseFix1LocalContext' + $fakeRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-task6-local-gh-' + [guid]::NewGuid().ToString('N')) + $fakeGh = Join-Path $fakeRoot 'gh.cmd' + $saved = @{} + try { + [IO.Directory]::CreateDirectory($fakeRoot) | Out-Null + [IO.File]::WriteAllText($fakeGh, "@echo off`r`nif `"%1`"==`"release`" if `"%2`"==`"view`" (`r`n echo {`"tagName`":`"v2.5.22`",`"isDraft`":true,`"databaseId`":123,`"assets`":[]}`r`n)`r`nexit /b 0`r`n", [Text.UTF8Encoding]::new($false)) + foreach ($name in @('GITHUB_ACTIONS','GITHUB_SERVER_URL','GITHUB_REPOSITORY','GITHUB_RUN_ID','GH_TOKEN','Path')) { $saved[$name] = [Environment]::GetEnvironmentVariable($name, 'Process') } + Remove-Item Env:GITHUB_ACTIONS,Env:GITHUB_SERVER_URL,Env:GITHUB_REPOSITORY,Env:GITHUB_RUN_ID -ErrorAction SilentlyContinue + $env:GH_TOKEN = 'x' + $env:Path = $fakeRoot + ';' + $saved.Path + $view = &$module { $adapters = Get-CcodGitHubDraftReleaseDefaultAdapters; & $adapters.ViewRelease 'v2.5.22' } + Assert-CcodTrue ($view.Draft -is [bool] -and $view.Draft) 'local authenticated draft readback is allowed without Actions context' + } finally { + foreach ($name in @('GITHUB_ACTIONS','GITHUB_SERVER_URL','GITHUB_REPOSITORY','GITHUB_RUN_ID','GH_TOKEN','Path')) { + if ($null -eq $saved[$name]) { Remove-Item ("Env:" + $name) -ErrorAction SilentlyContinue } else { Set-Item ("Env:" + $name) $saved[$name] } + } + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + if (Test-Path -LiteralPath $fakeRoot) { Remove-Item -LiteralPath $fakeRoot -Recurse -Force -ErrorAction SilentlyContinue } + } +} + +Invoke-CcodTask6Test 'fix1-pwsh-schema-version' 'preflight schemaVersion accepts the Int64 representation emitted by PowerShell Core' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name 'CcodGitHubDraftReleaseFix1PwshSchema' + $path = Join-Path ([IO.Path]::GetTempPath()) ('ccod-task6-pwsh-schema-' + [guid]::NewGuid().ToString('N') + '.json') + try { + [IO.File]::WriteAllText($path, '{}', [Text.UTF8Encoding]::new($false)) + $record = [pscustomobject][ordered]@{ + schemaVersion = [int64]1 + valid = $true + version = '2.5.22' + gitCommit = 'c' * 40 + repositoryRoot = [IO.Path]::GetFullPath($repositoryRoot) + } + &$module { param($Value) Set-Item Function:Read-CcodGitHubDraftContractJson -Value { param($JsonPath,$ErrorId) [pscustomobject]@{ Raw = '{}'; Value = $Value } }.GetNewClosure() } $record + $result = &$module { param($Path,$Version,$Commit) Read-CcodGitHubDraftPreflight -Path $Path -Version $Version -GitCommit $Commit } $path '2.5.22' ('c' * 40) + Assert-CcodEqual 1 ([long]$result.schemaVersion) 'PowerShell Core Int64 schemaVersion is accepted' + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue } + } +} + +Invoke-CcodTask6Test 'fix1-release-contract-int64-numeric-fields' 'release Defender receipts accept the Int64 numeric representation emitted by PowerShell Core' { + $module = Import-Module $assetContractPath -Force -PassThru -ErrorAction Stop + $version = '2.5.22' + $commit = 'c' * 40 + $names = @(&$module { param($Version) Get-CcodExpectedReleaseAssetNames -Version $Version } $version) + $scanStart = [datetime]::UtcNow.AddMinutes(-1) + $timestamp = $scanStart.ToString('o') + $completedTimestamp = $scanStart.AddSeconds(1).ToString('o') + $receipt = [pscustomobject][ordered]@{ + schemaVersion = [int64]2 + assetType = 'PortableZip' + assetName = $names[0] + assetSha256 = '0' * 64 + checksumName = $names[1] + checksumSha256 = '1' * 64 + manifestName = $names[4] + manifestSha256 = '2' * 64 + version = $version + gitCommit = $commit + origin = 'InternetDownload' + workflowArtifactIdentity = $null + zoneId = [int64]3 + defenderServiceEnabled = $true + antivirusEnabled = $true + realTimeProtectionEnabled = $true + defenderPlatformVersion = 'fixture-platform' + defenderEngineVersion = 'fixture-engine' + signatureVersion = 'fixture-signature' + signatureUpdatedAtUtc = $timestamp + scanStartedAtUtc = $timestamp + scanCompletedAtUtc = $completedTimestamp + detectionCount = [int64]0 + outcome = 'Completed' + errorCode = $null + } + $raw = $receipt | ConvertTo-Json -Compress -Depth 8 + try { + $validated = &$module { param($Receipt,$Raw,$Version,$Commit,$Names) Test-CcodReleasePromotionReceipt -Receipt $Receipt -Raw $Raw -Path 'fixture-receipt.json' -AssetType 'PortableZip' -Version $Version -GitCommit $Commit -AssetNames $Names } $receipt $raw $version $commit $names + Assert-CcodEqual 2 ([long]$validated.schemaVersion) 'PowerShell Core Int64 Defender receipt schemaVersion is accepted' + Assert-CcodEqual 3 ([long]$validated.zoneId) 'PowerShell Core Int64 Defender receipt zoneId is accepted' + Assert-CcodEqual 0 ([long]$validated.detectionCount) 'PowerShell Core Int64 Defender receipt detectionCount is accepted' + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + } +} + +Invoke-CcodTask6Test 'fix1-release-manifest-int64-dispatch' 'release manifests dispatch Int64 schemaVersion values to the portable validator' { + $module = Import-Module $assetContractPath -Force -PassThru -ErrorAction Stop + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-task6-manifest-dispatch-' + [guid]::NewGuid().ToString('N')) + try { + [IO.Directory]::CreateDirectory($root) | Out-Null + $manifestPath = Join-Path $root 'manifest.json' + [IO.File]::WriteAllText($manifestPath, '{}', [Text.UTF8Encoding]::new($false)) + $global:CcodReleaseManifestDispatchValue = [pscustomobject][ordered]@{ schemaVersion = [int64]2 } + $global:CcodReleaseManifestDispatchMarker = $null + &$module { + Set-Item Function:Read-CcodReleaseContractJson -Value { param($Path,$ErrorId) [pscustomobject]@{ Raw = '{}'; Value = $global:CcodReleaseManifestDispatchValue } } + Set-Item Function:Test-CcodReleasePortableManifestDeep -Value { param($Manifest,$ManifestRaw,$ManifestPath,$Directory,$Version,$ErrorId) $global:CcodReleaseManifestDispatchMarker = 'portable'; [pscustomobject]@{ Marker = 'portable' } } + } + $result = &$module { param($ManifestPath,$AssetDirectory) Test-CcodReleaseAssetManifest -ManifestPath $ManifestPath -AssetDirectory $AssetDirectory -ExpectedVersion '2.5.22' } $manifestPath $root + Assert-CcodEqual 'portable' ([string]$result.Marker) 'Int64 schemaVersion dispatches to the portable manifest validator' + Assert-CcodEqual 'portable' ([string]$global:CcodReleaseManifestDispatchMarker) 'portable dispatch marker is reached' + } finally { + Remove-Variable -Name CcodReleaseManifestDispatchValue,CcodReleaseManifestDispatchMarker -Scope Global -ErrorAction SilentlyContinue + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } + } +} + +Invoke-CcodTask6Test 'fix1-preflight-root-canonical' 'preflight rejects drive-relative repository roots' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name 'CcodGitHubDraftReleaseFix1PreflightRoot' + $path = Join-Path ([IO.Path]::GetTempPath()) ('ccod-task6-preflight-root-' + [guid]::NewGuid().ToString('N') + '.json') + try { + [IO.File]::WriteAllText($path, '{}', [Text.UTF8Encoding]::new($false)) + $record = [pscustomobject][ordered]@{ + schemaVersion = 1 + valid = $true + version = '2.5.22' + gitCommit = 'c' * 40 + repositoryRoot = 'C:relative-root' + } + &$module { param($Value) Set-Item Function:Read-CcodGitHubDraftContractJson -Value { param($JsonPath,$ErrorId) [pscustomobject]@{ Raw = '{}'; Value = $Value } }.GetNewClosure() } $record + Assert-CcodThrows { + &$module { param($Path,$Version,$Commit) Read-CcodGitHubDraftPreflight -Path $Path -Version $Version -GitCommit $Commit } $path '2.5.22' ('c' * 40) + } 'CCOD_GITHUB_DRAFT_PREFLIGHT_INVALID' + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue } + } +} + +Invoke-CcodTask6Test 'fix1-install-root-probe' 'clean runner treats a file at the install root as product state and distinguishes absence' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Test-CleanReleaseRunner.ps1') -Name 'CcodCleanReleaseRunnerFix1InstallRoot' + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-task6-install-root-' + [guid]::NewGuid().ToString('N')) + $file = Join-Path $root 'malformed-install-root' + try { + [IO.Directory]::CreateDirectory($root) | Out-Null + [IO.File]::WriteAllText($file, 'not-a-directory', [Text.UTF8Encoding]::new($false)) + $present = &$module { param($Path) Get-CcodCleanReleaseRunnerInstallRootPresent -InstallRoot $Path } $file + $missing = &$module { param($Path) Get-CcodCleanReleaseRunnerInstallRootPresent -InstallRoot $Path } (Join-Path $root 'missing') + Assert-CcodTrue ([bool]$present) 'existing file at install root is treated as present' + Assert-CcodEqual $false ([bool]$missing) 'missing install root is treated as absent' + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } + } +} + +Invoke-CcodTask6Test 'fix1-ignored-clean-runner' 'clean runner includes ignored files in the contamination check' { + $raw = [IO.File]::ReadAllText((Join-Path $repositoryRoot 'tools/Test-CleanReleaseRunner.ps1'), [Text.UTF8Encoding]::new($false)) + Assert-CcodTrue ($raw.Contains('--ignored=matching')) 'clean runner asks git status to report ignored contamination' +} + +Invoke-CcodTask6Test 'fix1-mode-normalization' 'case-insensitive mode binding dispatches lowercase verify to Verify rather than Stage' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name 'CcodGitHubDraftReleaseFix1Mode' + $assetFixture = $null + try { + $assetFixture = New-CcodTask5ExactAssetFixture + $evidence = $assetFixture.Outside + $preflightRecord = [ordered]@{ schemaVersion = 1; valid = $true; version = '2.5.22'; gitCommit = [string]$assetFixture.GitCommit; repositoryRoot = [IO.Path]::GetFullPath($repositoryRoot) } + [IO.File]::WriteAllText((Join-Path $evidence 'CodexRemote-fix-2.5.22-clean-preflight.json'), (($preflightRecord | ConvertTo-Json -Depth 8) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + $draft = New-CcodTask6DraftAdapterFixture -AssetDirectory $assetFixture.Root + foreach ($name in @($assetFixture.Names)) { $draft.State.Uploaded.Add($name) } + $result = Invoke-CcodTask6DraftCore -Module $module -Mode 'verify' -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory $evidence -Adapters $draft.Adapters + Assert-CcodEqual 'Verify' ([string]$result.Mode) 'lowercase verify dispatches to the Verify mode' + Assert-CcodEqual 0 (@($draft.State.Calls | Where-Object { $_ -eq 'CreateDraft' }).Count) 'lowercase verify does not create a release' + Assert-CcodEqual 0 (@($draft.State.Calls | Where-Object { [string]$_ -like 'UploadAsset:*' }).Count) 'lowercase verify does not upload assets' + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + if ($null -ne $assetFixture) { Remove-CcodTask5ExactAssetFixture $assetFixture } + } +} + +Invoke-CcodTask6Test 'fix1-tag-commit-binding' 'release operations bind the remote tag to the immutable candidate commit' { + $draft = [IO.File]::ReadAllText((Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1'), [Text.UTF8Encoding]::new($false)) + Assert-CcodTrue ($draft.Contains('GetTagCommit')) 'draft tool resolves the remote tag commit' + Assert-CcodTrue ($draft.Contains('repos/naipi11/CodexRemote-fix/commits/')) 'tag resolution is bound to the official repository' + Assert-CcodTrue ($draft.Contains("'--verify-tag'")) 'draft creation verifies the existing remote tag' +} + +Invoke-CcodTask6Test 'fix1-create-private-readback' 'Stage confirms the newly created release is still private before uploading the first asset' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name 'CcodGitHubDraftReleaseFix1CreatePrivate' + $assetFixture = $null + try { + $assetFixture = New-CcodTask5ExactAssetFixture + $evidence = $assetFixture.Outside + $preflightRecord = [ordered]@{ schemaVersion = 1; valid = $true; version = '2.5.22'; gitCommit = [string]$assetFixture.GitCommit; repositoryRoot = [IO.Path]::GetFullPath($repositoryRoot) } + [IO.File]::WriteAllText((Join-Path $evidence 'CodexRemote-fix-2.5.22-clean-preflight.json'), (($preflightRecord | ConvertTo-Json -Depth 8) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + $draft = New-CcodTask6DraftAdapterFixture -AssetDirectory $assetFixture.Root + Invoke-CcodTask6DraftCore -Module $module -Mode Stage -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory $evidence -Adapters $draft.Adapters | Out-Null + $firstUpload = @($draft.State.Calls | Where-Object { [string]$_ -like 'UploadAsset:*' })[0] + $firstUploadIndex = [array]::IndexOf([string[]]$draft.State.Calls, [string]$firstUpload) + $firstViewIndex = [array]::IndexOf([string[]]$draft.State.Calls, 'ViewRelease') + Assert-CcodTrue ($firstViewIndex -ge 0 -and $firstViewIndex -lt $firstUploadIndex) 'private release state is read before the first asset upload' + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + if ($null -ne $assetFixture) { Remove-CcodTask5ExactAssetFixture $assetFixture } + } +} + +Invoke-CcodTask6Test 'fix1-stage-readback-private-recovery' 'Stage reasserts private visibility when post-upload asset readback fails' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name 'CcodGitHubDraftReleaseFix1StageReadback' + $assetFixture = $null + try { + $assetFixture = New-CcodTask5ExactAssetFixture + $evidence = $assetFixture.Outside + $preflightRecord = [ordered]@{ schemaVersion = 1; valid = $true; version = '2.5.22'; gitCommit = [string]$assetFixture.GitCommit; repositoryRoot = [IO.Path]::GetFullPath($repositoryRoot) } + [IO.File]::WriteAllText((Join-Path $evidence 'CodexRemote-fix-2.5.22-clean-preflight.json'), (($preflightRecord | ConvertTo-Json -Depth 8) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + $draft = New-CcodTask6DraftAdapterFixture -AssetDirectory $assetFixture.Root -ReadbackFails -ReadbackFailurePublishes + Assert-CcodThrows { Invoke-CcodTask6DraftCore -Module $module -Mode Stage -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory $evidence -Adapters $draft.Adapters | Out-Null } 'CCOD_GITHUB_DRAFT_READBACK_FAILED' + Assert-CcodTrue $draft.State.DraftPrivate 'post-upload readback failure restores private draft visibility' + Assert-CcodTrue (-not $draft.State.Promoted) 'post-upload readback failure never promotes' + $viewCalls = @($draft.State.Calls | Where-Object { $_ -eq 'ViewRelease' }) + Assert-CcodTrue ($viewCalls.Count -ge 3) 'post-upload readback failure performs a recovery state readback' + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + if ($null -ne $assetFixture) { Remove-CcodTask5ExactAssetFixture $assetFixture } + } +} + +Invoke-CcodTask6Test 'fix1-stage-final-private-readback' 'Stage rejects a visibility change after the final asset readback' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name 'CcodGitHubDraftReleaseFix1StageFinalPrivate' + $assetFixture = $null + try { + $assetFixture = New-CcodTask5ExactAssetFixture + $evidence = $assetFixture.Outside + $preflightRecord = [ordered]@{ schemaVersion = 1; valid = $true; version = '2.5.22'; gitCommit = [string]$assetFixture.GitCommit; repositoryRoot = [IO.Path]::GetFullPath($repositoryRoot) } + [IO.File]::WriteAllText((Join-Path $evidence 'CodexRemote-fix-2.5.22-clean-preflight.json'), (($preflightRecord | ConvertTo-Json -Depth 8) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + $draft = New-CcodTask6DraftAdapterFixture -AssetDirectory $assetFixture.Root + $draft.Adapters.DownloadAsset = { + param($Tag, $Name, $Destination) + [IO.File]::Copy((Join-Path $assetFixture.Root $Name), $Destination, $true) + $draft.State.DraftPrivate = $false + }.GetNewClosure() + Assert-CcodThrows { Invoke-CcodTask6DraftCore -Module $module -Mode Stage -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory $evidence -Adapters $draft.Adapters | Out-Null } 'CCOD_GITHUB_DRAFT_NOT_STAGED' + Assert-CcodTrue $draft.State.DraftPrivate 'final stage private readback restores visibility before returning failure' + Assert-CcodTrue (-not $draft.State.Promoted) 'final stage visibility drift never promotes' + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + if ($null -ne $assetFixture) { Remove-CcodTask5ExactAssetFixture $assetFixture } + } +} + +Invoke-CcodTask6Test 'fix1-stage-final-tag-binding' 'Stage checks the remote tag commit after the final asset upload' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name 'CcodGitHubDraftReleaseFix1StageFinalTag' + $assetFixture = $null + try { + $assetFixture = New-CcodTask5ExactAssetFixture + $evidence = $assetFixture.Outside + $preflightRecord = [ordered]@{ schemaVersion = 1; valid = $true; version = '2.5.22'; gitCommit = [string]$assetFixture.GitCommit; repositoryRoot = [IO.Path]::GetFullPath($repositoryRoot) } + [IO.File]::WriteAllText((Join-Path $evidence 'CodexRemote-fix-2.5.22-clean-preflight.json'), (($preflightRecord | ConvertTo-Json -Depth 8) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + $draft = New-CcodTask6DraftAdapterFixture -AssetDirectory $assetFixture.Root + $state = $draft.State + $state | Add-Member -NotePropertyName FinalTagDriftObserved -NotePropertyValue $false + $expectedNames = [string[]]$assetFixture.Names + $draft.Adapters.GetTagCommit = { + param($Tag, [bool]$ActionsOnly) + if ($state.Uploaded.Count -ge $expectedNames.Count) { $state.FinalTagDriftObserved = $true; return 'd' * 40 } + return 'c' * 40 + }.GetNewClosure() + Assert-CcodThrows { Invoke-CcodTask6DraftCore -Module $module -Mode Stage -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory $evidence -Adapters $draft.Adapters | Out-Null } 'CCOD_GITHUB_DRAFT_UPLOAD_FAILED' + Assert-CcodEqual $expectedNames.Count $state.Uploaded.Count 'all fixture assets were uploaded before the final tag change' + Assert-CcodTrue $state.FinalTagDriftObserved 'Stage actually reaches and rejects the final tag-commit observation' + Assert-CcodTrue $draft.State.DraftPrivate 'final tag mismatch keeps the draft private' + Assert-CcodTrue (-not $draft.State.Promoted) 'final tag mismatch never promotes' + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + if ($null -ne $assetFixture) { Remove-CcodTask5ExactAssetFixture $assetFixture } + } +} + +Invoke-CcodTask6Test 'fix1-default-adapter-property-order' 'Default GitHub adapter readbacks preserve the strict remote-view property order' { + $source = [IO.File]::ReadAllText((Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1')) + Assert-CcodTrue ($source.Contains('[pscustomobject][ordered]@{ Tag = $Tag; Id = $id; Draft = $true; AssetNames = @() }')) 'CreateDraft returns an ordered remote view object' + Assert-CcodTrue ($source.Contains('[pscustomobject][ordered]@{ Tag = [string]$value.tagName; Id = $id; Draft = $value.isDraft; AssetNames = [string[]]$assetNames }')) 'ViewRelease returns an ordered remote view object' +} + +Invoke-CcodTask6Test 'fix1-stage-private-recovery-validates-view-before-mutation' 'Stage private recovery validates the bound release identity before changing visibility' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name 'CcodGitHubDraftReleaseFix1StageRecoveryView' + $assetFixture = $null + try { + $assetFixture = New-CcodTask5ExactAssetFixture + $draft = New-CcodTask6DraftAdapterFixture -AssetDirectory $assetFixture.Root + $state = $draft.State + $state | Add-Member -NotePropertyName PrivateSetCalls -NotePropertyValue 0 + $state.DraftPrivate = $false + $draft.Adapters.ViewRelease = { + param($Tag) + [pscustomobject][ordered]@{ Tag = $Tag; Id = [string]$state.DraftId; Draft = [bool]$state.DraftPrivate; AssetNames = @('unexpected.bin') } + }.GetNewClosure() + $draft.Adapters.SetReleaseDraftState = { + param($Tag, [bool]$Draft) + if ($Draft) { $state.PrivateSetCalls++ } + $state.DraftPrivate = $Draft + }.GetNewClosure() + & $module {param($Adapters) Restore-CcodGitHubDraftStagePrivateState -Adapters $Adapters -Tag 'v2.5.22' -Version '2.5.22' -DraftId '123' -ExpectedGitCommit ('c'*40)} $draft.Adapters + Assert-CcodTrue $state.DraftPrivate 'same-ID control hides unexpected content before mutating only the release ID' + $state.PrivateSetCalls = 0 + $state.DraftPrivate = $false + $state.DraftId = '999' + Assert-CcodThrows { + &$module { + param($Adapters) + Restore-CcodGitHubDraftStagePrivateState -Adapters $Adapters -Tag 'v2.5.22' -Version '2.5.22' -DraftId '123' -ExpectedGitCommit ('c' * 40) + } $draft.Adapters + } 'CCOD_GITHUB_DRAFT_UPLOAD_PRIVATE_RECOVERY_FAILED' + Assert-CcodEqual 0 $state.PrivateSetCalls 'stage recovery does not mutate an invalid release view' + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + if ($null -ne $assetFixture) { Remove-CcodTask5ExactAssetFixture $assetFixture } + } +} + +Invoke-CcodTask6Test 'fix1-acceptance-module-import-path-safety' 'Acceptance validator import is guarded by plain-file and reparse ancestry validation' { + $toolPath = Join-Path $repositoryRoot 'tools\Invoke-GitHubDraftRelease.ps1' + $source = [IO.File]::ReadAllText($toolPath, [Text.UTF8Encoding]::new($false)) + Assert-CcodTrue ($source.Contains('Assert-CcodGitHubDraftTrustedModulePath -Path $acceptanceModulePath -Directory $false')) 'acceptance import uses the complete trusted ancestry validator' + $module = Import-CcodTask6ToolModule -Path $toolPath -Name 'CcodGitHubDraftTrustedModulePath' + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-trusted-module-' + [guid]::NewGuid().ToString('N')) + $link = Join-Path $root 'redirected' + try { + $modules = Join-Path $root 'plain\modules' + [IO.Directory]::CreateDirectory($modules) | Out-Null + $plain = Join-Path $modules 'validator.psm1' + [IO.File]::WriteAllText($plain, '# inert module path fixture', [Text.UTF8Encoding]::new($false)) + $validated = &$module { param($Path) Assert-CcodGitHubDraftTrustedModulePath -Path $Path -Directory $false } $plain + Assert-CcodEqual $plain $validated 'regular module with plain ancestry is accepted' + New-Item -ItemType Junction -Path $link -Target (Join-Path $root 'plain') | Out-Null + Assert-CcodThrows { + &$module { param($Path) Assert-CcodGitHubDraftTrustedModulePath -Path $Path -Directory $false } (Join-Path $link 'modules\validator.psm1') | Out-Null + } 'CCOD_GITHUB_DRAFT_CONTRACT_MISSING' + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + if ([IO.Directory]::Exists($link)) { [IO.Directory]::Delete($link, $false) } + if ([IO.Directory]::Exists($root)) { [IO.Directory]::Delete($root, $true) } + } +} + +Invoke-CcodTask6Test 'fix1-create-attempt-recovery' 'Stage records the create attempt before invoking remote draft creation' { + $source = [IO.File]::ReadAllText((Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1'), [Text.UTF8Encoding]::new($false)) + Assert-CcodTrue ($source.Contains('$createAttempted = $true')) 'Stage records remote create before the adapter call' + Assert-CcodTrue ($source.Contains('if ($created -or $createAttempted)')) 'Stage recovery covers a create adapter failure after remote mutation' +} + +Invoke-CcodTask6Test 'fix1-create-attempt-tag-identity' 'Stage rejects ambiguous creation without changing visibility of an unowned release' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name 'CcodGitHubDraftReleaseFix1CreateAttemptTagIdentity' + $assetFixture = $null + try { + $assetFixture = New-CcodTask5ExactAssetFixture + $evidence = $assetFixture.Outside + $preflightRecord = [ordered]@{ schemaVersion = 1; valid = $true; version = '2.5.22'; gitCommit = [string]$assetFixture.GitCommit; repositoryRoot = [IO.Path]::GetFullPath($repositoryRoot) } + [IO.File]::WriteAllText((Join-Path $evidence 'CodexRemote-fix-2.5.22-clean-preflight.json'), (($preflightRecord | ConvertTo-Json -Depth 8) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + $draft = New-CcodTask6DraftAdapterFixture -AssetDirectory $assetFixture.Root + $state = $draft.State + $state | Add-Member -NotePropertyName SetCalls -NotePropertyValue 0 + $draft.Adapters.CreateDraft = { + param($Tag, $Title, $Notes) + $state.Created = $true + $state.DraftPrivate = $false + throw 'create failed after remote mutation' + }.GetNewClosure() + $draft.Adapters.GetTagCommit = { param($Tag, [bool]$ActionsOnly) if ($state.Created) { 'd' * 40 } else { 'c' * 40 } }.GetNewClosure() + $draft.Adapters.SetReleaseDraftState = { + param($Tag, [bool]$Draft) + $state.SetCalls++ + $state.DraftPrivate = $Draft + }.GetNewClosure() + Assert-CcodThrows { + Invoke-CcodTask6DraftCore -Module $module -Mode Stage -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory $evidence -Adapters $draft.Adapters | Out-Null + } 'CCOD_GITHUB_DRAFT_CREATE_AMBIGUOUS' + Assert-CcodTrue $state.Created 'fixture reaches creation before returning an ambiguous failure' + Assert-CcodEqual 0 $state.SetCalls 'ambiguous creation never gains authority to mutate release visibility' + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + if ($null -ne $assetFixture) { Remove-CcodTask5ExactAssetFixture $assetFixture } + } +} + +Invoke-CcodTask6Test 'fix1-stage-private-recovery-tag-identity' 'Stage private recovery contains a bound draft when its tag commit drifts' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name 'CcodGitHubDraftReleaseFix1StageRecoveryTagIdentity' + $assetFixture = $null + try { + $assetFixture = New-CcodTask5ExactAssetFixture + $evidence = $assetFixture.Outside + $preflightRecord = [ordered]@{ schemaVersion = 1; valid = $true; version = '2.5.22'; gitCommit = [string]$assetFixture.GitCommit; repositoryRoot = [IO.Path]::GetFullPath($repositoryRoot) } + [IO.File]::WriteAllText((Join-Path $evidence 'CodexRemote-fix-2.5.22-clean-preflight.json'), (($preflightRecord | ConvertTo-Json -Depth 8) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + $draft = New-CcodTask6DraftAdapterFixture -AssetDirectory $assetFixture.Root + $state = $draft.State + $state | Add-Member -NotePropertyName UploadAttempted -NotePropertyValue $false + $state | Add-Member -NotePropertyName SetCalls -NotePropertyValue 0 + $draft.Adapters.GetTagCommit = { param($Tag, [bool]$ActionsOnly) if ($state.UploadAttempted) { 'd' * 40 } else { 'c' * 40 } }.GetNewClosure() + $draft.Adapters.UploadAsset = { + param($Tag, $Name, $Path) + $state.UploadAttempted = $true + $state.DraftPrivate = $false + throw 'upload failed after remote mutation' + }.GetNewClosure() + $draft.Adapters.SetReleaseDraftState = { + param($Tag, [bool]$Draft, $ExpectedReleaseId) + if ($ExpectedReleaseId -cne '123') { throw 'stage recovery ID was not bound' } + $state.SetCalls++ + $state.DraftPrivate = $Draft + }.GetNewClosure() + Assert-CcodThrows { + Invoke-CcodTask6DraftCore -Module $module -Mode Stage -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory $evidence -Adapters $draft.Adapters | Out-Null + } 'CCOD_GITHUB_DRAFT_UPLOAD_FAILED' + Assert-CcodEqual 1 $state.SetCalls 'created-draft recovery hides the same release despite tag drift' + Assert-CcodTrue $state.DraftPrivate 'a failed Stage is not left public' + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + if ($null -ne $assetFixture) { Remove-CcodTask5ExactAssetFixture $assetFixture } + } +} + +Invoke-CcodTask6Test 'fix1-manual-proof-canonical-timestamp' 'Release promotion rejects noncanonical tray and remote proof timestamps' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name 'CcodGitHubDraftReleaseFix1ManualProofTimestamp' + try { + $tray = [pscustomobject][ordered]@{ timestampUtc = '2030-02-03T04:05:06+00:00'; command = 'ShowAbout'; revision = [long]1; code = 'CCOD_TRAY_ACTION_COMPLETED'; status = 'Completed' } + $remote = [pscustomobject][ordered]@{ + schemaVersion = 1; timestampUtc = '2030-02-03T04:05:06+00:00'; kind = 'remote-control-manual-proof'; operation = 'SecondDeviceControl'; deviceRole = 'SecondDevice'; challenge = 'run-1'; candidateVersion = '2.5.22'; runtimeId = 'runtime-2.5.22'; runtimeGeneration = [long]2; runtimeManifestSha256 = ('d' * 64); attestation = 'HumanReviewedStructuredAttestation'; connection = 'Connected'; control = 'Completed'; outcome = 'Completed'; code = 'CCOD_REMOTE_ACTION_COMPLETED' + } + $trayControl=$tray.PSObject.Copy();$trayControl.timestampUtc='2030-02-03T04:05:06.0000000Z' + $remoteControl=$remote.PSObject.Copy();$remoteControl.timestampUtc='2030-02-03T04:05:06.0000000Z' + Assert-CcodTrue (&$module {param($Proof)Test-CcodGitHubDraftManualProof -Proof $Proof -Index 0 -Version '2.5.22'} $trayControl) 'canonical tray proof is accepted before mutating only its timestamp' + Assert-CcodTrue (&$module {param($Proof)Test-CcodGitHubDraftManualProof -Proof $Proof -Index 4 -Version '2.5.22'} $remoteControl) 'canonical remote proof is accepted before mutating only its timestamp' + $trayValid = &$module { param($Proof) Test-CcodGitHubDraftManualProof -Proof $Proof -Index 0 -Version '2.5.22' } $tray + $remoteValid = &$module { param($Proof) Test-CcodGitHubDraftManualProof -Proof $Proof -Index 4 -Version '2.5.22' } $remote + Assert-CcodTrue (-not [bool]$trayValid -and -not [bool]$remoteValid) 'release manual proof validator rejects offset timestamps' + $canonical=&$module {${function:Test-CcodGitHubDraftCanonicalUtc}} + try { + &$module {Set-Item -LiteralPath Function:script:Test-CcodGitHubDraftCanonicalUtc -Value {param($Value)return $true}} + Assert-CcodTrue (&$module {param($Proof)Test-CcodGitHubDraftManualProof -Proof $Proof -Index 0 -Version '2.5.22'} $tray) 'removing only the timestamp guard makes the otherwise-valid tray negative pass' + Assert-CcodTrue (&$module {param($Proof)Test-CcodGitHubDraftManualProof -Proof $Proof -Index 4 -Version '2.5.22'} $remote) 'removing only the timestamp guard makes the otherwise-valid remote negative pass' + } finally {&$module {param($Original)Set-Item -LiteralPath Function:script:Test-CcodGitHubDraftCanonicalUtc -Value $Original} $canonical} + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + } +} + +Invoke-CcodTask6Test 'review2-stage-held-upload' 'Stage uploads only originally validated frozen bytes through readback' { + $module=Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name 'CcodStageHeldUpload' + $fixture=$null + try { + $fixture=New-CcodTask5ExactAssetFixture + $originals=@{};foreach($name in $fixture.Names){$originals[$name]=[IO.File]::ReadAllBytes((Join-Path $fixture.Root $name))} + foreach($mode in @('Clean','AtUpload','AtReadback')) { + $evidence=Join-Path $fixture.Outside $mode;[IO.Directory]::CreateDirectory($evidence)|Out-Null + Write-CcodTask5Json -Path (Join-Path $evidence 'CodexRemote-fix-2.5.22-clean-preflight.json') -Value ([ordered]@{schemaVersion=1;valid=$true;version='2.5.22';gitCommit=$fixture.GitCommit;repositoryRoot=[IO.Path]::GetFullPath($repositoryRoot)}) + $draft=New-CcodTask6DraftAdapterFixture -AssetDirectory $fixture.Root;$state=$draft.State + $probe=[pscustomobject]@{Attempts=0;Blocked=0;Remote=@{};Frozen=$null;ReadCount=0} + $draft.Adapters.UploadAsset={ + param($Tag,$Name,$Path) + $probe.Frozen=Split-Path -Parent $Path + if($mode-ceq'AtUpload'-and$Name-ceq$fixture.Names[0]){ + $probe.Attempts++ + try{[IO.File]::WriteAllText($Path,'replacement-before-native-upload')}catch [IO.IOException]{$probe.Blocked++} + } + $probe.Remote[$Name]=[IO.File]::ReadAllBytes($Path);$state.Uploaded.Add($Name) + }.GetNewClosure() + $draft.Adapters.DownloadAsset={ + param($Tag,$Name,$Destination) + $probe.ReadCount++ + if($mode-ceq'AtReadback'-and$probe.ReadCount-eq1){ + $probe.Attempts++ + try{[IO.File]::WriteAllText((Join-Path $probe.Frozen $Name),'replacement-before-readback');$probe.Remote[$Name]=[IO.File]::ReadAllBytes((Join-Path $probe.Frozen $Name))}catch [IO.IOException]{$probe.Blocked++} + } + [IO.File]::WriteAllBytes($Destination,[byte[]]$probe.Remote[$Name]) + }.GetNewClosure() + $result=Invoke-CcodTask6DraftCore -Module $module -Mode Stage -Tag 'v2.5.22' -AssetDirectory $fixture.Root -EvidenceDirectory $evidence -Adapters $draft.Adapters + Assert-CcodTrue $result.Draft 'the isolated adapter keeps publication private' + Assert-CcodEqual $fixture.Names.Count $probe.ReadCount 'every uploaded byte set is read from the simulated remote, not the original directory' + foreach($name in $fixture.Names){Assert-CcodEqual ([Convert]::ToBase64String($originals[$name])) ([Convert]::ToBase64String($probe.Remote[$name])) 'uploaded bytes retain the independently captured original identity'} + if($mode-cne'Clean'){Assert-CcodEqual 1 $probe.Attempts 'one mutation reaches consumption';Assert-CcodEqual 1 $probe.Blocked 'held authority blocks the attempted changed bytes'} + Assert-CcodTrue (-not[IO.Directory]::Exists($probe.Frozen)) 'owned frozen directory is removed after all held files are released' + } + } finally {Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue;if($null-ne$fixture){Remove-CcodTask5ExactAssetFixture $fixture}} +} + +Invoke-CcodTask6Test 'review2-native-commit-shape' 'native tag commits remain one exact lowercase scalar without repairing output' { + $module=Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name 'CcodNativeCommitShape' + $root=Join-Path ([IO.Path]::GetTempPath()) ('ccod-native-commit-'+[guid]::NewGuid().ToString('N')) + $fakeGh=Join-Path $root 'gh.cmd';$acceptedInvalid=[Collections.Generic.List[string]]::new() + try { + [IO.Directory]::CreateDirectory($root)|Out-Null + & $module { + param($Path) + $script:CcodCommitGh=$Path + Set-Item Function:script:Assert-CcodGitHubDraftAuthenticatedContext -Value {param($Tag)} + Set-Item Function:script:Assert-CcodGitHubDraftActionsContext -Value {param($Tag)} + Set-Alias -Name gh -Value $Path -Scope Script + } $fakeGh + foreach($mode in @('Valid','Split','Whitespace','Uppercase','BlankBefore','BlankAfter','Duplicate','Empty','ExitFailure')) { + $lines=switch($mode) { + 'Valid' {@('echo('+('c'*40))} + 'Split' {@(('echo('+('c'*20)),('echo('+('c'*20)))} + 'Whitespace' {@('echo( '+('c'*40)+' ')} + 'Uppercase' {@('echo('+('C'*40))} + 'BlankBefore' {@('echo(',('echo('+('c'*40)))} + 'BlankAfter' {@(('echo('+('c'*40)),'echo(')} + 'Duplicate' {@(('echo('+('c'*40)),('echo('+('c'*40)))} + 'Empty' {@()} + 'ExitFailure' {@('echo('+('c'*40))} + } + $exitValue=if($mode-ceq'ExitFailure'){3}else{0} + [IO.File]::WriteAllText($fakeGh,(@('@echo off')+@($lines)+@('exit /b '+$exitValue)-join"`r`n")+"`r`n",[Text.UTF8Encoding]::new($false)) + foreach($actionsOnly in @($false,$true)) { + $failure=$null;$value=$null + try{$value=&$module {param($Actions) Get-CcodGitHubDraftTagCommit -Tag 'v2.5.22' -ActionsOnly $Actions} $actionsOnly}catch{$failure=$_.FullyQualifiedErrorId} + if($mode-ceq'Valid') { + Assert-CcodEqual $null $failure 'ordinary native newline termination is accepted' + Assert-CcodEqual ('c'*40) $value 'one canonical native SHA is returned unchanged' + } elseif($null-eq$failure){$acceptedInvalid.Add($mode+':'+$actionsOnly)} + else{Assert-CcodTrue ($failure-like'CCOD_GITHUB_DRAFT_TAG_COMMIT_FAILED*') 'invalid native commit uses the specific failure'} + } + } + Assert-CcodEqual '' ($acceptedInvalid-join',') 'no multi-line or noncanonical native SHA is repaired into acceptance' + } finally {Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue;if([IO.Directory]::Exists($root)){[IO.Directory]::Delete($root,$true)}} +} + +Invoke-CcodTask6Test 'review2-final-commit-shape' 'Verify never repairs a malformed tag SHA before or after receipt publication' { + $module=Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name 'CcodFinalCommitShape' + $fixture=$null + try { + $fixture=New-CcodTask5ExactAssetFixture + foreach($mode in @('Valid','Whitespace','Uppercase','PostPublication')) { + $evidence=Join-Path $fixture.Outside $mode;[IO.Directory]::CreateDirectory($evidence)|Out-Null + Write-CcodTask5Json -Path (Join-Path $evidence 'CodexRemote-fix-2.5.22-clean-preflight.json') -Value ([ordered]@{schemaVersion=1;valid=$true;version='2.5.22';gitCommit=$fixture.GitCommit;repositoryRoot=[IO.Path]::GetFullPath($repositoryRoot)}) + $path=&$module {param($Root) Get-CcodGitHubDraftVerificationPath -EvidenceDirectory $Root -Version '2.5.22'} $evidence + $draft=New-CcodTask6DraftAdapterFixture -AssetDirectory $fixture.Root;$state=$draft.State + $state.Uploaded.AddRange([string[]]$fixture.Names) + $probe=[pscustomobject]@{Downloads=0;Injected=0;PublicationSeen=$false} + $draft.Adapters.DownloadAsset={param($Tag,$Name,$Destination)[IO.File]::Copy((Join-Path $fixture.Root $Name),$Destination,$false);$probe.Downloads++}.GetNewClosure() + $draft.Adapters.GetTagCommit={ + param($Tag,$ActionsOnly) + $published=[IO.File]::Exists($path) + if($published){$probe.PublicationSeen=$true} + if($mode-cne'Valid'-and$probe.Downloads-eq$fixture.Names.Count-and($mode-cne'PostPublication'-or$published)) { + $probe.Injected++ + if($mode-ceq'Uppercase'){return ('C'*40)} + return (' '+$fixture.GitCommit) + } + return $fixture.GitCommit + }.GetNewClosure() + if($mode-ceq'Valid') { + $result=Invoke-CcodTask6DraftCore -Module $module -Mode Verify -Tag 'v2.5.22' -AssetDirectory $fixture.Root -EvidenceDirectory $evidence -Adapters $draft.Adapters + Assert-CcodTrue $result.Verified 'canonical final observations and real publication are a passing control' + Assert-CcodEqual $path $result.VerificationPath 'control uses the actual production verification target' + Assert-CcodTrue $probe.PublicationSeen 'passing control covers the readback callback after publication' + } else { + Assert-CcodThrows {Invoke-CcodTask6DraftCore -Module $module -Mode Verify -Tag 'v2.5.22' -AssetDirectory $fixture.Root -EvidenceDirectory $evidence -Adapters $draft.Adapters|Out-Null} 'CCOD_GITHUB_DRAFT_VERIFY_EVIDENCE_INVALID' + Assert-CcodTrue ($probe.Injected-gt0) 'malformed final SHA reaches the target callback' + Assert-CcodTrue (-not[IO.File]::Exists($path)) 'invalid final identity leaves no success-shaped verification' + if($mode-ceq'PostPublication'){Assert-CcodTrue $probe.PublicationSeen 'late negative crosses actual publication before rollback'} + } + Assert-CcodEqual $fixture.Names.Count $probe.Downloads 'all exact assets are downloaded before the target seam' + } + } finally {Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue;if($null-ne$fixture){Remove-CcodTask5ExactAssetFixture $fixture}} +} + +Invoke-CcodTask6Test 'review2-commit-binding-shape' 'tag binding rejects noncanonical adapter SHA before comparison' { + $module=Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name 'CcodCommitBindingShape' + try { + $state=[pscustomobject]@{Raw=('c'*40)} + $adapters=@{GetTagCommit={param($Tag,$ActionsOnly) return ,$state.Raw}.GetNewClosure()} + & $module {param($Adapters) Assert-CcodGitHubDraftTagCommit -Tag 'v2.5.22' -ExpectedCommit ('c'*40) -Adapters $Adapters -ActionsOnly $false} $adapters + $invalid=[Collections.Generic.List[object]]::new() + $invalid.Add([object]@('c'*40));$invalid.Add(' '+('c'*40));$invalid.Add(('c'*40)+"`n");$invalid.Add('C'*40);$invalid.Add([pscustomobject]@{value=('c'*40)}) + $accepted=0 + foreach($raw in $invalid) { + $state.Raw=$raw;$failure=$null + try{&$module {param($Adapters) Assert-CcodGitHubDraftTagCommit -Tag 'v2.5.22' -ExpectedCommit ('c'*40) -Adapters $Adapters -ActionsOnly $false} $adapters}catch{$failure=$_.FullyQualifiedErrorId} + if($null-eq$failure){$accepted++}else{Assert-CcodTrue ($failure-like'CCOD_GITHUB_DRAFT_TAG_COMMIT_FAILED*') 'malformed observed SHA fails before equality comparison'} + } + Assert-CcodEqual 0 $accepted 'caller does not repair whitespace or case after a native or private adapter' + } finally {Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue} +} + +Invoke-CcodTask6Test 'review-native-private-id' 'native private recovery addresses a strict numeric release ID rather than a mutable tag' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name 'CcodNativePrivateId' + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-native-private-id-' + [guid]::NewGuid().ToString('N')) + $fakeGh = Join-Path $root 'gh.cmd' + try { + [IO.Directory]::CreateDirectory($root) | Out-Null + [IO.File]::WriteAllText($fakeGh,"@echo off`r`necho %*>>`"%~dp0calls.txt`"`r`necho native-fixture-output`r`nexit /b 0`r`n",[Text.UTF8Encoding]::new($false)) + & $module { + param($Path) + $script:CcodPrivateFakeGh=$Path + $script:CcodPrivateCalls=0 + Set-Item Function:script:Assert-CcodGitHubDraftAuthenticatedContext -Value {param($Tag)} + Set-Item Function:script:gh -Value { $script:CcodPrivateCalls++; & $script:CcodPrivateFakeGh @args; $script:LASTEXITCODE=$LASTEXITCODE } + } $fakeGh + $output=@(& $module { $adapters=Get-CcodGitHubDraftReleaseDefaultAdapters; & $adapters.SetReleaseDraftState 'v2.5.22' $true '123' }) + Assert-CcodEqual 0 $output.Count 'native mutation output is not exposed as a success value' + $line=[IO.File]::ReadAllText((Join-Path $root 'calls.txt')).Trim() + Assert-CcodEqual 'api repos/naipi11/CodexRemote-fix/releases/123 --method PATCH --field draft=true --silent' $line 'the visibility write is pinned to the authenticated numeric ID' + foreach($bad in @($null,'',123,@('123'),'0123','123/../999',"123`n")){ + $before=& $module {$script:CcodPrivateCalls} + Assert-CcodThrows { & $module {param($Id) $adapters=Get-CcodGitHubDraftReleaseDefaultAdapters; & $adapters.SetReleaseDraftState 'v2.5.22' $true $Id} $bad } 'CCOD_GITHUB_DRAFT_PROMOTE_FAILED' + Assert-CcodEqual $before (& $module {$script:CcodPrivateCalls}) 'invalid bound ID is rejected before any native call' + } + & $module {$adapters=Get-CcodGitHubDraftReleaseDefaultAdapters; & $adapters.SetReleaseDraftState 'v2.5.22' $false '123'} + $lines=[IO.File]::ReadAllLines((Join-Path $root 'calls.txt')) + Assert-CcodEqual 'api repos/naipi11/CodexRemote-fix/releases/123 --method PATCH --field draft=false --silent' $lines[-1].Trim() 'publication uses the same immutable ID and the explicit Boolean requested by its gated caller' + $before=& $module {$script:CcodPrivateCalls} + Assert-CcodThrows { & $module {$adapters=Get-CcodGitHubDraftReleaseDefaultAdapters; & $adapters.SetReleaseDraftState 'v2.5.22' $false} } 'CCOD_GITHUB_DRAFT_PROMOTE_FAILED' + Assert-CcodEqual $before (& $module {$script:CcodPrivateCalls}) 'unbound visibility changes are rejected rather than falling back to tag addressing' + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + if([IO.Directory]::Exists($root)){[IO.Directory]::Delete($root,$true)} + } +} + +Invoke-CcodTask6Test 'review-bound-private-recovery' 'bound release recovery restores privacy despite asset or tag drift' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name 'CcodBoundPrivateRecovery' + $fixture = $null + try { + & $module { Import-CcodDraftReleaseAssetContract } + $fixture = New-CcodTask5ExactAssetFixture + foreach ($mode in @('Promote','Stage')) { + foreach ($change in @('None','Missing','Extra','TagDrift')) { + $state = [pscustomobject]@{ Private=$false; Calls=0; TargetId=$null; Names=[string[]]$fixture.Names; Commit=[string]$fixture.GitCommit } + if($change -ceq 'Missing'){$state.Names=@($fixture.Names | Select-Object -Skip 1)} + if($change -ceq 'Extra'){$state.Names=@($fixture.Names)+@('unexpected.txt')} + if($change -ceq 'TagDrift'){$state.Commit='d'*40} + $adapters = @{ + ViewRelease = { param($Tag) [pscustomobject][ordered]@{Tag=$Tag;Id='123';Draft=$state.Private;AssetNames=@($state.Names)} }.GetNewClosure() + GetTagCommit = { param($Tag,$ActionsOnly) $state.Commit }.GetNewClosure() + SetReleaseDraftState = { param($Tag,[bool]$Draft,$ExpectedReleaseId) $state.Calls++;$state.TargetId=$ExpectedReleaseId;$state.Private=$Draft }.GetNewClosure() + } + [Console]::WriteLine('CCOD_BOUND_RECOVERY mode='+$mode+' change='+$change) + & $module { + param($Adapters,$Mode,$Commit) + if($Mode -ceq 'Promote'){ + Restore-CcodGitHubDraftPrivateState -Adapters $Adapters -Tag 'v2.5.22' -Version '2.5.22' -Verification ([pscustomobject]@{draftId='123'}) -Acceptance ([pscustomobject]@{draft=[pscustomobject]@{id='123'}}) -ExpectedGitCommit $Commit + }else{ + Restore-CcodGitHubDraftStagePrivateState -Adapters $Adapters -Tag 'v2.5.22' -Version '2.5.22' -DraftId '123' -ExpectedGitCommit $Commit + } + } $adapters $mode $fixture.GitCommit + Assert-CcodTrue $state.Private 'failed content must not prevent restoring the authenticated release to private' + Assert-CcodEqual 1 $state.Calls 'one private visibility mutation is requested' + if($change -cne 'None'){Assert-CcodEqual '123' $state.TargetId 'recovery mutation carries the already-bound release ID'} + Assert-CcodEqual ([string]$state.Commit) ([string](& $adapters.GetTagCommit 'v2.5.22' $false)) 'recovery does not repair or silently normalize tag content' + } + } + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + if($null -ne $fixture){Remove-CcodTask5ExactAssetFixture $fixture} + } +} + +Invoke-CcodTask6Test 'review-bound-recovery-denials' 'private recovery rejects identity ambiguity and never targets a retargeted release' { + $module=Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name 'CcodRecoveryDenials' + try { + foreach($case in @('None','AlreadyPrivate','BeforeId','BeforeTag','BeforeDraftType','ReceiptArray','ReceiptMismatch','IdZero','IdLF','AfterId','AfterPublic','WriteThrows','TagRace')){ + $state=[pscustomobject]@{ Reads=0; Writes=0; Private=$false; OtherPrivate=$false; BeforeId='123'; BeforeTag='v2.5.22'; BoundId=$null } + $verification=[pscustomobject]@{draftId='123'} + $acceptance=[pscustomobject]@{draft=[pscustomobject]@{id='123'}} + if($case -ceq 'AlreadyPrivate'){$state.Private=$true} + if($case -ceq 'BeforeId'){$state.BeforeId='999'} + if($case -ceq 'BeforeTag'){$state.BeforeTag='v2.5.21'} + if($case -ceq 'ReceiptArray'){$verification.draftId=@('123')} + if($case -ceq 'ReceiptMismatch'){$acceptance.draft.id='999'} + if($case -ceq 'IdZero'){$verification.draftId='0';$acceptance.draft.id='0';$state.BeforeId='0'} + if($case -ceq 'IdLF'){$verification.draftId="123`n";$acceptance.draft.id="123`n";$state.BeforeId="123`n"} + $adapters=@{ + ViewRelease={param($Tag) + $state.Reads++ + $id=$state.BeforeId;$private=$state.Private + if($state.Reads -gt 1 -and $case -in @('AfterId','TagRace')){$id='999'} + if($case -ceq 'BeforeDraftType'){$private='false'} + if($state.Reads -gt 1 -and $case -ceq 'AfterPublic'){$private=$false} + [pscustomobject][ordered]@{Tag=$state.BeforeTag;Id=$id;Draft=$private;AssetNames=@('untrusted-content.bin')} + }.GetNewClosure() + SetReleaseDraftState={param($Tag,[bool]$Draft,$ExpectedReleaseId) + $state.Writes++;$state.BoundId=$ExpectedReleaseId + if($case -ceq 'WriteThrows'){throw 'native write failure'} + $target=if($null -ne $ExpectedReleaseId){$ExpectedReleaseId}elseif($case -ceq 'TagRace'){'999'}else{'123'} + if($target -ceq '123'){$state.Private=$Draft}else{$state.OtherPrivate=$Draft} + }.GetNewClosure() + GetTagCommit={throw 'content validation must not gate containment'} + } + $invoke={&$module {param($A,$V,$E) Restore-CcodGitHubDraftPrivateState -Adapters $A -Tag 'v2.5.22' -Version '2.5.22' -Verification $V -Acceptance $E -ExpectedGitCommit ('c'*40)} $adapters $verification $acceptance}.GetNewClosure() + if($case -in @('None','AlreadyPrivate')){ + & $invoke + Assert-CcodTrue $state.Private 'successful recovery is followed by a private readback' + Assert-CcodEqual 2 $state.Reads 'successful recovery reads the bound release before and after' + }else{Assert-CcodThrows {& $invoke} 'CCOD_GITHUB_DRAFT_PROMOTE_ROLLBACK_FAILED'} + $expectedWrites=if($case -in @('None','AfterId','AfterPublic','WriteThrows','TagRace')){1}else{0} + Assert-CcodEqual $expectedWrites $state.Writes "$case does not mutate before identity authorization" + Assert-CcodTrue (-not $state.OtherPrivate) 'retargeting cannot cause a different release to be hidden' + if($state.Writes){Assert-CcodEqual '123' $state.BoundId 'every recovery write retains the original ID'} + } + } finally {Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue} +} + +Invoke-CcodTask6Test 'fix1-private-recovery-identity-before-mutation' 'Promote recovery validates release identity before changing visibility' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name 'CcodGitHubDraftReleaseFix1RecoveryIdentity' + $assetFixture = $null + try { + $assetFixture = New-CcodTask5ExactAssetFixture + $draft = New-CcodTask6DraftAdapterFixture -AssetDirectory $assetFixture.Root + $state = $draft.State + $state | Add-Member -NotePropertyName SetCalls -NotePropertyValue 0 + $expectedNames = [string[]]$assetFixture.Names + $draft.Adapters.ViewRelease = { + param($Tag) + [pscustomobject][ordered]@{ Tag = $Tag; Id = '999'; Draft = $false; AssetNames = $expectedNames } + }.GetNewClosure() + $draft.Adapters.SetReleaseDraftState = { + param($Tag, [bool]$Draft) + $state.SetCalls++ + }.GetNewClosure() + $verification = [pscustomobject][ordered]@{ draftId = '123' } + $acceptance = [pscustomobject][ordered]@{ draft = [pscustomobject][ordered]@{ id = '123' } } + Assert-CcodThrows { + &$module { + param($Adapters,$Verification,$Acceptance) + Restore-CcodGitHubDraftPrivateState -Adapters $Adapters -Tag 'v2.5.22' -Version '2.5.22' -Verification $Verification -Acceptance $Acceptance -ExpectedGitCommit ('c' * 40) + } $draft.Adapters $verification $acceptance + } 'CCOD_GITHUB_DRAFT_PROMOTE_ROLLBACK_FAILED' + Assert-CcodEqual 0 $state.SetCalls 'private recovery does not mutate an unverified release identity' + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + if ($null -ne $assetFixture) { Remove-CcodTask5ExactAssetFixture $assetFixture } + } +} + +Invoke-CcodTask6Test 'fix1-post-promote-recovery-uses-identity-first-helper' 'Post-promotion readback recovery delegates identity validation before visibility mutation' { + $source = [IO.File]::ReadAllText((Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1')) + $start = $source.LastIndexOf('$postReadRoot = $null') + $end = $source.Length + Assert-CcodTrue ($start -ge 0 -and $end -gt $start) 'post-promotion readback recovery block is present' + $block = $source.Substring($start, $end - $start) + Assert-CcodTrue ($block.Contains('Restore-CcodGitHubDraftPrivateState -Adapters $adapters')) 'post-promotion recovery uses the identity-first helper' + Assert-CcodTrue (-not $block.Contains('& $adapters.SetReleaseDraftState $Tag $true')) 'post-promotion recovery does not mutate visibility before identity validation' +} + +Invoke-CcodTask6Test 'review-native-json-field-order' 'default GitHub adapters accept reordered native JSON without relaxing identity types' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name 'CcodNativeJsonFieldOrder' + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-native-json-order-' + [guid]::NewGuid().ToString('N')) + $fakeGh = Join-Path $root 'gh.cmd' + $response = Join-Path $root 'response.json' + try { + [IO.Directory]::CreateDirectory($root) | Out-Null + [IO.File]::WriteAllText($fakeGh, "@echo off`r`nif `"%1`"==`"release`" if `"%2`"==`"create`" exit /b 0`r`nif `"%1`"==`"release`" if `"%2`"==`"view`" (`r`n type `"%~dp0response.json`"`r`n exit /b 0`r`n)`r`nexit /b 87`r`n", [Text.UTF8Encoding]::new($false)) + & $module { + param($FakeGh) + $script:CcodNativeJsonGh = $FakeGh + $script:CcodNativeJsonCalls = 0 + Set-Item Function:script:Assert-CcodGitHubDraftAuthenticatedContext -Value { param($Tag) } + Set-Item Function:script:Assert-CcodGitHubDraftActionsContext -Value { param($Tag) } + Set-Item Function:script:gh -Value { + if (($args -join '|') -cnotmatch '\|--repo\|naipi11/CodexRemote-fix\|') { throw 'native fixture received an unbound repository' } + $script:CcodNativeJsonCalls++ + & $script:CcodNativeJsonGh @args + $script:LASTEXITCODE = $LASTEXITCODE + } + } $fakeGh + $expectedCalls = 0 + foreach ($mode in @('CreateDraft','ViewRelease')) { + $value = [pscustomobject][ordered]@{ databaseId = [long]2147483648; tagName = 'v2.5.22'; isDraft = $true } + $orders = @('databaseId,tagName,isDraft','databaseId,isDraft,tagName','isDraft,tagName,databaseId') + $errorId = 'CCOD_GITHUB_DRAFT_CREATE_FAILED' + $callsPerInvocation = 2 + if ($mode -ceq 'ViewRelease') { + $value | Add-Member -NotePropertyName assets -NotePropertyValue @([pscustomobject]@{name='first.zip'},[pscustomobject]@{name='second.exe'}) + $orders = @('tagName,isDraft,databaseId,assets','assets,databaseId,isDraft,tagName','isDraft,assets,tagName,databaseId') + $errorId = 'CCOD_GITHUB_DRAFT_NOT_STAGED' + $callsPerInvocation = 1 + } + $invoke = { + & $module { param($Mode) $adapters=Get-CcodGitHubDraftReleaseDefaultAdapters; if($Mode -ceq 'CreateDraft'){& $adapters.CreateDraft 'v2.5.22' 'fixture title' 'fixture notes'}else{& $adapters.ViewRelease 'v2.5.22'} } $mode + }.GetNewClosure() + foreach ($order in $orders) { + $reordered = [ordered]@{} + foreach ($name in $order.Split(',')) { $reordered[$name] = $value.$name } + [IO.File]::WriteAllText($response,($reordered | ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + [Console]::WriteLine('CCOD_NATIVE_JSON_ORDER mode=' + $mode + ' fields=' + $order) + $result = & $invoke + $expectedCalls += $callsPerInvocation + Assert-CcodEqual '2147483648' $result.Id "$mode accepts exact fields in native order $order" + Assert-CcodTrue ($result.Draft -is [bool] -and $result.Draft) 'draft state remains a strict Boolean' + Assert-CcodEqual 'v2.5.22' $result.Tag 'tag identity remains exact' + if ($mode -ceq 'ViewRelease') { Assert-CcodEqual 'first.zip,second.exe' ($result.AssetNames -join ',') 'asset array is preserved' } + } + foreach ($bad in @('Missing','Extra','Case','IdString','IdBoolean','IdFraction','IdZero','TagArray','TagMismatch','DraftString','Null')) { + $mutated = $value.PSObject.Copy() + switch ($bad) { + 'Missing' { $mutated.PSObject.Properties.Remove('databaseId') } + 'Extra' { $mutated | Add-Member -NotePropertyName unexpected -NotePropertyValue $true } + 'IdString' { $mutated.databaseId = '2147483648' } + 'IdBoolean' { $mutated.databaseId = $true } + 'IdFraction' { $mutated.databaseId = [double]123.5 } + 'IdZero' { $mutated.databaseId = 0 } + 'TagArray' { $mutated.tagName = @('v2.5.22') } + 'TagMismatch' { $mutated.tagName = 'v2.5.21' } + 'DraftString' { $mutated.isDraft = 'true' } + } + $json = $mutated | ConvertTo-Json -Depth 8 -Compress + if ($bad -ceq 'Case') { $json = $json.Replace('"databaseId":','"DatabaseId":') } + if ($bad -ceq 'Null') { $json = 'null' } + [IO.File]::WriteAllText($response,$json,[Text.UTF8Encoding]::new($false)) + Assert-CcodThrows { & $invoke | Out-Null } $errorId + $expectedCalls += $callsPerInvocation + } + } + Assert-CcodEqual $expectedCalls (& $module {$script:CcodNativeJsonCalls}) 'every adapter invocation used only the local native fixture' + } finally { + if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + if ([IO.Directory]::Exists($root)) { [IO.Directory]::Delete($root,$true) } + } +} + +Invoke-CcodTask6Test 'fix2-view-database-id' 'default GitHub ViewRelease rejects coerced database IDs using only a local gh stub' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name 'CcodViewDatabaseId' + try { + &$module { + $script:CcodViewGhCalls = 0 + Set-Item Function:script:Assert-CcodGitHubDraftAuthenticatedContext -Value { param($Tag) } + Set-Item Function:script:gh -Value { + $script:LASTEXITCODE = 0 + $script:CcodViewGhCalls++ + return $script:CcodViewJson + } + } + foreach ($case in @( + [pscustomobject]@{ Json = '123'; Valid = $true }, + [pscustomobject]@{ Json = '2147483648'; Valid = $true }, + [pscustomobject]@{ Json = '[123]'; Valid = $false }, + [pscustomobject]@{ Json = '"123"'; Valid = $false }, + [pscustomobject]@{ Json = 'true'; Valid = $false }, + [pscustomobject]@{ Json = '123.5'; Valid = $false }, + [pscustomobject]@{ Json = 'null'; Valid = $false }, + [pscustomobject]@{ Json = '0'; Valid = $false } + )) { + &$module { param($Value) $script:CcodViewJson = '{"tagName":"v2.5.22","isDraft":true,"databaseId":' + $Value + ',"assets":[]}' } $case.Json + $invoke = { &$module { $adapters = Get-CcodGitHubDraftReleaseDefaultAdapters; & $adapters.ViewRelease 'v2.5.22' } } + if ($case.Valid) { + $view = & $invoke + Assert-CcodEqual $case.Json $view.Id 'canonical JSON Int32/Int64 database ID is accepted' + } else { Assert-CcodThrows { & $invoke | Out-Null } 'CCOD_GITHUB_DRAFT_NOT_STAGED' } + } + Assert-CcodEqual 8 (&$module { $script:CcodViewGhCalls }) 'every view is served by the module-local gh stub' + } finally { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } +} + +Invoke-CcodTask6Test 'fix2-create-tag-type' 'Stage rejects singleton array tags from creation before any asset upload' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name 'CcodCreateTagType' + $fixture = $null + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-create-tag-type-' + [guid]::NewGuid().ToString('N')) + try { + $fixture = New-CcodTask5ExactAssetFixture + [IO.Directory]::CreateDirectory($root) | Out-Null + Write-CcodTask5Json (Join-Path $root 'CodexRemote-fix-2.5.22-clean-preflight.json') ([ordered]@{ schemaVersion = 1; valid = $true; version = '2.5.22'; gitCommit = $fixture.GitCommit; repositoryRoot = [IO.Path]::GetFullPath($repositoryRoot) }) + $draft = New-CcodTask6DraftAdapterFixture -AssetDirectory $fixture.Root + $draft.Adapters.CreateDraft = { param($Tag,$Title,$Notes) [pscustomobject][ordered]@{ Tag = @($Tag); Id = '123'; Draft = $true; AssetNames = @() } } + Assert-CcodThrows { Invoke-CcodTask6DraftCore -Module $module -Mode Stage -Tag 'v2.5.22' -AssetDirectory $fixture.Root -EvidenceDirectory $root -Adapters $draft.Adapters | Out-Null } 'CCOD_GITHUB_DRAFT_CREATE_AMBIGUOUS' + Assert-CcodEqual 0 $draft.State.Uploaded.Count 'malformed creation identity cannot authorize upload' + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + if ([IO.Directory]::Exists($root)) { [IO.Directory]::Delete($root, $true) } + if ($null -ne $fixture) { Remove-CcodTask5ExactAssetFixture $fixture } + } +} + +Invoke-CcodTask6Test 'fix1-post-promote-readback' 'Promote re-downloads and re-hashes public assets after changing draft visibility' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name 'CcodGitHubDraftReleaseFix1PostPromote' + $assetFixture = $null + $promotion = $null + $remoteRoot = $null + try { + $assetFixture = New-CcodTask5ExactAssetFixture + $promotion = New-CcodTask5PromotionFixture $assetFixture + $defender = Join-Path $promotion.Root 'defender' + $acceptanceDirectory = Join-Path $promotion.Root 'acceptance' + [IO.Directory]::CreateDirectory($defender) | Out-Null + foreach ($name in @($promotion.Names)) { Move-Item -LiteralPath (Join-Path $promotion.Root $name) -Destination (Join-Path $defender $name) } + $preflightRecord = [ordered]@{ schemaVersion = 1; valid = $true; version = '2.5.22'; gitCommit = [string]$assetFixture.GitCommit; repositoryRoot = [IO.Path]::GetFullPath($repositoryRoot) } + [IO.File]::WriteAllText((Join-Path $promotion.Root 'CodexRemote-fix-2.5.22-clean-preflight.json'), (($preflightRecord | ConvertTo-Json -Depth 8) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + $expected = [string[]]$assetFixture.Names + $remoteRoot = Join-Path $assetFixture.Outside 'remote-assets' + [IO.Directory]::CreateDirectory($remoteRoot) | Out-Null + $draft = New-CcodTask6DraftAdapterFixture -AssetDirectory $assetFixture.Root + $state = $draft.State + $draft.Adapters.UploadAsset = { + param($Tag, $Name, $Path) + [IO.File]::Copy($Path, (Join-Path $remoteRoot $Name), $true) + $state.Uploaded.Add($Name) + }.GetNewClosure() + $draft.Adapters.DownloadAsset = { + param($Tag, $Name, $Destination) + [IO.File]::Copy((Join-Path $remoteRoot $Name), $Destination, $true) + }.GetNewClosure() + Invoke-CcodTask6DraftCore -Module $module -Mode Stage -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory $promotion.Root -Adapters $draft.Adapters | Out-Null + Invoke-CcodTask6DraftCore -Module $module -Mode Verify -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory $promotion.Root -Adapters $draft.Adapters | Out-Null + $acceptanceRecord = New-CcodTask9AcceptanceRecord -AssetFixture $assetFixture -EvidenceRoot (Get-CcodTask9EvidenceRoot) + [IO.Directory]::CreateDirectory($acceptanceDirectory) | Out-Null + [IO.File]::WriteAllText((Join-Path $acceptanceDirectory 'CodexRemote-fix-2.5.22-official-draft.complete.json'), (($acceptanceRecord | ConvertTo-Json -Depth 8) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + $state | Add-Member -NotePropertyName PublicAttempts -NotePropertyValue 0 + $state | Add-Member -NotePropertyName RecoveryWrites -NotePropertyValue 0 + foreach ($change in @('Bytes','Missing','Extra')) { + foreach($name in $expected){[IO.File]::Copy((Join-Path $assetFixture.Root $name),(Join-Path $remoteRoot $name),$true)} + $state.Uploaded.Clear();$state.Uploaded.AddRange($expected) + $state.DraftPrivate=$true;$state.Promoted=$false + $beforePublic=$state.PublicAttempts;$beforeRecovery=$state.RecoveryWrites + $draft.Adapters.SetReleaseDraftState = { + param($Tag, [bool]$Draft, $ExpectedReleaseId) + if($ExpectedReleaseId -cne '123'){throw 'all visibility writes must carry the bound release ID'} + $state.DraftPrivate = $Draft + $state.Promoted = -not $Draft + if (-not $Draft) { + $state.PublicAttempts++ + if($change -ceq 'Bytes'){[IO.File]::WriteAllText((Join-Path $remoteRoot $expected[0]),'post-promotion-mutated',[Text.UTF8Encoding]::new($false))} + if($change -ceq 'Missing'){[IO.File]::Delete((Join-Path $remoteRoot $expected[0]));[void]$state.Uploaded.Remove($expected[0])} + if($change -ceq 'Extra'){$state.Uploaded.Add('unexpected.txt');[IO.File]::WriteAllText((Join-Path $remoteRoot 'unexpected.txt'),'unexpected',[Text.UTF8Encoding]::new($false))} + }else{ + if($ExpectedReleaseId -cne '123'){throw 'post-promotion recovery ID was not bound'} + $state.RecoveryWrites++ + } + }.GetNewClosure() + Assert-CcodThrows { + Invoke-CcodTask6DraftCore -Module $module -Mode Promote -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory $promotion.Root -Adapters $draft.Adapters | Out-Null + } 'CCOD_GITHUB_DRAFT_PROMOTE_READBACK_FAILED' + Assert-CcodEqual ($beforePublic+1) $state.PublicAttempts "$change occurs only after actual fixture publication" + Assert-CcodEqual ($beforeRecovery+1) $state.RecoveryWrites "$change triggers bound-ID recovery" + Assert-CcodTrue (-not $state.Promoted -and $state.DraftPrivate) 'post-promotion content failures restore the same draft to private before failing' + } + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + if ($null -ne $promotion -and (Test-Path -LiteralPath $promotion.Root)) { Remove-Item -LiteralPath $promotion.Root -Recurse -Force -ErrorAction SilentlyContinue } + if ($null -ne $assetFixture) { Remove-CcodTask5ExactAssetFixture $assetFixture } + } +} + +Invoke-CcodTask6Test 'fix1-post-promote-tag-identity' 'Promote rechecks the immutable tag commit after the final public asset readback' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name 'CcodGitHubDraftReleaseFix1PostPromoteTagIdentity' + $assetFixture = $null + $promotion = $null + $remoteRoot = $null + try { + $assetFixture = New-CcodTask5ExactAssetFixture + $promotion = New-CcodTask5PromotionFixture $assetFixture + $defender = Join-Path $promotion.Root 'defender' + $acceptanceDirectory = Join-Path $promotion.Root 'acceptance' + [IO.Directory]::CreateDirectory($defender) | Out-Null + foreach ($name in @($promotion.Names)) { Move-Item -LiteralPath (Join-Path $promotion.Root $name) -Destination (Join-Path $defender $name) } + $preflightRecord = [ordered]@{ schemaVersion = 1; valid = $true; version = '2.5.22'; gitCommit = [string]$assetFixture.GitCommit; repositoryRoot = [IO.Path]::GetFullPath($repositoryRoot) } + [IO.File]::WriteAllText((Join-Path $promotion.Root 'CodexRemote-fix-2.5.22-clean-preflight.json'), (($preflightRecord | ConvertTo-Json -Depth 8) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + $expected = [string[]]$assetFixture.Names + $remoteRoot = Join-Path $assetFixture.Outside 'remote-assets' + [IO.Directory]::CreateDirectory($remoteRoot) | Out-Null + $draft = New-CcodTask6DraftAdapterFixture -AssetDirectory $assetFixture.Root + $state = $draft.State + $state | Add-Member -NotePropertyName PostRootReadyAtPublic -NotePropertyValue $false + $mutation = [pscustomobject]@{ DownloadCount = 0; TagSwapped = $false } + $draft.Adapters.UploadAsset = { + param($Tag, $Name, $Path) + [IO.File]::Copy($Path, (Join-Path $remoteRoot $Name), $true) + $state.Uploaded.Add($Name) + }.GetNewClosure() + $draft.Adapters.GetTagCommit = { + param($Tag, [bool]$ActionsOnly) + if ($mutation.TagSwapped) { 'd' * 40 } else { 'c' * 40 } + }.GetNewClosure() + $draft.Adapters.DownloadAsset = { + param($Tag, $Name, $Destination) + [IO.File]::Copy((Join-Path $remoteRoot $Name), $Destination, $true) + $mutation.DownloadCount++ + if ($mutation.DownloadCount -eq 44) { $mutation.TagSwapped = $true } + }.GetNewClosure() + Invoke-CcodTask6DraftCore -Module $module -Mode Stage -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory $promotion.Root -Adapters $draft.Adapters | Out-Null + Invoke-CcodTask6DraftCore -Module $module -Mode Verify -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory $promotion.Root -Adapters $draft.Adapters | Out-Null + $acceptanceRecord = New-CcodTask9AcceptanceRecord -AssetFixture $assetFixture -EvidenceRoot (Get-CcodTask9EvidenceRoot) + [IO.Directory]::CreateDirectory($acceptanceDirectory) | Out-Null + [IO.File]::WriteAllText((Join-Path $acceptanceDirectory 'CodexRemote-fix-2.5.22-official-draft.complete.json'), (($acceptanceRecord | ConvertTo-Json -Depth 8) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + $draft.Adapters.SetReleaseDraftState = { + param($Tag, [bool]$Draft) + if (-not $Draft) { + $state.PostRootReadyAtPublic = @([IO.Directory]::GetDirectories([IO.Path]::GetTempPath(), 'ccod-draft-post-promote-*')).Count -gt 0 + } + $state.DraftPrivate = $Draft + $state.Promoted = -not $Draft + }.GetNewClosure() + Assert-CcodThrows { + Invoke-CcodTask6DraftCore -Module $module -Mode Promote -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory $promotion.Root -Adapters $draft.Adapters | Out-Null + } 'CCOD_GITHUB_DRAFT_TAG_COMMIT_MISMATCH' + Assert-CcodTrue $state.PostRootReadyAtPublic 'post-readback root must be prepared before public visibility mutation' + Assert-CcodTrue ($mutation.TagSwapped -and $mutation.DownloadCount -eq 44) 'tag identity changes only after the final public asset readback' + Assert-CcodTrue (-not $state.Promoted -and $state.DraftPrivate) 'same release ID is restored private while the original tag failure remains reported' + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + if ($null -ne $promotion -and (Test-Path -LiteralPath $promotion.Root)) { Remove-Item -LiteralPath $promotion.Root -Recurse -Force -ErrorAction SilentlyContinue } + if ($null -ne $assetFixture) { Remove-CcodTask5ExactAssetFixture $assetFixture } + } +} + +Invoke-CcodTask6Test 'fix1-restore-revalidates-tag-commit' 'Private recovery restores the bound release despite a changed tag commit' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name 'CcodGitHubDraftReleaseFix1RestoreTagCommit' + $assetFixture = $null + try { + $assetFixture = New-CcodTask5ExactAssetFixture + $names = [string[]]$assetFixture.Names + $state = [pscustomobject]@{ Private = $false; TargetId = $null } + $adapters = @{ + ViewRelease = { param($Tag) [pscustomobject][ordered]@{ Tag = $Tag; Id = '123'; Draft = $state.Private; AssetNames = $names } }.GetNewClosure() + SetReleaseDraftState = { param($Tag, [bool]$Draft, $ExpectedReleaseId) $state.Private = $Draft; $state.TargetId = $ExpectedReleaseId }.GetNewClosure() + GetTagCommit = { param($Tag, [bool]$ActionsOnly) 'd' * 40 }.GetNewClosure() + } + $verification = [pscustomobject]@{ draftId = '123' } + $acceptance = [pscustomobject]@{ draft = [pscustomobject]@{ id = '123' } } + &$module { param($Adapters,$Verification,$Acceptance) Restore-CcodGitHubDraftPrivateState -Adapters $Adapters -Tag 'v2.5.22' -Version '2.5.22' -Verification $Verification -Acceptance $Acceptance -ExpectedGitCommit ('c' * 40) } $adapters $verification $acceptance + Assert-CcodTrue $state.Private 'content drift does not prevent privacy containment for the authenticated ID' + Assert-CcodEqual '123' $state.TargetId 'recovery does not select a release using its changed tag commit' + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + if ($null -ne $assetFixture) { Remove-CcodTask5ExactAssetFixture $assetFixture } + } +} + +Invoke-CcodTask6Test 'fix1-promote-final-evidence-hold' 'Promote rejects a valid acceptance replacement after final evidence was initially held' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name 'CcodGitHubDraftReleaseFix1FinalEvidenceHold' + $assetFixture = $null + $promotion = $null + try { + $assetFixture = New-CcodTask5ExactAssetFixture + $promotion = New-CcodTask5PromotionFixture $assetFixture + $defender = Join-Path $promotion.Root 'defender' + $verification = Join-Path $promotion.Root 'verification' + $acceptance = Join-Path $promotion.Root 'acceptance' + [IO.Directory]::CreateDirectory($defender) | Out-Null + [IO.Directory]::CreateDirectory($verification) | Out-Null + [IO.Directory]::CreateDirectory($acceptance) | Out-Null + foreach ($name in @($promotion.Names)) { Move-Item -LiteralPath (Join-Path $promotion.Root $name) -Destination (Join-Path $defender $name) } + $preflightRecord = [ordered]@{ schemaVersion = 1; valid = $true; version = '2.5.22'; gitCommit = [string]$assetFixture.GitCommit; repositoryRoot = [IO.Path]::GetFullPath($repositoryRoot) } + [IO.File]::WriteAllText((Join-Path $promotion.Root 'CodexRemote-fix-2.5.22-clean-preflight.json'), (($preflightRecord | ConvertTo-Json -Depth 8) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + $draft = New-CcodTask6DraftAdapterFixture -AssetDirectory $assetFixture.Root + $state = $draft.State + $draft.Adapters.UploadAsset = { param($Tag,$Name,$Path); $state.Uploaded.Add($Name); [IO.File]::Copy($Path,(Join-Path $promotion.Root ('remote-' + $Name)),$true) }.GetNewClosure() + $draft.Adapters.DownloadAsset = { param($Tag,$Name,$Destination); [IO.File]::Copy((Join-Path $promotion.Root ('remote-' + $Name)),$Destination,$true) }.GetNewClosure() + Invoke-CcodTask6DraftCore -Module $module -Mode Stage -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory $promotion.Root -Adapters $draft.Adapters | Out-Null + Invoke-CcodTask6DraftCore -Module $module -Mode Verify -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory $promotion.Root -Adapters $draft.Adapters | Out-Null + $acceptanceRecord = New-CcodTask9AcceptanceRecord -AssetFixture $assetFixture -EvidenceRoot (Get-CcodTask9EvidenceRoot) + $acceptancePath = Join-Path $acceptance 'CodexRemote-fix-2.5.22-official-draft.complete.json' + [IO.File]::WriteAllText($acceptancePath, (($acceptanceRecord | ConvertTo-Json -Depth 8) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + $viewCalls = 0 + $draft.Adapters.ViewRelease = { + param($Tag) + $viewCalls++ + if ($viewCalls -eq 1) { + $replacement = [IO.File]::ReadAllText($acceptancePath) | ConvertFrom-Json + $replacement.completedAtUtc = '2030-02-03T04:05:08.0000000Z' + [IO.File]::WriteAllText($acceptancePath, (($replacement | ConvertTo-Json -Depth 8) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + } + return [pscustomobject][ordered]@{ Tag = $Tag; Id = [string]$state.DraftId; Draft = [bool]$state.DraftPrivate; AssetNames = @($state.Uploaded) } + }.GetNewClosure() + Assert-CcodThrows { + Invoke-CcodTask6DraftCore -Module $module -Mode Promote -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory $promotion.Root -Adapters $draft.Adapters | Out-Null + } 'CCOD_GITHUB_DRAFT_ACCEPTANCE_INVALID' + Assert-CcodTrue (-not $state.Promoted -and $state.DraftPrivate) 'a changed final acceptance hash never changes draft visibility' + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + if ($null -ne $promotion -and (Test-Path -LiteralPath $promotion.Root)) { Remove-Item -LiteralPath $promotion.Root -Recurse -Force -ErrorAction SilentlyContinue } + if ($null -ne $assetFixture) { Remove-CcodTask5ExactAssetFixture $assetFixture } + } +} + +Invoke-CcodTask6Test 'fix1-promote-defender-evidence-hold' 'Promote holds Defender receipt identity through the visibility mutation' { + $source = [IO.File]::ReadAllText((Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1'), [Text.UTF8Encoding]::new($false)) + Assert-CcodTrue ($source.Contains('$heldEvidenceHashes[[string]$preflight]')) 'Promote pins the transferred preflight file through the visibility mutation' + + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name 'CcodGitHubDraftReleaseFix1DefenderEvidenceHold' + $assetFixture = $null + $promotion = $null + $backup = $null + try { + $assetFixture = New-CcodTask5ExactAssetFixture + $promotion = New-CcodTask5PromotionFixture $assetFixture + $defender = Join-Path $promotion.Root 'defender' + $acceptanceDirectory = Join-Path $promotion.Root 'acceptance' + [IO.Directory]::CreateDirectory($defender) | Out-Null + foreach ($name in @($promotion.Names)) { Move-Item -LiteralPath (Join-Path $promotion.Root $name) -Destination (Join-Path $defender $name) } + $preflightRecord = [ordered]@{ schemaVersion = 1; valid = $true; version = '2.5.22'; gitCommit = [string]$assetFixture.GitCommit; repositoryRoot = [IO.Path]::GetFullPath($repositoryRoot) } + [IO.File]::WriteAllText((Join-Path $promotion.Root 'CodexRemote-fix-2.5.22-clean-preflight.json'), (($preflightRecord | ConvertTo-Json -Depth 8) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + $draft = New-CcodTask6DraftAdapterFixture -AssetDirectory $assetFixture.Root + Invoke-CcodTask6DraftCore -Module $module -Mode Stage -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory $promotion.Root -Adapters $draft.Adapters | Out-Null + Invoke-CcodTask6DraftCore -Module $module -Mode Verify -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory $promotion.Root -Adapters $draft.Adapters | Out-Null + $acceptanceRecord = New-CcodTask9AcceptanceRecord -AssetFixture $assetFixture -EvidenceRoot (Get-CcodTask9EvidenceRoot) + [IO.Directory]::CreateDirectory($acceptanceDirectory) | Out-Null + [IO.File]::WriteAllText((Join-Path $acceptanceDirectory 'CodexRemote-fix-2.5.22-official-draft.complete.json'), (($acceptanceRecord | ConvertTo-Json -Depth 8) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + $state = $draft.State + $state | Add-Member -NotePropertyName DefenderReceiptBlocked -NotePropertyValue $false + $receiptPath = Join-Path $defender $promotion.Names[0] + $backup = $receiptPath + '.replacement-source' + $draft.Adapters.SetReleaseDraftState = { + param($Tag, [bool]$Draft) + if (-not $Draft) { + try { + [IO.File]::Move($receiptPath, $backup) + [IO.File]::Copy($backup, $receiptPath, $false) + } catch [IO.IOException] { + $state.DefenderReceiptBlocked = $true + } + } + $state.DraftPrivate = $Draft + $state.Promoted = -not $Draft + }.GetNewClosure() + $result = Invoke-CcodTask6DraftCore -Module $module -Mode Promote -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory $promotion.Root -Adapters $draft.Adapters + Assert-CcodTrue ([bool]$result.Promoted -and $state.Promoted) 'Promote completes while Defender receipt identity remains held' + Assert-CcodTrue $state.DefenderReceiptBlocked 'replacement of a held Defender receipt is blocked during visibility mutation' + } finally { + if ($null -ne $backup -and (Test-Path -LiteralPath $backup)) { + if (Test-Path -LiteralPath ($backup.Substring(0, $backup.Length - '.replacement-source'.Length))) { Remove-Item -LiteralPath ($backup.Substring(0, $backup.Length - '.replacement-source'.Length)) -Force -ErrorAction SilentlyContinue } + Move-Item -LiteralPath $backup -Destination ($backup.Substring(0, $backup.Length - '.replacement-source'.Length)) -Force -ErrorAction SilentlyContinue + } + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + if ($null -ne $promotion -and (Test-Path -LiteralPath $promotion.Root)) { Remove-Item -LiteralPath $promotion.Root -Recurse -Force -ErrorAction SilentlyContinue } + if ($null -ne $assetFixture) { Remove-CcodTask5ExactAssetFixture $assetFixture } + } +} + +Invoke-CcodTask6Test 'fix1-promote-acceptance-evidence-hold' 'Promote holds acceptance and Verify evidence identity through the visibility mutation' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name 'CcodGitHubDraftReleaseFix1AcceptanceEvidenceHold' + $assetFixture = $null + $promotion = $null + try { + $assetFixture = New-CcodTask5ExactAssetFixture + $promotion = New-CcodTask5PromotionFixture $assetFixture + $defender = Join-Path $promotion.Root 'defender' + $acceptanceDirectory = Join-Path $promotion.Root 'acceptance' + $verificationDirectory = Join-Path $promotion.Root 'verification' + [IO.Directory]::CreateDirectory($defender) | Out-Null + [IO.Directory]::CreateDirectory($acceptanceDirectory) | Out-Null + [IO.Directory]::CreateDirectory($verificationDirectory) | Out-Null + foreach ($name in @($promotion.Names)) { Move-Item -LiteralPath (Join-Path $promotion.Root $name) -Destination (Join-Path $defender $name) } + $preflightRecord = [ordered]@{ schemaVersion = 1; valid = $true; version = '2.5.22'; gitCommit = [string]$assetFixture.GitCommit; repositoryRoot = [IO.Path]::GetFullPath($repositoryRoot) } + [IO.File]::WriteAllText((Join-Path $promotion.Root 'CodexRemote-fix-2.5.22-clean-preflight.json'), (($preflightRecord | ConvertTo-Json -Depth 8) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + $draft = New-CcodTask6DraftAdapterFixture -AssetDirectory $assetFixture.Root + Invoke-CcodTask6DraftCore -Module $module -Mode Stage -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory $promotion.Root -Adapters $draft.Adapters | Out-Null + Invoke-CcodTask6DraftCore -Module $module -Mode Verify -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory $promotion.Root -Adapters $draft.Adapters | Out-Null + $acceptanceRecord = New-CcodTask9AcceptanceRecord -AssetFixture $assetFixture -EvidenceRoot (Get-CcodTask9EvidenceRoot) + $acceptancePath = Join-Path $acceptanceDirectory 'CodexRemote-fix-2.5.22-official-draft.complete.json' + [IO.File]::WriteAllText($acceptancePath, (($acceptanceRecord | ConvertTo-Json -Depth 8) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + $state = $draft.State + $state | Add-Member -NotePropertyName AcceptanceReplacementBlocked -NotePropertyValue $false + $state | Add-Member -NotePropertyName VerificationReplacementBlocked -NotePropertyValue $false + $state | Add-Member -NotePropertyName AcceptanceStateReplacementBlocked -NotePropertyValue $false + $state | Add-Member -NotePropertyName ManualArtifactReplacementBlocked -NotePropertyValue $false + $verificationPath = Join-Path $verificationDirectory 'CodexRemote-fix-2.5.22-draft-verified.json' + $acceptanceStatePath = Join-Path $promotion.Root 'official-draft-acceptance\01-Preflight.json' + $manualArtifactPath = Join-Path $promotion.Root 'official-draft-artifacts\About\screenshot.bin' + $draft.Adapters.SetReleaseDraftState = { + param($Tag, [bool]$Draft) + if (-not $Draft) { + try { [IO.File]::AppendAllText($acceptancePath, "tamper`n") } catch [IO.IOException] { $state.AcceptanceReplacementBlocked = $true } + try { [IO.File]::AppendAllText($verificationPath, "tamper`n") } catch [IO.IOException] { $state.VerificationReplacementBlocked = $true } + try { [IO.File]::AppendAllText($acceptanceStatePath, "tamper`n") } catch [IO.IOException] { $state.AcceptanceStateReplacementBlocked = $true } + try { [IO.File]::AppendAllText($manualArtifactPath, "tamper`n") } catch [IO.IOException] { $state.ManualArtifactReplacementBlocked = $true } + } + $state.DraftPrivate = $Draft + $state.Promoted = -not $Draft + }.GetNewClosure() + $result = Invoke-CcodTask6DraftCore -Module $module -Mode Promote -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory $promotion.Root -Adapters $draft.Adapters + Assert-CcodTrue ([bool]$result.Promoted -and $state.Promoted) 'Promote completes while acceptance and Verify evidence identities remain held' + Assert-CcodTrue $state.AcceptanceReplacementBlocked 'replacement of held acceptance evidence is blocked during visibility mutation' + Assert-CcodTrue $state.VerificationReplacementBlocked 'replacement of held Verify evidence is blocked during visibility mutation' + Assert-CcodTrue $state.AcceptanceStateReplacementBlocked 'replacement of held acceptance state is blocked during visibility mutation' + Assert-CcodTrue $state.ManualArtifactReplacementBlocked 'replacement of held manual artifact is blocked during visibility mutation' + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + if ($null -ne $promotion -and (Test-Path -LiteralPath $promotion.Root)) { Remove-Item -LiteralPath $promotion.Root -Recurse -Force -ErrorAction SilentlyContinue } + if ($null -ne $assetFixture) { Remove-CcodTask5ExactAssetFixture $assetFixture } + } +} + +Invoke-CcodTask6Test 'fix1-stage-final-identity' 'Stage rejects a release-object identity swap after the last upload' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name 'CcodGitHubDraftReleaseFix1StageFinalIdentity' + $assetFixture = $null + try { + $assetFixture = New-CcodTask5ExactAssetFixture + $evidence = $assetFixture.Outside + $preflightRecord = [ordered]@{ schemaVersion = 1; valid = $true; version = '2.5.22'; gitCommit = [string]$assetFixture.GitCommit; repositoryRoot = [IO.Path]::GetFullPath($repositoryRoot) } + [IO.File]::WriteAllText((Join-Path $evidence 'CodexRemote-fix-2.5.22-clean-preflight.json'), (($preflightRecord | ConvertTo-Json -Depth 8) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + $draft = New-CcodTask6DraftAdapterFixture -AssetDirectory $assetFixture.Root + $state = $draft.State + $expected = [string[]]$assetFixture.Names + $draft.Adapters.ViewRelease = { + param($Tag) + $assetNames = [string[]]$state.Uploaded + $id = if ($assetNames.Count -eq $expected.Count) { '999' } else { '123' } + [pscustomobject][ordered]@{ Tag = $Tag; Id = $id; Draft = $true; AssetNames = $assetNames } + }.GetNewClosure() + Assert-CcodThrows { Invoke-CcodTask6DraftCore -Module $module -Mode Stage -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory $evidence -Adapters $draft.Adapters | Out-Null } 'CCOD_GITHUB_DRAFT_UPLOAD_PRIVATE_RECOVERY_FAILED' + Assert-CcodTrue (-not $state.Promoted) 'Stage identity swap never promotes the draft' + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + if ($null -ne $assetFixture) { Remove-CcodTask5ExactAssetFixture $assetFixture } + } +} + +Invoke-CcodTask6Test 'fix1-defender-plane-required' 'Promote refuses Defender receipts in the shared evidence root' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name 'CcodGitHubDraftReleaseFix1DefenderPlane' + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-task6-shared-defender-' + [guid]::NewGuid().ToString('N')) + try { + [IO.Directory]::CreateDirectory($root) | Out-Null + $result = &$module { param($Evidence) Get-CcodGitHubDraftDefenderEvidenceDirectory $Evidence } $root + throw 'ASSERT_THROWS: shared Defender evidence root was accepted' + } catch { + Assert-CcodTrue ([string]$_.FullyQualifiedErrorId -like 'CCOD_GITHUB_DRAFT_EVIDENCE_PATH_INVALID*') 'shared evidence root is rejected when the dedicated Defender plane is absent' + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } + } +} + +Invoke-CcodTask6Test 'fix1-stage-lock' 'default Stage lock serializes same-tag processes and releases after the operation' { + $scriptPath = Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1' + $source = [IO.File]::ReadAllText($scriptPath, [Text.UTF8Encoding]::new($false)) + Assert-CcodTrue ($source.Contains('if ($defaultLockHeld)') -and -not $source.Contains('if ($null -eq $Adapters -and $defaultLockHeld)')) 'injected Stage and Verify adapters release the same-tag lock in finally' + + $ready = Join-Path ([IO.Path]::GetTempPath()) ('ccod-task6-lock-ready-' + [guid]::NewGuid().ToString('N')) + $release = Join-Path ([IO.Path]::GetTempPath()) ('ccod-task6-lock-release-' + [guid]::NewGuid().ToString('N')) + $job = $null + $secondModule = $null + try { + $job = Start-Job -ArgumentList $scriptPath, $ready, $release -ScriptBlock { + param($Path, $ReadyPath, $ReleasePath) + $text = [IO.File]::ReadAllText($Path, [Text.UTF8Encoding]::new($false)) + $module = New-Module -Name ('CcodLockHolder-' + [guid]::NewGuid().ToString('N')) -ScriptBlock ([scriptblock]::Create($text)) + Import-Module $module -Force -DisableNameChecking | Out-Null + $held = [bool](&$module { $adapters = Get-CcodGitHubDraftReleaseDefaultAdapters; & $adapters.AcquireStageLock 'v2.5.22' }) + if (-not $held) { throw 'CCOD_TEST_LOCK_HOLDER_FAILED' } + [IO.File]::WriteAllText($ReadyPath, 'held', [Text.UTF8Encoding]::new($false)) + while (-not [IO.File]::Exists($ReleasePath)) { Start-Sleep -Milliseconds 25 } + &$module { $adapters = Get-CcodGitHubDraftReleaseDefaultAdapters; & $adapters.ReleaseStageLock 'v2.5.22' } + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + } + $deadline = [DateTime]::UtcNow.AddSeconds(20) + while (-not [IO.File]::Exists($ready) -and [DateTime]::UtcNow -lt $deadline) { Start-Sleep -Milliseconds 50 } + Assert-CcodTrue ([IO.File]::Exists($ready)) 'separate lock-holder process acquired the same-tag lock' + $secondModule = Import-CcodTask6ToolModule -Path $scriptPath -Name 'CcodGitHubDraftReleaseFix1LockSecond' + $second = [bool](&$secondModule { $adapters = Get-CcodGitHubDraftReleaseDefaultAdapters; & $adapters.AcquireStageLock 'v2.5.22' }) + Assert-CcodTrue (-not $second) 'second same-tag process is rejected by the default lock' + [IO.File]::WriteAllText($release, 'release', [Text.UTF8Encoding]::new($false)) + $completed = Wait-Job -Job $job -Timeout 20 + Assert-CcodTrue ($null -ne $completed) 'lock-holder process releases before timeout' + Receive-Job -Job $job -ErrorAction Stop | Out-Null + $afterModule = Import-CcodTask6ToolModule -Path $scriptPath -Name 'CcodGitHubDraftReleaseFix1LockAfter' + try { + $after = [bool](&$afterModule { $adapters = Get-CcodGitHubDraftReleaseDefaultAdapters; & $adapters.AcquireStageLock 'v2.5.22' }) + Assert-CcodTrue $after 'same-tag lock can be reacquired after the holder releases' + &$afterModule { $adapters = Get-CcodGitHubDraftReleaseDefaultAdapters; & $adapters.ReleaseStageLock 'v2.5.22' } + } finally { + Remove-Module -Name $afterModule.Name -Force -ErrorAction SilentlyContinue + } + } finally { + if ($null -ne $secondModule) { Remove-Module -Name $secondModule.Name -Force -ErrorAction SilentlyContinue } + if ($null -ne $job) { Stop-Job -Job $job -ErrorAction SilentlyContinue | Out-Null; Remove-Job -Job $job -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $ready) { Remove-Item -LiteralPath $ready -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $release) { Remove-Item -LiteralPath $release -Force -ErrorAction SilentlyContinue } + } +} + +Invoke-CcodTask6Test 'fix2-operation-lock-release' 'failed Stage Verify and Promote release the real same-tag mutex and reject the old finally mutant' { + $fixture = $null; $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-operation-lock-' + [guid]::NewGuid().ToString('N')) + try { + $fixture = New-CcodTask5ExactAssetFixture + [IO.Directory]::CreateDirectory($root) | Out-Null + Write-CcodTask5Json (Join-Path $root 'CodexRemote-fix-2.5.22-clean-preflight.json') ([ordered]@{ schemaVersion = 1; valid = $false; version = '2.5.22'; gitCommit = $fixture.GitCommit; repositoryRoot = [IO.Path]::GetFullPath($repositoryRoot) }) + foreach ($mutant in @($false, $true)) { + foreach ($mode in @('Stage','Verify','Promote')) { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name ('CcodOperationLock-' + [guid]::NewGuid().ToString('N')) + $job = $null + try { + if ($mutant) { + &$module { + $source = ${function:Invoke-CcodGitHubDraftReleaseCore}.ToString() + $changed = $source.Replace('if ($defaultLockHeld)', 'if ($null -eq $Adapters -and $defaultLockHeld)').Replace('if ($promoteLockHeld)', 'if ($null -eq $Adapters -and $promoteLockHeld)') + if ($source -ceq $changed) { throw 'CCOD_TEST_LOCK_MUTANT_NOT_APPLIED' } + Set-Item Function:script:Invoke-CcodGitHubDraftReleaseCore -Value ([scriptblock]::Create($changed)) + } + } + $draft = New-CcodTask6DraftAdapterFixture -AssetDirectory $fixture.Root + $lockProbe = [pscustomobject]@{ Acquired = $false } + $draft.Adapters.TryStageLock = { + param($Tag) + $lockProbe.Acquired = [bool](&$module { param($Tag) Acquire-CcodGitHubDraftStageLock $Tag } $Tag) + return $lockProbe.Acquired + }.GetNewClosure() + Assert-CcodThrows { Invoke-CcodTask6DraftCore -Module $module -Mode $mode -Tag 'v2.5.22' -AssetDirectory $fixture.Root -EvidenceDirectory $root -Adapters $draft.Adapters | Out-Null } 'CCOD_GITHUB_DRAFT_PREFLIGHT_INVALID' + Assert-CcodTrue $lockProbe.Acquired "$mode reaches the failure after acquiring the real lock" + $mutexName = &$module { Get-CcodGitHubDraftStageLockName 'v2.5.22' } + $job = Start-Job -ArgumentList $mutexName -ScriptBlock { + param($Name) + $mutex = [Threading.Mutex]::new($false, $Name) + $held = $false + try { + try { $held = $mutex.WaitOne(0) } catch [Threading.AbandonedMutexException] { $held = $true } + return [bool]$held + } finally { if ($held) { $mutex.ReleaseMutex() }; $mutex.Dispose() } + } + $completed = Wait-Job $job -Timeout 20 + Assert-CcodTrue ($null -ne $completed -and $job.State -ceq 'Completed') "$mode separate-process lock probe completes" + $result = @(Receive-Job $job -ErrorAction Stop) + Assert-CcodEqual 1 $result.Count 'lock probe returns one explicit result' + Assert-CcodTrue ($result[0] -is [bool]) 'lock probe returns a real boolean' + Assert-CcodEqual (-not $mutant) $result[0] "$mode reacquires only when the operation finally actually releases" + } finally { + if ($null -ne $job) { Stop-Job $job -ErrorAction SilentlyContinue | Out-Null; Remove-Job $job -Force -ErrorAction SilentlyContinue } + &$module { Release-CcodGitHubDraftStageLock 'v2.5.22' } + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + } + } + } + } finally { + if ([IO.Directory]::Exists($root)) { [IO.Directory]::Delete($root, $true) } + if ($null -ne $fixture) { Remove-CcodTask5ExactAssetFixture $fixture } + } +} + +Invoke-CcodTask6Test 'fix2-manual-state-before-hold' 'Promote rejects manual state replacement after validation but before holding its bytes' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name 'CcodPromoteManualStateBeforeHold' + $fixture = $null; $promotion = $null + try { + $fixture = New-CcodTask5ExactAssetFixture + $promotion = New-CcodTask5PromotionFixture $fixture + $defender = Join-Path $promotion.Root 'defender'; $acceptance = Join-Path $promotion.Root 'acceptance' + [IO.Directory]::CreateDirectory($defender) | Out-Null + [IO.Directory]::CreateDirectory($acceptance) | Out-Null + foreach ($name in $promotion.Names) { Move-Item -LiteralPath (Join-Path $promotion.Root $name) -Destination (Join-Path $defender $name) } + Write-CcodTask5Json (Join-Path $promotion.Root 'CodexRemote-fix-2.5.22-clean-preflight.json') ([ordered]@{ schemaVersion = 1; valid = $true; version = '2.5.22'; gitCommit = $fixture.GitCommit; repositoryRoot = [IO.Path]::GetFullPath($repositoryRoot) }) + $draft = New-CcodTask6DraftAdapterFixture -AssetDirectory $fixture.Root + $draft.State.Uploaded.AddRange([string[]]$fixture.Names) + Invoke-CcodTask6DraftCore -Module $module -Mode Verify -Tag 'v2.5.22' -AssetDirectory $fixture.Root -EvidenceDirectory $promotion.Root -Adapters $draft.Adapters | Out-Null + Write-CcodTask5Json (Join-Path $acceptance 'CodexRemote-fix-2.5.22-official-draft.complete.json') (New-CcodTask9AcceptanceRecord -AssetFixture $fixture -EvidenceRoot $promotion.Root) + $probe = [pscustomobject]@{ Reads = 0; Replaced = $false } + &$module { + param($Path,$Probe) + $script:CcodManualStatePath = $Path; $script:CcodManualStateProbe = $Probe + $script:CcodOriginalAcceptanceReader = ${function:Read-CcodGitHubDraftAcceptance} + Set-Item Function:script:Read-CcodGitHubDraftAcceptance -Value { + param($EvidenceDirectory,$AssetDirectory,$Tag,$Version,$GitCommit) + $record = & $script:CcodOriginalAcceptanceReader @PSBoundParameters + $script:CcodManualStateProbe.Reads++ + if ($script:CcodManualStateProbe.Reads -eq 2) { + [IO.File]::WriteAllText($script:CcodManualStatePath, '{"schemaVersion":1}', [Text.UTF8Encoding]::new($false)) + $script:CcodManualStateProbe.Replaced = $true + } + return $record + } + } (Join-Path $promotion.Root 'official-draft-acceptance/manual-evidence/TrayEvidence-About.json') $probe + Assert-CcodThrows { Invoke-CcodTask6DraftCore -Module $module -Mode Promote -Tag 'v2.5.22' -AssetDirectory $fixture.Root -EvidenceDirectory $promotion.Root -Adapters $draft.Adapters | Out-Null } 'CCOD_GITHUB_DRAFT_ACCEPTANCE_INVALID' + Assert-CcodTrue $probe.Replaced 'replacement occurs after the final unlocked acceptance read' + Assert-CcodTrue (-not $draft.State.Promoted) 'invalid manual state cannot change public visibility' + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + if ($null -ne $promotion -and [IO.Directory]::Exists($promotion.Root)) { [IO.Directory]::Delete($promotion.Root, $true) } + if ($null -ne $fixture) { Remove-CcodTask5ExactAssetFixture $fixture } + } +} + +Invoke-CcodTask6Test 'fix2-preflight-pinned-hash' 'Promote rejects same-meaning preflight replacement after its pinned read' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools\Invoke-GitHubDraftRelease.ps1') -Name 'CcodPromotePinnedPreflight' + $fixture = $null; $promotion = $null + try { + $fixture = New-CcodTask5ExactAssetFixture + $promotion = New-CcodTask5PromotionFixture $fixture + $defender = Join-Path $promotion.Root 'defender' + $acceptance = Join-Path $promotion.Root 'acceptance' + [IO.Directory]::CreateDirectory($defender) | Out-Null + [IO.Directory]::CreateDirectory($acceptance) | Out-Null + foreach ($name in $promotion.Names) { Move-Item -LiteralPath (Join-Path $promotion.Root $name) -Destination (Join-Path $defender $name) } + $preflight = Join-Path $promotion.Root 'CodexRemote-fix-2.5.22-clean-preflight.json' + Write-CcodTask5Json $preflight ([ordered]@{ schemaVersion = 1; valid = $true; version = '2.5.22'; gitCommit = $fixture.GitCommit; repositoryRoot = [IO.Path]::GetFullPath($repositoryRoot) }) + $draft = New-CcodTask6DraftAdapterFixture -AssetDirectory $fixture.Root + $draft.State.Uploaded.AddRange([string[]]$fixture.Names) + Invoke-CcodTask6DraftCore -Module $module -Mode Verify -Tag 'v2.5.22' -AssetDirectory $fixture.Root -EvidenceDirectory $promotion.Root -Adapters $draft.Adapters | Out-Null + Write-CcodTask5Json (Join-Path $acceptance 'CodexRemote-fix-2.5.22-official-draft.complete.json') (New-CcodTask9AcceptanceRecord -AssetFixture $fixture -EvidenceRoot $promotion.Root) + $probe = [pscustomobject]@{ Replaced = $false } + &$module { + param($Path,$Probe) + $script:CcodPinnedPreflightPath = $Path + $script:CcodPinnedPreflightProbe = $Probe + $script:CcodOriginalPinnedReader = ${function:Read-CcodGitHubDraftContractJson} + Set-Item Function:script:Read-CcodGitHubDraftContractJson -Value { + param($Path,$ErrorId) + $document = & $script:CcodOriginalPinnedReader -Path $Path -ErrorId $ErrorId + if ($Path -ceq $script:CcodPinnedPreflightPath -and -not $script:CcodPinnedPreflightProbe.Replaced) { + [IO.File]::AppendAllText($Path, ' ', [Text.UTF8Encoding]::new($false)) + $script:CcodPinnedPreflightProbe.Replaced = $true + } + return $document + } + } $preflight $probe + Assert-CcodThrows { + Invoke-CcodTask6DraftCore -Module $module -Mode Promote -Tag 'v2.5.22' -AssetDirectory $fixture.Root -EvidenceDirectory $promotion.Root -Adapters $draft.Adapters | Out-Null + } 'CCOD_GITHUB_DRAFT_PREFLIGHT_INVALID' + Assert-CcodTrue $probe.Replaced 'replacement lands after the validated pinned read' + Assert-CcodTrue (-not $draft.State.Promoted) 'no visibility mutation occurs for changed preflight bytes' + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + if ($null -ne $promotion -and [IO.Directory]::Exists($promotion.Root)) { [IO.Directory]::Delete($promotion.Root, $true) } + if ($null -ne $fixture) { Remove-CcodTask5ExactAssetFixture $fixture } + } +} + +Invoke-CcodTask6Test 'fix1-evidence-layout' 'Promote accepts separate Defender receipts, verification, and acceptance evidence and reads the final draft state back' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name 'CcodGitHubDraftReleaseFix1Evidence' + $assetFixture = $null + $promotion = $null + try { + $assetFixture = New-CcodTask5ExactAssetFixture + $promotion = New-CcodTask5PromotionFixture $assetFixture + $defender = Join-Path $promotion.Root 'defender' + $verification = Join-Path $promotion.Root 'verification' + $acceptance = Join-Path $promotion.Root 'acceptance' + [IO.Directory]::CreateDirectory($defender) | Out-Null + [IO.Directory]::CreateDirectory($verification) | Out-Null + [IO.Directory]::CreateDirectory($acceptance) | Out-Null + foreach ($name in @($promotion.Names)) { + Move-Item -LiteralPath (Join-Path $promotion.Root $name) -Destination (Join-Path $defender $name) + } + $preflightRecord = [ordered]@{ schemaVersion = 1; valid = $true; version = '2.5.22'; gitCommit = [string]$assetFixture.GitCommit; repositoryRoot = [IO.Path]::GetFullPath($repositoryRoot) } + [IO.File]::WriteAllText((Join-Path $promotion.Root 'CodexRemote-fix-2.5.22-clean-preflight.json'), (($preflightRecord | ConvertTo-Json -Depth 8) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + $draft = New-CcodTask6DraftAdapterFixture -AssetDirectory $assetFixture.Root + Invoke-CcodTask6DraftCore -Module $module -Mode Stage -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory $promotion.Root -Adapters $draft.Adapters | Out-Null + Invoke-CcodTask6DraftCore -Module $module -Mode Verify -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory $promotion.Root -Adapters $draft.Adapters | Out-Null + $acceptanceRecord = New-CcodTask9AcceptanceRecord -AssetFixture $assetFixture -EvidenceRoot (Get-CcodTask9EvidenceRoot) + [IO.File]::WriteAllText((Join-Path $acceptance 'CodexRemote-fix-2.5.22-official-draft.complete.json'), (($acceptanceRecord | ConvertTo-Json -Depth 8) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + $result = Invoke-CcodTask6DraftCore -Module $module -Mode Promote -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory $promotion.Root -Adapters $draft.Adapters + Assert-CcodTrue ([bool]$result.Promoted) 'promotion succeeds with separated evidence planes' + Assert-CcodTrue (-not $draft.State.DraftPrivate) 'promotion changes the already verified draft to public' + Assert-CcodTrue (-not $draft.State.Rebuilt) 'promotion never rebuilds the candidate' + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + if ($null -ne $promotion -and (Test-Path -LiteralPath $promotion.Root)) { Remove-Item -LiteralPath $promotion.Root -Recurse -Force -ErrorAction SilentlyContinue } + if ($null -ne $assetFixture) { Remove-CcodTask5ExactAssetFixture $assetFixture } + } +} + +Invoke-CcodTask6Test 'fix1-workflow-transfer' 'release workflow transfers one immutable commit and the clean preflight as artifacts without rerunning the runner in Stage' { + $workflowPath = Join-Path $repositoryRoot '.github/workflows/release.yml' + $raw = [IO.File]::ReadAllText($workflowPath, [Text.UTF8Encoding]::new($false)) + Assert-CcodTrue ($raw.Contains('commit:')) 'preflight publishes an immutable commit output' + Assert-CcodTrue ($raw.Contains('ref: ${{ needs.preflight.outputs.commit }}')) 'build and stage use the immutable preflight commit' + Assert-CcodTrue ($raw.Contains('-NotesPath $notesPath')) 'generated release notes are passed to draft creation' + Assert-CcodTrue ([regex]::Matches($raw, 'actions/upload-artifact@').Count -ge 2) 'preflight and candidate are transferred as separate artifacts' + Assert-CcodTrue ([regex]::Matches($raw, 'actions/download-artifact@').Count -ge 2) 'stage downloads both immutable artifacts' + Assert-CcodEqual 1 ([regex]::Matches($raw, 'Test-CcodCleanReleaseRunner').Count) 'Stage does not rerun clean runner as a substitute for transferred evidence' +} + +Invoke-CcodTask6Test 'fix1-runner-defaults' 'default clean runner adapters fail closed on git failure and preflight evidence is create-only with readback' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Test-CleanReleaseRunner.ps1') -Name 'CcodCleanReleaseRunnerFix1Defaults' + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-task6-runner-defaults-' + [guid]::NewGuid().ToString('N')) + try { + [IO.Directory]::CreateDirectory($root) | Out-Null + $adapters = &$module { Get-CcodCleanReleaseRunnerDefaultAdapters } + Assert-CcodThrows { & $adapters.GetGitPorcelain $root | Out-Null } 'CCOD_CLEAN_RUNNER_INSPECTION_FAILED' + $evidence = Join-Path $root 'preflight.json' + $record = [ordered]@{ schemaVersion = 1; valid = $true; version = '2.5.22'; gitCommit = ('c' * 40); repositoryRoot = $root } + & $adapters.WritePreflightEvidence $evidence $record | Out-Null + Assert-CcodTrue (Test-Path -LiteralPath $evidence -PathType Leaf) 'default preflight writer creates its evidence file' + Assert-CcodThrows { & $adapters.WritePreflightEvidence $evidence $record | Out-Null } 'CCOD_CLEAN_RUNNER_PREFLIGHT_WRITE_FAILED' + $written = [IO.File]::ReadAllText($evidence, [Text.UTF8Encoding]::new($false)) | ConvertFrom-Json + Assert-CcodEqual $record.gitCommit ([string]$written.gitCommit) 'preflight writer readback retains the exact commit' + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } + } +} + +Invoke-CcodTask6Test 'fix1-evidence-types' 'persisted Verify and acceptance records reject scalar fields encoded as single-element arrays' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name 'CcodGitHubDraftReleaseFix1EvidenceTypes' + $assetFixture = $null + try { + $assetFixture = New-CcodTask5ExactAssetFixture + $evidence = Join-Path $assetFixture.Outside 'typed-evidence' + $acceptanceDirectory = Join-Path $evidence 'acceptance' + $verificationDirectory = Join-Path $evidence 'verification' + [IO.Directory]::CreateDirectory($acceptanceDirectory) | Out-Null + [IO.Directory]::CreateDirectory($verificationDirectory) | Out-Null + $manifestHash = Get-CcodTestFileSha256 (Join-Path $assetFixture.Root $assetFixture.Names[4]) + $acceptanceRecord = New-CcodTask9AcceptanceRecord -AssetFixture $assetFixture -EvidenceRoot $evidence + [IO.File]::WriteAllText((Join-Path $acceptanceDirectory 'CodexRemote-fix-2.5.22-official-draft.complete.json'), (($acceptanceRecord | ConvertTo-Json -Depth 8) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + &$module {param($Evidence,$Assets,$Commit)Read-CcodGitHubDraftAcceptance -EvidenceDirectory $Evidence -AssetDirectory $Assets -Tag 'v2.5.22' -Version '2.5.22' -GitCommit $Commit|Out-Null} $evidence $assetFixture.Root $assetFixture.GitCommit + [Console]::WriteLine('CCOD_ACCEPTANCE_SCALAR_CONTROL_PASSED') + $acceptanceRecord.version=@('2.5.22') + [IO.File]::WriteAllText((Join-Path $acceptanceDirectory 'CodexRemote-fix-2.5.22-official-draft.complete.json'), (($acceptanceRecord | ConvertTo-Json -Depth 8) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + Assert-CcodThrows { + &$module { param($Evidence,$Assets,$Tag,$Version,$Commit) Read-CcodGitHubDraftAcceptance -EvidenceDirectory $Evidence -AssetDirectory $Assets -Tag $Tag -Version $Version -GitCommit $Commit } $evidence $assetFixture.Root 'v2.5.22' '2.5.22' $assetFixture.GitCommit | Out-Null + } 'CCOD_GITHUB_DRAFT_ACCEPTANCE_INVALID' + $names = [string[]]$assetFixture.Names + $originalAcceptance=&$module {${function:Read-CcodGitHubDraftAcceptance}} + try { + &$module { + param($Original) + $body=$Original.ToString();$guard='$record.version -isnot [string] -or ' + Assert-CcodTrue (([regex]::Matches($body,[regex]::Escape($guard))).Count-eq1) 'one acceptance version type guard exists' + Set-Item -LiteralPath Function:script:Read-CcodGitHubDraftAcceptance -Value ([scriptblock]::Create($body.Replace($guard,''))) + } $originalAcceptance + &$module {param($Evidence,$Assets,$Commit)Read-CcodGitHubDraftAcceptance -EvidenceDirectory $Evidence -AssetDirectory $Assets -Tag 'v2.5.22' -Version '2.5.22' -GitCommit $Commit|Out-Null} $evidence $assetFixture.Root $assetFixture.GitCommit + [Console]::WriteLine('CCOD_ACCEPTANCE_VERSION_GUARD_REMOVAL_REACHED') + } finally {&$module {param($Original)Set-Item -LiteralPath Function:script:Read-CcodGitHubDraftAcceptance -Value $Original} $originalAcceptance} + $hashes = [Collections.Generic.List[string]]::new() + foreach ($name in $names) { $hashes.Add((Get-CcodTestFileSha256 (Join-Path $assetFixture.Root $name))) } + $verificationRecord = [ordered]@{ + schemaVersion = 1 + kind = 'github-draft-verification' + tag = 'v2.5.22' + draftId = '123' + version = '2.5.22' + gitCommit = [string]$assetFixture.GitCommit + draft = $true + verified = $true + assetNames = $names + assetSha256 = [string[]]$hashes + candidateManifestSha256 = $manifestHash + } + [IO.File]::WriteAllText((Join-Path $verificationDirectory 'CodexRemote-fix-2.5.22-draft-verified.json'), (($verificationRecord | ConvertTo-Json -Depth 8) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + &$module {param($Evidence,$Assets,$Commit)Read-CcodGitHubDraftVerification -EvidenceDirectory $Evidence -AssetDirectory $Assets -Tag 'v2.5.22' -Version '2.5.22' -GitCommit $Commit|Out-Null} $evidence $assetFixture.Root $assetFixture.GitCommit + [Console]::WriteLine('CCOD_VERIFY_SCALAR_CONTROL_PASSED') + $verificationRecord.tag=@('v2.5.22') + [IO.File]::WriteAllText((Join-Path $verificationDirectory 'CodexRemote-fix-2.5.22-draft-verified.json'), (($verificationRecord | ConvertTo-Json -Depth 8) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + Assert-CcodThrows { + &$module { param($Evidence,$Assets,$Tag,$Version,$Commit) Read-CcodGitHubDraftVerification -EvidenceDirectory $Evidence -AssetDirectory $Assets -Tag $Tag -Version $Version -GitCommit $Commit } $evidence $assetFixture.Root 'v2.5.22' '2.5.22' $assetFixture.GitCommit | Out-Null + } 'CCOD_GITHUB_DRAFT_VERIFY_EVIDENCE_INVALID' + $verificationRecord.tag = 'v2.5.22' + [IO.File]::WriteAllText((Join-Path $verificationDirectory 'CodexRemote-fix-2.5.22-draft-verified.json'), (($verificationRecord | ConvertTo-Json -Depth 8) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + &$module {param($Evidence,$Assets,$Commit)Read-CcodGitHubDraftVerification -EvidenceDirectory $Evidence -AssetDirectory $Assets -Tag 'v2.5.22' -Version '2.5.22' -GitCommit $Commit|Out-Null} $evidence $assetFixture.Root $assetFixture.GitCommit + $malformedNames = New-Object object[] $names.Count + $nestedName = New-Object object[] 1 + $nestedName[0] = $names[0] + $malformedNames[0] = $nestedName + for ($index = 1; $index -lt $names.Count; $index++) { $malformedNames[$index] = $names[$index] } + $malformedRecord = [pscustomobject][ordered]@{ + schemaVersion = 1 + kind = 'github-draft-verification' + tag = 'v2.5.22' + draftId = '123' + version = '2.5.22' + gitCommit = [string]$assetFixture.GitCommit + draft = $true + verified = $true + assetNames = $malformedNames + assetSha256 = [string[]]$hashes + candidateManifestSha256 = $manifestHash + } + &$module { param($Value) Set-Item Function:Read-CcodGitHubDraftContractJson -Value { param($JsonPath,$ErrorId) [pscustomobject]@{ Raw = '{}'; Value = $Value } }.GetNewClosure() } $malformedRecord + Assert-CcodThrows { + &$module { param($Evidence,$Assets,$Tag,$Version,$Commit) Read-CcodGitHubDraftVerification -EvidenceDirectory $Evidence -AssetDirectory $Assets -Tag $Tag -Version $Version -GitCommit $Commit } $evidence $assetFixture.Root 'v2.5.22' '2.5.22' $assetFixture.GitCommit | Out-Null + } 'CCOD_GITHUB_DRAFT_VERIFY_EVIDENCE_INVALID' + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + if ($null -ne $assetFixture) { Remove-CcodTask5ExactAssetFixture $assetFixture } + } +} + +Invoke-CcodTask6Test 'review-verify-frozen-authority' 'Verify never attests a frozen asset changed after its remote comparison' { + foreach ($attack in @($false,$true)) { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name ('CcodVerifyFrozenAuthority'+[guid]::NewGuid().ToString('N')) + $fixture = $null + try { + $fixture = New-CcodTask5ExactAssetFixture + $preflight = [ordered]@{schemaVersion=1;valid=$true;version='2.5.22';gitCommit=$fixture.GitCommit;repositoryRoot=[IO.Path]::GetFullPath($repositoryRoot)} + Write-CcodTask5Json -Path (Join-Path $fixture.Outside 'CodexRemote-fix-2.5.22-clean-preflight.json') -Value $preflight + $draft = New-CcodTask6DraftAdapterFixture -AssetDirectory $fixture.Root + Invoke-CcodTask6DraftCore -Module $module -Mode Stage -Tag 'v2.5.22' -AssetDirectory $fixture.Root -EvidenceDirectory $fixture.Outside -Adapters $draft.Adapters | Out-Null + $firstName = [string]$fixture.Names[0] + $originalHash = Get-CcodTestFileSha256 (Join-Path $fixture.Root $firstName) + $state = [pscustomobject]@{Frozen=$null;Downloads=0;Attempted=$false;Changed=$false;Attack=$attack;Names=[string[]]$fixture.Names;RemoteRoot=$fixture.Root} + & $module { + param($State) + $script:CcodVerifyFreezeState=$State + $script:CcodVerifyOriginalFreeze=${function:New-CcodGitHubDraftFrozenAssetSet} + function script:New-CcodGitHubDraftFrozenAssetSet { + param($AssetDirectory,$Version,$Contract) + $result=& $script:CcodVerifyOriginalFreeze -AssetDirectory $AssetDirectory -Version $Version -Contract $Contract + $script:CcodVerifyFreezeState.Frozen=$result.Directory + return $result + } + } $state + $draft.Adapters.DownloadAsset = { + param($Tag,$Name,$Destination) + [IO.File]::Copy((Join-Path $state.RemoteRoot $Name),$Destination,$false) + $state.Downloads++ + if ($state.Attack -and $state.Downloads -eq $state.Names.Count) { + $state.Attempted=$true + [IO.File]::WriteAllText((Join-Path $state.Frozen $state.Names[0]),'changed after the earlier comparison',[Text.UTF8Encoding]::new($false)) + $state.Changed=$true + } + }.GetNewClosure() + $outcome = try { + $result = Invoke-CcodTask6DraftCore -Module $module -Mode Verify -Tag 'v2.5.22' -AssetDirectory $fixture.Root -EvidenceDirectory $fixture.Outside -Adapters $draft.Adapters + [pscustomobject]@{Success=$true;Result=$result;ErrorId=$null} + } catch { [pscustomobject]@{Success=$false;Result=$null;ErrorId=$_.FullyQualifiedErrorId} } + Assert-CcodEqual $fixture.Names.Count $state.Downloads 'all remote comparisons are reached before the mutation seam' + Assert-CcodTrue (-not [string]::IsNullOrWhiteSpace($state.Frozen)) 'the actual production freeze helper was invoked' + if (-not $attack) { + Assert-CcodTrue $outcome.Success 'an unchanged frozen candidate has a passing control' + $receipt = [IO.File]::ReadAllText($outcome.Result.VerificationPath) | ConvertFrom-Json + Assert-CcodEqual $originalHash $receipt.assetSha256[0] 'the control attests the original remote bytes' + $manifestName = & $module { Get-CcodGitHubDraftManifestName '2.5.22' } + Assert-CcodEqual (Get-CcodTestFileSha256 (Join-Path $fixture.Root $manifestName)) $receipt.candidateManifestSha256 'the candidate manifest hash remains bound to the initially validated bytes' + [Console]::WriteLine('CCOD_VERIFY_FROZEN_CONTROL_PASSED') + } else { + Assert-CcodTrue $state.Attempted 'the writer reaches an already-compared frozen asset' + Assert-CcodTrue (-not $outcome.Success) 'Verify must not mark changed local bytes as remotely verified' + Assert-CcodTrue (-not $state.Changed) 'the held frozen file excludes the writer' + Assert-CcodTrue ($outcome.ErrorId -like 'CCOD_GITHUB_DRAFT_READBACK_FAILED*') 'the failed download boundary stays fail-closed' + } + Assert-CcodTrue (-not [IO.Directory]::Exists($state.Frozen)) 'frozen handles are released before temporary cleanup' + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + if ($null -ne $fixture) { Remove-CcodTask5ExactAssetFixture $fixture } + } + } +} + +Invoke-CcodTask6Test 'review-verify-ancestor' 'Verify excludes ancestor rename while comparing frozen assets' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name ('CcodVerifyAncestor'+[guid]::NewGuid().ToString('N')) + $fixture = $null + try { + $fixture = New-CcodTask5ExactAssetFixture + Write-CcodTask5Json -Path (Join-Path $fixture.Outside 'CodexRemote-fix-2.5.22-clean-preflight.json') -Value ([ordered]@{schemaVersion=1;valid=$true;version='2.5.22';gitCommit=$fixture.GitCommit;repositoryRoot=[IO.Path]::GetFullPath($repositoryRoot)}) + $draft = New-CcodTask6DraftAdapterFixture -AssetDirectory $fixture.Root + Invoke-CcodTask6DraftCore -Module $module -Mode Stage -Tag 'v2.5.22' -AssetDirectory $fixture.Root -EvidenceDirectory $fixture.Outside -Adapters $draft.Adapters | Out-Null + $parent = Join-Path $fixture.Outside 'owned-freeze-parent' + [IO.Directory]::CreateDirectory($parent) | Out-Null + $state = [pscustomobject]@{Parent=$parent;Frozen=$null;Downloads=0;Attempted=$false;Renamed=$false;Names=[string[]]$fixture.Names;RemoteRoot=$fixture.Root} + & $module { + param($State) + $script:CcodVerifyAncestorState=$State + $script:CcodVerifyOriginalFreeze=${function:New-CcodGitHubDraftFrozenAssetSet} + function script:New-CcodGitHubDraftFrozenAssetSet { + param($AssetDirectory,$Version,$Contract) + $result=& $script:CcodVerifyOriginalFreeze -AssetDirectory $AssetDirectory -Version $Version -Contract $Contract + $target=Join-Path $script:CcodVerifyAncestorState.Parent 'frozen' + [IO.Directory]::Move($result.Directory,$target) + $result.Directory=$target + $script:CcodVerifyAncestorState.Frozen=$target + return $result + } + } $state + $draft.Adapters.DownloadAsset = { + param($Tag,$Name,$Destination) + [IO.File]::Copy((Join-Path $state.RemoteRoot $Name),$Destination,$false) + $state.Downloads++ + if ($state.Downloads -eq $state.Names.Count) { + $state.Attempted=$true + $moved=$state.Parent+'.moved' + try {[IO.Directory]::Move($state.Parent,$moved);$state.Renamed=$true} + finally {if($state.Renamed){[IO.Directory]::Move($moved,$state.Parent)}} + } + }.GetNewClosure() + $outcome = try { + Invoke-CcodTask6DraftCore -Module $module -Mode Verify -Tag 'v2.5.22' -AssetDirectory $fixture.Root -EvidenceDirectory $fixture.Outside -Adapters $draft.Adapters | Out-Null + [pscustomobject]@{Success=$true;ErrorId=$null} + } catch {[pscustomobject]@{Success=$false;ErrorId=$_.FullyQualifiedErrorId}} + Assert-CcodEqual $fixture.Names.Count $state.Downloads 'the real held comparison path reaches its last download' + Assert-CcodTrue $state.Attempted 'only the fixture-owned ancestor was targeted' + Assert-CcodTrue (-not $state.Renamed) 'frozen ancestor authority excludes an ABA rename' + Assert-CcodTrue (-not $outcome.Success) 'the blocked adapter operation cannot produce verification evidence' + Assert-CcodTrue ($outcome.ErrorId -like 'CCOD_GITHUB_DRAFT_READBACK_FAILED*') 'ancestor failure is not disguised as an unrelated fixture error' + Assert-CcodTrue (-not [IO.Directory]::Exists($state.Frozen)) 'the frozen subtree is cleaned after releasing authorities' + [IO.Directory]::Move($parent,$parent+'.released') + [IO.Directory]::Move($parent+'.released',$parent) + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + if ($null -ne $fixture) {Remove-CcodTask5ExactAssetFixture $fixture} + } +} + +Invoke-CcodTask6Test 'review-verify-authority-boundaries' 'Verify pins original bytes before callbacks and holds them through receipt readback' { + foreach ($mode in @('Control','ChangedBeforePin','ExistingWriter','ReceiptWrite','ReceiptDirectory')) { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name ('CcodVerifyAuthorityBoundary'+[guid]::NewGuid().ToString('N')) + $fixture=$null;$state=$null + try { + $fixture=New-CcodTask5ExactAssetFixture + Write-CcodTask5Json -Path (Join-Path $fixture.Outside 'CodexRemote-fix-2.5.22-clean-preflight.json') -Value ([ordered]@{schemaVersion=1;valid=$true;version='2.5.22';gitCommit=$fixture.GitCommit;repositoryRoot=[IO.Path]::GetFullPath($repositoryRoot)}) + $draft=New-CcodTask6DraftAdapterFixture -AssetDirectory $fixture.Root + Invoke-CcodTask6DraftCore -Module $module -Mode Stage -Tag 'v2.5.22' -AssetDirectory $fixture.Root -EvidenceDirectory $fixture.Outside -Adapters $draft.Adapters | Out-Null + $receiptPath=& $module {param($Root) Get-CcodGitHubDraftVerificationPath $Root '2.5.22' -CreatePlane} $fixture.Outside + $state=[pscustomobject]@{Mode=$mode;Frozen=$null;Writer=$null;Names=[string[]]$fixture.Names;Downloads=0;RemoteRoot=$fixture.Root;Attempted=$false;Changed=$false;PublishedSeen=$false;Receipt=$receiptPath} + & $module { + param($State) + $script:CcodVerifyBoundaryState=$State + $script:CcodVerifyOriginalFreeze=${function:New-CcodGitHubDraftFrozenAssetSet} + function script:New-CcodGitHubDraftFrozenAssetSet { + param($AssetDirectory,$Version,$Contract) + $result=& $script:CcodVerifyOriginalFreeze -AssetDirectory $AssetDirectory -Version $Version -Contract $Contract + $script:CcodVerifyBoundaryState.Frozen=$result.Directory + $target=Join-Path $result.Directory $script:CcodVerifyBoundaryState.Names[1] + if ($script:CcodVerifyBoundaryState.Mode -ceq 'ChangedBeforePin') { + [IO.File]::WriteAllText($target,'changed before pin',[Text.UTF8Encoding]::new($false)) + $script:CcodVerifyBoundaryState.Attempted=$true + } + if ($script:CcodVerifyBoundaryState.Mode -ceq 'ExistingWriter') { + $script:CcodVerifyBoundaryState.Writer=[IO.File]::Open($target,[IO.FileMode]::Open,[IO.FileAccess]::ReadWrite,([IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete)) + $script:CcodVerifyBoundaryState.Attempted=$true + } + return $result + } + } $state + $draft.Adapters.DownloadAsset={param($Tag,$Name,$Destination) $state.Downloads++;[IO.File]::Copy((Join-Path $state.RemoteRoot $Name),$Destination,$false)}.GetNewClosure() + $draft.Adapters.GetTagCommit={ + param($Tag,[bool]$ActionsOnly) + if ($state.Mode -in @('ReceiptWrite','ReceiptDirectory') -and [IO.File]::Exists($state.Receipt)) { + $state.PublishedSeen=$true;$state.Attempted=$true + if ($state.Mode -ceq 'ReceiptWrite') { + [IO.File]::WriteAllText((Join-Path $state.Frozen $state.Names[0]),'changed during receipt readback',[Text.UTF8Encoding]::new($false));$state.Changed=$true + } else { + try {[IO.Directory]::Move($state.Frozen,$state.Frozen+'.moved');$state.Changed=$true} + finally {if($state.Changed){[IO.Directory]::Move($state.Frozen+'.moved',$state.Frozen)}} + } + } + return 'c'*40 + }.GetNewClosure() + $outcome=try { + $result=Invoke-CcodTask6DraftCore -Module $module -Mode Verify -Tag 'v2.5.22' -AssetDirectory $fixture.Root -EvidenceDirectory $fixture.Outside -Adapters $draft.Adapters + [pscustomobject]@{Success=$true;ErrorId=$null;Result=$result} + } catch {[pscustomobject]@{Success=$false;ErrorId=$_.FullyQualifiedErrorId;Result=$null}} + if ($mode -ceq 'Control') { + Assert-CcodTrue $outcome.Success 'an unchanged candidate verifies before introducing any boundary fault' + Assert-CcodTrue ([IO.File]::Exists($receiptPath)) 'the control produces a verification receipt' + } else { + Assert-CcodTrue $state.Attempted "$mode reaches its intended mutation seam" + Assert-CcodTrue (-not $outcome.Success) "$mode cannot yield a successful Verify" + Assert-CcodTrue (-not [IO.File]::Exists($receiptPath)) "$mode leaves no verification receipt" + if ($mode -in @('ChangedBeforePin','ExistingWriter')) { + Assert-CcodEqual 0 $state.Downloads 'invalid or writable frozen bytes are rejected before download comparisons' + Assert-CcodTrue ($outcome.ErrorId -like 'CCOD_GITHUB_DRAFT_ASSET_FREEZE_FAILED*') 'the acquisition rejects the exact asset authority' + } else { + Assert-CcodEqual $fixture.Names.Count $state.Downloads 'the published-receipt probe follows all remote comparisons' + Assert-CcodTrue $state.PublishedSeen 'readback failure is injected only after actual receipt publication' + Assert-CcodTrue (-not $state.Changed) 'the held file/directory blocks the callback mutation' + Assert-CcodTrue ($outcome.ErrorId -like 'CCOD_GITHUB_DRAFT_VERIFY_EVIDENCE_INVALID*') "owned evidence rollback retains the plane-specific error mode=$mode actual=$($outcome.ErrorId)" + } + } + if ($null -ne $state.Writer) {$state.Writer.Dispose();$state.Writer=$null} + Assert-CcodTrue (-not [IO.Directory]::Exists($state.Frozen)) 'partial and complete authority acquisition both release before cleanup' + } finally { + if ($null -ne $state -and $null -ne $state.Writer) {$state.Writer.Dispose()} + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + if ($null -ne $fixture) {Remove-CcodTask5ExactAssetFixture $fixture} + } + } +} + +Invoke-CcodTask6Test 'review-verify-rejected-receipt' 'A rejected draft receipt closes its deleted handle before proving absence' { + $module=Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name ('CcodRejectedDraftReceipt'+[guid]::NewGuid().ToString('N')) + $root=Join-Path ([IO.Path]::GetTempPath()) ('ccod-draft-receipt-rejection-'+[guid]::NewGuid().ToString('N')) + [IO.Directory]::CreateDirectory($root) | Out-Null + try { + $record=[ordered]@{schemaVersion=1;kind='synthetic-self-test'} + $good=Join-Path $root 'control.json' + & $module {param($Path,$Record) Write-CcodGitHubDraftJsonCreateOnly -Path $Path -Record $Record -ErrorId 'CCOD_TEST_VERIFY_WRITE_FAILED' -ReadbackVerifier {param($PublishedPath) return $true} | Out-Null} $good $record + Assert-CcodTrue ([IO.File]::Exists($good)) 'a valid control publishes and reads back successfully' + $target=Join-Path $root 'rejected.json' + $state=[pscustomobject]@{Calls=0;PublishedSeen=$false} + $reject={param($Path) $state.Calls++;$state.PublishedSeen=[IO.File]::Exists($Path);return $false}.GetNewClosure() + Assert-CcodThrows { + & $module {param($Path,$Record,$Verifier) Write-CcodGitHubDraftJsonCreateOnly -Path $Path -Record $Record -ErrorId 'CCOD_TEST_VERIFY_WRITE_FAILED' -ReadbackVerifier $Verifier | Out-Null} $target $record $reject + } 'CCOD_TEST_VERIFY_WRITE_FAILED' + Assert-CcodEqual 1 $state.Calls 'the callback rejects exactly one actual publication' + Assert-CcodTrue $state.PublishedSeen 'the callback does not delete or hide the file itself' + Assert-CcodTrue (-not [IO.File]::Exists($target)) 'production cleanup proves its rejected receipt absent' + Assert-CcodTrue ([IO.File]::Exists($good)) 'cleanup preserves the unrelated control receipt' + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + if ([IO.Directory]::Exists($root)) {Remove-Item -LiteralPath $root -Recurse -Force} + } +} + +Invoke-CcodTask6Test 'fix1-freeze-assets' 'Stage uploads the exact candidate bytes even when a source asset changes after validation' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name 'CcodGitHubDraftReleaseFix1FreezeAssets' + $assetFixture = $null + $uploadedRoot = $null + try { + $assetFixture = New-CcodTask5ExactAssetFixture + $evidence = Join-Path $assetFixture.Outside 'freeze-assets' + [IO.Directory]::CreateDirectory($evidence) | Out-Null + $expected = [string[]]$assetFixture.Names + $preflightRecord = [ordered]@{ schemaVersion = 1; valid = $true; version = '2.5.22'; gitCommit = [string]$assetFixture.GitCommit; repositoryRoot = [IO.Path]::GetFullPath($repositoryRoot) } + [IO.File]::WriteAllText((Join-Path $evidence 'CodexRemote-fix-2.5.22-clean-preflight.json'), (($preflightRecord | ConvertTo-Json -Depth 8) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + $originalHash = Get-CcodTestFileSha256 (Join-Path $assetFixture.Root $expected[0]) + $uploadedRoot = Join-Path $assetFixture.Outside 'uploaded' + [IO.Directory]::CreateDirectory($uploadedRoot) | Out-Null + $draft = New-CcodTask6DraftAdapterFixture -AssetDirectory $assetFixture.Root + $state = $draft.State + $sourceRoot = $assetFixture.Root + $hashFile = { + param($Path) + $sha = [Security.Cryptography.SHA256]::Create() + try { + $stream = [IO.File]::OpenRead($Path) + try { return [BitConverter]::ToString($sha.ComputeHash($stream)).Replace('-', '').ToLowerInvariant() } finally { $stream.Dispose() } + } finally { $sha.Dispose() } + }.GetNewClosure() + $draft.Adapters.CreateDraft = { + param($Tag, $Title, $Notes) + [IO.File]::WriteAllText((Join-Path $sourceRoot $expected[0]), 'mutated-after-exact-set', [Text.UTF8Encoding]::new($false)) + $state.Created = $true + $state.DraftPrivate = $true + [pscustomobject][ordered]@{ Tag = $Tag; Id = [string]$state.DraftId; Draft = $true; AssetNames = @() } + }.GetNewClosure() + $draft.Adapters.UploadAsset = { + param($Tag, $Name, $Path) + $destination = Join-Path $uploadedRoot $Name + [IO.File]::Copy($Path, $destination, $true) + $state.Uploaded.Add($Name) + $state.Assets[$Name] = & $hashFile $destination + }.GetNewClosure() + $draft.Adapters.DownloadAsset = { + param($Tag, $Name, $Destination) + [IO.File]::Copy((Join-Path $uploadedRoot $Name), $Destination, $true) + }.GetNewClosure() + $notesFile = Join-Path $assetFixture.Outside 'notes.txt' + [IO.File]::WriteAllText($notesFile, 'outside notes', [Text.UTF8Encoding]::new($false)) + Assert-CcodThrows { Invoke-CcodTask6DraftCore -Module $module -Mode Stage -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory $evidence -Adapters $draft.Adapters -NotesPath $notesFile | Out-Null } 'CCOD_GITHUB_DRAFT_NOTES_INVALID' + Invoke-CcodTask6DraftCore -Module $module -Mode Stage -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory $evidence -Adapters $draft.Adapters | Out-Null + Assert-CcodEqual $originalHash ([string]$state.Assets[$expected[0]]) 'Stage uploads the pre-validation bytes rather than the raced source bytes' + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + if ($null -ne $assetFixture) { Remove-CcodTask5ExactAssetFixture $assetFixture } + } +} + +Invoke-CcodTask6Test 'fix1-promote-lock' 'Promote refuses an occupied same-tag lock before changing draft visibility' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name 'CcodGitHubDraftReleaseFix1PromoteLock' + $source = [IO.File]::ReadAllText((Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1'), [Text.UTF8Encoding]::new($false)) + Assert-CcodTrue ($source.Contains('$frozen = $null')) 'Promote initializes frozen state before any failure-prone acquisition' + $assetFixture = $null + $promotion = $null + try { + $assetFixture = New-CcodTask5ExactAssetFixture + $promotion = New-CcodTask5PromotionFixture $assetFixture + $defender = Join-Path $promotion.Root 'defender' + $acceptanceDirectory = Join-Path $promotion.Root 'acceptance' + [IO.Directory]::CreateDirectory($defender) | Out-Null + foreach ($name in @($promotion.Names)) { Move-Item -LiteralPath (Join-Path $promotion.Root $name) -Destination (Join-Path $defender $name) } + $preflightRecord = [ordered]@{ schemaVersion = 1; valid = $true; version = '2.5.22'; gitCommit = [string]$assetFixture.GitCommit; repositoryRoot = [IO.Path]::GetFullPath($repositoryRoot) } + [IO.File]::WriteAllText((Join-Path $promotion.Root 'CodexRemote-fix-2.5.22-clean-preflight.json'), (($preflightRecord | ConvertTo-Json -Depth 8) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + $expected = [string[]]$assetFixture.Names + $draft = New-CcodTask6DraftAdapterFixture -AssetDirectory $assetFixture.Root + $draft.State.Uploaded.AddRange($expected) + Invoke-CcodTask6DraftCore -Module $module -Mode Verify -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory $promotion.Root -Adapters $draft.Adapters | Out-Null + $acceptanceRecord = New-CcodTask9AcceptanceRecord -AssetFixture $assetFixture -EvidenceRoot (Get-CcodTask9EvidenceRoot) + [IO.Directory]::CreateDirectory($acceptanceDirectory) | Out-Null + [IO.File]::WriteAllText((Join-Path $acceptanceDirectory 'CodexRemote-fix-2.5.22-official-draft.complete.json'), (($acceptanceRecord | ConvertTo-Json -Depth 8) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + $draft.Adapters.TryStageLock = { param($Tag) $false }.GetNewClosure() + Assert-CcodThrows { + Invoke-CcodTask6DraftCore -Module $module -Mode Promote -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory $promotion.Root -Adapters $draft.Adapters | Out-Null + } 'CCOD_GITHUB_DRAFT_CONCURRENT' + Assert-CcodTrue (-not $draft.State.Promoted) 'occupied Promote lock never changes visibility' + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + if ($null -ne $promotion -and (Test-Path -LiteralPath $promotion.Root)) { Remove-Item -LiteralPath $promotion.Root -Recurse -Force -ErrorAction SilentlyContinue } + if ($null -ne $assetFixture) { Remove-CcodTask5ExactAssetFixture $assetFixture } + } +} + +Invoke-CcodTask6Test 'fix1-evidence-paths' 'verification and acceptance evidence reject junction ancestry outside the evidence root' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name 'CcodGitHubDraftReleaseFix1EvidencePaths' + $assetFixture = $null + try { + $assetFixture = New-CcodTask5ExactAssetFixture + $evidence = Join-Path $assetFixture.Outside 'evidence-paths' + $verificationOutside = Join-Path $assetFixture.Outside 'verification-outside' + $acceptanceOutside = Join-Path $assetFixture.Outside 'acceptance-outside' + [IO.Directory]::CreateDirectory($evidence) | Out-Null + [IO.Directory]::CreateDirectory($verificationOutside) | Out-Null + [IO.Directory]::CreateDirectory($acceptanceOutside) | Out-Null + $verificationLink = Join-Path $evidence 'verification' + $acceptanceLink = Join-Path $evidence 'acceptance' + $previous = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + & cmd.exe /d /c mklink /J $verificationLink $verificationOutside 2>&1 | Out-Null + $verificationCode = $LASTEXITCODE + & cmd.exe /d /c mklink /J $acceptanceLink $acceptanceOutside 2>&1 | Out-Null + $acceptanceCode = $LASTEXITCODE + } finally { $ErrorActionPreference = $previous } + if ($verificationCode -ne 0 -or $acceptanceCode -ne 0) { throw 'evidence path junction fixture failed' } + $preflightRecord = [ordered]@{ schemaVersion = 1; valid = $true; version = '2.5.22'; gitCommit = [string]$assetFixture.GitCommit; repositoryRoot = [IO.Path]::GetFullPath($repositoryRoot) } + [IO.File]::WriteAllText((Join-Path $evidence 'CodexRemote-fix-2.5.22-clean-preflight.json'), (($preflightRecord | ConvertTo-Json -Depth 8) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + $expected = [string[]]$assetFixture.Names + $verify = New-CcodTask6DraftAdapterFixture -AssetDirectory $assetFixture.Root + $verify.State.Uploaded.AddRange($expected) + Assert-CcodThrows { + Invoke-CcodTask6DraftCore -Module $module -Mode Verify -Tag 'v2.5.22' -AssetDirectory $assetFixture.Root -EvidenceDirectory $evidence -Adapters $verify.Adapters | Out-Null + } 'CCOD_GITHUB_DRAFT_EVIDENCE_PATH_INVALID' + Assert-CcodTrue (-not (Test-Path -LiteralPath (Join-Path $verificationOutside 'CodexRemote-fix-2.5.22-draft-verified.json') -PathType Leaf)) 'Verify does not write through an external verification junction' + $acceptanceRecord = New-CcodTask9AcceptanceRecord -AssetFixture $assetFixture -EvidenceRoot (Get-CcodTask9EvidenceRoot) + [IO.File]::WriteAllText((Join-Path $acceptanceOutside 'CodexRemote-fix-2.5.22-official-draft.complete.json'), (($acceptanceRecord | ConvertTo-Json -Depth 8) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + Assert-CcodThrows { + &$module { param($Evidence,$Assets,$Tag,$Version,$Commit) Read-CcodGitHubDraftAcceptance -EvidenceDirectory $Evidence -AssetDirectory $Assets -Tag $Tag -Version $Version -GitCommit $Commit } $evidence $assetFixture.Root 'v2.5.22' '2.5.22' $assetFixture.GitCommit | Out-Null + } 'CCOD_GITHUB_DRAFT_EVIDENCE_PATH_INVALID' + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + if ($null -ne $assetFixture) { Remove-CcodTask5ExactAssetFixture $assetFixture } + } +} + +Invoke-CcodTask6Test 'fix1-notes-pinned-read' 'Stage reads approved release notes through a held file authority' { + $source = [IO.File]::ReadAllText((Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1'), [Text.UTF8Encoding]::new($false)) + $start = $source.IndexOf('if (-not [string]::IsNullOrWhiteSpace($NotesPath))') + $end = $source.IndexOf('$expected = [string[]]$frozen.Names', $start) + Assert-CcodTrue ($start -ge 0 -and $end -gt $start) 'Stage notes block is present' + $block = $source.Substring($start, $end - $start) + Assert-CcodTrue ($block.Contains('Read-CcodGitHubDraftNotesPinned')) 'Stage notes use the pinned authority reader' + Assert-CcodTrue (-not $block.Contains('[IO.File]::ReadAllText($notesFile')) 'Stage notes do not reopen the pathname directly' +} + +Invoke-CcodTask6Test 'fix1-concurrency-canonical' 'workflow dispatch rejects whitespace tag variants instead of normalizing them after concurrency grouping' { + $releasePath = Join-Path $repositoryRoot '.github/workflows/release.yml' + $raw = [IO.File]::ReadAllText($releasePath, [Text.UTF8Encoding]::new($false)) + Assert-CcodTrue ($raw -cnotmatch '\$tag\s*=\s*\$tag\.Trim\(\)') 'dispatch tag is validated without post-group normalization' +} + +Invoke-CcodTask6Test 'fix2-defender-absolute' 'Defender entrypoint version commit and artifact digest reject trailing newlines before scan' { + $module = Import-Module $releaseDefenderModulePath -Force -PassThru -DisableNameChecking + try { + $commands = @((Get-Command $defenderPath), (&$module { Get-Command Invoke-CcodReleaseDefenderCheck }), (&$module { Get-Command Invoke-CcodReleaseDefenderCheckCore })) + foreach ($command in $commands) { + foreach ($parameter in @('ExpectedVersion','ExpectedGitCommit')) { + $value = if ($parameter -ceq 'ExpectedVersion') { '2.5.22' } else { 'a' * 40 } + $patterns = @($command.Parameters[$parameter].Attributes | Where-Object { $_ -is [Management.Automation.ValidatePatternAttribute] }) + Assert-CcodEqual 1 $patterns.Count "$($command.Name) has one strict $parameter validator" + Assert-CcodTrue ([regex]::IsMatch($value, $patterns[0].RegexPattern)) 'canonical input remains valid' + foreach ($suffix in @("`n","`r`n",' ')) { + Assert-CcodTrue (-not [regex]::IsMatch($value + $suffix, $patterns[0].RegexPattern)) "$($command.Name) $parameter rejects a noncanonical suffix" + } + } + } + $identity = New-CcodTask5WorkflowIdentity + $valid = &$module { param($Identity) Assert-CcodReleaseDefenderOrigin -Origin TrustedWorkflowArtifact -WorkflowArtifactIdentity $Identity -ExpectedGitCommit ('a' * 40) } $identity + Assert-CcodEqual $identity.artifactDigest $valid.artifactDigest 'canonical workflow digest remains valid' + $identity.artifactDigest += "`n" + Assert-CcodThrows { + &$module { param($Identity) Assert-CcodReleaseDefenderOrigin -Origin TrustedWorkflowArtifact -WorkflowArtifactIdentity $Identity -ExpectedGitCommit ('a' * 40) } $identity | Out-Null + } 'CCOD_DEFENDER_ORIGIN_INVALID' + } finally { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } +} + +Invoke-CcodTask6Test 'fix1-absolute-tag-anchor' 'release tag and version validators reject trailing newline variants' { + $release = [IO.File]::ReadAllText((Join-Path $repositoryRoot '.github/workflows/release.yml'), [Text.UTF8Encoding]::new($false)) + $draft = [IO.File]::ReadAllText((Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1'), [Text.UTF8Encoding]::new($false)) + $clean = [IO.File]::ReadAllText((Join-Path $repositoryRoot 'tools/Test-CleanReleaseRunner.ps1'), [Text.UTF8Encoding]::new($false)) + $assetContract = [IO.File]::ReadAllText((Join-Path $repositoryRoot 'tools/ReleaseAssetContract.psm1'), [Text.UTF8Encoding]::new($false)) + Assert-CcodTrue ($release.Contains('^v\d+\.\d+\.\d+\z')) 'release workflow uses an absolute tag end anchor' + Assert-CcodTrue ($draft.Contains('^v\d+\.\d+\.\d+\z')) 'draft command uses an absolute tag end anchor' + Assert-CcodTrue ($clean.Contains('^\d+\.\d+\.\d+\z')) 'clean runner uses an absolute version end anchor' + Assert-CcodTrue (-not $assetContract.Contains('^\d+\.\d+\.\d+$')) 'release asset contract has no weak version end anchor' + Assert-CcodTrue (-not $assetContract.Contains("'^[0-9a-f]{64}$'")) 'release asset contract has no weak hash end anchor' + Assert-CcodTrue (-not $assetContract.Contains("'^[0-9a-f]{40}$'")) 'release asset contract has no weak commit end anchor' + Assert-CcodTrue (-not $draft.Contains("'^[0-9a-f]{64}$'")) 'draft command has no weak hash end anchor' + Assert-CcodTrue (-not $draft.Contains("'^[0-9a-f]{40}$'")) 'draft command has no weak commit end anchor' +} + +Invoke-CcodTask6Test 'task9-manual-acceptance-strict-parser' 'Promote rejects array-typed phases and newline-suffixed manual hashes' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name 'CcodGitHubDraftReleaseTask9StrictManual' + $assetFixture = $null + $evidence = $null + try { + $assetFixture = New-CcodTask5ExactAssetFixture + $evidence = Join-Path $assetFixture.Outside 'task9-strict-manual' + $acceptanceDirectory = Join-Path $evidence 'acceptance' + [IO.Directory]::CreateDirectory($acceptanceDirectory) | Out-Null + $acceptancePath = Join-Path $acceptanceDirectory 'CodexRemote-fix-2.5.22-official-draft.complete.json' + $record = New-CcodTask9AcceptanceRecord -AssetFixture $assetFixture -EvidenceRoot (Get-CcodTask9EvidenceRoot) + $record.manualEvidence[0].phase = @('TrayEvidence') + [IO.File]::WriteAllText($acceptancePath, (($record | ConvertTo-Json -Depth 12) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + Assert-CcodThrows { + &$module { param($Evidence,$Assets,$Tag,$Version,$Commit) Read-CcodGitHubDraftAcceptance -EvidenceDirectory $Evidence -AssetDirectory $Assets -Tag $Tag -Version $Version -GitCommit $Commit } $evidence $assetFixture.Root 'v2.5.22' '2.5.22' $assetFixture.GitCommit | Out-Null + } 'CCOD_GITHUB_DRAFT_ACCEPTANCE_INVALID' + $record = New-CcodTask9AcceptanceRecord -AssetFixture $assetFixture -EvidenceRoot (Get-CcodTask9EvidenceRoot) + $record.manualEvidence[0].screenshotSha256 = ('1' * 64) + [char]10 + [IO.File]::WriteAllText($acceptancePath, (($record | ConvertTo-Json -Depth 12) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + Assert-CcodThrows { + &$module { param($Evidence,$Assets,$Tag,$Version,$Commit) Read-CcodGitHubDraftAcceptance -EvidenceDirectory $Evidence -AssetDirectory $Assets -Tag $Tag -Version $Version -GitCommit $Commit } $evidence $assetFixture.Root 'v2.5.22' '2.5.22' $assetFixture.GitCommit | Out-Null + } 'CCOD_GITHUB_DRAFT_ACCEPTANCE_INVALID' + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + if ($null -ne $assetFixture) { Remove-CcodTask5ExactAssetFixture $assetFixture } + } +} + +Invoke-CcodTask6Test 'task9-promote-binds-manual-state' 'Promote rejects acceptance manual hashes that differ from the persisted Complete state' { + $module = $null + $assetFixture = $null + $root = $null + try { + $assetFixture = New-CcodTask5ExactAssetFixture + $root = Join-Path $assetFixture.Outside 'task9-promote-manual-state' + $acceptanceDirectory = Join-Path $root 'acceptance' + [IO.Directory]::CreateDirectory($acceptanceDirectory) | Out-Null + $record = New-CcodTask9AcceptanceRecord -AssetFixture $assetFixture -EvidenceRoot $root + $acceptancePath = Join-Path $acceptanceDirectory 'CodexRemote-fix-2.5.22-official-draft.complete.json' + [IO.File]::WriteAllText($acceptancePath, (($record | ConvertTo-Json -Depth 12) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name 'CcodGitHubDraftReleaseTask9ManualState' + $control=&$module {param($Evidence,$Assets,$Commit) Read-CcodGitHubDraftAcceptance -EvidenceDirectory $Evidence -AssetDirectory $Assets -Tag 'v2.5.22' -Version '2.5.22' -GitCommit $Commit} $root $assetFixture.Root $assetFixture.GitCommit + Assert-CcodEqual 'Complete' $control.phase 'complete persisted state and artifact control passes before mutation' + $statePaths=@((Join-Path $root 'official-draft-acceptance\08-Complete.json'),(Join-Path $root 'official-draft-acceptance\manual-evidence\TrayEvidence-About.json')) + $stateHashes=@($statePaths|ForEach-Object {Get-CcodTestFileSha256 $_}) + $screenshotPath=Join-Path $root 'official-draft-artifacts\About\screenshot.bin' + [IO.File]::AppendAllText($screenshotPath,'replacement-reviewed-screenshot',[Text.UTF8Encoding]::new($false)) + $record.manualEvidence[0].screenshotSha256 = Get-CcodTestFileSha256 $screenshotPath + Write-CcodTask5Json -Path $acceptancePath -Value $record + Assert-CcodTrue (&$module {param($Root,$Manual) Assert-CcodGitHubDraftManualEvidenceArtifacts -EvidenceDirectory $Root -ManualEvidence $Manual} $root $record.manualEvidence) 'updated acceptance and artifact agree independently of the original persisted state' + Assert-CcodThrows { &$module { param($Evidence,$Assets,$Tag,$Commit) Read-CcodGitHubDraftAcceptance -EvidenceDirectory $Evidence -AssetDirectory $Assets -Tag $Tag -Version '2.5.22' -GitCommit $Commit } $root $assetFixture.Root 'v2.5.22' $assetFixture.GitCommit } 'CCOD_GITHUB_DRAFT_ACCEPTANCE_INVALID' + $original=&$module {${function:Read-CcodGitHubDraftAcceptance}} + try { + &$module { + param($Original) + $body=$Original.ToString();$start=$body.IndexOf('$stateCompleteManual =');$end=$body.IndexOf('[void](Assert-CcodGitHubDraftManualEvidenceArtifacts', $start) + if($start-lt0-or$end-le$start){throw 'manual-state mutant boundary missing'} + Set-Item Function:script:Read-CcodGitHubDraftAcceptance -Value ([scriptblock]::Create($body.Remove($start,$end-$start))) + } $original + $mutant=&$module {param($Evidence,$Assets,$Commit) Read-CcodGitHubDraftAcceptance -EvidenceDirectory $Evidence -AssetDirectory $Assets -Tag 'v2.5.22' -Version '2.5.22' -GitCommit $Commit} $root $assetFixture.Root $assetFixture.GitCommit + Assert-CcodEqual 'Complete' $mutant.phase 'removing only persisted-state comparison admits the otherwise-valid negative' + } finally {&$module {param($Original) Set-Item Function:script:Read-CcodGitHubDraftAcceptance -Value $Original} $original} + for($index=0;$index-lt$statePaths.Count;$index++){Assert-CcodEqual $stateHashes[$index] (Get-CcodTestFileSha256 $statePaths[$index]) 'independent persisted state was never rewritten with the acceptance mutation'} + Assert-CcodThrows {&$module {param($Evidence,$Assets,$Commit) Read-CcodGitHubDraftAcceptance -EvidenceDirectory $Evidence -AssetDirectory $Assets -Tag 'v2.5.22' -Version '2.5.22' -GitCommit $Commit} $root $assetFixture.Root $assetFixture.GitCommit} 'CCOD_GITHUB_DRAFT_ACCEPTANCE_INVALID' + } finally { + if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + if ($null -ne $assetFixture) { Remove-CcodTask5ExactAssetFixture $assetFixture } + } +} + +Invoke-CcodTask6Test 'task9-promote-binds-all-assets' 'Promote rejects changed Setup bytes even when portable and manifest bytes are unchanged' { + $module = $null + $assetFixture = $null + $root = $null + try { + $assetFixture = New-CcodTask5ExactAssetFixture + $root = Join-Path $assetFixture.Outside 'task9-promote-all-assets' + $acceptanceDirectory = Join-Path $root 'acceptance' + [IO.Directory]::CreateDirectory($acceptanceDirectory) | Out-Null + $record = New-CcodTask9AcceptanceRecord -AssetFixture $assetFixture -EvidenceRoot $root + $acceptancePath = Join-Path $acceptanceDirectory 'CodexRemote-fix-2.5.22-official-draft.complete.json' + [IO.File]::WriteAllText($acceptancePath, (($record | ConvertTo-Json -Depth 12) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name 'CcodGitHubDraftReleaseTask9AllAssets' + $control=&$module {param($Evidence,$Assets,$Commit) Read-CcodGitHubDraftAcceptance -EvidenceDirectory $Evidence -AssetDirectory $Assets -Tag 'v2.5.22' -Version '2.5.22' -GitCommit $Commit} $root $assetFixture.Root $assetFixture.GitCommit + Assert-CcodEqual 'Complete' $control.phase 'original complete candidate passes before changing Setup' + $portableHash=Get-CcodTestFileSha256 (Join-Path $assetFixture.Root $assetFixture.Names[4]) + $completePath=Join-Path $root 'official-draft-acceptance\08-Complete.json' + $completeHash=Get-CcodTestFileSha256 $completePath + [IO.File]::AppendAllText((Join-Path $assetFixture.Root $assetFixture.Names[5]), 'candidate-drift', [Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText((Join-Path $assetFixture.Root $assetFixture.Names[6]),((Get-CcodTestFileSha256 (Join-Path $assetFixture.Root $assetFixture.Names[5]))+' *'+$assetFixture.Names[5]),[Text.UTF8Encoding]::new($false)) + Sync-CcodTask5OuterAssetHash -Fixture $assetFixture -Distribution Setup -AssetName $assetFixture.Names[5] + Sync-CcodTask5OuterAssetHash -Fixture $assetFixture -Distribution Setup -AssetName $assetFixture.Names[6] + $contract=&$module {param($Root) Test-CcodExactReleaseAssetSet -AssetDirectory $Root -Version '2.5.22'} $assetFixture.Root + Assert-CcodTrue $contract.Valid 'changed Setup fixture independently satisfies the complete asset contract' + Assert-CcodThrows { &$module { param($Evidence,$Assets,$Tag,$Commit) Read-CcodGitHubDraftAcceptance -EvidenceDirectory $Evidence -AssetDirectory $Assets -Tag $Tag -Version '2.5.22' -GitCommit $Commit } $root $assetFixture.Root 'v2.5.22' $assetFixture.GitCommit } 'CCOD_GITHUB_DRAFT_ACCEPTANCE_INVALID' + $original=&$module {${function:Read-CcodGitHubDraftAcceptance}} + try { + &$module { + param($Original) + $body=$Original.ToString();$start=$body.IndexOf('$currentAssets =');$end=$body.IndexOf('if ([string]$completeCandidate.version', $start) + if($start-lt0-or$end-le$start){throw 'all-assets mutant boundary missing'} + Set-Item Function:script:Read-CcodGitHubDraftAcceptance -Value ([scriptblock]::Create($body.Remove($start,$end-$start))) + } $original + $mutant=&$module {param($Evidence,$Assets,$Commit) Read-CcodGitHubDraftAcceptance -EvidenceDirectory $Evidence -AssetDirectory $Assets -Tag 'v2.5.22' -Version '2.5.22' -GitCommit $Commit} $root $assetFixture.Root $assetFixture.GitCommit + Assert-CcodEqual 'Complete' $mutant.phase 'removing only the persisted asset comparison admits a valid replacement candidate' + } finally {&$module {param($Original) Set-Item Function:script:Read-CcodGitHubDraftAcceptance -Value $Original} $original} + Assert-CcodEqual $portableHash (Get-CcodTestFileSha256 (Join-Path $assetFixture.Root $assetFixture.Names[4])) 'portable release identity was not changed' + Assert-CcodEqual $completeHash (Get-CcodTestFileSha256 $completePath) 'original completed candidate was not rebound to new Setup bytes' + Assert-CcodThrows {&$module {param($Evidence,$Assets,$Commit) Read-CcodGitHubDraftAcceptance -EvidenceDirectory $Evidence -AssetDirectory $Assets -Tag 'v2.5.22' -Version '2.5.22' -GitCommit $Commit} $root $assetFixture.Root $assetFixture.GitCommit} 'CCOD_GITHUB_DRAFT_ACCEPTANCE_INVALID' + } finally { + if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + if ($null -ne $assetFixture) { Remove-CcodTask5ExactAssetFixture $assetFixture } + } +} + +Invoke-CcodTask6Test 'fix1-promote-requires-manual-artifacts' 'Promote rejects manual hashes without matching evidence artifacts' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name 'CcodGitHubDraftReleaseTask9ManualArtifacts' + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-task9-manual-artifacts-' + [guid]::NewGuid().ToString('N')) + try { + [IO.Directory]::CreateDirectory($root) | Out-Null + $manual = @(New-CcodTask9ManualEvidenceFixture -ManifestHash ('a' * 64) -Commit ('b' * 40)) + Assert-CcodThrows { &$module { param($Evidence,$Entries) Assert-CcodGitHubDraftManualEvidenceArtifacts -EvidenceDirectory $Evidence -ManualEvidence $Entries } $root $manual } 'CCOD_GITHUB_DRAFT_ACCEPTANCE_INVALID' + } finally { + if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } + } +} + +Invoke-CcodTask6Test 'fix1-json-writer-rollback' 'Draft JSON writer removes a published destination when readback fails' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name 'CcodGitHubDraftJsonWriter' + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-draft-json-writer-' + [guid]::NewGuid().ToString('N')) + $path = Join-Path $root 'verification.json' + try { + [IO.Directory]::CreateDirectory($root) | Out-Null + $record = [pscustomobject][ordered]@{ schemaVersion = 1; kind = 'fixture' } + $control=Join-Path $root 'control.json' + &$module {param($Path,$Value) Write-CcodGitHubDraftJsonCreateOnly -Path $Path -Record $Value -ErrorId 'CCOD_TEST_VERIFY_WRITE_FAILED' -ReadbackVerifier {param($Target) return $true} | Out-Null} $control $record + Assert-CcodTrue ([IO.File]::Exists($control)) 'the same writer succeeds with a valid readback' + $state=[pscustomobject]@{Calls=0;PublishedSeen=$false} + $verifier = {param($Target) $state.Calls++;$state.PublishedSeen=[IO.File]::Exists($Target);return $false}.GetNewClosure() + Assert-CcodThrows { &$module { param($Path,$Value,$Verifier) Write-CcodGitHubDraftJsonCreateOnly -Path $Path -Record $Value -ErrorId 'CCOD_TEST_VERIFY_WRITE_FAILED' -ReadbackVerifier $Verifier } $path $record $verifier } 'CCOD_TEST_VERIFY_WRITE_FAILED' + Assert-CcodEqual 1 $state.Calls 'readback rejection is actually invoked' + Assert-CcodTrue $state.PublishedSeen 'the callback leaves the published file for production cleanup' + Assert-CcodTrue (-not [IO.File]::Exists($path)) 'failed draft JSON readback leaves no destination' + Assert-CcodTrue ([IO.File]::Exists($control)) 'failed write cleanup preserves an unrelated existing receipt' + } finally { + if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } + } +} + +Invoke-CcodTask6Test 'fix1-json-writer-holds-directory-authority' 'Draft JSON writer holds the evidence directory while publishing and reading back' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name 'CcodGitHubDraftJsonWriterAuthority' + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-draft-json-authority-' + [guid]::NewGuid().ToString('N')) + $moved = $root + '.moved' + $path = Join-Path $root 'verification.json' + try { + [IO.Directory]::CreateDirectory($root) | Out-Null + $record = [pscustomobject][ordered]@{ schemaVersion = 1; kind = 'fixture' } + $bytes = [Text.UTF8Encoding]::new($false).GetBytes((($record | ConvertTo-Json -Depth 8) + [Environment]::NewLine)) + $attack = [pscustomobject]@{ Succeeded = $false; Attempted = $false } + $verifier = { + param($Target) + try { + $replacement = [byte[]]$bytes.Clone() + $directory = Split-Path -Parent $Target + $attack.Attempted = $true + Move-Item -LiteralPath $directory -Destination $moved -Force -ErrorAction Stop + [IO.Directory]::CreateDirectory($directory) | Out-Null + [IO.File]::WriteAllBytes((Join-Path $directory ([IO.Path]::GetFileName($Target))), $replacement) + $attack.Succeeded = $true + } catch { $attack.Succeeded = $false } + return $true + }.GetNewClosure() + &$module { param($Path,$Value,$Verifier) Write-CcodGitHubDraftJsonCreateOnly -Path $Path -Record $Value -ErrorId 'CCOD_TEST_VERIFY_WRITE_FAILED' -ReadbackVerifier $Verifier | Out-Null } $path $record $verifier + Assert-CcodTrue $attack.Attempted 'the actual directory rename is attempted, not masked by an earlier file read failure' + Assert-CcodTrue (-not $attack.Succeeded) 'held evidence directory authority blocks same-path replacement during readback' + } finally { + if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + foreach ($directory in @($root,$moved)) { if (Test-Path -LiteralPath $directory) { Remove-Item -LiteralPath $directory -Recurse -Force -ErrorAction SilentlyContinue } } + } +} + +Invoke-CcodTask6Test 'fix1-json-reader-pins-parent-authority' 'Draft JSON reader rejects a junction parent instead of following pathname substitution' { + $module = Import-CcodTask6ToolModule -Path (Join-Path $repositoryRoot 'tools/Invoke-GitHubDraftRelease.ps1') -Name 'CcodGitHubDraftJsonReaderAuthority' + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-draft-json-reader-' + [guid]::NewGuid().ToString('N')) + $outside = Join-Path $root 'outside' + $link = Join-Path $root 'link' + try { + [IO.Directory]::CreateDirectory($outside) | Out-Null + [IO.File]::WriteAllText((Join-Path $outside 'verification.json'), '{"schemaVersion":1}', [Text.UTF8Encoding]::new($false)) + New-Item -ItemType Junction -Path $link -Target $outside | Out-Null + Assert-CcodThrows { + &$module { param($Path) Read-CcodGitHubDraftContractJson -Path $Path -ErrorId 'CCOD_TEST_PINNED_READ_INVALID' } (Join-Path $link 'verification.json') | Out-Null + } 'CCOD_TEST_PINNED_READ_INVALID' + } finally { + if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $link) { + try { [IO.Directory]::Delete($link, $false) } catch { & cmd.exe /d /c rmdir /s /q $link | Out-Null } + } + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } + } +} + +if ($script:CcodReleaseExecuted -le 0) { throw 'CCOD_RELEASE_TEST_SELECTION_INVALID: no tests executed.' } +if ($script:CcodReleaseFocus) { + Write-Host "CCOD_RELEASE_FOCUSED_TESTS_PASSED case=$script:CcodReleaseFocus executed=$script:CcodReleaseExecuted skipped=$script:CcodReleaseSkipped" +} else { + if ($script:CcodReleaseSkipped -ne 0) { throw 'CCOD_RELEASE_TEST_SELECTION_INVALID: full suite skipped tests.' } + Write-Host "Release workflow self-tests passed: $script:CcodReleaseExecuted; skipped=0." +} diff --git a/tests/persistence/RuntimeManifest.SelfTest.ps1 b/tests/persistence/RuntimeManifest.SelfTest.ps1 index 1b9cc1c..a1799bc 100644 --- a/tests/persistence/RuntimeManifest.SelfTest.ps1 +++ b/tests/persistence/RuntimeManifest.SelfTest.ps1 @@ -7,6 +7,7 @@ $installLifecycleModule = Import-Module (Join-Path $repositoryRoot 'src\persiste Import-Module (Join-Path $repositoryRoot 'src\persistence\modules\PersistenceIO.psm1') -Force Import-Module (Join-Path $repositoryRoot 'src\persistence\modules\RuntimeManifest.psm1') -Force Import-Module (Join-Path $repositoryRoot 'src\persistence\modules\LifecycleEpoch.psm1') -Force +Import-Module (Join-Path $repositoryRoot 'src\persistence\modules\InstallFileTransaction.psm1') Import-Module (Join-Path $repositoryRoot 'src\persistence\modules\PersistenceIO.psm1') -Force $root = Join-Path ([IO.Path]::GetTempPath()) ("ccod-runtime-manifest-" + [guid]::NewGuid().ToString('N')) @@ -43,14 +44,65 @@ function Set-CcodTestActiveRuntime { $current = if ([IO.File]::Exists((Join-Path $InstallRoot 'active.json'))) { Read-CcodActiveRuntime -InstallRoot $InstallRoot } else { $null } $owner = [pscustomobject][ordered]@{ pid=[int]$process.Id; creationTimeUtc=$process.StartTime.ToUniversalTime().ToString('o') } $ownership = Enter-CcodLifecycleOwnership -InstallRoot $InstallRoot -RuntimeId $(if ($null -eq $current) { $NewRuntimeId } else { $current.activeRuntime }) -RuntimeGeneration $(if ($null -eq $current) { [UInt64]1 } else { [UInt64]$current.generation }) -OwnerIdentity $owner -UserSid $identity.User.Value -SessionId ([int]$process.SessionId) - return Set-CcodActiveRuntime -InstallRoot $InstallRoot -NewRuntimeId $NewRuntimeId -Ownership $ownership -Adapters $Adapters + return Invoke-CcodRuntimeCoreSet -InstallRoot $InstallRoot -NewRuntimeId $NewRuntimeId -Ownership $ownership -Adapters $Adapters } finally { if ($null -ne $ownership -and -not $ownership.released) { Exit-CcodLifecycleOwnership -Ownership $ownership | Out-Null } $process.Dispose(); $identity.Dispose() } } +function Invoke-CcodRuntimeCoreSet { + param([Parameter(Mandatory)][string]$InstallRoot,[string]$NewRuntimeId,$TargetGeneration,$FileTransaction,$Ownership,[hashtable]$Adapters) + $module = Get-Module -Name RuntimeManifest + return & $module { param($Root,$Runtime,$Generation,$Transaction,$Lease,$Injected) Set-CcodActiveRuntimeCore -InstallRoot $Root -NewRuntimeId $Runtime -TargetGeneration $Generation -FileTransaction $Transaction -Ownership $Lease -Adapters $Injected } $InstallRoot $NewRuntimeId $TargetGeneration $FileTransaction $Ownership $Adapters +} + try { + Invoke-CcodTest 'runtime manifest file records preserve strict property order' { + $source = [IO.File]::ReadAllText((Join-Path $repositoryRoot 'src/persistence/modules/RuntimeManifest.psm1'), [Text.UTF8Encoding]::new($false)) + Assert-CcodTrue ($source.Contains('$records.Add([pscustomobject][ordered]@{')) 'runtime file records use deterministic property order' + } + + Invoke-CcodTest 'runtime root existence probe preserves lookup failures' { + $source = [IO.File]::ReadAllText((Join-Path $repositoryRoot 'src/persistence/modules/RuntimeManifest.psm1'), [Text.UTF8Encoding]::new($false)) + $start = $source.IndexOf('function Get-CcodRuntimeRoot') + $end = $source.IndexOf('function Get-CcodRuntimeFileSha256') + Assert-CcodTrue ($start -ge 0 -and $end -gt $start) 'runtime root helper is present as one inspectable definition' + $body = $source.Substring($start, $end - $start) + Assert-CcodTrue (-not $body.Contains('[IO.Directory]::Exists($root)')) 'runtime root does not collapse access failures through Directory.Exists' + Assert-CcodTrue ($body.Contains('catch [Management.Automation.ItemNotFoundException]')) 'runtime root handles only an explicit missing-item exception as absence' + $manifestStart = $source.IndexOf('function Test-CcodRuntimeManifest') + $manifestEnd = $source.IndexOf('function Get-CcodRuntimeDirectoryForId') + Assert-CcodTrue ($manifestStart -ge 0 -and $manifestEnd -gt $manifestStart) 'runtime manifest validator is present as one inspectable definition' + Assert-CcodTrue (-not $source.Substring($manifestStart, $manifestEnd - $manifestStart).Contains("'^[0-9a-f]{64}$'")) 'runtime manifest hash validation uses an absolute end anchor' + } + + Invoke-CcodTest 'runtime root propagates an injected access failure instead of treating it as missing' { + $module = Get-Module -Name RuntimeManifest + Assert-CcodTrue ($null -ne $module) 'RuntimeManifest module is loaded for the behavioral probe' + try { + $error = & $module { + function Get-Item { throw [UnauthorizedAccessException]::new('fixture access denied') } + try { + Get-CcodRuntimeRoot -RuntimeDirectory ([IO.Path]::GetFullPath((Join-Path $root 'access-denied'))) + return $null + } catch { + return $_ + } finally { + Remove-Item -LiteralPath Function:\Get-Item -Force -ErrorAction SilentlyContinue + } + } + Assert-CcodTrue ($null -ne $error -and $error.Exception -is [UnauthorizedAccessException]) 'access failures escape the runtime-root probe' + } finally { + Remove-Item -LiteralPath Function:\Get-Item -Force -ErrorAction SilentlyContinue + } + } + + Invoke-CcodTest 'public active runtime setter does not expose adapter injection' { + $command = Get-Command Set-CcodActiveRuntime -Module RuntimeManifest + Assert-CcodTrue (-not $command.Parameters.ContainsKey('Adapters')) 'public active runtime mutation cannot replace lifecycle fence adapters' + } + Invoke-CcodTest 'includes the External renderer integration module in the staged runtime manifest input' { $sourceFiles = @(& $installLifecycleModule { param($sourceRoot) Get-CcodLifecycleSourceFiles -SourceRoot $sourceRoot } $repositoryRoot) $rendererModule = @($sourceFiles | Where-Object { $_.Relative -ceq 'src\persistence\modules\RendererIntegration.psm1' }) @@ -69,14 +121,44 @@ try { Write-CcodAtomicJson -Path (Join-Path $runtime 'manifest.json') -Value $first $second = New-CcodRuntimeManifest -RuntimeDirectory $runtime -ProjectVersion '2.0.0' - Assert-CcodTrue ($first.runtimeId -match '^[A-Za-z0-9._-]{1,96}$') 'runtime ID must be a safe name' - Assert-CcodEqual $first.runtimeId $second.runtimeId 'the same runtime bytes must produce the same runtime ID' + Assert-CcodTrue ($first.runtimeId -cmatch '^2\.0\.0-[0-9a-f]{16}-[0-9a-f]{32}$') 'runtime ID binds project version, file digest, and nonce' + Assert-CcodTrue ($first.runtimeId -cne $second.runtimeId) 'the same runtime bytes receive unique per-attempt nonces' + Assert-CcodEqual (($first.runtimeId -split '-')[-2]) (($second.runtimeId -split '-')[-2]) 'identical sorted file records retain the same deterministic digest' Assert-CcodEqual 'a.txt' $first.files[0].path 'files must sort ordinally' Assert-CcodEqual 'b.txt' $first.files[1].path 'files must sort ordinally' Assert-CcodEqual 2 $first.files.Count 'manifest must exclude manifest.json itself' Assert-CcodTrue ($first.files[0].sha256 -cmatch '^[0-9a-f]{64}$') 'file hash must be lowercase SHA-256' } + Invoke-CcodTest 'runtime IDs use canonical TAB delimiters' { + $files = @( + [pscustomobject]@{ path = 'a.txt'; length = [int64]5; sha256 = ('a' * 64) } + [pscustomobject]@{ path = 'b.txt'; length = [int64]4; sha256 = ('b' * 64) } + ) + $nonce = '0123456789abcdef0123456789abcdef' + $expected = '2.5.22-e71f4818a0f8e98f-0123456789abcdef0123456789abcdef' + Assert-CcodEqual $expected (Get-CcodRuntimeId -ProjectVersion '2.5.22' -Files $files -Nonce $nonce) 'runtime ID digest input must use literal TAB delimiters' + } + + Invoke-CcodTest 'runtime manifest producer and validator agree on filenames containing consecutive dots' { + $runtime = Join-Path $root 'consecutive-dots' + New-Item -ItemType Directory -Path $runtime | Out-Null + [IO.File]::WriteAllText((Join-Path $runtime 'foo..bar'), 'dot filename', [Text.UTF8Encoding]::new($false)) + $manifest = New-CcodRuntimeManifest -RuntimeDirectory $runtime -ProjectVersion '2.5.22' + Write-CcodAtomicJson -Path (Join-Path $runtime 'manifest.json') -Value $manifest + $validation = Test-CcodRuntimeManifest -RuntimeDirectory $runtime -ExpectedRuntimeId $manifest.runtimeId + Assert-CcodTrue $validation.Valid 'a safe filename with consecutive dots must validate after production' + } + + Invoke-CcodTest 'runtime manifest rejects control and format characters in relative paths' { + $module = Get-Module -Name RuntimeManifest + foreach ($control in @("`t", "`n", "`r", [char]1, [char]0x7f, [char]0x200b)) { + Assert-CcodThrows { & $module { param($Path) Assert-CcodManifestRelativePath -Path $Path | Out-Null } ('safe' + $control + 'name.txt') } 'CCOD_PATH_OUTSIDE_ROOT' + $fullName = Join-Path $root ('safe' + $control + 'name.txt') + Assert-CcodThrows { & $module { param($Root, $FullName) ConvertTo-CcodRuntimeRelativePath -Root $Root -FullName $FullName | Out-Null } $root $fullName } 'CCOD_PATH_OUTSIDE_ROOT' + } + } + Invoke-CcodTest 'verifies exact runtime bytes and rejects tampering' { $runtime = Join-Path $root 'verify' New-Item -ItemType Directory -Path $runtime | Out-Null @@ -91,6 +173,21 @@ try { Assert-CcodEqual $false $result.Valid 'tampered bytes must be rejected' } + Invoke-CcodTest 'rejects copied renamed and nonce-tampered generation identity' { + $runtime=Join-Path $root 'identity-original';New-Item -ItemType Directory -Path $runtime|Out-Null + [IO.File]::WriteAllText((Join-Path $runtime 'a.txt'),'alpha',[Text.UTF8Encoding]::new($false)) + $manifest=New-CcodRuntimeManifest -RuntimeDirectory $runtime -ProjectVersion '2.5.22' + Write-CcodAtomicJson -Path (Join-Path $runtime 'manifest.json') -Value $manifest + $originalManifestSha=Get-CcodTestFileSha256 -Path (Join-Path $runtime 'manifest.json') + $parts=$manifest.runtimeId -split '-';$renamedId=($parts[0..($parts.Count-2)] -join '-')+'-'+('f'*32) + $renamed=Join-Path $root $renamedId;Copy-Item -LiteralPath $runtime -Destination $renamed -Recurse + $altered=Get-Content -LiteralPath (Join-Path $renamed 'manifest.json') -Raw|ConvertFrom-Json;$altered.runtimeId=$renamedId + Write-CcodAtomicJson -Path (Join-Path $renamed 'manifest.json') -Value $altered + Assert-CcodEqual $false (Test-CcodRuntimeManifest -RuntimeDirectory $renamed -ExpectedRuntimeId $renamedId -ExpectedManifestSha256 $originalManifestSha).Valid 'copied generation cannot authorize a renamed nonce by rewriting its manifest identity' + $altered.runtimeId=$manifest.runtimeId.Substring(0,$manifest.runtimeId.Length-1)+'A';Write-CcodAtomicJson -Path (Join-Path $renamed 'manifest.json') -Value $altered + Assert-CcodEqual $false (Test-CcodRuntimeManifest -RuntimeDirectory $renamed -ExpectedRuntimeId $altered.runtimeId).Valid 'wrong non-lowercase nonce is rejected' + } + Invoke-CcodTest 'rejects an unsafe manifest path instead of reading outside its runtime' { $runtime = Join-Path $root 'unsafe-manifest' New-Item -ItemType Directory -Path $runtime | Out-Null @@ -104,6 +201,58 @@ try { Assert-CcodThrows { Test-CcodRuntimeManifest -RuntimeDirectory $runtime -ExpectedRuntimeId '2.0.0-safe' } 'CCOD_PATH_OUTSIDE_ROOT' } + Invoke-CcodTest 'rejects a runtime directory reached through a reparse ancestor' { + $outsideRuntime = Join-Path $outside 'reparse-runtime' + $junctionParent = Join-Path $root 'reparse-runtime-parent' + New-Item -ItemType Directory -Path $outsideRuntime | Out-Null + [IO.File]::WriteAllText((Join-Path $outsideRuntime 'payload.txt'), 'outside runtime', [Text.UTF8Encoding]::new($false)) + $outsideManifest = New-CcodRuntimeManifest -RuntimeDirectory $outsideRuntime -ProjectVersion '2.0.0' + Write-CcodAtomicJson -Path (Join-Path $outsideRuntime 'manifest.json') -Value $outsideManifest + New-Item -ItemType Junction -Path $junctionParent -Target $outside | Out-Null + $runtime = Join-Path $junctionParent 'reparse-runtime' + Assert-CcodThrows { New-CcodRuntimeManifest -RuntimeDirectory $runtime -ProjectVersion '2.0.0' } 'CCOD_REPARSE_PATH' + Assert-CcodThrows { Test-CcodRuntimeManifest -RuntimeDirectory $runtime -ExpectedRuntimeId $outsideManifest.runtimeId } 'CCOD_REPARSE_PATH' + } + + Invoke-CcodTest 'runtime manifest rejects hardlinked payload files' { + $runtime = Join-Path $root 'hardlink-payload' + $outsidePayload = Join-Path $outside 'hardlink-payload.txt' + New-Item -ItemType Directory -Path $runtime | Out-Null + [IO.File]::WriteAllText($outsidePayload, 'hardlinked payload', [Text.UTF8Encoding]::new($false)) + New-Item -ItemType HardLink -Path (Join-Path $runtime 'payload.txt') -Target $outsidePayload | Out-Null + Assert-CcodThrows { New-CcodRuntimeManifest -RuntimeDirectory $runtime -ProjectVersion '2.0.0' } 'CCOD_RUNTIME_FILE_INVALID' + } + + Invoke-CcodTest 'runtime manifest rejects a noncanonical runtime directory' { + $fixture = New-CcodRuntimeFixture -InstallRoot $root -ProjectVersion '2.0.0' -AContent 'a' -BContent 'b' + $noncanonical = [string]$fixture.Runtime + '\.' + Assert-CcodThrows { Test-CcodRuntimeManifest -RuntimeDirectory $noncanonical -ExpectedRuntimeId $fixture.Manifest.runtimeId } 'CCOD_RUNTIME_PATH_INVALID' + } + + Invoke-CcodTest 'runtime manifest rejects a directory at its manifest leaf instead of treating it as missing' { + $runtime = Join-Path $root 'manifest-leaf-directory' + New-Item -ItemType Directory -Path $runtime | Out-Null + [IO.File]::WriteAllText((Join-Path $runtime 'payload.txt'), 'payload', [Text.UTF8Encoding]::new($false)) + $manifest = New-CcodRuntimeManifest -RuntimeDirectory $runtime -ProjectVersion '2.0.0' + New-Item -ItemType Directory -Path (Join-Path $runtime 'manifest.json') | Out-Null + $result = Test-CcodRuntimeManifest -RuntimeDirectory $runtime -ExpectedRuntimeId $manifest.runtimeId + Assert-CcodEqual 'CCOD_RUNTIME_MANIFEST_INVALID' $result.Code 'manifest directory is invalid state rather than missing file' + } + + Invoke-CcodTest 'active pointer rejects a noninteger schema version' { + $runtime = New-CcodRuntimeFixture -InstallRoot $root -ProjectVersion '2.5.0' -AContent 'a' -BContent 'b' + $pointer = '{"schemaVersion":2.0,"activeRuntime":"' + $runtime.Manifest.runtimeId + '","previousRuntime":null,"generation":1,"updatedAtUtc":"2030-02-03T04:05:06.0000000Z"}' + [IO.File]::WriteAllText((Join-Path $root 'active.json'), $pointer, [Text.UTF8Encoding]::new($false)) + Assert-CcodThrows { Read-CcodActiveRuntime -InstallRoot $root } 'CCOD_RUNTIME_POINTER_INVALID' + } + + Invoke-CcodTest 'active pointer rejects a noncanonical UTC timestamp' { + $runtime = New-CcodRuntimeFixture -InstallRoot $root -ProjectVersion '2.5.0' -AContent 'a' -BContent 'b' + $pointer = [ordered]@{ schemaVersion = 2; activeRuntime = $runtime.Manifest.runtimeId; previousRuntime = $null; generation = [UInt64]1; updatedAtUtc = 123 } + [IO.File]::WriteAllText((Join-Path $root 'active.json'), ($pointer | ConvertTo-Json -Compress), [Text.UTF8Encoding]::new($false)) + Assert-CcodThrows { Read-CcodActiveRuntime -InstallRoot $root } 'CCOD_RUNTIME_POINTER_INVALID' + } + Invoke-CcodTest 'rotates an active pointer only to a verified runtime' { $installRoot = Join-Path $root 'install' New-Item -ItemType Directory -Path $installRoot | Out-Null @@ -160,6 +309,75 @@ try { Assert-CcodThrows { Read-CcodActiveRuntime -InstallRoot $installRoot } 'CCOD_RUNTIME_ID_INVALID' } + Invoke-CcodTest 'rejects dot-only runtime IDs before resolving active runtime paths' { + foreach ($runtimeId in @('.', '..')) { + $installRoot = Join-Path $root ('dot-runtime-' + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $installRoot | Out-Null + Write-CcodAtomicJson -Path (Join-Path $installRoot 'active.json') -Value ([ordered]@{ + schemaVersion = 1 + activeRuntime = $runtimeId + previousRuntime = $null + updatedAtUtc = '2030-02-03T04:05:06.0000000Z' + }) + Assert-CcodThrows { Read-CcodActiveRuntime -InstallRoot $installRoot } 'CCOD_RUNTIME_ID_INVALID' + if (Test-Path -LiteralPath $installRoot) { Remove-Item -LiteralPath $installRoot -Recurse -Force } + } + } + + Invoke-CcodTest 'default immutable pointer fence commits fresh and append-only upgrade with real ownership' { + $installRoot=Join-Path $root 'default-immutable-fence';[IO.Directory]::CreateDirectory($installRoot)|Out-Null;$identity=[Security.Principal.WindowsIdentity]::GetCurrent();$process=[Diagnostics.Process]::GetCurrentProcess();$firstTx=$null;$secondTx=$null;$ownership=$null + $makeGeneration={param($Root,$Content,$Nonce)$source=Join-Path $Root ("source-$Nonce.txt");[IO.File]::WriteAllText($source,$Content,[Text.UTF8Encoding]::new($false));$sha=(Get-FileHash -LiteralPath $source -Algorithm SHA256).Hash.ToLowerInvariant();$records=@([pscustomobject]@{path='payload.txt';length=[int64](Get-Item $source).Length;sha256=$sha});$id=Get-CcodRuntimeId -ProjectVersion '2.5.22' -Files $records -Nonce $Nonce;$tx=Open-CcodInstallGeneration -InstallRoot $Root -RuntimeId $id;Copy-CcodInstallSealedSource -Generation $tx -SourcePath $source -Leaf 'payload.txt' -ExpectedLength $records[0].length -ExpectedSha256 $sha|Out-Null;$runtime=Join-Path $Root "runtime\$id";$manifest=New-CcodRuntimeManifest -RuntimeDirectory $runtime -ProjectVersion '2.5.22' -RuntimeId $id;Write-CcodInstallGenerationManifest -Generation $tx -Manifest $manifest|Out-Null;[pscustomobject]@{Id=$id;Transaction=$tx;Generation=$tx}} + try{ + $first=&$makeGeneration $installRoot 'first' ('1'*32);$firstTx=$first.Transaction + $owner=[pscustomobject][ordered]@{pid=[int]$process.Id;creationTimeUtc=$process.StartTime.ToUniversalTime().ToString('o')} + $ownership=Enter-CcodLifecycleOwnership -InstallRoot $installRoot -RuntimeId $first.Id -RuntimeGeneration 1 -OwnerIdentity $owner -UserSid $identity.User.Value -SessionId ([int]$process.SessionId) + $fresh=Set-CcodActiveRuntime -InstallRoot $installRoot -NewRuntimeId $first.Id -TargetGeneration $first.Generation -FileTransaction $first.Transaction -Ownership $ownership + Assert-CcodEqual 1 $fresh.generation 'default fresh fence commits generation one' + Exit-CcodLifecycleOwnership $ownership|Out-Null;$ownership=$null;Close-CcodInstallFileTransaction $firstTx Ready;$firstTx=$null + $second=&$makeGeneration $installRoot 'second' ('2'*32);$secondTx=$second.Transaction + $ownership=Enter-CcodLifecycleOwnership -InstallRoot $installRoot -RuntimeId $fresh.activeRuntime -RuntimeGeneration $fresh.generation -OwnerIdentity $owner -UserSid $identity.User.Value -SessionId ([int]$process.SessionId) + $upgraded=Set-CcodActiveRuntime -InstallRoot $installRoot -NewRuntimeId $second.Id -TargetGeneration $second.Generation -FileTransaction $second.Transaction -Ownership $ownership + Assert-CcodEqual 2 $upgraded.generation 'default upgrade fence advances append-only generation';Assert-CcodEqual $second.Id $upgraded.activeRuntime 'default upgrade fence selects the new target' + }finally{if($null-ne$ownership-and-not$ownership.released){Exit-CcodLifecycleOwnership $ownership|Out-Null};if($null-ne$firstTx){Close-CcodInstallFileTransaction $firstTx Failed};if($null-ne$secondTx){Close-CcodInstallFileTransaction $secondTx Failed};$process.Dispose();$identity.Dispose()} + } + + Invoke-CcodTest 'append-only active selector rejects unknown reparse ADS and multi-linked leaves' { + foreach($kind in @('unknown','reparse','ads','multilink')){ + $installRoot=Join-Path $root ("pointer-$kind");$pointerRoot=Join-Path $installRoot 'state\active-generation';$target=Join-Path $outside ("pointer-$kind") + [IO.Directory]::CreateDirectory($pointerRoot)|Out-Null;[IO.Directory]::CreateDirectory($target)|Out-Null + $runtimeId='2.5.22-1111111111111111-22222222222222222222222222222222';$path=Join-Path $pointerRoot '00000000000000000001.json';$json='{"schemaVersion":1,"generation":1,"activeRuntime":"'+$runtimeId+'","previousGeneration":0}' + if($kind-ceq'unknown'){[IO.File]::WriteAllText((Join-Path $pointerRoot 'unknown.bin'),'x',[Text.UTF8Encoding]::new($false))} + elseif($kind-ceq'reparse'){New-Item -ItemType Junction -Path $path -Target $target|Out-Null} + elseif($kind-ceq'ads'){[IO.File]::WriteAllText($path,$json,[Text.UTF8Encoding]::new($false));Set-Content -LiteralPath $path -Stream 'evidence' -Value 'x' -NoNewline} + else{$outsideFile=Join-Path $target 'pointer.json';[IO.File]::WriteAllText($outsideFile,$json,[Text.UTF8Encoding]::new($false));New-Item -ItemType HardLink -Path $path -Target $outsideFile|Out-Null} + Assert-CcodThrows {Read-CcodActiveRuntime -InstallRoot $installRoot|Out-Null} 'CCOD_RUNTIME_POINTER_INVALID' + if(Test-Path $installRoot){Remove-Item $installRoot -Recurse -Force};if(Test-Path $target){Remove-Item $target -Recurse -Force} + } + } + + Invoke-CcodTest 'default immutable fence rejects selector root files and non-not-found lookup failures before pointer commit' { + foreach($kind in @('root-file','lookup-error')){ + $installRoot=Join-Path $root ("pointer-root-$kind");[IO.Directory]::CreateDirectory($installRoot)|Out-Null;$identity=[Security.Principal.WindowsIdentity]::GetCurrent();$process=[Diagnostics.Process]::GetCurrentProcess();$transaction=$null;$ownership=$null + try{ + $source=Join-Path $installRoot 'source.txt';[IO.File]::WriteAllText($source,'selector-root-proof',[Text.UTF8Encoding]::new($false));$sha=(Get-FileHash -LiteralPath $source -Algorithm SHA256).Hash.ToLowerInvariant();$records=@([pscustomobject]@{path='payload.txt';length=[int64](Get-Item $source).Length;sha256=$sha});$runtimeId=Get-CcodRuntimeId -ProjectVersion '2.5.22' -Files $records -Nonce ('3'*32);$transaction=Open-CcodInstallGeneration -InstallRoot $installRoot -RuntimeId $runtimeId;Copy-CcodInstallSealedSource -Generation $transaction -SourcePath $source -Leaf 'payload.txt' -ExpectedLength $records[0].length -ExpectedSha256 $sha|Out-Null;$runtime=Join-Path $installRoot "runtime\$runtimeId";$manifest=New-CcodRuntimeManifest -RuntimeDirectory $runtime -ProjectVersion '2.5.22' -RuntimeId $runtimeId;Write-CcodInstallGenerationManifest -Generation $transaction -Manifest $manifest|Out-Null + $pointerRoot=Join-Path $installRoot 'state\active-generation';if($kind-ceq'root-file'){[IO.Directory]::CreateDirectory((Split-Path $pointerRoot -Parent))|Out-Null;[IO.File]::WriteAllText($pointerRoot,'not-a-directory',[Text.UTF8Encoding]::new($false))} + $owner=[pscustomobject][ordered]@{pid=[int]$process.Id;creationTimeUtc=$process.StartTime.ToUniversalTime().ToString('o')};$ownership=Enter-CcodLifecycleOwnership -InstallRoot $installRoot -RuntimeId $runtimeId -RuntimeGeneration 1 -OwnerIdentity $owner -UserSid $identity.User.Value -SessionId ([int]$process.SessionId) + $adapters=if($kind-ceq'lookup-error'){@{GetSelectorRootItem={param($Path)throw [UnauthorizedAccessException]::new('selector lookup denied')}}}else{$null} + Assert-CcodThrows { Invoke-CcodRuntimeCoreSet -InstallRoot $installRoot -NewRuntimeId $runtimeId -TargetGeneration $transaction -FileTransaction $transaction -Ownership $ownership -Adapters $adapters | Out-Null } 'CCOD_RUNTIME_POINTER_INVALID' + Assert-CcodEqual $false ([IO.File]::Exists((Join-Path $pointerRoot '00000000000000000001.json'))) "$kind failure publishes no active generation" + }finally{if($null-ne$ownership-and-not$ownership.released){Exit-CcodLifecycleOwnership $ownership|Out-Null};if($null-ne$transaction){Close-CcodInstallFileTransaction $transaction Failed};$process.Dispose();$identity.Dispose()} + } + } + + Invoke-CcodTest 'legacy fallback rejects a state ancestor file before active runtime authorization' { + $installRoot=Join-Path $root 'pointer-state-file';[IO.Directory]::CreateDirectory($installRoot)|Out-Null + $runtimeId='2.5.22-1111111111111111-22222222222222222222222222222222' + $active=[ordered]@{schemaVersion=2;activeRuntime=$runtimeId;previousRuntime=$null;generation=[uint64]1;updatedAtUtc='2030-02-03T04:05:06.0000000Z'} + [IO.File]::WriteAllText((Join-Path $installRoot 'active.json'),($active|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText((Join-Path $installRoot 'state'),'not-a-directory',[Text.UTF8Encoding]::new($false)) + Assert-CcodThrows {Read-CcodActiveRuntime -InstallRoot $installRoot|Out-Null} 'CCOD_RUNTIME_POINTER_INVALID' + } + Invoke-CcodTest 'includes the lifecycle worker and coordinator in the staged runtime closure' { $sourceFiles=@(& $installLifecycleModule {param($sourceRoot)Get-CcodLifecycleSourceFiles -SourceRoot $sourceRoot} $repositoryRoot) foreach($relative in @('src\persistence\LifecycleWorker.ps1','src\persistence\SessionController.ps1','src\persistence\modules\LifecycleCoordinator.psm1','src\persistence\modules\LifecycleEpoch.psm1','src\persistence\modules\ProcessControl.psm1','src\persistence\modules\SessionEngine.psm1','src\persistence\modules\WorkerRuntime.psm1')){ @@ -280,7 +498,7 @@ try { } return $true }.GetNewClosure() - Assert-CcodThrows { Set-CcodActiveRuntime -InstallRoot $installRoot -NewRuntimeId $second.Manifest.runtimeId -Ownership $ownership -Adapters @{ AssertLifecycleFence=$raceFence } } 'CCOD_RUNTIME_FENCE_STALE' + Assert-CcodThrows { Invoke-CcodRuntimeCoreSet -InstallRoot $installRoot -NewRuntimeId $second.Manifest.runtimeId -Ownership $ownership -Adapters @{ AssertLifecycleFence=$raceFence } } 'CCOD_RUNTIME_FENCE_STALE' $unchanged = Read-CcodActiveRuntime -InstallRoot $installRoot Assert-CcodEqual 2 ([UInt64]$unchanged.generation) 'concurrent generation remains committed instead of being reused' Assert-CcodEqual $first.Manifest.runtimeId $unchanged.activeRuntime 'stale owner cannot replace the concurrent active runtime' @@ -303,6 +521,55 @@ try { [IO.File]::WriteAllText((Join-Path $installRoot 'active.json'), '{"schemaVersion":2,"activeRuntime":"2.5.0-a","previousRuntime":null,"generation":18446744073709551616,"updatedAtUtc":"2030-02-03T04:05:06.0000000Z"}', [Text.UTF8Encoding]::new($false)) Assert-CcodThrows { Read-CcodActiveRuntime -InstallRoot $installRoot } 'CCOD_RUNTIME_GENERATION_INVALID' } + + Invoke-CcodTest 'accepts Decimal previousGeneration values in active-generation records' { + $installRoot = Join-Path $root 'decimal-previous-generation' + $pointerRoot = Join-Path $installRoot 'state\active-generation' + New-Item -ItemType Directory -Path $pointerRoot -Force | Out-Null + $record = [ordered]@{ schemaVersion = 1; generation = [decimal]2; activeRuntime = '2.5.22-a'; previousGeneration = [decimal]1 } + [IO.File]::WriteAllText((Join-Path $pointerRoot '00000000000000000002.json'), ($record | ConvertTo-Json -Compress), [Text.UTF8Encoding]::new($false)) + $first = [ordered]@{ schemaVersion = 1; generation = 1; activeRuntime = '2.5.22-b'; previousGeneration = 0 } + [IO.File]::WriteAllText((Join-Path $pointerRoot '00000000000000000001.json'), ($first | ConvertTo-Json -Compress), [Text.UTF8Encoding]::new($false)) + $actual = Read-CcodActiveRuntime -InstallRoot $installRoot + Assert-CcodTrue ([UInt64]$actual.generation -eq [UInt64]2) 'Decimal previousGeneration must not invalidate an otherwise canonical selector chain' + } + + Invoke-CcodTest 'rejects legacy active pointer files with alternate data streams' { + $installRoot = Join-Path $root 'legacy-pointer-stream' + New-Item -ItemType Directory -Path $installRoot | Out-Null + $runtime = New-CcodRuntimeFixture -InstallRoot $installRoot -ProjectVersion '2.3.0' -AContent 'ads alpha' -BContent 'ads beta' + Write-CcodAtomicJson -Path (Join-Path $installRoot 'active.json') -Value ([ordered]@{ schemaVersion = 1; activeRuntime = $runtime.Manifest.runtimeId; previousRuntime = $null; updatedAtUtc = '2030-02-03T04:05:06.0000000Z' }) + Add-Content -LiteralPath (Join-Path $installRoot 'active.json') -Stream 'unexpected' -Value 'tampered' + Assert-CcodThrows { Read-CcodActiveRuntime -InstallRoot $installRoot } 'CCOD_RUNTIME_POINTER_INVALID' + } + + Invoke-CcodTest 'runtime manifest rejects scalar files and noninteger lengths' { + $installRoot = Join-Path $root 'manifest-strict-shape' + New-Item -ItemType Directory -Path $installRoot | Out-Null + $fixture = New-CcodRuntimeFixture -InstallRoot $installRoot -ProjectVersion '2.3.1' -AContent 'shape alpha' -BContent 'shape beta' + $path = Join-Path $fixture.Runtime 'manifest.json' + $valid = $fixture.Manifest + $scalar = [ordered]@{ schemaVersion = 1; projectVersion = '2.3.1'; runtimeId = $valid.runtimeId; files = [ordered]@{ path = 'a.txt'; length = 1; sha256 = ('0' * 64) } } + Write-CcodAtomicJson -Path $path -Value $scalar + Assert-CcodEqual 'CCOD_RUNTIME_MANIFEST_INVALID' (Test-CcodRuntimeManifest -RuntimeDirectory $fixture.Runtime -ExpectedRuntimeId $valid.runtimeId).Code 'scalar files are rejected by shape' + $fraction = [ordered]@{ schemaVersion = 1; projectVersion = '2.3.1'; runtimeId = $valid.runtimeId; files = @([ordered]@{ path = 'a.txt'; length = 1.5; sha256 = ('0' * 64) }) } + Write-CcodAtomicJson -Path $path -Value $fraction + Assert-CcodEqual 'CCOD_RUNTIME_MANIFEST_INVALID' (Test-CcodRuntimeManifest -RuntimeDirectory $fixture.Runtime -ExpectedRuntimeId $valid.runtimeId).Code 'fractional lengths are rejected by shape' + } + + Invoke-CcodTest 'runtime manifest rejects extra properties and string schema versions' { + $installRoot = Join-Path $root 'manifest-strict-extra' + New-Item -ItemType Directory -Path $installRoot | Out-Null + $fixture = New-CcodRuntimeFixture -InstallRoot $installRoot -ProjectVersion '2.3.2' -AContent 'extra alpha' -BContent 'extra beta' + $path = Join-Path $fixture.Runtime 'manifest.json' + $valid = $fixture.Manifest + $extra = [ordered]@{ schemaVersion = 1; projectVersion = '2.3.2'; runtimeId = $valid.runtimeId; files = @(); unexpected = 'x' } + Write-CcodAtomicJson -Path $path -Value $extra + Assert-CcodEqual 'CCOD_RUNTIME_MANIFEST_INVALID' (Test-CcodRuntimeManifest -RuntimeDirectory $fixture.Runtime -ExpectedRuntimeId $valid.runtimeId).Code 'extra manifest fields are rejected' + $stringSchema = [ordered]@{ schemaVersion = '1'; projectVersion = '2.3.2'; runtimeId = $valid.runtimeId; files = @() } + Write-CcodAtomicJson -Path $path -Value $stringSchema + Assert-CcodEqual 'CCOD_RUNTIME_MANIFEST_INVALID' (Test-CcodRuntimeManifest -RuntimeDirectory $fixture.Runtime -ExpectedRuntimeId $valid.runtimeId).Code 'string schema version is rejected' + } } finally { if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } if (Test-Path -LiteralPath $outside) { Remove-Item -LiteralPath $outside -Recurse -Force } diff --git a/tests/persistence/SessionEngine.SelfTest.ps1 b/tests/persistence/SessionEngine.SelfTest.ps1 index ff0c1d6..cbd2c14 100644 --- a/tests/persistence/SessionEngine.SelfTest.ps1 +++ b/tests/persistence/SessionEngine.SelfTest.ps1 @@ -1029,6 +1029,7 @@ try { $stopped=[Collections.Generic.List[int]]::new();$adapters=New-CcodEngineAdapters -Processes @($source) $adapters.InvokeNode={param($NodePath,$Arguments)throw 'bridge failed'} $adapters.ListProcesses={param($StatusEvidence)@()} + $adapters.GetPreferredRendererPort={param($Excluded)41001} $adapters.GetTree={param($Root,$StatusEvidence)@($rootSpecial,$child)}.GetNewClosure() $adapters.GetProcess={param($ProcessId,$StatusEvidence)switch($ProcessId){201{$rootSpecial}202{$child}999{$unrelated}default{$source}}}.GetNewClosure() $adapters.StopProcess={param($Expected,$StatusEvidence,$TimeoutMilliseconds)$stopped.Add([int]$Expected.Pid);[pscustomobject]@{Outcome='Stopped';StoppedByController=$true;Snapshot=$Expected}}.GetNewClosure() @@ -1396,6 +1397,41 @@ try { } } + Invoke-CcodTest 'retries transient Electron tree churn before committing and closes every verified member' { + $root=New-CcodEngineSnapshot + $child=New-CcodEngineSnapshot -Pid 101 -CreationTimeUtc '2030-02-03T04:05:07.0000000Z' -Mode Unrelated -ParentPid 100 -IsTopLevel $false + $alive=@{100=$root;101=$child};$events=[Collections.Generic.List[string]]::new();$treeReads=[pscustomobject]@{Count=0} + $adapters=New-CcodEngineAdapters -State (New-CcodEngineState -Status ([pscustomobject]@{schemaVersion=1;session=$null})) -Processes @($root) -Events $events + $adapters.ListProcesses={param($StatusEvidence)@($root)}.GetNewClosure() + $adapters.GetProcess={param($ProcessId,$StatusEvidence)if($alive.ContainsKey([int]$ProcessId)){$alive[[int]$ProcessId]}else{$null}}.GetNewClosure() + $adapters.GetTree={param($Root,$StatusEvidence)$treeReads.Count++;if($treeReads.Count-eq1){@()}else{@($root,$child)}}.GetNewClosure() + $adapters.StopProcess={param($Expected,$StatusEvidence,$TimeoutMilliseconds)$events.Add("Stop:$($Expected.Pid)");$alive.Remove([int]$Expected.Pid);[pscustomobject]@{Outcome='Stopped';StoppedByController=$true;Snapshot=$Expected}}.GetNewClosure() + Set-CcodEngineAliveIdentityObserver -Adapters $adapters -Alive $alive + + $result=Invoke-CcodCloseSession -Request (New-CcodEngineRequest -SchemaVersion 2 -Action Close -RestartOrdinary $false) -Paths $paths -Adapters $adapters + + Assert-CcodEqual 'Closed' $result.outcome 'transient tree churn still reaches durable Closed' + Assert-CcodEqual 2 $treeReads.Count 'close retries the one transient empty verified tree' + Assert-CcodEqual 'Stop:101,Stop:100' ((@($events|Where-Object{$_ -like 'Stop:*'}))-join ',') 'retry preserves child-first proof for every stopped member' + Assert-CcodTrue ([Array]::IndexOf(@($events),'IntentWritten') -lt [Array]::IndexOf(@($events),'Stop:101')) 'all mutations remain after stable tree acquisition' + } + + Invoke-CcodTest 'root drift during stable tree acquisition performs no close mutation' { + $root=New-CcodEngineSnapshot + $changed=New-CcodEngineSnapshot -CreationTimeUtc '2030-02-03T04:05:09.0000000Z' + $events=[Collections.Generic.List[string]]::new();$processReads=[pscustomobject]@{Count=0} + $adapters=New-CcodEngineAdapters -State (New-CcodEngineState -Status ([pscustomobject]@{schemaVersion=1;session=$null})) -Processes @($root) -Events $events + $adapters.ListProcesses={param($StatusEvidence)@($root)}.GetNewClosure() + $adapters.GetProcess={param($ProcessId,$StatusEvidence)$processReads.Count++;if($processReads.Count-eq1){$root}else{$changed}}.GetNewClosure() + $adapters.GetTree={param($Root,$StatusEvidence)@()} + + $result=Invoke-CcodCloseSession -Request (New-CcodEngineRequest -SchemaVersion 2 -Action Close -RestartOrdinary $false) -Paths $paths -Adapters $adapters + + Assert-CcodEqual 'Error' $result.outcome 'root drift fails close safely' + Assert-CcodEqual 'CCOD_CLOSE_UNPROVEN' $result.error.code 'root drift keeps the stable close failure code' + Assert-CcodEqual 0 @($events|Where-Object{$_ -in @('IntentWritten','CloseRequested','StopProcess') -or $_ -like 'Stop:*'}).Count 'root drift occurs before every close mutation' + } + Invoke-CcodTest 'closes an already empty session and leaves unsafe close evidence durable' { $counters=[pscustomobject]@{SpecialStart=0;OrdinaryStart=0;Recover=0;Node=0};$adapters=New-CcodEngineAdapters -Processes @() -Counters $counters $empty=Invoke-CcodRecoverSession -Request (New-CcodEngineRequest -Action Recover -RestartOrdinary $false) -Paths $paths -Adapters $adapters @@ -1678,7 +1714,7 @@ try { Invoke-CcodTest 'production adapter declarations call upstream APIs and contain no empty process placeholder' { $moduleText=[IO.File]::ReadAllText((Join-Path $repositoryRoot 'src\persistence\modules\SessionEngine.psm1')) - foreach($command in @('Invoke-CcodStaticProbe','Get-CcodProcessSnapshot','Test-CcodProcessMatch','Stop-CcodProcessIfMatch','Get-CcodVerifiedProcessTree','Get-CcodTransactionProcessResult','Get-CcodAvailableLoopbackPort','Start-CcodProcess','Wait-CcodPortClosed','Read-CcodState','Write-CcodStatus','Write-CcodVerifiedPackages','New-CcodTransition','Set-CcodTransitionStage','Complete-CcodTransition')){Assert-CcodTrue ($moduleText -cmatch [regex]::Escape($command)) "production adapters wire $command"} + foreach($command in @('Invoke-CcodStaticProbe','Get-CcodProcessSnapshot','Test-CcodProcessMatch','Stop-CcodProcessIfMatch','Get-CcodVerifiedProcessTree','Get-CcodStableVerifiedProcessTree','Get-CcodTransactionProcessResult','Get-CcodAvailableLoopbackPort','Start-CcodProcess','Wait-CcodPortClosed','Read-CcodState','Write-CcodStatus','Write-CcodVerifiedPackages','New-CcodTransition','Set-CcodTransitionStage','Complete-CcodTransition')){Assert-CcodTrue ($moduleText -cmatch [regex]::Escape($command)) "production adapters wire $command"} Assert-CcodTrue ($moduleText -cnotmatch 'ListProcesses=\{\s*param\([^)]*\)\s*@\(\)\s*\}') 'production process enumeration is not an always-empty placeholder' $inspectionStart=$moduleText.IndexOf('function Merge-CcodInspectionAdapters',[StringComparison]::Ordinal) $inspectionEnd=$moduleText.IndexOf('function Assert-CcodInspectionState',[StringComparison]::Ordinal) diff --git a/tests/persistence/StateStore.SelfTest.ps1 b/tests/persistence/StateStore.SelfTest.ps1 index 14033a0..e130193 100644 --- a/tests/persistence/StateStore.SelfTest.ps1 +++ b/tests/persistence/StateStore.SelfTest.ps1 @@ -124,7 +124,7 @@ function Assert-CcodFailedAttemptClearReceipt { Assert-CcodEqual $Outcome $Receipt.Outcome "$Message receipt outcome" } -$root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-state-selftest-' + [Guid]::NewGuid().ToString('N')) +$root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-state-store-' + [Guid]::NewGuid().ToString('N')) try { Invoke-CcodTest 'exposes trusted safe-exit persistence with the state-store contract' { $commands = @('Get-CcodTrustedLogonIdentity', 'Read-CcodSafeExitIntent', 'Write-CcodSafeExitIntent', 'Test-CcodSafeExitIntentForCurrentLogon', 'Clear-CcodSafeExitIntent') @@ -841,5 +841,5 @@ try { Write-Error $_ exit 1 } finally { - if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + if (Test-Path -LiteralPath $root) { Remove-CcodTestOwnedTree -Path $root } } diff --git a/tests/persistence/StaticProbeWorker.SelfTest.ps1 b/tests/persistence/StaticProbeWorker.SelfTest.ps1 index efc4691..17a35cb 100644 --- a/tests/persistence/StaticProbeWorker.SelfTest.ps1 +++ b/tests/persistence/StaticProbeWorker.SelfTest.ps1 @@ -180,12 +180,12 @@ function New-CcodWorkerSuccessResult { function Get-CcodTestRuntimeId { param([string]$ProjectVersion, [object[]]$Files) - $lines = foreach ($file in $Files) { '{0}`t{1}`t{2}' -f $file.path,[int64]$file.length,$file.sha256 } + $lines = foreach ($file in $Files) { "{0}`t{1}`t{2}" -f $file.path,[int64]$file.length,$file.sha256 } $canonical = $lines -join "`n" $sha = [Security.Cryptography.SHA256]::Create() try { $digest = [BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($canonical))).Replace('-','').ToLowerInvariant() } finally { $sha.Dispose() } - return '{0}-{1}' -f $ProjectVersion,$digest.Substring(0,16) + return '{0}-{1}-{2}' -f $ProjectVersion,$digest.Substring(0,16),('c'*32) } function Get-CcodTestRuntimeRecords { @@ -202,8 +202,78 @@ function Get-CcodTestRuntimeRecords { return $records.ToArray() } +function Test-CcodTransientFixtureMoveFailure { + param([Parameter(Mandatory)]$ErrorRecord) + $transientHresults = @(-2146232800,-2147024891,-2147024864,-2147024863,-2147024726) + foreach ($exception in @($ErrorRecord.Exception, $ErrorRecord.Exception.InnerException)) { + if ($null -eq $exception) { continue } + if (($exception -is [IO.IOException] -or $exception -is [UnauthorizedAccessException]) -and [int64]$exception.HResult -in $transientHresults) { return $true } + } + return $false +} + +function Move-CcodTestFixtureDirectory { + param([Parameter(Mandatory)][string]$Source,[Parameter(Mandatory)][string]$Destination,[Parameter(Mandatory)][scriptblock]$Move) + $lastFailure = $null + for ($attempt = 1; $attempt -le 8; $attempt++) { + try { & $Move $Source $Destination; return } + catch { + $lastFailure = $_ + if ($attempt -ge 8 -or -not (Test-CcodTransientFixtureMoveFailure -ErrorRecord $_)) { throw } + Start-Sleep -Milliseconds ([Math]::Min(200, 10 * $attempt)) + } + } + throw $lastFailure +} + +Invoke-CcodTest 'fixture directory move retries a transient Windows file-system failure' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-static-move-retry-' + [guid]::NewGuid().ToString('N')) + $source = Join-Path $root 'source' + $destination = Join-Path $root 'destination' + try { + [IO.Directory]::CreateDirectory($source) | Out-Null + $state = [pscustomobject]@{ Calls = 0 } + $move = { + param($From,$To) + $state.Calls++ + if ($state.Calls -lt 3) { throw [IO.IOException]::new('transient fixture sharing violation') } + [IO.Directory]::Move($From,$To) + }.GetNewClosure() + Move-CcodTestFixtureDirectory -Source $source -Destination $destination -Move $move + Assert-CcodEqual 3 $state.Calls 'fixture move retries transient failures before succeeding' + Assert-CcodTrue ([IO.Directory]::Exists($destination)) 'fixture move publishes the destination after retry' + Assert-CcodTrue (-not [IO.Directory]::Exists($source)) 'fixture move leaves no source directory after retry' + } finally { + if ([IO.Directory]::Exists($root)) { [IO.Directory]::Delete($root,$true) } + } +} + +Invoke-CcodTest 'fixture directory move does not retry a non-I/O exception with an I/O HResult' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-static-move-nonio-' + [guid]::NewGuid().ToString('N')) + $source = Join-Path $root 'source' + $destination = Join-Path $root 'destination' + try { + [IO.Directory]::CreateDirectory($source) | Out-Null + $state = [pscustomobject]@{ Calls = 0 } + $failure = $null + $move = { + param($From,$To) + $state.Calls++ + $exception = [Exception]::new('non-I/O fixture failure') + $field = [Exception].GetField('_HResult', [Reflection.BindingFlags]'NonPublic,Instance') + $field.SetValue($exception, [int32]-2146232800) + throw $exception + }.GetNewClosure() + try { Move-CcodTestFixtureDirectory -Source $source -Destination $destination -Move $move } catch { $failure = $_ } + Assert-CcodEqual 1 $state.Calls 'non-I/O exceptions are not retried from a coincidental I/O HResult' + Assert-CcodEqual 'non-I/O fixture failure' $failure.Exception.Message 'the original non-I/O failure is preserved' + } finally { + if ([IO.Directory]::Exists($root)) { [IO.Directory]::Delete($root,$true) } + } +} + function New-CcodAuthorizedRuntimeFixture { - param([string]$Root,[string]$ReadStrictJsonMarker) + param([string]$Root,[string]$ReadStrictJsonMarker,[switch]$AppendOnly) $install = Join-Path $Root 'install' $staging = Join-Path $install 'staging' foreach ($relative in @( @@ -232,11 +302,11 @@ function New-CcodAuthorizedRuntimeFixture { $runtimeId = Get-CcodTestRuntimeId -ProjectVersion '2.0.0' -Files $records $runtime = Join-Path (Join-Path $install 'runtime') $runtimeId [IO.Directory]::CreateDirectory((Split-Path $runtime -Parent)) | Out-Null - [IO.Directory]::Move($staging,$runtime) + Move-CcodTestFixtureDirectory -Source $staging -Destination $runtime -Move { param($Source,$Destination) [IO.Directory]::Move($Source,$Destination) } $manifest = [pscustomobject][ordered]@{schemaVersion=1;projectVersion='2.0.0';runtimeId=$runtimeId;files=$records} [IO.File]::WriteAllText((Join-Path $runtime 'manifest.json'),($manifest|ConvertTo-Json -Depth 12),[Text.UTF8Encoding]::new($false)) - $active = [pscustomobject][ordered]@{schemaVersion=1;activeRuntime=$runtimeId;previousRuntime=$null;updatedAtUtc='2030-02-03T04:05:06.0000000Z'} - [IO.File]::WriteAllText((Join-Path $install 'active.json'),($active|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + if($AppendOnly){$pointerRoot=Join-Path $install 'state\active-generation';[IO.Directory]::CreateDirectory($pointerRoot)|Out-Null;$active=[pscustomobject][ordered]@{schemaVersion=1;generation=[uint64]1;activeRuntime=$runtimeId;previousGeneration=[uint64]0};[IO.File]::WriteAllText((Join-Path $pointerRoot '00000000000000000001.json'),($active|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new($false))} + else{$active = [pscustomobject][ordered]@{schemaVersion=1;activeRuntime=$runtimeId;previousRuntime=$null;updatedAtUtc='2030-02-03T04:05:06.0000000Z'};[IO.File]::WriteAllText((Join-Path $install 'active.json'),($active|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false))} $workers = Join-Path $install 'state\workers' [IO.Directory]::CreateDirectory($workers)|Out-Null return [pscustomobject][ordered]@{ @@ -302,7 +372,7 @@ function New-CcodWorkerHarness { [pscustomobject][ordered]@{RequestPath=$requestPath;ResultPath=$resultPath;Context=$context;Adapters=$adapters;Events=$events;Stdout=$stdout;Written=$written;ProbeCalls=$probeCalls} } -$root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-static-worker-'+[guid]::NewGuid().ToString('N')) +$root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-static-worker-' + [guid]::NewGuid().ToString('N')) try { Invoke-CcodTest 'strictly rejects every request shape, order, type, case, and correlation mutation' { $base=New-CcodWorkerRequest @@ -504,6 +574,79 @@ try { Assert-CcodEqual 0 $requestHarness.ProbeCalls.Count 'duplicate request never probes' } + Invoke-CcodTest 'authorizes append-only active generation without legacy active json' { + $fixture=New-CcodAuthorizedRuntimeFixture -Root (Join-Path $root 'append-only-runtime') -AppendOnly + Assert-CcodEqual $false (Test-Path -LiteralPath (Join-Path $fixture.InstallRoot 'active.json')) 'append-only fixture has no legacy selector' + $context=Get-CcodStaticProbeRuntimeAuthorization -ScriptPath $fixture.WorkerPath + Assert-CcodEqual $fixture.RuntimeId $context.RuntimeId 'static worker binds the append-only selected runtime' + } + + Invoke-CcodTest 'append-only authorization accepts integral Decimal generation records' { + $large=[decimal]::Parse('9223372036854775808',[Globalization.CultureInfo]::InvariantCulture) + $maximum=[decimal]::Parse('18446744073709551615',[Globalization.CultureInfo]::InvariantCulture) + Assert-CcodEqual '9223372036854775808' ([string](ConvertTo-CcodStaticRuntimeGeneration -Value $large -Path 'fixture')) 'static worker accepts integral Decimal generations above Int64' + Assert-CcodEqual '18446744073709551615' ([string](ConvertTo-CcodStaticRuntimeGeneration -Value $maximum -Path 'fixture')) 'static worker accepts the maximum UInt64 generation represented as Decimal' + Assert-CcodEqual '0' ([string](ConvertTo-CcodStaticRuntimeGeneration -Value ([decimal]0) -Path 'fixture' -AllowZero)) 'static worker accepts Decimal zero for previousGeneration' + Assert-CcodThrows { ConvertTo-CcodStaticRuntimeGeneration -Value ([decimal]1.5) -Path 'fixture' } 'CCOD_STATIC_RUNTIME_UNAUTHORIZED' + Assert-CcodThrows { ConvertTo-CcodStaticRuntimeGeneration -Value ([decimal]-1) -Path 'fixture' } 'CCOD_STATIC_RUNTIME_UNAUTHORIZED' + Assert-CcodThrows { ConvertTo-CcodStaticRuntimeGeneration -Value ([decimal]::Parse('18446744073709551616',[Globalization.CultureInfo]::InvariantCulture)) -Path 'fixture' } 'CCOD_STATIC_RUNTIME_UNAUTHORIZED' + $fixture=New-CcodAuthorizedRuntimeFixture -Root (Join-Path $root 'append-decimal-generations') -AppendOnly + $pointerRoot=Join-Path $fixture.InstallRoot 'state\active-generation' + $json='{"schemaVersion":1,"generation":1.0,"activeRuntime":"'+$fixture.RuntimeId+'","previousGeneration":0.0}' + [IO.File]::WriteAllText((Join-Path $pointerRoot '00000000000000000001.json'),$json,[Text.UTF8Encoding]::new($false)) + $authorization=Get-CcodStaticProbeRuntimeAuthorization -ScriptPath $fixture.WorkerPath + Assert-CcodEqual $fixture.RuntimeId $authorization.RuntimeId 'static worker accepts integral Decimal append-only generations' + } + + Invoke-CcodTest 'append-only authorization rejects unsafe roots leaves JSON and generations' { + foreach($kind in @('root-file','root-reparse','leaf-reparse','ads','multilink','malformed','schema','duplicate','fractional','noncanonical')){ + $fixture=New-CcodAuthorizedRuntimeFixture -Root (Join-Path $root ("append-hostile-$kind")) -AppendOnly;$pointerRoot=Join-Path $fixture.InstallRoot 'state\active-generation';$leaf=Join-Path $pointerRoot '00000000000000000001.json';$target=Join-Path $fixture.InstallRoot ("target-$kind") + if($kind-ceq'root-file'){Remove-Item $pointerRoot -Recurse -Force;[IO.File]::WriteAllText($pointerRoot,'x',[Text.UTF8Encoding]::new($false))} + elseif($kind-ceq'root-reparse'){Remove-Item $pointerRoot -Recurse -Force;[IO.Directory]::CreateDirectory($target)|Out-Null;New-Item -ItemType Junction -Path $pointerRoot -Target $target|Out-Null} + elseif($kind-ceq'leaf-reparse'){[IO.File]::Delete($leaf);[IO.Directory]::CreateDirectory($target)|Out-Null;New-Item -ItemType Junction -Path $leaf -Target $target|Out-Null} + elseif($kind-ceq'ads'){Set-Content -LiteralPath $leaf -Stream evidence -Value x -NoNewline} + elseif($kind-ceq'multilink'){$text=[IO.File]::ReadAllText($leaf);[IO.File]::Delete($leaf);$outside=Join-Path $fixture.InstallRoot 'outside-pointer.json';[IO.File]::WriteAllText($outside,$text,[Text.UTF8Encoding]::new($false));New-Item -ItemType HardLink -Path $leaf -Target $outside|Out-Null} + elseif($kind-ceq'malformed'){[IO.File]::WriteAllText($leaf,'{',[Text.UTF8Encoding]::new($false))} + elseif($kind-ceq'schema'){$id=$fixture.RuntimeId;[IO.File]::WriteAllText($leaf,('{"schemaVersion":2,"generation":1,"activeRuntime":"'+$id+'","previousGeneration":0}'),[Text.UTF8Encoding]::new($false))} + elseif($kind-ceq'duplicate'){$id=$fixture.RuntimeId;[IO.File]::WriteAllText($leaf,('{"schemaVersion":1,"schemaVersion":1,"generation":1,"activeRuntime":"'+$id+'","previousGeneration":0}'),[Text.UTF8Encoding]::new($false))} + elseif($kind-ceq'fractional'){$id=$fixture.RuntimeId;[IO.File]::WriteAllText($leaf,('{"schemaVersion":1,"generation":1.5,"activeRuntime":"'+$id+'","previousGeneration":0}'),[Text.UTF8Encoding]::new($false))} + else{Move-Item -LiteralPath $leaf -Destination (Join-Path $pointerRoot '00000000000000000002.json')} + $failure=$null;try{Get-CcodStaticProbeRuntimeAuthorization -ScriptPath $fixture.WorkerPath|Out-Null}catch{$failure=$_} + $actualId=if($null-eq$failure){''}else{[string]$failure.FullyQualifiedErrorId} + Assert-CcodTrue ($null-ne$failure-and$actualId-like'CCOD_STATIC_RUNTIME_UNAUTHORIZED*') "$kind selector mutation is rejected before runtime authorization (actual=$actualId)" + } + } + + Invoke-CcodTest 'append-only selector lookup errors cannot fall back to a valid legacy pointer' { + $fixture=New-CcodAuthorizedRuntimeFixture -Root (Join-Path $root 'append-lookup-error') + $pointerRoot=Join-Path $fixture.InstallRoot 'state\active-generation' + $getItem={param($Path,$AllowMissing)if([IO.Path]::GetFullPath($Path)-ceq[IO.Path]::GetFullPath($pointerRoot)){throw [UnauthorizedAccessException]::new('selector lookup denied')};try{return Get-Item -LiteralPath $Path -Force -ErrorAction Stop}catch [Management.Automation.ItemNotFoundException]{if($AllowMissing){return $null};throw}}.GetNewClosure() + Assert-CcodThrows {Get-CcodStaticProbeRuntimeAuthorization -ScriptPath $fixture.WorkerPath -Adapters @{GetItem=$getItem}|Out-Null} 'CCOD_STATIC_RUNTIME_UNAUTHORIZED' + } + + Invoke-CcodTest 'selector lookup requires an explicit discriminated absence proof' { + $fixture=New-CcodAuthorizedRuntimeFixture -Root (Join-Path $root 'append-explicit-absence') + $pointerRoot=Join-Path $fixture.InstallRoot 'state\active-generation' + $getItem={param($Path,$AllowMissing)if([IO.Path]::GetFullPath($Path)-ceq[IO.Path]::GetFullPath($pointerRoot)){throw [Management.Automation.ItemNotFoundException]::new('selector is absent')};Get-Item -LiteralPath $Path -Force -ErrorAction Stop}.GetNewClosure() + $context=Get-CcodStaticProbeRuntimeAuthorization -ScriptPath $fixture.WorkerPath -Adapters @{GetItem=$getItem} + Assert-CcodEqual $fixture.RuntimeId $context.RuntimeId 'an exact ItemNotFound result permits legacy fallback' + foreach($case in @( + @{Name='null';Callback={return $null}}, + @{Name='empty';Callback={}}, + @{Name='malformed';Callback={[pscustomobject]@{Status='Missing'}}}, + @{Name='unknown';Callback={[pscustomobject][ordered]@{Status='Unknown';Item=$null}}} + )){ + Assert-CcodThrows {Get-CcodStaticProbeRuntimeAuthorization -ScriptPath $fixture.WorkerPath -Adapters @{GetSelectorRootResult=$case.Callback}|Out-Null} 'CCOD_STATIC_RUNTIME_UNAUTHORIZED' + } + } + + Invoke-CcodTest 'legacy authorization rejects a state ancestor file' { + $fixture=New-CcodAuthorizedRuntimeFixture -Root (Join-Path $root 'legacy-state-file') + $state=Join-Path $fixture.InstallRoot 'state';Remove-Item -LiteralPath $state -Recurse -Force + [IO.File]::WriteAllText($state,'not-a-directory',[Text.UTF8Encoding]::new($false)) + Assert-CcodThrows {Get-CcodStaticProbeRuntimeAuthorization -ScriptPath $fixture.WorkerPath|Out-Null} 'CCOD_STATIC_RUNTIME_UNAUTHORIZED' + } + Invoke-CcodTest 'imports only exact private bound runtime APIs and unloads every module command surface' { $fixture=New-CcodAuthorizedRuntimeFixture -Root (Join-Path $root 'private-runtime-api') $context=Get-CcodStaticProbeRuntimeAuthorization -ScriptPath $fixture.WorkerPath @@ -574,7 +717,7 @@ try { Assert-CcodEqual $(if($boundary -ceq 'ImportRuntime'){'CCOD_STATIC_MODULE_LOAD_FAILED'}else{'CCOD_STATIC_RUNTIME_UNAUTHORIZED'}) $harness.Written[0].error.code "$boundary failure keeps its fixed code" } - $fixture=New-CcodAuthorizedRuntimeFixture -Root (Join-Path $root 'production-module-failure-publication') + $fixture=New-CcodAuthorizedRuntimeFixture -Root (Join-Path $root 'mod-fail-pub') $request=New-CcodWorkerRequest;$request.runtimeId=$fixture.RuntimeId $requestPath=[IO.Path]::GetFullPath((Join-Path $fixture.WorkersRoot ("static-probe-$($request.requestId).request.json"))) $resultPath=[IO.Path]::GetFullPath((Join-Path $fixture.WorkersRoot ("static-probe-$($request.requestId).result.json"))) @@ -595,7 +738,7 @@ try { Assert-CcodEqual 1 $stdout.Count 'successful fallback atomic publication emits one stdout frame' Assert-CcodEqual 0 $stderr.Count 'successful fallback atomic publication emits no stderr frame' - $raceFixture=New-CcodAuthorizedRuntimeFixture -Root (Join-Path $root 'production-module-failure-result-race') + $raceFixture=New-CcodAuthorizedRuntimeFixture -Root (Join-Path $root 'mod-fail-race') $raceRequest=New-CcodWorkerRequest;$raceRequest.runtimeId=$raceFixture.RuntimeId $raceRequestPath=[IO.Path]::GetFullPath((Join-Path $raceFixture.WorkersRoot ("static-probe-$($raceRequest.requestId).request.json"))) $raceResultPath=[IO.Path]::GetFullPath((Join-Path $raceFixture.WorkersRoot ("static-probe-$($raceRequest.requestId).result.json"))) @@ -1323,9 +1466,19 @@ try { Assert-CcodTrue ($identity.UserSid -is [string] -and $identity.UserSid -ceq ([Security.Principal.WindowsIdentity]::GetCurrent().User.Value)) 'identity owner SID is exact' Assert-CcodEqual ([int](Get-Process -Id $PID).SessionId) $identity.SessionId 'identity session is exact' } + + Invoke-CcodTest 'static probe runtime IDs use canonical TAB delimiters' { + $files = @( + [pscustomobject]@{ path = 'a.txt'; length = [int64]5; sha256 = ('a' * 64) } + [pscustomobject]@{ path = 'b.txt'; length = [int64]4; sha256 = ('b' * 64) } + ) + $nonce = '0123456789abcdef0123456789abcdef' + $expected = '2.0.0-e71f4818a0f8e98f-0123456789abcdef0123456789abcdef' + Assert-CcodEqual $expected (Get-CcodStaticRuntimeIdFromRecords -ProjectVersion '2.0.0' -Files $files -ExpectedRuntimeId $expected) 'static probe runtime ID digest input must use literal TAB delimiters' + } } catch { Write-Error $_ exit 1 } finally { - if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + if ([IO.Directory]::Exists($root)) { Remove-CcodTestOwnedTree -Path $root } } diff --git a/tests/persistence/Supervisor.SelfTest.ps1 b/tests/persistence/Supervisor.SelfTest.ps1 index fae9fd7..13ddaba 100644 --- a/tests/persistence/Supervisor.SelfTest.ps1 +++ b/tests/persistence/Supervisor.SelfTest.ps1 @@ -102,10 +102,12 @@ function New-CcodSupervisorFake { [pscustomobject][ordered]@{SchemaVersion=1;Name="Fake-$Kind";Kind=$Kind;CreatedNew=$false;Handle=[pscustomobject]@{Kind=$Kind};Disposed=$false} } $adapters.GetIdentity={$world.Calls.Add('Identity');$identity}.GetNewClosure() + $adapters.GetTrayHostIdentity={param($ProcessId,$ExpectedCreationTimeUtc)$world.Calls.Add("TrayIdentity:$ProcessId");[pscustomobject][ordered]@{Outcome='SameIdentity';Pid=[int]$ProcessId;CreationTimeUtc=[string]$ExpectedCreationTimeUtc}}.GetNewClosure() $adapters.ResolveLayout={$world.Calls.Add('Layout');$layout}.GetNewClosure() $adapters.StartClock={$world.Calls.Add('Clock');[pscustomobject]@{Kind='Clock'}}.GetNewClosure() $adapters.GetElapsedMilliseconds={param($Clock)if($world.Elapsed.Count){[long]$world.Elapsed.Dequeue()}else{[long]0}}.GetNewClosure() $adapters.GetUtcNow={[DateTime]::Parse('2030-02-03T03:04:05Z').ToUniversalTime()}.GetNewClosure() + $adapters.Delay={param($Milliseconds)} $adapters.EnterLease={ param($Kind,$UserSid,$SessionId,$TimeoutMilliseconds) $world.Calls.Add("Enter:$Kind`:$TimeoutMilliseconds") @@ -151,7 +153,7 @@ function New-CcodSupervisorFake { $world.Calls.Add("New:Lifecycle:$Kind") & $transactionModule {param($K,$O,$R,$G,$E,$Owner,$Logon,$Now)New-CcodLifecycleRequest -Kind $K -Origin $O -RuntimeId $R -RuntimeGeneration $G -LeaseEpoch $E -OwnerIdentity $Owner -LogonIdentity $Logon -NowUtc $Now} $Kind $Origin $RuntimeId $RuntimeGeneration $LeaseEpoch $OwnerIdentity $LogonIdentity $NowUtc }.GetNewClosure() - $adapters.WriteLifecycleRequest={param($StateRoot,$Request)$world.Calls.Add("Write:Lifecycle:$($Request.phase)");$world.ActiveLifecycleRequest=$Request;$world.LifecycleWrites.Add($Request)}.GetNewClosure() + $adapters.WriteLifecycleRequest={param($StateRoot,$Request)$world.Calls.Add("Write:Lifecycle:$($Request.phase)");if($world.FailAt -ceq 'WriteLifecycleRequest'){throw 'PRIVATE_LIFECYCLE_WRITE_SECRET'};$world.ActiveLifecycleRequest=$Request;$world.LifecycleWrites.Add($Request)}.GetNewClosure() $adapters.MoveLifecyclePhase={param($Request,$NextPhase,$NowUtc)$world.Calls.Add("Move:Lifecycle:$NextPhase");$world.LifecycleMoves.Add($NextPhase);& $transactionModule {param($Value,$Phase,$Now)Move-CcodLifecyclePhase -Request $Value -NextPhase $Phase -NowUtc $Now} $Request $NextPhase $NowUtc}.GetNewClosure() $adapters.CompleteLifecycleRequest={param($StateRoot,$Request)$world.Calls.Add("Complete:Lifecycle:$($Request.phase)");$world.CompletedLifecycleRequests.Add($Request);$world.ActiveLifecycleRequest=$null}.GetNewClosure() $adapters.GetLifecycleStep={param($Request,$Observation,$NowUtc)& $coordinatorModule {param($Value,$Observed,$Now)Get-CcodLifecycleStep -Request $Value -Observation $Observed -NowUtc $Now} $Request $Observation $NowUtc}.GetNewClosure() @@ -190,7 +192,7 @@ function New-CcodSupervisorFake { $adapters.NewQueue={param($Kind)$world.Calls.Add("Queue:$Kind");if($Kind -ceq 'Command'){Write-Output -NoEnumerate $world.CommandQueue}else{Write-Output -NoEnumerate $world.EventQueue}}.GetNewClosure() $adapters.GetQueueCount={param($Queue)[int]$Queue.Count}.GetNewClosure() $adapters.TryDequeue={param($Queue)$world.TryDequeueSawRealQueue=[object]::ReferenceEquals($Queue,$world.CommandQueue);if($Queue.Count){[pscustomobject][ordered]@{Succeeded=$true;Value=$Queue.Dequeue()}}else{[pscustomobject][ordered]@{Succeeded=$false;Value=$null}}}.GetNewClosure() - $adapters.NewTray={param($Queue,$OnTick,$Catalog,$LanguageMode,$SystemCultureName)$world.NewTraySawRealQueue=[object]::ReferenceEquals($Queue,$world.CommandQueue);$world.Calls.Add('New:Tray');if($world.FailAt -ceq 'NewTray'){throw 'PRIVATE_TRAY_SECRET'};$world.OnTick=$OnTick;$world.TrayArguments=[pscustomobject][ordered]@{Queue=$Queue;OnTick=$OnTick;Catalog=$Catalog;LanguageMode=$LanguageMode;SystemCultureName=$SystemCultureName};[pscustomobject]@{Kind='Tray';CurrentRevision=[UInt64]1;Timer=[pscustomobject]@{Kind='Timer'};ApplicationContext=[pscustomobject]@{Kind='App'}}}.GetNewClosure() + $adapters.NewTray={param($Queue,$OnTick,$Catalog,$LanguageMode,$SystemCultureName)$world.NewTraySawRealQueue=[object]::ReferenceEquals($Queue,$world.CommandQueue);$world.Calls.Add('New:Tray');if($world.FailAt -ceq 'NewTray'){throw 'PRIVATE_TRAY_SECRET'};$world.OnTick=$OnTick;$world.TrayArguments=[pscustomobject][ordered]@{Queue=$Queue;OnTick=$OnTick;Catalog=$Catalog;LanguageMode=$LanguageMode;SystemCultureName=$SystemCultureName};[pscustomobject]@{Kind='Tray';CurrentRevision=[UInt64]1;Timer=[pscustomobject]@{Kind='Timer'};ApplicationContext=[pscustomobject]@{Kind='App'};Client=[pscustomobject]@{Receipt=[pscustomobject]@{HostPid=700;HostCreationFileTimeUtc=[DateTime]::Parse('2030-02-03T03:04:00Z').ToFileTimeUtc();RuntimeId=$layout.RuntimeId;ProtocolMajor=[UInt16]2;Capabilities=[UInt64]2}}}}.GetNewClosure() $adapters.SetTrayPresentation={param($Tray,$Presentation,$Catalog,$LanguageMode,$SystemCultureName,$WaitForAcknowledgement)$world.Calls.Add('Set:Presentation');if($world.FailAt -ceq 'SetTrayPresentation'){throw 'PRIVATE_PRESENTATION_SECRET'};$world.PresentationArguments.Add([pscustomobject][ordered]@{Tray=$Tray;Presentation=$Presentation;Catalog=$Catalog;LanguageMode=$LanguageMode;SystemCultureName=$SystemCultureName;WaitForAcknowledgement=[bool]$WaitForAcknowledgement})}.GetNewClosure() $adapters.SendTrayActionResult={param($Tray,$ActionId,$Revision,$Status,$ErrorCode,$TransactionId)$world.Calls.Add("ActionResult:$Status");$world.TrayActionResults.Add([pscustomobject][ordered]@{ActionId=$ActionId;Revision=[UInt64]$Revision;Status=$Status;ErrorCode=$ErrorCode;TransactionId=$TransactionId});return $true}.GetNewClosure() $adapters.VerifyActiveRuntimeForAbout={param($InstallRoot,$RuntimeId)$world.Calls.Add('Verify:About');[pscustomobject][ordered]@{RuntimeId=$RuntimeId;Version='2.5.0'}}.GetNewClosure() @@ -354,6 +356,14 @@ Invoke-CcodTest 'exposes only the frozen ReadyToken CLI and rejects an invalid t Assert-CcodEqual 0 $fake.World.Calls.Count 'invalid token invokes no adapter' } +Invoke-CcodTest 'Supervisor strict identifiers reject regex end-anchor variants' { + $source = [IO.File]::ReadAllText($supervisorPath, [Text.UTF8Encoding]::new($false)) + Assert-CcodTrue (-not ($source -cmatch '\[0-9a-f\]\{64\}\$')) 'Supervisor SHA-256 validators use absolute end anchors' + Assert-CcodTrue (-not ($source -cmatch 'A-Za-z0-9\.\._-\]\{0,127\}\$')) 'Supervisor runtime identifiers use absolute end anchors' + Assert-CcodTrue (-not ($source -cmatch '\\d\+\\\.\\d\+\\\.\\d\+\$')) 'Supervisor version and UTC validators use absolute end anchors' + Assert-CcodTrue (-not ($source -cmatch 'ProcessId.*\\}\$')) 'Supervisor transaction and process identifiers use absolute end anchors' +} + Invoke-CcodTest 'accepts the canonical five-field active runtime pointer returned by RuntimeManifest' { $root=Join-Path ([IO.Path]::GetTempPath()) ('ccod-supervisor-active-' + [guid]::NewGuid().ToString('N')) try { @@ -371,6 +381,59 @@ Invoke-CcodTest 'accepts the canonical five-field active runtime pointer returne } } +Invoke-CcodTest 'TrayHost readiness rejects receipt objects with extra fields' { + $creation = [DateTime]::Parse('2030-02-03T03:04:00Z').ToFileTimeUtc() + $state = [pscustomobject]@{ + Tray = [pscustomobject]@{ Client = [pscustomobject]@{ Receipt = [pscustomobject][ordered]@{ HostPid = [int]700; HostCreationFileTimeUtc = [long]$creation; RuntimeId = 'runtime-1'; ProtocolMajor = [UInt16]2; Capabilities = [UInt64]2; Unexpected = 'fixture' } } } + Layout = [pscustomobject]@{ RuntimeId = 'runtime-1' } + RuntimeCleanupCodes = [Collections.Generic.List[string]]::new() + } + $adapters = @{ + GetUtcNow = { [DateTime]::Parse('2030-02-03T03:04:05Z').ToUniversalTime() } + WriteLog = { param($Record) } + GetTrayHostIdentity = { param($Pid,$CreationTimeUtc) [pscustomobject][ordered]@{ Outcome = 'SameIdentity'; Pid = [int]$Pid; CreationTimeUtc = [string]$CreationTimeUtc } } + } + Assert-CcodEqual $false (Write-CcodSupervisorTrayHostReady -HostState $state -Adapters $adapters) 'unexpected tray receipt fields are rejected' +} + +Invoke-CcodTest 'TrayHost readiness accepts the real CLR start receipt property shape' { + if($null -eq ('CcodClrTrayHostReceiptFixture' -as [type])) { + Add-Type -TypeDefinition @' +public sealed class CcodClrTrayHostReceiptFixture { + public int HostPid { get; set; } + public long HostCreationFileTimeUtc { get; set; } + public string RuntimeId { get; set; } + public ushort ProtocolMajor { get; set; } + public ulong Capabilities { get; set; } +} +'@ + } + $fixture=New-CcodTickFixture + $fake=$fixture.Fake + $state=$fixture.Host + $creation=[datetime]::Parse('2030-02-03T03:04:00Z').ToFileTimeUtc() + $receipt=[CcodClrTrayHostReceiptFixture]::new() + $receipt.HostPid=700;$receipt.HostCreationFileTimeUtc=$creation;$receipt.RuntimeId='runtime-1';$receipt.ProtocolMajor=[uint16]2;$receipt.Capabilities=[uint64]2 + $state.Tray=[pscustomobject]@{Client=[pscustomobject]@{Receipt=$receipt}} + Assert-CcodEqual $true (Write-CcodSupervisorTrayHostReady -HostState $state -Adapters $fake.Adapters) 'CLR TrayHostStartReceipt passes readiness validation' + Assert-CcodEqual 1 @($fake.World.UiFailureRecords|Where-Object{$_.stage -ceq 'TrayHostReady'}).Count 'CLR receipt emits authenticated readiness proof' +} + +Invoke-CcodTest 'TrayHost readiness rejects a receipt whose process identity is not trusted' { + $creation = [DateTime]::Parse('2030-02-03T03:04:00Z').ToFileTimeUtc() + $state = [pscustomobject]@{ + Tray = [pscustomobject]@{ Client = [pscustomobject]@{ Receipt = [pscustomobject][ordered]@{ HostPid = [int]700; HostCreationFileTimeUtc = [long]$creation; RuntimeId = 'runtime-1'; ProtocolMajor = [UInt16]2; Capabilities = [UInt64]2 } } } + Layout = [pscustomobject]@{ RuntimeId = 'runtime-1' } + RuntimeCleanupCodes = [Collections.Generic.List[string]]::new() + } + $adapters = @{ + GetUtcNow = { [DateTime]::Parse('2030-02-03T03:04:05Z').ToUniversalTime() } + WriteLog = { param($Record) } + GetTrayHostIdentity = { param($Pid,$CreationTimeUtc) [pscustomobject][ordered]@{ Outcome = 'IdentityChanged'; Pid = [int]$Pid; CreationTimeUtc = [string]$CreationTimeUtc } } + } + Assert-CcodEqual $false (Write-CcodSupervisorTrayHostReady -HostState $state -Adapters $adapters) 'untrusted tray process identity is rejected' +} + Invoke-CcodTest 'acquires both lifetime leases and signals Ready only after all prerequisites' { $fake=New-CcodSupervisorFake $receipt=Invoke-CcodSupervisorHost -ReadyToken $readyToken -Adapters $fake.Adapters @@ -378,6 +441,9 @@ Invoke-CcodTest 'acquires both lifetime leases and signals Ready only after all Assert-CcodEqual 1 $fake.World.ReadySignals 'Ready signals once' Assert-CcodTrue $fake.World.NewTraySawRealQueue 'tray receives the command queue itself' Assert-CcodTrue $fake.World.NewWatcherSawRealQueue 'watcher receives the event queue itself' + $readyRecords=@($fake.World.UiFailureRecords|Where-Object{$_.stage -ceq 'TrayHostReady'}) + Assert-CcodEqual 1 $readyRecords.Count 'authenticated TrayHost readiness is written after NewTray returns' + Assert-CcodEqual 'CCOD_TRAYHOST_READY' ([string]$readyRecords[0].code) 'TrayHost readiness uses the canonical code' $calls=@($fake.World.Calls) foreach($before in @('Enter:AccountSupervisor:30000','Enter:Supervisor:30000','Read:ActiveRuntime','Read:LogonIdentity','Enter:LifecycleOwnership:7','Open:Ready','Open:Shutdown','Open:LifecycleWake','Read:State','Read:Journal','Read:LifecycleRequest','Read:UiPreference','Get:SystemCulture','Get:UiCatalog:System','Queue:Command','Queue:Event','New:Tray','New:Watcher')){ Assert-CcodTrue ([Array]::IndexOf($calls,$before) -ge 0) "$before occurs" @@ -393,6 +459,15 @@ Invoke-CcodTest 'acquires both lifetime leases and signals Ready only after all Assert-CcodTrue ([Array]::IndexOf($calls,'Exit:Supervisor') -lt [Array]::IndexOf($calls,'Exit:AccountSupervisor')) 'leases release in reverse order' } +Invoke-CcodTest 'does not signal Ready when authenticated TrayHost evidence cannot be written' { + $fake=New-CcodSupervisorFake -FailAt 'WriteLog' + $receipt=Invoke-CcodSupervisorHost -ReadyToken $readyToken -Adapters $fake.Adapters + Assert-CcodReceipt $receipt 'Failed' 1 + Assert-CcodEqual 0 $fake.World.ReadySignals 'TrayHost evidence failure prevents Ready signaling' + Assert-CcodTrue (-not $fake.World.Calls.Contains('Run:UI')) 'TrayHost evidence failure prevents entering the UI loop' + Assert-CcodTrue (@($receipt.CleanupCodes) -contains 'CCOD_SUPERVISOR_TRAY_READY_LOG_FAILED') 'TrayHost evidence failure keeps the stable cleanup code' +} + Invoke-CcodTest 'uses one monotonic 30000ms acquisition budget' { $fake=New-CcodSupervisorFake $fake.World.Elapsed.Enqueue([long]0);$fake.World.Elapsed.Enqueue([long]4200) @@ -457,6 +532,22 @@ Invoke-CcodTest 'continues cleanup after timer watcher tray event and lease fail } } +Invoke-CcodTest 'propagates a structured tray close cleanup receipt into the supervisor receipt' { + $fake=New-CcodSupervisorFake + $fake.Adapters.CloseTray={param($Tray)[pscustomobject][ordered]@{SchemaVersion=1;Closed=$true;CleanupCodes=@('CCOD_TRAY_CLEANUP_MENU_DISPOSE_FAILED')}} + $receipt=Invoke-CcodSupervisorHost -ReadyToken $readyToken -Adapters $fake.Adapters + Assert-CcodReceipt $receipt 'Stopped' 0 + Assert-CcodTrue (@($receipt.CleanupCodes) -contains 'CCOD_SUPERVISOR_TRAY_CLOSE_FAILED') 'tray cleanup receipt failure is retained as a stable supervisor code' +} + +Invoke-CcodTest 'propagates a structured watcher cleanup receipt into the supervisor receipt' { + $fake=New-CcodSupervisorFake + $fake.Adapters.StopWatcher={param($Watcher)[pscustomobject][ordered]@{SchemaVersion=1;Stopped=$true;CleanupCodes=@('CCOD_WATCHER_CLEANUP_QUEUE_DRAIN_FAILED')}} + $receipt=Invoke-CcodSupervisorHost -ReadyToken $readyToken -Adapters $fake.Adapters + Assert-CcodReceipt $receipt 'Stopped' 0 + Assert-CcodTrue (@($receipt.CleanupCodes) -contains 'CCOD_SUPERVISOR_WATCHER_STOP_FAILED') 'watcher cleanup receipt failure is retained as a stable supervisor code' +} + Invoke-CcodTest 'surviving lifecycle worker keeps framing and lifecycle ownership contained' { $fake=New-CcodSupervisorFake $fake.World.LifecycleSubmissions.Enqueue([pscustomobject][ordered]@{schemaVersion=1;submissionId='dddddddd-eeee-ffff-0000-111111111111';kind='RestartAndRepair';origin='Installer';runtimeId='runtime-1';runtimeGeneration=[UInt64]7;createdAtUtc='2030-02-03T03:04:05.0000000Z'}) @@ -564,6 +655,93 @@ Invoke-CcodTest 'SafeExit refuses UI shutdown when its correlated terminal resul Assert-CcodEqual 0 $world.CompletedLifecycleRequests.Count 'undelivered terminal result preserves the active request for resume' } +Invoke-CcodTest 'recovered language failure history remains readable by the installed Ready collector' { + # $env:TEMP can be a Windows 8.3 short-name alias such as C:\Users\RUNNER~1\AppData\Local\Temp + # on hosted runners, and the installed Ready collector rejects noncanonical evidence paths. + $tempRoot = [IO.Path]::GetFullPath(([IO.Path]::GetTempPath()).TrimEnd('\')) + $root=Join-Path $tempRoot ('ccod-language-ready-history-'+[guid]::NewGuid().ToString('N')) + $integration=$null + try { + [IO.Directory]::CreateDirectory((Join-Path $root 'logs'))|Out-Null + $log=Join-Path $root 'logs/supervisor.log' + $integration=New-Module -ArgumentList (Join-Path $repositoryRoot 'tests/installed/Invoke-InstalledLifecycleIntegration.ps1') -ScriptBlock {param($Path). $Path -Library;Export-ModuleMember -Function Get-CcodInstalledLifecycleTrayHostReadyProof} + $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host + $fixture.Fake.Adapters.WriteLog={param($Record)[IO.File]::AppendAllText($log,(($Record|ConvertTo-Json -Compress)+[Environment]::NewLine),[Text.UTF8Encoding]::new($false))}.GetNewClosure() + $created='2030-02-03T04:05:01.0000006Z' + $hostState.Tray|Add-Member -NotePropertyName Client -NotePropertyValue ([pscustomobject]@{Receipt=[pscustomobject]@{HostPid=201;HostCreationFileTimeUtc=[DateTime]::Parse($created).ToFileTimeUtc();RuntimeId=$hostState.Layout.RuntimeId;ProtocolMajor=[uint16]2;Capabilities=[uint64]2}}) + $fixture.Fake.Adapters.GetTrayHostIdentity={param($ProcessId,$Created)[pscustomobject][ordered]@{Outcome='SameIdentity';Pid=[int]$ProcessId;CreationTimeUtc=$Created}} + Assert-CcodTrue (Write-CcodSupervisorTrayHostReady $hostState $fixture.Fake.Adapters) 'real Ready producer produces baseline' + $hosts=@([pscustomobject]@{Pid=201;CreationTimeUtc=$created}) + Assert-CcodTrue (&$integration {param($Root,$Runtime,$Hosts)Get-CcodInstalledLifecycleTrayHostReadyProof -InstallRoot $Root -RuntimeId $Runtime -TrayHost $Hosts} $root $hostState.Layout.RuntimeId $hosts) 'plain real Ready baseline succeeds' + $action=[pscustomobject][ordered]@{ActionId=[guid]::NewGuid();Command='SetLanguageEnglish';Revision=[uint64]1} + $world.FailAt='SetTrayPresentation' + $failed=Invoke-CcodSupervisorLanguageAction $hostState $fixture.Fake.Adapters $action 'en-US' + Assert-CcodEqual 'Failed' $failed.Status 'real language presentation failure reaches rollback log' + $world.FailAt=$null + $recovered=Invoke-CcodSupervisorLanguageAction $hostState $fixture.Fake.Adapters $action 'en-US' + Assert-CcodEqual 'Completed' $recovered.Status 'real language retry completes' + Assert-CcodTrue (Write-CcodSupervisorTrayHostReady $hostState $fixture.Fake.Adapters) 'actual Ready producer records recovered host' + $records=@([IO.File]::ReadAllLines($log)|ForEach-Object{$_|ConvertFrom-Json}) + Assert-CcodEqual 1 @($records|Where-Object{$_.stage-ceq'LanguageChange'-and$_.code-ceq'CCOD_LANGUAGE_CHANGE_ROLLED_BACK'}).Count 'target producer frame exists in history' + Assert-CcodTrue (&$integration {param($Root,$Runtime,$Hosts)Get-CcodInstalledLifecycleTrayHostReadyProof -InstallRoot $Root -RuntimeId $Runtime -TrayHost $Hosts} $root $hostState.Layout.RuntimeId $hosts) 'recovered language history does not poison current Ready' + foreach($event in @(@('ErrorDialog','CCOD_TRAY_ACTION_RESULT_FAILED'),@('TrayCallback','CCOD_TRAY_CALLBACK_FAILED'))){ + Write-CcodSupervisorUiFailure $hostState $fixture.Fake.Adapters $event[0] $event[1] + Assert-CcodTrue (&$integration {param($Root,$Runtime,$Hosts)Get-CcodInstalledLifecycleTrayHostReadyProof -InstallRoot $Root -RuntimeId $Runtime -TrayHost $Hosts} $root $hostState.Layout.RuntimeId $hosts) 'known UI failure producer remains well-formed history' + } + $good=[IO.File]::ReadAllText($log) + $bad=@($records|Where-Object{$_.stage-ceq'LanguageChange'})[0].PSObject.Copy();$bad.outcome='Completed' + [IO.File]::WriteAllText($log,($good+($bad|ConvertTo-Json -Compress)+[Environment]::NewLine)) + $errorId=$null;try{&$integration {param($Root,$Runtime,$Hosts)Get-CcodInstalledLifecycleTrayHostReadyProof -InstallRoot $Root -RuntimeId $Runtime -TrayHost $Hosts} $root $hostState.Layout.RuntimeId $hosts|Out-Null}catch{$errorId=$_.FullyQualifiedErrorId} + Assert-CcodTrue ($errorId-like'CCOD_INTEGRATION_OBSERVATION_UNAVAILABLE*') 'malformed UI history still rejects' + } finally { + if($null-ne$integration){Remove-Module $integration -Force -ErrorAction SilentlyContinue} + if(Test-Path -LiteralPath $root){Remove-Item -LiteralPath $root -Recurse -Force} + } +} + +Invoke-CcodTest 'ordinary terminal delivery requires a current lifecycle fence before publishing success' { + foreach($stale in @($false,$true)){ + $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host + $request=New-CcodPersistedLifecycleRequest -Kind CheckAndRepair + $request.phase='Completed';$request.error=$null + $hostState.LifecycleRequest=$request;$world.ActiveLifecycleRequest=$request + $action=[pscustomobject][ordered]@{ActionId=[guid]::NewGuid();Command='CheckAndRepair';Revision=[UInt64]1} + $entry=[pscustomobject][ordered]@{Action=$action;TransactionId=$request.transactionId;TerminalSent=$false} + $hostState.TrayActionIds[$action.ActionId.ToString('D')]=$entry + if($stale){$fixture.Fake.Adapters.AssertLifecycleFence={param($InstallRoot,$Ownership)$world.Calls.Add('Fence:Lost');throw 'CCOD_TEST_TERMINAL_FENCE_LOST'}.GetNewClosure()} + $failure=$null + try{Complete-CcodSupervisorLifecycleTerminal $hostState $fixture.Fake.Adapters|Out-Null}catch{$failure=$_.Exception.Message} + if(-not$stale){ + Assert-CcodEqual $null $failure 'valid ownership permits terminal completion' + Assert-CcodEqual 1 $world.TrayActionResults.Count 'valid control delivered one terminal result' + Assert-CcodEqual 'Completed' $world.TrayActionResults[0].Status 'valid control reports success' + Assert-CcodTrue $entry.TerminalSent 'valid control acknowledges terminal delivery' + Assert-CcodEqual 1 $world.CompletedLifecycleRequests.Count 'valid control commits completion' + }else{ + Assert-CcodEqual 'The supervisor adapter failed safely.' $failure 'negative preserves the safe adapter error' + Assert-CcodTrue ($world.Calls-contains'Fence:Lost') 'negative reaches actual fence refusal' + Assert-CcodEqual 0 $world.TrayActionResults.Count 'stale ownership publishes no terminal result' + Assert-CcodEqual $false $entry.TerminalSent 'stale ownership does not mark terminal delivery' + Assert-CcodEqual 0 $world.CompletedLifecycleRequests.Count 'stale ownership commits no completion' + Assert-CcodTrue ($null-ne$hostState.LifecycleRequest) 'stale request remains retained' + } + } +} + +Invoke-CcodTest 'ordinary lifecycle retains a terminal request when tray result delivery is not acknowledged' { + $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host + $request=New-CcodPersistedLifecycleRequest -Kind CheckAndRepair + $request.phase='Completed';$request.error=$null + $hostState.LifecycleRequest=$request;$world.ActiveLifecycleRequest=$request + $action=[pscustomobject][ordered]@{ActionId=[guid]'77777777-8888-9999-aaaa-bbbbbbbbbbbb';Command='CheckAndRepair';Revision=[UInt64]1} + $hostState.TrayActionIds[$action.ActionId.ToString('D')]=[pscustomobject][ordered]@{Action=$action;TransactionId=$request.transactionId;TerminalSent=$false} + $fixture.Fake.Adapters.SendTrayActionResult={param($Tray,$ActionId,$Revision,$Status,$ErrorCode,$TransactionId)$world.Calls.Add('ActionResult:Undelivered');return $false}.GetNewClosure() + Complete-CcodSupervisorLifecycleTerminal $hostState $fixture.Fake.Adapters + Assert-CcodEqual 0 $world.CompletedLifecycleRequests.Count 'undelivered ordinary terminal result does not complete the durable request' + Assert-CcodTrue ($null -ne $hostState.LifecycleRequest -and $hostState.LifecycleRequest.phase -ceq 'Completed') 'undelivered ordinary terminal result retains the terminal request for retry' + Assert-CcodEqual $false $hostState.TrayActionIds[$action.ActionId.ToString('D')].TerminalSent 'undelivered ordinary terminal result remains retryable' +} + Invoke-CcodTest 'SafeExit completes only after the terminal result is acknowledged' { # Production mutation caught: writing the completion receipt and deleting the active request before terminal action acknowledgement. $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host @@ -618,9 +796,67 @@ Invoke-CcodTest 'SafeExit fence loss stays running and records the stable recove Assert-CcodEqual 0 @($world.Calls|Where-Object { $_ -ceq 'Exit:UI' }).Count 'fence loss never requests UI exit' } +Invoke-CcodTest 'SafeExit recovery reasserts the lifecycle fence before persisting its failure' { + $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host + $request=New-CcodPersistedLifecycleRequest -Kind SafeExit + $transactionModule=Import-Module $lifecycleTransactionPath -Force -PassThru + $request=& $transactionModule {param($Value) Move-CcodLifecyclePhase -Request $Value -NextPhase CancelledBeforeClose -NowUtc '2030-02-03T03:04:06.0000000Z'} $request + $hostState.LifecycleRequest=$request;$world.ActiveLifecycleRequest=$request + $action=[pscustomobject][ordered]@{ActionId=[guid]::NewGuid();Command='Exit';Revision=[UInt64]1} + $hostState.TrayActionIds[$action.ActionId.ToString('D')]=[pscustomobject][ordered]@{Action=$action;TransactionId=$request.transactionId;TerminalSent=$false} + $world | Add-Member -NotePropertyName FenceCalls -NotePropertyValue 0 + $fixture.Fake.Adapters.AssertLifecycleFence={param($InstallRoot,$Ownership)$world.FenceCalls++;if($world.FenceCalls -ge 2){throw 'FENCE_LOST_AFTER_TERMINAL'};$true}.GetNewClosure() + Assert-CcodEqual 'SafeExit' $request.kind 'SafeExit recovery fixture kind' + Assert-CcodEqual 'CancelledBeforeClose' $request.phase 'SafeExit recovery fixture phase' + Assert-CcodEqual $true (Test-CcodSupervisorLifecycleTerminal $request.phase) 'SafeExit recovery fixture phase is terminal' + Assert-CcodEqual $null $request.error 'SafeExit recovery fixture has no terminal error' + Complete-CcodSupervisorLifecycleTerminal $hostState $fixture.Fake.Adapters + Assert-CcodTrue ($world.FenceCalls -ge 2) "SafeExit test reached the post-terminal fence (calls=$($world.FenceCalls))" + Assert-CcodEqual 0 @($world.Calls|Where-Object{$_ -like 'Write:Lifecycle:*'}).Count 'stale SafeExit recovery does not write without a revalidated fence' + Assert-CcodEqual 'Error' $hostState.SessionState 'stale SafeExit recovery enters bounded error state' + Assert-CcodTrue ($world.Calls -contains 'Log:CCOD_SUPERVISOR_SAFE_EXIT_RECOVERY_PERSIST_FAILED') 'stale SafeExit recovery records the persistence diagnostic' +} + +Invoke-CcodTest 'SafeExit Completed recovery remains disarmed on the next lifecycle tick' { + foreach($failure in @('None','WriteSafeExitIntent','SetTrayPresentation')){ + $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host + $request=New-CcodPersistedLifecycleRequest -Kind SafeExit + foreach($observation in @('Special','Closed','Ordinary','Ordinary')){ + $step=& $fixture.Fake.Adapters.GetLifecycleStep $request $observation '2030-02-03T03:04:05.0000000Z' + $request=& $fixture.Fake.Adapters.MoveLifecyclePhase $request $step.nextPhase '2030-02-03T03:04:05.0000000Z' + } + Assert-CcodEqual 'RemoteVerified' $request.phase 'real coordinator transitions reach the Completed predecessor' + $hostState.LifecycleRequest=$request;$world.ActiveLifecycleRequest=$request;$hostState.LifecycleObservation='Ordinary' + $action=[pscustomobject][ordered]@{ActionId=[guid]::NewGuid();Command='Exit';Revision=[UInt64]1} + $entry=[pscustomobject][ordered]@{Action=$action;TransactionId=$request.transactionId;TerminalSent=$false} + $hostState.TrayActionIds[$action.ActionId.ToString('D')]=$entry + $world.FailAt=$failure + Invoke-CcodSupervisorDriveLifecycle $hostState $fixture.Fake.Adapters|Out-Null + Assert-CcodTrue ($world.LifecycleMoves-contains'Completed') 'real drive reaches Completed' + if($failure-ceq'None'){ + Assert-CcodTrue $hostState.ShutdownRequested 'successful Completed control permits shutdown' + Assert-CcodEqual 1 @($world.Calls|Where-Object{$_-ceq'Exit:UI'}).Count 'successful control exits UI once' + Assert-CcodEqual 'Completed' $world.TrayActionResults[0].Status 'successful control sends Completed' + continue + } + Assert-CcodEqual 'Completed' $hostState.LifecycleRequest.phase 'failed recovery retains the actual terminal phase' + Assert-CcodEqual 'SAFE_EXIT_RECOVERY_FAILED' $hostState.LifecycleRequest.error 'first tick durably disarms SafeExit' + Assert-CcodEqual 0 $world.TrayActionResults.Count 'failure occurs before terminal delivery' + Assert-CcodEqual 0 @($world.Calls|Where-Object{$_-ceq'Exit:UI'}).Count 'first tick never exits' + $markerAttempts=@($world.Calls|Where-Object{$_-ceq'Write:SafeExitIntent'}).Count + $world.FailAt=$null + Invoke-CcodSupervisorDriveLifecycle $hostState $fixture.Fake.Adapters|Out-Null + Assert-CcodEqual 0 @($world.Calls|Where-Object{$_-ceq'Exit:UI'}).Count 'recovered adapter cannot rearm a failed Completed SafeExit' + Assert-CcodEqual $false $hostState.ShutdownRequested 'second tick keeps protection running' + Assert-CcodEqual $markerAttempts @($world.Calls|Where-Object{$_-ceq'Write:SafeExitIntent'}).Count 'second tick does not retry successful exit intent' + Assert-CcodEqual 'Failed' $world.TrayActionResults[0].Status 'second tick reports failure instead of success' + Assert-CcodEqual 'SAFE_EXIT_RECOVERY_FAILED' $world.CompletedLifecycleRequests[0].error 'completed failure remains disarmed in durable output' + } +} + Invoke-CcodTest 'SafeExit marker presentation and completion failures are durably disarmed' { # Production mutations caught: allowing any pre-terminal boundary failure to retain an armed successful SafeExit request. - foreach($failure in @('WriteSafeExitIntent','SetTrayPresentation','CompleteLifecycleRequest')){ + foreach($failure in @('WriteSafeExitIntent','SetTrayPresentation','CompleteLifecycleRequest','WriteLifecycleRequest')){ $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host $request=& (Get-Module LifecycleTransaction | Select-Object -First 1) { param($RuntimeId,$Generation,$Epoch,$Owner,$Logon) @@ -631,10 +867,12 @@ Invoke-CcodTest 'SafeExit marker presentation and completion failures are durabl if($failure-ceq'WriteSafeExitIntent'){$fixture.Fake.Adapters.WriteSafeExitIntent={param($a,$b,$c,$d,$e)throw 'fail'}.GetNewClosure()} if($failure-ceq'SetTrayPresentation'){$fixture.Fake.Adapters.SetTrayPresentation={param($a,$b,$c,$d,$e,$f)throw 'fail'}.GetNewClosure()} if($failure-ceq'CompleteLifecycleRequest'){$fixture.Fake.Adapters.CompleteLifecycleRequest={param($a,$b)throw 'fail'}.GetNewClosure()} + if($failure-ceq'WriteLifecycleRequest'){$world.FailAt='WriteLifecycleRequest';$fixture.Fake.Adapters.SetTrayPresentation={param($a,$b,$c,$d,$e,$f)throw 'fail'}.GetNewClosure()} Complete-CcodSupervisorLifecycleTerminal $hostState $fixture.Fake.Adapters Assert-CcodEqual 'Running' $hostState.ProtectionState "$failure keeps protection running" Assert-CcodEqual 'SAFE_EXIT_RECOVERY_FAILED' $hostState.LifecycleRequest.error "$failure stores a stable disarmed SafeExit error" Assert-CcodEqual $(if($failure-ceq'CompleteLifecycleRequest'){1}else{0}) @($world.Calls|Where-Object{$_-ceq'Exit:UI'}).Count "$failure has the expected pre-completion UI boundary" + if($failure-ceq'WriteLifecycleRequest'){Assert-CcodTrue ($hostState.RuntimeCleanupCodes -contains 'CCOD_SUPERVISOR_SAFE_EXIT_RECOVERY_PERSIST_FAILED') 'SafeExit lifecycle persistence failure is retained as a bounded diagnostic'} } } @@ -713,6 +951,14 @@ Invoke-CcodTest 'accepts at most one of two simultaneous durable submissions and Assert-CcodEqual 0 @($fake.World.Calls|Where-Object{$_ -like 'Start:Controller:*'}).Count 'submission acceptance never dispatches legacy controller mutation' } +Invoke-CcodTest 'rejects lifecycle submissions with coercible runtime generations' { + foreach($badGeneration in @('7',[double]7,[decimal]7)){ + $fake=New-CcodSupervisorFake + $submission=[pscustomobject][ordered]@{schemaVersion=1;submissionId='bbbbbbbb-cccc-dddd-eeee-ffffffffffff';kind='RestartAndRepair';origin='Installer';runtimeId='runtime-1';runtimeGeneration=$badGeneration;createdAtUtc='2030-02-03T03:04:05.0000000Z'} + Assert-CcodTrue (-not (Test-CcodSupervisorLifecycleSubmission $submission)) "submission generation type $($badGeneration.GetType().FullName) is rejected before coercion" + } +} + Invoke-CcodTest 'rejects a durable submission for a stale runtime generation without creating a transaction' { $fake=New-CcodSupervisorFake $fake.World.LifecycleSubmissions.Enqueue([pscustomobject][ordered]@{schemaVersion=1;submissionId='cccccccc-dddd-eeee-ffff-000000000000';kind='RestartAndRepair';origin='Installer';runtimeId='runtime-1';runtimeGeneration=[UInt64]6;createdAtUtc='2030-02-03T03:04:05.0000000Z'}) @@ -752,6 +998,30 @@ Invoke-CcodTest 'resumes WaitingForManualLaunch after Supervisor restart and com Assert-CcodEqual 0 @($fake.World.Calls|Where-Object{$_ -like 'Start:Controller:*'}).Count 'resume performs no mutation outside LifecycleWorker slot' } +Invoke-CcodTest 'terminal lifecycle results preserve output-free tray ticks and normal shutdown' { + $fake=New-CcodSupervisorFake + $fake.World.ActiveLifecycleRequest=New-CcodPersistedLifecycleRequest -Kind CheckAndRepair + $special=New-CcodSupervisorTestSnapshot -ProcessId 201 -CreationTimeUtc '2030-02-03T03:02:00.0000000Z' + $special.Mode='Special';$special.RendererPort=[int]41001;$special.MainPort=[int]41002 + $fake.World.ProcessIds=@([int]201);$fake.World.Snapshots[[int]201]=$special + $fake.World.AutoCompleteLifecycleWorkers=$true;$fake.World.TickCount=4 + $receipt=Invoke-CcodSupervisorHost -ReadyToken $readyToken -Adapters $fake.Adapters + Assert-CcodEqual 1 $fake.World.CompletedLifecycleRequests.Count 'real lifecycle reduction reaches its terminal callback before shutdown' + Assert-CcodEqual 'Inspect' (@($fake.World.StartedLifecycleRequests.action)-join '|') 'the worker-poll completion path was reached' + Assert-CcodReceipt $receipt 'Stopped' 0 + foreach($phase in @('Completed','RemoteVerified')){ + $fixture=New-CcodTickFixture + $request=New-CcodPersistedLifecycleRequest -Kind CheckAndRepair + $request.phase=$phase;$request.error=$null + $fixture.Host.LifecycleRequest=$request;$fixture.Fake.World.ActiveLifecycleRequest=$request + $fixture.Host.LifecycleObservation='RemoteVerified' + $routing=@(Invoke-CcodSupervisorDriveLifecycle $fixture.Host $fixture.Fake.Adapters) + Assert-CcodEqual 1 $routing.Count "the $phase drive branch returns only its routing result" + Assert-CcodTrue ($routing[0] -is [bool] -and $routing[0]) 'the routing result remains one true Boolean' + Assert-CcodEqual 1 $fixture.Fake.World.CompletedLifecycleRequests.Count 'the drive branch completes its request exactly once' + } +} + Invoke-CcodTest 'supervisor restart performs one strong Inspect before adopting a persisted special session' { $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host $special=New-CcodSupervisorTestSnapshot -ProcessId 201 -CreationTimeUtc '2030-02-03T03:02:00.0000000Z' @@ -811,6 +1081,114 @@ Invoke-CcodTest 'successful lifecycle verification binds a fresh special proof b Assert-CcodEqual $null $hostState.LifecycleRequest 'later guardian decision creates no redundant repair lifecycle' } +function Import-CcodTrayHostCorrelationTestBridge { + if($null-ne('TrayHostCorrelationTestBridge' -as[type])){return} + $trayRoot=Join-Path $repositoryRoot 'src\trayhost' + Add-Type -Path @( + (Join-Path $trayRoot 'PresentationSnapshot.cs'), + (Join-Path $trayRoot 'PipeProtocol.cs'), + (Join-Path $trayRoot 'TransportMessages.cs'), + (Join-Path $trayRoot 'TrayTerminalDiagnostic.cs'), + (Join-Path $trayRoot 'TrayTerminalReceiptSink.cs'), + (Join-Path $trayRoot 'NativeMethods.cs'), + (Join-Path $trayRoot 'InputModeGuard.cs'), + (Join-Path $trayRoot 'NativeMenu.cs'), + (Join-Path $trayRoot 'TrayWindow.cs'), + (Join-Path $trayRoot 'TrayHostApplication.cs'), + (Join-Path $trayRoot 'HostTransport.cs'), + (Join-Path $repositoryRoot 'tests\trayhost\TrayHostCorrelationTestBridge.cs') + ) +} + +Invoke-CcodTest 'post-worker rebind retries one transient missing enumeration for the same exact candidate' { + $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host + $transactionModule=Import-Module $lifecycleTransactionPath -Force -PassThru + $request=New-CcodPersistedLifecycleRequest -Kind RestartAndRepair + foreach($phase in @('CloseRequested','CloseConfirmed','RepairRequested')){ + $request=& $transactionModule {param($Value,$NextPhase)Move-CcodLifecyclePhase -Request $Value -NextPhase $NextPhase -NowUtc '2030-02-03T03:01:05.0000000Z'} $request $phase + } + $request.ownerIdentity=$hostState.LifecycleOwnership.ownerIdentity;$request.leaseEpoch=[UInt64]$hostState.LifecycleOwnership.epoch + $world.ActiveLifecycleRequest=$request;$hostState.LifecycleRequest=$request;$world.AutoCompleteLifecycleWorkers=$true + $special=New-CcodSupervisorTestSnapshot -ProcessId 201 -CreationTimeUtc '2030-02-03T03:02:00.0000000Z' + $special.Mode='Special';$special.RendererPort=[int]41001;$special.MainPort=[int]41002 + $world.ProcessIds=@(201);$world.Snapshots[201]=$special + $fixture.Fake.Adapters.ReadState={param($StateRoot,$SuppressionKey)$hostState.State}.GetNewClosure() + $enumerations=[pscustomobject]@{Count=0};$delays=[Collections.Generic.List[int]]::new() + $fixture.Fake.Adapters.Delay={param($Milliseconds)$delays.Add([int]$Milliseconds)}.GetNewClosure() + $fixture.Fake.Adapters.EnumerateProcessIds={ + $enumerations.Count++ + if($enumerations.Count-eq2){Write-Output -NoEnumerate @();return} + Write-Output -NoEnumerate @(201) + }.GetNewClosure() + + Start-CcodSupervisorLifecycleWorkerSlot $hostState $fixture.Fake.Adapters VerifyRemote $null|Out-Null + Invoke-CcodSupervisorPollLifecycleSlot $hostState $fixture.Fake.Adapters + + Assert-CcodEqual 3 $enumerations.Count 'one transient empty rebind enumeration receives exactly one bounded retry' + Assert-CcodEqual '50' (($delays)-join ',') 'same-candidate retry enters one bounded condition-based delay' + Assert-CcodTrue ($null-ne$hostState.SpecialProof) 'same exact candidate is rebound after transient enumeration churn' + Assert-CcodEqual 201 $hostState.SpecialProof.Pid 'rebound proof retains the original candidate PID' + Assert-CcodEqual $special.CreationTimeUtc $hostState.SpecialProof.CreationTimeUtc 'rebound proof retains the original candidate creation time' + Assert-CcodEqual 'RemoteVerified' $hostState.LifecycleObservation 'same-candidate retry publishes RemoteVerified' +} + +Invoke-CcodTest 'post-worker rebind does not retry a transient enumeration after candidate creation-time drift' { + $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host + $transactionModule=Import-Module $lifecycleTransactionPath -Force -PassThru + $request=New-CcodPersistedLifecycleRequest -Kind RestartAndRepair + foreach($phase in @('CloseRequested','CloseConfirmed','RepairRequested')){ + $request=& $transactionModule {param($Value,$NextPhase)Move-CcodLifecyclePhase -Request $Value -NextPhase $NextPhase -NowUtc '2030-02-03T03:01:05.0000000Z'} $request $phase + } + $request.ownerIdentity=$hostState.LifecycleOwnership.ownerIdentity;$request.leaseEpoch=[UInt64]$hostState.LifecycleOwnership.epoch + $world.ActiveLifecycleRequest=$request;$hostState.LifecycleRequest=$request;$world.AutoCompleteLifecycleWorkers=$true + $special=New-CcodSupervisorTestSnapshot -ProcessId 201 -CreationTimeUtc '2030-02-03T03:02:00.0000000Z' + $special.Mode='Special';$special.RendererPort=[int]41001;$special.MainPort=[int]41002 + $drift=$special.PSObject.Copy();$drift.CreationTimeUtc='2030-02-03T03:02:01.0000000Z' + $world.ProcessIds=@(201);$world.Snapshots[201]=$special + $fixture.Fake.Adapters.ReadState={param($StateRoot,$SuppressionKey)$hostState.State}.GetNewClosure() + $enumerations=[pscustomobject]@{Count=0};$delays=[Collections.Generic.List[int]]::new() + $fixture.Fake.Adapters.Delay={param($Milliseconds)$delays.Add([int]$Milliseconds)}.GetNewClosure() + $fixture.Fake.Adapters.EnumerateProcessIds={ + $enumerations.Count++ + if($enumerations.Count-eq1){Write-Output -NoEnumerate @(201);return} + $world.Snapshots[201]=$drift + Write-Output -NoEnumerate @() + }.GetNewClosure() + + Start-CcodSupervisorLifecycleWorkerSlot $hostState $fixture.Fake.Adapters VerifyRemote $null|Out-Null + Invoke-CcodSupervisorPollLifecycleSlot $hostState $fixture.Fake.Adapters + + Assert-CcodEqual 2 $enumerations.Count 'changed candidate receives no post-drift retry' + Assert-CcodEqual 0 $delays.Count 'changed candidate never enters the rebind delay' + Assert-CcodEqual 1 $world.CompletedLifecycleRequests.Count 'candidate drift produces one terminal lifecycle receipt' + Assert-CcodEqual 'CCOD_REMOTE_PROOF_REBIND_FAILED' $world.CompletedLifecycleRequests[0].error 'candidate drift keeps the stable rebind failure reason' + Assert-CcodEqual $null $hostState.SpecialProof 'changed candidate never receives proof authority' +} + +Invoke-CcodTest 'candidate port or mode drift never enters the bounded rebind delay' { + foreach($case in @( + @{Name='port';Mutate={param($Snapshot)$Snapshot.RendererPort=[int]42001;$Snapshot}}, + @{Name='mode';Mutate={param($Snapshot)$Snapshot.Mode='Ordinary';$Snapshot.RendererPort=$null;$Snapshot.MainPort=$null;$Snapshot}} + )){ + $fixture=New-CcodTickFixture;$hostState=$fixture.Host + $candidate=New-CcodSupervisorTestSnapshot -ProcessId 201 -CreationTimeUtc '2030-02-03T03:02:00.0000000Z' + $candidate.Mode='Special';$candidate.RendererPort=[int]41001;$candidate.MainPort=[int]41002 + $changed=& $case.Mutate $candidate.PSObject.Copy() + $delays=[Collections.Generic.List[int]]::new() + $fixture.Fake.Adapters.ReadState={param($StateRoot,$SuppressionKey)$hostState.State}.GetNewClosure() + $fixture.Fake.Adapters.EnumerateProcessIds={Write-Output -NoEnumerate @()} + $fixture.Fake.Adapters.GetProcessSnapshot={param($ProcessId,$StatusEvidence)$changed}.GetNewClosure() + $fixture.Fake.Adapters.Delay={param($Milliseconds)$delays.Add([int]$Milliseconds)}.GetNewClosure() + + $confirmed=Confirm-CcodSupervisorLifecycleProofCandidate $hostState $fixture.Fake.Adapters $candidate + + Assert-CcodEqual $false $confirmed "$($case.Name) drift cannot confirm the candidate" + Assert-CcodEqual 0 $delays.Count "$($case.Name) drift never enters the retry delay" + Assert-CcodEqual $null $hostState.SpecialProof "$($case.Name) drift receives no proof authority" + Assert-CcodEqual ('201|'+$candidate.CreationTimeUtc) $hostState.FailedSpecialProofKey "$($case.Name) drift records the original stable failure key" + } +} + Invoke-CcodTest 'post-verify identity drift becomes one terminal failure and suppresses same-root guardian retries' { $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host $transactionModule=Import-Module $lifecycleTransactionPath -Force -PassThru @@ -917,6 +1295,40 @@ Invoke-CcodTest 'ordinary observation uses the durable transaction start as its Assert-CcodTrue ($workerRequest.notBeforeUtc -cne '2030-02-03T03:04:05.0000000Z') 'observation does not move the lower bound to the current worker tick' } +Invoke-CcodTest 'rejects malformed renderer handoff receipts instead of accepting partial identity' { + $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host + $slot=[pscustomobject][ordered]@{Action='Apply'} + $result=[pscustomobject][ordered]@{ok=$true;safeState='SpecialValidated';special=[pscustomobject][ordered]@{rendererPort=[int]41002}} + $fixture.Fake.Adapters.HandoffRenderer={param($WorkerResult,$RendererPort)[pscustomobject][ordered]@{Outcome='Started';Code='not-canonical';ProcessId=$null}}.GetNewClosure() + Invoke-CcodSupervisorRendererHandoff $hostState $slot $result $fixture.Fake.Adapters + Assert-CcodTrue (@($world.UiFailureRecords|Where-Object{$_.code -ceq 'CCOD_RENDERER_HANDOFF_FAILED'}).Count -eq 1) 'malformed renderer receipt produces a correlated handoff failure' +} + +Invoke-CcodTest 'rejects lifecycle worker poll frames with non-boolean overflow or negative byte counts' { + foreach($case in @( + [pscustomobject]@{StdoutByteCount=[int]-1;StdoutOverflow=$false;StderrByteCount=[int]0;StderrOverflow=$false}, + [pscustomobject]@{StdoutByteCount=[int]0;StdoutOverflow=[int]0;StderrByteCount=[int]0;StderrOverflow=$false}, + [pscustomobject]@{StdoutByteCount=[int]0;StdoutOverflow=$false;StderrByteCount=[int]-1;StderrOverflow=$false}, + [pscustomobject]@{StdoutByteCount=[int]0;StdoutOverflow=$false;StderrByteCount=[int]0;StderrOverflow=[int]0} + )){ + $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host + $transactionModule=Import-Module $lifecycleTransactionPath -Force -PassThru + $request=New-CcodPersistedLifecycleRequest -Kind RestartAndRepair + foreach($phase in @('CloseRequested','CloseConfirmed','RepairRequested')){ + $request=& $transactionModule {param($Value,$NextPhase) Move-CcodLifecyclePhase -Request $Value -NextPhase $NextPhase -NowUtc '2030-02-03T03:01:05.0000000Z'} $request $phase + } + $world.ActiveLifecycleRequest=$request;$hostState.LifecycleRequest=$request + Start-CcodSupervisorLifecycleWorkerSlot $hostState $fixture.Fake.Adapters 'Apply' $null | Out-Null + $world.WorkerResult=[pscustomobject][ordered]@{schemaVersion=1;transactionId=$request.transactionId;action='Apply';ok=$true;outcome='Activated';observation='Special';error=$null} + $json=$world.WorkerResult|ConvertTo-Json -Depth 8 -Compress + $world.Poll=[pscustomobject][ordered]@{Completed=$true;ExitCode=[int]0;StdoutText=$json;StdoutByteCount=$case.StdoutByteCount;StdoutOverflow=$case.StdoutOverflow;StderrByteCount=$case.StderrByteCount;StderrOverflow=$case.StderrOverflow} + $failure=$null + try{Invoke-CcodSupervisorPollLifecycleSlot $hostState $fixture.Fake.Adapters}catch{$failure=$_} + Assert-CcodTrue ($null-ne$failure) 'malformed lifecycle poll framing is rejected before result reduction' + Assert-CcodEqual 0 @($world.Calls|Where-Object{$_ -eq 'Reduce:Lifecycle:Apply'}).Count 'malformed lifecycle poll framing is never reduced' + } +} + Invoke-CcodTest 'rejects a nonzero lifecycle worker exit even when its result frame claims success' { $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host $transactionModule=Import-Module $lifecycleTransactionPath -Force -PassThru @@ -938,6 +1350,43 @@ Invoke-CcodTest 'rejects a nonzero lifecycle worker exit even when its result fr Assert-CcodEqual 1 $world.LifecycleOwnershipResumes 'Supervisor safely reacquires ownership before rejecting the frame' } +Invoke-CcodTest 'retains a lifecycle worker slot when malformed framing cannot prove termination' { + $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host + $transactionModule=Import-Module $lifecycleTransactionPath -Force -PassThru + $request=New-CcodPersistedLifecycleRequest -Kind RestartAndRepair + foreach($phase in @('CloseRequested','CloseConfirmed','RepairRequested')){$request=& $transactionModule {param($Value,$NextPhase) Move-CcodLifecyclePhase -Request $Value -NextPhase $NextPhase -NowUtc '2030-02-03T03:01:05.0000000Z'} $request $phase} + $world.ActiveLifecycleRequest=$request;$hostState.LifecycleRequest=$request + Start-CcodSupervisorLifecycleWorkerSlot $hostState $fixture.Fake.Adapters 'Apply' $null | Out-Null + $world.WorkerWaitResults.Enqueue($false);$world.WorkerWaitResults.Enqueue($false);$world.WorkerWaitResults.Enqueue($false);$world.WorkerTerminateResults.Enqueue($false);$world.WorkerTerminateResults.Enqueue($false) + $world.Poll=[pscustomobject][ordered]@{Completed='invalid';ExitCode=[int]0;StdoutText='';StdoutByteCount=[int]0;StdoutOverflow=$false;StderrByteCount=[int]0;StderrOverflow=$false} + try { Invoke-CcodSupervisorPollLifecycleSlot $hostState $fixture.Fake.Adapters } catch { } + Assert-CcodTrue ($null -ne $hostState.LifecycleWorkerSlot) 'malformed framing retains a live worker slot when termination is unproven' + Assert-CcodEqual 0 @($world.Calls|Where-Object{$_ -like 'Dispose:*'}).Count 'unproven worker is not disposed as if terminated' + Assert-CcodTrue ($hostState.ConnectionState -ceq 'Error') 'unproven malformed worker enters bounded error state' +} + +Invoke-CcodTest 'rejects an empty completed Controller frame instead of silently clearing the result' { + $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host + Start-CcodSupervisorWorkerSlot $hostState $fixture.Fake.Adapters Controller Inspect $null | Out-Null + $world.Poll=[pscustomobject][ordered]@{Completed=$true;ExitCode=[int]0;StdoutText='';StdoutByteCount=[int]0;StdoutOverflow=$false;StderrByteCount=[int]0;StderrOverflow=$false} + Invoke-CcodSupervisorPollSlot $hostState $fixture.Fake.Adapters + Assert-CcodEqual 'Error' $hostState.SessionState 'empty completed Controller output enters error state' + Assert-CcodTrue $hostState.BlockAutomaticActions 'empty completed Controller output blocks automatic actions' + Assert-CcodEqual 0 @($world.Calls|Where-Object{$_ -like 'Reduce:Inspect'}).Count 'empty Controller output never reaches the reducer' +} + +Invoke-CcodTest 'rejects a Controller exit-code and result-ok mismatch before reduction' { + $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host + Start-CcodSupervisorWorkerSlot $hostState $fixture.Fake.Adapters Controller Inspect $null | Out-Null + $world.WorkerResult=[pscustomobject][ordered]@{schemaVersion=1;transactionId='11111111-2222-3333-4444-555555555555';action='Inspect';ok=$true;outcome='Inspected';observation='RemoteVerified';error=$null} + $json=$world.WorkerResult|ConvertTo-Json -Depth 8 -Compress + $world.Poll=[pscustomobject][ordered]@{Completed=$true;ExitCode=[int]1;StdoutText=$json;StdoutByteCount=[Text.Encoding]::UTF8.GetByteCount($json);StdoutOverflow=$false;StderrByteCount=[int]0;StderrOverflow=$false} + Invoke-CcodSupervisorPollSlot $hostState $fixture.Fake.Adapters + Assert-CcodEqual 'Error' $hostState.SessionState 'Controller exit/result mismatch enters error state' + Assert-CcodTrue $hostState.BlockAutomaticActions 'Controller exit/result mismatch blocks automatic actions' + Assert-CcodEqual 0 @($world.Calls|Where-Object{$_ -like 'Reduce:Inspect'}).Count 'Controller exit/result mismatch never reaches the reducer' +} + Invoke-CcodTest 'reclaims exact stale lifecycle worker framing left by a killed prior Supervisor job' { $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host $request=New-CcodPersistedLifecycleRequest -Kind RestartAndRepair @@ -1227,7 +1676,7 @@ Invoke-CcodTest 'bounds stale-package special reconciliation to one live process }.GetNewClosure() $world.WorkerResult=[pscustomobject][ordered]@{ok=$false;outcome='Error';safeState='Error';special=$null} $stdout=$world.WorkerResult|ConvertTo-Json -Depth 20 -Compress - $world.Poll=[pscustomobject][ordered]@{Completed=$true;ExitCode=[int]0;StdoutText=$stdout;StdoutByteCount=[int]$stdout.Length;StdoutOverflow=$false;StderrByteCount=[int]0;StderrOverflow=$false} + $world.Poll=[pscustomobject][ordered]@{Completed=$true;ExitCode=[int]1;StdoutText=$stdout;StdoutByteCount=[int]$stdout.Length;StdoutOverflow=$false;StderrByteCount=[int]0;StderrOverflow=$false} Start-CcodSupervisorWorkerSlot $hostState $fixture.Fake.Adapters 'Controller' 'Inspect' $null|Out-Null Invoke-CcodSupervisorPollSlot $hostState $fixture.Fake.Adapters Assert-CcodEqual 'StalePackageStatus' $hostState.Reason 'stale worker result exposes only the fixed reason' @@ -1476,6 +1925,155 @@ Invoke-CcodTest 'authorizes an acknowledged displayed revision after a newer cap Assert-CcodEqual 'Accepted' $result.Status 'the displayed and acknowledged revision remains action authority after a newer capable projection' } +Invoke-CcodTest 'records an acknowledged OpenLogs terminal outcome after its handler and before result delivery' { + # Production mutation caught: omitting the sanitized terminal record, logging before the handler, or bypassing the revision-bound OpenLogs handler. + $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host + $hostState.Tray.CurrentRevision=[UInt64]7 + $enabled=[pscustomobject][ordered]@{RepairEnabled=$true;LanguageEnabled=$true;OpenLogsEnabled=$true;AboutEnabled=$true;ExitEnabled=$true} + $hostState.Tray.AcknowledgedPresentations['7']=$enabled + $hostState.LastAcknowledgedPresentation=$enabled + $action=[pscustomobject][ordered]@{ActionId=[guid]'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeee71';Command='OpenLogs';Revision=[UInt64]7} + + $result=@(Invoke-CcodSupervisorCommand $hostState $fixture.Fake.Adapters $action)[0] + + Assert-CcodEqual 'Completed' $result.Status 'acknowledged revision reaches the OpenLogs handler' + Assert-CcodEqual 1 @($world.Calls|Where-Object{$_ -eq 'Open:Logs'}).Count 'current acknowledged action invokes OpenLogs exactly once' + Assert-CcodEqual 1 $world.UiFailureRecords.Count 'terminal action writes exactly one local diagnostic record' + $record=$world.UiFailureRecords[0] + foreach($field in @('command','revision','status','code')){Assert-CcodTrue ($null-ne$record.PSObject.Properties[$field]) "terminal diagnostic includes required field $field"} + foreach($field in @('ActionId','TransactionId','path','token','exception','message','raw')){Assert-CcodTrue ($null-eq$record.PSObject.Properties[$field]) "terminal diagnostic excludes unsafe field $field"} + Assert-CcodEqual 'Supervisor' $record.component 'terminal diagnostic identifies the authorizing component' + Assert-CcodEqual 'TrayAction' $record.stage 'terminal diagnostic uses the tray action stage' + Assert-CcodEqual 'CCOD_TRAY_ACTION_COMPLETED' $record.code 'successful terminal diagnostic uses a safe canonical code' + Assert-CcodEqual 'Completed' $record.outcome 'terminal diagnostic outcome is completed' + Assert-CcodEqual 'OpenLogs' $record.command 'terminal diagnostic preserves the exact command' + Assert-CcodEqual ([UInt64]7) $record.revision 'terminal diagnostic preserves the displayed presentation revision' + Assert-CcodEqual 'Completed' $record.status 'terminal diagnostic preserves the exact terminal status' + $calls=@($world.Calls) + Assert-CcodTrue ([Array]::IndexOf($calls,'Open:Logs') -lt [Array]::IndexOf($calls,'Log:CCOD_TRAY_ACTION_COMPLETED')) 'handler side effect precedes terminal logging' + Assert-CcodTrue ([Array]::IndexOf($calls,'Log:CCOD_TRAY_ACTION_COMPLETED') -lt [Array]::IndexOf($calls,'ActionResult:Completed')) 'terminal logging precedes host result delivery' +} + +Invoke-CcodTest 'rejects and records an unacknowledged OpenLogs revision before any handler side effect' { + # Production mutation caught: authorizing an unacknowledged revision, invoking OpenLogs before the gate, or losing the stable stale diagnostic. + $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host + $hostState.Tray.CurrentRevision=[UInt64]8 + $action=[pscustomobject][ordered]@{ActionId=[guid]'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeee81';Command='OpenLogs';Revision=[UInt64]8} + + $result=@(Invoke-CcodSupervisorCommand $hostState $fixture.Fake.Adapters $action)[0] + + Assert-CcodEqual 'Rejected' $result.Status 'unacknowledged revision is rejected' + Assert-CcodEqual 'CCOD_TRAY_ACTION_STALE' $result.ErrorCode 'unacknowledged revision returns the stable stale code' + Assert-CcodEqual 0 @($world.Calls|Where-Object{$_ -eq 'Open:Logs'}).Count 'stale revision is rejected before the OpenLogs handler' + Assert-CcodEqual 1 $world.UiFailureRecords.Count 'stale action writes exactly one local diagnostic record' + $record=$world.UiFailureRecords[0] + foreach($field in @('command','revision','status','code')){Assert-CcodTrue ($null-ne$record.PSObject.Properties[$field]) "stale diagnostic includes required field $field"} + foreach($field in @('ActionId','TransactionId','path','token','exception','message','raw')){Assert-CcodTrue ($null-eq$record.PSObject.Properties[$field]) "stale diagnostic excludes unsafe field $field"} + Assert-CcodEqual 'CCOD_TRAY_ACTION_STALE' $record.code 'stale diagnostic preserves the exact canonical code' + Assert-CcodEqual 'OpenLogs' $record.command 'stale diagnostic preserves the rejected command' + Assert-CcodEqual ([UInt64]8) $record.revision 'stale diagnostic preserves the unacknowledged revision' + Assert-CcodEqual 'Rejected' $record.status 'stale diagnostic preserves the rejected terminal status' + $calls=@($world.Calls) + Assert-CcodTrue ([Array]::IndexOf($calls,'Log:CCOD_TRAY_ACTION_STALE') -lt [Array]::IndexOf($calls,'ActionResult:Rejected')) 'stale diagnostic is written before generic failure feedback can be queued' +} + +Invoke-CcodTest 'sanitizes a malformed terminal action code before local logging' { + # Production mutation caught: allowing a newline or other noncanonical text into the local terminal diagnostic record. + $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host + $action=[pscustomobject][ordered]@{ActionId=[guid]'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeee91';Command='OpenLogs';Revision=[UInt64]1} + + [void](Send-CcodSupervisorTrayActionResult $hostState $fixture.Fake.Adapters $action Rejected "CCOD_INJECTED`n" $null) + + Assert-CcodEqual 1 $world.UiFailureRecords.Count 'malformed terminal code still produces one safe diagnostic' + Assert-CcodEqual 'CCOD_TRAY_ACTION_FAILED' $world.UiFailureRecords[0].code 'malformed terminal code is replaced by the canonical fallback' +} + +Invoke-CcodTest 'does not queue generic terminal feedback when its local diagnostic write fails' { + # Production mutation caught: stranding a pending HostTransport action when Supervisor logging fails instead of using the authenticated host-side diagnostic gate. + Import-CcodTrayHostCorrelationTestBridge + $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host;$world.FailAt='WriteLog' + $bridge=[TrayHostCorrelationTestBridge]::new([UInt64]2,[TrayCommand]::OpenLogs,$false) + try{ + $world|Add-Member -NotePropertyName Bridge -NotePropertyValue $bridge + $fixture.Fake.Adapters.SendTrayActionResult={ + param($Tray,$ActionId,$Revision,$Status,$ErrorCode,$TransactionId) + $world.TrayActionResults.Add([pscustomobject][ordered]@{ActionId=$ActionId;Revision=[UInt64]$Revision;Status=$Status;ErrorCode=$ErrorCode;TransactionId=$TransactionId}) + return $world.Bridge.Acknowledge($ActionId,[UInt64]$Revision,$Status,$ErrorCode) + }.GetNewClosure() + foreach($index in 1..10){ + $bridge.SelectFromNativeMenu() + $action=[pscustomobject][ordered]@{ActionId=$bridge.ActionId;Command=$bridge.Command;Revision=[UInt64]$bridge.Revision} + $result=@(Invoke-CcodSupervisorCommand $hostState $fixture.Fake.Adapters $action)[0] + Assert-CcodEqual 'Rejected' $result.Status "log-failure stale action $index retains its Supervisor status" + Assert-CcodEqual $true $result.Delivered "log-failure stale action $index is terminally accepted by HostTransport" + } + Assert-CcodEqual 0 $bridge.FeedbackCount 'host-side diagnostic failures suppress every generic feedback item' + Assert-CcodTrue ($hostState.RuntimeCleanupCodes.Contains('CCOD_SUPERVISOR_LOG_FAILED')) 'Supervisor log failure remains visible in the cleanup receipt' + + $world.FailAt=$null;$bridge.SetDiagnosticPersistence($true) + $enabled=[pscustomobject][ordered]@{RepairEnabled=$true;LanguageEnabled=$true;OpenLogsEnabled=$true;AboutEnabled=$true;ExitEnabled=$true} + $hostState.Tray.AcknowledgedPresentations['2']=$enabled;$hostState.LastAcknowledgedPresentation=$enabled + $bridge.SelectFromNativeMenu() + $recovered=[pscustomobject][ordered]@{ActionId=$bridge.ActionId;Command=$bridge.Command;Revision=[UInt64]$bridge.Revision} + $result=@(Invoke-CcodSupervisorCommand $hostState $fixture.Fake.Adapters $recovered)[0] + Assert-CcodEqual 'Completed' $result.Status 'new command completes after both diagnostic paths recover' + Assert-CcodEqual $true $result.Delivered 'recovered HostTransport acknowledges the new completed result' + Assert-CcodEqual 1 @($world.Calls|Where-Object{$_ -eq 'Open:Logs'}).Count 'recovered new command reaches its handler exactly once' + }finally{$bridge.Dispose()} +} + +Invoke-CcodTest 'correlates native menu actions through HostTransport Supervisor ACK authorization and result ACK' { + # Production mutation caught: disconnecting native revision selection, Supervisor acknowledgement authority, or the exact HostTransport terminal ACK. + Import-CcodTrayHostCorrelationTestBridge + $enabled=[pscustomobject][ordered]@{RepairEnabled=$true;LanguageEnabled=$true;OpenLogsEnabled=$true;AboutEnabled=$true;ExitEnabled=$true} + + $current=[TrayHostCorrelationTestBridge]::new([UInt64]7,[TrayCommand]::OpenLogs) + try{ + $current.SelectFromNativeMenu() + $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host + $hostState.Tray.CurrentRevision=[UInt64]7;$hostState.Tray.AcknowledgedPresentations['7']=$enabled;$hostState.LastAcknowledgedPresentation=$enabled + $world|Add-Member -NotePropertyName Bridge -NotePropertyValue $current + $fixture.Fake.Adapters.SendTrayActionResult={ + param($Tray,$ActionId,$Revision,$Status,$ErrorCode,$TransactionId) + $world.Calls.Add("ActionResult:$Status") + $world.TrayActionResults.Add([pscustomobject][ordered]@{ActionId=$ActionId;Revision=[UInt64]$Revision;Status=$Status;ErrorCode=$ErrorCode;TransactionId=$TransactionId}) + return $world.Bridge.Acknowledge($ActionId,[UInt64]$Revision,$Status,$ErrorCode) + }.GetNewClosure() + $action=[pscustomobject][ordered]@{ActionId=$current.ActionId;Command=$current.Command;Revision=[UInt64]$current.Revision} + + $result=@(Invoke-CcodSupervisorCommand $hostState $fixture.Fake.Adapters $action)[0] + + Assert-CcodEqual 'Completed' $result.Status 'current displayed revision reaches its specific Supervisor handler' + Assert-CcodEqual 1 @($world.Calls|Where-Object{$_ -eq 'Open:Logs'}).Count 'native current action invokes OpenLogs exactly once' + Assert-CcodEqual 'Completed' $current.TerminalStatus 'real HostTransport acknowledges the completed terminal result' + Assert-CcodEqual ([UInt64]7) $current.TerminalRevision 'real HostTransport terminal result retains the displayed revision' + }finally{$current.Dispose()} + + $stale=[TrayHostCorrelationTestBridge]::new([UInt64]8,[TrayCommand]::OpenLogs) + try{ + $stale.SelectFromNativeMenu() + $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host + $hostState.Tray.CurrentRevision=[UInt64]8 + $world|Add-Member -NotePropertyName Bridge -NotePropertyValue $stale + $fixture.Fake.Adapters.SendTrayActionResult={ + param($Tray,$ActionId,$Revision,$Status,$ErrorCode,$TransactionId) + $world.Calls.Add("ActionResult:$Status") + $world.TrayActionResults.Add([pscustomobject][ordered]@{ActionId=$ActionId;Revision=[UInt64]$Revision;Status=$Status;ErrorCode=$ErrorCode;TransactionId=$TransactionId}) + return $world.Bridge.Acknowledge($ActionId,[UInt64]$Revision,$Status,$ErrorCode) + }.GetNewClosure() + $action=[pscustomobject][ordered]@{ActionId=$stale.ActionId;Command=$stale.Command;Revision=[UInt64]$stale.Revision} + + $result=@(Invoke-CcodSupervisorCommand $hostState $fixture.Fake.Adapters $action)[0] + + Assert-CcodEqual 'Rejected' $result.Status 'unacknowledged native revision is rejected' + Assert-CcodEqual 'CCOD_TRAY_ACTION_STALE' $result.ErrorCode 'unacknowledged native revision preserves the stale code' + Assert-CcodEqual 0 @($world.Calls|Where-Object{$_ -eq 'Open:Logs'}).Count 'unacknowledged native action cannot invoke OpenLogs' + Assert-CcodEqual 'Rejected' $stale.TerminalStatus 'real HostTransport acknowledges the stale terminal rejection' + Assert-CcodEqual 'CCOD_TRAY_ACTION_STALE' $stale.TerminalCode 'real HostTransport retains the exact stale code before generic feedback' + Assert-CcodEqual ([UInt64]8) $stale.TerminalRevision 'real HostTransport stale result retains the displayed revision' + }finally{$stale.Dispose()} +} + Invoke-CcodTest 'rejects an action revision that was published but never acknowledged as displayed' { $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host $hostState.Tray.CurrentRevision=[UInt64]9 diff --git a/tests/persistence/TestSupport.SelfTest.ps1 b/tests/persistence/TestSupport.SelfTest.ps1 new file mode 100644 index 0000000..1082093 --- /dev/null +++ b/tests/persistence/TestSupport.SelfTest.ps1 @@ -0,0 +1,113 @@ +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'TestSupport.ps1') + +$tempRoot = Get-CcodTestCanonicalTempRoot +$root = Join-Path $tempRoot ('ccod-test-support-' + [guid]::NewGuid().ToString('N')) +$plain = Join-Path $root 'plain' +$sentinel = Join-Path $tempRoot ('sentinel-' + [guid]::NewGuid().ToString('N')) +try { + [IO.Directory]::CreateDirectory($plain) | Out-Null + [IO.File]::WriteAllText((Join-Path $plain 'a.txt'), 'a') + [IO.Directory]::CreateDirectory((Join-Path $plain 'nested')) | Out-Null + [IO.File]::WriteAllText((Join-Path $plain 'nested/b.txt'), 'b') + Remove-CcodTestOwnedTree -Path $root + if ([IO.Directory]::Exists($root)) { throw 'ASSERT_TRUE: plain test tree was not removed' } + + [IO.Directory]::CreateDirectory($sentinel) | Out-Null + $failure = $null + try { Remove-CcodTestOwnedTree -Path $sentinel } catch { $failure = $_ } + if ($null -eq $failure -or $failure.Exception.Message -notlike '*unnamed*') { throw 'ASSERT_THROWS: ordinary temp tree was accepted' } + if (-not [IO.Directory]::Exists($sentinel)) { throw 'ASSERT_TRUE: rejected sentinel was removed' } + + $failure = $null + try { Remove-CcodTestOwnedTree -Path ([IO.Path]::GetTempPath()).TrimEnd('\') } catch { $failure = $_ } + if ($null -eq $failure -or $failure.Exception.Message -notlike '*non-temporary*') { throw 'ASSERT_THROWS: temp root was accepted' } + + + # A hosted runner can expose an 8.3 short-name TEMP alias. Fixture roots built + # from that alias are rejected by the installed lifecycle observation boundary, + # so the shared helper must always return the canonical long form. + $canonical = Get-CcodTestCanonicalTempRoot + if ([string]::IsNullOrWhiteSpace($canonical) -or -not [IO.Path]::IsPathRooted($canonical)) { + throw 'ASSERT_TRUE: canonical temp root must be an absolute path' + } + if ($canonical.Contains('~')) { throw 'ASSERT_TRUE: canonical temp root still contains a short-name alias' } + if ($canonical -cne [IO.Path]::GetFullPath($canonical)) { throw 'ASSERT_EQUAL: canonical temp root is not canonical' } + if ($canonical -cne [IO.Path]::GetFullPath(([IO.Path]::GetTempPath()).TrimEnd('\'))) { throw 'ASSERT_EQUAL: canonical temp root is detached from the ambient temp path' } + if ($canonical.TrimEnd('\') -cne ($canonical + '\').TrimEnd('\')) { throw 'ASSERT_TRUE: canonical temp root keeps a stable trailing boundary' } + + + # A hosted runner can present a UTF-8 console page. Windows PowerShell 5.1 then + # makes Process.StandardInput emit a byte-order mark unless the payload is written + # as explicit bytes, and the receiving child must tolerate a BOM either way. + $handshakeRoot = Join-Path $tempRoot ('ccod-handshake-' + [guid]::NewGuid().ToString('N')) + [IO.Directory]::CreateDirectory($handshakeRoot) | Out-Null + $childScript = Join-Path $handshakeRoot 'handshake.ps1' + [IO.File]::WriteAllText($childScript, @' +$reader = [IO.StreamReader]::new([Console]::OpenStandardInput(), [Text.UTF8Encoding]::new($false), $true) +$line = $reader.ReadLine() +if ($line -cne 'continue') { [Console]::Error.WriteLine('handshake failed'); exit 1 } +[Console]::Out.WriteLine('CCOD_HANDSHAKE_OK') +'@, [Text.UTF8Encoding]::new($false)) + + $previousInputEncoding = [Console]::InputEncoding + $handshake = $null + try { + [Console]::InputEncoding = [Text.UTF8Encoding]::new($true) + $start = [Diagnostics.ProcessStartInfo]::new() + $start.FileName = Join-Path $PSHOME 'powershell.exe' + $start.Arguments = '-NoLogo -NoProfile -NonInteractive -File "' + $childScript + '"' + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + $start.RedirectStandardInput = $true + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + $start.StandardOutputEncoding = [Text.UTF8Encoding]::new($false) + $start.StandardErrorEncoding = [Text.UTF8Encoding]::new($false) + [void]$start.EnvironmentVariables.Remove('PSExecutionPolicyPreference') + $handshake = [Diagnostics.Process]::Start($start) + $stdout = $handshake.StandardOutput.ReadToEndAsync() + $stderr = $handshake.StandardError.ReadToEndAsync() + Write-CcodTestProcessInput -Process $handshake -Text 'continue' -AddNewLine + $handshake.StandardInput.Close() + if (-not $handshake.WaitForExit(30000)) { throw 'ASSERT_TRUE: handshake child did not exit' } + if ($handshake.ExitCode -ne 0) { throw ('ASSERT_EQUAL: handshake child failed: ' + $stderr.Result) } + if ($stdout.Result -cne ('CCOD_HANDSHAKE_OK' + [Environment]::NewLine)) { throw ('ASSERT_EQUAL: handshake output mismatch: ' + $stdout.Result) } + if ($stderr.Result.Length -ne 0) { throw ('ASSERT_EQUAL: handshake child stderr: ' + $stderr.Result) } + if ((Read-CcodTestStandardInputText) -cne '') { throw 'ASSERT_EQUAL: helper read no explicit input as non-empty' } + } finally { + [Console]::InputEncoding = $previousInputEncoding + if ($null -ne $handshake) { + try { if (-not $handshake.HasExited) { $handshake.Kill(); $handshake.WaitForExit() } } finally { $handshake.Dispose() } + } + if ([IO.Directory]::Exists($handshakeRoot)) { Remove-CcodTestOwnedTree -Path $handshakeRoot } + if ([IO.Directory]::Exists($root)) { Remove-CcodTestOwnedTree -Path $root } + } + + + # npm and Actions start these suites from a PowerShell 7 shell, which leaks a + # PowerShell 7 PSModulePath into the Windows PowerShell 5.1 child processes. + $previousModulePath = [Environment]::GetEnvironmentVariable('PSModulePath', 'Process') + try { + if ($PSVersionTable.PSEdition -eq 'Desktop') { + [Environment]::SetEnvironmentVariable('PSModulePath', 'C:\Program Files\PowerShell\7\Modules', 'Process') + Restore-CcodTestDesktopModulePath + $restored = [Environment]::GetEnvironmentVariable('PSModulePath', 'Process') + if ($restored -match 'PowerShell\\7(\\|$|;)') { throw 'ASSERT_TRUE: a leaked PowerShell 7 module path was not replaced' } + if ([string]::IsNullOrWhiteSpace($restored)) { throw 'ASSERT_TRUE: the restored module path is empty' } + [Environment]::SetEnvironmentVariable('PSModulePath', 'C:\custom-desktop-modules', 'Process') + Restore-CcodTestDesktopModulePath + if ([Environment]::GetEnvironmentVariable('PSModulePath', 'Process') -cne 'C:\custom-desktop-modules') { + throw 'ASSERT_EQUAL: a Desktop-only module path was overwritten' + } + } + } finally { + [Environment]::SetEnvironmentVariable('PSModulePath', $previousModulePath, 'Process') + } + + Write-Output 'TestSupport self-tests passed.' +} finally { + if ([IO.Directory]::Exists($sentinel)) { [IO.Directory]::Delete($sentinel, $true) } + if ([IO.Directory]::Exists($root)) { Remove-CcodTestOwnedTree -Path $root } +} + exit 0 diff --git a/tests/persistence/TestSupport.ps1 b/tests/persistence/TestSupport.ps1 index 301ea9b..b973b79 100644 --- a/tests/persistence/TestSupport.ps1 +++ b/tests/persistence/TestSupport.ps1 @@ -1,4 +1,4 @@ -function Assert-CcodTrue([bool]$Condition, [string]$Message) { +function Assert-CcodTrue([bool]$Condition, [string]$Message) { if (-not $Condition) { throw "ASSERT_TRUE: $Message" } } @@ -39,3 +39,117 @@ function Get-CcodTestFileSha256 { $sha.Dispose() } } + +function Write-CcodTestProcessInput { + # Windows PowerShell 5.1 has no ProcessStartInfo.StandardInputEncoding, and + # Process.StandardInput inherits Console.InputEncoding. Under a UTF-8 console + # (for example chcp 65001 on a hosted runner) that writer emits a leading + # byte-order mark, which corrupts the first token a child reads. Write explicit + # UTF-8 bytes without a BOM instead. + [CmdletBinding()] + param( + [Parameter(Mandatory)]$Process, + [Parameter(Mandatory)][AllowEmptyString()][string]$Text, + [switch]$AddNewLine + ) + $payload = if ($AddNewLine) { $Text + "`n" } else { $Text } + $bytes = [Text.UTF8Encoding]::new($false).GetBytes($payload) + $stream = $Process.StandardInput.BaseStream + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush() +} + +function Read-CcodTestStandardInputText { + # Read standard input as explicit UTF-8 bytes and tolerate an optional + # byte-order mark, so a child handshake survives any ambient console page. + [CmdletBinding()] + param() + $reader = [IO.StreamReader]::new([Console]::OpenStandardInput(), [Text.UTF8Encoding]::new($false), $true) + try { return $reader.ReadToEnd() } finally { $reader.Dispose() } +} + +function Read-CcodTestStandardInputLine { + # Read one standard input line without depending on the ambient console page. + [CmdletBinding()] + param() + $reader = [IO.StreamReader]::new([Console]::OpenStandardInput(), [Text.UTF8Encoding]::new($false), $true) + try { return $reader.ReadLine() } finally { $reader.Dispose() } +} + +function Restore-CcodTestDesktopModulePath { + # npm and GitHub Actions launch these Windows PowerShell 5.1 entry points from a + # PowerShell 7 shell. The inherited PSModulePath then contains only the + # PowerShell 7 module layout, so a 5.1 child process cannot resolve inbox + # cmdlets such as Get-ExecutionPolicy or Get-FileHash. The repository already + # normalizes this in tests/Validate.ps1 and tests/PersistenceSelfTest.ps1; do the + # same for any suite that CI starts directly from a PowerShell 7 shell. + [CmdletBinding()] + param() + if ($PSVersionTable.PSEdition -ne 'Desktop') { return } + $current = [Environment]::GetEnvironmentVariable('PSModulePath', 'Process') + if ([string]::IsNullOrWhiteSpace($current) -or $current -notmatch 'PowerShell\\7(\\|$|;)') { return } + $desktopModulePaths = @( + [Environment]::GetEnvironmentVariable('PSModulePath', 'User'), + [Environment]::GetEnvironmentVariable('PSModulePath', 'Machine') + ) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + if ($desktopModulePaths.Count -gt 0) { + $env:PSModulePath = $desktopModulePaths -join ';' + } +} + +function Get-CcodTestCanonicalTempRoot { + # $env:TEMP can be a Windows 8.3 short-name alias such as + # C:\Users\RUNNER~1\AppData\Local\Temp on hosted runners. Fixture roots + # built from the alias are rejected by the installed lifecycle harness, which + # requires canonical long-form paths. Canonicalize once so every fixture root is + # already canonical and owned-tree cleanup still matches its allowlist. + return [IO.Path]::GetFullPath(([IO.Path]::GetTempPath()).TrimEnd('\')) +} + +function Remove-CcodTestOwnedTree { + [CmdletBinding()] + param([Parameter(Mandatory)][string]$Path) + + $full = [IO.Path]::GetFullPath($Path) + $temp = [IO.Path]::GetFullPath(([IO.Path]::GetTempPath()).TrimEnd('\')) + if ($full -eq $temp -or -not $full.StartsWith($temp + '\', [StringComparison]::OrdinalIgnoreCase)) { + throw 'Refusing to remove a non-temporary test tree.' + } + $leaf = [IO.Path]::GetFileName($full) + if ($leaf -notmatch '^ccod-[A-Za-z0-9_.-]+$') { + throw 'Refusing to remove an unnamed test tree.' + } + if (-not [IO.Directory]::Exists($full)) { return } + + $root = Get-Item -LiteralPath $full -Force -ErrorAction Stop + if ($root.Attributes -band [IO.FileAttributes]::ReparsePoint) { + throw 'Refusing to remove a reparse-point test root.' + } + $items = @(Get-ChildItem -LiteralPath $full -Force -Recurse -ErrorAction Stop | + Sort-Object { $_.FullName.Length } -Descending) + foreach ($item in $items) { + if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { + if ($item.PSIsContainer) { + try { [IO.Directory]::Delete($item.FullName, $false) } + catch [IO.DirectoryNotFoundException] { } + } else { + try { [IO.File]::SetAttributes($item.FullName, [IO.FileAttributes]::Normal); [IO.File]::Delete($item.FullName) } + catch [IO.FileNotFoundException] { } + catch [IO.DirectoryNotFoundException] { } + } + continue + } + try { + if ($item.PSIsContainer) { + [IO.Directory]::Delete($item.FullName, $false) + } else { + [IO.File]::SetAttributes($item.FullName, [IO.FileAttributes]::Normal) + [IO.File]::Delete($item.FullName) + } + } catch [IO.FileNotFoundException] { } + catch [IO.DirectoryNotFoundException] { } + } + try { [IO.Directory]::Delete($full, $false) } + catch [IO.DirectoryNotFoundException] { } + if ([IO.Directory]::Exists($full)) { throw ('Test-owned cleanup left residue: ' + $full) } +} diff --git a/tests/persistence/TrayHostBuild.SelfTest.ps1 b/tests/persistence/TrayHostBuild.SelfTest.ps1 index 2e9153e..88de1a0 100644 --- a/tests/persistence/TrayHostBuild.SelfTest.ps1 +++ b/tests/persistence/TrayHostBuild.SelfTest.ps1 @@ -4,19 +4,111 @@ $ErrorActionPreference='Stop' $repositoryRoot=Split-Path (Split-Path $PSScriptRoot -Parent) -Parent $lockPath=Join-Path $repositoryRoot 'build\trayhost-packages.lock.json' -Invoke-CcodTest '2.5.21 source metadata binds the package and native assembly identities' { +Invoke-CcodTest 'TrayHost artifact validation binds the manifest and embedded PE version to the requested release' { + Import-Module (Join-Path $repositoryRoot 'build\TrayHostBuild.psm1') -Force + $package = Get-Content -LiteralPath (Join-Path $repositoryRoot 'package.json') -Raw | ConvertFrom-Json + $version = [string]$package.version + $nativeVersion = "$version.0" + $artifact = Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-native-version-artifact-' + [Guid]::NewGuid().ToString('N')) + $fixtureRoot = Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-native-version-repository-' + [Guid]::NewGuid().ToString('N')) + try { + Invoke-CcodTrayHostBuild -RepositoryRoot $repositoryRoot -Version $version -OutputDirectory $artifact -GitCommit ('b' * 40) -BuildTimestampUtc '2026-08-28T00:00:00.0000000Z' | Out-Null + [IO.Directory]::CreateDirectory((Join-Path $fixtureRoot 'src')) | Out-Null + Copy-Item -LiteralPath (Join-Path $repositoryRoot 'src\trayhost') -Destination (Join-Path $fixtureRoot 'src\trayhost') -Recurse + [IO.Directory]::CreateDirectory((Join-Path $fixtureRoot 'assets\codexremote-fix')) | Out-Null + Copy-Item -LiteralPath (Join-Path $repositoryRoot 'assets\codexremote-fix\codexremote-fix.ico') -Destination (Join-Path $fixtureRoot 'assets\codexremote-fix\codexremote-fix.ico') + Copy-Item -LiteralPath (Join-Path $repositoryRoot 'package.json') -Destination (Join-Path $fixtureRoot 'package.json') + + $manifestPath = Join-Path $fixtureRoot 'src\trayhost\CodexRemote.TrayHost.manifest' + $manifestBaseline = [IO.File]::ReadAllText($manifestPath,[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($manifestPath,$manifestBaseline.Replace('',''),[Text.UTF8Encoding]::new($false)) + $manifestHashError = '' + try { Test-CcodTrayHostArtifact -RepositoryRoot $fixtureRoot -Version $version -ArtifactDirectory $artifact -ExpectedGitCommit ('b' * 40) | Out-Null } + catch { $manifestHashError = ([string]$_.FullyQualifiedErrorId -split ',')[0] } + + [IO.File]::WriteAllText($manifestPath,$manifestBaseline.Replace($nativeVersion,'9.9.9.0'),[Text.UTF8Encoding]::new($false)) + $manifestError = '' + try { Test-CcodTrayHostArtifact -RepositoryRoot $fixtureRoot -Version $version -ArtifactDirectory $artifact -ExpectedGitCommit ('b' * 40) | Out-Null } + catch { $manifestError = ([string]$_.FullyQualifiedErrorId -split ',')[0] } + + [IO.File]::WriteAllText($manifestPath,$manifestBaseline.Replace($nativeVersion,'9.9.9.0'),[Text.UTF8Encoding]::new($false)) + $assemblyPath = Join-Path $fixtureRoot 'src\trayhost\AssemblyInfo.cs' + $assembly = [IO.File]::ReadAllText($assemblyPath,[Text.UTF8Encoding]::new($false)).Replace($nativeVersion,'9.9.9.0') + [IO.File]::WriteAllText($assemblyPath,$assembly,[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText((Join-Path $fixtureRoot 'package.json'),'{"version":"9.9.9"}',[Text.UTF8Encoding]::new($false)) + $provenancePath = Join-Path $artifact 'trayhost-build-provenance.json' + $provenance = Get-Content -LiteralPath $provenancePath -Raw | ConvertFrom-Json + $provenance.version = '9.9.9' + $assemblyRecord = @($provenance.sourceFiles | Where-Object { $_.name -ceq 'AssemblyInfo.cs' })[0] + $assemblyRecord.sha256 = Get-CcodTestFileSha256 -Path $assemblyPath + $provenance.manifestSha256 = Get-CcodTestFileSha256 -Path $manifestPath + [IO.File]::WriteAllText($provenancePath,($provenance | ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + $peError = '' + try { Test-CcodTrayHostArtifact -RepositoryRoot $fixtureRoot -Version '9.9.9' -ArtifactDirectory $artifact -ExpectedGitCommit ('b' * 40) | Out-Null } + catch { $peError = ([string]$_.FullyQualifiedErrorId -split ',')[0] } + + Assert-CcodEqual 'CCOD_TRAYHOST_ARTIFACT_TAMPERED|CCOD_TRAYHOST_VERSION_MISMATCH|CCOD_TRAYHOST_ARTIFACT_VERSION_INVALID' "$manifestHashError|$manifestError|$peError" 'validator binds manifest bytes and rejects static or PE version drift' + } finally { + if (Test-Path -LiteralPath $artifact) { Remove-Item -LiteralPath $artifact -Recurse -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $fixtureRoot) { Remove-Item -LiteralPath $fixtureRoot -Recurse -Force -ErrorAction SilentlyContinue } + } +} + +Invoke-CcodTest 'native build entrypoints reject mismatched package AssemblyInfo and manifest versions before compilation' { + Import-Module (Join-Path $repositoryRoot 'build\TrayHostBuild.psm1') -Force + $root = Join-Path (Get-CcodTestCanonicalTempRoot) ('ccod-native-version-contract-' + [Guid]::NewGuid().ToString('N')) + try { + [IO.Directory]::CreateDirectory($root) | Out-Null + $cases = @( + [pscustomobject]@{ Component='trayhost'; PackageVersion='9.9.9'; AssemblyVersion='2.5.22.0'; FileVersion='2.5.22.0'; ManifestVersion='2.5.22.0'; ExpectedError='CCOD_TRAYHOST_VERSION_MISMATCH' }, + [pscustomobject]@{ Component='trayhost'; PackageVersion='2.5.22'; AssemblyVersion='9.9.9.0'; FileVersion='2.5.22.0'; ManifestVersion='2.5.22.0'; ExpectedError='CCOD_TRAYHOST_VERSION_MISMATCH' }, + [pscustomobject]@{ Component='trayhost'; PackageVersion='2.5.22'; AssemblyVersion='2.5.22.0'; FileVersion='9.9.9.0'; ManifestVersion='2.5.22.0'; ExpectedError='CCOD_TRAYHOST_VERSION_MISMATCH' }, + [pscustomobject]@{ Component='trayhost'; PackageVersion='2.5.22'; AssemblyVersion='2.5.22.0'; FileVersion='2.5.22.0'; ManifestVersion='9.9.9.0'; ExpectedError='CCOD_TRAYHOST_VERSION_MISMATCH' }, + [pscustomobject]@{ Component='portable'; PackageVersion='9.9.9'; AssemblyVersion='2.5.22.0'; FileVersion='2.5.22.0'; ManifestVersion='2.5.22.0'; ExpectedError='CCOD_PORTABLE_LAUNCHER_VERSION_MISMATCH' }, + [pscustomobject]@{ Component='portable'; PackageVersion='2.5.22'; AssemblyVersion='9.9.9.0'; FileVersion='2.5.22.0'; ManifestVersion='2.5.22.0'; ExpectedError='CCOD_PORTABLE_LAUNCHER_VERSION_MISMATCH' }, + [pscustomobject]@{ Component='portable'; PackageVersion='2.5.22'; AssemblyVersion='2.5.22.0'; FileVersion='9.9.9.0'; ManifestVersion='2.5.22.0'; ExpectedError='CCOD_PORTABLE_LAUNCHER_VERSION_MISMATCH' }, + [pscustomobject]@{ Component='portable'; PackageVersion='2.5.22'; AssemblyVersion='2.5.22.0'; FileVersion='2.5.22.0'; ManifestVersion='9.9.9.0'; ExpectedError='CCOD_PORTABLE_LAUNCHER_VERSION_MISMATCH' } + ) + foreach ($case in $cases) { + [IO.File]::WriteAllText((Join-Path $root 'package.json'),("{{`"version`":`"{0}`"}}" -f $case.PackageVersion),[Text.UTF8Encoding]::new($false)) + $sourceRoot = Join-Path $root ('src\' + $case.Component) + [IO.Directory]::CreateDirectory($sourceRoot) | Out-Null + $assemblyInfo = "using System.Reflection;`r`n[assembly: AssemblyVersion(`"$($case.AssemblyVersion)`")]`r`n[assembly: AssemblyFileVersion(`"$($case.FileVersion)`")]`r`n" + [IO.File]::WriteAllText((Join-Path $sourceRoot 'AssemblyInfo.cs'),$assemblyInfo,[Text.UTF8Encoding]::new($false)) + $manifestName = if ($case.Component -ceq 'trayhost') { 'CodexRemote.TrayHost.manifest' } else { 'CodexRemote.Portable.manifest' } + $identityName = if ($case.Component -ceq 'trayhost') { 'CodexRemote.fix.TrayHost' } else { 'CodexRemote.fix.Portable' } + $manifest = "" + [IO.File]::WriteAllText((Join-Path $sourceRoot $manifestName),$manifest,[Text.UTF8Encoding]::new($false)) + $actualError = '' + try { + if ($case.Component -ceq 'trayhost') { + Invoke-CcodTrayHostBuild -RepositoryRoot $root -Version '2.5.22' -OutputDirectory (Join-Path $root 'trayhost-out') -GitCommit ('a' * 40) -BuildTimestampUtc '2026-08-28T00:00:00.0000000Z' | Out-Null + } else { + Invoke-CcodPortableLauncherBuild -RepositoryRoot $root -Version '2.5.22' -OutputDirectory (Join-Path $root 'portable-out') -GitCommit ('a' * 40) -BuildTimestampUtc '2026-08-28T00:00:00.0000000Z' | Out-Null + } + } catch { + $actualError = ([string]$_.FullyQualifiedErrorId -split ',')[0] + } + Assert-CcodEqual ([string]$case.ExpectedError) $actualError "$($case.Component) rejects $($case.PackageVersion)/$($case.AssemblyVersion)/$($case.FileVersion)/$($case.ManifestVersion) before resolving compiler inputs" + } + } finally { + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } + } +} + +Invoke-CcodTest '2.5.22 source metadata binds the package and native assembly identities' { $package=Get-Content -LiteralPath (Join-Path $repositoryRoot 'package.json') -Raw|ConvertFrom-Json - Assert-CcodEqual '2.5.21' ([string]$package.version) 'package version is the 2.5.21 release version' + Assert-CcodEqual '2.5.22' ([string]$package.version) 'package version is the 2.5.22 release version' $assemblyInfo=Get-Content -LiteralPath (Join-Path $repositoryRoot 'src\trayhost\AssemblyInfo.cs') -Raw - Assert-CcodTrue ($assemblyInfo -match 'AssemblyVersion\("2\.5\.21\.0"\)') 'TrayHost assembly version is 2.5.21.0' - Assert-CcodTrue ($assemblyInfo -match 'AssemblyFileVersion\("2\.5\.21\.0"\)') 'TrayHost file version is 2.5.21.0' + Assert-CcodTrue ($assemblyInfo -match 'AssemblyVersion\("2\.5\.22\.0"\)') 'TrayHost assembly version is 2.5.22.0' + Assert-CcodTrue ($assemblyInfo -match 'AssemblyFileVersion\("2\.5\.22\.0"\)') 'TrayHost file version is 2.5.22.0' $manifest=Get-Content -LiteralPath (Join-Path $repositoryRoot 'src\trayhost\CodexRemote.TrayHost.manifest') -Raw - Assert-CcodTrue ($manifest -match '