From cf27ee5e34c27063541d782d6f0a8e8968bd1164 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 01:10:30 +0800 Subject: [PATCH 001/187] docs: plan v2.5.22 runtime reliability --- ...08-28-v2522-install-runtime-reliability.md | 255 ++++++++++++++++++ ...2522-install-runtime-reliability-design.md | 100 +++++++ 2 files changed, 355 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-28-v2522-install-runtime-reliability.md create mode 100644 docs/superpowers/specs/2026-08-28-v2522-install-runtime-reliability-design.md 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..dc9fb82 --- /dev/null +++ b/docs/superpowers/plans/2026-08-28-v2522-install-runtime-reliability.md @@ -0,0 +1,255 @@ +# 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: Retry only transient Electron tree reads + +**Files:** +- Modify: `src/persistence/modules/ProcessControl.psm1` +- Modify: `src/persistence/modules/SessionEngine.psm1` +- Test: `tests/persistence/ProcessControl.SelfTest.ps1` +- Test: `tests/persistence/SessionEngine.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. + +- [ ] **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. + +- [ ] **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 and SessionEngine self-tests. Confirm child churn retries +successfully, 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 3: Make tray action correlation testable and diagnosable + +**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 4: 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 changes only pre-mutation tree acquisition and explicitly leaves all + identity and post-stop checks strict. +- Task 3 has no dependency on Task 2 and can be reviewed independently; its + diagnostics make future live failures actionable without weakening protocol + authentication. +- Task 4 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/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..cfdb7bc --- /dev/null +++ b/docs/superpowers/specs/2026-08-28-v2522-install-runtime-reliability-design.md @@ -0,0 +1,100 @@ +# 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. + +### 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 only before a mutation starts. Once a close transition is +written, the existing per-member pre-stop, post-stop, and final identity checks +remain strict. 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. From 30cf6dcde037a8719c60f85ae4c07ee17d44f4cb Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 01:29:29 +0800 Subject: [PATCH 002/187] fix: bind setup activation to immutable payload --- Activate-CcodRemoteFix.ps1 | 84 +++++++++++- Install-CodexControlOtherDevices.ps1 | 8 +- build/CodexControlOtherDevices.iss | 8 ++ build/build.ps1 | 23 +++- src/persistence/modules/InstallLifecycle.psm1 | 125 +++++++++++++++++- .../persistence/InstallLifecycle.SelfTest.ps1 | 107 +++++++++++++++ .../persistence/ReleaseWorkflow.SelfTest.ps1 | 13 ++ 7 files changed, 361 insertions(+), 7 deletions(-) diff --git a/Activate-CcodRemoteFix.ps1 b/Activate-CcodRemoteFix.ps1 index 9837167..3fa3cf5 100644 --- a/Activate-CcodRemoteFix.ps1 +++ b/Activate-CcodRemoteFix.ps1 @@ -2,6 +2,8 @@ param( [Parameter(Mandatory)][string]$AppRoot, [Parameter(Mandatory)][string]$InstallRoot, + [string]$PayloadRoot, + [string]$ExpectedVersion, [string]$ActivationId, [switch]$ValidateReceiptOnly, [switch]$ValidateReceiptWithTimeout, @@ -51,6 +53,59 @@ function Write-CcodActivationRecord { } catch { } } +function Assert-CcodActivationPayload { + param( + [Parameter(Mandatory)][string]$Root, + [Parameter(Mandatory)][string]$Version + ) + + if ($Version -cnotmatch '^\d+\.\d+\.\d+$') { throw 'CCOD_INSTALL_PAYLOAD_VERSION_MISMATCH' } + $payload = [IO.Path]::GetFullPath($Root) + $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' + } + try { $package = Get-Content -LiteralPath $packagePath -Raw | 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' + } + $module = Import-Module $modulePath -Force -PassThru + try { + & $module { + param($SourceRoot,$ExpectedVersion,$ManifestPath) + Get-CcodLifecyclePayloadManifestFiles -SourceRoot $SourceRoot -ExpectedVersion $ExpectedVersion -PayloadManifestPath $ManifestPath | Out-Null + } $payload $Version $manifestPath + } finally { + Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue + } + return $manifestPath +} + +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) @@ -179,6 +234,9 @@ function Invoke-CcodOwnedInstallWorker { '-ActivationId', $ActivationId ) + if (-not [string]::IsNullOrWhiteSpace($ExpectedVersion)) { + $arguments += @('-ExpectedVersion',$ExpectedVersion,'-PayloadManifestPath',(Join-Path $PayloadRoot 'installer-payload.manifest.json')) + } $startInfo = [Diagnostics.ProcessStartInfo]::new() $startInfo.FileName = $PowerShellPath $startInfo.Arguments = (($arguments | ForEach-Object { ConvertTo-CcodNativeProcessArgument ([string]$_) }) -join ' ') @@ -235,12 +293,15 @@ function Invoke-CcodBoundedReceiptValidator { $PSCommandPath, '-AppRoot', $AppRoot, + '-PayloadRoot', + $PayloadRoot, '-InstallRoot', $InstallRoot, '-ValidateReceiptOnly', '-ActivationId', $ActivationId ) + if (-not [string]::IsNullOrWhiteSpace($ExpectedVersion)) { $arguments += @('-ExpectedVersion',$ExpectedVersion) } $startInfo = [Diagnostics.ProcessStartInfo]::new() $startInfo.FileName = $hostExecutable $startInfo.Arguments = (($arguments | ForEach-Object { ConvertTo-CcodNativeProcessArgument ([string]$_) }) -join ' ') @@ -286,6 +347,17 @@ function Show-CcodActivationFailure { } if ($ValidateReceiptOnly -and $ValidateReceiptWithTimeout) { Write-Error 'CCOD_ACTIVATION_VALIDATOR_MODE_INVALID' -ErrorAction Continue; exit 3 } +if ([string]::IsNullOrWhiteSpace($PayloadRoot)) { $PayloadRoot = $AppRoot } +$PayloadRoot = [IO.Path]::GetFullPath($PayloadRoot) +if (-not [string]::IsNullOrWhiteSpace($ExpectedVersion)) { + try { $null = Assert-CcodActivationPayload -Root $PayloadRoot -Version $ExpectedVersion } + 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 +371,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 $PayloadRoot 'src\persistence\modules\RuntimeManifest.psm1') + } + exit 0 + } if ($terminalReceipt.phase -ceq 'Failed') { exit 2 } throw 'CCOD_ACTIVATION_RECEIPT_NOT_READY' } catch { @@ -313,7 +390,7 @@ $clock = [Diagnostics.Stopwatch]::StartNew() try { $root = [IO.Path]::GetFullPath($AppRoot) $stateRoot = [IO.Path]::GetFullPath($InstallRoot) - $installScript = Join-Path $root 'Install-CodexControlOtherDevices.ps1' + $installScript = Join-Path $PayloadRoot '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 @@ -328,6 +405,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 $PayloadRoot 'src\persistence\modules\RuntimeManifest.psm1') + } Write-CcodActivationRecord -Code 'RUNTIME_ACTIVATED' -DurationMilliseconds $clock.ElapsedMilliseconds } catch { $candidate = ([string]$_.FullyQualifiedErrorId -split ',')[0] diff --git a/Install-CodexControlOtherDevices.ps1 b/Install-CodexControlOtherDevices.ps1 index 3b5d71f..4960ea0 100644 --- a/Install-CodexControlOtherDevices.ps1 +++ b/Install-CodexControlOtherDevices.ps1 @@ -4,7 +4,9 @@ param( [switch]$EnableCandidateCompatibleUpdates, [switch]$RepairState, [switch]$DoNotStart, - [string]$ActivationId + [string]$ActivationId, + [string]$ExpectedVersion, + [string]$PayloadManifestPath ) $ErrorActionPreference = 'Stop' @@ -37,7 +39,9 @@ $receipt = Invoke-CcodInstall ` -EnableCandidateCompatibleUpdates:([bool]$EnableCandidateCompatibleUpdates) ` -RepairState:([bool]$RepairState) ` -DoNotStart:([bool]$DoNotStart) ` - -ActivationId $ActivationId + -ActivationId $ActivationId ` + -ExpectedVersion $ExpectedVersion ` + -PayloadManifestPath $PayloadManifestPath Write-Host '' Write-Host 'CodexRemote-fix - install result' -ForegroundColor Cyan diff --git a/build/CodexControlOtherDevices.iss b/build/CodexControlOtherDevices.iss index 49445df..c8c6e29 100644 --- a/build/CodexControlOtherDevices.iss +++ b/build/CodexControlOtherDevices.iss @@ -5,6 +5,9 @@ #ifndef PortableArtifactDirectory #define PortableArtifactDirectory SourcePath + "\generated\portable" #endif +#ifndef InstallerPayloadDirectory +#define InstallerPayloadDirectory SourcePath + "\.." +#endif AppId={{2B9E9F2E-7A32-4A7E-9C1D-9F5B5C6D7E8F} AppName=CodexRemote-fix AppVersion={#ProjectVersion} @@ -68,6 +71,7 @@ Source: "..\Reset-CodexControlOtherDevices.ps1"; DestDir: "{app}"; Flags: ignore Source: "..\Test-CodexControlOtherDevices.ps1"; DestDir: "{app}"; Flags: ignoreversion Source: "..\Activate-CcodRemoteFix.ps1"; DestDir: "{app}"; Flags: ignoreversion Source: "..\Prompt-CcodRestart.ps1"; DestDir: "{app}"; Flags: ignoreversion +Source: "{#InstallerPayloadDirectory}\*"; DestDir: "{app}\payload\{#ProjectVersion}"; Flags: ignoreversion recursesubdirs createallsubdirs [InstallDelete] Type: files; Name: "{userprograms}\Codex Control other devices\Codex Control other devices for Windows.lnk" @@ -294,6 +298,8 @@ begin UpdateActivationStartingPresentation(); Parameters := '-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "' + ExpandConstant('{app}\Activate-CcodRemoteFix.ps1') + '" -AppRoot "' + ExpandConstant('{app}') + + '" -PayloadRoot "' + ExpandConstant('{app}\payload\{#ProjectVersion}') + + '" -ExpectedVersion "{#ProjectVersion}' + '" -InstallRoot "' + ExpandConstant('{localappdata}\CodexControlOtherDevices') + '" -ActivationId "' + ActivationId + '" -FirstReceiptTimeoutMilliseconds ' + IntToStr(FIRST_ACTIVATION_RECEIPT_TIMEOUT_MILLISECONDS) + ' -ActivationTimeoutMilliseconds ' + @@ -312,6 +318,8 @@ begin end; Parameters := '-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "' + ExpandConstant('{app}\Activate-CcodRemoteFix.ps1') + '" -AppRoot "' + ExpandConstant('{app}') + + '" -PayloadRoot "' + ExpandConstant('{app}\payload\{#ProjectVersion}') + + '" -ExpectedVersion "{#ProjectVersion}' + '" -InstallRoot "' + ExpandConstant('{localappdata}\CodexControlOtherDevices') + '" -ValidateReceiptWithTimeout -ValidationTimeoutMilliseconds ' + IntToStr(VALIDATION_TIMEOUT_MILLISECONDS) + ' -ActivationId "' + ActivationId + '"'; diff --git a/build/build.ps1 b/build/build.ps1 index 3fc8236..af3ba71 100644 --- a/build/build.ps1 +++ b/build/build.ps1 @@ -138,6 +138,7 @@ foreach ($path in @($bundle,$checksum,$provenance,$payloadManifestAsset,$release } $stageRoot = Join-Path $PSScriptRoot ('.portable-stage-' + [guid]::NewGuid().ToString('N')) +$installerPayloadDirectory = Join-Path $PSScriptRoot ('.installer-payload-stage-' + [guid]::NewGuid().ToString('N')) try { [IO.Directory]::CreateDirectory($stageRoot) | Out-Null $payloadRoot = Join-Path $stageRoot 'payload' @@ -186,6 +187,16 @@ 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) + } + $installerPayloadManifest = [ordered]@{ + schemaVersion = 1 + projectVersion = $Version + files = $payloadRecords + } + Write-CcodBuildUtf8 -Path (Join-Path $installerPayloadDirectory 'installer-payload.manifest.json') -Text (($installerPayloadManifest | ConvertTo-Json -Depth 8) + [Environment]::NewLine) $payloadManifestPath = Join-Path $stageRoot 'payload-manifest.json' $payloadManifest = [ordered]@{ schemaVersion = 1 @@ -257,7 +268,7 @@ $iscc = $isccCandidates | Where-Object { -not [string]::IsNullOrWhiteSpace($_) - 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 +& $iscc "/DProjectVersion=$Version" "/DTrayHostArtifactDirectory=$trayHostArtifact" "/DPortableArtifactDirectory=$portableArtifact" "/DInstallerPayloadDirectory=$installerPayloadDirectory" "/O$dist\." $issPath if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $setupExe -PathType Leaf)) { throw "Inno Setup compilation failed with exit code $LASTEXITCODE" } @@ -285,3 +296,13 @@ Write-Host (" Setup: {0}" -f $setupExe) Write-Host (" SHA-256: {0}" -f $setupChecksum) Write-Host (" Manifest: {0}" -f $setupReleaseManifest) Write-Host '' + +if ([IO.Directory]::Exists($installerPayloadDirectory)) { + $installerPayloadFull = [IO.Path]::GetFullPath($installerPayloadDirectory) + $buildFull = [IO.Path]::GetFullPath($PSScriptRoot).TrimEnd('\') + '\' + if ($installerPayloadFull.StartsWith($buildFull,[StringComparison]::OrdinalIgnoreCase) -and + [IO.Path]::GetFileName($installerPayloadFull).StartsWith('.installer-payload-stage-',[StringComparison]::Ordinal) -and + -not ((Get-Item -LiteralPath $installerPayloadFull -Force).Attributes -band [IO.FileAttributes]::ReparsePoint)) { + Remove-Item -LiteralPath $installerPayloadFull -Recurse -Force -ErrorAction SilentlyContinue + } +} diff --git a/src/persistence/modules/InstallLifecycle.psm1 b/src/persistence/modules/InstallLifecycle.psm1 index ffc7656..ed5c4c3 100644 --- a/src/persistence/modules/InstallLifecycle.psm1 +++ b/src/persistence/modules/InstallLifecycle.psm1 @@ -363,6 +363,108 @@ function Get-CcodLifecycleSourceFiles { return @($files) } +function Get-CcodLifecyclePayloadManifestFiles { + param( + [Parameter(Mandatory)][string]$SourceRoot, + [Parameter(Mandatory)][string]$ExpectedVersion, + [Parameter(Mandatory)][string]$PayloadManifestPath + ) + + 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 -ceq $root) { break } + $cursor = Split-Path $cursor -Parent + if ([string]::IsNullOrWhiteSpace($cursor)) { + Throw-CcodLifecycleError 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' 'Payload manifest escaped the source root' $manifestPath + } + } + try { $manifest = Get-Content -LiteralPath $manifestPath -Raw | 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 -ceq $root) { 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, @@ -407,7 +509,7 @@ function Copy-CcodLifecycleStaging { } 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 @@ -1562,6 +1664,8 @@ function Invoke-CcodInstall { [switch]$RepairState, [switch]$DoNotStart, [string]$ActivationId, + [string]$ExpectedVersion, + [string]$PayloadManifestPath, [hashtable]$Adapters ) @@ -1586,6 +1690,13 @@ function Invoke-CcodInstall { 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) + if ($payloadBound -and ([string]::IsNullOrWhiteSpace($ExpectedVersion) -or [string]::IsNullOrWhiteSpace($PayloadManifestPath))) { + Throw-CcodLifecycleError 'CCOD_INSTALL_INPUT_INVALID' 'ExpectedVersion and PayloadManifestPath must be supplied together' $null + } + $files = if ($payloadBound) { + @(Get-CcodLifecyclePayloadManifestFiles -SourceRoot $sourceRoot -ExpectedVersion $ExpectedVersion -PayloadManifestPath $PayloadManifestPath) + } else { $null } if (-not (& $adapters.ValidateSource $sourceRoot)) { Throw-CcodLifecycleError 'CCOD_INSTALL_SOURCE_INVALID' 'Source checkout failed hermetic validation' $sourceRoot } @@ -1594,8 +1705,11 @@ function Invoke-CcodInstall { Throw-CcodLifecycleError 'CCOD_INSTALL_IDENTITY_INVALID' 'Current user identity is unavailable' $null } $projectVersion = & $adapters.GetProjectVersion $sourceRoot + 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) + if (-not $payloadBound) { $files = @(Get-CcodLifecycleSourceFiles -SourceRoot $sourceRoot -RequireTrayHost) } $existingPointer = $null $activePath = Join-Path $root 'active.json' @@ -1730,6 +1844,13 @@ 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 } + $finalPointer = Read-CcodActiveRuntime -InstallRoot $root + $finalValidation = Test-CcodRuntimeManifest -RuntimeDirectory $runtimeRoot -ExpectedRuntimeId $runtimeId + $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 | Out-Null if ($upgrade) { try { Remove-CcodLifecycleOldRuntimes -InstallRoot $root -ActiveRuntimeId $runtimeId -PreviousRuntimeId $pointer.previousRuntime } diff --git a/tests/persistence/InstallLifecycle.SelfTest.ps1 b/tests/persistence/InstallLifecycle.SelfTest.ps1 index d84bd9b..e8710b9 100644 --- a/tests/persistence/InstallLifecycle.SelfTest.ps1 +++ b/tests/persistence/InstallLifecycle.SelfTest.ps1 @@ -60,6 +60,34 @@ function New-CcodLifecycleSourceFixture { return $Root } +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 New-CcodLifecycleFakeNode { param([Parameter(Mandatory)][string]$Root) New-Item -ItemType Directory -Path (Join-Path $Root 'node') -Force | Out-Null @@ -639,6 +667,85 @@ $results += Invoke-CcodTest 'first install stages verifies activates task and pe } } +# 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 + + Assert-CcodThrows { + Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -ExpectedVersion '2.5.22' -PayloadManifestPath $manifestPath -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 + + $receipt = Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -ExpectedVersion '2.5.22' -PayloadManifestPath $manifestPath -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.22' | Out-Null + $manifestPath = New-CcodLifecyclePayloadManifest -Root $source -Version '2.5.22' + $nodePath = New-CcodLifecycleFakeNode -Root $nodeRoot + $first = Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters (New-CcodLifecycleFake -NodePath $nodePath).Adapters + $oldPointer = Read-CcodActiveRuntime -InstallRoot $install + $fake = New-CcodLifecycleFake -NodePath $nodePath + $fake.Adapters.WaitNewRuntimeReady = { + param($InstallRoot,$RuntimeId,$RuntimeGeneration,$Identity,$TaskStartedAtUtc,$TimeoutMilliseconds) + [IO.File]::WriteAllText((Join-Path $InstallRoot 'active.json'),($oldPointer | ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + [pscustomobject][ordered]@{ SupervisorReady = $true; TrayReady = $true } + }.GetNewClosure() + + Assert-CcodThrows { + Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -ExpectedVersion '2.5.22' -PayloadManifestPath $manifestPath -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: routing the activation progress receipt through the generic pretty-printed JSON writer. $results += Invoke-CcodTest 'activation progress receipt uses one compact JSON line for PowerShell 5.1 and Inno' { $install = New-CcodLifecycleTempRoot diff --git a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 index 2fc3b87..e8b881a 100644 --- a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 +++ b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 @@ -80,6 +80,19 @@ Invoke-CcodTest 'release defender tool exposes manifest and scan functions witho Assert-CcodTrue ($null -ne (Get-Command Invoke-CcodReleaseDefenderCheck -ErrorAction SilentlyContinue)) 'Defender invocation is available' } +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 + + Assert-CcodTrue ($build -cmatch 'installer-payload\.manifest\.json' -and $build -cmatch 'schemaVersion\s*=\s*1\s+projectVersion\s*=\s*\$Version\s+files\s*=\s*\$payloadRecords') 'build emits the canonical ordered setup payload manifest' + Assert-CcodTrue ($inno -cmatch 'InstallerPayloadDirectory' -and $inno -cmatch 'DestDir:\s*"\{app\}\\payload\\\{#ProjectVersion\}"') 'Inno copies the immutable build payload into its exact version directory' + Assert-CcodTrue ($inno -cmatch "ExpandConstant\('\{app\}\\payload\\\{#ProjectVersion\}'\)" -and $inno -cmatch '-ExpectedVersion\s+"\{#ProjectVersion\}') 'Inno binds activation to its compiled payload version' + Assert-CcodTrue ($activation -cmatch '\[string\]\$ExpectedVersion' -and $activation -cmatch 'installer-payload\.manifest\.json') 'activation accepts and resolves the expected payload contract' + Assert-CcodTrue ($installer -cmatch '\[string\]\$ExpectedVersion' -and $installer -cmatch '\[string\]\$PayloadManifestPath' -and $installer -cmatch '-ExpectedVersion\s+\$ExpectedVersion' -and $installer -cmatch '-PayloadManifestPath\s+\$PayloadManifestPath') 'installer forwards the immutable payload contract to lifecycle activation' +} + Invoke-CcodTest 'release manifest binds the final asset names hashes version commit and timestamp' { . $defenderPath -Library $fixture = New-CcodReleaseFixture From aec0eeb06b55ada520a517b76f3928363df532cf Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 02:06:21 +0800 Subject: [PATCH 003/187] fix: harden immutable setup payload verification --- Activate-CcodRemoteFix.ps1 | 123 +++++++- build/CodexControlOtherDevices.iss | 32 +- build/build.ps1 | 19 +- src/persistence/modules/InstallLifecycle.psm1 | 79 ++++- .../persistence/InstallLifecycle.SelfTest.ps1 | 135 +++++++++ .../persistence/ReleaseWorkflow.SelfTest.ps1 | 277 +++++++++++++++++- tools/New-InstallerPayloadManifest.ps1 | 54 ++++ 7 files changed, 683 insertions(+), 36 deletions(-) create mode 100644 tools/New-InstallerPayloadManifest.ps1 diff --git a/Activate-CcodRemoteFix.ps1 b/Activate-CcodRemoteFix.ps1 index 3fa3cf5..bebd91a 100644 --- a/Activate-CcodRemoteFix.ps1 +++ b/Activate-CcodRemoteFix.ps1 @@ -4,6 +4,7 @@ param( [Parameter(Mandatory)][string]$InstallRoot, [string]$PayloadRoot, [string]$ExpectedVersion, + [string]$ExpectedPayloadManifestSha256, [string]$ActivationId, [switch]$ValidateReceiptOnly, [switch]$ValidateReceiptWithTimeout, @@ -53,34 +54,126 @@ function Write-CcodActivationRecord { } catch { } } +function Get-CcodActivationFileSha256 { + 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 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 Assert-CcodActivationPayload { param( + [Parameter(Mandatory)][string]$AppRoot, [Parameter(Mandatory)][string]$Root, - [Parameter(Mandatory)][string]$Version + [Parameter(Mandatory)][string]$Version, + [Parameter(Mandatory)][string]$ExpectedManifestSha256 ) - if ($Version -cnotmatch '^\d+\.\d+\.\d+$') { throw 'CCOD_INSTALL_PAYLOAD_VERSION_MISMATCH' } - $payload = [IO.Path]::GetFullPath($Root) + 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' } - try { $package = Get-Content -LiteralPath $packagePath -Raw | ConvertFrom-Json -ErrorAction Stop } + [void](Assert-CcodActivationNonReparsePath -Root $payload -Path $manifestPath) + $manifestItem = Get-Item -LiteralPath $manifestPath -Force -ErrorAction Stop + if ($manifestItem.PSIsContainer -or $manifestItem.Length -le 0 -or $manifestItem.Length -gt 4194304 -or + (Test-CcodActivationAlternateDataStreams -Path $manifestPath) -or + (Get-CcodActivationFileSha256 -Path $manifestPath) -cne $ExpectedManifestSha256) { + throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' + } + try { + $manifest = [IO.File]::ReadAllText($manifestPath,[Text.Encoding]::UTF8) | 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) + $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) + $item = Get-Item -LiteralPath $source -Force -ErrorAction Stop + if ($item.PSIsContainer -or (Test-CcodActivationAlternateDataStreams -Path $source) -or [int64]$item.Length -ne [int64]$record.length -or + (Get-CcodActivationFileSha256 -Path $source) -cne [string]$record.sha256) { throw 'CCOD_INSTALL_FILE_HASH_MISMATCH' } + } + $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' } + } + try { $package = [IO.File]::ReadAllText($packagePath,[Text.Encoding]::UTF8) | 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' } - $module = Import-Module $modulePath -Force -PassThru - try { - & $module { - param($SourceRoot,$ExpectedVersion,$ManifestPath) - Get-CcodLifecyclePayloadManifestFiles -SourceRoot $SourceRoot -ExpectedVersion $ExpectedVersion -PayloadManifestPath $ManifestPath | Out-Null - } $payload $Version $manifestPath - } finally { - Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue - } return $manifestPath } @@ -301,7 +394,7 @@ function Invoke-CcodBoundedReceiptValidator { '-ActivationId', $ActivationId ) - if (-not [string]::IsNullOrWhiteSpace($ExpectedVersion)) { $arguments += @('-ExpectedVersion',$ExpectedVersion) } + 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 ' ') @@ -350,7 +443,7 @@ if ($ValidateReceiptOnly -and $ValidateReceiptWithTimeout) { Write-Error 'CCOD_A if ([string]::IsNullOrWhiteSpace($PayloadRoot)) { $PayloadRoot = $AppRoot } $PayloadRoot = [IO.Path]::GetFullPath($PayloadRoot) if (-not [string]::IsNullOrWhiteSpace($ExpectedVersion)) { - try { $null = Assert-CcodActivationPayload -Root $PayloadRoot -Version $ExpectedVersion } + try { $null = Assert-CcodActivationPayload -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' } diff --git a/build/CodexControlOtherDevices.iss b/build/CodexControlOtherDevices.iss index c8c6e29..1a0eade 100644 --- a/build/CodexControlOtherDevices.iss +++ b/build/CodexControlOtherDevices.iss @@ -6,7 +6,10 @@ #define PortableArtifactDirectory SourcePath + "\generated\portable" #endif #ifndef InstallerPayloadDirectory -#define InstallerPayloadDirectory SourcePath + "\.." +#error InstallerPayloadDirectory must be supplied by the release builder +#endif +#ifndef InstallerPayloadManifestSha256 +#error InstallerPayloadManifestSha256 must be supplied by the release builder #endif AppId={{2B9E9F2E-7A32-4A7E-9C1D-9F5B5C6D7E8F} AppName=CodexRemote-fix @@ -71,6 +74,7 @@ Source: "..\Reset-CodexControlOtherDevices.ps1"; DestDir: "{app}"; Flags: ignore Source: "..\Test-CodexControlOtherDevices.ps1"; DestDir: "{app}"; Flags: ignoreversion Source: "..\Activate-CcodRemoteFix.ps1"; DestDir: "{app}"; Flags: ignoreversion Source: "..\Prompt-CcodRestart.ps1"; DestDir: "{app}"; Flags: ignoreversion +Source: "..\tools\New-InstallerPayloadManifest.ps1"; DestDir: "{app}\tools"; Flags: ignoreversion Source: "{#InstallerPayloadDirectory}\*"; DestDir: "{app}\payload\{#ProjectVersion}"; Flags: ignoreversion recursesubdirs createallsubdirs [InstallDelete] @@ -115,6 +119,30 @@ function StringFromGUID2(var Guid: TGUID; GuidString: String; MaxCharacters: Int function GetFileAttributesW(const FileName: String): Cardinal; external 'GetFileAttributesW@kernel32.dll stdcall'; +function IsSafeExistingPayloadDirectory(const DirectoryName: String): Boolean; +var + Attributes: Cardinal; +begin + Attributes := GetFileAttributesW(DirectoryName); + Result := (Attributes = CCOD_INVALID_FILE_ATTRIBUTES) or + (((Attributes and CCOD_FILE_ATTRIBUTE_DIRECTORY) <> 0) and + ((Attributes and CCOD_FILE_ATTRIBUTE_REPARSE_POINT) = 0)); +end; + +function PrepareToInstall(var NeedsRestart: Boolean): String; +var + AppDirectory, PayloadDirectory, VersionDirectory: String; +begin + Result := ''; + AppDirectory := ExpandConstant('{app}'); + PayloadDirectory := ExpandConstant('{app}\payload'); + VersionDirectory := ExpandConstant('{app}\payload\{#ProjectVersion}'); + if not IsSafeExistingPayloadDirectory(AppDirectory) or + not IsSafeExistingPayloadDirectory(PayloadDirectory) or + not IsSafeExistingPayloadDirectory(VersionDirectory) then + Result := 'CodexRemote-fix refused an unsafe existing payload directory.'; +end; + function NewActivationId(): String; var Guid: TGUID; @@ -300,6 +328,7 @@ begin ExpandConstant('{app}\Activate-CcodRemoteFix.ps1') + '" -AppRoot "' + ExpandConstant('{app}') + '" -PayloadRoot "' + ExpandConstant('{app}\payload\{#ProjectVersion}') + '" -ExpectedVersion "{#ProjectVersion}' + + '" -ExpectedPayloadManifestSha256 "{#InstallerPayloadManifestSha256}' + '" -InstallRoot "' + ExpandConstant('{localappdata}\CodexControlOtherDevices') + '" -ActivationId "' + ActivationId + '" -FirstReceiptTimeoutMilliseconds ' + IntToStr(FIRST_ACTIVATION_RECEIPT_TIMEOUT_MILLISECONDS) + ' -ActivationTimeoutMilliseconds ' + @@ -320,6 +349,7 @@ begin ExpandConstant('{app}\Activate-CcodRemoteFix.ps1') + '" -AppRoot "' + ExpandConstant('{app}') + '" -PayloadRoot "' + ExpandConstant('{app}\payload\{#ProjectVersion}') + '" -ExpectedVersion "{#ProjectVersion}' + + '" -ExpectedPayloadManifestSha256 "{#InstallerPayloadManifestSha256}' + '" -InstallRoot "' + ExpandConstant('{localappdata}\CodexControlOtherDevices') + '" -ValidateReceiptWithTimeout -ValidationTimeoutMilliseconds ' + IntToStr(VALIDATION_TIMEOUT_MILLISECONDS) + ' -ActivationId "' + ActivationId + '"'; diff --git a/build/build.ps1 b/build/build.ps1 index af3ba71..27b0b29 100644 --- a/build/build.ps1 +++ b/build/build.ps1 @@ -191,12 +191,19 @@ try { foreach ($record in $payloadRecords) { Copy-CcodBuildPayloadFile -Source (Join-Path $payloadRoot ([string]$record.path).Replace('/','\')) -PayloadRoot $installerPayloadDirectory -Relative ([string]$record.path) } - $installerPayloadManifest = [ordered]@{ - schemaVersion = 1 - projectVersion = $Version - files = $payloadRecords + $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.' + } } - Write-CcodBuildUtf8 -Path (Join-Path $installerPayloadDirectory 'installer-payload.manifest.json') -Text (($installerPayloadManifest | ConvertTo-Json -Depth 8) + [Environment]::NewLine) + $installerPayloadManifestSha256 = Get-CcodBuildFileSha256 -Path $installerPayloadManifestPath $payloadManifestPath = Join-Path $stageRoot 'payload-manifest.json' $payloadManifest = [ordered]@{ schemaVersion = 1 @@ -268,7 +275,7 @@ $iscc = $isccCandidates | Where-Object { -not [string]::IsNullOrWhiteSpace($_) - 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" "/DInstallerPayloadDirectory=$installerPayloadDirectory" "/O$dist\." $issPath +& $iscc "/DProjectVersion=$Version" "/DTrayHostArtifactDirectory=$trayHostArtifact" "/DPortableArtifactDirectory=$portableArtifact" "/DInstallerPayloadDirectory=$installerPayloadDirectory" "/DInstallerPayloadManifestSha256=$installerPayloadManifestSha256" "/O$dist\." $issPath if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $setupExe -PathType Leaf)) { throw "Inno Setup compilation failed with exit code $LASTEXITCODE" } diff --git a/src/persistence/modules/InstallLifecycle.psm1 b/src/persistence/modules/InstallLifecycle.psm1 index ed5c4c3..ecbf18e 100644 --- a/src/persistence/modules/InstallLifecycle.psm1 +++ b/src/persistence/modules/InstallLifecycle.psm1 @@ -142,6 +142,38 @@ function Get-CcodLifecycleCanonicalRoot { return $root.TrimEnd('\') } +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 Test-CcodLifecycleReparse { param([Parameter(Mandatory)][string]$Path) if (-not (Test-Path -LiteralPath $Path)) { return $false } @@ -385,7 +417,7 @@ function Get-CcodLifecyclePayloadManifestFiles { if (Test-CcodLifecycleReparse -Path $cursor) { Throw-CcodLifecycleError 'CCOD_INSTALL_SOURCE_REPARSE' 'Payload manifest path contains a reparse point' $cursor } - if ($cursor -ceq $root) { break } + 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 @@ -444,7 +476,7 @@ function Get-CcodLifecyclePayloadManifestFiles { if (Test-CcodLifecycleReparse -Path $cursor) { Throw-CcodLifecycleError 'CCOD_INSTALL_SOURCE_REPARSE' 'A manifest-listed source path contains a reparse point' $cursor } - if ($cursor -ceq $root) { break } + if ($cursor.Equals($root,[StringComparison]::OrdinalIgnoreCase)) { break } $cursor = Split-Path $cursor -Parent } $item = Get-Item -LiteralPath $source -Force -ErrorAction Stop @@ -473,10 +505,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) @@ -505,7 +541,9 @@ function Copy-CcodLifecycleStaging { if (-not $destination.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { Throw-CcodLifecycleError 'CCOD_INSTALL_STAGING_PATH_INVALID' 'Staging path escaped the staging root' $destination } + [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $destination) & $Adapters.CopyFile $file.Source $destination + [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $destination) } foreach ($file in $Files) { $destination = [IO.Path]::GetFullPath((Join-Path $stagingDirectory $file.Relative)) @@ -515,21 +553,29 @@ function Copy-CcodLifecycleStaging { 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 } & $Adapters.CopyFile $bootstrapSource $bootstrapPath + [void](Assert-CcodLifecycleInstallPathSafe -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)) { & $Adapters.CopyFile $uninstallerSource $uninstallerPath + [void](Assert-CcodLifecycleInstallPathSafe -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-CcodLifecycleInstallPathSafe -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 { } @@ -1613,8 +1659,10 @@ 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) Remove-Item -LiteralPath $directory.FullName -Recurse -Force -ErrorAction Stop } @@ -1673,6 +1721,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 @@ -1782,18 +1831,24 @@ function Invoke-CcodInstall { 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)) + $runtimeContainer = [IO.Path]::GetFullPath((Join-Path $root 'runtime')) + [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $root -Path $runtimeContainer) + $runtimeRoot = [IO.Path]::GetFullPath((Join-Path $runtimeContainer $runtimeId)) + [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $root -Path $runtimeRoot) 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 { } + try { [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $root -Path $stagingDirectory); Remove-Item -LiteralPath $stagingDirectory -Recurse -Force -ErrorAction Stop } catch { } } } else { - [IO.Directory]::CreateDirectory((Split-Path $runtimeRoot -Parent)) | Out-Null + [IO.Directory]::CreateDirectory($runtimeContainer) | Out-Null + [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $root -Path $runtimeContainer) + [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $root -Path $stagingDirectory) [IO.Directory]::Move($stagingDirectory, $runtimeRoot) + [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $root -Path $runtimeRoot) $runtimeCreated = $true } $stagingDirectory = $null @@ -1921,7 +1976,7 @@ function Invoke-CcodInstall { } } if (-not $pointerCommitted -and $runtimeCreated -and $null -ne $runtimeRoot -and [IO.Directory]::Exists($runtimeRoot)) { - try { Remove-Item -LiteralPath $runtimeRoot -Recurse -Force -ErrorAction Stop } catch { } + try { [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $root -Path $runtimeRoot); Remove-Item -LiteralPath $runtimeRoot -Recurse -Force -ErrorAction Stop } catch { } } if ($errorCode -cne 'CCOD_INSTALL_ACTIVATION_RECEIPT_FAILED') { try { @@ -1941,11 +1996,11 @@ function Invoke-CcodInstall { 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 { } + try { [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $root -Path $stagingDirectory); Remove-Item -LiteralPath $stagingDirectory -Recurse -Force -ErrorAction Stop } 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 { } + try { [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $root -Path $stagingRoot); Remove-Item -LiteralPath $stagingRoot -Recurse -Force -ErrorAction Stop } catch { } } } diff --git a/tests/persistence/InstallLifecycle.SelfTest.ps1 b/tests/persistence/InstallLifecycle.SelfTest.ps1 index e8710b9..92cb918 100644 --- a/tests/persistence/InstallLifecycle.SelfTest.ps1 +++ b/tests/persistence/InstallLifecycle.SelfTest.ps1 @@ -746,6 +746,141 @@ $results += Invoke-CcodTest 'payload-bound install rereads the active pointer af } } +# 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)) + $manifestPath = New-CcodLifecyclePayloadManifest -Root $source -Version '2.5.22' + $nodePath = New-CcodLifecycleFakeNode -Root $nodeRoot + $fake = New-CcodLifecycleFake -NodePath $nodePath + $fake.World.CopyOverride = [pscustomobject]@{ + Match = '*Uninstall-CodexControlOtherDevices.ps1' + Action = { + param($SourcePath,$DestinationPath) + [IO.Directory]::CreateDirectory((Split-Path $DestinationPath -Parent)) | Out-Null + [IO.File]::Copy($SourcePath,$DestinationPath,$true) + if ([IO.Path]::GetFullPath($SourcePath).Equals([IO.Path]::GetFullPath($uninstallerSource),[StringComparison]::OrdinalIgnoreCase)) { + [IO.File]::WriteAllText($SourcePath,"# mutated after staging`r`n",[Text.UTF8Encoding]::new($false)) + } + }.GetNewClosure() + } + + $receipt = Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -ExpectedVersion '2.5.22' -PayloadManifestPath $manifestPath -Adapters $fake.Adapters + + $runtimeRoot = Join-Path $install "runtime\$($receipt.RuntimeId)" + Assert-CcodEqual (Get-CcodTestFileSha256 -Path (Join-Path $runtimeRoot 'src\persistence\bootstrap.ps1')) (Get-CcodTestFileSha256 -Path (Join-Path $install 'bootstrap.ps1')) 'stable bootstrap equals its verified runtime byte copy' + Assert-CcodEqual (Get-CcodTestFileSha256 -Path (Join-Path $runtimeRoot 'Uninstall-CodexControlOtherDevices.ps1')) (Get-CcodTestFileSha256 -Path (Join-Path $install 'Uninstall-CodexControlOtherDevices.ps1')) 'stable uninstaller ignores post-staging source mutation' + } 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 'staging junction is rejected before payload writes' { + $source = New-CcodLifecycleTempRoot + $install = New-CcodLifecycleTempRoot + $target = New-CcodLifecycleTempRoot + $nodeRoot = New-CcodLifecycleTempRoot + try { + 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 '.staging') -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-CcodEqual 0 @(Get-ChildItem -LiteralPath $target -Force).Count 'staging junction target remains empty' + } finally { + $junction = Join-Path $install '.staging' + 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 } } + } +} + +$results += Invoke-CcodTest 'runtime junction is rejected before staging promotion' { + $source = New-CcodLifecycleTempRoot + $install = New-CcodLifecycleTempRoot + $target = New-CcodLifecycleTempRoot + $nodeRoot = New-CcodLifecycleTempRoot + try { + 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_REPARSE_PATH' + 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 { + $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 } } + } +} + +$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.22' | Out-Null + $manifestPath = New-CcodLifecyclePayloadManifest -Root $source -Version '2.5.22' + $nodePath = New-CcodLifecycleFakeNode -Root $nodeRoot + $receipt = Invoke-CcodInstall -SourceRoot $source.ToUpperInvariant() -InstallRoot $install -ExpectedVersion '2.5.22' -PayloadManifestPath $manifestPath -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 'payload-bound install rejects a changed runtime manifest after readiness' { + $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' + $nodePath = New-CcodLifecycleFakeNode -Root $nodeRoot + $fake = New-CcodLifecycleFake -NodePath $nodePath + $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() + Assert-CcodThrows { + Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -ExpectedVersion '2.5.22' -PayloadManifestPath $manifestPath -Adapters $fake.Adapters | Out-Null + } 'CCOD_INSTALL_RUNTIME_ACTIVATION_UNPROVEN' + 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 } } + } +} + # Production mutation caught: routing the activation progress receipt through the generic pretty-printed JSON writer. $results += Invoke-CcodTest 'activation progress receipt uses one compact JSON line for PowerShell 5.1 and Inno' { $install = New-CcodLifecycleTempRoot diff --git a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 index e8b881a..be03513 100644 --- a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 +++ b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 @@ -80,19 +80,292 @@ Invoke-CcodTest 'release defender tool exposes manifest and scan functions witho Assert-CcodTrue ($null -ne (Get-Command Invoke-CcodReleaseDefenderCheck -ErrorAction SilentlyContinue)) '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 { + $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 } + } +} + +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)) + [IO.File]::WriteAllText((Join-Path $payloadRoot 'Install-CodexControlOtherDevices.ps1'),'exit 0',[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText((Join-Path $payloadRoot 'src\persistence\modules\InstallLifecycle.psm1'),'function Get-CcodLifecyclePayloadManifestFiles { @() }',[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 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))} +} + +function Invoke-CcodInnoPayloadCompileFixture { + param([switch]$IncludePayloadDefines) + + $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)) + $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' } + $arguments = @('/DProjectVersion=2.5.21',"/DTrayHostArtifactDirectory=$tray","/DPortableArtifactDirectory=$portable","/O$output\",(Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss')) + if ($IncludePayloadDefines) { + $arguments = @('/DProjectVersion=2.5.21',"/DTrayHostArtifactDirectory=$tray","/DPortableArtifactDirectory=$portable","/DInstallerPayloadDirectory=$payload","/DInstallerPayloadManifestSha256=$(Get-CcodTestFileSha256 -Path $manifestPath)","/O$output\",(Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss')) + } + $previousPreference = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + $compileOutput = @(& $iscc @arguments 2>&1) + $exitCode = $LASTEXITCODE + } finally { $ErrorActionPreference = $previousPreference } + return [pscustomobject]@{Root=$root;ExitCode=$exitCode;Output=($compileOutput -join "`n");SetupPath=(Join-Path $output 'CodexRemote-fix-2.5.21-setup.exe')} +} + 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 - Assert-CcodTrue ($build -cmatch 'installer-payload\.manifest\.json' -and $build -cmatch 'schemaVersion\s*=\s*1\s+projectVersion\s*=\s*\$Version\s+files\s*=\s*\$payloadRecords') 'build emits the canonical ordered setup payload manifest' + Assert-CcodTrue ($build -cmatch 'New-InstallerPayloadManifest\.ps1' -and $build -cmatch 'InstallerPayloadManifestSha256') 'build invokes the tested generator and binds its manifest hash into setup compilation' Assert-CcodTrue ($inno -cmatch 'InstallerPayloadDirectory' -and $inno -cmatch 'DestDir:\s*"\{app\}\\payload\\\{#ProjectVersion\}"') 'Inno copies the immutable build payload into its exact version directory' Assert-CcodTrue ($inno -cmatch "ExpandConstant\('\{app\}\\payload\\\{#ProjectVersion\}'\)" -and $inno -cmatch '-ExpectedVersion\s+"\{#ProjectVersion\}') 'Inno binds activation to its compiled payload version' - Assert-CcodTrue ($activation -cmatch '\[string\]\$ExpectedVersion' -and $activation -cmatch 'installer-payload\.manifest\.json') 'activation accepts and resolves the expected payload contract' + Assert-CcodTrue ($activation -cmatch '\[string\]\$ExpectedVersion' -and $activation -cmatch '\[string\]\$ExpectedPayloadManifestSha256' -and $activation -cmatch 'installer-payload\.manifest\.json') 'activation accepts and resolves the expected payload contract' Assert-CcodTrue ($installer -cmatch '\[string\]\$ExpectedVersion' -and $installer -cmatch '\[string\]\$PayloadManifestPath' -and $installer -cmatch '-ExpectedVersion\s+\$ExpectedVersion' -and $installer -cmatch '-PayloadManifestPath\s+\$PayloadManifestPath') 'installer forwards the immutable payload contract to lifecycle activation' } +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 installer payload directory and manifest hash' + Assert-CcodTrue ($compile.Output -cmatch 'InstallerPayloadDirectory') 'compiler identifies the missing immutable payload 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.Output -cmatch 'installer-payload\.manifest\.json' -and $compile.Output -cmatch 'payload\\package\.json') 'compiler input trace contains the exact payload manifest and manifest-listed file' + } finally { + if (Test-Path -LiteralPath $compile.Root) { Remove-Item -LiteralPath $compile.Root -Recurse -Force } + } +} + +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' + Assert-CcodTrue ($inno -cmatch '(?ms)^function PrepareToInstall\(var NeedsRestart: Boolean\): String;.*?IsSafeExistingPayloadDirectory') 'PrepareToInstall rejects unsafe app and payload directories 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' + $resultPath = Join-Path $root 'result.txt' + [IO.Directory]::CreateDirectory($normal) | Out-Null + [IO.Directory]::CreateDirectory($target) | Out-Null + New-Item -ItemType Junction -Path $junction -Target $target | Out-Null + $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_INVALID_FILE_ATTRIBUTES = `$FFFFFFFF; +function GetFileAttributesW(const FileName: String): Cardinal; + external 'GetFileAttributesW@kernel32.dll stdcall'; +$($helper.Value) +function InitializeSetup(): Boolean; +begin + if IsSafeExistingPayloadDirectory('$($normal.Replace("'","''"))') and + IsSafeExistingPayloadDirectory('$($missing.Replace("'","''"))') and + (not IsSafeExistingPayloadDirectory('$($junction.Replace("'","''"))')) then + SaveStringToFile('$($resultPath.Replace("'","''"))','pass',False); + Result := False; +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 accepts absent/normal directories and rejects a junction' + } finally { + 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' { . $defenderPath -Library $fixture = New-CcodReleaseFixture diff --git a/tools/New-InstallerPayloadManifest.ps1 b/tools/New-InstallerPayloadManifest.ps1 new file mode 100644 index 0000000..ce22b3a --- /dev/null +++ b/tools/New-InstallerPayloadManifest.ps1 @@ -0,0 +1,54 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$PayloadRoot, + [Parameter(Mandatory)][string]$ProjectVersion, + [Parameter(Mandatory)][string]$OutputPath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-CcodInstallerPayloadSha256 { + 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-CcodInstallerPayloadRelativePath { + param([Parameter(Mandatory)][string]$Path) + return $Path -cmatch '^[A-Za-z0-9][A-Za-z0-9._/-]{0,255}$' -and -not $Path.Contains('//') -and -not $Path.Contains('..') -and -not $Path.Contains(':') +} + +if ($ProjectVersion -cnotmatch '^\d+\.\d+\.\d+$') { throw "Invalid installer payload project version: $ProjectVersion" } +$root = [IO.Path]::GetFullPath($PayloadRoot).TrimEnd('\') +$output = [IO.Path]::GetFullPath($OutputPath) +if (-not [IO.Directory]::Exists($root)) { throw "Installer payload root is missing: $root" } +if ([IO.File]::Exists($output) -or [IO.Directory]::Exists($output)) { throw "Refusing to overwrite installer payload manifest: $output" } +$rootItem = Get-Item -LiteralPath $root -Force -ErrorAction Stop +if (($rootItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "Installer payload root is a reparse point: $root" } +$prefix = $root + '\' +$records = [Collections.Generic.List[object]]::new() +foreach ($item in @(Get-ChildItem -LiteralPath $root -Force -Recurse -ErrorAction Stop)) { + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "Installer payload contains a reparse point: $($item.FullName)" } + if ($item.PSIsContainer) { continue } + $full = [IO.Path]::GetFullPath($item.FullName) + if (-not $full.StartsWith($prefix,[StringComparison]::OrdinalIgnoreCase)) { throw "Installer payload file escaped its root: $full" } + $relative = $full.Substring($prefix.Length).Replace('\','/') + if (-not (Test-CcodInstallerPayloadRelativePath $relative)) { throw "Installer payload relative path is invalid: $relative" } + $records.Add([pscustomobject][ordered]@{path=$relative;length=[int64]$item.Length;sha256=Get-CcodInstallerPayloadSha256 -Path $full}) +} +$comparison = [System.Comparison[object]]{param($left,$right)[StringComparer]::Ordinal.Compare([string]$left.path,[string]$right.path)} +$records.Sort($comparison) +if ($records.Count -eq 0) { throw 'Installer payload is empty.' } +$manifest = [ordered]@{schemaVersion=1;projectVersion=$ProjectVersion;files=@($records)} +$outputParent = Split-Path $output -Parent +if (-not [IO.Directory]::Exists($outputParent)) { [IO.Directory]::CreateDirectory($outputParent) | Out-Null } +[IO.File]::WriteAllText($output,(($manifest|ConvertTo-Json -Depth 8)+[Environment]::NewLine),[Text.UTF8Encoding]::new($false)) +Write-Output ([pscustomobject]$manifest) From b9e74657e5439e02a8a4eab8d6b5bc70e84ee751 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 02:17:38 +0800 Subject: [PATCH 004/187] docs: refine stable rebind acceptance --- ...6-08-28-v2522-install-runtime-reliability.md | 17 ++++++++++++++--- ...-v2522-install-runtime-reliability-design.md | 16 ++++++++++++---- 2 files changed, 26 insertions(+), 7 deletions(-) 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 index dc9fb82..49ea10c 100644 --- a/docs/superpowers/plans/2026-08-28-v2522-install-runtime-reliability.md +++ b/docs/superpowers/plans/2026-08-28-v2522-install-runtime-reliability.md @@ -97,14 +97,19 @@ fix: bind setup activation to immutable payload **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** @@ -121,6 +126,11 @@ Assert-CcodEqual 2 $tree.Count 'one transient empty tree is retried without rela 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` @@ -138,9 +148,10 @@ pre-stop/post-stop/final proofs unchanged. - [ ] **Step 4: Run focused tests and prove GREEN** -Run ProcessControl and SessionEngine self-tests. Confirm child churn retries -successfully, root drift remains fail-closed, and ordinary close tests still -prove every stopped member. +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** 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 index cfdb7bc..021295f 100644 --- 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 @@ -65,10 +65,18 @@ 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 only before a mutation starts. Once a close transition is -written, the existing per-member pre-stop, post-stop, and final identity checks -remain strict. Diagnostics classify the last stable failure boundary so a -future support log can distinguish tree churn from an unsafe process identity. +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 From 493245e54884db4e0cf7e982dacef137e5c6d00a Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 02:36:22 +0800 Subject: [PATCH 005/187] fix: close setup payload reparse write windows --- build/CodexControlOtherDevices.iss | 48 ++++++++++- src/persistence/modules/InstallLifecycle.psm1 | 59 ++++++++++--- .../persistence/InstallLifecycle.SelfTest.ps1 | 82 +++++++++++++++++++ .../persistence/ReleaseWorkflow.SelfTest.ps1 | 17 +++- 4 files changed, 192 insertions(+), 14 deletions(-) diff --git a/build/CodexControlOtherDevices.iss b/build/CodexControlOtherDevices.iss index 1a0eade..4e79f0e 100644 --- a/build/CodexControlOtherDevices.iss +++ b/build/CodexControlOtherDevices.iss @@ -129,6 +129,42 @@ begin ((Attributes and CCOD_FILE_ATTRIBUTE_REPARSE_POINT) = 0)); end; +function IsSafeExistingSetupTree(const DirectoryName: String): Boolean; +var + Attributes: Cardinal; + FindRec: TFindRec; + ChildPath: String; +begin + Result := IsSafeExistingPayloadDirectory(DirectoryName); + if not Result then Exit; + Attributes := GetFileAttributesW(DirectoryName); + if Attributes = CCOD_INVALID_FILE_ATTRIBUTES then Exit; + if FindFirst(AddBackslash(DirectoryName) + '*', FindRec) then + begin + try + repeat + if (FindRec.Name <> '.') and (FindRec.Name <> '..') then + begin + ChildPath := AddBackslash(DirectoryName) + FindRec.Name; + if (FindRec.Attributes and CCOD_FILE_ATTRIBUTE_REPARSE_POINT) <> 0 then + begin + Result := False; + Exit; + end; + if ((FindRec.Attributes and CCOD_FILE_ATTRIBUTE_DIRECTORY) <> 0) and + (not IsSafeExistingSetupTree(ChildPath)) then + begin + Result := False; + Exit; + end; + end; + until not FindNext(FindRec); + finally + FindClose(FindRec); + end; + end; +end; + function PrepareToInstall(var NeedsRestart: Boolean): String; var AppDirectory, PayloadDirectory, VersionDirectory: String; @@ -137,7 +173,17 @@ begin AppDirectory := ExpandConstant('{app}'); PayloadDirectory := ExpandConstant('{app}\payload'); VersionDirectory := ExpandConstant('{app}\payload\{#ProjectVersion}'); - if not IsSafeExistingPayloadDirectory(AppDirectory) or + if not IsSafeExistingSetupTree(AppDirectory) or + not IsSafeExistingPayloadDirectory(ExpandConstant('{app}\build')) or + not IsSafeExistingPayloadDirectory(ExpandConstant('{app}\bin')) or + not IsSafeExistingPayloadDirectory(ExpandConstant('{app}\.github')) or + not IsSafeExistingPayloadDirectory(ExpandConstant('{app}\.github\workflows')) or + not IsSafeExistingPayloadDirectory(ExpandConstant('{app}\assets')) or + not IsSafeExistingPayloadDirectory(ExpandConstant('{app}\assets\codexremote-fix')) or + not IsSafeExistingPayloadDirectory(ExpandConstant('{app}\docs')) or + not IsSafeExistingPayloadDirectory(ExpandConstant('{app}\src')) or + not IsSafeExistingPayloadDirectory(ExpandConstant('{app}\tests')) or + not IsSafeExistingPayloadDirectory(ExpandConstant('{app}\tools')) or not IsSafeExistingPayloadDirectory(PayloadDirectory) or not IsSafeExistingPayloadDirectory(VersionDirectory) then Result := 'CodexRemote-fix refused an unsafe existing payload directory.'; diff --git a/src/persistence/modules/InstallLifecycle.psm1 b/src/persistence/modules/InstallLifecycle.psm1 index ecbf18e..329f1ed 100644 --- a/src/persistence/modules/InstallLifecycle.psm1 +++ b/src/persistence/modules/InstallLifecycle.psm1 @@ -174,6 +174,31 @@ function Assert-CcodLifecycleInstallPathSafe { 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) } + } + } + return $tree +} + function Test-CcodLifecycleReparse { param([Parameter(Mandatory)][string]$Path) if (-not (Test-Path -LiteralPath $Path)) { return $false } @@ -541,6 +566,10 @@ 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-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $destination) & $Adapters.CopyFile $file.Source $destination [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $destination) @@ -557,6 +586,7 @@ function Copy-CcodLifecycleStaging { if (-not [IO.File]::Exists($bootstrapSource)) { Throw-CcodLifecycleError 'CCOD_INSTALL_SOURCE_INCOMPLETE' 'The verified staging bootstrap script is missing' $bootstrapSource } + [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $bootstrapPath) & $Adapters.CopyFile $bootstrapSource $bootstrapPath [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $bootstrapPath) if ((Get-CcodLifecycleFileSha256 -Path $bootstrapPath) -cne (Get-CcodLifecycleFileSha256 -Path $bootstrapSource)) { @@ -565,6 +595,7 @@ function Copy-CcodLifecycleStaging { $copiedStableBootstrap = $true $uninstallerSource = Join-Path $stagingDirectory 'Uninstall-CodexControlOtherDevices.ps1' if ([IO.File]::Exists($uninstallerSource)) { + [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $uninstallerPath) & $Adapters.CopyFile $uninstallerSource $uninstallerPath [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $uninstallerPath) if ((Get-CcodLifecycleFileSha256 -Path $uninstallerPath) -cne (Get-CcodLifecycleFileSha256 -Path $uninstallerSource)) { @@ -575,13 +606,13 @@ function Copy-CcodLifecycleStaging { return $stagingDirectory } catch { if ([IO.Directory]::Exists($stagingDirectory)) { - try { [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $stagingDirectory); 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-CcodLifecycleInstallPathSafe -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-CcodLifecycleInstallPathSafe -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_*') { @@ -1422,6 +1453,10 @@ function Get-CcodLifecycleAdapters { [IO.Directory]::CreateDirectory((Split-Path $Destination -Parent)) | Out-Null [IO.File]::Copy($Source, $Destination, $true) } + NewRuntimeManifest = { + param($RuntimeDirectory, $ProjectVersion) + New-CcodRuntimeManifest -RuntimeDirectory $RuntimeDirectory -ProjectVersion $ProjectVersion + } WriteLog = { param($InstallRoot, $Record) $logDirectory = Join-Path $InstallRoot 'logs' @@ -1664,6 +1699,7 @@ function Remove-CcodLifecycleOldRuntimes { 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 } } @@ -1820,12 +1856,15 @@ function Invoke-CcodInstall { 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 + $manifest = & $adapters.NewRuntimeManifest $stagingDirectory $projectVersion + $stagingManifestPath = Join-Path $stagingDirectory 'manifest.json' + [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $root -Path $stagingManifestPath) [IO.File]::WriteAllText( - (Join-Path $stagingDirectory 'manifest.json'), + $stagingManifestPath, ($manifest | ConvertTo-Json -Depth 16), [Text.UTF8Encoding]::new($false) ) + [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $root -Path $stagingManifestPath) $runtimeId = [string]$manifest.runtimeId $validation = Test-CcodRuntimeManifest -RuntimeDirectory $stagingDirectory -ExpectedRuntimeId $runtimeId if (-not $validation.Valid) { @@ -1841,12 +1880,12 @@ function Invoke-CcodInstall { Throw-CcodLifecycleError 'CCOD_INSTALL_RUNTIME_CONFLICT' 'An invalid runtime already occupies the target runtime id' $runtimeRoot } if ([IO.Directory]::Exists($stagingDirectory)) { - try { [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $root -Path $stagingDirectory); Remove-Item -LiteralPath $stagingDirectory -Recurse -Force -ErrorAction Stop } catch { } + try { [void](Assert-CcodLifecycleInstallTreeSafe -InstallRoot $root -Path $stagingDirectory); Remove-Item -LiteralPath $stagingDirectory -Recurse -Force -ErrorAction Stop } catch { } } } else { [IO.Directory]::CreateDirectory($runtimeContainer) | Out-Null [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $root -Path $runtimeContainer) - [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $root -Path $stagingDirectory) + [void](Assert-CcodLifecycleInstallTreeSafe -InstallRoot $root -Path $stagingDirectory) [IO.Directory]::Move($stagingDirectory, $runtimeRoot) [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $root -Path $runtimeRoot) $runtimeCreated = $true @@ -1976,7 +2015,7 @@ function Invoke-CcodInstall { } } if (-not $pointerCommitted -and $runtimeCreated -and $null -ne $runtimeRoot -and [IO.Directory]::Exists($runtimeRoot)) { - try { [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $root -Path $runtimeRoot); Remove-Item -LiteralPath $runtimeRoot -Recurse -Force -ErrorAction Stop } catch { } + try { [void](Assert-CcodLifecycleInstallTreeSafe -InstallRoot $root -Path $runtimeRoot); Remove-Item -LiteralPath $runtimeRoot -Recurse -Force -ErrorAction Stop } catch { } } if ($errorCode -cne 'CCOD_INSTALL_ACTIVATION_RECEIPT_FAILED') { try { @@ -1996,11 +2035,11 @@ function Invoke-CcodInstall { try { & $adapters.ExitLifecycleOwnership $lifecycleOwnership | Out-Null } catch { } } if ($null -ne $stagingDirectory -and [IO.Directory]::Exists($stagingDirectory)) { - try { [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $root -Path $stagingDirectory); Remove-Item -LiteralPath $stagingDirectory -Recurse -Force -ErrorAction Stop } catch { } + try { [void](Assert-CcodLifecycleInstallTreeSafe -InstallRoot $root -Path $stagingDirectory); Remove-Item -LiteralPath $stagingDirectory -Recurse -Force -ErrorAction Stop } catch { } } $stagingRoot = [IO.Path]::GetFullPath((Join-Path $root '.staging')) if ([IO.Directory]::Exists($stagingRoot) -and -not (Test-CcodLifecycleReparse -Path $stagingRoot)) { - try { [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $root -Path $stagingRoot); Remove-Item -LiteralPath $stagingRoot -Recurse -Force -ErrorAction Stop } catch { } + try { [void](Assert-CcodLifecycleInstallTreeSafe -InstallRoot $root -Path $stagingRoot); Remove-Item -LiteralPath $stagingRoot -Recurse -Force -ErrorAction Stop } catch { } } } diff --git a/tests/persistence/InstallLifecycle.SelfTest.ps1 b/tests/persistence/InstallLifecycle.SelfTest.ps1 index 92cb918..3f3013a 100644 --- a/tests/persistence/InstallLifecycle.SelfTest.ps1 +++ b/tests/persistence/InstallLifecycle.SelfTest.ps1 @@ -840,6 +840,88 @@ $results += Invoke-CcodTest 'runtime junction is rejected before staging promoti } } +$results += Invoke-CcodTest 'stable bootstrap leaf junction is rejected before copy' { + $source = New-CcodLifecycleTempRoot + $install = New-CcodLifecycleTempRoot + $target = New-CcodLifecycleTempRoot + $nodeRoot = New-CcodLifecycleTempRoot + try { + New-CcodLifecycleSourceFixture -Root $source | Out-Null + [IO.Directory]::CreateDirectory($install) | Out-Null + [IO.Directory]::CreateDirectory($target) | Out-Null + $bootstrapLink = Join-Path $install 'bootstrap.ps1' + New-Item -ItemType Junction -Path $bootstrapLink -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-CcodEqual 0 @(Get-ChildItem -LiteralPath $target -Force).Count 'stable bootstrap junction target receives no bytes' + } finally { + $bootstrapLink = Join-Path $install 'bootstrap.ps1' + if (Test-Path -LiteralPath $bootstrapLink) { [IO.Directory]::Delete($bootstrapLink) } + foreach ($path in @($source,$install,$target,$nodeRoot)) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force } } + } +} + +$results += Invoke-CcodTest 'runtime manifest leaf junction is rejected before write' { + $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 + $nodePath = New-CcodLifecycleFakeNode -Root $nodeRoot + $fake = New-CcodLifecycleFake -NodePath $nodePath + $fake.Adapters.NewRuntimeManifest = { + param($RuntimeDirectory,$ProjectVersion) + $manifest = New-CcodRuntimeManifest -RuntimeDirectory $RuntimeDirectory -ProjectVersion $ProjectVersion + New-Item -ItemType Junction -Path (Join-Path $RuntimeDirectory 'manifest.json') -Target $target | Out-Null + return $manifest + }.GetNewClosure() + Assert-CcodThrows { Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters $fake.Adapters | Out-Null } 'CCOD_INSTALL_REPARSE_PATH' + Assert-CcodEqual 0 @(Get-ChildItem -LiteralPath $target -Force).Count 'runtime manifest junction target receives no bytes' + } finally { + foreach ($staging in @(Get-ChildItem -LiteralPath (Join-Path $install '.staging') -Directory -ErrorAction SilentlyContinue)) { + $link = Join-Path $staging.FullName 'manifest.json' + if (Test-Path -LiteralPath $link) { [IO.Directory]::Delete($link) } + } + foreach ($path in @($source,$install,$target,$nodeRoot)) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force } } + } +} + +$results += Invoke-CcodTest 'staging cleanup refuses a nested junction after copy failure' { + $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 + [IO.File]::WriteAllText((Join-Path $target 'sentinel.txt'),'keep',[Text.UTF8Encoding]::new($false)) + $nodePath = New-CcodLifecycleFakeNode -Root $nodeRoot + $fake = New-CcodLifecycleFake -NodePath $nodePath + $fake.World.CopyOverride = [pscustomobject]@{ + Match = '*Supervisor.ps1' + Action = { + param($SourcePath,$DestinationPath) + $stagingDirectory = Split-Path (Split-Path (Split-Path $DestinationPath -Parent) -Parent) -Parent + [IO.Directory]::CreateDirectory($stagingDirectory) | Out-Null + New-Item -ItemType Junction -Path (Join-Path $stagingDirectory 'cleanup-escape') -Target $target | Out-Null + throw 'PRIVATE_COPY_FAILURE_AFTER_JUNCTION' + }.GetNewClosure() + } + Assert-CcodThrows { Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters $fake.Adapters | Out-Null } 'CCOD_INSTALL_STAGING_FAILED' + $retained = @(Get-ChildItem -LiteralPath (Join-Path $install '.staging') -Directory -ErrorAction SilentlyContinue | Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName 'cleanup-escape') }) + Assert-CcodEqual 1 $retained.Count 'unsafe staging tree is retained instead of recursively deleted' + Assert-CcodEqual 'keep' ([IO.File]::ReadAllText((Join-Path $target 'sentinel.txt'))) 'cleanup never deletes through the nested junction' + } finally { + foreach ($staging in @(Get-ChildItem -LiteralPath (Join-Path $install '.staging') -Directory -ErrorAction SilentlyContinue)) { + $link = Join-Path $staging.FullName 'cleanup-escape' + if (Test-Path -LiteralPath $link) { [IO.Directory]::Delete($link) } + } + foreach ($path in @($source,$install,$target,$nodeRoot)) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force } } + } +} + $results += Invoke-CcodTest 'payload manifest ancestry treats Windows root equality case-insensitively' { $source = New-CcodLifecycleTempRoot $install = New-CcodLifecycleTempRoot diff --git a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 index be03513..d51bf1b 100644 --- a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 +++ b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 @@ -316,17 +316,25 @@ 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' - Assert-CcodTrue ($inno -cmatch '(?ms)^function PrepareToInstall\(var NeedsRestart: Boolean\): String;.*?IsSafeExistingPayloadDirectory') 'PrepareToInstall rejects unsafe app and payload directories before file copy' + $treeHelper = [regex]::Match($inno,'(?ms)^function IsSafeExistingSetupTree\(.*?^end;') + Assert-CcodTrue $treeHelper.Success 'production Inno script exposes a recursive destination-tree predicate' + Assert-CcodTrue ($inno -cmatch '(?ms)^function PrepareToInstall\(var NeedsRestart: Boolean\): String;.*?IsSafeExistingSetupTree') 'PrepareToInstall rejects unsafe nested setup destinations 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' $resultPath = Join-Path $root 'result.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)) $harnessPath = Join-Path $root 'ReparseGate.iss' $harness = @" [Setup] @@ -345,11 +353,14 @@ const function GetFileAttributesW(const FileName: String): Cardinal; external 'GetFileAttributesW@kernel32.dll stdcall'; $($helper.Value) +$($treeHelper.Value) function InitializeSetup(): Boolean; begin if IsSafeExistingPayloadDirectory('$($normal.Replace("'","''"))') and IsSafeExistingPayloadDirectory('$($missing.Replace("'","''"))') and - (not IsSafeExistingPayloadDirectory('$($junction.Replace("'","''"))')) then + (not IsSafeExistingPayloadDirectory('$($junction.Replace("'","''"))')) and + (not IsSafeExistingSetupTree('$($tree.Replace("'","''"))')) and + (not IsSafeExistingSetupTree('$($fileDirectory.Replace("'","''"))')) then SaveStringToFile('$($resultPath.Replace("'","''"))','pass',False); Result := False; end; @@ -360,7 +371,7 @@ end; 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 accepts absent/normal directories and rejects a junction' + Assert-CcodEqual 'pass' ([IO.File]::ReadAllText($resultPath,[Text.UTF8Encoding]::new($false))) 'production predicate rejects root/nested junctions and file-valued directory paths' } finally { if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } } From 6dcb725a8a6fae4e3f1178d7c69ada060f8fb8ac Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 02:52:27 +0800 Subject: [PATCH 006/187] fix: inventory all setup destination directories --- build/CodexControlOtherDevices.iss | 44 ++++++---- build/build.ps1 | 15 +++- .../persistence/ReleaseWorkflow.SelfTest.ps1 | 73 ++++++++++++++-- tools/New-InstallerDestinationInventory.ps1 | 83 +++++++++++++++++++ 4 files changed, 193 insertions(+), 22 deletions(-) create mode 100644 tools/New-InstallerDestinationInventory.ps1 diff --git a/build/CodexControlOtherDevices.iss b/build/CodexControlOtherDevices.iss index 4e79f0e..c9da999 100644 --- a/build/CodexControlOtherDevices.iss +++ b/build/CodexControlOtherDevices.iss @@ -11,6 +11,9 @@ #ifndef InstallerPayloadManifestSha256 #error InstallerPayloadManifestSha256 must be supplied by the release builder #endif +#ifndef InstallerDestinationInventoryInclude +#error InstallerDestinationInventoryInclude must be supplied by the release builder +#endif AppId={{2B9E9F2E-7A32-4A7E-9C1D-9F5B5C6D7E8F} AppName=CodexRemote-fix AppVersion={#ProjectVersion} @@ -75,6 +78,7 @@ Source: "..\Test-CodexControlOtherDevices.ps1"; DestDir: "{app}"; Flags: ignorev Source: "..\Activate-CcodRemoteFix.ps1"; DestDir: "{app}"; Flags: ignoreversion Source: "..\Prompt-CcodRestart.ps1"; DestDir: "{app}"; Flags: ignoreversion Source: "..\tools\New-InstallerPayloadManifest.ps1"; DestDir: "{app}\tools"; Flags: ignoreversion +Source: "..\tools\New-InstallerDestinationInventory.ps1"; DestDir: "{app}\tools"; Flags: ignoreversion Source: "{#InstallerPayloadDirectory}\*"; DestDir: "{app}\payload\{#ProjectVersion}"; Flags: ignoreversion recursesubdirs createallsubdirs [InstallDelete] @@ -95,6 +99,7 @@ Name: "{group}\Uninstall CodexRemote-fix"; Filename: "{app}\unins000.exe"; IconF 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" [Code] +#include InstallerDestinationInventoryInclude const ACTIVATION_TIMEOUT_MILLISECONDS = 300000; FIRST_ACTIVATION_RECEIPT_TIMEOUT_MILLISECONDS = 90000; @@ -165,27 +170,36 @@ begin end; end; +function AreCcodExpectedSetupDirectoriesSafe(const AppDirectory: String): Boolean; +var + Directories: TStringList; + Index: Integer; + Candidate: String; +begin + Result := False; + Directories := TStringList.Create; + try + AddCcodExpectedSetupDirectories(Directories); + if Directories.Count = 0 then Exit; + for Index := 0 to Directories.Count - 1 do + begin + Candidate := AddBackslash(AppDirectory) + Directories[Index]; + if not IsSafeExistingPayloadDirectory(Candidate) then Exit; + end; + Result := True; + finally + Directories.Free; + end; +end; + function PrepareToInstall(var NeedsRestart: Boolean): String; var - AppDirectory, PayloadDirectory, VersionDirectory: String; + AppDirectory: String; begin Result := ''; AppDirectory := ExpandConstant('{app}'); - PayloadDirectory := ExpandConstant('{app}\payload'); - VersionDirectory := ExpandConstant('{app}\payload\{#ProjectVersion}'); if not IsSafeExistingSetupTree(AppDirectory) or - not IsSafeExistingPayloadDirectory(ExpandConstant('{app}\build')) or - not IsSafeExistingPayloadDirectory(ExpandConstant('{app}\bin')) or - not IsSafeExistingPayloadDirectory(ExpandConstant('{app}\.github')) or - not IsSafeExistingPayloadDirectory(ExpandConstant('{app}\.github\workflows')) or - not IsSafeExistingPayloadDirectory(ExpandConstant('{app}\assets')) or - not IsSafeExistingPayloadDirectory(ExpandConstant('{app}\assets\codexremote-fix')) or - not IsSafeExistingPayloadDirectory(ExpandConstant('{app}\docs')) or - not IsSafeExistingPayloadDirectory(ExpandConstant('{app}\src')) or - not IsSafeExistingPayloadDirectory(ExpandConstant('{app}\tests')) or - not IsSafeExistingPayloadDirectory(ExpandConstant('{app}\tools')) or - not IsSafeExistingPayloadDirectory(PayloadDirectory) or - not IsSafeExistingPayloadDirectory(VersionDirectory) then + not AreCcodExpectedSetupDirectoriesSafe(AppDirectory) then Result := 'CodexRemote-fix refused an unsafe existing payload directory.'; end; diff --git a/build/build.ps1 b/build/build.ps1 index 27b0b29..da5a147 100644 --- a/build/build.ps1 +++ b/build/build.ps1 @@ -139,6 +139,7 @@ foreach ($path in @($bundle,$checksum,$provenance,$payloadManifestAsset,$release $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') try { [IO.Directory]::CreateDirectory($stageRoot) | Out-Null $payloadRoot = Join-Path $stageRoot 'payload' @@ -204,6 +205,9 @@ try { } } $installerPayloadManifestSha256 = Get-CcodBuildFileSha256 -Path $installerPayloadManifestPath + $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 @@ -275,7 +279,7 @@ $iscc = $isccCandidates | Where-Object { -not [string]::IsNullOrWhiteSpace($_) - 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" "/DInstallerPayloadDirectory=$installerPayloadDirectory" "/DInstallerPayloadManifestSha256=$installerPayloadManifestSha256" "/O$dist\." $issPath +& $iscc "/DProjectVersion=$Version" "/DTrayHostArtifactDirectory=$trayHostArtifact" "/DPortableArtifactDirectory=$portableArtifact" "/DInstallerPayloadDirectory=$installerPayloadDirectory" "/DInstallerPayloadManifestSha256=$installerPayloadManifestSha256" "/DInstallerDestinationInventoryInclude=$installerDestinationInventoryPath" "/O$dist\." $issPath if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $setupExe -PathType Leaf)) { throw "Inno Setup compilation failed with exit code $LASTEXITCODE" } @@ -313,3 +317,12 @@ if ([IO.Directory]::Exists($installerPayloadDirectory)) { Remove-Item -LiteralPath $installerPayloadFull -Recurse -Force -ErrorAction SilentlyContinue } } +if ([IO.File]::Exists($installerDestinationInventoryPath)) { + $inventoryFull = [IO.Path]::GetFullPath($installerDestinationInventoryPath) + $buildFull = [IO.Path]::GetFullPath($PSScriptRoot).TrimEnd('\') + '\' + if ($inventoryFull.StartsWith($buildFull,[StringComparison]::OrdinalIgnoreCase) -and + [IO.Path]::GetFileName($inventoryFull).StartsWith('.installer-destination-inventory-',[StringComparison]::Ordinal) -and + -not ((Get-Item -LiteralPath $inventoryFull -Force).Attributes -band [IO.FileAttributes]::ReparsePoint)) { + Remove-Item -LiteralPath $inventoryFull -Force -ErrorAction SilentlyContinue + } +} diff --git a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 index d51bf1b..1013c1b 100644 --- a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 +++ b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 @@ -104,6 +104,23 @@ Invoke-CcodTest 'production installer payload generator writes ordered version-b } } +Invoke-CcodTest 'production setup destination inventory derives every nested directory from Inno sources and payload input' { + $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' + & (Join-Path $repositoryRoot 'tools\New-InstallerDestinationInventory.ps1') -RepositoryRoot $repositoryRoot -PayloadRoot $payload -ProjectVersion '2.5.22' -InnoScriptPath (Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss') -OutputPath $output | Out-Null + $inventory = [IO.File]::ReadAllText($output,[Text.UTF8Encoding]::new($false)) + foreach ($relative in @('src','src\persistence','src\persistence\modules','payload','payload\2.5.22','payload\2.5.22\src','payload\2.5.22\src\persistence','payload\2.5.22\src\persistence\modules')) { + Assert-CcodTrue ($inventory.Contains("Directories.Add('$relative');")) "generated setup inventory contains $relative" + } + } finally { + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } +} + function New-CcodActivationPayloadFixture { param([string]$Version = '2.5.22') @@ -138,7 +155,7 @@ function Invoke-CcodActivationVerifierFixture { } function Invoke-CcodInnoPayloadCompileFixture { - param([switch]$IncludePayloadDefines) + param([switch]$IncludePayloadDefines,[switch]$OmitDestinationInventory) $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-inno-payload-contract-' + [guid]::NewGuid().ToString('N')) $tray = Join-Path $root 'tray' @@ -157,6 +174,8 @@ function Invoke-CcodInnoPayloadCompileFixture { $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' + & (Join-Path $repositoryRoot 'tools\New-InstallerDestinationInventory.ps1') -RepositoryRoot $repositoryRoot -PayloadRoot $payload -ProjectVersion '2.5.21' -InnoScriptPath (Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss') -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'), @@ -165,7 +184,9 @@ function Invoke-CcodInnoPayloadCompileFixture { if (-not $iscc) { throw 'Inno Setup 6 is required for the setup payload contract' } $arguments = @('/DProjectVersion=2.5.21',"/DTrayHostArtifactDirectory=$tray","/DPortableArtifactDirectory=$portable","/O$output\",(Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss')) if ($IncludePayloadDefines) { - $arguments = @('/DProjectVersion=2.5.21',"/DTrayHostArtifactDirectory=$tray","/DPortableArtifactDirectory=$portable","/DInstallerPayloadDirectory=$payload","/DInstallerPayloadManifestSha256=$(Get-CcodTestFileSha256 -Path $manifestPath)","/O$output\",(Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss')) + $arguments = @('/DProjectVersion=2.5.21',"/DTrayHostArtifactDirectory=$tray","/DPortableArtifactDirectory=$portable","/DInstallerPayloadDirectory=$payload","/DInstallerPayloadManifestSha256=$(Get-CcodTestFileSha256 -Path $manifestPath)") + if (-not $OmitDestinationInventory) { $arguments += "/DInstallerDestinationInventoryInclude=$inventoryPath" } + $arguments += @("/O$output\",(Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss')) } $previousPreference = $ErrorActionPreference try { @@ -182,7 +203,7 @@ Invoke-CcodTest 'setup build and activation bind one immutable versioned payload $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 - Assert-CcodTrue ($build -cmatch 'New-InstallerPayloadManifest\.ps1' -and $build -cmatch 'InstallerPayloadManifestSha256') 'build invokes the tested generator and binds its manifest hash into setup compilation' + Assert-CcodTrue ($build -cmatch 'New-InstallerPayloadManifest\.ps1' -and $build -cmatch 'New-InstallerDestinationInventory\.ps1' -and $build -cmatch 'InstallerPayloadManifestSha256' -and $build -cmatch 'InstallerDestinationInventoryInclude') 'build invokes the tested generators and binds their outputs into setup compilation' Assert-CcodTrue ($inno -cmatch 'InstallerPayloadDirectory' -and $inno -cmatch 'DestDir:\s*"\{app\}\\payload\\\{#ProjectVersion\}"') 'Inno copies the immutable build payload into its exact version directory' Assert-CcodTrue ($inno -cmatch "ExpandConstant\('\{app\}\\payload\\\{#ProjectVersion\}'\)" -and $inno -cmatch '-ExpectedVersion\s+"\{#ProjectVersion\}') 'Inno binds activation to its compiled payload version' Assert-CcodTrue ($activation -cmatch '\[string\]\$ExpectedVersion' -and $activation -cmatch '\[string\]\$ExpectedPayloadManifestSha256' -and $activation -cmatch 'installer-payload\.manifest\.json') 'activation accepts and resolves the expected payload contract' @@ -306,7 +327,18 @@ Invoke-CcodTest 'Inno compile packages the explicit manifest-bound installer pay 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.Output -cmatch 'installer-payload\.manifest\.json' -and $compile.Output -cmatch 'payload\\package\.json') 'compiler input trace contains the exact payload manifest and manifest-listed file' + Assert-CcodTrue ($compile.Output -cmatch 'InstallerDestinationInventory\.iss' -and $compile.Output -cmatch 'installer-payload\.manifest\.json' -and $compile.Output -cmatch 'payload\\package\.json') 'compiler input trace contains the destination inventory, payload manifest, and manifest-listed file' + } finally { + if (Test-Path -LiteralPath $compile.Root) { Remove-Item -LiteralPath $compile.Root -Recurse -Force } + } +} + +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 when only the destination inventory define is omitted' + Assert-CcodTrue ($compile.Output -cmatch 'InstallerDestinationInventoryInclude') 'compiler identifies the missing generated inventory define' + Assert-CcodTrue (-not (Test-Path -LiteralPath $compile.SetupPath)) 'missing inventory define produces no setup artifact' } finally { if (Test-Path -LiteralPath $compile.Root) { Remove-Item -LiteralPath $compile.Root -Recurse -Force } } @@ -318,7 +350,9 @@ Invoke-CcodTest 'Inno exposes a pre-write payload-directory reparse gate' { Assert-CcodTrue $helper.Success 'production Inno script exposes the directory predicate used before payload writes' $treeHelper = [regex]::Match($inno,'(?ms)^function IsSafeExistingSetupTree\(.*?^end;') Assert-CcodTrue $treeHelper.Success 'production Inno script exposes a recursive destination-tree predicate' - Assert-CcodTrue ($inno -cmatch '(?ms)^function PrepareToInstall\(var NeedsRestart: Boolean\): String;.*?IsSafeExistingSetupTree') 'PrepareToInstall rejects unsafe nested setup destinations before file copy' + $inventoryValidator = [regex]::Match($inno,'(?ms)^function AreCcodExpectedSetupDirectoriesSafe\(.*?^end;') + Assert-CcodTrue $inventoryValidator.Success 'production Inno script exposes a generated destination-inventory validator' + Assert-CcodTrue ($inno -cmatch 'InstallerDestinationInventoryInclude' -and $inno -cmatch '(?ms)^function PrepareToInstall\(var NeedsRestart: Boolean\): String;.*?AreCcodExpectedSetupDirectoriesSafe') 'PrepareToInstall consumes the compile-bound generated inventory before file copy' $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-inno-reparse-harness-' + [guid]::NewGuid().ToString('N')) try { $normal = Join-Path $root 'normal' @@ -328,6 +362,12 @@ Invoke-CcodTest 'Inno exposes a pre-write payload-directory reparse gate' { $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' + $fileWriteMarker = Join-Path $expectedFileRoot 'payload-write-marker.txt' + $junctionWriteMarker = Join-Path $expectedJunctionRoot 'payload-write-marker.txt' $resultPath = Join-Path $root 'result.txt' [IO.Directory]::CreateDirectory($normal) | Out-Null [IO.Directory]::CreateDirectory($target) | Out-Null @@ -335,6 +375,16 @@ Invoke-CcodTest 'Inno exposes a pre-write payload-directory reparse gate' { 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((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] @@ -354,13 +404,22 @@ function GetFileAttributesW(const FileName: String): Cardinal; external 'GetFileAttributesW@kernel32.dll stdcall'; $($helper.Value) $($treeHelper.Value) +$inventorySource +$($inventoryValidator.Value) function InitializeSetup(): Boolean; begin + if AreCcodExpectedSetupDirectoriesSafe('$($expectedFileRoot.Replace("'","''"))') then + SaveStringToFile('$($fileWriteMarker.Replace("'","''"))','unsafe write',False); + if AreCcodExpectedSetupDirectoriesSafe('$($expectedJunctionRoot.Replace("'","''"))') then + SaveStringToFile('$($junctionWriteMarker.Replace("'","''"))','unsafe write',False); if IsSafeExistingPayloadDirectory('$($normal.Replace("'","''"))') and IsSafeExistingPayloadDirectory('$($missing.Replace("'","''"))') and (not IsSafeExistingPayloadDirectory('$($junction.Replace("'","''"))')) and (not IsSafeExistingSetupTree('$($tree.Replace("'","''"))')) and - (not IsSafeExistingSetupTree('$($fileDirectory.Replace("'","''"))')) then + (not IsSafeExistingSetupTree('$($fileDirectory.Replace("'","''"))')) and + AreCcodExpectedSetupDirectoriesSafe('$($normalExpectedRoot.Replace("'","''"))') and + (not AreCcodExpectedSetupDirectoriesSafe('$($expectedFileRoot.Replace("'","''"))')) and + (not AreCcodExpectedSetupDirectoriesSafe('$($expectedJunctionRoot.Replace("'","''"))')) then SaveStringToFile('$($resultPath.Replace("'","''"))','pass',False); Result := False; end; @@ -372,6 +431,8 @@ end; $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' } finally { if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } } diff --git a/tools/New-InstallerDestinationInventory.ps1 b/tools/New-InstallerDestinationInventory.ps1 new file mode 100644 index 0000000..6c0ba4b --- /dev/null +++ b/tools/New-InstallerDestinationInventory.ps1 @@ -0,0 +1,83 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$RepositoryRoot, + [Parameter(Mandatory)][string]$PayloadRoot, + [Parameter(Mandatory)][string]$ProjectVersion, + [Parameter(Mandatory)][string]$InnoScriptPath, + [Parameter(Mandatory)][string]$OutputPath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if ($ProjectVersion -cnotmatch '^\d+\.\d+\.\d+$') { throw "Invalid project version: $ProjectVersion" } +$repository = [IO.Path]::GetFullPath($RepositoryRoot).TrimEnd('\') +$payload = [IO.Path]::GetFullPath($PayloadRoot).TrimEnd('\') +$inno = [IO.Path]::GetFullPath($InnoScriptPath) +$output = [IO.Path]::GetFullPath($OutputPath) +if (-not [IO.Directory]::Exists($repository) -or -not [IO.Directory]::Exists($payload) -or -not [IO.File]::Exists($inno)) { throw 'Setup destination inventory input is missing.' } +if ([IO.File]::Exists($output) -or [IO.Directory]::Exists($output)) { throw "Refusing to overwrite setup destination inventory: $output" } + +$directories = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) +function Add-CcodExpectedDirectory { + param([Parameter(Mandatory)][AllowEmptyString()][string]$Relative) + $normalized = $Relative.Replace('/','\').Trim('\') + if ([string]::IsNullOrWhiteSpace($normalized)) { return } + if ($normalized -notmatch '^[A-Za-z0-9._-]+(?:\\[A-Za-z0-9._-]+)*$') { throw "Unsafe setup destination directory: $normalized" } + $segments = $normalized -split '\\' + $current = '' + foreach ($segment in $segments) { + $current = if ([string]::IsNullOrEmpty($current)) { $segment } else { "$current\$segment" } + [void]$directories.Add($current) + } +} + +$lines = @(Get-Content -LiteralPath $inno -Encoding UTF8) +$insideFiles = $false +foreach ($line in $lines) { + if ($line -ceq '[Files]') { $insideFiles = $true; continue } + if ($insideFiles -and $line -match '^\[') { break } + if (-not $insideFiles -or [string]::IsNullOrWhiteSpace($line)) { continue } + $match = [regex]::Match($line,'^Source:\s*"(?[^"]+)";\s*DestDir:\s*"(?[^"]+)";(?.*)$') + if (-not $match.Success) { throw "Unsupported [Files] entry for destination inventory: $line" } + $destination = $match.Groups['destination'].Value.Replace('{#ProjectVersion}',$ProjectVersion) + if (-not $destination.StartsWith('{app}',[StringComparison]::Ordinal)) { throw "Setup destination is outside app root: $destination" } + $destinationRelative = $destination.Substring(5).TrimStart('\') + Add-CcodExpectedDirectory -Relative $destinationRelative + if ($match.Groups['tail'].Value -cnotmatch '(?:^|\s)recursesubdirs(?:\s|$)') { continue } + + $sourceExpression = $match.Groups['source'].Value + $sourceRoot = $null + if ($sourceExpression.StartsWith('{#InstallerPayloadDirectory}',[StringComparison]::Ordinal)) { + $sourceRoot = $payload + } elseif ($sourceExpression.StartsWith('..\',[StringComparison]::Ordinal) -and $sourceExpression.EndsWith('\*',[StringComparison]::Ordinal)) { + $sourceRoot = [IO.Path]::GetFullPath((Join-Path (Split-Path $inno -Parent) $sourceExpression.Substring(0,$sourceExpression.Length-2))) + } else { + throw "Recursive [Files] source cannot be inventoried deterministically: $sourceExpression" + } + if (-not [IO.Directory]::Exists($sourceRoot)) { throw "Recursive [Files] source root is missing: $sourceRoot" } + $sourcePrefix = $sourceRoot.TrimEnd('\') + '\' + foreach ($entry in @(Get-ChildItem -LiteralPath $sourceRoot -Force -Recurse -ErrorAction Stop)) { + if (($entry.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "Recursive [Files] source contains a reparse point: $($entry.FullName)" } + $relativeDirectory = if ($entry.PSIsContainer) { + $entry.FullName.Substring($sourcePrefix.Length) + } else { + $parent = Split-Path $entry.FullName -Parent + if ($parent.Equals($sourceRoot,[StringComparison]::OrdinalIgnoreCase)) { '' } else { $parent.Substring($sourcePrefix.Length) } + } + if (-not [string]::IsNullOrWhiteSpace($relativeDirectory)) { + Add-CcodExpectedDirectory -Relative $(if ([string]::IsNullOrWhiteSpace($destinationRelative)) { $relativeDirectory } else { "$destinationRelative\$relativeDirectory" }) + } + } +} +if (-not $insideFiles -or $directories.Count -eq 0) { throw 'No setup destination directories were generated.' } +$ordered = [Collections.Generic.List[string]]::new() +foreach ($directory in $directories) { $ordered.Add($directory) } +$ordered.Sort([StringComparer]::Ordinal) +$builder = [Text.StringBuilder]::new() +[void]$builder.AppendLine('procedure AddCcodExpectedSetupDirectories(Directories: TStrings);') +[void]$builder.AppendLine('begin') +foreach ($directory in $ordered) { [void]$builder.AppendLine(" Directories.Add('$directory');") } +[void]$builder.AppendLine('end;') +[IO.File]::WriteAllText($output,$builder.ToString(),[Text.UTF8Encoding]::new($false)) +Write-Output ([pscustomobject]@{Path=$output;Directories=@($ordered)}) From 8611e73b2c51761c260e064b649d86f925735968 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 02:59:37 +0800 Subject: [PATCH 007/187] fix: require one Inno Files section --- .../persistence/ReleaseWorkflow.SelfTest.ps1 | 35 +++++++++++++++++++ tools/New-InstallerDestinationInventory.ps1 | 4 ++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 index 1013c1b..0c00285 100644 --- a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 +++ b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 @@ -121,6 +121,41 @@ Invoke-CcodTest 'production setup destination inventory derives every nested dir } } +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 } + } +} + function New-CcodActivationPayloadFixture { param([string]$Version = '2.5.22') diff --git a/tools/New-InstallerDestinationInventory.ps1 b/tools/New-InstallerDestinationInventory.ps1 index 6c0ba4b..1475da2 100644 --- a/tools/New-InstallerDestinationInventory.ps1 +++ b/tools/New-InstallerDestinationInventory.ps1 @@ -33,9 +33,11 @@ function Add-CcodExpectedDirectory { } $lines = @(Get-Content -LiteralPath $inno -Encoding UTF8) +$filesSectionCount = @($lines | Where-Object { $_ -match '^\s*\[Files\]\s*$' }).Count +if ($filesSectionCount -ne 1) { throw "Setup destination inventory requires exactly one [Files] section; found $filesSectionCount." } $insideFiles = $false foreach ($line in $lines) { - if ($line -ceq '[Files]') { $insideFiles = $true; continue } + if ($line -match '^\s*\[Files\]\s*$') { $insideFiles = $true; continue } if ($insideFiles -and $line -match '^\[') { break } if (-not $insideFiles -or [string]::IsNullOrWhiteSpace($line)) { continue } $match = [regex]::Match($line,'^Source:\s*"(?[^"]+)";\s*DestDir:\s*"(?[^"]+)";(?.*)$') From 8ac6106eb0f6ee0389ec4d9fbbf019e713f852b6 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 03:21:24 +0800 Subject: [PATCH 008/187] fix: enforce Inno inventory include boundary --- build/build.ps1 | 46 +++++++- .../persistence/ReleaseWorkflow.SelfTest.ps1 | 110 +++++++++++++++++- tools/New-InstallerDestinationInventory.ps1 | 6 + 3 files changed, 155 insertions(+), 7 deletions(-) diff --git a/build/build.ps1 b/build/build.ps1 index da5a147..c89f84b 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,34 @@ 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 Invoke-CcodBuildInnoCompiler { + param( + [Parameter(Mandatory)][string]$InventoryPath, + [Parameter(Mandatory)][string]$IsccPath, + [Parameter(Mandatory)][AllowEmptyCollection()][string[]]$Arguments, + [Parameter(Mandatory)][string]$SetupPath + ) + Assert-CcodBuildInstallerDestinationInventory -Path $InventoryPath | Out-Null + & $IsccPath @Arguments + $compilerExitCode = $LASTEXITCODE + if ($compilerExitCode -ne 0 -or -not (Test-Path -LiteralPath $SetupPath -PathType Leaf)) { + throw "Inno Setup compilation failed with exit code $compilerExitCode" + } +} + +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') @@ -279,10 +308,17 @@ $iscc = $isccCandidates | Where-Object { -not [string]::IsNullOrWhiteSpace($_) - 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" "/DInstallerPayloadDirectory=$installerPayloadDirectory" "/DInstallerPayloadManifestSha256=$installerPayloadManifestSha256" "/DInstallerDestinationInventoryInclude=$installerDestinationInventoryPath" "/O$dist\." $issPath -if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $setupExe -PathType Leaf)) { - throw "Inno Setup compilation failed with exit code $LASTEXITCODE" -} +$isccArguments = @( + "/DProjectVersion=$Version", + "/DTrayHostArtifactDirectory=$trayHostArtifact", + "/DPortableArtifactDirectory=$portableArtifact", + "/DInstallerPayloadDirectory=$installerPayloadDirectory", + "/DInstallerPayloadManifestSha256=$installerPayloadManifestSha256", + "/DInstallerDestinationInventoryInclude=$installerDestinationInventoryPath", + "/O$dist\.", + $issPath +) +Invoke-CcodBuildInnoCompiler -InventoryPath $installerDestinationInventoryPath -IsccPath $iscc -Arguments $isccArguments -SetupPath $setupExe $setupHash = Get-CcodBuildFileSha256 -Path $setupExe Write-CcodBuildUtf8 -Path $setupChecksum -Text ("{0} *{1}" -f $setupHash,[IO.Path]::GetFileName($setupExe)) $setupReleaseManifest = Join-Path $dist "CodexRemote-fix-$Version-setup-release-manifest.json" diff --git a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 index 0c00285..e203d90 100644 --- a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 +++ b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 @@ -104,14 +104,17 @@ Invoke-CcodTest 'production installer payload generator writes ordered version-b } } -Invoke-CcodTest 'production setup destination inventory derives every nested directory from Inno sources and payload input' { +Invoke-CcodTest 'production setup destination inventory accepts the literal symbolic include and derives every nested directory' { $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' - & (Join-Path $repositoryRoot 'tools\New-InstallerDestinationInventory.ps1') -RepositoryRoot $repositoryRoot -PayloadRoot $payload -ProjectVersion '2.5.22' -InnoScriptPath (Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss') -OutputPath $output | Out-Null + $innoPath = Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss' + $allowedIncludes = @([IO.File]::ReadAllLines($innoPath,[Text.UTF8Encoding]::new($false)) | Where-Object { [string]$_ -ceq '#include InstallerDestinationInventoryInclude' }) + Assert-CcodEqual 1 $allowedIncludes.Count 'production fixture exercises the one permitted literal 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)) foreach ($relative in @('src','src\persistence','src\persistence\modules','payload','payload\2.5.22','payload\2.5.22\src','payload\2.5.22\src\persistence','payload\2.5.22\src\persistence\modules')) { Assert-CcodTrue ($inventory.Contains("Directories.Add('$relative');")) "generated setup inventory contains $relative" @@ -156,6 +159,109 @@ Source: "fixture-b.txt"; DestDir: "{app}\second"; Flags: ignoreversion } } +Invoke-CcodTest 'production setup destination inventory rejects an included second Files section before ISCC' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-installer-included-files-section-' + [guid]::NewGuid().ToString('N')) + try { + $payload = Join-Path $root 'payload' + $setupOutput = Join-Path $root 'setup-output' + [IO.Directory]::CreateDirectory($payload) | Out-Null + [IO.Directory]::CreateDirectory($setupOutput) | 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)) + $innoPath = Join-Path $root 'IncludedFilesSection.iss' + $innoSource = @" +[Setup] +AppName=IncludedFilesSection +AppVersion=1.0.0 +DefaultDirName={tmp}\IncludedFilesSection +OutputDir=$setupOutput +OutputBaseFilename=IncludedFilesSection +Uninstallable=no +[Files] +Source: "$firstSource"; DestDir: "{app}\first"; Flags: ignoreversion +[Code] +procedure Fixture(); +begin +end; +#include "extra.iss" +"@ + [IO.File]::WriteAllText($innoPath,$innoSource,[Text.UTF8Encoding]::new($false)) + $inventoryPath = Join-Path $root 'Inventory.iss' + $setupPath = Join-Path $setupOutput 'IncludedFilesSection.exe' + $generatorFailure = $null + $compileExitCode = $null + try { + & (Join-Path $repositoryRoot 'tools\New-InstallerDestinationInventory.ps1') -RepositoryRoot $repositoryRoot -PayloadRoot $payload -ProjectVersion '2.5.22' -InnoScriptPath $innoPath -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 include-boundary fixture' } + $previousPreference = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + $null = @(& $iscc $innoPath 2>&1) + $compileExitCode = $LASTEXITCODE + } finally { $ErrorActionPreference = $previousPreference } + } catch { $generatorFailure = $_ } + Assert-CcodTrue ($null -ne $generatorFailure) ("generator rejects the arbitrary include before ISCC; inventory={0} compileExit={1} setup={2}" -f [IO.File]::Exists($inventoryPath),$compileExitCode,[IO.File]::Exists($setupPath)) + Assert-CcodTrue ($generatorFailure.Exception.Message -cmatch 'literal include directive') 'arbitrary include failure identifies the literal include boundary' + Assert-CcodTrue (-not [IO.File]::Exists($inventoryPath)) 'rejected include produces no destination inventory artifact' + Assert-CcodTrue (-not [IO.File]::Exists($setupPath)) 'rejected include produces no setup artifact' + } finally { + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } +} + +Invoke-CcodTest 'build validates generated inventory before invoking ISCC' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-installer-inventory-header-' + [guid]::NewGuid().ToString('N')) + try { + [IO.Directory]::CreateDirectory($root) | Out-Null + $inventoryPath = Join-Path $root 'Inventory.iss' + $inventorySource = @' +procedure AddCcodExpectedSetupDirectories(Directories: TStrings); +begin +end; +[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 -InventoryPath $inventoryPath -IsccPath $compilerPath -Arguments @() -SetupPath $setupPath + } catch { $failure = $_ } + Assert-CcodTrue ($null -ne $failure) 'build rejects a generated inventory carrying an Inno section header' + Assert-CcodTrue ($failure.Exception.Message -cmatch 'section header') 'inventory validation explains the forbidden section header' + Assert-CcodTrue (-not [IO.File]::Exists($compilerMarker)) 'ISCC is not invoked after inventory validation fails' + Assert-CcodTrue (-not [IO.File]::Exists($setupPath)) 'invalid inventory produces no setup artifact' + $validInventorySource = @' +procedure AddCcodExpectedSetupDirectories(Directories: TStrings); +begin +end; +'@ + [IO.File]::WriteAllText($inventoryPath,$validInventorySource,[Text.UTF8Encoding]::new($false)) + Invoke-CcodBuildInnoCompiler -InventoryPath $inventoryPath -IsccPath $compilerPath -Arguments @() -SetupPath $setupPath + Assert-CcodTrue ([IO.File]::Exists($compilerMarker)) 'validated inventory reaches ISCC' + Assert-CcodTrue ([IO.File]::Exists($setupPath)) 'validated inventory accepts the compiler setup artifact' + } finally { + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } +} + function New-CcodActivationPayloadFixture { param([string]$Version = '2.5.22') diff --git a/tools/New-InstallerDestinationInventory.ps1 b/tools/New-InstallerDestinationInventory.ps1 index 1475da2..4274730 100644 --- a/tools/New-InstallerDestinationInventory.ps1 +++ b/tools/New-InstallerDestinationInventory.ps1 @@ -33,6 +33,12 @@ function Add-CcodExpectedDirectory { } $lines = @(Get-Content -LiteralPath $inno -Encoding UTF8) +$includeDirectives = @($lines | Where-Object { $_ -match '^\s*#\s*include\b' }) +$allowedIncludeDirective = '#include InstallerDestinationInventoryInclude' +if ($includeDirectives.Count -gt 1 -or + ($includeDirectives.Count -eq 1 -and [string]$includeDirectives[0] -cne $allowedIncludeDirective)) { + throw "Setup destination inventory permits only one literal include directive: $allowedIncludeDirective" +} $filesSectionCount = @($lines | Where-Object { $_ -match '^\s*\[Files\]\s*$' }).Count if ($filesSectionCount -ne 1) { throw "Setup destination inventory requires exactly one [Files] section; found $filesSectionCount." } $insideFiles = $false From c81987e07efc69e7bbe13d7cc08b045afddeb8bd Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 03:31:49 +0800 Subject: [PATCH 009/187] docs: replace Inno include parsing --- ...08-28-v2522-install-runtime-reliability.md | 64 +++++++++++++++++++ ...2522-install-runtime-reliability-design.md | 7 ++ 2 files changed, 71 insertions(+) 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 index 49ea10c..739a622 100644 --- a/docs/superpowers/plans/2026-08-28-v2522-install-runtime-reliability.md +++ b/docs/superpowers/plans/2026-08-28-v2522-install-runtime-reliability.md @@ -92,6 +92,68 @@ fixtures fail before active-pointer mutation. fix: bind setup activation to immutable payload ``` +### Task 1b: 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 rejects all external `#include` and `#+` 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 an actual ISCC test showing that the generated script compiles with its +injected inventory, while a template with either include spelling is 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 include-alias fixture is accepted before the new template +boundary exists. + +- [ ] **Step 3: Implement the generated include-free compiler input** + +Replace the source include with one unique marker. Build reads the checked-in +template, refuses any `#include` or `#+` directive, substitutes the generated +inventory text once, writes a GUID-named temporary `.iss`, validates that the +result has no marker or external include, and passes that generated path to +ISCC. Clean the temporary script in `finally`. + +- [ ] **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 and arbitrary include aliases +leave neither setup nor inventory output. + +- [ ] **Step 5: Commit** + +```text +fix: compile include-free setup inventory +``` + ### Task 2: Retry only transient Electron tree reads **Files:** @@ -256,6 +318,8 @@ release: prepare CodexRemote-fix v2.5.22 - Task 1 produces the immutable source/expected-version contract consumed by the new setup flow and release contract. +- Task 1b removes the preprocessor include ambiguity from the Task 1 setup + source before any release compiler invocation. - Task 2 changes only pre-mutation tree acquisition and explicitly leaves all identity and post-stop checks strict. - Task 3 has no dependency on Task 2 and can be reviewed independently; its 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 index 021295f..a57c866 100644 --- 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 @@ -55,6 +55,13 @@ 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 contains no external `#include` or `#+` directive; +build rejects both spellings before ISCC runs. This avoids attempting to +reimplement ISPP preprocessing 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 From ec4194f3791142bbed65150c4466f8c9b8ca5b1e Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 03:33:57 +0800 Subject: [PATCH 010/187] docs: number include boundary task --- ...26-08-28-v2522-install-runtime-reliability.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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 index 739a622..f83ada1 100644 --- a/docs/superpowers/plans/2026-08-28-v2522-install-runtime-reliability.md +++ b/docs/superpowers/plans/2026-08-28-v2522-install-runtime-reliability.md @@ -92,7 +92,7 @@ fixtures fail before active-pointer mutation. fix: bind setup activation to immutable payload ``` -### Task 1b: Compile an include-free generated setup script +### Task 2: Compile an include-free generated setup script **Files:** - Modify: `build/build.ps1` @@ -154,7 +154,7 @@ leave neither setup nor inventory output. fix: compile include-free setup inventory ``` -### Task 2: Retry only transient Electron tree reads +### Task 3: Retry only transient Electron tree reads **Files:** - Modify: `src/persistence/modules/ProcessControl.psm1` @@ -221,7 +221,7 @@ member. fix: stabilize verified Electron process trees ``` -### Task 3: Make tray action correlation testable and diagnosable +### Task 4: Make tray action correlation testable and diagnosable **Files:** - Modify: `src/persistence/modules/TrayHostClient.psm1` @@ -274,7 +274,7 @@ exact terminal code into local diagnostics. test: cover tray action revision correlation ``` -### Task 4: Version, documentation, and release acceptance +### Task 5: Version, documentation, and release acceptance **Files:** - Modify: `package.json` @@ -318,13 +318,13 @@ release: prepare CodexRemote-fix v2.5.22 - Task 1 produces the immutable source/expected-version contract consumed by the new setup flow and release contract. -- Task 1b removes the preprocessor include ambiguity from the Task 1 setup +- Task 2 removes the preprocessor include ambiguity from the Task 1 setup source before any release compiler invocation. -- Task 2 changes only pre-mutation tree acquisition and explicitly leaves all +- Task 3 changes only pre-mutation tree acquisition and explicitly leaves all identity and post-stop checks strict. -- Task 3 has no dependency on Task 2 and can be reviewed independently; its +- 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 4 consumes the completed behavior and performs no release publication; +- Task 5 consumes the completed behavior and performs no release publication; publishing, installation, and reboot remain a separately authorized final acceptance step. From 31323b53659799af51478782cbea8bcf216ef1ad Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 03:46:52 +0800 Subject: [PATCH 011/187] fix: compile include-free setup inventory --- build/CodexControlOtherDevices.iss | 5 +- build/build.ps1 | 91 +++++- .../persistence/ReleaseWorkflow.SelfTest.ps1 | 301 +++++++++++++----- tools/New-InstallerDestinationInventory.ps1 | 8 +- 4 files changed, 310 insertions(+), 95 deletions(-) diff --git a/build/CodexControlOtherDevices.iss b/build/CodexControlOtherDevices.iss index c9da999..ff42b79 100644 --- a/build/CodexControlOtherDevices.iss +++ b/build/CodexControlOtherDevices.iss @@ -11,9 +11,6 @@ #ifndef InstallerPayloadManifestSha256 #error InstallerPayloadManifestSha256 must be supplied by the release builder #endif -#ifndef InstallerDestinationInventoryInclude -#error InstallerDestinationInventoryInclude must be supplied by the release builder -#endif AppId={{2B9E9F2E-7A32-4A7E-9C1D-9F5B5C6D7E8F} AppName=CodexRemote-fix AppVersion={#ProjectVersion} @@ -99,7 +96,7 @@ Name: "{group}\Uninstall CodexRemote-fix"; Filename: "{app}\unins000.exe"; IconF 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" [Code] -#include InstallerDestinationInventoryInclude +// CCOD_INSTALLER_DESTINATION_INVENTORY const ACTIVATION_TIMEOUT_MILLISECONDS = 300000; FIRST_ACTIVATION_RECEIPT_TIMEOUT_MILLISECONDS = 90000; diff --git a/build/build.ps1 b/build/build.ps1 index c89f84b..426b0f3 100644 --- a/build/build.ps1 +++ b/build/build.ps1 @@ -116,18 +116,93 @@ function Assert-CcodBuildInstallerDestinationInventory { return $inventoryPath } +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)) + if ($templateSource -match '(?m)^\s*#\s*(?:include\b|\+)') { + throw 'The Inno Setup template must be include-free; external #include and #+ directives are not permitted.' + } + $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.' + } + if ($generatedSource -match '(?m)^\s*#\s*(?:include\b|\+)') { + throw 'The generated Inno Setup script contains an external #include or #+ directive.' + } + + 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 ) - Assert-CcodBuildInstallerDestinationInventory -Path $InventoryPath | Out-Null - & $IsccPath @Arguments - $compilerExitCode = $LASTEXITCODE - if ($compilerExitCode -ne 0 -or -not (Test-Path -LiteralPath $SetupPath -PathType Leaf)) { - throw "Inno Setup compilation failed with exit code $compilerExitCode" + $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 + } } } @@ -314,11 +389,9 @@ $isccArguments = @( "/DPortableArtifactDirectory=$portableArtifact", "/DInstallerPayloadDirectory=$installerPayloadDirectory", "/DInstallerPayloadManifestSha256=$installerPayloadManifestSha256", - "/DInstallerDestinationInventoryInclude=$installerDestinationInventoryPath", - "/O$dist\.", - $issPath + "/O$dist\." ) -Invoke-CcodBuildInnoCompiler -InventoryPath $installerDestinationInventoryPath -IsccPath $iscc -Arguments $isccArguments -SetupPath $setupExe +Invoke-CcodBuildInnoCompiler -TemplatePath $issPath -InventoryPath $installerDestinationInventoryPath -IsccPath $iscc -Arguments $isccArguments -SetupPath $setupExe $setupHash = Get-CcodBuildFileSha256 -Path $setupExe Write-CcodBuildUtf8 -Path $setupChecksum -Text ("{0} *{1}" -f $setupHash,[IO.Path]::GetFileName($setupExe)) $setupReleaseManifest = Join-Path $dist "CodexRemote-fix-$Version-setup-release-manifest.json" diff --git a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 index e203d90..af07a01 100644 --- a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 +++ b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 @@ -104,7 +104,7 @@ Invoke-CcodTest 'production installer payload generator writes ordered version-b } } -Invoke-CcodTest 'production setup destination inventory accepts the literal symbolic include and derives every nested directory' { +Invoke-CcodTest 'production setup template has one inventory marker, no external includes, and derives every nested directory' { $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-installer-directory-inventory-' + [guid]::NewGuid().ToString('N')) try { $payload = Join-Path $root 'payload' @@ -112,8 +112,11 @@ Invoke-CcodTest 'production setup destination inventory accepts the literal symb [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' - $allowedIncludes = @([IO.File]::ReadAllLines($innoPath,[Text.UTF8Encoding]::new($false)) | Where-Object { [string]$_ -ceq '#include InstallerDestinationInventoryInclude' }) - Assert-CcodEqual 1 $allowedIncludes.Count 'production fixture exercises the one permitted literal include directive' + $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)) foreach ($relative in @('src','src\persistence','src\persistence\modules','payload','payload\2.5.22','payload\2.5.22\src','payload\2.5.22\src\persistence','payload\2.5.22\src\persistence\modules')) { @@ -159,104 +162,220 @@ Source: "fixture-b.txt"; DestDir: "{app}\second"; Flags: ignoreversion } } -Invoke-CcodTest 'production setup destination inventory rejects an included second Files section before ISCC' { - $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-installer-included-files-section-' + [guid]::NewGuid().ToString('N')) +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' - $setupOutput = Join-Path $root 'setup-output' [IO.Directory]::CreateDirectory($payload) | Out-Null - [IO.Directory]::CreateDirectory($setupOutput) | 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)) - $innoPath = Join-Path $root 'IncludedFilesSection.iss' - $innoSource = @" + $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=IncludedFilesSection +AppName=ExternalIncludeFixture AppVersion=1.0.0 -DefaultDirName={tmp}\IncludedFilesSection -OutputDir=$setupOutput -OutputBaseFilename=IncludedFilesSection -Uninstallable=no +DefaultDirName={app} [Files] Source: "$firstSource"; DestDir: "{app}\first"; Flags: ignoreversion [Code] procedure Fixture(); begin end; -#include "extra.iss" +$($fixture.Directive) "@ - [IO.File]::WriteAllText($innoPath,$innoSource,[Text.UTF8Encoding]::new($false)) - $inventoryPath = Join-Path $root 'Inventory.iss' - $setupPath = Join-Path $setupOutput 'IncludedFilesSection.exe' - $generatorFailure = $null - $compileExitCode = $null - try { - & (Join-Path $repositoryRoot 'tools\New-InstallerDestinationInventory.ps1') -RepositoryRoot $repositoryRoot -PayloadRoot $payload -ProjectVersion '2.5.22' -InnoScriptPath $innoPath -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 include-boundary fixture' } - $previousPreference = $ErrorActionPreference + [IO.File]::WriteAllText($innoPath,$innoSource,[Text.UTF8Encoding]::new($false)) + $inventoryPath = Join-Path $root (($fixture.Name -replace '[^A-Za-z]','') + '-Inventory.iss') + $generatorFailure = $null try { - $ErrorActionPreference = 'Continue' - $null = @(& $iscc $innoPath 2>&1) - $compileExitCode = $LASTEXITCODE - } finally { $ErrorActionPreference = $previousPreference } - } catch { $generatorFailure = $_ } - Assert-CcodTrue ($null -ne $generatorFailure) ("generator rejects the arbitrary include before ISCC; inventory={0} compileExit={1} setup={2}" -f [IO.File]::Exists($inventoryPath),$compileExitCode,[IO.File]::Exists($setupPath)) - Assert-CcodTrue ($generatorFailure.Exception.Message -cmatch 'literal include directive') 'arbitrary include failure identifies the literal include boundary' - Assert-CcodTrue (-not [IO.File]::Exists($inventoryPath)) 'rejected include produces no destination inventory artifact' - Assert-CcodTrue (-not [IO.File]::Exists($setupPath)) 'rejected include produces no setup artifact' + & $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 'build validates generated inventory before invoking ISCC' { - $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-installer-inventory-header-' + [guid]::NewGuid().ToString('N')) +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' - $inventorySource = @' + $validInventorySource = @' procedure AddCcodExpectedSetupDirectories(Directories: TStrings); begin end; -[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 + 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 -InventoryPath $inventoryPath -IsccPath $compilerPath -Arguments @() -SetupPath $setupPath - } catch { $failure = $_ } - Assert-CcodTrue ($null -ne $failure) 'build rejects a generated inventory carrying an Inno section header' - Assert-CcodTrue ($failure.Exception.Message -cmatch 'section header') 'inventory validation explains the forbidden section header' - Assert-CcodTrue (-not [IO.File]::Exists($compilerMarker)) 'ISCC is not invoked after inventory validation fails' + 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' - $validInventorySource = @' + 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 '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,$validInventorySource,[Text.UTF8Encoding]::new($false)) - Invoke-CcodBuildInnoCompiler -InventoryPath $inventoryPath -IsccPath $compilerPath -Arguments @() -SetupPath $setupPath - Assert-CcodTrue ([IO.File]::Exists($compilerMarker)) 'validated inventory reaches ISCC' - Assert-CcodTrue ([IO.File]::Exists($setupPath)) 'validated inventory accepts the compiler setup artifact' + [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 } } @@ -316,26 +435,50 @@ function Invoke-CcodInnoPayloadCompileFixture { $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' - & (Join-Path $repositoryRoot 'tools\New-InstallerDestinationInventory.ps1') -RepositoryRoot $repositoryRoot -PayloadRoot $payload -ProjectVersion '2.5.21' -InnoScriptPath (Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss') -OutputPath $inventoryPath | Out-Null + $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' } - $arguments = @('/DProjectVersion=2.5.21',"/DTrayHostArtifactDirectory=$tray","/DPortableArtifactDirectory=$portable","/O$output\",(Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss')) + $arguments = @('/DProjectVersion=2.5.21',"/DTrayHostArtifactDirectory=$tray","/DPortableArtifactDirectory=$portable","/O$output\") if ($IncludePayloadDefines) { $arguments = @('/DProjectVersion=2.5.21',"/DTrayHostArtifactDirectory=$tray","/DPortableArtifactDirectory=$portable","/DInstallerPayloadDirectory=$payload","/DInstallerPayloadManifestSha256=$(Get-CcodTestFileSha256 -Path $manifestPath)") - if (-not $OmitDestinationInventory) { $arguments += "/DInstallerDestinationInventoryInclude=$inventoryPath" } - $arguments += @("/O$output\",(Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss')) + $arguments += "/O$output\" } - $previousPreference = $ErrorActionPreference + . (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 { - $ErrorActionPreference = 'Continue' - $compileOutput = @(& $iscc @arguments 2>&1) - $exitCode = $LASTEXITCODE - } finally { $ErrorActionPreference = $previousPreference } - return [pscustomobject]@{Root=$root;ExitCode=$exitCode;Output=($compileOutput -join "`n");SetupPath=(Join-Path $output 'CodexRemote-fix-2.5.21-setup.exe')} + $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 + } } Invoke-CcodTest 'setup build and activation bind one immutable versioned payload end to end' { @@ -344,7 +487,7 @@ Invoke-CcodTest 'setup build and activation bind one immutable versioned payload $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 - Assert-CcodTrue ($build -cmatch 'New-InstallerPayloadManifest\.ps1' -and $build -cmatch 'New-InstallerDestinationInventory\.ps1' -and $build -cmatch 'InstallerPayloadManifestSha256' -and $build -cmatch 'InstallerDestinationInventoryInclude') 'build invokes the tested generators and binds their outputs into setup compilation' + Assert-CcodTrue ($build -cmatch 'New-InstallerPayloadManifest\.ps1' -and $build -cmatch 'New-InstallerDestinationInventory\.ps1' -and $build -cmatch 'InstallerPayloadManifestSha256' -and $build -cmatch 'New-CcodBuildGeneratedInnoScript') 'build invokes the tested generators and binds the inventory into generated setup compilation' Assert-CcodTrue ($inno -cmatch 'InstallerPayloadDirectory' -and $inno -cmatch 'DestDir:\s*"\{app\}\\payload\\\{#ProjectVersion\}"') 'Inno copies the immutable build payload into its exact version directory' Assert-CcodTrue ($inno -cmatch "ExpandConstant\('\{app\}\\payload\\\{#ProjectVersion\}'\)" -and $inno -cmatch '-ExpectedVersion\s+"\{#ProjectVersion\}') 'Inno binds activation to its compiled payload version' Assert-CcodTrue ($activation -cmatch '\[string\]\$ExpectedVersion' -and $activation -cmatch '\[string\]\$ExpectedPayloadManifestSha256' -and $activation -cmatch 'installer-payload\.manifest\.json') 'activation accepts and resolves the expected payload contract' @@ -468,7 +611,10 @@ Invoke-CcodTest 'Inno compile packages the explicit manifest-bound installer pay 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.Output -cmatch 'InstallerDestinationInventory\.iss' -and $compile.Output -cmatch 'installer-payload\.manifest\.json' -and $compile.Output -cmatch 'payload\\package\.json') 'compiler input trace contains the destination inventory, payload manifest, and manifest-listed file' + Assert-CcodTrue ($compile.GeneratedSource -cmatch 'procedure AddCcodExpectedSetupDirectories' -and $compile.GeneratedSource -cmatch "Directories\.Add\('payload\\2\.5\.21'") 'compiler input contains the generated 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-payload\.manifest\.json' -and $compile.Output -cmatch 'payload\\package\.json') 'compiler input trace contains the payload manifest and manifest-listed file' } finally { if (Test-Path -LiteralPath $compile.Root) { Remove-Item -LiteralPath $compile.Root -Recurse -Force } } @@ -477,9 +623,10 @@ Invoke-CcodTest 'Inno compile packages the explicit manifest-bound installer pay 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 when only the destination inventory define is omitted' - Assert-CcodTrue ($compile.Output -cmatch 'InstallerDestinationInventoryInclude') 'compiler identifies the missing generated inventory define' + 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 } } @@ -493,7 +640,7 @@ Invoke-CcodTest 'Inno exposes a pre-write payload-directory reparse gate' { 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' - Assert-CcodTrue ($inno -cmatch 'InstallerDestinationInventoryInclude' -and $inno -cmatch '(?ms)^function PrepareToInstall\(var NeedsRestart: Boolean\): String;.*?AreCcodExpectedSetupDirectoriesSafe') 'PrepareToInstall consumes the compile-bound generated inventory before file copy' + 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' diff --git a/tools/New-InstallerDestinationInventory.ps1 b/tools/New-InstallerDestinationInventory.ps1 index 4274730..c12f952 100644 --- a/tools/New-InstallerDestinationInventory.ps1 +++ b/tools/New-InstallerDestinationInventory.ps1 @@ -33,11 +33,9 @@ function Add-CcodExpectedDirectory { } $lines = @(Get-Content -LiteralPath $inno -Encoding UTF8) -$includeDirectives = @($lines | Where-Object { $_ -match '^\s*#\s*include\b' }) -$allowedIncludeDirective = '#include InstallerDestinationInventoryInclude' -if ($includeDirectives.Count -gt 1 -or - ($includeDirectives.Count -eq 1 -and [string]$includeDirectives[0] -cne $allowedIncludeDirective)) { - throw "Setup destination inventory permits only one literal include directive: $allowedIncludeDirective" +$includeDirectives = @($lines | Where-Object { $_ -match '^\s*#\s*(?:include\b|\+)' }) +if ($includeDirectives.Count -ne 0) { + throw 'Setup destination inventory requires an include-free template; external #include and #+ directives are not permitted.' } $filesSectionCount = @($lines | Where-Object { $_ -match '^\s*\[Files\]\s*$' }).Count if ($filesSectionCount -ne 1) { throw "Setup destination inventory requires exactly one [Files] section; found $filesSectionCount." } From 67b9939b2bfb8caa1a0477b464efe58fecbff08f Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:09:28 +0800 Subject: [PATCH 012/187] docs: constrain setup preprocessor boundary --- ...08-28-v2522-install-runtime-reliability.md | 33 +++++++++++-------- ...2522-install-runtime-reliability-design.md | 11 ++++--- 2 files changed, 26 insertions(+), 18 deletions(-) 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 index f83ada1..39f2442 100644 --- a/docs/superpowers/plans/2026-08-28-v2522-install-runtime-reliability.md +++ b/docs/superpowers/plans/2026-08-28-v2522-install-runtime-reliability.md @@ -104,7 +104,8 @@ fix: bind setup activation to immutable payload - 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 rejects all external `#include` and `#+` directives. +- 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** @@ -114,7 +115,8 @@ $template = @' AppName=fixture [Files] Source: "first.txt"; DestDir: "{app}\first" -#+ "extra.iss" +# \ ++ "extra.iss" '@ Assert-CcodThrows { & $generator -RepositoryRoot $root -PayloadRoot $payload -ProjectVersion '2.5.22' ` @@ -123,30 +125,33 @@ Assert-CcodThrows { Assert-CcodFalse (Test-Path -LiteralPath $inventoryPath) 'include bypass produces no partial inventory' ``` -Add an actual ISCC test showing that the generated script compiles with its -injected inventory, while a template with either include spelling is rejected -before ISCC is launched. +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 include-alias fixture is accepted before the new template -boundary exists. +Expected: the line-spanned include-alias fixture is accepted before the new +template boundary exists. - [ ] **Step 3: Implement the generated include-free compiler input** -Replace the source include with one unique marker. Build reads the checked-in -template, refuses any `#include` or `#+` directive, substitutes the generated -inventory text once, writes a GUID-named temporary `.iss`, validates that the -result has no marker or external include, and passes that generated path to -ISCC. Clean the temporary script in `finally`. +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 and arbitrary include aliases -leave neither setup nor inventory output. +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** 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 index a57c866..081e19d 100644 --- 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 @@ -57,10 +57,13 @@ 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 contains no external `#include` or `#+` directive; -build rejects both spellings before ISCC runs. This avoids attempting to -reimplement ISPP preprocessing while making the directory inventory and the -exact source compiled by ISCC one artifact. +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 From d1747115e030f5b36d63667d2e15d82e683e7b4f Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:24:38 +0800 Subject: [PATCH 013/187] fix: reject unsafe Inno preprocessor directives --- build/build.ps1 | 28 +- .../persistence/ReleaseWorkflow.SelfTest.ps1 | 278 ++++++++++++++++++ tools/New-InstallerDestinationInventory.ps1 | 22 +- 3 files changed, 318 insertions(+), 10 deletions(-) diff --git a/build/build.ps1 b/build/build.ps1 index 426b0f3..36e7693 100644 --- a/build/build.ps1 +++ b/build/build.ps1 @@ -116,6 +116,26 @@ function Assert-CcodBuildInstallerDestinationInventory { return $inventoryPath } +function Assert-CcodBuildInnoPreprocessorLines { + param( + [Parameter(Mandatory)][AllowEmptyCollection()][AllowEmptyString()][string[]]$Lines, + [Parameter(Mandatory)][string]$Kind + ) + $allowedDirectives = @('ifndef','define','error','endif') + for ($lineIndex = 0; $lineIndex -lt $Lines.Count; $lineIndex++) { + $line = [string]$Lines[$lineIndex] + $lineNumber = $lineIndex + 1 + if ($line -match '\\[ \t]*$') { + throw "$Kind requires an include-free simple preprocessor source; line continuation is not permitted at line $lineNumber." + } + if ($line -notmatch '^[ \t]*#') { continue } + $directiveMatch = [regex]::Match($line,'^[ \t]*#(?[A-Za-z]+)\b') + if (-not $directiveMatch.Success -or $allowedDirectives -cnotcontains $directiveMatch.Groups['directive'].Value) { + throw "$Kind requires an include-free simple preprocessor source; unsafe directive at line ${lineNumber}: $line" + } + } +} + function New-CcodBuildGeneratedInnoScript { param( [Parameter(Mandatory)][string]$TemplatePath, @@ -137,9 +157,7 @@ function New-CcodBuildGeneratedInnoScript { $marker = '// CCOD_INSTALLER_DESTINATION_INVENTORY' $templateSource = [IO.File]::ReadAllText($template,[Text.UTF8Encoding]::new($false)) - if ($templateSource -match '(?m)^\s*#\s*(?:include\b|\+)') { - throw 'The Inno Setup template must be include-free; external #include and #+ directives are not permitted.' - } + 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) { @@ -151,9 +169,7 @@ function New-CcodBuildGeneratedInnoScript { if ($generatedSource.Contains($marker)) { throw 'The generated Inno Setup script contains an unresolved inventory marker.' } - if ($generatedSource -match '(?m)^\s*#\s*(?:include\b|\+)') { - throw 'The generated Inno Setup script contains an external #include or #+ directive.' - } + Assert-CcodBuildInnoPreprocessorLines -Lines ([regex]::Split($generatedSource,'\r\n|\n|\r')) -Kind 'The generated Inno Setup script' try { Write-CcodBuildUtf8 -Path $output -Text $generatedSource diff --git a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 index af07a01..7a70560 100644 --- a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 +++ b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 @@ -203,6 +203,237 @@ $($fixture.Directive) } } +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 '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 { @@ -325,6 +556,53 @@ end; } } +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 { diff --git a/tools/New-InstallerDestinationInventory.ps1 b/tools/New-InstallerDestinationInventory.ps1 index c12f952..e898f7a 100644 --- a/tools/New-InstallerDestinationInventory.ps1 +++ b/tools/New-InstallerDestinationInventory.ps1 @@ -32,11 +32,25 @@ function Add-CcodExpectedDirectory { } } -$lines = @(Get-Content -LiteralPath $inno -Encoding UTF8) -$includeDirectives = @($lines | Where-Object { $_ -match '^\s*#\s*(?:include\b|\+)' }) -if ($includeDirectives.Count -ne 0) { - throw 'Setup destination inventory requires an include-free template; external #include and #+ directives are not permitted.' +function Assert-CcodInstallerInnoPreprocessorLines { + param([Parameter(Mandatory)][AllowEmptyCollection()][AllowEmptyString()][string[]]$Lines) + $allowedDirectives = @('ifndef','define','error','endif') + for ($lineIndex = 0; $lineIndex -lt $Lines.Count; $lineIndex++) { + $line = [string]$Lines[$lineIndex] + $lineNumber = $lineIndex + 1 + if ($line -match '\\[ \t]*$') { + throw "Setup destination inventory requires an include-free simple preprocessor template; line continuation is not permitted at line $lineNumber." + } + if ($line -notmatch '^[ \t]*#') { continue } + $directiveMatch = [regex]::Match($line,'^[ \t]*#(?[A-Za-z]+)\b') + if (-not $directiveMatch.Success -or $allowedDirectives -cnotcontains $directiveMatch.Groups['directive'].Value) { + throw "Setup destination inventory requires an include-free simple preprocessor template; unsafe directive at line ${lineNumber}: $line" + } + } } + +$lines = @(Get-Content -LiteralPath $inno -Encoding UTF8) +Assert-CcodInstallerInnoPreprocessorLines -Lines $lines $filesSectionCount = @($lines | Where-Object { $_ -match '^\s*\[Files\]\s*$' }).Count if ($filesSectionCount -ne 1) { throw "Setup destination inventory requires exactly one [Files] section; found $filesSectionCount." } $insideFiles = $false From 05d3646b5f64e6eff9975722a5bd95c4261ab053 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:47:37 +0800 Subject: [PATCH 014/187] fix: enforce exact Inno preprocessor constructs --- build/build.ps1 | 39 +++- .../persistence/ReleaseWorkflow.SelfTest.ps1 | 212 ++++++++++++++++++ tools/New-InstallerDestinationInventory.ps1 | 39 +++- 3 files changed, 278 insertions(+), 12 deletions(-) diff --git a/build/build.ps1 b/build/build.ps1 index 36e7693..2f53eb1 100644 --- a/build/build.ps1 +++ b/build/build.ps1 @@ -121,17 +121,44 @@ function Assert-CcodBuildInnoPreprocessorLines { [Parameter(Mandatory)][AllowEmptyCollection()][AllowEmptyString()][string[]]$Lines, [Parameter(Mandatory)][string]$Kind ) - $allowedDirectives = @('ifndef','define','error','endif') + $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', + '#endif' + ) + $allowedInlineConstructs = @( + '{#ProjectVersion}', + '{#TrayHostArtifactDirectory}', + '{#PortableArtifactDirectory}', + '{#InstallerPayloadDirectory}', + '{#InstallerPayloadManifestSha256}' + ) for ($lineIndex = 0; $lineIndex -lt $Lines.Count; $lineIndex++) { $line = [string]$Lines[$lineIndex] $lineNumber = $lineIndex + 1 - if ($line -match '\\[ \t]*$') { + if ($line -match '\\\s*$') { throw "$Kind requires an include-free simple preprocessor source; line continuation is not permitted at line $lineNumber." } - if ($line -notmatch '^[ \t]*#') { continue } - $directiveMatch = [regex]::Match($line,'^[ \t]*#(?[A-Za-z]+)\b') - if (-not $directiveMatch.Success -or $allowedDirectives -cnotcontains $directiveMatch.Groups['directive'].Value) { - throw "$Kind requires an include-free simple preprocessor source; unsafe directive at line ${lineNumber}: $line" + 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) } } } diff --git a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 index 7a70560..9c8116a 100644 --- a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 +++ b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 @@ -327,6 +327,218 @@ $($fixture.Directive) } } +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() diff --git a/tools/New-InstallerDestinationInventory.ps1 b/tools/New-InstallerDestinationInventory.ps1 index e898f7a..57f543f 100644 --- a/tools/New-InstallerDestinationInventory.ps1 +++ b/tools/New-InstallerDestinationInventory.ps1 @@ -34,17 +34,44 @@ function Add-CcodExpectedDirectory { function Assert-CcodInstallerInnoPreprocessorLines { param([Parameter(Mandatory)][AllowEmptyCollection()][AllowEmptyString()][string[]]$Lines) - $allowedDirectives = @('ifndef','define','error','endif') + $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', + '#endif' + ) + $allowedInlineConstructs = @( + '{#ProjectVersion}', + '{#TrayHostArtifactDirectory}', + '{#PortableArtifactDirectory}', + '{#InstallerPayloadDirectory}', + '{#InstallerPayloadManifestSha256}' + ) for ($lineIndex = 0; $lineIndex -lt $Lines.Count; $lineIndex++) { $line = [string]$Lines[$lineIndex] $lineNumber = $lineIndex + 1 - if ($line -match '\\[ \t]*$') { + if ($line -match '\\\s*$') { throw "Setup destination inventory requires an include-free simple preprocessor template; line continuation is not permitted at line $lineNumber." } - if ($line -notmatch '^[ \t]*#') { continue } - $directiveMatch = [regex]::Match($line,'^[ \t]*#(?[A-Za-z]+)\b') - if (-not $directiveMatch.Success -or $allowedDirectives -cnotcontains $directiveMatch.Groups['directive'].Value) { - throw "Setup destination inventory requires an include-free simple preprocessor template; unsafe directive at line ${lineNumber}: $line" + if ($line -match '^\s*#' -and $allowedSimpleDirectives -cnotcontains $line) { + throw "Setup destination inventory requires an include-free exact preprocessor template; 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 "Setup destination inventory contains an unterminated inline preprocessor construct at line $lineNumber." + } + $inlineConstruct = $line.Substring($inlineStart,$inlineEnd - $inlineStart + 1) + if ($allowedInlineConstructs -cnotcontains $inlineConstruct) { + throw "Setup destination inventory contains an unsafe inline preprocessor construct at line ${lineNumber}: $inlineConstruct" + } + $inlineStart = $line.IndexOf('{#',$inlineEnd + 1,[StringComparison]::Ordinal) } } } From b34b0d1aed066ee0a649e34d9845b538e85216ca Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 05:09:15 +0800 Subject: [PATCH 015/187] fix: stabilize verified Electron process trees --- src/persistence/Supervisor.ps1 | 24 +++-- src/persistence/modules/ProcessControl.psm1 | 40 ++++++++- src/persistence/modules/SessionEngine.psm1 | 17 +++- tests/persistence/ProcessControl.SelfTest.ps1 | 47 ++++++++++ tests/persistence/SessionEngine.SelfTest.ps1 | 38 +++++++- tests/persistence/Supervisor.SelfTest.ps1 | 90 +++++++++++++++++++ 6 files changed, 242 insertions(+), 14 deletions(-) diff --git a/src/persistence/Supervisor.ps1 b/src/persistence/Supervisor.ps1 index 0dbfb21..9742335 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','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', @@ -248,6 +248,7 @@ function Get-CcodSupervisorDefaultAdapters { $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)} @@ -662,14 +663,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 { 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/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/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/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/Supervisor.SelfTest.ps1 b/tests/persistence/Supervisor.SelfTest.ps1 index fae9fd7..afe968f 100644 --- a/tests/persistence/Supervisor.SelfTest.ps1 +++ b/tests/persistence/Supervisor.SelfTest.ps1 @@ -106,6 +106,7 @@ function New-CcodSupervisorFake { $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") @@ -811,6 +812,95 @@ Invoke-CcodTest 'successful lifecycle verification binds a fresh special proof b Assert-CcodEqual $null $hostState.LifecycleRequest 'later guardian decision creates no redundant repair lifecycle' } +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 From fe34fdfcd0115b1a2d25d51ff226cced9501aafd Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 05:10:07 +0800 Subject: [PATCH 016/187] docs: record Task 3 verification --- .../task-3-report.md | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 .superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-3-report.md 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. From b312174a4807463a3b068f6080399cbad297de2f Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 05:25:31 +0800 Subject: [PATCH 017/187] test: cover tray action revision correlation --- src/persistence/Supervisor.ps1 | 17 ++++++ tests/persistence/Supervisor.SelfTest.ps1 | 61 +++++++++++++++++++ tests/persistence/TrayHostClient.SelfTest.ps1 | 26 +++++++- tests/trayhost/TrayHostTransportSelfTest.cs | 4 +- 4 files changed, 105 insertions(+), 3 deletions(-) diff --git a/src/persistence/Supervisor.ps1 b/src/persistence/Supervisor.ps1 index 9742335..7bab1a3 100644 --- a/src/persistence/Supervisor.ps1 +++ b/src/persistence/Supervisor.ps1 @@ -1154,9 +1154,26 @@ function Throw-CcodSupervisorCommandError { throw [Management.Automation.ErrorRecord]::new([InvalidOperationException]::new($Message),$Code,[Management.Automation.ErrorCategory]::InvalidData,$Target) } +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 + }catch{Add-CcodSupervisorCleanupCode $HostState.RuntimeCleanupCodes 'CCOD_SUPERVISOR_LOG_FAILED'} +} + 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'){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'} diff --git a/tests/persistence/Supervisor.SelfTest.ps1 b/tests/persistence/Supervisor.SelfTest.ps1 index afe968f..5686621 100644 --- a/tests/persistence/Supervisor.SelfTest.ps1 +++ b/tests/persistence/Supervisor.SelfTest.ps1 @@ -1566,6 +1566,67 @@ 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] + Assert-CcodEqual 'schemaVersion,timestampUtc,component,stage,code,outcome,command,revision,status' (@($record.PSObject.Properties.Name)-join ',') 'terminal diagnostic record is an exact sanitized schema' + 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] + Assert-CcodEqual 'schemaVersion,timestampUtc,component,stage,code,outcome,command,revision,status' (@($record.PSObject.Properties.Name)-join ',') 'stale diagnostic record is an exact sanitized schema' + 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 '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/TrayHostClient.SelfTest.ps1 b/tests/persistence/TrayHostClient.SelfTest.ps1 index c835400..37f650c 100644 --- a/tests/persistence/TrayHostClient.SelfTest.ps1 +++ b/tests/persistence/TrayHostClient.SelfTest.ps1 @@ -51,7 +51,13 @@ Invoke-CcodTest 'TrayHost starts fail-closed until the Supervisor publishes an e } Invoke-CcodTest 'TrayHost reuses an identical presentation revision and records only an exact acknowledgement' { - if($null-eq('PresentationSnapshot' -as [type])){Add-Type -Path (Join-Path $repositoryRoot 'src\trayhost\PresentationSnapshot.cs')} + if($null-eq('TrayActionResult' -as [type])){ + Add-Type -Path @( + (Join-Path $repositoryRoot 'src\trayhost\PresentationSnapshot.cs'), + (Join-Path $repositoryRoot 'src\trayhost\PipeProtocol.cs'), + (Join-Path $repositoryRoot 'src\trayhost\TransportMessages.cs') + ) + } Import-Module $localizationPath -Force $catalog=Get-CcodUiCatalog -ResourcesRoot (Join-Path $repositoryRoot 'src\persistence\resources') -LanguageMode en-US -SystemCultureName en-US $presentation=[pscustomobject][ordered]@{Color='Green';ConnectionState='Connected';ProtectionState='Running';RepairEnabled=$false;LanguageEnabled=$true;OpenLogsEnabled=$true;AboutEnabled=$true;ExitEnabled=$true;Busy=$false} @@ -80,4 +86,22 @@ Invoke-CcodTest 'TrayHost reuses an identical presentation revision and records Assert-CcodTrue (-not$context.AcknowledgedPresentations.Contains('1')) 'an unrecorded revision is never inferred from a higher acknowledgement' } +Invoke-CcodTest 'TrayHost client delivers the exact correlated terminal result to the authenticated parent client' { + # Production mutation caught: losing the action id, revision, status, or stable terminal code at the PowerShell-to-TrayHost boundary. + $received=[Collections.Generic.List[object]]::new() + $client=[pscustomobject]@{Received=$received} + $client|Add-Member -MemberType ScriptMethod -Name TryAcknowledgeAction -Value {param($Result)$this.Received.Add($Result);return $true} + $context=[pscustomobject]@{Client=$client} + $actionId=[guid]'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeea1' + + $delivered=Send-CcodTrayHostActionResult -Context $context -ActionId $actionId -Revision ([UInt64]8) -Status Rejected -ErrorCode 'CCOD_TRAY_ACTION_STALE' -TransactionId $null + + Assert-CcodEqual $true $delivered 'terminal result is accepted by the authenticated parent client' + Assert-CcodEqual 1 $received.Count 'terminal result is delivered exactly once' + Assert-CcodEqual $actionId $received[0].ActionId 'terminal result preserves the action id' + Assert-CcodEqual ([UInt64]8) $received[0].Revision 'terminal result preserves the presentation revision' + Assert-CcodEqual 'Rejected' $received[0].Status.ToString() 'terminal result preserves the rejected status' + Assert-CcodEqual 'CCOD_TRAY_ACTION_STALE' $received[0].ErrorCode 'terminal result preserves the stable stale code' +} + Write-Host 'TrayHost client self-tests passed.' diff --git a/tests/trayhost/TrayHostTransportSelfTest.cs b/tests/trayhost/TrayHostTransportSelfTest.cs index 63d0c7d..928289c 100644 --- a/tests/trayhost/TrayHostTransportSelfTest.cs +++ b/tests/trayhost/TrayHostTransportSelfTest.cs @@ -142,8 +142,8 @@ private static void TestRejectedAndFailedActionsQueueUserFeedback() AssertTrue(host.TryAcknowledgeAction(new TrayActionResult(rejectedId, 20UL, TrayActionResultStatus.Rejected, "CCOD_TRAY_ACTION_UNAVAILABLE", null)), "correlated rejected result is accepted"); AssertTrue(host.TryAcknowledgeAction(new TrayActionResult(failedId, 20UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED", null)), "correlated failed result is accepted"); TrayActionResult first; TrayActionResult second; TrayActionResult none; - AssertTrue(host.TryTakeFailedAction(out first) && first.ActionId == rejectedId && first.Status == TrayActionResultStatus.Rejected, "rejected result queues user feedback"); - AssertTrue(host.TryTakeFailedAction(out second) && second.ActionId == failedId && second.Status == TrayActionResultStatus.Failed, "failed result queues user feedback"); + AssertTrue(host.TryTakeFailedAction(out first) && first.ActionId == rejectedId && first.Revision == 20UL && first.Status == TrayActionResultStatus.Rejected && String.Equals(first.ErrorCode, "CCOD_TRAY_ACTION_UNAVAILABLE", StringComparison.Ordinal), "rejected result preserves its exact correlated terminal record before generic feedback"); + AssertTrue(host.TryTakeFailedAction(out second) && second.ActionId == failedId && second.Revision == 20UL && second.Status == TrayActionResultStatus.Failed && String.Equals(second.ErrorCode, "CCOD_TRAY_ACTION_FAILED", StringComparison.Ordinal), "failed result preserves its exact correlated terminal record before generic feedback"); AssertTrue(!host.TryTakeFailedAction(out none), "each terminal failure queues feedback exactly once"); host.Dispose(); } From 6554d289a8e155acaf5520ba7f8b0ec6275b8083 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 05:26:46 +0800 Subject: [PATCH 018/187] docs: record Task 4 verification --- .../task-4-report.md | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 .superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-4-report.md 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..672e452 --- /dev/null +++ b/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-4-report.md @@ -0,0 +1,179 @@ +# 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. From 94ca92fcc65a6e47616f8da82be2e40e7652380d Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 05:38:01 +0800 Subject: [PATCH 019/187] fix: gate tray feedback on durable diagnostics --- src/persistence/Supervisor.ps1 | 5 +- tests/persistence/Supervisor.SelfTest.ps1 | 89 ++++++++++++++++- .../trayhost/TrayHostCorrelationTestBridge.cs | 99 +++++++++++++++++++ 3 files changed, 189 insertions(+), 4 deletions(-) create mode 100644 tests/trayhost/TrayHostCorrelationTestBridge.cs diff --git a/src/persistence/Supervisor.ps1 b/src/persistence/Supervisor.ps1 index 7bab1a3..96054fa 100644 --- a/src/persistence/Supervisor.ps1 +++ b/src/persistence/Supervisor.ps1 @@ -1167,13 +1167,14 @@ function Write-CcodSupervisorTrayActionTerminal { command=[string]$Action.Command;revision=[UInt64]$Action.Revision;status=$Status } Invoke-CcodSupervisorAdapter $Adapters.WriteLog @($record) 0 - }catch{Add-CcodSupervisorCleanupCode $HostState.RuntimeCleanupCodes 'CCOD_SUPERVISOR_LOG_FAILED'} + 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'){Write-CcodSupervisorTrayActionTerminal $HostState $Adapters $Action $Status $ErrorCode} + if($Status-cne'Accepted'-and-not(Write-CcodSupervisorTrayActionTerminal $HostState $Adapters $Action $Status $ErrorCode)){$result|Add-Member -NotePropertyName Delivered -NotePropertyValue $false;return $result} 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'} diff --git a/tests/persistence/Supervisor.SelfTest.ps1 b/tests/persistence/Supervisor.SelfTest.ps1 index 5686621..e2e8a0e 100644 --- a/tests/persistence/Supervisor.SelfTest.ps1 +++ b/tests/persistence/Supervisor.SelfTest.ps1 @@ -812,6 +812,22 @@ 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 'NativeMethods.cs'), + (Join-Path $trayRoot 'InputModeGuard.cs'), + (Join-Path $trayRoot 'NativeMenu.cs'), + (Join-Path $trayRoot 'TrayWindow.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 @@ -1581,7 +1597,8 @@ Invoke-CcodTest 'records an acknowledged OpenLogs terminal outcome after its han 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] - Assert-CcodEqual 'schemaVersion,timestampUtc,component,stage,code,outcome,command,revision,status' (@($record.PSObject.Properties.Name)-join ',') 'terminal diagnostic record is an exact sanitized schema' + 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' @@ -1607,7 +1624,8 @@ Invoke-CcodTest 'rejects and records an unacknowledged OpenLogs revision before 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] - Assert-CcodEqual 'schemaVersion,timestampUtc,component,stage,code,outcome,command,revision,status' (@($record.PSObject.Properties.Name)-join ',') 'stale diagnostic record is an exact sanitized schema' + 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' @@ -1627,6 +1645,73 @@ Invoke-CcodTest 'sanitizes a malformed terminal action code before local logging 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: delivering a rejected terminal result to TrayHost after the required local diagnostic failed to persist. + $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host;$world.FailAt='WriteLog' + $action=[pscustomobject][ordered]@{ActionId=[guid]'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeb1';Command='OpenLogs';Revision=[UInt64]2} + + $result=@(Invoke-CcodSupervisorCommand $hostState $fixture.Fake.Adapters $action)[0] + + Assert-CcodEqual 'Rejected' $result.Status 'stale action retains its supervisor terminal status' + Assert-CcodEqual 'CCOD_TRAY_ACTION_STALE' $result.ErrorCode 'stale action retains its stable code' + Assert-CcodEqual $false $result.Delivered 'failed diagnostic persistence prevents terminal feedback delivery' + Assert-CcodEqual 0 $world.TrayActionResults.Count 'TrayHost receives no terminal result without a durable local record' + Assert-CcodEqual 0 @($world.Calls|Where-Object{$_ -eq 'Open:Logs'}).Count 'stale action still performs no handler side effect' + Assert-CcodTrue ($hostState.RuntimeCleanupCodes.Contains('CCOD_SUPERVISOR_LOG_FAILED')) 'failed diagnostic persistence remains visible in the supervisor cleanup receipt' +} + +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/trayhost/TrayHostCorrelationTestBridge.cs b/tests/trayhost/TrayHostCorrelationTestBridge.cs new file mode 100644 index 0000000..22d13f1 --- /dev/null +++ b/tests/trayhost/TrayHostCorrelationTestBridge.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; + +public sealed class TrayHostCorrelationTestBridge : IDisposable +{ + private sealed class Platform : INativeTrayPlatform + { + internal uint Selection; + public IntPtr CreateOwner() { return new IntPtr(10); } + public IntPtr AssociateOwnerInputContext(IntPtr owner, IntPtr context) { return new IntPtr(20); } + public IntPtr GetOwnerInputContext(IntPtr owner) { return IntPtr.Zero; } + public bool ReleaseInputContext(IntPtr owner, IntPtr context) { return true; } + public IntPtr LoadIcon() { return new IntPtr(40); } + public bool DestroyIcon(IntPtr icon) { return true; } + public bool AddIcon(ref TrayIconData icon) { return true; } + public bool SetIconVersion(ref TrayIconData icon) { return true; } + public bool DeleteIcon(ref TrayIconData icon) { return true; } + public IntPtr CreatePopupMenu() { return new IntPtr(30); } + public IntPtr CreateSubMenu() { return new IntPtr(31); } + public bool AppendMenu(IntPtr menu, uint flags, UIntPtr command, string text) { return true; } + public bool AppendSubMenu(IntPtr menu, IntPtr child, string text) { return true; } + public bool ShowOwner(IntPtr owner) { return true; } + public bool HideOwner(IntPtr owner) { return true; } + public bool SetForegroundWindow(IntPtr owner) { return true; } + public IntPtr GetForegroundWindow() { return new IntPtr(10); } + public uint TrackPopupMenuEx(IntPtr menu, uint flags, int x, int y, IntPtr owner, IntPtr parameters) { return Selection; } + public bool PostMessage(IntPtr owner, uint message, UIntPtr wParam, IntPtr lParam) { return true; } + public bool SetNotificationFocus(ref TrayIconData icon) { return true; } + public bool ShowMessageBox(IntPtr owner, string text, string caption) { return true; } + public bool ConfirmExit(IntPtr owner, string text, string caption) { return true; } + public bool DestroyMenu(IntPtr menu) { return true; } + public bool EndMenu() { return true; } + public bool DestroyOwner(IntPtr owner) { return true; } + } + + private readonly Platform _platform; + private readonly HostTransport _transport; + private readonly TrayWindow _window; + private TrayHostAction _selected; + private TrayActionResult _terminal; + + public TrayHostCorrelationTestBridge(ulong revision, TrayCommand command) + { + _platform = new Platform { Selection = (uint)command }; + _transport = new HostTransport(); + _window = new TrayWindow(_platform, _transport.SetMenuOpen); + _window.CommandSelected += delegate(TrayCommand selected, ulong displayedRevision) + { + TrayHostAction action = new TrayHostAction(Guid.NewGuid(), selected, displayedRevision); + if (!_transport.TryRegisterAction(action)) { throw new InvalidOperationException("test action registration failed"); } + _selected = action; + }; + _window.Create(Snapshot(revision)); + } + + public void SelectFromNativeMenu() + { + _window.HandleContextMenu(new TrayPoint(5, 5)); + if (_selected == null) { throw new InvalidOperationException("native menu produced no action"); } + } + + public Guid ActionId { get { return _selected == null ? Guid.Empty : _selected.ActionId; } } + public string Command { get { return _selected == null ? String.Empty : _selected.Command.ToString(); } } + public ulong Revision { get { return _selected == null ? 0UL : _selected.Revision; } } + + public bool Acknowledge(Guid actionId, ulong revision, string status, string errorCode) + { + TrayActionResultStatus parsed = (TrayActionResultStatus)Enum.Parse(typeof(TrayActionResultStatus), status, false); + TrayActionResult result = new TrayActionResult(actionId, revision, parsed, errorCode, null); + bool accepted = _transport.TryAcknowledgeAction(result); + if (!accepted) { return false; } + if (parsed == TrayActionResultStatus.Rejected || parsed == TrayActionResultStatus.Failed) + { + TrayActionResult queued; + if (!_transport.TryTakeFailedAction(out queued)) { return false; } + _terminal = queued; + } + else { _terminal = result; } + return true; + } + + public string TerminalStatus { get { return _terminal == null ? String.Empty : _terminal.Status.ToString(); } } + public string TerminalCode { get { return _terminal == null ? String.Empty : (_terminal.ErrorCode ?? String.Empty); } } + public ulong TerminalRevision { get { return _terminal == null ? 0UL : _terminal.Revision; } } + + private static PresentationSnapshot Snapshot(ulong revision) + { + string[] strings = new string[16]; + for (int index = 0; index < strings.Length; index++) { strings[index] = "correlation-" + index.ToString(); } + return new PresentationSnapshot(revision, TrayColor.Green, ConnectionState.Connected, ProtectionState.Running, LanguageMode.English, + PresentationFlags.OpenLogsEnabled | PresentationFlags.LanguageEnabled | PresentationFlags.AboutEnabled, strings); + } + + public void Dispose() + { + _window.Dispose(); + _transport.Dispose(); + } +} From 1c8a6454e013828f8a4e92a495711cf88edd4686 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 05:38:38 +0800 Subject: [PATCH 020/187] docs: record Task 4 fix round 1 --- .../task-4-report.md | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) 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 index 672e452..3969c76 100644 --- 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 @@ -177,3 +177,134 @@ No internal code is added to the dialog. 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. From 3bdd575dd8afd91396cef02f1a4357915d42ec3f Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 05:50:17 +0800 Subject: [PATCH 021/187] fix: release tray actions after diagnostic failure --- src/persistence/Supervisor.ps1 | 2 +- src/trayhost/HostTransport.cs | 22 +++++++-- src/trayhost/Program.cs | 4 +- src/trayhost/TrayTerminalDiagnostic.cs | 35 ++++++++++++++ tests/persistence/Supervisor.SelfTest.ps1 | 42 +++++++++++----- tests/trayhost/Invoke-TrayHostSelfTest.ps1 | 1 + .../trayhost/TrayHostCorrelationTestBridge.cs | 19 ++++++-- tests/trayhost/TrayHostNativeSelfTest.cs | 19 +++++++- tests/trayhost/TrayHostTransportSelfTest.cs | 48 +++++++++++++++++-- 9 files changed, 163 insertions(+), 29 deletions(-) create mode 100644 src/trayhost/TrayTerminalDiagnostic.cs diff --git a/src/persistence/Supervisor.ps1 b/src/persistence/Supervisor.ps1 index 96054fa..47219a1 100644 --- a/src/persistence/Supervisor.ps1 +++ b/src/persistence/Supervisor.ps1 @@ -1174,7 +1174,7 @@ function Write-CcodSupervisorTrayActionTerminal { 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'-and-not(Write-CcodSupervisorTrayActionTerminal $HostState $Adapters $Action $Status $ErrorCode)){$result|Add-Member -NotePropertyName Delivered -NotePropertyValue $false;return $result} + 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'} diff --git a/src/trayhost/HostTransport.cs b/src/trayhost/HostTransport.cs index f71a77b..e9d5848 100644 --- a/src/trayhost/HostTransport.cs +++ b/src/trayhost/HostTransport.cs @@ -11,6 +11,7 @@ private sealed class PendingAction private readonly object _gate = new object(); private readonly Action _presentationReady; + private readonly Func _writeTerminalDiagnostic; private readonly Queue _completedAbout = new Queue(); private readonly Queue _failedActions = new Queue(); private readonly Dictionary _pendingActions = new Dictionary(); @@ -20,13 +21,18 @@ private sealed class PendingAction 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 writeTerminalDiagnostic) { _presentationReady = presentationReady; + _writeTerminalDiagnostic = writeTerminalDiagnostic; } internal void SetMenuOpen(bool value) @@ -97,10 +103,16 @@ 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; } + bool recorded = false; + Func writer = _writeTerminalDiagnostic; + if (writer != null) + { + try { recorded = writer(new TrayTerminalDiagnostic(pending.Action.Command, result.Revision, result.Status, result.ErrorCode)); } + catch { recorded = false; } + } _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); } + if (recorded && result.Status == TrayActionResultStatus.Completed && pending.Action.Command == TrayCommand.ShowAbout) { _completedAbout.Enqueue(result); } + if (recorded && (result.Status == TrayActionResultStatus.Rejected || result.Status == TrayActionResultStatus.Failed) && _failedActions.Count < 8) { _failedActions.Enqueue(result); } return true; } } diff --git a/src/trayhost/Program.cs b/src/trayhost/Program.cs index 106b3e6..d227d2e 100644 --- a/src/trayhost/Program.cs +++ b/src/trayhost/Program.cs @@ -39,7 +39,9 @@ private static int RunChild(string[] args) 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(); } }); + string terminalLogPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "CodexControlOtherDevices", "logs", "trayhost-actions.log"); + try { Directory.CreateDirectory(Path.GetDirectoryName(terminalLogPath)); } catch { } + HostTransport transport = new HostTransport(delegate { TrayHostApplication current = application; if (current != null) { current.PostWork(); } }, delegate(TrayTerminalDiagnostic record) { return TrayTerminalDiagnosticLog.TryAppend(terminalLogPath, record); }); TrayWindow window = new TrayWindow(platform, transport.SetMenuOpen); bool shutdownRequested = false; bool shutdownSent = false; object stateGate = new object(); Action command = delegate(TrayCommand selected, ulong revision) diff --git a/src/trayhost/TrayTerminalDiagnostic.cs b/src/trayhost/TrayTerminalDiagnostic.cs new file mode 100644 index 0000000..b4f84c8 --- /dev/null +++ b/src/trayhost/TrayTerminalDiagnostic.cs @@ -0,0 +1,35 @@ +using System; +using System.IO; + +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 static class TrayTerminalDiagnosticLog +{ + private static readonly object Gate = new object(); + + internal static bool TryAppend(string path, TrayTerminalDiagnostic record) + { + if (String.IsNullOrEmpty(path) || record == null) { return false; } + try + { + string line = "command=" + record.Command.ToString() + " revision=" + record.Revision.ToString(System.Globalization.CultureInfo.InvariantCulture) + " status=" + record.Status.ToString() + " code=" + record.Code + Environment.NewLine; + lock (Gate) { File.AppendAllText(path, line); } + return true; + } + catch { return false; } + } +} diff --git a/tests/persistence/Supervisor.SelfTest.ps1 b/tests/persistence/Supervisor.SelfTest.ps1 index e2e8a0e..42d120d 100644 --- a/tests/persistence/Supervisor.SelfTest.ps1 +++ b/tests/persistence/Supervisor.SelfTest.ps1 @@ -819,6 +819,7 @@ function Import-CcodTrayHostCorrelationTestBridge { (Join-Path $trayRoot 'PresentationSnapshot.cs'), (Join-Path $trayRoot 'PipeProtocol.cs'), (Join-Path $trayRoot 'TransportMessages.cs'), + (Join-Path $trayRoot 'TrayTerminalDiagnostic.cs'), (Join-Path $trayRoot 'NativeMethods.cs'), (Join-Path $trayRoot 'InputModeGuard.cs'), (Join-Path $trayRoot 'NativeMenu.cs'), @@ -1646,18 +1647,37 @@ Invoke-CcodTest 'sanitizes a malformed terminal action code before local logging } Invoke-CcodTest 'does not queue generic terminal feedback when its local diagnostic write fails' { - # Production mutation caught: delivering a rejected terminal result to TrayHost after the required local diagnostic failed to persist. + # 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' - $action=[pscustomobject][ordered]@{ActionId=[guid]'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeb1';Command='OpenLogs';Revision=[UInt64]2} - - $result=@(Invoke-CcodSupervisorCommand $hostState $fixture.Fake.Adapters $action)[0] - - Assert-CcodEqual 'Rejected' $result.Status 'stale action retains its supervisor terminal status' - Assert-CcodEqual 'CCOD_TRAY_ACTION_STALE' $result.ErrorCode 'stale action retains its stable code' - Assert-CcodEqual $false $result.Delivered 'failed diagnostic persistence prevents terminal feedback delivery' - Assert-CcodEqual 0 $world.TrayActionResults.Count 'TrayHost receives no terminal result without a durable local record' - Assert-CcodEqual 0 @($world.Calls|Where-Object{$_ -eq 'Open:Logs'}).Count 'stale action still performs no handler side effect' - Assert-CcodTrue ($hostState.RuntimeCleanupCodes.Contains('CCOD_SUPERVISOR_LOG_FAILED')) 'failed diagnostic persistence remains visible in the supervisor cleanup receipt' + $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' { diff --git a/tests/trayhost/Invoke-TrayHostSelfTest.ps1 b/tests/trayhost/Invoke-TrayHostSelfTest.ps1 index b17c01a..8846e6d 100644 --- a/tests/trayhost/Invoke-TrayHostSelfTest.ps1 +++ b/tests/trayhost/Invoke-TrayHostSelfTest.ps1 @@ -22,6 +22,7 @@ $nativeFiles=@( ) $transportFiles=@( (Join-Path $repositoryRoot 'src\trayhost\TransportMessages.cs'), + (Join-Path $repositoryRoot 'src\trayhost\TrayTerminalDiagnostic.cs'), (Join-Path $repositoryRoot 'src\trayhost\ParentTransport.cs'), (Join-Path $repositoryRoot 'src\trayhost\HostTransport.cs') ) diff --git a/tests/trayhost/TrayHostCorrelationTestBridge.cs b/tests/trayhost/TrayHostCorrelationTestBridge.cs index 22d13f1..a79ba20 100644 --- a/tests/trayhost/TrayHostCorrelationTestBridge.cs +++ b/tests/trayhost/TrayHostCorrelationTestBridge.cs @@ -38,11 +38,18 @@ private sealed class Platform : INativeTrayPlatform private readonly TrayWindow _window; private TrayHostAction _selected; private TrayActionResult _terminal; + private bool _persistDiagnostics = true; + private int _feedbackCount; - public TrayHostCorrelationTestBridge(ulong revision, TrayCommand command) + public TrayHostCorrelationTestBridge(ulong revision, TrayCommand command) : this(revision, command, true) + { + } + + public TrayHostCorrelationTestBridge(ulong revision, TrayCommand command, bool persistDiagnostics) { _platform = new Platform { Selection = (uint)command }; - _transport = new HostTransport(); + _persistDiagnostics = persistDiagnostics; + _transport = new HostTransport(null, delegate(TrayTerminalDiagnostic record) { return _persistDiagnostics; }); _window = new TrayWindow(_platform, _transport.SetMenuOpen); _window.CommandSelected += delegate(TrayCommand selected, ulong displayedRevision) { @@ -69,16 +76,18 @@ public bool Acknowledge(Guid actionId, ulong revision, string status, string err TrayActionResult result = new TrayActionResult(actionId, revision, parsed, errorCode, null); bool accepted = _transport.TryAcknowledgeAction(result); if (!accepted) { return false; } + _terminal = result; if (parsed == TrayActionResultStatus.Rejected || parsed == TrayActionResultStatus.Failed) { TrayActionResult queued; - if (!_transport.TryTakeFailedAction(out queued)) { return false; } - _terminal = queued; + if (_transport.TryTakeFailedAction(out queued)) { _terminal = queued; _feedbackCount++; } } - else { _terminal = result; } return true; } + public void SetDiagnosticPersistence(bool value) { _persistDiagnostics = value; } + public int FeedbackCount { get { return _feedbackCount; } } + public string TerminalStatus { get { return _terminal == null ? String.Empty : _terminal.Status.ToString(); } } public string TerminalCode { get { return _terminal == null ? String.Empty : (_terminal.ErrorCode ?? String.Empty); } } public ulong TerminalRevision { get { return _terminal == null ? 0UL : _terminal.Revision; } } diff --git a/tests/trayhost/TrayHostNativeSelfTest.cs b/tests/trayhost/TrayHostNativeSelfTest.cs index 820be16..273c597 100644 --- a/tests/trayhost/TrayHostNativeSelfTest.cs +++ b/tests/trayhost/TrayHostNativeSelfTest.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.IO; internal sealed class FakeTrayPlatform : INativeTrayPlatform { @@ -208,6 +209,21 @@ private static void TestActionFailureUsesTheAcknowledgedSnapshotStrings() window.Dispose(); } + private static void TestTerminalDiagnosticLogIsSanitizedAndReportsPersistence() + { + string root = Path.Combine(Path.GetTempPath(), "ccod-tray-terminal-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + string path = Path.Combine(root, "trayhost-actions.log"); + try + { + TrayTerminalDiagnostic record = new TrayTerminalDiagnostic(TrayCommand.OpenLogs, 23UL, TrayActionResultStatus.Rejected, "CCOD_TRAY_ACTION_STALE"); + AssertTrue(TrayTerminalDiagnosticLog.TryAppend(path, record), "terminal diagnostic append reports durable success"); + AssertEqual("command=OpenLogs revision=23 status=Rejected code=CCOD_TRAY_ACTION_STALE" + Environment.NewLine, File.ReadAllText(path), "terminal diagnostic file contains only the approved correlation fields"); + AssertTrue(!TrayTerminalDiagnosticLog.TryAppend(root, record), "terminal diagnostic append reports persistence failure without throwing"); + } + finally { try { Directory.Delete(root, true); } catch { } } + } + private static void TestNoHimcFailureIsSafe() { FakeTrayPlatform platform = new FakeTrayPlatform(); @@ -287,12 +303,13 @@ public static int Main(string[] args) TestAboutCommandDefersProofToSupervisor(); TestVerifiedAboutUsesTheAcknowledgedSnapshotVersion(); TestActionFailureUsesTheAcknowledgedSnapshotStrings(); + TestTerminalDiagnosticLogIsSanitizedAndReportsPersistence(); TestSimplifiedMenuAndExitConfirmation(); TestNoHimcFailureIsSafe(); TestShellRightClickNotificationMapping(); TestRealNativePInvokeSurface(); TestPostedWorkMessageDispatchesToItsOwnerWindow(); - Console.WriteLine("TrayHost native self-tests passed: 15"); + Console.WriteLine("TrayHost native self-tests passed: 16"); return 0; } catch (Exception error) diff --git a/tests/trayhost/TrayHostTransportSelfTest.cs b/tests/trayhost/TrayHostTransportSelfTest.cs index 928289c..7a55cfa 100644 --- a/tests/trayhost/TrayHostTransportSelfTest.cs +++ b/tests/trayhost/TrayHostTransportSelfTest.cs @@ -3,6 +3,8 @@ internal static class TrayHostTransportSelfTest { + private static bool PersistTerminal(TrayTerminalDiagnostic record) { return true; } + private static void AssertTrue(bool value, string message) { if (!value) { throw new InvalidOperationException(message); } @@ -124,7 +126,7 @@ private static void TestActionResultCorrelationAndControlPriority() private static void TestAcknowledgedAboutQueuesOneUiWorkItem() { - HostTransport host = new HostTransport(); Guid actionId = Guid.NewGuid(); + HostTransport host = new HostTransport(null, PersistTerminal); Guid actionId = Guid.NewGuid(); AssertTrue(host.TryRegisterAction(new TrayHostAction(actionId, TrayCommand.ShowAbout, 14UL)), "About action registers"); AssertTrue(host.TryAcknowledgeAction(new TrayActionResult(actionId, 14UL, TrayActionResultStatus.Completed, null, null)), "verified About completion is accepted"); TrayActionResult result; @@ -135,7 +137,7 @@ private static void TestAcknowledgedAboutQueuesOneUiWorkItem() private static void TestRejectedAndFailedActionsQueueUserFeedback() { - HostTransport host = new HostTransport(); + HostTransport host = new HostTransport(null, PersistTerminal); Guid rejectedId = Guid.NewGuid(); Guid failedId = Guid.NewGuid(); AssertTrue(host.TryRegisterAction(new TrayHostAction(rejectedId, TrayCommand.OpenLogs, 20UL)), "rejected action registers"); AssertTrue(host.TryRegisterAction(new TrayHostAction(failedId, TrayCommand.SetLanguageEnglish, 20UL)), "failed action registers"); @@ -150,14 +152,49 @@ private static void TestRejectedAndFailedActionsQueueUserFeedback() private static void TestUndisplayedActionFailureFeedbackIsBounded() { - HostTransport host = new HostTransport(); + HostTransport host = new HostTransport(null, PersistTerminal); for (int index = 0; index < 9; index++) { Guid actionId = Guid.NewGuid(); AssertTrue(host.TryRegisterAction(new TrayHostAction(actionId, TrayCommand.OpenLogs, 21UL)), "terminal failure releases pending action capacity"); bool accepted = host.TryAcknowledgeAction(new TrayActionResult(actionId, 21UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED", null)); - AssertTrue(index < 8 ? accepted : !accepted, "undisplayed action failure feedback is bounded to the pending-action capacity"); + AssertTrue(accepted, "bounded undisplayed feedback never strands an authenticated terminal action"); + } + host.Dispose(); + } + + private static void TestTerminalDiagnosticFailureNeverStrandsPendingActions() + { + HostTransport missingWriter = new HostTransport(); + Guid missingWriterId = Guid.NewGuid(); + AssertTrue(missingWriter.TryRegisterAction(new TrayHostAction(missingWriterId, TrayCommand.OpenLogs, 22UL)), "action registers without a diagnostic writer"); + AssertTrue(missingWriter.TryAcknowledgeAction(new TrayActionResult(missingWriterId, 22UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED", null)), "missing diagnostic writer still releases the authenticated terminal action"); + TrayActionResult missingWriterFeedback; + AssertTrue(!missingWriter.TryTakeFailedAction(out missingWriterFeedback), "missing diagnostic writer cannot authorize generic feedback"); + missingWriter.Dispose(); + + bool persist = false; + List records = new List(); + HostTransport host = new HostTransport(null, delegate(TrayTerminalDiagnostic record) { records.Add(record); return persist; }); + for (int index = 0; index < 10; index++) + { + Guid actionId = Guid.NewGuid(); + AssertTrue(host.TryRegisterAction(new TrayHostAction(actionId, TrayCommand.OpenLogs, 22UL)), "diagnostic failure never consumes pending-action capacity"); + AssertTrue(host.TryAcknowledgeAction(new TrayActionResult(actionId, 22UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED", null)), "authenticated terminal result releases its pending action even when local persistence fails"); + TrayActionResult suppressed; + AssertTrue(!host.TryTakeFailedAction(out suppressed), "generic feedback is suppressed when its local terminal diagnostic did not persist"); } + persist = true; + Guid recoveredId = Guid.NewGuid(); + AssertTrue(host.TryRegisterAction(new TrayHostAction(recoveredId, TrayCommand.OpenLogs, 22UL)), "new action registers after repeated diagnostic failures"); + AssertTrue(host.TryAcknowledgeAction(new TrayActionResult(recoveredId, 22UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED", null)), "recovered diagnostic persistence accepts a new terminal result"); + TrayActionResult recoveredFeedback; + AssertTrue(host.TryTakeFailedAction(out recoveredFeedback) && recoveredFeedback.ActionId == recoveredId, "generic feedback resumes only after local terminal persistence recovers"); + Guid completedId = Guid.NewGuid(); + AssertTrue(host.TryRegisterAction(new TrayHostAction(completedId, TrayCommand.OpenLogs, 22UL)), "post-recovery command can still register"); + AssertTrue(host.TryAcknowledgeAction(new TrayActionResult(completedId, 22UL, TrayActionResultStatus.Completed, null, null)), "post-recovery command can complete"); + TrayTerminalDiagnostic last = records[records.Count - 1]; + AssertTrue(last.Command == TrayCommand.OpenLogs && last.Revision == 22UL && last.Status == TrayActionResultStatus.Completed && String.Equals(last.Code, "CCOD_TRAY_ACTION_COMPLETED", StringComparison.Ordinal), "terminal diagnostic exposes only the exact canonical correlation fields"); host.Dispose(); } @@ -173,7 +210,8 @@ public static int Main(string[] args) TestAcknowledgedAboutQueuesOneUiWorkItem(); TestRejectedAndFailedActionsQueueUserFeedback(); TestUndisplayedActionFailureFeedbackIsBounded(); - Console.WriteLine("TrayHost transport self-tests passed: 8"); + TestTerminalDiagnosticFailureNeverStrandsPendingActions(); + Console.WriteLine("TrayHost transport self-tests passed: 9"); return 0; } catch (Exception error) From 424f9c84f0d3f853b2091b9f825f8ee5ff55e0f0 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 05:50:53 +0800 Subject: [PATCH 022/187] docs: record Task 4 fix round 2 --- .../task-4-report.md | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) 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 index 3969c76..9c7613a 100644 --- 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 @@ -308,3 +308,141 @@ lifecycle completion only after acknowledgement. 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. From 9c1e4dfcb9acb4c6f6dc9d7a495b67d28edd6859 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 06:05:01 +0800 Subject: [PATCH 023/187] fix: harden tray terminal diagnostics --- src/trayhost/HostTransport.cs | 22 +-- src/trayhost/Program.cs | 12 +- src/trayhost/TrayTerminalDiagnostic.cs | 64 ++++++++- .../TrayHostProductionTrace.SelfTest.ps1 | 30 +++++ tests/trayhost/Invoke-TrayHostSelfTest.ps1 | 16 ++- tests/trayhost/TrayHostNativeSelfTest.cs | 35 ++++- .../trayhost/TrayHostParentClientSelfTest.cs | 125 +++++++++++++++--- tests/trayhost/TrayHostTransportSelfTest.cs | 41 +++++- 8 files changed, 306 insertions(+), 39 deletions(-) create mode 100644 tests/persistence/TrayHostProductionTrace.SelfTest.ps1 diff --git a/src/trayhost/HostTransport.cs b/src/trayhost/HostTransport.cs index e9d5848..be636aa 100644 --- a/src/trayhost/HostTransport.cs +++ b/src/trayhost/HostTransport.cs @@ -90,10 +90,11 @@ internal bool TryRegisterAction(TrayHostAction action) internal bool TryAcknowledgeAction(TrayActionResult result) { if (result == null) { return false; } + PendingAction pending; + TrayTerminalDiagnostic diagnostic; lock (_gate) { if (_disposed) { return false; } - PendingAction pending; if (!_pendingActions.TryGetValue(result.ActionId, out pending) || pending.Action.Revision != result.Revision) { return false; } if (result.Status == TrayActionResultStatus.Accepted) { @@ -103,14 +104,19 @@ 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; } - bool recorded = false; - Func writer = _writeTerminalDiagnostic; - if (writer != null) - { - try { recorded = writer(new TrayTerminalDiagnostic(pending.Action.Command, result.Revision, result.Status, result.ErrorCode)); } - catch { recorded = false; } - } + diagnostic = new TrayTerminalDiagnostic(pending.Action.Command, result.Revision, result.Status, result.ErrorCode); _pendingActions.Remove(result.ActionId); + } + bool recorded = false; + Func writer = _writeTerminalDiagnostic; + if (writer != null) + { + try { recorded = writer(diagnostic); } + catch { recorded = false; } + } + lock (_gate) + { + if (_disposed) { return true; } if (recorded && result.Status == TrayActionResultStatus.Completed && pending.Action.Command == TrayCommand.ShowAbout) { _completedAbout.Enqueue(result); } if (recorded && (result.Status == TrayActionResultStatus.Rejected || result.Status == TrayActionResultStatus.Failed) && _failedActions.Count < 8) { _failedActions.Enqueue(result); } return true; diff --git a/src/trayhost/Program.cs b/src/trayhost/Program.cs index d227d2e..f438004 100644 --- a/src/trayhost/Program.cs +++ b/src/trayhost/Program.cs @@ -39,8 +39,8 @@ private static int RunChild(string[] args) PresentationSnapshot initial = TrayHostWire.ReadPresentation(initialFrame.Payload); Win32TrayPlatform platform = new Win32TrayPlatform(); TrayHostApplication application = null; - string terminalLogPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "CodexControlOtherDevices", "logs", "trayhost-actions.log"); - try { Directory.CreateDirectory(Path.GetDirectoryName(terminalLogPath)); } catch { } + string terminalLogPath; + if (!TrayTerminalDiagnosticLog.TryGetDefaultPath(out terminalLogPath)) { terminalLogPath = null; } HostTransport transport = new HostTransport(delegate { TrayHostApplication current = application; if (current != null) { current.PostWork(); } }, delegate(TrayTerminalDiagnostic record) { return TrayTerminalDiagnosticLog.TryAppend(terminalLogPath, record); }); TrayWindow window = new TrayWindow(platform, transport.SetMenuOpen); bool shutdownRequested = false; bool shutdownSent = false; object stateGate = new object(); @@ -91,7 +91,7 @@ private static int RunChild(string[] args) { 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.ActionResult) { if (!TryDispatchAuthenticatedActionResult(transport, 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); } } } @@ -108,6 +108,12 @@ private static bool VerifyParentIdentity(int pid, long creation) try { using (Process process = Process.GetProcessById(pid)) { return process.StartTime.ToFileTimeUtc() == creation; } } catch { return false; } } + internal static bool TryDispatchAuthenticatedActionResult(HostTransport transport, byte[] payload) + { + if (transport == null || payload == null) { return false; } + return transport.TryAcknowledgeAction(TrayHostWire.ReadActionResult(payload)); + } + private static int RunHeadlessSmoke() { string[] strings = new string[16]; for (int i = 0; i < strings.Length; i++) { strings[i] = "smoke-" + i; } diff --git a/src/trayhost/TrayTerminalDiagnostic.cs b/src/trayhost/TrayTerminalDiagnostic.cs index b4f84c8..c3f160e 100644 --- a/src/trayhost/TrayTerminalDiagnostic.cs +++ b/src/trayhost/TrayTerminalDiagnostic.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Text; internal sealed class TrayTerminalDiagnostic { @@ -19,17 +20,76 @@ internal TrayTerminalDiagnostic(TrayCommand command, ulong revision, TrayActionR internal static class TrayTerminalDiagnosticLog { + internal const long MaximumBytes = 64L * 1024L; private static readonly object Gate = new object(); + internal static bool TryGetDefaultPath(out string path) + { + path = null; + try + { + string local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + if (String.IsNullOrEmpty(local) || !Path.IsPathRooted(local) || !IsSafeExistingDirectory(local)) { return false; } + string product = Path.Combine(local, "CodexControlOtherDevices"); + if (!TryCreateSafeChildDirectory(product)) { return false; } + string logs = Path.Combine(product, "logs"); + if (!TryCreateSafeChildDirectory(logs)) { return false; } + path = Path.Combine(logs, "trayhost-actions.log"); + return IsSafePath(path); + } + catch { path = null; return false; } + } + internal static bool TryAppend(string path, TrayTerminalDiagnostic record) { - if (String.IsNullOrEmpty(path) || record == null) { return false; } + if (String.IsNullOrEmpty(path) || record == null || !Path.IsPathRooted(path)) { return false; } try { string line = "command=" + record.Command.ToString() + " revision=" + record.Revision.ToString(System.Globalization.CultureInfo.InvariantCulture) + " status=" + record.Status.ToString() + " code=" + record.Code + Environment.NewLine; - lock (Gate) { File.AppendAllText(path, line); } + byte[] bytes = new UTF8Encoding(false).GetBytes(line); + if (bytes.LongLength > MaximumBytes) { return false; } + string full = Path.GetFullPath(path); + lock (Gate) + { + if (!IsSafePath(full)) { return false; } + FileMode mode = File.Exists(full) && new FileInfo(full).Length + bytes.LongLength <= MaximumBytes ? FileMode.Append : FileMode.Create; + using (FileStream stream = new FileStream(full, mode, FileAccess.Write, FileShare.Read)) + { + stream.Write(bytes, 0, bytes.Length); + stream.Flush(true); + } + } return true; } catch { return false; } } + + private static bool TryCreateSafeChildDirectory(string path) + { + if (Directory.Exists(path)) { return IsSafeExistingDirectory(path); } + string parent = Path.GetDirectoryName(path); + if (String.IsNullOrEmpty(parent) || !IsSafeExistingDirectory(parent)) { return false; } + Directory.CreateDirectory(path); + return IsSafeExistingDirectory(path); + } + + private static bool IsSafePath(string path) + { + if (!Path.IsPathRooted(path) || !String.Equals(Path.GetFileName(path), "trayhost-actions.log", StringComparison.OrdinalIgnoreCase) || Directory.Exists(path)) { return false; } + if (File.Exists(path) && (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0) { return false; } + string directory = Path.GetDirectoryName(path); + return !String.IsNullOrEmpty(directory) && IsSafeExistingDirectory(directory); + } + + private static bool IsSafeExistingDirectory(string path) + { + DirectoryInfo current = new DirectoryInfo(path); + if (!current.Exists) { return false; } + while (current != null) + { + if (!current.Exists || (current.Attributes & FileAttributes.ReparsePoint) != 0) { return false; } + current = current.Parent; + } + return true; + } } diff --git a/tests/persistence/TrayHostProductionTrace.SelfTest.ps1 b/tests/persistence/TrayHostProductionTrace.SelfTest.ps1 new file mode 100644 index 0000000..a5bff83 --- /dev/null +++ b/tests/persistence/TrayHostProductionTrace.SelfTest.ps1 @@ -0,0 +1,30 @@ +param([Parameter(Mandatory)][string]$AssemblyPath) +$ErrorActionPreference='Stop' +. (Join-Path $PSScriptRoot 'TestSupport.ps1') +$repositoryRoot=Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +[Reflection.Assembly]::LoadFrom([IO.Path]::GetFullPath($AssemblyPath))|Out-Null +Import-Module (Join-Path $repositoryRoot 'src\persistence\modules\TrayHostClient.psm1') -Force + +function Invoke-CcodProductionTraceCase { + param([bool]$Stale) + $client=[TrayHostProductionTraceFixture]::Start($AssemblyPath,$Stale) + try{ + $queue=[Collections.Generic.Queue[object]]::new() + $context=[pscustomobject][ordered]@{Client=$client;CommandQueue=$queue;Exited=$false;LastError=$null;PublishedPresentations=[ordered]@{};AcknowledgedPresentations=[ordered]@{}} + $deadline=[DateTime]::UtcNow.AddSeconds(3) + while($queue.Count-eq0-and[DateTime]::UtcNow-lt$deadline){[void]$client.WaitForActivity([TimeSpan]::FromMilliseconds(100));Receive-CcodTrayHostEvents -Context $context} + Assert-CcodEqual 1 $queue.Count 'real TrayHostClient receives one authenticated production action' + $action=$queue.Dequeue() + Assert-CcodEqual 'OpenLogs' $action.Command 'production trace preserves command through TrayHostWire and TrayHostClient' + $expectedRevision=if($Stale){[UInt64]8}else{[UInt64]1} + Assert-CcodEqual $expectedRevision $action.Revision 'production trace preserves the exact presentation revision' + $status=if($Stale){'Rejected'}else{'Completed'} + $code=if($Stale){'CCOD_TRAY_ACTION_STALE'}else{$null} + Assert-CcodEqual $true (Send-CcodTrayHostActionResult -Context $context -ActionId $action.ActionId -Revision $action.Revision -Status $status -ErrorCode $code -TransactionId $null) 'real TrayHostClient sends the authenticated correlated terminal result' + Assert-CcodTrue $client.WaitForStopped([TimeSpan]::FromSeconds(3)) 'production trace peer validates Program dispatch and exits' + }finally{$client.Dispose()} +} + +Invoke-CcodProductionTraceCase $false +Invoke-CcodProductionTraceCase $true +Write-Host 'TrayHost production correlation trace passed: 2' diff --git a/tests/trayhost/Invoke-TrayHostSelfTest.ps1 b/tests/trayhost/Invoke-TrayHostSelfTest.ps1 index 8846e6d..f38ae62 100644 --- a/tests/trayhost/Invoke-TrayHostSelfTest.ps1 +++ b/tests/trayhost/Invoke-TrayHostSelfTest.ps1 @@ -35,12 +35,20 @@ $parentClientFiles=@( (Join-Path $repositoryRoot 'src\trayhost\JobObject.cs'), (Join-Path $repositoryRoot 'src\trayhost\TrayHostParentClient.cs') ) +$programTraceFiles=@( + (Join-Path $repositoryRoot 'src\trayhost\NativeMethods.cs'), + (Join-Path $repositoryRoot 'src\trayhost\InputModeGuard.cs'), + (Join-Path $repositoryRoot 'src\trayhost\NativeMenu.cs'), + (Join-Path $repositoryRoot 'src\trayhost\TrayWindow.cs'), + (Join-Path $repositoryRoot 'src\trayhost\TrayHostApplication.cs'), + (Join-Path $repositoryRoot 'src\trayhost\Program.cs') +) $testPath=if($ProtocolOnly){Join-Path $repositoryRoot 'tests\trayhost\TrayHostProtocolSelfTest.cs'}elseif($NativeOnly){Join-Path $repositoryRoot 'tests\trayhost\TrayHostNativeSelfTest.cs'}elseif($TransportOnly){Join-Path $repositoryRoot 'tests\trayhost\TrayHostTransportSelfTest.cs'}else{Join-Path $repositoryRoot 'tests\trayhost\TrayHostParentClientSelfTest.cs'} $requiredFiles=@($protocolPath,$presentationPath,$testPath) if($ProtocolOnly){$requiredFiles+=$protocolSupportFiles} if($NativeOnly){$requiredFiles+=$nativeFiles+$transportFiles} if($TransportOnly){$requiredFiles+=$transportFiles} -if($ParentClientOnly){$requiredFiles+=$transportFiles+$parentClientFiles} +if($ParentClientOnly){$requiredFiles+=$transportFiles+$parentClientFiles+$programTraceFiles} if($ProductionOnly){$requiredFiles+=(Get-ChildItem -LiteralPath (Join-Path $repositoryRoot 'src\trayhost') -Filter '*.cs' -File | ForEach-Object FullName)} if($requiredFiles|Where-Object{ -not(Test-Path -LiteralPath $_ -PathType Leaf)}){throw 'CCOD_TRAYHOST_SOURCE_MISSING'} Import-Module (Join-Path $repositoryRoot 'build\TrayHostReferencePack.psm1') -Force @@ -62,11 +70,15 @@ try{ if($ProtocolOnly){$args+=$protocolSupportFiles} if($NativeOnly){$args+=$nativeFiles+$transportFiles} if($TransportOnly){$args+=$transportFiles} - if($ParentClientOnly){$args+=$transportFiles+$parentClientFiles} + if($ParentClientOnly){$args+=$transportFiles+$parentClientFiles+$programTraceFiles} } $args+=$testPath & $compiler @args if($LASTEXITCODE -ne 0){if($ProtocolOnly){throw 'CCOD_TRAYHOST_PROTOCOL_COMPILE_FAILED'}elseif($ProductionOnly){throw 'CCOD_TRAYHOST_PRODUCTION_COMPILE_FAILED'}else{throw 'CCOD_TRAYHOST_NATIVE_COMPILE_FAILED'}} if($ProductionOnly){& $outputPath '--headless-smoke'}else{& $outputPath} if($LASTEXITCODE -ne 0){if($ProtocolOnly){throw 'CCOD_TRAYHOST_PROTOCOL_TEST_FAILED'}elseif($ProductionOnly){throw 'CCOD_TRAYHOST_HEADLESS_SMOKE_FAILED'}else{throw 'CCOD_TRAYHOST_NATIVE_TEST_FAILED'}} + if($ParentClientOnly){ + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $repositoryRoot 'tests\persistence\TrayHostProductionTrace.SelfTest.ps1') -AssemblyPath $outputPath + if($LASTEXITCODE -ne 0){throw 'CCOD_TRAYHOST_PRODUCTION_TRACE_FAILED'} + } }finally{if(Test-Path -LiteralPath $temporaryRoot){Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue}} diff --git a/tests/trayhost/TrayHostNativeSelfTest.cs b/tests/trayhost/TrayHostNativeSelfTest.cs index 273c597..67a1980 100644 --- a/tests/trayhost/TrayHostNativeSelfTest.cs +++ b/tests/trayhost/TrayHostNativeSelfTest.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; internal sealed class FakeTrayPlatform : INativeTrayPlatform @@ -224,6 +225,37 @@ private static void TestTerminalDiagnosticLogIsSanitizedAndReportsPersistence() finally { try { Directory.Delete(root, true); } catch { } } } + private static void TestTerminalDiagnosticLogIsBoundedAndRejectsReparseComponents() + { + string root = Path.Combine(Path.GetTempPath(), "ccod-tray-terminal-bound-" + Guid.NewGuid().ToString("N")); + string target = Path.Combine(root, "target"); + string junction = Path.Combine(root, "junction"); + Directory.CreateDirectory(target); + string path = Path.Combine(target, "trayhost-actions.log"); + try + { + string lastLine = String.Empty; + for (ulong revision = 1UL; revision <= 1400UL; revision++) + { + TrayTerminalDiagnostic record = new TrayTerminalDiagnostic(TrayCommand.OpenLogs, revision, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED"); + AssertTrue(TrayTerminalDiagnosticLog.TryAppend(path, record), "bounded terminal diagnostic append succeeds"); + lastLine = "command=OpenLogs revision=" + revision.ToString(System.Globalization.CultureInfo.InvariantCulture) + " status=Failed code=CCOD_TRAY_ACTION_FAILED" + Environment.NewLine; + } + FileInfo retained = new FileInfo(path); + AssertTrue(retained.Length <= TrayTerminalDiagnosticLog.MaximumBytes, "terminal diagnostic retention never exceeds its fixed byte bound"); + AssertTrue(File.ReadAllText(path).EndsWith(lastLine, StringComparison.Ordinal), "deterministic rollover retains the complete latest terminal record"); + + ProcessStartInfo junctionInfo = new ProcessStartInfo { + FileName = Environment.GetEnvironmentVariable("ComSpec"), + Arguments = "/d /c mklink /J \"" + junction + "\" \"" + target + "\"", + UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true + }; + using (Process junctionProcess = Process.Start(junctionInfo)) { junctionProcess.WaitForExit(); AssertTrue(junctionProcess.ExitCode == 0, "test junction is created"); } + AssertTrue(!TrayTerminalDiagnosticLog.TryAppend(Path.Combine(junction, "trayhost-actions.log"), new TrayTerminalDiagnostic(TrayCommand.OpenLogs, 1UL, TrayActionResultStatus.Rejected, "CCOD_TRAY_ACTION_STALE")), "terminal diagnostic rejects a reparse-point path component"); + } + finally { try { if (Directory.Exists(junction)) { Directory.Delete(junction); } } catch { } try { Directory.Delete(root, true); } catch { } } + } + private static void TestNoHimcFailureIsSafe() { FakeTrayPlatform platform = new FakeTrayPlatform(); @@ -304,12 +336,13 @@ public static int Main(string[] args) TestVerifiedAboutUsesTheAcknowledgedSnapshotVersion(); TestActionFailureUsesTheAcknowledgedSnapshotStrings(); TestTerminalDiagnosticLogIsSanitizedAndReportsPersistence(); + TestTerminalDiagnosticLogIsBoundedAndRejectsReparseComponents(); TestSimplifiedMenuAndExitConfirmation(); TestNoHimcFailureIsSafe(); TestShellRightClickNotificationMapping(); TestRealNativePInvokeSurface(); TestPostedWorkMessageDispatchesToItsOwnerWindow(); - Console.WriteLine("TrayHost native self-tests passed: 16"); + Console.WriteLine("TrayHost native self-tests passed: 17"); return 0; } catch (Exception error) diff --git a/tests/trayhost/TrayHostParentClientSelfTest.cs b/tests/trayhost/TrayHostParentClientSelfTest.cs index ae81f18..f2e8589 100644 --- a/tests/trayhost/TrayHostParentClientSelfTest.cs +++ b/tests/trayhost/TrayHostParentClientSelfTest.cs @@ -5,9 +5,39 @@ using System.Security.Cryptography; using System.Threading; +internal sealed class ProductionTraceTrayPlatform : INativeTrayPlatform +{ + internal string Message; + public IntPtr CreateOwner() { return new IntPtr(10); } + public IntPtr AssociateOwnerInputContext(IntPtr owner, IntPtr context) { return new IntPtr(20); } + public IntPtr GetOwnerInputContext(IntPtr owner) { return IntPtr.Zero; } + public bool ReleaseInputContext(IntPtr owner, IntPtr context) { return true; } + public IntPtr LoadIcon() { return new IntPtr(40); } + public bool DestroyIcon(IntPtr icon) { return true; } + public bool AddIcon(ref TrayIconData icon) { return true; } + public bool SetIconVersion(ref TrayIconData icon) { return true; } + public bool DeleteIcon(ref TrayIconData icon) { return true; } + public IntPtr CreatePopupMenu() { return new IntPtr(30); } + public IntPtr CreateSubMenu() { return new IntPtr(31); } + public bool AppendMenu(IntPtr menu, uint flags, UIntPtr command, string text) { return true; } + public bool AppendSubMenu(IntPtr menu, IntPtr child, string text) { return true; } + public bool ShowOwner(IntPtr owner) { return true; } + public bool HideOwner(IntPtr owner) { return true; } + public bool SetForegroundWindow(IntPtr owner) { return true; } + public IntPtr GetForegroundWindow() { return new IntPtr(10); } + public uint TrackPopupMenuEx(IntPtr menu, uint flags, int x, int y, IntPtr owner, IntPtr parameters) { return 0U; } + public bool PostMessage(IntPtr owner, uint message, UIntPtr wParam, IntPtr lParam) { return true; } + public bool SetNotificationFocus(ref TrayIconData icon) { return true; } + public bool ShowMessageBox(IntPtr owner, string text, string caption) { Message = caption + "|" + text; return true; } + public bool ConfirmExit(IntPtr owner, string text, string caption) { return true; } + public bool DestroyMenu(IntPtr menu) { return true; } + public bool EndMenu() { return true; } + public bool DestroyOwner(IntPtr owner) { return true; } +} + internal static class TrayHostParentClientSelfTest { - private static string PeerArguments = "--peer"; + internal static string PeerArguments = "--peer"; private static void AssertTrue(bool value, string message) { if (!value) { throw new InvalidOperationException(message); } } @@ -18,7 +48,7 @@ private static PresentationSnapshot Snapshot(ulong revision) return new PresentationSnapshot(revision, TrayColor.Green, ConnectionState.Connected, ProtectionState.Running, LanguageMode.Chinese, PresentationFlags.OpenLogsEnabled, strings); } - private static Process StartPeer(ProcessStartInfo requested) + internal static Process StartPeer(ProcessStartInfo requested) { requested.Arguments = PeerArguments; requested.UseShellExecute = false; @@ -29,7 +59,7 @@ private static Process StartPeer(ProcessStartInfo requested) return Process.Start(requested); } - private static void RunPeer(bool emitFaultAndIgnoreShutdown, bool emitAction) + private static void RunPeer(bool emitFaultAndIgnoreShutdown, bool emitAction, bool staleAction) { Stream input = Console.OpenStandardInput(); Stream output = Console.OpenStandardOutput(); @@ -50,16 +80,27 @@ private static void RunPeer(bool emitFaultAndIgnoreShutdown, bool emitAction) if (emitAction) { Guid actionId = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); - Guid transactionId = Guid.Parse("11111111-2222-3333-4444-555555555555"); - ProtocolCodec.WriteAuthenticated(output, ProtocolFrame.Authenticated(ProtocolDirection.HostToParent, TrayHostMessageType.Action, epoch, outboundSequence++, TrayHostWire.WriteAction(new TrayHostAction(actionId, TrayCommand.CheckAndRepair, 1UL))), keys.HostToParent); - ProtocolFrame acceptedFrame = ProtocolCodec.ReadAuthenticated(input, ProtocolDirection.ParentToHost, epoch, inboundSequence++, keys.ParentToHost); - AssertTrue(acceptedFrame.MessageType == TrayHostMessageType.ActionResult, "peer receives one parent action result"); - TrayActionResult accepted = TrayHostWire.ReadActionResult(acceptedFrame.Payload); - AssertTrue(accepted.ActionId == actionId && accepted.Revision == 1UL && accepted.Status == TrayActionResultStatus.Accepted && accepted.TransactionId.HasValue && accepted.TransactionId.Value == transactionId, "peer receives the correlated accepted result"); - ProtocolFrame completedFrame = ProtocolCodec.ReadAuthenticated(input, ProtocolDirection.ParentToHost, epoch, inboundSequence++, keys.ParentToHost); - AssertTrue(completedFrame.MessageType == TrayHostMessageType.ActionResult, "peer receives one parent terminal result"); - TrayActionResult completed = TrayHostWire.ReadActionResult(completedFrame.Payload); - AssertTrue(completed.ActionId == actionId && completed.Status == TrayActionResultStatus.Completed && completed.TransactionId.HasValue && completed.TransactionId.Value == transactionId, "peer receives the correlated completed result"); + TrayHostAction action = new TrayHostAction(actionId, TrayCommand.OpenLogs, staleAction ? 8UL : 1UL); + HostTransport host = new HostTransport(null, delegate(TrayTerminalDiagnostic record) { return true; }); + ProductionTraceTrayPlatform tracePlatform = new ProductionTraceTrayPlatform(); + TrayWindow traceWindow = new TrayWindow(tracePlatform); traceWindow.Create(Snapshot(1UL)); + AssertTrue(host.TryRegisterAction(action), "program peer registers the exact outbound action"); + ProtocolCodec.WriteAuthenticated(output, ProtocolFrame.Authenticated(ProtocolDirection.HostToParent, TrayHostMessageType.Action, epoch, outboundSequence++, TrayHostWire.WriteAction(action)), keys.HostToParent); + ProtocolFrame terminalFrame = ProtocolCodec.ReadAuthenticated(input, ProtocolDirection.ParentToHost, epoch, inboundSequence++, keys.ParentToHost); + AssertTrue(terminalFrame.MessageType == TrayHostMessageType.ActionResult, "program peer receives one authenticated terminal result"); + AssertTrue(Program.TryDispatchAuthenticatedActionResult(host, terminalFrame.Payload), "production Program dispatch accepts the correlated authenticated result"); + TrayActionResult feedback; + if (staleAction) + { + AssertTrue(host.TryTakeFailedAction(out feedback) && feedback.Revision == 8UL && feedback.Status == TrayActionResultStatus.Rejected && String.Equals(feedback.ErrorCode, "CCOD_TRAY_ACTION_STALE", StringComparison.Ordinal), "stale authenticated result reaches the production generic-feedback queue with its exact code"); + traceWindow.ShowActionFailed(); + AssertTrue(String.Equals(tracePlatform.Message, "string-0|string-15", StringComparison.Ordinal), "stale production trace collapses internal details to the generic acknowledged-snapshot dialog"); + } + else + { + AssertTrue(!host.TryTakeFailedAction(out feedback) && tracePlatform.Message == null, "completed current action produces no generic failure feedback"); + } + traceWindow.Dispose(); host.Dispose(); return; } if (emitFaultAndIgnoreShutdown) @@ -86,7 +127,7 @@ private static void RunPeer(bool emitFaultAndIgnoreShutdown, bool emitAction) } } - private static TrayHostStartOptions Options() + internal static TrayHostStartOptions Options() { Process current = Process.GetCurrentProcess(); return new TrayHostStartOptions { @@ -172,16 +213,30 @@ private static void TestCorrelatedActionResults() TrayHostEvent value; while (client.TryDequeueEvent(out value)) { if (value.Kind == TrayHostEventKind.Action) { action = value; break; } } } - AssertTrue(action != null && action.Command == TrayCommand.CheckAndRepair && action.Revision == 1UL, "parent receives the exact v2 action event"); - Guid transactionId = Guid.Parse("11111111-2222-3333-4444-555555555555"); - AssertTrue(client.TryAcknowledgeAction(new TrayActionResult(action.ActionId, action.Revision, TrayActionResultStatus.Accepted, null, transactionId)), "parent queues the accepted action result"); + AssertTrue(action != null && action.Command == TrayCommand.OpenLogs && action.Revision == 1UL, "parent receives the exact current v2 action event"); bool completed = false; deadline = DateTime.UtcNow.AddSeconds(2); while (!completed && DateTime.UtcNow < deadline) { - completed = client.TryAcknowledgeAction(new TrayActionResult(action.ActionId, action.Revision, TrayActionResultStatus.Completed, null, transactionId)); + completed = client.TryAcknowledgeAction(new TrayActionResult(action.ActionId, action.Revision, TrayActionResultStatus.Completed, null, null)); if (!completed) { Thread.Sleep(20); } } - AssertTrue(completed, "parent queues the terminal result after the accepted result drains"); + AssertTrue(completed, "parent queues the current terminal result through the authenticated writer"); + } + finally { if (client != null) { client.Dispose(); } PeerArguments = "--peer"; } + + PeerArguments = "--stale-action-peer"; + try + { + client = TrayHostParentClient.Start(Options()); + TrayHostEvent action = null; DateTime deadline = DateTime.UtcNow.AddSeconds(2); + while (action == null && DateTime.UtcNow < deadline) + { + client.WaitForActivity(TimeSpan.FromMilliseconds(100)); + TrayHostEvent value; + while (client.TryDequeueEvent(out value)) { if (value.Kind == TrayHostEventKind.Action) { action = value; break; } } + } + AssertTrue(action != null && action.Command == TrayCommand.OpenLogs && action.Revision == 8UL, "parent receives the exact stale v2 action event"); + AssertTrue(client.TryAcknowledgeAction(new TrayActionResult(action.ActionId, action.Revision, TrayActionResultStatus.Rejected, "CCOD_TRAY_ACTION_STALE", null)), "parent queues the stale terminal result through the authenticated writer"); } finally { if (client != null) { client.Dispose(); } PeerArguments = "--peer"; } } @@ -190,9 +245,10 @@ public static int Main(string[] args) { try { - if (args.Length == 1 && args[0] == "--peer") { RunPeer(false, false); return 0; } - if (args.Length == 1 && args[0] == "--fault-peer") { RunPeer(true, false); return 0; } - if (args.Length == 1 && args[0] == "--action-peer") { RunPeer(false, true); return 0; } + if (args.Length == 1 && args[0] == "--peer") { RunPeer(false, false, false); return 0; } + if (args.Length == 1 && args[0] == "--fault-peer") { RunPeer(true, false, false); return 0; } + if (args.Length == 1 && args[0] == "--action-peer") { RunPeer(false, true, false); return 0; } + if (args.Length == 1 && args[0] == "--stale-action-peer") { RunPeer(false, true, true); return 0; } TrayHostParentClient.TestProcessFactory = StartPeer; TrayHostParentClient client = TrayHostParentClient.Start(Options()); AssertTrue(client.Receipt.ProtocolMajor == 2 && client.GetHealth() == TrayHostHealth.Ready, "parent waits for verified v2 ready"); @@ -217,3 +273,28 @@ public static int Main(string[] args) } } } + +public static class TrayHostProductionTraceFixture +{ + public static TrayHostParentClient Start(string executablePath, bool stale) + { + if (String.IsNullOrEmpty(executablePath)) { throw new ArgumentException("trace executable is required", "executablePath"); } + Process current = Process.GetCurrentProcess(); + TrayHostParentClient.TestProcessFactory = TrayHostParentClientSelfTest.StartPeer; + TrayHostParentClientSelfTest.PeerArguments = stale ? "--stale-action-peer" : "--action-peer"; + return TrayHostParentClient.Start(new TrayHostStartOptions { + ExePath = executablePath, + RuntimeId = "trace-runtime", + ParentPid = current.Id, + ParentCreationFileTimeUtc = current.StartTime.ToFileTimeUtc(), + InitialPresentation = CreateSnapshot() + }); + } + + private static PresentationSnapshot CreateSnapshot() + { + string[] strings = new string[16]; + for (int index = 0; index < strings.Length; index++) { strings[index] = "trace-" + index.ToString(); } + return new PresentationSnapshot(1UL, TrayColor.Green, ConnectionState.Connected, ProtectionState.Running, LanguageMode.English, PresentationFlags.OpenLogsEnabled, strings); + } +} diff --git a/tests/trayhost/TrayHostTransportSelfTest.cs b/tests/trayhost/TrayHostTransportSelfTest.cs index 7a55cfa..d276a07 100644 --- a/tests/trayhost/TrayHostTransportSelfTest.cs +++ b/tests/trayhost/TrayHostTransportSelfTest.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; internal static class TrayHostTransportSelfTest { @@ -198,6 +200,42 @@ private static void TestTerminalDiagnosticFailureNeverStrandsPendingActions() host.Dispose(); } + private static void TestBlockingTerminalWriterRunsOutsideCorrelationLock() + { + ManualResetEvent writerEntered = new ManualResetEvent(false); + ManualResetEvent releaseWriter = new ManualResetEvent(false); + bool first = true; + HostTransport host = new HostTransport(null, delegate(TrayTerminalDiagnostic record) + { + if (first) { first = false; writerEntered.Set(); releaseWriter.WaitOne(TimeSpan.FromSeconds(3)); } + return true; + }); + Guid blockedId = Guid.NewGuid(); + AssertTrue(host.TryRegisterAction(new TrayHostAction(blockedId, TrayCommand.OpenLogs, 24UL)), "blocking-writer action registers"); + bool acknowledged = false; + Thread acknowledgement = new Thread((ThreadStart)delegate { acknowledged = host.TryAcknowledgeAction(new TrayActionResult(blockedId, 24UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED", null)); }); + acknowledgement.IsBackground = true; + acknowledgement.Start(); + try + { + AssertTrue(writerEntered.WaitOne(TimeSpan.FromSeconds(2)), "terminal writer enters deterministically"); + Guid concurrentId = Guid.NewGuid(); + Stopwatch registration = Stopwatch.StartNew(); + bool registered = host.TryRegisterAction(new TrayHostAction(concurrentId, TrayCommand.OpenLogs, 24UL)); + registration.Stop(); + AssertTrue(registered && registration.Elapsed < TimeSpan.FromMilliseconds(250), "blocking terminal I/O cannot hold the correlation lock or pending capacity"); + releaseWriter.Set(); + AssertTrue(acknowledgement.Join(TimeSpan.FromSeconds(2)) && acknowledged, "terminal acknowledgement completes after the writer is released"); + AssertTrue(host.TryAcknowledgeAction(new TrayActionResult(concurrentId, 24UL, TrayActionResultStatus.Completed, null, null)), "concurrent action remains independently completable"); + } + finally + { + releaseWriter.Set(); + if (acknowledgement.IsAlive) { acknowledgement.Join(TimeSpan.FromSeconds(2)); } + writerEntered.Dispose(); releaseWriter.Dispose(); host.Dispose(); + } + } + public static int Main(string[] args) { try @@ -211,7 +249,8 @@ public static int Main(string[] args) TestRejectedAndFailedActionsQueueUserFeedback(); TestUndisplayedActionFailureFeedbackIsBounded(); TestTerminalDiagnosticFailureNeverStrandsPendingActions(); - Console.WriteLine("TrayHost transport self-tests passed: 9"); + TestBlockingTerminalWriterRunsOutsideCorrelationLock(); + Console.WriteLine("TrayHost transport self-tests passed: 10"); return 0; } catch (Exception error) From 4aad2a7f84e3d4cf3acb2608154e9f8c7a123ec9 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 06:05:44 +0800 Subject: [PATCH 024/187] docs: record Task 4 fix round 3 --- .../task-4-report.md | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) 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 index 9c7613a..f7a2224 100644 --- 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 @@ -446,3 +446,183 @@ the new diagnostic type, and runs only the existing no-UI headless path. 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. From a7292f6b952a290a23ac852ea6ae56600293f214 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 08:25:59 +0800 Subject: [PATCH 025/187] docs: plan async tray receipt reliability --- .../2026-08-28-v2522-async-tray-receipts.md | 235 ++++++++++++++++++ ...08-28-v2522-install-runtime-reliability.md | 7 + ...-08-28-v2522-async-tray-receipts-design.md | 124 +++++++++ 3 files changed, 366 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-28-v2522-async-tray-receipts.md create mode 100644 docs/superpowers/specs/2026-08-28-v2522-async-tray-receipts-design.md 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..6d2b628 --- /dev/null +++ b/docs/superpowers/plans/2026-08-28-v2522-async-tray-receipts.md @@ -0,0 +1,235 @@ +# 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 directory components and the fixed leaf through `CreateFileW` safe +handles. 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` +- 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 About/failure work to the existing application context. On admission, +store, callback, queue, 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 index 39f2442..246a845 100644 --- a/docs/superpowers/plans/2026-08-28-v2522-install-runtime-reliability.md +++ b/docs/superpowers/plans/2026-08-28-v2522-install-runtime-reliability.md @@ -228,6 +228,13 @@ 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` 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..8cbc4f8 --- /dev/null +++ b/docs/superpowers/specs/2026-08-28-v2522-async-tray-receipts-design.md @@ -0,0 +1,124 @@ +# 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. The callback posts +work to the STA application context outside transport locks. The UI queue is +also bounded at eight shared items. + +### 2. Pinned, bounded native receipt store + +The store uses the fixed path +`%LOCALAPPDATA%\CodexControlOtherDevices\logs\trayhost-actions.log`. It opens +the LocalAppData, product, and logs directories as native directory handles, +with reparse-point opening flags and delete sharing denied, validates each +opened handle as the expected non-reparse directory, and holds the directory +chain 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. From 365fde2430723fc8f80106e86490f7fb70be163f Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 08:54:02 +0800 Subject: [PATCH 026/187] fix: pin tray terminal receipt storage --- src/trayhost/Program.cs | 7 +- src/trayhost/TrayTerminalDiagnostic.cs | 353 ++++++++++++++++++--- tests/trayhost/Invoke-TrayHostSelfTest.ps1 | 1 + tests/trayhost/TrayHostNativeSelfTest.cs | 252 +++++++++++++-- 4 files changed, 543 insertions(+), 70 deletions(-) diff --git a/src/trayhost/Program.cs b/src/trayhost/Program.cs index f438004..895c118 100644 --- a/src/trayhost/Program.cs +++ b/src/trayhost/Program.cs @@ -39,9 +39,8 @@ private static int RunChild(string[] args) PresentationSnapshot initial = TrayHostWire.ReadPresentation(initialFrame.Payload); Win32TrayPlatform platform = new Win32TrayPlatform(); TrayHostApplication application = null; - string terminalLogPath; - if (!TrayTerminalDiagnosticLog.TryGetDefaultPath(out terminalLogPath)) { terminalLogPath = null; } - HostTransport transport = new HostTransport(delegate { TrayHostApplication current = application; if (current != null) { current.PostWork(); } }, delegate(TrayTerminalDiagnostic record) { return TrayTerminalDiagnosticLog.TryAppend(terminalLogPath, record); }); + TrayTerminalDiagnosticStore terminalStore = new TrayTerminalDiagnosticStore(); + HostTransport transport = new HostTransport(delegate { TrayHostApplication current = application; if (current != null) { current.PostWork(); } }, delegate(TrayTerminalDiagnostic record) { return terminalStore.TryAppendDurably(record); }); TrayWindow window = new TrayWindow(platform, transport.SetMenuOpen); bool shutdownRequested = false; bool shutdownSent = false; object stateGate = new object(); Action command = delegate(TrayCommand selected, ulong revision) @@ -100,7 +99,7 @@ private static int RunChild(string[] args) })) { IsBackground = true, Name = "CodexRemote.TrayHost.Reader" }; reader.Start(); int result = application.Run(); - application.Dispose(); transport.Dispose(); return result; + application.Dispose(); transport.Dispose(); terminalStore.Dispose(); return result; } private static bool VerifyParentIdentity(int pid, long creation) diff --git a/src/trayhost/TrayTerminalDiagnostic.cs b/src/trayhost/TrayTerminalDiagnostic.cs index c3f160e..43497af 100644 --- a/src/trayhost/TrayTerminalDiagnostic.cs +++ b/src/trayhost/TrayTerminalDiagnostic.cs @@ -1,6 +1,11 @@ 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 { @@ -18,78 +23,350 @@ internal TrayTerminalDiagnostic(TrayCommand command, ulong revision, TrayActionR } } -internal static class TrayTerminalDiagnosticLog +internal sealed class TrayTerminalDiagnosticStore : IDisposable { + private sealed class ReceiptPaths + { + internal string LocalRoot; + internal string Product; + internal string Logs; + 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 static readonly object Gate = new object(); + 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 { - string local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - if (String.IsNullOrEmpty(local) || !Path.IsPathRooted(local) || !IsSafeExistingDirectory(local)) { return false; } - string product = Path.Combine(local, "CodexControlOtherDevices"); - if (!TryCreateSafeChildDirectory(product)) { return false; } - string logs = Path.Combine(product, "logs"); - if (!TryCreateSafeChildDirectory(logs)) { return false; } - path = Path.Combine(logs, "trayhost-actions.log"); - return IsSafePath(path); + ReceiptPaths paths; + if (!TryBuildPaths(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), out paths)) { return false; } + path = paths.Leaf; + return true; } catch { path = null; return false; } } - internal static bool TryAppend(string path, TrayTerminalDiagnostic record) + internal bool TryAppendDurably(TrayTerminalDiagnostic record) { - if (String.IsNullOrEmpty(path) || record == null || !Path.IsPathRooted(path)) { return false; } + 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; - byte[] bytes = new UTF8Encoding(false).GetBytes(line); + bytes = new UTF8Encoding(false).GetBytes(line); if (bytes.LongLength > MaximumBytes) { return false; } - string full = Path.GetFullPath(path); - lock (Gate) + } + catch { return false; } + + lock (_gate) + { + if (_disposed) { return false; } + try { - if (!IsSafePath(full)) { return false; } - FileMode mode = File.Exists(full) && new FileInfo(full).Length + bytes.LongLength <= MaximumBytes ? FileMode.Append : FileMode.Create; - using (FileStream stream = new FileStream(full, mode, FileAccess.Write, FileShare.Read)) + 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; } + if (!TryCreateChildDirectory(paths.Product)) { return false; } + using (SafeFileHandle product = OpenDirectory(paths.Product)) + { + if (!IsExpectedDirectory(product, paths.Product, true)) { return false; } + if (!TryCreateChildDirectory(paths.Logs)) { return false; } + using (SafeFileHandle logs = OpenDirectory(paths.Logs)) { - stream.Write(bytes, 0, bytes.Length); - stream.Flush(true); + if (!IsExpectedDirectory(logs, paths.Logs, 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 leaf = NormalizePath(Path.Combine(logs, "trayhost-actions.log")); + if (String.IsNullOrEmpty(product) || String.IsNullOrEmpty(logs) || 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(leaf), logs, 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(leaf), "trayhost-actions.log", StringComparison.Ordinal) || Path.GetFileName(leaf).IndexOf(':') >= 0) { return false; } + paths = new ReceiptPaths { LocalRoot = canonicalRoot, Product = product, Logs = logs, 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 TryCreateChildDirectory(string path) + { + try + { + if (Directory.Exists(path)) { return true; } + Directory.CreateDirectory(path, CreatePrivateDirectorySecurity()); return true; } catch { return false; } } - private static bool TryCreateSafeChildDirectory(string path) + 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) { - if (Directory.Exists(path)) { return IsSafeExistingDirectory(path); } - string parent = Path.GetDirectoryName(path); - if (String.IsNullOrEmpty(parent) || !IsSafeExistingDirectory(parent)) { return false; } - Directory.CreateDirectory(path); - return IsSafeExistingDirectory(path); + 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 IsSafePath(string path) + private static bool IsExpectedFinalPath(SafeFileHandle handle, string expectedPath) { - if (!Path.IsPathRooted(path) || !String.Equals(Path.GetFileName(path), "trayhost-actions.log", StringComparison.OrdinalIgnoreCase) || Directory.Exists(path)) { return false; } - if (File.Exists(path) && (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0) { return false; } - string directory = Path.GetDirectoryName(path); - return !String.IsNullOrEmpty(directory) && IsSafeExistingDirectory(directory); + string actual; + if (!TryGetFinalPath(handle, out actual)) { return false; } + return String.Equals(actual, NormalizePath(expectedPath), StringComparison.OrdinalIgnoreCase); } - private static bool IsSafeExistingDirectory(string path) + private static bool TryGetFinalPath(SafeFileHandle handle, out string path) { - DirectoryInfo current = new DirectoryInfo(path); - if (!current.Exists) { return false; } - while (current != null) + 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 (!current.Exists || (current.Attributes & FileAttributes.ReparsePoint) != 0) { return false; } - current = current.Parent; + 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; } } - return true; + 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/tests/trayhost/Invoke-TrayHostSelfTest.ps1 b/tests/trayhost/Invoke-TrayHostSelfTest.ps1 index f38ae62..ad95efa 100644 --- a/tests/trayhost/Invoke-TrayHostSelfTest.ps1 +++ b/tests/trayhost/Invoke-TrayHostSelfTest.ps1 @@ -62,6 +62,7 @@ try{ $mainType=if($ProtocolOnly){'TrayHostProtocolSelfTest'}elseif($NativeOnly){'TrayHostNativeSelfTest'}elseif($TransportOnly){'TrayHostTransportSelfTest'}elseif($ParentClientOnly){'TrayHostParentClientSelfTest'}else{'Program'} $outputPath=Join-Path $temporaryRoot $outputName $args=@('/nologo','/noconfig','/nostdlib+','/target:exe','/platform:anycpu','/optimize+','/checked+','/warn:4','/warnaserror+',('/out:{0}' -f $outputPath),('/main:{0}' -f $mainType)) + if($NativeOnly){$args+='/define:TRAYHOST_SELF_TEST'} foreach($leaf in @('mscorlib.dll','System.dll','System.Core.dll','System.Drawing.dll')){$args+=('/reference:'+ (Join-Path $reference.ReferenceRoot $leaf))} if($ProductionOnly){$args+=(Get-ChildItem -LiteralPath (Join-Path $repositoryRoot 'src\trayhost') -Filter '*.cs' -File | ForEach-Object FullName)} else diff --git a/tests/trayhost/TrayHostNativeSelfTest.cs b/tests/trayhost/TrayHostNativeSelfTest.cs index 67a1980..316ea72 100644 --- a/tests/trayhost/TrayHostNativeSelfTest.cs +++ b/tests/trayhost/TrayHostNativeSelfTest.cs @@ -2,6 +2,11 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.Runtime.InteropServices; +using System.Security.AccessControl; +using System.Security.Principal; +using System.Text; +using System.Threading; internal sealed class FakeTrayPlatform : INativeTrayPlatform { @@ -48,9 +53,48 @@ internal sealed class FakeTrayPlatform : INativeTrayPlatform internal static class TrayHostNativeSelfTest { + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CreateHardLinkW(string fileName, string existingFileName, IntPtr securityAttributes); + private static void AssertTrue(bool value, string message) { if (!value) { throw new InvalidOperationException(message); } } private static void AssertEqual(string expected, string actual, string message) { if (!String.Equals(expected, actual, StringComparison.Ordinal)) { throw new InvalidOperationException(message + " expected=[" + expected + "] actual=[" + actual + "]"); } } + private static string ReceiptPath(string root) + { + return Path.Combine(root, "CodexControlOtherDevices", "logs", "trayhost-actions.log"); + } + + private static TrayTerminalDiagnosticStore TestStore(string root, Action directoryChainOpened, Action leafValidated) + { + return TrayTerminalDiagnosticStore.CreateForTesting(root, directoryChainOpened, leafValidated); + } + + private static void CreateJunction(string link, string target) + { + ProcessStartInfo info = new ProcessStartInfo { + FileName = Environment.GetEnvironmentVariable("ComSpec"), + Arguments = "/d /c mklink /J \"" + link + "\" \"" + target + "\"", + UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true + }; + using (Process process = Process.Start(info)) + { + process.WaitForExit(); + AssertTrue(process.ExitCode == 0, "test junction is created: " + process.StandardError.ReadToEnd()); + } + } + + private static void DeleteJunction(string path) + { + try { if (Directory.Exists(path)) { Directory.Delete(path); } } catch { } + } + + private static byte[] ReceiptBytes(TrayCommand command, ulong revision, TrayActionResultStatus status, string code) + { + string line = "command=" + command.ToString() + " revision=" + revision.ToString(System.Globalization.CultureInfo.InvariantCulture) + " status=" + status.ToString() + " code=" + code + Environment.NewLine; + return new UTF8Encoding(false).GetBytes(line); + } + private static PresentationSnapshot Snapshot(ulong revision) { return SnapshotV2(revision); @@ -214,46 +258,194 @@ private static void TestTerminalDiagnosticLogIsSanitizedAndReportsPersistence() { string root = Path.Combine(Path.GetTempPath(), "ccod-tray-terminal-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); - string path = Path.Combine(root, "trayhost-actions.log"); + string path = ReceiptPath(root); try { TrayTerminalDiagnostic record = new TrayTerminalDiagnostic(TrayCommand.OpenLogs, 23UL, TrayActionResultStatus.Rejected, "CCOD_TRAY_ACTION_STALE"); - AssertTrue(TrayTerminalDiagnosticLog.TryAppend(path, record), "terminal diagnostic append reports durable success"); - AssertEqual("command=OpenLogs revision=23 status=Rejected code=CCOD_TRAY_ACTION_STALE" + Environment.NewLine, File.ReadAllText(path), "terminal diagnostic file contains only the approved correlation fields"); - AssertTrue(!TrayTerminalDiagnosticLog.TryAppend(root, record), "terminal diagnostic append reports persistence failure without throwing"); + using (TrayTerminalDiagnosticStore store = TestStore(root, null, null)) + { + AssertTrue(store.TryAppendDurably(record), "terminal diagnostic append reports durable success"); + } + byte[] expected = ReceiptBytes(TrayCommand.OpenLogs, 23UL, TrayActionResultStatus.Rejected, "CCOD_TRAY_ACTION_STALE"); + AssertTrue(Convert.ToBase64String(expected) == Convert.ToBase64String(File.ReadAllBytes(path)), "terminal diagnostic file is exact UTF-8 without a BOM and contains only approved fields"); + string defaultPath; + AssertTrue(TrayTerminalDiagnosticStore.TryGetDefaultPath(out defaultPath), "fixed production receipt path resolves"); + string expectedDefaultPath = Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "CodexControlOtherDevices", "logs", "trayhost-actions.log")); + AssertTrue(String.Equals(expectedDefaultPath, defaultPath, StringComparison.OrdinalIgnoreCase), "production receipt path is fixed to the current user's exact LocalApplicationData leaf"); } finally { try { Directory.Delete(root, true); } catch { } } } - private static void TestTerminalDiagnosticLogIsBoundedAndRejectsReparseComponents() + private static void TestTerminalDiagnosticStoreRejectsUnsafeObjectsAndProtectsOutsideSentinel() { - string root = Path.Combine(Path.GetTempPath(), "ccod-tray-terminal-bound-" + Guid.NewGuid().ToString("N")); - string target = Path.Combine(root, "target"); - string junction = Path.Combine(root, "junction"); - Directory.CreateDirectory(target); - string path = Path.Combine(target, "trayhost-actions.log"); + string root = Path.Combine(Path.GetTempPath(), "ccod-tray-terminal-unsafe-" + Guid.NewGuid().ToString("N")); + string outside = Path.Combine(Path.GetTempPath(), "ccod-tray-terminal-outside-" + Guid.NewGuid().ToString("N")); + string outsideSentinel = Path.Combine(outside, "trayhost-actions.log"); + string path = ReceiptPath(root); + string logs = Path.GetDirectoryName(path); + byte[] sentinel = new byte[] { 0x10, 0x22, 0x34, 0x46, 0x58, 0x6a }; + Directory.CreateDirectory(root); Directory.CreateDirectory(outside); File.WriteAllBytes(outsideSentinel, sentinel); try { - string lastLine = String.Empty; - for (ulong revision = 1UL; revision <= 1400UL; revision++) + TrayTerminalDiagnostic record = new TrayTerminalDiagnostic(TrayCommand.OpenLogs, 1UL, TrayActionResultStatus.Rejected, "CCOD_TRAY_ACTION_STALE"); + using (TrayTerminalDiagnosticStore bootstrap = TestStore(root, null, null)) { AssertTrue(bootstrap.TryAppendDurably(record), "safe receipt directories are bootstrapped"); } + File.Delete(path); Directory.Delete(logs); + + CreateJunction(logs, outside); + using (TrayTerminalDiagnosticStore reparseDirectory = TestStore(root, null, null)) + { + AssertTrue(!reparseDirectory.TryAppendDurably(record), "a reparse logs directory is rejected before writing"); + } + AssertTrue(Convert.ToBase64String(sentinel) == Convert.ToBase64String(File.ReadAllBytes(outsideSentinel)), "reparse directory cannot alter the outside sentinel"); + DeleteJunction(logs); + + using (TrayTerminalDiagnosticStore restore = TestStore(root, null, null)) { AssertTrue(restore.TryAppendDurably(record), "safe logs directory is restored for leaf tests"); } + File.Delete(path); + CreateJunction(path, outside); + using (TrayTerminalDiagnosticStore reparseLeaf = TestStore(root, null, null)) + { + AssertTrue(!reparseLeaf.TryAppendDurably(record), "a reparse receipt leaf is rejected before writing"); + } + AssertTrue(Convert.ToBase64String(sentinel) == Convert.ToBase64String(File.ReadAllBytes(outsideSentinel)), "reparse leaf cannot alter the outside sentinel"); + DeleteJunction(path); + + AssertTrue(CreateHardLinkW(path, outsideSentinel, IntPtr.Zero), "test hard link is created"); + using (TrayTerminalDiagnosticStore hardLinkLeaf = TestStore(root, null, null)) { - TrayTerminalDiagnostic record = new TrayTerminalDiagnostic(TrayCommand.OpenLogs, revision, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED"); - AssertTrue(TrayTerminalDiagnosticLog.TryAppend(path, record), "bounded terminal diagnostic append succeeds"); - lastLine = "command=OpenLogs revision=" + revision.ToString(System.Globalization.CultureInfo.InvariantCulture) + " status=Failed code=CCOD_TRAY_ACTION_FAILED" + Environment.NewLine; + AssertTrue(!hardLinkLeaf.TryAppendDurably(record), "a multi-link receipt leaf is rejected before writing"); } - FileInfo retained = new FileInfo(path); - AssertTrue(retained.Length <= TrayTerminalDiagnosticLog.MaximumBytes, "terminal diagnostic retention never exceeds its fixed byte bound"); - AssertTrue(File.ReadAllText(path).EndsWith(lastLine, StringComparison.Ordinal), "deterministic rollover retains the complete latest terminal record"); - - ProcessStartInfo junctionInfo = new ProcessStartInfo { - FileName = Environment.GetEnvironmentVariable("ComSpec"), - Arguments = "/d /c mklink /J \"" + junction + "\" \"" + target + "\"", - UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true + AssertTrue(Convert.ToBase64String(sentinel) == Convert.ToBase64String(File.ReadAllBytes(outsideSentinel)), "hard-link leaf cannot alter the outside sentinel"); + File.Delete(path); + + DirectorySecurity unsafeSecurity = Directory.GetAccessControl(logs); + unsafeSecurity.AddAccessRule(new FileSystemAccessRule(new SecurityIdentifier(WellKnownSidType.WorldSid, null), FileSystemRights.FullControl, InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, AccessControlType.Allow)); + Directory.SetAccessControl(logs, unsafeSecurity); + using (TrayTerminalDiagnosticStore unsafeAcl = TestStore(root, null, null)) + { + AssertTrue(!unsafeAcl.TryAppendDurably(record), "an unexpected logs DACL is rejected before writing"); + } + AssertTrue(!File.Exists(path), "unsafe directory ACL cannot create or write the receipt leaf"); + AssertTrue(Convert.ToBase64String(sentinel) == Convert.ToBase64String(File.ReadAllBytes(outsideSentinel)), "unsafe directory ACL cannot alter the outside sentinel"); + } + finally + { + DeleteJunction(path); DeleteJunction(logs); + try { Directory.Delete(root, true); } catch { } + try { Directory.Delete(outside, true); } catch { } + } + } + + private static void TestTerminalDiagnosticStorePinsDirectoryChainAgainstReplacement() + { + string root = Path.Combine(Path.GetTempPath(), "ccod-tray-terminal-pin-" + Guid.NewGuid().ToString("N")); + string outside = Path.Combine(Path.GetTempPath(), "ccod-tray-terminal-pin-outside-" + Guid.NewGuid().ToString("N")); + string path = ReceiptPath(root); string logs = Path.GetDirectoryName(path); string displaced = logs + ".displaced"; + string outsideSentinel = Path.Combine(outside, "trayhost-actions.log"); + byte[] sentinel = new byte[] { 0x71, 0x72, 0x73, 0x74, 0x75 }; + bool barrierEntered = false; bool replacementSucceeded = false; + Directory.CreateDirectory(root); Directory.CreateDirectory(outside); File.WriteAllBytes(outsideSentinel, sentinel); + try + { + TrayTerminalDiagnostic record = new TrayTerminalDiagnostic(TrayCommand.OpenLogs, 2UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED"); + using (TrayTerminalDiagnosticStore bootstrap = TestStore(root, null, null)) { AssertTrue(bootstrap.TryAppendDurably(record), "safe receipt chain is bootstrapped"); } + File.Delete(path); + Action barrier = delegate + { + barrierEntered = true; + try { Directory.Move(logs, displaced); replacementSucceeded = true; CreateJunction(logs, outside); } catch (IOException) { } + catch (UnauthorizedAccessException) { } + throw new InvalidOperationException("intentional barrier stop"); }; - using (Process junctionProcess = Process.Start(junctionInfo)) { junctionProcess.WaitForExit(); AssertTrue(junctionProcess.ExitCode == 0, "test junction is created"); } - AssertTrue(!TrayTerminalDiagnosticLog.TryAppend(Path.Combine(junction, "trayhost-actions.log"), new TrayTerminalDiagnostic(TrayCommand.OpenLogs, 1UL, TrayActionResultStatus.Rejected, "CCOD_TRAY_ACTION_STALE")), "terminal diagnostic rejects a reparse-point path component"); + using (TrayTerminalDiagnosticStore store = TestStore(root, barrier, null)) + { + AssertTrue(!store.TryAppendDurably(record), "a replacement attempt at the directory-to-leaf barrier fails closed"); + } + AssertTrue(barrierEntered, "replacement barrier runs after the directory chain is opened"); + AssertTrue(!replacementSucceeded, "the opened logs handle denies rename replacement"); + AssertTrue(Convert.ToBase64String(sentinel) == Convert.ToBase64String(File.ReadAllBytes(outsideSentinel)), "replacement attempt leaves the outside sentinel byte-identical"); + } + finally + { + DeleteJunction(logs); + try { if (Directory.Exists(displaced) && !Directory.Exists(logs)) { Directory.Move(displaced, logs); } } catch { } + try { Directory.Delete(root, true); } catch { } + try { Directory.Delete(outside, true); } catch { } + } + } + + private static void TestTerminalDiagnosticStoreRejectsConcurrentWriter() + { + string root = Path.Combine(Path.GetTempPath(), "ccod-tray-terminal-share-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + ManualResetEvent leafValidated = new ManualResetEvent(false); ManualResetEvent release = new ManualResetEvent(false); + bool firstResult = false; + try + { + TrayTerminalDiagnostic first = new TrayTerminalDiagnostic(TrayCommand.OpenLogs, 3UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED"); + TrayTerminalDiagnostic second = new TrayTerminalDiagnostic(TrayCommand.OpenLogs, 4UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED"); + using (TrayTerminalDiagnosticStore firstStore = TestStore(root, null, delegate { leafValidated.Set(); release.WaitOne(); })) + using (TrayTerminalDiagnosticStore secondStore = TestStore(root, null, null)) + { + Thread writer = new Thread(new ThreadStart(delegate { firstResult = firstStore.TryAppendDurably(first); })); + writer.Start(); + AssertTrue(leafValidated.WaitOne(5000), "first writer reaches the validated leaf barrier"); + Stopwatch rejection = Stopwatch.StartNew(); + AssertTrue(!secondStore.TryAppendDurably(second), "a concurrent writer is rejected by leaf sharing"); + rejection.Stop(); AssertTrue(rejection.ElapsedMilliseconds < 250L, "a sharing conflict fails without waiting or retrying"); + release.Set(); AssertTrue(writer.Join(5000), "first writer completes after the sharing probe"); + AssertTrue(firstResult, "the validated first writer remains durable"); + } + } + finally { release.Set(); leafValidated.Dispose(); release.Dispose(); try { Directory.Delete(root, true); } catch { } } + } + + private static void TestTerminalDiagnosticStoreRejectsDirectoryLeafWithoutWriting() + { + string root = Path.Combine(Path.GetTempPath(), "ccod-tray-terminal-directory-leaf-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); string path = ReceiptPath(root); + try + { + TrayTerminalDiagnostic record = new TrayTerminalDiagnostic(TrayCommand.OpenLogs, 5UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED"); + using (TrayTerminalDiagnosticStore bootstrap = TestStore(root, null, null)) { AssertTrue(bootstrap.TryAppendDurably(record), "safe receipt chain is bootstrapped for directory-leaf test"); } + File.Delete(path); Directory.CreateDirectory(path); + using (TrayTerminalDiagnosticStore directoryLeaf = TestStore(root, null, null)) + { + AssertTrue(!directoryLeaf.TryAppendDurably(record), "a directory at the fixed receipt leaf is rejected before writing"); + } + AssertTrue(Directory.Exists(path) && Directory.GetFileSystemEntries(path).Length == 0, "directory leaf remains unchanged after rejection"); } - finally { try { if (Directory.Exists(junction)) { Directory.Delete(junction); } } catch { } try { Directory.Delete(root, true); } catch { } } + finally { try { if (Directory.Exists(path)) { Directory.Delete(path, true); } } catch { } try { Directory.Delete(root, true); } catch { } } + } + + private static void TestTerminalDiagnosticStoreIsBoundedThroughValidatedHandle() + { + string root = Path.Combine(Path.GetTempPath(), "ccod-tray-terminal-bound-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); string path = ReceiptPath(root); + try + { + TrayTerminalDiagnostic first = new TrayTerminalDiagnostic(TrayCommand.OpenLogs, 41UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED"); + TrayTerminalDiagnostic second = new TrayTerminalDiagnostic(TrayCommand.OpenLogs, 42UL, TrayActionResultStatus.Rejected, "CCOD_TRAY_ACTION_STALE"); + byte[] firstBytes = ReceiptBytes(TrayCommand.OpenLogs, 41UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED"); + byte[] secondBytes = ReceiptBytes(TrayCommand.OpenLogs, 42UL, TrayActionResultStatus.Rejected, "CCOD_TRAY_ACTION_STALE"); + using (TrayTerminalDiagnosticStore store = TestStore(root, null, null)) + { + AssertTrue(store.TryAppendDurably(first), "safe receipt chain is created before boundary setup"); + byte[] boundaryPrefix = new byte[checked((int)(TrayTerminalDiagnosticStore.MaximumBytes - firstBytes.Length))]; + for (int i = 0; i < boundaryPrefix.Length; i++) { boundaryPrefix[i] = (byte)'x'; } + File.WriteAllBytes(path, boundaryPrefix); + AssertTrue(store.TryAppendDurably(first), "a whole receipt fitting the exact 64-KiB boundary is appended"); + AssertTrue(new FileInfo(path).Length == TrayTerminalDiagnosticStore.MaximumBytes, "exact-boundary append retains exactly 64 KiB"); + AssertTrue(File.ReadAllText(path).EndsWith(Encoding.UTF8.GetString(firstBytes), StringComparison.Ordinal), "exact-boundary append retains the whole latest record"); + + AssertTrue(store.TryAppendDurably(second), "a boundary-crossing receipt rolls over through the validated handle"); + AssertTrue(Convert.ToBase64String(secondBytes) == Convert.ToBase64String(File.ReadAllBytes(path)), "rollover retains exactly one complete latest record"); + + File.WriteAllBytes(path, new byte[checked((int)TrayTerminalDiagnosticStore.MaximumBytes + 17)]); + AssertTrue(store.TryAppendDurably(first), "an oversized existing receipt rolls over through the validated handle"); + AssertTrue(Convert.ToBase64String(firstBytes) == Convert.ToBase64String(File.ReadAllBytes(path)), "oversized rollover retains exactly one complete latest record"); + } + } + finally { try { Directory.Delete(root, true); } catch { } } } private static void TestNoHimcFailureIsSafe() @@ -336,13 +528,17 @@ public static int Main(string[] args) TestVerifiedAboutUsesTheAcknowledgedSnapshotVersion(); TestActionFailureUsesTheAcknowledgedSnapshotStrings(); TestTerminalDiagnosticLogIsSanitizedAndReportsPersistence(); - TestTerminalDiagnosticLogIsBoundedAndRejectsReparseComponents(); + TestTerminalDiagnosticStoreRejectsUnsafeObjectsAndProtectsOutsideSentinel(); + TestTerminalDiagnosticStorePinsDirectoryChainAgainstReplacement(); + TestTerminalDiagnosticStoreRejectsConcurrentWriter(); + TestTerminalDiagnosticStoreRejectsDirectoryLeafWithoutWriting(); + TestTerminalDiagnosticStoreIsBoundedThroughValidatedHandle(); TestSimplifiedMenuAndExitConfirmation(); TestNoHimcFailureIsSafe(); TestShellRightClickNotificationMapping(); TestRealNativePInvokeSurface(); TestPostedWorkMessageDispatchesToItsOwnerWindow(); - Console.WriteLine("TrayHost native self-tests passed: 17"); + Console.WriteLine("TrayHost native self-tests passed: 21"); return 0; } catch (Exception error) From 592ef5ad682b4c55ba96d4cf4d796a0716b488e2 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 08:55:55 +0800 Subject: [PATCH 027/187] docs: record async tray receipt Task 1 --- .../task-1-report.md | 221 ++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 .superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-1-report.md 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..90d935c --- /dev/null +++ b/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-1-report.md @@ -0,0 +1,221 @@ +# 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. From debe0935ea68433816971756679f7aad7ac97a33 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:16:01 +0800 Subject: [PATCH 028/187] docs: refine tray receipt compatibility boundary --- .../2026-08-28-v2522-async-tray-receipts.md | 13 +++++++---- ...-08-28-v2522-async-tray-receipts-design.md | 23 ++++++++++++------- 2 files changed, 23 insertions(+), 13 deletions(-) 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 index 6d2b628..6b2a809 100644 --- a/docs/superpowers/plans/2026-08-28-v2522-async-tray-receipts.md +++ b/docs/superpowers/plans/2026-08-28-v2522-async-tray-receipts.md @@ -66,11 +66,14 @@ does not own native handles or the barrier contract. - [ ] **Step 3: Implement the minimal handle-pinned store** -Open directory components and the fixed leaf through `CreateFileW` safe -handles. 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. +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** 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 index 8cbc4f8..6c09ae1 100644 --- 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 @@ -61,14 +61,21 @@ also bounded at eight shared items. ### 2. Pinned, bounded native receipt store The store uses the fixed path -`%LOCALAPPDATA%\CodexControlOtherDevices\logs\trayhost-actions.log`. It opens -the LocalAppData, product, and logs directories as native directory handles, -with reparse-point opening flags and delete sharing denied, validates each -opened handle as the expected non-reparse directory, and holds the directory -chain 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. +`%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 From f207496df1ceee744a09bbc5c3d24a48695e58bc Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:23:53 +0800 Subject: [PATCH 029/187] fix: preserve tray receipt parent compatibility --- src/trayhost/TrayTerminalDiagnostic.cs | 55 +++++----- tests/trayhost/TrayHostNativeSelfTest.cs | 127 +++++++++++++++++++---- 2 files changed, 136 insertions(+), 46 deletions(-) diff --git a/src/trayhost/TrayTerminalDiagnostic.cs b/src/trayhost/TrayTerminalDiagnostic.cs index 43497af..b1b7774 100644 --- a/src/trayhost/TrayTerminalDiagnostic.cs +++ b/src/trayhost/TrayTerminalDiagnostic.cs @@ -30,6 +30,7 @@ private sealed class ReceiptPaths internal string LocalRoot; internal string Product; internal string Logs; + internal string ReceiptDirectory; internal string Leaf; } @@ -157,35 +158,38 @@ private bool TryAppendThroughValidatedHandles(ReceiptPaths paths, byte[] bytes) using (SafeFileHandle local = OpenDirectory(paths.LocalRoot)) { if (!IsExpectedDirectory(local, paths.LocalRoot, false)) { return false; } - if (!TryCreateChildDirectory(paths.Product)) { return false; } using (SafeFileHandle product = OpenDirectory(paths.Product)) { - if (!IsExpectedDirectory(product, paths.Product, true)) { return false; } - if (!TryCreateChildDirectory(paths.Logs)) { return false; } + if (!IsExpectedDirectory(product, paths.Product, false)) { return false; } using (SafeFileHandle logs = OpenDirectory(paths.Logs)) { - if (!IsExpectedDirectory(logs, paths.Logs, 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 (!IsExpectedDirectory(logs, paths.Logs, false)) { return false; } + if (!TryCreatePrivateReceiptDirectory(paths.ReceiptDirectory)) { return false; } + using (SafeFileHandle receiptDirectory = OpenDirectory(paths.ReceiptDirectory)) { - if (!IsExpectedLeaf(leaf, paths.Leaf)) { return false; } + if (!IsExpectedDirectory(receiptDirectory, paths.ReceiptDirectory, true)) { return false; } #if TRAYHOST_SELF_TEST - if (_testLeafValidated != null) { _testLeafValidated(); } + if (_testDirectoryChainOpened != null) { _testDirectoryChainOpened(); } #endif - using (FileStream stream = new FileStream(leaf, FileAccess.ReadWrite, 4096, false)) + using (SafeFileHandle leaf = CreateFileW(paths.Leaf, GenericRead | GenericWrite | ReadControl, FileShareRead, IntPtr.Zero, OpenAlways, FileFlagOpenReparsePoint | FileFlagWriteThrough, IntPtr.Zero)) { - long length = stream.Length; - if (length > MaximumBytes || length > MaximumBytes - bytes.LongLength) + 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)) { - stream.SetLength(0L); - stream.Position = 0L; + 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; } - else { stream.Position = length; } - stream.Write(bytes, 0, bytes.Length); - stream.Flush(true); - return true; } } } @@ -201,11 +205,12 @@ private static bool TryBuildPaths(string localRoot, out ReceiptPaths paths) if (String.IsNullOrEmpty(canonicalRoot)) { return false; } string product = NormalizePath(Path.Combine(canonicalRoot, "CodexControlOtherDevices")); string logs = NormalizePath(Path.Combine(product, "logs")); - string leaf = NormalizePath(Path.Combine(logs, "trayhost-actions.log")); - if (String.IsNullOrEmpty(product) || String.IsNullOrEmpty(logs) || 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(leaf), logs, 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(leaf), "trayhost-actions.log", StringComparison.Ordinal) || Path.GetFileName(leaf).IndexOf(':') >= 0) { return false; } - paths = new ReceiptPaths { LocalRoot = canonicalRoot, Product = product, Logs = logs, Leaf = leaf }; + 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; } @@ -221,7 +226,7 @@ private static string NormalizePath(string path) catch { return null; } } - private static bool TryCreateChildDirectory(string path) + private static bool TryCreatePrivateReceiptDirectory(string path) { try { diff --git a/tests/trayhost/TrayHostNativeSelfTest.cs b/tests/trayhost/TrayHostNativeSelfTest.cs index 316ea72..42298dc 100644 --- a/tests/trayhost/TrayHostNativeSelfTest.cs +++ b/tests/trayhost/TrayHostNativeSelfTest.cs @@ -60,9 +60,59 @@ internal static class TrayHostNativeSelfTest private static void AssertTrue(bool value, string message) { if (!value) { throw new InvalidOperationException(message); } } private static void AssertEqual(string expected, string actual, string message) { if (!String.Equals(expected, actual, StringComparison.Ordinal)) { throw new InvalidOperationException(message + " expected=[" + expected + "] actual=[" + actual + "]"); } } + private static string ProductPath(string root) + { + return Path.Combine(root, "CodexControlOtherDevices"); + } + + private static string LogsPath(string root) + { + return Path.Combine(ProductPath(root), "logs"); + } + + private static string ReceiptDirectoryPath(string root) + { + return Path.Combine(LogsPath(root), "tray-receipts"); + } + private static string ReceiptPath(string root) { - return Path.Combine(root, "CodexControlOtherDevices", "logs", "trayhost-actions.log"); + return Path.Combine(ReceiptDirectoryPath(root), "trayhost-actions.log"); + } + + private static string LegacyReceiptPath(string root) + { + return Path.Combine(LogsPath(root), "trayhost-actions.log"); + } + + private static void CreateCompatibilityParents(string root) + { + Directory.CreateDirectory(LogsPath(root)); + } + + private static void AssertPrivateReceiptDirectory(string path) + { + DirectorySecurity security = Directory.GetAccessControl(path); + SecurityIdentifier owner = security.GetOwner(typeof(SecurityIdentifier)) as SecurityIdentifier; + SecurityIdentifier current; + using (WindowsIdentity identity = WindowsIdentity.GetCurrent()) { current = identity.User; } + AssertTrue(security.AreAccessRulesProtected && owner != null && current != null && String.Equals(owner.Value, current.Value, StringComparison.Ordinal), "receipt child has a protected current-user owner/DACL"); + HashSet expected = new HashSet(StringComparer.Ordinal) { + current.Value, + new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null).Value, + new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null).Value + }; + HashSet seen = new HashSet(StringComparer.Ordinal); + AuthorizationRuleCollection rules = security.GetAccessRules(true, false, typeof(SecurityIdentifier)); + AssertTrue(rules.Count == 3, "receipt child DACL contains exactly three explicit rules"); + foreach (FileSystemAccessRule rule in rules) + { + SecurityIdentifier sid = rule.IdentityReference as SecurityIdentifier; + AssertTrue(sid != null && expected.Contains(sid.Value) && seen.Add(sid.Value), "receipt child DACL contains only current-user, SYSTEM, and Administrators rules"); + AssertTrue(rule.AccessControlType == AccessControlType.Allow && rule.FileSystemRights == FileSystemRights.FullControl, "receipt child principals receive exact full control"); + AssertTrue(rule.InheritanceFlags == (InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit) && rule.PropagationFlags == PropagationFlags.None, "receipt child private rules inherit to its fixed leaf"); + } + AssertTrue(seen.Count == expected.Count, "receipt child DACL proves every required principal"); } private static TrayTerminalDiagnosticStore TestStore(string root, Action directoryChainOpened, Action leafValidated) @@ -258,6 +308,7 @@ private static void TestTerminalDiagnosticLogIsSanitizedAndReportsPersistence() { string root = Path.Combine(Path.GetTempPath(), "ccod-tray-terminal-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); + CreateCompatibilityParents(root); string path = ReceiptPath(root); try { @@ -270,36 +321,69 @@ private static void TestTerminalDiagnosticLogIsSanitizedAndReportsPersistence() AssertTrue(Convert.ToBase64String(expected) == Convert.ToBase64String(File.ReadAllBytes(path)), "terminal diagnostic file is exact UTF-8 without a BOM and contains only approved fields"); string defaultPath; AssertTrue(TrayTerminalDiagnosticStore.TryGetDefaultPath(out defaultPath), "fixed production receipt path resolves"); - string expectedDefaultPath = Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "CodexControlOtherDevices", "logs", "trayhost-actions.log")); + string expectedDefaultPath = Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "CodexControlOtherDevices", "logs", "tray-receipts", "trayhost-actions.log")); AssertTrue(String.Equals(expectedDefaultPath, defaultPath, StringComparison.OrdinalIgnoreCase), "production receipt path is fixed to the current user's exact LocalApplicationData leaf"); } finally { try { Directory.Delete(root, true); } catch { } } } + private static void TestTerminalDiagnosticStoreSupportsInheritedCompatibilityParentsAndCreatesPrivateChild() + { + string root = Path.Combine(Path.GetTempPath(), "ccod-tray-terminal-compat-" + Guid.NewGuid().ToString("N")); + string product = ProductPath(root); string logs = LogsPath(root); string receiptDirectory = ReceiptDirectoryPath(root); + string path = ReceiptPath(root); string legacyPath = LegacyReceiptPath(root); + byte[] legacySentinel = new byte[] { 0x42, 0x31, 0x37, 0x29 }; + Directory.CreateDirectory(root); CreateCompatibilityParents(root); File.WriteAllBytes(legacyPath, legacySentinel); + try + { + AssertTrue(!Directory.GetAccessControl(product).AreAccessRulesProtected && !Directory.GetAccessControl(logs).AreAccessRulesProtected, "supported existing product/logs parents retain inherited ACLs"); + TrayTerminalDiagnostic record = new TrayTerminalDiagnostic(TrayCommand.OpenLogs, 24UL, TrayActionResultStatus.Rejected, "CCOD_TRAY_ACTION_STALE"); + using (TrayTerminalDiagnosticStore store = TestStore(root, null, null)) + { + AssertTrue(store.TryAppendDurably(record), "inherited supported parents accept a durable receipt in the dedicated private child"); + } + AssertPrivateReceiptDirectory(receiptDirectory); + byte[] expected = ReceiptBytes(TrayCommand.OpenLogs, 24UL, TrayActionResultStatus.Rejected, "CCOD_TRAY_ACTION_STALE"); + AssertTrue(Convert.ToBase64String(expected) == Convert.ToBase64String(File.ReadAllBytes(path)), "compatible parent flow writes only the fixed dedicated receipt leaf"); + AssertTrue(Convert.ToBase64String(legacySentinel) == Convert.ToBase64String(File.ReadAllBytes(legacyPath)), "legacy direct logs receipt leaf remains byte-identical and is never reused"); + } + finally { try { Directory.Delete(root, true); } catch { } } + } + private static void TestTerminalDiagnosticStoreRejectsUnsafeObjectsAndProtectsOutsideSentinel() { string root = Path.Combine(Path.GetTempPath(), "ccod-tray-terminal-unsafe-" + Guid.NewGuid().ToString("N")); string outside = Path.Combine(Path.GetTempPath(), "ccod-tray-terminal-outside-" + Guid.NewGuid().ToString("N")); string outsideSentinel = Path.Combine(outside, "trayhost-actions.log"); string path = ReceiptPath(root); - string logs = Path.GetDirectoryName(path); + string receiptDirectory = ReceiptDirectoryPath(root); + string logs = LogsPath(root); byte[] sentinel = new byte[] { 0x10, 0x22, 0x34, 0x46, 0x58, 0x6a }; - Directory.CreateDirectory(root); Directory.CreateDirectory(outside); File.WriteAllBytes(outsideSentinel, sentinel); + Directory.CreateDirectory(root); CreateCompatibilityParents(root); Directory.CreateDirectory(outside); File.WriteAllBytes(outsideSentinel, sentinel); try { TrayTerminalDiagnostic record = new TrayTerminalDiagnostic(TrayCommand.OpenLogs, 1UL, TrayActionResultStatus.Rejected, "CCOD_TRAY_ACTION_STALE"); using (TrayTerminalDiagnosticStore bootstrap = TestStore(root, null, null)) { AssertTrue(bootstrap.TryAppendDurably(record), "safe receipt directories are bootstrapped"); } - File.Delete(path); Directory.Delete(logs); + File.Delete(path); Directory.Delete(receiptDirectory); + Directory.Delete(logs); CreateJunction(logs, outside); + using (TrayTerminalDiagnosticStore reparseParent = TestStore(root, null, null)) + { + AssertTrue(!reparseParent.TryAppendDurably(record), "a reparse compatibility logs parent is rejected before writing"); + } + AssertTrue(Convert.ToBase64String(sentinel) == Convert.ToBase64String(File.ReadAllBytes(outsideSentinel)), "reparse compatibility parent cannot alter the outside sentinel"); + DeleteJunction(logs); CreateCompatibilityParents(root); + + CreateJunction(receiptDirectory, outside); using (TrayTerminalDiagnosticStore reparseDirectory = TestStore(root, null, null)) { - AssertTrue(!reparseDirectory.TryAppendDurably(record), "a reparse logs directory is rejected before writing"); + AssertTrue(!reparseDirectory.TryAppendDurably(record), "a reparse private receipt directory is rejected before writing"); } - AssertTrue(Convert.ToBase64String(sentinel) == Convert.ToBase64String(File.ReadAllBytes(outsideSentinel)), "reparse directory cannot alter the outside sentinel"); - DeleteJunction(logs); + AssertTrue(Convert.ToBase64String(sentinel) == Convert.ToBase64String(File.ReadAllBytes(outsideSentinel)), "reparse receipt directory cannot alter the outside sentinel"); + DeleteJunction(receiptDirectory); - using (TrayTerminalDiagnosticStore restore = TestStore(root, null, null)) { AssertTrue(restore.TryAppendDurably(record), "safe logs directory is restored for leaf tests"); } + using (TrayTerminalDiagnosticStore restore = TestStore(root, null, null)) { AssertTrue(restore.TryAppendDurably(record), "safe private receipt directory is restored for leaf tests"); } File.Delete(path); CreateJunction(path, outside); using (TrayTerminalDiagnosticStore reparseLeaf = TestStore(root, null, null)) @@ -317,19 +401,19 @@ private static void TestTerminalDiagnosticStoreRejectsUnsafeObjectsAndProtectsOu AssertTrue(Convert.ToBase64String(sentinel) == Convert.ToBase64String(File.ReadAllBytes(outsideSentinel)), "hard-link leaf cannot alter the outside sentinel"); File.Delete(path); - DirectorySecurity unsafeSecurity = Directory.GetAccessControl(logs); + DirectorySecurity unsafeSecurity = Directory.GetAccessControl(receiptDirectory); unsafeSecurity.AddAccessRule(new FileSystemAccessRule(new SecurityIdentifier(WellKnownSidType.WorldSid, null), FileSystemRights.FullControl, InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, AccessControlType.Allow)); - Directory.SetAccessControl(logs, unsafeSecurity); + Directory.SetAccessControl(receiptDirectory, unsafeSecurity); using (TrayTerminalDiagnosticStore unsafeAcl = TestStore(root, null, null)) { - AssertTrue(!unsafeAcl.TryAppendDurably(record), "an unexpected logs DACL is rejected before writing"); + AssertTrue(!unsafeAcl.TryAppendDurably(record), "an unexpected private receipt-child DACL is rejected before writing"); } - AssertTrue(!File.Exists(path), "unsafe directory ACL cannot create or write the receipt leaf"); - AssertTrue(Convert.ToBase64String(sentinel) == Convert.ToBase64String(File.ReadAllBytes(outsideSentinel)), "unsafe directory ACL cannot alter the outside sentinel"); + AssertTrue(!File.Exists(path), "unsafe receipt-child ACL cannot create or write the receipt leaf"); + AssertTrue(Convert.ToBase64String(sentinel) == Convert.ToBase64String(File.ReadAllBytes(outsideSentinel)), "unsafe receipt-child ACL cannot alter the outside sentinel"); } finally { - DeleteJunction(path); DeleteJunction(logs); + DeleteJunction(path); DeleteJunction(receiptDirectory); DeleteJunction(logs); try { Directory.Delete(root, true); } catch { } try { Directory.Delete(outside, true); } catch { } } @@ -339,11 +423,11 @@ private static void TestTerminalDiagnosticStorePinsDirectoryChainAgainstReplacem { string root = Path.Combine(Path.GetTempPath(), "ccod-tray-terminal-pin-" + Guid.NewGuid().ToString("N")); string outside = Path.Combine(Path.GetTempPath(), "ccod-tray-terminal-pin-outside-" + Guid.NewGuid().ToString("N")); - string path = ReceiptPath(root); string logs = Path.GetDirectoryName(path); string displaced = logs + ".displaced"; + string path = ReceiptPath(root); string logs = LogsPath(root); string displaced = logs + ".displaced"; string outsideSentinel = Path.Combine(outside, "trayhost-actions.log"); byte[] sentinel = new byte[] { 0x71, 0x72, 0x73, 0x74, 0x75 }; bool barrierEntered = false; bool replacementSucceeded = false; - Directory.CreateDirectory(root); Directory.CreateDirectory(outside); File.WriteAllBytes(outsideSentinel, sentinel); + Directory.CreateDirectory(root); CreateCompatibilityParents(root); Directory.CreateDirectory(outside); File.WriteAllBytes(outsideSentinel, sentinel); try { TrayTerminalDiagnostic record = new TrayTerminalDiagnostic(TrayCommand.OpenLogs, 2UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED"); @@ -376,7 +460,7 @@ private static void TestTerminalDiagnosticStorePinsDirectoryChainAgainstReplacem private static void TestTerminalDiagnosticStoreRejectsConcurrentWriter() { string root = Path.Combine(Path.GetTempPath(), "ccod-tray-terminal-share-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(root); + Directory.CreateDirectory(root); CreateCompatibilityParents(root); ManualResetEvent leafValidated = new ManualResetEvent(false); ManualResetEvent release = new ManualResetEvent(false); bool firstResult = false; try @@ -402,7 +486,7 @@ private static void TestTerminalDiagnosticStoreRejectsConcurrentWriter() private static void TestTerminalDiagnosticStoreRejectsDirectoryLeafWithoutWriting() { string root = Path.Combine(Path.GetTempPath(), "ccod-tray-terminal-directory-leaf-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(root); string path = ReceiptPath(root); + Directory.CreateDirectory(root); CreateCompatibilityParents(root); string path = ReceiptPath(root); try { TrayTerminalDiagnostic record = new TrayTerminalDiagnostic(TrayCommand.OpenLogs, 5UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED"); @@ -420,7 +504,7 @@ private static void TestTerminalDiagnosticStoreRejectsDirectoryLeafWithoutWritin private static void TestTerminalDiagnosticStoreIsBoundedThroughValidatedHandle() { string root = Path.Combine(Path.GetTempPath(), "ccod-tray-terminal-bound-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(root); string path = ReceiptPath(root); + Directory.CreateDirectory(root); CreateCompatibilityParents(root); string path = ReceiptPath(root); try { TrayTerminalDiagnostic first = new TrayTerminalDiagnostic(TrayCommand.OpenLogs, 41UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED"); @@ -527,6 +611,7 @@ public static int Main(string[] args) TestAboutCommandDefersProofToSupervisor(); TestVerifiedAboutUsesTheAcknowledgedSnapshotVersion(); TestActionFailureUsesTheAcknowledgedSnapshotStrings(); + TestTerminalDiagnosticStoreSupportsInheritedCompatibilityParentsAndCreatesPrivateChild(); TestTerminalDiagnosticLogIsSanitizedAndReportsPersistence(); TestTerminalDiagnosticStoreRejectsUnsafeObjectsAndProtectsOutsideSentinel(); TestTerminalDiagnosticStorePinsDirectoryChainAgainstReplacement(); @@ -538,7 +623,7 @@ public static int Main(string[] args) TestShellRightClickNotificationMapping(); TestRealNativePInvokeSurface(); TestPostedWorkMessageDispatchesToItsOwnerWindow(); - Console.WriteLine("TrayHost native self-tests passed: 21"); + Console.WriteLine("TrayHost native self-tests passed: 22"); return 0; } catch (Exception error) From 6cdbcdbcc77d504c7debdbb95dca0ce5db33d9a2 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:24:55 +0800 Subject: [PATCH 030/187] docs: record tray receipt compatibility fix --- .../task-1-report.md | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) 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 index 90d935c..4c9448c 100644 --- 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 @@ -219,3 +219,85 @@ Exit `0`; only Git's existing LF-to-CRLF conversion notices were printed. - 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. From 7549dde154fd6c2430a13aa9ddcf66698560f717 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:59:03 +0800 Subject: [PATCH 031/187] fix: isolate tray terminal receipt writes --- src/trayhost/HostTransport.cs | 94 +++- src/trayhost/Program.cs | 17 +- src/trayhost/TrayTerminalReceiptSink.cs | 168 +++++++ tests/trayhost/Invoke-TrayHostSelfTest.ps1 | 1 + tests/trayhost/TrayHostNativeSelfTest.cs | 32 +- .../trayhost/TrayHostParentClientSelfTest.cs | 13 +- tests/trayhost/TrayHostTransportSelfTest.cs | 428 +++++++++++++----- 7 files changed, 619 insertions(+), 134 deletions(-) create mode 100644 src/trayhost/TrayTerminalReceiptSink.cs diff --git a/src/trayhost/HostTransport.cs b/src/trayhost/HostTransport.cs index be636aa..25d405b 100644 --- a/src/trayhost/HostTransport.cs +++ b/src/trayhost/HostTransport.cs @@ -11,28 +11,23 @@ private sealed class PendingAction private readonly object _gate = new object(); private readonly Action _presentationReady; - private readonly Func _writeTerminalDiagnostic; private readonly Queue _completedAbout = new Queue(); private readonly Queue _failedActions = new Queue(); private readonly Dictionary _pendingActions = new Dictionary(); private readonly Queue _recentActionOrder = new Queue(); private readonly HashSet _recentActions = new HashSet(); private PresentationSnapshot _pendingPresentation; + private int _uiWorkCount; private bool _menuOpen; private bool _disposed; - internal HostTransport() : this(null, null) + internal HostTransport() : this(null) { } - internal HostTransport(Action presentationReady) : this(presentationReady, null) - { - } - - internal HostTransport(Action presentationReady, Func writeTerminalDiagnostic) + internal HostTransport(Action presentationReady) { _presentationReady = presentationReady; - _writeTerminalDiagnostic = writeTerminalDiagnostic; } internal void SetMenuOpen(bool value) @@ -87,14 +82,14 @@ 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; } - PendingAction pending; - TrayTerminalDiagnostic diagnostic; lock (_gate) { if (_disposed) { return false; } + PendingAction pending; if (!_pendingActions.TryGetValue(result.ActionId, out pending) || pending.Action.Revision != result.Revision) { return false; } if (result.Status == TrayActionResultStatus.Accepted) { @@ -104,22 +99,70 @@ 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; } - diagnostic = new TrayTerminalDiagnostic(pending.Action.Command, result.Revision, result.Status, result.ErrorCode); + receipt = new TrayTerminalReceipt(this, pending.Action.Command, result); _pendingActions.Remove(result.ActionId); + return true; + } + } + + internal bool TryPublishDurableReceipt(TrayTerminalReceipt receipt) + { + if (receipt == null) { return false; } + bool notify = false; + bool accepted = false; + lock (_gate) + { + if (_disposed || !receipt.TryClaimPublication(this)) { return false; } + TrayActionResult result = receipt.Result; + if (result.Status == TrayActionResultStatus.Completed && receipt.Command == TrayCommand.ShowAbout) + { + if (_uiWorkCount >= 8) { return false; } + _completedAbout.Enqueue(result); + _uiWorkCount++; + notify = true; + accepted = true; + } + else if (result.Status == TrayActionResultStatus.Rejected || result.Status == TrayActionResultStatus.Failed) + { + if (_uiWorkCount >= 8) { return false; } + _failedActions.Enqueue(result); + _uiWorkCount++; + notify = true; + accepted = true; + } + else + { + accepted = true; + } } - bool recorded = false; - Func writer = _writeTerminalDiagnostic; - if (writer != null) + Action presentationReady = _presentationReady; + if (notify && presentationReady != null) { - try { recorded = writer(diagnostic); } - catch { recorded = false; } + try { presentationReady(); } + catch + { + DropUiWork(receipt.Result); + return false; + } } + return accepted; + } + + private void DropUiWork(TrayActionResult target) + { + if (target == null) { return; } lock (_gate) { - if (_disposed) { return true; } - if (recorded && result.Status == TrayActionResultStatus.Completed && pending.Action.Command == TrayCommand.ShowAbout) { _completedAbout.Enqueue(result); } - if (recorded && (result.Status == TrayActionResultStatus.Rejected || result.Status == TrayActionResultStatus.Failed) && _failedActions.Count < 8) { _failedActions.Enqueue(result); } - return true; + Queue queue = target.Status == TrayActionResultStatus.Completed ? _completedAbout : _failedActions; + int count = queue.Count; + bool removed = false; + for (int index = 0; index < count; index++) + { + TrayActionResult current = queue.Dequeue(); + if (!removed && Object.ReferenceEquals(current, target)) { removed = true; } + else { queue.Enqueue(current); } + } + if (removed && _uiWorkCount > 0) { _uiWorkCount--; } } } @@ -128,7 +171,9 @@ internal bool TryTakeCompletedAbout(out TrayActionResult result) lock (_gate) { if (_completedAbout.Count == 0) { result = null; return false; } - result = _completedAbout.Dequeue(); return true; + result = _completedAbout.Dequeue(); + if (_uiWorkCount > 0) { _uiWorkCount--; } + return true; } } @@ -137,7 +182,9 @@ internal bool TryTakeFailedAction(out TrayActionResult result) lock (_gate) { if (_failedActions.Count == 0) { result = null; return false; } - result = _failedActions.Dequeue(); return true; + result = _failedActions.Dequeue(); + if (_uiWorkCount > 0) { _uiWorkCount--; } + return true; } } @@ -149,6 +196,7 @@ public void Dispose() _disposed = true; _completedAbout.Clear(); _failedActions.Clear(); + _uiWorkCount = 0; _pendingActions.Clear(); _recentActions.Clear(); _recentActionOrder.Clear(); diff --git a/src/trayhost/Program.cs b/src/trayhost/Program.cs index 895c118..2cdaed1 100644 --- a/src/trayhost/Program.cs +++ b/src/trayhost/Program.cs @@ -40,7 +40,8 @@ private static int RunChild(string[] args) Win32TrayPlatform platform = new Win32TrayPlatform(); TrayHostApplication application = null; TrayTerminalDiagnosticStore terminalStore = new TrayTerminalDiagnosticStore(); - HostTransport transport = new HostTransport(delegate { TrayHostApplication current = application; if (current != null) { current.PostWork(); } }, delegate(TrayTerminalDiagnostic record) { return terminalStore.TryAppendDurably(record); }); + HostTransport transport = new HostTransport(delegate { TrayHostApplication current = application; if (current != null) { current.PostWork(); } }); + TrayTerminalReceiptSink receiptSink = new TrayTerminalReceiptSink(terminalStore.TryAppendDurably, transport.TryPublishDurableReceipt, terminalStore.Dispose); TrayWindow window = new TrayWindow(platform, transport.SetMenuOpen); bool shutdownRequested = false; bool shutdownSent = false; object stateGate = new object(); Action command = delegate(TrayCommand selected, ulong revision) @@ -90,7 +91,7 @@ private static int RunChild(string[] args) { 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 (!TryDispatchAuthenticatedActionResult(transport, frame.Payload)) { throw new ProtocolViolationException("action result is uncorrelated"); } application.PostWork(); } + else if (frame.MessageType == TrayHostMessageType.ActionResult) { if (!TryDispatchAuthenticatedActionResult(transport, receiptSink, frame.Payload)) { throw new ProtocolViolationException("action result is uncorrelated"); } } 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); } } } @@ -99,7 +100,7 @@ private static int RunChild(string[] args) })) { IsBackground = true, Name = "CodexRemote.TrayHost.Reader" }; reader.Start(); int result = application.Run(); - application.Dispose(); transport.Dispose(); terminalStore.Dispose(); return result; + receiptSink.Dispose(); application.Dispose(); transport.Dispose(); return result; } private static bool VerifyParentIdentity(int pid, long creation) @@ -107,10 +108,16 @@ private static bool VerifyParentIdentity(int pid, long creation) try { using (Process process = Process.GetProcessById(pid)) { return process.StartTime.ToFileTimeUtc() == creation; } } catch { return false; } } - internal static bool TryDispatchAuthenticatedActionResult(HostTransport transport, byte[] payload) + internal static bool TryDispatchAuthenticatedActionResult(HostTransport transport, TrayTerminalReceiptSink receiptSink, byte[] payload) { if (transport == null || payload == null) { return false; } - return transport.TryAcknowledgeAction(TrayHostWire.ReadActionResult(payload)); + TrayTerminalReceipt receipt; + if (!transport.TryAcknowledgeAction(TrayHostWire.ReadActionResult(payload), out receipt)) { return false; } + if (receipt != null && receiptSink != null) + { + try { receiptSink.TrySubmit(receipt); } catch { } + } + return true; } private static int RunHeadlessSmoke() diff --git a/src/trayhost/TrayTerminalReceiptSink.cs b/src/trayhost/TrayTerminalReceiptSink.cs new file mode 100644 index 0000000..c9b5f1c --- /dev/null +++ b/src/trayhost/TrayTerminalReceiptSink.cs @@ -0,0 +1,168 @@ +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; + + 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 (durable && !IsClosed()) + { + try { _publishDurable(receipt); } + catch { } + } + lock (_gate) + { + if (_outstanding > 0) { _outstanding--; } + if (_closed) { return; } + } + } + } + finally + { + lock (_gate) + { + _closed = true; + _pending.Clear(); + _outstanding = 0; + } + CleanupAfterWriter(); + } + } + + private bool IsClosed() + { + lock (_gate) { return _closed; } + } + + 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/tests/trayhost/Invoke-TrayHostSelfTest.ps1 b/tests/trayhost/Invoke-TrayHostSelfTest.ps1 index ad95efa..b249423 100644 --- a/tests/trayhost/Invoke-TrayHostSelfTest.ps1 +++ b/tests/trayhost/Invoke-TrayHostSelfTest.ps1 @@ -23,6 +23,7 @@ $nativeFiles=@( $transportFiles=@( (Join-Path $repositoryRoot 'src\trayhost\TransportMessages.cs'), (Join-Path $repositoryRoot 'src\trayhost\TrayTerminalDiagnostic.cs'), + (Join-Path $repositoryRoot 'src\trayhost\TrayTerminalReceiptSink.cs'), (Join-Path $repositoryRoot 'src\trayhost\ParentTransport.cs'), (Join-Path $repositoryRoot 'src\trayhost\HostTransport.cs') ) diff --git a/tests/trayhost/TrayHostNativeSelfTest.cs b/tests/trayhost/TrayHostNativeSelfTest.cs index 42298dc..f9185b9 100644 --- a/tests/trayhost/TrayHostNativeSelfTest.cs +++ b/tests/trayhost/TrayHostNativeSelfTest.cs @@ -304,6 +304,35 @@ private static void TestActionFailureUsesTheAcknowledgedSnapshotStrings() window.Dispose(); } + private static void TestDurableNativeReceiptAuthorizesGenericFeedbackOnce() + { + string root = Path.Combine(Path.GetTempPath(), "ccod-tray-terminal-native-feedback-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); CreateCompatibilityParents(root); + FakeTrayPlatform platform = new FakeTrayPlatform(); TrayWindow window = new TrayWindow(platform); window.Create(SnapshotV2(1)); + HostTransport transport = new HostTransport(); + TrayTerminalDiagnosticStore store = TestStore(root, null, null); + TrayTerminalReceiptSink sink = new TrayTerminalReceiptSink(store.TryAppendDurably, transport.TryPublishDurableReceipt); + try + { + Guid actionId = Guid.NewGuid(); TrayTerminalReceipt receipt; + AssertTrue(transport.TryRegisterAction(new TrayHostAction(actionId, TrayCommand.OpenLogs, 1UL)), "native durable-feedback action registers"); + AssertTrue(transport.TryAcknowledgeAction(new TrayActionResult(actionId, 1UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED", null), out receipt) && receipt != null, "native durable-feedback action returns one typed receipt"); + TrayActionResult feedback; + AssertTrue(!transport.TryTakeFailedAction(out feedback) && platform.MessageBoxes.Count == 0, "native generic feedback is unavailable before durable receipt success"); + AssertTrue(sink.TrySubmit(receipt), "native receipt enters the asynchronous sink"); + Stopwatch elapsed = Stopwatch.StartNew(); + while (!transport.TryTakeFailedAction(out feedback) && elapsed.ElapsedMilliseconds < 3000L) { Thread.Sleep(5); } + AssertTrue(feedback != null && feedback.ActionId == actionId, "handle-pinned durable success authorizes the exact native feedback item"); + window.ShowActionFailed(); + AssertTrue(platform.MessageBoxes.Count == 1, "durable native receipt displays generic feedback exactly once"); + AssertTrue(!transport.TryTakeFailedAction(out feedback), "durable native feedback has no duplicate queue item"); + } + finally + { + sink.Dispose(); transport.Dispose(); window.Dispose(); store.Dispose(); try { Directory.Delete(root, true); } catch { } + } + } + private static void TestTerminalDiagnosticLogIsSanitizedAndReportsPersistence() { string root = Path.Combine(Path.GetTempPath(), "ccod-tray-terminal-" + Guid.NewGuid().ToString("N")); @@ -611,6 +640,7 @@ public static int Main(string[] args) TestAboutCommandDefersProofToSupervisor(); TestVerifiedAboutUsesTheAcknowledgedSnapshotVersion(); TestActionFailureUsesTheAcknowledgedSnapshotStrings(); + TestDurableNativeReceiptAuthorizesGenericFeedbackOnce(); TestTerminalDiagnosticStoreSupportsInheritedCompatibilityParentsAndCreatesPrivateChild(); TestTerminalDiagnosticLogIsSanitizedAndReportsPersistence(); TestTerminalDiagnosticStoreRejectsUnsafeObjectsAndProtectsOutsideSentinel(); @@ -623,7 +653,7 @@ public static int Main(string[] args) TestShellRightClickNotificationMapping(); TestRealNativePInvokeSurface(); TestPostedWorkMessageDispatchesToItsOwnerWindow(); - Console.WriteLine("TrayHost native self-tests passed: 22"); + Console.WriteLine("TrayHost native self-tests passed: 23"); return 0; } catch (Exception error) diff --git a/tests/trayhost/TrayHostParentClientSelfTest.cs b/tests/trayhost/TrayHostParentClientSelfTest.cs index f2e8589..e83f597 100644 --- a/tests/trayhost/TrayHostParentClientSelfTest.cs +++ b/tests/trayhost/TrayHostParentClientSelfTest.cs @@ -81,26 +81,31 @@ private static void RunPeer(bool emitFaultAndIgnoreShutdown, bool emitAction, bo { Guid actionId = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); TrayHostAction action = new TrayHostAction(actionId, TrayCommand.OpenLogs, staleAction ? 8UL : 1UL); - HostTransport host = new HostTransport(null, delegate(TrayTerminalDiagnostic record) { return true; }); + ManualResetEvent receiptPersisted = new ManualResetEvent(false); + HostTransport host = new HostTransport(); + TrayTerminalReceiptSink receiptSink = new TrayTerminalReceiptSink(delegate(TrayTerminalDiagnostic record) { receiptPersisted.Set(); return true; }, host.TryPublishDurableReceipt); ProductionTraceTrayPlatform tracePlatform = new ProductionTraceTrayPlatform(); TrayWindow traceWindow = new TrayWindow(tracePlatform); traceWindow.Create(Snapshot(1UL)); AssertTrue(host.TryRegisterAction(action), "program peer registers the exact outbound action"); ProtocolCodec.WriteAuthenticated(output, ProtocolFrame.Authenticated(ProtocolDirection.HostToParent, TrayHostMessageType.Action, epoch, outboundSequence++, TrayHostWire.WriteAction(action)), keys.HostToParent); ProtocolFrame terminalFrame = ProtocolCodec.ReadAuthenticated(input, ProtocolDirection.ParentToHost, epoch, inboundSequence++, keys.ParentToHost); AssertTrue(terminalFrame.MessageType == TrayHostMessageType.ActionResult, "program peer receives one authenticated terminal result"); - AssertTrue(Program.TryDispatchAuthenticatedActionResult(host, terminalFrame.Payload), "production Program dispatch accepts the correlated authenticated result"); + AssertTrue(Program.TryDispatchAuthenticatedActionResult(host, receiptSink, terminalFrame.Payload), "production Program dispatch accepts the correlated authenticated result without waiting for receipt storage"); TrayActionResult feedback; if (staleAction) { - AssertTrue(host.TryTakeFailedAction(out feedback) && feedback.Revision == 8UL && feedback.Status == TrayActionResultStatus.Rejected && String.Equals(feedback.ErrorCode, "CCOD_TRAY_ACTION_STALE", StringComparison.Ordinal), "stale authenticated result reaches the production generic-feedback queue with its exact code"); + Stopwatch feedbackWait = Stopwatch.StartNew(); + while (!host.TryTakeFailedAction(out feedback) && feedbackWait.ElapsedMilliseconds < 3000L) { Thread.Sleep(5); } + AssertTrue(feedback != null && feedback.Revision == 8UL && feedback.Status == TrayActionResultStatus.Rejected && String.Equals(feedback.ErrorCode, "CCOD_TRAY_ACTION_STALE", StringComparison.Ordinal), "stale authenticated result reaches the production generic-feedback queue only after its durable callback"); traceWindow.ShowActionFailed(); AssertTrue(String.Equals(tracePlatform.Message, "string-0|string-15", StringComparison.Ordinal), "stale production trace collapses internal details to the generic acknowledged-snapshot dialog"); } else { + AssertTrue(receiptPersisted.WaitOne(3000), "completed current action reaches durable receipt storage asynchronously"); AssertTrue(!host.TryTakeFailedAction(out feedback) && tracePlatform.Message == null, "completed current action produces no generic failure feedback"); } - traceWindow.Dispose(); host.Dispose(); + receiptSink.Dispose(); traceWindow.Dispose(); host.Dispose(); receiptPersisted.Dispose(); return; } if (emitFaultAndIgnoreShutdown) diff --git a/tests/trayhost/TrayHostTransportSelfTest.cs b/tests/trayhost/TrayHostTransportSelfTest.cs index d276a07..43436fe 100644 --- a/tests/trayhost/TrayHostTransportSelfTest.cs +++ b/tests/trayhost/TrayHostTransportSelfTest.cs @@ -1,11 +1,86 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Reflection; using System.Threading; internal static class TrayHostTransportSelfTest { - private static bool PersistTerminal(TrayTerminalDiagnostic record) { return true; } + private enum ReceiptStoreBlockStage + { + None = 0, + BeforeOpen = 1, + DuringWrite = 2, + DuringFlush = 3 + } + + private sealed class ControllableReceiptStore : IDisposable + { + private readonly object _gate = new object(); + private readonly ReceiptStoreBlockStage _blockStage; + private readonly Queue _outcomes = new Queue(); + private readonly HashSet _writerThreads = new HashSet(); + private readonly ManualResetEvent _entered = new ManualResetEvent(false); + private readonly ManualResetEvent _release = new ManualResetEvent(false); + private int _active; + private int _callCount; + private int _completedCount; + private int _maximumConcurrent; + + internal ControllableReceiptStore(ReceiptStoreBlockStage blockStage, params int[] outcomes) + { + _blockStage = blockStage; + if (outcomes != null) { for (int index = 0; index < outcomes.Length; index++) { _outcomes.Enqueue(outcomes[index]); } } + } + + internal bool TryAppendDurably(TrayTerminalDiagnostic record) + { + if (record == null) { return false; } + int call = Interlocked.Increment(ref _callCount); + int active = Interlocked.Increment(ref _active); + lock (_gate) + { + _writerThreads.Add(Thread.CurrentThread.ManagedThreadId); + if (active > _maximumConcurrent) { _maximumConcurrent = active; } + } + try + { + BlockAt(call, ReceiptStoreBlockStage.BeforeOpen); + BlockAt(call, ReceiptStoreBlockStage.DuringWrite); + BlockAt(call, ReceiptStoreBlockStage.DuringFlush); + int outcome = 1; + lock (_gate) { if (_outcomes.Count != 0) { outcome = _outcomes.Dequeue(); } } + if (outcome < 0) { throw new InvalidOperationException("intentional receipt-store failure"); } + return outcome > 0; + } + finally + { + Interlocked.Decrement(ref _active); + Interlocked.Increment(ref _completedCount); + } + } + + internal bool WaitUntilBlocked(int milliseconds) { return _entered.WaitOne(milliseconds); } + internal void Release() { _release.Set(); } + internal int CallCount { get { return Interlocked.CompareExchange(ref _callCount, 0, 0); } } + internal int CompletedCount { get { return Interlocked.CompareExchange(ref _completedCount, 0, 0); } } + internal int MaximumConcurrent { get { lock (_gate) { return _maximumConcurrent; } } } + internal int WriterThreadCount { get { lock (_gate) { return _writerThreads.Count; } } } + + private void BlockAt(int call, ReceiptStoreBlockStage stage) + { + if (call != 1 || _blockStage != stage) { return; } + _entered.Set(); + _release.WaitOne(); + } + + public void Dispose() + { + _release.Set(); + _entered.Dispose(); + _release.Dispose(); + } + } private static void AssertTrue(bool value, string message) { @@ -19,6 +94,18 @@ private static void AssertThrows(Action action, string message) AssertTrue(threw, message); } + private static void AssertReturnsQuickly(Stopwatch elapsed, string message) + { + AssertTrue(elapsed.Elapsed < TimeSpan.FromMilliseconds(250), message + " elapsedMs=" + elapsed.ElapsedMilliseconds.ToString()); + } + + private static void WaitUntil(Func condition, int milliseconds, string message) + { + Stopwatch elapsed = Stopwatch.StartNew(); + while (!condition() && elapsed.ElapsedMilliseconds < milliseconds) { Thread.Sleep(5); } + AssertTrue(condition(), message); + } + private static PresentationSnapshot Snapshot(ulong revision) { string[] strings = new string[16]; @@ -26,6 +113,29 @@ private static PresentationSnapshot Snapshot(ulong revision) return new PresentationSnapshot(revision, TrayColor.Green, ConnectionState.Connected, ProtectionState.Running, LanguageMode.Chinese, PresentationFlags.OpenLogsEnabled, strings); } + private static bool TryAcknowledge(HostTransport host, TrayActionResult result) + { + TrayTerminalReceipt ignored; + return host.TryAcknowledgeAction(result, out ignored); + } + + private static TrayTerminalReceipt AcknowledgeTerminalQuickly(HostTransport host, TrayActionResult result, string message) + { + TrayTerminalReceipt receipt; + Stopwatch elapsed = Stopwatch.StartNew(); + bool accepted = host.TryAcknowledgeAction(result, out receipt); + elapsed.Stop(); + AssertTrue(accepted && receipt != null, message + " is correlated into a typed receipt"); + AssertReturnsQuickly(elapsed, message + " never waits for receipt storage"); + return receipt; + } + + private static TrayTerminalReceipt RegisterAndAcknowledgeFailure(HostTransport host, Guid actionId, ulong revision, string message) + { + AssertTrue(host.TryRegisterAction(new TrayHostAction(actionId, TrayCommand.OpenLogs, revision)), message + " registers"); + return AcknowledgeTerminalQuickly(host, new TrayActionResult(actionId, revision, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED", null), message); + } + private static void TestParentLatestAndReservedControl() { ParentTransport transport = new ParentTransport(); @@ -69,12 +179,12 @@ private static void TestHostPendingAndReplayBound() Guid actionId = Guid.NewGuid(); AssertTrue(transport.TryRegisterAction(new TrayHostAction(actionId, TrayCommand.OpenLogs, 2UL)), "first action accepted"); AssertTrue(!transport.TryRegisterAction(new TrayHostAction(actionId, TrayCommand.OpenLogs, 2UL)), "replayed action id is rejected"); - AssertTrue(transport.TryAcknowledgeAction(new TrayActionResult(actionId, 2UL, TrayActionResultStatus.Completed, null, null)), "terminal non-lifecycle result clears the pending action"); + AssertTrue(TryAcknowledge(transport, new TrayActionResult(actionId, 2UL, TrayActionResultStatus.Completed, null, null)), "terminal non-lifecycle result clears the pending action"); for (int i = 0; i < 63; i++) { Guid next = Guid.NewGuid(); AssertTrue(transport.TryRegisterAction(new TrayHostAction(next, TrayCommand.OpenLogs, 2UL)), "replay cache accepts distinct ids"); - AssertTrue(transport.TryAcknowledgeAction(new TrayActionResult(next, 2UL, TrayActionResultStatus.Completed, null, null)), "terminal non-lifecycle result drains independently"); + AssertTrue(TryAcknowledge(transport, new TrayActionResult(next, 2UL, TrayActionResultStatus.Completed, null, null)), "terminal non-lifecycle result drains independently"); } AssertTrue(!transport.TryRegisterAction(new TrayHostAction(actionId, TrayCommand.OpenLogs, 2UL)), "the 64-entry replay cache rejects a replayed id"); transport.Dispose(); @@ -97,17 +207,18 @@ private static void TestActionResultCorrelationAndControlPriority() Guid actionId = Guid.NewGuid(); Guid transactionId = Guid.Parse("11111111-2222-3333-4444-555555555555"); HostTransport host = new HostTransport(); AssertTrue(host.TryRegisterAction(new TrayHostAction(actionId, TrayCommand.CheckAndRepair, 12UL)), "host registers one v2 lifecycle action"); - AssertTrue(!host.TryAcknowledgeAction(new TrayActionResult(actionId, 12UL, TrayActionResultStatus.Accepted, null, null)), "lifecycle accepted result requires its durable transaction id"); + AssertTrue(!TryAcknowledge(host, new TrayActionResult(actionId, 12UL, TrayActionResultStatus.Accepted, null, null)), "lifecycle accepted result requires its durable transaction id"); TrayActionResult accepted = new TrayActionResult(actionId, 12UL, TrayActionResultStatus.Accepted, null, transactionId); - AssertTrue(host.TryAcknowledgeAction(accepted), "host accepts the correlated accepted result"); - AssertTrue(!host.TryAcknowledgeAction(accepted), "host rejects a duplicate accepted result"); - AssertTrue(!host.TryAcknowledgeAction(new TrayActionResult(Guid.NewGuid(), 12UL, TrayActionResultStatus.Completed, null, transactionId)), "host rejects a result for an unknown action id"); - AssertTrue(!host.TryAcknowledgeAction(new TrayActionResult(actionId, 99UL, TrayActionResultStatus.Completed, null, transactionId)), "host rejects a result with the wrong revision"); - AssertTrue(host.TryAcknowledgeAction(new TrayActionResult(actionId, 12UL, TrayActionResultStatus.Completed, null, transactionId)), "host accepts the correlated terminal result after accepted"); - AssertTrue(!host.TryAcknowledgeAction(new TrayActionResult(actionId, 12UL, TrayActionResultStatus.Completed, null, transactionId)), "host rejects a double terminal result"); + TrayTerminalReceipt terminal; + AssertTrue(host.TryAcknowledgeAction(accepted, out terminal) && terminal == null, "host accepts the correlated nonterminal accepted result without a receipt"); + AssertTrue(!TryAcknowledge(host, accepted), "host rejects a duplicate accepted result"); + AssertTrue(!TryAcknowledge(host, new TrayActionResult(Guid.NewGuid(), 12UL, TrayActionResultStatus.Completed, null, transactionId)), "host rejects a result for an unknown action id"); + AssertTrue(!TryAcknowledge(host, new TrayActionResult(actionId, 99UL, TrayActionResultStatus.Completed, null, transactionId)), "host rejects a result with the wrong revision"); + AssertTrue(host.TryAcknowledgeAction(new TrayActionResult(actionId, 12UL, TrayActionResultStatus.Completed, null, transactionId), out terminal) && terminal != null, "host returns the correlated terminal receipt after accepted"); + AssertTrue(!TryAcknowledge(host, new TrayActionResult(actionId, 12UL, TrayActionResultStatus.Completed, null, transactionId)), "host rejects a double terminal result"); Guid prematureId = Guid.NewGuid(); AssertTrue(host.TryRegisterAction(new TrayHostAction(prematureId, TrayCommand.CheckAndRepair, 13UL)), "second lifecycle action registers"); - AssertTrue(!host.TryAcknowledgeAction(new TrayActionResult(prematureId, 13UL, TrayActionResultStatus.Completed, null, null)), "lifecycle completion before accepted is rejected"); + AssertTrue(!TryAcknowledge(host, new TrayActionResult(prematureId, 13UL, TrayActionResultStatus.Completed, null, null)), "lifecycle completion before accepted is rejected"); AssertThrows(delegate { new TrayActionResult(Guid.NewGuid(), 1UL, TrayActionResultStatus.Rejected, "not-canonical", null); }, "invalid action result error code is rejected"); AssertThrows(delegate { new TrayActionResult(Guid.NewGuid(), 1UL, TrayActionResultStatus.Completed, null, Guid.Empty); }, "empty action result transaction id is rejected"); host.Dispose(); @@ -126,113 +237,228 @@ private static void TestActionResultCorrelationAndControlPriority() parent.Dispose(); } - private static void TestAcknowledgedAboutQueuesOneUiWorkItem() + private static void TestBlockedReceiptStagesNeverBlockCorrelationOrUi() { - HostTransport host = new HostTransport(null, PersistTerminal); Guid actionId = Guid.NewGuid(); - AssertTrue(host.TryRegisterAction(new TrayHostAction(actionId, TrayCommand.ShowAbout, 14UL)), "About action registers"); - AssertTrue(host.TryAcknowledgeAction(new TrayActionResult(actionId, 14UL, TrayActionResultStatus.Completed, null, null)), "verified About completion is accepted"); - TrayActionResult result; - AssertTrue(host.TryTakeCompletedAbout(out result) && result.ActionId == actionId && result.Status == TrayActionResultStatus.Completed, "verified About completion queues one UI work item"); - AssertTrue(!host.TryTakeCompletedAbout(out result), "About UI work item is consumed exactly once"); - host.Dispose(); + ReceiptStoreBlockStage[] stages = new ReceiptStoreBlockStage[] { ReceiptStoreBlockStage.BeforeOpen, ReceiptStoreBlockStage.DuringWrite, ReceiptStoreBlockStage.DuringFlush }; + for (int stageIndex = 0; stageIndex < stages.Length; stageIndex++) + { + ControllableReceiptStore store = new ControllableReceiptStore(stages[stageIndex], 1, 1); + int uiSignals = 0; + HostTransport host = new HostTransport(delegate { Interlocked.Increment(ref uiSignals); }); + TrayTerminalReceiptSink sink = new TrayTerminalReceiptSink(store.TryAppendDurably, host.TryPublishDurableReceipt); + try + { + Guid firstId = Guid.NewGuid(); Guid followingId = Guid.NewGuid(); + TrayTerminalReceipt first = RegisterAndAcknowledgeFailure(host, firstId, 24UL, stages[stageIndex].ToString() + " current terminal result"); + Stopwatch firstSubmit = Stopwatch.StartNew(); bool firstAdmitted = sink.TrySubmit(first); firstSubmit.Stop(); + AssertTrue(firstAdmitted, "current receipt is admitted before the fake store blocks " + stages[stageIndex].ToString()); + AssertReturnsQuickly(firstSubmit, "current receipt admission is zero-wait"); + AssertTrue(store.WaitUntilBlocked(2000), "fake store reaches " + stages[stageIndex].ToString()); + + AssertTrue(host.TryRegisterAction(new TrayHostAction(followingId, TrayCommand.ShowAbout, 24UL)), stages[stageIndex].ToString() + " following About action registers"); + TrayTerminalReceipt following = AcknowledgeTerminalQuickly(host, new TrayActionResult(followingId, 24UL, TrayActionResultStatus.Completed, null, null), stages[stageIndex].ToString() + " following About terminal result"); + Stopwatch followingSubmit = Stopwatch.StartNew(); bool followingAdmitted = sink.TrySubmit(following); followingSubmit.Stop(); + AssertTrue(followingAdmitted, "following receipt is admitted while the store is blocked " + stages[stageIndex].ToString()); + AssertReturnsQuickly(followingSubmit, "following receipt admission never waits for the current store operation"); + TrayTerminalReceipt duplicate; + AssertTrue(!host.TryAcknowledgeAction(new TrayActionResult(firstId, 24UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED", null), out duplicate), "current terminal action is removed before sink submission"); + AssertTrue(!host.TryAcknowledgeAction(new TrayActionResult(followingId, 24UL, TrayActionResultStatus.Completed, null, null), out duplicate), "following terminal action is removed before sink submission"); + TrayActionResult prematureFailure; TrayActionResult prematureAbout; + AssertTrue(!host.TryTakeFailedAction(out prematureFailure) && !host.TryTakeCompletedAbout(out prematureAbout) && Interlocked.CompareExchange(ref uiSignals, 0, 0) == 0, "blocked store exposes no generic or About UI work before durable success"); + + store.Release(); + WaitUntil(delegate { return Interlocked.CompareExchange(ref uiSignals, 0, 0) == 2; }, 2000, "both durable successes post exactly one work signal"); + TrayActionResult firstUi; TrayActionResult followingUi; TrayActionResult none; + AssertTrue(host.TryTakeFailedAction(out firstUi) && firstUi.ActionId == firstId, "first durable receipt publishes its exact generic-feedback item"); + AssertTrue(host.TryTakeCompletedAbout(out followingUi) && followingUi.ActionId == followingId, "following durable receipt publishes its exact About item"); + AssertTrue(!host.TryTakeFailedAction(out none), "durable callbacks publish no duplicate feedback"); + AssertTrue(store.WriterThreadCount == 1 && store.MaximumConcurrent == 1, "all blocked stages are serviced by exactly one non-overlapping writer"); + } + finally + { + store.Release(); + sink.Dispose(); + host.Dispose(); + store.Dispose(); + } + } } - private static void TestRejectedAndFailedActionsQueueUserFeedback() + private static void TestNinthReceiptAndBusyAdmissionDropWithoutPendingOrUi() { - HostTransport host = new HostTransport(null, PersistTerminal); - Guid rejectedId = Guid.NewGuid(); Guid failedId = Guid.NewGuid(); - AssertTrue(host.TryRegisterAction(new TrayHostAction(rejectedId, TrayCommand.OpenLogs, 20UL)), "rejected action registers"); - AssertTrue(host.TryRegisterAction(new TrayHostAction(failedId, TrayCommand.SetLanguageEnglish, 20UL)), "failed action registers"); - AssertTrue(host.TryAcknowledgeAction(new TrayActionResult(rejectedId, 20UL, TrayActionResultStatus.Rejected, "CCOD_TRAY_ACTION_UNAVAILABLE", null)), "correlated rejected result is accepted"); - AssertTrue(host.TryAcknowledgeAction(new TrayActionResult(failedId, 20UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED", null)), "correlated failed result is accepted"); - TrayActionResult first; TrayActionResult second; TrayActionResult none; - AssertTrue(host.TryTakeFailedAction(out first) && first.ActionId == rejectedId && first.Revision == 20UL && first.Status == TrayActionResultStatus.Rejected && String.Equals(first.ErrorCode, "CCOD_TRAY_ACTION_UNAVAILABLE", StringComparison.Ordinal), "rejected result preserves its exact correlated terminal record before generic feedback"); - AssertTrue(host.TryTakeFailedAction(out second) && second.ActionId == failedId && second.Revision == 20UL && second.Status == TrayActionResultStatus.Failed && String.Equals(second.ErrorCode, "CCOD_TRAY_ACTION_FAILED", StringComparison.Ordinal), "failed result preserves its exact correlated terminal record before generic feedback"); - AssertTrue(!host.TryTakeFailedAction(out none), "each terminal failure queues feedback exactly once"); - host.Dispose(); + ControllableReceiptStore store = new ControllableReceiptStore(ReceiptStoreBlockStage.BeforeOpen, 1); + HostTransport host = new HostTransport(); + TrayTerminalReceiptSink sink = new TrayTerminalReceiptSink(store.TryAppendDurably, host.TryPublishDurableReceipt); + try + { + TrayTerminalReceipt first = RegisterAndAcknowledgeFailure(host, Guid.NewGuid(), 25UL, "first hung-store action"); + AssertTrue(sink.TrySubmit(first), "current receipt occupies the first outstanding slot"); + AssertTrue(store.WaitUntilBlocked(2000), "single writer enters the hung fake store"); + for (int index = 1; index < 8; index++) + { + TrayTerminalReceipt queued = RegisterAndAcknowledgeFailure(host, Guid.NewGuid(), 25UL, "queued outstanding action " + index.ToString()); + AssertTrue(sink.TrySubmit(queued), "outstanding receipt slots include current plus seven queued items"); + } + + Guid ninthId = Guid.NewGuid(); + TrayTerminalReceipt ninth = RegisterAndAcknowledgeFailure(host, ninthId, 25UL, "ninth full-sink action"); + Stopwatch ninthSubmit = Stopwatch.StartNew(); bool ninthAdmitted = sink.TrySubmit(ninth); ninthSubmit.Stop(); + AssertTrue(!ninthAdmitted, "ninth outstanding receipt is dropped"); + AssertReturnsQuickly(ninthSubmit, "ninth outstanding admission is rejected immediately"); + TrayTerminalReceipt duplicate; + AssertTrue(!host.TryAcknowledgeAction(new TrayActionResult(ninthId, 25UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED", null), out duplicate), "full-sink terminal action remains consumed"); + for (int index = 0; index < 8; index++) + { + Guid droppedId = Guid.NewGuid(); + TrayTerminalReceipt dropped = RegisterAndAcknowledgeFailure(host, droppedId, 25UL, "additional full-sink action " + index.ToString()); + AssertTrue(!sink.TrySubmit(dropped), "repeated full-sink paths drop only receipt feedback"); + } + for (int index = 0; index < 8; index++) + { + Guid capacityId = Guid.NewGuid(); + AssertTrue(host.TryRegisterAction(new TrayHostAction(capacityId, TrayCommand.OpenLogs, 25UL)), "receipt saturation never consumes pending action capacity"); + AssertTrue(TryAcknowledge(host, new TrayActionResult(capacityId, 25UL, TrayActionResultStatus.Completed, null, null)), "capacity proof action remains terminally consumable"); + } + AssertTrue(store.CallCount == 1 && store.WriterThreadCount == 1 && store.MaximumConcurrent == 1, "hung writer is never replaced"); + TrayActionResult none; + AssertTrue(!host.TryTakeFailedAction(out none), "full and hung paths show no UI"); + + FieldInfo gateField = typeof(TrayTerminalReceiptSink).GetField("_gate", BindingFlags.Instance | BindingFlags.NonPublic); + AssertTrue(gateField != null, "busy-admission fixture locates the sink admission gate"); + object admissionGate = gateField.GetValue(sink); + ManualResetEvent gateHeld = new ManualResetEvent(false); ManualResetEvent releaseGate = new ManualResetEvent(false); + Thread holder = new Thread((ThreadStart)delegate { lock (admissionGate) { gateHeld.Set(); releaseGate.WaitOne(); } }); + holder.IsBackground = true; holder.Start(); + try + { + AssertTrue(gateHeld.WaitOne(2000), "busy-admission fixture holds the sink gate"); + Guid busyId = Guid.NewGuid(); + TrayTerminalReceipt busy = RegisterAndAcknowledgeFailure(host, busyId, 25UL, "busy-sink action"); + bool busyReturned = false; bool busyAdmitted = true; + Thread submitter = new Thread((ThreadStart)delegate { busyAdmitted = sink.TrySubmit(busy); busyReturned = true; }); + submitter.IsBackground = true; submitter.Start(); + AssertTrue(submitter.Join(250) && busyReturned && !busyAdmitted, "busy admission returns immediately without waiting for the gate"); + AssertTrue(!host.TryAcknowledgeAction(new TrayActionResult(busyId, 25UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED", null), out duplicate), "busy-sink terminal action remains consumed"); + AssertTrue(!host.TryTakeFailedAction(out none), "busy admission shows no UI"); + } + finally + { + releaseGate.Set(); holder.Join(2000); gateHeld.Dispose(); releaseGate.Dispose(); + } + } + finally + { + store.Release(); + sink.Dispose(); + host.Dispose(); + store.Dispose(); + } } - private static void TestUndisplayedActionFailureFeedbackIsBounded() + private static void TestStoreAndCallbackFailuresRecoverOnTheSameWriter() { - HostTransport host = new HostTransport(null, PersistTerminal); - for (int index = 0; index < 9; index++) + ControllableReceiptStore store = new ControllableReceiptStore(ReceiptStoreBlockStage.DuringFlush, 0, 1, 1); + bool failCallback = true; int successfulSignals = 0; + HostTransport host = new HostTransport(delegate { - Guid actionId = Guid.NewGuid(); - AssertTrue(host.TryRegisterAction(new TrayHostAction(actionId, TrayCommand.OpenLogs, 21UL)), "terminal failure releases pending action capacity"); - bool accepted = host.TryAcknowledgeAction(new TrayActionResult(actionId, 21UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED", null)); - AssertTrue(accepted, "bounded undisplayed feedback never strands an authenticated terminal action"); + if (failCallback) { failCallback = false; throw new InvalidOperationException("intentional UI callback failure"); } + Interlocked.Increment(ref successfulSignals); + }); + TrayTerminalReceiptSink sink = new TrayTerminalReceiptSink(store.TryAppendDurably, host.TryPublishDurableReceipt); + try + { + Guid storeFailureId = Guid.NewGuid(); Guid callbackFailureId = Guid.NewGuid(); Guid recoveredId = Guid.NewGuid(); + AssertTrue(sink.TrySubmit(RegisterAndAcknowledgeFailure(host, storeFailureId, 26UL, "store-failure action")), "first receipt is admitted before the store failure"); + AssertTrue(store.WaitUntilBlocked(2000), "first receipt blocks during the simulated flush"); + AssertTrue(sink.TrySubmit(RegisterAndAcknowledgeFailure(host, callbackFailureId, 26UL, "callback-failure action")), "second receipt queues behind the first store failure"); + AssertTrue(sink.TrySubmit(RegisterAndAcknowledgeFailure(host, recoveredId, 26UL, "recovered action")), "third receipt queues for recovery"); + TrayActionResult none; + AssertTrue(!host.TryTakeFailedAction(out none), "blocked and not-yet-durable receipts expose no UI"); + store.Release(); + WaitUntil(delegate { return store.CompletedCount == 3 && Interlocked.CompareExchange(ref successfulSignals, 0, 0) == 1; }, 3000, "same writer continues through store and callback failures to a later success"); + TrayActionResult recovered; TrayActionResult extra; + AssertTrue(host.TryTakeFailedAction(out recovered) && recovered.ActionId == recoveredId, "only the later durable callback publishes generic feedback"); + AssertTrue(!host.TryTakeFailedAction(out extra), "store and callback failures publish no latent UI"); + AssertTrue(store.WriterThreadCount == 1 && store.MaximumConcurrent == 1, "recovery keeps the original single writer instead of starting a replacement"); + } + finally + { + store.Release(); sink.Dispose(); host.Dispose(); store.Dispose(); } - host.Dispose(); } - private static void TestTerminalDiagnosticFailureNeverStrandsPendingActions() + private static void TestDurableReceiptTrustAndSharedUiBound() { - HostTransport missingWriter = new HostTransport(); - Guid missingWriterId = Guid.NewGuid(); - AssertTrue(missingWriter.TryRegisterAction(new TrayHostAction(missingWriterId, TrayCommand.OpenLogs, 22UL)), "action registers without a diagnostic writer"); - AssertTrue(missingWriter.TryAcknowledgeAction(new TrayActionResult(missingWriterId, 22UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED", null)), "missing diagnostic writer still releases the authenticated terminal action"); - TrayActionResult missingWriterFeedback; - AssertTrue(!missingWriter.TryTakeFailedAction(out missingWriterFeedback), "missing diagnostic writer cannot authorize generic feedback"); - missingWriter.Dispose(); - - bool persist = false; - List records = new List(); - HostTransport host = new HostTransport(null, delegate(TrayTerminalDiagnostic record) { records.Add(record); return persist; }); - for (int index = 0; index < 10; index++) + int signals = 0; + HostTransport host = new HostTransport(delegate { Interlocked.Increment(ref signals); }); + HostTransport foreign = new HostTransport(); + try { - Guid actionId = Guid.NewGuid(); - AssertTrue(host.TryRegisterAction(new TrayHostAction(actionId, TrayCommand.OpenLogs, 22UL)), "diagnostic failure never consumes pending-action capacity"); - AssertTrue(host.TryAcknowledgeAction(new TrayActionResult(actionId, 22UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED", null)), "authenticated terminal result releases its pending action even when local persistence fails"); - TrayActionResult suppressed; - AssertTrue(!host.TryTakeFailedAction(out suppressed), "generic feedback is suppressed when its local terminal diagnostic did not persist"); + Guid aboutId = Guid.NewGuid(); + AssertTrue(host.TryRegisterAction(new TrayHostAction(aboutId, TrayCommand.ShowAbout, 27UL)), "About action registers for durable authorization"); + TrayTerminalReceipt about = AcknowledgeTerminalQuickly(host, new TrayActionResult(aboutId, 27UL, TrayActionResultStatus.Completed, null, null), "About terminal result"); + TrayActionResult beforeDurable; + AssertTrue(!host.TryTakeCompletedAbout(out beforeDurable), "typed receipt alone cannot expose About UI before durable publication"); + AssertTrue(!foreign.TryPublishDurableReceipt(about), "a different transport rejects a receipt it did not correlate"); + AssertTrue(host.TryPublishDurableReceipt(about), "originating transport accepts one explicit durable-success callback"); + AssertTrue(!host.TryPublishDurableReceipt(about), "the same durable receipt cannot publish twice"); + + for (int index = 0; index < 7; index++) + { + TrayTerminalReceipt failure = RegisterAndAcknowledgeFailure(host, Guid.NewGuid(), 27UL, "bounded UI failure " + index.ToString()); + AssertTrue(host.TryPublishDurableReceipt(failure), "shared UI queue admits through eight total items"); + } + TrayTerminalReceipt overflow = RegisterAndAcknowledgeFailure(host, Guid.NewGuid(), 27UL, "bounded UI overflow"); + AssertTrue(!host.TryPublishDurableReceipt(overflow), "ninth shared About/failure UI item is dropped"); + + Guid noUiId = Guid.NewGuid(); + AssertTrue(host.TryRegisterAction(new TrayHostAction(noUiId, TrayCommand.OpenLogs, 27UL)), "non-UI completion registers while UI work is full"); + TrayTerminalReceipt noUi = AcknowledgeTerminalQuickly(host, new TrayActionResult(noUiId, 27UL, TrayActionResultStatus.Completed, null, null), "non-UI completion"); + AssertTrue(host.TryPublishDurableReceipt(noUi), "durable non-UI completion is consumed without UI capacity"); + + TrayActionResult aboutUi; + AssertTrue(host.TryTakeCompletedAbout(out aboutUi) && aboutUi.ActionId == aboutId, "durable About maps to one About UI item"); + int failures = 0; TrayActionResult failed; + while (host.TryTakeFailedAction(out failed)) { failures++; } + AssertTrue(failures == 7, "About and generic failure share one eight-item UI bound"); + AssertTrue(Interlocked.CompareExchange(ref signals, 0, 0) == 8, "only admitted UI items post application work"); } - persist = true; - Guid recoveredId = Guid.NewGuid(); - AssertTrue(host.TryRegisterAction(new TrayHostAction(recoveredId, TrayCommand.OpenLogs, 22UL)), "new action registers after repeated diagnostic failures"); - AssertTrue(host.TryAcknowledgeAction(new TrayActionResult(recoveredId, 22UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED", null)), "recovered diagnostic persistence accepts a new terminal result"); - TrayActionResult recoveredFeedback; - AssertTrue(host.TryTakeFailedAction(out recoveredFeedback) && recoveredFeedback.ActionId == recoveredId, "generic feedback resumes only after local terminal persistence recovers"); - Guid completedId = Guid.NewGuid(); - AssertTrue(host.TryRegisterAction(new TrayHostAction(completedId, TrayCommand.OpenLogs, 22UL)), "post-recovery command can still register"); - AssertTrue(host.TryAcknowledgeAction(new TrayActionResult(completedId, 22UL, TrayActionResultStatus.Completed, null, null)), "post-recovery command can complete"); - TrayTerminalDiagnostic last = records[records.Count - 1]; - AssertTrue(last.Command == TrayCommand.OpenLogs && last.Revision == 22UL && last.Status == TrayActionResultStatus.Completed && String.Equals(last.Code, "CCOD_TRAY_ACTION_COMPLETED", StringComparison.Ordinal), "terminal diagnostic exposes only the exact canonical correlation fields"); - host.Dispose(); + finally { foreign.Dispose(); host.Dispose(); } } - private static void TestBlockingTerminalWriterRunsOutsideCorrelationLock() + private static void TestDisposalReturnsWhileStoreIsHungAndSuppressesLateCallbacks() { - ManualResetEvent writerEntered = new ManualResetEvent(false); - ManualResetEvent releaseWriter = new ManualResetEvent(false); - bool first = true; - HostTransport host = new HostTransport(null, delegate(TrayTerminalDiagnostic record) - { - if (first) { first = false; writerEntered.Set(); releaseWriter.WaitOne(TimeSpan.FromSeconds(3)); } - return true; - }); - Guid blockedId = Guid.NewGuid(); - AssertTrue(host.TryRegisterAction(new TrayHostAction(blockedId, TrayCommand.OpenLogs, 24UL)), "blocking-writer action registers"); - bool acknowledged = false; - Thread acknowledgement = new Thread((ThreadStart)delegate { acknowledged = host.TryAcknowledgeAction(new TrayActionResult(blockedId, 24UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED", null)); }); - acknowledgement.IsBackground = true; - acknowledgement.Start(); + ControllableReceiptStore store = new ControllableReceiptStore(ReceiptStoreBlockStage.BeforeOpen, 1); + int signals = 0; + HostTransport host = new HostTransport(delegate { Interlocked.Increment(ref signals); }); + TrayTerminalReceiptSink sink = new TrayTerminalReceiptSink(store.TryAppendDurably, host.TryPublishDurableReceipt); try { - AssertTrue(writerEntered.WaitOne(TimeSpan.FromSeconds(2)), "terminal writer enters deterministically"); - Guid concurrentId = Guid.NewGuid(); - Stopwatch registration = Stopwatch.StartNew(); - bool registered = host.TryRegisterAction(new TrayHostAction(concurrentId, TrayCommand.OpenLogs, 24UL)); - registration.Stop(); - AssertTrue(registered && registration.Elapsed < TimeSpan.FromMilliseconds(250), "blocking terminal I/O cannot hold the correlation lock or pending capacity"); - releaseWriter.Set(); - AssertTrue(acknowledgement.Join(TimeSpan.FromSeconds(2)) && acknowledged, "terminal acknowledgement completes after the writer is released"); - AssertTrue(host.TryAcknowledgeAction(new TrayActionResult(concurrentId, 24UL, TrayActionResultStatus.Completed, null, null)), "concurrent action remains independently completable"); + TrayTerminalReceipt current = RegisterAndAcknowledgeFailure(host, Guid.NewGuid(), 28UL, "dispose-hung current action"); + AssertTrue(sink.TrySubmit(current), "dispose-hung receipt is admitted"); + AssertTrue(store.WaitUntilBlocked(2000), "writer is hung before sink disposal"); + Stopwatch disposal = Stopwatch.StartNew(); sink.Dispose(); disposal.Stop(); + AssertReturnsQuickly(disposal, "sink disposal uses a bounded join when the only writer is hung"); + + Guid closedId = Guid.NewGuid(); + TrayTerminalReceipt closed = RegisterAndAcknowledgeFailure(host, closedId, 28UL, "closed-sink action"); + Stopwatch closedSubmit = Stopwatch.StartNew(); bool closedAdmitted = sink.TrySubmit(closed); closedSubmit.Stop(); + AssertTrue(!closedAdmitted, "disposed sink rejects new receipts"); + AssertReturnsQuickly(closedSubmit, "closed admission is immediate"); + TrayTerminalReceipt duplicate; + AssertTrue(!host.TryAcknowledgeAction(new TrayActionResult(closedId, 28UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED", null), out duplicate), "closed-sink action remains terminally consumed"); + + store.Release(); + WaitUntil(delegate { return store.CompletedCount == 1; }, 2000, "hung store can return after disposal without reviving the sink"); + Stopwatch finalJoin = Stopwatch.StartNew(); sink.Dispose(); finalJoin.Stop(); + AssertReturnsQuickly(finalJoin, "disposed sink remains bounded while joining its original completed writer"); + TrayActionResult none; + AssertTrue(Interlocked.CompareExchange(ref signals, 0, 0) == 0 && !host.TryTakeFailedAction(out none), "late durable return after disposal cannot publish UI"); + AssertTrue(store.CallCount == 1 && store.WriterThreadCount == 1, "disposal never starts a replacement writer"); } finally { - releaseWriter.Set(); - if (acknowledgement.IsAlive) { acknowledgement.Join(TimeSpan.FromSeconds(2)); } - writerEntered.Dispose(); releaseWriter.Dispose(); host.Dispose(); + store.Release(); sink.Dispose(); host.Dispose(); store.Dispose(); } } @@ -245,11 +471,11 @@ public static int Main(string[] args) TestHostPendingAndReplayBound(); TestDirectPresentationAcksDoNotAccumulateAnUnusedControlQueue(); TestActionResultCorrelationAndControlPriority(); - TestAcknowledgedAboutQueuesOneUiWorkItem(); - TestRejectedAndFailedActionsQueueUserFeedback(); - TestUndisplayedActionFailureFeedbackIsBounded(); - TestTerminalDiagnosticFailureNeverStrandsPendingActions(); - TestBlockingTerminalWriterRunsOutsideCorrelationLock(); + TestBlockedReceiptStagesNeverBlockCorrelationOrUi(); + TestNinthReceiptAndBusyAdmissionDropWithoutPendingOrUi(); + TestStoreAndCallbackFailuresRecoverOnTheSameWriter(); + TestDurableReceiptTrustAndSharedUiBound(); + TestDisposalReturnsWhileStoreIsHungAndSuppressesLateCallbacks(); Console.WriteLine("TrayHost transport self-tests passed: 10"); return 0; } From 1f1ecc5f72fbbb1c070d62a745b36be003b50506 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:07:49 +0800 Subject: [PATCH 032/187] fix: hide provisional tray receipt UI --- src/trayhost/HostTransport.cs | 77 +++++++++++-------- src/trayhost/Program.cs | 11 ++- tests/trayhost/TrayHostTransportSelfTest.cs | 85 +++++++++++++++++++-- 3 files changed, 135 insertions(+), 38 deletions(-) diff --git a/src/trayhost/HostTransport.cs b/src/trayhost/HostTransport.cs index 25d405b..fe426f1 100644 --- a/src/trayhost/HostTransport.cs +++ b/src/trayhost/HostTransport.cs @@ -9,10 +9,18 @@ private sealed class PendingAction internal bool Accepted; } + private sealed class UiWorkItem + { + internal TrayActionResult Result; + internal bool Ready; + internal bool ProbeObserved; + } + private readonly object _gate = new object(); private readonly Action _presentationReady; - private readonly Queue _completedAbout = new Queue(); - private readonly Queue _failedActions = new Queue(); + private readonly Action _receiptReady; + private readonly Queue _completedAbout = new Queue(); + private readonly Queue _failedActions = new Queue(); private readonly Dictionary _pendingActions = new Dictionary(); private readonly Queue _recentActionOrder = new Queue(); private readonly HashSet _recentActions = new HashSet(); @@ -21,13 +29,18 @@ private sealed class PendingAction private bool _menuOpen; private bool _disposed; - internal HostTransport() : this(null) + internal HostTransport() : this(null, null) + { + } + + internal HostTransport(Action presentationReady) : this(presentationReady, presentationReady) { } - internal HostTransport(Action presentationReady) + internal HostTransport(Action presentationReady, Action receiptReady) { _presentationReady = presentationReady; + _receiptReady = receiptReady; } internal void SetMenuOpen(bool value) @@ -108,8 +121,8 @@ internal bool TryAcknowledgeAction(TrayActionResult result, out TrayTerminalRece internal bool TryPublishDurableReceipt(TrayTerminalReceipt receipt) { if (receipt == null) { return false; } - bool notify = false; - bool accepted = false; + UiWorkItem work = null; + Action receiptReady = _receiptReady; lock (_gate) { if (_disposed || !receipt.TryClaimPublication(this)) { return false; } @@ -117,48 +130,48 @@ internal bool TryPublishDurableReceipt(TrayTerminalReceipt receipt) if (result.Status == TrayActionResultStatus.Completed && receipt.Command == TrayCommand.ShowAbout) { if (_uiWorkCount >= 8) { return false; } - _completedAbout.Enqueue(result); + work = new UiWorkItem { Result = result, Ready = receiptReady == null }; + _completedAbout.Enqueue(work); _uiWorkCount++; - notify = true; - accepted = true; } else if (result.Status == TrayActionResultStatus.Rejected || result.Status == TrayActionResultStatus.Failed) { if (_uiWorkCount >= 8) { return false; } - _failedActions.Enqueue(result); + work = new UiWorkItem { Result = result, Ready = receiptReady == null }; + _failedActions.Enqueue(work); _uiWorkCount++; - notify = true; - accepted = true; - } - else - { - accepted = true; } } - Action presentationReady = _presentationReady; - if (notify && presentationReady != null) + if (work == null || receiptReady == null) { return true; } + try { receiptReady(); } + catch { - try { presentationReady(); } - catch - { - DropUiWork(receipt.Result); - return false; - } + DropUiWork(work); + return false; + } + bool repost; + lock (_gate) + { + if (_disposed) { return false; } + work.Ready = true; + repost = work.ProbeObserved; + work.ProbeObserved = false; } - return accepted; + if (repost) { try { receiptReady(); } catch { } } + return true; } - private void DropUiWork(TrayActionResult target) + private void DropUiWork(UiWorkItem target) { if (target == null) { return; } lock (_gate) { - Queue queue = target.Status == TrayActionResultStatus.Completed ? _completedAbout : _failedActions; + Queue queue = target.Result.Status == TrayActionResultStatus.Completed ? _completedAbout : _failedActions; int count = queue.Count; bool removed = false; for (int index = 0; index < count; index++) { - TrayActionResult current = queue.Dequeue(); + UiWorkItem current = queue.Dequeue(); if (!removed && Object.ReferenceEquals(current, target)) { removed = true; } else { queue.Enqueue(current); } } @@ -171,7 +184,9 @@ internal bool TryTakeCompletedAbout(out TrayActionResult result) lock (_gate) { if (_completedAbout.Count == 0) { result = null; return false; } - result = _completedAbout.Dequeue(); + UiWorkItem work = _completedAbout.Peek(); + if (!work.Ready) { work.ProbeObserved = true; result = null; return false; } + result = _completedAbout.Dequeue().Result; if (_uiWorkCount > 0) { _uiWorkCount--; } return true; } @@ -182,7 +197,9 @@ internal bool TryTakeFailedAction(out TrayActionResult result) lock (_gate) { if (_failedActions.Count == 0) { result = null; return false; } - result = _failedActions.Dequeue(); + UiWorkItem work = _failedActions.Peek(); + if (!work.Ready) { work.ProbeObserved = true; result = null; return false; } + result = _failedActions.Dequeue().Result; if (_uiWorkCount > 0) { _uiWorkCount--; } return true; } diff --git a/src/trayhost/Program.cs b/src/trayhost/Program.cs index 2cdaed1..82c310a 100644 --- a/src/trayhost/Program.cs +++ b/src/trayhost/Program.cs @@ -39,10 +39,17 @@ private static int RunChild(string[] args) PresentationSnapshot initial = TrayHostWire.ReadPresentation(initialFrame.Payload); Win32TrayPlatform platform = new Win32TrayPlatform(); TrayHostApplication application = null; + TrayWindow window = null; TrayTerminalDiagnosticStore terminalStore = new TrayTerminalDiagnosticStore(); - HostTransport transport = new HostTransport(delegate { TrayHostApplication current = application; if (current != null) { current.PostWork(); } }); + HostTransport transport = new HostTransport( + delegate { TrayHostApplication current = application; if (current != null) { current.PostWork(); } }, + delegate + { + TrayWindow current = window; + if (current == null || !Win32TrayPlatform.PostToWindow(current.OwnerHandle, TrayNativeConstants.WmApp + 2U)) { throw new InvalidOperationException("receipt UI callback failed"); } + }); TrayTerminalReceiptSink receiptSink = new TrayTerminalReceiptSink(terminalStore.TryAppendDurably, transport.TryPublishDurableReceipt, terminalStore.Dispose); - TrayWindow window = new TrayWindow(platform, transport.SetMenuOpen); + window = new TrayWindow(platform, transport.SetMenuOpen); bool shutdownRequested = false; bool shutdownSent = false; object stateGate = new object(); Action command = delegate(TrayCommand selected, ulong revision) { diff --git a/tests/trayhost/TrayHostTransportSelfTest.cs b/tests/trayhost/TrayHostTransportSelfTest.cs index 43436fe..1352d8c 100644 --- a/tests/trayhost/TrayHostTransportSelfTest.cs +++ b/tests/trayhost/TrayHostTransportSelfTest.cs @@ -102,8 +102,9 @@ private static void AssertReturnsQuickly(Stopwatch elapsed, string message) private static void WaitUntil(Func condition, int milliseconds, string message) { Stopwatch elapsed = Stopwatch.StartNew(); - while (!condition() && elapsed.ElapsedMilliseconds < milliseconds) { Thread.Sleep(5); } - AssertTrue(condition(), message); + bool satisfied = condition(); + while (!satisfied && elapsed.ElapsedMilliseconds < milliseconds) { Thread.Sleep(5); satisfied = condition(); } + AssertTrue(satisfied, message); } private static PresentationSnapshot Snapshot(ulong revision) @@ -269,8 +270,11 @@ private static void TestBlockedReceiptStagesNeverBlockCorrelationOrUi() store.Release(); WaitUntil(delegate { return Interlocked.CompareExchange(ref uiSignals, 0, 0) == 2; }, 2000, "both durable successes post exactly one work signal"); TrayActionResult firstUi; TrayActionResult followingUi; TrayActionResult none; - AssertTrue(host.TryTakeFailedAction(out firstUi) && firstUi.ActionId == firstId, "first durable receipt publishes its exact generic-feedback item"); - AssertTrue(host.TryTakeCompletedAbout(out followingUi) && followingUi.ActionId == followingId, "following durable receipt publishes its exact About item"); + firstUi = null; followingUi = null; + WaitUntil(delegate { return host.TryTakeFailedAction(out firstUi); }, 2000, "first durable receipt becomes visible after its callback succeeds"); + WaitUntil(delegate { return host.TryTakeCompletedAbout(out followingUi); }, 2000, "following durable About becomes visible after its callback succeeds"); + AssertTrue(firstUi.ActionId == firstId, "first durable receipt publishes its exact generic-feedback item"); + AssertTrue(followingUi.ActionId == followingId, "following durable receipt publishes its exact About item"); AssertTrue(!host.TryTakeFailedAction(out none), "durable callbacks publish no duplicate feedback"); AssertTrue(store.WriterThreadCount == 1 && store.MaximumConcurrent == 1, "all blocked stages are serviced by exactly one non-overlapping writer"); } @@ -377,7 +381,9 @@ private static void TestStoreAndCallbackFailuresRecoverOnTheSameWriter() store.Release(); WaitUntil(delegate { return store.CompletedCount == 3 && Interlocked.CompareExchange(ref successfulSignals, 0, 0) == 1; }, 3000, "same writer continues through store and callback failures to a later success"); TrayActionResult recovered; TrayActionResult extra; - AssertTrue(host.TryTakeFailedAction(out recovered) && recovered.ActionId == recoveredId, "only the later durable callback publishes generic feedback"); + recovered = null; + WaitUntil(delegate { return host.TryTakeFailedAction(out recovered); }, 2000, "later durable callback makes its recovered feedback visible"); + AssertTrue(recovered.ActionId == recoveredId, "only the later durable callback publishes generic feedback"); AssertTrue(!host.TryTakeFailedAction(out extra), "store and callback failures publish no latent UI"); AssertTrue(store.WriterThreadCount == 1 && store.MaximumConcurrent == 1, "recovery keeps the original single writer instead of starting a replacement"); } @@ -426,6 +432,71 @@ private static void TestDurableReceiptTrustAndSharedUiBound() finally { foreign.Dispose(); host.Dispose(); } } + private static void TestFailingCallbackNeverExposesProvisionalUiWork() + { + ManualResetEvent callbackEntered = new ManualResetEvent(false); + ManualResetEvent releaseCallback = new ManualResetEvent(false); + HostTransport host = new HostTransport(delegate + { + callbackEntered.Set(); + releaseCallback.WaitOne(); + throw new InvalidOperationException("intentional UI callback failure"); + }); + try + { + TrayTerminalReceipt receipt = RegisterAndAcknowledgeFailure(host, Guid.NewGuid(), 29UL, "provisional callback-failure action"); + bool published = true; + Thread publisher = new Thread((ThreadStart)delegate { published = host.TryPublishDurableReceipt(receipt); }); + publisher.IsBackground = true; publisher.Start(); + AssertTrue(callbackEntered.WaitOne(2000), "durable UI callback enters before failing"); + TrayActionResult provisional = null; bool takeReturned = false; bool tookProvisional = true; + Thread taker = new Thread((ThreadStart)delegate { tookProvisional = host.TryTakeFailedAction(out provisional); takeReturned = true; }); + taker.IsBackground = true; taker.Start(); + AssertTrue(taker.Join(250) && takeReturned && !tookProvisional && provisional == null, "provisional UI work returns immediately but remains unavailable while callback outcome is unknown"); + releaseCallback.Set(); + AssertTrue(publisher.Join(2000) && !published, "throwing durable callback reports publication failure"); + TrayActionResult none; + AssertTrue(!host.TryTakeFailedAction(out none), "callback failure leaves no latent generic UI work"); + } + finally + { + releaseCallback.Set(); host.Dispose(); callbackEntered.Dispose(); releaseCallback.Dispose(); + } + } + + private static void TestSuccessfulCallbackRepostsAfterAnEarlyUiProbe() + { + ManualResetEvent callbackEntered = new ManualResetEvent(false); + ManualResetEvent releaseCallback = new ManualResetEvent(false); + int callbackCount = 0; + HostTransport host = new HostTransport(delegate + { + int call = Interlocked.Increment(ref callbackCount); + if (call == 1) { callbackEntered.Set(); releaseCallback.WaitOne(); } + }); + try + { + Guid actionId = Guid.NewGuid(); + TrayTerminalReceipt receipt = RegisterAndAcknowledgeFailure(host, actionId, 30UL, "early-probe durable action"); + bool published = false; + Thread publisher = new Thread((ThreadStart)delegate { published = host.TryPublishDurableReceipt(receipt); }); + publisher.IsBackground = true; publisher.Start(); + AssertTrue(callbackEntered.WaitOne(2000), "first successful UI post is held before returning"); + TrayActionResult early; + AssertTrue(!host.TryTakeFailedAction(out early), "work-message probe cannot consume provisional feedback"); + releaseCallback.Set(); + AssertTrue(publisher.Join(2000) && published, "durable publication succeeds after its first UI post returns"); + AssertTrue(Interlocked.CompareExchange(ref callbackCount, 0, 0) == 2, "an early work-message probe causes one replacement UI post"); + TrayActionResult ready; TrayActionResult none; + AssertTrue(host.TryTakeFailedAction(out ready) && ready.ActionId == actionId, "replacement post observes the now-ready durable feedback"); + AssertTrue(!host.TryTakeFailedAction(out none), "replacement posting never duplicates the UI item"); + } + finally + { + releaseCallback.Set(); host.Dispose(); callbackEntered.Dispose(); releaseCallback.Dispose(); + } + } + private static void TestDisposalReturnsWhileStoreIsHungAndSuppressesLateCallbacks() { ControllableReceiptStore store = new ControllableReceiptStore(ReceiptStoreBlockStage.BeforeOpen, 1); @@ -475,8 +546,10 @@ public static int Main(string[] args) TestNinthReceiptAndBusyAdmissionDropWithoutPendingOrUi(); TestStoreAndCallbackFailuresRecoverOnTheSameWriter(); TestDurableReceiptTrustAndSharedUiBound(); + TestFailingCallbackNeverExposesProvisionalUiWork(); + TestSuccessfulCallbackRepostsAfterAnEarlyUiProbe(); TestDisposalReturnsWhileStoreIsHungAndSuppressesLateCallbacks(); - Console.WriteLine("TrayHost transport self-tests passed: 10"); + Console.WriteLine("TrayHost transport self-tests passed: 12"); return 0; } catch (Exception error) From 1212579676a1b4773450ed961094a02c6322d0f7 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:10:14 +0800 Subject: [PATCH 033/187] fix: drop failed tray receipt reposts --- src/trayhost/HostTransport.cs | 10 +++++- tests/trayhost/TrayHostTransportSelfTest.cs | 35 ++++++++++++++++++++- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/trayhost/HostTransport.cs b/src/trayhost/HostTransport.cs index fe426f1..5a6048b 100644 --- a/src/trayhost/HostTransport.cs +++ b/src/trayhost/HostTransport.cs @@ -157,7 +157,15 @@ internal bool TryPublishDurableReceipt(TrayTerminalReceipt receipt) repost = work.ProbeObserved; work.ProbeObserved = false; } - if (repost) { try { receiptReady(); } catch { } } + if (repost) + { + try { receiptReady(); } + catch + { + DropUiWork(work); + return false; + } + } return true; } diff --git a/tests/trayhost/TrayHostTransportSelfTest.cs b/tests/trayhost/TrayHostTransportSelfTest.cs index 1352d8c..c5cec99 100644 --- a/tests/trayhost/TrayHostTransportSelfTest.cs +++ b/tests/trayhost/TrayHostTransportSelfTest.cs @@ -497,6 +497,38 @@ private static void TestSuccessfulCallbackRepostsAfterAnEarlyUiProbe() } } + private static void TestReplacementCallbackFailureDropsReadyUiWork() + { + ManualResetEvent callbackEntered = new ManualResetEvent(false); + ManualResetEvent releaseCallback = new ManualResetEvent(false); + int callbackCount = 0; + HostTransport host = new HostTransport(delegate + { + int call = Interlocked.Increment(ref callbackCount); + if (call == 1) { callbackEntered.Set(); releaseCallback.WaitOne(); return; } + throw new InvalidOperationException("intentional replacement UI callback failure"); + }); + try + { + TrayTerminalReceipt receipt = RegisterAndAcknowledgeFailure(host, Guid.NewGuid(), 31UL, "replacement-callback failure action"); + bool published = true; + Thread publisher = new Thread((ThreadStart)delegate { published = host.TryPublishDurableReceipt(receipt); }); + publisher.IsBackground = true; publisher.Start(); + AssertTrue(callbackEntered.WaitOne(2000), "first UI post is held before replacement-failure probe"); + TrayActionResult early; + AssertTrue(!host.TryTakeFailedAction(out early), "replacement-failure probe cannot consume provisional feedback"); + releaseCallback.Set(); + AssertTrue(publisher.Join(2000) && !published, "replacement callback failure reports publication failure"); + TrayActionResult none; + AssertTrue(!host.TryTakeFailedAction(out none), "replacement callback failure removes the ready-but-unnotified UI item"); + AssertTrue(Interlocked.CompareExchange(ref callbackCount, 0, 0) == 2, "replacement callback is attempted exactly once"); + } + finally + { + releaseCallback.Set(); host.Dispose(); callbackEntered.Dispose(); releaseCallback.Dispose(); + } + } + private static void TestDisposalReturnsWhileStoreIsHungAndSuppressesLateCallbacks() { ControllableReceiptStore store = new ControllableReceiptStore(ReceiptStoreBlockStage.BeforeOpen, 1); @@ -548,8 +580,9 @@ public static int Main(string[] args) TestDurableReceiptTrustAndSharedUiBound(); TestFailingCallbackNeverExposesProvisionalUiWork(); TestSuccessfulCallbackRepostsAfterAnEarlyUiProbe(); + TestReplacementCallbackFailureDropsReadyUiWork(); TestDisposalReturnsWhileStoreIsHungAndSuppressesLateCallbacks(); - Console.WriteLine("TrayHost transport self-tests passed: 12"); + Console.WriteLine("TrayHost transport self-tests passed: 13"); return 0; } catch (Exception error) From 3315c33adeb67edd6dce0a22ad905a16ad49e7bd Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:11:26 +0800 Subject: [PATCH 034/187] docs: record async tray receipt Task 2 --- .../task-2-report.md | 268 ++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 .superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-2-report.md 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..2320aa5 --- /dev/null +++ b/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-2-report.md @@ -0,0 +1,268 @@ +# 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. From 2f028bb1f13d3c55fd74da26c78b6b599c628571 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:35:35 +0800 Subject: [PATCH 035/187] docs: clarify async tray receipt callback boundary --- .../plans/2026-08-28-v2522-async-tray-receipts.md | 14 +++++++++----- .../2026-08-28-v2522-async-tray-receipts-design.md | 14 +++++++++++--- 2 files changed, 20 insertions(+), 8 deletions(-) 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 index 6b2a809..0a3cfea 100644 --- a/docs/superpowers/plans/2026-08-28-v2522-async-tray-receipts.md +++ b/docs/superpowers/plans/2026-08-28-v2522-async-tray-receipts.md @@ -92,6 +92,7 @@ fix: pin tray terminal receipt storage - 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` @@ -121,11 +122,14 @@ 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 About/failure work to the existing application context. On admission, -store, callback, queue, or disposal failure, consume the terminal result but -show no feedback. Do not alter the wire result or add a new message type. +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** 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 index 6c09ae1..f97052c 100644 --- 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 @@ -54,9 +54,17 @@ 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. The callback posts -work to the STA application context outside transport locks. The UI queue is -also bounded at eight shared items. +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 From 85882890202f420479728a9ed1532f2085b970c8 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:23:35 +0800 Subject: [PATCH 036/187] fix: seal tray receipt dispatch races --- src/trayhost/HostTransport.cs | 157 +++++----- src/trayhost/NativeMethods.cs | 3 +- src/trayhost/Program.cs | 21 +- src/trayhost/TrayHostApplication.cs | 30 +- src/trayhost/TrayTerminalReceiptSink.cs | 28 +- tests/trayhost/Invoke-TrayHostSelfTest.ps1 | 1 + .../trayhost/TrayHostCorrelationTestBridge.cs | 1 + tests/trayhost/TrayHostNativeSelfTest.cs | 106 ++++++- .../trayhost/TrayHostParentClientSelfTest.cs | 1 + tests/trayhost/TrayHostTransportSelfTest.cs | 274 ++++++++++++++---- 10 files changed, 459 insertions(+), 163 deletions(-) diff --git a/src/trayhost/HostTransport.cs b/src/trayhost/HostTransport.cs index 5a6048b..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 @@ -11,21 +17,24 @@ private sealed class PendingAction private sealed class UiWorkItem { + internal uint Token; + internal TrayTerminalReceiptUiKind Kind; internal TrayActionResult Result; - internal bool Ready; - internal bool ProbeObserved; + 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 Action _receiptReady; - 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 int _uiWorkCount; + private uint _nextReceiptToken = unchecked((uint)Guid.NewGuid().GetHashCode()); private bool _menuOpen; private bool _disposed; @@ -33,11 +42,11 @@ internal HostTransport() : this(null, null) { } - internal HostTransport(Action presentationReady) : this(presentationReady, presentationReady) + internal HostTransport(Action presentationReady) : this(presentationReady, null) { } - internal HostTransport(Action presentationReady, Action receiptReady) + internal HostTransport(Action presentationReady, Func receiptReady) { _presentationReady = presentationReady; _receiptReady = receiptReady; @@ -122,95 +131,71 @@ internal bool TryPublishDurableReceipt(TrayTerminalReceipt receipt) { if (receipt == null) { return false; } UiWorkItem work = null; - Action receiptReady = _receiptReady; + Func receiptReady = _receiptReady; lock (_gate) { if (_disposed || !receipt.TryClaimPublication(this)) { return false; } TrayActionResult result = receipt.Result; - if (result.Status == TrayActionResultStatus.Completed && receipt.Command == TrayCommand.ShowAbout) - { - if (_uiWorkCount >= 8) { return false; } - work = new UiWorkItem { Result = result, Ready = receiptReady == null }; - _completedAbout.Enqueue(work); - _uiWorkCount++; - } - else if (result.Status == TrayActionResultStatus.Rejected || result.Status == TrayActionResultStatus.Failed) - { - if (_uiWorkCount >= 8) { return false; } - work = new UiWorkItem { Result = result, Ready = receiptReady == null }; - _failedActions.Enqueue(work); - _uiWorkCount++; - } - } - if (work == null || receiptReady == null) { return true; } - try { receiptReady(); } - catch - { - DropUiWork(work); - return false; + 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); } - bool repost; - lock (_gate) - { - if (_disposed) { return false; } - work.Ready = true; - repost = work.ProbeObserved; - work.ProbeObserved = false; - } - if (repost) + + for (int attempt = 0; attempt < MaximumTokenPostAttempts; attempt++) { - try { receiptReady(); } - catch + bool posted = false; + try { posted = receiptReady(work.Token); } + catch { posted = false; } + if (!posted) { DropUiWork(work); return false; } + lock (_gate) { - DropUiWork(work); - return false; + 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; } } - return true; + + DropUiWork(work); + return false; } - private void DropUiWork(UiWorkItem target) + internal bool TryTakeReceiptUi(uint token, out TrayTerminalReceiptUiKind kind, out TrayActionResult result) { - if (target == null) { return; } + kind = 0; result = null; + if (token == 0U) { return false; } lock (_gate) { - Queue queue = target.Result.Status == TrayActionResultStatus.Completed ? _completedAbout : _failedActions; - int count = queue.Count; - bool removed = false; - for (int index = 0; index < count; index++) - { - UiWorkItem current = queue.Dequeue(); - if (!removed && Object.ReferenceEquals(current, target)) { removed = true; } - else { queue.Enqueue(current); } - } - if (removed && _uiWorkCount > 0) { _uiWorkCount--; } + 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) { - lock (_gate) - { - if (_completedAbout.Count == 0) { result = null; return false; } - UiWorkItem work = _completedAbout.Peek(); - if (!work.Ready) { work.ProbeObserved = true; result = null; return false; } - result = _completedAbout.Dequeue().Result; - if (_uiWorkCount > 0) { _uiWorkCount--; } - return true; - } + result = null; + return false; } internal bool TryTakeFailedAction(out TrayActionResult result) { - lock (_gate) - { - if (_failedActions.Count == 0) { result = null; return false; } - UiWorkItem work = _failedActions.Peek(); - if (!work.Ready) { work.ProbeObserved = true; result = null; return false; } - result = _failedActions.Dequeue().Result; - if (_uiWorkCount > 0) { _uiWorkCount--; } - return true; - } + result = null; + return false; } public void Dispose() @@ -219,13 +204,31 @@ public void Dispose() { if (_disposed) { return; } _disposed = true; - _completedAbout.Clear(); - _failedActions.Clear(); - _uiWorkCount = 0; + _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 82c310a..c2193a5 100644 --- a/src/trayhost/Program.cs +++ b/src/trayhost/Program.cs @@ -43,11 +43,7 @@ private static int RunChild(string[] args) TrayTerminalDiagnosticStore terminalStore = new TrayTerminalDiagnosticStore(); HostTransport transport = new HostTransport( delegate { TrayHostApplication current = application; if (current != null) { current.PostWork(); } }, - delegate - { - TrayWindow current = window; - if (current == null || !Win32TrayPlatform.PostToWindow(current.OwnerHandle, TrayNativeConstants.WmApp + 2U)) { throw new InvalidOperationException("receipt UI callback failed"); } - }); + delegate(uint token) { TrayHostApplication current = application; return current != null && current.PostReceiptWork(token); }); TrayTerminalReceiptSink receiptSink = new TrayTerminalReceiptSink(terminalStore.TryAppendDurably, transport.TryPublishDurableReceipt, terminalStore.Dispose); window = new TrayWindow(platform, transport.SetMenuOpen); bool shutdownRequested = false; bool shutdownSent = false; object stateGate = new object(); @@ -69,10 +65,6 @@ private static int RunChild(string[] args) 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) @@ -82,8 +74,15 @@ private static int RunChild(string[] args) application.RequestExit(); } }; + Action receiptWork = delegate(uint token) + { + TrayTerminalReceiptUiKind kind; TrayActionResult receiptResult; + if (!transport.TryTakeReceiptUi(token, out kind, out receiptResult)) { return; } + if (kind == TrayTerminalReceiptUiKind.About) { window.ShowAbout(); } + else if (kind == TrayTerminalReceiptUiKind.Failure) { window.ShowActionFailed(); } + }; window.CommandSelected += command; - application = new TrayHostApplication(platform, window, command, work); + application = new TrayHostApplication(platform, window, command, work, receiptWork); window.Create(initial); lock (WriteGate) { @@ -107,7 +106,7 @@ private static int RunChild(string[] args) })) { IsBackground = true, Name = "CodexRemote.TrayHost.Reader" }; reader.Start(); int result = application.Run(); - receiptSink.Dispose(); application.Dispose(); transport.Dispose(); return result; + receiptSink.Dispose(); transport.Dispose(); application.Dispose(); return result; } private static bool VerifyParentIdentity(int pid, long creation) 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/TrayTerminalReceiptSink.cs b/src/trayhost/TrayTerminalReceiptSink.cs index c9b5f1c..9aeb213 100644 --- a/src/trayhost/TrayTerminalReceiptSink.cs +++ b/src/trayhost/TrayTerminalReceiptSink.cs @@ -42,6 +42,11 @@ internal sealed class TrayTerminalReceiptSink : IDisposable 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) @@ -129,10 +134,26 @@ private void WriterMain() bool durable = false; try { durable = _writeDurably(receipt.Diagnostic); } catch { durable = false; } - if (durable && !IsClosed()) +#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) { @@ -153,11 +174,6 @@ private void WriterMain() } } - private bool IsClosed() - { - lock (_gate) { return _closed; } - } - private void CleanupAfterWriter() { if (Interlocked.CompareExchange(ref _cleanupStarted, 1, 0) != 0) { return; } diff --git a/tests/trayhost/Invoke-TrayHostSelfTest.ps1 b/tests/trayhost/Invoke-TrayHostSelfTest.ps1 index b249423..e29ca76 100644 --- a/tests/trayhost/Invoke-TrayHostSelfTest.ps1 +++ b/tests/trayhost/Invoke-TrayHostSelfTest.ps1 @@ -64,6 +64,7 @@ try{ $outputPath=Join-Path $temporaryRoot $outputName $args=@('/nologo','/noconfig','/nostdlib+','/target:exe','/platform:anycpu','/optimize+','/checked+','/warn:4','/warnaserror+',('/out:{0}' -f $outputPath),('/main:{0}' -f $mainType)) if($NativeOnly){$args+='/define:TRAYHOST_SELF_TEST'} + if($TransportOnly){$args+='/define:TRAYHOST_RECEIPT_SELF_TEST'} foreach($leaf in @('mscorlib.dll','System.dll','System.Core.dll','System.Drawing.dll')){$args+=('/reference:'+ (Join-Path $reference.ReferenceRoot $leaf))} if($ProductionOnly){$args+=(Get-ChildItem -LiteralPath (Join-Path $repositoryRoot 'src\trayhost') -Filter '*.cs' -File | ForEach-Object FullName)} else diff --git a/tests/trayhost/TrayHostCorrelationTestBridge.cs b/tests/trayhost/TrayHostCorrelationTestBridge.cs index a79ba20..5add2e8 100644 --- a/tests/trayhost/TrayHostCorrelationTestBridge.cs +++ b/tests/trayhost/TrayHostCorrelationTestBridge.cs @@ -6,6 +6,7 @@ public sealed class TrayHostCorrelationTestBridge : IDisposable private sealed class Platform : INativeTrayPlatform { internal uint Selection; + public void SetMessageHandler(Action handler) { } public IntPtr CreateOwner() { return new IntPtr(10); } public IntPtr AssociateOwnerInputContext(IntPtr owner, IntPtr context) { return new IntPtr(20); } public IntPtr GetOwnerInputContext(IntPtr owner) { return IntPtr.Zero; } diff --git a/tests/trayhost/TrayHostNativeSelfTest.cs b/tests/trayhost/TrayHostNativeSelfTest.cs index f9185b9..c34083d 100644 --- a/tests/trayhost/TrayHostNativeSelfTest.cs +++ b/tests/trayhost/TrayHostNativeSelfTest.cs @@ -10,6 +10,7 @@ internal sealed class FakeTrayPlatform : INativeTrayPlatform { + private Action _messageHandler; internal readonly List Calls = new List(); internal bool ForegroundResult = true; internal bool ForegroundProof = true; @@ -23,6 +24,13 @@ internal sealed class FakeTrayPlatform : INativeTrayPlatform internal readonly List AppendedText = new List(); internal readonly List Commands = new List(); internal bool ConfirmExitResult = true; + internal Func PostMessageBehavior; + internal readonly List PostedMessages = new List(); + internal readonly List PostedWParams = new List(); + internal readonly List PostedLParams = new List(); + + public void SetMessageHandler(Action handler) { _messageHandler = handler; } + internal void DispatchMessage(uint message, UIntPtr wParam, IntPtr lParam) { Action handler = _messageHandler; if (handler != null) { IntPtr raw = IntPtr.Size == 4 ? new IntPtr(unchecked((int)wParam.ToUInt32())) : new IntPtr(unchecked((long)wParam.ToUInt64())); handler(message, raw, lParam); } } public IntPtr CreateOwner() { Calls.Add("CreateOwner"); return new IntPtr(10); } public IntPtr AssociateOwnerInputContext(IntPtr owner, IntPtr context) { Calls.Add(context == IntPtr.Zero ? "Associate:null" : "Associate:restore"); return new IntPtr(20); } @@ -42,7 +50,7 @@ internal sealed class FakeTrayPlatform : INativeTrayPlatform public bool SetForegroundWindow(IntPtr owner) { Calls.Add("SetForeground"); return ForegroundResult; } public IntPtr GetForegroundWindow() { Calls.Add("GetForeground"); return ForegroundProof ? new IntPtr(10) : new IntPtr(11); } public uint TrackPopupMenuEx(IntPtr menu, uint flags, int x, int y, IntPtr owner, IntPtr parameters) { Calls.Add("Track"); TrackCalls++; if (DuringTrack != null) { DuringTrack(); } return TrackResult; } - public bool PostMessage(IntPtr owner, uint message, UIntPtr wParam, IntPtr lParam) { Calls.Add("WM_NULL"); return true; } + public bool PostMessage(IntPtr owner, uint message, UIntPtr wParam, IntPtr lParam) { Calls.Add("WM_NULL"); PostedMessages.Add(message); PostedWParams.Add(wParam); PostedLParams.Add(lParam); Func behavior = PostMessageBehavior; return behavior == null || behavior(owner, message, wParam, lParam); } public bool SetNotificationFocus(ref TrayIconData icon) { Calls.Add("NIM_SETFOCUS"); return true; } public bool ShowMessageBox(IntPtr owner, string text, string caption) { MessageBoxes.Add(caption + "|" + text); Calls.Add("MessageBox"); return true; } public bool ConfirmExit(IntPtr owner, string text, string caption) { MessageBoxes.Add(caption + "|" + text); Calls.Add("ConfirmExit"); return ConfirmExitResult; } @@ -309,27 +317,36 @@ private static void TestDurableNativeReceiptAuthorizesGenericFeedbackOnce() string root = Path.Combine(Path.GetTempPath(), "ccod-tray-terminal-native-feedback-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); CreateCompatibilityParents(root); FakeTrayPlatform platform = new FakeTrayPlatform(); TrayWindow window = new TrayWindow(platform); window.Create(SnapshotV2(1)); - HostTransport transport = new HostTransport(); + TrayHostApplication application = null; HostTransport transport = null; + transport = new HostTransport(null, delegate(uint token) { return application != null && application.PostReceiptWork(token); }); + Action receiptWork = delegate(uint token) + { + TrayTerminalReceiptUiKind kind; TrayActionResult feedback; + if (transport.TryTakeReceiptUi(token, out kind, out feedback) && kind == TrayTerminalReceiptUiKind.Failure) { window.ShowActionFailed(); } + }; + application = new TrayHostApplication(platform, window, null, null, receiptWork); TrayTerminalDiagnosticStore store = TestStore(root, null, null); - TrayTerminalReceiptSink sink = new TrayTerminalReceiptSink(store.TryAppendDurably, transport.TryPublishDurableReceipt); + ManualResetEvent publicationCompleted = new ManualResetEvent(false); + TrayTerminalReceiptSink sink = new TrayTerminalReceiptSink(store.TryAppendDurably, delegate(TrayTerminalReceipt durableReceipt) { bool published = transport.TryPublishDurableReceipt(durableReceipt); publicationCompleted.Set(); return published; }); try { Guid actionId = Guid.NewGuid(); TrayTerminalReceipt receipt; AssertTrue(transport.TryRegisterAction(new TrayHostAction(actionId, TrayCommand.OpenLogs, 1UL)), "native durable-feedback action registers"); AssertTrue(transport.TryAcknowledgeAction(new TrayActionResult(actionId, 1UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED", null), out receipt) && receipt != null, "native durable-feedback action returns one typed receipt"); TrayActionResult feedback; - AssertTrue(!transport.TryTakeFailedAction(out feedback) && platform.MessageBoxes.Count == 0, "native generic feedback is unavailable before durable receipt success"); + AssertTrue(!transport.TryTakeFailedAction(out feedback) && platform.PostedMessages.Count == 0 && platform.MessageBoxes.Count == 0, "native generic feedback is unavailable before durable receipt success"); AssertTrue(sink.TrySubmit(receipt), "native receipt enters the asynchronous sink"); - Stopwatch elapsed = Stopwatch.StartNew(); - while (!transport.TryTakeFailedAction(out feedback) && elapsed.ElapsedMilliseconds < 3000L) { Thread.Sleep(5); } - AssertTrue(feedback != null && feedback.ActionId == actionId, "handle-pinned durable success authorizes the exact native feedback item"); - window.ShowActionFailed(); + AssertTrue(publicationCompleted.WaitOne(3000), "handle-pinned durable success completes receipt-token publication"); + AssertTrue(platform.PostedMessages.Count == 1 && platform.PostedMessages[0] == TrayNativeConstants.WmApp + 3U && platform.PostedWParams[0] != UIntPtr.Zero, "handle-pinned durable success posts one exact receipt token to the native application"); + AssertTrue(platform.MessageBoxes.Count == 0, "posted token does not display generic feedback before STA dispatch"); + platform.DispatchMessage(platform.PostedMessages[0], platform.PostedWParams[0], platform.PostedLParams[0]); AssertTrue(platform.MessageBoxes.Count == 1, "durable native receipt displays generic feedback exactly once"); - AssertTrue(!transport.TryTakeFailedAction(out feedback), "durable native feedback has no duplicate queue item"); + platform.DispatchMessage(platform.PostedMessages[0], platform.PostedWParams[0], platform.PostedLParams[0]); + AssertTrue(platform.MessageBoxes.Count == 1 && !transport.TryTakeFailedAction(out feedback), "durable native feedback has no duplicate token or ordinary queue item"); } finally { - sink.Dispose(); transport.Dispose(); window.Dispose(); store.Dispose(); try { Directory.Delete(root, true); } catch { } + sink.Dispose(); transport.Dispose(); application.Dispose(); store.Dispose(); publicationCompleted.Dispose(); try { Directory.Delete(root, true); } catch { } } } @@ -626,6 +643,71 @@ private static void TestPostedWorkMessageDispatchesToItsOwnerWindow() } } + private static void TestReceiptWorkMessageRequiresItsExactInternalToken() + { + uint token; + AssertTrue(!TrayHostApplication.TryDecodeReceiptWorkToken(TrayNativeConstants.WmApp + 2U, new IntPtr(41), IntPtr.Zero, out token), "ordinary work message never decodes a receipt token"); + AssertTrue(!TrayHostApplication.TryDecodeReceiptWorkToken(TrayNativeConstants.WmApp + 3U, IntPtr.Zero, IntPtr.Zero, out token), "zero receipt token is rejected"); + AssertTrue(!TrayHostApplication.TryDecodeReceiptWorkToken(TrayNativeConstants.WmApp + 3U, new IntPtr(41), new IntPtr(1), out token), "receipt message rejects unexpected lParam data"); + AssertTrue(TrayHostApplication.TryDecodeReceiptWorkToken(TrayNativeConstants.WmApp + 3U, new IntPtr(41), IntPtr.Zero, out token) && token == 41U, "receipt-specific work message preserves its exact nonzero token"); + } + + private static void TestFailedTokenPostCannotLeakIntoOrdinaryApplicationWork() + { + FakeTrayPlatform platform = new FakeTrayPlatform(); TrayWindow window = new TrayWindow(platform); window.Create(SnapshotV2(1)); + ManualResetEvent replacementPostEntered = new ManualResetEvent(false); ManualResetEvent releaseReplacementPost = new ManualResetEvent(false); + TrayHostApplication application = null; HostTransport transport = null; + int receiptPosts = 0; int ordinaryRuns = 0; uint observedToken = 0U; bool published = true; + try + { + transport = new HostTransport(null, delegate(uint token) { return application != null && application.PostReceiptWork(token); }); + Action ordinaryWork = delegate + { + Interlocked.Increment(ref ordinaryRuns); + TrayActionResult ordinary; + if (transport.TryTakeFailedAction(out ordinary)) { window.ShowActionFailed(); } + }; + Action receiptWork = delegate(uint token) + { + TrayTerminalReceiptUiKind kind; TrayActionResult receiptResult; + if (transport.TryTakeReceiptUi(token, out kind, out receiptResult) && kind == TrayTerminalReceiptUiKind.Failure) { window.ShowActionFailed(); } + }; + application = new TrayHostApplication(platform, window, null, ordinaryWork, receiptWork); + platform.PostMessageBehavior = delegate(IntPtr owner, uint message, UIntPtr wParam, IntPtr lParam) + { + if (message == TrayNativeConstants.WmApp + 2U) { platform.DispatchMessage(message, wParam, lParam); return true; } + if (message != TrayNativeConstants.WmApp + 3U) { return true; } + observedToken = unchecked((uint)wParam.ToUInt64()); + int call = Interlocked.Increment(ref receiptPosts); + if (call == 1) { platform.DispatchMessage(message, wParam, lParam); return true; } + replacementPostEntered.Set(); releaseReplacementPost.WaitOne(); + throw new InvalidOperationException("intentional token-post failure"); + }; + + Guid actionId = Guid.NewGuid(); TrayTerminalReceipt receipt; + AssertTrue(transport.TryRegisterAction(new TrayHostAction(actionId, TrayCommand.OpenLogs, 1UL)), "token-race native action registers"); + AssertTrue(transport.TryAcknowledgeAction(new TrayActionResult(actionId, 1UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED", null), out receipt) && receipt != null, "token-race native action returns one typed receipt"); + Thread publisher = new Thread((ThreadStart)delegate { published = transport.TryPublishDurableReceipt(receipt); }); + publisher.IsBackground = true; publisher.Start(); + AssertTrue(replacementPostEntered.WaitOne(2000), "replacement receipt-token post is blocked before failure"); + + application.PostWork(); + AssertTrue(Interlocked.CompareExchange(ref ordinaryRuns, 0, 0) == 1 && platform.MessageBoxes.Count == 0, "ordinary WmApp work cannot consume or display the unresolved receipt"); + releaseReplacementPost.Set(); + AssertTrue(publisher.Join(2000) && !published, "failed replacement token post reports publication failure"); + AssertTrue(observedToken != 0U && Interlocked.CompareExchange(ref receiptPosts, 0, 0) == 2, "receipt path uses one exact token across its bounded replacement post"); + + platform.DispatchMessage(TrayNativeConstants.WmApp + 3U, new UIntPtr(observedToken), IntPtr.Zero); + TrayTerminalReceiptUiKind missingKind; TrayActionResult missingResult; + AssertTrue(!transport.TryTakeReceiptUi(observedToken, out missingKind, out missingResult) && platform.MessageBoxes.Count == 0, "failed token post retracts the receipt before stale token dispatch can show a generic dialog"); + } + finally + { + releaseReplacementPost.Set(); if (transport != null) { transport.Dispose(); } if (application != null) { application.Dispose(); } else { window.Dispose(); } + replacementPostEntered.Dispose(); releaseReplacementPost.Dispose(); + } + } + public static int Main(string[] args) { try @@ -653,7 +735,9 @@ public static int Main(string[] args) TestShellRightClickNotificationMapping(); TestRealNativePInvokeSurface(); TestPostedWorkMessageDispatchesToItsOwnerWindow(); - Console.WriteLine("TrayHost native self-tests passed: 23"); + TestReceiptWorkMessageRequiresItsExactInternalToken(); + TestFailedTokenPostCannotLeakIntoOrdinaryApplicationWork(); + Console.WriteLine("TrayHost native self-tests passed: 25"); return 0; } catch (Exception error) diff --git a/tests/trayhost/TrayHostParentClientSelfTest.cs b/tests/trayhost/TrayHostParentClientSelfTest.cs index e83f597..5e02eec 100644 --- a/tests/trayhost/TrayHostParentClientSelfTest.cs +++ b/tests/trayhost/TrayHostParentClientSelfTest.cs @@ -8,6 +8,7 @@ internal sealed class ProductionTraceTrayPlatform : INativeTrayPlatform { internal string Message; + public void SetMessageHandler(Action handler) { } public IntPtr CreateOwner() { return new IntPtr(10); } public IntPtr AssociateOwnerInputContext(IntPtr owner, IntPtr context) { return new IntPtr(20); } public IntPtr GetOwnerInputContext(IntPtr owner) { return IntPtr.Zero; } diff --git a/tests/trayhost/TrayHostTransportSelfTest.cs b/tests/trayhost/TrayHostTransportSelfTest.cs index c5cec99..fe2e2ef 100644 --- a/tests/trayhost/TrayHostTransportSelfTest.cs +++ b/tests/trayhost/TrayHostTransportSelfTest.cs @@ -82,6 +82,30 @@ public void Dispose() } } + private sealed class ReceiptTokenInbox + { + private readonly object _gate = new object(); + private readonly Queue _tokens = new Queue(); + + internal bool Post(uint token) + { + if (token == 0U) { return false; } + lock (_gate) { _tokens.Enqueue(token); } + return true; + } + + internal bool TryTake(out uint token) + { + lock (_gate) + { + if (_tokens.Count == 0) { token = 0U; return false; } + token = _tokens.Dequeue(); return true; + } + } + + internal int Count { get { lock (_gate) { return _tokens.Count; } } } + } + private static void AssertTrue(bool value, string message) { if (!value) { throw new InvalidOperationException(message); } @@ -137,6 +161,35 @@ private static TrayTerminalReceipt RegisterAndAcknowledgeFailure(HostTransport h return AcknowledgeTerminalQuickly(host, new TrayActionResult(actionId, revision, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED", null), message); } + private static uint WaitForToken(ReceiptTokenInbox inbox, string message) + { + uint token = 0U; + WaitUntil(delegate { return inbox.TryTake(out token); }, 2000, message); + return token; + } + + private static TrayActionResult TakeReceiptUi(HostTransport host, uint token, TrayTerminalReceiptUiKind expectedKind, string message) + { + TrayTerminalReceiptUiKind kind; TrayActionResult result; + AssertTrue(host.TryTakeReceiptUi(token, out kind, out result) && kind == expectedKind && result != null, message); + return result; + } + + private static TrayActionResult WaitForPostedReceiptUi(HostTransport host, ReceiptTokenInbox inbox, TrayTerminalReceiptUiKind expectedKind, string message) + { + for (int attempt = 0; attempt < 2; attempt++) + { + uint token = WaitForToken(inbox, message + " token post"); + TrayTerminalReceiptUiKind kind; TrayActionResult result; + if (host.TryTakeReceiptUi(token, out kind, out result)) + { + AssertTrue(kind == expectedKind && result != null, message); + return result; + } + } + throw new InvalidOperationException(message + " was never committed"); + } + private static void TestParentLatestAndReservedControl() { ParentTransport transport = new ParentTransport(); @@ -244,8 +297,8 @@ private static void TestBlockedReceiptStagesNeverBlockCorrelationOrUi() for (int stageIndex = 0; stageIndex < stages.Length; stageIndex++) { ControllableReceiptStore store = new ControllableReceiptStore(stages[stageIndex], 1, 1); - int uiSignals = 0; - HostTransport host = new HostTransport(delegate { Interlocked.Increment(ref uiSignals); }); + ReceiptTokenInbox inbox = new ReceiptTokenInbox(); + HostTransport host = new HostTransport(null, inbox.Post); TrayTerminalReceiptSink sink = new TrayTerminalReceiptSink(store.TryAppendDurably, host.TryPublishDurableReceipt); try { @@ -265,14 +318,12 @@ private static void TestBlockedReceiptStagesNeverBlockCorrelationOrUi() AssertTrue(!host.TryAcknowledgeAction(new TrayActionResult(firstId, 24UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED", null), out duplicate), "current terminal action is removed before sink submission"); AssertTrue(!host.TryAcknowledgeAction(new TrayActionResult(followingId, 24UL, TrayActionResultStatus.Completed, null, null), out duplicate), "following terminal action is removed before sink submission"); TrayActionResult prematureFailure; TrayActionResult prematureAbout; - AssertTrue(!host.TryTakeFailedAction(out prematureFailure) && !host.TryTakeCompletedAbout(out prematureAbout) && Interlocked.CompareExchange(ref uiSignals, 0, 0) == 0, "blocked store exposes no generic or About UI work before durable success"); + AssertTrue(!host.TryTakeFailedAction(out prematureFailure) && !host.TryTakeCompletedAbout(out prematureAbout) && inbox.Count == 0, "blocked store exposes no generic or About UI work before durable success"); store.Release(); - WaitUntil(delegate { return Interlocked.CompareExchange(ref uiSignals, 0, 0) == 2; }, 2000, "both durable successes post exactly one work signal"); TrayActionResult firstUi; TrayActionResult followingUi; TrayActionResult none; - firstUi = null; followingUi = null; - WaitUntil(delegate { return host.TryTakeFailedAction(out firstUi); }, 2000, "first durable receipt becomes visible after its callback succeeds"); - WaitUntil(delegate { return host.TryTakeCompletedAbout(out followingUi); }, 2000, "following durable About becomes visible after its callback succeeds"); + firstUi = WaitForPostedReceiptUi(host, inbox, TrayTerminalReceiptUiKind.Failure, "first durable receipt becomes visible only through its exact token"); + followingUi = WaitForPostedReceiptUi(host, inbox, TrayTerminalReceiptUiKind.About, "following durable About becomes visible only through its exact token"); AssertTrue(firstUi.ActionId == firstId, "first durable receipt publishes its exact generic-feedback item"); AssertTrue(followingUi.ActionId == followingId, "following durable receipt publishes its exact About item"); AssertTrue(!host.TryTakeFailedAction(out none), "durable callbacks publish no duplicate feedback"); @@ -362,11 +413,11 @@ private static void TestNinthReceiptAndBusyAdmissionDropWithoutPendingOrUi() private static void TestStoreAndCallbackFailuresRecoverOnTheSameWriter() { ControllableReceiptStore store = new ControllableReceiptStore(ReceiptStoreBlockStage.DuringFlush, 0, 1, 1); - bool failCallback = true; int successfulSignals = 0; - HostTransport host = new HostTransport(delegate + bool failCallback = true; ReceiptTokenInbox inbox = new ReceiptTokenInbox(); + HostTransport host = new HostTransport(null, delegate(uint token) { if (failCallback) { failCallback = false; throw new InvalidOperationException("intentional UI callback failure"); } - Interlocked.Increment(ref successfulSignals); + return inbox.Post(token); }); TrayTerminalReceiptSink sink = new TrayTerminalReceiptSink(store.TryAppendDurably, host.TryPublishDurableReceipt); try @@ -379,10 +430,9 @@ private static void TestStoreAndCallbackFailuresRecoverOnTheSameWriter() TrayActionResult none; AssertTrue(!host.TryTakeFailedAction(out none), "blocked and not-yet-durable receipts expose no UI"); store.Release(); - WaitUntil(delegate { return store.CompletedCount == 3 && Interlocked.CompareExchange(ref successfulSignals, 0, 0) == 1; }, 3000, "same writer continues through store and callback failures to a later success"); + WaitUntil(delegate { return store.CompletedCount == 3 && inbox.Count != 0; }, 3000, "same writer continues through store and callback failures to a later token post"); TrayActionResult recovered; TrayActionResult extra; - recovered = null; - WaitUntil(delegate { return host.TryTakeFailedAction(out recovered); }, 2000, "later durable callback makes its recovered feedback visible"); + recovered = WaitForPostedReceiptUi(host, inbox, TrayTerminalReceiptUiKind.Failure, "later durable callback makes its recovered feedback visible by token"); AssertTrue(recovered.ActionId == recoveredId, "only the later durable callback publishes generic feedback"); AssertTrue(!host.TryTakeFailedAction(out extra), "store and callback failures publish no latent UI"); AssertTrue(store.WriterThreadCount == 1 && store.MaximumConcurrent == 1, "recovery keeps the original single writer instead of starting a replacement"); @@ -395,8 +445,8 @@ private static void TestStoreAndCallbackFailuresRecoverOnTheSameWriter() private static void TestDurableReceiptTrustAndSharedUiBound() { - int signals = 0; - HostTransport host = new HostTransport(delegate { Interlocked.Increment(ref signals); }); + ReceiptTokenInbox inbox = new ReceiptTokenInbox(); + HostTransport host = new HostTransport(null, inbox.Post); HostTransport foreign = new HostTransport(); try { @@ -404,7 +454,7 @@ private static void TestDurableReceiptTrustAndSharedUiBound() AssertTrue(host.TryRegisterAction(new TrayHostAction(aboutId, TrayCommand.ShowAbout, 27UL)), "About action registers for durable authorization"); TrayTerminalReceipt about = AcknowledgeTerminalQuickly(host, new TrayActionResult(aboutId, 27UL, TrayActionResultStatus.Completed, null, null), "About terminal result"); TrayActionResult beforeDurable; - AssertTrue(!host.TryTakeCompletedAbout(out beforeDurable), "typed receipt alone cannot expose About UI before durable publication"); + AssertTrue(!host.TryTakeCompletedAbout(out beforeDurable) && inbox.Count == 0, "typed receipt alone cannot expose About UI before durable publication"); AssertTrue(!foreign.TryPublishDurableReceipt(about), "a different transport rejects a receipt it did not correlate"); AssertTrue(host.TryPublishDurableReceipt(about), "originating transport accepts one explicit durable-success callback"); AssertTrue(!host.TryPublishDurableReceipt(about), "the same durable receipt cannot publish twice"); @@ -422,12 +472,16 @@ private static void TestDurableReceiptTrustAndSharedUiBound() TrayTerminalReceipt noUi = AcknowledgeTerminalQuickly(host, new TrayActionResult(noUiId, 27UL, TrayActionResultStatus.Completed, null, null), "non-UI completion"); AssertTrue(host.TryPublishDurableReceipt(noUi), "durable non-UI completion is consumed without UI capacity"); - TrayActionResult aboutUi; - AssertTrue(host.TryTakeCompletedAbout(out aboutUi) && aboutUi.ActionId == aboutId, "durable About maps to one About UI item"); - int failures = 0; TrayActionResult failed; - while (host.TryTakeFailedAction(out failed)) { failures++; } + AssertTrue(inbox.Count == 8, "only the eight admitted shared UI items receive token posts"); + uint aboutToken; AssertTrue(inbox.TryTake(out aboutToken), "durable About token is retained"); + TrayActionResult aboutUi = TakeReceiptUi(host, aboutToken, TrayTerminalReceiptUiKind.About, "durable About maps to one tokened About UI item"); + AssertTrue(aboutUi.ActionId == aboutId, "durable About token retains its correlated action"); + int failures = 0; uint failedToken; + while (inbox.TryTake(out failedToken)) + { + TakeReceiptUi(host, failedToken, TrayTerminalReceiptUiKind.Failure, "durable failure token maps to generic UI"); failures++; + } AssertTrue(failures == 7, "About and generic failure share one eight-item UI bound"); - AssertTrue(Interlocked.CompareExchange(ref signals, 0, 0) == 8, "only admitted UI items post application work"); } finally { foreign.Dispose(); host.Dispose(); } } @@ -436,8 +490,10 @@ private static void TestFailingCallbackNeverExposesProvisionalUiWork() { ManualResetEvent callbackEntered = new ManualResetEvent(false); ManualResetEvent releaseCallback = new ManualResetEvent(false); - HostTransport host = new HostTransport(delegate + uint observedToken = 0U; + HostTransport host = new HostTransport(null, delegate(uint token) { + observedToken = token; callbackEntered.Set(); releaseCallback.WaitOne(); throw new InvalidOperationException("intentional UI callback failure"); @@ -449,14 +505,14 @@ private static void TestFailingCallbackNeverExposesProvisionalUiWork() Thread publisher = new Thread((ThreadStart)delegate { published = host.TryPublishDurableReceipt(receipt); }); publisher.IsBackground = true; publisher.Start(); AssertTrue(callbackEntered.WaitOne(2000), "durable UI callback enters before failing"); - TrayActionResult provisional = null; bool takeReturned = false; bool tookProvisional = true; - Thread taker = new Thread((ThreadStart)delegate { tookProvisional = host.TryTakeFailedAction(out provisional); takeReturned = true; }); + TrayActionResult provisional = null; TrayTerminalReceiptUiKind provisionalKind = 0; bool takeReturned = false; bool tookProvisional = true; + Thread taker = new Thread((ThreadStart)delegate { tookProvisional = host.TryTakeReceiptUi(observedToken, out provisionalKind, out provisional); takeReturned = true; }); taker.IsBackground = true; taker.Start(); AssertTrue(taker.Join(250) && takeReturned && !tookProvisional && provisional == null, "provisional UI work returns immediately but remains unavailable while callback outcome is unknown"); releaseCallback.Set(); AssertTrue(publisher.Join(2000) && !published, "throwing durable callback reports publication failure"); - TrayActionResult none; - AssertTrue(!host.TryTakeFailedAction(out none), "callback failure leaves no latent generic UI work"); + TrayActionResult none; TrayTerminalReceiptUiKind noneKind; + AssertTrue(!host.TryTakeReceiptUi(observedToken, out noneKind, out none), "callback failure leaves no latent tokened generic UI work"); } finally { @@ -466,35 +522,31 @@ private static void TestFailingCallbackNeverExposesProvisionalUiWork() private static void TestSuccessfulCallbackRepostsAfterAnEarlyUiProbe() { - ManualResetEvent callbackEntered = new ManualResetEvent(false); - ManualResetEvent releaseCallback = new ManualResetEvent(false); - int callbackCount = 0; - HostTransport host = new HostTransport(delegate + int callbackCount = 0; uint observedToken = 0U; + HostTransport host = null; + host = new HostTransport(null, delegate(uint token) { + observedToken = token; int call = Interlocked.Increment(ref callbackCount); - if (call == 1) { callbackEntered.Set(); releaseCallback.WaitOne(); } + if (call == 1) + { + TrayTerminalReceiptUiKind earlyKind; TrayActionResult earlyResult; + AssertTrue(!host.TryTakeReceiptUi(token, out earlyKind, out earlyResult), "early token dispatch cannot consume provisional feedback"); + } + return true; }); try { Guid actionId = Guid.NewGuid(); TrayTerminalReceipt receipt = RegisterAndAcknowledgeFailure(host, actionId, 30UL, "early-probe durable action"); - bool published = false; - Thread publisher = new Thread((ThreadStart)delegate { published = host.TryPublishDurableReceipt(receipt); }); - publisher.IsBackground = true; publisher.Start(); - AssertTrue(callbackEntered.WaitOne(2000), "first successful UI post is held before returning"); - TrayActionResult early; - AssertTrue(!host.TryTakeFailedAction(out early), "work-message probe cannot consume provisional feedback"); - releaseCallback.Set(); - AssertTrue(publisher.Join(2000) && published, "durable publication succeeds after its first UI post returns"); + AssertTrue(host.TryPublishDurableReceipt(receipt), "durable publication succeeds after one early token delivery retry"); AssertTrue(Interlocked.CompareExchange(ref callbackCount, 0, 0) == 2, "an early work-message probe causes one replacement UI post"); - TrayActionResult ready; TrayActionResult none; - AssertTrue(host.TryTakeFailedAction(out ready) && ready.ActionId == actionId, "replacement post observes the now-ready durable feedback"); - AssertTrue(!host.TryTakeFailedAction(out none), "replacement posting never duplicates the UI item"); - } - finally - { - releaseCallback.Set(); host.Dispose(); callbackEntered.Dispose(); releaseCallback.Dispose(); + TrayActionResult ready = TakeReceiptUi(host, observedToken, TrayTerminalReceiptUiKind.Failure, "replacement token observes the now-committed durable feedback"); + AssertTrue(ready.ActionId == actionId, "replacement token preserves the exact correlated action"); + TrayActionResult none; TrayTerminalReceiptUiKind noneKind; + AssertTrue(!host.TryTakeReceiptUi(observedToken, out noneKind, out none), "replacement posting never duplicates the tokened UI item"); } + finally { host.Dispose(); } } private static void TestReplacementCallbackFailureDropsReadyUiWork() @@ -502,10 +554,18 @@ private static void TestReplacementCallbackFailureDropsReadyUiWork() ManualResetEvent callbackEntered = new ManualResetEvent(false); ManualResetEvent releaseCallback = new ManualResetEvent(false); int callbackCount = 0; - HostTransport host = new HostTransport(delegate + uint observedToken = 0U; HostTransport host = null; + host = new HostTransport(null, delegate(uint token) { + observedToken = token; int call = Interlocked.Increment(ref callbackCount); - if (call == 1) { callbackEntered.Set(); releaseCallback.WaitOne(); return; } + if (call == 1) + { + callbackEntered.Set(); releaseCallback.WaitOne(); + TrayTerminalReceiptUiKind earlyKind; TrayActionResult earlyResult; + AssertTrue(!host.TryTakeReceiptUi(token, out earlyKind, out earlyResult), "replacement-failure early token cannot consume provisional feedback"); + return true; + } throw new InvalidOperationException("intentional replacement UI callback failure"); }); try @@ -515,12 +575,10 @@ private static void TestReplacementCallbackFailureDropsReadyUiWork() Thread publisher = new Thread((ThreadStart)delegate { published = host.TryPublishDurableReceipt(receipt); }); publisher.IsBackground = true; publisher.Start(); AssertTrue(callbackEntered.WaitOne(2000), "first UI post is held before replacement-failure probe"); - TrayActionResult early; - AssertTrue(!host.TryTakeFailedAction(out early), "replacement-failure probe cannot consume provisional feedback"); releaseCallback.Set(); AssertTrue(publisher.Join(2000) && !published, "replacement callback failure reports publication failure"); - TrayActionResult none; - AssertTrue(!host.TryTakeFailedAction(out none), "replacement callback failure removes the ready-but-unnotified UI item"); + TrayActionResult none; TrayTerminalReceiptUiKind noneKind; + AssertTrue(!host.TryTakeReceiptUi(observedToken, out noneKind, out none), "replacement callback failure removes the uncommitted tokened UI item"); AssertTrue(Interlocked.CompareExchange(ref callbackCount, 0, 0) == 2, "replacement callback is attempted exactly once"); } finally @@ -529,6 +587,50 @@ private static void TestReplacementCallbackFailureDropsReadyUiWork() } } + private static void TestTokenedPostFailureNeverCommitsReceiptVisibility() + { + ManualResetEvent tokenCallbackEntered = new ManualResetEvent(false); + ManualResetEvent releaseTokenCallback = new ManualResetEvent(false); + int callbackCount = 0; int genericDialogs = 0; uint observedToken = 0U; + HostTransport host = null; + host = new HostTransport(null, delegate(uint token) + { + observedToken = token; + int call = Interlocked.Increment(ref callbackCount); + if (call == 1) + { + TrayTerminalReceiptUiKind earlyKind; TrayActionResult earlyResult; + AssertTrue(!host.TryTakeReceiptUi(token, out earlyKind, out earlyResult), "early token dispatch cannot consume an uncommitted receipt"); + return true; + } + tokenCallbackEntered.Set(); + releaseTokenCallback.WaitOne(); + throw new InvalidOperationException("intentional token-post failure"); + }); + try + { + TrayTerminalReceipt receipt = RegisterAndAcknowledgeFailure(host, Guid.NewGuid(), 34UL, "token-post failure action"); + bool published = true; + Thread publisher = new Thread((ThreadStart)delegate { published = host.TryPublishDurableReceipt(receipt); }); + publisher.IsBackground = true; publisher.Start(); + AssertTrue(tokenCallbackEntered.WaitOne(2000), "replacement token post is held before failure"); + + TrayTerminalReceiptUiKind ordinaryKind; TrayActionResult ordinaryResult; + if (host.TryTakeReceiptUi(0U, out ordinaryKind, out ordinaryResult)) { Interlocked.Increment(ref genericDialogs); } + AssertTrue(ordinaryResult == null && Interlocked.CompareExchange(ref genericDialogs, 0, 0) == 0, "ordinary no-token work cannot observe receipt UI while token post is unresolved"); + + releaseTokenCallback.Set(); + AssertTrue(publisher.Join(2000) && !published, "failed replacement token post retracts the receipt"); + TrayTerminalReceiptUiKind missingKind; TrayActionResult missingResult; + AssertTrue(observedToken != 0U && !host.TryTakeReceiptUi(observedToken, out missingKind, out missingResult) && missingResult == null, "failed token post leaves no exact-token receipt item"); + AssertTrue(Interlocked.CompareExchange(ref genericDialogs, 0, 0) == 0, "failed token post produces no generic dialog"); + } + finally + { + releaseTokenCallback.Set(); host.Dispose(); tokenCallbackEntered.Dispose(); releaseTokenCallback.Dispose(); + } + } + private static void TestDisposalReturnsWhileStoreIsHungAndSuppressesLateCallbacks() { ControllableReceiptStore store = new ControllableReceiptStore(ReceiptStoreBlockStage.BeforeOpen, 1); @@ -565,6 +667,69 @@ private static void TestDisposalReturnsWhileStoreIsHungAndSuppressesLateCallback } } + private static void TestDisposeClosesCallbackAdmissionAfterDurableStoreReturn() + { + ManualResetEvent beforeAdmission = new ManualResetEvent(false); + ManualResetEvent releaseAdmission = new ManualResetEvent(false); + int callbackStarts = 0; + HostTransport host = new HostTransport(); + TrayTerminalReceiptSink.BeforeCallbackAdmissionForTesting = delegate { beforeAdmission.Set(); releaseAdmission.WaitOne(); }; + TrayTerminalReceiptSink sink = new TrayTerminalReceiptSink( + delegate(TrayTerminalDiagnostic record) { return true; }, + delegate(TrayTerminalReceipt receipt) { Interlocked.Increment(ref callbackStarts); return host.TryPublishDurableReceipt(receipt); }, + null); + try + { + TrayTerminalReceipt receipt = RegisterAndAcknowledgeFailure(host, Guid.NewGuid(), 33UL, "durable-to-callback dispose handoff action"); + AssertTrue(sink.TrySubmit(receipt), "handoff receipt is admitted before its durable write"); + AssertTrue(beforeAdmission.WaitOne(2000), "worker reaches the controlled handoff after durable store return and before callback begin"); + Stopwatch disposal = Stopwatch.StartNew(); sink.Dispose(); disposal.Stop(); + AssertReturnsQuickly(disposal, "dispose returns while the writer is held before callback admission"); + releaseAdmission.Set(); + sink.Dispose(); + AssertTrue(Interlocked.CompareExchange(ref callbackStarts, 0, 0) == 0, "close gate prevents any new durable callback from starting after Dispose"); + TrayActionResult none; + AssertTrue(!host.TryTakeFailedAction(out none), "post-close handoff cannot publish receipt UI"); + } + finally + { + releaseAdmission.Set(); sink.Dispose(); TrayTerminalReceiptSink.BeforeCallbackAdmissionForTesting = null; host.Dispose(); beforeAdmission.Dispose(); releaseAdmission.Dispose(); + } + } + + private static void TestAlreadyStartedCallbackCannotPublishToDisposedTransport() + { + ManualResetEvent callbackStarted = new ManualResetEvent(false); + ManualResetEvent releaseCallback = new ManualResetEvent(false); + int tokenPosts = 0; bool callbackResult = true; + HostTransport host = new HostTransport(null, delegate(uint token) { Interlocked.Increment(ref tokenPosts); return true; }); + TrayTerminalReceiptSink sink = new TrayTerminalReceiptSink( + delegate(TrayTerminalDiagnostic record) { return true; }, + delegate(TrayTerminalReceipt receipt) + { + callbackStarted.Set(); + releaseCallback.WaitOne(); + callbackResult = host.TryPublishDurableReceipt(receipt); + return callbackResult; + }); + try + { + TrayTerminalReceipt receipt = RegisterAndAcknowledgeFailure(host, Guid.NewGuid(), 35UL, "already-started callback disposal action"); + AssertTrue(sink.TrySubmit(receipt), "already-started callback receipt is admitted"); + AssertTrue(callbackStarted.WaitOne(2000), "durable callback starts before sink closure"); + Stopwatch disposal = Stopwatch.StartNew(); sink.Dispose(); disposal.Stop(); + AssertReturnsQuickly(disposal, "sink disposal remains bounded with one already-started callback"); + host.Dispose(); + releaseCallback.Set(); + sink.Dispose(); + AssertTrue(!callbackResult && Interlocked.CompareExchange(ref tokenPosts, 0, 0) == 0, "already-started callback cannot token-post receipt UI to a disposed transport"); + } + finally + { + releaseCallback.Set(); sink.Dispose(); host.Dispose(); callbackStarted.Dispose(); releaseCallback.Dispose(); + } + } + public static int Main(string[] args) { try @@ -582,7 +747,10 @@ public static int Main(string[] args) TestSuccessfulCallbackRepostsAfterAnEarlyUiProbe(); TestReplacementCallbackFailureDropsReadyUiWork(); TestDisposalReturnsWhileStoreIsHungAndSuppressesLateCallbacks(); - Console.WriteLine("TrayHost transport self-tests passed: 13"); + TestDisposeClosesCallbackAdmissionAfterDurableStoreReturn(); + TestAlreadyStartedCallbackCannotPublishToDisposedTransport(); + TestTokenedPostFailureNeverCommitsReceiptVisibility(); + Console.WriteLine("TrayHost transport self-tests passed: 16"); return 0; } catch (Exception error) From 924a97ac829f827908c06a8b7c73cfa6f2c5948d Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:25:05 +0800 Subject: [PATCH 037/187] docs: record tray receipt race fix --- .../task-2-report.md | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) 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 index 2320aa5..86f9638 100644 --- 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 @@ -266,3 +266,94 @@ counted as production-trace acceptance here. 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. From 31a74c6bbe315a293fca78bf7bfdbb88239b05d7 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:06:02 +0800 Subject: [PATCH 038/187] test: trace authenticated tray terminal receipts --- src/trayhost/Program.cs | 118 +------ src/trayhost/TrayHostChildSession.cs | 257 +++++++++++++++ src/trayhost/WindowsTrayHostRuntime.cs | 71 +++++ tests/persistence/Supervisor.SelfTest.ps1 | 2 + tests/persistence/TrayHostClient.SelfTest.ps1 | 9 + .../TrayHostProductionTrace.SelfTest.ps1 | 146 +++++++-- tests/trayhost/Invoke-TrayHostSelfTest.ps1 | 39 ++- .../trayhost/TrayHostCorrelationTestBridge.cs | 92 +++++- .../trayhost/TrayHostParentClientSelfTest.cs | 85 +---- .../TrayHostProductionTraceSelfTest.cs | 298 ++++++++++++++++++ 10 files changed, 883 insertions(+), 234 deletions(-) create mode 100644 src/trayhost/TrayHostChildSession.cs create mode 100644 src/trayhost/WindowsTrayHostRuntime.cs create mode 100644 tests/trayhost/TrayHostProductionTraceSelfTest.cs diff --git a/src/trayhost/Program.cs b/src/trayhost/Program.cs index c2193a5..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,109 +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; - TrayWindow window = null; - TrayTerminalDiagnosticStore terminalStore = new TrayTerminalDiagnosticStore(); - HostTransport transport = new HostTransport( - delegate { TrayHostApplication current = application; if (current != null) { current.PostWork(); } }, - delegate(uint token) { TrayHostApplication current = application; return current != null && current.PostReceiptWork(token); }); - TrayTerminalReceiptSink receiptSink = new TrayTerminalReceiptSink(terminalStore.TryAppendDurably, transport.TryPublishDurableReceipt, terminalStore.Dispose); - 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)) - { - if (window.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) - { - window.RequestShutdown(); - lock (WriteGate) { ProtocolCodec.WriteAuthenticated(output, ProtocolFrame.Authenticated(ProtocolDirection.HostToParent, TrayHostMessageType.ShutdownAck, epoch, outboundSequence++, TrayHostWire.WriteRevision(window.CurrentRevision)), keys.HostToParent); } - application.RequestExit(); - } - }; - Action receiptWork = delegate(uint token) - { - TrayTerminalReceiptUiKind kind; TrayActionResult receiptResult; - if (!transport.TryTakeReceiptUi(token, out kind, out receiptResult)) { return; } - if (kind == TrayTerminalReceiptUiKind.About) { window.ShowAbout(); } - else if (kind == TrayTerminalReceiptUiKind.Failure) { window.ShowActionFailed(); } - }; - window.CommandSelected += command; - application = new TrayHostApplication(platform, window, command, work, receiptWork); - 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 + TrayHostChildIdentity identity; + if (!TrayHostChildSession.TryCreateIdentity(args, out identity)) { return 2; } + return TrayHostChildSession.Run( + Console.OpenStandardInput(), + Console.OpenStandardOutput(), + identity, + new WindowsTrayHostRuntime(), + delegate(HostTransport transport) { - 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 (!TryDispatchAuthenticatedActionResult(transport, receiptSink, frame.Payload)) { throw new ProtocolViolationException("action result is uncorrelated"); } } - 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(); - receiptSink.Dispose(); transport.Dispose(); application.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; } - } - - internal static bool TryDispatchAuthenticatedActionResult(HostTransport transport, TrayTerminalReceiptSink receiptSink, byte[] payload) - { - if (transport == null || payload == null) { return false; } - TrayTerminalReceipt receipt; - if (!transport.TryAcknowledgeAction(TrayHostWire.ReadActionResult(payload), out receipt)) { return false; } - if (receipt != null && receiptSink != null) - { - try { receiptSink.TrySubmit(receipt); } catch { } - } - return true; + TrayTerminalDiagnosticStore terminalStore = new TrayTerminalDiagnosticStore(); + return new TrayTerminalReceiptSink(terminalStore.TryAppendDurably, transport.TryPublishDurableReceipt, terminalStore.Dispose); + }); } private static int RunHeadlessSmoke() 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/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/persistence/Supervisor.SelfTest.ps1 b/tests/persistence/Supervisor.SelfTest.ps1 index 42d120d..7cda07f 100644 --- a/tests/persistence/Supervisor.SelfTest.ps1 +++ b/tests/persistence/Supervisor.SelfTest.ps1 @@ -820,10 +820,12 @@ function Import-CcodTrayHostCorrelationTestBridge { (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') ) diff --git a/tests/persistence/TrayHostClient.SelfTest.ps1 b/tests/persistence/TrayHostClient.SelfTest.ps1 index 37f650c..e22877b 100644 --- a/tests/persistence/TrayHostClient.SelfTest.ps1 +++ b/tests/persistence/TrayHostClient.SelfTest.ps1 @@ -104,4 +104,13 @@ Invoke-CcodTest 'TrayHost client delivers the exact correlated terminal result t Assert-CcodEqual 'CCOD_TRAY_ACTION_STALE' $received[0].ErrorCode 'terminal result preserves the stable stale code' } +Invoke-CcodTest 'production trace launches the temporary child through the normal parent client' { + # Production mutation caught: replacing the real child process with TestProcessFactory or bypassing TrayHostParentClient.Start. + $traceSource=Get-Content -LiteralPath (Join-Path $repositoryRoot 'tests\trayhost\TrayHostProductionTraceSelfTest.cs') -Raw -Encoding UTF8 + $traceScript=Get-Content -LiteralPath (Join-Path $repositoryRoot 'tests\persistence\TrayHostProductionTrace.SelfTest.ps1') -Raw -Encoding UTF8 + Assert-CcodTrue ($traceSource -cmatch 'TrayHostParentClient\.Start\(') 'temporary trace uses the normal parent-client start path' + Assert-CcodTrue ($traceSource -cnotmatch 'TestProcessFactory\s*=') 'temporary trace never assigns the parent-client test process factory' + Assert-CcodTrue ($traceScript -cnotmatch 'TryDispatchAuthenticatedActionResult|TryTakeFailedAction') 'PowerShell trace cannot bypass authenticated child dispatch or tokened receipt work' +} + Write-Host 'TrayHost client self-tests passed.' diff --git a/tests/persistence/TrayHostProductionTrace.SelfTest.ps1 b/tests/persistence/TrayHostProductionTrace.SelfTest.ps1 index a5bff83..31db00a 100644 --- a/tests/persistence/TrayHostProductionTrace.SelfTest.ps1 +++ b/tests/persistence/TrayHostProductionTrace.SelfTest.ps1 @@ -1,30 +1,136 @@ -param([Parameter(Mandatory)][string]$AssemblyPath) +param( + [string]$AssemblyPath, + [string]$CurrentTracePath, + [string]$StaleTracePath, + [string]$ProductionExePath +) + $ErrorActionPreference='Stop' -. (Join-Path $PSScriptRoot 'TestSupport.ps1') $repositoryRoot=Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +if([string]::IsNullOrEmpty($AssemblyPath)-and[string]::IsNullOrEmpty($CurrentTracePath)-and[string]::IsNullOrEmpty($StaleTracePath)-and[string]::IsNullOrEmpty($ProductionExePath)){ + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $repositoryRoot 'tests\trayhost\Invoke-TrayHostSelfTest.ps1') -ProductionTraceOnly + exit $LASTEXITCODE +} +foreach($path in @($AssemblyPath,$CurrentTracePath,$StaleTracePath,$ProductionExePath)){if([string]::IsNullOrEmpty($path)-or-not(Test-Path -LiteralPath $path -PathType Leaf)){throw 'CCOD_TRAYHOST_PRODUCTION_TRACE_INPUT_INVALID'}} + +. (Join-Path $PSScriptRoot 'TestSupport.ps1') [Reflection.Assembly]::LoadFrom([IO.Path]::GetFullPath($AssemblyPath))|Out-Null -Import-Module (Join-Path $repositoryRoot 'src\persistence\modules\TrayHostClient.psm1') -Force +Import-Module (Join-Path $repositoryRoot 'src\persistence\modules\TrayHostClient.psm1') -Force -WarningAction SilentlyContinue +. (Join-Path $repositoryRoot 'src\persistence\Supervisor.ps1') -ReadyToken ('a'*64) + +function New-CcodProductionTraceContext { + param($Client) + $enabled=[pscustomobject][ordered]@{RepairEnabled=$false;LanguageEnabled=$false;OpenLogsEnabled=$true;AboutEnabled=$false;ExitEnabled=$false;Busy=$false} + $acknowledged=[ordered]@{};$acknowledged['1']=$enabled + $queue=[Collections.Generic.Queue[object]]::new() + $context=[pscustomobject][ordered]@{ + Client=$Client;CommandQueue=$queue;CurrentRevision=[UInt64]1;LastAcknowledgedRevision=[UInt64]1 + PublishedPresentations=[ordered]@{};AcknowledgedPresentations=$acknowledged;Exited=$false;LastError=$null + } + return [pscustomobject][ordered]@{Context=$context;Enabled=$enabled} +} + +function New-CcodProductionTraceHostState { + param($Context,$Enabled) + return [pscustomobject][ordered]@{ + Tray=$Context;LastAcknowledgedPresentation=$Enabled;TrayActionIds=[ordered]@{} + WorkerSlot=$null;LifecycleWorkerSlot=$null;LifecycleRequest=$null;Journal=$null + State=[pscustomobject][ordered]@{AutomationEnabled=$true;Settings=[pscustomobject]@{candidateCompatibleOptIn=$false};VerifiedPackages=[pscustomobject][ordered]@{};Damage=$null} + Identity=[pscustomobject][ordered]@{UserSid='S-1-5-21-111-222-333-1001';SessionId=[int]1} + Layout=[pscustomobject][ordered]@{RuntimeId='trace-runtime';LogDirectory='C:\Trace\Logs'} + PackageFullName=$null;AppAsarSha256=$null;Classification=$null;Ordinary=[object[]]@();Special=[object[]]@() + AttemptKeys=[ordered]@{};RecoveryIgnoreKeys=[ordered]@{};SuppressionKeys=[ordered]@{} + RuntimeCleanupCodes=[Collections.Generic.List[string]]::new() + } +} + +function Wait-CcodProductionTraceWitness { + param([string]$Path,$Context,[scriptblock]$Condition) + $deadline=[DateTime]::UtcNow.AddSeconds(4) + while([DateTime]::UtcNow-lt$deadline){ + Receive-CcodTrayHostEvents -Context $Context + $lines=if(Test-Path -LiteralPath $Path -PathType Leaf){@(Get-Content -LiteralPath $Path -ErrorAction SilentlyContinue)}else{@()} + if(& $Condition $lines){return @($lines)} + [void]$Context.Client.WaitForActivity([TimeSpan]::FromMilliseconds(25)) + } + throw 'CCOD_TRAYHOST_PRODUCTION_TRACE_WITNESS_TIMEOUT' +} function Invoke-CcodProductionTraceCase { - param([bool]$Stale) - $client=[TrayHostProductionTraceFixture]::Start($AssemblyPath,$Stale) + param([string]$TracePath,[bool]$Stale) + $client=[TrayHostProductionTraceFixture]::Start($TracePath) try{ - $queue=[Collections.Generic.Queue[object]]::new() - $context=[pscustomobject][ordered]@{Client=$client;CommandQueue=$queue;Exited=$false;LastError=$null;PublishedPresentations=[ordered]@{};AcknowledgedPresentations=[ordered]@{}} - $deadline=[DateTime]::UtcNow.AddSeconds(3) - while($queue.Count-eq0-and[DateTime]::UtcNow-lt$deadline){[void]$client.WaitForActivity([TimeSpan]::FromMilliseconds(100));Receive-CcodTrayHostEvents -Context $context} - Assert-CcodEqual 1 $queue.Count 'real TrayHostClient receives one authenticated production action' - $action=$queue.Dequeue() - Assert-CcodEqual 'OpenLogs' $action.Command 'production trace preserves command through TrayHostWire and TrayHostClient' + $parentProcess=[Diagnostics.Process]::GetCurrentProcess() + try{Assert-CcodTrue ($client.Receipt.HostPid-gt0-and$client.Receipt.HostPid-ne$parentProcess.Id) 'normal parent start launches a distinct child process'}finally{$parentProcess.Dispose()} + $childProcess=[Diagnostics.Process]::GetProcessById($client.Receipt.HostPid) + try{Assert-CcodTrue ([string]::Equals([IO.Path]::GetFullPath($TracePath),[IO.Path]::GetFullPath($childProcess.MainModule.FileName),[StringComparison]::OrdinalIgnoreCase)) 'normal parent start launches the requested temporary trace executable'}finally{$childProcess.Dispose()} + Assert-CcodEqual 'trace-runtime' $client.Receipt.RuntimeId 'real child handshake preserves the opaque runtime identity' + $fixture=New-CcodProductionTraceContext $client;$context=$fixture.Context + $hostState=New-CcodProductionTraceHostState $context $fixture.Enabled + $opened=[Collections.Generic.List[string]]::new();$supervisorReceipts=[Collections.Generic.List[object]]::new() + $adapters=@{ + GetUtcNow={ [DateTime]::Parse('2030-02-03T03:04:05Z').ToUniversalTime() } + OpenLogs={param($Path)[void]$opened.Add([string]$Path)}.GetNewClosure() + WriteLog={param($Record)[void]$supervisorReceipts.Add($Record)}.GetNewClosure() + SendTrayActionResult={param($Tray,$ActionId,$Revision,$Status,$ErrorCode,$TransactionId)[bool](Send-CcodTrayHostActionResult -Context $context -ActionId $ActionId -Revision ([UInt64]$Revision) -Status $Status -ErrorCode $ErrorCode -TransactionId $TransactionId)}.GetNewClosure() + } + + $deadline=[DateTime]::UtcNow.AddSeconds(4) + while($context.CommandQueue.Count-eq0-and[DateTime]::UtcNow-lt$deadline){[void]$client.WaitForActivity([TimeSpan]::FromMilliseconds(25));Receive-CcodTrayHostEvents -Context $context} + Assert-CcodEqual 1 $context.CommandQueue.Count 'real TrayHostClient receives one authenticated child-session action' + $action=$context.CommandQueue.Dequeue() + Assert-CcodTrue ($action.ActionId-ne[guid]::Empty) 'production trace preserves a nonempty action id' + Assert-CcodEqual 'OpenLogs' $action.Command 'production trace preserves OpenLogs through native/session/wire/client' $expectedRevision=if($Stale){[UInt64]8}else{[UInt64]1} - Assert-CcodEqual $expectedRevision $action.Revision 'production trace preserves the exact presentation revision' - $status=if($Stale){'Rejected'}else{'Completed'} - $code=if($Stale){'CCOD_TRAY_ACTION_STALE'}else{$null} - Assert-CcodEqual $true (Send-CcodTrayHostActionResult -Context $context -ActionId $action.ActionId -Revision $action.Revision -Status $status -ErrorCode $code -TransactionId $null) 'real TrayHostClient sends the authenticated correlated terminal result' - Assert-CcodTrue $client.WaitForStopped([TimeSpan]::FromSeconds(3)) 'production trace peer validates Program dispatch and exits' + Assert-CcodEqual $expectedRevision $action.Revision 'production trace preserves the exact action revision' + + $results=@(Invoke-CcodSupervisorCommand $hostState $adapters $action) + Assert-CcodEqual 1 $results.Count 'Supervisor produces one terminal result' + $expectedStatus=if($Stale){'Rejected'}else{'Completed'} + $expectedCode=if($Stale){'CCOD_TRAY_ACTION_STALE'}else{$null} + Assert-CcodEqual $expectedStatus $results[0].Status 'Supervisor makes the expected revision-bound decision' + if($Stale){Assert-CcodEqual $expectedCode $results[0].ErrorCode 'Supervisor preserves the expected terminal code'}else{Assert-CcodTrue ([string]::IsNullOrEmpty($results[0].ErrorCode)) 'current completion carries no terminal error code'} + Assert-CcodEqual $true $results[0].Delivered 'Supervisor sends the terminal result through real TrayHostClient' + Assert-CcodEqual $(if($Stale){0}else{1}) $opened.Count 'OpenLogs side effect occurs only for the current action' + Assert-CcodEqual 1 $supervisorReceipts.Count 'Supervisor records exactly one terminal decision' + + $witnessPath=[TrayHostProductionTraceFixture]::GetWitnessPath($TracePath) + $condition=if($Stale){{param($Items)@($Items|Where-Object{$_-like'dialog *'}).Count-eq1}}else{{param($Items)@($Items|Where-Object{$_-eq'publication accepted=true'}).Count-eq1}} + $lines=Wait-CcodProductionTraceWitness $witnessPath $context $condition + $receipt="receipt command=OpenLogs revision=$expectedRevision status=$expectedStatus code=$(if($Stale){'CCOD_TRAY_ACTION_STALE'}else{'CCOD_TRAY_ACTION_COMPLETED'})" + Assert-CcodEqual 1 @($lines|Where-Object{$_-like'receipt *'}).Count 'child async sink writes exactly one terminal receipt' + Assert-CcodTrue ($lines-ccontains$receipt) 'child receipt is exact and sanitized' + Assert-CcodEqual 1 @($lines|Where-Object{$_-eq'publication accepted=true'}).Count 'durable receipt callback publishes exactly once' + if($Stale){ + Assert-CcodTrue ($lines-ccontains'fixture command=OpenLogs revision=8') 'stale action comes from the test-only authenticated fixture' + $tokenPosts=@($lines|Where-Object{$_-cmatch'^post message=32771 token=[1-9][0-9]*$'}) + Assert-CcodTrue ($tokenPosts.Count-ge1) 'stale durable callback uses nonzero tokened WmApp+3 work' + $dialog='dialog caption=trace-0 text=trace-15' + Assert-CcodEqual 1 @($lines|Where-Object{$_-like'dialog *'}).Count 'stale result displays one generic dialog' + Assert-CcodTrue ($lines-ccontains$dialog) 'stale dialog contains only acknowledged generic UI text' + Assert-CcodTrue ([Array]::IndexOf([object[]]$lines,$receipt)-lt[Array]::IndexOf([object[]]$lines,$dialog)) 'generic dialog occurs only after durable receipt success' + Assert-CcodTrue ([Array]::IndexOf([object[]]$lines,$tokenPosts[0])-lt[Array]::IndexOf([object[]]$lines,$dialog)) 'generic dialog is drained through tokened receipt work' + Assert-CcodTrue ($dialog-cnotmatch'CCOD_|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}') 'generic dialog leaks no internal code or correlation id' + }else{ + Assert-CcodTrue ($lines-ccontains'native-menu command=OpenLogs') 'current action originates from real TrayWindow.HandleContextMenu' + Assert-CcodTrue ($lines-ccontains'native command=OpenLogs revision=1') 'current native selection retains the displayed revision' + Assert-CcodEqual 0 @($lines|Where-Object{$_-like'dialog *'}).Count 'current completion displays no generic dialog' + } + + Receive-CcodTrayHostEvents -Context $context + Assert-CcodEqual 0 $context.CommandQueue.Count 'production trace emits no duplicate action' + Assert-CcodTrue ([string]::IsNullOrEmpty($context.LastError)) 'production trace emits no remote fault' + Assert-CcodTrue $client.BeginShutdown([ShutdownReason]::SupervisorExit,[UInt64]1) 'parent requests normal authenticated shutdown' + Assert-CcodTrue $client.WaitForStopped([TimeSpan]::FromSeconds(4)) 'child session returns an authenticated shutdown acknowledgement' + Receive-CcodTrayHostEvents -Context $context + Assert-CcodEqual $true $context.Exited 'TrayHostClient observes the normal child exit' + Assert-CcodTrue ([string]::IsNullOrEmpty($context.LastError)) 'normal child shutdown has no fault' + Assert-CcodEqual 0 $context.CommandQueue.Count 'normal child shutdown adds no extra action' }finally{$client.Dispose()} } -Invoke-CcodProductionTraceCase $false -Invoke-CcodProductionTraceCase $true -Write-Host 'TrayHost production correlation trace passed: 2' +& $ProductionExePath '--production-trace-test-selector' +Assert-CcodEqual 2 $LASTEXITCODE 'shipped Program.Main rejects the temporary trace selector' +Invoke-CcodProductionTraceCase $CurrentTracePath $false +Invoke-CcodProductionTraceCase $StaleTracePath $true +Write-Host 'TrayHost production child-session trace passed: 2' diff --git a/tests/trayhost/Invoke-TrayHostSelfTest.ps1 b/tests/trayhost/Invoke-TrayHostSelfTest.ps1 index e29ca76..661f0d1 100644 --- a/tests/trayhost/Invoke-TrayHostSelfTest.ps1 +++ b/tests/trayhost/Invoke-TrayHostSelfTest.ps1 @@ -4,12 +4,13 @@ param( [switch]$NativeOnly, [switch]$TransportOnly, [switch]$ParentClientOnly, + [switch]$ProductionTraceOnly, [switch]$ProductionOnly ) $ErrorActionPreference='Stop' $repositoryRoot=Split-Path (Split-Path $PSScriptRoot -Parent) -Parent -if((@($ProtocolOnly,$NativeOnly,$TransportOnly,$ParentClientOnly,$ProductionOnly)|Where-Object{$_}).Count -ne 1){throw 'CCOD_TRAYHOST_TEST_MODE_REQUIRED'} +if((@($ProtocolOnly,$NativeOnly,$TransportOnly,$ParentClientOnly,$ProductionTraceOnly,$ProductionOnly)|Where-Object{$_}).Count -ne 1){throw 'CCOD_TRAYHOST_TEST_MODE_REQUIRED'} $protocolPath=Join-Path $repositoryRoot 'src\trayhost\PipeProtocol.cs' $presentationPath=Join-Path $repositoryRoot 'src\trayhost\PresentationSnapshot.cs' $nativeFiles=@( @@ -36,20 +37,13 @@ $parentClientFiles=@( (Join-Path $repositoryRoot 'src\trayhost\JobObject.cs'), (Join-Path $repositoryRoot 'src\trayhost\TrayHostParentClient.cs') ) -$programTraceFiles=@( - (Join-Path $repositoryRoot 'src\trayhost\NativeMethods.cs'), - (Join-Path $repositoryRoot 'src\trayhost\InputModeGuard.cs'), - (Join-Path $repositoryRoot 'src\trayhost\NativeMenu.cs'), - (Join-Path $repositoryRoot 'src\trayhost\TrayWindow.cs'), - (Join-Path $repositoryRoot 'src\trayhost\TrayHostApplication.cs'), - (Join-Path $repositoryRoot 'src\trayhost\Program.cs') -) -$testPath=if($ProtocolOnly){Join-Path $repositoryRoot 'tests\trayhost\TrayHostProtocolSelfTest.cs'}elseif($NativeOnly){Join-Path $repositoryRoot 'tests\trayhost\TrayHostNativeSelfTest.cs'}elseif($TransportOnly){Join-Path $repositoryRoot 'tests\trayhost\TrayHostTransportSelfTest.cs'}else{Join-Path $repositoryRoot 'tests\trayhost\TrayHostParentClientSelfTest.cs'} +$testPath=if($ProtocolOnly){Join-Path $repositoryRoot 'tests\trayhost\TrayHostProtocolSelfTest.cs'}elseif($NativeOnly){Join-Path $repositoryRoot 'tests\trayhost\TrayHostNativeSelfTest.cs'}elseif($TransportOnly){Join-Path $repositoryRoot 'tests\trayhost\TrayHostTransportSelfTest.cs'}elseif($ProductionTraceOnly){Join-Path $repositoryRoot 'tests\trayhost\TrayHostProductionTraceSelfTest.cs'}else{Join-Path $repositoryRoot 'tests\trayhost\TrayHostParentClientSelfTest.cs'} $requiredFiles=@($protocolPath,$presentationPath,$testPath) if($ProtocolOnly){$requiredFiles+=$protocolSupportFiles} if($NativeOnly){$requiredFiles+=$nativeFiles+$transportFiles} if($TransportOnly){$requiredFiles+=$transportFiles} -if($ParentClientOnly){$requiredFiles+=$transportFiles+$parentClientFiles+$programTraceFiles} +if($ParentClientOnly){$requiredFiles+=$transportFiles+$parentClientFiles} +if($ProductionTraceOnly){$requiredFiles+=(Get-ChildItem -LiteralPath (Join-Path $repositoryRoot 'src\trayhost') -Filter '*.cs' -File | ForEach-Object FullName)} if($ProductionOnly){$requiredFiles+=(Get-ChildItem -LiteralPath (Join-Path $repositoryRoot 'src\trayhost') -Filter '*.cs' -File | ForEach-Object FullName)} if($requiredFiles|Where-Object{ -not(Test-Path -LiteralPath $_ -PathType Leaf)}){throw 'CCOD_TRAYHOST_SOURCE_MISSING'} Import-Module (Join-Path $repositoryRoot 'build\TrayHostReferencePack.psm1') -Force @@ -59,6 +53,23 @@ $compiler=$compilerCandidates|Where-Object{Test-Path -LiteralPath $_ -PathType L if($null -eq $compiler){throw 'CCOD_TRAYHOST_COMPILER_MISSING'} $temporaryRoot=Join-Path $env:TEMP ('ccod-trayhost-protocol-'+[Guid]::NewGuid().ToString('N'));New-Item -ItemType Directory -Path $temporaryRoot -Force|Out-Null try{ + if($ProductionTraceOnly){ + $productionSources=@(Get-ChildItem -LiteralPath (Join-Path $repositoryRoot 'src\trayhost') -Filter '*.cs' -File|Sort-Object Name|ForEach-Object FullName) + $productionPath=Join-Path $temporaryRoot 'CodexRemote.TrayHost.exe' + $currentTracePath=Join-Path $temporaryRoot 'TrayHostProductionTrace.Current.exe' + $staleTracePath=Join-Path $temporaryRoot 'TrayHostProductionTrace.Stale.exe' + $compilerBase=@('/nologo','/noconfig','/nostdlib+','/target:exe','/platform:anycpu','/optimize+','/checked+','/warn:4','/warnaserror+') + foreach($leaf in @('mscorlib.dll','System.dll','System.Core.dll','System.Drawing.dll')){$compilerBase+=('/reference:'+ (Join-Path $reference.ReferenceRoot $leaf))} + & $compiler @compilerBase ('/out:'+ $productionPath) '/main:Program' @productionSources + if($LASTEXITCODE -ne 0){throw 'CCOD_TRAYHOST_PRODUCTION_COMPILE_FAILED'} + & $compiler @compilerBase ('/out:'+ $currentTracePath) '/main:TrayHostProductionTraceMain' @productionSources $testPath + if($LASTEXITCODE -ne 0){throw 'CCOD_TRAYHOST_PRODUCTION_TRACE_COMPILE_FAILED'} + & $compiler @compilerBase '/define:TRAYHOST_TRACE_STALE' ('/out:'+ $staleTracePath) '/main:TrayHostProductionTraceMain' @productionSources $testPath + if($LASTEXITCODE -ne 0){throw 'CCOD_TRAYHOST_PRODUCTION_TRACE_COMPILE_FAILED'} + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $repositoryRoot 'tests\persistence\TrayHostProductionTrace.SelfTest.ps1') -AssemblyPath $currentTracePath -CurrentTracePath $currentTracePath -StaleTracePath $staleTracePath -ProductionExePath $productionPath + if($LASTEXITCODE -ne 0){throw 'CCOD_TRAYHOST_PRODUCTION_TRACE_FAILED'} + return + } $outputName=if($ProtocolOnly){'TrayHostProtocolSelfTest.exe'}elseif($NativeOnly){'TrayHostNativeSelfTest.exe'}elseif($TransportOnly){'TrayHostTransportSelfTest.exe'}elseif($ParentClientOnly){'TrayHostParentClientSelfTest.exe'}else{'CodexRemote.TrayHost.exe'} $mainType=if($ProtocolOnly){'TrayHostProtocolSelfTest'}elseif($NativeOnly){'TrayHostNativeSelfTest'}elseif($TransportOnly){'TrayHostTransportSelfTest'}elseif($ParentClientOnly){'TrayHostParentClientSelfTest'}else{'Program'} $outputPath=Join-Path $temporaryRoot $outputName @@ -73,15 +84,11 @@ try{ if($ProtocolOnly){$args+=$protocolSupportFiles} if($NativeOnly){$args+=$nativeFiles+$transportFiles} if($TransportOnly){$args+=$transportFiles} - if($ParentClientOnly){$args+=$transportFiles+$parentClientFiles+$programTraceFiles} + if($ParentClientOnly){$args+=$transportFiles+$parentClientFiles} } $args+=$testPath & $compiler @args if($LASTEXITCODE -ne 0){if($ProtocolOnly){throw 'CCOD_TRAYHOST_PROTOCOL_COMPILE_FAILED'}elseif($ProductionOnly){throw 'CCOD_TRAYHOST_PRODUCTION_COMPILE_FAILED'}else{throw 'CCOD_TRAYHOST_NATIVE_COMPILE_FAILED'}} if($ProductionOnly){& $outputPath '--headless-smoke'}else{& $outputPath} if($LASTEXITCODE -ne 0){if($ProtocolOnly){throw 'CCOD_TRAYHOST_PROTOCOL_TEST_FAILED'}elseif($ProductionOnly){throw 'CCOD_TRAYHOST_HEADLESS_SMOKE_FAILED'}else{throw 'CCOD_TRAYHOST_NATIVE_TEST_FAILED'}} - if($ParentClientOnly){ - & powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $repositoryRoot 'tests\persistence\TrayHostProductionTrace.SelfTest.ps1') -AssemblyPath $outputPath - if($LASTEXITCODE -ne 0){throw 'CCOD_TRAYHOST_PRODUCTION_TRACE_FAILED'} - } }finally{if(Test-Path -LiteralPath $temporaryRoot){Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue}} diff --git a/tests/trayhost/TrayHostCorrelationTestBridge.cs b/tests/trayhost/TrayHostCorrelationTestBridge.cs index 5add2e8..a7e4c6d 100644 --- a/tests/trayhost/TrayHostCorrelationTestBridge.cs +++ b/tests/trayhost/TrayHostCorrelationTestBridge.cs @@ -1,12 +1,25 @@ using System; using System.Collections.Generic; +using System.Threading; public sealed class TrayHostCorrelationTestBridge : IDisposable { private sealed class Platform : INativeTrayPlatform { + private sealed class Message + { + internal uint Id; + internal IntPtr WParam; + internal IntPtr LParam; + } + + private readonly object _gate = new object(); + private readonly Queue _messages = new Queue(); + private readonly AutoResetEvent _available = new AutoResetEvent(false); + private Action _handler; internal uint Selection; - public void SetMessageHandler(Action handler) { } + internal int MessageBoxCount; + public void SetMessageHandler(Action handler) { _handler = handler; } public IntPtr CreateOwner() { return new IntPtr(10); } public IntPtr AssociateOwnerInputContext(IntPtr owner, IntPtr context) { return new IntPtr(20); } public IntPtr GetOwnerInputContext(IntPtr owner) { return IntPtr.Zero; } @@ -25,22 +38,48 @@ public void SetMessageHandler(Action handler) { } public bool SetForegroundWindow(IntPtr owner) { return true; } public IntPtr GetForegroundWindow() { return new IntPtr(10); } public uint TrackPopupMenuEx(IntPtr menu, uint flags, int x, int y, IntPtr owner, IntPtr parameters) { return Selection; } - public bool PostMessage(IntPtr owner, uint message, UIntPtr wParam, IntPtr lParam) { return true; } + public bool PostMessage(IntPtr owner, uint message, UIntPtr wParam, IntPtr lParam) + { + ulong raw = wParam.ToUInt64(); + IntPtr converted = IntPtr.Size == 4 ? new IntPtr(unchecked((int)raw)) : new IntPtr(unchecked((long)raw)); + lock (_gate) { _messages.Enqueue(new Message { Id = message, WParam = converted, LParam = lParam }); } + _available.Set(); + return true; + } public bool SetNotificationFocus(ref TrayIconData icon) { return true; } - public bool ShowMessageBox(IntPtr owner, string text, string caption) { return true; } + public bool ShowMessageBox(IntPtr owner, string text, string caption) { MessageBoxCount++; return true; } public bool ConfirmExit(IntPtr owner, string text, string caption) { return true; } public bool DestroyMenu(IntPtr menu) { return true; } public bool EndMenu() { return true; } public bool DestroyOwner(IntPtr owner) { return true; } + + internal bool DispatchNext(TimeSpan timeout) + { + Message message = null; + lock (_gate) { if (_messages.Count != 0) { message = _messages.Dequeue(); } } + if (message == null) + { + if (!_available.WaitOne(timeout)) { return false; } + lock (_gate) { if (_messages.Count != 0) { message = _messages.Dequeue(); } } + } + if (message == null || _handler == null) { return false; } + _handler(message.Id, message.WParam, message.LParam); + return true; + } + + internal void Dispose() { _available.Dispose(); } } private readonly Platform _platform; private readonly HostTransport _transport; private readonly TrayWindow _window; + private readonly TrayHostApplication _application; + private readonly TrayTerminalReceiptSink _receiptSink; + private readonly ManualResetEvent _receiptAttempted = new ManualResetEvent(false); + private readonly ManualResetEvent _publicationCompleted = new ManualResetEvent(false); private TrayHostAction _selected; private TrayActionResult _terminal; private bool _persistDiagnostics = true; - private int _feedbackCount; public TrayHostCorrelationTestBridge(ulong revision, TrayCommand command) : this(revision, command, true) { @@ -50,7 +89,8 @@ public TrayHostCorrelationTestBridge(ulong revision, TrayCommand command, bool p { _platform = new Platform { Selection = (uint)command }; _persistDiagnostics = persistDiagnostics; - _transport = new HostTransport(null, delegate(TrayTerminalDiagnostic record) { return _persistDiagnostics; }); + TrayHostApplication application = null; + _transport = new HostTransport(null, delegate(uint token) { return application != null && application.PostReceiptWork(token); }); _window = new TrayWindow(_platform, _transport.SetMenuOpen); _window.CommandSelected += delegate(TrayCommand selected, ulong displayedRevision) { @@ -58,6 +98,20 @@ public TrayHostCorrelationTestBridge(ulong revision, TrayCommand command, bool p if (!_transport.TryRegisterAction(action)) { throw new InvalidOperationException("test action registration failed"); } _selected = action; }; + Action receiptWork = delegate(uint token) + { + TrayTerminalReceiptUiKind kind; + TrayActionResult result; + if (!_transport.TryTakeReceiptUi(token, out kind, out result)) { return; } + _terminal = result; + if (kind == TrayTerminalReceiptUiKind.Failure) { _window.ShowActionFailed(); } + else if (kind == TrayTerminalReceiptUiKind.About) { _window.ShowAbout(); } + }; + application = new TrayHostApplication(_platform, _window, null, delegate { }, receiptWork); + _application = application; + _receiptSink = new TrayTerminalReceiptSink( + delegate(TrayTerminalDiagnostic record) { bool persisted = _persistDiagnostics; _receiptAttempted.Set(); return persisted; }, + delegate(TrayTerminalReceipt receipt) { bool published = _transport.TryPublishDurableReceipt(receipt); _publicationCompleted.Set(); return published; }); _window.Create(Snapshot(revision)); } @@ -75,19 +129,31 @@ public bool Acknowledge(Guid actionId, ulong revision, string status, string err { TrayActionResultStatus parsed = (TrayActionResultStatus)Enum.Parse(typeof(TrayActionResultStatus), status, false); TrayActionResult result = new TrayActionResult(actionId, revision, parsed, errorCode, null); - bool accepted = _transport.TryAcknowledgeAction(result); + _receiptAttempted.Reset(); + _publicationCompleted.Reset(); + TrayTerminalReceipt receipt; + bool accepted = _transport.TryAcknowledgeAction(result, out receipt); if (!accepted) { return false; } _terminal = result; - if (parsed == TrayActionResultStatus.Rejected || parsed == TrayActionResultStatus.Failed) + if (receipt != null && _receiptSink.TrySubmit(receipt)) { - TrayActionResult queued; - if (_transport.TryTakeFailedAction(out queued)) { _terminal = queued; _feedbackCount++; } + if (!_receiptAttempted.WaitOne(TimeSpan.FromSeconds(2))) { throw new InvalidOperationException("test receipt writer did not run"); } + if (_persistDiagnostics) + { + if (!_publicationCompleted.WaitOne(TimeSpan.FromSeconds(2))) { throw new InvalidOperationException("test receipt publication did not complete"); } + if (parsed == TrayActionResultStatus.Rejected || parsed == TrayActionResultStatus.Failed) + { + DateTime deadline = DateTime.UtcNow.AddSeconds(2); + while (_platform.MessageBoxCount == 0 && DateTime.UtcNow < deadline) { _platform.DispatchNext(TimeSpan.FromMilliseconds(25)); } + if (_platform.MessageBoxCount != 1) { throw new InvalidOperationException("test receipt UI was not token-dispatched"); } + } + } } return true; } public void SetDiagnosticPersistence(bool value) { _persistDiagnostics = value; } - public int FeedbackCount { get { return _feedbackCount; } } + public int FeedbackCount { get { return _platform.MessageBoxCount; } } public string TerminalStatus { get { return _terminal == null ? String.Empty : _terminal.Status.ToString(); } } public string TerminalCode { get { return _terminal == null ? String.Empty : (_terminal.ErrorCode ?? String.Empty); } } @@ -103,7 +169,11 @@ private static PresentationSnapshot Snapshot(ulong revision) public void Dispose() { - _window.Dispose(); + _receiptSink.Dispose(); _transport.Dispose(); + _application.Dispose(); + _receiptAttempted.Dispose(); + _publicationCompleted.Dispose(); + _platform.Dispose(); } } diff --git a/tests/trayhost/TrayHostParentClientSelfTest.cs b/tests/trayhost/TrayHostParentClientSelfTest.cs index 5e02eec..d455f73 100644 --- a/tests/trayhost/TrayHostParentClientSelfTest.cs +++ b/tests/trayhost/TrayHostParentClientSelfTest.cs @@ -5,37 +5,6 @@ using System.Security.Cryptography; using System.Threading; -internal sealed class ProductionTraceTrayPlatform : INativeTrayPlatform -{ - internal string Message; - public void SetMessageHandler(Action handler) { } - public IntPtr CreateOwner() { return new IntPtr(10); } - public IntPtr AssociateOwnerInputContext(IntPtr owner, IntPtr context) { return new IntPtr(20); } - public IntPtr GetOwnerInputContext(IntPtr owner) { return IntPtr.Zero; } - public bool ReleaseInputContext(IntPtr owner, IntPtr context) { return true; } - public IntPtr LoadIcon() { return new IntPtr(40); } - public bool DestroyIcon(IntPtr icon) { return true; } - public bool AddIcon(ref TrayIconData icon) { return true; } - public bool SetIconVersion(ref TrayIconData icon) { return true; } - public bool DeleteIcon(ref TrayIconData icon) { return true; } - public IntPtr CreatePopupMenu() { return new IntPtr(30); } - public IntPtr CreateSubMenu() { return new IntPtr(31); } - public bool AppendMenu(IntPtr menu, uint flags, UIntPtr command, string text) { return true; } - public bool AppendSubMenu(IntPtr menu, IntPtr child, string text) { return true; } - public bool ShowOwner(IntPtr owner) { return true; } - public bool HideOwner(IntPtr owner) { return true; } - public bool SetForegroundWindow(IntPtr owner) { return true; } - public IntPtr GetForegroundWindow() { return new IntPtr(10); } - public uint TrackPopupMenuEx(IntPtr menu, uint flags, int x, int y, IntPtr owner, IntPtr parameters) { return 0U; } - public bool PostMessage(IntPtr owner, uint message, UIntPtr wParam, IntPtr lParam) { return true; } - public bool SetNotificationFocus(ref TrayIconData icon) { return true; } - public bool ShowMessageBox(IntPtr owner, string text, string caption) { Message = caption + "|" + text; return true; } - public bool ConfirmExit(IntPtr owner, string text, string caption) { return true; } - public bool DestroyMenu(IntPtr menu) { return true; } - public bool EndMenu() { return true; } - public bool DestroyOwner(IntPtr owner) { return true; } -} - internal static class TrayHostParentClientSelfTest { internal static string PeerArguments = "--peer"; @@ -82,32 +51,13 @@ private static void RunPeer(bool emitFaultAndIgnoreShutdown, bool emitAction, bo { Guid actionId = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); TrayHostAction action = new TrayHostAction(actionId, TrayCommand.OpenLogs, staleAction ? 8UL : 1UL); - ManualResetEvent receiptPersisted = new ManualResetEvent(false); - HostTransport host = new HostTransport(); - TrayTerminalReceiptSink receiptSink = new TrayTerminalReceiptSink(delegate(TrayTerminalDiagnostic record) { receiptPersisted.Set(); return true; }, host.TryPublishDurableReceipt); - ProductionTraceTrayPlatform tracePlatform = new ProductionTraceTrayPlatform(); - TrayWindow traceWindow = new TrayWindow(tracePlatform); traceWindow.Create(Snapshot(1UL)); - AssertTrue(host.TryRegisterAction(action), "program peer registers the exact outbound action"); ProtocolCodec.WriteAuthenticated(output, ProtocolFrame.Authenticated(ProtocolDirection.HostToParent, TrayHostMessageType.Action, epoch, outboundSequence++, TrayHostWire.WriteAction(action)), keys.HostToParent); ProtocolFrame terminalFrame = ProtocolCodec.ReadAuthenticated(input, ProtocolDirection.ParentToHost, epoch, inboundSequence++, keys.ParentToHost); - AssertTrue(terminalFrame.MessageType == TrayHostMessageType.ActionResult, "program peer receives one authenticated terminal result"); - AssertTrue(Program.TryDispatchAuthenticatedActionResult(host, receiptSink, terminalFrame.Payload), "production Program dispatch accepts the correlated authenticated result without waiting for receipt storage"); - TrayActionResult feedback; - if (staleAction) - { - Stopwatch feedbackWait = Stopwatch.StartNew(); - while (!host.TryTakeFailedAction(out feedback) && feedbackWait.ElapsedMilliseconds < 3000L) { Thread.Sleep(5); } - AssertTrue(feedback != null && feedback.Revision == 8UL && feedback.Status == TrayActionResultStatus.Rejected && String.Equals(feedback.ErrorCode, "CCOD_TRAY_ACTION_STALE", StringComparison.Ordinal), "stale authenticated result reaches the production generic-feedback queue only after its durable callback"); - traceWindow.ShowActionFailed(); - AssertTrue(String.Equals(tracePlatform.Message, "string-0|string-15", StringComparison.Ordinal), "stale production trace collapses internal details to the generic acknowledged-snapshot dialog"); - } - else - { - AssertTrue(receiptPersisted.WaitOne(3000), "completed current action reaches durable receipt storage asynchronously"); - AssertTrue(!host.TryTakeFailedAction(out feedback) && tracePlatform.Message == null, "completed current action produces no generic failure feedback"); - } - receiptSink.Dispose(); traceWindow.Dispose(); host.Dispose(); receiptPersisted.Dispose(); - return; + AssertTrue(terminalFrame.MessageType == TrayHostMessageType.ActionResult, "synthetic peer receives one authenticated terminal result"); + TrayActionResult terminal = TrayHostWire.ReadActionResult(terminalFrame.Payload); + AssertTrue(terminal.ActionId == action.ActionId && terminal.Revision == action.Revision, "parent writer preserves the exact correlated action identity"); + if (staleAction) { AssertTrue(terminal.Status == TrayActionResultStatus.Rejected && String.Equals(terminal.ErrorCode, "CCOD_TRAY_ACTION_STALE", StringComparison.Ordinal), "parent writer preserves the stale terminal result"); } + else { AssertTrue(terminal.Status == TrayActionResultStatus.Completed && terminal.ErrorCode == null, "parent writer preserves the current completion result"); } } if (emitFaultAndIgnoreShutdown) { @@ -279,28 +229,3 @@ public static int Main(string[] args) } } } - -public static class TrayHostProductionTraceFixture -{ - public static TrayHostParentClient Start(string executablePath, bool stale) - { - if (String.IsNullOrEmpty(executablePath)) { throw new ArgumentException("trace executable is required", "executablePath"); } - Process current = Process.GetCurrentProcess(); - TrayHostParentClient.TestProcessFactory = TrayHostParentClientSelfTest.StartPeer; - TrayHostParentClientSelfTest.PeerArguments = stale ? "--stale-action-peer" : "--action-peer"; - return TrayHostParentClient.Start(new TrayHostStartOptions { - ExePath = executablePath, - RuntimeId = "trace-runtime", - ParentPid = current.Id, - ParentCreationFileTimeUtc = current.StartTime.ToFileTimeUtc(), - InitialPresentation = CreateSnapshot() - }); - } - - private static PresentationSnapshot CreateSnapshot() - { - string[] strings = new string[16]; - for (int index = 0; index < strings.Length; index++) { strings[index] = "trace-" + index.ToString(); } - return new PresentationSnapshot(1UL, TrayColor.Green, ConnectionState.Connected, ProtectionState.Running, LanguageMode.English, PresentationFlags.OpenLogsEnabled, strings); - } -} diff --git a/tests/trayhost/TrayHostProductionTraceSelfTest.cs b/tests/trayhost/TrayHostProductionTraceSelfTest.cs new file mode 100644 index 0000000..869102a --- /dev/null +++ b/tests/trayhost/TrayHostProductionTraceSelfTest.cs @@ -0,0 +1,298 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Text; +using System.Threading; + +public static class TrayHostProductionTraceFixture +{ + public static TrayHostParentClient Start(string executablePath) + { + if (String.IsNullOrEmpty(executablePath) || !Path.IsPathRooted(executablePath) || !File.Exists(executablePath)) { throw new ArgumentException("trace executable is required", "executablePath"); } + string witnessPath = GetWitnessPath(executablePath); + if (File.Exists(witnessPath)) { File.Delete(witnessPath); } + using (Process current = Process.GetCurrentProcess()) + { + return TrayHostParentClient.Start(new TrayHostStartOptions { + ExePath = executablePath, + RuntimeId = "trace-runtime", + ParentPid = current.Id, + ParentCreationFileTimeUtc = current.StartTime.ToFileTimeUtc(), + InitialPresentation = CreateSnapshot() + }); + } + } + + public static string GetWitnessPath(string executablePath) + { + return Path.GetFullPath(executablePath) + ".witness.log"; + } + + private static PresentationSnapshot CreateSnapshot() + { + string[] strings = new string[16]; + for (int index = 0; index < strings.Length; index++) { strings[index] = "trace-" + index.ToString(); } + return new PresentationSnapshot(1UL, TrayColor.Green, ConnectionState.Connected, ProtectionState.Running, LanguageMode.English, PresentationFlags.OpenLogsEnabled, strings); + } +} + +internal static class TrayHostProductionTraceMain +{ + public static int Main(string[] args) + { + string witnessPath = TrayHostProductionTraceFixture.GetWitnessPath(Process.GetCurrentProcess().MainModule.FileName); + TraceWitness witness = new TraceWitness(witnessPath); + try + { + TrayHostChildIdentity identity; + if (!TrayHostChildSession.TryCreateIdentity(args, out identity)) { return 2; } +#if TRAYHOST_TRACE_STALE + bool stale = true; +#else + bool stale = false; +#endif + using (ScriptedTraceRuntime runtime = new ScriptedTraceRuntime(witness, stale)) + { + return TrayHostChildSession.Run( + Console.OpenStandardInput(), + Console.OpenStandardOutput(), + identity, + runtime, + delegate(HostTransport transport) + { + return new TrayTerminalReceiptSink( + witness.TryWriteDurably, + delegate(TrayTerminalReceipt receipt) + { + bool published = transport.TryPublishDurableReceipt(receipt); + witness.Append("publication accepted=" + published.ToString().ToLowerInvariant()); + runtime.ReleaseReceiptDispatch(); + return published; + }); + }); + } + } + catch (Exception error) + { + witness.Append("fatal type=" + error.GetType().Name); + return 1; + } + } +} + +internal sealed class ScriptedTraceRuntime : ITrayHostRuntime +{ + private readonly TraceWitness _witness; + private readonly bool _stale; + private readonly ScriptedTracePlatform _platform; + private TrayWindow _window; + private TrayHostApplication _application; + private Action _commandSelected; + private bool _exitRequested; + private bool _started; + private bool _disposed; + + internal ScriptedTraceRuntime(TraceWitness witness, bool stale) + { + if (witness == null) { throw new ArgumentNullException("witness"); } + _witness = witness; + _stale = stale; + _platform = new ScriptedTracePlatform(witness); + } + + public ulong CurrentRevision { get { return _window == null ? 0UL : _window.CurrentRevision; } } + + public void Initialize(Action menuOpenChanged, Action commandSelected, Action work, Action receiptWork) + { + if (_window != null || commandSelected == null || work == null || receiptWork == null) { throw new InvalidOperationException("trace runtime initialization is invalid"); } + _commandSelected = commandSelected; + _window = new TrayWindow(_platform, menuOpenChanged); + _window.CommandSelected += commandSelected; + _application = new TrayHostApplication(_platform, _window, commandSelected, work, receiptWork); + } + + public void Create(PresentationSnapshot initial) { _window.Create(initial); } + public bool Apply(PresentationSnapshot snapshot) { return _window.Apply(snapshot); } + public void PostWork() { _application.PostWork(); } + public bool PostReceiptWork(uint token) { return _application.PostReceiptWork(token); } + public void ShowAbout() { _window.ShowAbout(); } + public void ShowActionFailed() { _window.ShowActionFailed(); } + public void RequestShutdown() { _window.RequestShutdown(); } + + internal void ReleaseReceiptDispatch() { _platform.ReleaseReceiptDispatch(); } + + public void RequestExit() + { + _exitRequested = true; + _platform.Signal(); + } + + public int Run() + { + if (_started || _window == null) { throw new InvalidOperationException("trace runtime lifecycle is invalid"); } + _started = true; + if (_stale) + { + _witness.Append("fixture command=OpenLogs revision=8"); + _commandSelected(TrayCommand.OpenLogs, 8UL); + } + else + { + uint? selected = _window.HandleContextMenu(new TrayPoint(4, 5)); + if (!selected.HasValue || selected.Value != (uint)TrayCommand.OpenLogs) { throw new InvalidOperationException("native OpenLogs selection failed"); } + _witness.Append("native command=OpenLogs revision=" + CurrentRevision.ToString()); + } + while (!_exitRequested) { _platform.TryDispatch(TimeSpan.FromMilliseconds(250)); } + return 0; + } + + public void Dispose() + { + if (_disposed) { return; } + _disposed = true; + if (_application != null) { _application.Dispose(); } + _platform.Dispose(); + } +} + +internal sealed class ScriptedTracePlatform : INativeTrayPlatform, IDisposable +{ + private sealed class QueuedMessage + { + internal uint Message; + internal IntPtr WParam; + internal IntPtr LParam; + } + + private readonly object _gate = new object(); + private readonly Queue _messages = new Queue(); + private readonly AutoResetEvent _available = new AutoResetEvent(false); + private readonly TraceWitness _witness; + private Action _handler; + private bool _receiptDispatchReleased; + private bool _disposed; + + internal ScriptedTracePlatform(TraceWitness witness) { _witness = witness; } + + public void SetMessageHandler(Action handler) { _handler = handler; } + public IntPtr CreateOwner() { return new IntPtr(10); } + public IntPtr AssociateOwnerInputContext(IntPtr owner, IntPtr context) { return new IntPtr(20); } + public IntPtr GetOwnerInputContext(IntPtr owner) { return IntPtr.Zero; } + public bool ReleaseInputContext(IntPtr owner, IntPtr context) { return true; } + public IntPtr LoadIcon() { return new IntPtr(40); } + public bool DestroyIcon(IntPtr icon) { return true; } + public bool AddIcon(ref TrayIconData icon) { return true; } + public bool SetIconVersion(ref TrayIconData icon) { return true; } + public bool DeleteIcon(ref TrayIconData icon) { return true; } + public IntPtr CreatePopupMenu() { return new IntPtr(30); } + public IntPtr CreateSubMenu() { return new IntPtr(31); } + public bool AppendMenu(IntPtr menu, uint flags, UIntPtr command, string text) { return true; } + public bool AppendSubMenu(IntPtr menu, IntPtr child, string text) { return true; } + public bool ShowOwner(IntPtr owner) { return true; } + public bool HideOwner(IntPtr owner) { return true; } + public bool SetForegroundWindow(IntPtr owner) { return true; } + public IntPtr GetForegroundWindow() { return new IntPtr(10); } + + public uint TrackPopupMenuEx(IntPtr menu, uint flags, int x, int y, IntPtr owner, IntPtr parameters) + { + _witness.Append("native-menu command=OpenLogs"); + return (uint)TrayCommand.OpenLogs; + } + + public bool PostMessage(IntPtr owner, uint message, UIntPtr wParam, IntPtr lParam) + { + ulong raw = wParam.ToUInt64(); + IntPtr converted = IntPtr.Size == 4 ? new IntPtr(unchecked((int)raw)) : new IntPtr(unchecked((long)raw)); + lock (_gate) + { + if (_disposed) { return false; } + _messages.Enqueue(new QueuedMessage { Message = message, WParam = converted, LParam = lParam }); + } + _witness.Append("post message=" + message.ToString() + " token=" + raw.ToString()); + _available.Set(); + return true; + } + + public bool SetNotificationFocus(ref TrayIconData icon) { return true; } + + public bool ShowMessageBox(IntPtr owner, string text, string caption) + { + _witness.Append("dialog caption=" + caption + " text=" + text); + return true; + } + + public bool ConfirmExit(IntPtr owner, string text, string caption) { return true; } + public bool DestroyMenu(IntPtr menu) { return true; } + public bool EndMenu() { return true; } + public bool DestroyOwner(IntPtr owner) { return true; } + + internal bool TryDispatch(TimeSpan timeout) + { + QueuedMessage queued = null; + lock (_gate) + { + if (_messages.Count != 0 && (_messages.Peek().Message != TrayNativeConstants.WmApp + 3U || _receiptDispatchReleased)) { queued = _messages.Dequeue(); } + } + if (queued == null) + { + try { if (!_available.WaitOne(timeout)) { return false; } } + catch (ObjectDisposedException) { return false; } + lock (_gate) + { + if (_messages.Count != 0 && (_messages.Peek().Message != TrayNativeConstants.WmApp + 3U || _receiptDispatchReleased)) { queued = _messages.Dequeue(); } + } + } + if (queued == null) { return false; } + Action handler = _handler; + if (handler == null) { throw new InvalidOperationException("trace native handler is unavailable"); } + handler(queued.Message, queued.WParam, queued.LParam); + return true; + } + + internal void Signal() { try { _available.Set(); } catch (ObjectDisposedException) { } } + + internal void ReleaseReceiptDispatch() + { + lock (_gate) { if (!_disposed) { _receiptDispatchReleased = true; } } + Signal(); + } + + public void Dispose() + { + lock (_gate) { if (_disposed) { return; } _disposed = true; _messages.Clear(); } + _available.Dispose(); + } +} + +internal sealed class TraceWitness +{ + private readonly object _gate = new object(); + private readonly string _path; + + internal TraceWitness(string path) + { + if (String.IsNullOrEmpty(path) || !Path.IsPathRooted(path)) { throw new ArgumentException("trace witness path is invalid", "path"); } + _path = path; + } + + internal bool TryWriteDurably(TrayTerminalDiagnostic record) + { + if (record == null) { return false; } + Append("receipt command=" + record.Command.ToString() + " revision=" + record.Revision.ToString() + " status=" + record.Status.ToString() + " code=" + record.Code); + return true; + } + + internal void Append(string value) + { + byte[] bytes = new UTF8Encoding(false).GetBytes(value + "\r\n"); + lock (_gate) + { + using (FileStream stream = new FileStream(_path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite)) + { + stream.Write(bytes, 0, bytes.Length); + stream.Flush(true); + } + } + } +} From 939f2fac6df639d998d10fb94dd815bb8801a8d7 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:06:12 +0800 Subject: [PATCH 039/187] docs: record authenticated tray receipt trace --- .../task-3-report.md | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 .superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-3-report.md 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..e729421 --- /dev/null +++ b/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-3-report.md @@ -0,0 +1,159 @@ +# 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. From efd2f902a03c20140e3dfd9960da3fdbf6970502 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:26:25 +0800 Subject: [PATCH 040/187] fix: require tray child natural exit after ack --- .../task-3-report.md | 68 +++++++++++++++++++ src/trayhost/TrayHostParentClient.cs | 15 +++- .../TrayHostProductionTrace.SelfTest.ps1 | 44 ++++++++++-- tests/trayhost/Invoke-TrayHostSelfTest.ps1 | 5 +- .../TrayHostProductionTraceSelfTest.cs | 18 ++++- 5 files changed, 140 insertions(+), 10 deletions(-) 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 index e729421..af3ef87 100644 --- 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 @@ -157,3 +157,71 @@ remains green. 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/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/tests/persistence/TrayHostProductionTrace.SelfTest.ps1 b/tests/persistence/TrayHostProductionTrace.SelfTest.ps1 index 31db00a..4173b02 100644 --- a/tests/persistence/TrayHostProductionTrace.SelfTest.ps1 +++ b/tests/persistence/TrayHostProductionTrace.SelfTest.ps1 @@ -2,16 +2,17 @@ param( [string]$AssemblyPath, [string]$CurrentTracePath, [string]$StaleTracePath, + [string]$HungTracePath, [string]$ProductionExePath ) $ErrorActionPreference='Stop' $repositoryRoot=Split-Path (Split-Path $PSScriptRoot -Parent) -Parent -if([string]::IsNullOrEmpty($AssemblyPath)-and[string]::IsNullOrEmpty($CurrentTracePath)-and[string]::IsNullOrEmpty($StaleTracePath)-and[string]::IsNullOrEmpty($ProductionExePath)){ +if([string]::IsNullOrEmpty($AssemblyPath)-and[string]::IsNullOrEmpty($CurrentTracePath)-and[string]::IsNullOrEmpty($StaleTracePath)-and[string]::IsNullOrEmpty($HungTracePath)-and[string]::IsNullOrEmpty($ProductionExePath)){ & powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $repositoryRoot 'tests\trayhost\Invoke-TrayHostSelfTest.ps1') -ProductionTraceOnly exit $LASTEXITCODE } -foreach($path in @($AssemblyPath,$CurrentTracePath,$StaleTracePath,$ProductionExePath)){if([string]::IsNullOrEmpty($path)-or-not(Test-Path -LiteralPath $path -PathType Leaf)){throw 'CCOD_TRAYHOST_PRODUCTION_TRACE_INPUT_INVALID'}} +foreach($path in @($AssemblyPath,$CurrentTracePath,$StaleTracePath,$HungTracePath,$ProductionExePath)){if([string]::IsNullOrEmpty($path)-or-not(Test-Path -LiteralPath $path -PathType Leaf)){throw 'CCOD_TRAYHOST_PRODUCTION_TRACE_INPUT_INVALID'}} . (Join-Path $PSScriptRoot 'TestSupport.ps1') [Reflection.Assembly]::LoadFrom([IO.Path]::GetFullPath($AssemblyPath))|Out-Null @@ -59,11 +60,12 @@ function Wait-CcodProductionTraceWitness { function Invoke-CcodProductionTraceCase { param([string]$TracePath,[bool]$Stale) $client=[TrayHostProductionTraceFixture]::Start($TracePath) + $childProcess=$null try{ $parentProcess=[Diagnostics.Process]::GetCurrentProcess() try{Assert-CcodTrue ($client.Receipt.HostPid-gt0-and$client.Receipt.HostPid-ne$parentProcess.Id) 'normal parent start launches a distinct child process'}finally{$parentProcess.Dispose()} $childProcess=[Diagnostics.Process]::GetProcessById($client.Receipt.HostPid) - try{Assert-CcodTrue ([string]::Equals([IO.Path]::GetFullPath($TracePath),[IO.Path]::GetFullPath($childProcess.MainModule.FileName),[StringComparison]::OrdinalIgnoreCase)) 'normal parent start launches the requested temporary trace executable'}finally{$childProcess.Dispose()} + Assert-CcodTrue ([string]::Equals([IO.Path]::GetFullPath($TracePath),[IO.Path]::GetFullPath($childProcess.MainModule.FileName),[StringComparison]::OrdinalIgnoreCase)) 'normal parent start launches the requested temporary trace executable' Assert-CcodEqual 'trace-runtime' $client.Receipt.RuntimeId 'real child handshake preserves the opaque runtime identity' $fixture=New-CcodProductionTraceContext $client;$context=$fixture.Context $hostState=New-CcodProductionTraceHostState $context $fixture.Enabled @@ -121,16 +123,46 @@ function Invoke-CcodProductionTraceCase { Assert-CcodEqual 0 $context.CommandQueue.Count 'production trace emits no duplicate action' Assert-CcodTrue ([string]::IsNullOrEmpty($context.LastError)) 'production trace emits no remote fault' Assert-CcodTrue $client.BeginShutdown([ShutdownReason]::SupervisorExit,[UInt64]1) 'parent requests normal authenticated shutdown' - Assert-CcodTrue $client.WaitForStopped([TimeSpan]::FromSeconds(4)) 'child session returns an authenticated shutdown acknowledgement' + Assert-CcodTrue $client.WaitForStopped([TimeSpan]::FromSeconds(4)) 'parent observes graceful child termination' + Assert-CcodTrue $childProcess.WaitForExit(4000) 'graceful shutdown waits for the actual child process to exit naturally' + Assert-CcodEqual 0 ([int]$childProcess.ExitCode) 'graceful child process exits naturally with code zero before Dispose' Receive-CcodTrayHostEvents -Context $context Assert-CcodEqual $true $context.Exited 'TrayHostClient observes the normal child exit' Assert-CcodTrue ([string]::IsNullOrEmpty($context.LastError)) 'normal child shutdown has no fault' Assert-CcodEqual 0 $context.CommandQueue.Count 'normal child shutdown adds no extra action' - }finally{$client.Dispose()} + }finally{$client.Dispose();if($null-ne$childProcess){$childProcess.Dispose()}} +} + +function Invoke-CcodHungAfterAckTraceCase { + param([string]$TracePath) + $client=[TrayHostProductionTraceFixture]::Start($TracePath) + $childProcess=$null + try{ + $childProcess=[Diagnostics.Process]::GetProcessById($client.Receipt.HostPid) + $fixture=New-CcodProductionTraceContext $client;$context=$fixture.Context + $deadline=[DateTime]::UtcNow.AddSeconds(4) + while($context.CommandQueue.Count-eq0-and[DateTime]::UtcNow-lt$deadline){[void]$client.WaitForActivity([TimeSpan]::FromMilliseconds(25));Receive-CcodTrayHostEvents -Context $context} + Assert-CcodEqual 1 $context.CommandQueue.Count 'hung-after-ACK fixture reaches the authenticated action loop' + $action=$context.CommandQueue.Dequeue() + Assert-CcodTrue (Send-CcodTrayHostActionResult -Context $context -ActionId $action.ActionId -Revision $action.Revision -Status Completed -ErrorCode $null -TransactionId $null) 'hung-after-ACK fixture consumes its current action normally' + $witnessPath=[TrayHostProductionTraceFixture]::GetWitnessPath($TracePath) + [void](Wait-CcodProductionTraceWitness $witnessPath $context {param($Items)$Items-ccontains'publication accepted=true'}) + Assert-CcodTrue $client.BeginShutdown([ShutdownReason]::SupervisorExit,[UInt64]1) 'hung-after-ACK fixture accepts authenticated shutdown' + [void](Wait-CcodProductionTraceWitness $witnessPath $context {param($Items)$Items-ccontains'shutdown-ack-written-exit-blocked'}) + Assert-CcodEqual $false $client.WaitForStopped([TimeSpan]::FromMilliseconds(250)) 'ShutdownAck alone cannot satisfy WaitForStopped while the child is alive' + Receive-CcodTrayHostEvents -Context $context + Assert-CcodEqual $false $context.Exited 'ShutdownAck alone cannot publish an Exited event' + Assert-CcodEqual $false $childProcess.HasExited 'hung-after-ACK child remains observable and alive before Dispose cleanup' + Assert-CcodTrue $client.WaitForStopped([TimeSpan]::FromSeconds(3)) 'hung-after-ACK child reaches a bounded transport failure' + Receive-CcodTrayHostEvents -Context $context + Assert-CcodEqual 'CCOD_TRAYHOST_TRANSPORT_FAILED' $context.LastError 'hung-after-ACK child reports a fault instead of a normal exit' + Assert-CcodEqual $false $childProcess.HasExited 'transport failure does not masquerade a forced kill as natural child exit' + }finally{$client.Dispose();if($null-ne$childProcess){$childProcess.Dispose()}} } & $ProductionExePath '--production-trace-test-selector' Assert-CcodEqual 2 $LASTEXITCODE 'shipped Program.Main rejects the temporary trace selector' Invoke-CcodProductionTraceCase $CurrentTracePath $false Invoke-CcodProductionTraceCase $StaleTracePath $true -Write-Host 'TrayHost production child-session trace passed: 2' +Invoke-CcodHungAfterAckTraceCase $HungTracePath +Write-Host 'TrayHost production child-session trace passed: 3' diff --git a/tests/trayhost/Invoke-TrayHostSelfTest.ps1 b/tests/trayhost/Invoke-TrayHostSelfTest.ps1 index 661f0d1..1d3ed9c 100644 --- a/tests/trayhost/Invoke-TrayHostSelfTest.ps1 +++ b/tests/trayhost/Invoke-TrayHostSelfTest.ps1 @@ -58,6 +58,7 @@ try{ $productionPath=Join-Path $temporaryRoot 'CodexRemote.TrayHost.exe' $currentTracePath=Join-Path $temporaryRoot 'TrayHostProductionTrace.Current.exe' $staleTracePath=Join-Path $temporaryRoot 'TrayHostProductionTrace.Stale.exe' + $hungTracePath=Join-Path $temporaryRoot 'TrayHostProductionTrace.HungAfterAck.exe' $compilerBase=@('/nologo','/noconfig','/nostdlib+','/target:exe','/platform:anycpu','/optimize+','/checked+','/warn:4','/warnaserror+') foreach($leaf in @('mscorlib.dll','System.dll','System.Core.dll','System.Drawing.dll')){$compilerBase+=('/reference:'+ (Join-Path $reference.ReferenceRoot $leaf))} & $compiler @compilerBase ('/out:'+ $productionPath) '/main:Program' @productionSources @@ -66,7 +67,9 @@ try{ if($LASTEXITCODE -ne 0){throw 'CCOD_TRAYHOST_PRODUCTION_TRACE_COMPILE_FAILED'} & $compiler @compilerBase '/define:TRAYHOST_TRACE_STALE' ('/out:'+ $staleTracePath) '/main:TrayHostProductionTraceMain' @productionSources $testPath if($LASTEXITCODE -ne 0){throw 'CCOD_TRAYHOST_PRODUCTION_TRACE_COMPILE_FAILED'} - & powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $repositoryRoot 'tests\persistence\TrayHostProductionTrace.SelfTest.ps1') -AssemblyPath $currentTracePath -CurrentTracePath $currentTracePath -StaleTracePath $staleTracePath -ProductionExePath $productionPath + & $compiler @compilerBase '/define:TRAYHOST_TRACE_HANG_AFTER_ACK' ('/out:'+ $hungTracePath) '/main:TrayHostProductionTraceMain' @productionSources $testPath + if($LASTEXITCODE -ne 0){throw 'CCOD_TRAYHOST_PRODUCTION_TRACE_COMPILE_FAILED'} + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $repositoryRoot 'tests\persistence\TrayHostProductionTrace.SelfTest.ps1') -AssemblyPath $currentTracePath -CurrentTracePath $currentTracePath -StaleTracePath $staleTracePath -HungTracePath $hungTracePath -ProductionExePath $productionPath if($LASTEXITCODE -ne 0){throw 'CCOD_TRAYHOST_PRODUCTION_TRACE_FAILED'} return } diff --git a/tests/trayhost/TrayHostProductionTraceSelfTest.cs b/tests/trayhost/TrayHostProductionTraceSelfTest.cs index 869102a..49cffd7 100644 --- a/tests/trayhost/TrayHostProductionTraceSelfTest.cs +++ b/tests/trayhost/TrayHostProductionTraceSelfTest.cs @@ -52,7 +52,12 @@ public static int Main(string[] args) #else bool stale = false; #endif - using (ScriptedTraceRuntime runtime = new ScriptedTraceRuntime(witness, stale)) +#if TRAYHOST_TRACE_HANG_AFTER_ACK + bool hangAfterShutdownAck = true; +#else + bool hangAfterShutdownAck = false; +#endif + using (ScriptedTraceRuntime runtime = new ScriptedTraceRuntime(witness, stale, hangAfterShutdownAck)) { return TrayHostChildSession.Run( Console.OpenStandardInput(), @@ -85,6 +90,7 @@ internal sealed class ScriptedTraceRuntime : ITrayHostRuntime { private readonly TraceWitness _witness; private readonly bool _stale; + private readonly bool _hangAfterShutdownAck; private readonly ScriptedTracePlatform _platform; private TrayWindow _window; private TrayHostApplication _application; @@ -92,12 +98,14 @@ internal sealed class ScriptedTraceRuntime : ITrayHostRuntime private bool _exitRequested; private bool _started; private bool _disposed; + private bool _blockedExitRecorded; - internal ScriptedTraceRuntime(TraceWitness witness, bool stale) + internal ScriptedTraceRuntime(TraceWitness witness, bool stale, bool hangAfterShutdownAck) { if (witness == null) { throw new ArgumentNullException("witness"); } _witness = witness; _stale = stale; + _hangAfterShutdownAck = hangAfterShutdownAck; _platform = new ScriptedTracePlatform(witness); } @@ -124,6 +132,12 @@ public void Initialize(Action menuOpenChanged, Action public void RequestExit() { + if (_hangAfterShutdownAck) + { + if (!_blockedExitRecorded) { _blockedExitRecorded = true; _witness.Append("shutdown-ack-written-exit-blocked"); } + _platform.Signal(); + return; + } _exitRequested = true; _platform.Signal(); } From 1743de6b40744c0b549d7eb6a3db962afafd36d1 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:48:57 +0800 Subject: [PATCH 041/187] test: gate release on tray receipt trace --- .github/workflows/ci.yml | 4 + .github/workflows/release.yml | 4 + build/TrayHostBuild.psm1 | 17 +- .../persistence/ReleaseWorkflow.SelfTest.ps1 | 310 ++++++++++++++++++ tests/persistence/TrayHostBuild.SelfTest.ps1 | 23 ++ 5 files changed, 357 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46fdb75..27ef0a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,3 +49,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..16dfd70 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -65,6 +65,10 @@ 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: diff --git a/build/TrayHostBuild.psm1 b/build/TrayHostBuild.psm1 index cbfd380..7609bfd 100644 --- a/build/TrayHostBuild.psm1 +++ b/build/TrayHostBuild.psm1 @@ -91,7 +91,22 @@ function Test-CcodTrayHostArtifact { 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'} } + $sourceRoot=Join-Path $repo 'src\trayhost' + $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)} } diff --git a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 index 9c8116a..11c7998 100644 --- a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 +++ b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 @@ -4,6 +4,149 @@ $ErrorActionPreference = 'Stop' $repositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent $defenderPath = Join-Path $repositoryRoot 'tools\Test-ReleaseDefender.ps1' +function ConvertFrom-CcodWorkflowScalar { + param([Parameter(Mandatory)][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 = '' } + $currentJob.Steps.Add($currentStep) + $key = [string]$Matches.key + $value = [string]$Matches.value + if ($key -in @('name', 'shell', 'run')) { + if ($key -ceq 'run' -and $value.Trim() -in @('|', '|-', '|+')) { + $runBlock = $true + $runLines.Clear() + } else { + $currentStep.$key = ConvertFrom-CcodWorkflowScalar $value + } + } + continue + } + if ($null -eq $currentStep) { continue } + if ($line -cmatch '^ (?shell|run):\s*(?.*)$') { + $key = $Matches.key + $value = $Matches.value.Trim() + if ($key -ceq 'run' -and $value -in @('|', '|-', '|+')) { + $runBlock = $true + $runLines.Clear() + } else { + $currentStep.$key = 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 + ) + $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) { + 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' } + } + } +} + function New-CcodReleaseFixture { $root = Join-Path $env:TEMP ('ccod-release-workflow-' + [guid]::NewGuid().ToString('N')) $null = [IO.Directory]::CreateDirectory($root) @@ -1333,6 +1476,173 @@ Invoke-CcodTest 'Defender gate requires Internet Zone before a custom scan and w } } +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 { + [IO.Directory]::CreateDirectory($artifact) | Out-Null + $executable = Join-Path $artifact 'CodexRemote.TrayHost.exe' + $config = Join-Path $artifact 'CodexRemote.TrayHost.exe.config' + $provenancePath = Join-Path $artifact 'trayhost-build-provenance.json' + [IO.File]::WriteAllBytes($executable, [byte[]](7, 5, 2, 2, 1)) + [IO.File]::WriteAllText($config, '', [Text.UTF8Encoding]::new($false)) + $sourceRoot = Join-Path $repositoryRoot 'src\trayhost' + $sourceRecords = @(Get-ChildItem -LiteralPath $sourceRoot -Filter '*.cs' -File | Sort-Object Name | ForEach-Object { + [ordered]@{ name = $_.Name; sha256 = Get-CcodTestFileSha256 -Path $_.FullName } + }) + 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' + $commit = 'c' * 40 + $baseline = [ordered]@{ + schemaVersion = 1 + product = 'CodexRemote-fix' + version = '2.5.21' + gitCommit = $commit + buildTimestampUtc = '2026-08-28T00:00:00.0000000Z' + targetFramework = 'net48' + sourceFiles = $sourceRecords + iconSha256 = Get-CcodTestFileSha256 -Path (Join-Path $repositoryRoot 'assets\codexremote-fix\codexremote-fix.ico') + artifactSha256 = Get-CcodTestFileSha256 -Path $executable + configArtifactSha256 = Get-CcodTestFileSha256 -Path $config + } + $baselineJson = $baseline | ConvertTo-Json -Depth 8 + [IO.File]::WriteAllText($provenancePath, $baselineJson, [Text.UTF8Encoding]::new($false)) + Test-CcodTrayHostArtifact -RepositoryRoot $repositoryRoot -Version '2.5.21' -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.21' -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 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 + $invalidFixtures = @( + [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' diff --git a/tests/persistence/TrayHostBuild.SelfTest.ps1 b/tests/persistence/TrayHostBuild.SelfTest.ps1 index 2e9153e..ad74661 100644 --- a/tests/persistence/TrayHostBuild.SelfTest.ps1 +++ b/tests/persistence/TrayHostBuild.SelfTest.ps1 @@ -75,6 +75,29 @@ Invoke-CcodTest 'TrayHost build emits one source-auditable artifact and rejects $currentCommit=(& git -C $repositoryRoot rev-parse HEAD).Trim().ToLowerInvariant() $validated=Test-CcodTrayHostArtifact -RepositoryRoot $repositoryRoot -Version '2.5.21' -ArtifactDirectory $artifact -ExpectedGitCommit $currentCommit Assert-CcodEqual $currentCommit ([string]$validated.GitCommit) 'artifact validation binds the current source commit when requested' + $expectedSourceNames=@(Get-ChildItem -LiteralPath (Join-Path $repositoryRoot 'src\trayhost') -Filter '*.cs' -File|Sort-Object Name|ForEach-Object Name) + Assert-CcodEqual ($expectedSourceNames -join ',') (@($provenance.sourceFiles.name) -join ',') 'built provenance records the exact compiled TrayHost source set in canonical order' + Assert-CcodTrue ($expectedSourceNames -ccontains 'TrayHostChildSession.cs') 'compiled source provenance includes the shared production child session' + Assert-CcodTrue ($expectedSourceNames -ccontains 'WindowsTrayHostRuntime.cs') 'compiled source provenance includes the production Windows runtime adapter' + $provenancePath=Join-Path $artifact 'trayhost-build-provenance.json' + $baselineJson=[IO.File]::ReadAllText($provenancePath,[Text.UTF8Encoding]::new($false)) + $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)) + try{ + Assert-CcodThrows { Test-CcodTrayHostArtifact -RepositoryRoot $repositoryRoot -Version '2.5.21' -ArtifactDirectory $artifact -ExpectedGitCommit $currentCommit|Out-Null } 'CCOD_TRAYHOST_SOURCE_TAMPERED' + }finally{ + [IO.File]::WriteAllText($provenancePath,$baselineJson,[Text.UTF8Encoding]::new($false)) + } + } $tampered=Join-Path $artifact 'CodexRemote.TrayHost.exe.config'; Add-Content -LiteralPath $tampered -Value 'x' $threw=$false; try{Test-CcodTrayHostArtifact -RepositoryRoot $repositoryRoot -Version '2.5.21' -ArtifactDirectory $artifact|Out-Null}catch{$threw=$true} Assert-CcodTrue $threw 'tampered artifact is rejected' From 2e0b49af4c940c683f6d37c915138c0ec4c949ae Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:51:41 +0800 Subject: [PATCH 042/187] docs: record release trace gate validation --- .../task-4-report.md | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 .superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-4-report.md 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..4481998 --- /dev/null +++ b/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-4-report.md @@ -0,0 +1,179 @@ +# 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 commit: + +```text +1743de6 test: gate release on tray receipt trace +``` + +## 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 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. + +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 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. | + +### 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. From f0e388ee6090d1be8d119c78033a6f245764a718 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:06:53 +0800 Subject: [PATCH 043/187] fix: reject bypassed tray trace gates --- .../persistence/ReleaseWorkflow.SelfTest.ps1 | 66 ++++++++++++++++--- 1 file changed, 58 insertions(+), 8 deletions(-) diff --git a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 index 11c7998..fed9492 100644 --- a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 +++ b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 @@ -5,7 +5,7 @@ $repositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent $defenderPath = Join-Path $repositoryRoot 'tools\Test-ReleaseDefender.ps1' function ConvertFrom-CcodWorkflowScalar { - param([Parameter(Mandatory)][string]$Value) + 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) @@ -56,29 +56,43 @@ function Get-CcodWorkflowStructure { } if (-not $inSteps) { continue } if ($line -cmatch '^ -(?:\s+(?[A-Za-z][A-Za-z0-9_-]*):\s*(?.*))?\s*$') { - $currentStep = [pscustomobject]@{ Name = ''; Shell = ''; Run = '' } + $currentStep = [pscustomobject]@{ Name = ''; Shell = ''; Run = ''; If = ''; ContinueOnError = '' } $currentJob.Steps.Add($currentStep) $key = [string]$Matches.key $value = [string]$Matches.value - if ($key -in @('name', 'shell', 'run')) { + if ($key -in @('name', 'shell', 'run', 'if', 'continue-on-error')) { if ($key -ceq 'run' -and $value.Trim() -in @('|', '|-', '|+')) { $runBlock = $true $runLines.Clear() } else { - $currentStep.$key = ConvertFrom-CcodWorkflowScalar $value + $property = switch ($key) { + 'name' { 'Name' } + 'shell' { 'Shell' } + 'run' { 'Run' } + 'if' { 'If' } + 'continue-on-error' { 'ContinueOnError' } + } + $currentStep.$property = ConvertFrom-CcodWorkflowScalar $value } } continue } if ($null -eq $currentStep) { continue } - if ($line -cmatch '^ (?shell|run):\s*(?.*)$') { + if ($line -cmatch '^ (?name|shell|run|if|continue-on-error):\s*(?.*)$') { $key = $Matches.key $value = $Matches.value.Trim() if ($key -ceq 'run' -and $value -in @('|', '|-', '|+')) { $runBlock = $true $runLines.Clear() } else { - $currentStep.$key = ConvertFrom-CcodWorkflowScalar $value + $property = switch ($key) { + 'name' { 'Name' } + 'shell' { 'Shell' } + 'run' { 'Run' } + 'if' { 'If' } + 'continue-on-error' { 'ContinueOnError' } + } + $currentStep.$property = ConvertFrom-CcodWorkflowScalar $value } } } @@ -133,7 +147,7 @@ function Assert-CcodAuthenticatedTraceWorkflowContract { $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) { + 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) { @@ -1534,7 +1548,7 @@ Invoke-CcodTest 'CI and release build jobs uniquely gate asset production on the -ReleasePath (Join-Path $repositoryRoot '.github\workflows\release.yml') } -Invoke-CcodTest 'authenticated trace workflow gate rejects comments wrong jobs duplicates and post-build placement' { +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 @@ -1571,7 +1585,43 @@ jobs: [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: From 1d6bd1fa26320201ce80b7bd308f6ff0b17f18d3 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:08:10 +0800 Subject: [PATCH 044/187] docs: record tray trace gate bypass fix --- .../task-4-report.md | 59 +++++++++++++++++-- 1 file changed, 53 insertions(+), 6 deletions(-) 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 index 4481998..4aba45b 100644 --- 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 @@ -20,10 +20,11 @@ CLI, authentication, installer, version metadata, or installed runtime. hashes the enumerated current sources first, stores them in an ordinal dictionary, and validates records against that bounded set. -Implementation commit: +Implementation commits: ```text 1743de6 test: gate release on tray receipt trace +f0e388e fix: reject bypassed tray trace gates ``` ## TDD RED evidence @@ -71,9 +72,11 @@ exited 0 and all four provenance mutations were rejected. - `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 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. +- 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. @@ -103,7 +106,7 @@ SHA-256 values. | `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 structural decoys and provenance mutations. | +| 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. | @@ -113,7 +116,51 @@ SHA-256 values. | `node.exe tests/PackageCheckerSelfTest.mjs` | exit 0. | | `git diff --check` | exit 0; only the repository's existing LF-to-CRLF warnings. | -### Full Persistence/Validate isolation boundary +## 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: From 49f88e2bd511a0f98ce93f48967e4cf3a333f93b Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:42:25 +0800 Subject: [PATCH 045/187] release: prepare CodexRemote-fix v2.5.22 --- .github/workflows/release.yml | 11 +- CHANGELOG.md | 16 +++ README.md | 17 +-- README.zh-CN.md | 14 +- build/TrayHostBuild.psm1 | 71 +++++++++- package.json | 2 +- src/portable/AssemblyInfo.cs | 4 +- src/portable/CodexRemote.Portable.manifest | 2 +- src/trayhost/AssemblyInfo.cs | 4 +- src/trayhost/CodexRemote.TrayHost.manifest | 2 +- .../persistence/InstallLifecycle.SelfTest.ps1 | 4 +- .../persistence/ReleaseWorkflow.SelfTest.ps1 | 127 +++++++++++------ tests/persistence/TrayHostBuild.SelfTest.ps1 | 130 +++++++++++++++--- tools/New-GitHubReleaseNotes.ps1 | 48 +++++++ 14 files changed, 353 insertions(+), 99 deletions(-) create mode 100644 tools/New-GitHubReleaseNotes.ps1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 16dfd70..deaf09f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -134,17 +134,8 @@ jobs: 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 + & (Join-Path $PWD 'tools\New-GitHubReleaseNotes.ps1') -ChangelogPath (Join-Path $PWD 'CHANGELOG.md') -Tag $tag -OutputPath $notesPath | Out-Null & gh release view $tag --repo $env:GITHUB_REPOSITORY --json assets 2>$null | Out-Null $exists = $LASTEXITCODE -eq 0 $existingAssets = @() diff --git a/CHANGELOG.md b/CHANGELOG.md index efe008c..78dac76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ This file keeps the release record. GitHub Release bodies are generated from the No unreleased changes. +## v2.5.22 + +### English + +- Bound Setup activation to the requested version and a hash-verified installer payload manifest, then revalidated the active runtime before reporting Ready. +- Restricted Setup compilation to one generated, inventoried input and rejected unapproved preprocessor constructs before ISCC runs. +- 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. + +### 简体中文 + +- 将 Setup 激活绑定到请求版本和经哈希校验的安装载荷 manifest,并在报告 Ready 前重新校验活动 runtime。 +- Setup 只编译一份已清点目录的生成输入;任何未获准的预处理构造都会在 ISCC 运行前被拒绝。 +- 为同一根进程加入有界 process-tree 重读,同时在关闭和远程重绑定时继续严格校验根进程与每个成员的身份。 +- 将原生托盘操作绑定到已确认的 presentation revision 和持久、有界的终态诊断;过期操作会记录清理后的支持代码。 + ## v2.5.21 ### English diff --git a/README.md b/README.md index 7728700..254b74a 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 stable release. The setup installer and EXE-based portable ZIP are version- and manifest-bound release assets. CodexRemote-fix enables the UI that ships with Codex Desktop for Windows but is hidden by a runtime defect: @@ -50,7 +50,7 @@ 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. Download either `CodexRemote-fix-2.5.22-setup.exe` (recommended installer) or `CodexRemote-fix-2.5.22-windows-x64.zip` from [Releases](https://github.com/naipi11/CodexRemote-fix/releases). 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. @@ -59,7 +59,8 @@ that existing page and leaves the Codex UI, account authorization, and enrollmen The current portable bundle, checksum, release manifest, and payload manifest are always published on the [Releases](https://github.com/naipi11/CodexRemote-fix/releases) page. -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. @@ -85,12 +86,12 @@ 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: +Every tagged release ships a Windows setup installer and an EXE-entry portable ZIP with separate SHA-256 checksums and release manifests. The portable distribution publishes its payload manifest as an asset; Setup embeds and hash-binds its versioned `installer-payload-manifest.json`. For v2.5.22, download: -- `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` +- `CodexRemote-fix-2.5.22-setup.exe` and its checksum +- `CodexRemote-fix-2.5.22-windows-x64.zip` and its checksum +- `CodexRemote-fix-2.5.22-payload-manifest.json` +- `CodexRemote-fix-2.5.22-release-manifest.json` and `CodexRemote-fix-2.5.22-setup-release-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..7d2a952 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 的发布资产。 CodexRemote-fix 在 Windows 版 Codex Desktop 中启用随应用一起打包、但因运行时缺陷被隐藏的: @@ -46,7 +46,7 @@ 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. 从 [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 设备密钥。 @@ -81,12 +81,12 @@ CodexRemote-fix 在 Windows 版 Codex Desktop 中启用随应用一起打包、 ## 发布(Releases) -每个带 tag 的发布都会附带 Windows 安装包和以 EXE 启动的便携 ZIP,各自带 SHA-256 校验文件、release manifest,并共享 payload manifest。`.github/workflows/release.yml` 会在 tag 上自动构建两者,因此 v2.5.21 提供: +每个带 tag 的发布都会附带 Windows 安装包和以 EXE 启动的便携 ZIP,并分别提供 SHA-256 校验文件与 release manifest。便携发行版会外发自己的 payload manifest;Setup 则内嵌并以哈希绑定版本化的 `installer-payload-manifest.json`。v2.5.22 提供: -- `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` +- `CodexRemote-fix-2.5.22-setup.exe` 及其校验文件 +- `CodexRemote-fix-2.5.22-windows-x64.zip` 及其校验文件 +- `CodexRemote-fix-2.5.22-payload-manifest.json` +- `CodexRemote-fix-2.5.22-release-manifest.json` 和 `CodexRemote-fix-2.5.22-setup-release-manifest.json` GitHub Release 正文只发布英文更新说明;本 README 继续保留中英文使用说明。 完整历史见 [CHANGELOG.md](CHANGELOG.md)。 diff --git a/build/TrayHostBuild.psm1 b/build/TrayHostBuild.psm1 index 7609bfd..9215fb4 100644 --- a/build/TrayHostBuild.psm1 +++ b/build/TrayHostBuild.psm1 @@ -35,6 +35,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 +93,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 +113,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 +132,23 @@ 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 $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' + $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)} @@ -121,12 +178,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 @@ -139,6 +197,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 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/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/tests/persistence/InstallLifecycle.SelfTest.ps1 b/tests/persistence/InstallLifecycle.SelfTest.ps1 index 3f3013a..a7e7c94 100644 --- a/tests/persistence/InstallLifecycle.SelfTest.ps1 +++ b/tests/persistence/InstallLifecycle.SelfTest.ps1 @@ -2585,9 +2585,9 @@ $results += Invoke-CcodTest 'installer exposes CodexRemote-fix as the searchable Assert-CcodTrue ($buildScript -cmatch '\$bundle\.sha256\.txt') 'build script writes a hash beside the public portable ZIP filename' } -$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' diff --git a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 index fed9492..9394583 100644 --- a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 +++ b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 @@ -1494,34 +1494,15 @@ Invoke-CcodTest 'TrayHost artifact validation requires the exact compiled source Import-Module (Join-Path $repositoryRoot 'build\TrayHostBuild.psm1') -Force $artifact = Join-Path ([IO.Path]::GetTempPath()) ('ccod-trayhost-provenance-fixture-' + [guid]::NewGuid().ToString('N')) try { - [IO.Directory]::CreateDirectory($artifact) | Out-Null - $executable = Join-Path $artifact 'CodexRemote.TrayHost.exe' - $config = Join-Path $artifact 'CodexRemote.TrayHost.exe.config' + $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' - [IO.File]::WriteAllBytes($executable, [byte[]](7, 5, 2, 2, 1)) - [IO.File]::WriteAllText($config, '', [Text.UTF8Encoding]::new($false)) - $sourceRoot = Join-Path $repositoryRoot 'src\trayhost' - $sourceRecords = @(Get-ChildItem -LiteralPath $sourceRoot -Filter '*.cs' -File | Sort-Object Name | ForEach-Object { - [ordered]@{ name = $_.Name; sha256 = Get-CcodTestFileSha256 -Path $_.FullName } - }) + $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' - $commit = 'c' * 40 - $baseline = [ordered]@{ - schemaVersion = 1 - product = 'CodexRemote-fix' - version = '2.5.21' - gitCommit = $commit - buildTimestampUtc = '2026-08-28T00:00:00.0000000Z' - targetFramework = 'net48' - sourceFiles = $sourceRecords - iconSha256 = Get-CcodTestFileSha256 -Path (Join-Path $repositoryRoot 'assets\codexremote-fix\codexremote-fix.ico') - artifactSha256 = Get-CcodTestFileSha256 -Path $executable - configArtifactSha256 = Get-CcodTestFileSha256 -Path $config - } - $baselineJson = $baseline | ConvertTo-Json -Depth 8 - [IO.File]::WriteAllText($provenancePath, $baselineJson, [Text.UTF8Encoding]::new($false)) - Test-CcodTrayHostArtifact -RepositoryRoot $repositoryRoot -Version '2.5.21' -ArtifactDirectory $artifact -ExpectedGitCommit $commit | Out-Null + 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 }) } }, @@ -1534,7 +1515,7 @@ Invoke-CcodTest 'TrayHost artifact validation requires the exact compiled source & $applyMutation $mutated [IO.File]::WriteAllText($provenancePath, ($mutated | ConvertTo-Json -Depth 8), [Text.UTF8Encoding]::new($false)) Assert-CcodThrows { - Test-CcodTrayHostArtifact -RepositoryRoot $repositoryRoot -Version '2.5.21' -ArtifactDirectory $artifact -ExpectedGitCommit $commit | Out-Null + Test-CcodTrayHostArtifact -RepositoryRoot $repositoryRoot -Version '2.5.22' -ArtifactDirectory $artifact -ExpectedGitCommit $commit | Out-Null } 'CCOD_TRAYHOST_SOURCE_TAMPERED' } } finally { @@ -1718,6 +1699,7 @@ Invoke-CcodTest 'package scripts build provenance and workflows retain the relea 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 ($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' @@ -1726,31 +1708,96 @@ Invoke-CcodTest 'package scripts build provenance and workflows retain the relea } $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' { +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 +'@ + [IO.File]::WriteAllText($changelogPath,$fixture.Replace("`n","`r`n"),[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 "# CodexRemote-fix 2.5.22`n`n- 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 '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.21' ([string]$package.version) 'package metadata is the 2.5.21 release' + 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.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' + $releaseSection = [regex]::Match($changelog, '(?ms)^## v2\.5\.22\s*\r?\n(?.*?)(?=^## |\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.21 is the current stable release')) 'English README marks v2.5.21 stable without a version-by-version What''s new block' + Assert-CcodTrue ($readme.Contains('v2.5.22 is the current stable release')) 'English README marks v2.5.22 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-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\.21 \u662F\u5F53\u524D\u7A33\u5B9A\u7248') 'Chinese README marks v2.5.21 stable' + Assert-CcodTrue ($readmeZh -match 'v2\.5\.22 \u662F\u5F53\u524D\u7A33\u5B9A\u7248') 'Chinese README marks v2.5.22 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-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 diff --git a/tests/persistence/TrayHostBuild.SelfTest.ps1 b/tests/persistence/TrayHostBuild.SelfTest.ps1 index ad74661..d78ed82 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 $env:TEMP ('ccod-native-version-artifact-' + [Guid]::NewGuid().ToString('N')) + $fixtureRoot = Join-Path $env:TEMP ('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 $env:TEMP ('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 '.*?)(?=^## |\z)' +$releaseSections = @([regex]::Matches($text,$releasePattern)) +if ($releaseSections.Count -ne 1) { + throw 'CCOD_RELEASE_NOTES_RELEASE_SECTION_INVALID' +} +$englishSections = @([regex]::Matches($releaseSections[0].Groups['body'].Value,'(?ms)^### English[ \t]*\r?\n(?.*?)(?=^### |\z)')) +if ($englishSections.Count -ne 1) { + throw 'CCOD_RELEASE_NOTES_ENGLISH_SECTION_INVALID' +} +$english = $englishSections[0].Groups['body'].Value.Trim().Replace("`r`n","`n").Replace("`r","`n") +if ([string]::IsNullOrWhiteSpace($english)) { + throw 'CCOD_RELEASE_NOTES_ENGLISH_SECTION_INVALID' +} + +$version = $Tag.Substring(1) +$notes = "# CodexRemote-fix $version`n`n$english`n" +[IO.File]::WriteAllText($output,$notes,[Text.UTF8Encoding]::new($false)) + +[pscustomobject][ordered]@{ + Tag = $Tag + Version = $version + OutputPath = $output +} From 9799d4fd2f0b9a17eb7ee2fd6738dcb7edbfedb0 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:45:10 +0800 Subject: [PATCH 046/187] docs: record v2.5.22 release validation --- .../task-5-report.md | 212 ++++++++++++++++++ README.md | 2 +- README.zh-CN.md | 2 +- .../persistence/ReleaseWorkflow.SelfTest.ps1 | 4 +- 4 files changed, 216 insertions(+), 4 deletions(-) create mode 100644 .superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-5-report.md 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..9bc617f --- /dev/null +++ b/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-5-report.md @@ -0,0 +1,212 @@ +# 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. diff --git a/README.md b/README.md index 254b74a..dd815b0 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ 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 with separate SHA-256 checksums and release manifests. The portable distribution publishes its payload manifest as an asset; Setup embeds and hash-binds its versioned `installer-payload-manifest.json`. For v2.5.22, download: +Every tagged release ships a Windows setup installer and an EXE-entry portable ZIP with separate SHA-256 checksums and release manifests. The portable distribution publishes its payload manifest as an asset; Setup embeds and hash-binds its versioned `installer-payload.manifest.json`. For v2.5.22, download: - `CodexRemote-fix-2.5.22-setup.exe` and its checksum - `CodexRemote-fix-2.5.22-windows-x64.zip` and its checksum diff --git a/README.zh-CN.md b/README.zh-CN.md index 7d2a952..7acbcf6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -81,7 +81,7 @@ CodexRemote-fix 在 Windows 版 Codex Desktop 中启用随应用一起打包、 ## 发布(Releases) -每个带 tag 的发布都会附带 Windows 安装包和以 EXE 启动的便携 ZIP,并分别提供 SHA-256 校验文件与 release manifest。便携发行版会外发自己的 payload manifest;Setup 则内嵌并以哈希绑定版本化的 `installer-payload-manifest.json`。v2.5.22 提供: +每个带 tag 的发布都会附带 Windows 安装包和以 EXE 启动的便携 ZIP,并分别提供 SHA-256 校验文件与 release manifest。便携发行版会外发自己的 payload manifest;Setup 则内嵌并以哈希绑定版本化的 `installer-payload.manifest.json`。v2.5.22 提供: - `CodexRemote-fix-2.5.22-setup.exe` 及其校验文件 - `CodexRemote-fix-2.5.22-windows-x64.zip` 及其校验文件 diff --git a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 index 9394583..5fdf4cd 100644 --- a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 +++ b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 @@ -1787,7 +1787,7 @@ Invoke-CcodTest '2.5.22 source metadata and documentation match the release cont 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 ($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\u7A33\u5B9A\u7248') 'Chinese README marks v2.5.22 stable' @@ -1797,7 +1797,7 @@ Invoke-CcodTest '2.5.22 source metadata and documentation match the release cont 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' + 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 From 6204a687816bdeea25f072363aadf62c23fe5da3 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:59:16 +0800 Subject: [PATCH 047/187] fix: verify portable release artifacts --- .../task-5-report.md | 64 +++++++++++++++++++ build/TrayHostBuild.psm1 | 42 +++++++++++- build/build.ps1 | 5 +- .../persistence/ReleaseWorkflow.SelfTest.ps1 | 7 +- tests/persistence/TrayHostBuild.SelfTest.ps1 | 44 +++++++++++++ tools/New-GitHubReleaseNotes.ps1 | 2 +- 6 files changed, 159 insertions(+), 5 deletions(-) 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 index 9bc617f..7e60ba7 100644 --- 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 @@ -210,3 +210,67 @@ the follow-up documentation HEAD without a fresh clean rebuild. 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. diff --git a/build/TrayHostBuild.psm1 b/build/TrayHostBuild.psm1 index 9215fb4..f767edf 100644 --- a/build/TrayHostBuild.psm1 +++ b/build/TrayHostBuild.psm1 @@ -210,4 +210,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=Get-Content -LiteralPath $provenancePath -Raw|ConvertFrom-Json}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 2f53eb1..8b900c8 100644 --- a/build/build.ps1 +++ b/build/build.ps1 @@ -321,7 +321,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) } @@ -425,7 +427,6 @@ $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' $isccArguments = @( "/DProjectVersion=$Version", "/DTrayHostArtifactDirectory=$trayHostArtifact", diff --git a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 index 5fdf4cd..91a6664 100644 --- a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 +++ b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 @@ -1749,7 +1749,7 @@ Invoke-CcodTest 'release notes extraction emits only the target release English [IO.File]::WriteAllText($changelogPath,$fixture.Replace("`n","`r`n"),[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 "# CodexRemote-fix 2.5.22`n`n- Target English note.`n- Second target English note.`n" $actual 'release body contains exactly the current target English notes' + 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" } @@ -1758,6 +1758,11 @@ Invoke-CcodTest 'release notes extraction emits only the target release English } } +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-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' diff --git a/tests/persistence/TrayHostBuild.SelfTest.ps1 b/tests/persistence/TrayHostBuild.SelfTest.ps1 index d78ed82..5fe0072 100644 --- a/tests/persistence/TrayHostBuild.SelfTest.ps1 +++ b/tests/persistence/TrayHostBuild.SelfTest.ps1 @@ -205,6 +205,7 @@ Invoke-CcodTest 'TrayHost build embeds the product ICO instead of relying on a r Invoke-CcodTest 'portable launcher build emits a tamper-bound double-click entrypoint' { Import-Module (Join-Path $repositoryRoot 'build\TrayHostBuild.psm1') -Force $artifact = Join-Path $env:TEMP ('ccod-portable-launcher-artifact-' + [Guid]::NewGuid().ToString('N')) + $fixtureRoot = Join-Path $env:TEMP ('ccod-portable-launcher-repository-' + [Guid]::NewGuid().ToString('N')) try { $result = Invoke-CcodPortableLauncherBuild -RepositoryRoot $repositoryRoot -Version '2.5.22' -OutputDirectory $artifact Assert-CcodTrue (Test-Path -LiteralPath (Join-Path $artifact 'CodexRemote.Portable.exe') -PathType Leaf) 'portable launcher executable exists' @@ -219,8 +220,51 @@ Invoke-CcodTest 'portable launcher build emits a tamper-bound double-click entry Assert-CcodTrue (@($provenance.sourceFiles.name) -ccontains 'AssemblyInfo.cs') 'portable launcher provenance includes assembly metadata' $validated = (Get-FileHash -LiteralPath (Join-Path $artifact 'CodexRemote.Portable.exe') -Algorithm SHA256).Hash.ToLowerInvariant() Assert-CcodEqual $validated ([string]$provenance.artifactSha256) 'portable launcher artifact binds its executable hash' + $currentCommit = (& git -C $repositoryRoot rev-parse HEAD).Trim().ToLowerInvariant() + $artifactValidation = Test-CcodPortableLauncherArtifact -RepositoryRoot $repositoryRoot -Version '2.5.22' -ArtifactDirectory $artifact -ExpectedGitCommit $currentCommit + Assert-CcodEqual $currentCommit ([string]$artifactValidation.GitCommit) 'portable artifact validator binds the expected source commit' + + $provenancePath = Join-Path $artifact 'portable-launcher-provenance.json' + $baselineJson = [IO.File]::ReadAllText($provenancePath,[Text.UTF8Encoding]::new($false)) + $mutations = @( + [pscustomobject]@{ Name='commit'; Error='CCOD_PORTABLE_LAUNCHER_PROVENANCE_INVALID'; Apply={param($record)$record.gitCommit='d'*40} }, + [pscustomobject]@{ Name='timestamp'; Error='CCOD_PORTABLE_LAUNCHER_PROVENANCE_INVALID'; Apply={param($record)$record.buildTimestampUtc='not-canonical'} }, + [pscustomobject]@{ Name='source hash'; Error='CCOD_PORTABLE_LAUNCHER_SOURCE_TAMPERED'; Apply={param($record)$record.sourceFiles[0].sha256='0'*64} }, + [pscustomobject]@{ Name='icon hash'; Error='CCOD_PORTABLE_LAUNCHER_ARTIFACT_TAMPERED'; Apply={param($record)$record.iconSha256='0'*64} }, + [pscustomobject]@{ Name='manifest hash'; Error='CCOD_PORTABLE_LAUNCHER_ARTIFACT_TAMPERED'; Apply={param($record)$record.manifestSha256='0'*64} }, + [pscustomobject]@{ Name='source config hash'; Error='CCOD_PORTABLE_LAUNCHER_ARTIFACT_TAMPERED'; Apply={param($record)$record.configSha256='0'*64} }, + [pscustomobject]@{ Name='executable hash'; Error='CCOD_PORTABLE_LAUNCHER_ARTIFACT_TAMPERED'; Apply={param($record)$record.artifactSha256='0'*64} }, + [pscustomobject]@{ Name='artifact config hash'; Error='CCOD_PORTABLE_LAUNCHER_ARTIFACT_TAMPERED'; Apply={param($record)$record.configArtifactSha256='0'*64} } + ) + foreach ($mutationCase in $mutations) { + $mutated = $baselineJson | ConvertFrom-Json + & ([scriptblock]$mutationCase.Apply) $mutated + [IO.File]::WriteAllText($provenancePath,($mutated | ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + try { + Assert-CcodThrows { Test-CcodPortableLauncherArtifact -RepositoryRoot $repositoryRoot -Version '2.5.22' -ArtifactDirectory $artifact -ExpectedGitCommit $currentCommit | Out-Null } ([string]$mutationCase.Error) + } finally { + [IO.File]::WriteAllText($provenancePath,$baselineJson,[Text.UTF8Encoding]::new($false)) + } + } + + [IO.Directory]::CreateDirectory((Join-Path $fixtureRoot 'src')) | Out-Null + Copy-Item -LiteralPath (Join-Path $repositoryRoot 'src\portable') -Destination (Join-Path $fixtureRoot 'src\portable') -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') + [IO.File]::WriteAllText((Join-Path $fixtureRoot 'package.json'),'{"version":"9.9.9"}',[Text.UTF8Encoding]::new($false)) + $assemblyPath = Join-Path $fixtureRoot 'src\portable\AssemblyInfo.cs' + [IO.File]::WriteAllText($assemblyPath,([IO.File]::ReadAllText($assemblyPath,[Text.UTF8Encoding]::new($false)).Replace('2.5.22.0','9.9.9.0')),[Text.UTF8Encoding]::new($false)) + $manifestPath = Join-Path $fixtureRoot 'src\portable\CodexRemote.Portable.manifest' + [IO.File]::WriteAllText($manifestPath,([IO.File]::ReadAllText($manifestPath,[Text.UTF8Encoding]::new($false)).Replace('2.5.22.0','9.9.9.0')),[Text.UTF8Encoding]::new($false)) + $falseVersion = $baselineJson | ConvertFrom-Json + $falseVersion.version = '9.9.9' + @($falseVersion.sourceFiles | Where-Object { $_.name -ceq 'AssemblyInfo.cs' })[0].sha256 = Get-CcodTestFileSha256 -Path $assemblyPath + $falseVersion.manifestSha256 = Get-CcodTestFileSha256 -Path $manifestPath + [IO.File]::WriteAllText($provenancePath,($falseVersion | ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + Assert-CcodThrows { Test-CcodPortableLauncherArtifact -RepositoryRoot $fixtureRoot -Version '9.9.9' -ArtifactDirectory $artifact -ExpectedGitCommit $currentCommit | Out-Null } 'CCOD_PORTABLE_LAUNCHER_ARTIFACT_VERSION_INVALID' } 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 } } } diff --git a/tools/New-GitHubReleaseNotes.ps1 b/tools/New-GitHubReleaseNotes.ps1 index 3856a76..62cc90a 100644 --- a/tools/New-GitHubReleaseNotes.ps1 +++ b/tools/New-GitHubReleaseNotes.ps1 @@ -38,7 +38,7 @@ if ([string]::IsNullOrWhiteSpace($english)) { } $version = $Tag.Substring(1) -$notes = "# CodexRemote-fix $version`n`n$english`n" +$notes = "$english`n" [IO.File]::WriteAllText($output,$notes,[Text.UTF8Encoding]::new($false)) [pscustomobject][ordered]@{ From f437d501feacc6ac7bf769842ace1a4be374ede5 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:13:44 +0800 Subject: [PATCH 048/187] test: normalize release notes fixture line endings --- .../task-5-report.md | 34 +++++++++++++++++++ .../persistence/ReleaseWorkflow.SelfTest.ps1 | 3 +- 2 files changed, 36 insertions(+), 1 deletion(-) 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 index 7e60ba7..72b3607 100644 --- 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 @@ -274,3 +274,37 @@ ReleaseWorkflow.SelfTest.ps1 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. diff --git a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 index 91a6664..91dd6eb 100644 --- a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 +++ b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 @@ -1746,7 +1746,8 @@ Invoke-CcodTest 'release notes extraction emits only the target release English - OLDER_DECOY '@ - [IO.File]::WriteAllText($changelogPath,$fixture.Replace("`n","`r`n"),[Text.UTF8Encoding]::new($false)) + $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' From 31fd365612db30a19398668c0a7b119555ddb5dd Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:34:48 +0800 Subject: [PATCH 049/187] test: update v2.5.22 release branding contract --- .../task-5-report.md | 32 +++++++++++++++++++ .../persistence/InstallLifecycle.SelfTest.ps1 | 25 +++++++++++---- 2 files changed, 51 insertions(+), 6 deletions(-) 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 index 72b3607..f883720 100644 --- 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 @@ -308,3 +308,35 @@ 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. diff --git a/tests/persistence/InstallLifecycle.SelfTest.ps1 b/tests/persistence/InstallLifecycle.SelfTest.ps1 index a7e7c94..538a5bd 100644 --- a/tests/persistence/InstallLifecycle.SelfTest.ps1 +++ b/tests/persistence/InstallLifecycle.SelfTest.ps1 @@ -2946,16 +2946,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' @@ -2968,8 +2968,21 @@ $results += Invoke-CcodTest 'README and release workflow publish current portabl 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' + 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 } + } } $results += Invoke-CcodTest 'installer carries the Inno contract needed by its self-validation' { From cb36d0ba202724c6c9bb223d94dc3ce838f45f9e Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:08:58 +0800 Subject: [PATCH 050/187] test: expose activation payload byte swap --- .../persistence/ReleaseWorkflow.SelfTest.ps1 | 155 +++++++++++++++++- 1 file changed, 153 insertions(+), 2 deletions(-) diff --git a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 index 91dd6eb..3fa3cea 100644 --- a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 +++ b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 @@ -1035,8 +1035,39 @@ function New-CcodActivationPayloadFixture { $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)) - [IO.File]::WriteAllText((Join-Path $payloadRoot 'Install-CodexControlOtherDevices.ps1'),'exit 0',[Text.UTF8Encoding]::new($false)) - [IO.File]::WriteAllText((Join-Path $payloadRoot 'src\persistence\modules\InstallLifecycle.psm1'),'function Get-CcodLifecyclePayloadManifestFiles { @() }',[Text.UTF8Encoding]::new($false)) + $installSource = @' +param( + [string]$InstallRoot, + [switch]$EnableCandidateCompatibleUpdates, + [string]$ActivationId, + [string]$ExpectedVersion, + [string]$PayloadManifestPath, + [string]$ExpectedPayloadManifestSha256 +) +$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')) | 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\post-install-activation.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('\','/') @@ -1049,6 +1080,48 @@ function New-CcodActivationPayloadFixture { 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) @@ -1141,6 +1214,84 @@ Invoke-CcodTest 'setup build and activation bind one immutable versioned payload Assert-CcodTrue ($installer -cmatch '\[string\]\$ExpectedVersion' -and $installer -cmatch '\[string\]\$PayloadManifestPath' -and $installer -cmatch '-ExpectedVersion\s+\$ExpectedVersion' -and $installer -cmatch '-PayloadManifestPath\s+\$PayloadManifestPath') 'installer forwards the immutable payload contract to lifecycle activation' } +# 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) + } + Assert-CcodTrue ([IO.File]::Exists($readyPath)) 'barrier proves parent payload verification completed before child execution' + + $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')) | 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\post-install-activation.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 } + } +} + 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 { From ed782ecadc005b60822d9fac745a2292ffab6e24 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:23:42 +0800 Subject: [PATCH 051/187] fix: seal activation bytes and reject linked leaves --- Activate-CcodRemoteFix.ps1 | 223 +++++++++++++----- Install-CodexControlOtherDevices.ps1 | 169 ++++++++++++- build/CodexControlOtherDevices.iss | 97 ++++++++ src/persistence/modules/InstallLifecycle.psm1 | 168 +++++++++++-- .../persistence/InstallLifecycle.SelfTest.ps1 | 66 +++++- .../persistence/ReleaseWorkflow.SelfTest.ps1 | 65 ++++- 6 files changed, 686 insertions(+), 102 deletions(-) diff --git a/Activate-CcodRemoteFix.ps1 b/Activate-CcodRemoteFix.ps1 index bebd91a..536f0df 100644 --- a/Activate-CcodRemoteFix.ps1 +++ b/Activate-CcodRemoteFix.ps1 @@ -54,19 +54,38 @@ function Write-CcodActivationRecord { } catch { } } -function Get-CcodActivationFileSha256 { - param([Parameter(Mandatory)][string]$Path) +function Get-CcodActivationBytesSha256 { + param([Parameter(Mandatory)][byte[]]$Bytes) $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() + return [BitConverter]::ToString($sha.ComputeHash($Bytes)).Replace('-','').ToLowerInvariant() } finally { - if ($null -ne $stream) { $stream.Dispose() } $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 Assert-CcodActivationNonReparsePath { param([Parameter(Mandatory)][string]$Root,[Parameter(Mandatory)][string]$Path) $canonicalRoot = [IO.Path]::GetFullPath($Root).TrimEnd('\') @@ -99,7 +118,32 @@ function Test-CcodActivationAlternateDataStreams { } } -function Assert-CcodActivationPayload { +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 { } } + } + Remove-CcodActivationPayloadStage -AppRoot $Seal.AppRoot -StageRoot $Seal.StageRoot +} + +function New-CcodActivationPayloadSeal { param( [Parameter(Mandatory)][string]$AppRoot, [Parameter(Mandatory)][string]$Root, @@ -122,59 +166,99 @@ function Assert-CcodActivationPayload { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' } [void](Assert-CcodActivationNonReparsePath -Root $payload -Path $manifestPath) - $manifestItem = Get-Item -LiteralPath $manifestPath -Force -ErrorAction Stop - if ($manifestItem.PSIsContainer -or $manifestItem.Length -le 0 -or $manifestItem.Length -gt 4194304 -or - (Test-CcodActivationAlternateDataStreams -Path $manifestPath) -or - (Get-CcodActivationFileSha256 -Path $manifestPath) -cne $ExpectedManifestSha256) { - throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' - } + $sourceLocks = [Collections.Generic.List[object]]::new() + $stageLocks = [Collections.Generic.List[object]]::new() + $stageRoot = Join-Path $app ('.activation-payload-stage-' + [guid]::NewGuid().ToString('N')) try { - $manifest = [IO.File]::ReadAllText($manifestPath,[Text.Encoding]::UTF8) | 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) - $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) - $item = Get-Item -LiteralPath $source -Force -ErrorAction Stop - if ($item.PSIsContainer -or (Test-CcodActivationAlternateDataStreams -Path $source) -or [int64]$item.Length -ne [int64]$record.length -or - (Get-CcodActivationFileSha256 -Path $source) -cne [string]$record.sha256) { throw 'CCOD_INSTALL_FILE_HASH_MISMATCH' } - } - $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' } - } - try { $package = [IO.File]::ReadAllText($packagePath,[Text.Encoding]::UTF8) | 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' + $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' } + + [IO.Directory]::CreateDirectory($stageRoot) | Out-Null + [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' } + [IO.Directory]::CreateDirectory((Split-Path $destination -Parent)) | Out-Null + $writer = [IO.File]::Open($destination,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) + try { $writer.Write([byte[]]$snapshot.Bytes,0,([byte[]]$snapshot.Bytes).Length); $writer.Flush($true) } finally { $writer.Dispose() } + [void](Assert-CcodActivationNonReparsePath -Root $stageRoot -Path $destination) + $stageLock = Open-CcodActivationLockedBytes -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 }) + } + } catch { + foreach ($locked in @($stageLocks)) { try { $locked.Stream.Dispose() } catch { } } + Remove-CcodActivationPayloadStage -AppRoot $app -StageRoot $stageRoot + throw + } finally { + foreach ($locked in @($sourceLocks)) { try { $locked.Stream.Dispose() } catch { } } } - return $manifestPath } function Assert-CcodActivatedRuntimeVersion { @@ -306,6 +390,7 @@ function Invoke-CcodOwnedInstallWorker { param( [Parameter(Mandatory)][string]$PowerShellPath, [Parameter(Mandatory)][string]$InstallScript, + [Parameter(Mandatory)][string]$VerifiedPayloadRoot, [Parameter(Mandatory)][string]$StateRoot, [Parameter(Mandatory)][int]$FirstReceiptTimeout, [Parameter(Mandatory)][int]$ActivationTimeout @@ -328,7 +413,11 @@ function Invoke-CcodOwnedInstallWorker { $ActivationId ) if (-not [string]::IsNullOrWhiteSpace($ExpectedVersion)) { - $arguments += @('-ExpectedVersion',$ExpectedVersion,'-PayloadManifestPath',(Join-Path $PayloadRoot 'installer-payload.manifest.json')) + $arguments += @( + '-ExpectedVersion',$ExpectedVersion, + '-PayloadManifestPath',(Join-Path $VerifiedPayloadRoot 'installer-payload.manifest.json'), + '-ExpectedPayloadManifestSha256',$ExpectedPayloadManifestSha256 + ) } $startInfo = [Diagnostics.ProcessStartInfo]::new() $startInfo.FileName = $PowerShellPath @@ -442,8 +531,10 @@ function Show-CcodActivationFailure { if ($ValidateReceiptOnly -and $ValidateReceiptWithTimeout) { Write-Error 'CCOD_ACTIVATION_VALIDATOR_MODE_INVALID' -ErrorAction Continue; exit 3 } if ([string]::IsNullOrWhiteSpace($PayloadRoot)) { $PayloadRoot = $AppRoot } $PayloadRoot = [IO.Path]::GetFullPath($PayloadRoot) +$payloadSeal = $null +try { if (-not [string]::IsNullOrWhiteSpace($ExpectedVersion)) { - try { $null = Assert-CcodActivationPayload -AppRoot $AppRoot -Root $PayloadRoot -Version $ExpectedVersion -ExpectedManifestSha256 $ExpectedPayloadManifestSha256 } + 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' } @@ -466,7 +557,7 @@ if ($ValidateReceiptOnly) { $terminalReceipt = Read-CcodTerminalActivationReceipt -Root ([IO.Path]::GetFullPath($InstallRoot)) -ExpectedActivationId $ActivationId 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 $PayloadRoot 'src\persistence\modules\RuntimeManifest.psm1') + 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 } @@ -483,11 +574,12 @@ $clock = [Diagnostics.Stopwatch]::StartNew() try { $root = [IO.Path]::GetFullPath($AppRoot) $stateRoot = [IO.Path]::GetFullPath($InstallRoot) - $installScript = Join-Path $PayloadRoot '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 -FirstReceiptTimeout $FirstReceiptTimeoutMilliseconds -ActivationTimeout $ActivationTimeoutMilliseconds if ($installExitCode -ne 0) { $reportedErrorCode = $null try { @@ -499,7 +591,7 @@ try { } $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 $PayloadRoot 'src\persistence\modules\RuntimeManifest.psm1') + 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 { @@ -530,3 +622,6 @@ if ($Prompt) { Write-CcodActivationRecord -Code 'COMPLETED' -DurationMilliseconds $clock.ElapsedMilliseconds exit 0 +} finally { + Close-CcodActivationPayloadSeal -Seal $payloadSeal +} diff --git a/Install-CodexControlOtherDevices.ps1 b/Install-CodexControlOtherDevices.ps1 index 4960ea0..bc8cf47 100644 --- a/Install-CodexControlOtherDevices.ps1 +++ b/Install-CodexControlOtherDevices.ps1 @@ -6,11 +6,135 @@ param( [switch]$DoNotStart, [string]$ActivationId, [string]$ExpectedVersion, - [string]$PayloadManifestPath + [string]$PayloadManifestPath, + [string]$ExpectedPayloadManifestSha256 ) $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) @@ -26,22 +150,41 @@ 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 ` - -ExpectedVersion $ExpectedVersion ` - -PayloadManifestPath $PayloadManifestPath +$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) + } + $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/build/CodexControlOtherDevices.iss b/build/CodexControlOtherDevices.iss index ff42b79..b99a09d 100644 --- a/build/CodexControlOtherDevices.iss +++ b/build/CodexControlOtherDevices.iss @@ -106,11 +106,39 @@ const VALIDATION_LAUNCH_BUDGET_MILLISECONDS = 5000; CCOD_FILE_ATTRIBUTE_DIRECTORY = $00000010; CCOD_FILE_ATTRIBUTE_REPARSE_POINT = $00000400; + CCOD_FILE_READ_ATTRIBUTES = $00000080; + 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_INVALID_FILE_ATTRIBUTES = $FFFFFFFF; + CCOD_INVALID_HANDLE_VALUE = -1; + CCOD_ERROR_HANDLE_EOF = 38; type TActivationPhase = (apNone, apStoppingPreviousRuntime, apInstallingRuntime, apActivatingRuntime, apStartingProtection, apReady, apFailed); + 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 GetTickCount64(): Int64; external 'GetTickCount64@kernel32.dll stdcall'; @@ -120,6 +148,25 @@ function StringFromGUID2(var Guid: TGUID; GuidString: String; MaxCharacters: Int external 'StringFromGUID2@ole32.dll stdcall'; 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'; function IsSafeExistingPayloadDirectory(const DirectoryName: String): Boolean; var @@ -131,6 +178,50 @@ begin ((Attributes and CCOD_FILE_ATTRIBUTE_REPARSE_POINT) = 0)); end; +function HasOnlyDefaultDataStream(const FileName: String): Boolean; +var + FindHandle: Integer; + StreamData: TCcodFindStreamData; + ErrorCode: Cardinal; +begin + Result := False; + FindHandle := FindFirstStreamW(FileName, 0, StreamData, 0); + if FindHandle = CCOD_INVALID_HANDLE_VALUE then Exit; + try + if FindNextStreamW(FindHandle, StreamData) then Exit; + ErrorCode := GetLastError(); + Result := ErrorCode = CCOD_ERROR_HANDLE_EOF; + finally + CcodFindClose(FindHandle); + end; +end; + +function IsSafeExistingSetupLeaf(const FileName: String): Boolean; +var + Attributes: Cardinal; + FileHandle: Integer; + Information: TCcodByHandleFileInformation; +begin + Result := False; + 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; + FileHandle := CreateFileW(FileName, CCOD_FILE_READ_ATTRIBUTES, + CCOD_FILE_SHARE_READ or CCOD_FILE_SHARE_WRITE or CCOD_FILE_SHARE_DELETE, + 0, CCOD_OPEN_EXISTING, CCOD_FILE_FLAG_OPEN_REPARSE_POINT, 0); + if FileHandle = CCOD_INVALID_HANDLE_VALUE then Exit; + try + if not GetFileInformationByHandle(FileHandle, Information) then Exit; + Result := (Information.NumberOfLinks = 1) and + ((Information.FileAttributes and CCOD_FILE_ATTRIBUTE_DIRECTORY) = 0) and + ((Information.FileAttributes and CCOD_FILE_ATTRIBUTE_REPARSE_POINT) = 0) and + HasOnlyDefaultDataStream(FileName); + finally + CloseHandle(FileHandle); + end; +end; + function IsSafeExistingSetupTree(const DirectoryName: String): Boolean; var Attributes: Cardinal; @@ -159,6 +250,12 @@ begin Result := False; Exit; end; + if ((FindRec.Attributes and CCOD_FILE_ATTRIBUTE_DIRECTORY) = 0) and + (not IsSafeExistingSetupLeaf(ChildPath)) then + begin + Result := False; + Exit; + end; end; until not FindNext(FindRec); finally diff --git a/src/persistence/modules/InstallLifecycle.psm1 b/src/persistence/modules/InstallLifecycle.psm1 index 329f1ed..e2a7e12 100644 --- a/src/persistence/modules/InstallLifecycle.psm1 +++ b/src/persistence/modules/InstallLifecycle.psm1 @@ -194,6 +194,7 @@ function Assert-CcodLifecycleInstallTreeSafe { 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 @@ -420,11 +421,126 @@ 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]$PayloadManifestPath, + [Parameter(Mandatory)][string]$ExpectedPayloadManifestSha256, + [Parameter(Mandatory)][string]$PayloadManifestBytesBase64 ) if ($ExpectedVersion -cnotmatch '^\d+\.\d+\.\d+$') { @@ -448,7 +564,16 @@ function Get-CcodLifecyclePayloadManifestFiles { Throw-CcodLifecycleError 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' 'Payload manifest escaped the source root' $manifestPath } } - try { $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json -ErrorAction Stop } + 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 @@ -570,9 +695,9 @@ function Copy-CcodLifecycleStaging { [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $destinationParent) [IO.Directory]::CreateDirectory($destinationParent) | Out-Null [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $destinationParent) - [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $destination) + [void](Assert-CcodLifecycleInstallLeafSafe -InstallRoot $InstallRoot -Path $destination -AllowMissing) & $Adapters.CopyFile $file.Source $destination - [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $destination) + [void](Assert-CcodLifecycleInstallLeafSafe -InstallRoot $InstallRoot -Path $destination) } foreach ($file in $Files) { $destination = [IO.Path]::GetFullPath((Join-Path $stagingDirectory $file.Relative)) @@ -586,18 +711,18 @@ function Copy-CcodLifecycleStaging { if (-not [IO.File]::Exists($bootstrapSource)) { Throw-CcodLifecycleError 'CCOD_INSTALL_SOURCE_INCOMPLETE' 'The verified staging bootstrap script is missing' $bootstrapSource } - [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $bootstrapPath) + [void](Assert-CcodLifecycleInstallLeafSafe -InstallRoot $InstallRoot -Path $bootstrapPath -AllowMissing) & $Adapters.CopyFile $bootstrapSource $bootstrapPath - [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $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 $stagingDirectory 'Uninstall-CodexControlOtherDevices.ps1' if ([IO.File]::Exists($uninstallerSource)) { - [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $uninstallerPath) + [void](Assert-CcodLifecycleInstallLeafSafe -InstallRoot $InstallRoot -Path $uninstallerPath -AllowMissing) & $Adapters.CopyFile $uninstallerSource $uninstallerPath - [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $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 } @@ -609,10 +734,10 @@ function Copy-CcodLifecycleStaging { 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 { [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $bootstrapPath); 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 { [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $uninstallerPath); 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_*') { @@ -1450,8 +1575,7 @@ 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 } NewRuntimeManifest = { param($RuntimeDirectory, $ProjectVersion) @@ -1750,6 +1874,8 @@ function Invoke-CcodInstall { [string]$ActivationId, [string]$ExpectedVersion, [string]$PayloadManifestPath, + [string]$ExpectedPayloadManifestSha256, + [string]$PayloadManifestBytesBase64, [hashtable]$Adapters ) @@ -1775,12 +1901,18 @@ function Invoke-CcodInstall { 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) - if ($payloadBound -and ([string]::IsNullOrWhiteSpace($ExpectedVersion) -or [string]::IsNullOrWhiteSpace($PayloadManifestPath))) { - Throw-CcodLifecycleError 'CCOD_INSTALL_INPUT_INVALID' 'ExpectedVersion and PayloadManifestPath must be supplied together' $null + $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) + @(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 @@ -1858,13 +1990,13 @@ function Invoke-CcodInstall { $stagingDirectory = Copy-CcodLifecycleStaging -SourceRoot $sourceRoot -InstallRoot $root -Adapters $adapters -Files $files $manifest = & $adapters.NewRuntimeManifest $stagingDirectory $projectVersion $stagingManifestPath = Join-Path $stagingDirectory 'manifest.json' - [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $root -Path $stagingManifestPath) + [void](Assert-CcodLifecycleInstallLeafSafe -InstallRoot $root -Path $stagingManifestPath -AllowMissing) [IO.File]::WriteAllText( $stagingManifestPath, ($manifest | ConvertTo-Json -Depth 16), [Text.UTF8Encoding]::new($false) ) - [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $root -Path $stagingManifestPath) + [void](Assert-CcodLifecycleInstallLeafSafe -InstallRoot $root -Path $stagingManifestPath) $runtimeId = [string]$manifest.runtimeId $validation = Test-CcodRuntimeManifest -RuntimeDirectory $stagingDirectory -ExpectedRuntimeId $runtimeId if (-not $validation.Valid) { diff --git a/tests/persistence/InstallLifecycle.SelfTest.ps1 b/tests/persistence/InstallLifecycle.SelfTest.ps1 index 538a5bd..55e5af8 100644 --- a/tests/persistence/InstallLifecycle.SelfTest.ps1 +++ b/tests/persistence/InstallLifecycle.SelfTest.ps1 @@ -88,6 +88,20 @@ function New-CcodLifecyclePayloadManifest { 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 @@ -681,8 +695,9 @@ $results += Invoke-CcodTest 'upgrade rejects a payload version mismatch before a $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 -ExpectedVersion '2.5.22' -PayloadManifestPath $manifestPath -Adapters $fake.Adapters | Out-Null + Invoke-CcodInstall -SourceRoot $source -InstallRoot $install @payloadBinding -Adapters $fake.Adapters | Out-Null } 'CCOD_INSTALL_PAYLOAD_VERSION_MISMATCH' $after = Read-CcodActiveRuntime -InstallRoot $install @@ -707,7 +722,8 @@ $results += Invoke-CcodTest 'matching payload stages only immutable manifest-lis [IO.File]::WriteAllText($stalePath,"module.exports = 'stale';`n",[Text.UTF8Encoding]::new($false)) $nodePath = New-CcodLifecycleFakeNode -Root $nodeRoot - $receipt = Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -ExpectedVersion '2.5.22' -PayloadManifestPath $manifestPath -Adapters (New-CcodLifecycleFake -NodePath $nodePath).Adapters + $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' @@ -737,8 +753,9 @@ $results += Invoke-CcodTest 'payload-bound install rereads the active pointer af [pscustomobject][ordered]@{ SupervisorReady = $true; TrayReady = $true } }.GetNewClosure() + $payloadBinding = Get-CcodLifecyclePayloadBinding -ManifestPath $manifestPath -ExpectedVersion '2.5.22' Assert-CcodThrows { - Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -ExpectedVersion '2.5.22' -PayloadManifestPath $manifestPath -Adapters $fake.Adapters | Out-Null + 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 { @@ -770,7 +787,8 @@ $results += Invoke-CcodTest 'stable bootstrap and uninstaller come from manifest }.GetNewClosure() } - $receipt = Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -ExpectedVersion '2.5.22' -PayloadManifestPath $manifestPath -Adapters $fake.Adapters + $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 $runtimeRoot 'src\persistence\bootstrap.ps1')) (Get-CcodTestFileSha256 -Path (Join-Path $install 'bootstrap.ps1')) 'stable bootstrap equals its verified runtime byte copy' @@ -861,6 +879,40 @@ $results += Invoke-CcodTest 'stable bootstrap leaf junction is rejected before c } } +# Production mutation caught: treating a non-reparse hard link as a safe overwrite target and changing bytes outside the install root. +$results += Invoke-CcodTest 'stable bootstrap and public uninstaller reject hard-linked target leaves before outside bytes change' { + foreach ($case in @( + [pscustomobject]@{Name='bootstrap';Leaf='bootstrap.ps1';AddUninstaller=$false}, + [pscustomobject]@{Name='uninstaller';Leaf='Uninstall-CodexControlOtherDevices.ps1';AddUninstaller=$true} + )) { + $source = New-CcodLifecycleTempRoot + $install = New-CcodLifecycleTempRoot + $outside = New-CcodLifecycleTempRoot + $nodeRoot = New-CcodLifecycleTempRoot + try { + New-CcodLifecycleSourceFixture -Root $source | Out-Null + if ($case.AddUninstaller) { + [IO.File]::WriteAllText((Join-Path $source 'Uninstall-CodexControlOtherDevices.ps1'),'# verified uninstaller',[Text.UTF8Encoding]::new($false)) + } + [IO.Directory]::CreateDirectory($install) | Out-Null + [IO.Directory]::CreateDirectory($outside) | Out-Null + $sentinel = Join-Path $outside ($case.Name + '-sentinel.txt') + [IO.File]::WriteAllText($sentinel,'outside-original',[Text.UTF8Encoding]::new($false)) + $targetLeaf = Join-Path $install $case.Leaf + New-Item -ItemType HardLink -Path $targetLeaf -Target $sentinel | 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_UNSAFE_LEAF' + Assert-CcodEqual 'outside-original' ([IO.File]::ReadAllText($sentinel,[Text.UTF8Encoding]::new($false))) "$($case.Name) hard-link sentinel remains unchanged" + Assert-CcodTrue (-not [IO.File]::Exists((Join-Path $install 'active.json'))) "$($case.Name) hard-link rejection cannot commit an active pointer" + } finally { + foreach ($path in @($source,$install,$outside,$nodeRoot)) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force -ErrorAction SilentlyContinue } } + } + } +} + $results += Invoke-CcodTest 'runtime manifest leaf junction is rejected before write' { $source = New-CcodLifecycleTempRoot $install = New-CcodLifecycleTempRoot @@ -930,7 +982,8 @@ $results += Invoke-CcodTest 'payload manifest ancestry treats Windows root equal New-CcodLifecycleSourceFixture -Root $source -Version '2.5.22' | Out-Null $manifestPath = New-CcodLifecyclePayloadManifest -Root $source -Version '2.5.22' $nodePath = New-CcodLifecycleFakeNode -Root $nodeRoot - $receipt = Invoke-CcodInstall -SourceRoot $source.ToUpperInvariant() -InstallRoot $install -ExpectedVersion '2.5.22' -PayloadManifestPath $manifestPath -Adapters (New-CcodLifecycleFake -NodePath $nodePath).Adapters + $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 } } @@ -954,8 +1007,9 @@ $results += Invoke-CcodTest 'payload-bound install rejects a changed runtime man [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 -ExpectedVersion '2.5.22' -PayloadManifestPath $manifestPath -Adapters $fake.Adapters | Out-Null + Invoke-CcodInstall -SourceRoot $source -InstallRoot $install @payloadBinding -Adapters $fake.Adapters | Out-Null } 'CCOD_INSTALL_RUNTIME_ACTIVATION_UNPROVEN' Assert-CcodTrue ($fake.World.Phases -notcontains 'Ready') 'changed selected runtime manifest cannot produce Ready' } finally { diff --git a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 index 3fa3cea..e75107d 100644 --- a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 +++ b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 @@ -1211,7 +1211,7 @@ Invoke-CcodTest 'setup build and activation bind one immutable versioned payload Assert-CcodTrue ($inno -cmatch 'InstallerPayloadDirectory' -and $inno -cmatch 'DestDir:\s*"\{app\}\\payload\\\{#ProjectVersion\}"') 'Inno copies the immutable build payload into its exact version directory' Assert-CcodTrue ($inno -cmatch "ExpandConstant\('\{app\}\\payload\\\{#ProjectVersion\}'\)" -and $inno -cmatch '-ExpectedVersion\s+"\{#ProjectVersion\}') 'Inno binds activation to its compiled payload version' Assert-CcodTrue ($activation -cmatch '\[string\]\$ExpectedVersion' -and $activation -cmatch '\[string\]\$ExpectedPayloadManifestSha256' -and $activation -cmatch 'installer-payload\.manifest\.json') 'activation accepts and resolves the expected payload contract' - Assert-CcodTrue ($installer -cmatch '\[string\]\$ExpectedVersion' -and $installer -cmatch '\[string\]\$PayloadManifestPath' -and $installer -cmatch '-ExpectedVersion\s+\$ExpectedVersion' -and $installer -cmatch '-PayloadManifestPath\s+\$PayloadManifestPath') 'installer forwards the immutable payload contract to lifecycle activation' + Assert-CcodTrue ($installer -cmatch '\[string\]\$ExpectedVersion' -and $installer -cmatch '\[string\]\$PayloadManifestPath' -and $installer -cmatch '\[string\]\$ExpectedPayloadManifestSha256' -and $installer -cmatch 'Open-CcodInstallerPayloadSeal' -and $installer -cmatch 'PayloadManifestBytesBase64') 'installer revalidates and forwards the exact immutable payload bytes to lifecycle activation' } # Production mutation caught: reopening mutable payload paths after the parent accepted their manifest bytes. @@ -1434,6 +1434,9 @@ 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;') @@ -1452,6 +1455,9 @@ Invoke-CcodTest 'Inno exposes a pre-write payload-directory reparse gate' { $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' @@ -1466,6 +1472,9 @@ Invoke-CcodTest 'Inno exposes a pre-write payload-directory reparse gate' { [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((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' @@ -1485,10 +1494,60 @@ Uninstallable=no const CCOD_FILE_ATTRIBUTE_DIRECTORY = `$00000010; CCOD_FILE_ATTRIBUTE_REPARSE_POINT = `$00000400; + CCOD_FILE_READ_ATTRIBUTES = `$00000080; + 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_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) @@ -1498,11 +1557,14 @@ begin 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 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 @@ -1519,6 +1581,7 @@ end; 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' } finally { if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } } From ca5a5f71f0348d8f6cb61b033405dfcaac6917ef Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:42:46 +0800 Subject: [PATCH 052/187] fix: bind setup artifact provenance --- CHANGELOG.md | 8 +- README.md | 2 +- README.zh-CN.md | 2 +- build/CodexControlOtherDevices.iss | 14 ++ build/SetupArtifact.psm1 | 185 ++++++++++++++++++ build/build.ps1 | 90 ++++++--- .../persistence/ReleaseWorkflow.SelfTest.ps1 | 89 ++++++++- tools/New-InstallerDestinationInventory.ps1 | 8 +- tools/Test-ReleaseDefender.ps1 | 21 +- 9 files changed, 378 insertions(+), 41 deletions(-) create mode 100644 build/SetupArtifact.psm1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 78dac76..e28d9b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,15 +10,15 @@ No unreleased changes. ### English -- Bound Setup activation to the requested version and a hash-verified installer payload manifest, then revalidated the active runtime before reporting Ready. -- Restricted Setup compilation to one generated, inventoried input and rejected unapproved preprocessor constructs before ISCC runs. +- Bound Setup activation to the exact verified manifest, installer, and module bytes across child launch, then revalidated the selected runtime before reporting Ready. +- Rejected hard-linked install leaves before writes and added independent Setup PE/provenance validation for version, commit, and the activation 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. ### 简体中文 -- 将 Setup 激活绑定到请求版本和经哈希校验的安装载荷 manifest,并在报告 Ready 前重新校验活动 runtime。 -- Setup 只编译一份已清点目录的生成输入;任何未获准的预处理构造都会在 ISCC 运行前被拒绝。 +- 将 Setup 激活绑定到跨子进程启动仍保持不变的 manifest、installer 与 module 精确字节,并在报告 Ready 前重新校验所选 runtime。 +- 在写入前拒绝安装目录中的硬链接叶子,并独立校验 Setup PE/构建 provenance 所绑定的版本、commit 与激活 payload-manifest 哈希。 - 为同一根进程加入有界 process-tree 重读,同时在关闭和远程重绑定时继续严格校验根进程与每个成员的身份。 - 将原生托盘操作绑定到已确认的 presentation revision 和持久、有界的终态诊断;过期操作会记录清理后的支持代码。 diff --git a/README.md b/README.md index dd815b0..76c384a 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@

> [!NOTE] -> **Release status:** v2.5.22 is the current stable release. The setup installer and EXE-based portable ZIP are version- and manifest-bound release assets. +> **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: diff --git a/README.zh-CN.md b/README.zh-CN.md index 7acbcf6..ba6c6fe 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -16,7 +16,7 @@

> [!NOTE] -> **发布状态:** v2.5.22 是当前稳定版。安装包与以 EXE 启动的便携 ZIP 都是绑定版本和 manifest 的发布资产。 +> **发布状态:** v2.5.22 是当前候选发布版。安装包与以 EXE 启动的便携 ZIP 都已绑定版本和 manifest;Windows 稳定版验收仍需记录安装、升级、重启、修复、UI 与 Defender 的实机验证结果。 CodexRemote-fix 在 Windows 版 Codex Desktop 中启用随应用一起打包、但因运行时缺陷被隐藏的: diff --git a/build/CodexControlOtherDevices.iss b/build/CodexControlOtherDevices.iss index b99a09d..c77f35c 100644 --- a/build/CodexControlOtherDevices.iss +++ b/build/CodexControlOtherDevices.iss @@ -11,6 +11,12 @@ #ifndef InstallerPayloadManifestSha256 #error InstallerPayloadManifestSha256 must be supplied by the release builder #endif +#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 AppVersion={#ProjectVersion} @@ -20,6 +26,12 @@ 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 +VersionInfoTextVersion={#ProjectVersion}.0 +VersionInfoProductName=CodexRemote-fix +VersionInfoProductTextVersion={#ProjectVersion}.0 +VersionInfoDescription=CCODSETUP {#ProjectVersion} +VersionInfoCompany={#SetupGitCommit} +VersionInfoCopyright={#InstallerPayloadManifestSha256} DefaultDirName={localappdata}\CodexControlOtherDevices-installer DefaultGroupName=CodexRemote-fix UsePreviousGroup=no @@ -43,6 +55,7 @@ MinVersion=10.0.17763 Name: "english"; MessagesFile: "compiler:Default.isl" [Files] +Source: "{#SetupProvenancePath}"; DestDir: "{app}"; DestName: "setup-provenance.json"; Flags: dontcopy Source: "..\README.md"; DestDir: "{app}"; Flags: ignoreversion Source: "..\README.zh-CN.md"; DestDir: "{app}"; Flags: ignoreversion Source: "..\LICENSE"; DestDir: "{app}"; Flags: ignoreversion @@ -51,6 +64,7 @@ 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\SetupArtifact.psm1"; 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 diff --git a/build/SetupArtifact.psm1 b/build/SetupArtifact.psm1 new file mode 100644 index 0000000..58995fe --- /dev/null +++ b/build/SetupArtifact.psm1 @@ -0,0 +1,185 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = '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 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, + [Parameter(Mandatory)][ValidatePattern('^[0-9a-f]{64}$')][string]$ExpectedPayloadManifestSha256 + ) + + $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() + if ($fileVersion -cne $expectedPeVersion -or $productVersion -cne $expectedPeVersion) { + 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 + } + if ($productName -cne 'CodexRemote-fix' -or $fileDescription -cne $expectedDescription -or + $companyName -cne $ExpectedGitCommit -or $legalCopyright -cne $ExpectedPayloadManifestSha256) { + Throw-CcodSetupArtifactError 'CCOD_SETUP_PAYLOAD_BINDING_INVALID' ("Setup PE does not bind the expected version, commit, and activation payload manifest hash: product={0}; description={1}; company={2}; copyright={3}" -f $productName,$fileDescription,$companyName,$legalCopyright) $setup + } + return [pscustomobject][ordered]@{ + Valid = $true + Sha256 = Get-CcodSetupArtifactHash -Path $setup + FileVersion = $fileVersion + ProductVersion = $productVersion + ProductName = $productName + FileDescription = $fileDescription + CompanyName = $companyName + LegalCopyright = $legalCopyright + } +} + +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 ($payloadRecord.schemaVersion -isnot [int] -or $payloadRecord.schemaVersion -ne 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 + ) + $path = Assert-CcodSetupRegularFile -Path $ProvenancePath -Kind 'Setup provenance' + try { $raw = [IO.File]::ReadAllText($path,[Text.UTF8Encoding]::new($false)); $record = $raw | ConvertFrom-Json -ErrorAction Stop } + catch { Throw-CcodSetupArtifactError 'CCOD_SETUP_PROVENANCE_INVALID' 'Setup provenance is invalid JSON' $path } + $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 + $record.schemaVersion -isnot [int] -or $record.schemaVersion -ne 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 ([string]$record.buildTimestampUtc)) -or + (-not [string]::IsNullOrWhiteSpace($ExpectedBuildTimestampUtc) -and [string]$record.buildTimestampUtc -cne $ExpectedBuildTimestampUtc) -or + $record.payloadManifest.sha256 -isnot [string] -or $record.payloadManifest.sha256 -cne $ExpectedPayloadManifestSha256 -or + $record.payloadManifest.name -isnot [string] -or $record.payloadManifest.name -cne 'installer-payload.manifest.json' -or + [int64]$record.payloadManifest.length -le 0 -or [int]$record.payloadManifest.fileCount -le 0 -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 -cnotmatch '^[0-9a-f]{64}$' -or + $record.buildInputs.destinationInventorySha256 -isnot [string] -or $record.buildInputs.destinationInventorySha256 -cnotmatch '^[0-9a-f]{64}$' -or + $record.buildInputs.compilerSha256 -isnot [string] -or $record.buildInputs.compilerSha256 -cnotmatch '^[0-9a-f]{64}$' -or + $record.buildInputs.compilerFileVersion -isnot [string] -or [string]::IsNullOrWhiteSpace([string]$record.buildInputs.compilerFileVersion)) { + Throw-CcodSetupArtifactError 'CCOD_SETUP_PROVENANCE_INVALID' 'Setup provenance is not exactly bound to the release contract' $path + } + return $record +} + +Export-ModuleMember -Function Get-CcodSetupArtifactHash,Get-CcodSetupDescription,New-CcodSetupBuildProvenance,Test-CcodSetupBuildProvenance,Test-CcodSetupArtifact diff --git a/build/build.ps1 b/build/build.ps1 index 8b900c8..c9cdf94 100644 --- a/build/build.ps1 +++ b/build/build.ps1 @@ -130,6 +130,10 @@ function Assert-CcodBuildInnoPreprocessorLines { '#error InstallerPayloadDirectory must be supplied by the release builder', '#ifndef InstallerPayloadManifestSha256', '#error InstallerPayloadManifestSha256 must be supplied by the release builder', + '#ifndef SetupGitCommit', + '#error SetupGitCommit must be supplied by the release builder', + '#ifndef SetupProvenancePath', + '#error SetupProvenancePath must be supplied by the release builder', '#endif' ) $allowedInlineConstructs = @( @@ -137,7 +141,9 @@ function Assert-CcodBuildInnoPreprocessorLines { '{#TrayHostArtifactDirectory}', '{#PortableArtifactDirectory}', '{#InstallerPayloadDirectory}', - '{#InstallerPayloadManifestSha256}' + '{#InstallerPayloadManifestSha256}', + '{#SetupGitCommit}', + '{#SetupProvenancePath}' ) for ($lineIndex = 0; $lineIndex -lt $Lines.Count; $lineIndex++) { $line = [string]$Lines[$lineIndex] @@ -249,6 +255,50 @@ function Invoke-CcodBuildInnoCompiler { } } +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 @@ -280,13 +330,16 @@ $payloadManifestAsset = Join-Path $dist "CodexRemote-fix-$Version-payload-manife $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)) { +$setupProvenance = Join-Path $dist "CodexRemote-fix-$Version-setup-provenance.json" +$setupReleaseManifest = Join-Path $dist "CodexRemote-fix-$Version-setup-release-manifest.json" +foreach ($path in @($bundle,$checksum,$provenance,$payloadManifestAsset,$releaseManifest,$setupExe,$setupChecksum,$setupProvenance,$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' @@ -427,18 +480,23 @@ $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' +Import-Module (Join-Path $PSScriptRoot 'SetupArtifact.psm1') -Force +$setupProvenanceRecord = New-CcodSetupBuildProvenance -Version $Version -GitCommit $gitCommit -BuildTimestampUtc $buildTimestampUtc -PayloadManifestPath $installerPayloadManifestPath -InnoTemplatePath $issPath -DestinationInventoryPath $installerDestinationInventoryPath -CompilerPath $iscc -OutputPath $setupProvenance +Test-CcodSetupBuildProvenance -ProvenancePath $setupProvenance -ExpectedVersion $Version -ExpectedGitCommit $gitCommit -ExpectedPayloadManifestSha256 $installerPayloadManifestSha256 -ExpectedBuildTimestampUtc $buildTimestampUtc | Out-Null $isccArguments = @( "/DProjectVersion=$Version", "/DTrayHostArtifactDirectory=$trayHostArtifact", "/DPortableArtifactDirectory=$portableArtifact", "/DInstallerPayloadDirectory=$installerPayloadDirectory", "/DInstallerPayloadManifestSha256=$installerPayloadManifestSha256", + "/DSetupGitCommit=$gitCommit", + "/DSetupProvenancePath=$setupProvenance", "/O$dist\." ) Invoke-CcodBuildInnoCompiler -TemplatePath $issPath -InventoryPath $installerDestinationInventoryPath -IsccPath $iscc -Arguments $isccArguments -SetupPath $setupExe -$setupHash = Get-CcodBuildFileSha256 -Path $setupExe +$setupValidation = Test-CcodSetupArtifact -SetupPath $setupExe -ExpectedVersion $Version -ExpectedGitCommit $gitCommit -ExpectedPayloadManifestSha256 $installerPayloadManifestSha256 +$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' @@ -448,7 +506,8 @@ $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 } ) } Write-CcodBuildUtf8 -Path $setupReleaseManifest -Text (($setupRecord | ConvertTo-Json -Depth 8) + [Environment]::NewLine) @@ -458,24 +517,7 @@ 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 '' - -if ([IO.Directory]::Exists($installerPayloadDirectory)) { - $installerPayloadFull = [IO.Path]::GetFullPath($installerPayloadDirectory) - $buildFull = [IO.Path]::GetFullPath($PSScriptRoot).TrimEnd('\') + '\' - if ($installerPayloadFull.StartsWith($buildFull,[StringComparison]::OrdinalIgnoreCase) -and - [IO.Path]::GetFileName($installerPayloadFull).StartsWith('.installer-payload-stage-',[StringComparison]::Ordinal) -and - -not ((Get-Item -LiteralPath $installerPayloadFull -Force).Attributes -band [IO.FileAttributes]::ReparsePoint)) { - Remove-Item -LiteralPath $installerPayloadFull -Recurse -Force -ErrorAction SilentlyContinue - } -} -if ([IO.File]::Exists($installerDestinationInventoryPath)) { - $inventoryFull = [IO.Path]::GetFullPath($installerDestinationInventoryPath) - $buildFull = [IO.Path]::GetFullPath($PSScriptRoot).TrimEnd('\') + '\' - if ($inventoryFull.StartsWith($buildFull,[StringComparison]::OrdinalIgnoreCase) -and - [IO.Path]::GetFileName($inventoryFull).StartsWith('.installer-destination-inventory-',[StringComparison]::Ordinal) -and - -not ((Get-Item -LiteralPath $inventoryFull -Force).Attributes -band [IO.FileAttributes]::ReparsePoint)) { - Remove-Item -LiteralPath $inventoryFull -Force -ErrorAction SilentlyContinue - } -} +}.GetNewClosure()) diff --git a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 index e75107d..85efd25 100644 --- a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 +++ b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 @@ -165,28 +165,52 @@ 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)) + $commit = 'a' * 40 + $payloadHash = 'd' * 64 + $typeName = 'SetupFixture' + [guid]::NewGuid().ToString('N') + $setupSource = @" +using System.Reflection; +[assembly: AssemblyVersion("2.5.0.0")] +[assembly: AssemblyFileVersion("2.5.0.0")] +[assembly: AssemblyInformationalVersion("2.5.0.0")] +[assembly: AssemblyProduct("CodexRemote-fix")] +[assembly: AssemblyTitle("CCODSETUP 2.5.0")] +[assembly: AssemblyDescription("CCODSETUP 2.5.0")] +[assembly: AssemblyCompany("$commit")] +[assembly: AssemblyCopyright("$payloadHash")] +public static class $typeName { public static int Main() { return 0; } } +"@ + Add-Type -TypeDefinition $setupSource -Language CSharp -OutputAssembly $installer -OutputType ConsoleApplication $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)) + [IO.File]::WriteAllText($trayHost, ('{"schemaVersion":1,"product":"CodexRemote-fix","version":"2.5.0","gitCommit":"' + $commit + '","buildTimestampUtc":"2026-08-24T00:00:00.0000000Z"}'), [Text.UTF8Encoding]::new($false)) + $setupProvenance = Join-Path $root 'CodexRemote-fix-2.5.0-setup-provenance.json' + $setupProvenanceRecord = [ordered]@{ + schemaVersion=1;product='CodexRemote-fix';version='2.5.0';gitCommit=$commit;buildTimestampUtc='2026-08-24T00:00:00.0000000Z' + payloadManifest=[ordered]@{name='installer-payload.manifest.json';length=[int64]123;sha256=$payloadHash;fileCount=3} + buildInputs=[ordered]@{innoTemplateSha256=('1'*64);destinationInventorySha256=('2'*64);compilerSha256=('3'*64);compilerFileVersion='6.7.3.0'} + peContract=[ordered]@{fileVersion='2.5.0.0';productVersion='2.5.0.0';productName='CodexRemote-fix';fileDescription='CCODSETUP 2.5.0';companyName=$commit;legalCopyright=$payloadHash} + } + [IO.File]::WriteAllText($setupProvenance,(($setupProvenanceRecord|ConvertTo-Json -Depth 8)+"`n"),[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 } + [ordered]@{ name = [IO.Path]::GetFileName($trayHost); sha256 = Get-CcodTestFileSha256 -Path $trayHost }, + [ordered]@{ name = [IO.Path]::GetFileName($setupProvenance); sha256 = Get-CcodTestFileSha256 -Path $setupProvenance } ) $record = [ordered]@{ schemaVersion = 1 product = 'CodexRemote-fix' version = '2.5.0' - gitCommit = ('a' * 40) + gitCommit = $commit buildTimestampUtc = '2026-08-24T00:00:00.0000000Z' assets = $assets } [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 } + return [pscustomobject]@{ Root = $root; Installer = $installer; Checksum = $checksum; TrayHost = $trayHost; SetupProvenance = $setupProvenance; Manifest = $manifest; PayloadManifestSha256 = $payloadHash } } function New-CcodPortableReleaseFixture { @@ -1165,9 +1189,13 @@ function Invoke-CcodInnoPayloadCompileFixture { (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' } - $arguments = @('/DProjectVersion=2.5.21',"/DTrayHostArtifactDirectory=$tray","/DPortableArtifactDirectory=$portable","/O$output\") + $payloadManifestSha256 = Get-CcodTestFileSha256 -Path $manifestPath + $setupGitCommit = 'c' * 40 + $setupProvenancePath = Join-Path $root 'setup-provenance.json' + [IO.File]::WriteAllText($setupProvenancePath,'{}',[Text.UTF8Encoding]::new($false)) + $arguments = @('/DProjectVersion=2.5.21',"/DTrayHostArtifactDirectory=$tray","/DPortableArtifactDirectory=$portable","/DSetupGitCommit=$setupGitCommit","/DSetupProvenancePath=$setupProvenancePath","/O$output\") if ($IncludePayloadDefines) { - $arguments = @('/DProjectVersion=2.5.21',"/DTrayHostArtifactDirectory=$tray","/DPortableArtifactDirectory=$portable","/DInstallerPayloadDirectory=$payload","/DInstallerPayloadManifestSha256=$(Get-CcodTestFileSha256 -Path $manifestPath)") + $arguments = @('/DProjectVersion=2.5.21',"/DTrayHostArtifactDirectory=$tray","/DPortableArtifactDirectory=$portable","/DInstallerPayloadDirectory=$payload","/DInstallerPayloadManifestSha256=$payloadManifestSha256","/DSetupGitCommit=$setupGitCommit","/DSetupProvenancePath=$setupProvenancePath") $arguments += "/O$output\" } . (Join-Path $repositoryRoot 'build\build.ps1') -Library @@ -1198,6 +1226,8 @@ function Invoke-CcodInnoPayloadCompileFixture { SetupPath = (Join-Path $output 'CodexRemote-fix-2.5.21-setup.exe') GeneratedPath = $generatedPath GeneratedSource = $generatedSource + PayloadManifestSha256 = $payloadManifestSha256 + SetupGitCommit = $setupGitCommit } } @@ -1418,6 +1448,47 @@ Invoke-CcodTest 'Inno compile packages the explicit manifest-bound installer pay } } +# 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 versions commit and activation payload manifest hash' { + $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 -ExpectedPayloadManifestSha256 $compile.PayloadManifestSha256 + 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 '2.5.21.0' ([string]$validated.ProductVersion) 'Setup PE ProductVersion is exact' + Assert-CcodThrows { + Test-CcodSetupArtifact -SetupPath $compile.SetupPath -ExpectedVersion '2.5.21' -ExpectedGitCommit $compile.SetupGitCommit -ExpectedPayloadManifestSha256 ('0' * 64) | Out-Null + } 'CCOD_SETUP_PAYLOAD_BINDING_INVALID' + } finally { + if (Test-Path -LiteralPath $compile.Root) { Remove-Item -LiteralPath $compile.Root -Recurse -Force } + } +} + +# 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 { @@ -1999,7 +2070,7 @@ Invoke-CcodTest '2.5.22 source metadata and documentation match the release cont 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 stable release')) 'English README marks v2.5.22 stable without a version-by-version What''s new block' + 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' @@ -2010,7 +2081,7 @@ Invoke-CcodTest '2.5.22 source metadata and documentation match the release cont 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\u7A33\u5B9A\u7248') 'Chinese README marks v2.5.22 stable' + 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' diff --git a/tools/New-InstallerDestinationInventory.ps1 b/tools/New-InstallerDestinationInventory.ps1 index 57f543f..7cb2706 100644 --- a/tools/New-InstallerDestinationInventory.ps1 +++ b/tools/New-InstallerDestinationInventory.ps1 @@ -43,6 +43,10 @@ function Assert-CcodInstallerInnoPreprocessorLines { '#error InstallerPayloadDirectory must be supplied by the release builder', '#ifndef InstallerPayloadManifestSha256', '#error InstallerPayloadManifestSha256 must be supplied by the release builder', + '#ifndef SetupGitCommit', + '#error SetupGitCommit must be supplied by the release builder', + '#ifndef SetupProvenancePath', + '#error SetupProvenancePath must be supplied by the release builder', '#endif' ) $allowedInlineConstructs = @( @@ -50,7 +54,9 @@ function Assert-CcodInstallerInnoPreprocessorLines { '{#TrayHostArtifactDirectory}', '{#PortableArtifactDirectory}', '{#InstallerPayloadDirectory}', - '{#InstallerPayloadManifestSha256}' + '{#InstallerPayloadManifestSha256}', + '{#SetupGitCommit}', + '{#SetupProvenancePath}' ) for ($lineIndex = 0; $lineIndex -lt $Lines.Count; $lineIndex++) { $line = [string]$Lines[$lineIndex] diff --git a/tools/Test-ReleaseDefender.ps1 b/tools/Test-ReleaseDefender.ps1 index 3a07321..ab73cdb 100644 --- a/tools/Test-ReleaseDefender.ps1 +++ b/tools/Test-ReleaseDefender.ps1 @@ -486,7 +486,8 @@ function Test-CcodReleaseAssetManifest { $expectedNames = @( "CodexRemote-fix-$ExpectedVersion-setup.exe", "CodexRemote-fix-$ExpectedVersion-setup.exe.sha256.txt", - "CodexRemote-fix-$ExpectedVersion-trayhost-provenance.json" + "CodexRemote-fix-$ExpectedVersion-trayhost-provenance.json", + "CodexRemote-fix-$ExpectedVersion-setup-provenance.json" ) if ($assets.Count -ne $expectedNames.Count) { Throw-CcodReleaseDefenderError 'CCOD_RELEASE_MANIFEST_INVALID' 'Release manifest does not bind the exact required asset set' $manifestFile } $assetHashes = @{} @@ -530,6 +531,22 @@ function Test-CcodReleaseAssetManifest { $trayHostTimestampText -cne $manifestTimestamp) { Throw-CcodReleaseDefenderError 'CCOD_RELEASE_MANIFEST_INVALID' 'TrayHost provenance is not bound to the release version, source commit, and timestamp' $expectedNames[2] } + $setupProvenancePath = Join-Path $directory $expectedNames[3] + $setupArtifactModule = Join-Path (Split-Path $PSScriptRoot -Parent) 'build\SetupArtifact.psm1' + if (-not [IO.File]::Exists($setupArtifactModule)) { + Throw-CcodReleaseDefenderError 'CCOD_RELEASE_MANIFEST_INVALID' 'Setup artifact validator is missing' $setupArtifactModule + } + Import-Module $setupArtifactModule -Force + try { + $setupRaw = [IO.File]::ReadAllText($setupProvenancePath) + $setupRecord = $setupRaw | ConvertFrom-Json -ErrorAction Stop + $payloadHash = [string]$setupRecord.payloadManifest.sha256 + if ($payloadHash -cnotmatch '^[0-9a-f]{64}$') { throw 'payload hash' } + Test-CcodSetupBuildProvenance -ProvenancePath $setupProvenancePath -ExpectedVersion $ExpectedVersion -ExpectedGitCommit ([string]$manifest.gitCommit) -ExpectedPayloadManifestSha256 $payloadHash -ExpectedBuildTimestampUtc $manifestTimestamp | Out-Null + Test-CcodSetupArtifact -SetupPath $installer -ExpectedVersion $ExpectedVersion -ExpectedGitCommit ([string]$manifest.gitCommit) -ExpectedPayloadManifestSha256 $payloadHash | Out-Null + } catch { + Throw-CcodReleaseDefenderError 'CCOD_RELEASE_MANIFEST_INVALID' ('Setup provenance or final PE contract is invalid: ' + $_.Exception.Message) $setupProvenancePath + } return [pscustomobject][ordered]@{ Valid = $true Version = $ExpectedVersion @@ -537,6 +554,8 @@ function Test-CcodReleaseAssetManifest { BuildTimestampUtc = $manifestTimestamp InstallerSha256 = [string]$assetHashes[$expectedNames[0]] InstallerName = $expectedNames[0] + PayloadManifestSha256 = $payloadHash + SetupProvenanceName = $expectedNames[3] } } From 1f4259f7003d29d1023a567d15c877c22dfabd6c Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:45:22 +0800 Subject: [PATCH 053/187] fix: validate setup provenance across hosts --- build/SetupArtifact.psm1 | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/build/SetupArtifact.psm1 b/build/SetupArtifact.psm1 index 58995fe..4458e71 100644 --- a/build/SetupArtifact.psm1 +++ b/build/SetupArtifact.psm1 @@ -158,12 +158,14 @@ function Test-CcodSetupBuildProvenance { $path = Assert-CcodSetupRegularFile -Path $ProvenancePath -Kind 'Setup provenance' try { $raw = [IO.File]::ReadAllText($path,[Text.UTF8Encoding]::new($false)); $record = $raw | ConvertFrom-Json -ErrorAction Stop } 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 - $record.schemaVersion -isnot [int] -or $record.schemaVersion -ne 1 -or $record.product -isnot [string] -or $record.product -cne 'CodexRemote-fix' -or + ($record.schemaVersion -isnot [int] -and $record.schemaVersion -isnot [long]) -or [int64]$record.schemaVersion -ne 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 ([string]$record.buildTimestampUtc)) -or - (-not [string]::IsNullOrWhiteSpace($ExpectedBuildTimestampUtc) -and [string]$record.buildTimestampUtc -cne $ExpectedBuildTimestampUtc) -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.name -isnot [string] -or $record.payloadManifest.name -cne 'installer-payload.manifest.json' -or [int64]$record.payloadManifest.length -le 0 -or [int]$record.payloadManifest.fileCount -le 0 -or From 5293182b8e5fb6653b5261dd7b039760a5eeab06 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:48:58 +0800 Subject: [PATCH 054/187] docs: record final security fix evidence --- .../task-5-report.md | 229 ++++++++++++++++++ 1 file changed, 229 insertions(+) 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 index f883720..b2dccac 100644 --- 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 @@ -340,3 +340,232 @@ 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. From f1ee9b7b8a592355f10e71e6b1d486ea9d29f876 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:05:51 +0800 Subject: [PATCH 055/187] test: expose linked whole-install deletion --- .../persistence/InstallLifecycle.SelfTest.ps1 | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/persistence/InstallLifecycle.SelfTest.ps1 b/tests/persistence/InstallLifecycle.SelfTest.ps1 index 55e5af8..8672679 100644 --- a/tests/persistence/InstallLifecycle.SelfTest.ps1 +++ b/tests/persistence/InstallLifecycle.SelfTest.ps1 @@ -913,6 +913,36 @@ $results += Invoke-CcodTest 'stable bootstrap and public uninstaller reject hard } } +# 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 { + $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 { + foreach ($path in @($install,$outside)) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force -ErrorAction SilentlyContinue } } + } +} + $results += Invoke-CcodTest 'runtime manifest leaf junction is rejected before write' { $source = New-CcodLifecycleTempRoot $install = New-CcodLifecycleTempRoot From 1a4abece619f47115b9d9564777de58471eedbc6 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:08:47 +0800 Subject: [PATCH 056/187] fix: preflight whole-install tree deletion --- src/persistence/modules/InstallLifecycle.psm1 | 1 + 1 file changed, 1 insertion(+) diff --git a/src/persistence/modules/InstallLifecycle.psm1 b/src/persistence/modules/InstallLifecycle.psm1 index e2a7e12..8385b5c 100644 --- a/src/persistence/modules/InstallLifecycle.psm1 +++ b/src/persistence/modules/InstallLifecycle.psm1 @@ -1803,6 +1803,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) } From b14510876ea9f5c85521de3e652f020acdb656f0 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:09:27 +0800 Subject: [PATCH 057/187] docs: record whole-install delete fix --- .../task-5-report.md | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) 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 index b2dccac..25e18fd 100644 --- 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 @@ -569,3 +569,72 @@ ca5a5f7 fix: bind setup artifact provenance - 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. From 2633509a33bc43222d7bc65fc06558b5d3fcacf3 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:41:04 +0800 Subject: [PATCH 058/187] test: expose activation stage substitution --- .../persistence/ReleaseWorkflow.SelfTest.ps1 | 71 ++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 index 85efd25..331b6b0 100644 --- a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 +++ b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 @@ -1279,7 +1279,8 @@ Invoke-CcodTest 'activation executes only the verified manifest installer and mo while (-not [IO.File]::Exists($readyPath) -and [DateTime]::UtcNow -lt $deadline -and -not $process.HasExited) { [Threading.Thread]::Sleep(10) } - Assert-CcodTrue ([IO.File]::Exists($readyPath)) 'barrier proves parent payload verification completed before child execution' + $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 = @" @@ -1322,6 +1323,74 @@ Export-ModuleMember -Function Invoke-CcodFixtureInstall } } +# 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 { From 39e838f4c0160fb1879017736ecb80a4bd6f0c0e Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:41:04 +0800 Subject: [PATCH 059/187] fix: pin activation stage write boundary --- Activate-CcodRemoteFix.ps1 | 259 ++++++++++++++++++++++++++++++++++++- 1 file changed, 254 insertions(+), 5 deletions(-) diff --git a/Activate-CcodRemoteFix.ps1 b/Activate-CcodRemoteFix.ps1 index 536f0df..d479e7b 100644 --- a/Activate-CcodRemoteFix.ps1 +++ b/Activate-CcodRemoteFix.ps1 @@ -86,6 +86,242 @@ function Open-CcodActivationLockedBytes { } } +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('\') @@ -140,6 +376,10 @@ function Close-CcodActivationPayloadSeal { 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 } @@ -168,6 +408,7 @@ function New-CcodActivationPayloadSeal { [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 @@ -231,17 +472,23 @@ function New-CcodActivationPayloadSeal { 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' } - [IO.Directory]::CreateDirectory($stageRoot) | Out-Null + 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' } - [IO.Directory]::CreateDirectory((Split-Path $destination -Parent)) | Out-Null - $writer = [IO.File]::Open($destination,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) - try { $writer.Write([byte[]]$snapshot.Bytes,0,([byte[]]$snapshot.Bytes).Length); $writer.Flush($true) } finally { $writer.Dispose() } + $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) - $stageLock = Open-CcodActivationLockedBytes -Path $destination $stageLocks.Add($stageLock) if ((Get-CcodActivationBytesSha256 -Bytes $stageLock.Bytes) -cne [string]$snapshot.Sha256) { throw 'CCOD_INSTALL_FILE_HASH_MISMATCH' } } @@ -251,9 +498,11 @@ function New-CcodActivationPayloadSeal { 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 { From 7bdd78c9fdb27dc3e9ebdba02daddf0b23fc010b Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:53:50 +0800 Subject: [PATCH 060/187] test: expose setup write TOCTOU and provenance drift --- .../persistence/ReleaseWorkflow.SelfTest.ps1 | 128 +++++++++++++++++- 1 file changed, 123 insertions(+), 5 deletions(-) diff --git a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 index 331b6b0..332fde2 100644 --- a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 +++ b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 @@ -166,7 +166,12 @@ function New-CcodReleaseFixture { $null = [IO.Directory]::CreateDirectory($root) $installer = Join-Path $root 'CodexRemote-fix-2.5.0-setup.exe' $commit = 'a' * 40 - $payloadHash = 'd' * 64 + $payloadInput = Join-Path $root 'CodexRemote-fix-2.5.0-setup-payload-manifest.json' + $inventoryInput = Join-Path $root 'CodexRemote-fix-2.5.0-setup-destination-inventory.iss' + $payloadInputRecord = [ordered]@{schemaVersion=1;projectVersion='2.5.0';files=@([ordered]@{path='package.json';length=[int64]1;sha256=('d'*64)})} + [IO.File]::WriteAllText($payloadInput,($payloadInputRecord|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($inventoryInput,"procedure AddCcodExpectedSetupDirectories(Directories: TStrings);`r`nbegin`r`nend;`r`n",[Text.UTF8Encoding]::new($false)) + $payloadHash = Get-CcodTestFileSha256 -Path $payloadInput $typeName = 'SetupFixture' + [guid]::NewGuid().ToString('N') $setupSource = @" using System.Reflection; @@ -189,17 +194,22 @@ public static class $typeName { public static int Main() { return 0; } } $setupProvenance = Join-Path $root 'CodexRemote-fix-2.5.0-setup-provenance.json' $setupProvenanceRecord = [ordered]@{ schemaVersion=1;product='CodexRemote-fix';version='2.5.0';gitCommit=$commit;buildTimestampUtc='2026-08-24T00:00:00.0000000Z' - payloadManifest=[ordered]@{name='installer-payload.manifest.json';length=[int64]123;sha256=$payloadHash;fileCount=3} - buildInputs=[ordered]@{innoTemplateSha256=('1'*64);destinationInventorySha256=('2'*64);compilerSha256=('3'*64);compilerFileVersion='6.7.3.0'} + payloadManifest=[ordered]@{name='installer-payload.manifest.json';length=[int64](Get-Item -LiteralPath $payloadInput).Length;sha256=$payloadHash;fileCount=1} + buildInputs=[ordered]@{innoTemplateSha256=$(Get-CcodTestFileSha256 -Path (Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss'));destinationInventorySha256=$(Get-CcodTestFileSha256 -Path $inventoryInput);compilerSha256='PLACEHOLDER';compilerFileVersion='PLACEHOLDER'} peContract=[ordered]@{fileVersion='2.5.0.0';productVersion='2.5.0.0';productName='CodexRemote-fix';fileDescription='CCODSETUP 2.5.0';companyName=$commit;legalCopyright=$payloadHash} } + $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 + $setupProvenanceRecord.buildInputs.compilerSha256 = Get-CcodTestFileSha256 -Path $iscc + $setupProvenanceRecord.buildInputs.compilerFileVersion = [string]([Diagnostics.FileVersionInfo]::GetVersionInfo($iscc).FileVersion) [IO.File]::WriteAllText($setupProvenance,(($setupProvenanceRecord|ConvertTo-Json -Depth 8)+"`n"),[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 }, - [ordered]@{ name = [IO.Path]::GetFileName($setupProvenance); sha256 = Get-CcodTestFileSha256 -Path $setupProvenance } + [ordered]@{ name = [IO.Path]::GetFileName($setupProvenance); sha256 = Get-CcodTestFileSha256 -Path $setupProvenance }, + [ordered]@{ name = [IO.Path]::GetFileName($payloadInput); sha256 = Get-CcodTestFileSha256 -Path $payloadInput }, + [ordered]@{ name = [IO.Path]::GetFileName($inventoryInput); sha256 = Get-CcodTestFileSha256 -Path $inventoryInput } ) $record = [ordered]@{ schemaVersion = 1 @@ -210,7 +220,7 @@ public static class $typeName { public static int Main() { return 0; } } assets = $assets } [IO.File]::WriteAllText($manifest, ($record | ConvertTo-Json -Depth 8), [Text.UTF8Encoding]::new($false)) - return [pscustomobject]@{ Root = $root; Installer = $installer; Checksum = $checksum; TrayHost = $trayHost; SetupProvenance = $setupProvenance; Manifest = $manifest; PayloadManifestSha256 = $payloadHash } + return [pscustomobject]@{ Root = $root; Installer = $installer; Checksum = $checksum; TrayHost = $trayHost; SetupProvenance = $setupProvenance; PayloadInput=$payloadInput;InventoryInput=$inventoryInput; Manifest = $manifest; PayloadManifestSha256 = $payloadHash } } function New-CcodPortableReleaseFixture { @@ -1536,6 +1546,46 @@ Invoke-CcodTest 'compiled Setup independently binds PE versions commit and activ } } +# 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: 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 @@ -1581,6 +1631,16 @@ Invoke-CcodTest 'Inno exposes a pre-write payload-directory reparse gate' { 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 { @@ -1601,6 +1661,12 @@ Invoke-CcodTest 'Inno exposes a pre-write payload-directory reparse gate' { $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 @@ -1615,6 +1681,23 @@ Invoke-CcodTest 'Inno exposes a pre-write payload-directory reparse gate' { [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' @@ -1635,11 +1718,13 @@ 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; @@ -1691,7 +1776,11 @@ $($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); @@ -1699,6 +1788,23 @@ begin 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 @@ -1711,6 +1817,10 @@ begin 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' @@ -1722,7 +1832,15 @@ end; 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 } } } From f5021b06297c1718e21249b38084ff439c8ce105 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:53:50 +0800 Subject: [PATCH 061/187] fix: pin setup writes and verify provenance inputs --- .github/workflows/release.yml | 4 +- README.md | 4 +- build/CodexControlOtherDevices.iss | 171 ++++++++++++++++++++++++++++- build/SetupArtifact.psm1 | 27 +++-- build/build.ps1 | 12 +- tools/Test-ReleaseDefender.ps1 | 16 ++- 6 files changed, 217 insertions(+), 17 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index deaf09f..4db31d4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -145,7 +145,7 @@ jobs: 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') }) + $assets = @(Get-ChildItem -LiteralPath (Join-Path $PWD 'build\dist') -File | Where-Object { $_.Extension -in @('.exe', '.zip', '.txt', '.json', '.iss') }) $assetNames = @($assets | ForEach-Object { $_.Name }) $unexpected = @($existingAssets | Where-Object { $assetNames -cnotcontains $_ }) if ($unexpected.Count -ne 0) { @@ -194,7 +194,7 @@ jobs: if ($tag -cnotmatch '^v\d+\.\d+\.\d+$') { 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') }) + $assets = @(Get-ChildItem -LiteralPath (Join-Path $PWD 'build\dist') -File | Where-Object { $_.Extension -in @('.exe', '.zip', '.txt', '.json', '.iss') }) $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 '|'))) { diff --git a/README.md b/README.md index 76c384a..838f510 100644 --- a/README.md +++ b/README.md @@ -62,8 +62,8 @@ The current portable bundle, checksum, release manifest, and payload manifest ar 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 diff --git a/build/CodexControlOtherDevices.iss b/build/CodexControlOtherDevices.iss index c77f35c..e5c5a8c 100644 --- a/build/CodexControlOtherDevices.iss +++ b/build/CodexControlOtherDevices.iss @@ -56,6 +56,7 @@ Name: "english"; MessagesFile: "compiler:Default.isl" [Files] Source: "{#SetupProvenancePath}"; DestDir: "{app}"; DestName: "setup-provenance.json"; Flags: dontcopy +Source: "{#InstallerPayloadDirectory}\installer-payload.manifest.json"; DestDir: "{app}"; DestName: "setup-input-payload-manifest.json"; Flags: dontcopy Source: "..\README.md"; DestDir: "{app}"; Flags: ignoreversion Source: "..\README.zh-CN.md"; DestDir: "{app}"; Flags: ignoreversion Source: "..\LICENSE"; DestDir: "{app}"; Flags: ignoreversion @@ -121,11 +122,13 @@ 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; @@ -278,6 +281,161 @@ begin end; end; +// CCOD_SETUP_PIN_BEGIN +var + CcodSetupPinHandles: array of Integer; + +procedure AddCcodSetupPin(Handle: Integer); +var + Count: Integer; +begin + Count := GetArrayLength(CcodSetupPinHandles); + SetArrayLength(CcodSetupPinHandles, Count + 1); + CcodSetupPinHandles[Count] := Handle; +end; + +procedure CloseCcodSetupPins(); +var + Index: Integer; +begin + for Index := GetArrayLength(CcodSetupPinHandles) - 1 downto 0 do + if CcodSetupPinHandles[Index] <> CCOD_INVALID_HANDLE_VALUE then + CloseHandle(CcodSetupPinHandles[Index]); + SetArrayLength(CcodSetupPinHandles, 0); +end; + +function PinCcodExistingSetupDirectory(const DirectoryName: String): Boolean; +var + Attributes: Cardinal; + DirectoryHandle: Integer; + Information: TCcodByHandleFileInformation; +begin + Result := False; + Attributes := GetFileAttributesW(DirectoryName); + if Attributes = CCOD_INVALID_FILE_ATTRIBUTES then + begin + Result := True; + Exit; + end; + if ((Attributes and CCOD_FILE_ATTRIBUTE_DIRECTORY) = 0) or + ((Attributes and CCOD_FILE_ATTRIBUTE_REPARSE_POINT) <> 0) then Exit; + DirectoryHandle := CreateFileW(DirectoryName, CCOD_FILE_READ_ATTRIBUTES or CCOD_DELETE_ACCESS, + CCOD_FILE_SHARE_READ or CCOD_FILE_SHARE_WRITE, 0, CCOD_OPEN_EXISTING, + CCOD_FILE_FLAG_BACKUP_SEMANTICS or CCOD_FILE_FLAG_OPEN_REPARSE_POINT, 0); + if DirectoryHandle = CCOD_INVALID_HANDLE_VALUE then Exit; + if (not GetFileInformationByHandle(DirectoryHandle, Information)) or + ((Information.FileAttributes and CCOD_FILE_ATTRIBUTE_DIRECTORY) = 0) or + ((Information.FileAttributes and CCOD_FILE_ATTRIBUTE_REPARSE_POINT) <> 0) then + begin + CloseHandle(DirectoryHandle); + Exit; + end; + Attributes := GetFileAttributesW(DirectoryName); + if (Attributes = CCOD_INVALID_FILE_ATTRIBUTES) or + ((Attributes and CCOD_FILE_ATTRIBUTE_DIRECTORY) = 0) or + ((Attributes and CCOD_FILE_ATTRIBUTE_REPARSE_POINT) <> 0) then + begin + CloseHandle(DirectoryHandle); + Exit; + end; + AddCcodSetupPin(DirectoryHandle); + Result := True; +end; + +function PinCcodExistingSetupLeaf(const FileName: String): Boolean; +var + Attributes: Cardinal; + FileHandle: Integer; + Information: TCcodByHandleFileInformation; +begin + Result := False; + if not IsSafeExistingSetupLeaf(FileName) then Exit; + FileHandle := CreateFileW(FileName, CCOD_FILE_READ_ATTRIBUTES, + CCOD_FILE_SHARE_READ or CCOD_FILE_SHARE_WRITE, 0, CCOD_OPEN_EXISTING, + CCOD_FILE_FLAG_OPEN_REPARSE_POINT, 0); + if FileHandle = CCOD_INVALID_HANDLE_VALUE then Exit; + if (not GetFileInformationByHandle(FileHandle, Information)) or + (Information.NumberOfLinks <> 1) or + ((Information.FileAttributes and CCOD_FILE_ATTRIBUTE_DIRECTORY) <> 0) or + ((Information.FileAttributes and CCOD_FILE_ATTRIBUTE_REPARSE_POINT) <> 0) or + (not HasOnlyDefaultDataStream(FileName)) then + begin + CloseHandle(FileHandle); + Exit; + end; + 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 + begin + CloseHandle(FileHandle); + Exit; + end; + AddCcodSetupPin(FileHandle); + Result := True; +end; + +function PinCcodExistingSetupTree(const DirectoryName: String): Boolean; +var + Attributes: Cardinal; + FindRec: TFindRec; + ChildPath: String; +begin + Result := PinCcodExistingSetupDirectory(DirectoryName); + if not Result then Exit; + Attributes := GetFileAttributesW(DirectoryName); + if Attributes = CCOD_INVALID_FILE_ATTRIBUTES then Exit; + if FindFirst(AddBackslash(DirectoryName) + '*', FindRec) then + begin + try + repeat + if (FindRec.Name <> '.') and (FindRec.Name <> '..') then + begin + ChildPath := AddBackslash(DirectoryName) + FindRec.Name; + if ((FindRec.Attributes and CCOD_FILE_ATTRIBUTE_DIRECTORY) <> 0) then + begin + if not PinCcodExistingSetupTree(ChildPath) then + begin + Result := False; + Exit; + end; + end + else if not PinCcodExistingSetupLeaf(ChildPath) then + begin + Result := False; + Exit; + end; + end; + until not FindNext(FindRec); + finally + FindClose(FindRec); + end; + end; +end; + +function PinCcodExpectedSetupDirectories(const AppDirectory: String): Boolean; +var + Directories: TStringList; + Index: Integer; + Candidate: String; +begin + Result := False; + Directories := TStringList.Create; + try + AddCcodExpectedSetupDirectories(Directories); + if Directories.Count = 0 then Exit; + for Index := 0 to Directories.Count - 1 do + begin + Candidate := AddBackslash(AppDirectory) + Directories[Index]; + if not PinCcodExistingSetupDirectory(Candidate) then Exit; + end; + Result := True; + finally + Directories.Free; + end; +end; +// CCOD_SETUP_PIN_END + function AreCcodExpectedSetupDirectoriesSafe(const AppDirectory: String): Boolean; var Directories: TStringList; @@ -305,10 +463,15 @@ var AppDirectory: String; begin Result := ''; + CloseCcodSetupPins(); AppDirectory := ExpandConstant('{app}'); if not IsSafeExistingSetupTree(AppDirectory) or not AreCcodExpectedSetupDirectoriesSafe(AppDirectory) then - Result := 'CodexRemote-fix refused an unsafe existing payload directory.'; + Result := 'CodexRemote-fix refused an unsafe existing payload directory.' + else if not PinCcodExistingSetupTree(AppDirectory) or + not PinCcodExpectedSetupDirectories(AppDirectory) then + Result := 'CodexRemote-fix could not retain the safe Setup write boundary.'; + if Result <> '' then CloseCcodSetupPins(); end; function NewActivationId(): String; @@ -488,6 +651,7 @@ var begin if CurStep <> ssPostInstall then Exit; + CloseCcodSetupPins(); ActivationId := NewActivationId(); ReceiptPath := ExpandConstant('{localappdata}\CodexControlOtherDevices\state\post-install-activation.json'); RefuseStaleActivationReceipt(ReceiptPath); @@ -547,6 +711,11 @@ begin end; end; +procedure DeinitializeSetup(); +begin + CloseCcodSetupPins(); +end; + function IsCanonicalUninstallTransactionId(const Value: String): Boolean; var Index: Integer; diff --git a/build/SetupArtifact.psm1 b/build/SetupArtifact.psm1 index 4458e71..a5eda58 100644 --- a/build/SetupArtifact.psm1 +++ b/build/SetupArtifact.psm1 @@ -153,9 +153,21 @@ function Test-CcodSetupBuildProvenance { [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 + [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 = $payloadRaw | ConvertFrom-Json -ErrorAction Stop } + 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 = $raw | ConvertFrom-Json -ErrorAction Stop } catch { Throw-CcodSetupArtifactError 'CCOD_SETUP_PROVENANCE_INVALID' 'Setup provenance is invalid JSON' $path } $timestampMatches = [regex]::Matches($raw,'"buildTimestampUtc"\s*:\s*"(?[^"\\]+)"') @@ -166,19 +178,20 @@ function Test-CcodSetupBuildProvenance { $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 -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 -le 0 -or [int]$record.payloadManifest.fileCount -le 0 -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 -cnotmatch '^[0-9a-f]{64}$' -or - $record.buildInputs.destinationInventorySha256 -isnot [string] -or $record.buildInputs.destinationInventorySha256 -cnotmatch '^[0-9a-f]{64}$' -or - $record.buildInputs.compilerSha256 -isnot [string] -or $record.buildInputs.compilerSha256 -cnotmatch '^[0-9a-f]{64}$' -or - $record.buildInputs.compilerFileVersion -isnot [string] -or [string]::IsNullOrWhiteSpace([string]$record.buildInputs.compilerFileVersion)) { + $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 diff --git a/build/build.ps1 b/build/build.ps1 index c9cdf94..cea0e03 100644 --- a/build/build.ps1 +++ b/build/build.ps1 @@ -331,8 +331,10 @@ $releaseManifest = Join-Path $dist "CodexRemote-fix-$Version-release-manifest.js $setupExe = Join-Path $dist "CodexRemote-fix-$Version-setup.exe" $setupChecksum = "$setupExe.sha256.txt" $setupProvenance = Join-Path $dist "CodexRemote-fix-$Version-setup-provenance.json" +$setupPayloadInput = Join-Path $dist "CodexRemote-fix-$Version-setup-payload-manifest.json" +$setupInventoryInput = Join-Path $dist "CodexRemote-fix-$Version-setup-destination-inventory.iss" $setupReleaseManifest = Join-Path $dist "CodexRemote-fix-$Version-setup-release-manifest.json" -foreach ($path in @($bundle,$checksum,$provenance,$payloadManifestAsset,$releaseManifest,$setupExe,$setupChecksum,$setupProvenance,$setupReleaseManifest)) { +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" } } @@ -481,8 +483,10 @@ $iscc = $isccCandidates | Where-Object { -not [string]::IsNullOrWhiteSpace($_) - 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' Import-Module (Join-Path $PSScriptRoot 'SetupArtifact.psm1') -Force +[IO.File]::Copy((Assert-CcodBuildRegularFile -Path $installerPayloadManifestPath -Kind 'Installer payload manifest'),$setupPayloadInput,$false) +[IO.File]::Copy((Assert-CcodBuildRegularFile -Path $installerDestinationInventoryPath -Kind 'Installer destination inventory'),$setupInventoryInput,$false) $setupProvenanceRecord = New-CcodSetupBuildProvenance -Version $Version -GitCommit $gitCommit -BuildTimestampUtc $buildTimestampUtc -PayloadManifestPath $installerPayloadManifestPath -InnoTemplatePath $issPath -DestinationInventoryPath $installerDestinationInventoryPath -CompilerPath $iscc -OutputPath $setupProvenance -Test-CcodSetupBuildProvenance -ProvenancePath $setupProvenance -ExpectedVersion $Version -ExpectedGitCommit $gitCommit -ExpectedPayloadManifestSha256 $installerPayloadManifestSha256 -ExpectedBuildTimestampUtc $buildTimestampUtc | Out-Null +Test-CcodSetupBuildProvenance -ProvenancePath $setupProvenance -ExpectedVersion $Version -ExpectedGitCommit $gitCommit -ExpectedPayloadManifestSha256 $installerPayloadManifestSha256 -ExpectedBuildTimestampUtc $buildTimestampUtc -InnoTemplatePath $issPath -DestinationInventoryPath $installerDestinationInventoryPath -CompilerPath $iscc -PayloadManifestPath $installerPayloadManifestPath | Out-Null $isccArguments = @( "/DProjectVersion=$Version", "/DTrayHostArtifactDirectory=$trayHostArtifact", @@ -507,7 +511,9 @@ $setupRecord = [ordered]@{ [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($setupProvenance); sha256 = Get-CcodBuildFileSha256 -Path $setupProvenance } + [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) diff --git a/tools/Test-ReleaseDefender.ps1 b/tools/Test-ReleaseDefender.ps1 index ab73cdb..f9ddffe 100644 --- a/tools/Test-ReleaseDefender.ps1 +++ b/tools/Test-ReleaseDefender.ps1 @@ -487,7 +487,9 @@ function Test-CcodReleaseAssetManifest { "CodexRemote-fix-$ExpectedVersion-setup.exe", "CodexRemote-fix-$ExpectedVersion-setup.exe.sha256.txt", "CodexRemote-fix-$ExpectedVersion-trayhost-provenance.json", - "CodexRemote-fix-$ExpectedVersion-setup-provenance.json" + "CodexRemote-fix-$ExpectedVersion-setup-provenance.json", + "CodexRemote-fix-$ExpectedVersion-setup-payload-manifest.json", + "CodexRemote-fix-$ExpectedVersion-setup-destination-inventory.iss" ) if ($assets.Count -ne $expectedNames.Count) { Throw-CcodReleaseDefenderError 'CCOD_RELEASE_MANIFEST_INVALID' 'Release manifest does not bind the exact required asset set' $manifestFile } $assetHashes = @{} @@ -542,7 +544,15 @@ function Test-CcodReleaseAssetManifest { $setupRecord = $setupRaw | ConvertFrom-Json -ErrorAction Stop $payloadHash = [string]$setupRecord.payloadManifest.sha256 if ($payloadHash -cnotmatch '^[0-9a-f]{64}$') { throw 'payload hash' } - Test-CcodSetupBuildProvenance -ProvenancePath $setupProvenancePath -ExpectedVersion $ExpectedVersion -ExpectedGitCommit ([string]$manifest.gitCommit) -ExpectedPayloadManifestSha256 $payloadHash -ExpectedBuildTimestampUtc $manifestTimestamp | Out-Null + $repositoryRoot = Split-Path $PSScriptRoot -Parent + $innoTemplatePath = Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss' + $compiler = @( + (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 { -not [string]::IsNullOrWhiteSpace($_) -and [IO.File]::Exists($_) } | Select-Object -First 1 + if ($null -eq $compiler) { throw 'canonical ISCC compiler missing' } + Test-CcodSetupBuildProvenance -ProvenancePath $setupProvenancePath -ExpectedVersion $ExpectedVersion -ExpectedGitCommit ([string]$manifest.gitCommit) -ExpectedPayloadManifestSha256 $payloadHash -ExpectedBuildTimestampUtc $manifestTimestamp -InnoTemplatePath $innoTemplatePath -DestinationInventoryPath (Join-Path $directory $expectedNames[5]) -CompilerPath $compiler -PayloadManifestPath (Join-Path $directory $expectedNames[4]) | Out-Null Test-CcodSetupArtifact -SetupPath $installer -ExpectedVersion $ExpectedVersion -ExpectedGitCommit ([string]$manifest.gitCommit) -ExpectedPayloadManifestSha256 $payloadHash | Out-Null } catch { Throw-CcodReleaseDefenderError 'CCOD_RELEASE_MANIFEST_INVALID' ('Setup provenance or final PE contract is invalid: ' + $_.Exception.Message) $setupProvenancePath @@ -556,6 +566,8 @@ function Test-CcodReleaseAssetManifest { InstallerName = $expectedNames[0] PayloadManifestSha256 = $payloadHash SetupProvenanceName = $expectedNames[3] + SetupPayloadInputName = $expectedNames[4] + SetupInventoryInputName = $expectedNames[5] } } From 8102bded7e668fff59f581cdfb1ce98cde3f3bca Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:54:21 +0800 Subject: [PATCH 062/187] docs: record final TOCTOU remediation --- .../task-5-report.md | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) 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 index 25e18fd..f5356b2 100644 --- 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 @@ -638,3 +638,100 @@ f1ee9b7 test: expose linked whole-install 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. From 144fc23fe9e2d7dec9c4858a654af78fae74746c Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Fri, 28 Aug 2026 20:50:41 +0800 Subject: [PATCH 063/187] docs: define transactional install release design --- ...22-transactional-install-release-design.md | 217 ++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-28-v2522-transactional-install-release-design.md 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..c61a9d6 --- /dev/null +++ b/docs/superpowers/specs/2026-08-28-v2522-transactional-install-release-design.md @@ -0,0 +1,217 @@ +# 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 old Inno `AppId`, installed user-facing name, current-user scope, and +existing control-panel uninstall entry are preserved for migration. A failed +transaction never unregisters or recursively deletes a legacy entry. Legacy +installer shell files become compatibility inputs only; they are never used as +a source-file discovery root. After `Ready`, the transaction records the +sealed package identity beside the existing entry so an explicit future +uninstall can distinguish owned legacy files from unknown files. + +### 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. | +| Enumerate/delete owned tree | Walks by pinned handles, rejects reparse points, alternate streams, and multi-link leaves, then removes only transaction-owned entries from leaves upward. | +| Close transaction | Releases handles only after a terminal success/failure decision; failed cleanup preserves its owned candidate for later diagnosis 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 + -> StableShellCommitted + -> PointerCommitted + -> ProtectionReady + -> Ready +``` + +- Before `PointerCommitted`, a failure leaves the previous stable shell and + active pointer unchanged, then proves the retained previous runtime can run. +- 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, and returns a stable rollback code. +- 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. Cleanup is 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; +- `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. From 0ecf0a5be00f488a239422bd10d589875c0c6c8f Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Sat, 29 Aug 2026 08:27:25 +0800 Subject: [PATCH 064/187] docs: plan transactional install release --- ...-29-v2522-transactional-install-release.md | 961 ++++++++++++++++++ ...22-transactional-install-release-design.md | 35 +- 2 files changed, 986 insertions(+), 10 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-29-v2522-transactional-install-release.md 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..afd8d7c --- /dev/null +++ b/docs/superpowers/plans/2026-08-29-v2522-transactional-install-release.md @@ -0,0 +1,961 @@ +# 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 +delete tree 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. + +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. Enumerate and +delete only transaction-owned objects from leaves upward; any unexpected +identity stops deletion 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 cleanup 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 cleanup, and failed-candidate cleanup 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/specs/2026-08-28-v2522-transactional-install-release-design.md b/docs/superpowers/specs/2026-08-28-v2522-transactional-install-release-design.md index c61a9d6..680a1b5 100644 --- 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 @@ -72,13 +72,18 @@ 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 old Inno `AppId`, installed user-facing name, current-user scope, and -existing control-panel uninstall entry are preserved for migration. A failed -transaction never unregisters or recursively deletes a legacy entry. Legacy -installer shell files become compatibility inputs only; they are never used as -a source-file discovery root. After `Ready`, the transaction records the -sealed package identity beside the existing entry so an explicit future -uninstall can distinguish owned legacy files from unknown files. +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 @@ -115,18 +120,27 @@ Prepared -> RuntimeStaged -> PreviousProtectionStopped -> RuntimePromoted - -> StableShellCommitted -> 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, and returns a stable rollback code. + 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. @@ -201,7 +215,8 @@ No release may be called stable until this evidence exists. - `Activate-CcodRemoteFix.ps1` and a private `src/persistence/modules/InstallFileTransaction.psm1`; - `src/persistence/modules/InstallLifecycle.psm1` and its transaction/state - tests; + 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 From dc52697b0000e234d1a3438d0cc23c3925a4ce9a Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Sat, 29 Aug 2026 08:49:44 +0800 Subject: [PATCH 065/187] feat: add pinned install file transaction --- .../modules/InstallFileTransaction.psm1 | 1018 +++++++++++++++++ tests/PersistenceSelfTest.ps1 | 4 + tests/Validate.ps1 | 2 + .../InstallFileTransaction.SelfTest.ps1 | 447 ++++++++ 4 files changed, 1471 insertions(+) create mode 100644 src/persistence/modules/InstallFileTransaction.psm1 create mode 100644 tests/persistence/InstallFileTransaction.SelfTest.ps1 diff --git a/src/persistence/modules/InstallFileTransaction.psm1 b/src/persistence/modules/InstallFileTransaction.psm1 new file mode 100644 index 0000000..7b1ebbc --- /dev/null +++ b/src/persistence/modules/InstallFileTransaction.psm1 @@ -0,0 +1,1018 @@ +Set-StrictMode -Version Latest + +$script:CcodInstallFileTransactionAfterSourceOpen = $null + +function Throw-CcodInstallFileError { + 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-CcodInstallFileNative { + if ($null -ne ('CcodInstallFileNative' -as [type])) { return } + 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 static class CcodInstallFileNative +{ + private const uint FILE_LIST_DIRECTORY = 0x00000001; + private const uint FILE_READ_ATTRIBUTES = 0x00000080; + private const uint DELETE = 0x00010000; + private const uint SYNCHRONIZE = 0x00100000; + private const uint GENERIC_READ = 0x80000000; + private const uint GENERIC_WRITE = 0x40000000; + private const uint FILE_SHARE_READ = 0x00000001; + private const uint FILE_SHARE_WRITE = 0x00000002; + private const uint FILE_SHARE_DELETE = 0x00000004; + private const uint FILE_OPEN = 0x00000001; + private const uint FILE_CREATE = 0x00000002; + private const uint FILE_OPEN_IF = 0x00000003; + private const uint FILE_DIRECTORY_FILE = 0x00000001; + private const uint FILE_WRITE_THROUGH = 0x00000002; + private const uint FILE_SYNCHRONOUS_IO_NONALERT = 0x00000020; + private const uint FILE_NON_DIRECTORY_FILE = 0x00000040; + private const uint FILE_OPEN_FOR_BACKUP_INTENT = 0x00004000; + 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 FILE_FLAG_WRITE_THROUGH = 0x80000000; + 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; + private const int FileRenameInformation = 10; + private const int FileDispositionInfo = 4; + private const int FileStreamInfo = 7; + private const int FileDirectoryInformation = 1; + private const int STATUS_NO_MORE_FILES = unchecked((int)0x80000006); + + [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)] + internal struct FILETIME_NATIVE + { + public uint Low; + public uint High; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct BY_HANDLE_FILE_INFORMATION + { + 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; + } + + public sealed class OpenResult + { + public SafeFileHandle Handle { get; private set; } + public bool Created { get; private set; } + public OpenResult(SafeFileHandle handle, bool created) { Handle = handle; Created = created; } + } + + public sealed class HandleInfo + { + public uint Attributes { get; private set; } + public uint NumberOfLinks { get; private set; } + public uint VolumeSerialNumber { get; private set; } + public ulong FileIndex { get; private set; } + public long Length { get; private set; } + public bool IsDirectory { get { return (Attributes & FILE_ATTRIBUTE_DIRECTORY) != 0; } } + public bool IsReparsePoint { get { return (Attributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0; } } + + internal HandleInfo(BY_HANDLE_FILE_INFORMATION value) + { + Attributes = value.FileAttributes; + NumberOfLinks = value.NumberOfLinks; + VolumeSerialNumber = value.VolumeSerialNumber; + FileIndex = ((ulong)value.FileIndexHigh << 32) | value.FileIndexLow; + Length = ((long)value.FileSizeHigh << 32) | value.FileSizeLow; + } + } + + public sealed class DirectoryEntry + { + public string Name { get; private set; } + public uint Attributes { get; private set; } + public DirectoryEntry(string name, uint attributes) { Name = name; Attributes = attributes; } + } + + [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("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern uint GetFinalPathNameByHandleW(SafeFileHandle handle, StringBuilder path, uint length, uint flags); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetFileInformationByHandleEx(SafeFileHandle handle, int informationClass, IntPtr information, uint size); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetFileInformationByHandle(SafeFileHandle handle, int informationClass, IntPtr information, uint size); + + [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); + + [DllImport("ntdll.dll")] + private static extern int NtQueryDirectoryFile(SafeFileHandle fileHandle, IntPtr eventHandle, IntPtr apcRoutine, + IntPtr apcContext, out IO_STATUS_BLOCK ioStatusBlock, IntPtr fileInformation, uint length, + int fileInformationClass, [MarshalAs(UnmanagedType.Bool)] bool returnSingleEntry, + IntPtr fileName, [MarshalAs(UnmanagedType.Bool)] bool restartScan); + + [DllImport("ntdll.dll")] + private static extern int NtSetInformationFile(SafeFileHandle fileHandle, out IO_STATUS_BLOCK ioStatusBlock, + IntPtr fileInformation, uint length, int fileInformationClass); + + [DllImport("ntdll.dll")] + private static extern uint RtlNtStatusToDosError(int status); + + private static void ThrowStatus(int status) + { + throw new Win32Exception(unchecked((int)RtlNtStatusToDosError(status))); + } + + private static OpenResult OpenRelative(SafeFileHandle parent, string name, uint access, uint share, + uint disposition, uint options) + { + if (parent == null || parent.IsInvalid || parent.IsClosed) throw new ArgumentException("Parent 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)) ThrowStatus(status); + return new OpenResult(new SafeFileHandle(rawHandle, true), statusBlock.Information.ToUInt64() == 2UL); + } + finally + { + if (unicodePointer != IntPtr.Zero) Marshal.FreeHGlobal(unicodePointer); + if (nameBuffer != IntPtr.Zero) Marshal.FreeHGlobal(nameBuffer); + } + } + + public static SafeFileHandle OpenRootDirectory(string path) + { + SafeFileHandle handle = CreateFileW(path, FILE_LIST_DIRECTORY | 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()); + return handle; + } + + public static OpenResult OpenDirectoryRelative(SafeFileHandle parent, string name, bool createIfMissing) + { + return OpenRelative(parent, name, FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES | DELETE | SYNCHRONIZE, + FILE_SHARE_READ | FILE_SHARE_WRITE, createIfMissing ? FILE_OPEN_IF : FILE_OPEN, + FILE_DIRECTORY_FILE | FILE_OPEN_FOR_BACKUP_INTENT); + } + + public static OpenResult CreateTemporaryFileRelative(SafeFileHandle parent, string name) + { + return OpenRelative(parent, name, GENERIC_READ | GENERIC_WRITE | DELETE | SYNCHRONIZE, + FILE_SHARE_READ, FILE_CREATE, FILE_NON_DIRECTORY_FILE | FILE_WRITE_THROUGH); + } + + public static OpenResult OpenAppendFileRelative(SafeFileHandle parent, string name) + { + return OpenRelative(parent, name, GENERIC_READ | GENERIC_WRITE | DELETE | SYNCHRONIZE, + FILE_SHARE_READ, FILE_OPEN_IF, FILE_NON_DIRECTORY_FILE | FILE_WRITE_THROUGH); + } + + public static SafeFileHandle OpenIdentityRelative(SafeFileHandle parent, string name) + { + return OpenRelative(parent, name, FILE_READ_ATTRIBUTES | SYNCHRONIZE, + FILE_SHARE_READ | FILE_SHARE_WRITE, FILE_OPEN, FILE_OPEN_FOR_BACKUP_INTENT).Handle; + } + + public static SafeFileHandle OpenSourceFile(string path) + { + SafeFileHandle handle = CreateFileW(path, GENERIC_READ | FILE_READ_ATTRIBUTES | SYNCHRONIZE, + FILE_SHARE_READ, IntPtr.Zero, OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT, IntPtr.Zero); + if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error()); + return handle; + } + + public static HandleInfo GetInfo(SafeFileHandle handle) + { + BY_HANDLE_FILE_INFORMATION information; + if (!GetFileInformationByHandle(handle, out information)) throw new Win32Exception(Marshal.GetLastWin32Error()); + return new HandleInfo(information); + } + + public static string GetFinalPath(SafeFileHandle handle) + { + StringBuilder path = new StringBuilder(512); + uint length = GetFinalPathNameByHandleW(handle, path, (uint)path.Capacity, 0); + if (length == 0) throw new Win32Exception(Marshal.GetLastWin32Error()); + if (length >= path.Capacity) + { + path.Capacity = checked((int)length + 1); + length = GetFinalPathNameByHandleW(handle, path, (uint)path.Capacity, 0); + if (length == 0 || length >= path.Capacity) throw new Win32Exception(Marshal.GetLastWin32Error()); + } + string value = path.ToString(); + if (value.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase)) return @"\\" + value.Substring(8); + if (value.StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase)) return value.Substring(4); + return value; + } + + public static bool HasOnlyDefaultDataStream(SafeFileHandle handle) + { + const int bufferSize = 65536; + IntPtr buffer = Marshal.AllocHGlobal(bufferSize); + try + { + if (!GetFileInformationByHandleEx(handle, FileStreamInfo, buffer, bufferSize)) + throw new Win32Exception(Marshal.GetLastWin32Error()); + int offset = 0; + int count = 0; + while (true) + { + uint next = unchecked((uint)Marshal.ReadInt32(buffer, offset)); + uint nameLength = unchecked((uint)Marshal.ReadInt32(buffer, offset + 4)); + if (nameLength > bufferSize - offset - 24) throw new InvalidDataException("Invalid stream information"); + string name = Marshal.PtrToStringUni(IntPtr.Add(buffer, offset + 24), checked((int)nameLength / 2)); + count++; + if (!String.Equals(name, "::$DATA", StringComparison.OrdinalIgnoreCase)) return false; + if (next == 0) break; + if (next > bufferSize - offset) throw new InvalidDataException("Invalid stream information offset"); + offset = checked(offset + (int)next); + } + return count == 1; + } + finally { Marshal.FreeHGlobal(buffer); } + } + + public static string[] EnumerateDirectory(SafeFileHandle directory) + { + const int bufferSize = 65536; + IntPtr buffer = Marshal.AllocHGlobal(bufferSize); + List names = new List(); + bool restart = true; + try + { + while (true) + { + IO_STATUS_BLOCK statusBlock; + int status = NtQueryDirectoryFile(directory, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, + out statusBlock, buffer, bufferSize, FileDirectoryInformation, false, IntPtr.Zero, restart); + restart = false; + if (status == STATUS_NO_MORE_FILES) break; + if (status < 0) ThrowStatus(status); + int offset = 0; + while (true) + { + uint next = unchecked((uint)Marshal.ReadInt32(buffer, offset)); + uint nameLength = unchecked((uint)Marshal.ReadInt32(buffer, offset + 60)); + if (nameLength > bufferSize - offset - 64) throw new InvalidDataException("Invalid directory information"); + string name = Marshal.PtrToStringUni(IntPtr.Add(buffer, offset + 64), checked((int)nameLength / 2)); + if (name != "." && name != "..") names.Add(name); + if (next == 0) break; + if (next > bufferSize - offset) throw new InvalidDataException("Invalid directory information offset"); + offset = checked(offset + (int)next); + } + } + return names.ToArray(); + } + finally { Marshal.FreeHGlobal(buffer); } + } + + public static int RenameRelative(SafeFileHandle source, SafeFileHandle parent, string destination, bool replaceIfExists) + { + byte[] name = Encoding.Unicode.GetBytes(destination); + int rootDirectoryOffset = IntPtr.Size; + int fileNameLengthOffset = rootDirectoryOffset + IntPtr.Size; + int fileNameOffset = fileNameLengthOffset + sizeof(uint); + int bufferSize = checked(fileNameOffset + name.Length + sizeof(char)); + IntPtr buffer = Marshal.AllocHGlobal(bufferSize); + try + { + for (int index = 0; index < bufferSize; index++) Marshal.WriteByte(buffer, index, 0); + Marshal.WriteByte(buffer, 0, replaceIfExists ? (byte)1 : (byte)0); + Marshal.WriteIntPtr(buffer, rootDirectoryOffset, parent.DangerousGetHandle()); + Marshal.WriteInt32(buffer, fileNameLengthOffset, name.Length); + Marshal.Copy(name, 0, IntPtr.Add(buffer, fileNameOffset), name.Length); + IO_STATUS_BLOCK statusBlock; + int status = NtSetInformationFile(source, out statusBlock, buffer, (uint)bufferSize, FileRenameInformation); + return status >= 0 ? 0 : unchecked((int)RtlNtStatusToDosError(status)); + } + finally { Marshal.FreeHGlobal(buffer); } + } + + public static int MarkDelete(SafeFileHandle handle) + { + IntPtr buffer = Marshal.AllocHGlobal(1); + try + { + Marshal.WriteByte(buffer, 1); + return SetFileInformationByHandle(handle, FileDispositionInfo, buffer, 1) + ? 0 : Marshal.GetLastWin32Error(); + } + finally { Marshal.FreeHGlobal(buffer); } + } +} +'@ +} + +function Assert-CcodInstallLeafName { + param([Parameter(Mandatory)][string]$Leaf) + if ([string]::IsNullOrWhiteSpace($Leaf) -or $Leaf.Length -gt 160 -or + $Leaf -in @('.','..') -or $Leaf -notmatch '^[A-Za-z0-9._-]+$' -or + $Leaf.EndsWith('.') -or $Leaf.EndsWith(' ') -or + $Leaf -match '^(?i:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)') { + Throw-CcodInstallFileError 'CCOD_INSTALL_LEAF_INVALID' 'Install transaction leaf must be one safe relative segment' $Leaf + } +} + +function Get-CcodInstallHandleInfo { + param([Parameter(Mandatory)]$Pin) + $handle = if ($null -ne $Pin.Stream) { $Pin.Stream.SafeFileHandle } else { $Pin.Handle } + return [CcodInstallFileNative]::GetInfo($handle) +} + +function Get-CcodInstallPinHandle { + param([Parameter(Mandatory)]$Pin) + if ($null -ne $Pin.Stream) { return $Pin.Stream.SafeFileHandle } + return $Pin.Handle +} + +function Get-CcodInstallIdentity { + param([Parameter(Mandatory)]$Information) + return [pscustomobject]@{ + VolumeSerialNumber = [uint32]$Information.VolumeSerialNumber + FileIndex = [uint64]$Information.FileIndex + Key = ('{0:x8}:{1:x16}' -f [uint32]$Information.VolumeSerialNumber,[uint64]$Information.FileIndex) + } +} + +function Get-CcodInstallObjectKey { + param([Parameter(Mandatory)]$ParentDirectory, [Parameter(Mandatory)][string]$Leaf) + return $ParentDirectory.Identity.Key + '|' + $Leaf.ToLowerInvariant() +} + +function Test-CcodInstallSameIdentity { + param($Expected, $Information) + return $null -ne $Expected -and + [uint32]$Expected.VolumeSerialNumber -eq [uint32]$Information.VolumeSerialNumber -and + [uint64]$Expected.FileIndex -eq [uint64]$Information.FileIndex +} + +function Assert-CcodInstallTransaction { + param([Parameter(Mandatory)]$Transaction) + if ($null -eq $Transaction -or $Transaction.Kind -cne 'CcodInstallFileTransaction') { + Throw-CcodInstallFileError 'CCOD_INSTALL_TRANSACTION_INVALID' 'Install file transaction context is invalid' $null + } + if ($Transaction.Closed) { + Throw-CcodInstallFileError 'CCOD_INSTALL_TRANSACTION_CLOSED' 'Install file transaction is already closed' $null + } +} + +function Assert-CcodInstallPin { + param( + [Parameter(Mandatory)]$Transaction, + [Parameter(Mandatory)]$Pin, + [ValidateSet('Directory','File')][string]$Kind + ) + Assert-CcodInstallTransaction $Transaction + if ($null -eq $Pin -or $Pin.TransactionId -cne $Transaction.Id -or $Pin.Closed -or + (-not [string]::IsNullOrWhiteSpace($Kind) -and $Pin.Kind -cne $Kind)) { + Throw-CcodInstallFileError 'CCOD_INSTALL_PIN_INVALID' 'Install file pin is invalid for this transaction' $null + } + $handle = Get-CcodInstallPinHandle $Pin + if ($null -eq $handle -or $handle.IsInvalid -or $handle.IsClosed) { + Throw-CcodInstallFileError 'CCOD_INSTALL_PIN_INVALID' 'Install file pin handle is closed or invalid' $null + } +} + +function Assert-CcodInstallPinCurrent { + param( + [Parameter(Mandatory)]$Transaction, + [Parameter(Mandatory)]$Pin, + [ValidateSet('Directory','File')][string]$Kind + ) + Assert-CcodInstallPin -Transaction $Transaction -Pin $Pin -Kind $Kind + try { + $information = Get-CcodInstallHandleInfo $Pin + $actualPath = [IO.Path]::GetFullPath([CcodInstallFileNative]::GetFinalPath((Get-CcodInstallPinHandle $Pin))).TrimEnd('\') + } catch { + Throw-CcodInstallFileError 'CCOD_INSTALL_PIN_CHANGED' 'Install file pin can no longer prove its identity' $Pin.Leaf + } + $expectedPath = [IO.Path]::GetFullPath([string]$Pin.ExpectedPath).TrimEnd('\') + if (-not (Test-CcodInstallSameIdentity $Pin.Identity $information) -or + -not $actualPath.Equals($expectedPath, [StringComparison]::OrdinalIgnoreCase) -or + $information.IsReparsePoint -or + ($Kind -ceq 'Directory' -and -not $information.IsDirectory) -or + ($Kind -ceq 'File' -and $information.IsDirectory)) { + Throw-CcodInstallFileError 'CCOD_INSTALL_PIN_CHANGED' 'Install file pin identity or final path changed' $Pin.Leaf + } + return $information +} + +function Assert-CcodInstallPlainFile { + param([Parameter(Mandatory)]$Information, [Parameter(Mandatory)]$Handle, [Parameter(Mandatory)][string]$Target) + if ($Information.IsDirectory) { + Throw-CcodInstallFileError 'CCOD_INSTALL_FILE_TYPE_INVALID' 'Install transaction expected a regular file' $Target + } + if ($Information.IsReparsePoint) { + Throw-CcodInstallFileError 'CCOD_INSTALL_REPARSE_LEAF' 'Install transaction rejects reparse leaves' $Target + } + if ([uint32]$Information.NumberOfLinks -ne 1) { + Throw-CcodInstallFileError 'CCOD_INSTALL_MULTILINK_LEAF' 'Install transaction rejects multi-link leaves' $Target + } + try { $defaultStreamOnly = [CcodInstallFileNative]::HasOnlyDefaultDataStream($Handle) } + catch { Throw-CcodInstallFileError 'CCOD_INSTALL_ADS_LEAF' 'Install transaction could not prove the leaf stream inventory' $Target } + if (-not $defaultStreamOnly) { + Throw-CcodInstallFileError 'CCOD_INSTALL_ADS_LEAF' 'Install transaction rejects alternate data streams' $Target + } +} + +function Close-CcodInstallPin { + param([Parameter(Mandatory)]$Pin) + if ($Pin.Closed) { return } + try { + if ($null -ne $Pin.Stream) { $Pin.Stream.Dispose() } + elseif ($null -ne $Pin.Handle) { $Pin.Handle.Dispose() } + } finally { $Pin.Closed = $true } +} + +function Open-CcodInstallFileTransaction { + [CmdletBinding()] + param([Parameter(Mandatory)][string]$InstallRoot) + + Initialize-CcodInstallFileNative + if (-not [IO.Path]::IsPathRooted($InstallRoot)) { + Throw-CcodInstallFileError 'CCOD_INSTALL_ROOT_INVALID' 'Install root must be absolute' $InstallRoot + } + $rootPath = [IO.Path]::GetFullPath($InstallRoot).TrimEnd('\') + if (-not [IO.Directory]::Exists($rootPath)) { + Throw-CcodInstallFileError 'CCOD_INSTALL_ROOT_MISSING' 'Pinned install root must already exist' $rootPath + } + $handle = $null + try { + $handle = [CcodInstallFileNative]::OpenRootDirectory($rootPath) + $information = [CcodInstallFileNative]::GetInfo($handle) + $finalPath = [IO.Path]::GetFullPath([CcodInstallFileNative]::GetFinalPath($handle)).TrimEnd('\') + if (-not $information.IsDirectory -or $information.IsReparsePoint -or + -not $finalPath.Equals($rootPath, [StringComparison]::OrdinalIgnoreCase)) { + Throw-CcodInstallFileError 'CCOD_INSTALL_ROOT_INVALID' 'Install root is not one plain directory identity' $rootPath + } + $id = [guid]::NewGuid().ToString('D') + $rootPin = [pscustomobject]@{ + Kind = 'Directory'; TransactionId = $id; Handle = $handle; Stream = $null + ParentDirectory = $null; Leaf = ''; ExpectedPath = $rootPath + Identity = Get-CcodInstallIdentity $information; Created = $false; Owned = $false; Closed = $false + } + $handle = $null + $pins = [Collections.Generic.List[object]]::new() + $pins.Add($rootPin) + return [pscustomobject]@{ + Kind = 'CcodInstallFileTransaction'; Id = $id; InstallRoot = $rootPath; RootDirectory = $rootPin + Pins = $pins + Objects = [Collections.Generic.Dictionary[string,object]]::new([StringComparer]::OrdinalIgnoreCase) + OwnedObjects = [Collections.Generic.Dictionary[string,object]]::new([StringComparer]::OrdinalIgnoreCase) + LastSourceOpenAttempt = $null; Closed = $false; Disposition = $null + } + } catch { + if ($null -ne $handle) { $handle.Dispose() } + throw + } +} + +function Open-CcodInstallPinnedDirectory { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$Transaction, + [Parameter(Mandatory)]$ParentDirectory, + [Parameter(Mandatory)][string]$Leaf, + [switch]$CreateIfMissing + ) + Assert-CcodInstallLeafName $Leaf + [void](Assert-CcodInstallPinCurrent -Transaction $Transaction -Pin $ParentDirectory -Kind Directory) + $key = Get-CcodInstallObjectKey $ParentDirectory $Leaf + if ($Transaction.Objects.ContainsKey($key)) { + $existing = $Transaction.Objects[$key] + [void](Assert-CcodInstallPinCurrent -Transaction $Transaction -Pin $existing -Kind Directory) + return $existing + } + $result = $null + try { + $result = [CcodInstallFileNative]::OpenDirectoryRelative($ParentDirectory.Handle, $Leaf, [bool]$CreateIfMissing) + $information = [CcodInstallFileNative]::GetInfo($result.Handle) + if (-not $information.IsDirectory) { + Throw-CcodInstallFileError 'CCOD_INSTALL_FILE_TYPE_INVALID' 'Pinned install child is not a directory' $Leaf + } + if ($information.IsReparsePoint) { + Throw-CcodInstallFileError 'CCOD_INSTALL_REPARSE_LEAF' 'Pinned install child is a reparse point' $Leaf + } + $expectedPath = Join-Path $ParentDirectory.ExpectedPath $Leaf + $finalPath = [IO.Path]::GetFullPath([CcodInstallFileNative]::GetFinalPath($result.Handle)).TrimEnd('\') + if (-not $finalPath.Equals([IO.Path]::GetFullPath($expectedPath).TrimEnd('\'), [StringComparison]::OrdinalIgnoreCase)) { + Throw-CcodInstallFileError 'CCOD_INSTALL_PIN_CHANGED' 'Pinned install child final path is unexpected' $Leaf + } + $pin = [pscustomobject]@{ + Kind = 'Directory'; TransactionId = $Transaction.Id; Handle = $result.Handle; Stream = $null + ParentDirectory = $ParentDirectory; Leaf = $Leaf; ExpectedPath = $expectedPath + Identity = Get-CcodInstallIdentity $information; Created = [bool]$result.Created; Owned = [bool]$result.Created; Closed = $false + } + $result = $null + $Transaction.Pins.Add($pin) + $Transaction.Objects.Add($key, $pin) + if ($pin.Owned) { $Transaction.OwnedObjects.Add($key, $pin) } + return $pin + } catch { + if ($null -ne $result) { $result.Handle.Dispose() } + throw + } +} + +function New-CcodInstallPinnedTemporaryLeaf { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$Transaction, + [Parameter(Mandatory)]$ParentDirectory, + [Parameter(Mandatory)][string]$Leaf + ) + Assert-CcodInstallLeafName $Leaf + [void](Assert-CcodInstallPinCurrent -Transaction $Transaction -Pin $ParentDirectory -Kind Directory) + $key = Get-CcodInstallObjectKey $ParentDirectory $Leaf + if ($Transaction.Objects.ContainsKey($key) -or $Transaction.OwnedObjects.ContainsKey($key)) { + Throw-CcodInstallFileError 'CCOD_INSTALL_TEMPORARY_EXISTS' 'Temporary install leaf is already tracked' $Leaf + } + $result = $null + $stream = $null + try { + $result = [CcodInstallFileNative]::CreateTemporaryFileRelative($ParentDirectory.Handle, $Leaf) + if (-not $result.Created) { Throw-CcodInstallFileError 'CCOD_INSTALL_TEMPORARY_EXISTS' 'Temporary install leaf already exists' $Leaf } + $stream = [IO.FileStream]::new($result.Handle, [IO.FileAccess]::ReadWrite, 65536, $false) + $result = $null + $information = [CcodInstallFileNative]::GetInfo($stream.SafeFileHandle) + Assert-CcodInstallPlainFile -Information $information -Handle $stream.SafeFileHandle -Target $Leaf + $pin = [pscustomobject]@{ + Kind = 'File'; TransactionId = $Transaction.Id; Handle = $null; Stream = $stream + ParentDirectory = $ParentDirectory; Leaf = $Leaf; ExpectedPath = (Join-Path $ParentDirectory.ExpectedPath $Leaf) + Identity = Get-CcodInstallIdentity $information; Created = $true; Owned = $true; Closed = $false + Sealed = $false; Length = [int64]0; Sha256 = $null + } + $stream = $null + $Transaction.Pins.Add($pin) + $Transaction.Objects.Add($key, $pin) + $Transaction.OwnedObjects.Add($key, $pin) + return $pin + } catch { + if ($null -ne $stream) { $stream.Dispose() } + if ($null -ne $result) { $result.Handle.Dispose() } + throw + } +} + +function Get-CcodInstallStreamSha256 { + param([Parameter(Mandatory)][IO.FileStream]$Stream) + $position = $Stream.Position + $sha = [Security.Cryptography.SHA256]::Create() + try { + $Stream.Position = 0 + return [BitConverter]::ToString($sha.ComputeHash($Stream)).Replace('-', '').ToLowerInvariant() + } finally { + $sha.Dispose() + $Stream.Position = $position + } +} + +function Copy-CcodInstallSealedFile { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$Transaction, + [Parameter(Mandatory)][string]$SourcePath, + [Parameter(Mandatory)]$DestinationLeaf, + [Parameter(Mandatory)][ValidateRange(0,[int64]::MaxValue)][int64]$ExpectedLength, + [Parameter(Mandatory)][string]$ExpectedSha256 + ) + if (-not [IO.Path]::IsPathRooted($SourcePath) -or $ExpectedSha256 -cnotmatch '^[0-9a-f]{64}$') { + Throw-CcodInstallFileError 'CCOD_INSTALL_SOURCE_INVALID' 'Sealed source contract is invalid' $SourcePath + } + $fullSource = [IO.Path]::GetFullPath($SourcePath) + $sourceTail = if ($fullSource.StartsWith('\\')) { $fullSource } else { $fullSource.Substring([IO.Path]::GetPathRoot($fullSource).Length) } + if ($sourceTail.Contains(':')) { + Throw-CcodInstallFileError 'CCOD_INSTALL_ADS_LEAF' 'Sealed source may not name an alternate data stream' $SourcePath + } + $destinationInformation = Assert-CcodInstallPinCurrent -Transaction $Transaction -Pin $DestinationLeaf -Kind File + Assert-CcodInstallPlainFile -Information $destinationInformation -Handle $DestinationLeaf.Stream.SafeFileHandle -Target $DestinationLeaf.Leaf + if (-not $DestinationLeaf.Owned -or $DestinationLeaf.ParentDirectory.TransactionId -cne $Transaction.Id) { + Throw-CcodInstallFileError 'CCOD_INSTALL_PIN_INVALID' 'Sealed copy destination is not a transaction-owned leaf' $DestinationLeaf.Leaf + } + + $sourceHandle = $null + $sourceStream = $null + try { + $sourceHandle = [CcodInstallFileNative]::OpenSourceFile($fullSource) + $sourceInformation = [CcodInstallFileNative]::GetInfo($sourceHandle) + Assert-CcodInstallPlainFile -Information $sourceInformation -Handle $sourceHandle -Target $fullSource + $finalSource = [IO.Path]::GetFullPath([CcodInstallFileNative]::GetFinalPath($sourceHandle)) + if (-not $finalSource.Equals($fullSource, [StringComparison]::OrdinalIgnoreCase)) { + Throw-CcodInstallFileError 'CCOD_INSTALL_SOURCE_INVALID' 'Sealed source final path is unexpected' $fullSource + } + if ($null -ne $script:CcodInstallFileTransactionAfterSourceOpen) { + $Transaction.LastSourceOpenAttempt = & $script:CcodInstallFileTransactionAfterSourceOpen $fullSource + } + $sourceStream = [IO.FileStream]::new($sourceHandle, [IO.FileAccess]::Read, 65536, $false) + $sourceHandle = $null + if ([int64]$sourceStream.Length -ne $ExpectedLength) { + Throw-CcodInstallFileError 'CCOD_INSTALL_SOURCE_MISMATCH' 'Sealed source length does not match the contract' $fullSource + } + $sourceSha = Get-CcodInstallStreamSha256 $sourceStream + if ($sourceSha -cne $ExpectedSha256) { + Throw-CcodInstallFileError 'CCOD_INSTALL_SOURCE_MISMATCH' 'Sealed source digest does not match the contract' $fullSource + } + $sourceStream.Position = 0 + $DestinationLeaf.Stream.Position = 0 + $DestinationLeaf.Stream.SetLength(0) + $sourceStream.CopyTo($DestinationLeaf.Stream, 65536) + $DestinationLeaf.Stream.Flush($true) + $destinationLength = [int64]$DestinationLeaf.Stream.Length + $destinationSha = Get-CcodInstallStreamSha256 $DestinationLeaf.Stream + if ($destinationLength -ne $ExpectedLength -or $destinationSha -cne $ExpectedSha256) { + Throw-CcodInstallFileError 'CCOD_INSTALL_DESTINATION_MISMATCH' 'Pinned destination bytes failed sealed readback' $DestinationLeaf.Leaf + } + $afterInformation = Assert-CcodInstallPinCurrent -Transaction $Transaction -Pin $DestinationLeaf -Kind File + if (-not (Test-CcodInstallSameIdentity $DestinationLeaf.Identity $afterInformation)) { + Throw-CcodInstallFileError 'CCOD_INSTALL_PIN_CHANGED' 'Pinned destination identity changed during copy' $DestinationLeaf.Leaf + } + $DestinationLeaf.Sealed = $true + $DestinationLeaf.Length = $destinationLength + $DestinationLeaf.Sha256 = $destinationSha + return [pscustomobject]@{ Length = $destinationLength; Sha256 = $destinationSha } + } finally { + if ($null -ne $sourceStream) { $sourceStream.Dispose() } + elseif ($null -ne $sourceHandle) { $sourceHandle.Dispose() } + } +} + +function Get-CcodInstallExistingPin { + param([Parameter(Mandatory)]$Transaction, [Parameter(Mandatory)]$ParentDirectory, [Parameter(Mandatory)][string]$Leaf) + $key = Get-CcodInstallObjectKey $ParentDirectory $Leaf + if ($Transaction.Objects.ContainsKey($key)) { return $Transaction.Objects[$key] } + return $null +} + +function Commit-CcodInstallPinnedPromotion { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$Transaction, + [Parameter(Mandatory)]$ParentDirectory, + [Parameter(Mandatory)]$TemporaryLeaf, + [Parameter(Mandatory)][string]$DestinationLeaf + ) + Assert-CcodInstallLeafName $DestinationLeaf + [void](Assert-CcodInstallPinCurrent -Transaction $Transaction -Pin $ParentDirectory -Kind Directory) + $temporaryInformation = Assert-CcodInstallPinCurrent -Transaction $Transaction -Pin $TemporaryLeaf -Kind File + Assert-CcodInstallPlainFile -Information $temporaryInformation -Handle $TemporaryLeaf.Stream.SafeFileHandle -Target $TemporaryLeaf.Leaf + if ($TemporaryLeaf.ParentDirectory.Identity.Key -cne $ParentDirectory.Identity.Key -or -not $TemporaryLeaf.Owned -or -not $TemporaryLeaf.Sealed) { + Throw-CcodInstallFileError 'CCOD_INSTALL_PROMOTION_INVALID' 'Promotion requires a sealed owned leaf under the pinned parent' $TemporaryLeaf.Leaf + } + if ($TemporaryLeaf.Leaf.Equals($DestinationLeaf, [StringComparison]::OrdinalIgnoreCase)) { return $TemporaryLeaf } + + $destinationKey = Get-CcodInstallObjectKey $ParentDirectory $DestinationLeaf + $existingPin = Get-CcodInstallExistingPin $Transaction $ParentDirectory $DestinationLeaf + $identityHandle = $null + $destinationExists = $false + try { + if ($null -ne $existingPin) { + $existingInformation = Assert-CcodInstallPinCurrent -Transaction $Transaction -Pin $existingPin -Kind File + Assert-CcodInstallPlainFile -Information $existingInformation -Handle (Get-CcodInstallPinHandle $existingPin) -Target $DestinationLeaf + $destinationExists = $true + } else { + try { $identityHandle = [CcodInstallFileNative]::OpenIdentityRelative($ParentDirectory.Handle, $DestinationLeaf) } + catch [ComponentModel.Win32Exception] { + if ($_.Exception.NativeErrorCode -notin @(2,3)) { throw } + } + if ($null -ne $identityHandle) { + $existingInformation = [CcodInstallFileNative]::GetInfo($identityHandle) + if ($existingInformation.IsReparsePoint) { + Throw-CcodInstallFileError 'CCOD_INSTALL_REPARSE_LEAF' 'Promotion destination is a reparse leaf' $DestinationLeaf + } + Assert-CcodInstallPlainFile -Information $existingInformation -Handle $identityHandle -Target $DestinationLeaf + $destinationExists = $true + } + } + } finally { + if ($null -ne $identityHandle) { $identityHandle.Dispose() } + } + + [void](Assert-CcodInstallPinCurrent -Transaction $Transaction -Pin $ParentDirectory -Kind Directory) + if ($null -ne $existingPin) { + Close-CcodInstallPin $existingPin + [void]$Transaction.Objects.Remove($destinationKey) + if ($Transaction.OwnedObjects.ContainsKey($destinationKey) -and $Transaction.OwnedObjects[$destinationKey] -eq $existingPin) { + [void]$Transaction.OwnedObjects.Remove($destinationKey) + } + } + $errorCode = [CcodInstallFileNative]::RenameRelative($TemporaryLeaf.Stream.SafeFileHandle, $ParentDirectory.Handle, $DestinationLeaf, $destinationExists) + if ($errorCode -ne 0) { + Throw-CcodInstallFileError 'CCOD_INSTALL_PROMOTION_FAILED' "Pinned promotion failed with Windows error $errorCode" $DestinationLeaf + } + + $temporaryKey = Get-CcodInstallObjectKey $ParentDirectory $TemporaryLeaf.Leaf + [void]$Transaction.Objects.Remove($temporaryKey) + [void]$Transaction.OwnedObjects.Remove($temporaryKey) + $TemporaryLeaf.Leaf = $DestinationLeaf + $TemporaryLeaf.ExpectedPath = Join-Path $ParentDirectory.ExpectedPath $DestinationLeaf + $Transaction.Objects[$destinationKey] = $TemporaryLeaf + $Transaction.OwnedObjects[$destinationKey] = $TemporaryLeaf + [void](Assert-CcodInstallPinCurrent -Transaction $Transaction -Pin $TemporaryLeaf -Kind File) + return $TemporaryLeaf +} + +function Write-CcodInstallPinnedBytes { + param([Parameter(Mandatory)]$Pin, [Parameter(Mandatory)][byte[]]$Bytes) + $Pin.Stream.Position = 0 + $Pin.Stream.SetLength(0) + $Pin.Stream.Write($Bytes, 0, $Bytes.Length) + $Pin.Stream.Flush($true) + $Pin.Length = [int64]$Bytes.LongLength + $Pin.Sha256 = Get-CcodInstallStreamSha256 $Pin.Stream + $Pin.Sealed = $true +} + +function Write-CcodInstallPinnedJson { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$Transaction, + [Parameter(Mandatory)]$ParentDirectory, + [Parameter(Mandatory)][string]$Leaf, + [Parameter(Mandatory)]$Value, + [switch]$Compress + ) + Assert-CcodInstallLeafName $Leaf + [void](Assert-CcodInstallPinCurrent -Transaction $Transaction -Pin $ParentDirectory -Kind Directory) + $json = ($Value | ConvertTo-Json -Depth 32 -Compress:$Compress) + "`n" + $bytes = [Text.UTF8Encoding]::new($false).GetBytes($json) + $prefix = ('.' + $Leaf + '.') + if ($prefix.Length -gt 100) { $prefix = '.json.' } + $temporaryName = $prefix + [guid]::NewGuid().ToString('N') + '.tmp' + $temporary = New-CcodInstallPinnedTemporaryLeaf -Transaction $Transaction -ParentDirectory $ParentDirectory -Leaf $temporaryName + Write-CcodInstallPinnedBytes -Pin $temporary -Bytes $bytes + return Commit-CcodInstallPinnedPromotion -Transaction $Transaction -ParentDirectory $ParentDirectory -TemporaryLeaf $temporary -DestinationLeaf $Leaf +} + +function Test-CcodInstallSanitizedValue { + param($Value, [int]$Depth = 0) + if ($Depth -gt 8) { return $false } + if ($null -eq $Value) { return $true } + if ($Value -is [string]) { return $Value.Length -le 4096 } + if ($Value -is [bool] -or $Value -is [byte] -or $Value -is [sbyte] -or + $Value -is [int16] -or $Value -is [uint16] -or $Value -is [int32] -or $Value -is [uint32] -or + $Value -is [int64] -or $Value -is [uint64] -or $Value -is [single] -or $Value -is [double] -or $Value -is [decimal]) { 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-CcodInstallSanitizedValue -Value $Value[$key] -Depth ($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-CcodInstallSanitizedValue -Value $property.Value -Depth ($Depth + 1))) { return $false } + } + return $true + } + if ($Value -is [Collections.IList]) { + foreach ($entry in @($Value)) { + if (-not (Test-CcodInstallSanitizedValue -Value $entry -Depth ($Depth + 1))) { return $false } + } + return $true + } + return $false +} + +function Append-CcodInstallPinnedLog { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$Transaction, + [Parameter(Mandatory)]$ParentDirectory, + [Parameter(Mandatory)][string]$Leaf, + [Parameter(Mandatory)]$Record + ) + Assert-CcodInstallLeafName $Leaf + if (($Record -isnot [Collections.IDictionary] -and $Record -isnot [pscustomobject]) -or + -not (Test-CcodInstallSanitizedValue $Record)) { + Throw-CcodInstallFileError 'CCOD_INSTALL_LOG_RECORD_INVALID' 'Install log record is not a bounded sanitized object' $null + } + [void](Assert-CcodInstallPinCurrent -Transaction $Transaction -Pin $ParentDirectory -Kind Directory) + $bytes = [Text.UTF8Encoding]::new($false).GetBytes(($Record | ConvertTo-Json -Depth 8 -Compress) + "`n") + if ($bytes.LongLength -gt 65536) { + Throw-CcodInstallFileError 'CCOD_INSTALL_LOG_RECORD_INVALID' 'Install log record exceeds the bounded size' $null + } + $key = Get-CcodInstallObjectKey $ParentDirectory $Leaf + $pin = Get-CcodInstallExistingPin $Transaction $ParentDirectory $Leaf + if ($null -ne $pin) { + $information = Assert-CcodInstallPinCurrent -Transaction $Transaction -Pin $pin -Kind File + Assert-CcodInstallPlainFile -Information $information -Handle $pin.Stream.SafeFileHandle -Target $Leaf + } else { + $result = $null + $stream = $null + try { + $result = [CcodInstallFileNative]::OpenAppendFileRelative($ParentDirectory.Handle, $Leaf) + $stream = [IO.FileStream]::new($result.Handle, [IO.FileAccess]::ReadWrite, 65536, $false) + $created = [bool]$result.Created + $result = $null + $information = [CcodInstallFileNative]::GetInfo($stream.SafeFileHandle) + Assert-CcodInstallPlainFile -Information $information -Handle $stream.SafeFileHandle -Target $Leaf + $pin = [pscustomobject]@{ + Kind = 'File'; TransactionId = $Transaction.Id; Handle = $null; Stream = $stream + ParentDirectory = $ParentDirectory; Leaf = $Leaf; ExpectedPath = (Join-Path $ParentDirectory.ExpectedPath $Leaf) + Identity = Get-CcodInstallIdentity $information; Created = $created; Owned = $created; Closed = $false + Sealed = $false; Length = [int64]$information.Length; Sha256 = $null + } + $stream = $null + $Transaction.Pins.Add($pin) + $Transaction.Objects.Add($key, $pin) + if ($pin.Owned) { $Transaction.OwnedObjects.Add($key, $pin) } + } catch { + if ($null -ne $stream) { $stream.Dispose() } + if ($null -ne $result) { $result.Handle.Dispose() } + throw + } + } + [void](Assert-CcodInstallPinCurrent -Transaction $Transaction -Pin $ParentDirectory -Kind Directory) + [void](Assert-CcodInstallPinCurrent -Transaction $Transaction -Pin $pin -Kind File) + $pin.Stream.Position = $pin.Stream.Length + $pin.Stream.Write($bytes, 0, $bytes.Length) + $pin.Stream.Flush($true) + $pin.Length = [int64]$pin.Stream.Length + return $pin +} + +function Get-CcodInstallOwnedDeletionPlan { + param( + [Parameter(Mandatory)]$Transaction, + [Parameter(Mandatory)]$DirectoryPin, + [Parameter(Mandatory)][AllowEmptyCollection()][Collections.Generic.List[object]]$Plan + ) + [void](Assert-CcodInstallPinCurrent -Transaction $Transaction -Pin $DirectoryPin -Kind Directory) + foreach ($name in [CcodInstallFileNative]::EnumerateDirectory($DirectoryPin.Handle)) { + Assert-CcodInstallLeafName $name + $key = Get-CcodInstallObjectKey $DirectoryPin $name + $pin = if ($Transaction.Objects.ContainsKey($key)) { $Transaction.Objects[$key] } else { $null } + if ($null -eq $pin) { + $probe = $null + try { + $probe = [CcodInstallFileNative]::OpenIdentityRelative($DirectoryPin.Handle, $name) + $probeInformation = [CcodInstallFileNative]::GetInfo($probe) + if ($probeInformation.IsReparsePoint) { + Throw-CcodInstallFileError 'CCOD_INSTALL_REPARSE_LEAF' 'Owned-tree cleanup encountered a reparse leaf' $name + } + if (-not $probeInformation.IsDirectory) { + Assert-CcodInstallPlainFile -Information $probeInformation -Handle $probe -Target $name + } + Throw-CcodInstallFileError 'CCOD_INSTALL_UNKNOWN_LEAF' 'Owned-tree cleanup encountered an unowned leaf' $name + } finally { + if ($null -ne $probe) { $probe.Dispose() } + } + } + if (-not $Transaction.OwnedObjects.ContainsKey($key) -or $Transaction.OwnedObjects[$key] -ne $pin) { + Throw-CcodInstallFileError 'CCOD_INSTALL_UNKNOWN_LEAF' 'Owned-tree cleanup encountered an unowned tracked leaf' $name + } + if ($pin.Kind -ceq 'Directory') { + [void](Assert-CcodInstallPinCurrent -Transaction $Transaction -Pin $pin -Kind Directory) + Get-CcodInstallOwnedDeletionPlan -Transaction $Transaction -DirectoryPin $pin -Plan $Plan + } else { + $information = Assert-CcodInstallPinCurrent -Transaction $Transaction -Pin $pin -Kind File + Assert-CcodInstallPlainFile -Information $information -Handle $pin.Stream.SafeFileHandle -Target $name + } + $Plan.Add($pin) + } +} + +function Remove-CcodInstallOwnedTree { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$Transaction, + [Parameter(Mandatory)]$ParentDirectory, + [Parameter(Mandatory)][string]$Leaf + ) + Assert-CcodInstallLeafName $Leaf + [void](Assert-CcodInstallPinCurrent -Transaction $Transaction -Pin $ParentDirectory -Kind Directory) + $key = Get-CcodInstallObjectKey $ParentDirectory $Leaf + if (-not $Transaction.OwnedObjects.ContainsKey($key)) { + Throw-CcodInstallFileError 'CCOD_INSTALL_UNKNOWN_LEAF' 'Owned-tree cleanup target is not transaction-owned' $Leaf + } + $target = $Transaction.OwnedObjects[$key] + [void](Assert-CcodInstallPinCurrent -Transaction $Transaction -Pin $target -Kind Directory) + $plan = [Collections.Generic.List[object]]::new() + Get-CcodInstallOwnedDeletionPlan -Transaction $Transaction -DirectoryPin $target -Plan $plan + $plan.Add($target) + + foreach ($pin in $plan) { + $pinKey = if ($null -eq $pin.ParentDirectory) { $null } else { Get-CcodInstallObjectKey $pin.ParentDirectory $pin.Leaf } + $errorCode = [CcodInstallFileNative]::MarkDelete((Get-CcodInstallPinHandle $pin)) + if ($errorCode -ne 0) { + Throw-CcodInstallFileError 'CCOD_INSTALL_DELETE_FAILED' "Owned-tree handle delete failed with Windows error $errorCode" $pin.Leaf + } + Close-CcodInstallPin $pin + if ($null -ne $pinKey) { + [void]$Transaction.Objects.Remove($pinKey) + [void]$Transaction.OwnedObjects.Remove($pinKey) + } + } +} + +function Close-CcodInstallFileTransaction { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$Transaction, + [Parameter(Mandatory)][ValidateSet('Ready','Failed')][string]$Disposition + ) + if ($null -eq $Transaction -or $Transaction.Kind -cne 'CcodInstallFileTransaction') { + Throw-CcodInstallFileError 'CCOD_INSTALL_TRANSACTION_INVALID' 'Install file transaction context is invalid' $null + } + if ($Transaction.Closed) { return $Transaction } + for ($index = $Transaction.Pins.Count - 1; $index -ge 0; $index--) { + Close-CcodInstallPin $Transaction.Pins[$index] + } + $Transaction.Disposition = $Disposition + $Transaction.Closed = $true + return $Transaction +} + +Export-ModuleMember -Function @( + 'Open-CcodInstallFileTransaction', + 'Open-CcodInstallPinnedDirectory', + 'New-CcodInstallPinnedTemporaryLeaf', + 'Copy-CcodInstallSealedFile', + 'Commit-CcodInstallPinnedPromotion', + 'Write-CcodInstallPinnedJson', + 'Append-CcodInstallPinnedLog', + 'Remove-CcodInstallOwnedTree', + 'Close-CcodInstallFileTransaction' +) diff --git a/tests/PersistenceSelfTest.ps1 b/tests/PersistenceSelfTest.ps1 index e6583a7..aafd8aa 100644 --- a/tests/PersistenceSelfTest.ps1 +++ b/tests/PersistenceSelfTest.ps1 @@ -14,6 +14,10 @@ if ($PSVersionTable.PSEdition -eq 'Desktop') { } $powershell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' +$requiredTransactionSelfTest = Join-Path $PSScriptRoot 'persistence\InstallFileTransaction.SelfTest.ps1' +if (-not (Test-Path -LiteralPath $requiredTransactionSelfTest -PathType Leaf)) { + throw "Required install file transaction self-test is missing: $requiredTransactionSelfTest" +} $tests = Get-ChildItem -LiteralPath (Join-Path $PSScriptRoot 'persistence') -Filter '*.SelfTest.ps1' | Sort-Object Name foreach ($test in $tests) { $previousErrorActionPreference = $ErrorActionPreference diff --git a/tests/Validate.ps1 b/tests/Validate.ps1 index 497264b..d1533e2 100644 --- a/tests/Validate.ps1 +++ b/tests/Validate.ps1 @@ -129,6 +129,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,6 +138,7 @@ 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', diff --git a/tests/persistence/InstallFileTransaction.SelfTest.ps1 b/tests/persistence/InstallFileTransaction.SelfTest.ps1 new file mode 100644 index 0000000..26f07e1 --- /dev/null +++ b/tests/persistence/InstallFileTransaction.SelfTest.ps1 @@ -0,0 +1,447 @@ +$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 = $null +if (Test-Path -LiteralPath $modulePath -PathType Leaf) { + $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking +} + +if ($null -eq ('CcodInstallTransactionTestNative' -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +public static class CcodInstallTransactionTestNative +{ + private const uint FILE_LIST_DIRECTORY = 0x00000001; + private const uint FILE_READ_ATTRIBUTES = 0x00000080; + private const uint DELETE = 0x00010000; + private const uint GENERIC_READ = 0x80000000; + private const uint GENERIC_WRITE = 0x40000000; + private const uint FILE_SHARE_READ = 0x00000001; + private const uint FILE_SHARE_WRITE = 0x00000002; + private const uint FILE_SHARE_DELETE = 0x00000004; + private const uint OPEN_EXISTING = 3; + private const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000; + private const uint FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000; + private const uint FILE_FLAG_WRITE_THROUGH = 0x80000000; + + [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", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CreateHardLinkW(string newName, string existingName, IntPtr securityAttributes); + + public static int CreateHardLink(string newName, string existingName) + { + return CreateHardLinkW(newName, existingName, IntPtr.Zero) ? 0 : Marshal.GetLastWin32Error(); + } + + public static SafeFileHandle OpenDirectoryAllowDeleteShare(string path) + { + SafeFileHandle handle = CreateFileW(path, FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES | DELETE, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, IntPtr.Zero, OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, IntPtr.Zero); + if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error()); + return handle; + } + + public static SafeFileHandle OpenFileAllowDeleteShare(string path) + { + SafeFileHandle handle = CreateFileW(path, GENERIC_READ | GENERIC_WRITE | DELETE | FILE_READ_ATTRIBUTES, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, IntPtr.Zero, OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_WRITE_THROUGH, IntPtr.Zero); + if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error()); + return handle; + } +} +'@ +} + +function Invoke-CcodInstallModule { + param([Parameter(Mandatory)][scriptblock]$Action, [object[]]$Arguments = @()) + if ($null -eq $module) { return & $Action @Arguments } + return & $module $Action @Arguments +} + +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' + $sentinelBytes = [Text.UTF8Encoding]::new($false).GetBytes('outside-sentinel-v1') + [IO.File]::WriteAllBytes($sentinel, $sentinelBytes) + return [pscustomobject]@{ + Base = $base + MovedBase = $null + Install = $install + Outside = $outside + Sentinel = $sentinel + SentinelSha256 = Get-CcodTestFileSha256 -Path $sentinel + Transaction = $null + } +} + +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 -Path $Fixture.Sentinel) "$Message sentinel bytes" +} + +function Close-CcodFixtureTransaction { + param([Parameter(Mandatory)]$Fixture) + if ($null -eq $Fixture.Transaction) { return } + try { + Invoke-CcodInstallModule { param($Tx) Close-CcodInstallFileTransaction -Transaction $Tx -Disposition Failed } @($Fixture.Transaction) | Out-Null + } catch { } + $Fixture.Transaction = $null +} + +function Remove-CcodInstallFileFixture { + param([Parameter(Mandatory)]$Fixture) + Close-CcodFixtureTransaction -Fixture $Fixture + $tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + '\' + foreach ($candidate in @($Fixture.Base, $Fixture.MovedBase, $Fixture.Outside)) { + if ([string]::IsNullOrWhiteSpace([string]$candidate)) { continue } + $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-CcodFixtureTransaction { + param([Parameter(Mandatory)]$Fixture) + $Fixture.Transaction = Invoke-CcodInstallModule { param($Install) Open-CcodInstallFileTransaction -InstallRoot $Install } @($Fixture.Install) + return $Fixture.Transaction +} + +function Invoke-CcodAttemptDirectoryReplacement { + param([Parameter(Mandatory)][string]$Path) + $backup = $Path + '.attacker-old' + try { + [IO.Directory]::Move($Path, $backup) + return [pscustomobject]@{ Outcome = 'replaced'; Backup = $backup } + } catch [IO.IOException] { + return [pscustomobject]@{ Outcome = 'blocked'; Backup = $null } + } catch [UnauthorizedAccessException] { + return [pscustomobject]@{ Outcome = 'blocked'; Backup = $null } + } +} + +function Invoke-CcodAttemptFileReplacement { + param([Parameter(Mandatory)][string]$Path) + $backup = $Path + '.attacker-old' + try { + [IO.File]::Move($Path, $backup) + return [pscustomobject]@{ Outcome = 'replaced'; Backup = $backup } + } catch [IO.IOException] { + return [pscustomobject]@{ Outcome = 'blocked'; Backup = $null } + } catch [UnauthorizedAccessException] { + return [pscustomobject]@{ Outcome = 'blocked'; Backup = $null } + } +} + +function Invoke-CcodReplacePinnedDirectory { + param( + [Parameter(Mandatory)]$Fixture, + [Parameter(Mandatory)]$Transaction, + [Parameter(Mandatory)]$DirectoryPin + ) + $renamedLeaf = $DirectoryPin.Leaf + '.attacker-old' + # The production pin intentionally denies delete sharing. Reopen only inside + # this fault-injection helper so the independent final-path guard is tested. + $descendantPrefix = [IO.Path]::GetFullPath($DirectoryPin.ExpectedPath).TrimEnd('\') + '\' + $descendants = @($Transaction.Pins | Where-Object { + -not $_.Closed -and $_.Kind -ceq 'File' -and + [IO.Path]::GetFullPath([string]$_.ExpectedPath).StartsWith($descendantPrefix, [StringComparison]::OrdinalIgnoreCase) + }) + foreach ($pin in $descendants) { $pin.Stream.Dispose() } + $DirectoryPin.Handle.Dispose() + $DirectoryPin.Handle = [CcodInstallTransactionTestNative]::OpenDirectoryAllowDeleteShare($DirectoryPin.ExpectedPath) + $moved = Join-Path $DirectoryPin.ParentDirectory.ExpectedPath $renamedLeaf + $renameError = Invoke-CcodInstallModule { + param($Pin,$RenamedLeaf) + [CcodInstallFileNative]::RenameRelative($Pin.Handle, $Pin.ParentDirectory.Handle, $RenamedLeaf, $false) + } @($DirectoryPin,$renamedLeaf) + if ($renameError -ne 0) { throw "Could not rename fault-injection directory: Windows error $renameError" } + foreach ($pin in $descendants) { + $relative = [IO.Path]::GetFullPath([string]$pin.ExpectedPath).Substring($descendantPrefix.Length) + $fileHandle = [CcodInstallTransactionTestNative]::OpenFileAllowDeleteShare((Join-Path $moved $relative)) + $pin.Stream = [IO.FileStream]::new($fileHandle, [IO.FileAccess]::ReadWrite, 65536, $false) + } + $result = & cmd.exe /d /c mklink /J $DirectoryPin.ExpectedPath $Fixture.Outside 2>&1 + if ($LASTEXITCODE -ne 0) { throw "Could not create replacement junction: $result" } + return [pscustomobject]@{ Outcome = 'replaced'; MovedDirectory = $moved } +} + +function New-CcodHardLink { + param([Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$Existing) + $errorCode = [CcodInstallTransactionTestNative]::CreateHardLink($Path, $Existing) + if ($errorCode -ne 0) { throw "CreateHardLink failed with Windows error $errorCode" } +} + +function New-CcodJunction { + param([Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$Target) + $result = & cmd.exe /d /c mklink /J $Path $Target 2>&1 + if ($LASTEXITCODE -ne 0) { throw "Could not create junction: $result" } +} + +function New-CcodSourceFile { + param([Parameter(Mandatory)]$Fixture, [string]$Name = 'source.bin', [string]$Content = 'sealed-source-v1') + $path = Join-Path $Fixture.Base $Name + $bytes = [Text.UTF8Encoding]::new($false).GetBytes($Content) + [IO.File]::WriteAllBytes($path, $bytes) + return [pscustomobject]@{ Path = $path; Length = [int64]$bytes.LongLength; Sha256 = Get-CcodTestFileSha256 -Path $path } +} + +Invoke-CcodTest 'pins a newly created child directory against pathname replacement' { + $fixture = New-CcodInstallFileFixture + try { + $tx = Open-CcodFixtureTransaction $fixture + $child = Invoke-CcodInstallModule { param($Tx) Open-CcodInstallPinnedDirectory -Transaction $Tx -ParentDirectory $Tx.RootDirectory -Leaf 'runtime' -CreateIfMissing } @($tx) + $result = Invoke-CcodAttemptDirectoryReplacement -Path (Join-Path $fixture.Install 'runtime') + Assert-CcodEqual 'blocked' $result.Outcome 'pinned missing directory cannot be replaced after creation' + Assert-CcodOutsideUnchanged $fixture 'directory replacement' + } finally { Remove-CcodInstallFileFixture $fixture } +} + +Invoke-CcodTest 'pins a temporary leaf against replacement before promotion' { + $fixture = New-CcodInstallFileFixture + try { + $tx = Open-CcodFixtureTransaction $fixture + $runtime = Invoke-CcodInstallModule { param($Tx) Open-CcodInstallPinnedDirectory -Transaction $Tx -ParentDirectory $Tx.RootDirectory -Leaf 'runtime' -CreateIfMissing } @($tx) + $temporary = Invoke-CcodInstallModule { param($Tx,$Parent) New-CcodInstallPinnedTemporaryLeaf -Transaction $Tx -ParentDirectory $Parent -Leaf 'payload.tmp' } @($tx,$runtime) + $result = Invoke-CcodAttemptFileReplacement -Path (Join-Path $fixture.Install 'runtime\payload.tmp') + Assert-CcodEqual 'blocked' $result.Outcome 'temporary leaf replacement is blocked while pinned' + Assert-CcodOutsideUnchanged $fixture 'temporary replacement' + } finally { Remove-CcodInstallFileFixture $fixture } +} + +Invoke-CcodTest 'pins sealed source bytes while copying and verifies the destination handle' { + $fixture = New-CcodInstallFileFixture + try { + $source = New-CcodSourceFile $fixture + $tx = Open-CcodFixtureTransaction $fixture + $runtime = Invoke-CcodInstallModule { param($Tx) Open-CcodInstallPinnedDirectory -Transaction $Tx -ParentDirectory $Tx.RootDirectory -Leaf 'runtime' -CreateIfMissing } @($tx) + $temporary = Invoke-CcodInstallModule { param($Tx,$Parent) New-CcodInstallPinnedTemporaryLeaf -Transaction $Tx -ParentDirectory $Parent -Leaf 'payload.tmp' } @($tx,$runtime) + $hook = { + param($OpenedSource) + try { + [IO.File]::Move($OpenedSource, ($OpenedSource + '.attacker-old')) + return [pscustomobject]@{ Outcome = 'replaced' } + } catch [IO.IOException] { + return [pscustomobject]@{ Outcome = 'blocked' } + } catch [UnauthorizedAccessException] { + return [pscustomobject]@{ Outcome = 'blocked' } + } + } + Invoke-CcodInstallModule { param($Hook) $script:CcodInstallFileTransactionAfterSourceOpen = $Hook } @($hook) | Out-Null + try { + $copy = Invoke-CcodInstallModule { + param($Tx,$Source,$Leaf,$Length,$Sha) + Copy-CcodInstallSealedFile -Transaction $Tx -SourcePath $Source -DestinationLeaf $Leaf -ExpectedLength $Length -ExpectedSha256 $Sha + } @($tx,$source.Path,$temporary,$source.Length,$source.Sha256) + } finally { + Invoke-CcodInstallModule { $script:CcodInstallFileTransactionAfterSourceOpen = $null } | Out-Null + } + Assert-CcodEqual 'blocked' $tx.LastSourceOpenAttempt.Outcome 'source replacement is blocked after its read handle opens' + Assert-CcodEqual $source.Length $copy.Length 'copied length is sealed' + Assert-CcodEqual $source.Sha256 $copy.Sha256 'copied digest is sealed' + Assert-CcodOutsideUnchanged $fixture 'source replacement' + } finally { Remove-CcodInstallFileFixture $fixture } +} + +Invoke-CcodTest 'rejects reparse ADS and multi-link leaf inputs' { + $fixture = New-CcodInstallFileFixture + try { + New-CcodJunction -Path (Join-Path $fixture.Install 'linked') -Target $fixture.Outside + $tx = Open-CcodFixtureTransaction $fixture + Assert-CcodThrows { + Invoke-CcodInstallModule { param($Tx) Open-CcodInstallPinnedDirectory -Transaction $Tx -ParentDirectory $Tx.RootDirectory -Leaf 'linked' } @($tx) | Out-Null + } 'CCOD_INSTALL_REPARSE_LEAF' + Assert-CcodThrows { + Invoke-CcodInstallModule { param($Tx) New-CcodInstallPinnedTemporaryLeaf -Transaction $Tx -ParentDirectory $Tx.RootDirectory -Leaf 'payload.tmp:ads' } @($tx) | Out-Null + } 'CCOD_INSTALL_LEAF_INVALID' + + $source = New-CcodSourceFile $fixture + New-CcodHardLink -Path (Join-Path $fixture.Base 'source-hardlink.bin') -Existing $source.Path + $temporary = Invoke-CcodInstallModule { param($Tx) New-CcodInstallPinnedTemporaryLeaf -Transaction $Tx -ParentDirectory $Tx.RootDirectory -Leaf 'payload.tmp' } @($tx) + Assert-CcodThrows { + Invoke-CcodInstallModule { + param($Tx,$Source,$Leaf,$Length,$Sha) + Copy-CcodInstallSealedFile -Transaction $Tx -SourcePath $Source -DestinationLeaf $Leaf -ExpectedLength $Length -ExpectedSha256 $Sha + } @($tx,$source.Path,$temporary,$source.Length,$source.Sha256) | Out-Null + } 'CCOD_INSTALL_MULTILINK_LEAF' + Assert-CcodTrue ([IO.File]::Exists((Join-Path $fixture.Install 'payload.tmp'))) 'rejected multi-link source retains the owned temporary candidate' + Assert-CcodOutsideUnchanged $fixture 'invalid leaves' + } finally { Remove-CcodInstallFileFixture $fixture } +} + +Invoke-CcodTest 'rejects an ADS-bearing promotion destination and retains the temporary leaf' { + $fixture = New-CcodInstallFileFixture + try { + $destination = Join-Path $fixture.Install 'active.json' + [IO.File]::WriteAllText($destination, 'old') + $adsResult = & cmd.exe /d /c "echo attacker>`"$destination`:metadata`"" 2>&1 + if ($LASTEXITCODE -ne 0) { throw "Could not create alternate data stream: $adsResult" } + $source = New-CcodSourceFile $fixture + $tx = Open-CcodFixtureTransaction $fixture + $temporary = Invoke-CcodInstallModule { param($Tx) New-CcodInstallPinnedTemporaryLeaf -Transaction $Tx -ParentDirectory $Tx.RootDirectory -Leaf 'active.tmp' } @($tx) + Invoke-CcodInstallModule { + param($Tx,$Source,$Leaf,$Length,$Sha) + Copy-CcodInstallSealedFile -Transaction $Tx -SourcePath $Source -DestinationLeaf $Leaf -ExpectedLength $Length -ExpectedSha256 $Sha | Out-Null + } @($tx,$source.Path,$temporary,$source.Length,$source.Sha256) + Assert-CcodThrows { + Invoke-CcodInstallModule { param($Tx,$Leaf) Commit-CcodInstallPinnedPromotion -Transaction $Tx -ParentDirectory $Tx.RootDirectory -TemporaryLeaf $Leaf -DestinationLeaf 'active.json' } @($tx,$temporary) | Out-Null + } 'CCOD_INSTALL_ADS_LEAF' + Assert-CcodTrue ([IO.File]::Exists((Join-Path $fixture.Install 'active.tmp'))) 'failed ADS promotion retains the temporary candidate' + Assert-CcodOutsideUnchanged $fixture 'ADS destination' + } finally { Remove-CcodInstallFileFixture $fixture } +} + +Invoke-CcodTest 'rejects promotion after an ancestor rename changes the pinned parent path' { + $fixture = New-CcodInstallFileFixture + try { + $source = New-CcodSourceFile $fixture + $tx = Open-CcodFixtureTransaction $fixture + $runtime = Invoke-CcodInstallModule { param($Tx) Open-CcodInstallPinnedDirectory -Transaction $Tx -ParentDirectory $Tx.RootDirectory -Leaf 'runtime' -CreateIfMissing } @($tx) + $temporary = Invoke-CcodInstallModule { param($Tx,$Parent) New-CcodInstallPinnedTemporaryLeaf -Transaction $Tx -ParentDirectory $Parent -Leaf 'payload.tmp' } @($tx,$runtime) + Invoke-CcodInstallModule { + param($Tx,$Source,$Leaf,$Length,$Sha) + Copy-CcodInstallSealedFile -Transaction $Tx -SourcePath $Source -DestinationLeaf $Leaf -ExpectedLength $Length -ExpectedSha256 $Sha | Out-Null + } @($tx,$source.Path,$temporary,$source.Length,$source.Sha256) + $attack = Invoke-CcodReplacePinnedDirectory -Fixture $fixture -Transaction $tx -DirectoryPin $runtime + Assert-CcodEqual 'replaced' $attack.Outcome 'fixture ancestor replacement is established' + Assert-CcodThrows { + Invoke-CcodInstallModule { param($Tx,$Parent,$Leaf) Commit-CcodInstallPinnedPromotion -Transaction $Tx -ParentDirectory $Parent -TemporaryLeaf $Leaf -DestinationLeaf 'payload.bin' } @($tx,$runtime,$temporary) | Out-Null + } 'CCOD_INSTALL_PIN_CHANGED' + Assert-CcodTrue ([IO.File]::Exists((Join-Path $attack.MovedDirectory 'payload.tmp'))) 'renamed-parent promotion retains its temporary candidate' + Assert-CcodOutsideUnchanged $fixture 'renamed parent promotion' + } finally { Remove-CcodInstallFileFixture $fixture } +} + +foreach ($case in @('unknown','reparse','multilink')) { + Invoke-CcodTest "retains an owned tree containing an unproven $case leaf" { + $fixture = New-CcodInstallFileFixture + try { + $tx = Open-CcodFixtureTransaction $fixture + $candidate = Invoke-CcodInstallModule { param($Tx) Open-CcodInstallPinnedDirectory -Transaction $Tx -ParentDirectory $Tx.RootDirectory -Leaf 'candidate' -CreateIfMissing } @($tx) + switch ($case) { + 'unknown' { [IO.File]::WriteAllText((Join-Path $fixture.Install 'candidate\surprise.bin'), 'unknown') } + 'reparse' { New-CcodJunction -Path (Join-Path $fixture.Install 'candidate\outside-link') -Target $fixture.Outside } + 'multilink' { + $owned = Invoke-CcodInstallModule { param($Tx,$Parent) New-CcodInstallPinnedTemporaryLeaf -Transaction $Tx -ParentDirectory $Parent -Leaf 'owned.tmp' } @($tx,$candidate) + New-CcodHardLink -Path (Join-Path $fixture.Outside 'owned-hardlink.tmp') -Existing (Join-Path $fixture.Install 'candidate\owned.tmp') + } + } + $expected = switch ($case) { + 'unknown' { 'CCOD_INSTALL_UNKNOWN_LEAF' } + 'reparse' { 'CCOD_INSTALL_REPARSE_LEAF' } + 'multilink' { 'CCOD_INSTALL_MULTILINK_LEAF' } + } + Assert-CcodThrows { + Invoke-CcodInstallModule { param($Tx) Remove-CcodInstallOwnedTree -Transaction $Tx -ParentDirectory $Tx.RootDirectory -Leaf 'candidate' } @($tx) | Out-Null + } $expected + Assert-CcodTrue ([IO.Directory]::Exists((Join-Path $fixture.Install 'candidate'))) "failed $case cleanup retains the candidate directory" + Assert-CcodOutsideUnchanged $fixture "$case cleanup" + } finally { Remove-CcodInstallFileFixture $fixture } + } +} + +Invoke-CcodTest 'deletes a fully proven transaction-owned tree from leaves upward' { + $fixture = New-CcodInstallFileFixture + try { + $source = New-CcodSourceFile $fixture + $tx = Open-CcodFixtureTransaction $fixture + $candidate = Invoke-CcodInstallModule { param($Tx) Open-CcodInstallPinnedDirectory -Transaction $Tx -ParentDirectory $Tx.RootDirectory -Leaf 'candidate' -CreateIfMissing } @($tx) + $temporary = Invoke-CcodInstallModule { param($Tx,$Parent) New-CcodInstallPinnedTemporaryLeaf -Transaction $Tx -ParentDirectory $Parent -Leaf 'payload.tmp' } @($tx,$candidate) + Invoke-CcodInstallModule { + param($Tx,$Source,$Leaf,$Length,$Sha) + Copy-CcodInstallSealedFile -Transaction $Tx -SourcePath $Source -DestinationLeaf $Leaf -ExpectedLength $Length -ExpectedSha256 $Sha | Out-Null + Commit-CcodInstallPinnedPromotion -Transaction $Tx -ParentDirectory $Leaf.ParentDirectory -TemporaryLeaf $Leaf -DestinationLeaf 'payload.bin' | Out-Null + } @($tx,$source.Path,$temporary,$source.Length,$source.Sha256) + Invoke-CcodInstallModule { param($Tx) Remove-CcodInstallOwnedTree -Transaction $Tx -ParentDirectory $Tx.RootDirectory -Leaf 'candidate' } @($tx) | Out-Null + Assert-CcodTrue (-not [IO.Directory]::Exists((Join-Path $fixture.Install 'candidate'))) 'proven owned tree is removed' + Assert-CcodOutsideUnchanged $fixture 'owned tree removal' + } finally { Remove-CcodInstallFileFixture $fixture } +} + +foreach ($case in @('json','log')) { + Invoke-CcodTest "rejects a pinned $case write after parent pathname replacement" { + $fixture = New-CcodInstallFileFixture + try { + $tx = Open-CcodFixtureTransaction $fixture + $parent = Invoke-CcodInstallModule { param($Tx,$Leaf) Open-CcodInstallPinnedDirectory -Transaction $Tx -ParentDirectory $Tx.RootDirectory -Leaf $Leaf -CreateIfMissing } @($tx,$case) + $attack = Invoke-CcodReplacePinnedDirectory -Fixture $fixture -Transaction $tx -DirectoryPin $parent + Assert-CcodEqual 'replaced' $attack.Outcome "$case fixture replacement is established" + if ($case -ceq 'json') { + Assert-CcodThrows { + Invoke-CcodInstallModule { param($Tx,$Parent) Write-CcodInstallPinnedJson -Transaction $Tx -ParentDirectory $Parent -Leaf 'state.json' -Value ([ordered]@{ schemaVersion = 1; value = 'safe' }) -Compress } @($tx,$parent) + } 'CCOD_INSTALL_PIN_CHANGED' + Assert-CcodTrue (-not [IO.File]::Exists((Join-Path $fixture.Outside 'state.json'))) 'replaced JSON path is untouched' + } else { + Assert-CcodThrows { + Invoke-CcodInstallModule { param($Tx,$Parent) Append-CcodInstallPinnedLog -Transaction $Tx -ParentDirectory $Parent -Leaf 'install.log' -Record ([ordered]@{ event = 'safe'; code = 'CCOD_TEST' }) } @($tx,$parent) + } 'CCOD_INSTALL_PIN_CHANGED' + Assert-CcodTrue (-not [IO.File]::Exists((Join-Path $fixture.Outside 'install.log'))) 'replaced log path is untouched' + } + Assert-CcodOutsideUnchanged $fixture "replaced $case parent" + } finally { Remove-CcodInstallFileFixture $fixture } + } +} + +Invoke-CcodTest 'writes pinned JSON and appends sanitized records through relative handles' { + $fixture = New-CcodInstallFileFixture + try { + $tx = Open-CcodFixtureTransaction $fixture + $state = Invoke-CcodInstallModule { param($Tx) Open-CcodInstallPinnedDirectory -Transaction $Tx -ParentDirectory $Tx.RootDirectory -Leaf 'state' -CreateIfMissing } @($tx) + $logs = Invoke-CcodInstallModule { param($Tx) Open-CcodInstallPinnedDirectory -Transaction $Tx -ParentDirectory $Tx.RootDirectory -Leaf 'logs' -CreateIfMissing } @($tx) + Invoke-CcodInstallModule { param($Tx,$Parent) Write-CcodInstallPinnedJson -Transaction $Tx -ParentDirectory $Parent -Leaf 'active.json' -Value ([ordered]@{ schemaVersion = 1; active = 'runtime-a' }) -Compress } @($tx,$state) | Out-Null + Invoke-CcodInstallModule { param($Tx,$Parent) Write-CcodInstallPinnedJson -Transaction $Tx -ParentDirectory $Parent -Leaf 'active.json' -Value ([ordered]@{ schemaVersion = 1; active = 'runtime-b' }) -Compress } @($tx,$state) | Out-Null + Invoke-CcodInstallModule { param($Tx,$Parent) Append-CcodInstallPinnedLog -Transaction $Tx -ParentDirectory $Parent -Leaf 'install.log' -Record ([ordered]@{ event = 'one'; code = 'CCOD_OK' }) } @($tx,$logs) | Out-Null + Invoke-CcodInstallModule { param($Tx,$Parent) Append-CcodInstallPinnedLog -Transaction $Tx -ParentDirectory $Parent -Leaf 'install.log' -Record ([ordered]@{ event = 'two'; code = 'CCOD_READY' }) } @($tx,$logs) | Out-Null + Invoke-CcodInstallModule { param($Tx) Close-CcodInstallFileTransaction -Transaction $Tx -Disposition Ready } @($tx) | Out-Null + $fixture.Transaction = $null + + $json = [IO.File]::ReadAllText((Join-Path $fixture.Install 'state\active.json'), [Text.UTF8Encoding]::new($false)) | ConvertFrom-Json + Assert-CcodEqual 'runtime-b' $json.active 'second pinned JSON write atomically replaces the first' + $records = @([IO.File]::ReadAllLines((Join-Path $fixture.Install 'logs\install.log'), [Text.UTF8Encoding]::new($false))) + Assert-CcodEqual 2 $records.Count 'pinned log appends one JSON line per record' + Assert-CcodEqual 'one' (($records[0] | ConvertFrom-Json).event) 'first log record is retained' + Assert-CcodEqual 'two' (($records[1] | ConvertFrom-Json).event) 'second log record is appended' + Assert-CcodOutsideUnchanged $fixture 'successful pinned writers' + } finally { Remove-CcodInstallFileFixture $fixture } +} + +Invoke-CcodTest 'exports only the private transaction surface and closes on a terminal disposition' { + $fixture = New-CcodInstallFileFixture + try { + $expectedExports = @( + 'Append-CcodInstallPinnedLog','Close-CcodInstallFileTransaction','Commit-CcodInstallPinnedPromotion', + 'Copy-CcodInstallSealedFile','New-CcodInstallPinnedTemporaryLeaf','Open-CcodInstallFileTransaction', + 'Open-CcodInstallPinnedDirectory','Remove-CcodInstallOwnedTree','Write-CcodInstallPinnedJson' + ) + $actualExports = @($module.ExportedFunctions.Keys | Sort-Object) + Assert-CcodEqual ($expectedExports -join ',') ($actualExports -join ',') 'module exports only the approved private transaction surface' + $tx = Open-CcodFixtureTransaction $fixture + Invoke-CcodInstallModule { param($Tx) Close-CcodInstallFileTransaction -Transaction $Tx -Disposition Ready } @($tx) | Out-Null + $fixture.Transaction = $null + Assert-CcodEqual 'Ready' $tx.Disposition 'close records the terminal disposition' + Assert-CcodTrue $tx.Closed 'close marks the context closed' + Assert-CcodThrows { + Invoke-CcodInstallModule { param($Tx) Open-CcodInstallPinnedDirectory -Transaction $Tx -ParentDirectory $Tx.RootDirectory -Leaf 'after-close' -CreateIfMissing } @($tx) | Out-Null + } 'CCOD_INSTALL_TRANSACTION_CLOSED' + } finally { Remove-CcodInstallFileFixture $fixture } +} + +Write-Host 'Install file transaction self-test passed.' -ForegroundColor Green From a431dfb26027b901e956ad90d26180f23aa83a8f Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Sat, 29 Aug 2026 09:40:55 +0800 Subject: [PATCH 066/187] fix: harden pinned install transaction capabilities --- .../modules/InstallFileTransaction.psm1 | 1182 +++-------------- .../InstallFileTransaction.SelfTest.ps1 | 327 ++++- 2 files changed, 457 insertions(+), 1052 deletions(-) diff --git a/src/persistence/modules/InstallFileTransaction.psm1 b/src/persistence/modules/InstallFileTransaction.psm1 index 7b1ebbc..f625e50 100644 --- a/src/persistence/modules/InstallFileTransaction.psm1 +++ b/src/persistence/modules/InstallFileTransaction.psm1 @@ -1,1018 +1,230 @@ Set-StrictMode -Version Latest -$script:CcodInstallFileTransactionAfterSourceOpen = $null +$script:CcodTransactions = [Runtime.CompilerServices.ConditionalWeakTable[object,object]]::new() +$script:CcodPins = [Runtime.CompilerServices.ConditionalWeakTable[object,object]]::new() function Throw-CcodInstallFileError { - param( - [Parameter(Mandatory)][string]$Id, - [Parameter(Mandatory)][string]$Message, - $Target - ) - throw [Management.Automation.ErrorRecord]::new( - [InvalidOperationException]::new($Message), - $Id, - [Management.Automation.ErrorCategory]::InvalidData, - $Target - ) + param([string]$Id,[string]$Message,$Target) + throw [Management.Automation.ErrorRecord]::new([InvalidOperationException]::new($Message),$Id,[Management.Automation.ErrorCategory]::InvalidData,$Target) } -function Initialize-CcodInstallFileNative { - if ($null -ne ('CcodInstallFileNative' -as [type])) { return } - Add-Type -TypeDefinition @' +function Initialize-CcodInstallRuntime { + if ($null -eq ('CcodInstallCapabilityMarker' -as [type])) { + Add-Type -TypeDefinition @' using System; using System.Collections.Generic; using System.ComponentModel; using System.IO; using System.Runtime.InteropServices; +using System.Runtime.CompilerServices; +using System.Security.Cryptography; using System.Text; using Microsoft.Win32.SafeHandles; -public static class CcodInstallFileNative +public sealed class CcodInstallCapabilityMarker { private CcodInstallCapabilityMarker() {} public static int CapabilityAbi { get { return 1; } } } + +internal sealed class CcodInstallRuntime : IDisposable { - private const uint FILE_LIST_DIRECTORY = 0x00000001; - private const uint FILE_READ_ATTRIBUTES = 0x00000080; - private const uint DELETE = 0x00010000; - private const uint SYNCHRONIZE = 0x00100000; - private const uint GENERIC_READ = 0x80000000; - private const uint GENERIC_WRITE = 0x40000000; - private const uint FILE_SHARE_READ = 0x00000001; - private const uint FILE_SHARE_WRITE = 0x00000002; - private const uint FILE_SHARE_DELETE = 0x00000004; - private const uint FILE_OPEN = 0x00000001; - private const uint FILE_CREATE = 0x00000002; - private const uint FILE_OPEN_IF = 0x00000003; - private const uint FILE_DIRECTORY_FILE = 0x00000001; - private const uint FILE_WRITE_THROUGH = 0x00000002; - private const uint FILE_SYNCHRONOUS_IO_NONALERT = 0x00000020; - private const uint FILE_NON_DIRECTORY_FILE = 0x00000040; - private const uint FILE_OPEN_FOR_BACKUP_INTENT = 0x00004000; - 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 FILE_FLAG_WRITE_THROUGH = 0x80000000; - 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; - private const int FileRenameInformation = 10; - private const int FileDispositionInfo = 4; - private const int FileStreamInfo = 7; - private const int FileDirectoryInformation = 1; + private const uint READ = 0x80000000, WRITE = 0x40000000, DELETE = 0x00010000, SYNC = 0x00100000; + private const uint READ_ATTRIBUTES = 0x80, 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, OPEN_IF = 3; + 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, FLAG_WRITE_THROUGH = 0x80000000; + private const uint OPEN_EXISTING = 3, OBJ_CASE_INSENSITIVE = 0x40, ATTR_DIRECTORY = 0x10, ATTR_REPARSE = 0x400; + private const int FileRenameInformation = 10, FileDispositionInformation = 13, FileStreamInfo = 7, FileDirectoryInformation = 1; private const int STATUS_NO_MORE_FILES = unchecked((int)0x80000006); - [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)] - internal struct FILETIME_NATIVE - { - public uint Low; - public uint High; - } - - [StructLayout(LayoutKind.Sequential)] - internal struct BY_HANDLE_FILE_INFORMATION - { - 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; - } - - public sealed class OpenResult - { - public SafeFileHandle Handle { get; private set; } - public bool Created { get; private set; } - public OpenResult(SafeFileHandle handle, bool created) { Handle = handle; Created = created; } - } - - public sealed class HandleInfo - { - public uint Attributes { get; private set; } - public uint NumberOfLinks { get; private set; } - public uint VolumeSerialNumber { get; private set; } - public ulong FileIndex { get; private set; } - public long Length { get; private set; } - public bool IsDirectory { get { return (Attributes & FILE_ATTRIBUTE_DIRECTORY) != 0; } } - public bool IsReparsePoint { get { return (Attributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0; } } - - internal HandleInfo(BY_HANDLE_FILE_INFORMATION value) - { - Attributes = value.FileAttributes; - NumberOfLinks = value.NumberOfLinks; - VolumeSerialNumber = value.VolumeSerialNumber; - FileIndex = ((ulong)value.FileIndexHigh << 32) | value.FileIndexLow; - Length = ((long)value.FileSizeHigh << 32) | value.FileSizeLow; - } - } - - public sealed class DirectoryEntry - { - public string Name { get; private set; } - public uint Attributes { get; private set; } - public DirectoryEntry(string name, uint attributes) { Name = name; Attributes = attributes; } - } - - [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("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - private static extern uint GetFinalPathNameByHandleW(SafeFileHandle handle, StringBuilder path, uint length, uint flags); - - [DllImport("kernel32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool GetFileInformationByHandleEx(SafeFileHandle handle, int informationClass, IntPtr information, uint size); - - [DllImport("kernel32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool SetFileInformationByHandle(SafeFileHandle handle, int informationClass, IntPtr information, uint size); - - [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); - - [DllImport("ntdll.dll")] - private static extern int NtQueryDirectoryFile(SafeFileHandle fileHandle, IntPtr eventHandle, IntPtr apcRoutine, - IntPtr apcContext, out IO_STATUS_BLOCK ioStatusBlock, IntPtr fileInformation, uint length, - int fileInformationClass, [MarshalAs(UnmanagedType.Bool)] bool returnSingleEntry, - IntPtr fileName, [MarshalAs(UnmanagedType.Bool)] bool restartScan); - - [DllImport("ntdll.dll")] - private static extern int NtSetInformationFile(SafeFileHandle fileHandle, out IO_STATUS_BLOCK ioStatusBlock, - IntPtr fileInformation, uint length, int fileInformationClass); - - [DllImport("ntdll.dll")] - private static extern uint RtlNtStatusToDosError(int status); - - private static void ThrowStatus(int status) - { - throw new Win32Exception(unchecked((int)RtlNtStatusToDosError(status))); - } - - private static OpenResult OpenRelative(SafeFileHandle parent, string name, uint access, uint share, - uint disposition, uint options) - { - if (parent == null || parent.IsInvalid || parent.IsClosed) throw new ArgumentException("Parent 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)) ThrowStatus(status); - return new OpenResult(new SafeFileHandle(rawHandle, true), statusBlock.Information.ToUInt64() == 2UL); - } - finally - { - if (unicodePointer != IntPtr.Zero) Marshal.FreeHGlobal(unicodePointer); - if (nameBuffer != IntPtr.Zero) Marshal.FreeHGlobal(nameBuffer); - } - } - - public static SafeFileHandle OpenRootDirectory(string path) - { - SafeFileHandle handle = CreateFileW(path, FILE_LIST_DIRECTORY | 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()); - return handle; - } - - public static OpenResult OpenDirectoryRelative(SafeFileHandle parent, string name, bool createIfMissing) - { - return OpenRelative(parent, name, FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES | DELETE | SYNCHRONIZE, - FILE_SHARE_READ | FILE_SHARE_WRITE, createIfMissing ? FILE_OPEN_IF : FILE_OPEN, - FILE_DIRECTORY_FILE | FILE_OPEN_FOR_BACKUP_INTENT); - } - - public static OpenResult CreateTemporaryFileRelative(SafeFileHandle parent, string name) - { - return OpenRelative(parent, name, GENERIC_READ | GENERIC_WRITE | DELETE | SYNCHRONIZE, - FILE_SHARE_READ, FILE_CREATE, FILE_NON_DIRECTORY_FILE | FILE_WRITE_THROUGH); - } - - public static OpenResult OpenAppendFileRelative(SafeFileHandle parent, string name) - { - return OpenRelative(parent, name, GENERIC_READ | GENERIC_WRITE | DELETE | SYNCHRONIZE, - FILE_SHARE_READ, FILE_OPEN_IF, FILE_NON_DIRECTORY_FILE | FILE_WRITE_THROUGH); - } - - public static SafeFileHandle OpenIdentityRelative(SafeFileHandle parent, string name) - { - return OpenRelative(parent, name, FILE_READ_ATTRIBUTES | SYNCHRONIZE, - FILE_SHARE_READ | FILE_SHARE_WRITE, FILE_OPEN, FILE_OPEN_FOR_BACKUP_INTENT).Handle; - } - - public static SafeFileHandle OpenSourceFile(string path) - { - SafeFileHandle handle = CreateFileW(path, GENERIC_READ | FILE_READ_ATTRIBUTES | SYNCHRONIZE, - FILE_SHARE_READ, IntPtr.Zero, OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT, IntPtr.Zero); - if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error()); - return handle; - } - - public static HandleInfo GetInfo(SafeFileHandle handle) - { - BY_HANDLE_FILE_INFORMATION information; - if (!GetFileInformationByHandle(handle, out information)) throw new Win32Exception(Marshal.GetLastWin32Error()); - return new HandleInfo(information); - } - - public static string GetFinalPath(SafeFileHandle handle) - { - StringBuilder path = new StringBuilder(512); - uint length = GetFinalPathNameByHandleW(handle, path, (uint)path.Capacity, 0); - if (length == 0) throw new Win32Exception(Marshal.GetLastWin32Error()); - if (length >= path.Capacity) - { - path.Capacity = checked((int)length + 1); - length = GetFinalPathNameByHandleW(handle, path, (uint)path.Capacity, 0); - if (length == 0 || length >= path.Capacity) throw new Win32Exception(Marshal.GetLastWin32Error()); - } - string value = path.ToString(); - if (value.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase)) return @"\\" + value.Substring(8); - if (value.StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase)) return value.Substring(4); - return value; - } - - public static bool HasOnlyDefaultDataStream(SafeFileHandle handle) - { - const int bufferSize = 65536; - IntPtr buffer = Marshal.AllocHGlobal(bufferSize); - try - { - if (!GetFileInformationByHandleEx(handle, FileStreamInfo, buffer, bufferSize)) - throw new Win32Exception(Marshal.GetLastWin32Error()); - int offset = 0; - int count = 0; - while (true) - { - uint next = unchecked((uint)Marshal.ReadInt32(buffer, offset)); - uint nameLength = unchecked((uint)Marshal.ReadInt32(buffer, offset + 4)); - if (nameLength > bufferSize - offset - 24) throw new InvalidDataException("Invalid stream information"); - string name = Marshal.PtrToStringUni(IntPtr.Add(buffer, offset + 24), checked((int)nameLength / 2)); - count++; - if (!String.Equals(name, "::$DATA", StringComparison.OrdinalIgnoreCase)) return false; - if (next == 0) break; - if (next > bufferSize - offset) throw new InvalidDataException("Invalid stream information offset"); - offset = checked(offset + (int)next); - } - return count == 1; - } - finally { Marshal.FreeHGlobal(buffer); } - } - - public static string[] EnumerateDirectory(SafeFileHandle directory) - { - const int bufferSize = 65536; - IntPtr buffer = Marshal.AllocHGlobal(bufferSize); - List names = new List(); - bool restart = true; - try - { - while (true) - { - IO_STATUS_BLOCK statusBlock; - int status = NtQueryDirectoryFile(directory, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, - out statusBlock, buffer, bufferSize, FileDirectoryInformation, false, IntPtr.Zero, restart); - restart = false; - if (status == STATUS_NO_MORE_FILES) break; - if (status < 0) ThrowStatus(status); - int offset = 0; - while (true) - { - uint next = unchecked((uint)Marshal.ReadInt32(buffer, offset)); - uint nameLength = unchecked((uint)Marshal.ReadInt32(buffer, offset + 60)); - if (nameLength > bufferSize - offset - 64) throw new InvalidDataException("Invalid directory information"); - string name = Marshal.PtrToStringUni(IntPtr.Add(buffer, offset + 64), checked((int)nameLength / 2)); - if (name != "." && name != "..") names.Add(name); - if (next == 0) break; - if (next > bufferSize - offset) throw new InvalidDataException("Invalid directory information offset"); - offset = checked(offset + (int)next); - } - } - return names.ToArray(); - } - finally { Marshal.FreeHGlobal(buffer); } - } - - public static int RenameRelative(SafeFileHandle source, SafeFileHandle parent, string destination, bool replaceIfExists) - { - byte[] name = Encoding.Unicode.GetBytes(destination); - int rootDirectoryOffset = IntPtr.Size; - int fileNameLengthOffset = rootDirectoryOffset + IntPtr.Size; - int fileNameOffset = fileNameLengthOffset + sizeof(uint); - int bufferSize = checked(fileNameOffset + name.Length + sizeof(char)); - IntPtr buffer = Marshal.AllocHGlobal(bufferSize); - try - { - for (int index = 0; index < bufferSize; index++) Marshal.WriteByte(buffer, index, 0); - Marshal.WriteByte(buffer, 0, replaceIfExists ? (byte)1 : (byte)0); - Marshal.WriteIntPtr(buffer, rootDirectoryOffset, parent.DangerousGetHandle()); - Marshal.WriteInt32(buffer, fileNameLengthOffset, name.Length); - Marshal.Copy(name, 0, IntPtr.Add(buffer, fileNameOffset), name.Length); - IO_STATUS_BLOCK statusBlock; - int status = NtSetInformationFile(source, out statusBlock, buffer, (uint)bufferSize, FileRenameInformation); - return status >= 0 ? 0 : unchecked((int)RtlNtStatusToDosError(status)); - } - finally { Marshal.FreeHGlobal(buffer); } - } - - public static int MarkDelete(SafeFileHandle handle) - { - IntPtr buffer = Marshal.AllocHGlobal(1); - try - { - Marshal.WriteByte(buffer, 1); - return SetFileInformationByHandle(handle, FileDispositionInfo, buffer, 1) - ? 0 : Marshal.GetLastWin32Error(); - } - finally { Marshal.FreeHGlobal(buffer); } - } + [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 p,uint a,uint s,IntPtr sec,uint c,uint f,IntPtr t); + [DllImport("kernel32.dll", SetLastError=true)] private static extern bool GetFileInformationByHandle(SafeFileHandle h,out FILE_INFO i); + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] private static extern uint GetFinalPathNameByHandleW(SafeFileHandle h,StringBuilder b,uint l,uint f); + [DllImport("kernel32.dll", SetLastError=true)] private static extern bool GetFileInformationByHandleEx(SafeFileHandle h,int c,IntPtr i,uint s); + [DllImport("ntdll.dll")] private static extern int NtCreateFile(out IntPtr h,uint a,ref OBJECT_ATTRIBUTES o,out IO_STATUS_BLOCK i,IntPtr z,uint fa,uint sh,uint d,uint op,IntPtr e,uint el); + [DllImport("ntdll.dll")] private static extern int NtQueryDirectoryFile(SafeFileHandle h,IntPtr e,IntPtr a,IntPtr c,out IO_STATUS_BLOCK i,IntPtr f,uint l,int cl,bool one,IntPtr n,bool restart); + [DllImport("ntdll.dll")] private static extern int NtSetInformationFile(SafeFileHandle h,out IO_STATUS_BLOCK i,IntPtr f,uint l,int cl); + [DllImport("ntdll.dll")] private static extern uint RtlNtStatusToDosError(int s); + + 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; internal long SealedLength; internal string SealedSha; + internal Pin(Pin parent,string leaf,string path,bool dir,bool owned,SafeFileHandle handle,FileStream stream,FILE_INFO info) + { Parent=parent;Leaf=leaf;Path=path;Directory=dir;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; Closed=true; if(Stream!=null)Stream.Dispose(); else if(Handle!=null)Handle.Dispose(); } + } + + private readonly string rootPath; private readonly Pin root; + private readonly Dictionary pins = new Dictionary(ReferenceEqualityComparer.Instance); + private readonly Dictionary names = new Dictionary(StringComparer.OrdinalIgnoreCase); + private bool disposed; + + private sealed class ReferenceEqualityComparer : IEqualityComparer + { internal static readonly ReferenceEqualityComparer Instance=new ReferenceEqualityComparer(); public new bool Equals(object x,object y){return Object.ReferenceEquals(x,y);} public int GetHashCode(object x){return System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(x);} } + + private CcodInstallRuntime(string path,Pin rootPin) { rootPath=path;root=rootPin;pins.Add(root.Token,root); } + internal static object Open(string path,out CcodInstallRuntime runtime) + { + runtime=null; string full=Path.GetFullPath(path).TrimEnd('\\'); + SafeFileHandle h=CreateFileW(full,LIST_DIRECTORY|ADD_FILE|ADD_SUBDIRECTORY|READ_ATTRIBUTES|SYNC,SHARE_READ|SHARE_WRITE,IntPtr.Zero,OPEN_EXISTING,FLAG_BACKUP|FLAG_REPARSE,IntPtr.Zero); + if(h.IsInvalid)throw new Win32Exception(Marshal.GetLastWin32Error()); + try { FILE_INFO i=Info(h); if(!IsDirectory(i)||(i.FileAttributes&ATTR_REPARSE)!=0||!SamePath(FinalPath(h),full))throw new InvalidDataException("root invalid");if(!OnlyDefaultStream(h))throw new InvalidDataException("alternate stream"); + Pin p=new Pin(null,"",full,true,false,h,null,i); h=null; runtime=new CcodInstallRuntime(full,p); return p.Token; } + finally { if(h!=null)h.Dispose(); } + } + [MethodImpl(MethodImplOptions.Synchronized)] internal object OpenDirectory(object parentToken,string leaf,bool create) + { + Pin parent=Require(parentToken,true); ValidateCurrent(parent); string key=Key(parent,leaf); Pin known; if(names.TryGetValue(key,out known)){ValidateCurrent(known);return known.Token;} + OpenResult r=OpenRelative(parent.Native,leaf,LIST_DIRECTORY|ADD_FILE|ADD_SUBDIRECTORY|READ_ATTRIBUTES|DELETE|SYNC,SHARE_READ|SHARE_WRITE,create?OPEN_IF:OPEN,DIRECTORY|BACKUP_INTENT); + try { FILE_INFO i=Info(r.Handle); if(!IsDirectory(i)||(i.FileAttributes&ATTR_REPARSE)!=0||!OnlyDefaultStream(r.Handle))throw new InvalidDataException("directory invalid"); + string path=Path.Combine(parent.Path,leaf); if(!SamePath(FinalPath(r.Handle),path))throw new InvalidDataException("path changed"); + Pin p=new Pin(parent,leaf,path,true,r.Created,r.Handle,null,i); r.Handle=null;AddPin(key,p);return p.Token; } + finally { if(r.Handle!=null)r.Handle.Dispose(); } + } + [MethodImpl(MethodImplOptions.Synchronized)] internal object CreateTemporary(object parentToken,string leaf) + { + Pin parent=Require(parentToken,true);ValidateCurrent(parent);string key=Key(parent,leaf);if(names.ContainsKey(key))throw new IOException("exists"); + OpenResult r=OpenRelative(parent.Native,leaf,READ|WRITE|DELETE|SYNC,SHARE_READ,CREATE,NON_DIRECTORY|WRITE_THROUGH); + FileStream s=null;try{s=new FileStream(r.Handle,FileAccess.ReadWrite,65536,false);r.Handle=null;FILE_INFO i=Info(s.SafeFileHandle);ValidatePlain(i,s.SafeFileHandle);Pin p=new Pin(parent,leaf,Path.Combine(parent.Path,leaf),false,true,null,s,i);s=null;AddPin(key,p);return p.Token;} + finally{if(s!=null)s.Dispose();if(r.Handle!=null)r.Handle.Dispose();} + } + [MethodImpl(MethodImplOptions.Synchronized)] internal object Copy(object destinationToken,string source,long expectedLength,string expectedSha) + { + Pin d=Require(destinationToken,false);ValidateCurrent(d);if(!d.Owned)throw new InvalidDataException("destination not owned"); + string full=Path.GetFullPath(source); SafeFileHandle sh=CreateFileW(full,READ|READ_ATTRIBUTES|SYNC,SHARE_READ,IntPtr.Zero,OPEN_EXISTING,FLAG_REPARSE,IntPtr.Zero);if(sh.IsInvalid)throw new Win32Exception(Marshal.GetLastWin32Error()); + try{FILE_INFO si=Info(sh);ValidatePlain(si,sh);if(!SamePath(FinalPath(sh),full))throw new InvalidDataException("source path");using(FileStream ss=new FileStream(sh,FileAccess.Read,65536,false)){sh=null;if(ss.Length!=expectedLength||Sha(ss)!=expectedSha)throw new InvalidDataException("source mismatch");ss.Position=0;d.Stream.Position=0;d.Stream.SetLength(0);ss.CopyTo(d.Stream);d.Stream.Flush(true);string actual=Sha(d.Stream);if(d.Stream.Length!=expectedLength||actual!=expectedSha)throw new InvalidDataException("destination mismatch");d.Sealed=true;d.SealedLength=expectedLength;d.SealedSha=expectedSha;}}finally{if(sh!=null)sh.Dispose();} + return d.Token; + } + [MethodImpl(MethodImplOptions.Synchronized)] internal object Promote(object parentToken,object tempToken,string destination) + { + Pin parent=Require(parentToken,true),temp=Require(tempToken,false);ValidateCurrent(parent);ValidateCurrent(temp); + if(temp.Parent!=parent||!temp.Owned||!temp.Sealed)throw new InvalidDataException("promotion invalid"); + if(temp.Stream.Length!=temp.SealedLength||Sha(temp.Stream)!=temp.SealedSha)throw new CryptographicException("seal mismatch"); + SafeFileHandle existing=null;try{try{existing=OpenRelative(parent.Native,destination,READ_ATTRIBUTES|SYNC,SHARE_READ|SHARE_WRITE,OPEN,BACKUP_INTENT).Handle;}catch(Win32Exception x){if(x.NativeErrorCode!=2&&x.NativeErrorCode!=3)throw;}if(existing!=null)throw new IOException("destination exists");} + finally{if(existing!=null)existing.Dispose();} + int ec=Rename(temp.Native,parent.Native,destination);if(ec==80||ec==183)throw new IOException("destination exists");if(ec!=0)throw new Win32Exception(ec);names.Remove(Key(parent,temp.Leaf));temp.Leaf=destination;temp.Path=Path.Combine(parent.Path,destination);names.Add(Key(parent,destination),temp);ValidateCurrent(temp);return temp.Token; + } + [MethodImpl(MethodImplOptions.Synchronized)] internal object WriteJson(object parentToken,string leaf,byte[] bytes) + { Pin parent=Require(parentToken,true);ValidateCurrent(parent);object t=CreateTemporary(parentToken,".json."+Guid.NewGuid().ToString("N")+".tmp");Pin p=Require(t,false);Write(p,bytes);p.Sealed=true;p.SealedLength=bytes.LongLength;p.SealedSha=Sha(p.Stream);return Promote(parentToken,t,leaf); } + [MethodImpl(MethodImplOptions.Synchronized)] internal object Append(object parentToken,string leaf,byte[] bytes) + { + Pin parent=Require(parentToken,true);ValidateCurrent(parent);string key=Key(parent,leaf);Pin p; + if(!names.TryGetValue(key,out p)){OpenResult r=OpenRelative(parent.Native,leaf,READ|WRITE|DELETE|SYNC,SHARE_READ,OPEN_IF,NON_DIRECTORY|WRITE_THROUGH);FileStream s=null;try{s=new FileStream(r.Handle,FileAccess.ReadWrite,65536,false);r.Handle=null;FILE_INFO i=Info(s.SafeFileHandle);ValidatePlain(i,s.SafeFileHandle);p=new Pin(parent,leaf,Path.Combine(parent.Path,leaf),false,r.Created,null,s,i);s=null;AddPin(key,p);}finally{if(s!=null)s.Dispose();if(r.Handle!=null)r.Handle.Dispose();}} + ValidateCurrent(p);p.Stream.Position=p.Stream.Length;p.Stream.Write(bytes,0,bytes.Length);p.Stream.Flush(true);return p.Token; + } + [MethodImpl(MethodImplOptions.Synchronized)] internal void RemoveOwnedTree(object parentToken,string leaf) + { + Pin parent=Require(parentToken,true);ValidateCurrent(parent);Pin target;string key=Key(parent,leaf);if(!names.TryGetValue(key,out target)||!target.Owned||!target.Directory)throw new InvalidDataException("unknown target"); + List plan=new List();BuildPlan(target,plan);RevalidatePlan(target,plan);List marked=new List(); + string originalLeaf=target.Leaf,originalPath=target.Path,tombstone=".__ccod_delete_"+Guid.NewGuid().ToString("N");bool quarantined=false,released=false; + try{ReleaseDescendants(plan);released=true;int rec=Rename(target.Native,parent.Native,tombstone);if(rec!=0)throw new Win32Exception(rec);MoveDirectoryPin(target,tombstone,Path.Combine(parent.Path,tombstone));quarantined=true;released=false;ReopenKnownTree(target);plan.Clear();BuildPlan(target,plan);RevalidatePlan(target,plan);foreach(Pin p in plan){if(p.Directory)continue;ValidateCurrent(p);int ec=SetDelete(p.Native,true);if(ec!=0)throw new Win32Exception(ec);marked.Add(p);ValidateMarkedFile(p);}RevalidateMarkedNamespace(target,new HashSet(marked));} + catch{for(int i=marked.Count-1;i>=0;i--)SetDelete(marked[i].Native,false);if(!released){ReleaseDescendants(plan);released=true;}if(quarantined){int back=Rename(target.Native,parent.Native,originalLeaf);if(back!=0)throw new Win32Exception(back);MoveDirectoryPin(target,originalLeaf,originalPath);quarantined=false;}ReopenKnownTree(target);throw;} + foreach(Pin p in marked)RemovePin(p); + foreach(Pin p in plan){if(!p.Directory)continue;ValidateCurrent(p);int ec=SetDelete(p.Native,true);if(ec!=0)throw new Win32Exception(ec);RemovePin(p);} + ValidateCurrent(target);int tec=SetDelete(target.Native,true);if(tec!=0)throw new Win32Exception(tec);RemovePin(target); + } + [MethodImpl(MethodImplOptions.Synchronized)] internal void Close(){if(disposed)return;disposed=true;List all=new List(pins.Values);for(int i=all.Count-1;i>=0;i--)all[i].Dispose();pins.Clear();names.Clear();} + public void Dispose(){Close();} + + private void BuildPlan(Pin dir,List plan){ValidateCurrent(dir);foreach(string n in Enumerate(dir.Native)){Pin p;string k=Key(dir,n);if(!names.TryGetValue(k,out p)||!p.Owned){ProbeUnknown(dir,n);throw new InvalidDataException("unknown leaf");}ValidateCurrent(p);if(p.Directory)BuildPlan(p,plan);plan.Add(p);}} + private static void ProbeUnknown(Pin dir,string name){OpenResult r=OpenRelative(dir.Native,name,READ_ATTRIBUTES|SYNC,SHARE_READ|SHARE_WRITE,OPEN,BACKUP_INTENT);try{FILE_INFO i=Info(r.Handle);if((i.FileAttributes&ATTR_REPARSE)!=0)throw new InvalidDataException("reparse leaf");if(!OnlyDefaultStream(r.Handle))throw new InvalidDataException("alternate stream");if(!IsDirectory(i)&&i.NumberOfLinks!=1)throw new InvalidDataException("multi-link");}finally{r.Handle.Dispose();}} + private void RevalidatePlan(Pin target,List plan){List second=new List();BuildPlan(target,second);if(second.Count!=plan.Count)throw new InvalidDataException("namespace changed");for(int i=0;i marked){ValidateCurrent(dir);foreach(string n in Enumerate(dir.Native)){Pin p;if(!names.TryGetValue(Key(dir,n),out p)||!p.Owned)throw new InvalidDataException("namespace changed");if(p.Directory)RevalidateMarkedNamespace(p,marked);else if(marked.Contains(p))ValidateMarkedFile(p);else throw new InvalidDataException("namespace changed");}} + private void MoveDirectoryPin(Pin dir,string newLeaf,string newPath){string oldKey=Key(dir.Parent,dir.Leaf),oldPath=dir.Path;names.Remove(oldKey);dir.Leaf=newLeaf;dir.Path=newPath;names.Add(Key(dir.Parent,newLeaf),dir);string prefix=oldPath.TrimEnd('\\')+"\\";foreach(Pin p in pins.Values)if(!Object.ReferenceEquals(p,dir)&&p.Path.StartsWith(prefix,StringComparison.OrdinalIgnoreCase))p.Path=newPath+"\\"+p.Path.Substring(prefix.Length);} + private static void ReleaseDescendants(List plan){foreach(Pin p in plan){if(p.Stream!=null){p.Stream.Dispose();p.Stream=null;}else if(p.Handle!=null){p.Handle.Dispose();p.Handle=null;}}} + private void ReopenKnownTree(Pin dir){List children=new List();foreach(Pin p in pins.Values)if(Object.ReferenceEquals(p.Parent,dir)&&!p.Closed)children.Add(p);foreach(Pin p in children){if(p.Directory){OpenResult r=OpenRelative(dir.Native,p.Leaf,LIST_DIRECTORY|ADD_FILE|ADD_SUBDIRECTORY|READ_ATTRIBUTES|DELETE|SYNC,SHARE_READ|SHARE_WRITE,OPEN,DIRECTORY|BACKUP_INTENT);p.Handle=r.Handle;ValidateCurrent(p);ReopenKnownTree(p);}else{OpenResult r=OpenRelative(dir.Native,p.Leaf,READ|WRITE|DELETE|SYNC,SHARE_READ,OPEN,NON_DIRECTORY|WRITE_THROUGH);p.Stream=new FileStream(r.Handle,FileAccess.ReadWrite,65536,false);r.Handle=null;ValidateCurrent(p);}}} + private void RemovePin(Pin p){string k=Key(p.Parent,p.Leaf);p.Dispose();pins.Remove(p.Token);names.Remove(k);} + private Pin Require(object token,bool directory){if(disposed)throw new ObjectDisposedException("transaction");Pin p;if(token==null||!pins.TryGetValue(token,out p)||p.Closed||p.Directory!=directory)throw new InvalidDataException("invalid capability");return p;} + private void AddPin(string key,Pin p){pins.Add(p.Token,p);names.Add(key,p);} + private static string Key(Pin p,string leaf){return p.Index.ToString("x16")+"|"+leaf.ToLowerInvariant();} + private static void Write(Pin p,byte[] b){p.Stream.Position=0;p.Stream.SetLength(0);p.Stream.Write(b,0,b.Length);p.Stream.Flush(true);} + private static void ValidateCurrent(Pin p){FILE_INFO i=Info(p.Native);if(i.VolumeSerialNumber!=p.Volume||(((ulong)i.FileIndexHigh<<32)|i.FileIndexLow)!=p.Index||IsDirectory(i)!=p.Directory||(i.FileAttributes&ATTR_REPARSE)!=0||!SamePath(FinalPath(p.Native),p.Path))throw new InvalidDataException("pin changed");if(!OnlyDefaultStream(p.Native))throw new InvalidDataException("alternate stream");if(!p.Directory&&i.NumberOfLinks!=1)throw new InvalidDataException("multi-link");} + private static void ValidatePlain(FILE_INFO i,SafeFileHandle h){if(IsDirectory(i))throw new InvalidDataException("file type");if((i.FileAttributes&ATTR_REPARSE)!=0)throw new InvalidDataException("reparse leaf");if(i.NumberOfLinks!=1)throw new InvalidDataException("multi-link");if(!OnlyDefaultStream(h))throw new InvalidDataException("alternate stream");} + private static bool IsDirectory(FILE_INFO i){return(i.FileAttributes&ATTR_DIRECTORY)!=0;} + private static FILE_INFO Info(SafeFileHandle h){FILE_INFO i;if(!GetFileInformationByHandle(h,out i))throw new Win32Exception(Marshal.GetLastWin32Error());return i;} + private static string FinalPath(SafeFileHandle h){StringBuilder b=new StringBuilder(512);uint n=GetFinalPathNameByHandleW(h,b,(uint)b.Capacity,0);if(n==0)throw new Win32Exception(Marshal.GetLastWin32Error());if(n>=b.Capacity){b.Capacity=(int)n+1;n=GetFinalPathNameByHandleW(h,b,(uint)b.Capacity,0);if(n==0||n>=b.Capacity)throw new Win32Exception(Marshal.GetLastWin32Error());}string s=b.ToString();if(s.StartsWith(@"\\?\UNC\",StringComparison.OrdinalIgnoreCase))return@"\\"+s.Substring(8);if(s.StartsWith(@"\\?\",StringComparison.OrdinalIgnoreCase))return s.Substring(4);return s;} + private static bool SamePath(string a,string b){return String.Equals(Path.GetFullPath(a).TrimEnd('\\'),Path.GetFullPath(b).TrimEnd('\\'),StringComparison.OrdinalIgnoreCase);} + private static string Sha(FileStream s){long p=s.Position;using(SHA256 h=SHA256.Create()){s.Position=0;string v=BitConverter.ToString(h.ComputeHash(s)).Replace("-","").ToLowerInvariant();s.Position=p;return v;}} + private static bool OnlyDefaultStream(SafeFileHandle h){IntPtr b=Marshal.AllocHGlobal(65536);try{if(!GetFileInformationByHandleEx(h,FileStreamInfo,b,65536)){int e=Marshal.GetLastWin32Error();if(e==38)return true;throw new Win32Exception(e);}int o=0;while(true){uint nx=(uint)Marshal.ReadInt32(b,o),nl=(uint)Marshal.ReadInt32(b,o+4);string n=Marshal.PtrToStringUni(IntPtr.Add(b,o+24),(int)nl/2);if(!String.Equals(n,"::$DATA",StringComparison.OrdinalIgnoreCase)&&!String.Equals(n,"::$INDEX_ALLOCATION",StringComparison.OrdinalIgnoreCase))return false;if(nx==0)break;o+=(int)nx;}return true;}finally{Marshal.FreeHGlobal(b);}} + private static string[] Enumerate(SafeFileHandle h){List r=new List();IntPtr b=Marshal.AllocHGlobal(65536);bool restart=true;try{while(true){IO_STATUS_BLOCK io;int st=NtQueryDirectoryFile(h,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,out io,b,65536,FileDirectoryInformation,false,IntPtr.Zero,restart);restart=false;if(st==STATUS_NO_MORE_FILES)break;if(st<0)throw new Win32Exception((int)RtlNtStatusToDosError(st));int o=0;while(true){uint nx=(uint)Marshal.ReadInt32(b,o),nl=(uint)Marshal.ReadInt32(b,o+60);string n=Marshal.PtrToStringUni(IntPtr.Add(b,o+64),(int)nl/2);if(n!="."&&n!="..")r.Add(n);if(nx==0)break;o+=(int)nx;}}return r.ToArray();}finally{Marshal.FreeHGlobal(b);}} + private sealed class OpenResult{internal SafeFileHandle Handle;internal bool Created;internal OpenResult(SafeFileHandle h,bool c){Handle=h;Created=c;}} + private static OpenResult OpenRelative(SafeFileHandle parent,string name,uint access,uint share,uint disposition,uint options) + {IntPtr nb=IntPtr.Zero,up=IntPtr.Zero;bool add=false;try{parent.DangerousAddRef(ref add);nb=Marshal.StringToHGlobalUni(name);UNICODE_STRING u=new UNICODE_STRING{Length=(ushort)(name.Length*2),MaximumLength=(ushort)((name.Length+1)*2),Buffer=nb};up=Marshal.AllocHGlobal(Marshal.SizeOf(typeof(UNICODE_STRING)));Marshal.StructureToPtr(u,up,false);OBJECT_ATTRIBUTES o=new OBJECT_ATTRIBUTES{Length=Marshal.SizeOf(typeof(OBJECT_ATTRIBUTES)),RootDirectory=parent.DangerousGetHandle(),ObjectName=up,Attributes=OBJ_CASE_INSENSITIVE};IO_STATUS_BLOCK io;IntPtr raw;int st=NtCreateFile(out raw,access,ref o,out io,IntPtr.Zero,0,share,disposition,options|SYNC_IO|OPEN_REPARSE,IntPtr.Zero,0);if(st<0)throw new Win32Exception((int)RtlNtStatusToDosError(st));return new OpenResult(new SafeFileHandle(raw,true),io.Information.ToUInt64()==2);}finally{if(up!=IntPtr.Zero)Marshal.FreeHGlobal(up);if(nb!=IntPtr.Zero)Marshal.FreeHGlobal(nb);if(add)parent.DangerousRelease();}} + private static int Rename(SafeFileHandle source,SafeFileHandle parent,string destination) + {byte[] n=Encoding.Unicode.GetBytes(destination);int ro=IntPtr.Size,lo=ro+IntPtr.Size,no=lo+4,size=no+n.Length+2;IntPtr b=Marshal.AllocHGlobal(size);bool pa=false,sa=false;try{source.DangerousAddRef(ref sa);parent.DangerousAddRef(ref pa);for(int i=0;i=0?0:(int)RtlNtStatusToDosError(st);}finally{if(pa)parent.DangerousRelease();if(sa)source.DangerousRelease();Marshal.FreeHGlobal(b);}} + private static int SetDelete(SafeFileHandle h,bool delete){IntPtr b=Marshal.AllocHGlobal(1);try{Marshal.WriteByte(b,delete?(byte)1:(byte)0);IO_STATUS_BLOCK io;int st=NtSetInformationFile(h,out io,b,1,FileDispositionInformation);return st>=0?0:(int)RtlNtStatusToDosError(st);}finally{Marshal.FreeHGlobal(b);}} } '@ -} - -function Assert-CcodInstallLeafName { - param([Parameter(Mandatory)][string]$Leaf) - if ([string]::IsNullOrWhiteSpace($Leaf) -or $Leaf.Length -gt 160 -or - $Leaf -in @('.','..') -or $Leaf -notmatch '^[A-Za-z0-9._-]+$' -or - $Leaf.EndsWith('.') -or $Leaf.EndsWith(' ') -or - $Leaf -match '^(?i:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)') { - Throw-CcodInstallFileError 'CCOD_INSTALL_LEAF_INVALID' 'Install transaction leaf must be one safe relative segment' $Leaf - } -} - -function Get-CcodInstallHandleInfo { - param([Parameter(Mandatory)]$Pin) - $handle = if ($null -ne $Pin.Stream) { $Pin.Stream.SafeFileHandle } else { $Pin.Handle } - return [CcodInstallFileNative]::GetInfo($handle) -} - -function Get-CcodInstallPinHandle { - param([Parameter(Mandatory)]$Pin) - if ($null -ne $Pin.Stream) { return $Pin.Stream.SafeFileHandle } - return $Pin.Handle -} - -function Get-CcodInstallIdentity { - param([Parameter(Mandatory)]$Information) - return [pscustomobject]@{ - VolumeSerialNumber = [uint32]$Information.VolumeSerialNumber - FileIndex = [uint64]$Information.FileIndex - Key = ('{0:x8}:{1:x16}' -f [uint32]$Information.VolumeSerialNumber,[uint64]$Information.FileIndex) - } -} - -function Get-CcodInstallObjectKey { - param([Parameter(Mandatory)]$ParentDirectory, [Parameter(Mandatory)][string]$Leaf) - return $ParentDirectory.Identity.Key + '|' + $Leaf.ToLowerInvariant() -} - -function Test-CcodInstallSameIdentity { - param($Expected, $Information) - return $null -ne $Expected -and - [uint32]$Expected.VolumeSerialNumber -eq [uint32]$Information.VolumeSerialNumber -and - [uint64]$Expected.FileIndex -eq [uint64]$Information.FileIndex -} - -function Assert-CcodInstallTransaction { - param([Parameter(Mandatory)]$Transaction) - if ($null -eq $Transaction -or $Transaction.Kind -cne 'CcodInstallFileTransaction') { - Throw-CcodInstallFileError 'CCOD_INSTALL_TRANSACTION_INVALID' 'Install file transaction context is invalid' $null } - if ($Transaction.Closed) { - Throw-CcodInstallFileError 'CCOD_INSTALL_TRANSACTION_CLOSED' 'Install file transaction is already closed' $null - } -} - -function Assert-CcodInstallPin { - param( - [Parameter(Mandatory)]$Transaction, - [Parameter(Mandatory)]$Pin, - [ValidateSet('Directory','File')][string]$Kind - ) - Assert-CcodInstallTransaction $Transaction - if ($null -eq $Pin -or $Pin.TransactionId -cne $Transaction.Id -or $Pin.Closed -or - (-not [string]::IsNullOrWhiteSpace($Kind) -and $Pin.Kind -cne $Kind)) { - Throw-CcodInstallFileError 'CCOD_INSTALL_PIN_INVALID' 'Install file pin is invalid for this transaction' $null - } - $handle = Get-CcodInstallPinHandle $Pin - if ($null -eq $handle -or $handle.IsInvalid -or $handle.IsClosed) { - Throw-CcodInstallFileError 'CCOD_INSTALL_PIN_INVALID' 'Install file pin handle is closed or invalid' $null - } -} - -function Assert-CcodInstallPinCurrent { - param( - [Parameter(Mandatory)]$Transaction, - [Parameter(Mandatory)]$Pin, - [ValidateSet('Directory','File')][string]$Kind - ) - Assert-CcodInstallPin -Transaction $Transaction -Pin $Pin -Kind $Kind - try { - $information = Get-CcodInstallHandleInfo $Pin - $actualPath = [IO.Path]::GetFullPath([CcodInstallFileNative]::GetFinalPath((Get-CcodInstallPinHandle $Pin))).TrimEnd('\') - } catch { - Throw-CcodInstallFileError 'CCOD_INSTALL_PIN_CHANGED' 'Install file pin can no longer prove its identity' $Pin.Leaf - } - $expectedPath = [IO.Path]::GetFullPath([string]$Pin.ExpectedPath).TrimEnd('\') - if (-not (Test-CcodInstallSameIdentity $Pin.Identity $information) -or - -not $actualPath.Equals($expectedPath, [StringComparison]::OrdinalIgnoreCase) -or - $information.IsReparsePoint -or - ($Kind -ceq 'Directory' -and -not $information.IsDirectory) -or - ($Kind -ceq 'File' -and $information.IsDirectory)) { - Throw-CcodInstallFileError 'CCOD_INSTALL_PIN_CHANGED' 'Install file pin identity or final path changed' $Pin.Leaf - } - return $information -} - -function Assert-CcodInstallPlainFile { - param([Parameter(Mandatory)]$Information, [Parameter(Mandatory)]$Handle, [Parameter(Mandatory)][string]$Target) - if ($Information.IsDirectory) { - Throw-CcodInstallFileError 'CCOD_INSTALL_FILE_TYPE_INVALID' 'Install transaction expected a regular file' $Target - } - if ($Information.IsReparsePoint) { - Throw-CcodInstallFileError 'CCOD_INSTALL_REPARSE_LEAF' 'Install transaction rejects reparse leaves' $Target - } - if ([uint32]$Information.NumberOfLinks -ne 1) { - Throw-CcodInstallFileError 'CCOD_INSTALL_MULTILINK_LEAF' 'Install transaction rejects multi-link leaves' $Target - } - try { $defaultStreamOnly = [CcodInstallFileNative]::HasOnlyDefaultDataStream($Handle) } - catch { Throw-CcodInstallFileError 'CCOD_INSTALL_ADS_LEAF' 'Install transaction could not prove the leaf stream inventory' $Target } - if (-not $defaultStreamOnly) { - Throw-CcodInstallFileError 'CCOD_INSTALL_ADS_LEAF' 'Install transaction rejects alternate data streams' $Target - } -} - -function Close-CcodInstallPin { - param([Parameter(Mandatory)]$Pin) - if ($Pin.Closed) { return } - try { - if ($null -ne $Pin.Stream) { $Pin.Stream.Dispose() } - elseif ($null -ne $Pin.Handle) { $Pin.Handle.Dispose() } - } finally { $Pin.Closed = $true } -} - -function Open-CcodInstallFileTransaction { - [CmdletBinding()] - param([Parameter(Mandatory)][string]$InstallRoot) - - Initialize-CcodInstallFileNative - if (-not [IO.Path]::IsPathRooted($InstallRoot)) { - Throw-CcodInstallFileError 'CCOD_INSTALL_ROOT_INVALID' 'Install root must be absolute' $InstallRoot - } - $rootPath = [IO.Path]::GetFullPath($InstallRoot).TrimEnd('\') - if (-not [IO.Directory]::Exists($rootPath)) { - Throw-CcodInstallFileError 'CCOD_INSTALL_ROOT_MISSING' 'Pinned install root must already exist' $rootPath - } - $handle = $null - try { - $handle = [CcodInstallFileNative]::OpenRootDirectory($rootPath) - $information = [CcodInstallFileNative]::GetInfo($handle) - $finalPath = [IO.Path]::GetFullPath([CcodInstallFileNative]::GetFinalPath($handle)).TrimEnd('\') - if (-not $information.IsDirectory -or $information.IsReparsePoint -or - -not $finalPath.Equals($rootPath, [StringComparison]::OrdinalIgnoreCase)) { - Throw-CcodInstallFileError 'CCOD_INSTALL_ROOT_INVALID' 'Install root is not one plain directory identity' $rootPath - } - $id = [guid]::NewGuid().ToString('D') - $rootPin = [pscustomobject]@{ - Kind = 'Directory'; TransactionId = $id; Handle = $handle; Stream = $null - ParentDirectory = $null; Leaf = ''; ExpectedPath = $rootPath - Identity = Get-CcodInstallIdentity $information; Created = $false; Owned = $false; Closed = $false - } - $handle = $null - $pins = [Collections.Generic.List[object]]::new() - $pins.Add($rootPin) - return [pscustomobject]@{ - Kind = 'CcodInstallFileTransaction'; Id = $id; InstallRoot = $rootPath; RootDirectory = $rootPin - Pins = $pins - Objects = [Collections.Generic.Dictionary[string,object]]::new([StringComparer]::OrdinalIgnoreCase) - OwnedObjects = [Collections.Generic.Dictionary[string,object]]::new([StringComparer]::OrdinalIgnoreCase) - LastSourceOpenAttempt = $null; Closed = $false; Disposition = $null - } - } catch { - if ($null -ne $handle) { $handle.Dispose() } - throw - } -} - -function Open-CcodInstallPinnedDirectory { - [CmdletBinding()] - param( - [Parameter(Mandatory)]$Transaction, - [Parameter(Mandatory)]$ParentDirectory, - [Parameter(Mandatory)][string]$Leaf, - [switch]$CreateIfMissing - ) - Assert-CcodInstallLeafName $Leaf - [void](Assert-CcodInstallPinCurrent -Transaction $Transaction -Pin $ParentDirectory -Kind Directory) - $key = Get-CcodInstallObjectKey $ParentDirectory $Leaf - if ($Transaction.Objects.ContainsKey($key)) { - $existing = $Transaction.Objects[$key] - [void](Assert-CcodInstallPinCurrent -Transaction $Transaction -Pin $existing -Kind Directory) - return $existing - } - $result = $null - try { - $result = [CcodInstallFileNative]::OpenDirectoryRelative($ParentDirectory.Handle, $Leaf, [bool]$CreateIfMissing) - $information = [CcodInstallFileNative]::GetInfo($result.Handle) - if (-not $information.IsDirectory) { - Throw-CcodInstallFileError 'CCOD_INSTALL_FILE_TYPE_INVALID' 'Pinned install child is not a directory' $Leaf - } - if ($information.IsReparsePoint) { - Throw-CcodInstallFileError 'CCOD_INSTALL_REPARSE_LEAF' 'Pinned install child is a reparse point' $Leaf - } - $expectedPath = Join-Path $ParentDirectory.ExpectedPath $Leaf - $finalPath = [IO.Path]::GetFullPath([CcodInstallFileNative]::GetFinalPath($result.Handle)).TrimEnd('\') - if (-not $finalPath.Equals([IO.Path]::GetFullPath($expectedPath).TrimEnd('\'), [StringComparison]::OrdinalIgnoreCase)) { - Throw-CcodInstallFileError 'CCOD_INSTALL_PIN_CHANGED' 'Pinned install child final path is unexpected' $Leaf - } - $pin = [pscustomobject]@{ - Kind = 'Directory'; TransactionId = $Transaction.Id; Handle = $result.Handle; Stream = $null - ParentDirectory = $ParentDirectory; Leaf = $Leaf; ExpectedPath = $expectedPath - Identity = Get-CcodInstallIdentity $information; Created = [bool]$result.Created; Owned = [bool]$result.Created; Closed = $false - } - $result = $null - $Transaction.Pins.Add($pin) - $Transaction.Objects.Add($key, $pin) - if ($pin.Owned) { $Transaction.OwnedObjects.Add($key, $pin) } - return $pin - } catch { - if ($null -ne $result) { $result.Handle.Dispose() } - throw - } -} - -function New-CcodInstallPinnedTemporaryLeaf { - [CmdletBinding()] - param( - [Parameter(Mandatory)]$Transaction, - [Parameter(Mandatory)]$ParentDirectory, - [Parameter(Mandatory)][string]$Leaf - ) - Assert-CcodInstallLeafName $Leaf - [void](Assert-CcodInstallPinCurrent -Transaction $Transaction -Pin $ParentDirectory -Kind Directory) - $key = Get-CcodInstallObjectKey $ParentDirectory $Leaf - if ($Transaction.Objects.ContainsKey($key) -or $Transaction.OwnedObjects.ContainsKey($key)) { - Throw-CcodInstallFileError 'CCOD_INSTALL_TEMPORARY_EXISTS' 'Temporary install leaf is already tracked' $Leaf - } - $result = $null - $stream = $null - try { - $result = [CcodInstallFileNative]::CreateTemporaryFileRelative($ParentDirectory.Handle, $Leaf) - if (-not $result.Created) { Throw-CcodInstallFileError 'CCOD_INSTALL_TEMPORARY_EXISTS' 'Temporary install leaf already exists' $Leaf } - $stream = [IO.FileStream]::new($result.Handle, [IO.FileAccess]::ReadWrite, 65536, $false) - $result = $null - $information = [CcodInstallFileNative]::GetInfo($stream.SafeFileHandle) - Assert-CcodInstallPlainFile -Information $information -Handle $stream.SafeFileHandle -Target $Leaf - $pin = [pscustomobject]@{ - Kind = 'File'; TransactionId = $Transaction.Id; Handle = $null; Stream = $stream - ParentDirectory = $ParentDirectory; Leaf = $Leaf; ExpectedPath = (Join-Path $ParentDirectory.ExpectedPath $Leaf) - Identity = Get-CcodInstallIdentity $information; Created = $true; Owned = $true; Closed = $false - Sealed = $false; Length = [int64]0; Sha256 = $null - } - $stream = $null - $Transaction.Pins.Add($pin) - $Transaction.Objects.Add($key, $pin) - $Transaction.OwnedObjects.Add($key, $pin) - return $pin - } catch { - if ($null -ne $stream) { $stream.Dispose() } - if ($null -ne $result) { $result.Handle.Dispose() } - throw - } -} - -function Get-CcodInstallStreamSha256 { - param([Parameter(Mandatory)][IO.FileStream]$Stream) - $position = $Stream.Position - $sha = [Security.Cryptography.SHA256]::Create() - try { - $Stream.Position = 0 - return [BitConverter]::ToString($sha.ComputeHash($Stream)).Replace('-', '').ToLowerInvariant() - } finally { - $sha.Dispose() - $Stream.Position = $position - } -} - -function Copy-CcodInstallSealedFile { - [CmdletBinding()] - param( - [Parameter(Mandatory)]$Transaction, - [Parameter(Mandatory)][string]$SourcePath, - [Parameter(Mandatory)]$DestinationLeaf, - [Parameter(Mandatory)][ValidateRange(0,[int64]::MaxValue)][int64]$ExpectedLength, - [Parameter(Mandatory)][string]$ExpectedSha256 - ) - if (-not [IO.Path]::IsPathRooted($SourcePath) -or $ExpectedSha256 -cnotmatch '^[0-9a-f]{64}$') { - Throw-CcodInstallFileError 'CCOD_INSTALL_SOURCE_INVALID' 'Sealed source contract is invalid' $SourcePath - } - $fullSource = [IO.Path]::GetFullPath($SourcePath) - $sourceTail = if ($fullSource.StartsWith('\\')) { $fullSource } else { $fullSource.Substring([IO.Path]::GetPathRoot($fullSource).Length) } - if ($sourceTail.Contains(':')) { - Throw-CcodInstallFileError 'CCOD_INSTALL_ADS_LEAF' 'Sealed source may not name an alternate data stream' $SourcePath - } - $destinationInformation = Assert-CcodInstallPinCurrent -Transaction $Transaction -Pin $DestinationLeaf -Kind File - Assert-CcodInstallPlainFile -Information $destinationInformation -Handle $DestinationLeaf.Stream.SafeFileHandle -Target $DestinationLeaf.Leaf - if (-not $DestinationLeaf.Owned -or $DestinationLeaf.ParentDirectory.TransactionId -cne $Transaction.Id) { - Throw-CcodInstallFileError 'CCOD_INSTALL_PIN_INVALID' 'Sealed copy destination is not a transaction-owned leaf' $DestinationLeaf.Leaf - } - - $sourceHandle = $null - $sourceStream = $null - try { - $sourceHandle = [CcodInstallFileNative]::OpenSourceFile($fullSource) - $sourceInformation = [CcodInstallFileNative]::GetInfo($sourceHandle) - Assert-CcodInstallPlainFile -Information $sourceInformation -Handle $sourceHandle -Target $fullSource - $finalSource = [IO.Path]::GetFullPath([CcodInstallFileNative]::GetFinalPath($sourceHandle)) - if (-not $finalSource.Equals($fullSource, [StringComparison]::OrdinalIgnoreCase)) { - Throw-CcodInstallFileError 'CCOD_INSTALL_SOURCE_INVALID' 'Sealed source final path is unexpected' $fullSource - } - if ($null -ne $script:CcodInstallFileTransactionAfterSourceOpen) { - $Transaction.LastSourceOpenAttempt = & $script:CcodInstallFileTransactionAfterSourceOpen $fullSource - } - $sourceStream = [IO.FileStream]::new($sourceHandle, [IO.FileAccess]::Read, 65536, $false) - $sourceHandle = $null - if ([int64]$sourceStream.Length -ne $ExpectedLength) { - Throw-CcodInstallFileError 'CCOD_INSTALL_SOURCE_MISMATCH' 'Sealed source length does not match the contract' $fullSource - } - $sourceSha = Get-CcodInstallStreamSha256 $sourceStream - if ($sourceSha -cne $ExpectedSha256) { - Throw-CcodInstallFileError 'CCOD_INSTALL_SOURCE_MISMATCH' 'Sealed source digest does not match the contract' $fullSource - } - $sourceStream.Position = 0 - $DestinationLeaf.Stream.Position = 0 - $DestinationLeaf.Stream.SetLength(0) - $sourceStream.CopyTo($DestinationLeaf.Stream, 65536) - $DestinationLeaf.Stream.Flush($true) - $destinationLength = [int64]$DestinationLeaf.Stream.Length - $destinationSha = Get-CcodInstallStreamSha256 $DestinationLeaf.Stream - if ($destinationLength -ne $ExpectedLength -or $destinationSha -cne $ExpectedSha256) { - Throw-CcodInstallFileError 'CCOD_INSTALL_DESTINATION_MISMATCH' 'Pinned destination bytes failed sealed readback' $DestinationLeaf.Leaf - } - $afterInformation = Assert-CcodInstallPinCurrent -Transaction $Transaction -Pin $DestinationLeaf -Kind File - if (-not (Test-CcodInstallSameIdentity $DestinationLeaf.Identity $afterInformation)) { - Throw-CcodInstallFileError 'CCOD_INSTALL_PIN_CHANGED' 'Pinned destination identity changed during copy' $DestinationLeaf.Leaf - } - $DestinationLeaf.Sealed = $true - $DestinationLeaf.Length = $destinationLength - $DestinationLeaf.Sha256 = $destinationSha - return [pscustomobject]@{ Length = $destinationLength; Sha256 = $destinationSha } - } finally { - if ($null -ne $sourceStream) { $sourceStream.Dispose() } - elseif ($null -ne $sourceHandle) { $sourceHandle.Dispose() } - } -} - -function Get-CcodInstallExistingPin { - param([Parameter(Mandatory)]$Transaction, [Parameter(Mandatory)]$ParentDirectory, [Parameter(Mandatory)][string]$Leaf) - $key = Get-CcodInstallObjectKey $ParentDirectory $Leaf - if ($Transaction.Objects.ContainsKey($key)) { return $Transaction.Objects[$key] } - return $null -} - -function Commit-CcodInstallPinnedPromotion { - [CmdletBinding()] - param( - [Parameter(Mandatory)]$Transaction, - [Parameter(Mandatory)]$ParentDirectory, - [Parameter(Mandatory)]$TemporaryLeaf, - [Parameter(Mandatory)][string]$DestinationLeaf - ) - Assert-CcodInstallLeafName $DestinationLeaf - [void](Assert-CcodInstallPinCurrent -Transaction $Transaction -Pin $ParentDirectory -Kind Directory) - $temporaryInformation = Assert-CcodInstallPinCurrent -Transaction $Transaction -Pin $TemporaryLeaf -Kind File - Assert-CcodInstallPlainFile -Information $temporaryInformation -Handle $TemporaryLeaf.Stream.SafeFileHandle -Target $TemporaryLeaf.Leaf - if ($TemporaryLeaf.ParentDirectory.Identity.Key -cne $ParentDirectory.Identity.Key -or -not $TemporaryLeaf.Owned -or -not $TemporaryLeaf.Sealed) { - Throw-CcodInstallFileError 'CCOD_INSTALL_PROMOTION_INVALID' 'Promotion requires a sealed owned leaf under the pinned parent' $TemporaryLeaf.Leaf - } - if ($TemporaryLeaf.Leaf.Equals($DestinationLeaf, [StringComparison]::OrdinalIgnoreCase)) { return $TemporaryLeaf } - - $destinationKey = Get-CcodInstallObjectKey $ParentDirectory $DestinationLeaf - $existingPin = Get-CcodInstallExistingPin $Transaction $ParentDirectory $DestinationLeaf - $identityHandle = $null - $destinationExists = $false - try { - if ($null -ne $existingPin) { - $existingInformation = Assert-CcodInstallPinCurrent -Transaction $Transaction -Pin $existingPin -Kind File - Assert-CcodInstallPlainFile -Information $existingInformation -Handle (Get-CcodInstallPinHandle $existingPin) -Target $DestinationLeaf - $destinationExists = $true - } else { - try { $identityHandle = [CcodInstallFileNative]::OpenIdentityRelative($ParentDirectory.Handle, $DestinationLeaf) } - catch [ComponentModel.Win32Exception] { - if ($_.Exception.NativeErrorCode -notin @(2,3)) { throw } - } - if ($null -ne $identityHandle) { - $existingInformation = [CcodInstallFileNative]::GetInfo($identityHandle) - if ($existingInformation.IsReparsePoint) { - Throw-CcodInstallFileError 'CCOD_INSTALL_REPARSE_LEAF' 'Promotion destination is a reparse leaf' $DestinationLeaf - } - Assert-CcodInstallPlainFile -Information $existingInformation -Handle $identityHandle -Target $DestinationLeaf - $destinationExists = $true - } - } - } finally { - if ($null -ne $identityHandle) { $identityHandle.Dispose() } - } - - [void](Assert-CcodInstallPinCurrent -Transaction $Transaction -Pin $ParentDirectory -Kind Directory) - if ($null -ne $existingPin) { - Close-CcodInstallPin $existingPin - [void]$Transaction.Objects.Remove($destinationKey) - if ($Transaction.OwnedObjects.ContainsKey($destinationKey) -and $Transaction.OwnedObjects[$destinationKey] -eq $existingPin) { - [void]$Transaction.OwnedObjects.Remove($destinationKey) - } - } - $errorCode = [CcodInstallFileNative]::RenameRelative($TemporaryLeaf.Stream.SafeFileHandle, $ParentDirectory.Handle, $DestinationLeaf, $destinationExists) - if ($errorCode -ne 0) { - Throw-CcodInstallFileError 'CCOD_INSTALL_PROMOTION_FAILED' "Pinned promotion failed with Windows error $errorCode" $DestinationLeaf - } - - $temporaryKey = Get-CcodInstallObjectKey $ParentDirectory $TemporaryLeaf.Leaf - [void]$Transaction.Objects.Remove($temporaryKey) - [void]$Transaction.OwnedObjects.Remove($temporaryKey) - $TemporaryLeaf.Leaf = $DestinationLeaf - $TemporaryLeaf.ExpectedPath = Join-Path $ParentDirectory.ExpectedPath $DestinationLeaf - $Transaction.Objects[$destinationKey] = $TemporaryLeaf - $Transaction.OwnedObjects[$destinationKey] = $TemporaryLeaf - [void](Assert-CcodInstallPinCurrent -Transaction $Transaction -Pin $TemporaryLeaf -Kind File) - return $TemporaryLeaf -} - -function Write-CcodInstallPinnedBytes { - param([Parameter(Mandatory)]$Pin, [Parameter(Mandatory)][byte[]]$Bytes) - $Pin.Stream.Position = 0 - $Pin.Stream.SetLength(0) - $Pin.Stream.Write($Bytes, 0, $Bytes.Length) - $Pin.Stream.Flush($true) - $Pin.Length = [int64]$Bytes.LongLength - $Pin.Sha256 = Get-CcodInstallStreamSha256 $Pin.Stream - $Pin.Sealed = $true -} - -function Write-CcodInstallPinnedJson { - [CmdletBinding()] - param( - [Parameter(Mandatory)]$Transaction, - [Parameter(Mandatory)]$ParentDirectory, - [Parameter(Mandatory)][string]$Leaf, - [Parameter(Mandatory)]$Value, - [switch]$Compress - ) - Assert-CcodInstallLeafName $Leaf - [void](Assert-CcodInstallPinCurrent -Transaction $Transaction -Pin $ParentDirectory -Kind Directory) - $json = ($Value | ConvertTo-Json -Depth 32 -Compress:$Compress) + "`n" - $bytes = [Text.UTF8Encoding]::new($false).GetBytes($json) - $prefix = ('.' + $Leaf + '.') - if ($prefix.Length -gt 100) { $prefix = '.json.' } - $temporaryName = $prefix + [guid]::NewGuid().ToString('N') + '.tmp' - $temporary = New-CcodInstallPinnedTemporaryLeaf -Transaction $Transaction -ParentDirectory $ParentDirectory -Leaf $temporaryName - Write-CcodInstallPinnedBytes -Pin $temporary -Bytes $bytes - return Commit-CcodInstallPinnedPromotion -Transaction $Transaction -ParentDirectory $ParentDirectory -TemporaryLeaf $temporary -DestinationLeaf $Leaf -} - -function Test-CcodInstallSanitizedValue { - param($Value, [int]$Depth = 0) + $script:CcodRuntimeType = [CcodInstallCapabilityMarker].Assembly.GetType('CcodInstallRuntime', $true) +} + +function Assert-CcodLeaf([string]$Leaf) { + if ([string]::IsNullOrWhiteSpace($Leaf) -or $Leaf.Length -gt 160 -or $Leaf -in @('.','..') -or $Leaf -notmatch '^[A-Za-z0-9._-]+$' -or $Leaf.EndsWith('.') -or $Leaf -match '^(?i:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)') { Throw-CcodInstallFileError 'CCOD_INSTALL_LEAF_INVALID' '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 Get-CcodTransactionState($Transaction) { $state=$null;if($null -eq $Transaction -or -not $script:CcodTransactions.TryGetValue($Transaction,[ref]$state)){Throw-CcodInstallFileError 'CCOD_INSTALL_TRANSACTION_INVALID' 'Invalid install transaction capability' $null};if($state.Closed){Throw-CcodInstallFileError 'CCOD_INSTALL_TRANSACTION_CLOSED' 'Install transaction is closed' $null};return $state } +function Get-CcodPinToken($Transaction,$Pin,[string]$Kind) { $state=Get-CcodTransactionState $Transaction;$record=$null;if($null -eq $Pin -or -not $script:CcodPins.TryGetValue($Pin,[ref]$record) -or -not [object]::ReferenceEquals($record.Transaction,$Transaction) -or $record.Kind -cne $Kind){Throw-CcodInstallFileError 'CCOD_INSTALL_PIN_INVALID' 'Invalid install pin capability' $null};return [pscustomobject]@{State=$state;Token=$record.Token} } +function New-CcodCapability { New-Object psobject } +function Add-CcodPinCapability($Transaction,$Token,[string]$Kind) {$cap=New-CcodCapability;$script:CcodPins.Add($cap,[pscustomobject]@{Transaction=$Transaction;Token=$Token;Kind=$Kind});return $cap} +function Convert-CcodRuntimeError($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 ($message -ceq 'destination exists') { Throw-CcodInstallFileError 'CCOD_INSTALL_PROMOTION_DESTINATION_EXISTS' $message $null } + if ($exception -is [ObjectDisposedException]) { Throw-CcodInstallFileError 'CCOD_INSTALL_TRANSACTION_CLOSED' $message $null } + if ($exception -is [Security.Cryptography.CryptographicException] -or $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 'reparse') { Throw-CcodInstallFileError 'CCOD_INSTALL_REPARSE_LEAF' $message $null } + if ($message -match 'multi-link') { Throw-CcodInstallFileError 'CCOD_INSTALL_MULTILINK_LEAF' $message $null } + if ($message -match 'unknown|namespace') { 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 Open-CcodInstallFileTransaction { 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);$root=Convert-CcodRuntimeError {Invoke-CcodRuntimeStatic Open $arguments} 'CCOD_INSTALL_ROOT_INVALID';$runtime=$arguments[1];$tx=New-CcodCapability;$rootCap=Add-CcodPinCapability $tx $root 'Directory';$tx|Add-Member -NotePropertyName RootDirectory -NotePropertyValue $rootCap;$script:CcodTransactions.Add($tx,[pscustomobject]@{Runtime=$runtime;Closed=$false;Root=$rootCap;Disposition=$null});return $tx } +function Open-CcodInstallPinnedDirectory { param([Parameter(Mandatory)]$Transaction,[Parameter(Mandatory)]$ParentDirectory,[Parameter(Mandatory)][string]$Leaf,[switch]$CreateIfMissing) Assert-CcodLeaf $Leaf;$p=Get-CcodPinToken $Transaction $ParentDirectory Directory;$token=Convert-CcodRuntimeError {Invoke-CcodRuntimeMethod $p.State.Runtime OpenDirectory @($p.Token,$Leaf,[bool]$CreateIfMissing)} 'CCOD_INSTALL_REPARSE_LEAF';return Add-CcodPinCapability $Transaction $token Directory } +function New-CcodInstallPinnedTemporaryLeaf { param([Parameter(Mandatory)]$Transaction,[Parameter(Mandatory)]$ParentDirectory,[Parameter(Mandatory)][string]$Leaf) Assert-CcodLeaf $Leaf;$p=Get-CcodPinToken $Transaction $ParentDirectory Directory;$token=Convert-CcodRuntimeError {Invoke-CcodRuntimeMethod $p.State.Runtime CreateTemporary @($p.Token,$Leaf)} 'CCOD_INSTALL_TEMPORARY_EXISTS';return Add-CcodPinCapability $Transaction $token File } +function Copy-CcodInstallSealedFile { param([Parameter(Mandatory)]$Transaction,[Parameter(Mandatory)][string]$SourcePath,[Parameter(Mandatory)]$DestinationLeaf,[Parameter(Mandatory)][int64]$ExpectedLength,[Parameter(Mandatory)][string]$ExpectedSha256) if(-not [IO.Path]::IsPathRooted($SourcePath)-or$ExpectedSha256-cnotmatch'^[0-9a-f]{64}$'){Throw-CcodInstallFileError 'CCOD_INSTALL_SOURCE_INVALID' 'Invalid source contract' $SourcePath};$p=Get-CcodPinToken $Transaction $DestinationLeaf File;Convert-CcodRuntimeError {Invoke-CcodRuntimeMethod $p.State.Runtime Copy @($p.Token,[IO.Path]::GetFullPath($SourcePath),$ExpectedLength,$ExpectedSha256)|Out-Null} 'CCOD_INSTALL_SOURCE_MISMATCH';[pscustomobject]@{Length=$ExpectedLength;Sha256=$ExpectedSha256} } +function Commit-CcodInstallPinnedPromotion { param([Parameter(Mandatory)]$Transaction,[Parameter(Mandatory)]$ParentDirectory,[Parameter(Mandatory)]$TemporaryLeaf,[Parameter(Mandatory)][string]$DestinationLeaf) Assert-CcodLeaf $DestinationLeaf;$p=Get-CcodPinToken $Transaction $ParentDirectory Directory;$t=Get-CcodPinToken $Transaction $TemporaryLeaf File;Convert-CcodRuntimeError {Invoke-CcodRuntimeMethod $p.State.Runtime Promote @($p.Token,$t.Token,$DestinationLeaf)|Out-Null} 'CCOD_INSTALL_PROMOTION_FAILED';return $TemporaryLeaf } +function Write-CcodInstallPinnedJson { param([Parameter(Mandatory)]$Transaction,[Parameter(Mandatory)]$ParentDirectory,[Parameter(Mandatory)][string]$Leaf,[Parameter(Mandatory)]$Value,[switch]$Compress) Assert-CcodLeaf $Leaf;$p=Get-CcodPinToken $Transaction $ParentDirectory Directory;$bytes=[Text.UTF8Encoding]::new($false).GetBytes(($Value|ConvertTo-Json -Depth 32 -Compress:$Compress)+"`n");$token=Convert-CcodRuntimeError {Invoke-CcodRuntimeMethod $p.State.Runtime WriteJson @($p.Token,$Leaf,$bytes)} 'CCOD_INSTALL_PROMOTION_FAILED';return Add-CcodPinCapability $Transaction $token File } +function Test-CcodSanitized($Value,[int]$Depth=0) { if ($Depth -gt 8) { return $false } if ($null -eq $Value) { return $true } - if ($Value -is [string]) { return $Value.Length -le 4096 } - if ($Value -is [bool] -or $Value -is [byte] -or $Value -is [sbyte] -or - $Value -is [int16] -or $Value -is [uint16] -or $Value -is [int32] -or $Value -is [uint32] -or - $Value -is [int64] -or $Value -is [uint64] -or $Value -is [single] -or $Value -is [double] -or $Value -is [decimal]) { 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-CcodInstallSanitizedValue -Value $Value[$key] -Depth ($Depth + 1))) { return $false } + foreach ($key in $Value.Keys) { + if ($key -isnot [string] -or $key -cnotmatch '^[A-Za-z0-9_.-]{1,96}$' -or -not (Test-CcodSanitized $Value[$key] ($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-CcodInstallSanitizedValue -Value $property.Value -Depth ($Depth + 1))) { return $false } - } - return $true - } - if ($Value -is [Collections.IList]) { - foreach ($entry in @($Value)) { - if (-not (Test-CcodInstallSanitizedValue -Value $entry -Depth ($Depth + 1))) { return $false } + foreach ($property in $Value.PSObject.Properties) { + if ($property.Name -cnotmatch '^[A-Za-z0-9_.-]{1,96}$' -or -not (Test-CcodSanitized $property.Value ($Depth + 1))) { return $false } } return $true } return $false } +function Append-CcodInstallPinnedLog { param([Parameter(Mandatory)]$Transaction,[Parameter(Mandatory)]$ParentDirectory,[Parameter(Mandatory)][string]$Leaf,[Parameter(Mandatory)]$Record) Assert-CcodLeaf $Leaf;if(-not(Test-CcodSanitized $Record)){Throw-CcodInstallFileError 'CCOD_INSTALL_LOG_RECORD_INVALID' 'Invalid log record' $null};$p=Get-CcodPinToken $Transaction $ParentDirectory Directory;$bytes=[Text.UTF8Encoding]::new($false).GetBytes(($Record|ConvertTo-Json -Depth 8 -Compress)+"`n");if($bytes.Length-gt65536){Throw-CcodInstallFileError 'CCOD_INSTALL_LOG_RECORD_INVALID' 'Log record too large' $null};$token=Convert-CcodRuntimeError {Invoke-CcodRuntimeMethod $p.State.Runtime Append @($p.Token,$Leaf,$bytes)} 'CCOD_INSTALL_LOG_WRITE_FAILED';return Add-CcodPinCapability $Transaction $token File } +function Remove-CcodInstallOwnedTree { param([Parameter(Mandatory)]$Transaction,[Parameter(Mandatory)]$ParentDirectory,[Parameter(Mandatory)][string]$Leaf) Assert-CcodLeaf $Leaf;$p=Get-CcodPinToken $Transaction $ParentDirectory Directory;Convert-CcodRuntimeError {Invoke-CcodRuntimeMethod $p.State.Runtime RemoveOwnedTree @($p.Token,$Leaf)|Out-Null} 'CCOD_INSTALL_DELETE_FAILED' } +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 transaction capability' $null};if(-not$state.Closed){Invoke-CcodRuntimeMethod $state.Runtime Close @()|Out-Null;$state.Closed=$true;$state.Disposition=$Disposition} } -function Append-CcodInstallPinnedLog { - [CmdletBinding()] - param( - [Parameter(Mandatory)]$Transaction, - [Parameter(Mandatory)]$ParentDirectory, - [Parameter(Mandatory)][string]$Leaf, - [Parameter(Mandatory)]$Record - ) - Assert-CcodInstallLeafName $Leaf - if (($Record -isnot [Collections.IDictionary] -and $Record -isnot [pscustomobject]) -or - -not (Test-CcodInstallSanitizedValue $Record)) { - Throw-CcodInstallFileError 'CCOD_INSTALL_LOG_RECORD_INVALID' 'Install log record is not a bounded sanitized object' $null - } - [void](Assert-CcodInstallPinCurrent -Transaction $Transaction -Pin $ParentDirectory -Kind Directory) - $bytes = [Text.UTF8Encoding]::new($false).GetBytes(($Record | ConvertTo-Json -Depth 8 -Compress) + "`n") - if ($bytes.LongLength -gt 65536) { - Throw-CcodInstallFileError 'CCOD_INSTALL_LOG_RECORD_INVALID' 'Install log record exceeds the bounded size' $null - } - $key = Get-CcodInstallObjectKey $ParentDirectory $Leaf - $pin = Get-CcodInstallExistingPin $Transaction $ParentDirectory $Leaf - if ($null -ne $pin) { - $information = Assert-CcodInstallPinCurrent -Transaction $Transaction -Pin $pin -Kind File - Assert-CcodInstallPlainFile -Information $information -Handle $pin.Stream.SafeFileHandle -Target $Leaf - } else { - $result = $null - $stream = $null - try { - $result = [CcodInstallFileNative]::OpenAppendFileRelative($ParentDirectory.Handle, $Leaf) - $stream = [IO.FileStream]::new($result.Handle, [IO.FileAccess]::ReadWrite, 65536, $false) - $created = [bool]$result.Created - $result = $null - $information = [CcodInstallFileNative]::GetInfo($stream.SafeFileHandle) - Assert-CcodInstallPlainFile -Information $information -Handle $stream.SafeFileHandle -Target $Leaf - $pin = [pscustomobject]@{ - Kind = 'File'; TransactionId = $Transaction.Id; Handle = $null; Stream = $stream - ParentDirectory = $ParentDirectory; Leaf = $Leaf; ExpectedPath = (Join-Path $ParentDirectory.ExpectedPath $Leaf) - Identity = Get-CcodInstallIdentity $information; Created = $created; Owned = $created; Closed = $false - Sealed = $false; Length = [int64]$information.Length; Sha256 = $null - } - $stream = $null - $Transaction.Pins.Add($pin) - $Transaction.Objects.Add($key, $pin) - if ($pin.Owned) { $Transaction.OwnedObjects.Add($key, $pin) } - } catch { - if ($null -ne $stream) { $stream.Dispose() } - if ($null -ne $result) { $result.Handle.Dispose() } - throw - } - } - [void](Assert-CcodInstallPinCurrent -Transaction $Transaction -Pin $ParentDirectory -Kind Directory) - [void](Assert-CcodInstallPinCurrent -Transaction $Transaction -Pin $pin -Kind File) - $pin.Stream.Position = $pin.Stream.Length - $pin.Stream.Write($bytes, 0, $bytes.Length) - $pin.Stream.Flush($true) - $pin.Length = [int64]$pin.Stream.Length - return $pin -} - -function Get-CcodInstallOwnedDeletionPlan { - param( - [Parameter(Mandatory)]$Transaction, - [Parameter(Mandatory)]$DirectoryPin, - [Parameter(Mandatory)][AllowEmptyCollection()][Collections.Generic.List[object]]$Plan - ) - [void](Assert-CcodInstallPinCurrent -Transaction $Transaction -Pin $DirectoryPin -Kind Directory) - foreach ($name in [CcodInstallFileNative]::EnumerateDirectory($DirectoryPin.Handle)) { - Assert-CcodInstallLeafName $name - $key = Get-CcodInstallObjectKey $DirectoryPin $name - $pin = if ($Transaction.Objects.ContainsKey($key)) { $Transaction.Objects[$key] } else { $null } - if ($null -eq $pin) { - $probe = $null - try { - $probe = [CcodInstallFileNative]::OpenIdentityRelative($DirectoryPin.Handle, $name) - $probeInformation = [CcodInstallFileNative]::GetInfo($probe) - if ($probeInformation.IsReparsePoint) { - Throw-CcodInstallFileError 'CCOD_INSTALL_REPARSE_LEAF' 'Owned-tree cleanup encountered a reparse leaf' $name - } - if (-not $probeInformation.IsDirectory) { - Assert-CcodInstallPlainFile -Information $probeInformation -Handle $probe -Target $name - } - Throw-CcodInstallFileError 'CCOD_INSTALL_UNKNOWN_LEAF' 'Owned-tree cleanup encountered an unowned leaf' $name - } finally { - if ($null -ne $probe) { $probe.Dispose() } - } - } - if (-not $Transaction.OwnedObjects.ContainsKey($key) -or $Transaction.OwnedObjects[$key] -ne $pin) { - Throw-CcodInstallFileError 'CCOD_INSTALL_UNKNOWN_LEAF' 'Owned-tree cleanup encountered an unowned tracked leaf' $name - } - if ($pin.Kind -ceq 'Directory') { - [void](Assert-CcodInstallPinCurrent -Transaction $Transaction -Pin $pin -Kind Directory) - Get-CcodInstallOwnedDeletionPlan -Transaction $Transaction -DirectoryPin $pin -Plan $Plan - } else { - $information = Assert-CcodInstallPinCurrent -Transaction $Transaction -Pin $pin -Kind File - Assert-CcodInstallPlainFile -Information $information -Handle $pin.Stream.SafeFileHandle -Target $name - } - $Plan.Add($pin) - } -} - -function Remove-CcodInstallOwnedTree { - [CmdletBinding()] - param( - [Parameter(Mandatory)]$Transaction, - [Parameter(Mandatory)]$ParentDirectory, - [Parameter(Mandatory)][string]$Leaf - ) - Assert-CcodInstallLeafName $Leaf - [void](Assert-CcodInstallPinCurrent -Transaction $Transaction -Pin $ParentDirectory -Kind Directory) - $key = Get-CcodInstallObjectKey $ParentDirectory $Leaf - if (-not $Transaction.OwnedObjects.ContainsKey($key)) { - Throw-CcodInstallFileError 'CCOD_INSTALL_UNKNOWN_LEAF' 'Owned-tree cleanup target is not transaction-owned' $Leaf - } - $target = $Transaction.OwnedObjects[$key] - [void](Assert-CcodInstallPinCurrent -Transaction $Transaction -Pin $target -Kind Directory) - $plan = [Collections.Generic.List[object]]::new() - Get-CcodInstallOwnedDeletionPlan -Transaction $Transaction -DirectoryPin $target -Plan $plan - $plan.Add($target) - - foreach ($pin in $plan) { - $pinKey = if ($null -eq $pin.ParentDirectory) { $null } else { Get-CcodInstallObjectKey $pin.ParentDirectory $pin.Leaf } - $errorCode = [CcodInstallFileNative]::MarkDelete((Get-CcodInstallPinHandle $pin)) - if ($errorCode -ne 0) { - Throw-CcodInstallFileError 'CCOD_INSTALL_DELETE_FAILED' "Owned-tree handle delete failed with Windows error $errorCode" $pin.Leaf - } - Close-CcodInstallPin $pin - if ($null -ne $pinKey) { - [void]$Transaction.Objects.Remove($pinKey) - [void]$Transaction.OwnedObjects.Remove($pinKey) - } - } -} - -function Close-CcodInstallFileTransaction { - [CmdletBinding()] - param( - [Parameter(Mandatory)]$Transaction, - [Parameter(Mandatory)][ValidateSet('Ready','Failed')][string]$Disposition - ) - if ($null -eq $Transaction -or $Transaction.Kind -cne 'CcodInstallFileTransaction') { - Throw-CcodInstallFileError 'CCOD_INSTALL_TRANSACTION_INVALID' 'Install file transaction context is invalid' $null - } - if ($Transaction.Closed) { return $Transaction } - for ($index = $Transaction.Pins.Count - 1; $index -ge 0; $index--) { - Close-CcodInstallPin $Transaction.Pins[$index] - } - $Transaction.Disposition = $Disposition - $Transaction.Closed = $true - return $Transaction -} - -Export-ModuleMember -Function @( - 'Open-CcodInstallFileTransaction', - 'Open-CcodInstallPinnedDirectory', - 'New-CcodInstallPinnedTemporaryLeaf', - 'Copy-CcodInstallSealedFile', - 'Commit-CcodInstallPinnedPromotion', - 'Write-CcodInstallPinnedJson', - 'Append-CcodInstallPinnedLog', - 'Remove-CcodInstallOwnedTree', - 'Close-CcodInstallFileTransaction' -) +Export-ModuleMember -Function Open-CcodInstallFileTransaction,Open-CcodInstallPinnedDirectory,New-CcodInstallPinnedTemporaryLeaf,Copy-CcodInstallSealedFile,Commit-CcodInstallPinnedPromotion,Write-CcodInstallPinnedJson,Append-CcodInstallPinnedLog,Remove-CcodInstallOwnedTree,Close-CcodInstallFileTransaction diff --git a/tests/persistence/InstallFileTransaction.SelfTest.ps1 b/tests/persistence/InstallFileTransaction.SelfTest.ps1 index 26f07e1..32cff86 100644 --- a/tests/persistence/InstallFileTransaction.SelfTest.ps1 +++ b/tests/persistence/InstallFileTransaction.SelfTest.ps1 @@ -12,7 +12,10 @@ if ($null -eq ('CcodInstallTransactionTestNative' -as [type])) { Add-Type -TypeDefinition @' using System; using System.ComponentModel; +using System.IO; using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; using Microsoft.Win32.SafeHandles; public static class CcodInstallTransactionTestNative @@ -60,6 +63,24 @@ public static class CcodInstallTransactionTestNative if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error()); return handle; } + + public static Task InsertUnknownWhenLeafBecomesUnavailable(string watchedLeaf, string unknownLeaf) + { + return Task.Run(() => { + DateTime deadline = DateTime.UtcNow.AddSeconds(10); + while (DateTime.UtcNow < deadline) + { + if (!File.Exists(watchedLeaf)) + { + try { File.WriteAllText(unknownLeaf, "concurrent-unknown"); return "inserted"; } + catch (IOException) { return "blocked"; } + catch (UnauthorizedAccessException) { return "blocked"; } + } + Thread.Yield(); + } + return "timeout"; + }); + } } '@ } @@ -154,32 +175,17 @@ function Invoke-CcodAttemptFileReplacement { function Invoke-CcodReplacePinnedDirectory { param( [Parameter(Mandatory)]$Fixture, - [Parameter(Mandatory)]$Transaction, - [Parameter(Mandatory)]$DirectoryPin + [Parameter(Mandatory)][string]$Path ) - $renamedLeaf = $DirectoryPin.Leaf + '.attacker-old' - # The production pin intentionally denies delete sharing. Reopen only inside - # this fault-injection helper so the independent final-path guard is tested. - $descendantPrefix = [IO.Path]::GetFullPath($DirectoryPin.ExpectedPath).TrimEnd('\') + '\' - $descendants = @($Transaction.Pins | Where-Object { - -not $_.Closed -and $_.Kind -ceq 'File' -and - [IO.Path]::GetFullPath([string]$_.ExpectedPath).StartsWith($descendantPrefix, [StringComparison]::OrdinalIgnoreCase) - }) - foreach ($pin in $descendants) { $pin.Stream.Dispose() } - $DirectoryPin.Handle.Dispose() - $DirectoryPin.Handle = [CcodInstallTransactionTestNative]::OpenDirectoryAllowDeleteShare($DirectoryPin.ExpectedPath) - $moved = Join-Path $DirectoryPin.ParentDirectory.ExpectedPath $renamedLeaf - $renameError = Invoke-CcodInstallModule { - param($Pin,$RenamedLeaf) - [CcodInstallFileNative]::RenameRelative($Pin.Handle, $Pin.ParentDirectory.Handle, $RenamedLeaf, $false) - } @($DirectoryPin,$renamedLeaf) - if ($renameError -ne 0) { throw "Could not rename fault-injection directory: Windows error $renameError" } - foreach ($pin in $descendants) { - $relative = [IO.Path]::GetFullPath([string]$pin.ExpectedPath).Substring($descendantPrefix.Length) - $fileHandle = [CcodInstallTransactionTestNative]::OpenFileAllowDeleteShare((Join-Path $moved $relative)) - $pin.Stream = [IO.FileStream]::new($fileHandle, [IO.FileAccess]::ReadWrite, 65536, $false) + $moved = $Path + '.attacker-old' + try { + [IO.Directory]::Move($Path, $moved) + } catch [IO.IOException] { + return [pscustomobject]@{ Outcome = 'blocked'; MovedDirectory = $null } + } catch [UnauthorizedAccessException] { + return [pscustomobject]@{ Outcome = 'blocked'; MovedDirectory = $null } } - $result = & cmd.exe /d /c mklink /J $DirectoryPin.ExpectedPath $Fixture.Outside 2>&1 + $result = & cmd.exe /d /c mklink /J $Path $Fixture.Outside 2>&1 if ($LASTEXITCODE -ne 0) { throw "Could not create replacement junction: $result" } return [pscustomobject]@{ Outcome = 'replaced'; MovedDirectory = $moved } } @@ -196,6 +202,17 @@ function New-CcodJunction { if ($LASTEXITCODE -ne 0) { throw "Could not create junction: $result" } } +function Invoke-CcodAttemptAdsWrite { + param([Parameter(Mandatory)][string]$Path) + $previous = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + $output = & cmd.exe /d /c "echo attacker>`"$Path`"" 2>&1 + $exitCode = $LASTEXITCODE + } finally { $ErrorActionPreference = $previous } + return [pscustomobject]@{ Outcome = $(if ($exitCode -eq 0) { 'created' } else { 'blocked' }); Output = $output } +} + function New-CcodSourceFile { param([Parameter(Mandatory)]$Fixture, [string]$Name = 'source.bin', [string]$Content = 'sealed-source-v1') $path = Join-Path $Fixture.Base $Name @@ -234,27 +251,10 @@ Invoke-CcodTest 'pins sealed source bytes while copying and verifies the destina $tx = Open-CcodFixtureTransaction $fixture $runtime = Invoke-CcodInstallModule { param($Tx) Open-CcodInstallPinnedDirectory -Transaction $Tx -ParentDirectory $Tx.RootDirectory -Leaf 'runtime' -CreateIfMissing } @($tx) $temporary = Invoke-CcodInstallModule { param($Tx,$Parent) New-CcodInstallPinnedTemporaryLeaf -Transaction $Tx -ParentDirectory $Parent -Leaf 'payload.tmp' } @($tx,$runtime) - $hook = { - param($OpenedSource) - try { - [IO.File]::Move($OpenedSource, ($OpenedSource + '.attacker-old')) - return [pscustomobject]@{ Outcome = 'replaced' } - } catch [IO.IOException] { - return [pscustomobject]@{ Outcome = 'blocked' } - } catch [UnauthorizedAccessException] { - return [pscustomobject]@{ Outcome = 'blocked' } - } - } - Invoke-CcodInstallModule { param($Hook) $script:CcodInstallFileTransactionAfterSourceOpen = $Hook } @($hook) | Out-Null - try { - $copy = Invoke-CcodInstallModule { - param($Tx,$Source,$Leaf,$Length,$Sha) - Copy-CcodInstallSealedFile -Transaction $Tx -SourcePath $Source -DestinationLeaf $Leaf -ExpectedLength $Length -ExpectedSha256 $Sha - } @($tx,$source.Path,$temporary,$source.Length,$source.Sha256) - } finally { - Invoke-CcodInstallModule { $script:CcodInstallFileTransactionAfterSourceOpen = $null } | Out-Null - } - Assert-CcodEqual 'blocked' $tx.LastSourceOpenAttempt.Outcome 'source replacement is blocked after its read handle opens' + $copy = Invoke-CcodInstallModule { + param($Tx,$Source,$Leaf,$Length,$Sha) + Copy-CcodInstallSealedFile -Transaction $Tx -SourcePath $Source -DestinationLeaf $Leaf -ExpectedLength $Length -ExpectedSha256 $Sha + } @($tx,$source.Path,$temporary,$source.Length,$source.Sha256) Assert-CcodEqual $source.Length $copy.Length 'copied length is sealed' Assert-CcodEqual $source.Sha256 $copy.Sha256 'copied digest is sealed' Assert-CcodOutsideUnchanged $fixture 'source replacement' @@ -287,7 +287,7 @@ Invoke-CcodTest 'rejects reparse ADS and multi-link leaf inputs' { } finally { Remove-CcodInstallFileFixture $fixture } } -Invoke-CcodTest 'rejects an ADS-bearing promotion destination and retains the temporary leaf' { +Invoke-CcodTest 'rejects an existing ADS-bearing promotion destination without replacement' { $fixture = New-CcodInstallFileFixture try { $destination = Join-Path $fixture.Install 'active.json' @@ -303,7 +303,7 @@ Invoke-CcodTest 'rejects an ADS-bearing promotion destination and retains the te } @($tx,$source.Path,$temporary,$source.Length,$source.Sha256) Assert-CcodThrows { Invoke-CcodInstallModule { param($Tx,$Leaf) Commit-CcodInstallPinnedPromotion -Transaction $Tx -ParentDirectory $Tx.RootDirectory -TemporaryLeaf $Leaf -DestinationLeaf 'active.json' } @($tx,$temporary) | Out-Null - } 'CCOD_INSTALL_ADS_LEAF' + } 'CCOD_INSTALL_PROMOTION_DESTINATION_EXISTS' Assert-CcodTrue ([IO.File]::Exists((Join-Path $fixture.Install 'active.tmp'))) 'failed ADS promotion retains the temporary candidate' Assert-CcodOutsideUnchanged $fixture 'ADS destination' } finally { Remove-CcodInstallFileFixture $fixture } @@ -320,12 +320,18 @@ Invoke-CcodTest 'rejects promotion after an ancestor rename changes the pinned p param($Tx,$Source,$Leaf,$Length,$Sha) Copy-CcodInstallSealedFile -Transaction $Tx -SourcePath $Source -DestinationLeaf $Leaf -ExpectedLength $Length -ExpectedSha256 $Sha | Out-Null } @($tx,$source.Path,$temporary,$source.Length,$source.Sha256) - $attack = Invoke-CcodReplacePinnedDirectory -Fixture $fixture -Transaction $tx -DirectoryPin $runtime - Assert-CcodEqual 'replaced' $attack.Outcome 'fixture ancestor replacement is established' - Assert-CcodThrows { + $attack = Invoke-CcodReplacePinnedDirectory -Fixture $fixture -Path (Join-Path $fixture.Install 'runtime') + if ($attack.Outcome -ceq 'replaced') { + Assert-CcodThrows { + Invoke-CcodInstallModule { param($Tx,$Parent,$Leaf) Commit-CcodInstallPinnedPromotion -Transaction $Tx -ParentDirectory $Parent -TemporaryLeaf $Leaf -DestinationLeaf 'payload.bin' } @($tx,$runtime,$temporary) | Out-Null + } 'CCOD_INSTALL_PIN_CHANGED' + Assert-CcodTrue ([IO.File]::Exists((Join-Path $attack.MovedDirectory 'payload.tmp'))) 'renamed-parent promotion retains its temporary candidate' + } else { Invoke-CcodInstallModule { param($Tx,$Parent,$Leaf) Commit-CcodInstallPinnedPromotion -Transaction $Tx -ParentDirectory $Parent -TemporaryLeaf $Leaf -DestinationLeaf 'payload.bin' } @($tx,$runtime,$temporary) | Out-Null - } 'CCOD_INSTALL_PIN_CHANGED' - Assert-CcodTrue ([IO.File]::Exists((Join-Path $attack.MovedDirectory 'payload.tmp'))) 'renamed-parent promotion retains its temporary candidate' + Close-CcodInstallFileTransaction -Transaction $tx -Disposition Ready | Out-Null + $fixture.Transaction = $null + Assert-CcodEqual $source.Sha256 (Get-CcodTestFileSha256 (Join-Path $fixture.Install 'runtime\payload.bin')) 'blocked parent rename promotes only sealed bytes' + } Assert-CcodOutsideUnchanged $fixture 'renamed parent promotion' } finally { Remove-CcodInstallFileFixture $fixture } } @@ -366,10 +372,10 @@ Invoke-CcodTest 'deletes a fully proven transaction-owned tree from leaves upwar $candidate = Invoke-CcodInstallModule { param($Tx) Open-CcodInstallPinnedDirectory -Transaction $Tx -ParentDirectory $Tx.RootDirectory -Leaf 'candidate' -CreateIfMissing } @($tx) $temporary = Invoke-CcodInstallModule { param($Tx,$Parent) New-CcodInstallPinnedTemporaryLeaf -Transaction $Tx -ParentDirectory $Parent -Leaf 'payload.tmp' } @($tx,$candidate) Invoke-CcodInstallModule { - param($Tx,$Source,$Leaf,$Length,$Sha) + param($Tx,$Source,$Parent,$Leaf,$Length,$Sha) Copy-CcodInstallSealedFile -Transaction $Tx -SourcePath $Source -DestinationLeaf $Leaf -ExpectedLength $Length -ExpectedSha256 $Sha | Out-Null - Commit-CcodInstallPinnedPromotion -Transaction $Tx -ParentDirectory $Leaf.ParentDirectory -TemporaryLeaf $Leaf -DestinationLeaf 'payload.bin' | Out-Null - } @($tx,$source.Path,$temporary,$source.Length,$source.Sha256) + Commit-CcodInstallPinnedPromotion -Transaction $Tx -ParentDirectory $Parent -TemporaryLeaf $Leaf -DestinationLeaf 'payload.bin' | Out-Null + } @($tx,$source.Path,$candidate,$temporary,$source.Length,$source.Sha256) Invoke-CcodInstallModule { param($Tx) Remove-CcodInstallOwnedTree -Transaction $Tx -ParentDirectory $Tx.RootDirectory -Leaf 'candidate' } @($tx) | Out-Null Assert-CcodTrue (-not [IO.Directory]::Exists((Join-Path $fixture.Install 'candidate'))) 'proven owned tree is removed' Assert-CcodOutsideUnchanged $fixture 'owned tree removal' @@ -382,17 +388,20 @@ foreach ($case in @('json','log')) { try { $tx = Open-CcodFixtureTransaction $fixture $parent = Invoke-CcodInstallModule { param($Tx,$Leaf) Open-CcodInstallPinnedDirectory -Transaction $Tx -ParentDirectory $Tx.RootDirectory -Leaf $Leaf -CreateIfMissing } @($tx,$case) - $attack = Invoke-CcodReplacePinnedDirectory -Fixture $fixture -Transaction $tx -DirectoryPin $parent - Assert-CcodEqual 'replaced' $attack.Outcome "$case fixture replacement is established" + $attack = Invoke-CcodReplacePinnedDirectory -Fixture $fixture -Path (Join-Path $fixture.Install $case) if ($case -ceq 'json') { - Assert-CcodThrows { - Invoke-CcodInstallModule { param($Tx,$Parent) Write-CcodInstallPinnedJson -Transaction $Tx -ParentDirectory $Parent -Leaf 'state.json' -Value ([ordered]@{ schemaVersion = 1; value = 'safe' }) -Compress } @($tx,$parent) - } 'CCOD_INSTALL_PIN_CHANGED' + if ($attack.Outcome -ceq 'replaced') { + Assert-CcodThrows { Invoke-CcodInstallModule { param($Tx,$Parent) Write-CcodInstallPinnedJson -Transaction $Tx -ParentDirectory $Parent -Leaf 'state.json' -Value ([ordered]@{ schemaVersion = 1; value = 'safe' }) -Compress } @($tx,$parent) } 'CCOD_INSTALL_PIN_CHANGED' + } else { + Invoke-CcodInstallModule { param($Tx,$Parent) Write-CcodInstallPinnedJson -Transaction $Tx -ParentDirectory $Parent -Leaf 'state.json' -Value ([ordered]@{ schemaVersion = 1; value = 'safe' }) -Compress } @($tx,$parent) | Out-Null + } Assert-CcodTrue (-not [IO.File]::Exists((Join-Path $fixture.Outside 'state.json'))) 'replaced JSON path is untouched' } else { - Assert-CcodThrows { - Invoke-CcodInstallModule { param($Tx,$Parent) Append-CcodInstallPinnedLog -Transaction $Tx -ParentDirectory $Parent -Leaf 'install.log' -Record ([ordered]@{ event = 'safe'; code = 'CCOD_TEST' }) } @($tx,$parent) - } 'CCOD_INSTALL_PIN_CHANGED' + if ($attack.Outcome -ceq 'replaced') { + Assert-CcodThrows { Invoke-CcodInstallModule { param($Tx,$Parent) Append-CcodInstallPinnedLog -Transaction $Tx -ParentDirectory $Parent -Leaf 'install.log' -Record ([ordered]@{ event = 'safe'; code = 'CCOD_TEST' }) } @($tx,$parent) } 'CCOD_INSTALL_PIN_CHANGED' + } else { + Invoke-CcodInstallModule { param($Tx,$Parent) Append-CcodInstallPinnedLog -Transaction $Tx -ParentDirectory $Parent -Leaf 'install.log' -Record ([ordered]@{ event = 'safe'; code = 'CCOD_TEST' }) } @($tx,$parent) | Out-Null + } Assert-CcodTrue (-not [IO.File]::Exists((Join-Path $fixture.Outside 'install.log'))) 'replaced log path is untouched' } Assert-CcodOutsideUnchanged $fixture "replaced $case parent" @@ -407,14 +416,16 @@ Invoke-CcodTest 'writes pinned JSON and appends sanitized records through relati $state = Invoke-CcodInstallModule { param($Tx) Open-CcodInstallPinnedDirectory -Transaction $Tx -ParentDirectory $Tx.RootDirectory -Leaf 'state' -CreateIfMissing } @($tx) $logs = Invoke-CcodInstallModule { param($Tx) Open-CcodInstallPinnedDirectory -Transaction $Tx -ParentDirectory $Tx.RootDirectory -Leaf 'logs' -CreateIfMissing } @($tx) Invoke-CcodInstallModule { param($Tx,$Parent) Write-CcodInstallPinnedJson -Transaction $Tx -ParentDirectory $Parent -Leaf 'active.json' -Value ([ordered]@{ schemaVersion = 1; active = 'runtime-a' }) -Compress } @($tx,$state) | Out-Null - Invoke-CcodInstallModule { param($Tx,$Parent) Write-CcodInstallPinnedJson -Transaction $Tx -ParentDirectory $Parent -Leaf 'active.json' -Value ([ordered]@{ schemaVersion = 1; active = 'runtime-b' }) -Compress } @($tx,$state) | Out-Null + Assert-CcodThrows { + Invoke-CcodInstallModule { param($Tx,$Parent) Write-CcodInstallPinnedJson -Transaction $Tx -ParentDirectory $Parent -Leaf 'active.json' -Value ([ordered]@{ schemaVersion = 1; active = 'runtime-b' }) -Compress } @($tx,$state) | Out-Null + } 'CCOD_INSTALL_PROMOTION_DESTINATION_EXISTS' Invoke-CcodInstallModule { param($Tx,$Parent) Append-CcodInstallPinnedLog -Transaction $Tx -ParentDirectory $Parent -Leaf 'install.log' -Record ([ordered]@{ event = 'one'; code = 'CCOD_OK' }) } @($tx,$logs) | Out-Null Invoke-CcodInstallModule { param($Tx,$Parent) Append-CcodInstallPinnedLog -Transaction $Tx -ParentDirectory $Parent -Leaf 'install.log' -Record ([ordered]@{ event = 'two'; code = 'CCOD_READY' }) } @($tx,$logs) | Out-Null Invoke-CcodInstallModule { param($Tx) Close-CcodInstallFileTransaction -Transaction $Tx -Disposition Ready } @($tx) | Out-Null $fixture.Transaction = $null $json = [IO.File]::ReadAllText((Join-Path $fixture.Install 'state\active.json'), [Text.UTF8Encoding]::new($false)) | ConvertFrom-Json - Assert-CcodEqual 'runtime-b' $json.active 'second pinned JSON write atomically replaces the first' + Assert-CcodEqual 'runtime-a' $json.active 'no-replace pinned JSON preserves the first committed object' $records = @([IO.File]::ReadAllLines((Join-Path $fixture.Install 'logs\install.log'), [Text.UTF8Encoding]::new($false))) Assert-CcodEqual 2 $records.Count 'pinned log appends one JSON line per record' Assert-CcodEqual 'one' (($records[0] | ConvertFrom-Json).event) 'first log record is retained' @@ -436,12 +447,194 @@ Invoke-CcodTest 'exports only the private transaction surface and closes on a te $tx = Open-CcodFixtureTransaction $fixture Invoke-CcodInstallModule { param($Tx) Close-CcodInstallFileTransaction -Transaction $Tx -Disposition Ready } @($tx) | Out-Null $fixture.Transaction = $null - Assert-CcodEqual 'Ready' $tx.Disposition 'close records the terminal disposition' - Assert-CcodTrue $tx.Closed 'close marks the context closed' + Assert-CcodEqual 'RootDirectory' (@($tx.PSObject.Properties.Name) -join ',') 'terminal transaction remains opaque' Assert-CcodThrows { Invoke-CcodInstallModule { param($Tx) Open-CcodInstallPinnedDirectory -Transaction $Tx -ParentDirectory $Tx.RootDirectory -Leaf 'after-close' -CreateIfMissing } @($tx) | Out-Null } 'CCOD_INSTALL_TRANSACTION_CLOSED' } finally { Remove-CcodInstallFileFixture $fixture } } +Invoke-CcodTest 'returns opaque unforgeable capabilities and hides native mutation helpers' { + $fixture = New-CcodInstallFileFixture + try { + $tx = Open-CcodFixtureTransaction $fixture + $txProperties = @($tx.PSObject.Properties.Name) + Assert-CcodEqual 'RootDirectory' ($txProperties -join ',') 'transaction reveals only its root capability' + $rootProperties = @($tx.RootDirectory.PSObject.Properties.Name) + Assert-CcodEqual '' ($rootProperties -join ',') 'directory capability reveals no native or ownership state' + $transactionAssemblyExports = @([CcodInstallCapabilityMarker].Assembly.ExportedTypes | Select-Object -ExpandProperty Name | Sort-Object) + Assert-CcodEqual 'CcodInstallCapabilityMarker' ($transactionAssemblyExports -join ',') 'transaction assembly exports only a non-mutating marker type' + $dangerousPublicMethods = @([CcodInstallCapabilityMarker].Assembly.ExportedTypes | ForEach-Object { + $_.GetMethods([Reflection.BindingFlags]'Public,Static') | Where-Object { + $_.Name -match 'Open|Create|Rename|Delete|Write|Append|Promote|Remove' -or + @($_.GetParameters().ParameterType.FullName) -contains 'Microsoft.Win32.SafeHandles.SafeFileHandle' + } + }) + Assert-CcodEqual 0 $dangerousPublicMethods.Count 'transaction assembly exposes no public native mutation helper' + Assert-CcodEqual $null ('CcodInstallRuntime' -as [type]) 'internal runtime type is not directly callable through PowerShell type resolution' + + $forged = [pscustomobject]@{} + Assert-CcodThrows { + Open-CcodInstallPinnedDirectory -Transaction $forged -ParentDirectory $tx.RootDirectory -Leaf 'forged' -CreateIfMissing | Out-Null + } 'CCOD_INSTALL_TRANSACTION_INVALID' + + $otherFixture = New-CcodInstallFileFixture + try { + $other = Open-CcodFixtureTransaction $otherFixture + Assert-CcodThrows { + Open-CcodInstallPinnedDirectory -Transaction $tx -ParentDirectory $other.RootDirectory -Leaf 'cross-issuer' -CreateIfMissing | Out-Null + } 'CCOD_INSTALL_PIN_INVALID' + } finally { Remove-CcodInstallFileFixture $otherFixture } + Assert-CcodOutsideUnchanged $fixture 'opaque capability rejection' + } finally { Remove-CcodInstallFileFixture $fixture } +} + +Invoke-CcodTest 'recomputes the private seal and never trusts forged public fields' { + $fixture = New-CcodInstallFileFixture + try { + $source = New-CcodSourceFile $fixture + $tx = Open-CcodFixtureTransaction $fixture + $temporary = New-CcodInstallPinnedTemporaryLeaf -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf 'sealed.tmp' + Copy-CcodInstallSealedFile -Transaction $tx -SourcePath $source.Path -DestinationLeaf $temporary -ExpectedLength $source.Length -ExpectedSha256 $source.Sha256 | Out-Null + $forgedLeaf = [pscustomobject]@{ Sealed = $true; Length = $source.Length; Sha256 = $source.Sha256 } + Assert-CcodThrows { + Commit-CcodInstallPinnedPromotion -Transaction $tx -ParentDirectory $tx.RootDirectory -TemporaryLeaf $forgedLeaf -DestinationLeaf 'forged.bin' | Out-Null + } 'CCOD_INSTALL_PIN_INVALID' + Assert-CcodTrue (-not [IO.File]::Exists((Join-Path $fixture.Install 'forged.bin'))) 'forged public sealing fields cannot create a destination' + $independentWrite = $null + try { + $independentWrite = [IO.File]::Open((Join-Path $fixture.Install 'sealed.tmp'), [IO.FileMode]::Open, [IO.FileAccess]::Write, [IO.FileShare]::Read) + } catch [IO.IOException] { } + if ($null -ne $independentWrite) { + try { + $bytes = [Text.UTF8Encoding]::new($false).GetBytes('tampered') + $independentWrite.SetLength(0); $independentWrite.Write($bytes,0,$bytes.Length); $independentWrite.Flush($true) + } finally { $independentWrite.Dispose() } + Assert-CcodThrows { + Commit-CcodInstallPinnedPromotion -Transaction $tx -ParentDirectory $tx.RootDirectory -TemporaryLeaf $temporary -DestinationLeaf 'sealed.bin' | Out-Null + } 'CCOD_INSTALL_SEAL_MISMATCH' + } else { + Commit-CcodInstallPinnedPromotion -Transaction $tx -ParentDirectory $tx.RootDirectory -TemporaryLeaf $temporary -DestinationLeaf 'sealed.bin' | Out-Null + Close-CcodInstallFileTransaction -Transaction $tx -Disposition Ready | Out-Null + $fixture.Transaction = $null + Assert-CcodEqual $source.Sha256 (Get-CcodTestFileSha256 (Join-Path $fixture.Install 'sealed.bin')) 'blocked mutation preserves sealed promotion bytes' + } + Assert-CcodOutsideUnchanged $fixture 'seal recomputation' + } finally { Remove-CcodInstallFileFixture $fixture } +} + +Invoke-CcodTest 'promotion is no-replace when a destination appears before commit' { + $fixture = New-CcodInstallFileFixture + try { + $source = New-CcodSourceFile $fixture + $tx = Open-CcodFixtureTransaction $fixture + $temporary = New-CcodInstallPinnedTemporaryLeaf -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf 'new.tmp' + Copy-CcodInstallSealedFile -Transaction $tx -SourcePath $source.Path -DestinationLeaf $temporary -ExpectedLength $source.Length -ExpectedSha256 $source.Sha256 | Out-Null + $destination = Join-Path $fixture.Install 'existing.bin' + [IO.File]::WriteAllText($destination, 'unexpected-existing-object') + $existingSha = Get-CcodTestFileSha256 $destination + Assert-CcodThrows { + Commit-CcodInstallPinnedPromotion -Transaction $tx -ParentDirectory $tx.RootDirectory -TemporaryLeaf $temporary -DestinationLeaf 'existing.bin' | Out-Null + } 'CCOD_INSTALL_PROMOTION_DESTINATION_EXISTS' + Assert-CcodEqual $existingSha (Get-CcodTestFileSha256 $destination) 'no-replace leaves the existing object untouched' + Assert-CcodTrue ([IO.File]::Exists((Join-Path $fixture.Install 'new.tmp'))) 'no-replace failure retains the sealed temporary leaf' + Assert-CcodOutsideUnchanged $fixture 'no-replace promotion' + } finally { Remove-CcodInstallFileFixture $fixture } +} + +foreach ($directoryCase in @('root','child','owned')) { + Invoke-CcodTest "rejects $directoryCase directory alternate data streams" { + $fixture = New-CcodInstallFileFixture + try { + if ($directoryCase -ceq 'root') { + $adsPath = $fixture.Install + ':metadata' + $adsResult = Invoke-CcodAttemptAdsWrite $adsPath + Assert-CcodEqual 'created' $adsResult.Outcome 'root ADS fixture is established before pinning' + Assert-CcodThrows { Open-CcodFixtureTransaction $fixture | Out-Null } 'CCOD_INSTALL_ADS_LEAF' + } else { + $tx = Open-CcodFixtureTransaction $fixture + $directory = Open-CcodInstallPinnedDirectory -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf $directoryCase -CreateIfMissing + $adsPath = (Join-Path $fixture.Install $directoryCase) + ':metadata' + $adsResult = Invoke-CcodAttemptAdsWrite $adsPath + if ($adsResult.Outcome -ceq 'blocked') { + Assert-CcodTrue $true "$directoryCase directory pin blocks ADS creation" + } elseif ($directoryCase -ceq 'child') { + Assert-CcodThrows { + New-CcodInstallPinnedTemporaryLeaf -Transaction $tx -ParentDirectory $directory -Leaf 'blocked.tmp' | Out-Null + } 'CCOD_INSTALL_ADS_LEAF' + } else { + Assert-CcodThrows { + Remove-CcodInstallOwnedTree -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf 'owned' + } 'CCOD_INSTALL_ADS_LEAF' + Assert-CcodTrue ([IO.Directory]::Exists((Join-Path $fixture.Install 'owned'))) 'directory ADS cleanup failure retains the owned directory' + } + } + Assert-CcodOutsideUnchanged $fixture "$directoryCase directory ADS" + } finally { Remove-CcodInstallFileFixture $fixture } + } +} + +Invoke-CcodTest 'rolls back earlier delete dispositions when a later owned leaf cannot be deleted' { + $fixture = New-CcodInstallFileFixture + try { + $tx = Open-CcodFixtureTransaction $fixture + $candidate = Open-CcodInstallPinnedDirectory -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf 'candidate' -CreateIfMissing + $first = New-CcodInstallPinnedTemporaryLeaf -Transaction $tx -ParentDirectory $candidate -Leaf 'a.tmp' + $later = New-CcodInstallPinnedTemporaryLeaf -Transaction $tx -ParentDirectory $candidate -Leaf 'z.tmp' + [IO.File]::SetAttributes((Join-Path $fixture.Install 'candidate\z.tmp'), [IO.FileAttributes]::ReadOnly) + Assert-CcodThrows { + Remove-CcodInstallOwnedTree -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf 'candidate' + } 'CCOD_INSTALL_DELETE_FAILED' + Assert-CcodTrue ([IO.File]::Exists((Join-Path $fixture.Install 'candidate\a.tmp'))) 'later deletion failure restores the first leaf disposition' + Assert-CcodTrue ([IO.File]::Exists((Join-Path $fixture.Install 'candidate\z.tmp'))) 'later deletion failure retains the blocked leaf' + Assert-CcodOutsideUnchanged $fixture 'all-or-nothing delete rollback' + } finally { Remove-CcodInstallFileFixture $fixture } +} + +Invoke-CcodTest 'detects an unknown insertion between deletion planning and first release' { + $fixture = New-CcodInstallFileFixture + try { + $tx = Open-CcodFixtureTransaction $fixture + $candidate = Open-CcodInstallPinnedDirectory -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf 'candidate' -CreateIfMissing + $first = New-CcodInstallPinnedTemporaryLeaf -Transaction $tx -ParentDirectory $candidate -Leaf 'a-first.tmp' + for ($index = 0; $index -lt 128; $index++) { + New-CcodInstallPinnedTemporaryLeaf -Transaction $tx -ParentDirectory $candidate -Leaf ('b-{0:d3}.tmp' -f $index) | Out-Null + } + $watch = Join-Path $fixture.Install 'candidate\a-first.tmp' + $unknown = Join-Path $fixture.Install 'candidate\concurrent-unknown.tmp' + $attacker = [CcodInstallTransactionTestNative]::InsertUnknownWhenLeafBecomesUnavailable($watch, $unknown) + $failure = $null + try { Remove-CcodInstallOwnedTree -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf 'candidate' } + catch { $failure = $_ } + $attackOutcome = $attacker.GetAwaiter().GetResult() + Assert-CcodTrue ($attackOutcome -in @('inserted','blocked')) 'concurrent namespace attempt reaches the delete boundary' + if ($attackOutcome -ceq 'inserted') { + Assert-CcodTrue ($null -ne $failure -and $failure.FullyQualifiedErrorId -like 'CCOD_INSTALL_UNKNOWN_LEAF*') "inserted unknown leaf fails the final namespace proof actual=$($failure.FullyQualifiedErrorId) message=$($failure.Exception.Message)" + Assert-CcodTrue ([IO.File]::Exists($watch)) 'failed final namespace proof restores the first delete disposition' + Assert-CcodTrue ([IO.File]::Exists($unknown)) 'unproven concurrent object is retained for diagnosis' + Assert-CcodTrue ([IO.Directory]::Exists((Join-Path $fixture.Install 'candidate'))) 'concurrent insertion retains the complete candidate tree' + } else { + Assert-CcodTrue ($null -eq $failure) 'sharing lock may block the concurrent insertion and permit proven cleanup' + } + Assert-CcodOutsideUnchanged $fixture 'concurrent cleanup insertion' + } finally { Remove-CcodInstallFileFixture $fixture } +} + +Invoke-CcodTest 'closing releases every sharing lock and rejects close-versus-relative operations' { + $fixture = New-CcodInstallFileFixture + try { + $tx = Open-CcodFixtureTransaction $fixture + $child = Open-CcodInstallPinnedDirectory -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf 'release-me' -CreateIfMissing + Close-CcodInstallFileTransaction -Transaction $tx -Disposition Failed | Out-Null + $fixture.Transaction = $null + Assert-CcodThrows { + Open-CcodInstallPinnedDirectory -Transaction $tx -ParentDirectory $child -Leaf 'after-close' -CreateIfMissing | Out-Null + } 'CCOD_INSTALL_TRANSACTION_CLOSED' + $renamed = Join-Path $fixture.Install 'released' + [IO.Directory]::Move((Join-Path $fixture.Install 'release-me'), $renamed) + [IO.Directory]::Delete($renamed) + Assert-CcodTrue (-not [IO.Directory]::Exists($renamed)) 'closed transaction releases directory sharing locks' + } finally { Remove-CcodInstallFileFixture $fixture } +} + Write-Host 'Install file transaction self-test passed.' -ForegroundColor Green From 6c8a70a3c4f1b69e56a7af605885a8b20affc488 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Sat, 29 Aug 2026 10:06:26 +0800 Subject: [PATCH 067/187] fix: make owned tree cleanup all-or-nothing --- .../modules/InstallFileTransaction.psm1 | 27 +++--- .../InstallFileTransaction.SelfTest.ps1 | 85 +++++++++++++++++-- 2 files changed, 89 insertions(+), 23 deletions(-) diff --git a/src/persistence/modules/InstallFileTransaction.psm1 b/src/persistence/modules/InstallFileTransaction.psm1 index f625e50..ec6dfe4 100644 --- a/src/persistence/modules/InstallFileTransaction.psm1 +++ b/src/persistence/modules/InstallFileTransaction.psm1 @@ -49,6 +49,7 @@ internal sealed class CcodInstallRuntime : IDisposable [DllImport("kernel32.dll", SetLastError=true)] private static extern bool GetFileInformationByHandle(SafeFileHandle h,out FILE_INFO i); [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] private static extern uint GetFinalPathNameByHandleW(SafeFileHandle h,StringBuilder b,uint l,uint f); [DllImport("kernel32.dll", SetLastError=true)] private static extern bool GetFileInformationByHandleEx(SafeFileHandle h,int c,IntPtr i,uint s); + [DllImport("kernel32.dll", SetLastError=true)] private static extern bool SetFileInformationByHandle(SafeFileHandle h,int c,IntPtr i,uint s); [DllImport("ntdll.dll")] private static extern int NtCreateFile(out IntPtr h,uint a,ref OBJECT_ATTRIBUTES o,out IO_STATUS_BLOCK i,IntPtr z,uint fa,uint sh,uint d,uint op,IntPtr e,uint el); [DllImport("ntdll.dll")] private static extern int NtQueryDirectoryFile(SafeFileHandle h,IntPtr e,IntPtr a,IntPtr c,out IO_STATUS_BLOCK i,IntPtr f,uint l,int cl,bool one,IntPtr n,bool restart); [DllImport("ntdll.dll")] private static extern int NtSetInformationFile(SafeFileHandle h,out IO_STATUS_BLOCK i,IntPtr f,uint l,int cl); @@ -86,10 +87,12 @@ internal sealed class CcodInstallRuntime : IDisposable [MethodImpl(MethodImplOptions.Synchronized)] internal object OpenDirectory(object parentToken,string leaf,bool create) { Pin parent=Require(parentToken,true); ValidateCurrent(parent); string key=Key(parent,leaf); Pin known; if(names.TryGetValue(key,out known)){ValidateCurrent(known);return known.Token;} - OpenResult r=OpenRelative(parent.Native,leaf,LIST_DIRECTORY|ADD_FILE|ADD_SUBDIRECTORY|READ_ATTRIBUTES|DELETE|SYNC,SHARE_READ|SHARE_WRITE,create?OPEN_IF:OPEN,DIRECTORY|BACKUP_INTENT); + OpenResult r=null; + if(create){try{r=OpenRelative(parent.Native,leaf,LIST_DIRECTORY|ADD_FILE|ADD_SUBDIRECTORY|READ_ATTRIBUTES|DELETE|SYNC,SHARE_READ,CREATE,DIRECTORY|BACKUP_INTENT);}catch(Win32Exception x){if(x.NativeErrorCode!=80&&x.NativeErrorCode!=183)throw;r=OpenRelative(parent.Native,leaf,LIST_DIRECTORY|ADD_FILE|ADD_SUBDIRECTORY|READ_ATTRIBUTES|DELETE|SYNC,SHARE_READ|SHARE_WRITE,OPEN,DIRECTORY|BACKUP_INTENT);}} + else r=OpenRelative(parent.Native,leaf,LIST_DIRECTORY|ADD_FILE|ADD_SUBDIRECTORY|READ_ATTRIBUTES|DELETE|SYNC,SHARE_READ|SHARE_WRITE,OPEN,DIRECTORY|BACKUP_INTENT); try { FILE_INFO i=Info(r.Handle); if(!IsDirectory(i)||(i.FileAttributes&ATTR_REPARSE)!=0||!OnlyDefaultStream(r.Handle))throw new InvalidDataException("directory invalid"); string path=Path.Combine(parent.Path,leaf); if(!SamePath(FinalPath(r.Handle),path))throw new InvalidDataException("path changed"); - Pin p=new Pin(parent,leaf,path,true,r.Created,r.Handle,null,i); r.Handle=null;AddPin(key,p);return p.Token; } + Pin p=new Pin(parent,leaf,path,true,r.Created,r.Handle,null,i);r.Handle=null;AddPin(key,p);return p.Token; } finally { if(r.Handle!=null)r.Handle.Dispose(); } } [MethodImpl(MethodImplOptions.Synchronized)] internal object CreateTemporary(object parentToken,string leaf) @@ -113,6 +116,7 @@ internal sealed class CcodInstallRuntime : IDisposable if(temp.Stream.Length!=temp.SealedLength||Sha(temp.Stream)!=temp.SealedSha)throw new CryptographicException("seal mismatch"); SafeFileHandle existing=null;try{try{existing=OpenRelative(parent.Native,destination,READ_ATTRIBUTES|SYNC,SHARE_READ|SHARE_WRITE,OPEN,BACKUP_INTENT).Handle;}catch(Win32Exception x){if(x.NativeErrorCode!=2&&x.NativeErrorCode!=3)throw;}if(existing!=null)throw new IOException("destination exists");} finally{if(existing!=null)existing.Dispose();} + if(parent.Owned)return PublishSealedCopy(parent,temp,destination); int ec=Rename(temp.Native,parent.Native,destination);if(ec==80||ec==183)throw new IOException("destination exists");if(ec!=0)throw new Win32Exception(ec);names.Remove(Key(parent,temp.Leaf));temp.Leaf=destination;temp.Path=Path.Combine(parent.Path,destination);names.Add(Key(parent,destination),temp);ValidateCurrent(temp);return temp.Token; } [MethodImpl(MethodImplOptions.Synchronized)] internal object WriteJson(object parentToken,string leaf,byte[] bytes) @@ -127,12 +131,8 @@ internal sealed class CcodInstallRuntime : IDisposable { Pin parent=Require(parentToken,true);ValidateCurrent(parent);Pin target;string key=Key(parent,leaf);if(!names.TryGetValue(key,out target)||!target.Owned||!target.Directory)throw new InvalidDataException("unknown target"); List plan=new List();BuildPlan(target,plan);RevalidatePlan(target,plan);List marked=new List(); - string originalLeaf=target.Leaf,originalPath=target.Path,tombstone=".__ccod_delete_"+Guid.NewGuid().ToString("N");bool quarantined=false,released=false; - try{ReleaseDescendants(plan);released=true;int rec=Rename(target.Native,parent.Native,tombstone);if(rec!=0)throw new Win32Exception(rec);MoveDirectoryPin(target,tombstone,Path.Combine(parent.Path,tombstone));quarantined=true;released=false;ReopenKnownTree(target);plan.Clear();BuildPlan(target,plan);RevalidatePlan(target,plan);foreach(Pin p in plan){if(p.Directory)continue;ValidateCurrent(p);int ec=SetDelete(p.Native,true);if(ec!=0)throw new Win32Exception(ec);marked.Add(p);ValidateMarkedFile(p);}RevalidateMarkedNamespace(target,new HashSet(marked));} - catch{for(int i=marked.Count-1;i>=0;i--)SetDelete(marked[i].Native,false);if(!released){ReleaseDescendants(plan);released=true;}if(quarantined){int back=Rename(target.Native,parent.Native,originalLeaf);if(back!=0)throw new Win32Exception(back);MoveDirectoryPin(target,originalLeaf,originalPath);quarantined=false;}ReopenKnownTree(target);throw;} - foreach(Pin p in marked)RemovePin(p); - foreach(Pin p in plan){if(!p.Directory)continue;ValidateCurrent(p);int ec=SetDelete(p.Native,true);if(ec!=0)throw new Win32Exception(ec);RemovePin(p);} - ValidateCurrent(target);int tec=SetDelete(target.Native,true);if(tec!=0)throw new Win32Exception(tec);RemovePin(target); + try{foreach(Pin p in plan){ValidateCurrent(p);int ec=SetDeleteOnClose(p.Native,true,p.Directory);if(ec!=0)throw new Win32Exception(ec);marked.Add(p);}ValidateCurrent(target);int tec=SetDeleteOnClose(target.Native,true,true);if(tec!=0)throw new Win32Exception(tec);marked.Add(target);RevalidatePlan(target,plan);foreach(Pin p in plan)RemovePin(p);RemovePin(target);} + catch{for(int i=marked.Count-1;i>=0;i--)if(!marked[i].Closed)SetDeleteOnClose(marked[i].Native,false,marked[i].Directory);throw;} } [MethodImpl(MethodImplOptions.Synchronized)] internal void Close(){if(disposed)return;disposed=true;List all=new List(pins.Values);for(int i=all.Count-1;i>=0;i--)all[i].Dispose();pins.Clear();names.Clear();} public void Dispose(){Close();} @@ -140,12 +140,8 @@ internal sealed class CcodInstallRuntime : IDisposable private void BuildPlan(Pin dir,List plan){ValidateCurrent(dir);foreach(string n in Enumerate(dir.Native)){Pin p;string k=Key(dir,n);if(!names.TryGetValue(k,out p)||!p.Owned){ProbeUnknown(dir,n);throw new InvalidDataException("unknown leaf");}ValidateCurrent(p);if(p.Directory)BuildPlan(p,plan);plan.Add(p);}} private static void ProbeUnknown(Pin dir,string name){OpenResult r=OpenRelative(dir.Native,name,READ_ATTRIBUTES|SYNC,SHARE_READ|SHARE_WRITE,OPEN,BACKUP_INTENT);try{FILE_INFO i=Info(r.Handle);if((i.FileAttributes&ATTR_REPARSE)!=0)throw new InvalidDataException("reparse leaf");if(!OnlyDefaultStream(r.Handle))throw new InvalidDataException("alternate stream");if(!IsDirectory(i)&&i.NumberOfLinks!=1)throw new InvalidDataException("multi-link");}finally{r.Handle.Dispose();}} private void RevalidatePlan(Pin target,List plan){List second=new List();BuildPlan(target,second);if(second.Count!=plan.Count)throw new InvalidDataException("namespace changed");for(int i=0;i marked){ValidateCurrent(dir);foreach(string n in Enumerate(dir.Native)){Pin p;if(!names.TryGetValue(Key(dir,n),out p)||!p.Owned)throw new InvalidDataException("namespace changed");if(p.Directory)RevalidateMarkedNamespace(p,marked);else if(marked.Contains(p))ValidateMarkedFile(p);else throw new InvalidDataException("namespace changed");}} - private void MoveDirectoryPin(Pin dir,string newLeaf,string newPath){string oldKey=Key(dir.Parent,dir.Leaf),oldPath=dir.Path;names.Remove(oldKey);dir.Leaf=newLeaf;dir.Path=newPath;names.Add(Key(dir.Parent,newLeaf),dir);string prefix=oldPath.TrimEnd('\\')+"\\";foreach(Pin p in pins.Values)if(!Object.ReferenceEquals(p,dir)&&p.Path.StartsWith(prefix,StringComparison.OrdinalIgnoreCase))p.Path=newPath+"\\"+p.Path.Substring(prefix.Length);} - private static void ReleaseDescendants(List plan){foreach(Pin p in plan){if(p.Stream!=null){p.Stream.Dispose();p.Stream=null;}else if(p.Handle!=null){p.Handle.Dispose();p.Handle=null;}}} - private void ReopenKnownTree(Pin dir){List children=new List();foreach(Pin p in pins.Values)if(Object.ReferenceEquals(p.Parent,dir)&&!p.Closed)children.Add(p);foreach(Pin p in children){if(p.Directory){OpenResult r=OpenRelative(dir.Native,p.Leaf,LIST_DIRECTORY|ADD_FILE|ADD_SUBDIRECTORY|READ_ATTRIBUTES|DELETE|SYNC,SHARE_READ|SHARE_WRITE,OPEN,DIRECTORY|BACKUP_INTENT);p.Handle=r.Handle;ValidateCurrent(p);ReopenKnownTree(p);}else{OpenResult r=OpenRelative(dir.Native,p.Leaf,READ|WRITE|DELETE|SYNC,SHARE_READ,OPEN,NON_DIRECTORY|WRITE_THROUGH);p.Stream=new FileStream(r.Handle,FileAccess.ReadWrite,65536,false);r.Handle=null;ValidateCurrent(p);}}} private void RemovePin(Pin p){string k=Key(p.Parent,p.Leaf);p.Dispose();pins.Remove(p.Token);names.Remove(k);} + private object PublishSealedCopy(Pin parent,Pin temp,string destination){OpenResult r=null;FileStream stream=null;try{try{r=OpenRelative(parent.Native,destination,READ|WRITE|DELETE|SYNC,0,CREATE,NON_DIRECTORY|WRITE_THROUGH);}catch(Win32Exception x){if(x.NativeErrorCode==80||x.NativeErrorCode==183)throw new IOException("destination exists");throw;}stream=new FileStream(r.Handle,FileAccess.ReadWrite,65536,false);r.Handle=null;temp.Stream.Position=0;temp.Stream.CopyTo(stream);stream.Flush(true);if(stream.Length!=temp.SealedLength||Sha(stream)!=temp.SealedSha)throw new CryptographicException("seal mismatch");FILE_INFO i=Info(stream.SafeFileHandle);ValidatePlain(i,stream.SafeFileHandle);Pin published=new Pin(parent,destination,Path.Combine(parent.Path,destination),false,true,null,stream,i);published.Sealed=true;published.SealedLength=temp.SealedLength;published.SealedSha=temp.SealedSha;stream=null;int dec=SetDelete(temp.Native,true);if(dec!=0){SetDelete(published.Native,true);published.Dispose();throw new Win32Exception(dec);}AddPin(Key(parent,destination),published);RemovePin(temp);return published.Token;}catch{if(stream!=null){SetDelete(stream.SafeFileHandle,true);stream.Dispose();}if(r!=null&&r.Handle!=null){SetDelete(r.Handle,true);r.Handle.Dispose();}throw;}} private Pin Require(object token,bool directory){if(disposed)throw new ObjectDisposedException("transaction");Pin p;if(token==null||!pins.TryGetValue(token,out p)||p.Closed||p.Directory!=directory)throw new InvalidDataException("invalid capability");return p;} private void AddPin(string key,Pin p){pins.Add(p.Token,p);names.Add(key,p);} private static string Key(Pin p,string leaf){return p.Index.ToString("x16")+"|"+leaf.ToLowerInvariant();} @@ -165,6 +161,7 @@ internal sealed class CcodInstallRuntime : IDisposable private static int Rename(SafeFileHandle source,SafeFileHandle parent,string destination) {byte[] n=Encoding.Unicode.GetBytes(destination);int ro=IntPtr.Size,lo=ro+IntPtr.Size,no=lo+4,size=no+n.Length+2;IntPtr b=Marshal.AllocHGlobal(size);bool pa=false,sa=false;try{source.DangerousAddRef(ref sa);parent.DangerousAddRef(ref pa);for(int i=0;i=0?0:(int)RtlNtStatusToDosError(st);}finally{if(pa)parent.DangerousRelease();if(sa)source.DangerousRelease();Marshal.FreeHGlobal(b);}} private static int SetDelete(SafeFileHandle h,bool delete){IntPtr b=Marshal.AllocHGlobal(1);try{Marshal.WriteByte(b,delete?(byte)1:(byte)0);IO_STATUS_BLOCK io;int st=NtSetInformationFile(h,out io,b,1,FileDispositionInformation);return st>=0?0:(int)RtlNtStatusToDosError(st);}finally{Marshal.FreeHGlobal(b);}} + private static int SetDeleteOnClose(SafeFileHandle h,bool delete,bool directory){IntPtr b=Marshal.AllocHGlobal(4);try{Marshal.WriteInt32(b,delete?1:0);return SetFileInformationByHandle(h,21,b,4)?0:Marshal.GetLastWin32Error();}finally{Marshal.FreeHGlobal(b);}} } '@ } @@ -177,7 +174,7 @@ function Assert-CcodLeaf([string]$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 Get-CcodTransactionState($Transaction) { $state=$null;if($null -eq $Transaction -or -not $script:CcodTransactions.TryGetValue($Transaction,[ref]$state)){Throw-CcodInstallFileError 'CCOD_INSTALL_TRANSACTION_INVALID' 'Invalid install transaction capability' $null};if($state.Closed){Throw-CcodInstallFileError 'CCOD_INSTALL_TRANSACTION_CLOSED' 'Install transaction is closed' $null};return $state } -function Get-CcodPinToken($Transaction,$Pin,[string]$Kind) { $state=Get-CcodTransactionState $Transaction;$record=$null;if($null -eq $Pin -or -not $script:CcodPins.TryGetValue($Pin,[ref]$record) -or -not [object]::ReferenceEquals($record.Transaction,$Transaction) -or $record.Kind -cne $Kind){Throw-CcodInstallFileError 'CCOD_INSTALL_PIN_INVALID' 'Invalid install pin capability' $null};return [pscustomobject]@{State=$state;Token=$record.Token} } +function Get-CcodPinToken($Transaction,$Pin,[string]$Kind) { $state=Get-CcodTransactionState $Transaction;$record=$null;if($null -eq $Pin -or -not $script:CcodPins.TryGetValue($Pin,[ref]$record) -or -not [object]::ReferenceEquals($record.Transaction,$Transaction) -or $record.Kind -cne $Kind){Throw-CcodInstallFileError 'CCOD_INSTALL_PIN_INVALID' 'Invalid install pin capability' $null};return [pscustomobject]@{State=$state;Token=$record.Token;Record=$record} } function New-CcodCapability { New-Object psobject } function Add-CcodPinCapability($Transaction,$Token,[string]$Kind) {$cap=New-CcodCapability;$script:CcodPins.Add($cap,[pscustomobject]@{Transaction=$Transaction;Token=$Token;Kind=$Kind});return $cap} function Convert-CcodRuntimeError($Action,[string]$DefaultId) { @@ -202,7 +199,7 @@ function Open-CcodInstallFileTransaction { param([Parameter(Mandatory)][string]$ function Open-CcodInstallPinnedDirectory { param([Parameter(Mandatory)]$Transaction,[Parameter(Mandatory)]$ParentDirectory,[Parameter(Mandatory)][string]$Leaf,[switch]$CreateIfMissing) Assert-CcodLeaf $Leaf;$p=Get-CcodPinToken $Transaction $ParentDirectory Directory;$token=Convert-CcodRuntimeError {Invoke-CcodRuntimeMethod $p.State.Runtime OpenDirectory @($p.Token,$Leaf,[bool]$CreateIfMissing)} 'CCOD_INSTALL_REPARSE_LEAF';return Add-CcodPinCapability $Transaction $token Directory } function New-CcodInstallPinnedTemporaryLeaf { param([Parameter(Mandatory)]$Transaction,[Parameter(Mandatory)]$ParentDirectory,[Parameter(Mandatory)][string]$Leaf) Assert-CcodLeaf $Leaf;$p=Get-CcodPinToken $Transaction $ParentDirectory Directory;$token=Convert-CcodRuntimeError {Invoke-CcodRuntimeMethod $p.State.Runtime CreateTemporary @($p.Token,$Leaf)} 'CCOD_INSTALL_TEMPORARY_EXISTS';return Add-CcodPinCapability $Transaction $token File } function Copy-CcodInstallSealedFile { param([Parameter(Mandatory)]$Transaction,[Parameter(Mandatory)][string]$SourcePath,[Parameter(Mandatory)]$DestinationLeaf,[Parameter(Mandatory)][int64]$ExpectedLength,[Parameter(Mandatory)][string]$ExpectedSha256) if(-not [IO.Path]::IsPathRooted($SourcePath)-or$ExpectedSha256-cnotmatch'^[0-9a-f]{64}$'){Throw-CcodInstallFileError 'CCOD_INSTALL_SOURCE_INVALID' 'Invalid source contract' $SourcePath};$p=Get-CcodPinToken $Transaction $DestinationLeaf File;Convert-CcodRuntimeError {Invoke-CcodRuntimeMethod $p.State.Runtime Copy @($p.Token,[IO.Path]::GetFullPath($SourcePath),$ExpectedLength,$ExpectedSha256)|Out-Null} 'CCOD_INSTALL_SOURCE_MISMATCH';[pscustomobject]@{Length=$ExpectedLength;Sha256=$ExpectedSha256} } -function Commit-CcodInstallPinnedPromotion { param([Parameter(Mandatory)]$Transaction,[Parameter(Mandatory)]$ParentDirectory,[Parameter(Mandatory)]$TemporaryLeaf,[Parameter(Mandatory)][string]$DestinationLeaf) Assert-CcodLeaf $DestinationLeaf;$p=Get-CcodPinToken $Transaction $ParentDirectory Directory;$t=Get-CcodPinToken $Transaction $TemporaryLeaf File;Convert-CcodRuntimeError {Invoke-CcodRuntimeMethod $p.State.Runtime Promote @($p.Token,$t.Token,$DestinationLeaf)|Out-Null} 'CCOD_INSTALL_PROMOTION_FAILED';return $TemporaryLeaf } +function Commit-CcodInstallPinnedPromotion { param([Parameter(Mandatory)]$Transaction,[Parameter(Mandatory)]$ParentDirectory,[Parameter(Mandatory)]$TemporaryLeaf,[Parameter(Mandatory)][string]$DestinationLeaf) Assert-CcodLeaf $DestinationLeaf;$p=Get-CcodPinToken $Transaction $ParentDirectory Directory;$t=Get-CcodPinToken $Transaction $TemporaryLeaf File;$newToken=Convert-CcodRuntimeError {Invoke-CcodRuntimeMethod $p.State.Runtime Promote @($p.Token,$t.Token,$DestinationLeaf)} 'CCOD_INSTALL_PROMOTION_FAILED';$t.Record.Token=$newToken;return $TemporaryLeaf } function Write-CcodInstallPinnedJson { param([Parameter(Mandatory)]$Transaction,[Parameter(Mandatory)]$ParentDirectory,[Parameter(Mandatory)][string]$Leaf,[Parameter(Mandatory)]$Value,[switch]$Compress) Assert-CcodLeaf $Leaf;$p=Get-CcodPinToken $Transaction $ParentDirectory Directory;$bytes=[Text.UTF8Encoding]::new($false).GetBytes(($Value|ConvertTo-Json -Depth 32 -Compress:$Compress)+"`n");$token=Convert-CcodRuntimeError {Invoke-CcodRuntimeMethod $p.State.Runtime WriteJson @($p.Token,$Leaf,$bytes)} 'CCOD_INSTALL_PROMOTION_FAILED';return Add-CcodPinCapability $Transaction $token File } function Test-CcodSanitized($Value,[int]$Depth=0) { if ($Depth -gt 8) { return $false } diff --git a/tests/persistence/InstallFileTransaction.SelfTest.ps1 b/tests/persistence/InstallFileTransaction.SelfTest.ps1 index 32cff86..6da0951 100644 --- a/tests/persistence/InstallFileTransaction.SelfTest.ps1 +++ b/tests/persistence/InstallFileTransaction.SelfTest.ps1 @@ -55,6 +55,16 @@ public static class CcodInstallTransactionTestNative return handle; } + public static int TryOpenDirectoryForChildWrite(string path) + { + using (SafeFileHandle handle = CreateFileW(path, 0x00000002 | 0x00000004 | FILE_READ_ATTRIBUTES, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, IntPtr.Zero, OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, IntPtr.Zero)) + { + return handle.IsInvalid ? Marshal.GetLastWin32Error() : 0; + } + } + public static SafeFileHandle OpenFileAllowDeleteShare(string path) { SafeFileHandle handle = CreateFileW(path, GENERIC_READ | GENERIC_WRITE | DELETE | FILE_READ_ATTRIBUTES, @@ -81,6 +91,16 @@ public static class CcodInstallTransactionTestNative return "timeout"; }); } + + public static Task InsertUnknownAfterDelay(string unknownLeaf, int delayMilliseconds) + { + return Task.Run(() => { + Thread.Sleep(delayMilliseconds); + try { File.WriteAllText(unknownLeaf, "concurrent-unknown"); return "inserted"; } + catch (IOException) { return "blocked"; } + catch (UnauthorizedAccessException) { return "blocked"; } + }); + } } '@ } @@ -364,7 +384,7 @@ foreach ($case in @('unknown','reparse','multilink')) { } } -Invoke-CcodTest 'deletes a fully proven transaction-owned tree from leaves upward' { +Invoke-CcodTest 'fails closed when a nonempty owned tree cannot establish every delete disposition' { $fixture = New-CcodInstallFileFixture try { $source = New-CcodSourceFile $fixture @@ -376,12 +396,26 @@ Invoke-CcodTest 'deletes a fully proven transaction-owned tree from leaves upwar Copy-CcodInstallSealedFile -Transaction $Tx -SourcePath $Source -DestinationLeaf $Leaf -ExpectedLength $Length -ExpectedSha256 $Sha | Out-Null Commit-CcodInstallPinnedPromotion -Transaction $Tx -ParentDirectory $Parent -TemporaryLeaf $Leaf -DestinationLeaf 'payload.bin' | Out-Null } @($tx,$source.Path,$candidate,$temporary,$source.Length,$source.Sha256) - Invoke-CcodInstallModule { param($Tx) Remove-CcodInstallOwnedTree -Transaction $Tx -ParentDirectory $Tx.RootDirectory -Leaf 'candidate' } @($tx) | Out-Null - Assert-CcodTrue (-not [IO.Directory]::Exists((Join-Path $fixture.Install 'candidate'))) 'proven owned tree is removed' + Assert-CcodThrows { + Invoke-CcodInstallModule { param($Tx) Remove-CcodInstallOwnedTree -Transaction $Tx -ParentDirectory $Tx.RootDirectory -Leaf 'candidate' } @($tx) | Out-Null + } 'CCOD_INSTALL_DELETE_FAILED' + Assert-CcodTrue ([IO.Directory]::Exists((Join-Path $fixture.Install 'candidate'))) 'failed root disposition retains the owned directory' + Assert-CcodTrue ([IO.File]::Exists((Join-Path $fixture.Install 'candidate\payload.bin'))) 'failed root disposition clears the prior file disposition' Assert-CcodOutsideUnchanged $fixture 'owned tree removal' } finally { Remove-CcodInstallFileFixture $fixture } } +Invoke-CcodTest 'deletes an empty fully-owned candidate after establishing its root disposition' { + $fixture = New-CcodInstallFileFixture + try { + $tx = Open-CcodFixtureTransaction $fixture + $candidate = Open-CcodInstallPinnedDirectory -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf 'empty-candidate' -CreateIfMissing + Remove-CcodInstallOwnedTree -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf 'empty-candidate' + Assert-CcodTrue (-not [IO.Directory]::Exists((Join-Path $fixture.Install 'empty-candidate'))) 'empty owned candidate is deleted only after its root disposition is established' + Assert-CcodOutsideUnchanged $fixture 'empty owned tree removal' + } finally { Remove-CcodInstallFileFixture $fixture } +} + foreach ($case in @('json','log')) { Invoke-CcodTest "rejects a pinned $case write after parent pathname replacement" { $fixture = New-CcodInstallFileFixture @@ -591,6 +625,21 @@ Invoke-CcodTest 'rolls back earlier delete dispositions when a later owned leaf } finally { Remove-CcodInstallFileFixture $fixture } } +Invoke-CcodTest 'clears a child-directory disposition when the later root mark fails' { + $fixture = New-CcodInstallFileFixture + try { + $tx = Open-CcodFixtureTransaction $fixture + $candidate = Open-CcodInstallPinnedDirectory -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf 'candidate' -CreateIfMissing + $child = Open-CcodInstallPinnedDirectory -Transaction $tx -ParentDirectory $candidate -Leaf 'child' -CreateIfMissing + Assert-CcodThrows { + Remove-CcodInstallOwnedTree -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf 'candidate' + } 'CCOD_INSTALL_DELETE_FAILED' + Assert-CcodTrue ([IO.Directory]::Exists((Join-Path $fixture.Install 'candidate')) ) 'later root-mark failure retains the candidate root' + Assert-CcodTrue ([IO.Directory]::Exists((Join-Path $fixture.Install 'candidate\child')) ) 'later root-mark failure clears the child-directory disposition' + Assert-CcodOutsideUnchanged $fixture 'directory disposition rollback' + } finally { Remove-CcodInstallFileFixture $fixture } +} + Invoke-CcodTest 'detects an unknown insertion between deletion planning and first release' { $fixture = New-CcodInstallFileFixture try { @@ -602,24 +651,44 @@ Invoke-CcodTest 'detects an unknown insertion between deletion planning and firs } $watch = Join-Path $fixture.Install 'candidate\a-first.tmp' $unknown = Join-Path $fixture.Install 'candidate\concurrent-unknown.tmp' - $attacker = [CcodInstallTransactionTestNative]::InsertUnknownWhenLeafBecomesUnavailable($watch, $unknown) + $attacker = [CcodInstallTransactionTestNative]::InsertUnknownAfterDelay($unknown, 1) $failure = $null try { Remove-CcodInstallOwnedTree -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf 'candidate' } catch { $failure = $_ } $attackOutcome = $attacker.GetAwaiter().GetResult() - Assert-CcodTrue ($attackOutcome -in @('inserted','blocked')) 'concurrent namespace attempt reaches the delete boundary' + Assert-CcodTrue ($attackOutcome -in @('inserted','blocked')) 'concurrent namespace attempt reaches the cleanup boundary' if ($attackOutcome -ceq 'inserted') { - Assert-CcodTrue ($null -ne $failure -and $failure.FullyQualifiedErrorId -like 'CCOD_INSTALL_UNKNOWN_LEAF*') "inserted unknown leaf fails the final namespace proof actual=$($failure.FullyQualifiedErrorId) message=$($failure.Exception.Message)" - Assert-CcodTrue ([IO.File]::Exists($watch)) 'failed final namespace proof restores the first delete disposition' + Assert-CcodTrue ($null -ne $failure -and $failure.FullyQualifiedErrorId -match '^CCOD_INSTALL_(?:UNKNOWN_LEAF|DELETE_FAILED)') "inserted unknown leaf fails cleanup before release actual=$($failure.FullyQualifiedErrorId) message=$($failure.Exception.Message)" + Assert-CcodTrue ([IO.File]::Exists($watch)) 'failed concurrent cleanup retains the first candidate leaf' Assert-CcodTrue ([IO.File]::Exists($unknown)) 'unproven concurrent object is retained for diagnosis' Assert-CcodTrue ([IO.Directory]::Exists((Join-Path $fixture.Install 'candidate'))) 'concurrent insertion retains the complete candidate tree' } else { - Assert-CcodTrue ($null -eq $failure) 'sharing lock may block the concurrent insertion and permit proven cleanup' + Assert-CcodTrue ($null -ne $failure -and $failure.FullyQualifiedErrorId -like 'CCOD_INSTALL_DELETE_FAILED*') 'blocked insertion still fails closed when the nonempty root disposition is unsupported' } Assert-CcodOutsideUnchanged $fixture 'concurrent cleanup insertion' } finally { Remove-CcodInstallFileFixture $fixture } } +Invoke-CcodTest 'owned directory denies external child creation and hard-link insertion for its lifetime' { + $fixture = New-CcodInstallFileFixture + try { + $outsideSource = Join-Path $fixture.Outside 'outside-source.bin' + [IO.File]::WriteAllText($outsideSource, 'outside-source') + $tx = Open-CcodFixtureTransaction $fixture + $candidate = Open-CcodInstallPinnedDirectory -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf 'candidate' -CreateIfMissing + $childPath = Join-Path $fixture.Install 'candidate\attacker-child.bin' + $writeHandleError = [CcodInstallTransactionTestNative]::TryOpenDirectoryForChildWrite((Join-Path $fixture.Install 'candidate')) + Assert-CcodTrue ($writeHandleError -ne 0) 'owned directory share lock blocks an external child-write handle' + + $hardLinkPath = Join-Path $fixture.Install 'candidate\attacker-hardlink.bin' + $hardLinkError = [CcodInstallTransactionTestNative]::CreateHardLink($hardLinkPath, $outsideSource) + Assert-CcodTrue ($hardLinkError -ne 0) 'owned directory share lock blocks external hard-link insertion' + Assert-CcodTrue (-not [IO.File]::Exists($childPath)) 'blocked child path remains absent' + Assert-CcodTrue (-not [IO.File]::Exists($hardLinkPath)) 'blocked hard-link path remains absent' + Assert-CcodOutsideUnchanged $fixture 'owned directory namespace lock' + } finally { Remove-CcodInstallFileFixture $fixture } +} + Invoke-CcodTest 'closing releases every sharing lock and rejects close-versus-relative operations' { $fixture = New-CcodInstallFileFixture try { From 713fb0b1611e594cc9cdc1106593770310777494 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Sat, 29 Aug 2026 10:19:00 +0800 Subject: [PATCH 068/187] docs: retire nonempty transaction trees safely --- ...-29-v2522-transactional-install-release.md | 20 +++++++++++-------- ...22-transactional-install-release-design.md | 10 ++++++---- 2 files changed, 18 insertions(+), 12 deletions(-) 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 index afd8d7c..55941c7 100644 --- a/docs/superpowers/plans/2026-08-29-v2522-transactional-install-release.md +++ b/docs/superpowers/plans/2026-08-29-v2522-transactional-install-release.md @@ -96,9 +96,11 @@ Assert-CcodEqual 'blocked' $result.Outcome 'pinned missing directory cannot be r 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 -delete tree 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. +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 @@ -122,9 +124,11 @@ 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. Enumerate and -delete only transaction-owned objects from leaves upward; any unexpected -identity stops deletion and leaves the candidate in place. +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. @@ -191,7 +195,7 @@ Assert-CcodEqual $oldPointerJson (Get-Content $active -Raw) 'failed promotion ne 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 cleanup after an unexpected leaf appears, and a legacy unknown +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 @@ -213,7 +217,7 @@ staging/promotion or early stable-shell copy is not transaction-bound. 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 cleanup, and failed-candidate cleanup with +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 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 index 680a1b5..cd9f519 100644 --- 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 @@ -99,8 +99,8 @@ Its required operations are: | 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. | -| Enumerate/delete owned tree | Walks by pinned handles, rejects reparse points, alternate streams, and multi-link leaves, then removes only transaction-owned entries from leaves upward. | -| Close transaction | Releases handles only after a terminal success/failure decision; failed cleanup preserves its owned candidate for later diagnosis rather than performing a pathname-recursive delete. | +| 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 @@ -147,8 +147,10 @@ Prepared - 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. Cleanup is limited to - transaction-owned objects that the module can prove safe. +- 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 From 0c2cf64c30e200ed70d35b7dc3ce69d370ffee59 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Sat, 29 Aug 2026 10:36:29 +0800 Subject: [PATCH 069/187] fix: retire owned install trees atomically --- .../modules/InstallFileTransaction.psm1 | 41 ++-- .../InstallFileTransaction.SelfTest.ps1 | 193 ++++++++++++++---- 2 files changed, 187 insertions(+), 47 deletions(-) diff --git a/src/persistence/modules/InstallFileTransaction.psm1 b/src/persistence/modules/InstallFileTransaction.psm1 index ec6dfe4..3ca21a6 100644 --- a/src/persistence/modules/InstallFileTransaction.psm1 +++ b/src/persistence/modules/InstallFileTransaction.psm1 @@ -18,6 +18,8 @@ using System.IO; using System.Runtime.InteropServices; using System.Runtime.CompilerServices; using System.Security.Cryptography; +using System.Security.AccessControl; +using System.Security.Principal; using System.Text; using Microsoft.Win32.SafeHandles; @@ -25,7 +27,7 @@ public sealed class CcodInstallCapabilityMarker { private CcodInstallCapabilityM internal sealed class CcodInstallRuntime : IDisposable { - private const uint READ = 0x80000000, WRITE = 0x40000000, DELETE = 0x00010000, SYNC = 0x00100000; + private const uint READ = 0x80000000, WRITE = 0x40000000, DELETE = 0x00010000, READ_CONTROL = 0x00020000, WRITE_DAC = 0x00040000, SYNC = 0x00100000; private const uint READ_ATTRIBUTES = 0x80, 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, OPEN_IF = 3; @@ -34,6 +36,7 @@ internal sealed class CcodInstallRuntime : IDisposable private const uint OPEN_EXISTING = 3, OBJ_CASE_INSENSITIVE = 0x40, ATTR_DIRECTORY = 0x10, ATTR_REPARSE = 0x400; private const int FileRenameInformation = 10, FileDispositionInformation = 13, FileStreamInfo = 7, FileDirectoryInformation = 1; private const int STATUS_NO_MORE_FILES = unchecked((int)0x80000006); + private const uint DACL_SECURITY_INFORMATION = 0x00000004, PROTECTED_DACL_SECURITY_INFORMATION = 0x80000000, UNPROTECTED_DACL_SECURITY_INFORMATION = 0x20000000; [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; } @@ -54,16 +57,18 @@ internal sealed class CcodInstallRuntime : IDisposable [DllImport("ntdll.dll")] private static extern int NtQueryDirectoryFile(SafeFileHandle h,IntPtr e,IntPtr a,IntPtr c,out IO_STATUS_BLOCK i,IntPtr f,uint l,int cl,bool one,IntPtr n,bool restart); [DllImport("ntdll.dll")] private static extern int NtSetInformationFile(SafeFileHandle h,out IO_STATUS_BLOCK i,IntPtr f,uint l,int cl); [DllImport("ntdll.dll")] private static extern uint RtlNtStatusToDosError(int s); + [DllImport("advapi32.dll", SetLastError=true)] private static extern bool GetKernelObjectSecurity(SafeFileHandle h,uint i,byte[] sd,uint length,out uint needed); + [DllImport("advapi32.dll", SetLastError=true)] private static extern bool SetKernelObjectSecurity(SafeFileHandle h,uint i,byte[] sd); 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 bool Directory, Owned; internal SafeFileHandle Handle; internal FileStream Stream; internal byte[] OriginalSecurity; internal bool RestoreSecurity; internal readonly uint Volume; internal readonly ulong Index; internal bool Closed, Sealed; internal long SealedLength; internal string SealedSha; internal Pin(Pin parent,string leaf,string path,bool dir,bool owned,SafeFileHandle handle,FileStream stream,FILE_INFO info) { Parent=parent;Leaf=leaf;Path=path;Directory=dir;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; Closed=true; if(Stream!=null)Stream.Dispose(); else if(Handle!=null)Handle.Dispose(); } + public void Dispose() { if(Closed)return; Closed=true; if(RestoreSecurity&&Handle!=null)RestoreDirectorySecurity(Handle,OriginalSecurity);if(Stream!=null)Stream.Dispose(); else if(Handle!=null)Handle.Dispose(); } } private readonly string rootPath; private readonly Pin root; @@ -88,7 +93,7 @@ internal sealed class CcodInstallRuntime : IDisposable { Pin parent=Require(parentToken,true); ValidateCurrent(parent); string key=Key(parent,leaf); Pin known; if(names.TryGetValue(key,out known)){ValidateCurrent(known);return known.Token;} OpenResult r=null; - if(create){try{r=OpenRelative(parent.Native,leaf,LIST_DIRECTORY|ADD_FILE|ADD_SUBDIRECTORY|READ_ATTRIBUTES|DELETE|SYNC,SHARE_READ,CREATE,DIRECTORY|BACKUP_INTENT);}catch(Win32Exception x){if(x.NativeErrorCode!=80&&x.NativeErrorCode!=183)throw;r=OpenRelative(parent.Native,leaf,LIST_DIRECTORY|ADD_FILE|ADD_SUBDIRECTORY|READ_ATTRIBUTES|DELETE|SYNC,SHARE_READ|SHARE_WRITE,OPEN,DIRECTORY|BACKUP_INTENT);}} + if(create){try{r=OpenRelative(parent.Native,leaf,LIST_DIRECTORY|ADD_FILE|ADD_SUBDIRECTORY|READ_ATTRIBUTES|DELETE|READ_CONTROL|WRITE_DAC|SYNC,SHARE_READ,CREATE,DIRECTORY|BACKUP_INTENT);}catch(Win32Exception x){if(x.NativeErrorCode!=80&&x.NativeErrorCode!=183)throw;r=OpenRelative(parent.Native,leaf,LIST_DIRECTORY|ADD_FILE|ADD_SUBDIRECTORY|READ_ATTRIBUTES|DELETE|SYNC,SHARE_READ|SHARE_WRITE,OPEN,DIRECTORY|BACKUP_INTENT);}} else r=OpenRelative(parent.Native,leaf,LIST_DIRECTORY|ADD_FILE|ADD_SUBDIRECTORY|READ_ATTRIBUTES|DELETE|SYNC,SHARE_READ|SHARE_WRITE,OPEN,DIRECTORY|BACKUP_INTENT); try { FILE_INFO i=Info(r.Handle); if(!IsDirectory(i)||(i.FileAttributes&ATTR_REPARSE)!=0||!OnlyDefaultStream(r.Handle))throw new InvalidDataException("directory invalid"); string path=Path.Combine(parent.Path,leaf); if(!SamePath(FinalPath(r.Handle),path))throw new InvalidDataException("path changed"); @@ -116,8 +121,7 @@ internal sealed class CcodInstallRuntime : IDisposable if(temp.Stream.Length!=temp.SealedLength||Sha(temp.Stream)!=temp.SealedSha)throw new CryptographicException("seal mismatch"); SafeFileHandle existing=null;try{try{existing=OpenRelative(parent.Native,destination,READ_ATTRIBUTES|SYNC,SHARE_READ|SHARE_WRITE,OPEN,BACKUP_INTENT).Handle;}catch(Win32Exception x){if(x.NativeErrorCode!=2&&x.NativeErrorCode!=3)throw;}if(existing!=null)throw new IOException("destination exists");} finally{if(existing!=null)existing.Dispose();} - if(parent.Owned)return PublishSealedCopy(parent,temp,destination); - int ec=Rename(temp.Native,parent.Native,destination);if(ec==80||ec==183)throw new IOException("destination exists");if(ec!=0)throw new Win32Exception(ec);names.Remove(Key(parent,temp.Leaf));temp.Leaf=destination;temp.Path=Path.Combine(parent.Path,destination);names.Add(Key(parent,destination),temp);ValidateCurrent(temp);return temp.Token; + int ec=Rename(temp.Native,parent.Native,destination);if(ec==80||ec==183)throw new IOException("destination exists");if(ec!=0)throw new Win32Exception(ec);names.Remove(Key(parent,temp.Leaf));temp.Leaf=destination;temp.Path=Path.Combine(parent.Path,destination);names.Add(Key(parent,destination),temp);MakeReadable(temp);ValidateCurrent(temp);return temp.Token; } [MethodImpl(MethodImplOptions.Synchronized)] internal object WriteJson(object parentToken,string leaf,byte[] bytes) { Pin parent=Require(parentToken,true);ValidateCurrent(parent);object t=CreateTemporary(parentToken,".json."+Guid.NewGuid().ToString("N")+".tmp");Pin p=Require(t,false);Write(p,bytes);p.Sealed=true;p.SealedLength=bytes.LongLength;p.SealedSha=Sha(p.Stream);return Promote(parentToken,t,leaf); } @@ -127,12 +131,15 @@ internal sealed class CcodInstallRuntime : IDisposable if(!names.TryGetValue(key,out p)){OpenResult r=OpenRelative(parent.Native,leaf,READ|WRITE|DELETE|SYNC,SHARE_READ,OPEN_IF,NON_DIRECTORY|WRITE_THROUGH);FileStream s=null;try{s=new FileStream(r.Handle,FileAccess.ReadWrite,65536,false);r.Handle=null;FILE_INFO i=Info(s.SafeFileHandle);ValidatePlain(i,s.SafeFileHandle);p=new Pin(parent,leaf,Path.Combine(parent.Path,leaf),false,r.Created,null,s,i);s=null;AddPin(key,p);}finally{if(s!=null)s.Dispose();if(r.Handle!=null)r.Handle.Dispose();}} ValidateCurrent(p);p.Stream.Position=p.Stream.Length;p.Stream.Write(bytes,0,bytes.Length);p.Stream.Flush(true);return p.Token; } - [MethodImpl(MethodImplOptions.Synchronized)] internal void RemoveOwnedTree(object parentToken,string leaf) + [MethodImpl(MethodImplOptions.Synchronized)] internal object RemoveOwnedTree(object parentToken,string leaf) + {return RemoveOwnedTreeCore(parentToken,leaf,".__ccod_retired_"+Guid.NewGuid().ToString("N"));} + [MethodImpl(MethodImplOptions.Synchronized)] private object RemoveOwnedTreeCore(object parentToken,string leaf,string retiredLeaf) { Pin parent=Require(parentToken,true);ValidateCurrent(parent);Pin target;string key=Key(parent,leaf);if(!names.TryGetValue(key,out target)||!target.Owned||!target.Directory)throw new InvalidDataException("unknown target"); - List plan=new List();BuildPlan(target,plan);RevalidatePlan(target,plan);List marked=new List(); - try{foreach(Pin p in plan){ValidateCurrent(p);int ec=SetDeleteOnClose(p.Native,true,p.Directory);if(ec!=0)throw new Win32Exception(ec);marked.Add(p);}ValidateCurrent(target);int tec=SetDeleteOnClose(target.Native,true,true);if(tec!=0)throw new Win32Exception(tec);marked.Add(target);RevalidatePlan(target,plan);foreach(Pin p in plan)RemovePin(p);RemovePin(target);} - catch{for(int i=marked.Count-1;i>=0;i--)if(!marked[i].Closed)SetDeleteOnClose(marked[i].Native,false,marked[i].Directory);throw;} + List plan=new List();BuildPlan(target,plan);RevalidatePlan(target,plan); + if(plan.Count==0){int ec=SetDeleteOnClose(target.Native,true,true);if(ec!=0)throw new Win32Exception(ec);ValidateCurrent(target);RemovePin(target);return null;} + SafeFileHandle collision=null;try{try{collision=OpenRelative(parent.Native,retiredLeaf,READ_ATTRIBUTES|SYNC,SHARE_READ|SHARE_WRITE,OPEN,BACKUP_INTENT).Handle;}catch(Win32Exception x){if(x.NativeErrorCode!=2&&x.NativeErrorCode!=3)throw;}if(collision!=null)throw new IOException("retirement destination exists");}finally{if(collision!=null)collision.Dispose();} + List restricted=RestrictRetirementTree(target,plan);try{RevalidatePlan(target,plan);}catch{RestoreLiveDirectorySecurity(restricted);throw;}ReleaseDescendants(plan);ValidateCurrent(target);int rec=Rename(target.Native,parent.Native,retiredLeaf);if(rec!=0){RestoreDirectorySecurity(target.Native,target.OriginalSecurity);target.RestoreSecurity=false;RestoreReleasedChildSecurity(target);ReopenKnownTree(target);if(rec==80||rec==183)throw new IOException("retirement destination exists");throw new Win32Exception(rec);}MoveDirectoryPin(target,retiredLeaf,Path.Combine(parent.Path,retiredLeaf));RestoreReleasedChildSecurity(target);ValidateCurrent(target);return target.Token; } [MethodImpl(MethodImplOptions.Synchronized)] internal void Close(){if(disposed)return;disposed=true;List all=new List(pins.Values);for(int i=all.Count-1;i>=0;i--)all[i].Dispose();pins.Clear();names.Clear();} public void Dispose(){Close();} @@ -141,9 +148,18 @@ internal sealed class CcodInstallRuntime : IDisposable private static void ProbeUnknown(Pin dir,string name){OpenResult r=OpenRelative(dir.Native,name,READ_ATTRIBUTES|SYNC,SHARE_READ|SHARE_WRITE,OPEN,BACKUP_INTENT);try{FILE_INFO i=Info(r.Handle);if((i.FileAttributes&ATTR_REPARSE)!=0)throw new InvalidDataException("reparse leaf");if(!OnlyDefaultStream(r.Handle))throw new InvalidDataException("alternate stream");if(!IsDirectory(i)&&i.NumberOfLinks!=1)throw new InvalidDataException("multi-link");}finally{r.Handle.Dispose();}} private void RevalidatePlan(Pin target,List plan){List second=new List();BuildPlan(target,second);if(second.Count!=plan.Count)throw new InvalidDataException("namespace changed");for(int i=0;i plan){foreach(Pin p in plan){if(p.Stream!=null){p.Stream.Dispose();p.Stream=null;}else if(p.Handle!=null){p.Handle.Dispose();p.Handle=null;}}} + private void ReopenKnownTree(Pin dir){List children=new List();foreach(Pin p in pins.Values)if(Object.ReferenceEquals(p.Parent,dir)&&!p.Closed)children.Add(p);foreach(Pin p in children){if(p.Directory){uint share=p.Owned?SHARE_READ:(SHARE_READ|SHARE_WRITE);OpenResult r=OpenRelative(dir.Native,p.Leaf,LIST_DIRECTORY|ADD_FILE|ADD_SUBDIRECTORY|READ_ATTRIBUTES|DELETE|SYNC,share,OPEN,DIRECTORY|BACKUP_INTENT);p.Handle=r.Handle;ValidateCurrent(p);ReopenKnownTree(p);}else{OpenResult r=OpenRelative(dir.Native,p.Leaf,READ|WRITE|DELETE|SYNC,SHARE_READ,OPEN,NON_DIRECTORY|WRITE_THROUGH);p.Stream=new FileStream(r.Handle,FileAccess.ReadWrite,65536,false);r.Handle=null;ValidateCurrent(p);}}} + private static void MakeReadable(Pin p){OpenResult bridge=OpenRelative(p.Parent.Native,p.Leaf,READ|READ_ATTRIBUTES|SYNC,SHARE_READ|SHARE_WRITE|SHARE_DELETE,OPEN,NON_DIRECTORY);FILE_INFO bi=Info(bridge.Handle);if(bi.VolumeSerialNumber!=p.Volume||(((ulong)bi.FileIndexHigh<<32)|bi.FileIndexLow)!=p.Index){bridge.Handle.Dispose();throw new InvalidDataException("pin changed");}p.Stream.Dispose();p.Stream=null;OpenResult strict=OpenRelative(p.Parent.Native,p.Leaf,READ|READ_ATTRIBUTES|SYNC,SHARE_READ|SHARE_WRITE,OPEN,NON_DIRECTORY);bridge.Handle.Dispose();p.Stream=new FileStream(strict.Handle,FileAccess.Read,65536,false);strict.Handle=null;} private Pin Require(object token,bool directory){if(disposed)throw new ObjectDisposedException("transaction");Pin p;if(token==null||!pins.TryGetValue(token,out p)||p.Closed||p.Directory!=directory)throw new InvalidDataException("invalid capability");return p;} private void AddPin(string key,Pin p){pins.Add(p.Token,p);names.Add(key,p);} + private static byte[] ReadDirectorySecurity(SafeFileHandle h){uint needed;GetKernelObjectSecurity(h,DACL_SECURITY_INFORMATION,null,0,out needed);int e=Marshal.GetLastWin32Error();if(needed==0)throw new Win32Exception(e);byte[] sd=new byte[needed];if(!GetKernelObjectSecurity(h,DACL_SECURITY_INFORMATION,sd,(uint)sd.Length,out needed))throw new Win32Exception(Marshal.GetLastWin32Error());return sd;} + private static byte[] RestrictRetirementDirectory(SafeFileHandle h){byte[] original=ReadDirectorySecurity(h);RawSecurityDescriptor raw=new RawSecurityDescriptor(original,0);RawAcl acl=raw.DiscretionaryAcl??new RawAcl(GenericAcl.AclRevision,1);int denyMask=0x00000002|0x00000004|0x00000010|0x00000040|0x00000100|0x00010000;acl.InsertAce(0,new CommonAce(AceFlags.None,AceQualifier.AccessDenied,denyMask,new SecurityIdentifier(WellKnownSidType.WorldSid,null),false,null));raw.DiscretionaryAcl=acl;raw.SetFlags(raw.ControlFlags|ControlFlags.DiscretionaryAclPresent|ControlFlags.DiscretionaryAclProtected);byte[] restricted=new byte[raw.BinaryLength];raw.GetBinaryForm(restricted,0);if(!SetKernelObjectSecurity(h,DACL_SECURITY_INFORMATION|PROTECTED_DACL_SECURITY_INFORMATION,restricted))throw new Win32Exception(Marshal.GetLastWin32Error());return original;} + private static void RestoreDirectorySecurity(SafeFileHandle h,byte[] original){if(original==null)return;RawSecurityDescriptor raw=new RawSecurityDescriptor(original,0);uint flags=DACL_SECURITY_INFORMATION|(((raw.ControlFlags&ControlFlags.DiscretionaryAclProtected)!=0)?PROTECTED_DACL_SECURITY_INFORMATION:UNPROTECTED_DACL_SECURITY_INFORMATION);if(!SetKernelObjectSecurity(h,flags,original))throw new Win32Exception(Marshal.GetLastWin32Error());} + private static List RestrictRetirementTree(Pin target,List plan){List restricted=new List();try{target.OriginalSecurity=RestrictRetirementDirectory(target.Native);target.RestoreSecurity=true;restricted.Add(target);foreach(Pin p in plan)if(p.Directory){p.OriginalSecurity=RestrictRetirementDirectory(p.Native);p.RestoreSecurity=true;restricted.Add(p);}return restricted;}catch{RestoreLiveDirectorySecurity(restricted);throw;}} + private static void RestoreLiveDirectorySecurity(List restricted){for(int i=restricted.Count-1;i>=0;i--){Pin p=restricted[i];RestoreDirectorySecurity(p.Native,p.OriginalSecurity);p.RestoreSecurity=false;p.OriginalSecurity=null;}} + private void RestoreReleasedChildSecurity(Pin dir){List children=new List();foreach(Pin p in pins.Values)if(Object.ReferenceEquals(p.Parent,dir)&&p.Directory&&!p.Closed)children.Add(p);foreach(Pin child in children){OpenResult r=OpenRelative(dir.Native,child.Leaf,READ_ATTRIBUTES|READ_CONTROL|WRITE_DAC|SYNC,SHARE_READ|SHARE_WRITE|SHARE_DELETE,OPEN,DIRECTORY|BACKUP_INTENT);RestoreDirectorySecurity(r.Handle,child.OriginalSecurity);child.RestoreSecurity=false;child.OriginalSecurity=null;child.Handle=r.Handle;RestoreReleasedChildSecurity(child);child.Handle.Dispose();child.Handle=null;}} private static string Key(Pin p,string leaf){return p.Index.ToString("x16")+"|"+leaf.ToLowerInvariant();} private static void Write(Pin p,byte[] b){p.Stream.Position=0;p.Stream.SetLength(0);p.Stream.Write(b,0,b.Length);p.Stream.Flush(true);} private static void ValidateCurrent(Pin p){FILE_INFO i=Info(p.Native);if(i.VolumeSerialNumber!=p.Volume||(((ulong)i.FileIndexHigh<<32)|i.FileIndexLow)!=p.Index||IsDirectory(i)!=p.Directory||(i.FileAttributes&ATTR_REPARSE)!=0||!SamePath(FinalPath(p.Native),p.Path))throw new InvalidDataException("pin changed");if(!OnlyDefaultStream(p.Native))throw new InvalidDataException("alternate stream");if(!p.Directory&&i.NumberOfLinks!=1)throw new InvalidDataException("multi-link");} @@ -184,6 +200,7 @@ function Convert-CcodRuntimeError($Action,[string]$DefaultId) { while ($null -ne $exception.InnerException -and $exception -is [Management.Automation.RuntimeException]) { $exception = $exception.InnerException } $message = [string]$exception.Message if ($message -ceq 'destination exists') { Throw-CcodInstallFileError 'CCOD_INSTALL_PROMOTION_DESTINATION_EXISTS' $message $null } + if ($message -ceq 'retirement destination exists') { Throw-CcodInstallFileError 'CCOD_INSTALL_RETIREMENT_DESTINATION_EXISTS' $message $null } if ($exception -is [ObjectDisposedException]) { Throw-CcodInstallFileError 'CCOD_INSTALL_TRANSACTION_CLOSED' $message $null } if ($exception -is [Security.Cryptography.CryptographicException] -or $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 } @@ -221,7 +238,7 @@ function Test-CcodSanitized($Value,[int]$Depth=0) { return $false } function Append-CcodInstallPinnedLog { param([Parameter(Mandatory)]$Transaction,[Parameter(Mandatory)]$ParentDirectory,[Parameter(Mandatory)][string]$Leaf,[Parameter(Mandatory)]$Record) Assert-CcodLeaf $Leaf;if(-not(Test-CcodSanitized $Record)){Throw-CcodInstallFileError 'CCOD_INSTALL_LOG_RECORD_INVALID' 'Invalid log record' $null};$p=Get-CcodPinToken $Transaction $ParentDirectory Directory;$bytes=[Text.UTF8Encoding]::new($false).GetBytes(($Record|ConvertTo-Json -Depth 8 -Compress)+"`n");if($bytes.Length-gt65536){Throw-CcodInstallFileError 'CCOD_INSTALL_LOG_RECORD_INVALID' 'Log record too large' $null};$token=Convert-CcodRuntimeError {Invoke-CcodRuntimeMethod $p.State.Runtime Append @($p.Token,$Leaf,$bytes)} 'CCOD_INSTALL_LOG_WRITE_FAILED';return Add-CcodPinCapability $Transaction $token File } -function Remove-CcodInstallOwnedTree { param([Parameter(Mandatory)]$Transaction,[Parameter(Mandatory)]$ParentDirectory,[Parameter(Mandatory)][string]$Leaf) Assert-CcodLeaf $Leaf;$p=Get-CcodPinToken $Transaction $ParentDirectory Directory;Convert-CcodRuntimeError {Invoke-CcodRuntimeMethod $p.State.Runtime RemoveOwnedTree @($p.Token,$Leaf)|Out-Null} 'CCOD_INSTALL_DELETE_FAILED' } +function Remove-CcodInstallOwnedTree { param([Parameter(Mandatory)]$Transaction,[Parameter(Mandatory)]$ParentDirectory,[Parameter(Mandatory)][string]$Leaf) Assert-CcodLeaf $Leaf;$p=Get-CcodPinToken $Transaction $ParentDirectory Directory;$token=Convert-CcodRuntimeError {Invoke-CcodRuntimeMethod $p.State.Runtime RemoveOwnedTree @($p.Token,$Leaf)} 'CCOD_INSTALL_RETIREMENT_FAILED';if($null -eq $token){return [pscustomobject]@{Disposition='Deleted';Object=$null}};$capability=Add-CcodPinCapability $Transaction $token Directory;return [pscustomobject]@{Disposition='Retired';Object=$capability} } 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 transaction capability' $null};if(-not$state.Closed){Invoke-CcodRuntimeMethod $state.Runtime Close @()|Out-Null;$state.Closed=$true;$state.Disposition=$Disposition} } Export-ModuleMember -Function Open-CcodInstallFileTransaction,Open-CcodInstallPinnedDirectory,New-CcodInstallPinnedTemporaryLeaf,Copy-CcodInstallSealedFile,Commit-CcodInstallPinnedPromotion,Write-CcodInstallPinnedJson,Append-CcodInstallPinnedLog,Remove-CcodInstallOwnedTree,Close-CcodInstallFileTransaction diff --git a/tests/persistence/InstallFileTransaction.SelfTest.ps1 b/tests/persistence/InstallFileTransaction.SelfTest.ps1 index 6da0951..814a443 100644 --- a/tests/persistence/InstallFileTransaction.SelfTest.ps1 +++ b/tests/persistence/InstallFileTransaction.SelfTest.ps1 @@ -101,6 +101,28 @@ public static class CcodInstallTransactionTestNative catch (UnauthorizedAccessException) { return "blocked"; } }); } + + public static Task ProbeNamespaceAfterLeafUnlock(string watchedLeaf, string directory, string hardLinkSource, string hardLinkDestination) + { + return Task.Run(() => { + DateTime deadline = DateTime.UtcNow.AddSeconds(10); + while (DateTime.UtcNow < deadline) + { + try + { + using (FileStream stream = File.Open(watchedLeaf, FileMode.Open, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete)) { } + int directoryError = TryOpenDirectoryForChildWrite(directory); + int linkError = CreateHardLink(hardLinkDestination, hardLinkSource); + return directoryError != 0 && linkError != 0 ? "blocked" : "escaped"; + } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + if (!Directory.Exists(directory)) return "retired-before-probe"; + Thread.Yield(); + } + return "timeout"; + }); + } } '@ } @@ -347,10 +369,11 @@ Invoke-CcodTest 'rejects promotion after an ancestor rename changes the pinned p } 'CCOD_INSTALL_PIN_CHANGED' Assert-CcodTrue ([IO.File]::Exists((Join-Path $attack.MovedDirectory 'payload.tmp'))) 'renamed-parent promotion retains its temporary candidate' } else { - Invoke-CcodInstallModule { param($Tx,$Parent,$Leaf) Commit-CcodInstallPinnedPromotion -Transaction $Tx -ParentDirectory $Parent -TemporaryLeaf $Leaf -DestinationLeaf 'payload.bin' } @($tx,$runtime,$temporary) | Out-Null - Close-CcodInstallFileTransaction -Transaction $tx -Disposition Ready | Out-Null - $fixture.Transaction = $null - Assert-CcodEqual $source.Sha256 (Get-CcodTestFileSha256 (Join-Path $fixture.Install 'runtime\payload.bin')) 'blocked parent rename promotes only sealed bytes' + Assert-CcodThrows { + Invoke-CcodInstallModule { param($Tx,$Parent,$Leaf) Commit-CcodInstallPinnedPromotion -Transaction $Tx -ParentDirectory $Parent -TemporaryLeaf $Leaf -DestinationLeaf 'payload.bin' } @($tx,$runtime,$temporary) | Out-Null + } 'CCOD_INSTALL_PROMOTION_FAILED' + Assert-CcodTrue (-not [IO.File]::Exists((Join-Path $fixture.Install 'runtime\payload.bin'))) 'incompatible owned parent publishes no final destination' + Assert-CcodTrue ([IO.File]::Exists((Join-Path $fixture.Install 'runtime\payload.tmp'))) 'incompatible owned parent retains sealed temporary bytes' } Assert-CcodOutsideUnchanged $fixture 'renamed parent promotion' } finally { Remove-CcodInstallFileFixture $fixture } @@ -384,7 +407,7 @@ foreach ($case in @('unknown','reparse','multilink')) { } } -Invoke-CcodTest 'fails closed when a nonempty owned tree cannot establish every delete disposition' { +Invoke-CcodTest 'retires a fully proven nonempty owned tree with every byte intact' { $fixture = New-CcodInstallFileFixture try { $source = New-CcodSourceFile $fixture @@ -392,25 +415,90 @@ Invoke-CcodTest 'fails closed when a nonempty owned tree cannot establish every $candidate = Invoke-CcodInstallModule { param($Tx) Open-CcodInstallPinnedDirectory -Transaction $Tx -ParentDirectory $Tx.RootDirectory -Leaf 'candidate' -CreateIfMissing } @($tx) $temporary = Invoke-CcodInstallModule { param($Tx,$Parent) New-CcodInstallPinnedTemporaryLeaf -Transaction $Tx -ParentDirectory $Parent -Leaf 'payload.tmp' } @($tx,$candidate) Invoke-CcodInstallModule { - param($Tx,$Source,$Parent,$Leaf,$Length,$Sha) + param($Tx,$Source,$Leaf,$Length,$Sha) Copy-CcodInstallSealedFile -Transaction $Tx -SourcePath $Source -DestinationLeaf $Leaf -ExpectedLength $Length -ExpectedSha256 $Sha | Out-Null - Commit-CcodInstallPinnedPromotion -Transaction $Tx -ParentDirectory $Parent -TemporaryLeaf $Leaf -DestinationLeaf 'payload.bin' | Out-Null - } @($tx,$source.Path,$candidate,$temporary,$source.Length,$source.Sha256) - Assert-CcodThrows { - Invoke-CcodInstallModule { param($Tx) Remove-CcodInstallOwnedTree -Transaction $Tx -ParentDirectory $Tx.RootDirectory -Leaf 'candidate' } @($tx) | Out-Null - } 'CCOD_INSTALL_DELETE_FAILED' - Assert-CcodTrue ([IO.Directory]::Exists((Join-Path $fixture.Install 'candidate'))) 'failed root disposition retains the owned directory' - Assert-CcodTrue ([IO.File]::Exists((Join-Path $fixture.Install 'candidate\payload.bin'))) 'failed root disposition clears the prior file disposition' + } @($tx,$source.Path,$temporary,$source.Length,$source.Sha256) + $retirement = Invoke-CcodInstallModule { param($Tx) Remove-CcodInstallOwnedTree -Transaction $Tx -ParentDirectory $Tx.RootDirectory -Leaf 'candidate' } @($tx) + Assert-CcodEqual 'Retired' $retirement.Disposition 'nonempty cleanup returns bounded retirement result' + Assert-CcodEqual '' (@($retirement.Object.PSObject.Properties.Name) -join ',') 'retired object capability exposes no path or handle' + Assert-CcodTrue (-not [IO.Directory]::Exists((Join-Path $fixture.Install 'candidate'))) 'one atomic retirement removes the live candidate name' + Close-CcodInstallFileTransaction -Transaction $tx -Disposition Failed | Out-Null + $fixture.Transaction = $null + $retired = @(Get-ChildItem -LiteralPath $fixture.Install -Directory -Force | Where-Object Name -Like '.__ccod_retired_*') + Assert-CcodEqual 1 $retired.Count 'exactly one retired quarantine object remains' + Assert-CcodEqual $source.Sha256 (Get-CcodTestFileSha256 (Join-Path $retired[0].FullName 'payload.tmp')) 'retired object retains every sealed payload byte' Assert-CcodOutsideUnchanged $fixture 'owned tree removal' } finally { Remove-CcodInstallFileFixture $fixture } } +Invoke-CcodTest 'retirement collision leaves live candidate and collision untouched' { + $fixture = New-CcodInstallFileFixture + try { + $source = New-CcodSourceFile $fixture + $collisionLeaf = '.__ccod_retired_forced_collision' + $collision = Join-Path $fixture.Install $collisionLeaf + [IO.Directory]::CreateDirectory($collision) | Out-Null + $collisionSentinel = Join-Path $collision 'collision.txt' + [IO.File]::WriteAllText($collisionSentinel, 'collision-original') + $collisionSha = Get-CcodTestFileSha256 $collisionSentinel + $tx = Open-CcodFixtureTransaction $fixture + $candidate = Open-CcodInstallPinnedDirectory -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf 'candidate' -CreateIfMissing + $temporary = New-CcodInstallPinnedTemporaryLeaf -Transaction $tx -ParentDirectory $candidate -Leaf 'payload.tmp' + Copy-CcodInstallSealedFile -Transaction $tx -SourcePath $source.Path -DestinationLeaf $temporary -ExpectedLength $source.Length -ExpectedSha256 $source.Sha256 | Out-Null + Assert-CcodThrows { + Invoke-CcodInstallModule { + param($Tx,$Parent,$Leaf,$RetiredLeaf) + $pin = Get-CcodPinToken $Tx $Parent Directory + Convert-CcodRuntimeError { + Invoke-CcodRuntimeMethod $pin.State.Runtime RemoveOwnedTreeCore @($pin.Token,$Leaf,$RetiredLeaf) | Out-Null + } 'CCOD_INSTALL_RETIREMENT_FAILED' + } @($tx,$tx.RootDirectory,'candidate',$collisionLeaf) + } 'CCOD_INSTALL_RETIREMENT_DESTINATION_EXISTS' + Assert-CcodTrue ([IO.Directory]::Exists((Join-Path $fixture.Install 'candidate'))) 'collision retains the original live candidate name' + Close-CcodInstallFileTransaction -Transaction $tx -Disposition Failed | Out-Null + $fixture.Transaction = $null + Assert-CcodEqual $source.Sha256 (Get-CcodTestFileSha256 (Join-Path $fixture.Install 'candidate\payload.tmp')) 'collision retains every candidate byte' + Assert-CcodEqual $collisionSha (Get-CcodTestFileSha256 $collisionSentinel) 'collision target remains untouched' + Assert-CcodOutsideUnchanged $fixture 'retirement collision' + } finally { Remove-CcodInstallFileFixture $fixture } +} + +Invoke-CcodTest 'owned-parent promotion fails closed without publishing a copy' { + $fixture = New-CcodInstallFileFixture + try { + $source = New-CcodSourceFile $fixture + $tx = Open-CcodFixtureTransaction $fixture + $candidate = Open-CcodInstallPinnedDirectory -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf 'candidate' -CreateIfMissing + $temporary = New-CcodInstallPinnedTemporaryLeaf -Transaction $tx -ParentDirectory $candidate -Leaf 'sealed.tmp' + Copy-CcodInstallSealedFile -Transaction $tx -SourcePath $source.Path -DestinationLeaf $temporary -ExpectedLength $source.Length -ExpectedSha256 $source.Sha256 | Out-Null + Assert-CcodThrows { + Commit-CcodInstallPinnedPromotion -Transaction $tx -ParentDirectory $candidate -TemporaryLeaf $temporary -DestinationLeaf 'final.bin' | Out-Null + } 'CCOD_INSTALL_PROMOTION_FAILED' + Assert-CcodTrue (-not [IO.File]::Exists((Join-Path $fixture.Install 'candidate\final.bin'))) 'failed owned-parent rename publishes no final destination' + Assert-CcodTrue ([IO.File]::Exists((Join-Path $fixture.Install 'candidate\sealed.tmp'))) 'failed owned-parent rename retains the sealed temporary leaf' + Assert-CcodOutsideUnchanged $fixture 'owned-parent promotion failure' + } finally { Remove-CcodInstallFileFixture $fixture } +} + +Invoke-CcodTest 'compatible-parent promotion is readable before transaction close' { + $fixture = New-CcodInstallFileFixture + try { + $source = New-CcodSourceFile $fixture + $tx = Open-CcodFixtureTransaction $fixture + $temporary = New-CcodInstallPinnedTemporaryLeaf -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf 'readable.tmp' + Copy-CcodInstallSealedFile -Transaction $tx -SourcePath $source.Path -DestinationLeaf $temporary -ExpectedLength $source.Length -ExpectedSha256 $source.Sha256 | Out-Null + Commit-CcodInstallPinnedPromotion -Transaction $tx -ParentDirectory $tx.RootDirectory -TemporaryLeaf $temporary -DestinationLeaf 'readable.bin' | Out-Null + Assert-CcodEqual $source.Sha256 (Get-CcodTestFileSha256 (Join-Path $fixture.Install 'readable.bin')) 'compatible atomic promotion is readable while transaction stays open' + Assert-CcodOutsideUnchanged $fixture 'readable compatible promotion' + } finally { Remove-CcodInstallFileFixture $fixture } +} + Invoke-CcodTest 'deletes an empty fully-owned candidate after establishing its root disposition' { $fixture = New-CcodInstallFileFixture try { $tx = Open-CcodFixtureTransaction $fixture $candidate = Open-CcodInstallPinnedDirectory -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf 'empty-candidate' -CreateIfMissing - Remove-CcodInstallOwnedTree -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf 'empty-candidate' + Remove-CcodInstallOwnedTree -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf 'empty-candidate' | Out-Null Assert-CcodTrue (-not [IO.Directory]::Exists((Join-Path $fixture.Install 'empty-candidate'))) 'empty owned candidate is deleted only after its root disposition is established' Assert-CcodOutsideUnchanged $fixture 'empty owned tree removal' } finally { Remove-CcodInstallFileFixture $fixture } @@ -427,7 +515,8 @@ foreach ($case in @('json','log')) { if ($attack.Outcome -ceq 'replaced') { Assert-CcodThrows { Invoke-CcodInstallModule { param($Tx,$Parent) Write-CcodInstallPinnedJson -Transaction $Tx -ParentDirectory $Parent -Leaf 'state.json' -Value ([ordered]@{ schemaVersion = 1; value = 'safe' }) -Compress } @($tx,$parent) } 'CCOD_INSTALL_PIN_CHANGED' } else { - Invoke-CcodInstallModule { param($Tx,$Parent) Write-CcodInstallPinnedJson -Transaction $Tx -ParentDirectory $Parent -Leaf 'state.json' -Value ([ordered]@{ schemaVersion = 1; value = 'safe' }) -Compress } @($tx,$parent) | Out-Null + Assert-CcodThrows { Invoke-CcodInstallModule { param($Tx,$Parent) Write-CcodInstallPinnedJson -Transaction $Tx -ParentDirectory $Parent -Leaf 'state.json' -Value ([ordered]@{ schemaVersion = 1; value = 'safe' }) -Compress } @($tx,$parent) } 'CCOD_INSTALL_PROMOTION_FAILED' + Assert-CcodTrue (-not [IO.File]::Exists((Join-Path $fixture.Install "$case\state.json"))) 'incompatible owned JSON parent publishes no final state' } Assert-CcodTrue (-not [IO.File]::Exists((Join-Path $fixture.Outside 'state.json'))) 'replaced JSON path is untouched' } else { @@ -447,8 +536,8 @@ Invoke-CcodTest 'writes pinned JSON and appends sanitized records through relati $fixture = New-CcodInstallFileFixture try { $tx = Open-CcodFixtureTransaction $fixture - $state = Invoke-CcodInstallModule { param($Tx) Open-CcodInstallPinnedDirectory -Transaction $Tx -ParentDirectory $Tx.RootDirectory -Leaf 'state' -CreateIfMissing } @($tx) - $logs = Invoke-CcodInstallModule { param($Tx) Open-CcodInstallPinnedDirectory -Transaction $Tx -ParentDirectory $Tx.RootDirectory -Leaf 'logs' -CreateIfMissing } @($tx) + $state = $tx.RootDirectory + $logs = $tx.RootDirectory Invoke-CcodInstallModule { param($Tx,$Parent) Write-CcodInstallPinnedJson -Transaction $Tx -ParentDirectory $Parent -Leaf 'active.json' -Value ([ordered]@{ schemaVersion = 1; active = 'runtime-a' }) -Compress } @($tx,$state) | Out-Null Assert-CcodThrows { Invoke-CcodInstallModule { param($Tx,$Parent) Write-CcodInstallPinnedJson -Transaction $Tx -ParentDirectory $Parent -Leaf 'active.json' -Value ([ordered]@{ schemaVersion = 1; active = 'runtime-b' }) -Compress } @($tx,$state) | Out-Null @@ -458,9 +547,9 @@ Invoke-CcodTest 'writes pinned JSON and appends sanitized records through relati Invoke-CcodInstallModule { param($Tx) Close-CcodInstallFileTransaction -Transaction $Tx -Disposition Ready } @($tx) | Out-Null $fixture.Transaction = $null - $json = [IO.File]::ReadAllText((Join-Path $fixture.Install 'state\active.json'), [Text.UTF8Encoding]::new($false)) | ConvertFrom-Json + $json = [IO.File]::ReadAllText((Join-Path $fixture.Install 'active.json'), [Text.UTF8Encoding]::new($false)) | ConvertFrom-Json Assert-CcodEqual 'runtime-a' $json.active 'no-replace pinned JSON preserves the first committed object' - $records = @([IO.File]::ReadAllLines((Join-Path $fixture.Install 'logs\install.log'), [Text.UTF8Encoding]::new($false))) + $records = @([IO.File]::ReadAllLines((Join-Path $fixture.Install 'install.log'), [Text.UTF8Encoding]::new($false))) Assert-CcodEqual 2 $records.Count 'pinned log appends one JSON line per record' Assert-CcodEqual 'one' (($records[0] | ConvertFrom-Json).event) 'first log record is retained' Assert-CcodEqual 'two' (($records[1] | ConvertFrom-Json).event) 'second log record is appended' @@ -608,7 +697,7 @@ foreach ($directoryCase in @('root','child','owned')) { } } -Invoke-CcodTest 'rolls back earlier delete dispositions when a later owned leaf cannot be deleted' { +Invoke-CcodTest 'retires a nonempty tree containing a read-only owned leaf without changing bytes' { $fixture = New-CcodInstallFileFixture try { $tx = Open-CcodFixtureTransaction $fixture @@ -616,27 +705,34 @@ Invoke-CcodTest 'rolls back earlier delete dispositions when a later owned leaf $first = New-CcodInstallPinnedTemporaryLeaf -Transaction $tx -ParentDirectory $candidate -Leaf 'a.tmp' $later = New-CcodInstallPinnedTemporaryLeaf -Transaction $tx -ParentDirectory $candidate -Leaf 'z.tmp' [IO.File]::SetAttributes((Join-Path $fixture.Install 'candidate\z.tmp'), [IO.FileAttributes]::ReadOnly) - Assert-CcodThrows { - Remove-CcodInstallOwnedTree -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf 'candidate' - } 'CCOD_INSTALL_DELETE_FAILED' - Assert-CcodTrue ([IO.File]::Exists((Join-Path $fixture.Install 'candidate\a.tmp'))) 'later deletion failure restores the first leaf disposition' - Assert-CcodTrue ([IO.File]::Exists((Join-Path $fixture.Install 'candidate\z.tmp'))) 'later deletion failure retains the blocked leaf' - Assert-CcodOutsideUnchanged $fixture 'all-or-nothing delete rollback' + $result = Remove-CcodInstallOwnedTree -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf 'candidate' + Assert-CcodEqual 'Retired' $result.Disposition 'read-only leaf uses atomic retirement instead of recursive deletion' + Assert-CcodTrue (-not [IO.Directory]::Exists((Join-Path $fixture.Install 'candidate'))) 'read-only candidate leaves the live namespace atomically' + Close-CcodInstallFileTransaction -Transaction $tx -Disposition Failed | Out-Null + $fixture.Transaction = $null + $retired = @(Get-ChildItem -LiteralPath $fixture.Install -Directory -Force | Where-Object Name -Like '.__ccod_retired_*') + Assert-CcodEqual 1 $retired.Count 'read-only candidate has one retired object' + Assert-CcodTrue ([IO.File]::Exists((Join-Path $retired[0].FullName 'a.tmp'))) 'first leaf remains in retired tree' + Assert-CcodTrue ([IO.File]::Exists((Join-Path $retired[0].FullName 'z.tmp'))) 'read-only leaf remains in retired tree' + Assert-CcodOutsideUnchanged $fixture 'read-only retirement' } finally { Remove-CcodInstallFileFixture $fixture } } -Invoke-CcodTest 'clears a child-directory disposition when the later root mark fails' { +Invoke-CcodTest 'retires a nested owned directory without releasing its contents' { $fixture = New-CcodInstallFileFixture try { $tx = Open-CcodFixtureTransaction $fixture $candidate = Open-CcodInstallPinnedDirectory -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf 'candidate' -CreateIfMissing $child = Open-CcodInstallPinnedDirectory -Transaction $tx -ParentDirectory $candidate -Leaf 'child' -CreateIfMissing - Assert-CcodThrows { - Remove-CcodInstallOwnedTree -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf 'candidate' - } 'CCOD_INSTALL_DELETE_FAILED' - Assert-CcodTrue ([IO.Directory]::Exists((Join-Path $fixture.Install 'candidate')) ) 'later root-mark failure retains the candidate root' - Assert-CcodTrue ([IO.Directory]::Exists((Join-Path $fixture.Install 'candidate\child')) ) 'later root-mark failure clears the child-directory disposition' - Assert-CcodOutsideUnchanged $fixture 'directory disposition rollback' + $result = Remove-CcodInstallOwnedTree -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf 'candidate' + Assert-CcodEqual 'Retired' $result.Disposition 'nested nonempty candidate is retired' + Assert-CcodTrue (-not [IO.Directory]::Exists((Join-Path $fixture.Install 'candidate'))) 'nested candidate live name disappears atomically' + Close-CcodInstallFileTransaction -Transaction $tx -Disposition Failed | Out-Null + $fixture.Transaction = $null + $retired = @(Get-ChildItem -LiteralPath $fixture.Install -Directory -Force | Where-Object Name -Like '.__ccod_retired_*') + Assert-CcodEqual 1 $retired.Count 'nested candidate has one retired object' + Assert-CcodTrue ([IO.Directory]::Exists((Join-Path $retired[0].FullName 'child'))) 'nested child directory remains intact after retirement' + Assert-CcodOutsideUnchanged $fixture 'nested directory retirement' } finally { Remove-CcodInstallFileFixture $fixture } } @@ -653,7 +749,8 @@ Invoke-CcodTest 'detects an unknown insertion between deletion planning and firs $unknown = Join-Path $fixture.Install 'candidate\concurrent-unknown.tmp' $attacker = [CcodInstallTransactionTestNative]::InsertUnknownAfterDelay($unknown, 1) $failure = $null - try { Remove-CcodInstallOwnedTree -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf 'candidate' } + $retirement = $null + try { $retirement = Remove-CcodInstallOwnedTree -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf 'candidate' } catch { $failure = $_ } $attackOutcome = $attacker.GetAwaiter().GetResult() Assert-CcodTrue ($attackOutcome -in @('inserted','blocked')) 'concurrent namespace attempt reaches the cleanup boundary' @@ -663,7 +760,7 @@ Invoke-CcodTest 'detects an unknown insertion between deletion planning and firs Assert-CcodTrue ([IO.File]::Exists($unknown)) 'unproven concurrent object is retained for diagnosis' Assert-CcodTrue ([IO.Directory]::Exists((Join-Path $fixture.Install 'candidate'))) 'concurrent insertion retains the complete candidate tree' } else { - Assert-CcodTrue ($null -ne $failure -and $failure.FullyQualifiedErrorId -like 'CCOD_INSTALL_DELETE_FAILED*') 'blocked insertion still fails closed when the nonempty root disposition is unsupported' + Assert-CcodTrue ($null -eq $failure -and $retirement.Disposition -ceq 'Retired') 'blocked insertion permits one atomic retirement' } Assert-CcodOutsideUnchanged $fixture 'concurrent cleanup insertion' } finally { Remove-CcodInstallFileFixture $fixture } @@ -689,6 +786,32 @@ Invoke-CcodTest 'owned directory denies external child creation and hard-link in } finally { Remove-CcodInstallFileFixture $fixture } } +Invoke-CcodTest 'target pin blocks namespace mutation after descendant handles are released' { + $fixture = New-CcodInstallFileFixture + try { + $outsideSource = Join-Path $fixture.Outside 'outside-source.bin' + [IO.File]::WriteAllText($outsideSource, 'outside-source') + $tx = Open-CcodFixtureTransaction $fixture + $candidate = Open-CcodInstallPinnedDirectory -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf 'candidate' -CreateIfMissing + $first = New-CcodInstallPinnedTemporaryLeaf -Transaction $tx -ParentDirectory $candidate -Leaf 'a-first.tmp' + for ($index = 0; $index -lt 256; $index++) { + New-CcodInstallPinnedTemporaryLeaf -Transaction $tx -ParentDirectory $candidate -Leaf ('b-{0:d3}.tmp' -f $index) | Out-Null + } + $candidatePath = Join-Path $fixture.Install 'candidate' + $probe = [CcodInstallTransactionTestNative]::ProbeNamespaceAfterLeafUnlock( + (Join-Path $candidatePath 'a-first.tmp'), + $candidatePath, + $outsideSource, + (Join-Path $candidatePath 'attacker-hardlink.bin') + ) + $retirement = Remove-CcodInstallOwnedTree -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf 'candidate' + Assert-CcodEqual 'Retired' $retirement.Disposition 'candidate retires after descendant release probe' + Assert-CcodEqual 'blocked' $probe.GetAwaiter().GetResult() 'retained target pin blocks child-write and hard-link namespace mutation after descendant release' + Assert-CcodTrue (-not [IO.File]::Exists((Join-Path $candidatePath 'attacker-hardlink.bin'))) 'descendant-release probe creates no attacker hard link' + Assert-CcodOutsideUnchanged $fixture 'descendant-release namespace lock' + } finally { Remove-CcodInstallFileFixture $fixture } +} + Invoke-CcodTest 'closing releases every sharing lock and rejects close-versus-relative operations' { $fixture = New-CcodInstallFileFixture try { From 03de616da2da39f164ad90a1d830e6f742935bd5 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:09:04 +0800 Subject: [PATCH 070/187] docs: plan immutable generation release model --- .../2026-08-29-v2522-immutable-generation.md | 602 ++++++++++++++++++ ...08-29-v2522-immutable-generation-design.md | 165 +++++ 2 files changed, 767 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-29-v2522-immutable-generation.md create mode 100644 docs/superpowers/specs/2026-08-29-v2522-immutable-generation-design.md 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..4e0f56e --- /dev/null +++ b/docs/superpowers/plans/2026-08-29-v2522-immutable-generation.md @@ -0,0 +1,602 @@ +# 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 +New-CcodInstallGenerationLeaf -Generation -Leaf +Copy-CcodInstallSealedSource -Generation -SourcePath -Leaf -ExpectedLength -ExpectedSha256 +Write-CcodInstallGenerationManifest -Generation -Manifest +Commit-CcodInstallActivePointer -InstallRoot -ExpectedPreviousGeneration -NewRuntimeId -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 public method accepts an +arbitrary path or bare handle. 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 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 no-replace relative rename; retain the quarantine tree. + +- [ ] **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 +Set-CcodActiveRuntime -InstallRoot -NewRuntimeId -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/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..9e4359a --- /dev/null +++ b/docs/superpowers/specs/2026-08-29-v2522-immutable-generation-design.md @@ -0,0 +1,165 @@ +# 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. + +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 generation becomes eligible only after every manifest-listed file is + copied, flushed, and rehashed, and its runtime manifest/version/commit match. +- 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. They are made visible only +after the active pointer is committed and the new protection has reached +verified readiness. An upgrade does not overwrite a stable file while the new +generation is still incomplete. + +### 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 +New-CcodInstallGenerationLeaf -Generation -Leaf +Copy-CcodInstallSealedSource -Generation -SourcePath -Leaf -ExpectedLength -ExpectedSha256 +Write-CcodInstallGenerationManifest -Generation -Manifest +Commit-CcodInstallActivePointer -InstallRoot -ExpectedPreviousGeneration -NewRuntimeId -FileTransaction +Retire-CcodInstallGeneration -InstallRoot -RuntimeId -FileTransaction +Close-CcodInstallFileTransaction -Transaction -Disposition Ready|Failed +``` + +`Retire-CcodInstallGeneration` removes a generation from the active namespace +by an atomic create-only pointer/retired-record update; it does not 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. + +### 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. From e3ad4f703884ed6893f89565b2bcdc925977a37d Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:18:23 +0800 Subject: [PATCH 071/187] fix: use immutable generation file primitives --- .../modules/InstallFileTransaction.psm1 | 347 ++++--- tests/PersistenceSelfTest.ps1 | 8 +- tests/Validate.ps1 | 5 + .../InstallFileTransaction.SelfTest.ps1 | 954 +++--------------- 4 files changed, 359 insertions(+), 955 deletions(-) diff --git a/src/persistence/modules/InstallFileTransaction.psm1 b/src/persistence/modules/InstallFileTransaction.psm1 index 3ca21a6..cb3ab93 100644 --- a/src/persistence/modules/InstallFileTransaction.psm1 +++ b/src/persistence/modules/InstallFileTransaction.psm1 @@ -1,7 +1,7 @@ Set-StrictMode -Version Latest $script:CcodTransactions = [Runtime.CompilerServices.ConditionalWeakTable[object,object]]::new() -$script:CcodPins = [Runtime.CompilerServices.ConditionalWeakTable[object,object]]::new() +$script:CcodScopes = [Runtime.CompilerServices.ConditionalWeakTable[object,object]]::new() function Throw-CcodInstallFileError { param([string]$Id,[string]$Message,$Target) @@ -9,34 +9,35 @@ function Throw-CcodInstallFileError { } function Initialize-CcodInstallRuntime { - if ($null -eq ('CcodInstallCapabilityMarker' -as [type])) { - Add-Type -TypeDefinition @' + if ($null -ne ('CcodInstallCapabilityMarker' -as [type])) { return } + Add-Type -TypeDefinition @' using System; using System.Collections.Generic; using System.ComponentModel; using System.IO; -using System.Runtime.InteropServices; using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using System.Security.Cryptography; -using System.Security.AccessControl; -using System.Security.Principal; using System.Text; using Microsoft.Win32.SafeHandles; -public sealed class CcodInstallCapabilityMarker { private CcodInstallCapabilityMarker() {} public static int CapabilityAbi { get { return 1; } } } +public sealed class CcodInstallCapabilityMarker +{ + private CcodInstallCapabilityMarker() { } + public static int CapabilityAbi { get { return 1; } } +} internal sealed class CcodInstallRuntime : IDisposable { - private const uint READ = 0x80000000, WRITE = 0x40000000, DELETE = 0x00010000, READ_CONTROL = 0x00020000, WRITE_DAC = 0x00040000, SYNC = 0x00100000; + private const uint READ = 0x80000000, WRITE = 0x40000000, DELETE = 0x00010000, SYNC = 0x00100000; private const uint READ_ATTRIBUTES = 0x80, 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, OPEN_IF = 3; + 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, FLAG_WRITE_THROUGH = 0x80000000; + 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, FileDispositionInformation = 13, FileStreamInfo = 7, FileDirectoryInformation = 1; + private const int FileRenameInformation = 10, FileStreamInfo = 7, FileDirectoryInformation = 1; private const int STATUS_NO_MORE_FILES = unchecked((int)0x80000006); - private const uint DACL_SECURITY_INFORMATION = 0x00000004, PROTECTED_DACL_SECURITY_INFORMATION = 0x80000000, UNPROTECTED_DACL_SECURITY_INFORMATION = 0x20000000; [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; } @@ -48,197 +49,215 @@ internal sealed class CcodInstallRuntime : IDisposable public uint VolumeSerialNumber, FileSizeHigh, FileSizeLow, NumberOfLinks, FileIndexHigh, FileIndexLow; } - [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] private static extern SafeFileHandle CreateFileW(string p,uint a,uint s,IntPtr sec,uint c,uint f,IntPtr t); - [DllImport("kernel32.dll", SetLastError=true)] private static extern bool GetFileInformationByHandle(SafeFileHandle h,out FILE_INFO i); - [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] private static extern uint GetFinalPathNameByHandleW(SafeFileHandle h,StringBuilder b,uint l,uint f); - [DllImport("kernel32.dll", SetLastError=true)] private static extern bool GetFileInformationByHandleEx(SafeFileHandle h,int c,IntPtr i,uint s); - [DllImport("kernel32.dll", SetLastError=true)] private static extern bool SetFileInformationByHandle(SafeFileHandle h,int c,IntPtr i,uint s); - [DllImport("ntdll.dll")] private static extern int NtCreateFile(out IntPtr h,uint a,ref OBJECT_ATTRIBUTES o,out IO_STATUS_BLOCK i,IntPtr z,uint fa,uint sh,uint d,uint op,IntPtr e,uint el); - [DllImport("ntdll.dll")] private static extern int NtQueryDirectoryFile(SafeFileHandle h,IntPtr e,IntPtr a,IntPtr c,out IO_STATUS_BLOCK i,IntPtr f,uint l,int cl,bool one,IntPtr n,bool restart); - [DllImport("ntdll.dll")] private static extern int NtSetInformationFile(SafeFileHandle h,out IO_STATUS_BLOCK i,IntPtr f,uint l,int cl); - [DllImport("ntdll.dll")] private static extern uint RtlNtStatusToDosError(int s); - [DllImport("advapi32.dll", SetLastError=true)] private static extern bool GetKernelObjectSecurity(SafeFileHandle h,uint i,byte[] sd,uint length,out uint needed); - [DllImport("advapi32.dll", SetLastError=true)] private static extern bool SetKernelObjectSecurity(SafeFileHandle h,uint i,byte[] sd); + [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); 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 byte[] OriginalSecurity; internal bool RestoreSecurity; - internal readonly uint Volume; internal readonly ulong Index; internal bool Closed, Sealed; internal long SealedLength; internal string SealedSha; - internal Pin(Pin parent,string leaf,string path,bool dir,bool owned,SafeFileHandle handle,FileStream stream,FILE_INFO info) - { Parent=parent;Leaf=leaf;Path=path;Directory=dir;Owned=owned;Handle=handle;Stream=stream;Volume=info.VolumeSerialNumber;Index=((ulong)info.FileIndexHigh<<32)|info.FileIndexLow; } + 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, Manifest; + 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; Closed=true; if(RestoreSecurity&&Handle!=null)RestoreDirectorySecurity(Handle,OriginalSecurity);if(Stream!=null)Stream.Dispose(); else if(Handle!=null)Handle.Dispose(); } + public void Dispose() { if(Closed)return;Closed=true;if(Stream!=null)Stream.Dispose();else if(Handle!=null)Handle.Dispose(); } } - private readonly string rootPath; private readonly Pin root; - private readonly Dictionary pins = new Dictionary(ReferenceEqualityComparer.Instance); + 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 List externalStreams = new List(); private bool disposed; - private sealed class ReferenceEqualityComparer : IEqualityComparer - { internal static readonly ReferenceEqualityComparer Instance=new ReferenceEqualityComparer(); public new bool Equals(object x,object y){return Object.ReferenceEquals(x,y);} public int GetHashCode(object x){return System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(x);} } + private CcodInstallRuntime(string installRoot,Pin root,Pin runtimeParent) + { this.installRoot=installRoot;this.root=root;this.runtimeParent=runtimeParent;AddPin(root);AddPin(runtimeParent); } - private CcodInstallRuntime(string path,Pin rootPin) { rootPath=path;root=rootPin;pins.Add(root.Token,root); } - internal static object Open(string path,out CcodInstallRuntime runtime) + internal static object Open(string path,string runtimeId,out CcodInstallRuntime runtime) { - runtime=null; string full=Path.GetFullPath(path).TrimEnd('\\'); - SafeFileHandle h=CreateFileW(full,LIST_DIRECTORY|ADD_FILE|ADD_SUBDIRECTORY|READ_ATTRIBUTES|SYNC,SHARE_READ|SHARE_WRITE,IntPtr.Zero,OPEN_EXISTING,FLAG_BACKUP|FLAG_REPARSE,IntPtr.Zero); - if(h.IsInvalid)throw new Win32Exception(Marshal.GetLastWin32Error()); - try { FILE_INFO i=Info(h); if(!IsDirectory(i)||(i.FileAttributes&ATTR_REPARSE)!=0||!SamePath(FinalPath(h),full))throw new InvalidDataException("root invalid");if(!OnlyDefaultStream(h))throw new InvalidDataException("alternate stream"); - Pin p=new Pin(null,"",full,true,false,h,null,i); h=null; runtime=new CcodInstallRuntime(full,p); return p.Token; } - finally { if(h!=null)h.Dispose(); } - } - [MethodImpl(MethodImplOptions.Synchronized)] internal object OpenDirectory(object parentToken,string leaf,bool create) - { - Pin parent=Require(parentToken,true); ValidateCurrent(parent); string key=Key(parent,leaf); Pin known; if(names.TryGetValue(key,out known)){ValidateCurrent(known);return known.Token;} - OpenResult r=null; - if(create){try{r=OpenRelative(parent.Native,leaf,LIST_DIRECTORY|ADD_FILE|ADD_SUBDIRECTORY|READ_ATTRIBUTES|DELETE|READ_CONTROL|WRITE_DAC|SYNC,SHARE_READ,CREATE,DIRECTORY|BACKUP_INTENT);}catch(Win32Exception x){if(x.NativeErrorCode!=80&&x.NativeErrorCode!=183)throw;r=OpenRelative(parent.Native,leaf,LIST_DIRECTORY|ADD_FILE|ADD_SUBDIRECTORY|READ_ATTRIBUTES|DELETE|SYNC,SHARE_READ|SHARE_WRITE,OPEN,DIRECTORY|BACKUP_INTENT);}} - else r=OpenRelative(parent.Native,leaf,LIST_DIRECTORY|ADD_FILE|ADD_SUBDIRECTORY|READ_ATTRIBUTES|DELETE|SYNC,SHARE_READ|SHARE_WRITE,OPEN,DIRECTORY|BACKUP_INTENT); - try { FILE_INFO i=Info(r.Handle); if(!IsDirectory(i)||(i.FileAttributes&ATTR_REPARSE)!=0||!OnlyDefaultStream(r.Handle))throw new InvalidDataException("directory invalid"); - string path=Path.Combine(parent.Path,leaf); if(!SamePath(FinalPath(r.Handle),path))throw new InvalidDataException("path changed"); - Pin p=new Pin(parent,leaf,path,true,r.Created,r.Handle,null,i);r.Handle=null;AddPin(key,p);return p.Token; } - finally { if(r.Handle!=null)r.Handle.Dispose(); } - } - [MethodImpl(MethodImplOptions.Synchronized)] internal object CreateTemporary(object parentToken,string leaf) + 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,CREATE,DIRECTORY|BACKUP_INTENT); + try + { + Pin generation=ValidateDirectoryPin(runtimePin,runtimeId,Path.Combine(runtimePin.Path,runtimeId),true,generationResult.Handle);generationResult.Handle=null; + runtime=new CcodInstallRuntime(full,rootPin,runtimePin);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; } + } + + [MethodImpl(MethodImplOptions.Synchronized)] internal object CreateDirectory(object parentToken,string leaf) { - Pin parent=Require(parentToken,true);ValidateCurrent(parent);string key=Key(parent,leaf);if(names.ContainsKey(key))throw new IOException("exists"); - OpenResult r=OpenRelative(parent.Native,leaf,READ|WRITE|DELETE|SYNC,SHARE_READ,CREATE,NON_DIRECTORY|WRITE_THROUGH); - FileStream s=null;try{s=new FileStream(r.Handle,FileAccess.ReadWrite,65536,false);r.Handle=null;FILE_INFO i=Info(s.SafeFileHandle);ValidatePlain(i,s.SafeFileHandle);Pin p=new Pin(parent,leaf,Path.Combine(parent.Path,leaf),false,true,null,s,i);s=null;AddPin(key,p);return p.Token;} - finally{if(s!=null)s.Dispose();if(r.Handle!=null)r.Handle.Dispose();} + Pin parent=Require(parentToken,true);ValidateCurrent(parent);OpenResult result=OpenRelative(parent.Native,leaf,LIST_DIRECTORY|ADD_FILE|ADD_SUBDIRECTORY|READ_ATTRIBUTES|DELETE|SYNC,SHARE_READ,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 Copy(object destinationToken,string source,long expectedLength,string expectedSha) + + [MethodImpl(MethodImplOptions.Synchronized)] internal object Copy(object parentToken,string leaf,string source,long expectedLength,string expectedSha) { - Pin d=Require(destinationToken,false);ValidateCurrent(d);if(!d.Owned)throw new InvalidDataException("destination not owned"); - string full=Path.GetFullPath(source); SafeFileHandle sh=CreateFileW(full,READ|READ_ATTRIBUTES|SYNC,SHARE_READ,IntPtr.Zero,OPEN_EXISTING,FLAG_REPARSE,IntPtr.Zero);if(sh.IsInvalid)throw new Win32Exception(Marshal.GetLastWin32Error()); - try{FILE_INFO si=Info(sh);ValidatePlain(si,sh);if(!SamePath(FinalPath(sh),full))throw new InvalidDataException("source path");using(FileStream ss=new FileStream(sh,FileAccess.Read,65536,false)){sh=null;if(ss.Length!=expectedLength||Sha(ss)!=expectedSha)throw new InvalidDataException("source mismatch");ss.Position=0;d.Stream.Position=0;d.Stream.SetLength(0);ss.CopyTo(d.Stream);d.Stream.Flush(true);string actual=Sha(d.Stream);if(d.Stream.Length!=expectedLength||actual!=expectedSha)throw new InvalidDataException("destination mismatch");d.Sealed=true;d.SealedLength=expectedLength;d.SealedSha=expectedSha;}}finally{if(sh!=null)sh.Dispose();} - return d.Token; + 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,destinationStream=null;OpenResult destinationResult=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; + if(sourceStream.Length!=expectedLength||Sha(sourceStream)!=expectedSha)throw new InvalidDataException("source mismatch");sourceStream.Position=0; + destinationResult=OpenRelative(parent.Native,leaf,READ|WRITE|SYNC,SHARE_READ,CREATE,NON_DIRECTORY|WRITE_THROUGH); + destinationStream=new FileStream(destinationResult.Handle,FileAccess.ReadWrite,65536,false);destinationResult.Handle=null; + FILE_INFO destinationInfo=Info(destinationStream.SafeFileHandle);ValidatePlain(destinationInfo,destinationStream.SafeFileHandle); + Pin destination=new Pin(parent,leaf,Path.Combine(parent.Path,leaf),false,true,null,destinationStream,destinationInfo);destinationStream=null; + sourceStream.CopyTo(destination.Stream);destination.Stream.Flush(true); + if(destination.Stream.Length!=expectedLength||Sha(destination.Stream)!=expectedSha)throw new InvalidDataException("destination mismatch");destination.Sealed=true;MakeReadable(destination);AddPin(destination);externalStreams.Add(sourceStream);sourceStream=null;return destination.Token; + } + finally { if(sourceStream!=null)sourceStream.Dispose();if(sourceHandle!=null)sourceHandle.Dispose();if(destinationStream!=null)destinationStream.Dispose();if(destinationResult!=null&&destinationResult.Handle!=null)destinationResult.Handle.Dispose(); } } - [MethodImpl(MethodImplOptions.Synchronized)] internal object Promote(object parentToken,object tempToken,string destination) + + [MethodImpl(MethodImplOptions.Synchronized)] internal object Write(object parentToken,string leaf,byte[] bytes,bool manifest) { - Pin parent=Require(parentToken,true),temp=Require(tempToken,false);ValidateCurrent(parent);ValidateCurrent(temp); - if(temp.Parent!=parent||!temp.Owned||!temp.Sealed)throw new InvalidDataException("promotion invalid"); - if(temp.Stream.Length!=temp.SealedLength||Sha(temp.Stream)!=temp.SealedSha)throw new CryptographicException("seal mismatch"); - SafeFileHandle existing=null;try{try{existing=OpenRelative(parent.Native,destination,READ_ATTRIBUTES|SYNC,SHARE_READ|SHARE_WRITE,OPEN,BACKUP_INTENT).Handle;}catch(Win32Exception x){if(x.NativeErrorCode!=2&&x.NativeErrorCode!=3)throw;}if(existing!=null)throw new IOException("destination exists");} - finally{if(existing!=null)existing.Dispose();} - int ec=Rename(temp.Native,parent.Native,destination);if(ec==80||ec==183)throw new IOException("destination exists");if(ec!=0)throw new Win32Exception(ec);names.Remove(Key(parent,temp.Leaf));temp.Leaf=destination;temp.Path=Path.Combine(parent.Path,destination);names.Add(Key(parent,destination),temp);MakeReadable(temp);ValidateCurrent(temp);return temp.Token; - } - [MethodImpl(MethodImplOptions.Synchronized)] internal object WriteJson(object parentToken,string leaf,byte[] bytes) - { Pin parent=Require(parentToken,true);ValidateCurrent(parent);object t=CreateTemporary(parentToken,".json."+Guid.NewGuid().ToString("N")+".tmp");Pin p=Require(t,false);Write(p,bytes);p.Sealed=true;p.SealedLength=bytes.LongLength;p.SealedSha=Sha(p.Stream);return Promote(parentToken,t,leaf); } - [MethodImpl(MethodImplOptions.Synchronized)] internal object Append(object parentToken,string leaf,byte[] bytes) + Pin parent=Require(parentToken,true);ValidateCurrent(parent);OpenResult result=OpenRelative(parent.Native,leaf,READ|WRITE|DELETE|SYNC,SHARE_READ,CREATE,NON_DIRECTORY|WRITE_THROUGH);FileStream stream=null; + try + { + stream=new FileStream(result.Handle,FileAccess.ReadWrite,65536,false);result.Handle=null;FILE_INFO info=Info(stream.SafeFileHandle);ValidatePlain(info,stream.SafeFileHandle);Pin pin=new Pin(parent,leaf,Path.Combine(parent.Path,leaf),false,true,null,stream,info);stream=null; + 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");pin.Sealed=true;pin.Manifest=manifest;if(manifest)MakeReadable(pin);AddPin(pin);return new object[]{pin.Token,pin.Stream.Length,sha}; + } + finally { if(stream!=null)stream.Dispose();if(result.Handle!=null)result.Handle.Dispose(); } + } + + [MethodImpl(MethodImplOptions.Synchronized)] internal object CommitPointer(object generationToken,ulong previousGeneration,string runtimeId,byte[] bytes) { - Pin parent=Require(parentToken,true);ValidateCurrent(parent);string key=Key(parent,leaf);Pin p; - if(!names.TryGetValue(key,out p)){OpenResult r=OpenRelative(parent.Native,leaf,READ|WRITE|DELETE|SYNC,SHARE_READ,OPEN_IF,NON_DIRECTORY|WRITE_THROUGH);FileStream s=null;try{s=new FileStream(r.Handle,FileAccess.ReadWrite,65536,false);r.Handle=null;FILE_INFO i=Info(s.SafeFileHandle);ValidatePlain(i,s.SafeFileHandle);p=new Pin(parent,leaf,Path.Combine(parent.Path,leaf),false,r.Created,null,s,i);s=null;AddPin(key,p);}finally{if(s!=null)s.Dispose();if(r.Handle!=null)r.Handle.Dispose();}} - ValidateCurrent(p);p.Stream.Position=p.Stream.Length;p.Stream.Write(bytes,0,bytes.Length);p.Stream.Flush(true);return p.Token; + Pin generation=Require(generationToken,true);ValidateCurrent(generation);if(!Object.ReferenceEquals(generation.Parent,runtimeParent)||!String.Equals(generation.Leaf,runtimeId,StringComparison.Ordinal))throw new InvalidDataException("transaction scope"); + Pin manifest=null;if(!names.TryGetValue(Key(generation,"manifest.json"),out manifest)||!manifest.Manifest||!manifest.Sealed)throw new InvalidDataException("manifest missing");ValidateCurrent(manifest); + Pin state=OpenOrCreateDirectory(root,"state"),pointer=OpenOrCreateDirectory(state,"active-generation");if(CurrentPointerGeneration(pointer)!=previousGeneration)throw new InvalidOperationException("pointer generation exists");string leaf=(previousGeneration+1).ToString("D20")+".json";return WriteAtomic(pointer,leaf,bytes); } - [MethodImpl(MethodImplOptions.Synchronized)] internal object RemoveOwnedTree(object parentToken,string leaf) - {return RemoveOwnedTreeCore(parentToken,leaf,".__ccod_retired_"+Guid.NewGuid().ToString("N"));} - [MethodImpl(MethodImplOptions.Synchronized)] private object RemoveOwnedTreeCore(object parentToken,string leaf,string retiredLeaf) + + [MethodImpl(MethodImplOptions.Synchronized)] internal object Retire(object generationToken,string runtimeId,string retiredLeaf,byte[] record) { - Pin parent=Require(parentToken,true);ValidateCurrent(parent);Pin target;string key=Key(parent,leaf);if(!names.TryGetValue(key,out target)||!target.Owned||!target.Directory)throw new InvalidDataException("unknown target"); - List plan=new List();BuildPlan(target,plan);RevalidatePlan(target,plan); - if(plan.Count==0){int ec=SetDeleteOnClose(target.Native,true,true);if(ec!=0)throw new Win32Exception(ec);ValidateCurrent(target);RemovePin(target);return null;} - SafeFileHandle collision=null;try{try{collision=OpenRelative(parent.Native,retiredLeaf,READ_ATTRIBUTES|SYNC,SHARE_READ|SHARE_WRITE,OPEN,BACKUP_INTENT).Handle;}catch(Win32Exception x){if(x.NativeErrorCode!=2&&x.NativeErrorCode!=3)throw;}if(collision!=null)throw new IOException("retirement destination exists");}finally{if(collision!=null)collision.Dispose();} - List restricted=RestrictRetirementTree(target,plan);try{RevalidatePlan(target,plan);}catch{RestoreLiveDirectorySecurity(restricted);throw;}ReleaseDescendants(plan);ValidateCurrent(target);int rec=Rename(target.Native,parent.Native,retiredLeaf);if(rec!=0){RestoreDirectorySecurity(target.Native,target.OriginalSecurity);target.RestoreSecurity=false;RestoreReleasedChildSecurity(target);ReopenKnownTree(target);if(rec==80||rec==183)throw new IOException("retirement destination exists");throw new Win32Exception(rec);}MoveDirectoryPin(target,retiredLeaf,Path.Combine(parent.Path,retiredLeaf));RestoreReleasedChildSecurity(target);ValidateCurrent(target);return target.Token; + Pin generation=Require(generationToken,true);ValidateCurrent(generation);if(!Object.ReferenceEquals(generation.Parent,runtimeParent)||!String.Equals(generation.Leaf,runtimeId,StringComparison.Ordinal)||!generation.Owned)throw new InvalidDataException("generation not owned"); + ValidateOwnedTree(generation);Pin retired=OpenOrCreateDirectory(root,"retired"),state=OpenOrCreateDirectory(root,"state"),retirements=OpenOrCreateDirectory(state,"retirements");ReleaseDescendants(generation);int error=Rename(generation.Native,retired.Native,retiredLeaf);if(error!=0)throw new Win32Exception(error);MoveDirectoryPin(generation,retired,retiredLeaf,Path.Combine(retired.Path,retiredLeaf)); + WriteAtomic(retirements,retiredLeaf+".json",record);return generation.Token; } - [MethodImpl(MethodImplOptions.Synchronized)] internal void Close(){if(disposed)return;disposed=true;List all=new List(pins.Values);for(int i=all.Count-1;i>=0;i--)all[i].Dispose();pins.Clear();names.Clear();} + + [MethodImpl(MethodImplOptions.Synchronized)] internal void Close() + { if(disposed)return;disposed=true;foreach(FileStream stream in externalStreams)stream.Dispose();externalStreams.Clear();List all=new List(pins.Values);for(int i=all.Count-1;i>=0;i--)all[i].Dispose();pins.Clear();names.Clear(); } public void Dispose(){Close();} - private void BuildPlan(Pin dir,List plan){ValidateCurrent(dir);foreach(string n in Enumerate(dir.Native)){Pin p;string k=Key(dir,n);if(!names.TryGetValue(k,out p)||!p.Owned){ProbeUnknown(dir,n);throw new InvalidDataException("unknown leaf");}ValidateCurrent(p);if(p.Directory)BuildPlan(p,plan);plan.Add(p);}} - private static void ProbeUnknown(Pin dir,string name){OpenResult r=OpenRelative(dir.Native,name,READ_ATTRIBUTES|SYNC,SHARE_READ|SHARE_WRITE,OPEN,BACKUP_INTENT);try{FILE_INFO i=Info(r.Handle);if((i.FileAttributes&ATTR_REPARSE)!=0)throw new InvalidDataException("reparse leaf");if(!OnlyDefaultStream(r.Handle))throw new InvalidDataException("alternate stream");if(!IsDirectory(i)&&i.NumberOfLinks!=1)throw new InvalidDataException("multi-link");}finally{r.Handle.Dispose();}} - private void RevalidatePlan(Pin target,List plan){List second=new List();BuildPlan(target,second);if(second.Count!=plan.Count)throw new InvalidDataException("namespace changed");for(int i=0;i plan){foreach(Pin p in plan){if(p.Stream!=null){p.Stream.Dispose();p.Stream=null;}else if(p.Handle!=null){p.Handle.Dispose();p.Handle=null;}}} - private void ReopenKnownTree(Pin dir){List children=new List();foreach(Pin p in pins.Values)if(Object.ReferenceEquals(p.Parent,dir)&&!p.Closed)children.Add(p);foreach(Pin p in children){if(p.Directory){uint share=p.Owned?SHARE_READ:(SHARE_READ|SHARE_WRITE);OpenResult r=OpenRelative(dir.Native,p.Leaf,LIST_DIRECTORY|ADD_FILE|ADD_SUBDIRECTORY|READ_ATTRIBUTES|DELETE|SYNC,share,OPEN,DIRECTORY|BACKUP_INTENT);p.Handle=r.Handle;ValidateCurrent(p);ReopenKnownTree(p);}else{OpenResult r=OpenRelative(dir.Native,p.Leaf,READ|WRITE|DELETE|SYNC,SHARE_READ,OPEN,NON_DIRECTORY|WRITE_THROUGH);p.Stream=new FileStream(r.Handle,FileAccess.ReadWrite,65536,false);r.Handle=null;ValidateCurrent(p);}}} - private static void MakeReadable(Pin p){OpenResult bridge=OpenRelative(p.Parent.Native,p.Leaf,READ|READ_ATTRIBUTES|SYNC,SHARE_READ|SHARE_WRITE|SHARE_DELETE,OPEN,NON_DIRECTORY);FILE_INFO bi=Info(bridge.Handle);if(bi.VolumeSerialNumber!=p.Volume||(((ulong)bi.FileIndexHigh<<32)|bi.FileIndexLow)!=p.Index){bridge.Handle.Dispose();throw new InvalidDataException("pin changed");}p.Stream.Dispose();p.Stream=null;OpenResult strict=OpenRelative(p.Parent.Native,p.Leaf,READ|READ_ATTRIBUTES|SYNC,SHARE_READ|SHARE_WRITE,OPEN,NON_DIRECTORY);bridge.Handle.Dispose();p.Stream=new FileStream(strict.Handle,FileAccess.Read,65536,false);strict.Handle=null;} - private Pin Require(object token,bool directory){if(disposed)throw new ObjectDisposedException("transaction");Pin p;if(token==null||!pins.TryGetValue(token,out p)||p.Closed||p.Directory!=directory)throw new InvalidDataException("invalid capability");return p;} - private void AddPin(string key,Pin p){pins.Add(p.Token,p);names.Add(key,p);} - private static byte[] ReadDirectorySecurity(SafeFileHandle h){uint needed;GetKernelObjectSecurity(h,DACL_SECURITY_INFORMATION,null,0,out needed);int e=Marshal.GetLastWin32Error();if(needed==0)throw new Win32Exception(e);byte[] sd=new byte[needed];if(!GetKernelObjectSecurity(h,DACL_SECURITY_INFORMATION,sd,(uint)sd.Length,out needed))throw new Win32Exception(Marshal.GetLastWin32Error());return sd;} - private static byte[] RestrictRetirementDirectory(SafeFileHandle h){byte[] original=ReadDirectorySecurity(h);RawSecurityDescriptor raw=new RawSecurityDescriptor(original,0);RawAcl acl=raw.DiscretionaryAcl??new RawAcl(GenericAcl.AclRevision,1);int denyMask=0x00000002|0x00000004|0x00000010|0x00000040|0x00000100|0x00010000;acl.InsertAce(0,new CommonAce(AceFlags.None,AceQualifier.AccessDenied,denyMask,new SecurityIdentifier(WellKnownSidType.WorldSid,null),false,null));raw.DiscretionaryAcl=acl;raw.SetFlags(raw.ControlFlags|ControlFlags.DiscretionaryAclPresent|ControlFlags.DiscretionaryAclProtected);byte[] restricted=new byte[raw.BinaryLength];raw.GetBinaryForm(restricted,0);if(!SetKernelObjectSecurity(h,DACL_SECURITY_INFORMATION|PROTECTED_DACL_SECURITY_INFORMATION,restricted))throw new Win32Exception(Marshal.GetLastWin32Error());return original;} - private static void RestoreDirectorySecurity(SafeFileHandle h,byte[] original){if(original==null)return;RawSecurityDescriptor raw=new RawSecurityDescriptor(original,0);uint flags=DACL_SECURITY_INFORMATION|(((raw.ControlFlags&ControlFlags.DiscretionaryAclProtected)!=0)?PROTECTED_DACL_SECURITY_INFORMATION:UNPROTECTED_DACL_SECURITY_INFORMATION);if(!SetKernelObjectSecurity(h,flags,original))throw new Win32Exception(Marshal.GetLastWin32Error());} - private static List RestrictRetirementTree(Pin target,List plan){List restricted=new List();try{target.OriginalSecurity=RestrictRetirementDirectory(target.Native);target.RestoreSecurity=true;restricted.Add(target);foreach(Pin p in plan)if(p.Directory){p.OriginalSecurity=RestrictRetirementDirectory(p.Native);p.RestoreSecurity=true;restricted.Add(p);}return restricted;}catch{RestoreLiveDirectorySecurity(restricted);throw;}} - private static void RestoreLiveDirectorySecurity(List restricted){for(int i=restricted.Count-1;i>=0;i--){Pin p=restricted[i];RestoreDirectorySecurity(p.Native,p.OriginalSecurity);p.RestoreSecurity=false;p.OriginalSecurity=null;}} - private void RestoreReleasedChildSecurity(Pin dir){List children=new List();foreach(Pin p in pins.Values)if(Object.ReferenceEquals(p.Parent,dir)&&p.Directory&&!p.Closed)children.Add(p);foreach(Pin child in children){OpenResult r=OpenRelative(dir.Native,child.Leaf,READ_ATTRIBUTES|READ_CONTROL|WRITE_DAC|SYNC,SHARE_READ|SHARE_WRITE|SHARE_DELETE,OPEN,DIRECTORY|BACKUP_INTENT);RestoreDirectorySecurity(r.Handle,child.OriginalSecurity);child.RestoreSecurity=false;child.OriginalSecurity=null;child.Handle=r.Handle;RestoreReleasedChildSecurity(child);child.Handle.Dispose();child.Handle=null;}} - private static string Key(Pin p,string leaf){return p.Index.ToString("x16")+"|"+leaf.ToLowerInvariant();} - private static void Write(Pin p,byte[] b){p.Stream.Position=0;p.Stream.SetLength(0);p.Stream.Write(b,0,b.Length);p.Stream.Flush(true);} - private static void ValidateCurrent(Pin p){FILE_INFO i=Info(p.Native);if(i.VolumeSerialNumber!=p.Volume||(((ulong)i.FileIndexHigh<<32)|i.FileIndexLow)!=p.Index||IsDirectory(i)!=p.Directory||(i.FileAttributes&ATTR_REPARSE)!=0||!SamePath(FinalPath(p.Native),p.Path))throw new InvalidDataException("pin changed");if(!OnlyDefaultStream(p.Native))throw new InvalidDataException("alternate stream");if(!p.Directory&&i.NumberOfLinks!=1)throw new InvalidDataException("multi-link");} - private static void ValidatePlain(FILE_INFO i,SafeFileHandle h){if(IsDirectory(i))throw new InvalidDataException("file type");if((i.FileAttributes&ATTR_REPARSE)!=0)throw new InvalidDataException("reparse leaf");if(i.NumberOfLinks!=1)throw new InvalidDataException("multi-link");if(!OnlyDefaultStream(h))throw new InvalidDataException("alternate stream");} - private static bool IsDirectory(FILE_INFO i){return(i.FileAttributes&ATTR_DIRECTORY)!=0;} - private static FILE_INFO Info(SafeFileHandle h){FILE_INFO i;if(!GetFileInformationByHandle(h,out i))throw new Win32Exception(Marshal.GetLastWin32Error());return i;} - private static string FinalPath(SafeFileHandle h){StringBuilder b=new StringBuilder(512);uint n=GetFinalPathNameByHandleW(h,b,(uint)b.Capacity,0);if(n==0)throw new Win32Exception(Marshal.GetLastWin32Error());if(n>=b.Capacity){b.Capacity=(int)n+1;n=GetFinalPathNameByHandleW(h,b,(uint)b.Capacity,0);if(n==0||n>=b.Capacity)throw new Win32Exception(Marshal.GetLastWin32Error());}string s=b.ToString();if(s.StartsWith(@"\\?\UNC\",StringComparison.OrdinalIgnoreCase))return@"\\"+s.Substring(8);if(s.StartsWith(@"\\?\",StringComparison.OrdinalIgnoreCase))return s.Substring(4);return s;} - private static bool SamePath(string a,string b){return String.Equals(Path.GetFullPath(a).TrimEnd('\\'),Path.GetFullPath(b).TrimEnd('\\'),StringComparison.OrdinalIgnoreCase);} - private static string Sha(FileStream s){long p=s.Position;using(SHA256 h=SHA256.Create()){s.Position=0;string v=BitConverter.ToString(h.ComputeHash(s)).Replace("-","").ToLowerInvariant();s.Position=p;return v;}} - private static bool OnlyDefaultStream(SafeFileHandle h){IntPtr b=Marshal.AllocHGlobal(65536);try{if(!GetFileInformationByHandleEx(h,FileStreamInfo,b,65536)){int e=Marshal.GetLastWin32Error();if(e==38)return true;throw new Win32Exception(e);}int o=0;while(true){uint nx=(uint)Marshal.ReadInt32(b,o),nl=(uint)Marshal.ReadInt32(b,o+4);string n=Marshal.PtrToStringUni(IntPtr.Add(b,o+24),(int)nl/2);if(!String.Equals(n,"::$DATA",StringComparison.OrdinalIgnoreCase)&&!String.Equals(n,"::$INDEX_ALLOCATION",StringComparison.OrdinalIgnoreCase))return false;if(nx==0)break;o+=(int)nx;}return true;}finally{Marshal.FreeHGlobal(b);}} - private static string[] Enumerate(SafeFileHandle h){List r=new List();IntPtr b=Marshal.AllocHGlobal(65536);bool restart=true;try{while(true){IO_STATUS_BLOCK io;int st=NtQueryDirectoryFile(h,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,out io,b,65536,FileDirectoryInformation,false,IntPtr.Zero,restart);restart=false;if(st==STATUS_NO_MORE_FILES)break;if(st<0)throw new Win32Exception((int)RtlNtStatusToDosError(st));int o=0;while(true){uint nx=(uint)Marshal.ReadInt32(b,o),nl=(uint)Marshal.ReadInt32(b,o+60);string n=Marshal.PtrToStringUni(IntPtr.Add(b,o+64),(int)nl/2);if(n!="."&&n!="..")r.Add(n);if(nx==0)break;o+=(int)nx;}}return r.ToArray();}finally{Marshal.FreeHGlobal(b);}} - private sealed class OpenResult{internal SafeFileHandle Handle;internal bool Created;internal OpenResult(SafeFileHandle h,bool c){Handle=h;Created=c;}} - private static OpenResult OpenRelative(SafeFileHandle parent,string name,uint access,uint share,uint disposition,uint options) - {IntPtr nb=IntPtr.Zero,up=IntPtr.Zero;bool add=false;try{parent.DangerousAddRef(ref add);nb=Marshal.StringToHGlobalUni(name);UNICODE_STRING u=new UNICODE_STRING{Length=(ushort)(name.Length*2),MaximumLength=(ushort)((name.Length+1)*2),Buffer=nb};up=Marshal.AllocHGlobal(Marshal.SizeOf(typeof(UNICODE_STRING)));Marshal.StructureToPtr(u,up,false);OBJECT_ATTRIBUTES o=new OBJECT_ATTRIBUTES{Length=Marshal.SizeOf(typeof(OBJECT_ATTRIBUTES)),RootDirectory=parent.DangerousGetHandle(),ObjectName=up,Attributes=OBJ_CASE_INSENSITIVE};IO_STATUS_BLOCK io;IntPtr raw;int st=NtCreateFile(out raw,access,ref o,out io,IntPtr.Zero,0,share,disposition,options|SYNC_IO|OPEN_REPARSE,IntPtr.Zero,0);if(st<0)throw new Win32Exception((int)RtlNtStatusToDosError(st));return new OpenResult(new SafeFileHandle(raw,true),io.Information.ToUInt64()==2);}finally{if(up!=IntPtr.Zero)Marshal.FreeHGlobal(up);if(nb!=IntPtr.Zero)Marshal.FreeHGlobal(nb);if(add)parent.DangerousRelease();}} - private static int Rename(SafeFileHandle source,SafeFileHandle parent,string destination) - {byte[] n=Encoding.Unicode.GetBytes(destination);int ro=IntPtr.Size,lo=ro+IntPtr.Size,no=lo+4,size=no+n.Length+2;IntPtr b=Marshal.AllocHGlobal(size);bool pa=false,sa=false;try{source.DangerousAddRef(ref sa);parent.DangerousAddRef(ref pa);for(int i=0;i=0?0:(int)RtlNtStatusToDosError(st);}finally{if(pa)parent.DangerousRelease();if(sa)source.DangerousRelease();Marshal.FreeHGlobal(b);}} - private static int SetDelete(SafeFileHandle h,bool delete){IntPtr b=Marshal.AllocHGlobal(1);try{Marshal.WriteByte(b,delete?(byte)1:(byte)0);IO_STATUS_BLOCK io;int st=NtSetInformationFile(h,out io,b,1,FileDispositionInformation);return st>=0?0:(int)RtlNtStatusToDosError(st);}finally{Marshal.FreeHGlobal(b);}} - private static int SetDeleteOnClose(SafeFileHandle h,bool delete,bool directory){IntPtr b=Marshal.AllocHGlobal(4);try{Marshal.WriteInt32(b,delete?1:0);return SetFileInformationByHandle(h,21,b,4)?0:Marshal.GetLastWin32Error();}finally{Marshal.FreeHGlobal(b);}} -} -'@ + 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(); } } - $script:CcodRuntimeType = [CcodInstallCapabilityMarker].Assembly.GetType('CcodInstallRuntime', $true) -} -function Assert-CcodLeaf([string]$Leaf) { - if ([string]::IsNullOrWhiteSpace($Leaf) -or $Leaf.Length -gt 160 -or $Leaf -in @('.','..') -or $Leaf -notmatch '^[A-Za-z0-9._-]+$' -or $Leaf.EndsWith('.') -or $Leaf -match '^(?i:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)') { Throw-CcodInstallFileError 'CCOD_INSTALL_LEAF_INVALID' '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 Get-CcodTransactionState($Transaction) { $state=$null;if($null -eq $Transaction -or -not $script:CcodTransactions.TryGetValue($Transaction,[ref]$state)){Throw-CcodInstallFileError 'CCOD_INSTALL_TRANSACTION_INVALID' 'Invalid install transaction capability' $null};if($state.Closed){Throw-CcodInstallFileError 'CCOD_INSTALL_TRANSACTION_CLOSED' 'Install transaction is closed' $null};return $state } -function Get-CcodPinToken($Transaction,$Pin,[string]$Kind) { $state=Get-CcodTransactionState $Transaction;$record=$null;if($null -eq $Pin -or -not $script:CcodPins.TryGetValue($Pin,[ref]$record) -or -not [object]::ReferenceEquals($record.Transaction,$Transaction) -or $record.Kind -cne $Kind){Throw-CcodInstallFileError 'CCOD_INSTALL_PIN_INVALID' 'Invalid install pin capability' $null};return [pscustomobject]@{State=$state;Token=$record.Token;Record=$record} } -function New-CcodCapability { New-Object psobject } -function Add-CcodPinCapability($Transaction,$Token,[string]$Kind) {$cap=New-CcodCapability;$script:CcodPins.Add($cap,[pscustomobject]@{Transaction=$Transaction;Token=$Token;Kind=$Kind});return $cap} -function Convert-CcodRuntimeError($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 ($message -ceq 'destination exists') { Throw-CcodInstallFileError 'CCOD_INSTALL_PROMOTION_DESTINATION_EXISTS' $message $null } - if ($message -ceq 'retirement destination exists') { Throw-CcodInstallFileError 'CCOD_INSTALL_RETIREMENT_DESTINATION_EXISTS' $message $null } - if ($exception -is [ObjectDisposedException]) { Throw-CcodInstallFileError 'CCOD_INSTALL_TRANSACTION_CLOSED' $message $null } - if ($exception -is [Security.Cryptography.CryptographicException] -or $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 'reparse') { Throw-CcodInstallFileError 'CCOD_INSTALL_REPARSE_LEAF' $message $null } - if ($message -match 'multi-link') { Throw-CcodInstallFileError 'CCOD_INSTALL_MULTILINK_LEAF' $message $null } - if ($message -match 'unknown|namespace') { 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 + 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");ValidateCurrent(child);if(child.Directory)ValidateOwnedTree(child);else if(!child.Sealed)throw new InvalidDataException("unsealed leaf");} + } + private object WriteAtomic(Pin parent,string leaf,byte[] bytes) + { + string temporary=".ccod."+Guid.NewGuid().ToString("N")+".tmp";object[] result=(object[])Write(parent.Token,temporary,bytes,false);Pin pin=Require(result[0],false);int error=Rename(pin.Native,parent.Native,leaf);if(error!=0)throw new Win32Exception(error);names.Remove(Key(parent,temporary));pin.Leaf=leaf;pin.Path=Path.Combine(parent.Path,leaf);names.Add(Key(parent,leaf),pin);MakeReadable(pin);return result; + } + private static ulong CurrentPointerGeneration(Pin pointer) + { + ulong maximum=0;foreach(string leaf in Enumerate(pointer.Native)){if(leaf.StartsWith(".ccod.",StringComparison.OrdinalIgnoreCase)&&leaf.EndsWith(".tmp",StringComparison.OrdinalIgnoreCase))continue;if(leaf.Length!=25||!leaf.EndsWith(".json",StringComparison.OrdinalIgnoreCase))throw new InvalidDataException("unknown pointer leaf");ulong value;if(!UInt64.TryParse(leaf.Substring(0,20),out value)||value==0)throw new InvalidDataException("unknown pointer leaf");if(value>maximum)maximum=value;}return maximum; } + private void ReleaseDescendants(Pin directory) + { + foreach(Pin pin in pins.Values)if(!pin.Closed&&!Object.ReferenceEquals(pin,directory)&&pin.Path.StartsWith(directory.Path.TrimEnd('\\')+"\\",StringComparison.OrdinalIgnoreCase))pin.Dispose(); + } + 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){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 void MoveDirectoryPin(Pin directory,Pin parent,string leaf,string path){string oldPath=directory.Path;names.Remove(Key(directory.Parent,directory.Leaf));directory.Leaf=leaf;directory.Path=path;names.Add(Key(parent,leaf),directory);string prefix=oldPath.TrimEnd('\\')+"\\";foreach(Pin pin in pins.Values)if(!Object.ReferenceEquals(pin,directory)&&pin.Path.StartsWith(prefix,StringComparison.OrdinalIgnoreCase))pin.Path=path+"\\"+pin.Path.Substring(prefix.Length);} + 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 static void MakeReadable(Pin pin){OpenResult bridge=OpenRelative(pin.Parent.Native,pin.Leaf,READ|READ_ATTRIBUTES|SYNC,SHARE_READ|SHARE_WRITE|SHARE_DELETE,OPEN,NON_DIRECTORY);FILE_INFO bridgeInfo=Info(bridge.Handle);if(bridgeInfo.VolumeSerialNumber!=pin.Volume||(((ulong)bridgeInfo.FileIndexHigh<<32)|bridgeInfo.FileIndexLow)!=pin.Index){bridge.Handle.Dispose();throw new InvalidDataException("pin changed");}pin.Stream.Dispose();pin.Stream=null;OpenResult strict=OpenRelative(pin.Parent.Native,pin.Leaf,READ|READ_ATTRIBUTES|SYNC,SHARE_READ,OPEN,NON_DIRECTORY);bridge.Handle.Dispose();pin.Stream=new FileStream(strict.Handle,FileAccess.Read,65536,false);strict.Handle=null;} + 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 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 string Sha(FileStream stream){long position=stream.Position;using(SHA256 hash=SHA256.Create()){stream.Position=0;string value=BitConverter.ToString(hash.ComputeHash(stream)).Replace("-","").ToLowerInvariant();stream.Position=position;return value;}} + 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);}} + 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);}} +} +'@ + $script:CcodRuntimeType = [CcodInstallCapabilityMarker].Assembly.GetType('CcodInstallRuntime',$true) } -function Open-CcodInstallFileTransaction { 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);$root=Convert-CcodRuntimeError {Invoke-CcodRuntimeStatic Open $arguments} 'CCOD_INSTALL_ROOT_INVALID';$runtime=$arguments[1];$tx=New-CcodCapability;$rootCap=Add-CcodPinCapability $tx $root 'Directory';$tx|Add-Member -NotePropertyName RootDirectory -NotePropertyValue $rootCap;$script:CcodTransactions.Add($tx,[pscustomobject]@{Runtime=$runtime;Closed=$false;Root=$rootCap;Disposition=$null});return $tx } -function Open-CcodInstallPinnedDirectory { param([Parameter(Mandatory)]$Transaction,[Parameter(Mandatory)]$ParentDirectory,[Parameter(Mandatory)][string]$Leaf,[switch]$CreateIfMissing) Assert-CcodLeaf $Leaf;$p=Get-CcodPinToken $Transaction $ParentDirectory Directory;$token=Convert-CcodRuntimeError {Invoke-CcodRuntimeMethod $p.State.Runtime OpenDirectory @($p.Token,$Leaf,[bool]$CreateIfMissing)} 'CCOD_INSTALL_REPARSE_LEAF';return Add-CcodPinCapability $Transaction $token Directory } -function New-CcodInstallPinnedTemporaryLeaf { param([Parameter(Mandatory)]$Transaction,[Parameter(Mandatory)]$ParentDirectory,[Parameter(Mandatory)][string]$Leaf) Assert-CcodLeaf $Leaf;$p=Get-CcodPinToken $Transaction $ParentDirectory Directory;$token=Convert-CcodRuntimeError {Invoke-CcodRuntimeMethod $p.State.Runtime CreateTemporary @($p.Token,$Leaf)} 'CCOD_INSTALL_TEMPORARY_EXISTS';return Add-CcodPinCapability $Transaction $token File } -function Copy-CcodInstallSealedFile { param([Parameter(Mandatory)]$Transaction,[Parameter(Mandatory)][string]$SourcePath,[Parameter(Mandatory)]$DestinationLeaf,[Parameter(Mandatory)][int64]$ExpectedLength,[Parameter(Mandatory)][string]$ExpectedSha256) if(-not [IO.Path]::IsPathRooted($SourcePath)-or$ExpectedSha256-cnotmatch'^[0-9a-f]{64}$'){Throw-CcodInstallFileError 'CCOD_INSTALL_SOURCE_INVALID' 'Invalid source contract' $SourcePath};$p=Get-CcodPinToken $Transaction $DestinationLeaf File;Convert-CcodRuntimeError {Invoke-CcodRuntimeMethod $p.State.Runtime Copy @($p.Token,[IO.Path]::GetFullPath($SourcePath),$ExpectedLength,$ExpectedSha256)|Out-Null} 'CCOD_INSTALL_SOURCE_MISMATCH';[pscustomobject]@{Length=$ExpectedLength;Sha256=$ExpectedSha256} } -function Commit-CcodInstallPinnedPromotion { param([Parameter(Mandatory)]$Transaction,[Parameter(Mandatory)]$ParentDirectory,[Parameter(Mandatory)]$TemporaryLeaf,[Parameter(Mandatory)][string]$DestinationLeaf) Assert-CcodLeaf $DestinationLeaf;$p=Get-CcodPinToken $Transaction $ParentDirectory Directory;$t=Get-CcodPinToken $Transaction $TemporaryLeaf File;$newToken=Convert-CcodRuntimeError {Invoke-CcodRuntimeMethod $p.State.Runtime Promote @($p.Token,$t.Token,$DestinationLeaf)} 'CCOD_INSTALL_PROMOTION_FAILED';$t.Record.Token=$newToken;return $TemporaryLeaf } -function Write-CcodInstallPinnedJson { param([Parameter(Mandatory)]$Transaction,[Parameter(Mandatory)]$ParentDirectory,[Parameter(Mandatory)][string]$Leaf,[Parameter(Mandatory)]$Value,[switch]$Compress) Assert-CcodLeaf $Leaf;$p=Get-CcodPinToken $Transaction $ParentDirectory Directory;$bytes=[Text.UTF8Encoding]::new($false).GetBytes(($Value|ConvertTo-Json -Depth 32 -Compress:$Compress)+"`n");$token=Convert-CcodRuntimeError {Invoke-CcodRuntimeMethod $p.State.Runtime WriteJson @($p.Token,$Leaf,$bytes)} 'CCOD_INSTALL_PROMOTION_FAILED';return Add-CcodPinCapability $Transaction $token File } -function Test-CcodSanitized($Value,[int]$Depth=0) { - if ($Depth -gt 8) { return $false } +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'){$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){Throw-CcodInstallFileError 'CCOD_INSTALL_GENERATION_RETIRED' 'Install generation is retired' $null};[pscustomobject]@{State=$state;Record=$record}} +function Add-CcodInstallScope($Transaction,$Token){$capability=New-CcodInstallCapability;$script:CcodScopes.Add($capability,[pscustomobject]@{Transaction=$Transaction;Token=$Token});$capability} +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($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'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 [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-CcodSanitized $Value[$key] ($Depth + 1))) { return $false } + 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-CcodSanitized $property.Value ($Depth + 1))) { return $false } + 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 Append-CcodInstallPinnedLog { param([Parameter(Mandatory)]$Transaction,[Parameter(Mandatory)]$ParentDirectory,[Parameter(Mandatory)][string]$Leaf,[Parameter(Mandatory)]$Record) Assert-CcodLeaf $Leaf;if(-not(Test-CcodSanitized $Record)){Throw-CcodInstallFileError 'CCOD_INSTALL_LOG_RECORD_INVALID' 'Invalid log record' $null};$p=Get-CcodPinToken $Transaction $ParentDirectory Directory;$bytes=[Text.UTF8Encoding]::new($false).GetBytes(($Record|ConvertTo-Json -Depth 8 -Compress)+"`n");if($bytes.Length-gt65536){Throw-CcodInstallFileError 'CCOD_INSTALL_LOG_RECORD_INVALID' 'Log record too large' $null};$token=Convert-CcodRuntimeError {Invoke-CcodRuntimeMethod $p.State.Runtime Append @($p.Token,$Leaf,$bytes)} 'CCOD_INSTALL_LOG_WRITE_FAILED';return Add-CcodPinCapability $Transaction $token File } -function Remove-CcodInstallOwnedTree { param([Parameter(Mandatory)]$Transaction,[Parameter(Mandatory)]$ParentDirectory,[Parameter(Mandatory)][string]$Leaf) Assert-CcodLeaf $Leaf;$p=Get-CcodPinToken $Transaction $ParentDirectory Directory;$token=Convert-CcodRuntimeError {Invoke-CcodRuntimeMethod $p.State.Runtime RemoveOwnedTree @($p.Token,$Leaf)} 'CCOD_INSTALL_RETIREMENT_FAILED';if($null -eq $token){return [pscustomobject]@{Disposition='Deleted';Object=$null}};$capability=Add-CcodPinCapability $Transaction $token Directory;return [pscustomobject]@{Disposition='Retired';Object=$capability} } -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 transaction capability' $null};if(-not$state.Closed){Invoke-CcodRuntimeMethod $state.Runtime Close @()|Out-Null;$state.Closed=$true;$state.Disposition=$Disposition} } +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 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;Closed=$false;Retired=$false;Disposition=$null});$script:CcodScopes.Add($transaction,[pscustomobject]@{Transaction=$transaction;Token=$token});$transaction +} +function New-CcodInstallGenerationLeaf { param([Parameter(Mandatory)]$Generation,[Parameter(Mandatory)][string]$Leaf) Assert-CcodInstallLeaf $Leaf;$scope=Get-CcodInstallTransaction $Generation;$token=Convert-CcodInstallRuntimeError {Invoke-CcodRuntimeMethod $scope.State.Runtime CreateDirectory @($scope.Record.Token,$Leaf)} 'CCOD_INSTALL_LEAF_CREATE_FAILED';Add-CcodInstallScope $scope.Record.Transaction $token } +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;Convert-CcodInstallRuntimeError {Invoke-CcodRuntimeMethod $scope.State.Runtime Copy @($scope.Record.Token,$Leaf,[IO.Path]::GetFullPath($SourcePath),$ExpectedLength,$ExpectedSha256)|Out-Null} 'CCOD_INSTALL_SOURCE_MISMATCH';[pscustomobject]@{Length=$ExpectedLength;Sha256=$ExpectedSha256} } +function Write-CcodInstallGenerationManifest { param([Parameter(Mandatory)]$Generation,[Parameter(Mandatory)]$Manifest) $scope=Get-CcodInstallTransaction $Generation;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=Convert-CcodInstallRuntimeError {Invoke-CcodRuntimeMethod $scope.State.Runtime Write @($scope.Record.Token,'manifest.json',$bytes,$true)} 'CCOD_INSTALL_MANIFEST_WRITE_FAILED';[pscustomobject]@{Length=[int64]$result[1];Sha256=[string]$result[2]} } +function Commit-CcodInstallActivePointer { param([Parameter(Mandatory)][string]$InstallRoot,[Parameter(Mandatory)][uint64]$ExpectedPreviousGeneration,[Parameter(Mandatory)][string]$NewRuntimeId,[Parameter(Mandatory)]$FileTransaction) Assert-CcodInstallLeaf $NewRuntimeId 'CCOD_INSTALL_RUNTIME_ID_INVALID';$scope=Get-CcodInstallTransaction $FileTransaction 'CCOD_INSTALL_TRANSACTION_INVALID';Assert-CcodInstallRootScope $scope.State $InstallRoot 'CCOD_INSTALL_TRANSACTION_SCOPE';$generation=$ExpectedPreviousGeneration+1;$pointer=[ordered]@{schemaVersion=1;generation=$generation;activeRuntime=$NewRuntimeId;previousGeneration=$ExpectedPreviousGeneration};$bytes=ConvertTo-CcodInstallJsonBytes $pointer;try{Convert-CcodInstallRuntimeError {Invoke-CcodRuntimeMethod $scope.State.Runtime CommitPointer @($scope.Record.Token,$ExpectedPreviousGeneration,$NewRuntimeId,$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};[pscustomobject]@{Generation=[uint64]$generation;RuntimeId=$NewRuntimeId} } +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';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};$retiredLeaf=$RuntimeId+'.'+[guid]::NewGuid().ToString('N');$record=ConvertTo-CcodInstallJsonBytes ([ordered]@{schemaVersion=1;runtimeId=$RuntimeId;retiredName=$retiredLeaf});$token=Convert-CcodInstallRuntimeError {Invoke-CcodRuntimeMethod $scope.State.Runtime Retire @($scope.Record.Token,$RuntimeId,$retiredLeaf,$record)} 'CCOD_INSTALL_RETIREMENT_FAILED';$capability=Add-CcodInstallScope $scope.Record.Transaction $token;$scope.State.Retired=$true;[pscustomobject]@{Disposition='Retired';Capability=$capability} } +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(-not$state.Closed){Invoke-CcodRuntimeMethod $state.Runtime Close @()|Out-Null;$state.Closed=$true;$state.Disposition=$Disposition} } -Export-ModuleMember -Function Open-CcodInstallFileTransaction,Open-CcodInstallPinnedDirectory,New-CcodInstallPinnedTemporaryLeaf,Copy-CcodInstallSealedFile,Commit-CcodInstallPinnedPromotion,Write-CcodInstallPinnedJson,Append-CcodInstallPinnedLog,Remove-CcodInstallOwnedTree,Close-CcodInstallFileTransaction +Initialize-CcodInstallRuntime +Export-ModuleMember -Function Open-CcodInstallGeneration,New-CcodInstallGenerationLeaf,Copy-CcodInstallSealedSource,Write-CcodInstallGenerationManifest,Commit-CcodInstallActivePointer,Retire-CcodInstallGeneration,Close-CcodInstallFileTransaction diff --git a/tests/PersistenceSelfTest.ps1 b/tests/PersistenceSelfTest.ps1 index aafd8aa..a67276f 100644 --- a/tests/PersistenceSelfTest.ps1 +++ b/tests/PersistenceSelfTest.ps1 @@ -14,11 +14,11 @@ if ($PSVersionTable.PSEdition -eq 'Desktop') { } $powershell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' -$requiredTransactionSelfTest = Join-Path $PSScriptRoot 'persistence\InstallFileTransaction.SelfTest.ps1' -if (-not (Test-Path -LiteralPath $requiredTransactionSelfTest -PathType Leaf)) { - throw "Required install file transaction self-test is missing: $requiredTransactionSelfTest" -} $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 d1533e2..7e62d03 100644 --- a/tests/Validate.ps1 +++ b/tests/Validate.ps1 @@ -24,6 +24,7 @@ $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' function Get-CcodValidationSafeChildMarkers { param($Output) @@ -94,6 +95,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 diff --git a/tests/persistence/InstallFileTransaction.SelfTest.ps1 b/tests/persistence/InstallFileTransaction.SelfTest.ps1 index 814a443..1495975 100644 --- a/tests/persistence/InstallFileTransaction.SelfTest.ps1 +++ b/tests/persistence/InstallFileTransaction.SelfTest.ps1 @@ -3,135 +3,7 @@ $ErrorActionPreference = 'Stop' $projectRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent $modulePath = Join-Path $projectRoot 'src\persistence\modules\InstallFileTransaction.psm1' -$module = $null -if (Test-Path -LiteralPath $modulePath -PathType Leaf) { - $module = Import-Module $modulePath -Force -PassThru -DisableNameChecking -} - -if ($null -eq ('CcodInstallTransactionTestNative' -as [type])) { - Add-Type -TypeDefinition @' -using System; -using System.ComponentModel; -using System.IO; -using System.Runtime.InteropServices; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Win32.SafeHandles; - -public static class CcodInstallTransactionTestNative -{ - private const uint FILE_LIST_DIRECTORY = 0x00000001; - private const uint FILE_READ_ATTRIBUTES = 0x00000080; - private const uint DELETE = 0x00010000; - private const uint GENERIC_READ = 0x80000000; - private const uint GENERIC_WRITE = 0x40000000; - private const uint FILE_SHARE_READ = 0x00000001; - private const uint FILE_SHARE_WRITE = 0x00000002; - private const uint FILE_SHARE_DELETE = 0x00000004; - private const uint OPEN_EXISTING = 3; - private const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000; - private const uint FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000; - private const uint FILE_FLAG_WRITE_THROUGH = 0x80000000; - - [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", CharSet = CharSet.Unicode, SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool CreateHardLinkW(string newName, string existingName, IntPtr securityAttributes); - - public static int CreateHardLink(string newName, string existingName) - { - return CreateHardLinkW(newName, existingName, IntPtr.Zero) ? 0 : Marshal.GetLastWin32Error(); - } - - public static SafeFileHandle OpenDirectoryAllowDeleteShare(string path) - { - SafeFileHandle handle = CreateFileW(path, FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES | DELETE, - FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, IntPtr.Zero, OPEN_EXISTING, - FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, IntPtr.Zero); - if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error()); - return handle; - } - - public static int TryOpenDirectoryForChildWrite(string path) - { - using (SafeFileHandle handle = CreateFileW(path, 0x00000002 | 0x00000004 | FILE_READ_ATTRIBUTES, - FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, IntPtr.Zero, OPEN_EXISTING, - FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, IntPtr.Zero)) - { - return handle.IsInvalid ? Marshal.GetLastWin32Error() : 0; - } - } - - public static SafeFileHandle OpenFileAllowDeleteShare(string path) - { - SafeFileHandle handle = CreateFileW(path, GENERIC_READ | GENERIC_WRITE | DELETE | FILE_READ_ATTRIBUTES, - FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, IntPtr.Zero, OPEN_EXISTING, - FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_WRITE_THROUGH, IntPtr.Zero); - if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error()); - return handle; - } - - public static Task InsertUnknownWhenLeafBecomesUnavailable(string watchedLeaf, string unknownLeaf) - { - return Task.Run(() => { - DateTime deadline = DateTime.UtcNow.AddSeconds(10); - while (DateTime.UtcNow < deadline) - { - if (!File.Exists(watchedLeaf)) - { - try { File.WriteAllText(unknownLeaf, "concurrent-unknown"); return "inserted"; } - catch (IOException) { return "blocked"; } - catch (UnauthorizedAccessException) { return "blocked"; } - } - Thread.Yield(); - } - return "timeout"; - }); - } - - public static Task InsertUnknownAfterDelay(string unknownLeaf, int delayMilliseconds) - { - return Task.Run(() => { - Thread.Sleep(delayMilliseconds); - try { File.WriteAllText(unknownLeaf, "concurrent-unknown"); return "inserted"; } - catch (IOException) { return "blocked"; } - catch (UnauthorizedAccessException) { return "blocked"; } - }); - } - - public static Task ProbeNamespaceAfterLeafUnlock(string watchedLeaf, string directory, string hardLinkSource, string hardLinkDestination) - { - return Task.Run(() => { - DateTime deadline = DateTime.UtcNow.AddSeconds(10); - while (DateTime.UtcNow < deadline) - { - try - { - using (FileStream stream = File.Open(watchedLeaf, FileMode.Open, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete)) { } - int directoryError = TryOpenDirectoryForChildWrite(directory); - int linkError = CreateHardLink(hardLinkDestination, hardLinkSource); - return directoryError != 0 && linkError != 0 ? "blocked" : "escaped"; - } - catch (IOException) { } - catch (UnauthorizedAccessException) { } - if (!Directory.Exists(directory)) return "retired-before-probe"; - Thread.Yield(); - } - return "timeout"; - }); - } -} -'@ -} - -function Invoke-CcodInstallModule { - param([Parameter(Mandatory)][scriptblock]$Action, [object[]]$Arguments = @()) - if ($null -eq $module) { return & $Action @Arguments } - return & $module $Action @Arguments -} +$module = Import-Module $modulePath -Force -PassThru -DisableNameChecking function New-CcodInstallFileFixture { $base = Join-Path ([IO.Path]::GetTempPath()) ('ccod-install-file-' + [guid]::NewGuid().ToString('N')) @@ -140,693 +12,201 @@ function New-CcodInstallFileFixture { [IO.Directory]::CreateDirectory($install) | Out-Null [IO.Directory]::CreateDirectory($outside) | Out-Null $sentinel = Join-Path $outside 'sentinel.txt' - $sentinelBytes = [Text.UTF8Encoding]::new($false).GetBytes('outside-sentinel-v1') - [IO.File]::WriteAllBytes($sentinel, $sentinelBytes) - return [pscustomobject]@{ - Base = $base - MovedBase = $null - Install = $install - Outside = $outside - Sentinel = $sentinel - SentinelSha256 = Get-CcodTestFileSha256 -Path $sentinel - Transaction = $null - } -} - -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 -Path $Fixture.Sentinel) "$Message sentinel bytes" -} - -function Close-CcodFixtureTransaction { - param([Parameter(Mandatory)]$Fixture) - if ($null -eq $Fixture.Transaction) { return } - try { - Invoke-CcodInstallModule { param($Tx) Close-CcodInstallFileTransaction -Transaction $Tx -Disposition Failed } @($Fixture.Transaction) | Out-Null - } catch { } - $Fixture.Transaction = $null + [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) - Close-CcodFixtureTransaction -Fixture $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.MovedBase, $Fixture.Outside)) { - if ([string]::IsNullOrWhiteSpace([string]$candidate)) { continue } + 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 (-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-CcodFixtureTransaction { - param([Parameter(Mandatory)]$Fixture) - $Fixture.Transaction = Invoke-CcodInstallModule { param($Install) Open-CcodInstallFileTransaction -InstallRoot $Install } @($Fixture.Install) - return $Fixture.Transaction -} - -function Invoke-CcodAttemptDirectoryReplacement { - param([Parameter(Mandatory)][string]$Path) - $backup = $Path + '.attacker-old' - try { - [IO.Directory]::Move($Path, $backup) - return [pscustomobject]@{ Outcome = 'replaced'; Backup = $backup } - } catch [IO.IOException] { - return [pscustomobject]@{ Outcome = 'blocked'; Backup = $null } - } catch [UnauthorizedAccessException] { - return [pscustomobject]@{ Outcome = 'blocked'; Backup = $null } - } -} - -function Invoke-CcodAttemptFileReplacement { - param([Parameter(Mandatory)][string]$Path) - $backup = $Path + '.attacker-old' - try { - [IO.File]::Move($Path, $backup) - return [pscustomobject]@{ Outcome = 'replaced'; Backup = $backup } - } catch [IO.IOException] { - return [pscustomobject]@{ Outcome = 'blocked'; Backup = $null } - } catch [UnauthorizedAccessException] { - return [pscustomobject]@{ Outcome = 'blocked'; Backup = $null } - } -} - -function Invoke-CcodReplacePinnedDirectory { - param( - [Parameter(Mandatory)]$Fixture, - [Parameter(Mandatory)][string]$Path - ) - $moved = $Path + '.attacker-old' - try { - [IO.Directory]::Move($Path, $moved) - } catch [IO.IOException] { - return [pscustomobject]@{ Outcome = 'blocked'; MovedDirectory = $null } - } catch [UnauthorizedAccessException] { - return [pscustomobject]@{ Outcome = 'blocked'; MovedDirectory = $null } - } - $result = & cmd.exe /d /c mklink /J $Path $Fixture.Outside 2>&1 - if ($LASTEXITCODE -ne 0) { throw "Could not create replacement junction: $result" } - return [pscustomobject]@{ Outcome = 'replaced'; MovedDirectory = $moved } -} - -function New-CcodHardLink { - param([Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$Existing) - $errorCode = [CcodInstallTransactionTestNative]::CreateHardLink($Path, $Existing) - if ($errorCode -ne 0) { throw "CreateHardLink failed with Windows error $errorCode" } -} - -function New-CcodJunction { - param([Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$Target) - $result = & cmd.exe /d /c mklink /J $Path $Target 2>&1 - if ($LASTEXITCODE -ne 0) { throw "Could not create junction: $result" } -} - -function Invoke-CcodAttemptAdsWrite { - param([Parameter(Mandatory)][string]$Path) - $previous = $ErrorActionPreference - try { - $ErrorActionPreference = 'Continue' - $output = & cmd.exe /d /c "echo attacker>`"$Path`"" 2>&1 - $exitCode = $LASTEXITCODE - } finally { $ErrorActionPreference = $previous } - return [pscustomobject]@{ Outcome = $(if ($exitCode -eq 0) { 'created' } else { 'blocked' }); Output = $output } +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, [string]$Name = 'source.bin', [string]$Content = 'sealed-source-v1') + param([Parameter(Mandatory)]$Fixture,[Parameter(Mandatory)][string]$Name,[Parameter(Mandatory)][string]$Content) $path = Join-Path $Fixture.Base $Name - $bytes = [Text.UTF8Encoding]::new($false).GetBytes($Content) - [IO.File]::WriteAllBytes($path, $bytes) - return [pscustomobject]@{ Path = $path; Length = [int64]$bytes.LongLength; Sha256 = Get-CcodTestFileSha256 -Path $path } -} - -Invoke-CcodTest 'pins a newly created child directory against pathname replacement' { - $fixture = New-CcodInstallFileFixture - try { - $tx = Open-CcodFixtureTransaction $fixture - $child = Invoke-CcodInstallModule { param($Tx) Open-CcodInstallPinnedDirectory -Transaction $Tx -ParentDirectory $Tx.RootDirectory -Leaf 'runtime' -CreateIfMissing } @($tx) - $result = Invoke-CcodAttemptDirectoryReplacement -Path (Join-Path $fixture.Install 'runtime') - Assert-CcodEqual 'blocked' $result.Outcome 'pinned missing directory cannot be replaced after creation' - Assert-CcodOutsideUnchanged $fixture 'directory replacement' - } finally { Remove-CcodInstallFileFixture $fixture } + [IO.File]::WriteAllText($path,$Content) + [pscustomobject]@{ Path=$path; Length=[int64](Get-Item -LiteralPath $path).Length; Sha256=(Get-CcodTestFileSha256 $path) } } -Invoke-CcodTest 'pins a temporary leaf against replacement before promotion' { - $fixture = New-CcodInstallFileFixture - try { - $tx = Open-CcodFixtureTransaction $fixture - $runtime = Invoke-CcodInstallModule { param($Tx) Open-CcodInstallPinnedDirectory -Transaction $Tx -ParentDirectory $Tx.RootDirectory -Leaf 'runtime' -CreateIfMissing } @($tx) - $temporary = Invoke-CcodInstallModule { param($Tx,$Parent) New-CcodInstallPinnedTemporaryLeaf -Transaction $Tx -ParentDirectory $Parent -Leaf 'payload.tmp' } @($tx,$runtime) - $result = Invoke-CcodAttemptFileReplacement -Path (Join-Path $fixture.Install 'runtime\payload.tmp') - Assert-CcodEqual 'blocked' $result.Outcome 'temporary leaf replacement is blocked while pinned' - Assert-CcodOutsideUnchanged $fixture 'temporary replacement' - } finally { Remove-CcodInstallFileFixture $fixture } -} - -Invoke-CcodTest 'pins sealed source bytes while copying and verifies the destination handle' { - $fixture = New-CcodInstallFileFixture - try { - $source = New-CcodSourceFile $fixture - $tx = Open-CcodFixtureTransaction $fixture - $runtime = Invoke-CcodInstallModule { param($Tx) Open-CcodInstallPinnedDirectory -Transaction $Tx -ParentDirectory $Tx.RootDirectory -Leaf 'runtime' -CreateIfMissing } @($tx) - $temporary = Invoke-CcodInstallModule { param($Tx,$Parent) New-CcodInstallPinnedTemporaryLeaf -Transaction $Tx -ParentDirectory $Parent -Leaf 'payload.tmp' } @($tx,$runtime) - $copy = Invoke-CcodInstallModule { - param($Tx,$Source,$Leaf,$Length,$Sha) - Copy-CcodInstallSealedFile -Transaction $Tx -SourcePath $Source -DestinationLeaf $Leaf -ExpectedLength $Length -ExpectedSha256 $Sha - } @($tx,$source.Path,$temporary,$source.Length,$source.Sha256) - Assert-CcodEqual $source.Length $copy.Length 'copied length is sealed' - Assert-CcodEqual $source.Sha256 $copy.Sha256 'copied digest is sealed' - Assert-CcodOutsideUnchanged $fixture 'source replacement' - } finally { Remove-CcodInstallFileFixture $fixture } -} - -Invoke-CcodTest 'rejects reparse ADS and multi-link leaf inputs' { - $fixture = New-CcodInstallFileFixture - try { - New-CcodJunction -Path (Join-Path $fixture.Install 'linked') -Target $fixture.Outside - $tx = Open-CcodFixtureTransaction $fixture - Assert-CcodThrows { - Invoke-CcodInstallModule { param($Tx) Open-CcodInstallPinnedDirectory -Transaction $Tx -ParentDirectory $Tx.RootDirectory -Leaf 'linked' } @($tx) | Out-Null - } 'CCOD_INSTALL_REPARSE_LEAF' - Assert-CcodThrows { - Invoke-CcodInstallModule { param($Tx) New-CcodInstallPinnedTemporaryLeaf -Transaction $Tx -ParentDirectory $Tx.RootDirectory -Leaf 'payload.tmp:ads' } @($tx) | Out-Null - } 'CCOD_INSTALL_LEAF_INVALID' - - $source = New-CcodSourceFile $fixture - New-CcodHardLink -Path (Join-Path $fixture.Base 'source-hardlink.bin') -Existing $source.Path - $temporary = Invoke-CcodInstallModule { param($Tx) New-CcodInstallPinnedTemporaryLeaf -Transaction $Tx -ParentDirectory $Tx.RootDirectory -Leaf 'payload.tmp' } @($tx) - Assert-CcodThrows { - Invoke-CcodInstallModule { - param($Tx,$Source,$Leaf,$Length,$Sha) - Copy-CcodInstallSealedFile -Transaction $Tx -SourcePath $Source -DestinationLeaf $Leaf -ExpectedLength $Length -ExpectedSha256 $Sha - } @($tx,$source.Path,$temporary,$source.Length,$source.Sha256) | Out-Null - } 'CCOD_INSTALL_MULTILINK_LEAF' - Assert-CcodTrue ([IO.File]::Exists((Join-Path $fixture.Install 'payload.tmp'))) 'rejected multi-link source retains the owned temporary candidate' - Assert-CcodOutsideUnchanged $fixture 'invalid leaves' - } finally { Remove-CcodInstallFileFixture $fixture } +function New-CcodGenerationManifest { + param([Parameter(Mandatory)][string]$RuntimeId,[object[]]$Files=@()) + [ordered]@{ schemaVersion=1; projectVersion='2.5.22'; runtimeId=$RuntimeId; commit='0123456789abcdef0123456789abcdef01234567'; files=@($Files) } } -Invoke-CcodTest 'rejects an existing ADS-bearing promotion destination without replacement' { - $fixture = New-CcodInstallFileFixture - try { - $destination = Join-Path $fixture.Install 'active.json' - [IO.File]::WriteAllText($destination, 'old') - $adsResult = & cmd.exe /d /c "echo attacker>`"$destination`:metadata`"" 2>&1 - if ($LASTEXITCODE -ne 0) { throw "Could not create alternate data stream: $adsResult" } - $source = New-CcodSourceFile $fixture - $tx = Open-CcodFixtureTransaction $fixture - $temporary = Invoke-CcodInstallModule { param($Tx) New-CcodInstallPinnedTemporaryLeaf -Transaction $Tx -ParentDirectory $Tx.RootDirectory -Leaf 'active.tmp' } @($tx) - Invoke-CcodInstallModule { - param($Tx,$Source,$Leaf,$Length,$Sha) - Copy-CcodInstallSealedFile -Transaction $Tx -SourcePath $Source -DestinationLeaf $Leaf -ExpectedLength $Length -ExpectedSha256 $Sha | Out-Null - } @($tx,$source.Path,$temporary,$source.Length,$source.Sha256) - Assert-CcodThrows { - Invoke-CcodInstallModule { param($Tx,$Leaf) Commit-CcodInstallPinnedPromotion -Transaction $Tx -ParentDirectory $Tx.RootDirectory -TemporaryLeaf $Leaf -DestinationLeaf 'active.json' } @($tx,$temporary) | Out-Null - } 'CCOD_INSTALL_PROMOTION_DESTINATION_EXISTS' - Assert-CcodTrue ([IO.File]::Exists((Join-Path $fixture.Install 'active.tmp'))) 'failed ADS promotion retains the temporary candidate' - Assert-CcodOutsideUnchanged $fixture 'ADS destination' - } finally { Remove-CcodInstallFileFixture $fixture } -} - -Invoke-CcodTest 'rejects promotion after an ancestor rename changes the pinned parent path' { - $fixture = New-CcodInstallFileFixture - try { - $source = New-CcodSourceFile $fixture - $tx = Open-CcodFixtureTransaction $fixture - $runtime = Invoke-CcodInstallModule { param($Tx) Open-CcodInstallPinnedDirectory -Transaction $Tx -ParentDirectory $Tx.RootDirectory -Leaf 'runtime' -CreateIfMissing } @($tx) - $temporary = Invoke-CcodInstallModule { param($Tx,$Parent) New-CcodInstallPinnedTemporaryLeaf -Transaction $Tx -ParentDirectory $Parent -Leaf 'payload.tmp' } @($tx,$runtime) - Invoke-CcodInstallModule { - param($Tx,$Source,$Leaf,$Length,$Sha) - Copy-CcodInstallSealedFile -Transaction $Tx -SourcePath $Source -DestinationLeaf $Leaf -ExpectedLength $Length -ExpectedSha256 $Sha | Out-Null - } @($tx,$source.Path,$temporary,$source.Length,$source.Sha256) - $attack = Invoke-CcodReplacePinnedDirectory -Fixture $fixture -Path (Join-Path $fixture.Install 'runtime') - if ($attack.Outcome -ceq 'replaced') { - Assert-CcodThrows { - Invoke-CcodInstallModule { param($Tx,$Parent,$Leaf) Commit-CcodInstallPinnedPromotion -Transaction $Tx -ParentDirectory $Parent -TemporaryLeaf $Leaf -DestinationLeaf 'payload.bin' } @($tx,$runtime,$temporary) | Out-Null - } 'CCOD_INSTALL_PIN_CHANGED' - Assert-CcodTrue ([IO.File]::Exists((Join-Path $attack.MovedDirectory 'payload.tmp'))) 'renamed-parent promotion retains its temporary candidate' - } else { - Assert-CcodThrows { - Invoke-CcodInstallModule { param($Tx,$Parent,$Leaf) Commit-CcodInstallPinnedPromotion -Transaction $Tx -ParentDirectory $Parent -TemporaryLeaf $Leaf -DestinationLeaf 'payload.bin' } @($tx,$runtime,$temporary) | Out-Null - } 'CCOD_INSTALL_PROMOTION_FAILED' - Assert-CcodTrue (-not [IO.File]::Exists((Join-Path $fixture.Install 'runtime\payload.bin'))) 'incompatible owned parent publishes no final destination' - Assert-CcodTrue ([IO.File]::Exists((Join-Path $fixture.Install 'runtime\payload.tmp'))) 'incompatible owned parent retains sealed temporary bytes' - } - Assert-CcodOutsideUnchanged $fixture 'renamed parent promotion' - } finally { Remove-CcodInstallFileFixture $fixture } -} - -foreach ($case in @('unknown','reparse','multilink')) { - Invoke-CcodTest "retains an owned tree containing an unproven $case leaf" { - $fixture = New-CcodInstallFileFixture - try { - $tx = Open-CcodFixtureTransaction $fixture - $candidate = Invoke-CcodInstallModule { param($Tx) Open-CcodInstallPinnedDirectory -Transaction $Tx -ParentDirectory $Tx.RootDirectory -Leaf 'candidate' -CreateIfMissing } @($tx) - switch ($case) { - 'unknown' { [IO.File]::WriteAllText((Join-Path $fixture.Install 'candidate\surprise.bin'), 'unknown') } - 'reparse' { New-CcodJunction -Path (Join-Path $fixture.Install 'candidate\outside-link') -Target $fixture.Outside } - 'multilink' { - $owned = Invoke-CcodInstallModule { param($Tx,$Parent) New-CcodInstallPinnedTemporaryLeaf -Transaction $Tx -ParentDirectory $Parent -Leaf 'owned.tmp' } @($tx,$candidate) - New-CcodHardLink -Path (Join-Path $fixture.Outside 'owned-hardlink.tmp') -Existing (Join-Path $fixture.Install 'candidate\owned.tmp') - } - } - $expected = switch ($case) { - 'unknown' { 'CCOD_INSTALL_UNKNOWN_LEAF' } - 'reparse' { 'CCOD_INSTALL_REPARSE_LEAF' } - 'multilink' { 'CCOD_INSTALL_MULTILINK_LEAF' } - } - Assert-CcodThrows { - Invoke-CcodInstallModule { param($Tx) Remove-CcodInstallOwnedTree -Transaction $Tx -ParentDirectory $Tx.RootDirectory -Leaf 'candidate' } @($tx) | Out-Null - } $expected - Assert-CcodTrue ([IO.Directory]::Exists((Join-Path $fixture.Install 'candidate'))) "failed $case cleanup retains the candidate directory" - Assert-CcodOutsideUnchanged $fixture "$case cleanup" - } finally { Remove-CcodInstallFileFixture $fixture } - } -} - -Invoke-CcodTest 'retires a fully proven nonempty owned tree with every byte intact' { - $fixture = New-CcodInstallFileFixture - try { - $source = New-CcodSourceFile $fixture - $tx = Open-CcodFixtureTransaction $fixture - $candidate = Invoke-CcodInstallModule { param($Tx) Open-CcodInstallPinnedDirectory -Transaction $Tx -ParentDirectory $Tx.RootDirectory -Leaf 'candidate' -CreateIfMissing } @($tx) - $temporary = Invoke-CcodInstallModule { param($Tx,$Parent) New-CcodInstallPinnedTemporaryLeaf -Transaction $Tx -ParentDirectory $Parent -Leaf 'payload.tmp' } @($tx,$candidate) - Invoke-CcodInstallModule { - param($Tx,$Source,$Leaf,$Length,$Sha) - Copy-CcodInstallSealedFile -Transaction $Tx -SourcePath $Source -DestinationLeaf $Leaf -ExpectedLength $Length -ExpectedSha256 $Sha | Out-Null - } @($tx,$source.Path,$temporary,$source.Length,$source.Sha256) - $retirement = Invoke-CcodInstallModule { param($Tx) Remove-CcodInstallOwnedTree -Transaction $Tx -ParentDirectory $Tx.RootDirectory -Leaf 'candidate' } @($tx) - Assert-CcodEqual 'Retired' $retirement.Disposition 'nonempty cleanup returns bounded retirement result' - Assert-CcodEqual '' (@($retirement.Object.PSObject.Properties.Name) -join ',') 'retired object capability exposes no path or handle' - Assert-CcodTrue (-not [IO.Directory]::Exists((Join-Path $fixture.Install 'candidate'))) 'one atomic retirement removes the live candidate name' - Close-CcodInstallFileTransaction -Transaction $tx -Disposition Failed | Out-Null - $fixture.Transaction = $null - $retired = @(Get-ChildItem -LiteralPath $fixture.Install -Directory -Force | Where-Object Name -Like '.__ccod_retired_*') - Assert-CcodEqual 1 $retired.Count 'exactly one retired quarantine object remains' - Assert-CcodEqual $source.Sha256 (Get-CcodTestFileSha256 (Join-Path $retired[0].FullName 'payload.tmp')) 'retired object retains every sealed payload byte' - Assert-CcodOutsideUnchanged $fixture 'owned tree removal' - } finally { Remove-CcodInstallFileFixture $fixture } -} - -Invoke-CcodTest 'retirement collision leaves live candidate and collision untouched' { - $fixture = New-CcodInstallFileFixture - try { - $source = New-CcodSourceFile $fixture - $collisionLeaf = '.__ccod_retired_forced_collision' - $collision = Join-Path $fixture.Install $collisionLeaf - [IO.Directory]::CreateDirectory($collision) | Out-Null - $collisionSentinel = Join-Path $collision 'collision.txt' - [IO.File]::WriteAllText($collisionSentinel, 'collision-original') - $collisionSha = Get-CcodTestFileSha256 $collisionSentinel - $tx = Open-CcodFixtureTransaction $fixture - $candidate = Open-CcodInstallPinnedDirectory -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf 'candidate' -CreateIfMissing - $temporary = New-CcodInstallPinnedTemporaryLeaf -Transaction $tx -ParentDirectory $candidate -Leaf 'payload.tmp' - Copy-CcodInstallSealedFile -Transaction $tx -SourcePath $source.Path -DestinationLeaf $temporary -ExpectedLength $source.Length -ExpectedSha256 $source.Sha256 | Out-Null - Assert-CcodThrows { - Invoke-CcodInstallModule { - param($Tx,$Parent,$Leaf,$RetiredLeaf) - $pin = Get-CcodPinToken $Tx $Parent Directory - Convert-CcodRuntimeError { - Invoke-CcodRuntimeMethod $pin.State.Runtime RemoveOwnedTreeCore @($pin.Token,$Leaf,$RetiredLeaf) | Out-Null - } 'CCOD_INSTALL_RETIREMENT_FAILED' - } @($tx,$tx.RootDirectory,'candidate',$collisionLeaf) - } 'CCOD_INSTALL_RETIREMENT_DESTINATION_EXISTS' - Assert-CcodTrue ([IO.Directory]::Exists((Join-Path $fixture.Install 'candidate'))) 'collision retains the original live candidate name' - Close-CcodInstallFileTransaction -Transaction $tx -Disposition Failed | Out-Null - $fixture.Transaction = $null - Assert-CcodEqual $source.Sha256 (Get-CcodTestFileSha256 (Join-Path $fixture.Install 'candidate\payload.tmp')) 'collision retains every candidate byte' - Assert-CcodEqual $collisionSha (Get-CcodTestFileSha256 $collisionSentinel) 'collision target remains untouched' - Assert-CcodOutsideUnchanged $fixture 'retirement collision' - } finally { Remove-CcodInstallFileFixture $fixture } -} - -Invoke-CcodTest 'owned-parent promotion fails closed without publishing a copy' { - $fixture = New-CcodInstallFileFixture - try { - $source = New-CcodSourceFile $fixture - $tx = Open-CcodFixtureTransaction $fixture - $candidate = Open-CcodInstallPinnedDirectory -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf 'candidate' -CreateIfMissing - $temporary = New-CcodInstallPinnedTemporaryLeaf -Transaction $tx -ParentDirectory $candidate -Leaf 'sealed.tmp' - Copy-CcodInstallSealedFile -Transaction $tx -SourcePath $source.Path -DestinationLeaf $temporary -ExpectedLength $source.Length -ExpectedSha256 $source.Sha256 | Out-Null - Assert-CcodThrows { - Commit-CcodInstallPinnedPromotion -Transaction $tx -ParentDirectory $candidate -TemporaryLeaf $temporary -DestinationLeaf 'final.bin' | Out-Null - } 'CCOD_INSTALL_PROMOTION_FAILED' - Assert-CcodTrue (-not [IO.File]::Exists((Join-Path $fixture.Install 'candidate\final.bin'))) 'failed owned-parent rename publishes no final destination' - Assert-CcodTrue ([IO.File]::Exists((Join-Path $fixture.Install 'candidate\sealed.tmp'))) 'failed owned-parent rename retains the sealed temporary leaf' - Assert-CcodOutsideUnchanged $fixture 'owned-parent promotion failure' - } finally { Remove-CcodInstallFileFixture $fixture } -} - -Invoke-CcodTest 'compatible-parent promotion is readable before transaction close' { - $fixture = New-CcodInstallFileFixture - try { - $source = New-CcodSourceFile $fixture - $tx = Open-CcodFixtureTransaction $fixture - $temporary = New-CcodInstallPinnedTemporaryLeaf -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf 'readable.tmp' - Copy-CcodInstallSealedFile -Transaction $tx -SourcePath $source.Path -DestinationLeaf $temporary -ExpectedLength $source.Length -ExpectedSha256 $source.Sha256 | Out-Null - Commit-CcodInstallPinnedPromotion -Transaction $tx -ParentDirectory $tx.RootDirectory -TemporaryLeaf $temporary -DestinationLeaf 'readable.bin' | Out-Null - Assert-CcodEqual $source.Sha256 (Get-CcodTestFileSha256 (Join-Path $fixture.Install 'readable.bin')) 'compatible atomic promotion is readable while transaction stays open' - Assert-CcodOutsideUnchanged $fixture 'readable compatible promotion' - } finally { Remove-CcodInstallFileFixture $fixture } -} - -Invoke-CcodTest 'deletes an empty fully-owned candidate after establishing its root disposition' { - $fixture = New-CcodInstallFileFixture - try { - $tx = Open-CcodFixtureTransaction $fixture - $candidate = Open-CcodInstallPinnedDirectory -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf 'empty-candidate' -CreateIfMissing - Remove-CcodInstallOwnedTree -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf 'empty-candidate' | Out-Null - Assert-CcodTrue (-not [IO.Directory]::Exists((Join-Path $fixture.Install 'empty-candidate'))) 'empty owned candidate is deleted only after its root disposition is established' - Assert-CcodOutsideUnchanged $fixture 'empty owned tree removal' - } finally { Remove-CcodInstallFileFixture $fixture } -} - -foreach ($case in @('json','log')) { - Invoke-CcodTest "rejects a pinned $case write after parent pathname replacement" { - $fixture = New-CcodInstallFileFixture - try { - $tx = Open-CcodFixtureTransaction $fixture - $parent = Invoke-CcodInstallModule { param($Tx,$Leaf) Open-CcodInstallPinnedDirectory -Transaction $Tx -ParentDirectory $Tx.RootDirectory -Leaf $Leaf -CreateIfMissing } @($tx,$case) - $attack = Invoke-CcodReplacePinnedDirectory -Fixture $fixture -Path (Join-Path $fixture.Install $case) - if ($case -ceq 'json') { - if ($attack.Outcome -ceq 'replaced') { - Assert-CcodThrows { Invoke-CcodInstallModule { param($Tx,$Parent) Write-CcodInstallPinnedJson -Transaction $Tx -ParentDirectory $Parent -Leaf 'state.json' -Value ([ordered]@{ schemaVersion = 1; value = 'safe' }) -Compress } @($tx,$parent) } 'CCOD_INSTALL_PIN_CHANGED' - } else { - Assert-CcodThrows { Invoke-CcodInstallModule { param($Tx,$Parent) Write-CcodInstallPinnedJson -Transaction $Tx -ParentDirectory $Parent -Leaf 'state.json' -Value ([ordered]@{ schemaVersion = 1; value = 'safe' }) -Compress } @($tx,$parent) } 'CCOD_INSTALL_PROMOTION_FAILED' - Assert-CcodTrue (-not [IO.File]::Exists((Join-Path $fixture.Install "$case\state.json"))) 'incompatible owned JSON parent publishes no final state' - } - Assert-CcodTrue (-not [IO.File]::Exists((Join-Path $fixture.Outside 'state.json'))) 'replaced JSON path is untouched' - } else { - if ($attack.Outcome -ceq 'replaced') { - Assert-CcodThrows { Invoke-CcodInstallModule { param($Tx,$Parent) Append-CcodInstallPinnedLog -Transaction $Tx -ParentDirectory $Parent -Leaf 'install.log' -Record ([ordered]@{ event = 'safe'; code = 'CCOD_TEST' }) } @($tx,$parent) } 'CCOD_INSTALL_PIN_CHANGED' - } else { - Invoke-CcodInstallModule { param($Tx,$Parent) Append-CcodInstallPinnedLog -Transaction $Tx -ParentDirectory $Parent -Leaf 'install.log' -Record ([ordered]@{ event = 'safe'; code = 'CCOD_TEST' }) } @($tx,$parent) | Out-Null - } - Assert-CcodTrue (-not [IO.File]::Exists((Join-Path $fixture.Outside 'install.log'))) 'replaced log path is untouched' - } - Assert-CcodOutsideUnchanged $fixture "replaced $case parent" - } finally { Remove-CcodInstallFileFixture $fixture } - } -} - -Invoke-CcodTest 'writes pinned JSON and appends sanitized records through relative handles' { - $fixture = New-CcodInstallFileFixture - try { - $tx = Open-CcodFixtureTransaction $fixture - $state = $tx.RootDirectory - $logs = $tx.RootDirectory - Invoke-CcodInstallModule { param($Tx,$Parent) Write-CcodInstallPinnedJson -Transaction $Tx -ParentDirectory $Parent -Leaf 'active.json' -Value ([ordered]@{ schemaVersion = 1; active = 'runtime-a' }) -Compress } @($tx,$state) | Out-Null - Assert-CcodThrows { - Invoke-CcodInstallModule { param($Tx,$Parent) Write-CcodInstallPinnedJson -Transaction $Tx -ParentDirectory $Parent -Leaf 'active.json' -Value ([ordered]@{ schemaVersion = 1; active = 'runtime-b' }) -Compress } @($tx,$state) | Out-Null - } 'CCOD_INSTALL_PROMOTION_DESTINATION_EXISTS' - Invoke-CcodInstallModule { param($Tx,$Parent) Append-CcodInstallPinnedLog -Transaction $Tx -ParentDirectory $Parent -Leaf 'install.log' -Record ([ordered]@{ event = 'one'; code = 'CCOD_OK' }) } @($tx,$logs) | Out-Null - Invoke-CcodInstallModule { param($Tx,$Parent) Append-CcodInstallPinnedLog -Transaction $Tx -ParentDirectory $Parent -Leaf 'install.log' -Record ([ordered]@{ event = 'two'; code = 'CCOD_READY' }) } @($tx,$logs) | Out-Null - Invoke-CcodInstallModule { param($Tx) Close-CcodInstallFileTransaction -Transaction $Tx -Disposition Ready } @($tx) | Out-Null - $fixture.Transaction = $null - - $json = [IO.File]::ReadAllText((Join-Path $fixture.Install 'active.json'), [Text.UTF8Encoding]::new($false)) | ConvertFrom-Json - Assert-CcodEqual 'runtime-a' $json.active 'no-replace pinned JSON preserves the first committed object' - $records = @([IO.File]::ReadAllLines((Join-Path $fixture.Install 'install.log'), [Text.UTF8Encoding]::new($false))) - Assert-CcodEqual 2 $records.Count 'pinned log appends one JSON line per record' - Assert-CcodEqual 'one' (($records[0] | ConvertFrom-Json).event) 'first log record is retained' - Assert-CcodEqual 'two' (($records[1] | ConvertFrom-Json).event) 'second log record is appended' - Assert-CcodOutsideUnchanged $fixture 'successful pinned writers' - } finally { Remove-CcodInstallFileFixture $fixture } -} - -Invoke-CcodTest 'exports only the private transaction surface and closes on a terminal disposition' { - $fixture = New-CcodInstallFileFixture - try { - $expectedExports = @( - 'Append-CcodInstallPinnedLog','Close-CcodInstallFileTransaction','Commit-CcodInstallPinnedPromotion', - 'Copy-CcodInstallSealedFile','New-CcodInstallPinnedTemporaryLeaf','Open-CcodInstallFileTransaction', - 'Open-CcodInstallPinnedDirectory','Remove-CcodInstallOwnedTree','Write-CcodInstallPinnedJson' - ) - $actualExports = @($module.ExportedFunctions.Keys | Sort-Object) - Assert-CcodEqual ($expectedExports -join ',') ($actualExports -join ',') 'module exports only the approved private transaction surface' - $tx = Open-CcodFixtureTransaction $fixture - Invoke-CcodInstallModule { param($Tx) Close-CcodInstallFileTransaction -Transaction $Tx -Disposition Ready } @($tx) | Out-Null - $fixture.Transaction = $null - Assert-CcodEqual 'RootDirectory' (@($tx.PSObject.Properties.Name) -join ',') 'terminal transaction remains opaque' - Assert-CcodThrows { - Invoke-CcodInstallModule { param($Tx) Open-CcodInstallPinnedDirectory -Transaction $Tx -ParentDirectory $Tx.RootDirectory -Leaf 'after-close' -CreateIfMissing } @($tx) | Out-Null - } 'CCOD_INSTALL_TRANSACTION_CLOSED' - } finally { Remove-CcodInstallFileFixture $fixture } -} - -Invoke-CcodTest 'returns opaque unforgeable capabilities and hides native mutation helpers' { - $fixture = New-CcodInstallFileFixture - try { - $tx = Open-CcodFixtureTransaction $fixture - $txProperties = @($tx.PSObject.Properties.Name) - Assert-CcodEqual 'RootDirectory' ($txProperties -join ',') 'transaction reveals only its root capability' - $rootProperties = @($tx.RootDirectory.PSObject.Properties.Name) - Assert-CcodEqual '' ($rootProperties -join ',') 'directory capability reveals no native or ownership state' - $transactionAssemblyExports = @([CcodInstallCapabilityMarker].Assembly.ExportedTypes | Select-Object -ExpandProperty Name | Sort-Object) - Assert-CcodEqual 'CcodInstallCapabilityMarker' ($transactionAssemblyExports -join ',') 'transaction assembly exports only a non-mutating marker type' - $dangerousPublicMethods = @([CcodInstallCapabilityMarker].Assembly.ExportedTypes | ForEach-Object { - $_.GetMethods([Reflection.BindingFlags]'Public,Static') | Where-Object { - $_.Name -match 'Open|Create|Rename|Delete|Write|Append|Promote|Remove' -or - @($_.GetParameters().ParameterType.FullName) -contains 'Microsoft.Win32.SafeHandles.SafeFileHandle' - } - }) - Assert-CcodEqual 0 $dangerousPublicMethods.Count 'transaction assembly exposes no public native mutation helper' - Assert-CcodEqual $null ('CcodInstallRuntime' -as [type]) 'internal runtime type is not directly callable through PowerShell type resolution' - - $forged = [pscustomobject]@{} - Assert-CcodThrows { - Open-CcodInstallPinnedDirectory -Transaction $forged -ParentDirectory $tx.RootDirectory -Leaf 'forged' -CreateIfMissing | Out-Null - } 'CCOD_INSTALL_TRANSACTION_INVALID' - - $otherFixture = New-CcodInstallFileFixture - try { - $other = Open-CcodFixtureTransaction $otherFixture - Assert-CcodThrows { - Open-CcodInstallPinnedDirectory -Transaction $tx -ParentDirectory $other.RootDirectory -Leaf 'cross-issuer' -CreateIfMissing | Out-Null - } 'CCOD_INSTALL_PIN_INVALID' - } finally { Remove-CcodInstallFileFixture $otherFixture } - Assert-CcodOutsideUnchanged $fixture 'opaque capability rejection' - } finally { Remove-CcodInstallFileFixture $fixture } +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" } -Invoke-CcodTest 'recomputes the private seal and never trusts forged public fields' { - $fixture = New-CcodInstallFileFixture - try { - $source = New-CcodSourceFile $fixture - $tx = Open-CcodFixtureTransaction $fixture - $temporary = New-CcodInstallPinnedTemporaryLeaf -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf 'sealed.tmp' - Copy-CcodInstallSealedFile -Transaction $tx -SourcePath $source.Path -DestinationLeaf $temporary -ExpectedLength $source.Length -ExpectedSha256 $source.Sha256 | Out-Null - $forgedLeaf = [pscustomobject]@{ Sealed = $true; Length = $source.Length; Sha256 = $source.Sha256 } - Assert-CcodThrows { - Commit-CcodInstallPinnedPromotion -Transaction $tx -ParentDirectory $tx.RootDirectory -TemporaryLeaf $forgedLeaf -DestinationLeaf 'forged.bin' | Out-Null - } 'CCOD_INSTALL_PIN_INVALID' - Assert-CcodTrue (-not [IO.File]::Exists((Join-Path $fixture.Install 'forged.bin'))) 'forged public sealing fields cannot create a destination' - $independentWrite = $null - try { - $independentWrite = [IO.File]::Open((Join-Path $fixture.Install 'sealed.tmp'), [IO.FileMode]::Open, [IO.FileAccess]::Write, [IO.FileShare]::Read) - } catch [IO.IOException] { } - if ($null -ne $independentWrite) { - try { - $bytes = [Text.UTF8Encoding]::new($false).GetBytes('tampered') - $independentWrite.SetLength(0); $independentWrite.Write($bytes,0,$bytes.Length); $independentWrite.Flush($true) - } finally { $independentWrite.Dispose() } - Assert-CcodThrows { - Commit-CcodInstallPinnedPromotion -Transaction $tx -ParentDirectory $tx.RootDirectory -TemporaryLeaf $temporary -DestinationLeaf 'sealed.bin' | Out-Null - } 'CCOD_INSTALL_SEAL_MISMATCH' - } else { - Commit-CcodInstallPinnedPromotion -Transaction $tx -ParentDirectory $tx.RootDirectory -TemporaryLeaf $temporary -DestinationLeaf 'sealed.bin' | Out-Null - Close-CcodInstallFileTransaction -Transaction $tx -Disposition Ready | Out-Null - $fixture.Transaction = $null - Assert-CcodEqual $source.Sha256 (Get-CcodTestFileSha256 (Join-Path $fixture.Install 'sealed.bin')) 'blocked mutation preserves sealed promotion bytes' - } - Assert-CcodOutsideUnchanged $fixture 'seal recomputation' - } finally { Remove-CcodInstallFileFixture $fixture } +function Invoke-CcodMoveAttempt { + param([Parameter(Mandatory)][string]$Path) + try { [IO.File]::Move($Path,$Path+'.moved'); 'moved' } catch [IO.IOException] { 'blocked' } catch [UnauthorizedAccessException] { 'blocked' } } -Invoke-CcodTest 'promotion is no-replace when a destination appears before commit' { - $fixture = New-CcodInstallFileFixture - try { - $source = New-CcodSourceFile $fixture - $tx = Open-CcodFixtureTransaction $fixture - $temporary = New-CcodInstallPinnedTemporaryLeaf -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf 'new.tmp' - Copy-CcodInstallSealedFile -Transaction $tx -SourcePath $source.Path -DestinationLeaf $temporary -ExpectedLength $source.Length -ExpectedSha256 $source.Sha256 | Out-Null - $destination = Join-Path $fixture.Install 'existing.bin' - [IO.File]::WriteAllText($destination, 'unexpected-existing-object') - $existingSha = Get-CcodTestFileSha256 $destination - Assert-CcodThrows { - Commit-CcodInstallPinnedPromotion -Transaction $tx -ParentDirectory $tx.RootDirectory -TemporaryLeaf $temporary -DestinationLeaf 'existing.bin' | Out-Null - } 'CCOD_INSTALL_PROMOTION_DESTINATION_EXISTS' - Assert-CcodEqual $existingSha (Get-CcodTestFileSha256 $destination) 'no-replace leaves the existing object untouched' - Assert-CcodTrue ([IO.File]::Exists((Join-Path $fixture.Install 'new.tmp'))) 'no-replace failure retains the sealed temporary leaf' - Assert-CcodOutsideUnchanged $fixture 'no-replace promotion' - } finally { Remove-CcodInstallFileFixture $fixture } +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 ' ')"} } -foreach ($directoryCase in @('root','child','owned')) { - Invoke-CcodTest "rejects $directoryCase directory alternate data streams" { - $fixture = New-CcodInstallFileFixture +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 ' ')"} +} + +Invoke-CcodTest 'exports only immutable generation operations and an inert CLR marker' { + $expected=@('Close-CcodInstallFileTransaction','Commit-CcodInstallActivePointer','Copy-CcodInstallSealedSource','New-CcodInstallGenerationLeaf','Open-CcodInstallGeneration','Retire-CcodInstallGeneration','Write-CcodInstallGenerationManifest') + Assert-CcodEqual ($expected -join '|') ((@($module.ExportedCommands.Keys)|Sort-Object)-join '|') 'module export surface is capability-only' + Assert-CcodEqual 1 ([CcodInstallCapabilityMarker]::CapabilityAbi) 'marker exposes a non-mutating ABI value' + Assert-CcodEqual 'CcodInstallCapabilityMarker' ((@([CcodInstallCapabilityMarker].Assembly.GetExportedTypes()|ForEach-Object FullName)) -join '|') 'CLR bridge exports only the inert marker' + $dangerous=@([CcodInstallCapabilityMarker].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' +} + +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 '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';$generation=Open-CcodFixtureGeneration $fixture $runtimeId + $copy=Copy-CcodInstallSealedSource -Generation $generation -SourcePath $source.Path -Leaf 'payload.bin' -ExpectedLength $source.Length -ExpectedSha256 $source.Sha256 + 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' + $destination=Join-Path $fixture.Install "runtime\$runtimeId\payload.bin";Assert-CcodEqual $source.Sha256 (Get-CcodTestFileSha256 $destination) 'destination bytes match sealed handle digest' + try {[IO.File]::WriteAllText($destination,'attacker');throw 'ASSERT_DESTINATION_MUTABLE'} catch [IO.IOException] {} + Assert-CcodEqual $source.Sha256 (Get-CcodTestFileSha256 $destination) 'destination handle blocks mutation after verification' + 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] {} + 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 { - if ($directoryCase -ceq 'root') { - $adsPath = $fixture.Install + ':metadata' - $adsResult = Invoke-CcodAttemptAdsWrite $adsPath - Assert-CcodEqual 'created' $adsResult.Outcome 'root ADS fixture is established before pinning' - Assert-CcodThrows { Open-CcodFixtureTransaction $fixture | Out-Null } 'CCOD_INSTALL_ADS_LEAF' - } else { - $tx = Open-CcodFixtureTransaction $fixture - $directory = Open-CcodInstallPinnedDirectory -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf $directoryCase -CreateIfMissing - $adsPath = (Join-Path $fixture.Install $directoryCase) + ':metadata' - $adsResult = Invoke-CcodAttemptAdsWrite $adsPath - if ($adsResult.Outcome -ceq 'blocked') { - Assert-CcodTrue $true "$directoryCase directory pin blocks ADS creation" - } elseif ($directoryCase -ceq 'child') { - Assert-CcodThrows { - New-CcodInstallPinnedTemporaryLeaf -Transaction $tx -ParentDirectory $directory -Leaf 'blocked.tmp' | Out-Null - } 'CCOD_INSTALL_ADS_LEAF' - } else { - Assert-CcodThrows { - Remove-CcodInstallOwnedTree -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf 'owned' - } 'CCOD_INSTALL_ADS_LEAF' - Assert-CcodTrue ([IO.Directory]::Exists((Join-Path $fixture.Install 'owned'))) 'directory ADS cleanup failure retains the owned directory' - } + $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 } - Assert-CcodOutsideUnchanged $fixture "$directoryCase directory ADS" - } finally { Remove-CcodInstallFileFixture $fixture } + $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 'retires a nonempty tree containing a read-only owned leaf without changing bytes' { - $fixture = New-CcodInstallFileFixture - try { - $tx = Open-CcodFixtureTransaction $fixture - $candidate = Open-CcodInstallPinnedDirectory -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf 'candidate' -CreateIfMissing - $first = New-CcodInstallPinnedTemporaryLeaf -Transaction $tx -ParentDirectory $candidate -Leaf 'a.tmp' - $later = New-CcodInstallPinnedTemporaryLeaf -Transaction $tx -ParentDirectory $candidate -Leaf 'z.tmp' - [IO.File]::SetAttributes((Join-Path $fixture.Install 'candidate\z.tmp'), [IO.FileAttributes]::ReadOnly) - $result = Remove-CcodInstallOwnedTree -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf 'candidate' - Assert-CcodEqual 'Retired' $result.Disposition 'read-only leaf uses atomic retirement instead of recursive deletion' - Assert-CcodTrue (-not [IO.Directory]::Exists((Join-Path $fixture.Install 'candidate'))) 'read-only candidate leaves the live namespace atomically' - Close-CcodInstallFileTransaction -Transaction $tx -Disposition Failed | Out-Null - $fixture.Transaction = $null - $retired = @(Get-ChildItem -LiteralPath $fixture.Install -Directory -Force | Where-Object Name -Like '.__ccod_retired_*') - Assert-CcodEqual 1 $retired.Count 'read-only candidate has one retired object' - Assert-CcodTrue ([IO.File]::Exists((Join-Path $retired[0].FullName 'a.tmp'))) 'first leaf remains in retired tree' - Assert-CcodTrue ([IO.File]::Exists((Join-Path $retired[0].FullName 'z.tmp'))) 'read-only leaf remains in retired tree' - Assert-CcodOutsideUnchanged $fixture 'read-only retirement' - } finally { Remove-CcodInstallFileFixture $fixture } -} - -Invoke-CcodTest 'retires a nested owned directory without releasing its contents' { - $fixture = New-CcodInstallFileFixture - try { - $tx = Open-CcodFixtureTransaction $fixture - $candidate = Open-CcodInstallPinnedDirectory -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf 'candidate' -CreateIfMissing - $child = Open-CcodInstallPinnedDirectory -Transaction $tx -ParentDirectory $candidate -Leaf 'child' -CreateIfMissing - $result = Remove-CcodInstallOwnedTree -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf 'candidate' - Assert-CcodEqual 'Retired' $result.Disposition 'nested nonempty candidate is retired' - Assert-CcodTrue (-not [IO.Directory]::Exists((Join-Path $fixture.Install 'candidate'))) 'nested candidate live name disappears atomically' - Close-CcodInstallFileTransaction -Transaction $tx -Disposition Failed | Out-Null - $fixture.Transaction = $null - $retired = @(Get-ChildItem -LiteralPath $fixture.Install -Directory -Force | Where-Object Name -Like '.__ccod_retired_*') - Assert-CcodEqual 1 $retired.Count 'nested candidate has one retired object' - Assert-CcodTrue ([IO.Directory]::Exists((Join-Path $retired[0].FullName 'child'))) 'nested child directory remains intact after retirement' - Assert-CcodOutsideUnchanged $fixture 'nested directory retirement' - } finally { Remove-CcodInstallFileFixture $fixture } -} - -Invoke-CcodTest 'detects an unknown insertion between deletion planning and first release' { - $fixture = New-CcodInstallFileFixture - try { - $tx = Open-CcodFixtureTransaction $fixture - $candidate = Open-CcodInstallPinnedDirectory -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf 'candidate' -CreateIfMissing - $first = New-CcodInstallPinnedTemporaryLeaf -Transaction $tx -ParentDirectory $candidate -Leaf 'a-first.tmp' - for ($index = 0; $index -lt 128; $index++) { - New-CcodInstallPinnedTemporaryLeaf -Transaction $tx -ParentDirectory $candidate -Leaf ('b-{0:d3}.tmp' -f $index) | Out-Null - } - $watch = Join-Path $fixture.Install 'candidate\a-first.tmp' - $unknown = Join-Path $fixture.Install 'candidate\concurrent-unknown.tmp' - $attacker = [CcodInstallTransactionTestNative]::InsertUnknownAfterDelay($unknown, 1) - $failure = $null - $retirement = $null - try { $retirement = Remove-CcodInstallOwnedTree -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf 'candidate' } - catch { $failure = $_ } - $attackOutcome = $attacker.GetAwaiter().GetResult() - Assert-CcodTrue ($attackOutcome -in @('inserted','blocked')) 'concurrent namespace attempt reaches the cleanup boundary' - if ($attackOutcome -ceq 'inserted') { - Assert-CcodTrue ($null -ne $failure -and $failure.FullyQualifiedErrorId -match '^CCOD_INSTALL_(?:UNKNOWN_LEAF|DELETE_FAILED)') "inserted unknown leaf fails cleanup before release actual=$($failure.FullyQualifiedErrorId) message=$($failure.Exception.Message)" - Assert-CcodTrue ([IO.File]::Exists($watch)) 'failed concurrent cleanup retains the first candidate leaf' - Assert-CcodTrue ([IO.File]::Exists($unknown)) 'unproven concurrent object is retained for diagnosis' - Assert-CcodTrue ([IO.Directory]::Exists((Join-Path $fixture.Install 'candidate'))) 'concurrent insertion retains the complete candidate tree' - } else { - Assert-CcodTrue ($null -eq $failure -and $retirement.Disposition -ceq 'Retired') 'blocked insertion permits one atomic retirement' - } - Assert-CcodOutsideUnchanged $fixture 'concurrent cleanup insertion' - } finally { Remove-CcodInstallFileFixture $fixture } -} - -Invoke-CcodTest 'owned directory denies external child creation and hard-link insertion for its lifetime' { - $fixture = New-CcodInstallFileFixture - try { - $outsideSource = Join-Path $fixture.Outside 'outside-source.bin' - [IO.File]::WriteAllText($outsideSource, 'outside-source') - $tx = Open-CcodFixtureTransaction $fixture - $candidate = Open-CcodInstallPinnedDirectory -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf 'candidate' -CreateIfMissing - $childPath = Join-Path $fixture.Install 'candidate\attacker-child.bin' - $writeHandleError = [CcodInstallTransactionTestNative]::TryOpenDirectoryForChildWrite((Join-Path $fixture.Install 'candidate')) - Assert-CcodTrue ($writeHandleError -ne 0) 'owned directory share lock blocks an external child-write handle' - - $hardLinkPath = Join-Path $fixture.Install 'candidate\attacker-hardlink.bin' - $hardLinkError = [CcodInstallTransactionTestNative]::CreateHardLink($hardLinkPath, $outsideSource) - Assert-CcodTrue ($hardLinkError -ne 0) 'owned directory share lock blocks external hard-link insertion' - Assert-CcodTrue (-not [IO.File]::Exists($childPath)) 'blocked child path remains absent' - Assert-CcodTrue (-not [IO.File]::Exists($hardLinkPath)) 'blocked hard-link path remains absent' - Assert-CcodOutsideUnchanged $fixture 'owned directory namespace lock' - } finally { Remove-CcodInstallFileFixture $fixture } -} - -Invoke-CcodTest 'target pin blocks namespace mutation after descendant handles are released' { - $fixture = New-CcodInstallFileFixture - try { - $outsideSource = Join-Path $fixture.Outside 'outside-source.bin' - [IO.File]::WriteAllText($outsideSource, 'outside-source') - $tx = Open-CcodFixtureTransaction $fixture - $candidate = Open-CcodInstallPinnedDirectory -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf 'candidate' -CreateIfMissing - $first = New-CcodInstallPinnedTemporaryLeaf -Transaction $tx -ParentDirectory $candidate -Leaf 'a-first.tmp' - for ($index = 0; $index -lt 256; $index++) { - New-CcodInstallPinnedTemporaryLeaf -Transaction $tx -ParentDirectory $candidate -Leaf ('b-{0:d3}.tmp' -f $index) | Out-Null - } - $candidatePath = Join-Path $fixture.Install 'candidate' - $probe = [CcodInstallTransactionTestNative]::ProbeNamespaceAfterLeafUnlock( - (Join-Path $candidatePath 'a-first.tmp'), - $candidatePath, - $outsideSource, - (Join-Path $candidatePath 'attacker-hardlink.bin') - ) - $retirement = Remove-CcodInstallOwnedTree -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf 'candidate' - Assert-CcodEqual 'Retired' $retirement.Disposition 'candidate retires after descendant release probe' - Assert-CcodEqual 'blocked' $probe.GetAwaiter().GetResult() 'retained target pin blocks child-write and hard-link namespace mutation after descendant release' - Assert-CcodTrue (-not [IO.File]::Exists((Join-Path $candidatePath 'attacker-hardlink.bin'))) 'descendant-release probe creates no attacker hard link' - Assert-CcodOutsideUnchanged $fixture 'descendant-release namespace lock' - } finally { Remove-CcodInstallFileFixture $fixture } -} - -Invoke-CcodTest 'closing releases every sharing lock and rejects close-versus-relative operations' { - $fixture = New-CcodInstallFileFixture - try { - $tx = Open-CcodFixtureTransaction $fixture - $child = Open-CcodInstallPinnedDirectory -Transaction $tx -ParentDirectory $tx.RootDirectory -Leaf 'release-me' -CreateIfMissing - Close-CcodInstallFileTransaction -Transaction $tx -Disposition Failed | Out-Null - $fixture.Transaction = $null - Assert-CcodThrows { - Open-CcodInstallPinnedDirectory -Transaction $tx -ParentDirectory $child -Leaf 'after-close' -CreateIfMissing | Out-Null - } 'CCOD_INSTALL_TRANSACTION_CLOSED' - $renamed = Join-Path $fixture.Install 'released' - [IO.Directory]::Move((Join-Path $fixture.Install 'release-me'), $renamed) - [IO.Directory]::Delete($renamed) - Assert-CcodTrue (-not [IO.Directory]::Exists($renamed)) 'closed transaction releases directory sharing locks' - } 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 -ExpectedPreviousGeneration ([uint64]0) -NewRuntimeId 'runtime-first' -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';$generation=Open-CcodFixtureGeneration $fixture $runtimeId;$child=New-CcodInstallGenerationLeaf -Generation $generation -Leaf 'child' + Close-CcodInstallFileTransaction -Transaction $generation -Disposition Failed|Out-Null + Assert-CcodThrows {New-CcodInstallGenerationLeaf -Generation $child -Leaf 'late'|Out-Null} 'CCOD_INSTALL_TRANSACTION_CLOSED' + $live=Join-Path $fixture.Install "runtime\$runtimeId";$moved=$live+'.moved';[IO.Directory]::Move($live,$moved);Assert-CcodTrue ([IO.Directory]::Exists($moved)) 'close released native handles';Assert-CcodOutsideUnchanged $fixture 'close barrier' + } finally {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 -ExpectedPreviousGeneration ([uint64]0) -NewRuntimeId 'runtime-pointer-one' -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 + Assert-CcodThrows {Commit-CcodInstallActivePointer -InstallRoot $fixture.Install -ExpectedPreviousGeneration ([uint64]0) -NewRuntimeId 'runtime-pointer-two' -FileTransaction $second|Out-Null} 'CCOD_INSTALL_POINTER_GENERATION_EXISTS' + $after=@(Get-ChildItem -LiteralPath (Join-Path $fixture.Install 'state\active-generation') -File -Filter '*.json');Assert-CcodEqual 1 $after.Count 'duplicate publishes no record';Assert-CcodEqual $before (Get-CcodTestFileSha256 $after[0].FullName) 'existing pointer unchanged';Assert-CcodOutsideUnchanged $fixture 'pointer collision' + } finally {Remove-CcodInstallFileFixture $fixture} +} + +Invoke-CcodTest 'atomically retires a proven nonempty generation without deleting bytes or changing its DACL' { + $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 (-not [IO.Directory]::Exists($live)) 'live name disappears' + $retired=@(Get-ChildItem -LiteralPath (Join-Path $fixture.Install 'retired') -Directory|Where-Object Name -Like "$runtimeId.*");Assert-CcodEqual 1 $retired.Count 'one quarantine generation';Assert-CcodTrue ($retired[0].Name-cmatch('^'+[regex]::Escape($runtimeId)+'\.[0-9a-f]{32}$')) 'unique owned suffix' + 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 $retired[0].FullName 'post-retire.bin'))) 'retired capability cannot mutate quarantine bytes' + Assert-CcodEqual $source.Sha256 (Get-CcodTestFileSha256 (Join-Path $retired[0].FullName 'child\payload.bin')) 'retired bytes unchanged';Assert-CcodEqual $beforeSddl (Get-Acl -LiteralPath $retired[0].FullName).Sddl 'no persistent DACL mutation' + Assert-CcodEqual 1 @(Get-ChildItem -LiteralPath (Join-Path $fixture.Install 'state\retirements') -File -Filter '*.json').Count 'one retirement record';Assert-CcodOutsideUnchanged $fixture 'retirement' + } finally {Remove-CcodInstallFileFixture $fixture} } Write-Host 'Install file transaction self-test passed.' -ForegroundColor Green From 2baf9dfccb92282349f4239d02203184df83d30a Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:30:34 +0800 Subject: [PATCH 072/187] docs: narrow immutable generation cleanup contract --- .../plans/2026-08-29-v2522-immutable-generation.md | 10 ++++++---- .../2026-08-29-v2522-immutable-generation-design.md | 10 +++++++--- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/docs/superpowers/plans/2026-08-29-v2522-immutable-generation.md b/docs/superpowers/plans/2026-08-29-v2522-immutable-generation.md index 4e0f56e..2315354 100644 --- a/docs/superpowers/plans/2026-08-29-v2522-immutable-generation.md +++ b/docs/superpowers/plans/2026-08-29-v2522-immutable-generation.md @@ -72,9 +72,10 @@ Close-CcodInstallFileTransaction -Transaction -Disposition Read ``` Capabilities are opaque reference tokens stored in module-private state. The -CLR bridge exposes only a non-mutating marker; no public method accepts an -arbitrary path or bare handle. There is no persistent DACL change and no -nonempty recursive deletion operation. +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** @@ -101,7 +102,8 @@ 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 no-replace relative rename; retain the quarantine tree. +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** 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 index 9e4359a..1ffeb23 100644 --- a/docs/superpowers/specs/2026-08-29-v2522-immutable-generation-design.md +++ b/docs/superpowers/specs/2026-08-29-v2522-immutable-generation-design.md @@ -20,7 +20,11 @@ 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. +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 @@ -86,8 +90,8 @@ Retire-CcodInstallGeneration -InstallRoot -RuntimeId Close-CcodInstallFileTransaction -Transaction -Disposition Ready|Failed ``` -`Retire-CcodInstallGeneration` removes a generation from the active namespace -by an atomic create-only pointer/retired-record update; it does not recursively +`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. From 90d9d8d6f4aa851073fb8973a47ac0c9c0f18b7d Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:54:00 +0800 Subject: [PATCH 073/187] fix: close immutable generation review gaps --- .../modules/InstallFileTransaction.psm1 | 60 +++--- .../InstallFileTransaction.SelfTest.ps1 | 190 ++++++++++++++++-- 2 files changed, 203 insertions(+), 47 deletions(-) diff --git a/src/persistence/modules/InstallFileTransaction.psm1 b/src/persistence/modules/InstallFileTransaction.psm1 index cb3ab93..a52406c 100644 --- a/src/persistence/modules/InstallFileTransaction.psm1 +++ b/src/persistence/modules/InstallFileTransaction.psm1 @@ -9,8 +9,9 @@ function Throw-CcodInstallFileError { } function Initialize-CcodInstallRuntime { - if ($null -ne ('CcodInstallCapabilityMarker' -as [type])) { return } - Add-Type -TypeDefinition @' + $marker = 'CcodInstallGenerationCapabilityMarkerV2' -as [type] + if ($null -eq $marker) { + Add-Type -TypeDefinition @' using System; using System.Collections.Generic; using System.ComponentModel; @@ -21,13 +22,13 @@ using System.Security.Cryptography; using System.Text; using Microsoft.Win32.SafeHandles; -public sealed class CcodInstallCapabilityMarker +public sealed class CcodInstallGenerationCapabilityMarkerV2 { - private CcodInstallCapabilityMarker() { } - public static int CapabilityAbi { get { return 1; } } + private CcodInstallGenerationCapabilityMarkerV2() { } + public static int CapabilityAbi { get { return 2; } } } -internal sealed class CcodInstallRuntime : IDisposable +internal sealed class CcodInstallGenerationRuntimeV2 : IDisposable { private const uint READ = 0x80000000, WRITE = 0x40000000, DELETE = 0x00010000, SYNC = 0x00100000; private const uint READ_ATTRIBUTES = 0x80, LIST_DIRECTORY = 0x1, ADD_FILE = 0x2, ADD_SUBDIRECTORY = 0x4; @@ -63,7 +64,7 @@ internal sealed class CcodInstallRuntime : 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, Manifest; + internal readonly uint Volume; internal readonly ulong Index; internal bool Closed, Sealed, 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; } } @@ -80,10 +81,10 @@ internal sealed class CcodInstallRuntime : IDisposable private readonly List externalStreams = new List(); private bool disposed; - private CcodInstallRuntime(string installRoot,Pin root,Pin runtimeParent) + private CcodInstallGenerationRuntimeV2(string installRoot,Pin root,Pin runtimeParent) { this.installRoot=installRoot;this.root=root;this.runtimeParent=runtimeParent;AddPin(root);AddPin(runtimeParent); } - internal static object Open(string path,string runtimeId,out CcodInstallRuntime runtime) + internal static object Open(string path,string runtimeId,out CcodInstallGenerationRuntimeV2 runtime) { runtime=null;string full=Path.GetFullPath(path).TrimEnd('\\');SafeFileHandle rootHandle=OpenAbsoluteDirectory(full,true);Pin rootPin=null,runtimePin=null; try @@ -98,7 +99,7 @@ internal sealed class CcodInstallRuntime : IDisposable try { Pin generation=ValidateDirectoryPin(runtimePin,runtimeId,Path.Combine(runtimePin.Path,runtimeId),true,generationResult.Handle);generationResult.Handle=null; - runtime=new CcodInstallRuntime(full,rootPin,runtimePin);rootPin=null;runtimePin=null;runtime.AddPin(generation);return generation.Token; + runtime=new CcodInstallGenerationRuntimeV2(full,rootPin,runtimePin);rootPin=null;runtimePin=null;runtime.AddPin(generation);return generation.Token; } finally { if(generationResult.Handle!=null)generationResult.Handle.Dispose(); } } @@ -127,7 +128,7 @@ internal sealed class CcodInstallRuntime : IDisposable FILE_INFO destinationInfo=Info(destinationStream.SafeFileHandle);ValidatePlain(destinationInfo,destinationStream.SafeFileHandle); Pin destination=new Pin(parent,leaf,Path.Combine(parent.Path,leaf),false,true,null,destinationStream,destinationInfo);destinationStream=null; sourceStream.CopyTo(destination.Stream);destination.Stream.Flush(true); - if(destination.Stream.Length!=expectedLength||Sha(destination.Stream)!=expectedSha)throw new InvalidDataException("destination mismatch");destination.Sealed=true;MakeReadable(destination);AddPin(destination);externalStreams.Add(sourceStream);sourceStream=null;return destination.Token; + if(destination.Stream.Length!=expectedLength||Sha(destination.Stream)!=expectedSha)throw new InvalidDataException("destination mismatch");destination.SealedLength=expectedLength;destination.SealedSha=expectedSha;AddPin(destination);try{MakeReadable(destination,expectedLength,expectedSha);}catch(Exception exception){throw new InvalidDataException("seal mismatch",exception);}destination.Sealed=true;externalStreams.Add(sourceStream);sourceStream=null;return destination.Token; } finally { if(sourceStream!=null)sourceStream.Dispose();if(sourceHandle!=null)sourceHandle.Dispose();if(destinationStream!=null)destinationStream.Dispose();if(destinationResult!=null&&destinationResult.Handle!=null)destinationResult.Handle.Dispose(); } } @@ -138,7 +139,7 @@ internal sealed class CcodInstallRuntime : IDisposable try { stream=new FileStream(result.Handle,FileAccess.ReadWrite,65536,false);result.Handle=null;FILE_INFO info=Info(stream.SafeFileHandle);ValidatePlain(info,stream.SafeFileHandle);Pin pin=new Pin(parent,leaf,Path.Combine(parent.Path,leaf),false,true,null,stream,info);stream=null; - 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");pin.Sealed=true;pin.Manifest=manifest;if(manifest)MakeReadable(pin);AddPin(pin);return new object[]{pin.Token,pin.Stream.Length,sha}; + 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");pin.SealedLength=bytes.LongLength;pin.SealedSha=sha;AddPin(pin);if(manifest)MakeReadable(pin,bytes.LongLength,sha);pin.Sealed=true;pin.Manifest=manifest;return new object[]{pin.Token,pin.SealedLength,sha}; } finally { if(stream!=null)stream.Dispose();if(result.Handle!=null)result.Handle.Dispose(); } } @@ -146,15 +147,14 @@ internal sealed class CcodInstallRuntime : IDisposable [MethodImpl(MethodImplOptions.Synchronized)] internal object CommitPointer(object generationToken,ulong previousGeneration,string runtimeId,byte[] bytes) { Pin generation=Require(generationToken,true);ValidateCurrent(generation);if(!Object.ReferenceEquals(generation.Parent,runtimeParent)||!String.Equals(generation.Leaf,runtimeId,StringComparison.Ordinal))throw new InvalidDataException("transaction scope"); - Pin manifest=null;if(!names.TryGetValue(Key(generation,"manifest.json"),out manifest)||!manifest.Manifest||!manifest.Sealed)throw new InvalidDataException("manifest missing");ValidateCurrent(manifest); - Pin state=OpenOrCreateDirectory(root,"state"),pointer=OpenOrCreateDirectory(state,"active-generation");if(CurrentPointerGeneration(pointer)!=previousGeneration)throw new InvalidOperationException("pointer generation exists");string leaf=(previousGeneration+1).ToString("D20")+".json";return WriteAtomic(pointer,leaf,bytes); + Pin manifest=null;if(!names.TryGetValue(Key(generation,"manifest.json"),out manifest)||!manifest.Manifest||!manifest.Sealed)throw new InvalidDataException("manifest missing");ValidateCurrent(manifest);ValidateOwnedTree(generation); + if(previousGeneration==UInt64.MaxValue)throw new OverflowException("pointer generation overflow");Pin state=OpenOrCreateDirectory(root,"state"),pointer=OpenOrCreateDirectory(state,"active-generation");if(CurrentPointerGeneration(pointer)!=previousGeneration)throw new InvalidOperationException("pointer generation exists");string leaf=(previousGeneration+1).ToString("D20")+".json";return WriteAtomic(pointer,leaf,bytes); } - [MethodImpl(MethodImplOptions.Synchronized)] internal object Retire(object generationToken,string runtimeId,string retiredLeaf,byte[] record) + [MethodImpl(MethodImplOptions.Synchronized)] internal object Retire(object generationToken,string runtimeId,string recordLeaf,byte[] record) { Pin generation=Require(generationToken,true);ValidateCurrent(generation);if(!Object.ReferenceEquals(generation.Parent,runtimeParent)||!String.Equals(generation.Leaf,runtimeId,StringComparison.Ordinal)||!generation.Owned)throw new InvalidDataException("generation not owned"); - ValidateOwnedTree(generation);Pin retired=OpenOrCreateDirectory(root,"retired"),state=OpenOrCreateDirectory(root,"state"),retirements=OpenOrCreateDirectory(state,"retirements");ReleaseDescendants(generation);int error=Rename(generation.Native,retired.Native,retiredLeaf);if(error!=0)throw new Win32Exception(error);MoveDirectoryPin(generation,retired,retiredLeaf,Path.Combine(retired.Path,retiredLeaf)); - WriteAtomic(retirements,retiredLeaf+".json",record);return generation.Token; + ValidateOwnedTree(generation);Pin state=OpenOrCreateDirectory(root,"state"),retirements=OpenOrCreateDirectory(state,"retired-generations");WriteAtomic(retirements,recordLeaf,record);ValidateCurrent(generation);return generation.Token; } [MethodImpl(MethodImplOptions.Synchronized)] internal void Close() @@ -173,26 +173,21 @@ internal sealed class CcodInstallRuntime : IDisposable 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");ValidateCurrent(child);if(child.Directory)ValidateOwnedTree(child);else if(!child.Sealed)throw new InvalidDataException("unsealed leaf");} + 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)throw new InvalidDataException("unsealed leaf");ValidateCurrent(child);}} } private object WriteAtomic(Pin parent,string leaf,byte[] bytes) { - string temporary=".ccod."+Guid.NewGuid().ToString("N")+".tmp";object[] result=(object[])Write(parent.Token,temporary,bytes,false);Pin pin=Require(result[0],false);int error=Rename(pin.Native,parent.Native,leaf);if(error!=0)throw new Win32Exception(error);names.Remove(Key(parent,temporary));pin.Leaf=leaf;pin.Path=Path.Combine(parent.Path,leaf);names.Add(Key(parent,leaf),pin);MakeReadable(pin);return result; + string temporary=".ccod."+Guid.NewGuid().ToString("N")+".tmp";object[] result=(object[])Write(parent.Token,temporary,bytes,false);Pin pin=Require(result[0],false);int error=Rename(pin.Native,parent.Native,leaf);if(error!=0)throw new Win32Exception(error);names.Remove(Key(parent,temporary));pin.Leaf=leaf;pin.Path=Path.Combine(parent.Path,leaf);names.Add(Key(parent,leaf),pin);MakeReadable(pin,pin.SealedLength,pin.SealedSha);return result; } private static ulong CurrentPointerGeneration(Pin pointer) { ulong maximum=0;foreach(string leaf in Enumerate(pointer.Native)){if(leaf.StartsWith(".ccod.",StringComparison.OrdinalIgnoreCase)&&leaf.EndsWith(".tmp",StringComparison.OrdinalIgnoreCase))continue;if(leaf.Length!=25||!leaf.EndsWith(".json",StringComparison.OrdinalIgnoreCase))throw new InvalidDataException("unknown pointer leaf");ulong value;if(!UInt64.TryParse(leaf.Substring(0,20),out value)||value==0)throw new InvalidDataException("unknown pointer leaf");if(value>maximum)maximum=value;}return maximum; } - private void ReleaseDescendants(Pin directory) - { - foreach(Pin pin in pins.Values)if(!pin.Closed&&!Object.ReferenceEquals(pin,directory)&&pin.Path.StartsWith(directory.Path.TrimEnd('\\')+"\\",StringComparison.OrdinalIgnoreCase))pin.Dispose(); - } 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){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 void MoveDirectoryPin(Pin directory,Pin parent,string leaf,string path){string oldPath=directory.Path;names.Remove(Key(directory.Parent,directory.Leaf));directory.Leaf=leaf;directory.Path=path;names.Add(Key(parent,leaf),directory);string prefix=oldPath.TrimEnd('\\')+"\\";foreach(Pin pin in pins.Values)if(!Object.ReferenceEquals(pin,directory)&&pin.Path.StartsWith(prefix,StringComparison.OrdinalIgnoreCase))pin.Path=path+"\\"+pin.Path.Substring(prefix.Length);} 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 static void MakeReadable(Pin pin){OpenResult bridge=OpenRelative(pin.Parent.Native,pin.Leaf,READ|READ_ATTRIBUTES|SYNC,SHARE_READ|SHARE_WRITE|SHARE_DELETE,OPEN,NON_DIRECTORY);FILE_INFO bridgeInfo=Info(bridge.Handle);if(bridgeInfo.VolumeSerialNumber!=pin.Volume||(((ulong)bridgeInfo.FileIndexHigh<<32)|bridgeInfo.FileIndexLow)!=pin.Index){bridge.Handle.Dispose();throw new InvalidDataException("pin changed");}pin.Stream.Dispose();pin.Stream=null;OpenResult strict=OpenRelative(pin.Parent.Native,pin.Leaf,READ|READ_ATTRIBUTES|SYNC,SHARE_READ,OPEN,NON_DIRECTORY);bridge.Handle.Dispose();pin.Stream=new FileStream(strict.Handle,FileAccess.Read,65536,false);strict.Handle=null;} + private static void MakeReadable(Pin pin,long expectedLength,string expectedSha){OpenResult bridge=null,strict=null;FileStream strictStream=null;try{bridge=OpenRelative(pin.Parent.Native,pin.Leaf,READ|READ_ATTRIBUTES|SYNC,SHARE_READ|SHARE_WRITE|SHARE_DELETE,OPEN,NON_DIRECTORY);FILE_INFO bridgeInfo=Info(bridge.Handle);if(bridgeInfo.VolumeSerialNumber!=pin.Volume||(((ulong)bridgeInfo.FileIndexHigh<<32)|bridgeInfo.FileIndexLow)!=pin.Index)throw new InvalidDataException("pin changed");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");pin.Stream=strictStream;strictStream=null;}finally{if(strictStream!=null)strictStream.Dispose();if(strict!=null&&strict.Handle!=null)strict.Handle.Dispose();if(bridge!=null&&bridge.Handle!=null)bridge.Handle.Dispose();}} 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");} @@ -207,7 +202,10 @@ internal sealed class CcodInstallRuntime : IDisposable 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);}} } '@ - $script:CcodRuntimeType = [CcodInstallCapabilityMarker].Assembly.GetType('CcodInstallRuntime',$true) + $marker = 'CcodInstallGenerationCapabilityMarkerV2' -as [type] + } + if ($null -eq $marker -or [int]$marker.GetProperty('CapabilityAbi').GetValue($null,$null) -ne 2) { Throw-CcodInstallFileError 'CCOD_INSTALL_RUNTIME_ABI_INVALID' 'Install generation runtime ABI is unavailable' $null } + $script:CcodRuntimeType = $marker.Assembly.GetType('CcodInstallGenerationRuntimeV2',$true) } function Assert-CcodInstallLeaf([string]$Leaf,[string]$ErrorId='CCOD_INSTALL_LEAF_INVALID') { @@ -216,9 +214,9 @@ function Assert-CcodInstallLeaf([string]$Leaf,[string]$ErrorId='CCOD_INSTALL_LEA 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'){$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){Throw-CcodInstallFileError 'CCOD_INSTALL_GENERATION_RETIRED' 'Install generation is retired' $null};[pscustomobject]@{State=$state;Record=$record}} +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){$capability=New-CcodInstallCapability;$script:CcodScopes.Add($capability,[pscustomobject]@{Transaction=$Transaction;Token=$Token});$capability} -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($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'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 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($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'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 } @@ -250,13 +248,13 @@ function Open-CcodInstallGeneration { 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;Closed=$false;Retired=$false;Disposition=$null});$script:CcodScopes.Add($transaction,[pscustomobject]@{Transaction=$transaction;Token=$token});$transaction + $runtime=$arguments[2];$transaction=New-CcodInstallCapability;$script:CcodTransactions.Add($transaction,[pscustomobject]@{Runtime=$runtime;InstallRoot=[IO.Path]::GetFullPath($InstallRoot).TrimEnd('\');RuntimeId=$RuntimeId;RootToken=$token;Closed=$false;Retired=$false;RetirementResult=$null;Disposition=$null});$script:CcodScopes.Add($transaction,[pscustomobject]@{Transaction=$transaction;Token=$token});$transaction } function New-CcodInstallGenerationLeaf { param([Parameter(Mandatory)]$Generation,[Parameter(Mandatory)][string]$Leaf) Assert-CcodInstallLeaf $Leaf;$scope=Get-CcodInstallTransaction $Generation;$token=Convert-CcodInstallRuntimeError {Invoke-CcodRuntimeMethod $scope.State.Runtime CreateDirectory @($scope.Record.Token,$Leaf)} 'CCOD_INSTALL_LEAF_CREATE_FAILED';Add-CcodInstallScope $scope.Record.Transaction $token } 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;Convert-CcodInstallRuntimeError {Invoke-CcodRuntimeMethod $scope.State.Runtime Copy @($scope.Record.Token,$Leaf,[IO.Path]::GetFullPath($SourcePath),$ExpectedLength,$ExpectedSha256)|Out-Null} 'CCOD_INSTALL_SOURCE_MISMATCH';[pscustomobject]@{Length=$ExpectedLength;Sha256=$ExpectedSha256} } function Write-CcodInstallGenerationManifest { param([Parameter(Mandatory)]$Generation,[Parameter(Mandatory)]$Manifest) $scope=Get-CcodInstallTransaction $Generation;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=Convert-CcodInstallRuntimeError {Invoke-CcodRuntimeMethod $scope.State.Runtime Write @($scope.Record.Token,'manifest.json',$bytes,$true)} 'CCOD_INSTALL_MANIFEST_WRITE_FAILED';[pscustomobject]@{Length=[int64]$result[1];Sha256=[string]$result[2]} } -function Commit-CcodInstallActivePointer { param([Parameter(Mandatory)][string]$InstallRoot,[Parameter(Mandatory)][uint64]$ExpectedPreviousGeneration,[Parameter(Mandatory)][string]$NewRuntimeId,[Parameter(Mandatory)]$FileTransaction) Assert-CcodInstallLeaf $NewRuntimeId 'CCOD_INSTALL_RUNTIME_ID_INVALID';$scope=Get-CcodInstallTransaction $FileTransaction 'CCOD_INSTALL_TRANSACTION_INVALID';Assert-CcodInstallRootScope $scope.State $InstallRoot 'CCOD_INSTALL_TRANSACTION_SCOPE';$generation=$ExpectedPreviousGeneration+1;$pointer=[ordered]@{schemaVersion=1;generation=$generation;activeRuntime=$NewRuntimeId;previousGeneration=$ExpectedPreviousGeneration};$bytes=ConvertTo-CcodInstallJsonBytes $pointer;try{Convert-CcodInstallRuntimeError {Invoke-CcodRuntimeMethod $scope.State.Runtime CommitPointer @($scope.Record.Token,$ExpectedPreviousGeneration,$NewRuntimeId,$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};[pscustomobject]@{Generation=[uint64]$generation;RuntimeId=$NewRuntimeId} } -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';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};$retiredLeaf=$RuntimeId+'.'+[guid]::NewGuid().ToString('N');$record=ConvertTo-CcodInstallJsonBytes ([ordered]@{schemaVersion=1;runtimeId=$RuntimeId;retiredName=$retiredLeaf});$token=Convert-CcodInstallRuntimeError {Invoke-CcodRuntimeMethod $scope.State.Runtime Retire @($scope.Record.Token,$RuntimeId,$retiredLeaf,$record)} 'CCOD_INSTALL_RETIREMENT_FAILED';$capability=Add-CcodInstallScope $scope.Record.Transaction $token;$scope.State.Retired=$true;[pscustomobject]@{Disposition='Retired';Capability=$capability} } +function Commit-CcodInstallActivePointer { param([Parameter(Mandatory)][string]$InstallRoot,[Parameter(Mandatory)][uint64]$ExpectedPreviousGeneration,[Parameter(Mandatory)][string]$NewRuntimeId,[Parameter(Mandatory)]$FileTransaction) Assert-CcodInstallLeaf $NewRuntimeId 'CCOD_INSTALL_RUNTIME_ID_INVALID';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';$generation=[uint64]($ExpectedPreviousGeneration+1);$pointer=[ordered]@{schemaVersion=1;generation=$generation;activeRuntime=$NewRuntimeId;previousGeneration=$ExpectedPreviousGeneration};$bytes=ConvertTo-CcodInstallJsonBytes $pointer;try{Convert-CcodInstallRuntimeError {Invoke-CcodRuntimeMethod $scope.State.Runtime CommitPointer @($scope.Record.Token,$ExpectedPreviousGeneration,$NewRuntimeId,$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};[pscustomobject]@{Generation=$generation;RuntimeId=$NewRuntimeId} } +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'});try{$token=Convert-CcodInstallRuntimeError {Invoke-CcodRuntimeMethod $scope.State.Runtime Retire @($scope.Record.Token,$RuntimeId,$recordLeaf,$record)} '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};$capability=Add-CcodInstallScope $scope.Record.Transaction $token;$result=[pscustomobject]@{Disposition='Retired';Capability=$capability};$scope.State.RetirementResult=$result;$scope.State.Retired=$true;return $result } 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(-not$state.Closed){Invoke-CcodRuntimeMethod $state.Runtime Close @()|Out-Null;$state.Closed=$true;$state.Disposition=$Disposition} } Initialize-CcodInstallRuntime diff --git a/tests/persistence/InstallFileTransaction.SelfTest.ps1 b/tests/persistence/InstallFileTransaction.SelfTest.ps1 index 1495975..b10fc5e 100644 --- a/tests/persistence/InstallFileTransaction.SelfTest.ps1 +++ b/tests/persistence/InstallFileTransaction.SelfTest.ps1 @@ -5,6 +5,46 @@ $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 string RaceCreateAndClose(object runtime,object token,string leaf) + { + Type type=runtime.GetType();MethodInfo create=type.GetMethod("CreateDirectory",BindingFlags.Instance|BindingFlags.NonPublic);MethodInfo close=type.GetMethod("Close",BindingFlags.Instance|BindingFlags.NonPublic);Barrier barrier=new Barrier(2);string createResult=null,closeResult=null; + Task first=Task.Run(() => {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; + } +} +'@ +} + 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')) @@ -74,12 +114,23 @@ function New-CcodHardLink { Invoke-CcodTest 'exports only immutable generation operations and an inert CLR marker' { $expected=@('Close-CcodInstallFileTransaction','Commit-CcodInstallActivePointer','Copy-CcodInstallSealedSource','New-CcodInstallGenerationLeaf','Open-CcodInstallGeneration','Retire-CcodInstallGeneration','Write-CcodInstallGenerationManifest') Assert-CcodEqual ($expected -join '|') ((@($module.ExportedCommands.Keys)|Sort-Object)-join '|') 'module export surface is capability-only' - Assert-CcodEqual 1 ([CcodInstallCapabilityMarker]::CapabilityAbi) 'marker exposes a non-mutating ABI value' - Assert-CcodEqual 'CcodInstallCapabilityMarker' ((@([CcodInstallCapabilityMarker].Assembly.GetExportedTypes()|ForEach-Object FullName)) -join '|') 'CLR bridge exports only the inert marker' - $dangerous=@([CcodInstallCapabilityMarker].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 2 ([CcodInstallGenerationCapabilityMarkerV2]::CapabilityAbi) 'marker exposes the current non-mutating ABI value' + Assert-CcodEqual 'CcodInstallGenerationCapabilityMarkerV2' ((@([CcodInstallGenerationCapabilityMarkerV2].Assembly.GetExportedTypes()|ForEach-Object FullName)) -join '|') 'current CLR bridge exports only the inert marker' + $dangerous=@([CcodInstallGenerationCapabilityMarkerV2].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' } +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 } +} + Invoke-CcodTest 'generation and capabilities are unique create-only opaque references' { $fixture=New-CcodInstallFileFixture try { @@ -111,12 +162,41 @@ Invoke-CcodTest 'destination collisions preserve the first object byte-for-byte' 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';$generation=Open-CcodFixtureGeneration $fixture $runtimeId - $copy=Copy-CcodInstallSealedSource -Generation $generation -SourcePath $source.Path -Leaf 'payload.bin' -ExpectedLength $source.Length -ExpectedSha256 $source.Sha256 + $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' + if($null-ne$copyFailure){ + Assert-CcodTrue ($null-ne$copyFailure-and$copyFailure.FullyQualifiedErrorId-match'^CCOD_INSTALL_(?:PIN_CHANGED|SEAL_MISMATCH)') 'an exchange during handoff cannot become an accepted sealed generation' + Write-CcodInstallGenerationManifest -Generation $generation -Manifest (New-CcodGenerationManifest $runtimeId)|Out-Null + Assert-CcodThrows {Commit-CcodInstallActivePointer -InstallRoot $fixture.Install -ExpectedPreviousGeneration ([uint64]0) -NewRuntimeId $runtimeId -FileTransaction $generation|Out-Null} 'CCOD_INSTALL_UNKNOWN_LEAF' + return + } 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' - $destination=Join-Path $fixture.Install "runtime\$runtimeId\payload.bin";Assert-CcodEqual $source.Sha256 (Get-CcodTestFileSha256 $destination) 'destination bytes match sealed handle digest' + $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]@{CanWrite=$stream.CanWrite;Length=[int64]($pinType.GetField('SealedLength',$flags).GetValue($pin));Sha256=[string]($pinType.GetField('SealedSha',$flags).GetValue($pin))} + } $generation + Assert-CcodTrue (-not $sealedPin.CanWrite) 'handoff ends on a strict read-only private handle' + 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' + Assert-CcodEqual $source.Sha256 (Get-CcodTestFileSha256 $destination) 'successful sealed leaf is readable by path before transaction close' try {[IO.File]::WriteAllText($destination,'attacker');throw 'ASSERT_DESTINATION_MUTABLE'} catch [IO.IOException] {} + Assert-CcodEqual 'blocked' (Invoke-CcodMoveAttempt $destination) 'destination name exchange remains blocked after sealing' Assert-CcodEqual $source.Sha256 (Get-CcodTestFileSha256 $destination) 'destination handle blocks mutation after verification' Assert-CcodOutsideUnchanged $fixture 'pinned copy' } finally {Remove-CcodInstallFileFixture $fixture} @@ -173,10 +253,13 @@ Invoke-CcodTest 'rejects forged cross-transaction and closed capabilities' { Invoke-CcodTest 'close serializes native handle release against later relative operations' { $fixture=New-CcodInstallFileFixture try { - $runtimeId='runtime-close-barrier';$generation=Open-CcodFixtureGeneration $fixture $runtimeId;$child=New-CcodInstallGenerationLeaf -Generation $generation -Leaf 'child' + $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' - $live=Join-Path $fixture.Install "runtime\$runtimeId";$moved=$live+'.moved';[IO.Directory]::Move($live,$moved);Assert-CcodTrue ([IO.Directory]::Exists($moved)) 'close released native handles';Assert-CcodOutsideUnchanged $fixture 'close barrier' + 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} } @@ -188,25 +271,100 @@ Invoke-CcodTest 'pointer generation records are monotonic create-only and collis 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 - Assert-CcodThrows {Commit-CcodInstallActivePointer -InstallRoot $fixture.Install -ExpectedPreviousGeneration ([uint64]0) -NewRuntimeId 'runtime-pointer-two' -FileTransaction $second|Out-Null} 'CCOD_INSTALL_POINTER_GENERATION_EXISTS' - $after=@(Get-ChildItem -LiteralPath (Join-Path $fixture.Install 'state\active-generation') -File -Filter '*.json');Assert-CcodEqual 1 $after.Count 'duplicate publishes no record';Assert-CcodEqual $before (Get-CcodTestFileSha256 $after[0].FullName) 'existing pointer unchanged';Assert-CcodOutsideUnchanged $fixture 'pointer collision' + $secondCommit=Commit-CcodInstallActivePointer -InstallRoot $fixture.Install -ExpectedPreviousGeneration ([uint64]1) -NewRuntimeId 'runtime-pointer-two' -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 -ExpectedPreviousGeneration ([uint64]1) -NewRuntimeId 'runtime-pointer-three' -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 -ExpectedPreviousGeneration ([uint64]::MaxValue) -NewRuntimeId 'runtime-pointer-three' -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 'atomically retires a proven nonempty generation without deleting bytes or changing its DACL' { +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 -ExpectedPreviousGeneration ([uint64]0) -NewRuntimeId 'runtime-pointer-initial' -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' -ExpectedPreviousGeneration ([uint64]1) -NewRuntimeId '$runtimeId' -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 '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 (-not [IO.Directory]::Exists($live)) 'live name disappears' - $retired=@(Get-ChildItem -LiteralPath (Join-Path $fixture.Install 'retired') -Directory|Where-Object Name -Like "$runtimeId.*");Assert-CcodEqual 1 $retired.Count 'one quarantine generation';Assert-CcodTrue ($retired[0].Name-cmatch('^'+[regex]::Escape($runtimeId)+'\.[0-9a-f]{32}$')) 'unique owned suffix' + 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 $retired[0].FullName 'post-retire.bin'))) 'retired capability cannot mutate quarantine bytes' - Assert-CcodEqual $source.Sha256 (Get-CcodTestFileSha256 (Join-Path $retired[0].FullName 'child\payload.bin')) 'retired bytes unchanged';Assert-CcodEqual $beforeSddl (Get-Acl -LiteralPath $retired[0].FullName).Sddl 'no persistent DACL mutation' - Assert-CcodEqual 1 @(Get-ChildItem -LiteralPath (Join-Path $fixture.Install 'state\retirements') -File -Filter '*.json').Count 'one retirement record';Assert-CcodOutsideUnchanged $fixture 'retirement' + 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 'blocked' (Invoke-CcodMoveAttempt $temporary[0].FullName) 'diagnostic temporary handle remains pinned until transaction close' + Close-CcodInstallFileTransaction -Transaction $generation -Disposition Failed|Out-Null + Assert-CcodEqual 'moved' (Invoke-CcodMoveAttempt $temporary[0].FullName) 'Failed disposition releases the retained temporary record handle' + } + } finally {Remove-CcodInstallFileFixture $fixture} + } +} + Write-Host 'Install file transaction self-test passed.' -ForegroundColor Green From e74cfdf28fa05c2b0f8c61634b6542f41e734f45 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:40:26 +0800 Subject: [PATCH 074/187] fix: seal temporary leaves before publication --- .../modules/InstallFileTransaction.psm1 | 68 ++++++++++--------- .../InstallFileTransaction.SelfTest.ps1 | 63 +++++++++++++---- 2 files changed, 85 insertions(+), 46 deletions(-) diff --git a/src/persistence/modules/InstallFileTransaction.psm1 b/src/persistence/modules/InstallFileTransaction.psm1 index a52406c..8471d8b 100644 --- a/src/persistence/modules/InstallFileTransaction.psm1 +++ b/src/persistence/modules/InstallFileTransaction.psm1 @@ -31,7 +31,7 @@ public sealed class CcodInstallGenerationCapabilityMarkerV2 internal sealed class CcodInstallGenerationRuntimeV2 : IDisposable { private const uint READ = 0x80000000, WRITE = 0x40000000, DELETE = 0x00010000, SYNC = 0x00100000; - private const uint READ_ATTRIBUTES = 0x80, LIST_DIRECTORY = 0x1, ADD_FILE = 0x2, ADD_SUBDIRECTORY = 0x4; + 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; @@ -64,7 +64,7 @@ internal sealed class CcodInstallGenerationRuntimeV2 : 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, Manifest; internal long SealedLength; internal string SealedSha; + 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; } } @@ -95,7 +95,7 @@ internal sealed class CcodInstallGenerationRuntimeV2 : IDisposable 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,CREATE,DIRECTORY|BACKUP_INTENT); + 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; @@ -108,7 +108,7 @@ internal sealed class CcodInstallGenerationRuntimeV2 : IDisposable [MethodImpl(MethodImplOptions.Synchronized)] internal object CreateDirectory(object parentToken,string leaf) { - Pin parent=Require(parentToken,true);ValidateCurrent(parent);OpenResult result=OpenRelative(parent.Native,leaf,LIST_DIRECTORY|ADD_FILE|ADD_SUBDIRECTORY|READ_ATTRIBUTES|DELETE|SYNC,SHARE_READ,CREATE,DIRECTORY|BACKUP_INTENT); + Pin parent=Require(parentToken,true);ValidateCurrent(parent);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(); } } @@ -117,44 +117,35 @@ internal sealed class CcodInstallGenerationRuntimeV2 : IDisposable { 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,destinationStream=null;OpenResult destinationResult=null; + 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; - if(sourceStream.Length!=expectedLength||Sha(sourceStream)!=expectedSha)throw new InvalidDataException("source mismatch");sourceStream.Position=0; - destinationResult=OpenRelative(parent.Native,leaf,READ|WRITE|SYNC,SHARE_READ,CREATE,NON_DIRECTORY|WRITE_THROUGH); - destinationStream=new FileStream(destinationResult.Handle,FileAccess.ReadWrite,65536,false);destinationResult.Handle=null; - FILE_INFO destinationInfo=Info(destinationStream.SafeFileHandle);ValidatePlain(destinationInfo,destinationStream.SafeFileHandle); - Pin destination=new Pin(parent,leaf,Path.Combine(parent.Path,leaf),false,true,null,destinationStream,destinationInfo);destinationStream=null; - sourceStream.CopyTo(destination.Stream);destination.Stream.Flush(true); - if(destination.Stream.Length!=expectedLength||Sha(destination.Stream)!=expectedSha)throw new InvalidDataException("destination mismatch");destination.SealedLength=expectedLength;destination.SealedSha=expectedSha;AddPin(destination);try{MakeReadable(destination,expectedLength,expectedSha);}catch(Exception exception){throw new InvalidDataException("seal mismatch",exception);}destination.Sealed=true;externalStreams.Add(sourceStream);sourceStream=null;return destination.Token; + 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();if(destinationStream!=null)destinationStream.Dispose();if(destinationResult!=null&&destinationResult.Handle!=null)destinationResult.Handle.Dispose(); } + 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) { - Pin parent=Require(parentToken,true);ValidateCurrent(parent);OpenResult result=OpenRelative(parent.Native,leaf,READ|WRITE|DELETE|SYNC,SHARE_READ,CREATE,NON_DIRECTORY|WRITE_THROUGH);FileStream stream=null; - try - { - stream=new FileStream(result.Handle,FileAccess.ReadWrite,65536,false);result.Handle=null;FILE_INFO info=Info(stream.SafeFileHandle);ValidatePlain(info,stream.SafeFileHandle);Pin pin=new Pin(parent,leaf,Path.Combine(parent.Path,leaf),false,true,null,stream,info);stream=null; - 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");pin.SealedLength=bytes.LongLength;pin.SealedSha=sha;AddPin(pin);if(manifest)MakeReadable(pin,bytes.LongLength,sha);pin.Sealed=true;pin.Manifest=manifest;return new object[]{pin.Token,pin.SealedLength,sha}; - } - finally { if(stream!=null)stream.Dispose();if(result.Handle!=null)result.Handle.Dispose(); } + Pin parent=Require(parentToken,true);ValidateCurrent(parent);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 CommitPointer(object generationToken,ulong previousGeneration,string runtimeId,byte[] bytes) { Pin generation=Require(generationToken,true);ValidateCurrent(generation);if(!Object.ReferenceEquals(generation.Parent,runtimeParent)||!String.Equals(generation.Leaf,runtimeId,StringComparison.Ordinal))throw new InvalidDataException("transaction scope"); - Pin manifest=null;if(!names.TryGetValue(Key(generation,"manifest.json"),out manifest)||!manifest.Manifest||!manifest.Sealed)throw new InvalidDataException("manifest missing");ValidateCurrent(manifest);ValidateOwnedTree(generation); - if(previousGeneration==UInt64.MaxValue)throw new OverflowException("pointer generation overflow");Pin state=OpenOrCreateDirectory(root,"state"),pointer=OpenOrCreateDirectory(state,"active-generation");if(CurrentPointerGeneration(pointer)!=previousGeneration)throw new InvalidOperationException("pointer generation exists");string leaf=(previousGeneration+1).ToString("D20")+".json";return WriteAtomic(pointer,leaf,bytes); + Pin manifest=null;if(!names.TryGetValue(Key(generation,"manifest.json"),out manifest)||!manifest.Manifest||!manifest.Sealed)throw new InvalidDataException("manifest missing");EnsurePublishedPin(manifest);ValidateOwnedTree(generation); + if(previousGeneration==UInt64.MaxValue)throw new OverflowException("pointer generation overflow");Pin state=OpenOrCreateDirectory(root,"state"),pointer=OpenOrCreateDirectory(state,"active-generation");if(CurrentPointerGeneration(pointer)!=previousGeneration)throw new InvalidOperationException("pointer generation exists");string leaf=(previousGeneration+1).ToString("D20")+".json";return Write(pointer.Token,leaf,bytes,false); } [MethodImpl(MethodImplOptions.Synchronized)] internal object Retire(object generationToken,string runtimeId,string recordLeaf,byte[] record) { Pin generation=Require(generationToken,true);ValidateCurrent(generation);if(!Object.ReferenceEquals(generation.Parent,runtimeParent)||!String.Equals(generation.Leaf,runtimeId,StringComparison.Ordinal)||!generation.Owned)throw new InvalidDataException("generation not owned"); - ValidateOwnedTree(generation);Pin state=OpenOrCreateDirectory(root,"state"),retirements=OpenOrCreateDirectory(state,"retired-generations");WriteAtomic(retirements,recordLeaf,record);ValidateCurrent(generation);return generation.Token; + ValidateOwnedTree(generation);Pin state=OpenOrCreateDirectory(root,"state"),retirements=OpenOrCreateDirectory(state,"retired-generations");Write(retirements.Token,recordLeaf,record,false);ValidateCurrent(generation);return generation.Token; } [MethodImpl(MethodImplOptions.Synchronized)] internal void Close() @@ -173,11 +164,11 @@ internal sealed class CcodInstallGenerationRuntimeV2 : IDisposable 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)throw new InvalidDataException("unsealed leaf");ValidateCurrent(child);}} + 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 object WriteAtomic(Pin parent,string leaf,byte[] bytes) + private Pin CreateTemporaryFile(Pin parent) { - string temporary=".ccod."+Guid.NewGuid().ToString("N")+".tmp";object[] result=(object[])Write(parent.Token,temporary,bytes,false);Pin pin=Require(result[0],false);int error=Rename(pin.Native,parent.Native,leaf);if(error!=0)throw new Win32Exception(error);names.Remove(Key(parent,temporary));pin.Leaf=leaf;pin.Path=Path.Combine(parent.Path,leaf);names.Add(Key(parent,leaf),pin);MakeReadable(pin,pin.SealedLength,pin.SealedSha);return result; + string temporary=".ccod."+Guid.NewGuid().ToString("N")+".tmp";OpenResult result=OpenRelative(parent.Native,temporary,READ|WRITE|DELETE|SYNC,SHARE_READ,CREATE,NON_DIRECTORY|WRITE_THROUGH);FileStream stream=null;try{stream=new FileStream(result.Handle,FileAccess.ReadWrite,65536,false);result.Handle=null;FILE_INFO info=Info(stream.SafeFileHandle);ValidatePlain(info,stream.SafeFileHandle);Pin pin=new Pin(parent,temporary,Path.Combine(parent.Path,temporary),false,true,null,stream,info);AddPin(pin);stream=null;return pin;}finally{if(stream!=null)stream.Dispose();if(result.Handle!=null)result.Handle.Dispose();} } private static ulong CurrentPointerGeneration(Pin pointer) { @@ -187,7 +178,21 @@ internal sealed class CcodInstallGenerationRuntimeV2 : IDisposable private void AddPin(Pin pin){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 static void MakeReadable(Pin pin,long expectedLength,string expectedSha){OpenResult bridge=null,strict=null;FileStream strictStream=null;try{bridge=OpenRelative(pin.Parent.Native,pin.Leaf,READ|READ_ATTRIBUTES|SYNC,SHARE_READ|SHARE_WRITE|SHARE_DELETE,OPEN,NON_DIRECTORY);FILE_INFO bridgeInfo=Info(bridge.Handle);if(bridgeInfo.VolumeSerialNumber!=pin.Volume||(((ulong)bridgeInfo.FileIndexHigh<<32)|bridgeInfo.FileIndexLow)!=pin.Index)throw new InvalidDataException("pin changed");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");pin.Stream=strictStream;strictStream=null;}finally{if(strictStream!=null)strictStream.Dispose();if(strict!=null&&strict.Handle!=null)strict.Handle.Dispose();if(bridge!=null&&bridge.Handle!=null)bridge.Handle.Dispose();}} + 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 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");} @@ -241,6 +246,7 @@ function Test-CcodInstallValue($Value,[int]$Depth=0) { 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 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 { @@ -251,10 +257,10 @@ function Open-CcodInstallGeneration { $runtime=$arguments[2];$transaction=New-CcodInstallCapability;$script:CcodTransactions.Add($transaction,[pscustomobject]@{Runtime=$runtime;InstallRoot=[IO.Path]::GetFullPath($InstallRoot).TrimEnd('\');RuntimeId=$RuntimeId;RootToken=$token;Closed=$false;Retired=$false;RetirementResult=$null;Disposition=$null});$script:CcodScopes.Add($transaction,[pscustomobject]@{Transaction=$transaction;Token=$token});$transaction } function New-CcodInstallGenerationLeaf { param([Parameter(Mandatory)]$Generation,[Parameter(Mandatory)][string]$Leaf) Assert-CcodInstallLeaf $Leaf;$scope=Get-CcodInstallTransaction $Generation;$token=Convert-CcodInstallRuntimeError {Invoke-CcodRuntimeMethod $scope.State.Runtime CreateDirectory @($scope.Record.Token,$Leaf)} 'CCOD_INSTALL_LEAF_CREATE_FAILED';Add-CcodInstallScope $scope.Record.Transaction $token } -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;Convert-CcodInstallRuntimeError {Invoke-CcodRuntimeMethod $scope.State.Runtime Copy @($scope.Record.Token,$Leaf,[IO.Path]::GetFullPath($SourcePath),$ExpectedLength,$ExpectedSha256)|Out-Null} 'CCOD_INSTALL_SOURCE_MISMATCH';[pscustomobject]@{Length=$ExpectedLength;Sha256=$ExpectedSha256} } -function Write-CcodInstallGenerationManifest { param([Parameter(Mandatory)]$Generation,[Parameter(Mandatory)]$Manifest) $scope=Get-CcodInstallTransaction $Generation;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=Convert-CcodInstallRuntimeError {Invoke-CcodRuntimeMethod $scope.State.Runtime Write @($scope.Record.Token,'manifest.json',$bytes,$true)} 'CCOD_INSTALL_MANIFEST_WRITE_FAILED';[pscustomobject]@{Length=[int64]$result[1];Sha256=[string]$result[2]} } -function Commit-CcodInstallActivePointer { param([Parameter(Mandatory)][string]$InstallRoot,[Parameter(Mandatory)][uint64]$ExpectedPreviousGeneration,[Parameter(Mandatory)][string]$NewRuntimeId,[Parameter(Mandatory)]$FileTransaction) Assert-CcodInstallLeaf $NewRuntimeId 'CCOD_INSTALL_RUNTIME_ID_INVALID';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';$generation=[uint64]($ExpectedPreviousGeneration+1);$pointer=[ordered]@{schemaVersion=1;generation=$generation;activeRuntime=$NewRuntimeId;previousGeneration=$ExpectedPreviousGeneration};$bytes=ConvertTo-CcodInstallJsonBytes $pointer;try{Convert-CcodInstallRuntimeError {Invoke-CcodRuntimeMethod $scope.State.Runtime CommitPointer @($scope.Record.Token,$ExpectedPreviousGeneration,$NewRuntimeId,$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};[pscustomobject]@{Generation=$generation;RuntimeId=$NewRuntimeId} } -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'});try{$token=Convert-CcodInstallRuntimeError {Invoke-CcodRuntimeMethod $scope.State.Runtime Retire @($scope.Record.Token,$RuntimeId,$recordLeaf,$record)} '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};$capability=Add-CcodInstallScope $scope.Record.Transaction $token;$result=[pscustomobject]@{Disposition='Retired';Capability=$capability};$scope.State.RetirementResult=$result;$scope.State.Retired=$true;return $result } +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;$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;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 Commit-CcodInstallActivePointer { param([Parameter(Mandatory)][string]$InstallRoot,[Parameter(Mandatory)][uint64]$ExpectedPreviousGeneration,[Parameter(Mandatory)][string]$NewRuntimeId,[Parameter(Mandatory)]$FileTransaction) Assert-CcodInstallLeaf $NewRuntimeId 'CCOD_INSTALL_RUNTIME_ID_INVALID';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';$generation=[uint64]($ExpectedPreviousGeneration+1);$pointer=[ordered]@{schemaVersion=1;generation=$generation;activeRuntime=$NewRuntimeId;previousGeneration=$ExpectedPreviousGeneration};$bytes=ConvertTo-CcodInstallJsonBytes $pointer;$result=[pscustomobject]@{Generation=$generation;RuntimeId=$NewRuntimeId};try{Convert-CcodInstallRuntimeError {Invoke-CcodRuntimeMethod $scope.State.Runtime CommitPointer @($scope.Record.Token,$ExpectedPreviousGeneration,$NewRuntimeId,$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 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(-not$state.Closed){Invoke-CcodRuntimeMethod $state.Runtime Close @()|Out-Null;$state.Closed=$true;$state.Disposition=$Disposition} } Initialize-CcodInstallRuntime diff --git a/tests/persistence/InstallFileTransaction.SelfTest.ps1 b/tests/persistence/InstallFileTransaction.SelfTest.ps1 index b10fc5e..e729282 100644 --- a/tests/persistence/InstallFileTransaction.SelfTest.ps1 +++ b/tests/persistence/InstallFileTransaction.SelfTest.ps1 @@ -34,6 +34,10 @@ public sealed class CcodInstallHandoffAttack : IDisposable public static class CcodInstallFileRaceProbe { + public static Task ObserveFirstVisibleBytes(string path) + { + return Task.Run(() => {DateTime deadline=DateTime.UtcNow.AddSeconds(15);while(DateTime.UtcNow Date: Sat, 29 Aug 2026 20:59:38 +0800 Subject: [PATCH 075/187] fix: finalize retirement and close cleanup --- .../modules/InstallFileTransaction.psm1 | 16 ++++++---- .../InstallFileTransaction.SelfTest.ps1 | 31 +++++++++++++++++++ 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/src/persistence/modules/InstallFileTransaction.psm1 b/src/persistence/modules/InstallFileTransaction.psm1 index 8471d8b..19046da 100644 --- a/src/persistence/modules/InstallFileTransaction.psm1 +++ b/src/persistence/modules/InstallFileTransaction.psm1 @@ -68,7 +68,7 @@ internal sealed class CcodInstallGenerationRuntimeV2 : IDisposable 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;Closed=true;if(Stream!=null)Stream.Dispose();else if(Handle!=null)Handle.Dispose(); } + public void Dispose() { if(Closed)return;if(Stream!=null)Stream.Dispose();else if(Handle!=null)Handle.Dispose();Closed=true; } } private sealed class ReferenceComparer : IEqualityComparer @@ -79,7 +79,7 @@ internal sealed class CcodInstallGenerationRuntimeV2 : IDisposable private readonly Dictionary pins = new Dictionary(ReferenceComparer.Instance); private readonly Dictionary names = new Dictionary(StringComparer.OrdinalIgnoreCase); private readonly List externalStreams = new List(); - private bool disposed; + private bool disposed; private string cleanupError; private CcodInstallGenerationRuntimeV2(string installRoot,Pin root,Pin runtimeParent) { this.installRoot=installRoot;this.root=root;this.runtimeParent=runtimeParent;AddPin(root);AddPin(runtimeParent); } @@ -145,11 +145,15 @@ internal sealed class CcodInstallGenerationRuntimeV2 : IDisposable [MethodImpl(MethodImplOptions.Synchronized)] internal object Retire(object generationToken,string runtimeId,string recordLeaf,byte[] record) { Pin generation=Require(generationToken,true);ValidateCurrent(generation);if(!Object.ReferenceEquals(generation.Parent,runtimeParent)||!String.Equals(generation.Leaf,runtimeId,StringComparison.Ordinal)||!generation.Owned)throw new InvalidDataException("generation not owned"); - ValidateOwnedTree(generation);Pin state=OpenOrCreateDirectory(root,"state"),retirements=OpenOrCreateDirectory(state,"retired-generations");Write(retirements.Token,recordLeaf,record,false);ValidateCurrent(generation);return generation.Token; + ValidateOwnedTree(generation);Pin state=OpenOrCreateDirectory(root,"state"),retirements=OpenOrCreateDirectory(state,"retired-generations");Write(retirements.Token,recordLeaf,record,false);return generation.Token; } [MethodImpl(MethodImplOptions.Synchronized)] internal void Close() - { if(disposed)return;disposed=true;foreach(FileStream stream in externalStreams)stream.Dispose();externalStreams.Clear();List all=new List(pins.Values);for(int i=all.Count-1;i>=0;i--)all[i].Dispose();pins.Clear();names.Clear(); } + { + disposed=true;Exception first=null;List 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) @@ -254,14 +258,14 @@ function Open-CcodInstallGeneration { 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;Closed=$false;Retired=$false;RetirementResult=$null;Disposition=$null});$script:CcodScopes.Add($transaction,[pscustomobject]@{Transaction=$transaction;Token=$token});$transaction + $runtime=$arguments[2];$transaction=New-CcodInstallCapability;$script:CcodTransactions.Add($transaction,[pscustomobject]@{Runtime=$runtime;InstallRoot=[IO.Path]::GetFullPath($InstallRoot).TrimEnd('\');RuntimeId=$RuntimeId;RootToken=$token;Closed=$false;CleanupFailed=$false;Retired=$false;RetirementResult=$null;Disposition=$null});$script:CcodScopes.Add($transaction,[pscustomobject]@{Transaction=$transaction;Token=$token});$transaction } function New-CcodInstallGenerationLeaf { param([Parameter(Mandatory)]$Generation,[Parameter(Mandatory)][string]$Leaf) Assert-CcodInstallLeaf $Leaf;$scope=Get-CcodInstallTransaction $Generation;$token=Convert-CcodInstallRuntimeError {Invoke-CcodRuntimeMethod $scope.State.Runtime CreateDirectory @($scope.Record.Token,$Leaf)} 'CCOD_INSTALL_LEAF_CREATE_FAILED';Add-CcodInstallScope $scope.Record.Transaction $token } 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;$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;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 Commit-CcodInstallActivePointer { param([Parameter(Mandatory)][string]$InstallRoot,[Parameter(Mandatory)][uint64]$ExpectedPreviousGeneration,[Parameter(Mandatory)][string]$NewRuntimeId,[Parameter(Mandatory)]$FileTransaction) Assert-CcodInstallLeaf $NewRuntimeId 'CCOD_INSTALL_RUNTIME_ID_INVALID';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';$generation=[uint64]($ExpectedPreviousGeneration+1);$pointer=[ordered]@{schemaVersion=1;generation=$generation;activeRuntime=$NewRuntimeId;previousGeneration=$ExpectedPreviousGeneration};$bytes=ConvertTo-CcodInstallJsonBytes $pointer;$result=[pscustomobject]@{Generation=$generation;RuntimeId=$NewRuntimeId};try{Convert-CcodInstallRuntimeError {Invoke-CcodRuntimeMethod $scope.State.Runtime CommitPointer @($scope.Record.Token,$ExpectedPreviousGeneration,$NewRuntimeId,$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 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(-not$state.Closed){Invoke-CcodRuntimeMethod $state.Runtime Close @()|Out-Null;$state.Closed=$true;$state.Disposition=$Disposition} } +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};try{Invoke-CcodRuntimeMethod $state.Runtime Close @()|Out-Null;$state.CleanupFailed=$false}catch{$state.CleanupFailed=$true;$state.Closed=$true;$state.Disposition=$Disposition;Throw-CcodInstallFileError 'CCOD_INSTALL_CLOSE_FAILED' 'Install file transaction cleanup failed; registered failures are retained for retry' $null};$state.Closed=$true;$state.Disposition=$Disposition } Initialize-CcodInstallRuntime Export-ModuleMember -Function Open-CcodInstallGeneration,New-CcodInstallGenerationLeaf,Copy-CcodInstallSealedSource,Write-CcodInstallGenerationManifest,Commit-CcodInstallActivePointer,Retire-CcodInstallGeneration,Close-CcodInstallFileTransaction diff --git a/tests/persistence/InstallFileTransaction.SelfTest.ps1 b/tests/persistence/InstallFileTransaction.SelfTest.ps1 index e729282..a8a9194 100644 --- a/tests/persistence/InstallFileTransaction.SelfTest.ps1 +++ b/tests/persistence/InstallFileTransaction.SelfTest.ps1 @@ -46,6 +46,20 @@ public static class CcodInstallFileRaceProbe 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");} + } +} '@ } @@ -296,6 +310,23 @@ Invoke-CcodTest 'close serializes native handle release against later relative o } 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} +} + Invoke-CcodTest 'pointer generation records are monotonic create-only and collision safe' { $fixture=New-CcodInstallFileFixture try { From 5cb83a77ac24d823968c26a1be36e508008fe22e Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Sat, 29 Aug 2026 21:23:21 +0800 Subject: [PATCH 076/187] docs: allow retained generation compensation --- .../plans/2026-08-29-v2522-immutable-generation.md | 13 ++++++++++--- .../2026-08-29-v2522-immutable-generation-design.md | 11 +++++++++-- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/plans/2026-08-29-v2522-immutable-generation.md b/docs/superpowers/plans/2026-08-29-v2522-immutable-generation.md index 2315354..ff490be 100644 --- a/docs/superpowers/plans/2026-08-29-v2522-immutable-generation.md +++ b/docs/superpowers/plans/2026-08-29-v2522-immutable-generation.md @@ -63,10 +63,13 @@ 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 -Commit-CcodInstallActivePointer -InstallRoot -ExpectedPreviousGeneration -NewRuntimeId -FileTransaction +Write-CcodInstallRecord -Transaction -Parent -Leaf -Record +Commit-CcodInstallActivePointer -InstallRoot -TargetGeneration -ExpectedPreviousGeneration -FileTransaction Retire-CcodInstallGeneration -InstallRoot -RuntimeId -FileTransaction Close-CcodInstallFileTransaction -Transaction -Disposition Ready|Failed ``` @@ -80,7 +83,10 @@ 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 barriers for +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 @@ -145,7 +151,8 @@ transaction. These private operations are added or adapted: New-CcodInstallTransactionRecord -TransactionId -OldRuntimeId -OldGeneration -NewRuntimeId -NewGeneration -SealedPackageSha256 -OwnedObjectNames Read-CcodInstallTransactionRecord -InstallRoot Set-CcodInstallTransactionPhase -InstallRoot -TransactionId -ExpectedPhase -NewPhase -FileTransaction -Set-CcodActiveRuntime -InstallRoot -NewRuntimeId -FileTransaction -Ownership +Open-CcodInstallRetainedGeneration -InstallRoot -RuntimeId -ExpectedManifestSha256 -FileTransaction +Set-CcodActiveRuntime -InstallRoot -TargetGeneration -FileTransaction -Ownership Write-CcodActivationReceiptFile -InstallRoot -Receipt -FileTransaction Invoke-CcodInstallCompensation -InstallRoot -TransactionRecord -FileTransaction -Adapters ``` 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 index 1ffeb23..d671916 100644 --- a/docs/superpowers/specs/2026-08-29-v2522-immutable-generation-design.md +++ b/docs/superpowers/specs/2026-08-29-v2522-immutable-generation-design.md @@ -34,8 +34,12 @@ control channel or change the authenticated TrayHost wire contract. - Every generation directory and file name is unique and create-only. No existing runtime or stable shell is overwritten during staging. -- A generation becomes eligible only after every manifest-listed file is +- 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. @@ -82,10 +86,13 @@ Required operations are create-only: ```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 -Commit-CcodInstallActivePointer -InstallRoot -ExpectedPreviousGeneration -NewRuntimeId -FileTransaction +Write-CcodInstallRecord -Transaction -Parent -Leaf -Record +Commit-CcodInstallActivePointer -InstallRoot -ExpectedPreviousGeneration -TargetGeneration -FileTransaction Retire-CcodInstallGeneration -InstallRoot -RuntimeId -FileTransaction Close-CcodInstallFileTransaction -Transaction -Disposition Ready|Failed ``` From c1e3ef08f0c820db79872f8438d66d1ced94eeba Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Sat, 29 Aug 2026 21:46:23 +0800 Subject: [PATCH 077/187] feat: add retained generation file capabilities --- .../modules/InstallFileTransaction.psm1 | 60 ++++++++++--- .../InstallFileTransaction.SelfTest.ps1 | 90 +++++++++++++++++-- 2 files changed, 130 insertions(+), 20 deletions(-) diff --git a/src/persistence/modules/InstallFileTransaction.psm1 b/src/persistence/modules/InstallFileTransaction.psm1 index 19046da..1be356b 100644 --- a/src/persistence/modules/InstallFileTransaction.psm1 +++ b/src/persistence/modules/InstallFileTransaction.psm1 @@ -20,6 +20,7 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; +using System.Text.RegularExpressions; using Microsoft.Win32.SafeHandles; public sealed class CcodInstallGenerationCapabilityMarkerV2 @@ -78,6 +79,7 @@ internal sealed class CcodInstallGenerationRuntimeV2 : IDisposable 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 List externalStreams = new List(); private bool disposed; private string cleanupError; @@ -113,6 +115,28 @@ internal sealed class CcodInstallGenerationRuntimeV2 : IDisposable 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);Pin known;if(names.TryGetValue(Key(parent,leaf),out known)){ValidateCurrent(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);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 object OpenRetained(string runtimeId,string expectedManifestSha) + { + 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);ValidateRetainedManifest(existing,runtimeId,expectedManifestSha);return existing.Token;}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); + ValidateRetainedManifest(generation,runtimeId,expectedManifestSha);ValidateRetainedTree(generation);retainedRoots.Add(generation.Token);return generation.Token; + } + catch(Exception exception){for(int i=added.Count-1;i>=0;i--){Pin pin=added[i];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) { Pin parent=Require(parentToken,true);ValidateCurrent(parent);string full=Path.GetFullPath(source); @@ -138,7 +162,7 @@ internal sealed class CcodInstallGenerationRuntimeV2 : IDisposable [MethodImpl(MethodImplOptions.Synchronized)] internal object CommitPointer(object generationToken,ulong previousGeneration,string runtimeId,byte[] bytes) { Pin generation=Require(generationToken,true);ValidateCurrent(generation);if(!Object.ReferenceEquals(generation.Parent,runtimeParent)||!String.Equals(generation.Leaf,runtimeId,StringComparison.Ordinal))throw new InvalidDataException("transaction scope"); - Pin manifest=null;if(!names.TryGetValue(Key(generation,"manifest.json"),out manifest)||!manifest.Manifest||!manifest.Sealed)throw new InvalidDataException("manifest missing");EnsurePublishedPin(manifest);ValidateOwnedTree(generation); + if(retainedRoots.Contains(generationToken))ValidateRetainedTree(generation);else{Pin manifest=null;if(!names.TryGetValue(Key(generation,"manifest.json"),out manifest)||!manifest.Manifest||!manifest.Sealed)throw new InvalidDataException("manifest missing");EnsurePublishedPin(manifest);ValidateOwnedTree(generation);} if(previousGeneration==UInt64.MaxValue)throw new OverflowException("pointer generation overflow");Pin state=OpenOrCreateDirectory(root,"state"),pointer=OpenOrCreateDirectory(state,"active-generation");if(CurrentPointerGeneration(pointer)!=previousGeneration)throw new InvalidOperationException("pointer generation exists");string leaf=(previousGeneration+1).ToString("D20")+".json";return Write(pointer.Token,leaf,bytes,false); } @@ -170,6 +194,15 @@ internal sealed class CcodInstallGenerationRuntimeV2 : IDisposable { 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)){if(!OnlyDefaultStream(probe.Handle)||!SamePath(FinalPath(probe.Handle),path))throw new InvalidDataException("directory invalid");Pin child=new Pin(directory,leaf,path,true,false,probe.Handle,null,info);probe.Handle=null;AddPin(child);added.Add(child);OpenRetainedTree(child,added);}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);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 void ValidateRetainedManifest(Pin generation,string runtimeId,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");string text=ReadText(manifest.Stream);string propertyPattern="\\\"runtimeId\\\"\\s*:";string valuePattern=propertyPattern+"\\s*\\\""+Regex.Escape(runtimeId)+"\\\"";if(Regex.Matches(text,propertyPattern).Count!=1||Regex.Matches(text,valuePattern).Count!=1)throw new InvalidDataException("retained runtime id mismatch");} + 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){pins.Add(pin.Token,pin);if(pin.Parent!=null)names.Add(Key(pin.Parent,pin.Leaf),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) @@ -206,7 +239,7 @@ internal sealed class CcodInstallGenerationRuntimeV2 : IDisposable 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);}} + 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);}} } @@ -224,8 +257,10 @@ function Invoke-CcodRuntimeStatic([string]$Name,[object[]]$Arguments){$method=$s 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){$capability=New-CcodInstallCapability;$script:CcodScopes.Add($capability,[pscustomobject]@{Transaction=$Transaction;Token=$Token});$capability} -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($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'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 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($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'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 } @@ -258,14 +293,17 @@ function Open-CcodInstallGeneration { 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;Closed=$false;CleanupFailed=$false;Retired=$false;RetirementResult=$null;Disposition=$null});$script:CcodScopes.Add($transaction,[pscustomobject]@{Transaction=$transaction;Token=$token});$transaction + $runtime=$arguments[2];$transaction=New-CcodInstallCapability;$script:CcodTransactions.Add($transaction,[pscustomobject]@{Runtime=$runtime;InstallRoot=[IO.Path]::GetFullPath($InstallRoot).TrimEnd('\');RuntimeId=$RuntimeId;RootToken=$token;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 New-CcodInstallGenerationLeaf { param([Parameter(Mandatory)]$Generation,[Parameter(Mandatory)][string]$Leaf) Assert-CcodInstallLeaf $Leaf;$scope=Get-CcodInstallTransaction $Generation;$token=Convert-CcodInstallRuntimeError {Invoke-CcodRuntimeMethod $scope.State.Runtime CreateDirectory @($scope.Record.Token,$Leaf)} 'CCOD_INSTALL_LEAF_CREATE_FAILED';Add-CcodInstallScope $scope.Record.Transaction $token } -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;$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;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 Commit-CcodInstallActivePointer { param([Parameter(Mandatory)][string]$InstallRoot,[Parameter(Mandatory)][uint64]$ExpectedPreviousGeneration,[Parameter(Mandatory)][string]$NewRuntimeId,[Parameter(Mandatory)]$FileTransaction) Assert-CcodInstallLeaf $NewRuntimeId 'CCOD_INSTALL_RUNTIME_ID_INVALID';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';$generation=[uint64]($ExpectedPreviousGeneration+1);$pointer=[ordered]@{schemaVersion=1;generation=$generation;activeRuntime=$NewRuntimeId;previousGeneration=$ExpectedPreviousGeneration};$bytes=ConvertTo-CcodInstallJsonBytes $pointer;$result=[pscustomobject]@{Generation=$generation;RuntimeId=$NewRuntimeId};try{Convert-CcodInstallRuntimeError {Invoke-CcodRuntimeMethod $scope.State.Runtime CommitPointer @($scope.Record.Token,$ExpectedPreviousGeneration,$NewRuntimeId,$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 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';Assert-CcodInstallRootScope $scope.State $InstallRoot 'CCOD_INSTALL_TRANSACTION_SCOPE';$token=Convert-CcodInstallRuntimeError {Invoke-CcodRuntimeMethod $scope.State.Runtime OpenRetained @($RuntimeId,$ExpectedManifestSha256)} 'CCOD_INSTALL_RETAINED_GENERATION_INVALID';Add-CcodInstallScope $scope.Record.Transaction $token Generation $true $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';Add-CcodInstallScope $Transaction $token Directory $false $null } +function New-CcodInstallGenerationLeaf { param([Parameter(Mandatory)]$Generation,[Parameter(Mandatory)][string]$Leaf) Assert-CcodInstallLeaf $Leaf;$scope=Get-CcodInstallTransaction $Generation;Assert-CcodInstallWritableScope $scope;$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 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};try{Invoke-CcodRuntimeMethod $state.Runtime Close @()|Out-Null;$state.CleanupFailed=$false}catch{$state.CleanupFailed=$true;$state.Closed=$true;$state.Disposition=$Disposition;Throw-CcodInstallFileError 'CCOD_INSTALL_CLOSE_FAILED' 'Install file transaction cleanup failed; registered failures are retained for retry' $null};$state.Closed=$true;$state.Disposition=$Disposition } Initialize-CcodInstallRuntime -Export-ModuleMember -Function Open-CcodInstallGeneration,New-CcodInstallGenerationLeaf,Copy-CcodInstallSealedSource,Write-CcodInstallGenerationManifest,Commit-CcodInstallActivePointer,Retire-CcodInstallGeneration,Close-CcodInstallFileTransaction +Export-ModuleMember -Function Open-CcodInstallGeneration,Open-CcodInstallRetainedGeneration,New-CcodInstallDirectory,New-CcodInstallGenerationLeaf,Copy-CcodInstallSealedSource,Write-CcodInstallGenerationManifest,Write-CcodInstallRecord,Commit-CcodInstallActivePointer,Retire-CcodInstallGeneration,Close-CcodInstallFileTransaction diff --git a/tests/persistence/InstallFileTransaction.SelfTest.ps1 b/tests/persistence/InstallFileTransaction.SelfTest.ps1 index a8a9194..f10889c 100644 --- a/tests/persistence/InstallFileTransaction.SelfTest.ps1 +++ b/tests/persistence/InstallFileTransaction.SelfTest.ps1 @@ -130,7 +130,7 @@ function New-CcodHardLink { } Invoke-CcodTest 'exports only immutable generation operations and an inert CLR marker' { - $expected=@('Close-CcodInstallFileTransaction','Commit-CcodInstallActivePointer','Copy-CcodInstallSealedSource','New-CcodInstallGenerationLeaf','Open-CcodInstallGeneration','Retire-CcodInstallGeneration','Write-CcodInstallGenerationManifest') + $expected=@('Close-CcodInstallFileTransaction','Commit-CcodInstallActivePointer','Copy-CcodInstallSealedSource','New-CcodInstallDirectory','New-CcodInstallGenerationLeaf','Open-CcodInstallGeneration','Open-CcodInstallRetainedGeneration','Retire-CcodInstallGeneration','Write-CcodInstallGenerationManifest','Write-CcodInstallRecord') Assert-CcodEqual ($expected -join '|') ((@($module.ExportedCommands.Keys)|Sort-Object)-join '|') 'module export surface is capability-only' Assert-CcodEqual 2 ([CcodInstallGenerationCapabilityMarkerV2]::CapabilityAbi) 'marker exposes the current non-mutating ABI value' Assert-CcodEqual 'CcodInstallGenerationCapabilityMarkerV2' ((@([CcodInstallGenerationCapabilityMarkerV2].Assembly.GetExportedTypes()|ForEach-Object FullName)) -join '|') 'current CLR bridge exports only the inert marker' @@ -149,6 +149,25 @@ Invoke-CcodTest 'force re-import rebinds the current runtime ABI before real use } finally { Remove-CcodInstallFileFixture $fixture } } +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 'generation and capabilities are unique create-only opaque references' { $fixture=New-CcodInstallFileFixture try { @@ -239,7 +258,7 @@ Invoke-CcodTest 'source and destination handles remain private pinned and same-h $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 -ExpectedPreviousGeneration ([uint64]0) -NewRuntimeId $runtimeId -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'} + 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' @@ -289,7 +308,7 @@ Invoke-CcodTest 'rejects forged cross-transaction and closed capabilities' { $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 -ExpectedPreviousGeneration ([uint64]0) -NewRuntimeId 'runtime-first' -FileTransaction $first|Out-Null} 'CCOD_INSTALL_TRANSACTION_SCOPE' + 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' @@ -331,17 +350,17 @@ Invoke-CcodTest 'pointer generation records are monotonic create-only and collis $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 -ExpectedPreviousGeneration ([uint64]0) -NewRuntimeId 'runtime-pointer-one' -FileTransaction $first + $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 -ExpectedPreviousGeneration ([uint64]1) -NewRuntimeId 'runtime-pointer-two' -FileTransaction $second + $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 -ExpectedPreviousGeneration ([uint64]1) -NewRuntimeId 'runtime-pointer-three' -FileTransaction $third|Out-Null} 'CCOD_INSTALL_POINTER_GENERATION_EXISTS' + 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 -ExpectedPreviousGeneration ([uint64]::MaxValue) -NewRuntimeId 'runtime-pointer-three' -FileTransaction $third|Out-Null} 'CCOD_INSTALL_POINTER_GENERATION_OVERFLOW' + 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} @@ -352,7 +371,7 @@ Invoke-CcodTest 'independent transactions race one pointer generation with exact $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 -ExpectedPreviousGeneration ([uint64]0) -NewRuntimeId 'runtime-pointer-initial' -FileTransaction $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" @@ -367,7 +386,7 @@ try { [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' -ExpectedPreviousGeneration ([uint64]1) -NewRuntimeId '$runtimeId' -FileTransaction `$tx|Out-Null;[IO.File]::WriteAllText('$($outcome.Replace("'","''"))','success')} + 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}} "@ @@ -389,6 +408,59 @@ try { } } +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-CcodTest 'retirement writes one create-only record and keeps the nonempty generation in place' { $fixture=New-CcodInstallFileFixture try { From 14178fc834f691489d167a3abb3d34e8af98d055 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Sat, 29 Aug 2026 22:19:13 +0800 Subject: [PATCH 078/187] fix: close retained generation capability gaps --- .../task-1-report.md | 482 ++++++++++++++++++ .../modules/InstallFileTransaction.psm1 | 45 +- .../InstallFileTransaction.SelfTest.ps1 | 74 ++- 3 files changed, 577 insertions(+), 24 deletions(-) create mode 100644 .superpowers/sdd/2026-08-29-v2522-immutable-generation/task-1-report.md 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..3b8176d --- /dev/null +++ b/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-1-report.md @@ -0,0 +1,482 @@ +# 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. diff --git a/src/persistence/modules/InstallFileTransaction.psm1 b/src/persistence/modules/InstallFileTransaction.psm1 index 1be356b..b0ebd33 100644 --- a/src/persistence/modules/InstallFileTransaction.psm1 +++ b/src/persistence/modules/InstallFileTransaction.psm1 @@ -9,7 +9,7 @@ function Throw-CcodInstallFileError { } function Initialize-CcodInstallRuntime { - $marker = 'CcodInstallGenerationCapabilityMarkerV2' -as [type] + $marker = 'CcodInstallGenerationCapabilityMarkerV3' -as [type] if ($null -eq $marker) { Add-Type -TypeDefinition @' using System; @@ -20,16 +20,15 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; -using System.Text.RegularExpressions; using Microsoft.Win32.SafeHandles; -public sealed class CcodInstallGenerationCapabilityMarkerV2 +public sealed class CcodInstallGenerationCapabilityMarkerV3 { - private CcodInstallGenerationCapabilityMarkerV2() { } - public static int CapabilityAbi { get { return 2; } } + private CcodInstallGenerationCapabilityMarkerV3() { } + public static int CapabilityAbi { get { return 3; } } } -internal sealed class CcodInstallGenerationRuntimeV2 : IDisposable +internal sealed class CcodInstallGenerationRuntimeV3 : IDisposable { private const uint READ = 0x80000000, WRITE = 0x40000000, DELETE = 0x00010000, SYNC = 0x00100000; private const uint READ_ATTRIBUTES = 0x80, WRITE_ATTRIBUTES = 0x100, LIST_DIRECTORY = 0x1, ADD_FILE = 0x2, ADD_SUBDIRECTORY = 0x4; @@ -80,13 +79,14 @@ internal sealed class CcodInstallGenerationRuntimeV2 : IDisposable 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 List externalStreams = new List(); private bool disposed; private string cleanupError; - private CcodInstallGenerationRuntimeV2(string installRoot,Pin root,Pin runtimeParent) + private CcodInstallGenerationRuntimeV3(string installRoot,Pin root,Pin runtimeParent) { this.installRoot=installRoot;this.root=root;this.runtimeParent=runtimeParent;AddPin(root);AddPin(runtimeParent); } - internal static object Open(string path,string runtimeId,out CcodInstallGenerationRuntimeV2 runtime) + internal static object Open(string path,string runtimeId,out CcodInstallGenerationRuntimeV3 runtime) { runtime=null;string full=Path.GetFullPath(path).TrimEnd('\\');SafeFileHandle rootHandle=OpenAbsoluteDirectory(full,true);Pin rootPin=null,runtimePin=null; try @@ -101,7 +101,7 @@ internal sealed class CcodInstallGenerationRuntimeV2 : IDisposable try { Pin generation=ValidateDirectoryPin(runtimePin,runtimeId,Path.Combine(runtimePin.Path,runtimeId),true,generationResult.Handle);generationResult.Handle=null; - runtime=new CcodInstallGenerationRuntimeV2(full,rootPin,runtimePin);rootPin=null;runtimePin=null;runtime.AddPin(generation);return generation.Token; + runtime=new CcodInstallGenerationRuntimeV3(full,rootPin,runtimePin);rootPin=null;runtimePin=null;runtime.AddPin(generation);return generation.Token; } finally { if(generationResult.Handle!=null)generationResult.Handle.Dispose(); } } @@ -117,23 +117,24 @@ internal sealed class CcodInstallGenerationRuntimeV2 : IDisposable [MethodImpl(MethodImplOptions.Synchronized)] internal object OpenDirectory(object parentToken,string leaf,bool createIfMissing) { - Pin parent=Require(parentToken,true);ValidateCurrent(parent);Pin known;if(names.TryGetValue(Key(parent,leaf),out known)){ValidateCurrent(known);return known.Token;}OpenResult result; + Pin parent=Require(parentToken,true);ValidateCurrent(parent);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);return pin.Token;}finally{if(result.Handle!=null)result.Handle.Dispose();} + 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(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);ValidateRetainedManifest(existing,runtimeId,expectedManifestSha);return existing.Token;}OpenResult result=OpenRelative(runtimeParent.Native,runtimeId,LIST_DIRECTORY|READ_ATTRIBUTES|SYNC,SHARE_READ|SHARE_WRITE,OPEN,DIRECTORY|BACKUP_INTENT);List added=new List(); + 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); - ValidateRetainedManifest(generation,runtimeId,expectedManifestSha);ValidateRetainedTree(generation);retainedRoots.Add(generation.Token);return generation.Token; + 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];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);} + 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();} } @@ -198,10 +199,11 @@ internal sealed class CcodInstallGenerationRuntimeV2 : IDisposable { 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)){if(!OnlyDefaultStream(probe.Handle)||!SamePath(FinalPath(probe.Handle),path))throw new InvalidDataException("directory invalid");Pin child=new Pin(directory,leaf,path,true,false,probe.Handle,null,info);probe.Handle=null;AddPin(child);added.Add(child);OpenRetainedTree(child,added);}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);stream=null;}finally{if(stream!=null)stream.Dispose();if(file.Handle!=null)file.Handle.Dispose();}}}finally{if(probe.Handle!=null)probe.Handle.Dispose();}} + 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 void ValidateRetainedManifest(Pin generation,string runtimeId,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");string text=ReadText(manifest.Stream);string propertyPattern="\\\"runtimeId\\\"\\s*:";string valuePattern=propertyPattern+"\\s*\\\""+Regex.Escape(runtimeId)+"\\\"";if(Regex.Matches(text,propertyPattern).Count!=1||Regex.Matches(text,valuePattern).Count!=1)throw new InvalidDataException("retained runtime id 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(offset=0?0:(int)RtlNtStatusToDosError(status);}finally{if(parentAdded)parent.DangerousRelease();if(sourceAdded)source.DangerousRelease();Marshal.FreeHGlobal(buffer);}} } '@ - $marker = 'CcodInstallGenerationCapabilityMarkerV2' -as [type] + $marker = 'CcodInstallGenerationCapabilityMarkerV3' -as [type] } - if ($null -eq $marker -or [int]$marker.GetProperty('CapabilityAbi').GetValue($null,$null) -ne 2) { Throw-CcodInstallFileError 'CCOD_INSTALL_RUNTIME_ABI_INVALID' 'Install generation runtime ABI is unavailable' $null } - $script:CcodRuntimeType = $marker.Assembly.GetType('CcodInstallGenerationRuntimeV2',$true) + if ($null -eq $marker -or [int]$marker.GetProperty('CapabilityAbi').GetValue($null,$null) -ne 3) { Throw-CcodInstallFileError 'CCOD_INSTALL_RUNTIME_ABI_INVALID' 'Install generation runtime ABI is unavailable' $null } + $script:CcodRuntimeType = $marker.Assembly.GetType('CcodInstallGenerationRuntimeV3',$true) } function Assert-CcodInstallLeaf([string]$Leaf,[string]$ErrorId='CCOD_INSTALL_LEAF_INVALID') { @@ -286,6 +288,7 @@ function Test-CcodInstallValue($Value,[int]$Depth=0) { } 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 Test-CcodRetainedManifestIdentity([string]$Json,[string]$RuntimeId){try{$manifest=$Json|ConvertFrom-Json -ErrorAction Stop}catch{return$false};if($manifest-isnot[pscustomobject]){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 { @@ -295,8 +298,8 @@ function Open-CcodInstallGeneration { 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;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-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';Assert-CcodInstallRootScope $scope.State $InstallRoot 'CCOD_INSTALL_TRANSACTION_SCOPE';$token=Convert-CcodInstallRuntimeError {Invoke-CcodRuntimeMethod $scope.State.Runtime OpenRetained @($RuntimeId,$ExpectedManifestSha256)} 'CCOD_INSTALL_RETAINED_GENERATION_INVALID';Add-CcodInstallScope $scope.Record.Transaction $token Generation $true $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';Add-CcodInstallScope $Transaction $token Directory $false $null } +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 } +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;$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 } diff --git a/tests/persistence/InstallFileTransaction.SelfTest.ps1 b/tests/persistence/InstallFileTransaction.SelfTest.ps1 index f10889c..a585cef 100644 --- a/tests/persistence/InstallFileTransaction.SelfTest.ps1 +++ b/tests/persistence/InstallFileTransaction.SelfTest.ps1 @@ -110,6 +110,12 @@ function Assert-CcodOutsideUnchanged { 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-CcodMoveAttempt { param([Parameter(Mandatory)][string]$Path) try { [IO.File]::Move($Path,$Path+'.moved'); 'moved' } catch [IO.IOException] { 'blocked' } catch [UnauthorizedAccessException] { 'blocked' } @@ -132,9 +138,9 @@ function New-CcodHardLink { Invoke-CcodTest 'exports only immutable generation operations and an inert CLR marker' { $expected=@('Close-CcodInstallFileTransaction','Commit-CcodInstallActivePointer','Copy-CcodInstallSealedSource','New-CcodInstallDirectory','New-CcodInstallGenerationLeaf','Open-CcodInstallGeneration','Open-CcodInstallRetainedGeneration','Retire-CcodInstallGeneration','Write-CcodInstallGenerationManifest','Write-CcodInstallRecord') Assert-CcodEqual ($expected -join '|') ((@($module.ExportedCommands.Keys)|Sort-Object)-join '|') 'module export surface is capability-only' - Assert-CcodEqual 2 ([CcodInstallGenerationCapabilityMarkerV2]::CapabilityAbi) 'marker exposes the current non-mutating ABI value' - Assert-CcodEqual 'CcodInstallGenerationCapabilityMarkerV2' ((@([CcodInstallGenerationCapabilityMarkerV2].Assembly.GetExportedTypes()|ForEach-Object FullName)) -join '|') 'current CLR bridge exports only the inert marker' - $dangerous=@([CcodInstallGenerationCapabilityMarkerV2].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 3 ([CcodInstallGenerationCapabilityMarkerV3]::CapabilityAbi) 'marker exposes the current non-mutating ABI value' + Assert-CcodEqual 'CcodInstallGenerationCapabilityMarkerV3' ((@([CcodInstallGenerationCapabilityMarkerV3].Assembly.GetExportedTypes()|ForEach-Object FullName)) -join '|') 'current CLR bridge exports only the inert marker' + $dangerous=@([CcodInstallGenerationCapabilityMarkerV3].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' } @@ -461,6 +467,68 @@ Invoke-CcodTest 'rejects structurally invalid or unowned retained generations' { } } +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 '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 'v3-reimport' 're-imports ABI V3 and exercises every retained extension export' { + $fixture=New-CcodInstallFileFixture + try { + if($null-eq('CcodInstallGenerationCapabilityMarkerV2'-as[type])){Add-Type -TypeDefinition 'public sealed class CcodInstallGenerationCapabilityMarkerV2 { private CcodInstallGenerationCapabilityMarkerV2() {} public static int CapabilityAbi { get { return 2; } } }'} + Assert-CcodEqual 2 ([CcodInstallGenerationCapabilityMarkerV2]::CapabilityAbi) 'old V2 marker is preloaded in the AppDomain' + $module=Import-Module $modulePath -Force -PassThru -DisableNameChecking;$module=Import-Module $modulePath -Force -PassThru -DisableNameChecking + $old=Open-CcodFixtureGeneration $fixture 'runtime-v3-old';$manifest=Write-CcodInstallGenerationManifest -Generation $old -Manifest (New-CcodGenerationManifest 'runtime-v3-old');Close-CcodInstallFileTransaction -Transaction $old -Disposition Ready|Out-Null + $transaction=Open-CcodFixtureGeneration $fixture 'runtime-v3-new';Write-CcodInstallGenerationManifest -Generation $transaction -Manifest (New-CcodGenerationManifest 'runtime-v3-new')|Out-Null + $state=New-CcodInstallDirectory -Transaction $transaction -Parent $transaction -Leaf 'state' -CreateIfMissing;$records=New-CcodInstallDirectory -Transaction $transaction -Parent $state -Leaf 'records' -CreateIfMissing;Write-CcodInstallRecord -Transaction $transaction -Parent $records -Leaf 'record.json' -Record ([ordered]@{schemaVersion=1;value='v3'})|Out-Null + $retained=Open-CcodInstallRetainedGeneration -InstallRoot $fixture.Install -RuntimeId 'runtime-v3-old' -ExpectedManifestSha256 $manifest.Sha256 -FileTransaction $transaction;Commit-CcodInstallActivePointer -InstallRoot $fixture.Install -TargetGeneration $retained -ExpectedPreviousGeneration ([uint64]0) -FileTransaction $transaction|Out-Null + Assert-CcodEqual 3 ([CcodInstallGenerationCapabilityMarkerV3]::CapabilityAbi) 'module binds the new ABI V3 despite the preloaded V2 marker' + } finally {Remove-CcodInstallFileFixture $fixture} +} + Invoke-CcodTest 'retirement writes one create-only record and keeps the nonempty generation in place' { $fixture=New-CcodInstallFileFixture try { From e80b829fc2ec19c2db46bccc5cbbeba8c2e15299 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Sat, 29 Aug 2026 22:39:26 +0800 Subject: [PATCH 079/187] fix: reject duplicate retained manifest identities --- .../task-1-report.md | 55 +++++++++++++++++++ .../modules/InstallFileTransaction.psm1 | 3 +- .../InstallFileTransaction.SelfTest.ps1 | 11 ++++ 3 files changed, 68 insertions(+), 1 deletion(-) 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 index 3b8176d..b0623ef 100644 --- 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 @@ -480,3 +480,58 @@ retained-alias `CCOD_INSTALL_GENERATION_READ_ONLY` assertion. - 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/src/persistence/modules/InstallFileTransaction.psm1 b/src/persistence/modules/InstallFileTransaction.psm1 index b0ebd33..c8a349a 100644 --- a/src/persistence/modules/InstallFileTransaction.psm1 +++ b/src/persistence/modules/InstallFileTransaction.psm1 @@ -288,7 +288,8 @@ function Test-CcodInstallValue($Value,[int]$Depth=0) { } 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 Test-CcodRetainedManifestIdentity([string]$Json,[string]$RuntimeId){try{$manifest=$Json|ConvertFrom-Json -ErrorAction Stop}catch{return$false};if($manifest-isnot[pscustomobject]){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 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 { diff --git a/tests/persistence/InstallFileTransaction.SelfTest.ps1 b/tests/persistence/InstallFileTransaction.SelfTest.ps1 index a585cef..b672990 100644 --- a/tests/persistence/InstallFileTransaction.SelfTest.ps1 +++ b/tests/persistence/InstallFileTransaction.SelfTest.ps1 @@ -506,6 +506,17 @@ Invoke-CcodExtensionRedTest 'semantic-runtime-id' 'rejects an escaped or nested } 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 { From 21014850ed43c04f996fd782a8ae41a771121c8e Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:45:50 +0800 Subject: [PATCH 080/187] fix: commit lifecycle through immutable generations --- .../task-2-report.md | 120 ++++ Install-CodexControlOtherDevices.ps1 | 4 +- ...08-29-v2522-immutable-generation-design.md | 17 + src/persistence/modules/InstallLifecycle.psm1 | 410 ++++++++++--- src/persistence/modules/PersistenceIO.psm1 | 29 +- src/persistence/modules/RuntimeManifest.psm1 | 40 +- src/persistence/modules/StateStore.psm1 | 32 +- src/persistence/modules/UiPreferences.psm1 | 10 +- .../persistence/InstallLifecycle.SelfTest.ps1 | 578 ++++++++---------- tests/persistence/PersistenceIO.SelfTest.ps1 | 6 + 10 files changed, 835 insertions(+), 411 deletions(-) create mode 100644 .superpowers/sdd/2026-08-29-v2522-immutable-generation/task-2-report.md 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..22cc90a --- /dev/null +++ b/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-2-report.md @@ -0,0 +1,120 @@ +# 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) diff --git a/Install-CodexControlOtherDevices.ps1 b/Install-CodexControlOtherDevices.ps1 index bc8cf47..8d5450d 100644 --- a/Install-CodexControlOtherDevices.ps1 +++ b/Install-CodexControlOtherDevices.ps1 @@ -7,7 +7,8 @@ param( [string]$ActivationId, [string]$ExpectedVersion, [string]$PayloadManifestPath, - [string]$ExpectedPayloadManifestSha256 + [string]$ExpectedPayloadManifestSha256, + [string]$SealedPackageSha256 ) $ErrorActionPreference = 'Stop' @@ -180,6 +181,7 @@ try { $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 } 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 index d671916..7b35f8a 100644 --- a/docs/superpowers/specs/2026-08-29-v2522-immutable-generation-design.md +++ b/docs/superpowers/specs/2026-08-29-v2522-immutable-generation-design.md @@ -76,6 +76,23 @@ after the active pointer is committed and the new protection has reached verified readiness. An upgrade does not overwrite a stable file while the new generation is still incomplete. +### 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 diff --git a/src/persistence/modules/InstallLifecycle.psm1 b/src/persistence/modules/InstallLifecycle.psm1 index 8385b5c..db01be6 100644 --- a/src/persistence/modules/InstallLifecycle.psm1 +++ b/src/persistence/modules/InstallLifecycle.psm1 @@ -14,6 +14,9 @@ $script:CcodLifecycleTaskName = 'Codex Control Other Devices Supervisor' $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','newRuntimeId','newGeneration','sealedPackageSha256','ownedObjectNames','phase','errorCode') +$script:CcodInstallTransactionRoots = [Runtime.CompilerServices.ConditionalWeakTable[object,object]]::new() function Throw-CcodLifecycleError { param( @@ -90,8 +93,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 +109,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 +126,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 +147,220 @@ 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 + $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)) { throw 'generation' } + } catch { + Throw-CcodLifecycleError 'CCOD_INSTALL_TRANSACTION_INVALID' 'Install transaction generations are invalid' $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 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)][string]$ContentSha256, + [scriptblock]$NewNonce = { [guid]::NewGuid().ToString('N') } + ) + $nonce = & $NewNonce + if ($ProjectVersion -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$' -or + $ContentSha256 -cnotmatch '^[0-9a-f]{64}$' -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 + } + $hasher = [Security.Cryptography.SHA256]::Create() + try { + $identityBytes = [Text.Encoding]::UTF8.GetBytes("$ProjectVersion`0$ContentSha256") + $identityDigest = ([BitConverter]::ToString($hasher.ComputeHash($identityBytes))).Replace('-','').ToLowerInvariant() + } finally { + $hasher.Dispose() + } + return "$identityDigest-$($nonce.Substring(0,31))" +} + +function New-CcodInstallTransactionRecord { + param( + [Parameter(Mandatory)][string]$TransactionId, + [AllowNull()]$OldRuntimeId, + [AllowNull()]$OldGeneration, + [AllowNull()]$NewRuntimeId, + [Parameter(Mandatory)][uint64]$NewGeneration, + [Parameter(Mandatory)][string]$SealedPackageSha256, + [string[]]$OwnedObjectNames = @() + ) + $record = [pscustomobject][ordered]@{ + schemaVersion = 1 + transactionId = $TransactionId + oldRuntimeId = $OldRuntimeId + oldGeneration = if ($null -eq $OldGeneration) { $null } else { [uint64]$OldGeneration } + newRuntimeId = $NewRuntimeId + newGeneration = [uint64]$NewGeneration + 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 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 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) } + } + 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] + $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.newRuntimeId -cne [string]$head.newRuntimeId -or + [uint64]$current.newGeneration -ne [uint64]$head.newGeneration -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' $directory} + if($nonterminal.Count-eq 1){return $nonterminal[0]} + $active=$null;try{$active=Read-CcodActiveRuntime -InstallRoot $root}catch{} + if($null-ne$active){$ready=@($heads|Where-Object{$_.phase-ceq'Ready'-and$_.newRuntimeId-ceq$active.activeRuntime-and[uint64]$_.newGeneration-eq[uint64]$active.generation});if($ready.Count-ne 0){if($ready.Count-ne 1){Throw-CcodLifecycleError 'CCOD_INSTALL_TRANSACTION_AMBIGUOUS' 'Active generation has multiple Ready records' $directory};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' $directory};return $same[0] +} + +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 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 Assert-CcodLifecycleInstallPathSafe { param( [Parameter(Mandatory)][string]$InstallRoot, @@ -747,6 +966,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) @@ -786,7 +1026,8 @@ function Write-CcodLifecycleLog { [Parameter(Mandatory)][string]$Stage, [Parameter(Mandatory)][string]$Code, [Parameter(Mandatory)][string]$Outcome, - [switch]$ThrowOnFailure + [switch]$ThrowOnFailure, + $FileTransaction ) try { @@ -799,7 +1040,7 @@ function Write-CcodLifecycleLog { code = $Code outcome = $Outcome } - & $Adapters.WriteLog $InstallRoot $record + & $Adapters.WriteLog $InstallRoot $record $FileTransaction } catch { if ($ThrowOnFailure) { throw } } @@ -1420,8 +1661,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) @@ -1432,8 +1673,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 } @@ -1531,8 +1773,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 -TargetGeneration $TargetGeneration -FileTransaction $FileTransaction -Ownership $Ownership } ExitLifecycleOwnership = { param($Ownership) @@ -1582,7 +1824,8 @@ function Get-CcodLifecycleAdapters { 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) @@ -1607,10 +1850,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 { @@ -1864,6 +2108,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 (Get-CcodLifecycleFileSha256 $manifestPath) -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( @@ -1877,6 +2146,7 @@ function Invoke-CcodInstall { [string]$PayloadManifestPath, [string]$ExpectedPayloadManifestSha256, [string]$PayloadManifestBytesBase64, + [string]$SealedPackageSha256, [hashtable]$Adapters ) @@ -1898,6 +2168,7 @@ function Invoke-CcodInstall { RepairCompleted = $false } } + if(-not[IO.Directory]::Exists($root)){[IO.Directory]::CreateDirectory($root)|Out-Null} if (-not [IO.Directory]::Exists($sourceRoot)) { Throw-CcodLifecycleError 'CCOD_INSTALL_SOURCE_MISSING' 'Source checkout does not exist' $sourceRoot @@ -1923,6 +2194,9 @@ 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 } @@ -1931,8 +2205,17 @@ function Invoke-CcodInstall { $existingPointer = $null $activePath = Join-Path $root 'active.json' - if ([IO.File]::Exists($activePath)) { - $existingPointer = Read-CcodActiveRuntime -InstallRoot $root + try{$existingPointer=Read-CcodActiveRuntime -InstallRoot $root}catch{if([IO.File]::Exists($activePath)-or[IO.Directory]::Exists((Join-Path $root 'state\active-generation'))){throw}} + if($null-ne$existingPointer-and$sealedPackageIdentitySupplied){ + $activeRuntimeRoot=[IO.Path]::GetFullPath((Join-Path (Join-Path $root 'runtime') ([string]$existingPointer.activeRuntime))) + $activeValidation=Test-CcodRuntimeManifest -RuntimeDirectory $activeRuntimeRoot -ExpectedRuntimeId ([string]$existingPointer.activeRuntime) + if($activeValidation.Valid){ + $activeTransaction=Read-CcodInstallTransactionRecord -InstallRoot $root + $sameIdentity=Test-CcodInstallPackageIdentity -TransactionRecord $activeTransaction -ProjectVersion ([string]$projectVersion) -ActiveProjectVersion ([string]$activeValidation.Manifest.projectVersion) -SealedPackageSha256 $SealedPackageSha256 -ActiveRuntimeId ([string]$existingPointer.activeRuntime) -ActiveGeneration ([uint64]$existingPointer.generation) + if($sameIdentity){ + return [pscustomobject][ordered]@{Outcome='AlreadyInstalled';Installed=$true;RuntimeId=[string]$existingPointer.activeRuntime;PreviousRuntimeId=$existingPointer.previousRuntime;RepairCompleted=$false} + } + } } if ([string]::IsNullOrWhiteSpace($ActivationId)) { $ActivationId = & $adapters.NewActivationId } @@ -1957,13 +2240,18 @@ function Invoke-CcodInstall { $shutdownGate = $null $lifecycleOwnership = $null $previousProtectionStopped = $false + $fileTransaction=$null;$generationCapability=$null;$installRecord=$null try { + $runtimeId=New-CcodUniqueRuntimeId -ProjectVersion $projectVersion -ContentSha256 $SealedPackageSha256;$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};$installRecord=New-CcodInstallTransactionRecord -TransactionId ([guid]::NewGuid().ToString('D')) -OldRuntimeId $(if($upgrade){$existingPointer.activeRuntime}else{$null}) -OldGeneration $(if($upgrade){$existingPointer.generation}else{$null}) -NewRuntimeId $runtimeId -NewGeneration $newGeneration -SealedPackageSha256 $SealedPackageSha256 -OwnedObjectNames @($runtimeId) + 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 @@ -1973,6 +2261,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) { @@ -1986,69 +2275,37 @@ 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 = & $adapters.NewRuntimeManifest $stagingDirectory $projectVersion - $stagingManifestPath = Join-Path $stagingDirectory 'manifest.json' - [void](Assert-CcodLifecycleInstallLeafSafe -InstallRoot $root -Path $stagingManifestPath -AllowMissing) - [IO.File]::WriteAllText( - $stagingManifestPath, - ($manifest | ConvertTo-Json -Depth 16), - [Text.UTF8Encoding]::new($false) - ) - [void](Assert-CcodLifecycleInstallLeafSafe -InstallRoot $root -Path $stagingManifestPath) - $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 - } - $runtimeContainer = [IO.Path]::GetFullPath((Join-Path $root 'runtime')) - [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $root -Path $runtimeContainer) - $runtimeRoot = [IO.Path]::GetFullPath((Join-Path $runtimeContainer $runtimeId)) - [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $root -Path $runtimeRoot) - 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 { [void](Assert-CcodLifecycleInstallTreeSafe -InstallRoot $root -Path $stagingDirectory); Remove-Item -LiteralPath $stagingDirectory -Recurse -Force -ErrorAction Stop } catch { } - } - } else { - [IO.Directory]::CreateDirectory($runtimeContainer) | Out-Null - [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $root -Path $runtimeContainer) - [void](Assert-CcodLifecycleInstallTreeSafe -InstallRoot $root -Path $stagingDirectory) - [IO.Directory]::Move($stagingDirectory, $runtimeRoot) - [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $root -Path $runtimeRoot) - $runtimeCreated = $true - } - $stagingDirectory = $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 $upgrade) { $stateRoot = Join-Path $root 'state' - Initialize-CcodState -StateRoot $stateRoot -NodeCandidates $nodeCandidates -CandidateCompatibleOptIn ([bool]$EnableCandidateCompatibleUpdates) - Initialize-CcodUiPreference -StateRoot $stateRoot | Out-Null + Initialize-CcodState -StateRoot $stateRoot -NodeCandidates $nodeCandidates -CandidateCompatibleOptIn ([bool]$EnableCandidateCompatibleUpdates) -FileTransaction $fileTransaction -InitializationId $runtimeId + Initialize-CcodUiPreference -StateRoot $stateRoot -FileTransaction $fileTransaction -InitializationId $runtimeId | 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 'ActivatingRuntime' -RuntimeId $runtimeId -PreviousRuntimeId $(if ($upgrade) { [string]$existingPointer.activeRuntime } else { $null }) -ErrorCode $null -Adapters $adapters -InstallRoot $root -FileTransaction $fileTransaction | Out-Null $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 } @@ -2071,6 +2328,7 @@ 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 } + $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 $finalExpectedVersion = if ($payloadBound) { $ExpectedVersion } else { [string]$projectVersion } @@ -2078,17 +2336,12 @@ function Invoke-CcodInstall { -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 | 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 { } - } - } + Write-CcodInstallActivationPhase -Activation $activation -Phase 'Ready' -RuntimeId $runtimeId -PreviousRuntimeId $pointer.previousRuntime -ErrorCode $null -Adapters $adapters -InstallRoot $root -FileTransaction $fileTransaction | Out-Null + $installRecord=Set-CcodInstallTransactionPhase -InstallRoot $root -TransactionId $installRecord.transactionId -ExpectedPhase 'ProtectionReady' -NewPhase 'Ready' -FileTransaction $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 { } } } catch { $caught = $_ @@ -2102,7 +2355,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)) { @@ -2110,6 +2363,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 { @@ -2138,21 +2392,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 { [void](Assert-CcodLifecycleInstallTreeSafe -InstallRoot $root -Path $runtimeRoot); 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 } @@ -2167,13 +2419,7 @@ function Invoke-CcodInstall { 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 { [void](Assert-CcodLifecycleInstallTreeSafe -InstallRoot $root -Path $stagingDirectory); Remove-Item -LiteralPath $stagingDirectory -Recurse -Force -ErrorAction Stop } catch { } - } - $stagingRoot = [IO.Path]::GetFullPath((Join-Path $root '.staging')) - if ([IO.Directory]::Exists($stagingRoot) -and -not (Test-CcodLifecycleReparse -Path $stagingRoot)) { - try { [void](Assert-CcodLifecycleInstallTreeSafe -InstallRoot $root -Path $stagingRoot); Remove-Item -LiteralPath $stagingRoot -Recurse -Force -ErrorAction Stop } catch { } - } + if($null-ne$fileTransaction){try{Close-CcodInstallFileTransaction -Transaction $fileTransaction -Disposition $(if($null-ne$installRecord-and$installRecord.phase-ceq'Ready'){'Ready'}else{'Failed'})}catch{}} } return [pscustomobject][ordered]@{ 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/RuntimeManifest.psm1 b/src/persistence/modules/RuntimeManifest.psm1 index 54e0d31..a422aa9 100644 --- a/src/persistence/modules/RuntimeManifest.psm1 +++ b/src/persistence/modules/RuntimeManifest.psm1 @@ -196,14 +196,15 @@ 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 } else { Assert-CcodRuntimeId $RuntimeId } files = $files } } @@ -309,10 +310,6 @@ function Test-CcodRuntimeManifest { } } - $computedRuntimeId = Get-CcodRuntimeId -ProjectVersion ([string]$projectVersionProperty.Value) -Files $actualFiles - if ($computedRuntimeId -cne $manifestRuntimeId) { - 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 } @@ -330,6 +327,24 @@ function Read-CcodActiveRuntime { [CmdletBinding()] param([Parameter(Mandatory)][string]$InstallRoot) + $pointerRoot = Resolve-CcodContainedPath -Root $InstallRoot -RelativePath 'state\active-generation' -AllowMissingLeaf + if ([IO.Directory]::Exists($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$'){Throw-CcodRuntimeError 'CCOD_RUNTIME_POINTER_INVALID' 'Active generation store contains an unknown object' $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]){Throw-CcodRuntimeError 'CCOD_RUNTIME_POINTER_INVALID' 'Active generation record fields are invalid' $entry.FullName} + $generation=ConvertTo-CcodRuntimeGeneration $record.generation $entry.FullName + $integerTypes=@([byte],[uint16],[uint32],[uint64],[int16],[int32],[int64]);$typed=$false;foreach($t in $integerTypes){if($record.previousGeneration-is$t){$typed=$true;break}};if(-not$typed){Throw-CcodRuntimeError 'CCOD_RUNTIME_POINTER_INVALID' 'Previous generation is not an integer' $entry.FullName} + [uint64]$previous=$record.previousGeneration;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=(Get-Item $pointerRoot).LastWriteTimeUtc.ToString('o')} + } $path = Resolve-CcodContainedPath -Root $InstallRoot -RelativePath 'active.json' -AllowMissingLeaf $active = $null $legacy = $false @@ -404,7 +419,9 @@ function Set-CcodActiveRuntime { [CmdletBinding()] param( [Parameter(Mandatory)][string]$InstallRoot, - [Parameter(Mandatory)][string]$NewRuntimeId, + [string]$NewRuntimeId, + $TargetGeneration, + $FileTransaction, $Ownership, [hashtable]$Adapters ) @@ -413,6 +430,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-CcodActiveRuntime $InstallRoot}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) $(if($null-ne$current){$current.activeRuntime}else{$null}))}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 diff --git a/src/persistence/modules/StateStore.psm1 b/src/persistence/modules/StateStore.psm1 index a24a49a..74aeb58 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 { @@ -398,7 +399,9 @@ function Initialize-CcodState { [Parameter(Mandatory)][string]$StateRoot, [string[]]$NodeCandidates = @(), [bool]$CandidateCompatibleOptIn = $false, - [hashtable]$Adapters + [hashtable]$Adapters, + $FileTransaction, + [string]$InitializationId ) $adapters = Get-CcodStateAdapters -Adapters $Adapters @@ -418,10 +421,25 @@ function Initialize-CcodState { 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 + if($null-ne$FileTransaction){ + if($null-eq(Get-Command New-CcodInstallDirectory -ErrorAction SilentlyContinue)){Import-Module (Join-Path $PSScriptRoot 'InstallFileTransaction.psm1') -ErrorAction Stop} + if($InitializationId-cnotmatch'^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$'){Throw-CcodStateError 'CCOD_STATE_INITIALIZATION_INVALID' 'Immutable initialization identity is invalid' $InitializationId} + $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 $InitializationId -CreateIfMissing + Write-CcodInstallRecord -Transaction $FileTransaction -Parent $baseline -Leaf 'settings.json' -Record $settings|Out-Null + Write-CcodInstallRecord -Transaction $FileTransaction -Parent $baseline -Leaf 'status.json' -Record $status|Out-Null + Write-CcodInstallRecord -Transaction $FileTransaction -Parent $baseline -Leaf 'verified-packages.json' -Record $verifiedPackages|Out-Null + Write-CcodInstallRecord -Transaction $FileTransaction -Parent $baseline -Leaf 'transition.json' -Record $transition|Out-Null + } + 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 } function Read-CcodSettings { diff --git a/src/persistence/modules/UiPreferences.psm1 b/src/persistence/modules/UiPreferences.psm1 index dac297b..76e2a10 100644 --- a/src/persistence/modules/UiPreferences.psm1 +++ b/src/persistence/modules/UiPreferences.psm1 @@ -100,11 +100,19 @@ function Get-CcodUiPreferenceTimestamp { function Initialize-CcodUiPreference { [CmdletBinding()] - param([Parameter(Mandatory)][string]$StateRoot) + param([Parameter(Mandatory)][string]$StateRoot,$FileTransaction,[string]$InitializationId) $path = Resolve-CcodContainedPath -Root $StateRoot -RelativePath 'ui-preferences.json' -AllowMissingLeaf $store = New-CcodUiPreferenceStore -LanguageMode 'System' -UpdatedAtUtc (Get-CcodUiPreferenceTimestamp -Adapters (Get-CcodUiPreferenceAdapters)) Assert-CcodUiPreferenceStore -Store $store + if($null-ne$FileTransaction){ + if($null-eq(Get-Command New-CcodInstallDirectory -ErrorAction SilentlyContinue)){Import-Module (Join-Path $PSScriptRoot 'InstallFileTransaction.psm1') -ErrorAction Stop} + if($InitializationId-cnotmatch'^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$'){Throw-CcodUiPreferenceError 'CCOD_UI_PREFERENCES_INVALID' 'Immutable initialization identity is invalid.' $InitializationId} + $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 $InitializationId -CreateIfMissing + try{Write-CcodInstallRecord -Transaction $FileTransaction -Parent $baseline -Leaf 'ui-preferences.json' -Record $store|Out-Null}catch{if($_.FullyQualifiedErrorId-like'CCOD_INSTALL_RECORD_EXISTS*'){Throw-CcodUiPreferenceError 'CCOD_UI_PREFERENCES_EXISTS' 'UI preference baseline already exists.' $path};throw} + } try { Write-CcodAtomicJsonIfAbsent -Path $path -Value $store } catch { diff --git a/tests/persistence/InstallLifecycle.SelfTest.ps1 b/tests/persistence/InstallLifecycle.SelfTest.ps1 index 8672679..6dbbb54 100644 --- a/tests/persistence/InstallLifecycle.SelfTest.ps1 +++ b/tests/persistence/InstallLifecycle.SelfTest.ps1 @@ -184,6 +184,7 @@ function New-CcodLifecycleFake { LogRecords = [Collections.Generic.List[object]]::new() FailLogCode = $null CopyOverride = $null + TaskRuntimeIds = [Collections.Generic.List[string]]::new() } $adapters = @{} $adapters.ValidateSource = { param($SourceRoot) $world.Calls.Add("Validate:$([IO.Path]::GetFileName($SourceRoot))"); [bool]$world.ValidateSource }.GetNewClosure() @@ -192,20 +193,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() @@ -252,10 +258,10 @@ function New-CcodLifecycleFake { } }.GetNewClosure() $adapters.SetActiveRuntime = { - param($InstallRoot, $RuntimeId, $Ownership) - if ($null -ne $world.SetActiveFailure) { & $world.SetActiveFailure $InstallRoot $RuntimeId $Ownership } + param($InstallRoot, $RuntimeId, $Ownership, $TargetGeneration, $FileTransaction) + if ($null -ne $world.SetActiveFailure) { & $world.SetActiveFailure $InstallRoot $RuntimeId $Ownership $TargetGeneration $FileTransaction } $assertFence = { param($Root, $Receipt, $ExpectActivePointer) if ($Receipt.released) { throw 'released lifecycle owner' }; $true } - Set-CcodActiveRuntime -InstallRoot $InstallRoot -NewRuntimeId $RuntimeId -Ownership $Ownership -Adapters @{ AssertLifecycleFence=$assertFence } + Set-CcodActiveRuntime -InstallRoot $InstallRoot -TargetGeneration $TargetGeneration -FileTransaction $FileTransaction -Ownership $Ownership -Adapters @{ AssertLifecycleFence=$assertFence } }.GetNewClosure() $adapters.ExitLifecycleOwnership = { param($Ownership) @@ -305,7 +311,7 @@ function New-CcodLifecycleFake { 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 { @@ -393,6 +399,163 @@ 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) -NewRuntimeId 'runtime-new' -NewGeneration ([uint64]8) -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 + newRuntimeId='runtime-new';newGeneration=[uint64]8;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 '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-idempotent'|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' + $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 sealed content identity while preserving per-attempt uniqueness' { + $module=Get-Module InstallLifecycle -ErrorAction Stop + $first=&$module { New-CcodUniqueRuntimeId -ProjectVersion '2.5.22' -ContentSha256 ('a'*64) -NewNonce { '1'*32 } } + $second=&$module { New-CcodUniqueRuntimeId -ProjectVersion '2.5.22' -ContentSha256 ('a'*64) -NewNonce { '2'*32 } } + $differentContent=&$module { New-CcodUniqueRuntimeId -ProjectVersion '2.5.22' -ContentSha256 ('b'*64) -NewNonce { '1'*32 } } + $differentVersion=&$module { New-CcodUniqueRuntimeId -ProjectVersion '2.5.23' -ContentSha256 ('a'*64) -NewNonce { '1'*32 } } + Assert-CcodEqual 'af45134e2aa385355392e1e45369c3ae41520cc99278076626aeeca7bfde0356-1111111111111111111111111111111' $first 'runtime id contains the full deterministic project/package digest and a canonical attempt nonce' + Assert-CcodEqual 96 $first.Length '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) 'sealed package content changes the deterministic runtime identity' + Assert-CcodTrue ($first-cne$differentVersion) 'project version changes the deterministic runtime identity' + Assert-CcodThrows { &$module { New-CcodUniqueRuntimeId -ProjectVersion '2.5.22' -ContentSha256 ('a'*64) -NewNonce { 'not-a-guid' } } } 'CCOD_INSTALL_RUNTIME_ID_INVALID' + Assert-CcodThrows { &$module { New-CcodUniqueRuntimeId -ProjectVersion '2.5.22' -ContentSha256 ('A'*64) -NewNonce { '1'*32 } } } '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 -NewRuntimeId 'scope-runtime' -NewGeneration 1 -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 '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;newRuntimeId='runtime-new';newGeneration=2;sealedPackageSha256=('f'*64);ownedObjectNames=@('runtime-new');phase='Prepared';errorCode=$null} + $verified=[pscustomobject][ordered]@{schemaVersion=1;transactionId=$transactionId;oldRuntimeId='runtime-old-b';oldGeneration=1;newRuntimeId='runtime-new';newGeneration=2;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;newRuntimeId=("runtime-new-$Generation");newGeneration=$Generation;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;newRuntimeId=$runtimeId;newGeneration=$generation;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 '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() @@ -651,10 +814,15 @@ $results += Invoke-CcodTest 'first install stages verifies activates task and pe $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' + $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') @@ -665,7 +833,19 @@ $results += Invoke-CcodTest 'first install stages verifies activates task and pe $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)" + $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 @@ -741,15 +921,20 @@ $results += Invoke-CcodTest 'payload-bound install rereads the active pointer af $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' + New-CcodLifecycleSourceFixture -Root $source -Version '2.5.21' | Out-Null $nodePath = New-CcodLifecycleFakeNode -Root $nodeRoot - $first = Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters (New-CcodLifecycleFake -NodePath $nodePath).Adapters + $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) - [IO.File]::WriteAllText((Join-Path $InstallRoot 'active.json'),($oldPointer | ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false)) + $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() @@ -772,27 +957,25 @@ $results += Invoke-CcodTest 'stable bootstrap and uninstaller come from manifest 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 - $fake.World.CopyOverride = [pscustomobject]@{ - Match = '*Uninstall-CodexControlOtherDevices.ps1' - Action = { - param($SourcePath,$DestinationPath) - [IO.Directory]::CreateDirectory((Split-Path $DestinationPath -Parent)) | Out-Null - [IO.File]::Copy($SourcePath,$DestinationPath,$true) - if ([IO.Path]::GetFullPath($SourcePath).Equals([IO.Path]::GetFullPath($uninstallerSource),[StringComparison]::OrdinalIgnoreCase)) { - [IO.File]::WriteAllText($SourcePath,"# mutated after staging`r`n",[Text.UTF8Encoding]::new($false)) - } - }.GetNewClosure() - } $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 $runtimeRoot 'src\persistence\bootstrap.ps1')) (Get-CcodTestFileSha256 -Path (Join-Path $install 'bootstrap.ps1')) 'stable bootstrap equals its verified runtime byte copy' - Assert-CcodEqual (Get-CcodTestFileSha256 -Path (Join-Path $runtimeRoot 'Uninstall-CodexControlOtherDevices.ps1')) (Get-CcodTestFileSha256 -Path (Join-Path $install 'Uninstall-CodexControlOtherDevices.ps1')) 'stable uninstaller ignores post-staging source mutation' + 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 } } } @@ -817,26 +1000,6 @@ $results += Invoke-CcodTest 'install root junction is rejected before staging wr } } -$results += Invoke-CcodTest 'staging junction is rejected before payload writes' { - $source = New-CcodLifecycleTempRoot - $install = New-CcodLifecycleTempRoot - $target = New-CcodLifecycleTempRoot - $nodeRoot = New-CcodLifecycleTempRoot - try { - 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 '.staging') -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-CcodEqual 0 @(Get-ChildItem -LiteralPath $target -Force).Count 'staging junction target remains empty' - } finally { - $junction = Join-Path $install '.staging' - 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 } } - } -} - $results += Invoke-CcodTest 'runtime junction is rejected before staging promotion' { $source = New-CcodLifecycleTempRoot $install = New-CcodLifecycleTempRoot @@ -848,7 +1011,7 @@ $results += Invoke-CcodTest 'runtime junction is rejected before staging promoti [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_REPARSE_PATH' + 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 { @@ -858,61 +1021,6 @@ $results += Invoke-CcodTest 'runtime junction is rejected before staging promoti } } -$results += Invoke-CcodTest 'stable bootstrap leaf junction is rejected before copy' { - $source = New-CcodLifecycleTempRoot - $install = New-CcodLifecycleTempRoot - $target = New-CcodLifecycleTempRoot - $nodeRoot = New-CcodLifecycleTempRoot - try { - New-CcodLifecycleSourceFixture -Root $source | Out-Null - [IO.Directory]::CreateDirectory($install) | Out-Null - [IO.Directory]::CreateDirectory($target) | Out-Null - $bootstrapLink = Join-Path $install 'bootstrap.ps1' - New-Item -ItemType Junction -Path $bootstrapLink -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-CcodEqual 0 @(Get-ChildItem -LiteralPath $target -Force).Count 'stable bootstrap junction target receives no bytes' - } finally { - $bootstrapLink = Join-Path $install 'bootstrap.ps1' - if (Test-Path -LiteralPath $bootstrapLink) { [IO.Directory]::Delete($bootstrapLink) } - foreach ($path in @($source,$install,$target,$nodeRoot)) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force } } - } -} - -# Production mutation caught: treating a non-reparse hard link as a safe overwrite target and changing bytes outside the install root. -$results += Invoke-CcodTest 'stable bootstrap and public uninstaller reject hard-linked target leaves before outside bytes change' { - foreach ($case in @( - [pscustomobject]@{Name='bootstrap';Leaf='bootstrap.ps1';AddUninstaller=$false}, - [pscustomobject]@{Name='uninstaller';Leaf='Uninstall-CodexControlOtherDevices.ps1';AddUninstaller=$true} - )) { - $source = New-CcodLifecycleTempRoot - $install = New-CcodLifecycleTempRoot - $outside = New-CcodLifecycleTempRoot - $nodeRoot = New-CcodLifecycleTempRoot - try { - New-CcodLifecycleSourceFixture -Root $source | Out-Null - if ($case.AddUninstaller) { - [IO.File]::WriteAllText((Join-Path $source 'Uninstall-CodexControlOtherDevices.ps1'),'# verified uninstaller',[Text.UTF8Encoding]::new($false)) - } - [IO.Directory]::CreateDirectory($install) | Out-Null - [IO.Directory]::CreateDirectory($outside) | Out-Null - $sentinel = Join-Path $outside ($case.Name + '-sentinel.txt') - [IO.File]::WriteAllText($sentinel,'outside-original',[Text.UTF8Encoding]::new($false)) - $targetLeaf = Join-Path $install $case.Leaf - New-Item -ItemType HardLink -Path $targetLeaf -Target $sentinel | 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_UNSAFE_LEAF' - Assert-CcodEqual 'outside-original' ([IO.File]::ReadAllText($sentinel,[Text.UTF8Encoding]::new($false))) "$($case.Name) hard-link sentinel remains unchanged" - Assert-CcodTrue (-not [IO.File]::Exists((Join-Path $install 'active.json'))) "$($case.Name) hard-link rejection cannot commit an active pointer" - } finally { - foreach ($path in @($source,$install,$outside,$nodeRoot)) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force -ErrorAction SilentlyContinue } } - } - } -} - # 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 @@ -943,67 +1051,6 @@ $results += Invoke-CcodTest 'whole-install deletion rejects a nested hard-link b } } -$results += Invoke-CcodTest 'runtime manifest leaf junction is rejected before write' { - $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 - $nodePath = New-CcodLifecycleFakeNode -Root $nodeRoot - $fake = New-CcodLifecycleFake -NodePath $nodePath - $fake.Adapters.NewRuntimeManifest = { - param($RuntimeDirectory,$ProjectVersion) - $manifest = New-CcodRuntimeManifest -RuntimeDirectory $RuntimeDirectory -ProjectVersion $ProjectVersion - New-Item -ItemType Junction -Path (Join-Path $RuntimeDirectory 'manifest.json') -Target $target | Out-Null - return $manifest - }.GetNewClosure() - Assert-CcodThrows { Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters $fake.Adapters | Out-Null } 'CCOD_INSTALL_REPARSE_PATH' - Assert-CcodEqual 0 @(Get-ChildItem -LiteralPath $target -Force).Count 'runtime manifest junction target receives no bytes' - } finally { - foreach ($staging in @(Get-ChildItem -LiteralPath (Join-Path $install '.staging') -Directory -ErrorAction SilentlyContinue)) { - $link = Join-Path $staging.FullName 'manifest.json' - if (Test-Path -LiteralPath $link) { [IO.Directory]::Delete($link) } - } - foreach ($path in @($source,$install,$target,$nodeRoot)) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force } } - } -} - -$results += Invoke-CcodTest 'staging cleanup refuses a nested junction after copy failure' { - $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 - [IO.File]::WriteAllText((Join-Path $target 'sentinel.txt'),'keep',[Text.UTF8Encoding]::new($false)) - $nodePath = New-CcodLifecycleFakeNode -Root $nodeRoot - $fake = New-CcodLifecycleFake -NodePath $nodePath - $fake.World.CopyOverride = [pscustomobject]@{ - Match = '*Supervisor.ps1' - Action = { - param($SourcePath,$DestinationPath) - $stagingDirectory = Split-Path (Split-Path (Split-Path $DestinationPath -Parent) -Parent) -Parent - [IO.Directory]::CreateDirectory($stagingDirectory) | Out-Null - New-Item -ItemType Junction -Path (Join-Path $stagingDirectory 'cleanup-escape') -Target $target | Out-Null - throw 'PRIVATE_COPY_FAILURE_AFTER_JUNCTION' - }.GetNewClosure() - } - Assert-CcodThrows { Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters $fake.Adapters | Out-Null } 'CCOD_INSTALL_STAGING_FAILED' - $retained = @(Get-ChildItem -LiteralPath (Join-Path $install '.staging') -Directory -ErrorAction SilentlyContinue | Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName 'cleanup-escape') }) - Assert-CcodEqual 1 $retained.Count 'unsafe staging tree is retained instead of recursively deleted' - Assert-CcodEqual 'keep' ([IO.File]::ReadAllText((Join-Path $target 'sentinel.txt'))) 'cleanup never deletes through the nested junction' - } finally { - foreach ($staging in @(Get-ChildItem -LiteralPath (Join-Path $install '.staging') -Directory -ErrorAction SilentlyContinue)) { - $link = Join-Path $staging.FullName 'cleanup-escape' - if (Test-Path -LiteralPath $link) { [IO.Directory]::Delete($link) } - } - foreach ($path in @($source,$install,$target,$nodeRoot)) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force } } - } -} - $results += Invoke-CcodTest 'payload manifest ancestry treats Windows root equality case-insensitively' { $source = New-CcodLifecycleTempRoot $install = New-CcodLifecycleTempRoot @@ -1040,7 +1087,7 @@ $results += Invoke-CcodTest 'payload-bound install rejects a changed runtime man $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' + } '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 } } @@ -1089,7 +1136,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' }) @@ -1166,9 +1213,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' @@ -1359,10 +1412,11 @@ $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};Set-CcodActiveRuntime -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 @@ -1375,27 +1429,31 @@ $results += Invoke-CcodTest 'upgrade boundaries fail closed with phase receipts 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 } } @@ -1403,33 +1461,6 @@ $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 - try { - New-CcodLifecycleSourceFixture -Root $source -Version '2.5.0-rollback-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)) - $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() - - 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' - $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}}} -} - # Production mutation caught: setting previousProtectionStopped only after task-idle proof, which skips rollback when exact Supervisor exit succeeds but IgnoreNew remains busy. $results += Invoke-CcodTest 'task-idle failure restarts and proves the old active generation before Failed' { $source=New-CcodLifecycleTempRoot;$install=New-CcodLifecycleTempRoot;$nodeRoot=New-CcodLifecycleTempRoot @@ -1441,7 +1472,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' @@ -1456,28 +1487,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 @@ -1489,42 +1500,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 @@ -1537,7 +1524,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' @@ -1579,61 +1566,30 @@ $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' { - $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 - $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' - } finally { - 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 - 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 } } - } -} - -$results += Invoke-CcodTest 'selected UI catalog mutation after inventory fails hash verification 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 + $sourceLock = $null try { New-CcodLifecycleSourceFixture -Root $source | Out-Null + [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 = '*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)) - } - } - 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 { + $sourceLock=[IO.File]::Open((Join-Path $source 'src\persistence\Supervisor.ps1'),[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::None) + $failure=$null;try{Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -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$sourceLock){$sourceLock.Dispose()} foreach ($path in @($source, $install, $nodeRoot)) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force } } } } @@ -1824,7 +1780,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 { 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' From bd5b690ec825673794cf2d9fdb7e1a628613ad44 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Tue, 1 Sep 2026 03:15:35 +0800 Subject: [PATCH 081/187] fix: complete generation bootstrap lifecycle --- .../task-2-report.md | 86 +++++++++++++ ...08-29-v2522-immutable-generation-design.md | 31 ++++- src/persistence/StaticProbeWorker.ps1 | 19 +-- src/persistence/UninstallBootstrap.ps1 | 24 ++-- src/persistence/bootstrap.ps1 | 64 ++++++++-- src/persistence/modules/InstallLifecycle.psm1 | 108 ++++++++++------ src/persistence/modules/RuntimeManifest.psm1 | 41 ++++++- src/persistence/modules/StateStore.psm1 | 18 +-- src/persistence/modules/UiPreferences.psm1 | 16 ++- tests/persistence/Bootstrap.SelfTest.ps1 | 34 +++++- .../persistence/InstallLifecycle.SelfTest.ps1 | 115 +++++++++++++----- .../persistence/RuntimeManifest.SelfTest.ps1 | 34 +++++- .../StaticProbeWorker.SelfTest.ps1 | 15 ++- .../UninstallBootstrap.SelfTest.ps1 | 10 +- 14 files changed, 477 insertions(+), 138 deletions(-) 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 index 22cc90a..10cb813 100644 --- 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 @@ -118,3 +118,89 @@ start/stop, WindowsApps access, DPAPI access, or external write was performed. ## 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) 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 index 7b35f8a..99269f6 100644 --- a/docs/superpowers/specs/2026-08-29-v2522-immutable-generation-design.md +++ b/docs/superpowers/specs/2026-08-29-v2522-immutable-generation-design.md @@ -71,10 +71,21 @@ 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. They are made visible only -after the active pointer is committed and the new protection has reached -verified readiness. An upgrade does not overwrite a stable file while the new -generation is still incomplete. +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. ### 1a. Immutable initialization evidence and operational state @@ -145,6 +156,18 @@ 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 diff --git a/src/persistence/StaticProbeWorker.ps1 b/src/persistence/StaticProbeWorker.ps1 index 47b29ca..4e63d37 100644 --- a/src/persistence/StaticProbeWorker.ps1 +++ b/src/persistence/StaticProbeWorker.ps1 @@ -421,12 +421,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))} $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 } @@ -452,11 +453,13 @@ 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'} + $pointerRoot=[IO.Path]::GetFullPath((Join-Path $installRoot 'state\active-generation')) + if([IO.Directory]::Exists($pointerRoot)){ + 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-CcodStaticProbeNoReparse $installRoot $entry.FullName -Adapters $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.generation-isnot[long]-and$record.generation-isnot[int]-or$record.previousGeneration-isnot[long]-and$record.previousGeneration-isnot[int]-or-not(Test-CcodStaticRuntimeId $record.activeRuntime)){throw 'active generation'};[uint64]$generation=$record.generation;[uint64]$previousGeneration=$record.previousGeneration;if($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 +475,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/UninstallBootstrap.ps1 b/src/persistence/UninstallBootstrap.ps1 index 779d04e..6f2c008 100644 --- a/src/persistence/UninstallBootstrap.ps1 +++ b/src/persistence/UninstallBootstrap.ps1 @@ -257,7 +257,7 @@ 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 } @@ -266,7 +266,8 @@ function Get-CcodUninstallBootstrapRuntimeId { $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() } } @@ -362,16 +363,12 @@ function Get-CcodUninstallBootstrapVerifiedRuntimeContext { 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 - } - $generation = ConvertTo-CcodUninstallBootstrapUInt64 $active.generation 'active runtime generation' + $pointerRoot=Resolve-CcodUninstallBootstrapChildPath -Root $install -RelativePath 'state\active-generation' -AllowMissingLeaf + if([IO.Directory]::Exists($pointerRoot)){ + $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'};$path=Resolve-CcodUninstallBootstrapChildPath -Root $install -RelativePath ('state\active-generation\'+$entry.Name) -RequireLeafFile;$record=Read-CcodUninstallBootstrapJson -Path $path -Kind 'Active generation';if(-not(Test-CcodUninstallBootstrapExactProperties $record @('schemaVersion','generation','activeRuntime','previousGeneration'))-or$record.schemaVersion-isnot[int]-or$record.schemaVersion-ne1-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($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 +398,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) { diff --git a/src/persistence/bootstrap.ps1 b/src/persistence/bootstrap.ps1 index 24a1c49..39ef577 100644 --- a/src/persistence/bootstrap.ps1 +++ b/src/persistence/bootstrap.ps1 @@ -124,9 +124,55 @@ 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) + $pointerRoot=Assert-CcodBootstrapContained -Root $InstallRoot -Path (Join-Path $InstallRoot 'state\active-generation') -AllowMissingLeaf + if([IO.Directory]::Exists($pointerRoot)){ + 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 + $record=Read-CcodBootstrapJson -Path $entry.FullName -Kind 'active generation selector' + $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' if ($null -eq $value.PSObject.Properties['schemaVersion'] -or $value.schemaVersion -isnot [int] -or $value.schemaVersion -notin @(1,2) -or @@ -157,6 +203,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,8 +267,10 @@ 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) { @@ -234,7 +283,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 +327,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 +372,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 +395,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 +892,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 +918,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/InstallLifecycle.psm1 b/src/persistence/modules/InstallLifecycle.psm1 index db01be6..fe0aabf 100644 --- a/src/persistence/modules/InstallLifecycle.psm1 +++ b/src/persistence/modules/InstallLifecycle.psm1 @@ -15,7 +15,7 @@ $script:CcodLifecycleDefaultInstallRoot = Join-Path ([Environment]::GetFolderPat $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','newRuntimeId','newGeneration','sealedPackageSha256','ownedObjectNames','phase','errorCode') +$script:CcodInstallTransactionFields = @('schemaVersion','transactionId','oldRuntimeId','oldGeneration','oldManifestSha256','newRuntimeId','newGeneration','newManifestSha256','sealedPackageSha256','ownedObjectNames','phase','errorCode') $script:CcodInstallTransactionRoots = [Runtime.CompilerServices.ConditionalWeakTable[object,object]]::new() function Throw-CcodLifecycleError { @@ -156,6 +156,8 @@ function Assert-CcodInstallTransactionRecord { -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 @@ -171,7 +173,7 @@ function Assert-CcodInstallTransactionRecord { 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)) { throw 'generation' } + 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 } @@ -208,23 +210,23 @@ function Assert-CcodInstallTransactionRoot { function New-CcodUniqueRuntimeId { param( [Parameter(Mandatory)][string]$ProjectVersion, - [Parameter(Mandatory)][string]$ContentSha256, + [Parameter(Mandatory)][object[]]$Files, [scriptblock]$NewNonce = { [guid]::NewGuid().ToString('N') } ) $nonce = & $NewNonce - if ($ProjectVersion -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$' -or - $ContentSha256 -cnotmatch '^[0-9a-f]{64}$' -or + 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 } - $hasher = [Security.Cryptography.SHA256]::Create() - try { - $identityBytes = [Text.Encoding]::UTF8.GetBytes("$ProjectVersion`0$ContentSha256") - $identityDigest = ([BitConverter]::ToString($hasher.ComputeHash($identityBytes))).Replace('-','').ToLowerInvariant() - } finally { - $hasher.Dispose() + $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}) } - return "$identityDigest-$($nonce.Substring(0,31))" + $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 { @@ -232,8 +234,10 @@ function New-CcodInstallTransactionRecord { [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 = @() ) @@ -242,8 +246,10 @@ function New-CcodInstallTransactionRecord { 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' @@ -265,6 +271,23 @@ function Get-CcodInstallTransactionDirectory { 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) @@ -302,8 +325,10 @@ function Read-CcodInstallTransactionRecord { 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 @@ -1119,11 +1144,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|$)' @@ -1147,9 +1173,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 @@ -1819,6 +1849,10 @@ function Get-CcodLifecycleAdapters { param($Source, $Destination) Copy-CcodLifecycleFileAtomically -Source $Source -Destination $Destination } + CommitReadyTransaction = { + param($InstallRoot,$TransactionId,$FileTransaction) + Set-CcodInstallTransactionPhase -InstallRoot $InstallRoot -TransactionId $TransactionId -ExpectedPhase 'ProtectionReady' -NewPhase 'Ready' -FileTransaction $FileTransaction + } NewRuntimeManifest = { param($RuntimeDirectory, $ProjectVersion) New-CcodRuntimeManifest -RuntimeDirectory $RuntimeDirectory -ProjectVersion $ProjectVersion @@ -1943,7 +1977,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*$' @@ -2113,7 +2147,7 @@ function Invoke-CcodInstallCompensation { [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 (Get-CcodLifecycleFileSha256 $manifestPath) -FileTransaction $FileTransaction + $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 @@ -2206,12 +2240,20 @@ function Invoke-CcodInstall { $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($null-ne$existingPointer-and$sealedPackageIdentitySupplied){ + $globalTransaction=Read-CcodInstallTransactionRecord -InstallRoot $root + if($null-ne$globalTransaction-and$globalTransaction.phase-notin@('Ready','Failed')){Throw-CcodLifecycleError 'CCOD_INSTALL_TRANSACTION_BUSY' 'A nonterminal install transaction must be recovered before a new attempt' $globalTransaction.transactionId} + $oldManifestSha256=$null;$activeValidation=$null + if($null-ne$existingPointer){ $activeRuntimeRoot=[IO.Path]::GetFullPath((Join-Path (Join-Path $root 'runtime') ([string]$existingPointer.activeRuntime))) - $activeValidation=Test-CcodRuntimeManifest -RuntimeDirectory $activeRuntimeRoot -ExpectedRuntimeId ([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} + $oldManifestSha256=Get-CcodLifecycleFileSha256 -Path $activeManifestPath + $activeValidation=Test-CcodRuntimeManifest -RuntimeDirectory $activeRuntimeRoot -ExpectedRuntimeId ([string]$existingPointer.activeRuntime) -ExpectedManifestSha256 $oldManifestSha256 + if(-not$activeValidation.Valid){Throw-CcodLifecycleError 'CCOD_INSTALL_RUNTIME_ACTIVATION_UNPROVEN' 'The active runtime manifest is invalid before upgrade' $activeRuntimeRoot} + } + if($null-ne$existingPointer-and$sealedPackageIdentitySupplied){ if($activeValidation.Valid){ - $activeTransaction=Read-CcodInstallTransactionRecord -InstallRoot $root - $sameIdentity=Test-CcodInstallPackageIdentity -TransactionRecord $activeTransaction -ProjectVersion ([string]$projectVersion) -ActiveProjectVersion ([string]$activeValidation.Manifest.projectVersion) -SealedPackageSha256 $SealedPackageSha256 -ActiveRuntimeId ([string]$existingPointer.activeRuntime) -ActiveGeneration ([uint64]$existingPointer.generation) + $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){ return [pscustomobject][ordered]@{Outcome='AlreadyInstalled';Installed=$true;RuntimeId=[string]$existingPointer.activeRuntime;PreviousRuntimeId=$existingPointer.previousRuntime;RepairCompleted=$false} } @@ -2240,10 +2282,10 @@ function Invoke-CcodInstall { $shutdownGate = $null $lifecycleOwnership = $null $previousProtectionStopped = $false - $fileTransaction=$null;$generationCapability=$null;$installRecord=$null + $fileTransaction=$null;$generationCapability=$null;$installRecord=$null;$readyReceiptCommitted=$false try { - $runtimeId=New-CcodUniqueRuntimeId -ProjectVersion $projectVersion -ContentSha256 $SealedPackageSha256;$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};$installRecord=New-CcodInstallTransactionRecord -TransactionId ([guid]::NewGuid().ToString('D')) -OldRuntimeId $(if($upgrade){$existingPointer.activeRuntime}else{$null}) -OldGeneration $(if($upgrade){$existingPointer.generation}else{$null}) -NewRuntimeId $runtimeId -NewGeneration $newGeneration -SealedPackageSha256 $SealedPackageSha256 -OwnedObjectNames @($runtimeId) + $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};$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 $generation.ManifestSha256 -SealedPackageSha256 $SealedPackageSha256 -OwnedObjectNames @($runtimeId) 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) { @@ -2279,11 +2321,7 @@ function Invoke-CcodInstall { 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 $upgrade) { - $stateRoot = Join-Path $root 'state' - Initialize-CcodState -StateRoot $stateRoot -NodeCandidates $nodeCandidates -CandidateCompatibleOptIn ([bool]$EnableCandidateCompatibleUpdates) -FileTransaction $fileTransaction -InitializationId $runtimeId - Initialize-CcodUiPreference -StateRoot $stateRoot -FileTransaction $fileTransaction -InitializationId $runtimeId | Out-Null - } + 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 $process = [Diagnostics.Process]::GetCurrentProcess() try { @@ -2330,14 +2368,15 @@ function Invoke-CcodInstall { } $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 + $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 - $installRecord=Set-CcodInstallTransactionPhase -InstallRoot $root -TransactionId $installRecord.transactionId -ExpectedPhase 'ProtectionReady' -NewPhase 'Ready' -FileTransaction $fileTransaction + $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 -FileTransaction $fileTransaction } catch { @@ -2346,6 +2385,7 @@ function Invoke-CcodInstall { } 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' } diff --git a/src/persistence/modules/RuntimeManifest.psm1 b/src/persistence/modules/RuntimeManifest.psm1 index a422aa9..947a665 100644 --- a/src/persistence/modules/RuntimeManifest.psm1 +++ b/src/persistence/modules/RuntimeManifest.psm1 @@ -175,9 +175,14 @@ 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}$'-or$Nonce-cnotmatch'^[0-9a-f]{32}$'){ + 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)) @@ -189,7 +194,13 @@ 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})$'-or$Matches.version-cne$ProjectVersion){return $false} + return (Get-CcodRuntimeId -ProjectVersion $ProjectVersion -Files $Files -Nonce $Matches.nonce)-ceq$RuntimeId } function New-CcodRuntimeManifest { @@ -204,7 +215,7 @@ function New-CcodRuntimeManifest { return [pscustomobject][ordered]@{ schemaVersion = 1 projectVersion = $ProjectVersion - runtimeId = if ([string]::IsNullOrWhiteSpace($RuntimeId)) { Get-CcodRuntimeId -ProjectVersion $ProjectVersion -Files $files } else { Assert-CcodRuntimeId $RuntimeId } + 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 } } @@ -229,7 +240,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 @@ -242,6 +254,9 @@ function Test-CcodRuntimeManifest { if (-not [IO.File]::Exists($manifestPath)) { return New-CcodRuntimeValidationResult -Valid $false -Code 'CCOD_RUNTIME_MANIFEST_MISSING' -RuntimeId $ExpectedRuntimeId -Manifest $null } + if(-not[string]::IsNullOrWhiteSpace($ExpectedManifestSha256)){ + if($ExpectedManifestSha256-cnotmatch'^[0-9a-f]{64}$'-or(Get-CcodRuntimeFileSha256 -Path $manifestPath)-cne$ExpectedManifestSha256){return New-CcodRuntimeValidationResult -Valid $false -Code 'CCOD_RUNTIME_MANIFEST_HASH_MISMATCH' -RuntimeId $ExpectedRuntimeId -Manifest $null} + } try { $manifest = Read-CcodStrictJson -Path $manifestPath -ExpectedSchema 1 -Kind 'runtime manifest' @@ -310,6 +325,9 @@ function Test-CcodRuntimeManifest { } } + 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 } @@ -323,6 +341,20 @@ function Get-CcodRuntimeDirectoryForId { return Resolve-CcodContainedPath -Root $InstallRoot -RelativePath (Join-Path 'runtime' $RuntimeId) -AllowMissingLeaf } +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-CcodActiveRuntime { [CmdletBinding()] param([Parameter(Mandatory)][string]$InstallRoot) @@ -334,6 +366,7 @@ function Read-CcodActiveRuntime { $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$'){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]){Throw-CcodRuntimeError 'CCOD_RUNTIME_POINTER_INVALID' 'Active generation record fields are invalid' $entry.FullName} $generation=ConvertTo-CcodRuntimeGeneration $record.generation $entry.FullName diff --git a/src/persistence/modules/StateStore.psm1 b/src/persistence/modules/StateStore.psm1 index 74aeb58..aed4eb4 100644 --- a/src/persistence/modules/StateStore.psm1 +++ b/src/persistence/modules/StateStore.psm1 @@ -399,9 +399,7 @@ function Initialize-CcodState { [Parameter(Mandatory)][string]$StateRoot, [string[]]$NodeCandidates = @(), [bool]$CandidateCompatibleOptIn = $false, - [hashtable]$Adapters, - $FileTransaction, - [string]$InitializationId + [hashtable]$Adapters ) $adapters = Get-CcodStateAdapters -Adapters $Adapters @@ -415,7 +413,7 @@ 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 @@ -425,21 +423,11 @@ function Initialize-CcodState { $status = New-CcodStatusStore $verifiedPackages = New-CcodVerifiedPackagesStore $transition = New-CcodTransitionStore - if($null-ne$FileTransaction){ - if($null-eq(Get-Command New-CcodInstallDirectory -ErrorAction SilentlyContinue)){Import-Module (Join-Path $PSScriptRoot 'InstallFileTransaction.psm1') -ErrorAction Stop} - if($InitializationId-cnotmatch'^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$'){Throw-CcodStateError 'CCOD_STATE_INITIALIZATION_INVALID' 'Immutable initialization identity is invalid' $InitializationId} - $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 $InitializationId -CreateIfMissing - Write-CcodInstallRecord -Transaction $FileTransaction -Parent $baseline -Leaf 'settings.json' -Record $settings|Out-Null - Write-CcodInstallRecord -Transaction $FileTransaction -Parent $baseline -Leaf 'status.json' -Record $status|Out-Null - Write-CcodInstallRecord -Transaction $FileTransaction -Parent $baseline -Leaf 'verified-packages.json' -Record $verifiedPackages|Out-Null - Write-CcodInstallRecord -Transaction $FileTransaction -Parent $baseline -Leaf 'transition.json' -Record $transition|Out-Null - } 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 76e2a10..ef50231 100644 --- a/src/persistence/modules/UiPreferences.psm1 +++ b/src/persistence/modules/UiPreferences.psm1 @@ -100,19 +100,16 @@ function Get-CcodUiPreferenceTimestamp { function Initialize-CcodUiPreference { [CmdletBinding()] - param([Parameter(Mandatory)][string]$StateRoot,$FileTransaction,[string]$InitializationId) + 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 - if($null-ne$FileTransaction){ - if($null-eq(Get-Command New-CcodInstallDirectory -ErrorAction SilentlyContinue)){Import-Module (Join-Path $PSScriptRoot 'InstallFileTransaction.psm1') -ErrorAction Stop} - if($InitializationId-cnotmatch'^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$'){Throw-CcodUiPreferenceError 'CCOD_UI_PREFERENCES_INVALID' 'Immutable initialization identity is invalid.' $InitializationId} - $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 $InitializationId -CreateIfMissing - try{Write-CcodInstallRecord -Transaction $FileTransaction -Parent $baseline -Leaf 'ui-preferences.json' -Record $store|Out-Null}catch{if($_.FullyQualifiedErrorId-like'CCOD_INSTALL_RECORD_EXISTS*'){Throw-CcodUiPreferenceError 'CCOD_UI_PREFERENCES_EXISTS' 'UI preference baseline already exists.' $path};throw} - } try { Write-CcodAtomicJsonIfAbsent -Path $path -Value $store } catch { @@ -121,6 +118,7 @@ function Initialize-CcodUiPreference { } throw } + return $store } function Read-CcodUiPreference { diff --git a/tests/persistence/Bootstrap.SelfTest.ps1 b/tests/persistence/Bootstrap.SelfTest.ps1 index d8473b4..463dc13 100644 --- a/tests/persistence/Bootstrap.SelfTest.ps1 +++ b/tests/persistence/Bootstrap.SelfTest.ps1 @@ -114,7 +114,8 @@ function Add-CcodTestRuntime { [Parameter(Mandatory)][string]$SupervisorScript, [AllowNull()][string]$RuntimeId, [bool]$IncludeFenceModules = $true, - [bool]$FailLifecycleRelease = $false + [bool]$FailLifecycleRelease = $false, + [bool]$IncludeGenerationBootstrap = $false ) if ([string]::IsNullOrWhiteSpace($RuntimeId)) { @@ -131,6 +132,7 @@ 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) @@ -200,15 +202,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 @@ -328,6 +338,24 @@ $results += Invoke-CcodTest 'production suppression caller fails closed for unsa } +$results += Invoke-CcodTest 'bootstrap append-only selector rejects unknown reparse ADS and multi-linked leaves' { + foreach($kind in @('unknown','reparse','ads','multilink')){$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'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}else{$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};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 '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 { diff --git a/tests/persistence/InstallLifecycle.SelfTest.ps1 b/tests/persistence/InstallLifecycle.SelfTest.ps1 index 6dbbb54..d261bcb 100644 --- a/tests/persistence/InstallLifecycle.SelfTest.ps1 +++ b/tests/persistence/InstallLifecycle.SelfTest.ps1 @@ -409,7 +409,7 @@ $results += Invoke-CcodTest 'immutable install records advance only through cano try { $record = & $module { param($Id,$PackageHash) - New-CcodInstallTransactionRecord -TransactionId $Id -OldRuntimeId 'runtime-old' -OldGeneration ([uint64]7) -NewRuntimeId 'runtime-new' -NewGeneration ([uint64]8) -SealedPackageSha256 $PackageHash -OwnedObjectNames @('runtime-new','bootstrap.ps1','Uninstall-CodexControlOtherDevices.ps1') + 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' @@ -433,7 +433,7 @@ $results += Invoke-CcodTest 'same-version immutable install identity is idempote $module = Get-Module -Name InstallLifecycle -ErrorAction Stop $existing = [pscustomobject][ordered]@{ schemaVersion=1;transactionId='11111111-2222-3333-4444-555555555555';oldRuntimeId='runtime-old';oldGeneration=[uint64]7 - newRuntimeId='runtime-new';newGeneration=[uint64]8;sealedPackageSha256=('b'*64);ownedObjectNames=@('runtime-new');phase='Ready';errorCode=$null + 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' @@ -483,37 +483,54 @@ $results += Invoke-CcodTest 'missing sealed package identity never enables idemp }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 sealed content identity while preserving per-attempt uniqueness' { +$results += Invoke-CcodTest 'unique immutable runtime ids bind project and manifest file identity while preserving per-attempt uniqueness' { $module=Get-Module InstallLifecycle -ErrorAction Stop - $first=&$module { New-CcodUniqueRuntimeId -ProjectVersion '2.5.22' -ContentSha256 ('a'*64) -NewNonce { '1'*32 } } - $second=&$module { New-CcodUniqueRuntimeId -ProjectVersion '2.5.22' -ContentSha256 ('a'*64) -NewNonce { '2'*32 } } - $differentContent=&$module { New-CcodUniqueRuntimeId -ProjectVersion '2.5.22' -ContentSha256 ('b'*64) -NewNonce { '1'*32 } } - $differentVersion=&$module { New-CcodUniqueRuntimeId -ProjectVersion '2.5.23' -ContentSha256 ('a'*64) -NewNonce { '1'*32 } } - Assert-CcodEqual 'af45134e2aa385355392e1e45369c3ae41520cc99278076626aeeca7bfde0356-1111111111111111111111111111111' $first 'runtime id contains the full deterministic project/package digest and a canonical attempt nonce' - Assert-CcodEqual 96 $first.Length 'runtime id remains within the manifest identity bound' + $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-a1c6387570c23404-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) 'sealed package content changes the deterministic runtime identity' + 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 { New-CcodUniqueRuntimeId -ProjectVersion '2.5.22' -ContentSha256 ('a'*64) -NewNonce { 'not-a-guid' } } } 'CCOD_INSTALL_RUNTIME_ID_INVALID' - Assert-CcodThrows { &$module { New-CcodUniqueRuntimeId -ProjectVersion '2.5.22' -ContentSha256 ('A'*64) -NewNonce { '1'*32 } } } 'CCOD_INSTALL_RUNTIME_ID_INVALID' + 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 -NewRuntimeId 'scope-runtime' -NewGeneration 1 -SealedPackageSha256 ('e'*64) -OwnedObjectNames @('scope-runtime')} + $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;newRuntimeId='runtime-new';newGeneration=2;sealedPackageSha256=('f'*64);ownedObjectNames=@('runtime-new');phase='Prepared';errorCode=$null} - $verified=[pscustomobject][ordered]@{schemaVersion=1;transactionId=$transactionId;oldRuntimeId='runtime-old-b';oldGeneration=1;newRuntimeId='runtime-new';newGeneration=2;sealedPackageSha256=('f'*64);ownedObjectNames=@('runtime-new');phase='PackageVerified';errorCode=$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 @@ -523,7 +540,7 @@ $results += Invoke-CcodTest 'transaction reader rejects immutable identity drift $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;newRuntimeId=("runtime-new-$Generation");newGeneration=$Generation;sealedPackageSha256=('a'*64);ownedObjectNames=@("runtime-new-$Generation");phase='Prepared';errorCode=$null}} + $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{ @@ -548,7 +565,7 @@ $results += Invoke-CcodTest 'transaction reader rejects ambiguous Ready and late $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;newRuntimeId=$runtimeId;newGeneration=$generation;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))} + 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' @@ -556,6 +573,19 @@ $results += Invoke-CcodTest 'transaction reader rejects ambiguous Ready and late } } +$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() @@ -1161,6 +1191,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 @@ -1180,6 +1211,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' @@ -1244,7 +1277,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 = @( @@ -1290,7 +1323,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 = @( @@ -1347,7 +1380,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"} @@ -1387,7 +1420,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}, @@ -1461,6 +1494,11 @@ $results += Invoke-CcodTest 'upgrade boundaries fail closed with phase receipts } } +$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. $results += Invoke-CcodTest 'task-idle failure restarts and proves the old active generation before Failed' { $source=New-CcodLifecycleTempRoot;$install=New-CcodLifecycleTempRoot;$nodeRoot=New-CcodLifecycleTempRoot @@ -1570,17 +1608,18 @@ $results += Invoke-CcodTest 'sealed-source copy failure retains the candidate an $source = New-CcodLifecycleTempRoot $install = New-CcodLifecycleTempRoot $nodeRoot = New-CcodLifecycleTempRoot - $sourceLock = $null + $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 - $sourceLock=[IO.File]::Open((Join-Path $source 'src\persistence\Supervisor.ps1'),[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::None) - $failure=$null;try{Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters $fake.Adapters|Out-Null}catch{$failure=$_} + $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' @@ -1589,11 +1628,16 @@ $results += Invoke-CcodTest 'sealed-source copy failure retains the candidate an 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$sourceLock){$sourceLock.Dispose()} + 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 '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}}} +} + $results += Invoke-CcodTest 'source reparse point fails closed before staging' { $source = New-CcodLifecycleTempRoot $install = New-CcodLifecycleTempRoot @@ -1837,7 +1881,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 @@ -1852,9 +1896,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' @@ -1869,7 +1913,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 @@ -1885,10 +1929,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 } } } @@ -2486,6 +2530,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 { diff --git a/tests/persistence/RuntimeManifest.SelfTest.ps1 b/tests/persistence/RuntimeManifest.SelfTest.ps1 index 1b9cc1c..e0db57e 100644 --- a/tests/persistence/RuntimeManifest.SelfTest.ps1 +++ b/tests/persistence/RuntimeManifest.SelfTest.ps1 @@ -69,8 +69,9 @@ 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' @@ -91,6 +92,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 @@ -160,6 +176,20 @@ try { Assert-CcodThrows { Read-CcodActiveRuntime -InstallRoot $installRoot } 'CCOD_RUNTIME_ID_INVALID' } + 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 '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')){ diff --git a/tests/persistence/StaticProbeWorker.SelfTest.ps1 b/tests/persistence/StaticProbeWorker.SelfTest.ps1 index efc4691..0344546 100644 --- a/tests/persistence/StaticProbeWorker.SelfTest.ps1 +++ b/tests/persistence/StaticProbeWorker.SelfTest.ps1 @@ -185,7 +185,7 @@ function Get-CcodTestRuntimeId { $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 { @@ -203,7 +203,7 @@ function Get-CcodTestRuntimeRecords { } 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 @( @@ -235,8 +235,8 @@ function New-CcodAuthorizedRuntimeFixture { [IO.Directory]::Move($staging,$runtime) $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]@{ @@ -504,6 +504,13 @@ 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 '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 diff --git a/tests/persistence/UninstallBootstrap.SelfTest.ps1 b/tests/persistence/UninstallBootstrap.SelfTest.ps1 index 3b7fc8c..acbdc2c 100644 --- a/tests/persistence/UninstallBootstrap.SelfTest.ps1 +++ b/tests/persistence/UninstallBootstrap.SelfTest.ps1 @@ -156,7 +156,7 @@ function Set-CcodUninstallBootstrapFixtureDirectoryOwner { } function New-CcodVerifiedUninstallRuntimeFixture { - param([Parameter(Mandatory)][string]$InstallRoot) + param([Parameter(Mandatory)][string]$InstallRoot,[switch]$AppendOnly) $runtimeRoot = Join-Path $InstallRoot 'runtime\pending' foreach ($entry in @( 'src/persistence/UninstallBootstrap.ps1', @@ -186,7 +186,8 @@ function New-CcodVerifiedUninstallRuntimeFixture { [IO.Directory]::CreateDirectory((Join-Path $InstallRoot 'state')) | Out-Null Set-CcodUninstallBootstrapFixtureDirectoryOwner -Path $InstallRoot $timestamp = '2030-02-03T03:04:05.0000000Z' - [IO.File]::WriteAllText((Join-Path $InstallRoot 'active.json'),([ordered]@{schemaVersion=2;activeRuntime=$manifest.runtimeId;previousRuntime=$null;generation=[uint64]7;updatedAtUtc=$timestamp}|ConvertTo-Json),[Text.UTF8Encoding]::new($false)) + if($AppendOnly){$pointerRoot=Join-Path $InstallRoot 'state\active-generation';[IO.Directory]::CreateDirectory($pointerRoot)|Out-Null;for($generation=1;$generation-le7;$generation++){[IO.File]::WriteAllText((Join-Path $pointerRoot ('{0:D20}.json'-f$generation)),([ordered]@{schemaVersion=1;generation=[uint64]$generation;activeRuntime=$manifest.runtimeId;previousGeneration=[uint64]($generation-1)}|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new($false))}} + else{[IO.File]::WriteAllText((Join-Path $InstallRoot 'active.json'),([ordered]@{schemaVersion=2;activeRuntime=$manifest.runtimeId;previousRuntime=$null;generation=[uint64]7;updatedAtUtc=$timestamp}|ConvertTo-Json),[Text.UTF8Encoding]::new($false))} [IO.File]::WriteAllText((Join-Path $InstallRoot 'state\lifecycle-epoch.json'),([ordered]@{schemaVersion=1;epoch=[uint64]11}|ConvertTo-Json),[Text.UTF8Encoding]::new($false)) return [pscustomobject][ordered]@{RuntimeRoot=$finalRuntime;RuntimeId=$manifest.runtimeId} } @@ -232,6 +233,11 @@ $results += Invoke-CcodTest 'production runtime verification binds the installed } } +$results += Invoke-CcodTest 'production uninstall authorization consumes append-only selector before legacy active json' { + $localAppData=Join-Path ([IO.Path]::GetTempPath()) ('ccod-uninstall-append-'+[guid]::NewGuid().ToString('N'));$installRoot=Join-Path $localAppData 'CodexControlOtherDevices';$previous=[Environment]::GetEnvironmentVariable('LOCALAPPDATA','Process') + try{[Environment]::SetEnvironmentVariable('LOCALAPPDATA',$localAppData,'Process');$fixture=New-CcodVerifiedUninstallRuntimeFixture -InstallRoot $installRoot -AppendOnly;$context=Get-CcodUninstallBootstrapVerifiedRuntimeContext -InstallerRoot $repositoryRoot -InstallRoot $installRoot;Assert-CcodEqual $fixture.RuntimeId $context.runtimeId 'uninstall context binds append-only active runtime';Assert-CcodEqual 7 ([uint64]$context.runtimeGeneration) 'uninstall context binds latest append-only generation';Assert-CcodEqual $false (Test-Path -LiteralPath (Join-Path $installRoot 'active.json')) 'append-only uninstall authorization needs no legacy pointer'}finally{[Environment]::SetEnvironmentVariable('LOCALAPPDATA',$previous,'Process');if(Test-Path $localAppData){Remove-Item $localAppData -Recurse -Force}} +} + $results += Invoke-CcodTest 'external staging refuses a cleanup source changed after runtime verification' { $localAppData = Join-Path ([IO.Path]::GetTempPath()) ('ccod-uninstall-race-' + [guid]::NewGuid().ToString('N')) $installRoot = Join-Path $localAppData 'CodexControlOtherDevices' From f10f7255ea8047de63c0c6e084f5f25c8f44c2d4 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Tue, 1 Sep 2026 10:28:48 +0800 Subject: [PATCH 082/187] fix: recover append-only lifecycle finalization --- .../task-2-report.md | 85 +++++++++++++++ ...08-29-v2522-immutable-generation-design.md | 9 ++ src/persistence/StaticProbeWorker.ps1 | 15 ++- src/persistence/UninstallBootstrap.ps1 | 13 ++- src/persistence/bootstrap.ps1 | 29 ++++- .../modules/InstallFileTransaction.psm1 | 58 ++++++---- src/persistence/modules/InstallLifecycle.psm1 | 100 ++++++++++++++++-- src/persistence/modules/RuntimeManifest.psm1 | 9 +- tests/persistence/Bootstrap.SelfTest.ps1 | 4 +- .../InstallFileTransaction.SelfTest.ps1 | 30 ++++-- .../persistence/InstallLifecycle.SelfTest.ps1 | 91 +++++++++++++++- .../persistence/RuntimeManifest.SelfTest.ps1 | 18 ++++ .../StaticProbeWorker.SelfTest.ps1 | 15 +++ .../UninstallBootstrap.SelfTest.ps1 | 5 + 14 files changed, 427 insertions(+), 54 deletions(-) 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 index 10cb813..1ca25ce 100644 --- 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 @@ -204,3 +204,88 @@ Important findings. This round keeps `2101485` and remediates all seven. ### 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. 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 index 99269f6..b59c532 100644 --- a/docs/superpowers/specs/2026-08-29-v2522-immutable-generation-design.md +++ b/docs/superpowers/specs/2026-08-29-v2522-immutable-generation-design.md @@ -114,6 +114,7 @@ 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 @@ -125,6 +126,14 @@ Retire-CcodInstallGeneration -InstallRoot -RuntimeId 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. + `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 diff --git a/src/persistence/StaticProbeWorker.ps1 b/src/persistence/StaticProbeWorker.ps1 index 4e63d37..fd25ee5 100644 --- a/src/persistence/StaticProbeWorker.ps1 +++ b/src/persistence/StaticProbeWorker.ps1 @@ -443,6 +443,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) @@ -454,9 +463,11 @@ function Get-CcodStaticProbeRuntimeAuthorization { $installRoot=[IO.Path]::GetFullPath($installRoot);$runtimeRoot=[IO.Path]::GetFullPath($runtimeRoot) Assert-CcodStaticProbeNoReparse -Root $installRoot -Path $ScriptPath -Adapters $Adapters $pointerRoot=[IO.Path]::GetFullPath((Join-Path $installRoot 'state\active-generation')) - if([IO.Directory]::Exists($pointerRoot)){ + $pointerRootItem=$null;try{$pointerRootItem=Get-Item -LiteralPath $pointerRoot -Force -ErrorAction Stop}catch{} + 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-CcodStaticProbeNoReparse $installRoot $entry.FullName -Adapters $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.generation-isnot[long]-and$record.generation-isnot[int]-or$record.previousGeneration-isnot[long]-and$record.previousGeneration-isnot[int]-or-not(Test-CcodStaticRuntimeId $record.activeRuntime)){throw 'active generation'};[uint64]$generation=$record.generation;[uint64]$previousGeneration=$record.previousGeneration;if($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})} + 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.generation-isnot[long]-and$record.generation-isnot[int]-or$record.previousGeneration-isnot[long]-and$record.previousGeneration-isnot[int]-or-not(Test-CcodStaticRuntimeId $record.activeRuntime)){throw 'active generation'};try{[uint64]$generation=$record.generation;[uint64]$previousGeneration=$record.previousGeneration}catch{throw 'active generation range'};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'} diff --git a/src/persistence/UninstallBootstrap.ps1 b/src/persistence/UninstallBootstrap.ps1 index 6f2c008..ea70a76 100644 --- a/src/persistence/UninstallBootstrap.ps1 +++ b/src/persistence/UninstallBootstrap.ps1 @@ -199,6 +199,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 @@ -363,10 +368,12 @@ function Get-CcodUninstallBootstrapVerifiedRuntimeContext { 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 } - $pointerRoot=Resolve-CcodUninstallBootstrapChildPath -Root $install -RelativePath 'state\active-generation' -AllowMissingLeaf - if([IO.Directory]::Exists($pointerRoot)){ + try{$pointerRoot=Resolve-CcodUninstallBootstrapChildPath -Root $install -RelativePath 'state\active-generation' -AllowMissingLeaf}catch{Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_RUNTIME_INVALID' 'The active generation selector root is unsafe' $install} + $pointerRootItem=$null;try{$pointerRootItem=Get-Item -LiteralPath $pointerRoot -Force -ErrorAction Stop}catch{} + if($null-ne$pointerRootItem-and-not$pointerRootItem.PSIsContainer){throw 'selector root type'} + if($null-ne$pointerRootItem){ $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'};$path=Resolve-CcodUninstallBootstrapChildPath -Root $install -RelativePath ('state\active-generation\'+$entry.Name) -RequireLeafFile;$record=Read-CcodUninstallBootstrapJson -Path $path -Kind 'Active generation';if(-not(Test-CcodUninstallBootstrapExactProperties $record @('schemaVersion','generation','activeRuntime','previousGeneration'))-or$record.schemaVersion-isnot[int]-or$record.schemaVersion-ne1-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($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})} + 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) diff --git a/src/persistence/bootstrap.ps1 b/src/persistence/bootstrap.ps1 index 39ef577..4893b28 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 } @@ -148,8 +167,10 @@ function Assert-CcodBootstrapPlainSelectorFile { function Read-CcodBootstrapActivePointer { param([Parameter(Mandatory)][string]$InstallRoot) - $pointerRoot=Assert-CcodBootstrapContained -Root $InstallRoot -Path (Join-Path $InstallRoot 'state\active-generation') -AllowMissingLeaf - if([IO.Directory]::Exists($pointerRoot)){ + try{$pointerRoot=Assert-CcodBootstrapContained -Root $InstallRoot -Path (Join-Path $InstallRoot 'state\active-generation') -AllowMissingLeaf}catch{Throw-CcodBootstrapError 'CCOD_BOOTSTRAP_POINTER_INVALID' 'Active generation selector root is unsafe' (Join-Path $InstallRoot 'state\active-generation')} + $pointerRootItem=$null;try{$pointerRootItem=Get-Item -LiteralPath $pointerRoot -Force -ErrorAction Stop}catch{} + if($null-ne$pointerRootItem-and-not$pointerRootItem.PSIsContainer){Throw-CcodBootstrapError 'CCOD_BOOTSTRAP_POINTER_INVALID' 'Active generation selector root is not a directory' $pointerRoot} + if($null-ne$pointerRootItem){ 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} @@ -157,7 +178,7 @@ function Read-CcodBootstrapActivePointer { 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 - $record=Read-CcodBootstrapJson -Path $entry.FullName -Kind 'active generation selector' + 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}} diff --git a/src/persistence/modules/InstallFileTransaction.psm1 b/src/persistence/modules/InstallFileTransaction.psm1 index c8a349a..194e887 100644 --- a/src/persistence/modules/InstallFileTransaction.psm1 +++ b/src/persistence/modules/InstallFileTransaction.psm1 @@ -9,7 +9,7 @@ function Throw-CcodInstallFileError { } function Initialize-CcodInstallRuntime { - $marker = 'CcodInstallGenerationCapabilityMarkerV3' -as [type] + $marker = 'CcodInstallGenerationCapabilityMarkerV4' -as [type] if ($null -eq $marker) { Add-Type -TypeDefinition @' using System; @@ -22,13 +22,13 @@ using System.Security.Cryptography; using System.Text; using Microsoft.Win32.SafeHandles; -public sealed class CcodInstallGenerationCapabilityMarkerV3 +public sealed class CcodInstallGenerationCapabilityMarkerV4 { - private CcodInstallGenerationCapabilityMarkerV3() { } - public static int CapabilityAbi { get { return 3; } } + private CcodInstallGenerationCapabilityMarkerV4() { } + public static int CapabilityAbi { get { return 4; } } } -internal sealed class CcodInstallGenerationRuntimeV3 : IDisposable +internal sealed class CcodInstallGenerationRuntimeV4 : IDisposable { private const uint READ = 0x80000000, WRITE = 0x40000000, DELETE = 0x00010000, SYNC = 0x00100000; private const uint READ_ATTRIBUTES = 0x80, WRITE_ATTRIBUTES = 0x100, LIST_DIRECTORY = 0x1, ADD_FILE = 0x2, ADD_SUBDIRECTORY = 0x4; @@ -83,10 +83,11 @@ internal sealed class CcodInstallGenerationRuntimeV3 : IDisposable private readonly List externalStreams = new List(); private bool disposed; private string cleanupError; - private CcodInstallGenerationRuntimeV3(string installRoot,Pin root,Pin runtimeParent) - { this.installRoot=installRoot;this.root=root;this.runtimeParent=runtimeParent;AddPin(root);AddPin(runtimeParent); } + private readonly bool stateOnly; + private CcodInstallGenerationRuntimeV4(string installRoot,Pin root,Pin runtimeParent,bool stateOnly) + { this.installRoot=installRoot;this.root=root;this.runtimeParent=runtimeParent;this.stateOnly=stateOnly;AddPin(root);if(runtimeParent!=null)AddPin(runtimeParent); } - internal static object Open(string path,string runtimeId,out CcodInstallGenerationRuntimeV3 runtime) + internal static object Open(string path,string runtimeId,out CcodInstallGenerationRuntimeV4 runtime) { runtime=null;string full=Path.GetFullPath(path).TrimEnd('\\');SafeFileHandle rootHandle=OpenAbsoluteDirectory(full,true);Pin rootPin=null,runtimePin=null; try @@ -101,23 +102,33 @@ internal sealed class CcodInstallGenerationRuntimeV3 : IDisposable try { Pin generation=ValidateDirectoryPin(runtimePin,runtimeId,Path.Combine(runtimePin.Path,runtimeId),true,generationResult.Handle);generationResult.Handle=null; - runtime=new CcodInstallGenerationRuntimeV3(full,rootPin,runtimePin);rootPin=null;runtimePin=null;runtime.AddPin(generation);return generation.Token; + runtime=new CcodInstallGenerationRuntimeV4(full,rootPin,runtimePin,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 CcodInstallGenerationRuntimeV4 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 CcodInstallGenerationRuntimeV4(full,rootPin,null,true);rootPin=null;return runtime.root.Token;} + catch{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(!stateOnly)return;if(Object.ReferenceEquals(parent,root)){if(!String.Equals(leaf,"state",StringComparison.Ordinal))throw new InvalidOperationException("state-only scope");return;}if(!IsStateScope(parent))throw new InvalidOperationException("state-only scope");} + [MethodImpl(MethodImplOptions.Synchronized)] internal object CreateDirectory(object parentToken,string leaf) { - Pin parent=Require(parentToken,true);ValidateCurrent(parent);OpenResult result=OpenRelative(parent.Native,leaf,LIST_DIRECTORY|ADD_FILE|ADD_SUBDIRECTORY|READ_ATTRIBUTES|DELETE|SYNC,SHARE_READ|SHARE_WRITE,CREATE,DIRECTORY|BACKUP_INTENT); + 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);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; + 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();} @@ -128,7 +139,7 @@ internal sealed class CcodInstallGenerationRuntimeV3 : IDisposable [MethodImpl(MethodImplOptions.Synchronized)] internal object OpenRetained(string runtimeId,string expectedManifestSha) { - 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(); + 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); @@ -140,7 +151,7 @@ internal sealed class CcodInstallGenerationRuntimeV3 : IDisposable [MethodImpl(MethodImplOptions.Synchronized)] internal object Copy(object parentToken,string leaf,string source,long expectedLength,string expectedSha) { - Pin parent=Require(parentToken,true);ValidateCurrent(parent);string full=Path.GetFullPath(source); + if(stateOnly)throw new InvalidOperationException("state-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 @@ -157,19 +168,19 @@ internal sealed class CcodInstallGenerationRuntimeV3 : IDisposable [MethodImpl(MethodImplOptions.Synchronized)] internal object Write(object parentToken,string leaf,byte[] bytes,bool manifest) { - Pin parent=Require(parentToken,true);ValidateCurrent(parent);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; + Pin parent=Require(parentToken,true);ValidateCurrent(parent);if(stateOnly&&(manifest||!IsStateScope(parent)))throw new InvalidOperationException("state-only 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 CommitPointer(object generationToken,ulong previousGeneration,string runtimeId,byte[] bytes) { - Pin generation=Require(generationToken,true);ValidateCurrent(generation);if(!Object.ReferenceEquals(generation.Parent,runtimeParent)||!String.Equals(generation.Leaf,runtimeId,StringComparison.Ordinal))throw new InvalidDataException("transaction scope"); + if(stateOnly)throw new InvalidOperationException("state-only scope");Pin generation=Require(generationToken,true);ValidateCurrent(generation);if(!Object.ReferenceEquals(generation.Parent,runtimeParent)||!String.Equals(generation.Leaf,runtimeId,StringComparison.Ordinal))throw new InvalidDataException("transaction scope"); if(retainedRoots.Contains(generationToken))ValidateRetainedTree(generation);else{Pin manifest=null;if(!names.TryGetValue(Key(generation,"manifest.json"),out manifest)||!manifest.Manifest||!manifest.Sealed)throw new InvalidDataException("manifest missing");EnsurePublishedPin(manifest);ValidateOwnedTree(generation);} if(previousGeneration==UInt64.MaxValue)throw new OverflowException("pointer generation overflow");Pin state=OpenOrCreateDirectory(root,"state"),pointer=OpenOrCreateDirectory(state,"active-generation");if(CurrentPointerGeneration(pointer)!=previousGeneration)throw new InvalidOperationException("pointer generation exists");string leaf=(previousGeneration+1).ToString("D20")+".json";return Write(pointer.Token,leaf,bytes,false); } [MethodImpl(MethodImplOptions.Synchronized)] internal object Retire(object generationToken,string runtimeId,string recordLeaf,byte[] record) { - Pin generation=Require(generationToken,true);ValidateCurrent(generation);if(!Object.ReferenceEquals(generation.Parent,runtimeParent)||!String.Equals(generation.Leaf,runtimeId,StringComparison.Ordinal)||!generation.Owned)throw new InvalidDataException("generation not owned"); + if(stateOnly)throw new InvalidOperationException("state-only scope");Pin generation=Require(generationToken,true);ValidateCurrent(generation);if(!Object.ReferenceEquals(generation.Parent,runtimeParent)||!String.Equals(generation.Leaf,runtimeId,StringComparison.Ordinal)||!generation.Owned)throw new InvalidDataException("generation not owned"); ValidateOwnedTree(generation);Pin state=OpenOrCreateDirectory(root,"state"),retirements=OpenOrCreateDirectory(state,"retired-generations");Write(retirements.Token,recordLeaf,record,false);return generation.Token; } @@ -246,10 +257,10 @@ internal sealed class CcodInstallGenerationRuntimeV3 : IDisposable 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);}} } '@ - $marker = 'CcodInstallGenerationCapabilityMarkerV3' -as [type] + $marker = 'CcodInstallGenerationCapabilityMarkerV4' -as [type] } - if ($null -eq $marker -or [int]$marker.GetProperty('CapabilityAbi').GetValue($null,$null) -ne 3) { Throw-CcodInstallFileError 'CCOD_INSTALL_RUNTIME_ABI_INVALID' 'Install generation runtime ABI is unavailable' $null } - $script:CcodRuntimeType = $marker.Assembly.GetType('CcodInstallGenerationRuntimeV3',$true) + if ($null -eq $marker -or [int]$marker.GetProperty('CapabilityAbi').GetValue($null,$null) -ne 4) { Throw-CcodInstallFileError 'CCOD_INSTALL_RUNTIME_ABI_INVALID' 'Install generation runtime ABI is unavailable' $null } + $script:CcodRuntimeType = $marker.Assembly.GetType('CcodInstallGenerationRuntimeV4',$true) } function Assert-CcodInstallLeaf([string]$Leaf,[string]$ErrorId='CCOD_INSTALL_LEAF_INVALID') { @@ -262,7 +273,7 @@ function Get-CcodInstallTransaction($Capability,[string]$InvalidId='CCOD_INSTALL 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($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'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 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($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'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 } @@ -297,11 +308,12 @@ function Open-CcodInstallGeneration { 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;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 + $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;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;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-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 } 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;$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 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 } @@ -310,4 +322,4 @@ function Retire-CcodInstallGeneration { param([Parameter(Mandatory)][string]$Ins 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};try{Invoke-CcodRuntimeMethod $state.Runtime Close @()|Out-Null;$state.CleanupFailed=$false}catch{$state.CleanupFailed=$true;$state.Closed=$true;$state.Disposition=$Disposition;Throw-CcodInstallFileError 'CCOD_INSTALL_CLOSE_FAILED' 'Install file transaction cleanup failed; registered failures are retained for retry' $null};$state.Closed=$true;$state.Disposition=$Disposition } Initialize-CcodInstallRuntime -Export-ModuleMember -Function Open-CcodInstallGeneration,Open-CcodInstallRetainedGeneration,New-CcodInstallDirectory,New-CcodInstallGenerationLeaf,Copy-CcodInstallSealedSource,Write-CcodInstallGenerationManifest,Write-CcodInstallRecord,Commit-CcodInstallActivePointer,Retire-CcodInstallGeneration,Close-CcodInstallFileTransaction +Export-ModuleMember -Function Open-CcodInstallGeneration,Open-CcodInstallStateTransaction,Open-CcodInstallRetainedGeneration,New-CcodInstallDirectory,New-CcodInstallGenerationLeaf,Copy-CcodInstallSealedSource,Write-CcodInstallGenerationManifest,Write-CcodInstallRecord,Commit-CcodInstallActivePointer,Retire-CcodInstallGeneration,Close-CcodInstallFileTransaction diff --git a/src/persistence/modules/InstallLifecycle.psm1 b/src/persistence/modules/InstallLifecycle.psm1 index fe0aabf..8e809e2 100644 --- a/src/persistence/modules/InstallLifecycle.psm1 +++ b/src/persistence/modules/InstallLifecycle.psm1 @@ -197,6 +197,15 @@ function Open-CcodLifecycleInstallGeneration { 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 Assert-CcodInstallTransactionRoot { param([Parameter(Mandatory)][string]$InstallRoot,[Parameter(Mandatory)]$FileTransaction) $binding = $null @@ -377,6 +386,80 @@ function Set-CcodInstallTransactionPhase { 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) + [void](Assert-CcodReadyFinalizationRecoveryProof -InstallRoot $InstallRoot -TransactionRecord $TransactionRecord) + $fileTransaction=$null + $observed=$null + 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} + }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{}} + } + $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} +} + 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 } @@ -1804,7 +1887,7 @@ function Get-CcodLifecycleAdapters { } SetActiveRuntime = { param($InstallRoot, $RuntimeId, $Ownership,$TargetGeneration,$FileTransaction) - Set-CcodActiveRuntime -InstallRoot $InstallRoot -TargetGeneration $TargetGeneration -FileTransaction $FileTransaction -Ownership $Ownership + Set-CcodActiveRuntime -InstallRoot $InstallRoot -NewRuntimeId $RuntimeId -TargetGeneration $TargetGeneration -FileTransaction $FileTransaction -Ownership $Ownership } ExitLifecycleOwnership = { param($Ownership) @@ -2204,6 +2287,16 @@ function Invoke-CcodInstall { } 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} + 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 } @@ -2237,11 +2330,6 @@ function Invoke-CcodInstall { $nodeCandidates = @(Get-CcodLifecycleNodeCandidates -Adapters $adapters) if (-not $payloadBound) { $files = @(Get-CcodLifecycleSourceFiles -SourceRoot $sourceRoot -RequireTrayHost) } - $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}} - $globalTransaction=Read-CcodInstallTransactionRecord -InstallRoot $root - if($null-ne$globalTransaction-and$globalTransaction.phase-notin@('Ready','Failed')){Throw-CcodLifecycleError 'CCOD_INSTALL_TRANSACTION_BUSY' 'A nonterminal install transaction must be recovered before a new attempt' $globalTransaction.transactionId} $oldManifestSha256=$null;$activeValidation=$null if($null-ne$existingPointer){ $activeRuntimeRoot=[IO.Path]::GetFullPath((Join-Path (Join-Path $root 'runtime') ([string]$existingPointer.activeRuntime))) diff --git a/src/persistence/modules/RuntimeManifest.psm1 b/src/persistence/modules/RuntimeManifest.psm1 index 947a665..05fa9e7 100644 --- a/src/persistence/modules/RuntimeManifest.psm1 +++ b/src/persistence/modules/RuntimeManifest.psm1 @@ -40,12 +40,13 @@ function Get-CcodRuntimeAdapters { UtcNow = { [DateTime]::UtcNow } 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{$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 } @@ -468,7 +469,7 @@ function Set-CcodActiveRuntime { 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-CcodActiveRuntime $InstallRoot}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) $(if($null-ne$current){$current.activeRuntime}else{$null}))}catch{Throw-CcodRuntimeError 'CCOD_RUNTIME_FENCE_STALE' 'Active runtime mutation lifecycle fence is stale' $InstallRoot} + 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 } diff --git a/tests/persistence/Bootstrap.SelfTest.ps1 b/tests/persistence/Bootstrap.SelfTest.ps1 index 463dc13..e4ccbe4 100644 --- a/tests/persistence/Bootstrap.SelfTest.ps1 +++ b/tests/persistence/Bootstrap.SelfTest.ps1 @@ -338,8 +338,8 @@ $results += Invoke-CcodTest 'production suppression caller fails closed for unsa } -$results += Invoke-CcodTest 'bootstrap append-only selector rejects unknown reparse ADS and multi-linked leaves' { - foreach($kind in @('unknown','reparse','ads','multilink')){$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'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}else{$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};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 append-only selector rejects unsafe roots leaves JSON and generations' { + foreach($kind in @('unknown','root-file','root-reparse','leaf-reparse','ads','multilink','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'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 'generation bootstrap launches the append-only selected runtime without root active json' { diff --git a/tests/persistence/InstallFileTransaction.SelfTest.ps1 b/tests/persistence/InstallFileTransaction.SelfTest.ps1 index b672990..b560dc0 100644 --- a/tests/persistence/InstallFileTransaction.SelfTest.ps1 +++ b/tests/persistence/InstallFileTransaction.SelfTest.ps1 @@ -136,14 +136,19 @@ function New-CcodHardLink { } Invoke-CcodTest 'exports only immutable generation operations and an inert CLR marker' { - $expected=@('Close-CcodInstallFileTransaction','Commit-CcodInstallActivePointer','Copy-CcodInstallSealedSource','New-CcodInstallDirectory','New-CcodInstallGenerationLeaf','Open-CcodInstallGeneration','Open-CcodInstallRetainedGeneration','Retire-CcodInstallGeneration','Write-CcodInstallGenerationManifest','Write-CcodInstallRecord') + $expected=@('Close-CcodInstallFileTransaction','Commit-CcodInstallActivePointer','Copy-CcodInstallSealedSource','New-CcodInstallDirectory','New-CcodInstallGenerationLeaf','Open-CcodInstallGeneration','Open-CcodInstallRetainedGeneration','Open-CcodInstallStateTransaction','Retire-CcodInstallGeneration','Write-CcodInstallGenerationManifest','Write-CcodInstallRecord') Assert-CcodEqual ($expected -join '|') ((@($module.ExportedCommands.Keys)|Sort-Object)-join '|') 'module export surface is capability-only' - Assert-CcodEqual 3 ([CcodInstallGenerationCapabilityMarkerV3]::CapabilityAbi) 'marker exposes the current non-mutating ABI value' - Assert-CcodEqual 'CcodInstallGenerationCapabilityMarkerV3' ((@([CcodInstallGenerationCapabilityMarkerV3].Assembly.GetExportedTypes()|ForEach-Object FullName)) -join '|') 'current CLR bridge exports only the inert marker' - $dangerous=@([CcodInstallGenerationCapabilityMarkerV3].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 4 ([CcodInstallGenerationCapabilityMarkerV4]::CapabilityAbi) 'marker exposes the current non-mutating ABI value' + Assert-CcodEqual 'CcodInstallGenerationCapabilityMarkerV4' ((@([CcodInstallGenerationCapabilityMarkerV4].Assembly.GetExportedTypes()|ForEach-Object FullName)) -join '|') 'current CLR bridge exports only the inert marker' + $dangerous=@([CcodInstallGenerationCapabilityMarkerV4].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' } +Invoke-CcodTest 'V4 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 '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 @@ -526,17 +531,26 @@ Invoke-CcodExtensionRedTest 'child-transaction' 'rejects a child directory capab } finally {Remove-CcodInstallFileFixture $fixture} } -Invoke-CcodExtensionRedTest 'v3-reimport' 're-imports ABI V3 and exercises every retained extension export' { +Invoke-CcodExtensionRedTest 'v3-reimport' 're-imports ABI V4 and exercises every retained extension export' { $fixture=New-CcodInstallFileFixture try { - if($null-eq('CcodInstallGenerationCapabilityMarkerV2'-as[type])){Add-Type -TypeDefinition 'public sealed class CcodInstallGenerationCapabilityMarkerV2 { private CcodInstallGenerationCapabilityMarkerV2() {} public static int CapabilityAbi { get { return 2; } } }'} - Assert-CcodEqual 2 ([CcodInstallGenerationCapabilityMarkerV2]::CapabilityAbi) 'old V2 marker is preloaded in the AppDomain' + if($null-eq('CcodInstallGenerationCapabilityMarkerV3'-as[type])){Add-Type -TypeDefinition 'public sealed class CcodInstallGenerationCapabilityMarkerV3 { private CcodInstallGenerationCapabilityMarkerV3() {} public static int CapabilityAbi { get { return 3; } } }'} + Assert-CcodEqual 3 ([CcodInstallGenerationCapabilityMarkerV3]::CapabilityAbi) 'old V3 marker is preloaded in the AppDomain' $module=Import-Module $modulePath -Force -PassThru -DisableNameChecking;$module=Import-Module $modulePath -Force -PassThru -DisableNameChecking $old=Open-CcodFixtureGeneration $fixture 'runtime-v3-old';$manifest=Write-CcodInstallGenerationManifest -Generation $old -Manifest (New-CcodGenerationManifest 'runtime-v3-old');Close-CcodInstallFileTransaction -Transaction $old -Disposition Ready|Out-Null $transaction=Open-CcodFixtureGeneration $fixture 'runtime-v3-new';Write-CcodInstallGenerationManifest -Generation $transaction -Manifest (New-CcodGenerationManifest 'runtime-v3-new')|Out-Null $state=New-CcodInstallDirectory -Transaction $transaction -Parent $transaction -Leaf 'state' -CreateIfMissing;$records=New-CcodInstallDirectory -Transaction $transaction -Parent $state -Leaf 'records' -CreateIfMissing;Write-CcodInstallRecord -Transaction $transaction -Parent $records -Leaf 'record.json' -Record ([ordered]@{schemaVersion=1;value='v3'})|Out-Null $retained=Open-CcodInstallRetainedGeneration -InstallRoot $fixture.Install -RuntimeId 'runtime-v3-old' -ExpectedManifestSha256 $manifest.Sha256 -FileTransaction $transaction;Commit-CcodInstallActivePointer -InstallRoot $fixture.Install -TargetGeneration $retained -ExpectedPreviousGeneration ([uint64]0) -FileTransaction $transaction|Out-Null - Assert-CcodEqual 3 ([CcodInstallGenerationCapabilityMarkerV3]::CapabilityAbi) 'module binds the new ABI V3 despite the preloaded V2 marker' + $stateOnly=Open-CcodInstallStateTransaction -InstallRoot $fixture.Install;$fixture.Transactions.Add($stateOnly);$stateOnlyRoot=New-CcodInstallDirectory -Transaction $stateOnly -Parent $stateOnly -Leaf 'state' -CreateIfMissing;$recovery=New-CcodInstallDirectory -Transaction $stateOnly -Parent $stateOnlyRoot -Leaf 'recovery' -CreateIfMissing;Write-CcodInstallRecord -Transaction $stateOnly -Parent $recovery -Leaf 'ready.json' -Record ([ordered]@{schemaVersion=1;phase='Ready'})|Out-Null + $source=New-CcodSourceFile $fixture 'v4-state-source.txt' 'state-only-after-v3' + Assert-CcodThrows {New-CcodInstallDirectory -Transaction $stateOnly -Parent $stateOnly -Leaf 'runtime' -CreateIfMissing|Out-Null} 'CCOD_INSTALL_STATE_SCOPE' + Assert-CcodThrows {New-CcodInstallGenerationLeaf -Generation $stateOnly -Leaf 'payload'|Out-Null} 'CCOD_INSTALL_STATE_SCOPE' + Assert-CcodThrows {Copy-CcodInstallSealedSource -Generation $stateOnly -SourcePath $source.Path -Leaf 'payload.bin' -ExpectedLength $source.Length -ExpectedSha256 $source.Sha256|Out-Null} 'CCOD_INSTALL_STATE_SCOPE' + Assert-CcodThrows {Write-CcodInstallGenerationManifest -Generation $stateOnly -Manifest (New-CcodGenerationManifest 'state-after-v3')|Out-Null} 'CCOD_INSTALL_STATE_SCOPE' + Assert-CcodThrows {Open-CcodInstallRetainedGeneration -InstallRoot $fixture.Install -RuntimeId 'runtime-v3-old' -ExpectedManifestSha256 $manifest.Sha256 -FileTransaction $stateOnly|Out-Null} 'CCOD_INSTALL_STATE_SCOPE' + Assert-CcodThrows {Commit-CcodInstallActivePointer -InstallRoot $fixture.Install -TargetGeneration $stateOnly -ExpectedPreviousGeneration ([uint64]1) -FileTransaction $stateOnly|Out-Null} 'CCOD_INSTALL_POINTER_TARGET_INVALID' + Assert-CcodThrows {Retire-CcodInstallGeneration -InstallRoot $fixture.Install -RuntimeId 'runtime-v3-old' -FileTransaction $stateOnly|Out-Null} 'CCOD_INSTALL_GENERATION_NOT_OWNED' + Assert-CcodEqual 4 ([CcodInstallGenerationCapabilityMarkerV4]::CapabilityAbi) 'module binds the new ABI V4 despite the preloaded V3 marker' } finally {Remove-CcodInstallFileFixture $fixture} } diff --git a/tests/persistence/InstallLifecycle.SelfTest.ps1 b/tests/persistence/InstallLifecycle.SelfTest.ps1 index d261bcb..1fa38ac 100644 --- a/tests/persistence/InstallLifecycle.SelfTest.ps1 +++ b/tests/persistence/InstallLifecycle.SelfTest.ps1 @@ -260,8 +260,8 @@ function New-CcodLifecycleFake { $adapters.SetActiveRuntime = { param($InstallRoot, $RuntimeId, $Ownership, $TargetGeneration, $FileTransaction) if ($null -ne $world.SetActiveFailure) { & $world.SetActiveFailure $InstallRoot $RuntimeId $Ownership $TargetGeneration $FileTransaction } - $assertFence = { param($Root, $Receipt, $ExpectActivePointer) if ($Receipt.released) { throw 'released lifecycle owner' }; $true } - Set-CcodActiveRuntime -InstallRoot $InstallRoot -TargetGeneration $TargetGeneration -FileTransaction $FileTransaction -Ownership $Ownership -Adapters @{ AssertLifecycleFence=$assertFence } + $assertFence = { param($Root, $Receipt, $ExpectActivePointer,$TargetRuntimeId) if ($Receipt.released) { throw 'released lifecycle owner' }; $true } + Set-CcodActiveRuntime -InstallRoot $InstallRoot -NewRuntimeId $RuntimeId -TargetGeneration $TargetGeneration -FileTransaction $FileTransaction -Ownership $Ownership -Adapters @{ AssertLifecycleFence=$assertFence } }.GetNewClosure() $adapters.ExitLifecycleOwnership = { param($Ownership) @@ -309,6 +309,33 @@ function New-CcodLifecycleFake { [pscustomobject]@{ World = $world; Adapters = $adapters } } +function New-CcodReadyFinalizationGapFixture { + $source=New-CcodLifecycleTempRoot + $install=New-CcodLifecycleTempRoot + $nodeRoot=New-CcodLifecycleTempRoot + try { + New-CcodLifecycleSourceFixture -Root $source -Version '2.5.22-finalization-recovery' | 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 Read-CcodActiveRuntime -InstallRoot $Root @@ -1638,6 +1665,66 @@ $results += Invoke-CcodTest 'final transaction snapshot failure leaves Ready rec 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 { + $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 $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 } +} + +# 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 { + 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" + } + } finally { + foreach($caseRoot in $caseRoots){if(Test-Path -LiteralPath $caseRoot){Remove-Item -LiteralPath $caseRoot -Recurse -Force}} + Remove-CcodReadyFinalizationGapFixture $fixture + } +} + $results += Invoke-CcodTest 'source reparse point fails closed before staging' { $source = New-CcodLifecycleTempRoot $install = New-CcodLifecycleTempRoot diff --git a/tests/persistence/RuntimeManifest.SelfTest.ps1 b/tests/persistence/RuntimeManifest.SelfTest.ps1 index e0db57e..2a324b5 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')) @@ -176,6 +177,23 @@ try { Assert-CcodThrows { Read-CcodActiveRuntime -InstallRoot $installRoot } 'CCOD_RUNTIME_ID_INVALID' } + 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") diff --git a/tests/persistence/StaticProbeWorker.SelfTest.ps1 b/tests/persistence/StaticProbeWorker.SelfTest.ps1 index 0344546..48943c7 100644 --- a/tests/persistence/StaticProbeWorker.SelfTest.ps1 +++ b/tests/persistence/StaticProbeWorker.SelfTest.ps1 @@ -511,6 +511,21 @@ try { Assert-CcodEqual $fixture.RuntimeId $context.RuntimeId 'static worker binds the append-only selected runtime' } + Invoke-CcodTest 'append-only authorization rejects unsafe roots leaves JSON and generations' { + foreach($kind in @('root-file','root-reparse','leaf-reparse','ads','multilink','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'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')} + 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 diff --git a/tests/persistence/UninstallBootstrap.SelfTest.ps1 b/tests/persistence/UninstallBootstrap.SelfTest.ps1 index acbdc2c..c9de83a 100644 --- a/tests/persistence/UninstallBootstrap.SelfTest.ps1 +++ b/tests/persistence/UninstallBootstrap.SelfTest.ps1 @@ -238,6 +238,11 @@ $results += Invoke-CcodTest 'production uninstall authorization consumes append- try{[Environment]::SetEnvironmentVariable('LOCALAPPDATA',$localAppData,'Process');$fixture=New-CcodVerifiedUninstallRuntimeFixture -InstallRoot $installRoot -AppendOnly;$context=Get-CcodUninstallBootstrapVerifiedRuntimeContext -InstallerRoot $repositoryRoot -InstallRoot $installRoot;Assert-CcodEqual $fixture.RuntimeId $context.runtimeId 'uninstall context binds append-only active runtime';Assert-CcodEqual 7 ([uint64]$context.runtimeGeneration) 'uninstall context binds latest append-only generation';Assert-CcodEqual $false (Test-Path -LiteralPath (Join-Path $installRoot 'active.json')) 'append-only uninstall authorization needs no legacy pointer'}finally{[Environment]::SetEnvironmentVariable('LOCALAPPDATA',$previous,'Process');if(Test-Path $localAppData){Remove-Item $localAppData -Recurse -Force}} } +$results += Invoke-CcodTest 'uninstall append-only authorization rejects unsafe roots leaves JSON and generations' { + $previous=[Environment]::GetEnvironmentVariable('LOCALAPPDATA','Process') + try{foreach($kind in @('root-file','root-reparse','leaf-reparse','ads','multilink','duplicate','fractional','noncanonical')){$local=Join-Path ([IO.Path]::GetTempPath()) ("ccod-uninstall-hostile-$kind-"+[guid]::NewGuid().ToString('N'));$install=Join-Path $local 'CodexControlOtherDevices';[Environment]::SetEnvironmentVariable('LOCALAPPDATA',$local,'Process');$fixture=New-CcodVerifiedUninstallRuntimeFixture -InstallRoot $install -AppendOnly;$pointerRoot=Join-Path $install 'state\active-generation';$leaf=Join-Path $pointerRoot '00000000000000000007.json';$target=Join-Path $install ("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 $install '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'duplicate'){$id=$fixture.RuntimeId;[IO.File]::WriteAllText($leaf,('{"schemaVersion":1,"schemaVersion":1,"generation":7,"activeRuntime":"'+$id+'","previousGeneration":6}'),[Text.UTF8Encoding]::new($false))}elseif($kind-ceq'fractional'){$id=$fixture.RuntimeId;[IO.File]::WriteAllText($leaf,('{"schemaVersion":1,"generation":7.5,"activeRuntime":"'+$id+'","previousGeneration":6}'),[Text.UTF8Encoding]::new($false))}else{Move-Item $leaf (Join-Path $pointerRoot '00000000000000000008.json')};Assert-CcodThrows {Get-CcodUninstallBootstrapVerifiedRuntimeContext -InstallerRoot $repositoryRoot -InstallRoot $install|Out-Null} 'CCOD_UNINSTALL_RUNTIME_INVALID';if(Test-Path $local){Remove-Item $local -Recurse -Force}}}finally{[Environment]::SetEnvironmentVariable('LOCALAPPDATA',$previous,'Process')} +} + $results += Invoke-CcodTest 'external staging refuses a cleanup source changed after runtime verification' { $localAppData = Join-Path ([IO.Path]::GetTempPath()) ('ccod-uninstall-race-' + [guid]::NewGuid().ToString('N')) $installRoot = Join-Path $localAppData 'CodexControlOtherDevices' From 8eb69208b186a4e5b147dda8b51c48299a222872 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:23:07 +0800 Subject: [PATCH 083/187] fix: fail closed selector fallback --- .../task-2-report.md | 77 +++++++++++++++++++ ...08-29-v2522-immutable-generation-design.md | 8 +- src/persistence/StaticProbeWorker.ps1 | 5 +- src/persistence/UninstallBootstrap.ps1 | 9 ++- src/persistence/bootstrap.ps1 | 9 ++- src/persistence/modules/RuntimeManifest.psm1 | 14 ++-- tests/persistence/Bootstrap.SelfTest.ps1 | 7 +- .../InstallFileTransaction.SelfTest.ps1 | 46 +++++++---- .../persistence/RuntimeManifest.SelfTest.ps1 | 14 ++++ .../StaticProbeWorker.SelfTest.ps1 | 15 +++- .../UninstallBootstrap.SelfTest.ps1 | 7 +- 11 files changed, 174 insertions(+), 37 deletions(-) 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 index 1ca25ce..10adf5e 100644 --- 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 @@ -289,3 +289,80 @@ Important findings. This round keeps `2101485` and remediates all seven. 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. 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 index b59c532..57f4cb6 100644 --- a/docs/superpowers/specs/2026-08-29-v2522-immutable-generation-design.md +++ b/docs/superpowers/specs/2026-08-29-v2522-immutable-generation-design.md @@ -87,6 +87,11 @@ 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: only an exact +ItemNotFound outcome permits legacy fallback. A selector-root file, reparse +point, access or I/O failure, malformed store, or unsupported record fails the +component's bounded authorization contract instead of consulting `active.json`. + ### 1a. Immutable initialization evidence and operational state The install transaction writes create-only, generation-bound initialization @@ -132,7 +137,8 @@ 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. +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 diff --git a/src/persistence/StaticProbeWorker.ps1 b/src/persistence/StaticProbeWorker.ps1 index fd25ee5..3c40e2d 100644 --- a/src/persistence/StaticProbeWorker.ps1 +++ b/src/persistence/StaticProbeWorker.ps1 @@ -463,11 +463,12 @@ function Get-CcodStaticProbeRuntimeAuthorization { $installRoot=[IO.Path]::GetFullPath($installRoot);$runtimeRoot=[IO.Path]::GetFullPath($runtimeRoot) Assert-CcodStaticProbeNoReparse -Root $installRoot -Path $ScriptPath -Adapters $Adapters $pointerRoot=[IO.Path]::GetFullPath((Join-Path $installRoot 'state\active-generation')) - $pointerRootItem=$null;try{$pointerRootItem=Get-Item -LiteralPath $pointerRoot -Force -ErrorAction Stop}catch{} + $pathAdapters=Get-CcodStaticProbePathAdapters $Adapters + $pointerRootItem=Invoke-CcodStaticAdapter $pathAdapters.GetItem @($pointerRoot,$true) OptionalSingle 'CCOD_STATIC_RUNTIME_UNAUTHORIZED' 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.generation-isnot[long]-and$record.generation-isnot[int]-or$record.previousGeneration-isnot[long]-and$record.previousGeneration-isnot[int]-or-not(Test-CcodStaticRuntimeId $record.activeRuntime)){throw 'active generation'};try{[uint64]$generation=$record.generation;[uint64]$previousGeneration=$record.previousGeneration}catch{throw 'active generation range'};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})} + 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($record.generation-isnot[long]-and$record.generation-isnot[int]-or$record.previousGeneration-isnot[long]-and$record.previousGeneration-isnot[int]-or-not(Test-CcodStaticRuntimeId $record.activeRuntime)){throw 'active generation'};try{[uint64]$generation=$record.generation;[uint64]$previousGeneration=$record.previousGeneration}catch{throw 'active generation range'};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'} diff --git a/src/persistence/UninstallBootstrap.ps1 b/src/persistence/UninstallBootstrap.ps1 index ea70a76..85a839e 100644 --- a/src/persistence/UninstallBootstrap.ps1 +++ b/src/persistence/UninstallBootstrap.ps1 @@ -353,7 +353,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) try { $installer = Get-CcodUninstallBootstrapComparablePath -Path $InstallerRoot -Kind 'Installer root' $install = Get-CcodUninstallBootstrapComparablePath -Path $InstallRoot -Kind 'Install root' @@ -369,9 +369,10 @@ function Get-CcodUninstallBootstrapVerifiedRuntimeContext { Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_BOOTSTRAP_INVALID' 'The uninstall bootstrap was not launched from the installed application root' $PSCommandPath } try{$pointerRoot=Resolve-CcodUninstallBootstrapChildPath -Root $install -RelativePath 'state\active-generation' -AllowMissingLeaf}catch{Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_RUNTIME_INVALID' 'The active generation selector root is unsafe' $install} - $pointerRootItem=$null;try{$pointerRootItem=Get-Item -LiteralPath $pointerRoot -Force -ErrorAction Stop}catch{} - if($null-ne$pointerRootItem-and-not$pointerRootItem.PSIsContainer){throw 'selector root type'} - if($null-ne$pointerRootItem){ + $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}} + 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 diff --git a/src/persistence/bootstrap.ps1 b/src/persistence/bootstrap.ps1 index 4893b28..d092d5d 100644 --- a/src/persistence/bootstrap.ps1 +++ b/src/persistence/bootstrap.ps1 @@ -165,12 +165,13 @@ function Assert-CcodBootstrapPlainSelectorFile { } function Read-CcodBootstrapActivePointer { - param([Parameter(Mandatory)][string]$InstallRoot) + param([Parameter(Mandatory)][string]$InstallRoot,[hashtable]$SelectorAdapters) try{$pointerRoot=Assert-CcodBootstrapContained -Root $InstallRoot -Path (Join-Path $InstallRoot 'state\active-generation') -AllowMissingLeaf}catch{Throw-CcodBootstrapError 'CCOD_BOOTSTRAP_POINTER_INVALID' 'Active generation selector root is unsafe' (Join-Path $InstallRoot 'state\active-generation')} - $pointerRootItem=$null;try{$pointerRootItem=Get-Item -LiteralPath $pointerRoot -Force -ErrorAction Stop}catch{} - if($null-ne$pointerRootItem-and-not$pointerRootItem.PSIsContainer){Throw-CcodBootstrapError 'CCOD_BOOTSTRAP_POINTER_INVALID' 'Active generation selector root is not a directory' $pointerRoot} - if($null-ne$pointerRootItem){ + $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}} + 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} diff --git a/src/persistence/modules/RuntimeManifest.psm1 b/src/persistence/modules/RuntimeManifest.psm1 index 05fa9e7..6c57e0d 100644 --- a/src/persistence/modules/RuntimeManifest.psm1 +++ b/src/persistence/modules/RuntimeManifest.psm1 @@ -38,6 +38,7 @@ function Get-CcodRuntimeAdapters { $resolved = @{ UtcNow = { [DateTime]::UtcNow } + GetSelectorRootItem = { param($Path) Get-Item -LiteralPath $Path -Force -ErrorAction Stop } AssertLifecycleFence = { param($InstallRoot, $Ownership, $ExpectActivePointer, $NewRuntimeId) if ([bool]$ExpectActivePointer) { @@ -45,7 +46,7 @@ function Get-CcodRuntimeAdapters { 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 } - try{$unexpected=Read-CcodActiveRuntime -InstallRoot $InstallRoot}catch{$unexpected=$null} + 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 @@ -358,10 +359,13 @@ public static class CcodRuntimePointerIdentity { [StructLayout(LayoutKind.Sequen function Read-CcodActiveRuntime { [CmdletBinding()] - param([Parameter(Mandatory)][string]$InstallRoot) + param([Parameter(Mandatory)][string]$InstallRoot,[hashtable]$Adapters) $pointerRoot = Resolve-CcodContainedPath -Root $InstallRoot -RelativePath 'state\active-generation' -AllowMissingLeaf - if ([IO.Directory]::Exists($pointerRoot)) { + $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}} + 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() @@ -377,7 +381,7 @@ function Read-CcodActiveRuntime { } $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=(Get-Item $pointerRoot).LastWriteTimeUtc.ToString('o')} + 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 @@ -467,7 +471,7 @@ function Set-CcodActiveRuntime { 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-CcodActiveRuntime $InstallRoot}catch{if((Get-CcodErrorId $_)-notin@('CCOD_STATE_MISSING','CCOD_PATH_MISSING')){throw}} + $current=$null;try{$current=Read-CcodActiveRuntime -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 diff --git a/tests/persistence/Bootstrap.SelfTest.ps1 b/tests/persistence/Bootstrap.SelfTest.ps1 index e4ccbe4..d3d5020 100644 --- a/tests/persistence/Bootstrap.SelfTest.ps1 +++ b/tests/persistence/Bootstrap.SelfTest.ps1 @@ -339,7 +339,12 @@ $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','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'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}}} + 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 'generation bootstrap launches the append-only selected runtime without root active json' { diff --git a/tests/persistence/InstallFileTransaction.SelfTest.ps1 b/tests/persistence/InstallFileTransaction.SelfTest.ps1 index b560dc0..31a4cfc 100644 --- a/tests/persistence/InstallFileTransaction.SelfTest.ps1 +++ b/tests/persistence/InstallFileTransaction.SelfTest.ps1 @@ -531,26 +531,38 @@ Invoke-CcodExtensionRedTest 'child-transaction' 'rejects a child directory capab } finally {Remove-CcodInstallFileFixture $fixture} } -Invoke-CcodExtensionRedTest 'v3-reimport' 're-imports ABI V4 and exercises every retained extension export' { +Invoke-CcodExtensionRedTest 'v3-reimport' 'a child process loads V3 first then proves the V4 state-only export boundary' { $fixture=New-CcodInstallFileFixture try { - if($null-eq('CcodInstallGenerationCapabilityMarkerV3'-as[type])){Add-Type -TypeDefinition 'public sealed class CcodInstallGenerationCapabilityMarkerV3 { private CcodInstallGenerationCapabilityMarkerV3() {} public static int CapabilityAbi { get { return 3; } } }'} - Assert-CcodEqual 3 ([CcodInstallGenerationCapabilityMarkerV3]::CapabilityAbi) 'old V3 marker is preloaded in the AppDomain' - $module=Import-Module $modulePath -Force -PassThru -DisableNameChecking;$module=Import-Module $modulePath -Force -PassThru -DisableNameChecking - $old=Open-CcodFixtureGeneration $fixture 'runtime-v3-old';$manifest=Write-CcodInstallGenerationManifest -Generation $old -Manifest (New-CcodGenerationManifest 'runtime-v3-old');Close-CcodInstallFileTransaction -Transaction $old -Disposition Ready|Out-Null - $transaction=Open-CcodFixtureGeneration $fixture 'runtime-v3-new';Write-CcodInstallGenerationManifest -Generation $transaction -Manifest (New-CcodGenerationManifest 'runtime-v3-new')|Out-Null - $state=New-CcodInstallDirectory -Transaction $transaction -Parent $transaction -Leaf 'state' -CreateIfMissing;$records=New-CcodInstallDirectory -Transaction $transaction -Parent $state -Leaf 'records' -CreateIfMissing;Write-CcodInstallRecord -Transaction $transaction -Parent $records -Leaf 'record.json' -Record ([ordered]@{schemaVersion=1;value='v3'})|Out-Null - $retained=Open-CcodInstallRetainedGeneration -InstallRoot $fixture.Install -RuntimeId 'runtime-v3-old' -ExpectedManifestSha256 $manifest.Sha256 -FileTransaction $transaction;Commit-CcodInstallActivePointer -InstallRoot $fixture.Install -TargetGeneration $retained -ExpectedPreviousGeneration ([uint64]0) -FileTransaction $transaction|Out-Null - $stateOnly=Open-CcodInstallStateTransaction -InstallRoot $fixture.Install;$fixture.Transactions.Add($stateOnly);$stateOnlyRoot=New-CcodInstallDirectory -Transaction $stateOnly -Parent $stateOnly -Leaf 'state' -CreateIfMissing;$recovery=New-CcodInstallDirectory -Transaction $stateOnly -Parent $stateOnlyRoot -Leaf 'recovery' -CreateIfMissing;Write-CcodInstallRecord -Transaction $stateOnly -Parent $recovery -Leaf 'ready.json' -Record ([ordered]@{schemaVersion=1;phase='Ready'})|Out-Null $source=New-CcodSourceFile $fixture 'v4-state-source.txt' 'state-only-after-v3' - Assert-CcodThrows {New-CcodInstallDirectory -Transaction $stateOnly -Parent $stateOnly -Leaf 'runtime' -CreateIfMissing|Out-Null} 'CCOD_INSTALL_STATE_SCOPE' - Assert-CcodThrows {New-CcodInstallGenerationLeaf -Generation $stateOnly -Leaf 'payload'|Out-Null} 'CCOD_INSTALL_STATE_SCOPE' - Assert-CcodThrows {Copy-CcodInstallSealedSource -Generation $stateOnly -SourcePath $source.Path -Leaf 'payload.bin' -ExpectedLength $source.Length -ExpectedSha256 $source.Sha256|Out-Null} 'CCOD_INSTALL_STATE_SCOPE' - Assert-CcodThrows {Write-CcodInstallGenerationManifest -Generation $stateOnly -Manifest (New-CcodGenerationManifest 'state-after-v3')|Out-Null} 'CCOD_INSTALL_STATE_SCOPE' - Assert-CcodThrows {Open-CcodInstallRetainedGeneration -InstallRoot $fixture.Install -RuntimeId 'runtime-v3-old' -ExpectedManifestSha256 $manifest.Sha256 -FileTransaction $stateOnly|Out-Null} 'CCOD_INSTALL_STATE_SCOPE' - Assert-CcodThrows {Commit-CcodInstallActivePointer -InstallRoot $fixture.Install -TargetGeneration $stateOnly -ExpectedPreviousGeneration ([uint64]1) -FileTransaction $stateOnly|Out-Null} 'CCOD_INSTALL_POINTER_TARGET_INVALID' - Assert-CcodThrows {Retire-CcodInstallGeneration -InstallRoot $fixture.Install -RuntimeId 'runtime-v3-old' -FileTransaction $stateOnly|Out-Null} 'CCOD_INSTALL_GENERATION_NOT_OWNED' - Assert-CcodEqual 4 ([CcodInstallGenerationCapabilityMarkerV4]::CapabilityAbi) 'module binds the new ABI V4 despite the preloaded V3 marker' + $escapedModule=$modulePath.Replace("'","''");$escapedInstall=$fixture.Install.Replace("'","''");$escapedSource=$source.Path.Replace("'","''") + $child=@" +`$ErrorActionPreference='Stop';`$ProgressPreference='SilentlyContinue' +Add-Type -TypeDefinition 'public sealed class CcodInstallGenerationCapabilityMarkerV3 { private CcodInstallGenerationCapabilityMarkerV3() {} public static int CapabilityAbi { get { return 3; } } }' +if([CcodInstallGenerationCapabilityMarkerV3]::CapabilityAbi-ne3-or`$null-ne('CcodInstallGenerationCapabilityMarkerV4'-as[type])){throw 'V3 was not loaded first'} +Import-Module '$escapedModule' -Force -DisableNameChecking -ErrorAction Stop +if([CcodInstallGenerationCapabilityMarkerV4]::CapabilityAbi-ne4){throw 'V4 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 'v3-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\v3-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('V3_FIRST_V4_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 V4 after loading only V3 first' + Assert-CcodEqual 'V3_FIRST_V4_STATE_ONLY_OK' ($output -join '') 'child proves only the exported V4 state-only boundary' } finally {Remove-CcodInstallFileFixture $fixture} } diff --git a/tests/persistence/RuntimeManifest.SelfTest.ps1 b/tests/persistence/RuntimeManifest.SelfTest.ps1 index 2a324b5..8d014eb 100644 --- a/tests/persistence/RuntimeManifest.SelfTest.ps1 +++ b/tests/persistence/RuntimeManifest.SelfTest.ps1 @@ -208,6 +208,20 @@ try { } } + 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 {Set-CcodActiveRuntime -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 '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')){ diff --git a/tests/persistence/StaticProbeWorker.SelfTest.ps1 b/tests/persistence/StaticProbeWorker.SelfTest.ps1 index 48943c7..ce319b7 100644 --- a/tests/persistence/StaticProbeWorker.SelfTest.ps1 +++ b/tests/persistence/StaticProbeWorker.SelfTest.ps1 @@ -512,20 +512,31 @@ try { } Invoke-CcodTest 'append-only authorization rejects unsafe roots leaves JSON and generations' { - foreach($kind in @('root-file','root-reparse','leaf-reparse','ads','multilink','duplicate','fractional','noncanonical')){ + 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')} - Assert-CcodThrows {Get-CcodStaticProbeRuntimeAuthorization -ScriptPath $fixture.WorkerPath|Out-Null} 'CCOD_STATIC_RUNTIME_UNAUTHORIZED' + $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 '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 diff --git a/tests/persistence/UninstallBootstrap.SelfTest.ps1 b/tests/persistence/UninstallBootstrap.SelfTest.ps1 index c9de83a..aa3bc40 100644 --- a/tests/persistence/UninstallBootstrap.SelfTest.ps1 +++ b/tests/persistence/UninstallBootstrap.SelfTest.ps1 @@ -240,7 +240,12 @@ $results += Invoke-CcodTest 'production uninstall authorization consumes append- $results += Invoke-CcodTest 'uninstall append-only authorization rejects unsafe roots leaves JSON and generations' { $previous=[Environment]::GetEnvironmentVariable('LOCALAPPDATA','Process') - try{foreach($kind in @('root-file','root-reparse','leaf-reparse','ads','multilink','duplicate','fractional','noncanonical')){$local=Join-Path ([IO.Path]::GetTempPath()) ("ccod-uninstall-hostile-$kind-"+[guid]::NewGuid().ToString('N'));$install=Join-Path $local 'CodexControlOtherDevices';[Environment]::SetEnvironmentVariable('LOCALAPPDATA',$local,'Process');$fixture=New-CcodVerifiedUninstallRuntimeFixture -InstallRoot $install -AppendOnly;$pointerRoot=Join-Path $install 'state\active-generation';$leaf=Join-Path $pointerRoot '00000000000000000007.json';$target=Join-Path $install ("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 $install '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'duplicate'){$id=$fixture.RuntimeId;[IO.File]::WriteAllText($leaf,('{"schemaVersion":1,"schemaVersion":1,"generation":7,"activeRuntime":"'+$id+'","previousGeneration":6}'),[Text.UTF8Encoding]::new($false))}elseif($kind-ceq'fractional'){$id=$fixture.RuntimeId;[IO.File]::WriteAllText($leaf,('{"schemaVersion":1,"generation":7.5,"activeRuntime":"'+$id+'","previousGeneration":6}'),[Text.UTF8Encoding]::new($false))}else{Move-Item $leaf (Join-Path $pointerRoot '00000000000000000008.json')};Assert-CcodThrows {Get-CcodUninstallBootstrapVerifiedRuntimeContext -InstallerRoot $repositoryRoot -InstallRoot $install|Out-Null} 'CCOD_UNINSTALL_RUNTIME_INVALID';if(Test-Path $local){Remove-Item $local -Recurse -Force}}}finally{[Environment]::SetEnvironmentVariable('LOCALAPPDATA',$previous,'Process')} + try{foreach($kind in @('root-file','root-reparse','leaf-reparse','ads','multilink','malformed','schema','duplicate','fractional','noncanonical')){$local=Join-Path ([IO.Path]::GetTempPath()) ("ccod-uninstall-hostile-$kind-"+[guid]::NewGuid().ToString('N'));$install=Join-Path $local 'CodexControlOtherDevices';[Environment]::SetEnvironmentVariable('LOCALAPPDATA',$local,'Process');$fixture=New-CcodVerifiedUninstallRuntimeFixture -InstallRoot $install -AppendOnly;$pointerRoot=Join-Path $install 'state\active-generation';$leaf=Join-Path $pointerRoot '00000000000000000007.json';$target=Join-Path $install ("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 $install '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":7,"activeRuntime":"'+$id+'","previousGeneration":6}'),[Text.UTF8Encoding]::new($false))}elseif($kind-ceq'duplicate'){$id=$fixture.RuntimeId;[IO.File]::WriteAllText($leaf,('{"schemaVersion":1,"schemaVersion":1,"generation":7,"activeRuntime":"'+$id+'","previousGeneration":6}'),[Text.UTF8Encoding]::new($false))}elseif($kind-ceq'fractional'){$id=$fixture.RuntimeId;[IO.File]::WriteAllText($leaf,('{"schemaVersion":1,"generation":7.5,"activeRuntime":"'+$id+'","previousGeneration":6}'),[Text.UTF8Encoding]::new($false))}else{Move-Item $leaf (Join-Path $pointerRoot '00000000000000000008.json')};Assert-CcodThrows {Get-CcodUninstallBootstrapVerifiedRuntimeContext -InstallerRoot $repositoryRoot -InstallRoot $install|Out-Null} 'CCOD_UNINSTALL_RUNTIME_INVALID';if(Test-Path $local){Remove-Item $local -Recurse -Force}}}finally{[Environment]::SetEnvironmentVariable('LOCALAPPDATA',$previous,'Process')} +} + +$results += Invoke-CcodTest 'uninstall selector fallback requires proven ItemNotFound instead of a lookup error' { + $local=Join-Path ([IO.Path]::GetTempPath()) ('ccod-uninstall-lookup-'+[guid]::NewGuid().ToString('N'));$install=Join-Path $local 'CodexControlOtherDevices';$previous=[Environment]::GetEnvironmentVariable('LOCALAPPDATA','Process') + try{[Environment]::SetEnvironmentVariable('LOCALAPPDATA',$local,'Process');New-CcodVerifiedUninstallRuntimeFixture -InstallRoot $install|Out-Null;Assert-CcodThrows {Get-CcodUninstallBootstrapVerifiedRuntimeContext -InstallerRoot $repositoryRoot -InstallRoot $install -SelectorAdapters @{GetSelectorRootItem={param($Path)throw [UnauthorizedAccessException]::new('selector lookup denied')}}|Out-Null} 'CCOD_UNINSTALL_RUNTIME_INVALID'}finally{[Environment]::SetEnvironmentVariable('LOCALAPPDATA',$previous,'Process');if(Test-Path $local){Remove-Item $local -Recurse -Force}} } $results += Invoke-CcodTest 'external staging refuses a cleanup source changed after runtime verification' { From d3ec629e4e161cc10337501fbe51a98970398f1e Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:11:59 +0800 Subject: [PATCH 084/187] fix: prove selector absence safely --- .../task-2-report.md | 72 +++++++++++++++++++ ...08-29-v2522-immutable-generation-design.md | 13 ++-- src/persistence/StaticProbeWorker.ps1 | 24 ++++++- src/persistence/UninstallBootstrap.ps1 | 7 +- src/persistence/bootstrap.ps1 | 7 +- src/persistence/modules/RuntimeManifest.psm1 | 7 +- tests/persistence/Bootstrap.SelfTest.ps1 | 11 +++ .../persistence/RuntimeManifest.SelfTest.ps1 | 9 +++ .../StaticProbeWorker.SelfTest.ps1 | 23 ++++++ .../UninstallBootstrap.SelfTest.ps1 | 11 +++ 10 files changed, 173 insertions(+), 11 deletions(-) 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 index 10adf5e..426b253 100644 --- 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 @@ -366,3 +366,75 @@ Important findings. This round keeps `2101485` and remediates all seven. - 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. 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 index 57f4cb6..484a791 100644 --- a/docs/superpowers/specs/2026-08-29-v2522-immutable-generation-design.md +++ b/docs/superpowers/specs/2026-08-29-v2522-immutable-generation-design.md @@ -87,10 +87,15 @@ 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: only an exact -ItemNotFound outcome permits legacy fallback. A selector-root file, reparse -point, access or I/O failure, malformed store, or unsupported record fails the -component's bounded authorization contract instead of consulting `active.json`. +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. ### 1a. Immutable initialization evidence and operational state diff --git a/src/persistence/StaticProbeWorker.ps1 b/src/persistence/StaticProbeWorker.ps1 index 3c40e2d..a976c32 100644 --- a/src/persistence/StaticProbeWorker.ps1 +++ b/src/persistence/StaticProbeWorker.ps1 @@ -248,6 +248,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 } @@ -462,9 +471,22 @@ 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 + $stateRoot=[IO.Path]::GetFullPath((Join-Path $installRoot 'state')) $pointerRoot=[IO.Path]::GetFullPath((Join-Path $installRoot 'state\active-generation')) $pathAdapters=Get-CcodStaticProbePathAdapters $Adapters - $pointerRootItem=Invoke-CcodStaticAdapter $pathAdapters.GetItem @($pointerRoot,$true) OptionalSingle 'CCOD_STATIC_RUNTIME_UNAUTHORIZED' + $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() diff --git a/src/persistence/UninstallBootstrap.ps1 b/src/persistence/UninstallBootstrap.ps1 index 85a839e..3248e44 100644 --- a/src/persistence/UninstallBootstrap.ps1 +++ b/src/persistence/UninstallBootstrap.ps1 @@ -368,9 +368,12 @@ function Get-CcodUninstallBootstrapVerifiedRuntimeContext { 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 } - try{$pointerRoot=Resolve-CcodUninstallBootstrapChildPath -Root $install -RelativePath 'state\active-generation' -AllowMissingLeaf}catch{Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_RUNTIME_INVALID' 'The active generation selector root is unsafe' $install} + try{$stateRoot=Resolve-CcodUninstallBootstrapChildPath -Root $install -RelativePath 'state' -AllowMissingLeaf;$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} $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}} - 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} + $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{$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() diff --git a/src/persistence/bootstrap.ps1 b/src/persistence/bootstrap.ps1 index d092d5d..83eef5e 100644 --- a/src/persistence/bootstrap.ps1 +++ b/src/persistence/bootstrap.ps1 @@ -167,9 +167,12 @@ function Assert-CcodBootstrapPlainSelectorFile { function Read-CcodBootstrapActivePointer { param([Parameter(Mandatory)][string]$InstallRoot,[hashtable]$SelectorAdapters) - try{$pointerRoot=Assert-CcodBootstrapContained -Root $InstallRoot -Path (Join-Path $InstallRoot 'state\active-generation') -AllowMissingLeaf}catch{Throw-CcodBootstrapError 'CCOD_BOOTSTRAP_POINTER_INVALID' 'Active generation selector root is unsafe' (Join-Path $InstallRoot 'state\active-generation')} + 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}} - 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} + $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} diff --git a/src/persistence/modules/RuntimeManifest.psm1 b/src/persistence/modules/RuntimeManifest.psm1 index 6c57e0d..974eee0 100644 --- a/src/persistence/modules/RuntimeManifest.psm1 +++ b/src/persistence/modules/RuntimeManifest.psm1 @@ -361,9 +361,12 @@ function Read-CcodActiveRuntime { [CmdletBinding()] param([Parameter(Mandatory)][string]$InstallRoot,[hashtable]$Adapters) - $pointerRoot = Resolve-CcodContainedPath -Root $InstallRoot -RelativePath 'state\active-generation' -AllowMissingLeaf + 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}} - 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} + $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) diff --git a/tests/persistence/Bootstrap.SelfTest.ps1 b/tests/persistence/Bootstrap.SelfTest.ps1 index d3d5020..7564f00 100644 --- a/tests/persistence/Bootstrap.SelfTest.ps1 +++ b/tests/persistence/Bootstrap.SelfTest.ps1 @@ -347,6 +347,17 @@ $results += Invoke-CcodTest 'bootstrap selector fallback requires proven ItemNot 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 '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{ diff --git a/tests/persistence/RuntimeManifest.SelfTest.ps1 b/tests/persistence/RuntimeManifest.SelfTest.ps1 index 8d014eb..0b4269d 100644 --- a/tests/persistence/RuntimeManifest.SelfTest.ps1 +++ b/tests/persistence/RuntimeManifest.SelfTest.ps1 @@ -222,6 +222,15 @@ try { } } + 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')){ diff --git a/tests/persistence/StaticProbeWorker.SelfTest.ps1 b/tests/persistence/StaticProbeWorker.SelfTest.ps1 index ce319b7..8eb04a2 100644 --- a/tests/persistence/StaticProbeWorker.SelfTest.ps1 +++ b/tests/persistence/StaticProbeWorker.SelfTest.ps1 @@ -537,6 +537,29 @@ try { 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 diff --git a/tests/persistence/UninstallBootstrap.SelfTest.ps1 b/tests/persistence/UninstallBootstrap.SelfTest.ps1 index aa3bc40..80cdd25 100644 --- a/tests/persistence/UninstallBootstrap.SelfTest.ps1 +++ b/tests/persistence/UninstallBootstrap.SelfTest.ps1 @@ -248,6 +248,17 @@ $results += Invoke-CcodTest 'uninstall selector fallback requires proven ItemNot try{[Environment]::SetEnvironmentVariable('LOCALAPPDATA',$local,'Process');New-CcodVerifiedUninstallRuntimeFixture -InstallRoot $install|Out-Null;Assert-CcodThrows {Get-CcodUninstallBootstrapVerifiedRuntimeContext -InstallerRoot $repositoryRoot -InstallRoot $install -SelectorAdapters @{GetSelectorRootItem={param($Path)throw [UnauthorizedAccessException]::new('selector lookup denied')}}|Out-Null} 'CCOD_UNINSTALL_RUNTIME_INVALID'}finally{[Environment]::SetEnvironmentVariable('LOCALAPPDATA',$previous,'Process');if(Test-Path $local){Remove-Item $local -Recurse -Force}} } +$results += Invoke-CcodTest 'uninstall legacy fallback rejects a state ancestor file at the selector boundary' { + $local=Join-Path ([IO.Path]::GetTempPath()) ('ccod-uninstall-state-file-'+[guid]::NewGuid().ToString('N'));$install=Join-Path $local 'CodexControlOtherDevices';$previous=[Environment]::GetEnvironmentVariable('LOCALAPPDATA','Process') + try{ + [Environment]::SetEnvironmentVariable('LOCALAPPDATA',$local,'Process');New-CcodVerifiedUninstallRuntimeFixture -InstallRoot $install|Out-Null + $state=Join-Path $install 'state';Remove-Item -LiteralPath $state -Recurse -Force;[IO.File]::WriteAllText($state,'not-a-directory',[Text.UTF8Encoding]::new($false)) + $failure=$null;try{Get-CcodUninstallBootstrapVerifiedRuntimeContext -InstallerRoot $repositoryRoot -InstallRoot $install|Out-Null}catch{$failure=$_} + Assert-CcodTrue ($null-ne$failure-and$failure.FullyQualifiedErrorId-like'CCOD_UNINSTALL_RUNTIME_INVALID*') 'state ancestor file fails uninstall authorization' + Assert-CcodTrue ($failure.Exception.Message-like'*selector*') 'state ancestor file is rejected at the selector boundary' + }finally{[Environment]::SetEnvironmentVariable('LOCALAPPDATA',$previous,'Process');if(Test-Path $local){Remove-Item $local -Recurse -Force}} +} + $results += Invoke-CcodTest 'external staging refuses a cleanup source changed after runtime verification' { $localAppData = Join-Path ([IO.Path]::GetTempPath()) ('ccod-uninstall-race-' + [guid]::NewGuid().ToString('N')) $installRoot = Join-Path $localAppData 'CodexControlOtherDevices' From 46692e77df56c4185db46869c2d3fc697970d8c5 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:05:56 +0800 Subject: [PATCH 085/187] fix: permit proven legacy selector fallback --- .../task-2-report.md | 73 +++++++++++++++++++ ...08-29-v2522-immutable-generation-design.md | 6 ++ src/persistence/UninstallBootstrap.ps1 | 4 +- tests/persistence/Bootstrap.SelfTest.ps1 | 41 +++++++++-- .../UninstallBootstrap.SelfTest.ps1 | 19 +++++ 5 files changed, 134 insertions(+), 9 deletions(-) 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 index 426b253..7f35626 100644 --- 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 @@ -438,3 +438,76 @@ Important findings. This round keeps `2101485` and remediates all seven. - 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/docs/superpowers/specs/2026-08-29-v2522-immutable-generation-design.md b/docs/superpowers/specs/2026-08-29-v2522-immutable-generation-design.md index 484a791..913db52 100644 --- a/docs/superpowers/specs/2026-08-29-v2522-immutable-generation-design.md +++ b/docs/superpowers/specs/2026-08-29-v2522-immutable-generation-design.md @@ -97,6 +97,12 @@ 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 diff --git a/src/persistence/UninstallBootstrap.ps1 b/src/persistence/UninstallBootstrap.ps1 index 3248e44..a368c66 100644 --- a/src/persistence/UninstallBootstrap.ps1 +++ b/src/persistence/UninstallBootstrap.ps1 @@ -368,12 +368,12 @@ function Get-CcodUninstallBootstrapVerifiedRuntimeContext { 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 } - try{$stateRoot=Resolve-CcodUninstallBootstrapChildPath -Root $install -RelativePath 'state' -AllowMissingLeaf;$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{$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{$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($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() diff --git a/tests/persistence/Bootstrap.SelfTest.ps1 b/tests/persistence/Bootstrap.SelfTest.ps1 index 7564f00..045e3eb 100644 --- a/tests/persistence/Bootstrap.SelfTest.ps1 +++ b/tests/persistence/Bootstrap.SelfTest.ps1 @@ -115,6 +115,7 @@ function Add-CcodTestRuntime { [AllowNull()][string]$RuntimeId, [bool]$IncludeFenceModules = $true, [bool]$FailLifecycleRelease = $false, + [AllowNull()][string]$LifecycleReleaseMarkerPath, [bool]$IncludeGenerationBootstrap = $false ) @@ -138,18 +139,22 @@ function Add-CcodTestRuntime { [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' @@ -228,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() @@ -241,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() @@ -250,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() @@ -484,17 +506,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 diff --git a/tests/persistence/UninstallBootstrap.SelfTest.ps1 b/tests/persistence/UninstallBootstrap.SelfTest.ps1 index 80cdd25..622e3cb 100644 --- a/tests/persistence/UninstallBootstrap.SelfTest.ps1 +++ b/tests/persistence/UninstallBootstrap.SelfTest.ps1 @@ -238,6 +238,25 @@ $results += Invoke-CcodTest 'production uninstall authorization consumes append- try{[Environment]::SetEnvironmentVariable('LOCALAPPDATA',$localAppData,'Process');$fixture=New-CcodVerifiedUninstallRuntimeFixture -InstallRoot $installRoot -AppendOnly;$context=Get-CcodUninstallBootstrapVerifiedRuntimeContext -InstallerRoot $repositoryRoot -InstallRoot $installRoot;Assert-CcodEqual $fixture.RuntimeId $context.runtimeId 'uninstall context binds append-only active runtime';Assert-CcodEqual 7 ([uint64]$context.runtimeGeneration) 'uninstall context binds latest append-only generation';Assert-CcodEqual $false (Test-Path -LiteralPath (Join-Path $installRoot 'active.json')) 'append-only uninstall authorization needs no legacy pointer'}finally{[Environment]::SetEnvironmentVariable('LOCALAPPDATA',$previous,'Process');if(Test-Path $localAppData){Remove-Item $localAppData -Recurse -Force}} } +$results += Invoke-CcodTest 'uninstall legacy fallback accepts a valid pointer when the entire state plane is absent' { + $local=Join-Path ([IO.Path]::GetTempPath()) ('ccod-uninstall-no-state-'+[guid]::NewGuid().ToString('N'));$install=Join-Path $local 'CodexControlOtherDevices';$previous=[Environment]::GetEnvironmentVariable('LOCALAPPDATA','Process') + try{ + [Environment]::SetEnvironmentVariable('LOCALAPPDATA',$local,'Process');New-CcodVerifiedUninstallRuntimeFixture -InstallRoot $install|Out-Null + $state=Join-Path $install 'state';Remove-Item -LiteralPath $state -Recurse -Force + $activePath=Join-Path $install 'active.json';$validActive=[IO.File]::ReadAllText($activePath) + Assert-CcodEqual $true (Test-Path -LiteralPath $activePath -PathType Leaf) 'legacy active pointer remains present' + Assert-CcodEqual $false (Test-Path -LiteralPath $state) 'new selector state plane is entirely absent' + [IO.File]::WriteAllText($activePath,'{"schemaVersion":3,"activeRuntime":"invalid","previousRuntime":null,"generation":7,"updatedAtUtc":"2030-01-02T03:04:05.0000000Z"}',[Text.UTF8Encoding]::new($false)) + $invalidFailure=$null;try{Get-CcodUninstallBootstrapVerifiedRuntimeContext -InstallerRoot $repositoryRoot -InstallRoot $install|Out-Null}catch{$invalidFailure=$_} + Assert-CcodTrue ($null-ne$invalidFailure-and$invalidFailure.FullyQualifiedErrorId-like'CCOD_UNINSTALL_RUNTIME_INVALID*') 'an invalid legacy pointer is not skipped when the selector plane is absent' + Assert-CcodEqual ([IO.Path]::GetFullPath($activePath)) ([string]$invalidFailure.TargetObject) 'legacy fallback still authorizes through the active pointer' + [IO.File]::WriteAllText($activePath,$validActive,[Text.UTF8Encoding]::new($false)) + $failure=$null;try{Get-CcodUninstallBootstrapVerifiedRuntimeContext -InstallerRoot $repositoryRoot -InstallRoot $install|Out-Null}catch{$failure=$_} + Assert-CcodTrue ($null-ne$failure-and$failure.FullyQualifiedErrorId-like'CCOD_UNINSTALL_REPARSE_OR_PATH_INVALID*') 'missing lifecycle epoch still fails closed after legacy authorization' + Assert-CcodEqual ([IO.Path]::GetFullPath($state)) ([string]$failure.TargetObject) 'valid legacy selector fallback reaches the later lifecycle epoch validation' + }finally{[Environment]::SetEnvironmentVariable('LOCALAPPDATA',$previous,'Process');if(Test-Path $local){Remove-Item $local -Recurse -Force}} +} + $results += Invoke-CcodTest 'uninstall append-only authorization rejects unsafe roots leaves JSON and generations' { $previous=[Environment]::GetEnvironmentVariable('LOCALAPPDATA','Process') try{foreach($kind in @('root-file','root-reparse','leaf-reparse','ads','multilink','malformed','schema','duplicate','fractional','noncanonical')){$local=Join-Path ([IO.Path]::GetTempPath()) ("ccod-uninstall-hostile-$kind-"+[guid]::NewGuid().ToString('N'));$install=Join-Path $local 'CodexControlOtherDevices';[Environment]::SetEnvironmentVariable('LOCALAPPDATA',$local,'Process');$fixture=New-CcodVerifiedUninstallRuntimeFixture -InstallRoot $install -AppendOnly;$pointerRoot=Join-Path $install 'state\active-generation';$leaf=Join-Path $pointerRoot '00000000000000000007.json';$target=Join-Path $install ("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 $install '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":7,"activeRuntime":"'+$id+'","previousGeneration":6}'),[Text.UTF8Encoding]::new($false))}elseif($kind-ceq'duplicate'){$id=$fixture.RuntimeId;[IO.File]::WriteAllText($leaf,('{"schemaVersion":1,"schemaVersion":1,"generation":7,"activeRuntime":"'+$id+'","previousGeneration":6}'),[Text.UTF8Encoding]::new($false))}elseif($kind-ceq'fractional'){$id=$fixture.RuntimeId;[IO.File]::WriteAllText($leaf,('{"schemaVersion":1,"generation":7.5,"activeRuntime":"'+$id+'","previousGeneration":6}'),[Text.UTF8Encoding]::new($false))}else{Move-Item $leaf (Join-Path $pointerRoot '00000000000000000008.json')};Assert-CcodThrows {Get-CcodUninstallBootstrapVerifiedRuntimeContext -InstallerRoot $repositoryRoot -InstallRoot $install|Out-Null} 'CCOD_UNINSTALL_RUNTIME_INVALID';if(Test-Path $local){Remove-Item $local -Recurse -Force}}}finally{[Environment]::SetEnvironmentVariable('LOCALAPPDATA',$previous,'Process')} From 9e1c41c72d296c2d0c014a45561a855699ebd39b Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:00:33 +0800 Subject: [PATCH 086/187] fix: bootstrap sealed installer package --- .../task-3-report.md | 98 ++ Activate-CcodRemoteFix.ps1 | 121 ++- Install-CodexControlOtherDevices.ps1 | 4 +- build/CodexControlOtherDevices.iss | 834 +++--------------- build/InstallerPackage.psm1 | 236 +++++ build/SetupArtifact.psm1 | 80 +- build/build.ps1 | 53 +- .../persistence/InstallLifecycle.SelfTest.ps1 | 149 ++-- .../persistence/ReleaseWorkflow.SelfTest.ps1 | 192 +++- tools/New-InstallerDestinationInventory.ps1 | 32 +- 10 files changed, 947 insertions(+), 852 deletions(-) create mode 100644 .superpowers/sdd/2026-08-29-v2522-immutable-generation/task-3-report.md create mode 100644 build/InstallerPackage.psm1 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..b2e43c6 --- /dev/null +++ b/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-3-report.md @@ -0,0 +1,98 @@ +# 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 + +- `fix: bootstrap sealed installer package` (the Task 3 commit containing this + report; resolve the exact object as this worktree's Task 3 HEAD). diff --git a/Activate-CcodRemoteFix.ps1 b/Activate-CcodRemoteFix.ps1 index d479e7b..87e12f2 100644 --- a/Activate-CcodRemoteFix.ps1 +++ b/Activate-CcodRemoteFix.ps1 @@ -1,8 +1,13 @@ [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, @@ -54,6 +59,48 @@ 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 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]@{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() @@ -557,8 +604,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 @@ -590,6 +650,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 } @@ -641,6 +702,7 @@ function Invoke-CcodOwnedInstallWorker { [Parameter(Mandatory)][string]$InstallScript, [Parameter(Mandatory)][string]$VerifiedPayloadRoot, [Parameter(Mandatory)][string]$StateRoot, + [string]$SealedPackageSha256, [Parameter(Mandatory)][int]$FirstReceiptTimeout, [Parameter(Mandatory)][int]$ActivationTimeout ) @@ -668,6 +730,9 @@ function Invoke-CcodOwnedInstallWorker { '-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 ' ') @@ -680,8 +745,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) { @@ -778,10 +842,49 @@ function Show-CcodActivationFailure { } if ($ValidateReceiptOnly -and $ValidateReceiptWithTimeout) { Write-Error 'CCOD_ACTIVATION_VALIDATOR_MODE_INVALID' -ErrorAction Continue; exit 3 } -if ([string]::IsNullOrWhiteSpace($PayloadRoot)) { $PayloadRoot = $AppRoot } -$PayloadRoot = [IO.Path]::GetFullPath($PayloadRoot) +$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 + $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 { @@ -828,7 +931,7 @@ try { 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 -VerifiedPayloadRoot $executionPayloadRoot -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 { @@ -873,4 +976,6 @@ Write-CcodActivationRecord -Code 'COMPLETED' -DurationMilliseconds $clock.Elapse 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/Install-CodexControlOtherDevices.ps1 b/Install-CodexControlOtherDevices.ps1 index 8d5450d..718e4a7 100644 --- a/Install-CodexControlOtherDevices.ps1 +++ b/Install-CodexControlOtherDevices.ps1 @@ -181,7 +181,9 @@ try { $invoke.ExpectedPayloadManifestSha256 = $ExpectedPayloadManifestSha256 $invoke.PayloadManifestBytesBase64 = [Convert]::ToBase64String([byte[]]$payloadSeal.ManifestBytes) } - if(-not[string]::IsNullOrWhiteSpace($SealedPackageSha256)){$invoke.SealedPackageSha256=$SealedPackageSha256} + 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 } diff --git a/build/CodexControlOtherDevices.iss b/build/CodexControlOtherDevices.iss index e5c5a8c..b1bd096 100644 --- a/build/CodexControlOtherDevices.iss +++ b/build/CodexControlOtherDevices.iss @@ -1,16 +1,29 @@ [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 InstallerPayloadDirectory -#error InstallerPayloadDirectory must be supplied by the release builder +#ifndef InstallerPackageManifestPath +#error InstallerPackageManifestPath must be supplied by the release builder #endif -#ifndef InstallerPayloadManifestSha256 -#error InstallerPayloadManifestSha256 must be supplied by the release builder +#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 @@ -27,20 +40,20 @@ AppSupportURL=https://github.com/naipi11/CodexRemote-fix/issues AppUpdatesURL=https://github.com/naipi11/CodexRemote-fix/releases VersionInfoVersion={#ProjectVersion}.0 VersionInfoTextVersion={#ProjectVersion}.0 -VersionInfoProductName=CodexRemote-fix -VersionInfoProductTextVersion={#ProjectVersion}.0 -VersionInfoDescription=CCODSETUP {#ProjectVersion} +VersionInfoProductName={#ActivationBootstrapSha256Last} +VersionInfoProductTextVersion={#InstallerPackageManifestSha256First} +VersionInfoDescription={#InstallerPackageManifestSha256Last} VersionInfoCompany={#SetupGitCommit} -VersionInfoCopyright={#InstallerPayloadManifestSha256} -DefaultDirName={localappdata}\CodexControlOtherDevices-installer -DefaultGroupName=CodexRemote-fix -UsePreviousGroup=no +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 @@ -55,762 +68,139 @@ MinVersion=10.0.17763 Name: "english"; MessagesFile: "compiler:Default.isl" [Files] -Source: "{#SetupProvenancePath}"; DestDir: "{app}"; DestName: "setup-provenance.json"; Flags: dontcopy -Source: "{#InstallerPayloadDirectory}\installer-payload.manifest.json"; DestDir: "{app}"; DestName: "setup-input-payload-manifest.json"; Flags: dontcopy -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\SetupArtifact.psm1"; 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 -Source: "..\tools\New-InstallerPayloadManifest.ps1"; DestDir: "{app}\tools"; Flags: ignoreversion -Source: "..\tools\New-InstallerDestinationInventory.ps1"; DestDir: "{app}\tools"; Flags: ignoreversion -Source: "{#InstallerPayloadDirectory}\*"; DestDir: "{app}\payload\{#ProjectVersion}"; Flags: ignoreversion recursesubdirs createallsubdirs - -[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_FILE_READ_ATTRIBUTES = $00000080; - CCOD_DELETE_ACCESS = $00010000; + CCOD_GENERIC_READ = $80000000; 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_FILE_ATTRIBUTE_NORMAL = $00000080; CCOD_INVALID_HANDLE_VALUE = -1; - CCOD_ERROR_HANDLE_EOF = 38; + 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); - 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; +var + CcodInputHandles: array of Integer; + CcodPackagePath: String; + CcodPackageManifestPath: String; + CcodBootstrapPath: String; -function GetTickCount64(): Int64; - external 'GetTickCount64@kernel32.dll stdcall'; -function CoCreateGuid(var Guid: TGUID): HResult; - external 'CoCreateGuid@ole32.dll stdcall'; -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 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'; - -function IsSafeExistingPayloadDirectory(const DirectoryName: String): Boolean; -var - Attributes: Cardinal; -begin - Attributes := GetFileAttributesW(DirectoryName); - Result := (Attributes = CCOD_INVALID_FILE_ATTRIBUTES) or - (((Attributes and CCOD_FILE_ATTRIBUTE_DIRECTORY) <> 0) and - ((Attributes and CCOD_FILE_ATTRIBUTE_REPARSE_POINT) = 0)); -end; - -function HasOnlyDefaultDataStream(const FileName: String): Boolean; -var - FindHandle: Integer; - StreamData: TCcodFindStreamData; - ErrorCode: Cardinal; -begin - Result := False; - FindHandle := FindFirstStreamW(FileName, 0, StreamData, 0); - if FindHandle = CCOD_INVALID_HANDLE_VALUE then Exit; - try - if FindNextStreamW(FindHandle, StreamData) then Exit; - ErrorCode := GetLastError(); - Result := ErrorCode = CCOD_ERROR_HANDLE_EOF; - finally - CcodFindClose(FindHandle); - end; -end; - -function IsSafeExistingSetupLeaf(const FileName: String): Boolean; -var - Attributes: Cardinal; - FileHandle: Integer; - Information: TCcodByHandleFileInformation; -begin - Result := False; - 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; - FileHandle := CreateFileW(FileName, CCOD_FILE_READ_ATTRIBUTES, - CCOD_FILE_SHARE_READ or CCOD_FILE_SHARE_WRITE or CCOD_FILE_SHARE_DELETE, - 0, CCOD_OPEN_EXISTING, CCOD_FILE_FLAG_OPEN_REPARSE_POINT, 0); - if FileHandle = CCOD_INVALID_HANDLE_VALUE then Exit; - try - if not GetFileInformationByHandle(FileHandle, Information) then Exit; - Result := (Information.NumberOfLinks = 1) and - ((Information.FileAttributes and CCOD_FILE_ATTRIBUTE_DIRECTORY) = 0) and - ((Information.FileAttributes and CCOD_FILE_ATTRIBUTE_REPARSE_POINT) = 0) and - HasOnlyDefaultDataStream(FileName); - finally - CloseHandle(FileHandle); - end; -end; - -function IsSafeExistingSetupTree(const DirectoryName: String): Boolean; -var - Attributes: Cardinal; - FindRec: TFindRec; - ChildPath: String; -begin - Result := IsSafeExistingPayloadDirectory(DirectoryName); - if not Result then Exit; - Attributes := GetFileAttributesW(DirectoryName); - if Attributes = CCOD_INVALID_FILE_ATTRIBUTES then Exit; - if FindFirst(AddBackslash(DirectoryName) + '*', FindRec) then - begin - try - repeat - if (FindRec.Name <> '.') and (FindRec.Name <> '..') then - begin - ChildPath := AddBackslash(DirectoryName) + FindRec.Name; - if (FindRec.Attributes and CCOD_FILE_ATTRIBUTE_REPARSE_POINT) <> 0 then - begin - Result := False; - Exit; - end; - if ((FindRec.Attributes and CCOD_FILE_ATTRIBUTE_DIRECTORY) <> 0) and - (not IsSafeExistingSetupTree(ChildPath)) then - begin - Result := False; - Exit; - end; - if ((FindRec.Attributes and CCOD_FILE_ATTRIBUTE_DIRECTORY) = 0) and - (not IsSafeExistingSetupLeaf(ChildPath)) then - begin - Result := False; - Exit; - end; - end; - until not FindNext(FindRec); - finally - FindClose(FindRec); - end; - end; -end; - -// CCOD_SETUP_PIN_BEGIN -var - CcodSetupPinHandles: array of Integer; - -procedure AddCcodSetupPin(Handle: Integer); -var - Count: Integer; -begin - Count := GetArrayLength(CcodSetupPinHandles); - SetArrayLength(CcodSetupPinHandles, Count + 1); - CcodSetupPinHandles[Count] := Handle; -end; +function CoCreateGuid(var Guid: TGUID): HResult; + external 'CoCreateGuid@ole32.dll stdcall'; +function StringFromGUID2(var Guid: TGUID; GuidString: String; + MaxCharacters: Integer): Integer; + external 'StringFromGUID2@ole32.dll stdcall'; -procedure CloseCcodSetupPins(); +procedure CloseCcodInputHandles(); var Index: Integer; begin - for Index := GetArrayLength(CcodSetupPinHandles) - 1 downto 0 do - if CcodSetupPinHandles[Index] <> CCOD_INVALID_HANDLE_VALUE then - CloseHandle(CcodSetupPinHandles[Index]); - SetArrayLength(CcodSetupPinHandles, 0); + for Index := GetArrayLength(CcodInputHandles) - 1 downto 0 do + if CcodInputHandles[Index] <> CCOD_INVALID_HANDLE_VALUE then + CloseHandle(CcodInputHandles[Index]); + SetArrayLength(CcodInputHandles, 0); end; -function PinCcodExistingSetupDirectory(const DirectoryName: String): Boolean; +function LockCcodInput(const Path, ExpectedSha256: String): Boolean; var - Attributes: Cardinal; - DirectoryHandle: Integer; - Information: TCcodByHandleFileInformation; + Handle: Integer; + Count: Integer; begin Result := False; - Attributes := GetFileAttributesW(DirectoryName); - if Attributes = CCOD_INVALID_FILE_ATTRIBUTES then - begin - Result := True; - Exit; - end; - if ((Attributes and CCOD_FILE_ATTRIBUTE_DIRECTORY) = 0) or - ((Attributes and CCOD_FILE_ATTRIBUTE_REPARSE_POINT) <> 0) then Exit; - DirectoryHandle := CreateFileW(DirectoryName, CCOD_FILE_READ_ATTRIBUTES or CCOD_DELETE_ACCESS, - CCOD_FILE_SHARE_READ or CCOD_FILE_SHARE_WRITE, 0, CCOD_OPEN_EXISTING, - CCOD_FILE_FLAG_BACKUP_SEMANTICS or CCOD_FILE_FLAG_OPEN_REPARSE_POINT, 0); - if DirectoryHandle = CCOD_INVALID_HANDLE_VALUE then Exit; - if (not GetFileInformationByHandle(DirectoryHandle, Information)) or - ((Information.FileAttributes and CCOD_FILE_ATTRIBUTE_DIRECTORY) = 0) or - ((Information.FileAttributes and CCOD_FILE_ATTRIBUTE_REPARSE_POINT) <> 0) then + 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 - CloseHandle(DirectoryHandle); + CloseHandle(Handle); Exit; end; - Attributes := GetFileAttributesW(DirectoryName); - if (Attributes = CCOD_INVALID_FILE_ATTRIBUTES) or - ((Attributes and CCOD_FILE_ATTRIBUTE_DIRECTORY) = 0) or - ((Attributes and CCOD_FILE_ATTRIBUTE_REPARSE_POINT) <> 0) then - begin - CloseHandle(DirectoryHandle); - Exit; - end; - AddCcodSetupPin(DirectoryHandle); + Count := GetArrayLength(CcodInputHandles); + SetArrayLength(CcodInputHandles, Count + 1); + CcodInputHandles[Count] := Handle; Result := True; end; -function PinCcodExistingSetupLeaf(const FileName: String): Boolean; -var - Attributes: Cardinal; - FileHandle: Integer; - Information: TCcodByHandleFileInformation; +function ExtractAndLockCcodInputs(): Boolean; begin Result := False; - if not IsSafeExistingSetupLeaf(FileName) then Exit; - FileHandle := CreateFileW(FileName, CCOD_FILE_READ_ATTRIBUTES, - CCOD_FILE_SHARE_READ or CCOD_FILE_SHARE_WRITE, 0, CCOD_OPEN_EXISTING, - CCOD_FILE_FLAG_OPEN_REPARSE_POINT, 0); - if FileHandle = CCOD_INVALID_HANDLE_VALUE then Exit; - if (not GetFileInformationByHandle(FileHandle, Information)) or - (Information.NumberOfLinks <> 1) or - ((Information.FileAttributes and CCOD_FILE_ATTRIBUTE_DIRECTORY) <> 0) or - ((Information.FileAttributes and CCOD_FILE_ATTRIBUTE_REPARSE_POINT) <> 0) or - (not HasOnlyDefaultDataStream(FileName)) then - begin - CloseHandle(FileHandle); - Exit; - end; - 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 - begin - CloseHandle(FileHandle); - Exit; - end; - AddCcodSetupPin(FileHandle); + 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 PinCcodExistingSetupTree(const DirectoryName: String): Boolean; -var - Attributes: Cardinal; - FindRec: TFindRec; - ChildPath: String; -begin - Result := PinCcodExistingSetupDirectory(DirectoryName); - if not Result then Exit; - Attributes := GetFileAttributesW(DirectoryName); - if Attributes = CCOD_INVALID_FILE_ATTRIBUTES then Exit; - if FindFirst(AddBackslash(DirectoryName) + '*', FindRec) then - begin - try - repeat - if (FindRec.Name <> '.') and (FindRec.Name <> '..') then - begin - ChildPath := AddBackslash(DirectoryName) + FindRec.Name; - if ((FindRec.Attributes and CCOD_FILE_ATTRIBUTE_DIRECTORY) <> 0) then - begin - if not PinCcodExistingSetupTree(ChildPath) then - begin - Result := False; - Exit; - end; - end - else if not PinCcodExistingSetupLeaf(ChildPath) then - begin - Result := False; - Exit; - end; - end; - until not FindNext(FindRec); - finally - FindClose(FindRec); - end; - end; -end; - -function PinCcodExpectedSetupDirectories(const AppDirectory: String): Boolean; -var - Directories: TStringList; - Index: Integer; - Candidate: String; -begin - Result := False; - Directories := TStringList.Create; - try - AddCcodExpectedSetupDirectories(Directories); - if Directories.Count = 0 then Exit; - for Index := 0 to Directories.Count - 1 do - begin - Candidate := AddBackslash(AppDirectory) + Directories[Index]; - if not PinCcodExistingSetupDirectory(Candidate) then Exit; - end; - Result := True; - finally - Directories.Free; - end; -end; -// CCOD_SETUP_PIN_END - -function AreCcodExpectedSetupDirectoriesSafe(const AppDirectory: String): Boolean; -var - Directories: TStringList; - Index: Integer; - Candidate: String; -begin - Result := False; - Directories := TStringList.Create; - try - AddCcodExpectedSetupDirectories(Directories); - if Directories.Count = 0 then Exit; - for Index := 0 to Directories.Count - 1 do - begin - Candidate := AddBackslash(AppDirectory) + Directories[Index]; - if not IsSafeExistingPayloadDirectory(Candidate) then Exit; - end; - Result := True; - finally - Directories.Free; - end; -end; - -function PrepareToInstall(var NeedsRestart: Boolean): String; -var - AppDirectory: String; -begin - Result := ''; - CloseCcodSetupPins(); - AppDirectory := ExpandConstant('{app}'); - if not IsSafeExistingSetupTree(AppDirectory) or - not AreCcodExpectedSetupDirectoriesSafe(AppDirectory) then - Result := 'CodexRemote-fix refused an unsafe existing payload directory.' - else if not PinCcodExistingSetupTree(AppDirectory) or - not PinCcodExpectedSetupDirectories(AppDirectory) then - Result := 'CodexRemote-fix could not retain the safe Setup write boundary.'; - if Result <> '' then CloseCcodSetupPins(); -end; - function NewActivationId(): String; 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; + Value: String; + Length: 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); + 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; -procedure RefuseStaleActivationReceipt(const ReceiptPath: String); -var - Attributes: Cardinal; -begin - Attributes := GetFileAttributesW(ReceiptPath); - if Attributes <> CCOD_INVALID_FILE_ATTRIBUTES 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.'); - end; - if GetFileAttributesW(ReceiptPath) <> CCOD_INVALID_FILE_ATTRIBUTES then - RaiseException('CodexRemote-fix refused a stale activation receipt.'); -end; - -function LoadBoundedActivationReceipt(const ReceiptPath: String; var Content: AnsiString): Boolean; -var - ReceiptSize: Int64; -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; +function GetCcodBootstrapParameters(const ActivationId: String; + ValidateOnly: Boolean): String; begin - Result := (Character = ' ') or (Character = #9) or - (Character = #10) or (Character = #13); -end; - -function HasJsonStringValue(const Content, FieldName, FieldValue: String): Boolean; -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; -end; - -procedure UpdateActivationPresentation(const Phase: TActivationPhase); -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; - 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); + 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 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; - CloseCcodSetupPins(); + if CurStep <> ssPostInstall then Exit; + CloseCcodInputHandles(); + if not ExtractAndLockCcodInputs() 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}') + - '" -PayloadRoot "' + ExpandConstant('{app}\payload\{#ProjectVersion}') + - '" -ExpectedVersion "{#ProjectVersion}' + - '" -ExpectedPayloadManifestSha256 "{#InstallerPayloadManifestSha256}' + - '" -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}') + - '" -PayloadRoot "' + ExpandConstant('{app}\payload\{#ProjectVersion}') + - '" -ExpectedVersion "{#ProjectVersion}' + - '" -ExpectedPayloadManifestSha256 "{#InstallerPayloadManifestSha256}' + - '" -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; + 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 DeinitializeSetup(); begin - CloseCcodSetupPins(); -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; -end; - -procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); -var - ResultCode: Integer; - Parameters, BootstrapPath: String; -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..55fd5ae --- /dev/null +++ b/build/InstallerPackage.psm1 @@ -0,0 +1,236 @@ +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 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 + $manifest.schemaVersion-isnot[int]-or$manifest.schemaVersion-ne 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 + $manifest.schemaVersion-isnot[int]-or$manifest.schemaVersion-ne 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 index a5eda58..a2cdcd6 100644 --- a/build/SetupArtifact.psm1 +++ b/build/SetupArtifact.psm1 @@ -55,7 +55,10 @@ function Test-CcodSetupArtifact { [Parameter(Mandatory)][string]$SetupPath, [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 + [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' @@ -69,12 +72,23 @@ function Test-CcodSetupArtifact { $fileDescription = ([string]$versionInfo.FileDescription).Trim() $companyName = ([string]$versionInfo.CompanyName).Trim() $legalCopyright = ([string]$versionInfo.LegalCopyright).Trim() - if ($fileVersion -cne $expectedPeVersion -or $productVersion -cne $expectedPeVersion) { + $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 } - if ($productName -cne 'CodexRemote-fix' -or $fileDescription -cne $expectedDescription -or - $companyName -cne $ExpectedGitCommit -or $legalCopyright -cne $ExpectedPayloadManifestSha256) { - Throw-CcodSetupArtifactError 'CCOD_SETUP_PAYLOAD_BINDING_INVALID' ("Setup PE does not bind the expected version, commit, and activation payload manifest hash: product={0}; description={1}; company={2}; copyright={3}" -f $productName,$fileDescription,$companyName,$legalCopyright) $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 @@ -85,6 +99,9 @@ function Test-CcodSetupArtifact { FileDescription = $fileDescription CompanyName = $companyName LegalCopyright = $legalCopyright + OriginalFilename = $originalFilename + PackageManifestSha256 = if ($sealedContract) { $productVersion + $fileDescription } else { $null } + ActivationBootstrapSha256 = if ($sealedContract) { $originalFilename + $productName } else { $null } } } @@ -197,4 +214,55 @@ function Test-CcodSetupBuildProvenance { return $record } -Export-ModuleMember -Function Get-CcodSetupArtifactHash,Get-CcodSetupDescription,New-CcodSetupBuildProvenance,Test-CcodSetupBuildProvenance,Test-CcodSetupArtifact +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=[IO.File]::ReadAllText($manifest,[Text.UTF8Encoding]::new($false))|ConvertFrom-Json -ErrorAction Stop}catch{Throw-CcodSetupArtifactError 'CCOD_SETUP_PROVENANCE_INVALID' 'Installer package manifest JSON is invalid' $manifest} + if($manifestRecord.schemaVersion-isnot[int]-or$manifestRecord.schemaVersion-ne 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{$record=[IO.File]::ReadAllText($path,[Text.UTF8Encoding]::new($false))|ConvertFrom-Json -ErrorAction Stop}catch{Throw-CcodSetupArtifactError 'CCOD_SETUP_PROVENANCE_INVALID' 'Sealed Setup provenance JSON is invalid' $path} + $fields='schemaVersion,product,version,gitCommit,buildTimestampUtc,installerPackage,installerPackageManifest,activationBootstrap,buildInputs,peContract' + $actualCompilerVersion=([string][Diagnostics.FileVersionInfo]::GetVersionInfo($compiler).FileVersion).Trim() + if((@($record.PSObject.Properties.Name)-join',')-cne$fields-or$record.schemaVersion-ne 2-or$record.product-cne'CodexRemote-fix'-or$record.version-cne$ExpectedVersion-or$record.gitCommit-cne$ExpectedGitCommit-or$record.buildTimestampUtc-cne$ExpectedBuildTimestampUtc-or-not(Test-CcodSetupCanonicalUtc ([string]$record.buildTimestampUtc))-or + $record.installerPackage.sha256-cne$ExpectedPackageSha256-or$record.installerPackage.sha256-cne(Get-CcodSetupArtifactHash $package)-or[long]$record.installerPackage.length-ne(Get-Item $package -Force).Length-or + $record.installerPackageManifest.sha256-cne$ExpectedPackageManifestSha256-or$record.installerPackageManifest.sha256-cne(Get-CcodSetupArtifactHash $manifest)-or + $record.activationBootstrap.sha256-cne$ExpectedActivationBootstrapSha256-or$record.activationBootstrap.sha256-cne(Get-CcodSetupArtifactHash $bootstrap)-or + $record.buildInputs.innoTemplateSha256-cne(Get-CcodSetupArtifactHash $template)-or$record.buildInputs.destinationInventorySha256-cne(Get-CcodSetupArtifactHash $inventory)-or$record.buildInputs.compilerSha256-cne(Get-CcodSetupArtifactHash $compiler)-or([string]$record.buildInputs.compilerFileVersion).Trim()-cne$actualCompilerVersion-or + $record.peContract.fileVersion-cne"$ExpectedVersion.0"-or$record.peContract.packageManifestFirst-cne$ExpectedPackageManifestSha256.Substring(0,32)-or$record.peContract.packageManifestLast-cne$ExpectedPackageManifestSha256.Substring(32,32)-or$record.peContract.bootstrapFirst-cne$ExpectedActivationBootstrapSha256.Substring(0,32)-or$record.peContract.bootstrapLast-cne$ExpectedActivationBootstrapSha256.Substring(32,32)-or$record.peContract.companyName-cne$ExpectedGitCommit-or$record.peContract.legalCopyright-cne$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/build.ps1 b/build/build.ps1 index cea0e03..b6f1359 100644 --- a/build/build.ps1 +++ b/build/build.ps1 @@ -130,6 +130,24 @@ function Assert-CcodBuildInnoPreprocessorLines { '#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', @@ -142,6 +160,16 @@ function Assert-CcodBuildInnoPreprocessorLines { '{#PortableArtifactDirectory}', '{#InstallerPayloadDirectory}', '{#InstallerPayloadManifestSha256}', + '{#InstallerPackagePath}', + '{#InstallerPackageManifestPath}', + '{#InstallerPackageSha256}', + '{#InstallerPackageManifestSha256}', + '{#InstallerPackageManifestSha256First}', + '{#InstallerPackageManifestSha256Last}', + '{#ActivationBootstrapPath}', + '{#ActivationBootstrapSha256}', + '{#ActivationBootstrapSha256First}', + '{#ActivationBootstrapSha256Last}', '{#SetupGitCommit}', '{#SetupProvenancePath}' ) @@ -409,6 +437,13 @@ try { } } $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 @@ -483,22 +518,24 @@ $iscc = $isccCandidates | Where-Object { -not [string]::IsNullOrWhiteSpace($_) - 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' Import-Module (Join-Path $PSScriptRoot 'SetupArtifact.psm1') -Force -[IO.File]::Copy((Assert-CcodBuildRegularFile -Path $installerPayloadManifestPath -Kind 'Installer payload manifest'),$setupPayloadInput,$false) +[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-CcodSetupBuildProvenance -Version $Version -GitCommit $gitCommit -BuildTimestampUtc $buildTimestampUtc -PayloadManifestPath $installerPayloadManifestPath -InnoTemplatePath $issPath -DestinationInventoryPath $installerDestinationInventoryPath -CompilerPath $iscc -OutputPath $setupProvenance -Test-CcodSetupBuildProvenance -ProvenancePath $setupProvenance -ExpectedVersion $Version -ExpectedGitCommit $gitCommit -ExpectedPayloadManifestSha256 $installerPayloadManifestSha256 -ExpectedBuildTimestampUtc $buildTimestampUtc -InnoTemplatePath $issPath -DestinationInventoryPath $installerDestinationInventoryPath -CompilerPath $iscc -PayloadManifestPath $installerPayloadManifestPath | Out-Null +$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", - "/DTrayHostArtifactDirectory=$trayHostArtifact", - "/DPortableArtifactDirectory=$portableArtifact", - "/DInstallerPayloadDirectory=$installerPayloadDirectory", - "/DInstallerPayloadManifestSha256=$installerPayloadManifestSha256", + "/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 -ExpectedPayloadManifestSha256 $installerPayloadManifestSha256 +$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)) $setupRecord = [ordered]@{ diff --git a/tests/persistence/InstallLifecycle.SelfTest.ps1 b/tests/persistence/InstallLifecycle.SelfTest.ps1 index 1fa38ac..ed948c6 100644 --- a/tests/persistence/InstallLifecycle.SelfTest.ps1 +++ b/tests/persistence/InstallLifecycle.SelfTest.ps1 @@ -649,6 +649,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' @@ -753,17 +754,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)ExtractAndLockCcodInputs.*GetCcodBootstrapParameters.*ewWaitUntilTerminated, ActivationResultCode.*GetCcodBootstrapParameters\(ActivationId, True\).*ewWaitUntilTerminated, ValidationResultCode') 'Setup retains one 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 "@ @@ -773,7 +784,7 @@ 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' @@ -785,16 +796,16 @@ exit 0 $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')) | Out-Null + [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\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)) 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((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 "@ @@ -804,7 +815,7 @@ exit 0 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 + $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' @@ -818,14 +829,14 @@ exit 0 $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')) | Out-Null + [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\post-install-activation.json'),(`$receipt|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new(`$false)) +[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)) @@ -2466,10 +2477,7 @@ $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' @@ -2717,18 +2725,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' { @@ -2739,22 +2740,11 @@ $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' @@ -2777,19 +2767,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)ExtractAndLockCcodInputs\(\).*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' @@ -2799,15 +2785,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. @@ -2847,8 +2832,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" @@ -2872,6 +2857,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 @@ -2879,6 +2866,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' @@ -2888,8 +2876,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' @@ -2907,25 +2895,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) @@ -2966,11 +2957,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) @@ -2985,11 +2976,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) @@ -3008,14 +2999,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)) @@ -3098,21 +3089,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' { @@ -3161,6 +3142,7 @@ $results += Invoke-CcodTest 'README and release workflow publish current portabl } } +<# 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";' }) @@ -3201,5 +3183,12 @@ $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' +} Write-Output "Install lifecycle self-tests passed: $($results.Count)" diff --git a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 index 332fde2..426b334 100644 --- a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 +++ b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 @@ -295,7 +295,78 @@ Invoke-CcodTest 'production installer payload generator writes ordered version-b } } -Invoke-CcodTest 'production setup template has one inventory marker, no external includes, and derives every nested directory' { +# 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-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 ($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 + 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' @@ -310,9 +381,8 @@ Invoke-CcodTest 'production setup template has one inventory marker, no external 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)) - foreach ($relative in @('src','src\persistence','src\persistence\modules','payload','payload\2.5.22','payload\2.5.22\src','payload\2.5.22\src\persistence','payload\2.5.22\src\persistence\modules')) { - Assert-CcodTrue ($inventory.Contains("Directories.Add('$relative');")) "generated setup inventory contains $relative" - } + 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 } } @@ -1076,8 +1146,11 @@ param( [string]$ActivationId, [string]$ExpectedVersion, [string]$PayloadManifestPath, - [string]$ExpectedPayloadManifestSha256 + [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) @@ -1087,11 +1160,11 @@ exit 0 $lifecycleSource = @' function Invoke-CcodFixtureInstall { param([string]$InstallRoot,[string]$ActivationId,[string]$ManifestVersion) - [IO.Directory]::CreateDirectory((Join-Path $InstallRoot 'state')) | Out-Null + [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\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)) } Export-ModuleMember -Function Invoke-CcodFixtureInstall '@ @@ -1168,6 +1241,28 @@ function Invoke-CcodActivationVerifierFixture { 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' + $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 $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 } + } +} + function Invoke-CcodInnoPayloadCompileFixture { param([switch]$IncludePayloadDefines,[switch]$OmitDestinationInventory) @@ -1201,11 +1296,17 @@ function Invoke-CcodInnoPayloadCompileFixture { 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 + $activationBootstrapPath = 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)) - $arguments = @('/DProjectVersion=2.5.21',"/DTrayHostArtifactDirectory=$tray","/DPortableArtifactDirectory=$portable","/DSetupGitCommit=$setupGitCommit","/DSetupProvenancePath=$setupProvenancePath","/O$output\") + $arguments = @('/DProjectVersion=2.5.21',"/DSetupGitCommit=$setupGitCommit","/DSetupProvenancePath=$setupProvenancePath","/O$output\") if ($IncludePayloadDefines) { - $arguments = @('/DProjectVersion=2.5.21',"/DTrayHostArtifactDirectory=$tray","/DPortableArtifactDirectory=$portable","/DInstallerPayloadDirectory=$payload","/DInstallerPayloadManifestSha256=$payloadManifestSha256","/DSetupGitCommit=$setupGitCommit","/DSetupProvenancePath=$setupProvenancePath") + $arguments = @('/DProjectVersion=2.5.21',"/DInstallerPackagePath=$installerPackagePath","/DInstallerPackageManifestPath=$installerPackageManifestPath","/DInstallerPackageSha256=$($installerPackage.PackageSha256)","/DInstallerPackageManifestSha256=$($installerPackage.ManifestSha256)","/DActivationBootstrapPath=$activationBootstrapPath","/DActivationBootstrapSha256=$activationBootstrapSha256","/DSetupGitCommit=$setupGitCommit","/DSetupProvenancePath=$setupProvenancePath") $arguments += "/O$output\" } . (Join-Path $repositoryRoot 'build\build.ps1') -Library @@ -1237,6 +1338,9 @@ function Invoke-CcodInnoPayloadCompileFixture { GeneratedPath = $generatedPath GeneratedSource = $generatedSource PayloadManifestSha256 = $payloadManifestSha256 + PackageSha256 = $installerPackage.PackageSha256 + PackageManifestSha256 = $installerPackage.ManifestSha256 + ActivationBootstrapSha256 = $activationBootstrapSha256 SetupGitCommit = $setupGitCommit } } @@ -1246,12 +1350,14 @@ Invoke-CcodTest 'setup build and activation bind one immutable versioned payload $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 - - Assert-CcodTrue ($build -cmatch 'New-InstallerPayloadManifest\.ps1' -and $build -cmatch 'New-InstallerDestinationInventory\.ps1' -and $build -cmatch 'InstallerPayloadManifestSha256' -and $build -cmatch 'New-CcodBuildGeneratedInnoScript') 'build invokes the tested generators and binds the inventory into generated setup compilation' - Assert-CcodTrue ($inno -cmatch 'InstallerPayloadDirectory' -and $inno -cmatch 'DestDir:\s*"\{app\}\\payload\\\{#ProjectVersion\}"') 'Inno copies the immutable build payload into its exact version directory' - Assert-CcodTrue ($inno -cmatch "ExpandConstant\('\{app\}\\payload\\\{#ProjectVersion\}'\)" -and $inno -cmatch '-ExpectedVersion\s+"\{#ProjectVersion\}') 'Inno binds activation to its compiled payload version' - Assert-CcodTrue ($activation -cmatch '\[string\]\$ExpectedVersion' -and $activation -cmatch '\[string\]\$ExpectedPayloadManifestSha256' -and $activation -cmatch 'installer-payload\.manifest\.json') 'activation accepts and resolves the expected payload contract' - Assert-CcodTrue ($installer -cmatch '\[string\]\$ExpectedVersion' -and $installer -cmatch '\[string\]\$PayloadManifestPath' -and $installer -cmatch '\[string\]\$ExpectedPayloadManifestSha256' -and $installer -cmatch 'Open-CcodInstallerPayloadSeal' -and $installer -cmatch 'PayloadManifestBytesBase64') 'installer revalidates and forwards the exact immutable payload bytes to lifecycle activation' + $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. @@ -1305,11 +1411,11 @@ exit 0 $replacementModule = @' function Invoke-CcodFixtureInstall { param([string]$InstallRoot,[string]$ActivationId,[string]$ManifestVersion) - [IO.Directory]::CreateDirectory((Join-Path $InstallRoot 'state')) | Out-Null + [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\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)) } Export-ModuleMember -Function Invoke-CcodFixtureInstall '@ @@ -1505,8 +1611,8 @@ Invoke-CcodTest 'activation rejects an unlisted nested payload junction' { 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 installer payload directory and manifest hash' - Assert-CcodTrue ($compile.Output -cmatch 'InstallerPayloadDirectory') 'compiler identifies the missing immutable payload define' + 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 } @@ -1518,28 +1624,28 @@ Invoke-CcodTest 'Inno compile packages the explicit manifest-bound installer pay 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 -cmatch "Directories\.Add\('payload\\2\.5\.21'") 'compiler input contains the generated destination inventory procedure' + 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-payload\.manifest\.json' -and $compile.Output -cmatch 'payload\\package\.json') 'compiler input trace contains the payload manifest and manifest-listed file' + 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 versions commit and activation payload manifest hash' { +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 -ExpectedPayloadManifestSha256 $compile.PayloadManifestSha256 + $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 '2.5.21.0' ([string]$validated.ProductVersion) 'Setup PE ProductVersion 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 -ExpectedPayloadManifestSha256 ('0' * 64) | Out-Null + 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 } @@ -1620,6 +1726,7 @@ Invoke-CcodTest 'Inno compile refuses a missing generated destination inventory' } } +<# 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;') @@ -1845,6 +1952,36 @@ end; } } +#> + +# 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' { . $defenderPath -Library $fixture = New-CcodReleaseFixture @@ -2179,7 +2316,10 @@ Invoke-CcodTest 'package scripts build provenance and workflows retain the relea 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' } -$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'; +$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 ($iss -cnotmatch 'CodexRemote\.Portable\.exe|PortableArtifactDirectory') 'Inno never copies a product launcher outside the sealed package transaction' 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 { diff --git a/tools/New-InstallerDestinationInventory.ps1 b/tools/New-InstallerDestinationInventory.ps1 index 7cb2706..bb54f98 100644 --- a/tools/New-InstallerDestinationInventory.ps1 +++ b/tools/New-InstallerDestinationInventory.ps1 @@ -43,6 +43,24 @@ function Assert-CcodInstallerInnoPreprocessorLines { '#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', @@ -55,6 +73,16 @@ function Assert-CcodInstallerInnoPreprocessorLines { '{#PortableArtifactDirectory}', '{#InstallerPayloadDirectory}', '{#InstallerPayloadManifestSha256}', + '{#InstallerPackagePath}', + '{#InstallerPackageManifestPath}', + '{#InstallerPackageSha256}', + '{#InstallerPackageManifestSha256}', + '{#InstallerPackageManifestSha256First}', + '{#InstallerPackageManifestSha256Last}', + '{#ActivationBootstrapPath}', + '{#ActivationBootstrapSha256}', + '{#ActivationBootstrapSha256First}', + '{#ActivationBootstrapSha256Last}', '{#SetupGitCommit}', '{#SetupProvenancePath}' ) @@ -91,6 +119,8 @@ foreach ($line in $lines) { if ($line -match '^\s*\[Files\]\s*$') { $insideFiles = $true; continue } if ($insideFiles -and $line -match '^\[') { break } if (-not $insideFiles -or [string]::IsNullOrWhiteSpace($line)) { continue } + $temporaryInput = [regex]::Match($line,'^Source:\s*"(?[^"]+)";\s*DestName:\s*"(?[A-Za-z0-9._-]+)";\s*Flags:\s*dontcopy\s*$') + if ($temporaryInput.Success) { continue } $match = [regex]::Match($line,'^Source:\s*"(?[^"]+)";\s*DestDir:\s*"(?[^"]+)";(?.*)$') if (-not $match.Success) { throw "Unsupported [Files] entry for destination inventory: $line" } $destination = $match.Groups['destination'].Value.Replace('{#ProjectVersion}',$ProjectVersion) @@ -123,7 +153,7 @@ foreach ($line in $lines) { } } } -if (-not $insideFiles -or $directories.Count -eq 0) { throw 'No setup destination directories were generated.' } +if (-not $insideFiles) { throw 'No setup Files section was inspected.' } $ordered = [Collections.Generic.List[string]]::new() foreach ($directory in $directories) { $ordered.Add($directory) } $ordered.Sort([StringComparer]::Ordinal) From 51def5c9ae2f5e0722b9083e478b17c507e9cee8 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:50:58 +0800 Subject: [PATCH 087/187] fix: harden sealed setup evidence --- Install-CodexRemote-fix.ps1 | 27 +++- build/CodexControlOtherDevices.iss | 17 ++- build/SetupArtifact.psm1 | 94 ++++++++++-- .../persistence/InstallLifecycle.SelfTest.ps1 | 4 +- .../persistence/ReleaseWorkflow.SelfTest.ps1 | 141 +++++++++++++++++- 5 files changed, 265 insertions(+), 18 deletions(-) diff --git a/Install-CodexRemote-fix.ps1 b/Install-CodexRemote-fix.ps1 index 816a4e7..610bcc6 100644 --- a/Install-CodexRemote-fix.ps1 +++ b/Install-CodexRemote-fix.ps1 @@ -122,6 +122,26 @@ 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]$VerifiedSealedPackageSha256, + [Parameter(Mandatory)][string]$ExpectedSealedPackageSha256, + [switch]$EnableCandidateCompatibleUpdates, + [switch]$DoNotStart + ) + if ($VerifiedSealedPackageSha256 -cnotmatch '^[0-9a-f]{64}$' -or + $ExpectedSealedPackageSha256 -cnotmatch '^[0-9a-f]{64}$' -or + $VerifiedSealedPackageSha256 -cne $ExpectedSealedPackageSha256) { + Throw-CcodPortableInstallerError 'CCOD_PORTABLE_PACKAGE_IDENTITY_INVALID' 'The revalidated portable payload identity changed before lifecycle activation.' $VerifiedSealedPackageSha256 + } + return & $InstallerPath -InstallRoot $InstallRoot ` + -EnableCandidateCompatibleUpdates:([bool]$EnableCandidateCompatibleUpdates) ` + -DoNotStart:([bool]$DoNotStart) ` + -SealedPackageSha256 $VerifiedSealedPackageSha256 +} + $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 +157,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 +171,10 @@ 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 ` + -VerifiedSealedPackageSha256 ([string]$revalidatedPayload.PayloadManifestSha256) ` + -ExpectedSealedPackageSha256 ([string]$payload.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/build/CodexControlOtherDevices.iss b/build/CodexControlOtherDevices.iss index b1bd096..7ab3d00 100644 --- a/build/CodexControlOtherDevices.iss +++ b/build/CodexControlOtherDevices.iss @@ -179,14 +179,27 @@ begin if ValidateOnly then Result := Result + ' -ValidateReceiptOnly'; end; +function PrepareToInstall(var NeedsRestart: Boolean): String; +begin + Result := ''; + CloseCcodInputHandles(); + try + if not ExtractAndLockCcodInputs() then + Result := 'CCOD_SETUP_INPUT_BINDING_INVALID'; + except + Result := 'CCOD_SETUP_INPUT_BINDING_INVALID'; + end; + if Result <> '' then CloseCcodInputHandles(); +end; + procedure CurStepChanged(CurStep: TSetupStep); var ActivationId, Parameters: String; ActivationResultCode, ValidationResultCode: Integer; begin if CurStep <> ssPostInstall then Exit; - CloseCcodInputHandles(); - if not ExtractAndLockCcodInputs() then RaiseException('CCOD_SETUP_INPUT_BINDING_INVALID'); + if GetArrayLength(CcodInputHandles) <> 3 then + RaiseException('CCOD_SETUP_INPUT_BINDING_INVALID'); ActivationId := NewActivationId(); Parameters := GetCcodBootstrapParameters(ActivationId, False); if (not Exec(ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe'), Parameters, diff --git a/build/SetupArtifact.psm1 b/build/SetupArtifact.psm1 index a2cdcd6..8f097fc 100644 --- a/build/SetupArtifact.psm1 +++ b/build/SetupArtifact.psm1 @@ -252,16 +252,92 @@ function Test-CcodSealedSetupBuildProvenance { [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{$record=[IO.File]::ReadAllText($path,[Text.UTF8Encoding]::new($false))|ConvertFrom-Json -ErrorAction Stop}catch{Throw-CcodSetupArtifactError 'CCOD_SETUP_PROVENANCE_INVALID' 'Sealed Setup provenance JSON is invalid' $path} - $fields='schemaVersion,product,version,gitCommit,buildTimestampUtc,installerPackage,installerPackageManifest,activationBootstrap,buildInputs,peContract' + $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)) + $record=$raw|ConvertFrom-Json -ErrorAction Stop + $manifestRaw=[IO.File]::ReadAllText($manifest,[Text.UTF8Encoding]::new($false)) + $packageManifest=$manifestRaw|ConvertFrom-Json -ErrorAction Stop + } 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() - if((@($record.PSObject.Properties.Name)-join',')-cne$fields-or$record.schemaVersion-ne 2-or$record.product-cne'CodexRemote-fix'-or$record.version-cne$ExpectedVersion-or$record.gitCommit-cne$ExpectedGitCommit-or$record.buildTimestampUtc-cne$ExpectedBuildTimestampUtc-or-not(Test-CcodSetupCanonicalUtc ([string]$record.buildTimestampUtc))-or - $record.installerPackage.sha256-cne$ExpectedPackageSha256-or$record.installerPackage.sha256-cne(Get-CcodSetupArtifactHash $package)-or[long]$record.installerPackage.length-ne(Get-Item $package -Force).Length-or - $record.installerPackageManifest.sha256-cne$ExpectedPackageManifestSha256-or$record.installerPackageManifest.sha256-cne(Get-CcodSetupArtifactHash $manifest)-or - $record.activationBootstrap.sha256-cne$ExpectedActivationBootstrapSha256-or$record.activationBootstrap.sha256-cne(Get-CcodSetupArtifactHash $bootstrap)-or - $record.buildInputs.innoTemplateSha256-cne(Get-CcodSetupArtifactHash $template)-or$record.buildInputs.destinationInventorySha256-cne(Get-CcodSetupArtifactHash $inventory)-or$record.buildInputs.compilerSha256-cne(Get-CcodSetupArtifactHash $compiler)-or([string]$record.buildInputs.compilerFileVersion).Trim()-cne$actualCompilerVersion-or - $record.peContract.fileVersion-cne"$ExpectedVersion.0"-or$record.peContract.packageManifestFirst-cne$ExpectedPackageManifestSha256.Substring(0,32)-or$record.peContract.packageManifestLast-cne$ExpectedPackageManifestSha256.Substring(32,32)-or$record.peContract.bootstrapFirst-cne$ExpectedActivationBootstrapSha256.Substring(0,32)-or$record.peContract.bootstrapLast-cne$ExpectedActivationBootstrapSha256.Substring(32,32)-or$record.peContract.companyName-cne$ExpectedGitCommit-or$record.peContract.legalCopyright-cne$ExpectedPackageSha256){Throw-CcodSetupArtifactError 'CCOD_SETUP_PROVENANCE_INVALID' 'Sealed Setup provenance is not exactly bound' $path} + $packageLength=[long](Get-Item $package -Force).Length + $manifestLength=[long](Get-Item $manifest -Force).Length + $bootstrapLength=[long](Get-Item $bootstrap -Force).Length + if($record.schemaVersion-isnot[int]-or$record.schemaVersion-ne 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 + $packageManifest.schemaVersion-isnot[int]-or$packageManifest.schemaVersion-ne 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 + $record.installerPackageManifest.fileCount-isnot[int]-or$record.installerPackageManifest.fileCount-ne@($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 } diff --git a/tests/persistence/InstallLifecycle.SelfTest.ps1 b/tests/persistence/InstallLifecycle.SelfTest.ps1 index ed948c6..a93c2fb 100644 --- a/tests/persistence/InstallLifecycle.SelfTest.ps1 +++ b/tests/persistence/InstallLifecycle.SelfTest.ps1 @@ -759,7 +759,7 @@ $results += Invoke-CcodTest 'Inno visibly starts and synchronously owns the boun $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)ExtractAndLockCcodInputs.*GetCcodBootstrapParameters.*ewWaitUntilTerminated, ActivationResultCode.*GetCcodBootstrapParameters\(ActivationId, True\).*ewWaitUntilTerminated, ValidationResultCode') 'Setup retains one locked bootstrap across activation and strict validation' + 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' } @@ -2770,7 +2770,7 @@ $results += Invoke-CcodTest 'installer accepts success only from the exited owne Assert-CcodTrue (Test-Path -LiteralPath $activationScript -PathType Leaf) 'post-install activation worker exists' Assert-CcodTrue ($installerScript -cmatch '(?m)^CloseApplications=no\r?$') 'installer never lets Restart Manager close Codex' 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)ExtractAndLockCcodInputs\(\).*GetCcodBootstrapParameters\(ActivationId, False\).*ewWaitUntilTerminated, ActivationResultCode') 'installer locks inputs before waiting for the exact owner to exit' + 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' diff --git a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 index 426b334..22b5bb1 100644 --- a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 +++ b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 @@ -1264,7 +1264,7 @@ Invoke-CcodTest 'temporary bootstrap verifies the sealed package and propagates } function Invoke-CcodInnoPayloadCompileFixture { - param([switch]$IncludePayloadDefines,[switch]$OmitDestinationInventory) + 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' @@ -1300,13 +1300,20 @@ function Invoke-CcodInnoPayloadCompileFixture { $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 - $activationBootstrapPath = Join-Path $repositoryRoot 'Activate-CcodRemoteFix.ps1' + $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) { - $arguments = @('/DProjectVersion=2.5.21',"/DInstallerPackagePath=$installerPackagePath","/DInstallerPackageManifestPath=$installerPackageManifestPath","/DInstallerPackageSha256=$($installerPackage.PackageSha256)","/DInstallerPackageManifestSha256=$($installerPackage.ManifestSha256)","/DActivationBootstrapPath=$activationBootstrapPath","/DActivationBootstrapSha256=$activationBootstrapSha256","/DSetupGitCommit=$setupGitCommit","/DSetupProvenancePath=$setupProvenancePath") + $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 @@ -1342,6 +1349,14 @@ function Invoke-CcodInnoPayloadCompileFixture { PackageManifestSha256 = $installerPackage.ManifestSha256 ActivationBootstrapSha256 = $activationBootstrapSha256 SetupGitCommit = $setupGitCommit + PackagePath = $installerPackagePath + PackageManifestPath = $installerPackageManifestPath + ActivationBootstrapPath = $activationBootstrapPath + InventoryPath = $inventoryPath + TemplatePath = $templatePath + CompilerPath = $iscc + ActivationMarker = $activationMarker + BoundPackageSha256 = $boundPackageHash } } @@ -1652,6 +1667,36 @@ Invoke-CcodTest 'compiled Setup independently binds PE version commit package ma } } +# 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' + $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' + 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' + $realProductRoot=Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)) 'CodexControlOtherDevices' + Assert-CcodTrue ($log.IndexOf($realProductRoot,[StringComparison]::OrdinalIgnoreCase)-lt0) 'failure log never reaches the real LocalAppData product root' + } 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 @@ -1692,6 +1737,59 @@ Invoke-CcodTest 'Setup provenance rejects self-consistent wrong canonical build } } +# 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)" + } + } 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 @@ -2504,4 +2602,41 @@ Invoke-CcodTest 'portable release manifest binds the ZIP payload manifest and ea } } +# 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 -VerifiedSealedPackageSha256 $Identity -ExpectedSealedPackageSha256 $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,$Verified,$Expected)Invoke-CcodPortableLifecycleInstaller -InstallerPath $Path -InstallRoot $Root -VerifiedSealedPackageSha256 $Verified -ExpectedSealedPackageSha256 $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} + } +} + Write-Host 'Release workflow self-tests passed.' From 728b62710c0729b40908e92887d3fe70903edbba Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:52:31 +0800 Subject: [PATCH 088/187] docs: record sealed setup evidence --- .../task-3-report.md | 69 ++++++++++++++++++- 1 file changed, 67 insertions(+), 2 deletions(-) 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 index b2e43c6..affe544 100644 --- 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 @@ -94,5 +94,70 @@ separately from production defects so they are not mistaken for regressions. ## Commit -- `fix: bootstrap sealed installer package` (the Task 3 commit containing this - report; resolve the exact object as this worktree's Task 3 HEAD). +- 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`). From 1cb91621f2565318143dffe08844a91a350a36d9 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:34:41 +0800 Subject: [PATCH 089/187] fix: bind portable sealed execution --- Install-CodexRemote-fix.ps1 | 22 +-- build/SetupArtifact.psm1 | 128 ++++++++++++++++++ src/persistence/modules/PortableRelease.psm1 | 4 +- .../persistence/ReleaseWorkflow.SelfTest.ps1 | 105 +++++++++++++- 4 files changed, 246 insertions(+), 13 deletions(-) diff --git a/Install-CodexRemote-fix.ps1 b/Install-CodexRemote-fix.ps1 index 610bcc6..b8c0eef 100644 --- a/Install-CodexRemote-fix.ps1 +++ b/Install-CodexRemote-fix.ps1 @@ -126,20 +126,23 @@ function Invoke-CcodPortableLifecycleInstaller { param( [Parameter(Mandatory)][string]$InstallerPath, [Parameter(Mandatory)][string]$InstallRoot, - [Parameter(Mandatory)][string]$VerifiedSealedPackageSha256, - [Parameter(Mandatory)][string]$ExpectedSealedPackageSha256, + [Parameter(Mandatory)][string]$CopiedSealedPackageSha256, + [Parameter(Mandatory)][string]$InitialSealedPackageSha256, + [Parameter(Mandatory)][string]$RevalidatedSourceSealedPackageSha256, [switch]$EnableCandidateCompatibleUpdates, [switch]$DoNotStart ) - if ($VerifiedSealedPackageSha256 -cnotmatch '^[0-9a-f]{64}$' -or - $ExpectedSealedPackageSha256 -cnotmatch '^[0-9a-f]{64}$' -or - $VerifiedSealedPackageSha256 -cne $ExpectedSealedPackageSha256) { - Throw-CcodPortableInstallerError 'CCOD_PORTABLE_PACKAGE_IDENTITY_INVALID' 'The revalidated portable payload identity changed before lifecycle activation.' $VerifiedSealedPackageSha256 + 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 $VerifiedSealedPackageSha256 + -SealedPackageSha256 $CopiedSealedPackageSha256 } $bundleRoot = Assert-CcodPortableInstallerPlainDirectory -Path $PSScriptRoot -Kind 'Portable bundle root' @@ -172,8 +175,9 @@ $copied = Copy-CcodPortablePayload -PayloadRoot $payloadRoot -ManifestPath $payl $installerPath = Assert-CcodPortableInstallerRegularFile -Path (Join-Path $copied.InstallerRoot 'Install-CodexControlOtherDevices.ps1') -Kind 'Installed lifecycle installer' try { $installReceipt = Invoke-CcodPortableLifecycleInstaller -InstallerPath $installerPath -InstallRoot $installRoot ` - -VerifiedSealedPackageSha256 ([string]$revalidatedPayload.PayloadManifestSha256) ` - -ExpectedSealedPackageSha256 ([string]$payload.PayloadManifestSha256) ` + -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/build/SetupArtifact.psm1 b/build/SetupArtifact.psm1 index 8f097fc..629ec6f 100644 --- a/build/SetupArtifact.psm1 +++ b/build/SetupArtifact.psm1 @@ -33,6 +33,132 @@ function Test-CcodSetupCanonicalUtc { $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" @@ -261,8 +387,10 @@ function Test-CcodSealedSetupBuildProvenance { $compiler=Assert-CcodSetupRegularFile $CompilerPath 'Inno compiler' try { $raw=[IO.File]::ReadAllText($path,[Text.UTF8Encoding]::new($false)) + Assert-CcodSetupUniqueJsonMembers -Json $raw -Target $path $record=$raw|ConvertFrom-Json -ErrorAction Stop $manifestRaw=[IO.File]::ReadAllText($manifest,[Text.UTF8Encoding]::new($false)) + Assert-CcodSetupUniqueJsonMembers -Json $manifestRaw -Target $manifest $packageManifest=$manifestRaw|ConvertFrom-Json -ErrorAction Stop } catch { Throw-CcodSetupArtifactError 'CCOD_SETUP_PROVENANCE_INVALID' 'Sealed Setup provenance or package manifest JSON is invalid' $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/tests/persistence/ReleaseWorkflow.SelfTest.ps1 b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 index 22b5bb1..5287b07 100644 --- a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 +++ b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 @@ -264,6 +264,46 @@ function New-CcodPortableReleaseFixture { return [pscustomobject]@{Root=$root;Bundle=$bundle;Checksum=$checksum;PayloadManifest=$payloadAsset;Manifest=$manifest} } +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-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 @@ -1676,6 +1716,8 @@ Invoke-CcodTest 'compiled production Setup rejects a wrong package hash before a 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")) @@ -1683,14 +1725,15 @@ Invoke-CcodTest 'compiled production Setup rejects a wrong package hash before a $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' - $realProductRoot=Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)) 'CodexControlOtherDevices' 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} @@ -1785,6 +1828,27 @@ Invoke-CcodTest 'sealed Setup provenance rejects every field type name length co 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} } @@ -2625,12 +2689,12 @@ param([string]`$InstallRoot,[switch]`$EnableCandidateCompatibleUpdates,[switch]` "@ [IO.File]::WriteAllText($child,$childSource,[Text.UTF8Encoding]::new($false)) $verified='a'*64 - $receipt=&$module {param($Path,$Root,$Identity)Invoke-CcodPortableLifecycleInstaller -InstallerPath $Path -InstallRoot $Root -VerifiedSealedPackageSha256 $Identity -ExpectedSealedPackageSha256 $Identity} $child $root $verified + $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,$Verified,$Expected)Invoke-CcodPortableLifecycleInstaller -InstallerPath $Path -InstallRoot $Root -VerifiedSealedPackageSha256 $Verified -ExpectedSealedPackageSha256 $Expected} $child $root ('b'*64) $verified | Out-Null + &$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 { @@ -2639,4 +2703,39 @@ param([string]`$InstallRoot,[switch]`$EnableCandidateCompatibleUpdates,[switch]` } } +# Production mutation caught: source bytes change after manifest validation but before the full portable copy path executes. +Invoke-CcodTest 'portable full copy path rejects a validation-to-copy source race before child or lifecycle state' { + $root=Join-Path ([IO.Path]::GetTempPath()) ('ccod-portable-copy-race-'+[guid]::NewGuid().ToString('N')) + $module=$null;$breakpoint=$null + try { + $payload=Join-Path $root 'payload';[IO.Directory]::CreateDirectory($payload)|Out-Null + $sourceFile=Join-Path $payload 'Install-CodexControlOtherDevices.ps1' + [IO.File]::WriteAllText($sourceFile,'original-child-bytes',[Text.UTF8Encoding]::new($false)) + $sha=Get-CcodTestFileSha256 -Path $sourceFile + $manifestPath=Join-Path $root 'payload-manifest.json' + $manifest=[ordered]@{schemaVersion=1;product='CodexRemote-fix';version='2.5.22';gitCommit=('a'*40);buildTimestampUtc='2030-02-03T04:05:06.0000000Z';files=@([ordered]@{path='Install-CodexControlOtherDevices.ps1';length=[int64](Get-Item $sourceFile).Length;sha256=$sha})} + [IO.File]::WriteAllText($manifestPath,(($manifest|ConvertTo-Json -Depth 8)+[Environment]::NewLine),[Text.UTF8Encoding]::new($false)) + $modulePath=Join-Path $repositoryRoot 'src\persistence\modules\PortableRelease.psm1' + $module=Import-Module $modulePath -Force -PassThru + $target=Join-Path $root 'copied-installer' + &$module {param($ExpectedRoot)Set-Item -Path Function:Get-CcodPortableReleaseExpectedInstallerRoot -Value { $ExpectedRoot }.GetNewClosure()} $target + $line=@(Select-String -Path $modulePath -Pattern '^\s*\[IO\.File\]::Copy\(\$sourceFile,\$destination,\$false\)'|ForEach-Object{$_.LineNumber}) + Assert-CcodEqual 1 $line.Count 'portable production copy barrier is unique' + $attack=[pscustomobject]@{Ran=$false} + $action={if(-not$attack.Ran){$attack.Ran=$true;[IO.File]::WriteAllText($sourceFile,'raced-child-bytes',[Text.UTF8Encoding]::new($false))}}.GetNewClosure() + $breakpoint=Set-PSBreakpoint -Script $modulePath -Line $line[0] -Action $action + $failure=$null + try{Copy-CcodPortablePayload -PayloadRoot $payload -ManifestPath $manifestPath -InstallerRoot $target|Out-Null}catch{$failure=$_} + Assert-CcodTrue $attack.Ran 'source race runs after validation at the production copy barrier' + Assert-CcodTrue ($null-ne$failure-and$failure.FullyQualifiedErrorId-like'CCOD_PORTABLE_COPY_HASH_MISMATCH*') 'raced source fails with the bounded copied-byte hash code' + Assert-CcodTrue (-not(Test-Path -LiteralPath $target)) 'raced copy publishes no installer root' + Assert-CcodTrue (-not(Test-Path -LiteralPath (Join-Path $root 'child-executed.txt'))) 'raced copy executes no lifecycle child' + Assert-CcodTrue (-not(Test-Path -LiteralPath (Join-Path $root 'lifecycle-state'))) 'raced copy creates no lifecycle state' + } finally { + if($null-ne$breakpoint){Remove-PSBreakpoint -Breakpoint $breakpoint -ErrorAction SilentlyContinue} + 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} + } +} + Write-Host 'Release workflow self-tests passed.' From a5d595892f134a5ef957feab4ec4e68228cec97b Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:35:52 +0800 Subject: [PATCH 090/187] docs: record portable sealed evidence --- .../task-3-report.md | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) 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 index affe544..de04247 100644 --- 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 @@ -161,3 +161,68 @@ failed with four Important findings. - `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`). From 58bd7603fa83133d78d2f62ac045ec00d25134c9 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:01:19 +0800 Subject: [PATCH 091/187] test: exercise portable sealed race end to end --- .../persistence/ReleaseWorkflow.SelfTest.ps1 | 52 +++++++++---------- 1 file changed, 25 insertions(+), 27 deletions(-) diff --git a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 index 5287b07..19249d5 100644 --- a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 +++ b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 @@ -2703,37 +2703,35 @@ param([string]`$InstallRoot,[switch]`$EnableCandidateCompatibleUpdates,[switch]` } } -# Production mutation caught: source bytes change after manifest validation but before the full portable copy path executes. -Invoke-CcodTest 'portable full copy path rejects a validation-to-copy source race before child or lifecycle state' { - $root=Join-Path ([IO.Path]::GetTempPath()) ('ccod-portable-copy-race-'+[guid]::NewGuid().ToString('N')) - $module=$null;$breakpoint=$null +# Production mutation caught: the real portable entrypoint ignores a source race between initial validation and copy. +Invoke-CcodTest 'actual portable entrypoint rejects a validation-to-copy race 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';[IO.Directory]::CreateDirectory($payload)|Out-Null - $sourceFile=Join-Path $payload 'Install-CodexControlOtherDevices.ps1' - [IO.File]::WriteAllText($sourceFile,'original-child-bytes',[Text.UTF8Encoding]::new($false)) - $sha=Get-CcodTestFileSha256 -Path $sourceFile - $manifestPath=Join-Path $root 'payload-manifest.json' - $manifest=[ordered]@{schemaVersion=1;product='CodexRemote-fix';version='2.5.22';gitCommit=('a'*40);buildTimestampUtc='2030-02-03T04:05:06.0000000Z';files=@([ordered]@{path='Install-CodexControlOtherDevices.ps1';length=[int64](Get-Item $sourceFile).Length;sha256=$sha})} - [IO.File]::WriteAllText($manifestPath,(($manifest|ConvertTo-Json -Depth 8)+[Environment]::NewLine),[Text.UTF8Encoding]::new($false)) - $modulePath=Join-Path $repositoryRoot 'src\persistence\modules\PortableRelease.psm1' - $module=Import-Module $modulePath -Force -PassThru - $target=Join-Path $root 'copied-installer' - &$module {param($ExpectedRoot)Set-Item -Path Function:Get-CcodPortableReleaseExpectedInstallerRoot -Value { $ExpectedRoot }.GetNewClosure()} $target - $line=@(Select-String -Path $modulePath -Pattern '^\s*\[IO\.File\]::Copy\(\$sourceFile,\$destination,\$false\)'|ForEach-Object{$_.LineNumber}) - Assert-CcodEqual 1 $line.Count 'portable production copy barrier is unique' + $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} - $action={if(-not$attack.Ran){$attack.Ran=$true;[IO.File]::WriteAllText($sourceFile,'raced-child-bytes',[Text.UTF8Encoding]::new($false))}}.GetNewClosure() - $breakpoint=Set-PSBreakpoint -Script $modulePath -Line $line[0] -Action $action + function Get-MpComputerStatus {[pscustomobject]@{AMProductVersion='fixture-platform';AntivirusSignatureVersion='fixture-signature';RealTimeProtectionEnabled=$true}} + function Get-MpThreatDetection {param($ErrorAction)@()} + function Start-MpScan {param($ScanType,$ScanPath,$ErrorAction)$attack.Ran=$true;[IO.File]::WriteAllText($child,'raced-child-source',[Text.UTF8Encoding]::new($false))} $failure=$null - try{Copy-CcodPortablePayload -PayloadRoot $payload -ManifestPath $manifestPath -InstallerRoot $target|Out-Null}catch{$failure=$_} - Assert-CcodTrue $attack.Ran 'source race runs after validation at the production copy barrier' - Assert-CcodTrue ($null-ne$failure-and$failure.FullyQualifiedErrorId-like'CCOD_PORTABLE_COPY_HASH_MISMATCH*') 'raced source fails with the bounded copied-byte hash code' - Assert-CcodTrue (-not(Test-Path -LiteralPath $target)) 'raced copy publishes no installer root' - Assert-CcodTrue (-not(Test-Path -LiteralPath (Join-Path $root 'child-executed.txt'))) 'raced copy executes no lifecycle child' - Assert-CcodTrue (-not(Test-Path -LiteralPath (Join-Path $root 'lifecycle-state'))) 'raced copy creates no lifecycle state' + 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($null-ne$breakpoint){Remove-PSBreakpoint -Breakpoint $breakpoint -ErrorAction SilentlyContinue} - 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} } } From 9e971d1fe26c0f60d8e8519318c11006798dc493 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:02:05 +0800 Subject: [PATCH 092/187] docs: record portable entrypoint race --- .../task-3-report.md | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) 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 index de04247..3ace5ce 100644 --- 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 @@ -226,3 +226,52 @@ remained FAIL with three Important findings. - `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`). From 141ab716001e7c1048db10e248101fa8cd3f54ae Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:39:06 +0800 Subject: [PATCH 093/187] test: exercise portable copy barrier race --- .../persistence/ReleaseWorkflow.SelfTest.ps1 | 62 ++++++++++++++++++- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 index 19249d5..d6f0e61 100644 --- a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 +++ b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 @@ -2703,8 +2703,8 @@ param([string]`$InstallRoot,[switch]`$EnableCandidateCompatibleUpdates,[switch]` } } -# Production mutation caught: the real portable entrypoint ignores a source race between initial validation and copy. -Invoke-CcodTest 'actual portable entrypoint rejects a validation-to-copy race before child or lifecycle state' { +# 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 @@ -2736,4 +2736,62 @@ Invoke-CcodTest 'actual portable entrypoint rejects a validation-to-copy race be } } +# 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]@{AMProductVersion='fixture-platform';AntivirusSignatureVersion='fixture-signature';RealTimeProtectionEnabled=$true}} + 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} + } +} + Write-Host 'Release workflow self-tests passed.' From 70bc23123be197b2ed34ac7fee334a91f319756d Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:39:59 +0800 Subject: [PATCH 094/187] docs: record portable copy barrier evidence --- .../task-3-report.md | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) 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 index 3ace5ce..b6f6a20 100644 --- 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 @@ -275,3 +275,67 @@ code change. - `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`). From e43d9ce18c5d79bb438e08c9ff2973be592277aa Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:52:08 +0800 Subject: [PATCH 095/187] feat: register product only after readiness --- Uninstall-CodexControlOtherDevices.ps1 | 18 ++ src/persistence/UninstallBootstrap.ps1 | 19 ++ .../modules/InstallFileTransaction.psm1 | 70 ++++- src/persistence/modules/InstallLifecycle.psm1 | 53 +++- .../modules/ProductRegistration.psm1 | 291 ++++++++++++++++++ .../InstallFileTransaction.SelfTest.ps1 | 2 +- .../persistence/InstallLifecycle.SelfTest.ps1 | 41 ++- .../ProductRegistration.SelfTest.ps1 | 179 +++++++++++ .../UninstallBootstrap.SelfTest.ps1 | 18 +- 9 files changed, 677 insertions(+), 14 deletions(-) create mode 100644 src/persistence/modules/ProductRegistration.psm1 create mode 100644 tests/persistence/ProductRegistration.SelfTest.ps1 diff --git a/Uninstall-CodexControlOtherDevices.ps1 b/Uninstall-CodexControlOtherDevices.ps1 index 13b4e43..10356c0 100644 --- a/Uninstall-CodexControlOtherDevices.ps1 +++ b/Uninstall-CodexControlOtherDevices.ps1 @@ -29,6 +29,24 @@ 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} } + try { $prepared=& $bootstrapPath -InstallerRoot $installerRoot -InstallRoot $expectedInstallRoot -Mode Prepare } + 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'ReadyForInno'-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')) + $stagedBootstrap=[IO.Path]::GetFullPath((Join-Path (Join-Path (Join-Path $transactionRoot $prepared.transactionId) 'payload') 'src\persistence\UninstallBootstrap.ps1')) + if(-not[IO.File]::Exists($stagedBootstrap)){Throw-CcodPublicUninstallError 'CCOD_UNINSTALL_INSTALLED_FINALIZER_MISSING' 'The verified external finalization bootstrap is missing.' $stagedBootstrap} + & $stagedBootstrap -InstallerRoot $installerRoot -InstallRoot $expectedInstallRoot -Mode FinalizeReceipt + Throw-CcodPublicUninstallError 'CCOD_UNINSTALL_INSTALLED_FINALIZATION_FAILED' 'The external finalization bootstrap returned without completing the uninstall receipt.' $prepared.transactionId +} $portableMarkerPath = Join-Path $installerRoot 'portable-release.json' if ([IO.File]::Exists($portableMarkerPath) -or [IO.Directory]::Exists($portableMarkerPath)) { if ($installerRoot -cne $expectedInstallerRoot) { diff --git a/src/persistence/UninstallBootstrap.ps1 b/src/persistence/UninstallBootstrap.ps1 index a368c66..23367f7 100644 --- a/src/persistence/UninstallBootstrap.ps1 +++ b/src/persistence/UninstallBootstrap.ps1 @@ -924,6 +924,10 @@ function Get-CcodUninstallBootstrapAdapters { Invoke-CcodUninstallCleanup -InstallRoot $Root -Transaction $Value -WriteTransaction $Writer } $InstallRoot $Transaction $writer) } + RemoveProductRegistration = { + param($Context) + Remove-CcodUninstallBootstrapProductRegistration -Context $Context + } TestInstallRootAbsent = { param($InstallerRoot) -not ([IO.Directory]::Exists($InstallerRoot) -or [IO.File]::Exists($InstallerRoot)) } GetUtcNow = { [DateTime]::UtcNow } } @@ -940,6 +944,20 @@ function Get-CcodUninstallBootstrapAdapters { return $resolved } +function Remove-CcodUninstallBootstrapProductRegistration { + param([Parameter(Mandatory)]$Context) + Assert-CcodUninstallBootstrapContext $Context + $registryPath='HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\CodexRemote-fix' + $programs=[Environment]::GetFolderPath([Environment+SpecialFolder]::Programs);$desktop=[Environment]::GetFolderPath([Environment+SpecialFolder]::Desktop) + if([string]::IsNullOrWhiteSpace($programs)-or[string]::IsNullOrWhiteSpace($desktop)-or-not[IO.Path]::IsPathRooted($programs)-or-not[IO.Path]::IsPathRooted($desktop)){Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_PRODUCT_REGISTRATION_INVALID' 'Current-user product special folders are unavailable' $null} + $shortcuts=@([IO.Path]::GetFullPath((Join-Path (Join-Path $programs 'CodexRemote-fix') 'CodexRemote-fix.lnk')),[IO.Path]::GetFullPath((Join-Path $desktop 'CodexRemote-fix.lnk'))) + $registryPresent=Test-Path -LiteralPath $registryPath -PathType Container + if($registryPresent){$value=Get-ItemProperty -LiteralPath $registryPath -ErrorAction Stop;if($value.CcodRuntimeId-isnot[string]-or[string]$value.CcodRuntimeId-cne[string]$Context.runtimeId){Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_PRODUCT_REGISTRATION_INVALID' 'Current product registration does not match the verified runtime' $registryPath}} + foreach($path in $shortcuts){if([IO.File]::Exists($path)-or[IO.Directory]::Exists($path)){$item=Get-Item -LiteralPath $path -Force -ErrorAction Stop;if($item.PSIsContainer-or($item.Attributes-band[IO.FileAttributes]::ReparsePoint)-ne0){Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_PRODUCT_REGISTRATION_INVALID' 'Current product shortcut is not an exact regular file' $path}}} + foreach($path in $shortcuts){if([IO.File]::Exists($path)){Remove-Item -LiteralPath $path -Force -ErrorAction Stop}} + if($registryPresent){Remove-Item -LiteralPath $registryPath -Force -ErrorAction Stop} +} + 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 } @@ -1042,6 +1060,7 @@ function Invoke-CcodUninstallBootstrap { 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 } + & $adapter.RemoveProductRegistration $context & $adapter.WriteReceipt $stageRoot $result return $result } catch { diff --git a/src/persistence/modules/InstallFileTransaction.psm1 b/src/persistence/modules/InstallFileTransaction.psm1 index 194e887..61ce8d0 100644 --- a/src/persistence/modules/InstallFileTransaction.psm1 +++ b/src/persistence/modules/InstallFileTransaction.psm1 @@ -80,6 +80,7 @@ internal sealed class CcodInstallGenerationRuntimeV4 : IDisposable 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; @@ -184,6 +185,40 @@ internal sealed class CcodInstallGenerationRuntimeV4 : IDisposable ValidateOwnedTree(generation);Pin state=OpenOrCreateDirectory(root,"state"),retirements=OpenOrCreateDirectory(state,"retired-generations");Write(retirements.Token,recordLeaf,record,false);return generation.Token; } + [MethodImpl(MethodImplOptions.Synchronized)] internal object OpenProductFolder(string kind,string path) + { + if(disposed)throw new ObjectDisposedException("transaction"); + string full=Path.GetFullPath(path).TrimEnd('\\');SafeFileHandle handle=OpenAbsoluteDirectory(full,true);Pin basePin=null; + try + { + basePin=ValidateDirectoryPin(null,"",full,false,handle);handle=null;AddPin(basePin); + Pin selected=basePin; + if(String.Equals(kind,"StartMenu",StringComparison.Ordinal))selected=OpenOrCreateDirectory(basePin,"CodexRemote-fix"); + else if(!String.Equals(kind,"Desktop",StringComparison.Ordinal))throw new InvalidDataException("unknown product folder"); + productFolders.Add(selected.Token);return selected.Token; + } + catch{if(handle!=null)handle.Dispose();throw;} + } + + [MethodImpl(MethodImplOptions.Synchronized)] internal object CopyProductShortcut(object folderToken,string kind,string source,string leaf,long expectedLength,string expectedSha) + { + Pin parent=Require(folderToken,true);ValidateCurrent(parent); + if(!productFolders.Contains(folderToken)||!String.Equals(leaf,"CodexRemote-fix.lnk",StringComparison.Ordinal)|| + (!String.Equals(kind,"StartMenu",StringComparison.Ordinal)&&!String.Equals(kind,"Desktop",StringComparison.Ordinal)))throw new InvalidDataException("product shortcut scope"); + 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"); + SealAndReplaceProduct(destination,leaf,expectedLength,expectedSha);EnsurePublishedPin(destination);return destination.Token; + } + finally{if(sourceStream!=null)sourceStream.Dispose();if(sourceHandle!=null)sourceHandle.Dispose();} + } + [MethodImpl(MethodImplOptions.Synchronized)] internal void Close() { disposed=true;Exception first=null;List 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); @@ -241,6 +276,21 @@ internal sealed class CcodInstallGenerationRuntimeV4 : IDisposable 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;} @@ -255,6 +305,7 @@ internal sealed class CcodInstallGenerationRuntimeV4 : IDisposable 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 = 'CcodInstallGenerationCapabilityMarkerV4' -as [type] @@ -319,7 +370,24 @@ function Write-CcodInstallGenerationManifest { param([Parameter(Mandatory)]$Gene 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)]$FileTransaction,[Parameter(Mandatory)][ValidateSet('StartMenu','Desktop')][string]$Kind) + $scope=Get-CcodInstallTransaction $FileTransaction 'CCOD_INSTALL_TRANSACTION_INVALID' + if(-not[object]::ReferenceEquals($scope.Record.Transaction,$FileTransaction)){Throw-CcodInstallFileError 'CCOD_INSTALL_PRODUCT_FOLDER_INVALID' 'Product special folder requires the transaction-root 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 $FileTransaction $token ProductFolder $false $null +} +function Copy-CcodInstallProductShortcut { + param([Parameter(Mandatory)]$Folder,[Parameter(Mandatory)][ValidateSet('StartMenu','Desktop')][string]$Kind,[Parameter(Mandatory)][string]$SourcePath,[Parameter(Mandatory)][string]$Leaf,[Parameter(Mandatory)][int64]$ExpectedLength,[Parameter(Mandatory)][string]$ExpectedSha256,[Parameter(Mandatory)]$FileTransaction) + if($Leaf-cne'CodexRemote-fix.lnk'-or-not[IO.Path]::IsPathRooted($SourcePath)-or$ExpectedLength-lt 0-or$ExpectedSha256-cnotmatch'^[0-9a-f]{64}$'){Throw-CcodInstallFileError 'CCOD_INSTALL_PRODUCT_SHORTCUT_INVALID' 'Invalid fixed product shortcut contract' $Leaf} + $scope=Get-CcodInstallTransaction $Folder 'CCOD_INSTALL_PRODUCT_FOLDER_INVALID';if(-not[object]::ReferenceEquals($scope.Record.Transaction,$FileTransaction)-or$scope.Record.Kind-cne'ProductFolder'){Throw-CcodInstallFileError 'CCOD_INSTALL_PRODUCT_FOLDER_INVALID' 'Product folder capability is outside the file transaction' $Kind} + Convert-CcodInstallRuntimeError {Invoke-CcodRuntimeMethod $scope.State.Runtime CopyProductShortcut @($scope.Record.Token,$Kind,[IO.Path]::GetFullPath($SourcePath),$Leaf,$ExpectedLength,$ExpectedSha256)} 'CCOD_INSTALL_PRODUCT_SHORTCUT_FAILED'|Out-Null + [pscustomobject]@{Kind=$Kind;Leaf=$Leaf;Length=$ExpectedLength;Sha256=$ExpectedSha256} +} 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};try{Invoke-CcodRuntimeMethod $state.Runtime Close @()|Out-Null;$state.CleanupFailed=$false}catch{$state.CleanupFailed=$true;$state.Closed=$true;$state.Disposition=$Disposition;Throw-CcodInstallFileError 'CCOD_INSTALL_CLOSE_FAILED' 'Install file transaction cleanup failed; registered failures are retained for retry' $null};$state.Closed=$true;$state.Disposition=$Disposition } Initialize-CcodInstallRuntime -Export-ModuleMember -Function Open-CcodInstallGeneration,Open-CcodInstallStateTransaction,Open-CcodInstallRetainedGeneration,New-CcodInstallDirectory,New-CcodInstallGenerationLeaf,Copy-CcodInstallSealedSource,Write-CcodInstallGenerationManifest,Write-CcodInstallRecord,Commit-CcodInstallActivePointer,Retire-CcodInstallGeneration,Close-CcodInstallFileTransaction +Export-ModuleMember -Function Open-CcodInstallGeneration,Open-CcodInstallStateTransaction,Open-CcodInstallRetainedGeneration,New-CcodInstallDirectory,New-CcodInstallGenerationLeaf,Copy-CcodInstallSealedSource,Write-CcodInstallGenerationManifest,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 8e809e2..9f5a670 100644 --- a/src/persistence/modules/InstallLifecycle.psm1 +++ b/src/persistence/modules/InstallLifecycle.psm1 @@ -11,6 +11,7 @@ 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') @@ -433,10 +434,10 @@ function Assert-CcodReadyFinalizationRecoveryProof { } function Complete-CcodReadyFinalizationRecovery { - param([Parameter(Mandatory)][string]$InstallRoot,[Parameter(Mandatory)]$TransactionRecord) - [void](Assert-CcodReadyFinalizationRecoveryProof -InstallRoot $InstallRoot -TransactionRecord $TransactionRecord) + param([Parameter(Mandatory)][string]$InstallRoot,[Parameter(Mandatory)]$TransactionRecord,[Parameter(Mandatory)][hashtable]$Adapters) + $proof=Assert-CcodReadyFinalizationRecoveryProof -InstallRoot $InstallRoot -TransactionRecord $TransactionRecord $fileTransaction=$null - $observed=$null + $observed=$null;$productRegistrationFailure=$null;$productRegistrationVerified=$false try{ $fileTransaction=Open-CcodLifecycleInstallStateTransaction -InstallRoot $InstallRoot [void](Assert-CcodReadyFinalizationRecoveryProof -InstallRoot $InstallRoot -TransactionRecord $TransactionRecord) @@ -446,6 +447,9 @@ function Complete-CcodReadyFinalizationRecovery { } $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{$registration=&$Adapters.RegisterProduct $InstallRoot ([string]$observed.newRuntimeId) ([string]$proof.Validation.Manifest.projectVersion) ([string]$observed.sealedPackageSha256) $fileTransaction $observed;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'){ @@ -455,9 +459,10 @@ function Complete-CcodReadyFinalizationRecovery { }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} + return [pscustomobject][ordered]@{Outcome='Recovered';Installed=$true;RuntimeId=[string]$ready.newRuntimeId;PreviousRuntimeId=$ready.oldRuntimeId;RepairCompleted=$false;ProductRegistrationVerified=[bool]$productRegistrationVerified} } function Test-CcodInstallPackageIdentity { @@ -1936,6 +1941,22 @@ function Get-CcodLifecycleAdapters { param($InstallRoot,$TransactionId,$FileTransaction) Set-CcodInstallTransactionPhase -InstallRoot $InstallRoot -TransactionId $TransactionId -ExpectedPhase 'ProtectionReady' -NewPhase 'Ready' -FileTransaction $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} + } + RegisterProduct = { + param($InstallRoot,$RuntimeId,$Version,$PackageSha256,$FileTransaction,$TransactionRecord) + $module=Import-Module (Join-Path $PSScriptRoot 'ProductRegistration.psm1') -Force -PassThru -ErrorAction Stop + $registration=New-CcodProductRegistration -InstallRoot $InstallRoot -RuntimeId $RuntimeId -Version $Version -PackageSha256 $PackageSha256 -FileTransaction $FileTransaction + $proof=[pscustomobject][ordered]@{phase=[string]$TransactionRecord.phase;runtimeId=$RuntimeId;version=$Version;packageSha256=$PackageSha256;bootstrapPath=$registration.bootstrapPath;uninstallerPath=$registration.uninstallerPath} + $receipt=Commit-CcodProductRegistration -Registration $registration -FileTransaction $FileTransaction -Adapters @{GetReadyProof={param($Ignored)$proof}.GetNewClosure()} + Remove-CcodLegacyProductRegistration -ExpectedAppId '{2B9E9F2E-7A32-4A7E-9C1D-9F5B5C6D7E8F}' + [pscustomobject]@{verified=[bool]$receipt.verified;legacyRemoved=$true} + } NewRuntimeManifest = { param($RuntimeDirectory, $ProjectVersion) New-CcodRuntimeManifest -RuntimeDirectory $RuntimeDirectory -ProjectVersion $ProjectVersion @@ -2289,7 +2310,7 @@ function Invoke-CcodInstall { $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} + 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 } @@ -2329,6 +2350,12 @@ function Invoke-CcodInstall { } $nodeCandidates = @(Get-CcodLifecycleNodeCandidates -Adapters $adapters) if (-not $payloadBound) { $files = @(Get-CcodLifecycleSourceFiles -SourceRoot $sourceRoot -RequireTrayHost) } + $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 + } $oldManifestSha256=$null;$activeValidation=$null if($null-ne$existingPointer){ @@ -2370,7 +2397,7 @@ function Invoke-CcodInstall { $shutdownGate = $null $lifecycleOwnership = $null $previousProtectionStopped = $false - $fileTransaction=$null;$generationCapability=$null;$installRecord=$null;$readyReceiptCommitted=$false + $fileTransaction=$null;$generationCapability=$null;$installRecord=$null;$readyReceiptCommitted=$false;$productRegistrationVerified=$false;$productRegistrationFailure=$null try { $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};$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 $generation.ManifestSha256 -SealedPackageSha256 $SealedPackageSha256 -OwnedObjectNames @($runtimeId) @@ -2470,6 +2497,13 @@ function Invoke-CcodInstall { } 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{ + $registrationReceipt=&$adapters.RegisterProduct $root $runtimeId ([string]$projectVersion) $SealedPackageSha256 $fileTransaction $installRecord + 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 @@ -2548,6 +2582,12 @@ function Invoke-CcodInstall { try { & $adapters.ExitLifecycleOwnership $lifecycleOwnership | Out-Null } 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]@{ @@ -2556,6 +2596,7 @@ function Invoke-CcodInstall { RuntimeId = $runtimeId PreviousRuntimeId = $pointer.previousRuntime RepairCompleted = $false + ProductRegistrationVerified = [bool]$productRegistrationVerified } } diff --git a/src/persistence/modules/ProductRegistration.psm1 b/src/persistence/modules/ProductRegistration.psm1 new file mode 100644 index 0000000..47efd8a --- /dev/null +++ b/src/persistence/modules/ProductRegistration.psm1 @@ -0,0 +1,291 @@ +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','bootstrapPath','uninstallerPath') +$script:CcodLegacyShortcutNames = @( + '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', + 'Programs\Codex Control other devices\CodexRemote-fix.lnk', + 'Programs\Codex Control other devices\CodexRemote-fix compatibility check.lnk', + 'Programs\Codex Control other devices\Uninstall CodexRemote-fix.lnk', + ('Desktop\Codex ' + [char]0x8BBE + [char]0x5907 + [char]0x8FDE + [char]0x63A5 + ' (Device Connection).lnk') +) + +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 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 } + 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 + $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) Write-CcodCurrentUserProductRegistration $Registration } + ReadProductRegistration = { param($Registration) Read-CcodCurrentUserProductRegistration $Registration } + WriteShortcut = { param($Kind,$Shortcut,$FileTransaction) Write-CcodCurrentUserProductShortcut -Kind $Kind -Shortcut $Shortcut -FileTransaction $FileTransaction } + 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 } + } + 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-CcodProductUninstallCommand($Registration) { + '"{0}" -NoProfile -ExecutionPolicy Bypass -File "{1}"' -f (Get-CcodProductPowerShellPath),([string]$Registration.uninstallerPath).Replace('"','') +} +function Write-CcodCurrentUserProductRegistration { + param($Registration) + $path=Get-CcodProductRegistryPath;$command=Get-CcodProductUninstallCommand $Registration + New-Item -Path $path -Force -ErrorAction Stop|Out-Null + $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;CcodPackageSha256=[string]$Registration.packageSha256} + foreach($name in $values.Keys){$type=if($name-in@('NoModify','NoRepair')){'DWord'}else{'String'};New-ItemProperty -LiteralPath $path -Name $name -Value $values[$name] -PropertyType $type -Force -ErrorAction Stop|Out-Null} +} +function Read-CcodCurrentUserProductRegistration { + param($Registration) + try{$value=Get-ItemProperty -LiteralPath (Get-CcodProductRegistryPath) -ErrorAction Stop}catch{return $null} + $command=Get-CcodProductUninstallCommand $Registration + if($value.DisplayName-cne'CodexRemote-fix'-or$value.DisplayVersion-cne$Registration.version-or$value.Publisher-cne'naipi11'-or$value.InstallLocation-cne$Registration.runtimeRoot-or$value.UninstallString-cne$command-or$value.QuietUninstallString-cne($command+' -Confirm:$false')-or[int]$value.NoModify-ne1-or[int]$value.NoRepair-ne1-or$value.CcodRuntimeId-cne$Registration.runtimeId-or$value.CcodPackageSha256-cne$Registration.packageSha256){return $null} + $Registration +} +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) + $source=[IO.Path]::GetFullPath([string]$Shortcut.candidatePath);$runtime=[IO.Path]::GetFullPath((Split-Path (Split-Path $source -Parent) -Parent));if(-not$source.StartsWith($runtime.TrimEnd('\')+'\',[StringComparison]::OrdinalIgnoreCase)-or-not[IO.File]::Exists($source)){throw 'shortcut candidate missing'} + $item=Get-Item -LiteralPath $source -Force -ErrorAction Stop;if($item.PSIsContainer-or($item.Attributes-band[IO.FileAttributes]::ReparsePoint)-ne0){throw 'shortcut candidate unsafe'} + Import-Module (Join-Path $PSScriptRoot 'InstallFileTransaction.psm1') -ErrorAction Stop + $folder=Open-CcodInstallProductSpecialFolder -FileTransaction $FileTransaction -Kind $Kind + Copy-CcodInstallProductShortcut -Folder $folder -Kind $Kind -SourcePath $source -Leaf 'CodexRemote-fix.lnk' -ExpectedLength ([int64]$item.Length) -ExpectedSha256 (Get-CcodProductFileSha256 $source) -FileTransaction $FileTransaction|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 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;$present=[Collections.Generic.List[string]]::new() + $programs=[Environment]::GetFolderPath([Environment+SpecialFolder]::Programs);$desktop=[Environment]::GetFolderPath([Environment+SpecialFolder]::Desktop) + foreach($name in $script:CcodLegacyShortcutNames){$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){$present.Add($name)}} + [pscustomobject]@{appId=$ExpectedAppId;uninstallString=[string]$value.UninstallString;shortcutNames=@($present)} +} +function Read-CcodCurrentUserVerifiedRegistration { + $path=Get-CcodProductRegistryPath;if(-not(Test-Path -LiteralPath $path -PathType Container)){return $null} + $value=Get-ItemProperty -LiteralPath $path -ErrorAction Stop + if($value.CcodRuntimeId-isnot[string]-or$value.CcodRuntimeId-cnotmatch'^2\.5\.22-[0-9a-f]{16}-[0-9a-f]{32}$'-or$value.DisplayVersion-cne'2.5.22'-or$value.CcodPackageSha256-isnot[string]-or$value.CcodPackageSha256-cnotmatch'^[0-9a-f]{64}$'){return $null} + foreach($kind in @('StartMenu','Desktop')){if(-not[IO.File]::Exists((Get-CcodProductShortcutDestination $kind))){return $null}} + [pscustomobject]@{verified=$true;runtimeId=[string]$value.CcodRuntimeId;version=[string]$value.DisplayVersion;packageSha256=[string]$value.CcodPackageSha256} +} +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 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 + & $adapter.WriteShortcut 'StartMenu' $Registration.startMenuShortcut $FileTransaction + & $adapter.WriteShortcut 'Desktop' $Registration.desktopShortcut $FileTransaction + $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, + [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 + } + $adapter = Get-CcodProductRegistrationAdapters $Adapters + $verified=&$adapter.ReadVerifiedRegistration + if($null-eq$verified-or$verified.verified-isnot[bool]-or-not$verified.verified){Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Legacy product migration requires a current three-record read-back proof.' $ExpectedAppId} + $legacy = & $adapter.ReadLegacyRegistration $ExpectedAppId + if($null-eq$legacy){return} + if ($legacy.appId -isnot [string] -or [string]$legacy.appId -cne $ExpectedAppId -or + $legacy.uninstallString -isnot [string] -or [string]::IsNullOrWhiteSpace([string]$legacy.uninstallString) -or + $null -eq $legacy.shortcutNames -or (@($legacy.shortcutNames) -join '|') -cne ($script:CcodLegacyShortcutNames -join '|')) { + Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Legacy product entry or shortcut names do not match the exact migration allowlist.' $ExpectedAppId + } + try { & $adapter.RemoveLegacyRegistration $ExpectedAppId @($script:CcodLegacyShortcutNames) } + catch { Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Legacy product migration failed without authorizing broader deletion.' $ExpectedAppId } +} + +Export-ModuleMember -Function New-CcodProductRegistration,Test-CcodProductRegistration,Commit-CcodProductRegistration,Remove-CcodLegacyProductRegistration diff --git a/tests/persistence/InstallFileTransaction.SelfTest.ps1 b/tests/persistence/InstallFileTransaction.SelfTest.ps1 index 31a4cfc..8126cc0 100644 --- a/tests/persistence/InstallFileTransaction.SelfTest.ps1 +++ b/tests/persistence/InstallFileTransaction.SelfTest.ps1 @@ -136,7 +136,7 @@ function New-CcodHardLink { } Invoke-CcodTest 'exports only immutable generation operations and an inert CLR marker' { - $expected=@('Close-CcodInstallFileTransaction','Commit-CcodInstallActivePointer','Copy-CcodInstallSealedSource','New-CcodInstallDirectory','New-CcodInstallGenerationLeaf','Open-CcodInstallGeneration','Open-CcodInstallRetainedGeneration','Open-CcodInstallStateTransaction','Retire-CcodInstallGeneration','Write-CcodInstallGenerationManifest','Write-CcodInstallRecord') + $expected=@('Close-CcodInstallFileTransaction','Commit-CcodInstallActivePointer','Copy-CcodInstallProductShortcut','Copy-CcodInstallSealedSource','New-CcodInstallDirectory','New-CcodInstallGenerationLeaf','Open-CcodInstallGeneration','Open-CcodInstallProductSpecialFolder','Open-CcodInstallRetainedGeneration','Open-CcodInstallStateTransaction','Retire-CcodInstallGeneration','Write-CcodInstallGenerationManifest','Write-CcodInstallRecord') Assert-CcodEqual ($expected -join '|') ((@($module.ExportedCommands.Keys)|Sort-Object)-join '|') 'module export surface is capability-only' Assert-CcodEqual 4 ([CcodInstallGenerationCapabilityMarkerV4]::CapabilityAbi) 'marker exposes the current non-mutating ABI value' Assert-CcodEqual 'CcodInstallGenerationCapabilityMarkerV4' ((@([CcodInstallGenerationCapabilityMarkerV4].Assembly.GetExportedTypes()|ForEach-Object FullName)) -join '|') 'current CLR bridge exports only the inert marker' diff --git a/tests/persistence/InstallLifecycle.SelfTest.ps1 b/tests/persistence/InstallLifecycle.SelfTest.ps1 index a93c2fb..b15dce2 100644 --- a/tests/persistence/InstallLifecycle.SelfTest.ps1 +++ b/tests/persistence/InstallLifecycle.SelfTest.ps1 @@ -185,6 +185,9 @@ function New-CcodLifecycleFake { 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() @@ -306,6 +309,15 @@ 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 } } @@ -314,7 +326,7 @@ function New-CcodReadyFinalizationGapFixture { $install=New-CcodLifecycleTempRoot $nodeRoot=New-CcodLifecycleTempRoot try { - New-CcodLifecycleSourceFixture -Root $source -Version '2.5.22-finalization-recovery' | Out-Null + 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'} @@ -1686,6 +1698,7 @@ $results += Invoke-CcodTest 'a later invocation recovers one missing Ready trans $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' @@ -3191,4 +3204,30 @@ $results += Invoke-CcodTest 'sealed Setup carries no mutable validation or legac 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: 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' + }finally{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/ProductRegistration.SelfTest.ps1 b/tests/persistence/ProductRegistration.SelfTest.ps1 new file mode 100644 index 0000000..ab08a00 --- /dev/null +++ b/tests/persistence/ProductRegistration.SelfTest.ps1 @@ -0,0 +1,179 @@ +$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" +} +Import-Module $modulePath -Force + +$runtimeId = '2.5.22-1111111111111111-22222222222222222222222222222222' +$packageSha256 = '3' * 64 +$appId = '{2B9E9F2E-7A32-4A7E-9C1D-9F5B5C6D7E8F}' + +function New-CcodRegistrationWorld { + param([string]$InstallRoot = 'C:\fixture\CodexControlOtherDevices') + + $registration = New-CcodProductRegistration -InstallRoot $InstallRoot -RuntimeId $runtimeId -Version '2.5.22' -PackageSha256 $packageSha256 -FileTransaction ([pscustomobject]@{}) + $world = [pscustomobject]@{ + Registration = $registration + Ready = [pscustomobject][ordered]@{ + phase = 'Ready' + runtimeId = $runtimeId + version = '2.5.22' + packageSha256 = $packageSha256 + bootstrapPath = $registration.bootstrapPath + uninstallerPath = $registration.uninstallerPath + } + Product = $null + Shortcuts = @{} + WriteProductFailure = $false + WriteShortcutFailure = $null + ReadProductFailure = $false + ReadShortcutFailure = $null + Legacy = [pscustomobject]@{ + appId = $appId + uninstallString = '"C:\legacy\unins000.exe"' + shortcutNames = @( + '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', + 'Programs\Codex Control other devices\CodexRemote-fix.lnk', + 'Programs\Codex Control other devices\CodexRemote-fix compatibility check.lnk', + 'Programs\Codex Control other devices\Uninstall CodexRemote-fix.lnk', + ('Desktop\Codex ' + [char]0x8BBE + [char]0x5907 + [char]0x8FDE + [char]0x63A5 + ' (Device Connection).lnk') + ) + } + LegacyRemoved = $false + LegacyRemovalCalls = 0 + Calls = [Collections.Generic.List[string]]::new() + } + $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 + }.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'); $world.Legacy }.GetNewClosure() + ReadVerifiedRegistration = { [pscustomobject]@{verified=($null-ne$world.Product-and$world.Shortcuts.Count-eq2)} }.GetNewClosure() + RemoveLegacyRegistration = { param($ExpectedAppId,$ExpectedShortcutNames) $world.Calls.Add('RemoveLegacy'); $world.LegacyRemovalCalls++; $world.LegacyRemoved = $true }.GetNewClosure() + } + return $world +} + +$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.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: 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 + $receipt = Commit-CcodProductRegistration -Registration $world.Registration -FileTransaction ([pscustomobject]@{}) -Adapters $world.Adapters + Assert-CcodTrue $receipt.verified 'registration returns a verified three-record receipt' + Assert-CcodEqual 'Ready,WriteProduct,WriteShortcut:StartMenu,WriteShortcut:Desktop,ReadProduct,ReadShortcut:StartMenu,ReadShortcut:Desktop' ($world.Calls -join ',') 'all new writes and read-backs precede legacy migration' + Remove-CcodLegacyProductRegistration -ExpectedAppId $appId -Adapters $world.Adapters + Assert-CcodEqual 1 $world.LegacyRemovalCalls 'exact legacy migration runs once after verified registration' + Assert-CcodTrue $world.LegacyRemoved 'exact legacy registration and shortcut set is removed' +} + +# Production mutation caught: accepting an unexpected AppId/name set or an unverified registration receipt. +$results += Invoke-CcodTest 'legacy migration rejects AppId and shortcut-name mismatches without deletion' { + foreach ($mismatch in @('AppId','Shortcut','Receipt')) { + $world = New-CcodRegistrationWorld + $receipt = Commit-CcodProductRegistration -Registration $world.Registration -FileTransaction ([pscustomobject]@{}) -Adapters $world.Adapters + if ($mismatch -ceq 'AppId') { $world.Legacy.appId = '{AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE}' } + if ($mismatch -ceq 'Shortcut') { $world.Legacy.shortcutNames = @('Programs\unexpected.lnk') } + if ($mismatch -ceq 'Receipt') { $world.Product = $null } + Assert-CcodThrows { Remove-CcodLegacyProductRegistration -ExpectedAppId $appId -Adapters $world.Adapters } 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' + Assert-CcodEqual 0 $world.LegacyRemovalCalls 'legacy mismatch is rejected before any deletion' + Assert-CcodTrue (-not $world.LegacyRemoved) 'legacy state remains intact on mismatch' + } +} + +Write-Output "Product registration self-tests passed: $($results.Count)" diff --git a/tests/persistence/UninstallBootstrap.SelfTest.ps1 b/tests/persistence/UninstallBootstrap.SelfTest.ps1 index 622e3cb..b8e93fa 100644 --- a/tests/persistence/UninstallBootstrap.SelfTest.ps1 +++ b/tests/persistence/UninstallBootstrap.SelfTest.ps1 @@ -130,6 +130,12 @@ function New-CcodUninstallBootstrapAdapters { & $WriteTransaction $TransactionRoot $Transaction return $Transaction }.GetNewClosure() + RemoveProductRegistration = { + param($Context) + [void]$World.Calls.Add('RemoveProduct') + if($null-eq$World.PSObject.Properties['ProductRegistrationRemovals']){$World|Add-Member -NotePropertyName ProductRegistrationRemovals -NotePropertyValue 0} + [void]($World.ProductRegistrationRemovals++) + }.GetNewClosure() TestInstallRootAbsent = { param($InstallRoot) [void]$World.Calls.Add('RootAbsent') @@ -205,10 +211,12 @@ $results += Invoke-CcodTest 'Prepare stages only the manifest-bound cleanup payl CleanupFailurePhase = $null InstallRootAbsent = $false StagedEntries = @() + ProductRegistrationRemovals = 0 } $receipt = Invoke-CcodUninstallBootstrap -InstallerRoot 'C:\installer' -InstallRoot 'C:\install' -Mode Prepare -Adapters (New-CcodUninstallBootstrapAdapters $world) Assert-CcodEqual 'ReadyForInno' $receipt.phase 'verified cleanup reaches the Inno boundary' - Assert-CcodEqual 'Validate,GetRoot,Read,NewId,Create,Write:Requested,Publish,Stage,Cleanup,Write:ReadyForInno,Receipt:ReadyForInno' ($world.Calls -join ',') 'Prepare publishes a recoverable transaction only after its durable transaction record exists' + Assert-CcodEqual 'Validate,GetRoot,Read,NewId,Create,Write:Requested,Publish,Stage,Cleanup,Write:ReadyForInno,RemoveProduct,Receipt:ReadyForInno' ($world.Calls -join ',') 'Prepare removes only verified product registration after protected application cleanup' + Assert-CcodEqual 1 $world.ProductRegistrationRemovals 'verified uninstall removes current product registration exactly once' Assert-CcodEqual 'src/persistence/UninstallBootstrap.ps1,src/persistence/PortableUninstallFinalizer.ps1,src/persistence/modules/InstallLifecycle.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' ($world.StagedEntries -join ',') 'staging has no device-key material' Assert-CcodEqual $null $receipt.errorCode 'ReadyForInno carries no failure code' } @@ -405,7 +413,7 @@ $results += Invoke-CcodTest 'Prepare resumes only the exact durable transaction } $receipt = Invoke-CcodUninstallBootstrap -InstallerRoot 'C:\installer' -InstallRoot 'C:\install' -Mode Prepare -Adapters (New-CcodUninstallBootstrapAdapters $world) Assert-CcodEqual 'ReadyForInno' $receipt.phase 'interrupted cleanup resumes to the Inno boundary' - Assert-CcodEqual 'Validate,GetRoot,Read,Cleanup,Write:ReadyForInno,Receipt:ReadyForInno' ($world.Calls -join ',') 'resume reuses the durable transaction and never makes a second payload' + Assert-CcodEqual 'Validate,GetRoot,Read,Cleanup,Write:ReadyForInno,RemoveProduct,Receipt:ReadyForInno' ($world.Calls -join ',') 'resume reuses the durable transaction and never makes a second payload' } $results += Invoke-CcodTest 'Prepare resumes a TaskRemoved transaction even after the runtime root and active pointer have been deleted' { @@ -430,7 +438,7 @@ $results += Invoke-CcodTest 'Prepare resumes a TaskRemoved transaction even afte } $receipt = Invoke-CcodUninstallBootstrap -InstallerRoot $repositoryRoot -InstallRoot $installRoot -Mode Prepare -Adapters (New-CcodUninstallBootstrapAdapters $world) Assert-CcodEqual 'ReadyForInno' $receipt.phase 'partial application deletion can resume without an active runtime pointer' - Assert-CcodEqual 'Validate,GetRoot,Read,Cleanup,Write:ReadyForInno,Receipt:ReadyForInno' ($world.Calls -join ',') 'partial deletion resume neither creates another transaction nor restages payload' + Assert-CcodEqual 'Validate,GetRoot,Read,Cleanup,Write:ReadyForInno,RemoveProduct,Receipt:ReadyForInno' ($world.Calls -join ',') 'partial deletion resume neither creates another transaction nor restages payload' } finally { [Environment]::SetEnvironmentVariable('LOCALAPPDATA',$previousLocalAppData,'Process') if (Test-Path -LiteralPath $localAppData) { Remove-Item -LiteralPath $localAppData -Recurse -Force } @@ -484,7 +492,7 @@ $results += Invoke-CcodTest 'failed recovery writes Failed and leaves the staged $world.CleanupError = $false $receipt = Invoke-CcodUninstallBootstrap -InstallerRoot 'C:\installer' -InstallRoot 'C:\install' -Mode Prepare -Adapters (New-CcodUninstallBootstrapAdapters $world) Assert-CcodEqual 'ReadyForInno' $receipt.phase 'a recovery failure can resume after the proof becomes available' - Assert-CcodEqual 'Validate,GetRoot,Read,Cleanup,Write:ReadyForInno,Receipt:ReadyForInno' ($world.Calls -join ',') 'recovery retry reuses the same payload without a second staging pass' + Assert-CcodEqual 'Validate,GetRoot,Read,Cleanup,Write:ReadyForInno,RemoveProduct,Receipt:ReadyForInno' ($world.Calls -join ',') 'recovery retry reuses the same payload without a second staging pass' } $results += Invoke-CcodTest 'payload staging failure writes a durable Failed transaction and receipt before Inno can delete files' { @@ -508,7 +516,7 @@ $results += Invoke-CcodTest 'payload staging failure writes a durable Failed tra $world.StageError = $false $receipt = Invoke-CcodUninstallBootstrap -InstallerRoot 'C:\installer' -InstallRoot 'C:\install' -Mode Prepare -Adapters (New-CcodUninstallBootstrapAdapters $world) Assert-CcodEqual 'ReadyForInno' $receipt.phase 'a payload staging failure can retry the same durable transaction' - Assert-CcodEqual 'Validate,GetRoot,Read,Stage,Cleanup,Write:ReadyForInno,Receipt:ReadyForInno' ($world.Calls -join ',') 'staging retry restages only the existing transaction payload and never republishes a new locator' + Assert-CcodEqual 'Validate,GetRoot,Read,Stage,Cleanup,Write:ReadyForInno,RemoveProduct,Receipt:ReadyForInno' ($world.Calls -join ',') 'staging retry restages only the existing transaction payload and never republishes a new locator' } $results += Invoke-CcodTest 'FinalizeReceipt marks completion only after Inno has removed the application root' { From 97cf17d804e46bee59dc81a008e266f36f795c77 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:04:00 +0800 Subject: [PATCH 096/187] docs: record post-readiness registration evidence --- .../task-4-report.md | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 .superpowers/sdd/2026-08-29-v2522-immutable-generation/task-4-report.md 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..102248b --- /dev/null +++ b/.superpowers/sdd/2026-08-29-v2522-immutable-generation/task-4-report.md @@ -0,0 +1,97 @@ +# 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`). From 2e76698d535e6e824df1c57d81314be1c2e053e8 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:22:53 +0800 Subject: [PATCH 097/187] fix: complete matched product lifecycle --- Uninstall-CodexControlOtherDevices.ps1 | 18 +++-- build/build.ps1 | 3 +- .../InstalledUninstallFinalizer.ps1 | 53 +++++++++++++ src/persistence/UninstallBootstrap.ps1 | 39 +++++----- .../modules/InstallFileTransaction.psm1 | 77 ++++++++++--------- src/persistence/modules/InstallLifecycle.psm1 | 25 ++++-- .../modules/ProductRegistration.psm1 | 60 ++++++++++++--- .../InstallFileTransaction.SelfTest.ps1 | 16 +++- .../persistence/InstallLifecycle.SelfTest.ps1 | 25 +++++- .../ProductRegistration.SelfTest.ps1 | 52 ++++++++++++- .../UninstallBootstrap.SelfTest.ps1 | 54 ++++++++++++- 11 files changed, 329 insertions(+), 93 deletions(-) create mode 100644 src/persistence/InstalledUninstallFinalizer.ps1 diff --git a/Uninstall-CodexControlOtherDevices.ps1 b/Uninstall-CodexControlOtherDevices.ps1 index 10356c0..7ce0c5a 100644 --- a/Uninstall-CodexControlOtherDevices.ps1 +++ b/Uninstall-CodexControlOtherDevices.ps1 @@ -38,14 +38,20 @@ if ($null -ne $installedRuntimeId -and $installedRuntimeId -cmatch '^2\.5\.22-[0 $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} } - try { $prepared=& $bootstrapPath -InstallerRoot $installerRoot -InstallRoot $expectedInstallRoot -Mode Prepare } + try { $prepared=& $bootstrapPath -InstallerRoot $installerRoot -InstallRoot $expectedInstallRoot -Mode PrepareInstalled } 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'ReadyForInno'-or$prepared.runtimeId-cne$installedRuntimeId){Throw-CcodPublicUninstallError 'CCOD_UNINSTALL_INSTALLED_PREPARE_FAILED' 'Sealed generation cleanup returned an invalid receipt.' $prepared} + 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')) - $stagedBootstrap=[IO.Path]::GetFullPath((Join-Path (Join-Path (Join-Path $transactionRoot $prepared.transactionId) 'payload') 'src\persistence\UninstallBootstrap.ps1')) - if(-not[IO.File]::Exists($stagedBootstrap)){Throw-CcodPublicUninstallError 'CCOD_UNINSTALL_INSTALLED_FINALIZER_MISSING' 'The verified external finalization bootstrap is missing.' $stagedBootstrap} - & $stagedBootstrap -InstallerRoot $installerRoot -InstallRoot $expectedInstallRoot -Mode FinalizeReceipt - Throw-CcodPublicUninstallError 'CCOD_UNINSTALL_INSTALLED_FINALIZATION_FAILED' 'The external finalization bootstrap returned without completing the uninstall receipt.' $prepared.transactionId + $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} + $current=[Diagnostics.Process]::GetCurrentProcess();try{$wrapperId=[int]$current.Id;$wrapperCreated=$current.StartTime.ToUniversalTime().ToString('o',[Globalization.CultureInfo]::InvariantCulture)}finally{$current.Dispose()} + function ConvertTo-CcodInstalledUninstallLiteral([string]$Value){"'"+$Value.Replace("'","''")+"'"} + $arguments='-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-Process -FilePath $powershellPath -ArgumentList $arguments -WindowStyle Hidden -RedirectStandardOutput (Join-Path $transactionDirectory 'installed-finalizer.stdout.log') -RedirectStandardError (Join-Path $transactionDirectory 'installed-finalizer.stderr.log') -PassThru -ErrorAction Stop}catch{Throw-CcodPublicUninstallError 'CCOD_UNINSTALL_INSTALLED_FINALIZER_START_FAILED' 'The external installed finalizer could not be started; 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)) { diff --git a/build/build.ps1 b/build/build.ps1 index b6f1359..8690e35 100644 --- a/build/build.ps1 +++ b/build/build.ps1 @@ -389,7 +389,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 } diff --git a/src/persistence/InstalledUninstallFinalizer.ps1 b/src/persistence/InstalledUninstallFinalizer.ps1 new file mode 100644 index 0000000..888ed3e --- /dev/null +++ b/src/persistence/InstalledUninstallFinalizer.ps1 @@ -0,0 +1,53 @@ +[CmdletBinding()] +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, + [int]$WrapperProcessId, + [string]$WrapperCreationTimeUtc +) + +Set-StrictMode -Version Latest +$ErrorActionPreference='Stop' + +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'} +function Get-CcodInstalledFinalizerAdapters { + param([hashtable]$Adapters,[string]$TransactionRoot,[string]$PayloadRoot) + $bootstrap=Join-Path $PayloadRoot 'src\persistence\UninstallBootstrap.ps1' + $lifecycle=Join-Path $PayloadRoot 'src\persistence\modules\InstallLifecycle.psm1' + $defaults=@{ + WaitWrapperExit={param($Identity,$Timeout);$stopwatch=[Diagnostics.Stopwatch]::StartNew();while($stopwatch.ElapsedMilliseconds-lt$Timeout){$process=Get-Process -Id $Identity.pid -ErrorAction SilentlyContinue;if($null-eq$process){return $true};try{if($process.StartTime.ToUniversalTime().ToString('o',[Globalization.CultureInfo]::InvariantCulture)-cne$Identity.creationTimeUtc){return $true}}finally{$process.Dispose()};Start-Sleep -Milliseconds 100};$false} + ReadPreparedTransaction={param($Id);. $bootstrap;$identity=Get-CcodUninstallBootstrapCurrentIdentity;$value=Read-CcodUninstallBootstrapStoredTransaction -TransactionRoot $TransactionRoot -ExpectedUserSid $identity.userSid -IncludeCompleted;if($null-eq$value-or$value.transactionId-cne$Id){return $null};$value}.GetNewClosure() + ValidateSelectedGeneration={param($SelectedRuntime,$Root,$Transaction);. $bootstrap;$context=Get-CcodUninstallBootstrapVerifiedRuntimeContext -InstallerRoot $SelectedRuntime -InstallRoot $Root -InvocationPath (Join-Path $SelectedRuntime 'src\persistence\UninstallBootstrap.ps1');$context.runtimeId-ceq$Transaction.runtimeId-and[uint64]$context.runtimeGeneration-eq[uint64]$Transaction.runtimeGeneration}.GetNewClosure() + RemoveMatchedApplicationState={param($SelectedRuntime,$Root,$Transaction);. $bootstrap;$module=Import-Module $lifecycle -Force -PassThru -ErrorAction Stop;$directory=Join-Path $TransactionRoot $Transaction.transactionId;$writer={param($Value)Write-CcodUninstallBootstrapStoredTransaction -TransactionDirectory $directory -Transaction $Value}.GetNewClosure();&$module {param($Install,$Value,$Write)Invoke-CcodUninstallCleanup -InstallRoot $Install -Transaction $Value -WriteTransaction $Write -StopAfterTaskRemoval:$false} $Root $Transaction $writer}.GetNewClosure() + TestInstallRootAbsent={param($Root)-not([IO.Directory]::Exists($Root)-or[IO.File]::Exists($Root))} + RemoveMatchedProductRegistration={param($Transaction);. $bootstrap;Remove-CcodUninstallBootstrapProductRegistration -Context ([pscustomobject][ordered]@{runtimeId=$Transaction.runtimeId;runtimeGeneration=[uint64]$Transaction.runtimeGeneration;leaseEpoch=[uint64]$Transaction.leaseEpoch;userSid=$Transaction.userSid;sessionId=[int]$Transaction.sessionId;payloadRecords=New-CcodUninstallBootstrapPayloadRecords -ResumeOnly})} + FinalizeReceipt={param($Transaction);& $bootstrap -InstallerRoot $RuntimeRoot -InstallRoot $InstallRoot -Mode FinalizeReceipt;Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Finalization bootstrap returned unexpectedly' $Transaction.transactionId}.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 Invoke-CcodInstalledUninstallFinalizer { + param([Parameter(Mandatory)][string]$TransactionId,[Parameter(Mandatory)][string]$RuntimeRoot,[Parameter(Mandatory)][string]$InstallRoot,[Parameter(Mandatory)]$WrapperIdentity,[hashtable]$Adapters) + if($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(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);$expected=[IO.Path]::GetFullPath((Join-Path (Join-Path $install 'runtime') ([IO.Path]::GetFileName($runtime))));if($runtime-cne$expected){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Runtime root is outside the fixed install runtime parent' $runtime} + $local=[Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData);$transactionRoot=[IO.Path]::GetFullPath((Join-Path $local 'CodexRemote-fix-uninstall'));$payloadRoot=[IO.Path]::GetFullPath((Join-Path (Join-Path $transactionRoot $TransactionId) 'payload'));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};$self=Get-Item -LiteralPath $expectedSelf -Force -ErrorAction Stop;if($self.PSIsContainer-or($self.Attributes-band[IO.FileAttributes]::ReparsePoint)-ne0){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Installed finalizer path is unsafe' $expectedSelf}};$adapter=Get-CcodInstalledFinalizerAdapters -Adapters $Adapters -TransactionRoot $transactionRoot -PayloadRoot $payloadRoot + if(-not(& $adapter.WaitWrapperExit $WrapperIdentity 15000)){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Installed wrapper did not exit with the expected identity' $WrapperIdentity} + $transaction=&$adapter.ReadPreparedTransaction $TransactionId + if($null-eq$transaction-or$transaction.transactionId-cne$TransactionId-or$transaction.phase-cne'TaskRemoved'-or$transaction.resumePhase-cne'TaskRemoved'){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Durable installed uninstall transaction is not at TaskRemoved' $transaction} + if(-not(&$adapter.ValidateSelectedGeneration $runtime $install $transaction)){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Selected generation proof does not match the transaction' $runtime} + $ready=&$adapter.RemoveMatchedApplicationState $runtime $install $transaction + if($null-eq$ready-or$ready.phase-cne'ReadyForInno'-or-not(&$adapter.TestInstallRootAbsent $install)){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Matched application state removal was not proven' $install} + &$adapter.RemoveMatchedProductRegistration $ready + $receipt=&$adapter.FinalizeReceipt $ready + if($null-eq$receipt-or$receipt.phase-cne'Completed'){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Completion receipt was not proven' $TransactionId} + $receipt +} + +if($MyInvocation.InvocationName-ne'.'){ + $identity=[pscustomobject]@{pid=[int]$WrapperProcessId;creationTimeUtc=$WrapperCreationTimeUtc} + try{Invoke-CcodInstalledUninstallFinalizer -TransactionId $TransactionId -RuntimeRoot $RuntimeRoot -InstallRoot $InstallRoot -WrapperIdentity $identity|Out-Null;exit 0}catch{Write-Error ([string]$_.FullyQualifiedErrorId);exit 3} +} diff --git a/src/persistence/UninstallBootstrap.ps1 b/src/persistence/UninstallBootstrap.ps1 index 23367f7..478d5e7 100644 --- a/src/persistence/UninstallBootstrap.ps1 +++ b/src/persistence/UninstallBootstrap.ps1 @@ -2,7 +2,7 @@ param( [string]$InstallerRoot, [string]$InstallRoot, - [ValidateSet('Prepare','FinalizeReceipt')][string]$Mode + [ValidateSet('Prepare','PrepareInstalled','FinalizeReceipt')][string]$Mode ) Set-StrictMode -Version Latest @@ -20,7 +20,9 @@ $script:CcodUninstallContextFields = @('runtimeId','runtimeGeneration','leaseEpo $script:CcodUninstallPayloadEntries = @( 'src/persistence/UninstallBootstrap.ps1', 'src/persistence/PortableUninstallFinalizer.ps1', + 'src/persistence/InstalledUninstallFinalizer.ps1', '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', @@ -353,7 +355,7 @@ function New-CcodUninstallBootstrapResumeContext { } function Get-CcodUninstallBootstrapVerifiedRuntimeContext { - param([Parameter(Mandatory)][string]$InstallerRoot,[Parameter(Mandatory)][string]$InstallRoot,[hashtable]$SelectorAdapters) + 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' @@ -365,8 +367,8 @@ 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 + 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 } 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}} @@ -911,7 +913,7 @@ function Get-CcodUninstallBootstrapAdapters { Write-CcodUninstallBootstrapStoredReceipt -TransactionDirectory $TransactionRoot -Transaction $Transaction } RunCleanup = { - param($InstallerRoot,$InstallRoot,$TransactionRoot,$Transaction,$WriteTransaction) + param($InstallerRoot,$InstallRoot,$TransactionRoot,$Transaction,$WriteTransaction,$Mode) $payloadRoot = Resolve-CcodUninstallBootstrapChildPath -Root $TransactionRoot -RelativePath 'payload' $modulePath = Resolve-CcodUninstallBootstrapChildPath -Root $payloadRoot -RelativePath 'src\persistence\modules\InstallLifecycle.psm1' -RequireLeafFile $module = Import-Module -Name $modulePath -Force -PassThru -ErrorAction Stop @@ -920,9 +922,9 @@ 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')) } RemoveProductRegistration = { param($Context) @@ -947,15 +949,8 @@ function Get-CcodUninstallBootstrapAdapters { function Remove-CcodUninstallBootstrapProductRegistration { param([Parameter(Mandatory)]$Context) Assert-CcodUninstallBootstrapContext $Context - $registryPath='HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\CodexRemote-fix' - $programs=[Environment]::GetFolderPath([Environment+SpecialFolder]::Programs);$desktop=[Environment]::GetFolderPath([Environment+SpecialFolder]::Desktop) - if([string]::IsNullOrWhiteSpace($programs)-or[string]::IsNullOrWhiteSpace($desktop)-or-not[IO.Path]::IsPathRooted($programs)-or-not[IO.Path]::IsPathRooted($desktop)){Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_PRODUCT_REGISTRATION_INVALID' 'Current-user product special folders are unavailable' $null} - $shortcuts=@([IO.Path]::GetFullPath((Join-Path (Join-Path $programs 'CodexRemote-fix') 'CodexRemote-fix.lnk')),[IO.Path]::GetFullPath((Join-Path $desktop 'CodexRemote-fix.lnk'))) - $registryPresent=Test-Path -LiteralPath $registryPath -PathType Container - if($registryPresent){$value=Get-ItemProperty -LiteralPath $registryPath -ErrorAction Stop;if($value.CcodRuntimeId-isnot[string]-or[string]$value.CcodRuntimeId-cne[string]$Context.runtimeId){Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_PRODUCT_REGISTRATION_INVALID' 'Current product registration does not match the verified runtime' $registryPath}} - foreach($path in $shortcuts){if([IO.File]::Exists($path)-or[IO.Directory]::Exists($path)){$item=Get-Item -LiteralPath $path -Force -ErrorAction Stop;if($item.PSIsContainer-or($item.Attributes-band[IO.FileAttributes]::ReparsePoint)-ne0){Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_PRODUCT_REGISTRATION_INVALID' 'Current product shortcut is not an exact regular file' $path}}} - foreach($path in $shortcuts){if([IO.File]::Exists($path)){Remove-Item -LiteralPath $path -Force -ErrorAction Stop}} - if($registryPresent){Remove-Item -LiteralPath $registryPath -Force -ErrorAction Stop} + $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 -ExpectedRuntimeId ([string]$Context.runtimeId)}catch{Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_PRODUCT_REGISTRATION_INVALID' 'Current product registration did not match the verified uninstall runtime' $Context.runtimeId} } function Set-CcodUninstallBootstrapFailedTransaction { @@ -976,7 +971,7 @@ 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, [hashtable]$Adapters ) @@ -1056,11 +1051,12 @@ function Invoke-CcodUninstallBootstrap { $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 + $result = & $adapter.RunCleanup $InstallerRoot $InstallRoot $stageRoot $transaction $adapter.WriteTransaction $Mode 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 } - & $adapter.RemoveProductRegistration $context + $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 { @@ -1078,6 +1074,7 @@ if ($MyInvocation.InvocationName -ne '.') { try { $receipt = Invoke-CcodUninstallBootstrap -InstallerRoot $InstallerRoot -InstallRoot $InstallRoot -Mode $Mode 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/modules/InstallFileTransaction.psm1 b/src/persistence/modules/InstallFileTransaction.psm1 index 61ce8d0..c3ace77 100644 --- a/src/persistence/modules/InstallFileTransaction.psm1 +++ b/src/persistence/modules/InstallFileTransaction.psm1 @@ -84,9 +84,9 @@ internal sealed class CcodInstallGenerationRuntimeV4 : IDisposable private readonly List externalStreams = new List(); private bool disposed; private string cleanupError; - private readonly bool stateOnly; - private CcodInstallGenerationRuntimeV4(string installRoot,Pin root,Pin runtimeParent,bool stateOnly) - { this.installRoot=installRoot;this.root=root;this.runtimeParent=runtimeParent;this.stateOnly=stateOnly;AddPin(root);if(runtimeParent!=null)AddPin(runtimeParent); } + private readonly bool stateOnly,productOnly; + private CcodInstallGenerationRuntimeV4(string installRoot,Pin root,Pin runtimeParent,bool stateOnly,bool productOnly) + { this.installRoot=installRoot;this.root=root;this.runtimeParent=runtimeParent;this.stateOnly=stateOnly;this.productOnly=productOnly;AddPin(root);if(runtimeParent!=null)AddPin(runtimeParent); } internal static object Open(string path,string runtimeId,out CcodInstallGenerationRuntimeV4 runtime) { @@ -103,7 +103,7 @@ internal sealed class CcodInstallGenerationRuntimeV4 : IDisposable try { Pin generation=ValidateDirectoryPin(runtimePin,runtimeId,Path.Combine(runtimePin.Path,runtimeId),true,generationResult.Handle);generationResult.Handle=null; - runtime=new CcodInstallGenerationRuntimeV4(full,rootPin,runtimePin,false);rootPin=null;runtimePin=null;runtime.AddPin(generation);return generation.Token; + runtime=new CcodInstallGenerationRuntimeV4(full,rootPin,runtimePin,false,false);rootPin=null;runtimePin=null;runtime.AddPin(generation);return generation.Token; } finally { if(generationResult.Handle!=null)generationResult.Handle.Dispose(); } } @@ -113,12 +113,18 @@ internal sealed class CcodInstallGenerationRuntimeV4 : IDisposable internal static object OpenState(string path,out CcodInstallGenerationRuntimeV4 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 CcodInstallGenerationRuntimeV4(full,rootPin,null,true);rootPin=null;return runtime.root.Token;} + try{rootPin=ValidateDirectoryPin(null,"",full,false,rootHandle);rootHandle=null;runtime=new CcodInstallGenerationRuntimeV4(full,rootPin,null,true,false);rootPin=null;return runtime.root.Token;} catch{if(rootPin!=null)rootPin.Dispose();if(rootHandle!=null)rootHandle.Dispose();throw;} } + internal static object OpenProduct(string path,out CcodInstallGenerationRuntimeV4 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 CcodInstallGenerationRuntimeV4(full,rootPin,runtimePin,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;} + } + 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(!stateOnly)return;if(Object.ReferenceEquals(parent,root)){if(!String.Equals(leaf,"state",StringComparison.Ordinal))throw new InvalidOperationException("state-only scope");return;}if(!IsStateScope(parent))throw new InvalidOperationException("state-only scope");} + private void RequireStateDirectory(Pin parent,string leaf){if(productOnly)throw new InvalidOperationException("product-only scope");if(!stateOnly)return;if(Object.ReferenceEquals(parent,root)){if(!String.Equals(leaf,"state",StringComparison.Ordinal))throw new InvalidOperationException("state-only scope");return;}if(!IsStateScope(parent))throw new InvalidOperationException("state-only scope");} [MethodImpl(MethodImplOptions.Synchronized)] internal object CreateDirectory(object parentToken,string leaf) { @@ -152,7 +158,7 @@ internal sealed class CcodInstallGenerationRuntimeV4 : IDisposable [MethodImpl(MethodImplOptions.Synchronized)] internal object Copy(object parentToken,string leaf,string source,long expectedLength,string expectedSha) { - if(stateOnly)throw new InvalidOperationException("state-only scope");Pin parent=Require(parentToken,true);ValidateCurrent(parent);string full=Path.GetFullPath(source); + if(stateOnly)throw new InvalidOperationException("state-only 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 @@ -169,25 +175,25 @@ internal sealed class CcodInstallGenerationRuntimeV4 : IDisposable [MethodImpl(MethodImplOptions.Synchronized)] internal object Write(object parentToken,string leaf,byte[] bytes,bool manifest) { - Pin parent=Require(parentToken,true);ValidateCurrent(parent);if(stateOnly&&(manifest||!IsStateScope(parent)))throw new InvalidOperationException("state-only 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; + 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");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 CommitPointer(object generationToken,ulong previousGeneration,string runtimeId,byte[] bytes) { - if(stateOnly)throw new InvalidOperationException("state-only scope");Pin generation=Require(generationToken,true);ValidateCurrent(generation);if(!Object.ReferenceEquals(generation.Parent,runtimeParent)||!String.Equals(generation.Leaf,runtimeId,StringComparison.Ordinal))throw new InvalidDataException("transaction scope"); + if(stateOnly)throw new InvalidOperationException("state-only scope");if(productOnly)throw new InvalidOperationException("product-only scope");Pin generation=Require(generationToken,true);ValidateCurrent(generation);if(!Object.ReferenceEquals(generation.Parent,runtimeParent)||!String.Equals(generation.Leaf,runtimeId,StringComparison.Ordinal))throw new InvalidDataException("transaction scope"); if(retainedRoots.Contains(generationToken))ValidateRetainedTree(generation);else{Pin manifest=null;if(!names.TryGetValue(Key(generation,"manifest.json"),out manifest)||!manifest.Manifest||!manifest.Sealed)throw new InvalidDataException("manifest missing");EnsurePublishedPin(manifest);ValidateOwnedTree(generation);} if(previousGeneration==UInt64.MaxValue)throw new OverflowException("pointer generation overflow");Pin state=OpenOrCreateDirectory(root,"state"),pointer=OpenOrCreateDirectory(state,"active-generation");if(CurrentPointerGeneration(pointer)!=previousGeneration)throw new InvalidOperationException("pointer generation exists");string leaf=(previousGeneration+1).ToString("D20")+".json";return Write(pointer.Token,leaf,bytes,false); } [MethodImpl(MethodImplOptions.Synchronized)] internal object Retire(object generationToken,string runtimeId,string recordLeaf,byte[] record) { - if(stateOnly)throw new InvalidOperationException("state-only scope");Pin generation=Require(generationToken,true);ValidateCurrent(generation);if(!Object.ReferenceEquals(generation.Parent,runtimeParent)||!String.Equals(generation.Leaf,runtimeId,StringComparison.Ordinal)||!generation.Owned)throw new InvalidDataException("generation not owned"); + if(stateOnly)throw new InvalidOperationException("state-only scope");if(productOnly)throw new InvalidOperationException("product-only scope");Pin generation=Require(generationToken,true);ValidateCurrent(generation);if(!Object.ReferenceEquals(generation.Parent,runtimeParent)||!String.Equals(generation.Leaf,runtimeId,StringComparison.Ordinal)||!generation.Owned)throw new InvalidDataException("generation not owned"); ValidateOwnedTree(generation);Pin state=OpenOrCreateDirectory(root,"state"),retirements=OpenOrCreateDirectory(state,"retired-generations");Write(retirements.Token,recordLeaf,record,false);return generation.Token; } [MethodImpl(MethodImplOptions.Synchronized)] internal object OpenProductFolder(string kind,string path) { - if(disposed)throw new ObjectDisposedException("transaction"); + if(stateOnly)throw new InvalidOperationException("state-only scope");if(disposed)throw new ObjectDisposedException("transaction"); string full=Path.GetFullPath(path).TrimEnd('\\');SafeFileHandle handle=OpenAbsoluteDirectory(full,true);Pin basePin=null; try { @@ -200,23 +206,20 @@ internal sealed class CcodInstallGenerationRuntimeV4 : IDisposable catch{if(handle!=null)handle.Dispose();throw;} } - [MethodImpl(MethodImplOptions.Synchronized)] internal object CopyProductShortcut(object folderToken,string kind,string source,string leaf,long expectedLength,string expectedSha) + [MethodImpl(MethodImplOptions.Synchronized)] internal object OpenRetainedFile(object generationToken,string relative) { - Pin parent=Require(folderToken,true);ValidateCurrent(parent); + if(stateOnly)throw new InvalidOperationException("state-only scope");if(relative!="registration/StartMenu.CodexRemote-fix.lnk"&&relative!="registration/Desktop.CodexRemote-fix.lnk")throw new InvalidDataException("product shortcut source");Pin cursor=Require(generationToken,true);ValidateCurrent(cursor);string[] segments=relative.Split('/');for(int i=0;i Date: Tue, 1 Sep 2026 20:23:32 +0800 Subject: [PATCH 098/187] docs: record matched product lifecycle evidence --- .../task-4-report.md | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) 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 index 102248b..878d731 100644 --- 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 @@ -95,3 +95,83 @@ performed. - `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`). From 283ecbe3eb012f1e78377e9336aacc7b3acc9336 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:49:25 +0800 Subject: [PATCH 099/187] fix: bind matched product finalization --- Uninstall-CodexControlOtherDevices.ps1 | 4 +-- .../InstalledUninstallFinalizer.ps1 | 25 ++++++------- src/persistence/UninstallBootstrap.ps1 | 34 ++++++++++++++---- .../modules/InstallFileTransaction.psm1 | 4 +-- src/persistence/modules/InstallLifecycle.psm1 | 4 +-- .../modules/ProductRegistration.psm1 | 35 +++++++++++-------- .../InstallFileTransaction.SelfTest.ps1 | 8 +++-- .../ProductRegistration.SelfTest.ps1 | 35 +++++++++++++++---- .../UninstallBootstrap.SelfTest.ps1 | 31 ++++++++++------ 9 files changed, 120 insertions(+), 60 deletions(-) diff --git a/Uninstall-CodexControlOtherDevices.ps1 b/Uninstall-CodexControlOtherDevices.ps1 index 7ce0c5a..c536c69 100644 --- a/Uninstall-CodexControlOtherDevices.ps1 +++ b/Uninstall-CodexControlOtherDevices.ps1 @@ -38,7 +38,8 @@ if ($null -ne $installedRuntimeId -and $installedRuntimeId -cmatch '^2\.5\.22-[0 $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} } - try { $prepared=& $bootstrapPath -InstallerRoot $installerRoot -InstallRoot $expectedInstallRoot -Mode PrepareInstalled } + $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=& $bootstrapPath -InstallerRoot $installerRoot -InstallRoot $expectedInstallRoot -Mode PrepareInstalled -WrapperIdentity $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')) @@ -47,7 +48,6 @@ if ($null -ne $installedRuntimeId -and $installedRuntimeId -cmatch '^2\.5\.22-[0 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} - $current=[Diagnostics.Process]::GetCurrentProcess();try{$wrapperId=[int]$current.Id;$wrapperCreated=$current.StartTime.ToUniversalTime().ToString('o',[Globalization.CultureInfo]::InvariantCulture)}finally{$current.Dispose()} function ConvertTo-CcodInstalledUninstallLiteral([string]$Value){"'"+$Value.Replace("'","''")+"'"} $arguments='-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-Process -FilePath $powershellPath -ArgumentList $arguments -WindowStyle Hidden -RedirectStandardOutput (Join-Path $transactionDirectory 'installed-finalizer.stdout.log') -RedirectStandardError (Join-Path $transactionDirectory 'installed-finalizer.stderr.log') -PassThru -ErrorAction Stop}catch{Throw-CcodPublicUninstallError 'CCOD_UNINSTALL_INSTALLED_FINALIZER_START_FAILED' 'The external installed finalizer could not be started; application state was retained.' $_} diff --git a/src/persistence/InstalledUninstallFinalizer.ps1 b/src/persistence/InstalledUninstallFinalizer.ps1 index 888ed3e..edf7b19 100644 --- a/src/persistence/InstalledUninstallFinalizer.ps1 +++ b/src/persistence/InstalledUninstallFinalizer.ps1 @@ -14,18 +14,19 @@ 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'} +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-CcodInstalledFinalizerAdapters { param([hashtable]$Adapters,[string]$TransactionRoot,[string]$PayloadRoot) $bootstrap=Join-Path $PayloadRoot 'src\persistence\UninstallBootstrap.ps1' $lifecycle=Join-Path $PayloadRoot 'src\persistence\modules\InstallLifecycle.psm1' $defaults=@{ - WaitWrapperExit={param($Identity,$Timeout);$stopwatch=[Diagnostics.Stopwatch]::StartNew();while($stopwatch.ElapsedMilliseconds-lt$Timeout){$process=Get-Process -Id $Identity.pid -ErrorAction SilentlyContinue;if($null-eq$process){return $true};try{if($process.StartTime.ToUniversalTime().ToString('o',[Globalization.CultureInfo]::InvariantCulture)-cne$Identity.creationTimeUtc){return $true}}finally{$process.Dispose()};Start-Sleep -Milliseconds 100};$false} + WaitWrapperExit={param($Identity,$Timeout);$process=Get-Process -Id $Identity.pid -ErrorAction SilentlyContinue;if($null-eq$process){return [pscustomobject]@{verifiedAtStart=$false;exited=$false}};try{if($process.StartTime.ToUniversalTime().ToString('o',[Globalization.CultureInfo]::InvariantCulture)-cne$Identity.creationTimeUtc-or[int]$process.SessionId-ne[int]$Identity.sessionId){return [pscustomobject]@{verifiedAtStart=$false;exited=$false}}}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=Get-Process -Id $Identity.pid -ErrorAction SilentlyContinue;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);. $bootstrap;$identity=Get-CcodUninstallBootstrapCurrentIdentity;$value=Read-CcodUninstallBootstrapStoredTransaction -TransactionRoot $TransactionRoot -ExpectedUserSid $identity.userSid -IncludeCompleted;if($null-eq$value-or$value.transactionId-cne$Id){return $null};$value}.GetNewClosure() - ValidateSelectedGeneration={param($SelectedRuntime,$Root,$Transaction);. $bootstrap;$context=Get-CcodUninstallBootstrapVerifiedRuntimeContext -InstallerRoot $SelectedRuntime -InstallRoot $Root -InvocationPath (Join-Path $SelectedRuntime 'src\persistence\UninstallBootstrap.ps1');$context.runtimeId-ceq$Transaction.runtimeId-and[uint64]$context.runtimeGeneration-eq[uint64]$Transaction.runtimeGeneration}.GetNewClosure() - RemoveMatchedApplicationState={param($SelectedRuntime,$Root,$Transaction);. $bootstrap;$module=Import-Module $lifecycle -Force -PassThru -ErrorAction Stop;$directory=Join-Path $TransactionRoot $Transaction.transactionId;$writer={param($Value)Write-CcodUninstallBootstrapStoredTransaction -TransactionDirectory $directory -Transaction $Value}.GetNewClosure();&$module {param($Install,$Value,$Write)Invoke-CcodUninstallCleanup -InstallRoot $Install -Transaction $Value -WriteTransaction $Write -StopAfterTaskRemoval:$false} $Root $Transaction $writer}.GetNewClosure() - TestInstallRootAbsent={param($Root)-not([IO.Directory]::Exists($Root)-or[IO.File]::Exists($Root))} - RemoveMatchedProductRegistration={param($Transaction);. $bootstrap;Remove-CcodUninstallBootstrapProductRegistration -Context ([pscustomobject][ordered]@{runtimeId=$Transaction.runtimeId;runtimeGeneration=[uint64]$Transaction.runtimeGeneration;leaseEpoch=[uint64]$Transaction.leaseEpoch;userSid=$Transaction.userSid;sessionId=[int]$Transaction.sessionId;payloadRecords=New-CcodUninstallBootstrapPayloadRecords -ResumeOnly})} + 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);. $bootstrap;$record=Read-CcodUninstallBootstrapJson -Path (Join-Path $Root 'state\lifecycle-epoch.json') -Kind 'Lifecycle epoch';[uint64]$record.epoch}.GetNewClosure() + RemoveSelectedGeneration={param($SelectedRuntime,$Transaction);. $bootstrap;$expected=[IO.Path]::GetFullPath([string]$Transaction.installedBinding.selectedRuntimeRoot);if([IO.Path]::GetFullPath($SelectedRuntime)-cne$expected){throw 'selected root changed'};$item=Get-Item -LiteralPath $expected -Force -ErrorAction Stop;if(-not$item.PSIsContainer-or($item.Attributes-band[IO.FileAttributes]::ReparsePoint)-ne0){throw 'selected root unsafe'};Remove-Item -LiteralPath $expected -Recurse -Force -ErrorAction Stop;$Transaction.phase='ReadyForInno';$Transaction.resumePhase='ReadyForInno';Write-CcodUninstallBootstrapStoredTransaction -TransactionDirectory (Join-Path $TransactionRoot $Transaction.transactionId) -Transaction $Transaction;$Transaction}.GetNewClosure() + TestSelectedRootAbsent={param($Root)-not([IO.Directory]::Exists($Root)-or[IO.File]::Exists($Root))} + RemoveMatchedProductRegistration={param($Transaction);. $bootstrap;Remove-CcodUninstallBootstrapProductRegistration -Context ([pscustomobject][ordered]@{runtimeId=$Transaction.runtimeId;runtimeGeneration=[uint64]$Transaction.runtimeGeneration;leaseEpoch=[uint64]$Transaction.leaseEpoch;userSid=$Transaction.userSid;sessionId=[int]$Transaction.sessionId;readyEvidence=$Transaction.readyEvidence;payloadRecords=New-CcodUninstallBootstrapPayloadRecords -ResumeOnly})} FinalizeReceipt={param($Transaction);& $bootstrap -InstallerRoot $RuntimeRoot -InstallRoot $InstallRoot -Mode FinalizeReceipt;Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Finalization bootstrap returned unexpectedly' $Transaction.transactionId}.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 @@ -35,12 +36,12 @@ function Invoke-CcodInstalledUninstallFinalizer { if($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(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);$expected=[IO.Path]::GetFullPath((Join-Path (Join-Path $install 'runtime') ([IO.Path]::GetFileName($runtime))));if($runtime-cne$expected){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Runtime root is outside the fixed install runtime parent' $runtime} $local=[Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData);$transactionRoot=[IO.Path]::GetFullPath((Join-Path $local 'CodexRemote-fix-uninstall'));$payloadRoot=[IO.Path]::GetFullPath((Join-Path (Join-Path $transactionRoot $TransactionId) 'payload'));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};$self=Get-Item -LiteralPath $expectedSelf -Force -ErrorAction Stop;if($self.PSIsContainer-or($self.Attributes-band[IO.FileAttributes]::ReparsePoint)-ne0){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Installed finalizer path is unsafe' $expectedSelf}};$adapter=Get-CcodInstalledFinalizerAdapters -Adapters $Adapters -TransactionRoot $transactionRoot -PayloadRoot $payloadRoot - if(-not(& $adapter.WaitWrapperExit $WrapperIdentity 15000)){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Installed wrapper did not exit with the expected identity' $WrapperIdentity} + $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} $transaction=&$adapter.ReadPreparedTransaction $TransactionId - if($null-eq$transaction-or$transaction.transactionId-cne$TransactionId-or$transaction.phase-cne'TaskRemoved'-or$transaction.resumePhase-cne'TaskRemoved'){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Durable installed uninstall transaction is not at TaskRemoved' $transaction} - if(-not(&$adapter.ValidateSelectedGeneration $runtime $install $transaction)){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Selected generation proof does not match the transaction' $runtime} - $ready=&$adapter.RemoveMatchedApplicationState $runtime $install $transaction - if($null-eq$ready-or$ready.phase-cne'ReadyForInno'-or-not(&$adapter.TestInstallRootAbsent $install)){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Matched application state removal was not proven' $install} + $binding=if($null-ne$transaction){$transaction.installedBinding}else{$null};if($null-eq$transaction-or$transaction.transactionId-cne$TransactionId-or$transaction.phase-cne'TaskRemoved'-or$transaction.resumePhase-cne'TaskRemoved'-or$transaction.runtimeId-isnot[string]-or[IO.Path]::GetFileName($runtime)-cne[string]$transaction.runtimeId-or$null-eq$binding-or[IO.Path]::GetFullPath([string]$binding.selectedRuntimeRoot)-cne$runtime-or$binding.runtimeManifestSha256-isnot[string]-or$binding.runtimeManifestSha256-cnotmatch'^[0-9a-f]{64}$'-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' 'Durable installed uninstall transaction identity is invalid' $transaction} + if([uint64](&$adapter.ReadCurrentEpoch $install)-ne[uint64]$transaction.leaseEpoch-or-not(&$adapter.ValidateSelectedGeneration $runtime $install $transaction)){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Selected generation manifest or epoch does not match the transaction' $runtime} + $ready=&$adapter.RemoveSelectedGeneration $runtime $transaction + if($null-eq$ready-or$ready.phase-cne'ReadyForInno'-or-not(&$adapter.TestSelectedRootAbsent $runtime)){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Selected generation removal was not proven' $runtime} &$adapter.RemoveMatchedProductRegistration $ready $receipt=&$adapter.FinalizeReceipt $ready if($null-eq$receipt-or$receipt.phase-cne'Completed'){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Completion receipt was not proven' $TransactionId} @@ -48,6 +49,6 @@ function Invoke-CcodInstalledUninstallFinalizer { } if($MyInvocation.InvocationName-ne'.'){ - $identity=[pscustomobject]@{pid=[int]$WrapperProcessId;creationTimeUtc=$WrapperCreationTimeUtc} + $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|Out-Null;exit 0}catch{Write-Error ([string]$_.FullyQualifiedErrorId);exit 3} } diff --git a/src/persistence/UninstallBootstrap.ps1 b/src/persistence/UninstallBootstrap.ps1 index 478d5e7..1e545f6 100644 --- a/src/persistence/UninstallBootstrap.ps1 +++ b/src/persistence/UninstallBootstrap.ps1 @@ -2,7 +2,8 @@ param( [string]$InstallerRoot, [string]$InstallRoot, - [ValidateSet('Prepare','PrepareInstalled','FinalizeReceipt')][string]$Mode + [ValidateSet('Prepare','PrepareInstalled','FinalizeReceipt')][string]$Mode, + $WrapperIdentity ) Set-StrictMode -Version Latest @@ -10,13 +11,13 @@ $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', @@ -348,6 +349,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 @@ -431,12 +433,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 { @@ -792,11 +797,13 @@ function Assert-CcodUninstallBootstrapContext { $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') + $ready=$Context.readyEvidence;if(-not(Test-CcodUninstallBootstrapExactProperties $ready @('phase','installRoot','runtimeId','runtimeGeneration','packageSha256','manifestSha256','startMenuSha256','desktopSha256','targetPath','arguments'))-or$ready.phase-cne'Ready'-or$ready.installRoot-isnot[string]-or$ready.runtimeId-cne$Context.runtimeId-or[uint64]$ready.runtimeGeneration-ne[uint64]$Context.runtimeGeneration-or$ready.packageSha256-cnotmatch'^[0-9a-f]{64}$'-or$ready.manifestSha256-cnotmatch'^[0-9a-f]{64}$'-or$ready.startMenuSha256-cnotmatch'^[0-9a-f]{64}$'-or$ready.desktopSha256-cnotmatch'^[0-9a-f]{64}$'-or$ready.targetPath-isnot[string]-or$ready.arguments-cne'/Run /TN "Codex Control Other Devices Supervisor"'){Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_BOOTSTRAP_INVALID' 'Ready cleanup evidence is invalid' $ready} $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++) { @@ -828,6 +835,8 @@ function Assert-CcodUninstallBootstrapTransaction { } [void](ConvertTo-CcodUninstallBootstrapUInt64 $Transaction.runtimeGeneration 'runtimeGeneration') [void](ConvertTo-CcodUninstallBootstrapUInt64 $Transaction.leaseEpoch 'leaseEpoch') + if($null-eq$Transaction.readyEvidence-or$Transaction.readyEvidence.runtimeId-cne$Transaction.runtimeId-or[uint64]$Transaction.readyEvidence.runtimeGeneration-ne[uint64]$Transaction.runtimeGeneration){Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_TRANSACTION_INVALID' 'Ready transaction evidence is missing or mismatched' $Transaction.readyEvidence} + if($null-ne$Transaction.installedBinding){$binding=$Transaction.installedBinding;if(-not(Test-CcodUninstallBootstrapExactProperties $binding @('selectedRuntimeRoot','runtimeManifestSha256','wrapperPid','wrapperCreationTimeUtc','wrapperSessionId','wrapperUserSid'))-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.wrapperUserSid-isnot[string]-or$binding.wrapperUserSid-cnotmatch'^S-'){Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_TRANSACTION_INVALID' 'Installed finalization binding is invalid' $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') @@ -839,7 +848,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 @@ -849,6 +858,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 @@ -930,6 +941,12 @@ function Get-CcodUninstallBootstrapAdapters { param($Context) Remove-CcodUninstallBootstrapProductRegistration -Context $Context } + GetInstalledBinding = { + param($InstallerRoot,$InstallRoot,$Context,$WrapperIdentity) + $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') + [pscustomobject][ordered]@{selectedRuntimeRoot=$selected;runtimeManifestSha256=[string]$manifest.sha256;wrapperPid=[int]$WrapperIdentity.pid;wrapperCreationTimeUtc=[string]$WrapperIdentity.creationTimeUtc;wrapperSessionId=[int]$WrapperIdentity.sessionId;wrapperUserSid=[string]$WrapperIdentity.userSid} + } TestInstallRootAbsent = { param($InstallerRoot) -not ([IO.Directory]::Exists($InstallerRoot) -or [IO.File]::Exists($InstallerRoot)) } GetUtcNow = { [DateTime]::UtcNow } } @@ -950,7 +967,7 @@ 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 -ExpectedRuntimeId ([string]$Context.runtimeId)}catch{Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_PRODUCT_REGISTRATION_INVALID' 'Current product registration did not match the verified uninstall runtime' $Context.runtimeId} + 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 { @@ -972,6 +989,7 @@ function Invoke-CcodUninstallBootstrap { [Parameter(Mandatory)][string]$InstallerRoot, [Parameter(Mandatory)][string]$InstallRoot, [Parameter(Mandatory)][ValidateSet('Prepare','PrepareInstalled','FinalizeReceipt')][string]$Mode, + $WrapperIdentity, [hashtable]$Adapters ) @@ -1040,12 +1058,14 @@ 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}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 + if($Mode-ceq'PrepareInstalled'){$binding=&$adapter.GetInstalledBinding $InstallerRoot $InstallRoot $context $WrapperIdentity;if($null-eq$transaction.installedBinding-or($binding|ConvertTo-Json -Compress)-cne($transaction.installedBinding|ConvertTo-Json -Compress)){Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_TRANSACTION_MISMATCH' 'Installed wrapper binding changed during resume' $binding}} $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 } $stagePayload = $transaction.phase -ceq 'Requested' -or ($transaction.phase -ceq 'Failed' -and $transaction.resumePhase -ceq 'Requested') @@ -1072,7 +1092,7 @@ function Invoke-CcodUninstallBootstrap { 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 } diff --git a/src/persistence/modules/InstallFileTransaction.psm1 b/src/persistence/modules/InstallFileTransaction.psm1 index c3ace77..b58b62d 100644 --- a/src/persistence/modules/InstallFileTransaction.psm1 +++ b/src/persistence/modules/InstallFileTransaction.psm1 @@ -365,9 +365,9 @@ function Open-CcodInstallGeneration { $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;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;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-CcodInstallProductRegistrationTransaction {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 OpenProduct $arguments} 'CCOD_INSTALL_PRODUCT_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=$true;Closed=$false;CleanupFailed=$false;Retired=$false;RetirementResult=$null;Disposition=$null});$script:CcodScopes.Add($transaction,[pscustomobject]@{Transaction=$transaction;Token=$token;Kind='ProductTransaction';ReadOnly=$true;RuntimeId=$null});$transaction} +function Open-CcodInstallProductRegistrationTransaction {param([Parameter(Mandatory)][string]$InstallRoot,[Parameter(Mandatory)]$ReadyEvidence)Initialize-CcodInstallRuntime;if(-not[IO.Path]::IsPathRooted($InstallRoot)){Throw-CcodInstallFileError 'CCOD_INSTALL_ROOT_INVALID' 'Install root must be absolute' $InstallRoot};$properties=@($ReadyEvidence.PSObject.Properties);$names=@('phase','runtimeId','runtimeGeneration','manifestSha256','packageSha256');if($properties.Count-ne$names.Count-or(@($properties.Name)-join'|')-cne($names-join'|')-or$ReadyEvidence.phase-cne'Ready'-or$ReadyEvidence.runtimeId-isnot[string]-or[uint64]$ReadyEvidence.runtimeGeneration-lt1-or$ReadyEvidence.manifestSha256-cnotmatch'^[0-9a-f]{64}$'-or$ReadyEvidence.packageSha256-cnotmatch'^[0-9a-f]{64}$'){Throw-CcodInstallFileError 'CCOD_INSTALL_PRODUCT_SCOPE' 'Product transaction requires exact Ready evidence' $ReadyEvidence};$root=[IO.Path]::GetFullPath($InstallRoot);$pointerRoot=Join-Path $root 'state\active-generation';$pointerLeaves=@(Get-ChildItem -LiteralPath $pointerRoot -File -Force -ErrorAction Stop|Sort-Object Name);if($pointerLeaves.Count-eq0-or$pointerLeaves[-1].Name-cne('{0:D20}.json'-f[uint64]$ReadyEvidence.runtimeGeneration)){Throw-CcodInstallFileError 'CCOD_INSTALL_PRODUCT_SCOPE' 'Ready evidence is not the latest selected generation' $ReadyEvidence};$pointerPath=Join-Path $root ('state\active-generation\{0:D20}.json'-f[uint64]$ReadyEvidence.runtimeGeneration);$manifestPath=Join-Path (Join-Path (Join-Path $root 'runtime') $ReadyEvidence.runtimeId) 'manifest.json';try{$pointer=Get-Content -LiteralPath $pointerPath -Raw -ErrorAction Stop|ConvertFrom-Json -ErrorAction Stop;if([string]$pointer.activeRuntime-cne[string]$ReadyEvidence.runtimeId-or[uint64]$pointer.generation-ne[uint64]$ReadyEvidence.runtimeGeneration-or(Get-FileHash -LiteralPath $manifestPath -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()-cne[string]$ReadyEvidence.manifestSha256){throw 'selected pointer or manifest mismatch'};$matches=@();foreach($file in @(Get-ChildItem -LiteralPath (Join-Path $root 'state\install-transactions') -File -Force -ErrorAction Stop)){try{$record=Get-Content -LiteralPath $file.FullName -Raw -ErrorAction Stop|ConvertFrom-Json -ErrorAction Stop;if($record.phase-ceq'Ready'-and$record.newRuntimeId-ceq$ReadyEvidence.runtimeId-and[uint64]$record.newGeneration-eq[uint64]$ReadyEvidence.runtimeGeneration-and$record.newManifestSha256-ceq$ReadyEvidence.manifestSha256-and$record.sealedPackageSha256-ceq$ReadyEvidence.packageSha256){$matches+=,$record}}catch{throw}};if($matches.Count-ne1){throw 'matching Ready record missing or ambiguous'}}catch{Throw-CcodInstallFileError 'CCOD_INSTALL_PRODUCT_SCOPE' 'Selected pointer manifest and Ready record could not be proven' $ReadyEvidence};$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=$ReadyEvidence.runtimeId;RootToken=$token;StateOnly=$false;ProductOnly=$true;ReadyEvidence=$ReadyEvidence;Closed=$false;CleanupFailed=$false;Retired=$false;RetirementResult=$null;Disposition=$null});$script:CcodScopes.Add($transaction,[pscustomobject]@{Transaction=$transaction;Token=$token;Kind='ProductTransaction';ReadOnly=$true;RuntimeId=$ReadyEvidence.runtimeId});$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 } -function Open-CcodInstallRetainedFile {param([Parameter(Mandatory)]$Generation,[Parameter(Mandatory)][string]$RelativePath)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';$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 Open-CcodInstallRetainedFile {param([Parameter(Mandatory)]$Generation,[Parameter(Mandatory)][string]$RelativePath,[Parameter(Mandatory)]$ReadyEvidence)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';if($ReadyEvidence.phase-cne'Ready'-or$ReadyEvidence.runtimeId-cne$scope.Record.RuntimeId-or[uint64]$ReadyEvidence.runtimeGeneration-lt1-or$ReadyEvidence.manifestSha256-cnotmatch'^[0-9a-f]{64}$'-or$ReadyEvidence.packageSha256-cnotmatch'^[0-9a-f]{64}$'){Throw-CcodInstallFileError 'CCOD_INSTALL_PRODUCT_SCOPE' 'Retained shortcut source lacks exact Ready selection evidence' $ReadyEvidence};$root=$scope.State.InstallRoot;$pointer=Get-Content -LiteralPath (Join-Path $root ('state\active-generation\{0:D20}.json'-f[uint64]$ReadyEvidence.runtimeGeneration)) -Raw -ErrorAction Stop|ConvertFrom-Json -ErrorAction Stop;if($pointer.activeRuntime-cne$ReadyEvidence.runtimeId-or[uint64]$pointer.generation-ne[uint64]$ReadyEvidence.runtimeGeneration){Throw-CcodInstallFileError 'CCOD_INSTALL_PRODUCT_SCOPE' 'Retained shortcut source is not selected' $ReadyEvidence};$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 } diff --git a/src/persistence/modules/InstallLifecycle.psm1 b/src/persistence/modules/InstallLifecycle.psm1 index 4c97c37..dd3745a 100644 --- a/src/persistence/modules/InstallLifecycle.psm1 +++ b/src/persistence/modules/InstallLifecycle.psm1 @@ -1954,9 +1954,9 @@ function Get-CcodLifecycleAdapters { $module=Import-Module (Join-Path $PSScriptRoot 'ProductRegistration.psm1') -Force -PassThru -ErrorAction Stop $ownedTransaction=$null;$sourceCapability=$FileTransaction try{ - if($null-eq$sourceCapability){Import-Module (Join-Path $PSScriptRoot 'InstallFileTransaction.psm1') -ErrorAction Stop;$ownedTransaction=Open-CcodInstallProductRegistrationTransaction -InstallRoot $InstallRoot;$sourceCapability=Open-CcodInstallRetainedGeneration -InstallRoot $InstallRoot -RuntimeId $RuntimeId -ExpectedManifestSha256 ([string]$TransactionRecord.newManifestSha256) -FileTransaction $ownedTransaction} + if($null-eq$sourceCapability){Import-Module (Join-Path $PSScriptRoot 'InstallFileTransaction.psm1') -ErrorAction Stop;$fileReady=[pscustomobject][ordered]@{phase='Ready';runtimeId=$RuntimeId;runtimeGeneration=[uint64]$TransactionRecord.newGeneration;manifestSha256=[string]$TransactionRecord.newManifestSha256;packageSha256=$PackageSha256};$ownedTransaction=Open-CcodInstallProductRegistrationTransaction -InstallRoot $InstallRoot -ReadyEvidence $fileReady;$sourceCapability=Open-CcodInstallRetainedGeneration -InstallRoot $InstallRoot -RuntimeId $RuntimeId -ExpectedManifestSha256 ([string]$TransactionRecord.newManifestSha256) -FileTransaction $ownedTransaction} $registration=New-CcodProductRegistration -InstallRoot $InstallRoot -RuntimeId $RuntimeId -Version $Version -PackageSha256 $PackageSha256 -FileTransaction $sourceCapability - $proof=[pscustomobject][ordered]@{phase=[string]$TransactionRecord.phase;runtimeId=$RuntimeId;version=$Version;packageSha256=$PackageSha256;bootstrapPath=$registration.bootstrapPath;uninstallerPath=$registration.uninstallerPath} + $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"';bootstrapPath=$registration.bootstrapPath;uninstallerPath=$registration.uninstallerPath} $receipt=Commit-CcodProductRegistration -Registration $registration -FileTransaction $sourceCapability -Adapters @{GetReadyProof={param($Ignored)$proof}.GetNewClosure()} Remove-CcodLegacyProductRegistration -ExpectedAppId '{2B9E9F2E-7A32-4A7E-9C1D-9F5B5C6D7E8F}' [pscustomobject]@{verified=[bool]$receipt.verified;legacyRemoved=$true} diff --git a/src/persistence/modules/ProductRegistration.psm1 b/src/persistence/modules/ProductRegistration.psm1 index f24a167..3de4b65 100644 --- a/src/persistence/modules/ProductRegistration.psm1 +++ b/src/persistence/modules/ProductRegistration.psm1 @@ -8,7 +8,7 @@ $script:CcodProductFields = @( 'productKeyName','startMenuShortcut','desktopShortcut' ) $script:CcodShortcutFields = @('kind','leaf','relativePath','candidatePath','bootstrapPath','installRoot') -$script:CcodReadyFields = @('phase','runtimeId','version','packageSha256','bootstrapPath','uninstallerPath') +$script:CcodReadyFields = @('phase','runtimeId','version','packageSha256','runtimeGeneration','manifestSha256','startMenuSha256','desktopSha256','targetPath','arguments','bootstrapPath','uninstallerPath') $script:CcodLegacyShortcutNames = @( 'Programs\Codex Control other devices\Codex Control other devices for Windows.lnk', 'Programs\Codex Control other devices\Open the tray supervisor.lnk', @@ -120,10 +120,12 @@ function Test-CcodProductRegistration { 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 $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 } @@ -132,9 +134,9 @@ 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) Write-CcodCurrentUserProductRegistration $Registration } + WriteProductRegistration = { param($Registration,$ReadyEvidence) Write-CcodCurrentUserProductRegistration $Registration $ReadyEvidence } ReadProductRegistration = { param($Registration) Read-CcodCurrentUserProductRegistration $Registration } - WriteShortcut = { param($Kind,$Shortcut,$FileTransaction) Write-CcodCurrentUserProductShortcut -Kind $Kind -Shortcut $Shortcut -FileTransaction $FileTransaction } + 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 } @@ -145,6 +147,7 @@ function Get-CcodProductRegistrationAdapters { 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 } @@ -168,12 +171,13 @@ function Get-CcodProductUninstallCommand($Registration) { '"{0}" -NoProfile -ExecutionPolicy Bypass -File "{1}"' -f (Get-CcodProductPowerShellPath),([string]$Registration.uninstallerPath).Replace('"','') } function Write-CcodCurrentUserProductRegistration { - param($Registration) + 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 - $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;CcodPackageSha256=[string]$Registration.packageSha256;CcodStartMenuSha256=Get-CcodProductFileSha256 ([string]$Registration.startMenuShortcut.candidatePath);CcodDesktopSha256=Get-CcodProductFileSha256 ([string]$Registration.desktopShortcut.candidatePath);CcodShortcutTarget=$target;CcodShortcutArguments=$arguments} - foreach($name in $values.Keys){$type=if($name-in@('NoModify','NoRepair')){'DWord'}else{'String'};New-ItemProperty -LiteralPath $path -Name $name -Value $values[$name] -PropertyType $type -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) @@ -201,10 +205,10 @@ function New-CcodProductShortcutCandidates { }finally{if($null-ne$shell){[void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($shell)}} } function Write-CcodCurrentUserProductShortcut { - param([string]$Kind,$Shortcut,$FileTransaction) + 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 + $fileReady=[pscustomobject][ordered]@{phase='Ready';runtimeId=[string]$ReadyEvidence.runtimeId;runtimeGeneration=[uint64]$ReadyEvidence.runtimeGeneration;manifestSha256=[string]$ReadyEvidence.manifestSha256;packageSha256=[string]$ReadyEvidence.packageSha256};$source=Open-CcodInstallRetainedFile -Generation $FileTransaction -RelativePath $relative -ReadyEvidence $fileReady $folder=Open-CcodInstallProductSpecialFolder -Generation $FileTransaction -Kind $Kind Copy-CcodInstallProductShortcut -Folder $folder -Source $source -Kind $Kind -Leaf 'CodexRemote-fix.lnk'|Out-Null } @@ -244,7 +248,7 @@ 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','CcodShortcutArguments','CcodShortcutTarget','CcodStartMenuSha256','DisplayName','DisplayVersion','InstallLocation','NoModify','NoRepair','Publisher','QuietUninstallString','UninstallString');$names=@($key.GetValueNames()|Sort-Object);if(($names-join'|')-cne(($allowed|Sort-Object)-join'|')){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=@()}}};foreach($stringName in @($allowed|Where-Object{$_-notin@('NoModify','NoRepair')})){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)}};return [pscustomobject]@{valid=$true;reason=$null;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})}} + try{$allowed=@('CcodDesktopSha256','CcodPackageSha256','CcodRuntimeId','CcodRuntimeGeneration','CcodManifestSha256','CcodShortcutArguments','CcodShortcutTarget','CcodStartMenuSha256','DisplayName','DisplayVersion','InstallLocation','NoModify','NoRepair','Publisher','QuietUninstallString','UninstallString');$names=@($key.GetValueNames()|Sort-Object);if(($names-join'|')-cne(($allowed|Sort-Object)-join'|')){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=@()}} } @@ -255,8 +259,8 @@ function Remove-CcodCurrentProductEntry { 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)][string]$ExpectedRuntimeId,[hashtable]$Adapters) - if($ExpectedRuntimeId-cnotmatch'^2\.5\.22-[0-9a-f]{16}-[0-9a-f]{32}$'){Throw-CcodProductRegistrationError 'CCOD_PRODUCT_CLEANUP_INVALID' 'Expected product runtime ID is invalid' $ExpectedRuntimeId};$adapter=Get-CcodProductRegistrationAdapters $Adapters;$state=&$adapter.ReadCurrentProductState $ExpectedRuntimeId;if($null-eq$state-or$state.valid-isnot[bool]-or-not$state.valid-or$null-eq$state.entries){Throw-CcodProductRegistrationError 'CCOD_PRODUCT_CLEANUP_INVALID' 'Current product state is not an exact matched registration' $ExpectedRuntimeId};$entries=@($state.entries);if($entries.Count-notin@(0,3)){Throw-CcodProductRegistrationError 'CCOD_PRODUCT_CLEANUP_INVALID' 'Current product state is ambiguous' $ExpectedRuntimeId};foreach($entry in $entries){&$adapter.RemoveCurrentProductEntry $entry} + [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 Read-CcodLegacySnapshot { param([string]$ExpectedAppId) @@ -265,6 +269,7 @@ function Read-CcodLegacySnapshot { function Remove-CcodLegacySnapshotEntry {param($Entry);if($Entry-is[string]){return};if($Entry.kind-ceq'Registry'){$key=$null;try{$key=Get-Item -LiteralPath $Entry.path -ErrorAction Stop;if((@($key.GetValueNames()|Sort-Object)-join'|')-cne((@($Entry.values.Keys)|Sort-Object)-join'|')-or$key.GetSubKeyNames().Count-ne0){throw 'legacy registry changed'};foreach($name in @($Entry.values.Keys)){$current=$key.GetValue($name,$null,[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames);if([string]$current-cne[string]$Entry.values[$name].value-or[string]$key.GetValueKind($name)-cne[string]$Entry.values[$name].kind){throw 'legacy registry value changed'}};foreach($name in @($Entry.values.Keys)){$key.DeleteValue($name,$true)};$key.Dispose();$key=$null;Remove-Item -LiteralPath $Entry.path -Force -ErrorAction Stop;return}catch{if($null-ne$key){$key.Dispose()};if(-not(Test-Path -LiteralPath $Entry.path)){New-Item -Path $Entry.path -Force|Out-Null};foreach($name in $Entry.values.Keys){if($null-eq(Get-ItemProperty -LiteralPath $Entry.path -Name $name -ErrorAction SilentlyContinue)){$kind=[Enum]::Parse([Microsoft.Win32.RegistryValueKind],[string]$Entry.values[$name].kind);New-ItemProperty -LiteralPath $Entry.path -Name $name -Value $Entry.values[$name].value -PropertyType $kind -Force|Out-Null}};throw}};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 Read-CcodLegacySnapshotEntry {param($Entry);if($Entry-is[string]){return $null};if($Entry.kind-ceq'Registry'){if(Test-Path -LiteralPath $Entry.path){return 'Present'};return $null};if([IO.File]::Exists($Entry.path)){return Get-CcodProductFileSha256 $Entry.path};$null} function Restore-CcodLegacySnapshotEntry {param($Entry);if($Entry-is[string]){return};if($Entry.kind-ceq'Registry'){if(Test-Path -LiteralPath $Entry.path){throw 'legacy registry replacement present'};New-Item -Path $Entry.path -Force|Out-Null;foreach($name in $Entry.values.Keys){$kind=[Enum]::Parse([Microsoft.Win32.RegistryValueKind],[string]$Entry.values[$name].kind);New-ItemProperty -LiteralPath $Entry.path -Name $name -Value $Entry.values[$name].value -PropertyType $kind -Force|Out-Null};return};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()] @@ -278,9 +283,9 @@ function Commit-CcodProductRegistration { Throw-CcodProductRegistrationError 'CCOD_PRODUCT_REGISTRATION_NOT_READY' 'Product registration requires the exact terminal Ready proof.' $Registration.runtimeId } try { - & $adapter.WriteProductRegistration $Registration - & $adapter.WriteShortcut 'StartMenu' $Registration.startMenuShortcut $FileTransaction - & $adapter.WriteShortcut 'Desktop' $Registration.desktopShortcut $FileTransaction + & $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 @@ -321,7 +326,7 @@ function Remove-CcodLegacyProductRegistration { $snapshot=&$adapter.ReadLegacySnapshot $ExpectedAppId if($null-eq$snapshot-or$snapshot.appId-cne$ExpectedAppId-or@($snapshot.entries).Count-ne9){Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Legacy snapshot is incomplete or ambiguous' $ExpectedAppId} $removed=[Collections.Generic.List[object]]::new() - try{foreach($entry in @($snapshot.entries)){&$adapter.RemoveLegacyEntry $entry;$removed.Add($entry)}}catch{for($index=$removed.Count-1;$index-ge0;$index--){$entry=$removed[$index];try{if($null-eq(&$adapter.ReadLegacyEntry $entry)){&$adapter.RestoreLegacyEntry $entry}}catch{}};Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Legacy product migration failed and exact prior entries were compensated where still absent.' $ExpectedAppId} + try{foreach($entry in @($snapshot.entries)){&$adapter.RemoveLegacyEntry $entry;$removed.Add($entry)}}catch{$unresolved=[Collections.Generic.List[string]]::new();for($index=$removed.Count-1;$index-ge0;$index--){$entry=$removed[$index];$name=if($entry-is[string]){[string]$entry}elseif($entry.kind-ceq'Registry'){'Registry'}else{[string]$entry.name};try{if($null-ne(&$adapter.ReadLegacyEntry $entry)){$unresolved.Add($name);continue};&$adapter.RestoreLegacyEntry $entry;if($null-eq(&$adapter.ReadLegacyEntry $entry)){$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 diff --git a/tests/persistence/InstallFileTransaction.SelfTest.ps1 b/tests/persistence/InstallFileTransaction.SelfTest.ps1 index c2ad124..0649f44 100644 --- a/tests/persistence/InstallFileTransaction.SelfTest.ps1 +++ b/tests/persistence/InstallFileTransaction.SelfTest.ps1 @@ -149,11 +149,13 @@ Invoke-CcodTest 'exports only immutable generation operations and an inert CLR m 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}));Close-CcodInstallFileTransaction -Transaction $generation -Disposition Ready - $product=Open-CcodInstallProductRegistrationTransaction -InstallRoot $fixture.Install;$fixture.Transactions.Add($product);$retained=Open-CcodInstallRetainedGeneration -InstallRoot $fixture.Install -RuntimeId $runtimeId -ExpectedManifestSha256 $manifest.Sha256 -FileTransaction $product;$file=Open-CcodInstallRetainedFile -Generation $retained -RelativePath 'registration/StartMenu.CodexRemote-fix.lnk' + $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 + $ready=[pscustomobject][ordered]@{phase='Ready';runtimeId=$runtimeId;runtimeGeneration=[uint64]1;manifestSha256=$manifest.Sha256;packageSha256=('d'*64)};$transactions=Join-Path $fixture.Install 'state\install-transactions';[IO.Directory]::CreateDirectory($transactions)|Out-Null;[IO.File]::WriteAllText((Join-Path $transactions 'ready.json'),([ordered]@{phase='Ready';newRuntimeId=$runtimeId;newGeneration=[uint64]1;newManifestSha256=$manifest.Sha256;sealedPackageSha256=('d'*64)}|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new($false));$product=Open-CcodInstallProductRegistrationTransaction -InstallRoot $fixture.Install -ReadyEvidence $ready;$fixture.Transactions.Add($product);$retained=Open-CcodInstallRetainedGeneration -InstallRoot $fixture.Install -RuntimeId $runtimeId -ExpectedManifestSha256 $manifest.Sha256 -FileTransaction $product;$file=Open-CcodInstallRetainedFile -Generation $retained -RelativePath 'registration/StartMenu.CodexRemote-fix.lnk' -ReadyEvidence $ready 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'|Out-Null} 'CCOD_INSTALL_PRODUCT_SHORTCUT_INVALID' + Assert-CcodThrows {Open-CcodInstallRetainedFile -Generation $retained -RelativePath 'C:\outside\arbitrary.lnk' -ReadyEvidence $ready|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=$ready.PSObject.Copy();$preReady.phase='ProtectionReady';Assert-CcodThrows {Open-CcodInstallProductRegistrationTransaction -InstallRoot $fixture.Install -ReadyEvidence $preReady|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-CcodInstallProductRegistrationTransaction -InstallRoot $fixture.Install -ReadyEvidence $ready|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} } diff --git a/tests/persistence/ProductRegistration.SelfTest.ps1 b/tests/persistence/ProductRegistration.SelfTest.ps1 index 27e94be..7015e29 100644 --- a/tests/persistence/ProductRegistration.SelfTest.ps1 +++ b/tests/persistence/ProductRegistration.SelfTest.ps1 @@ -11,6 +11,8 @@ Import-Module $modulePath -Force $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')) +$cleanupReady=[pscustomobject][ordered]@{phase='Ready';installRoot='C:\fixture\CodexControlOtherDevices';runtimeId=$runtimeId;runtimeGeneration=[uint64]7;packageSha256=$packageSha256;manifestSha256=('a'*64);startMenuSha256=('b'*64);desktopSha256=('c'*64);targetPath=$canonicalTaskTarget;arguments='/Run /TN "Codex Control Other Devices Supervisor"'} function New-CcodRegistrationWorld { param([string]$InstallRoot = 'C:\fixture\CodexControlOtherDevices') @@ -24,6 +26,12 @@ function New-CcodRegistrationWorld { runtimeId = $runtimeId version = '2.5.22' packageSha256 = $packageSha256 + runtimeGeneration = [uint64]7 + manifestSha256 = 'a'*64 + startMenuSha256 = 'b'*64 + desktopSha256 = 'c'*64 + targetPath = $canonicalTaskTarget + arguments = '/Run /TN "Codex Control Other Devices Supervisor"' bootstrapPath = $registration.bootstrapPath uninstallerPath = $registration.uninstallerPath } @@ -50,12 +58,14 @@ function New-CcodRegistrationWorld { LegacyRemoved = $false LegacyRemovalCalls = 0 CurrentProductRemovals = 0 - CurrentProductState = [pscustomobject]@{valid=$true;reason=$null;entries=@('Registry','StartMenu','Desktop')} + 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() Calls = [Collections.Generic.List[string]]::new() } $world | Add-Member -NotePropertyName Adapters -NotePropertyValue @{ @@ -92,7 +102,8 @@ function New-CcodRegistrationWorld { ReadLegacySnapshot = { param($ExpectedAppId);if($world.Legacy.appId-cne$ExpectedAppId){return [pscustomobject]@{appId=$world.Legacy.appId;entries=@($world.LegacyEntries)}};if(@($world.Legacy.shortcutNames).Count-ne8){return [pscustomobject]@{appId=$ExpectedAppId;entries=@('Unexpected')}};[pscustomobject]@{appId=$ExpectedAppId;entries=@($world.LegacyEntries)} }.GetNewClosure() RemoveLegacyEntry = { param($Entry);$world.LegacyRemoveAttempts++;if($world.LegacyRemoveFailureAt-eq$world.LegacyRemoveAttempts){throw 'fixture legacy delete failure'};[void]$world.LegacyEntries.Remove([string]$Entry) }.GetNewClosure() ReadLegacyEntry = { param($Entry);if($world.LegacyReplacementEntry-ceq[string]$Entry){return 'Replacement'};if($world.LegacyEntries.Contains([string]$Entry)){[string]$Entry}else{$null} }.GetNewClosure() - RestoreLegacyEntry = { param($Entry);if(-not$world.LegacyEntries.Contains([string]$Entry)){$world.LegacyEntries.Add([string]$Entry)};$world.LegacyRestores++ }.GetNewClosure() + RestoreLegacyEntry = { param($Entry);if($world.LegacyRestoreFailureEntry-ceq[string]$Entry){throw 'fixture restore failed'};if(-not$world.LegacyEntries.Contains([string]$Entry)){$world.LegacyEntries.Add([string]$Entry)};$world.LegacyRestores++ }.GetNewClosure() + WriteLegacyCompensationFailure = {param($Record)$world.LegacyUnresolvedRecords.Add($Record)}.GetNewClosure() } return $world } @@ -114,6 +125,7 @@ $results += Invoke-CcodTest 'registration performs no writes before an exact Rea { 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' } )) { @@ -192,14 +204,17 @@ $results += Invoke-CcodTest 'legacy migration rejects AppId and shortcut-name mi # 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','ShortcutHash','ShortcutTarget','ShortcutReparse','Ambiguous')){ + foreach($mutation in @('UnknownRegistryValue','ShortcutHash','ShortcutTarget','ShortcutReparse','Ambiguous','CoherentReplacement')){ $world=New-CcodRegistrationWorld;$world.CurrentProductState.valid=$false;$world.CurrentProductState.reason=$mutation - Assert-CcodThrows {Remove-CcodProductRegistration -ExpectedRuntimeId $runtimeId -Adapters $world.Adapters} 'CCOD_PRODUCT_CLEANUP_INVALID' + 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 -ExpectedRuntimeId $runtimeId -Adapters $world.Adapters + 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: a mid-sequence legacy deletion failure leaves earlier exact entries missing. @@ -215,9 +230,17 @@ $results += Invoke-CcodTest 'legacy migration restores earlier exact deletions a $results += Invoke-CcodTest 'legacy compensation never overwrites a replacement that appeared after deletion' { $world=New-CcodRegistrationWorld;$null=Commit-CcodProductRegistration -Registration $world.Registration -FileTransaction ([pscustomobject]@{}) -Adapters $world.Adapters;$world.LegacyRemoveFailureAt=4;$world.LegacyReplacementEntry='StartMenu1' - Assert-CcodThrows {Remove-CcodLegacyProductRegistration -ExpectedAppId $appId -Adapters $world.Adapters} 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' + Assert-CcodThrows {Remove-CcodLegacyProductRegistration -ExpectedAppId $appId -Adapters $world.Adapters} 'CCOD_LEGACY_PRODUCT_COMPENSATION_FAILED' Assert-CcodEqual 2 $world.LegacyRestores 'compensation restores only entries that remain absent' Assert-CcodTrue (-not$world.LegacyEntries.Contains('StartMenu1')) '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;$null=Commit-CcodProductRegistration -Registration $world.Registration -FileTransaction ([pscustomobject]@{}) -Adapters $world.Adapters;$world.LegacyRemoveFailureAt=4;$world.LegacyRestoreFailureEntry='StartMenu2' + Assert-CcodThrows {Remove-CcodLegacyProductRegistration -ExpectedAppId $appId -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'StartMenu2') 'unresolved record names the exact entry whose restoration failed' } Write-Output "Product registration self-tests passed: $($results.Count)" diff --git a/tests/persistence/UninstallBootstrap.SelfTest.ps1 b/tests/persistence/UninstallBootstrap.SelfTest.ps1 index b1fe2df..941776c 100644 --- a/tests/persistence/UninstallBootstrap.SelfTest.ps1 +++ b/tests/persistence/UninstallBootstrap.SelfTest.ps1 @@ -21,6 +21,7 @@ function New-CcodUninstallBootstrapContext { leaseEpoch = [uint64]11 userSid = 'S-1-5-21-111-222-333-1001' sessionId = 1 + readyEvidence = [pscustomobject][ordered]@{phase='Ready';installRoot='C:\install';runtimeId='2.5.0-uninstall-test';runtimeGeneration=[uint64]7;packageSha256=('0'*64);manifestSha256=('a'*64);startMenuSha256=('0'*64);desktopSha256=('0'*64);targetPath='C:\Windows\System32\schtasks.exe';arguments='/Run /TN "Codex Control Other Devices Supervisor"'} payloadRecords = New-CcodUninstallBootstrapPayloadRecords -ResumeOnly } } @@ -40,6 +41,8 @@ function New-CcodUninstallBootstrapTestTransaction { leaseEpoch = [uint64]11 userSid = 'S-1-5-21-111-222-333-1001' sessionId = 1 + readyEvidence = [pscustomobject][ordered]@{phase='Ready';installRoot='C:\install';runtimeId='2.5.0-uninstall-test';runtimeGeneration=[uint64]7;packageSha256=('0'*64);manifestSha256=('a'*64);startMenuSha256=('0'*64);desktopSha256=('0'*64);targetPath='C:\Windows\System32\schtasks.exe';arguments='/Run /TN "Codex Control Other Devices Supervisor"'} + installedBinding = $null phase = $Phase resumePhase = $Phase startedAtUtc = '2030-02-03T03:04:05.0000000Z' @@ -64,6 +67,7 @@ function New-CcodUninstallBootstrapAdapters { leaseEpoch = [uint64]11 userSid = 'S-1-5-21-111-222-333-1001' sessionId = 1 + readyEvidence = [pscustomobject][ordered]@{phase='Ready';installRoot='C:\install';runtimeId='2.5.0-uninstall-test';runtimeGeneration=[uint64]7;packageSha256=('0'*64);manifestSha256=('a'*64);startMenuSha256=('0'*64);desktopSha256=('0'*64);targetPath='C:\Windows\System32\schtasks.exe';arguments='/Run /TN "Codex Control Other Devices Supervisor"'} payloadRecords = New-CcodUninstallBootstrapPayloadRecords -ResumeOnly } }.GetNewClosure() @@ -563,39 +567,44 @@ $results += Invoke-CcodTest 'installed Prepare stops at TaskRemoved and external $adapters=New-CcodUninstallBootstrapAdapters $world $originalCleanup=$adapters.RunCleanup $adapters.RunCleanup={param($InstallerRoot,$InstallRoot,$TransactionRoot,$Transaction,$WriteTransaction,$Mode);$world.Calls.Add("Cleanup:$Mode");$Transaction.phase='TaskRemoved';$Transaction.resumePhase='TaskRemoved';&$WriteTransaction $TransactionRoot $Transaction;$Transaction}.GetNewClosure() - $prepared=Invoke-CcodUninstallBootstrap -InstallerRoot 'C:\runtime' -InstallRoot 'C:\install' -Mode PrepareInstalled -Adapters $adapters + $adapters.GetInstalledBinding={param($InstallerRoot,$InstallRoot,$Context,$WrapperIdentity)[pscustomobject]@{selectedRuntimeRoot='C:\install\runtime\2.5.0-uninstall-test';runtimeManifestSha256=('a'*64);wrapperPid=42;wrapperCreationTimeUtc='2030-02-03T03:04:05.0000000Z';wrapperSessionId=1;wrapperUserSid='S-1-5-21-111-222-333-1001'}} + $wrapperIdentity=[pscustomobject]@{pid=42;creationTimeUtc='2030-02-03T03:04:05.0000000Z';sessionId=1;userSid='S-1-5-21-111-222-333-1001'} + $prepared=Invoke-CcodUninstallBootstrap -InstallerRoot 'C:\runtime' -InstallRoot 'C:\install' -Mode PrepareInstalled -WrapperIdentity $wrapperIdentity -Adapters $adapters Assert-CcodEqual 'TaskRemoved' $prepared.phase 'installed wrapper retains application state until it exits' Assert-CcodEqual 0 $world.ProductRegistrationRemovals 'installed Prepare removes no product state before the external finalizer' $final=[pscustomobject]@{Calls=[Collections.Generic.List[string]]::new();WrapperExited=$true;ContextValid=$true;Transaction=$prepared;RootAbsent=$false;Finalized=$false;Deletes=0} $finalAdapters=@{ - WaitWrapperExit={param($Identity,$Timeout)$final.Calls.Add('WaitWrapper');$final.WrapperExited}.GetNewClosure() + WaitWrapperExit={param($Identity,$Timeout)$final.Calls.Add('WaitWrapper');[pscustomobject]@{verifiedAtStart=$true;exited=$final.WrapperExited}}.GetNewClosure() ReadPreparedTransaction={param($Id)$final.Calls.Add('ReadTransaction');$final.Transaction}.GetNewClosure() ValidateSelectedGeneration={param($RuntimeRoot,$InstallRoot,$Transaction)$final.Calls.Add('ValidateGeneration');$final.ContextValid}.GetNewClosure() - RemoveMatchedApplicationState={param($RuntimeRoot,$InstallRoot,$Transaction)$final.Calls.Add('RemoveMatched');$final.Deletes++;$final.RootAbsent=$true;$Transaction.phase='ReadyForInno';$Transaction.resumePhase='ReadyForInno';$Transaction}.GetNewClosure() - TestInstallRootAbsent={param($Root)$final.Calls.Add('RootAbsent');$final.RootAbsent}.GetNewClosure() + ReadCurrentEpoch={param($InstallRoot)[uint64]11} + RemoveSelectedGeneration={param($RuntimeRoot,$Transaction)$final.Calls.Add('RemoveSelected');$final.Deletes++;$final.RootAbsent=$true;$Transaction.phase='ReadyForInno';$Transaction.resumePhase='ReadyForInno';$Transaction}.GetNewClosure() + TestSelectedRootAbsent={param($Root)$final.Calls.Add('RootAbsent');$final.RootAbsent}.GetNewClosure() RemoveMatchedProductRegistration={param($Transaction)$final.Calls.Add('RemoveProduct')}.GetNewClosure() FinalizeReceipt={param($Transaction)$final.Calls.Add('Finalize');$final.Finalized=$true;[pscustomobject]@{phase='Completed'}}.GetNewClosure() } - $receipt=Invoke-CcodInstalledUninstallFinalizer -TransactionId $prepared.transactionId -RuntimeRoot 'C:\install\runtime\2.5.0-uninstall-test' -InstallRoot 'C:\install' -WrapperIdentity ([pscustomobject]@{pid=42;creationTimeUtc='2030-02-03T03:04:05.0000000Z'}) -Adapters $finalAdapters + $receipt=Invoke-CcodInstalledUninstallFinalizer -TransactionId $prepared.transactionId -RuntimeRoot 'C:\install\runtime\2.5.0-uninstall-test' -InstallRoot 'C:\install' -WrapperIdentity $wrapperIdentity -Adapters $finalAdapters Assert-CcodEqual 'Completed' $receipt.phase 'external finalizer reaches the completion receipt' - Assert-CcodEqual 'WaitWrapper,ReadTransaction,ValidateGeneration,RemoveMatched,RootAbsent,RemoveProduct,Finalize' ($final.Calls -join ',') 'wrapper exit and selected generation proof precede matched removal and finalization' + Assert-CcodEqual 'WaitWrapper,ReadTransaction,ValidateGeneration,RemoveSelected,RootAbsent,RemoveProduct,Finalize' ($final.Calls -join ',') 'wrapper exit and selected generation proof precede selected-root removal and finalization' } # Production mutation caught: treating any durable transaction/path/process as authority to delete application state. $results += Invoke-CcodTest 'installed finalizer wrong wrapper generation path or transaction performs no deletion' { - foreach($kind in @('Wrapper','Generation','Transaction')){ + foreach($kind in @('Wrapper','Generation','Transaction','Epoch','Sibling')){ $world=[pscustomobject]@{Calls=[Collections.Generic.List[string]]::new();Deletes=0} $transaction=New-CcodUninstallBootstrapTestTransaction -Phase 'TaskRemoved' + $transaction.installedBinding=[pscustomobject][ordered]@{selectedRuntimeRoot=$(if($kind-ceq'Sibling'){'C:\install\runtime\sibling'}else{'C:\install\runtime\2.5.0-uninstall-test'});runtimeManifestSha256=('a'*64);wrapperPid=42;wrapperCreationTimeUtc='2030-02-03T03:04:05.0000000Z';wrapperSessionId=1;wrapperUserSid='S-1-5-21-111-222-333-1001'} if($kind-ceq'Transaction'){$transaction.transactionId='aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'} $adapters=@{ - WaitWrapperExit={param($Identity,$Timeout)$kind-cne'Wrapper'}.GetNewClosure() + WaitWrapperExit={param($Identity,$Timeout)[pscustomobject]@{verifiedAtStart=($kind-cne'Wrapper');exited=$true}}.GetNewClosure() ReadPreparedTransaction={param($Id)$transaction}.GetNewClosure() ValidateSelectedGeneration={param($RuntimeRoot,$InstallRoot,$Transaction)$kind-cne'Generation'}.GetNewClosure() - RemoveMatchedApplicationState={param($RuntimeRoot,$InstallRoot,$Transaction)$world.Deletes++;$Transaction}.GetNewClosure() - TestInstallRootAbsent={param($Root)$false};RemoveMatchedProductRegistration={param($Transaction)};FinalizeReceipt={param($Transaction)[pscustomobject]@{phase='Completed'}} + ReadCurrentEpoch={param($InstallRoot)if($kind-ceq'Epoch'){[uint64]12}else{[uint64]11}}.GetNewClosure() + RemoveSelectedGeneration={param($RuntimeRoot,$Transaction)$world.Deletes++;$Transaction}.GetNewClosure() + TestSelectedRootAbsent={param($Root)$false};RemoveMatchedProductRegistration={param($Transaction)};FinalizeReceipt={param($Transaction)[pscustomobject]@{phase='Completed'}} } - Assert-CcodThrows {Invoke-CcodInstalledUninstallFinalizer -TransactionId '11111111-2222-3333-4444-555555555555' -RuntimeRoot 'C:\install\runtime\2.5.0-uninstall-test' -InstallRoot 'C:\install' -WrapperIdentity ([pscustomobject]@{pid=42;creationTimeUtc='2030-02-03T03:04:05.0000000Z'}) -Adapters $adapters|Out-Null} 'CCOD_INSTALLED_FINALIZER_INVALID' + Assert-CcodThrows {Invoke-CcodInstalledUninstallFinalizer -TransactionId '11111111-2222-3333-4444-555555555555' -RuntimeRoot 'C:\install\runtime\2.5.0-uninstall-test' -InstallRoot 'C:\install' -WrapperIdentity ([pscustomobject]@{pid=42;creationTimeUtc='2030-02-03T03:04:05.0000000Z';sessionId=1;userSid='S-1-5-21-111-222-333-1001'}) -Adapters $adapters|Out-Null} 'CCOD_INSTALLED_FINALIZER_INVALID' Assert-CcodEqual 0 $world.Deletes "$kind mismatch deletes no application state" } } From 2f8ff2f8d0edc401bce23d168b1374815715f18b Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:50:11 +0800 Subject: [PATCH 100/187] docs: record Ready-bound product evidence --- .../task-4-report.md | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) 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 index 878d731..7e36c36 100644 --- 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 @@ -175,3 +175,60 @@ transaction plus arbitrary absolute source path. - `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`). From 67c79d82e18c805f27aec8e05553938f1f706380 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:58:28 +0800 Subject: [PATCH 101/187] fix: reject invalid Ready evidence before cleanup --- src/persistence/UninstallBootstrap.ps1 | 5 +++-- tests/persistence/UninstallBootstrap.SelfTest.ps1 | 6 ++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/persistence/UninstallBootstrap.ps1 b/src/persistence/UninstallBootstrap.ps1 index 1e545f6..1989ba7 100644 --- a/src/persistence/UninstallBootstrap.ps1 +++ b/src/persistence/UninstallBootstrap.ps1 @@ -803,7 +803,7 @@ function Assert-CcodUninstallBootstrapContext { } [void](ConvertTo-CcodUninstallBootstrapUInt64 $Context.runtimeGeneration 'runtimeGeneration') [void](ConvertTo-CcodUninstallBootstrapUInt64 $Context.leaseEpoch 'leaseEpoch') - $ready=$Context.readyEvidence;if(-not(Test-CcodUninstallBootstrapExactProperties $ready @('phase','installRoot','runtimeId','runtimeGeneration','packageSha256','manifestSha256','startMenuSha256','desktopSha256','targetPath','arguments'))-or$ready.phase-cne'Ready'-or$ready.installRoot-isnot[string]-or$ready.runtimeId-cne$Context.runtimeId-or[uint64]$ready.runtimeGeneration-ne[uint64]$Context.runtimeGeneration-or$ready.packageSha256-cnotmatch'^[0-9a-f]{64}$'-or$ready.manifestSha256-cnotmatch'^[0-9a-f]{64}$'-or$ready.startMenuSha256-cnotmatch'^[0-9a-f]{64}$'-or$ready.desktopSha256-cnotmatch'^[0-9a-f]{64}$'-or$ready.targetPath-isnot[string]-or$ready.arguments-cne'/Run /TN "Codex Control Other Devices Supervisor"'){Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_BOOTSTRAP_INVALID' 'Ready cleanup evidence is invalid' $ready} + $ready=$Context.readyEvidence;$canonicalTarget=[IO.Path]::GetFullPath((Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::System)) 'schtasks.exe'));if(-not(Test-CcodUninstallBootstrapExactProperties $ready @('phase','installRoot','runtimeId','runtimeGeneration','packageSha256','manifestSha256','startMenuSha256','desktopSha256','targetPath','arguments'))-or$ready.phase-cne'Ready'-or$ready.installRoot-isnot[string]-or$ready.runtimeId-cne$Context.runtimeId-or[uint64]$ready.runtimeGeneration-ne[uint64]$Context.runtimeGeneration-or$ready.packageSha256-cnotmatch'^[0-9a-f]{64}$'-or$ready.manifestSha256-cnotmatch'^[0-9a-f]{64}$'-or$ready.startMenuSha256-cnotmatch'^[0-9a-f]{64}$'-or$ready.desktopSha256-cnotmatch'^[0-9a-f]{64}$'-or$ready.targetPath-isnot[string]-or[IO.Path]::GetFullPath([string]$ready.targetPath)-ine$canonicalTarget-or$ready.arguments-cne'/Run /TN "Codex Control Other Devices Supervisor"'){Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_BOOTSTRAP_INVALID' 'Ready cleanup evidence is invalid' $ready} $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++) { @@ -835,7 +835,7 @@ function Assert-CcodUninstallBootstrapTransaction { } [void](ConvertTo-CcodUninstallBootstrapUInt64 $Transaction.runtimeGeneration 'runtimeGeneration') [void](ConvertTo-CcodUninstallBootstrapUInt64 $Transaction.leaseEpoch 'leaseEpoch') - if($null-eq$Transaction.readyEvidence-or$Transaction.readyEvidence.runtimeId-cne$Transaction.runtimeId-or[uint64]$Transaction.readyEvidence.runtimeGeneration-ne[uint64]$Transaction.runtimeGeneration){Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_TRANSACTION_INVALID' 'Ready transaction evidence is missing or mismatched' $Transaction.readyEvidence} + $transactionReady=$Transaction.readyEvidence;$canonicalTaskTarget=[IO.Path]::GetFullPath((Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::System)) 'schtasks.exe'));if($null-eq$transactionReady-or$transactionReady.phase-cne'Ready'-or$transactionReady.installRoot-isnot[string]-or-not[IO.Path]::IsPathRooted($transactionReady.installRoot)-or$transactionReady.runtimeId-cne$Transaction.runtimeId-or[uint64]$transactionReady.runtimeGeneration-ne[uint64]$Transaction.runtimeGeneration-or$transactionReady.packageSha256-cnotmatch'^[0-9a-f]{64}$'-or$transactionReady.manifestSha256-cnotmatch'^[0-9a-f]{64}$'-or$transactionReady.startMenuSha256-cnotmatch'^[0-9a-f]{64}$'-or$transactionReady.desktopSha256-cnotmatch'^[0-9a-f]{64}$'-or$transactionReady.targetPath-isnot[string]-or[IO.Path]::GetFullPath([string]$transactionReady.targetPath)-ine$canonicalTaskTarget-or$transactionReady.arguments-cne'/Run /TN "Codex Control Other Devices Supervisor"'){Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_TRANSACTION_INVALID' 'Ready transaction evidence is missing or mismatched' $transactionReady} if($null-ne$Transaction.installedBinding){$binding=$Transaction.installedBinding;if(-not(Test-CcodUninstallBootstrapExactProperties $binding @('selectedRuntimeRoot','runtimeManifestSha256','wrapperPid','wrapperCreationTimeUtc','wrapperSessionId','wrapperUserSid'))-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.wrapperUserSid-isnot[string]-or$binding.wrapperUserSid-cnotmatch'^S-'){Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_TRANSACTION_INVALID' 'Installed finalization binding is invalid' $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) @@ -1035,6 +1035,7 @@ function Invoke-CcodUninstallBootstrap { Assert-CcodUninstallBootstrapContext $context } catch { $validationFailure = $_ + $context = $null } $transactionRoot = & $adapter.GetTransactionRoot $identity = if ($null -ne $context) { diff --git a/tests/persistence/UninstallBootstrap.SelfTest.ps1 b/tests/persistence/UninstallBootstrap.SelfTest.ps1 index 941776c..5b83382 100644 --- a/tests/persistence/UninstallBootstrap.SelfTest.ps1 +++ b/tests/persistence/UninstallBootstrap.SelfTest.ps1 @@ -609,5 +609,11 @@ $results += Invoke-CcodTest 'installed finalizer wrong wrapper generation path o } } +$results += Invoke-CcodTest 'noncanonical Ready shortcut target is rejected before uninstall cleanup' { + $world=[pscustomobject]@{Calls=[Collections.Generic.List[string]]::new();Transaction=$null;Receipt=$null;ValidationError=$false;StageError=$false;CleanupError=$false;CleanupFailurePhase=$null;InstallRootAbsent=$false;StagedEntries=@();ProductRegistrationRemovals=0};$adapters=New-CcodUninstallBootstrapAdapters $world;$context=New-CcodUninstallBootstrapContext;$context.readyEvidence.targetPath='C:\outside\task.exe';$adapters.ValidateInvocation={param($InstallerRoot,$InstallRoot)$context}.GetNewClosure() + Assert-CcodThrows {Invoke-CcodUninstallBootstrap -InstallerRoot 'C:\runtime' -InstallRoot 'C:\install' -Mode Prepare -Adapters $adapters|Out-Null} 'CCOD_UNINSTALL_BOOTSTRAP_INVALID' + Assert-CcodEqual 0 @($world.Calls|Where-Object{$_-ceq'Cleanup'}).Count 'invalid target evidence reaches no cleanup deletion' +} + $results | ForEach-Object { "PASS $($_.Name)" } Write-Output "Uninstall bootstrap self-tests passed: $($results.Count)" From 7c333e09d97262cdab6fdc82232636b0733a0183 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:59:25 +0800 Subject: [PATCH 102/187] docs: record pre-cleanup Ready gate evidence --- .../task-4-report.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 index 7e36c36..b879f27 100644 --- 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 @@ -232,3 +232,15 @@ network, release, push, tag, signing, or publication action was performed. - `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`). From 1ff17bb8759e3d5312c11336ca3b2188bb3a4fe2 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:48:13 +0800 Subject: [PATCH 103/187] fix: enforce strict retained product authority --- src/persistence/modules/InstallFileTransaction.psm1 | 4 ++-- src/persistence/modules/InstallLifecycle.psm1 | 4 ++-- src/persistence/modules/ProductRegistration.psm1 | 10 +++++----- tests/persistence/InstallFileTransaction.SelfTest.ps1 | 8 ++++---- tests/persistence/ProductRegistration.SelfTest.ps1 | 10 +++++++++- tests/persistence/UninstallBootstrap.SelfTest.ps1 | 7 +++++++ 6 files changed, 29 insertions(+), 14 deletions(-) diff --git a/src/persistence/modules/InstallFileTransaction.psm1 b/src/persistence/modules/InstallFileTransaction.psm1 index b58b62d..08775ad 100644 --- a/src/persistence/modules/InstallFileTransaction.psm1 +++ b/src/persistence/modules/InstallFileTransaction.psm1 @@ -365,9 +365,9 @@ function Open-CcodInstallGeneration { $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;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;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-CcodInstallProductRegistrationTransaction {param([Parameter(Mandatory)][string]$InstallRoot,[Parameter(Mandatory)]$ReadyEvidence)Initialize-CcodInstallRuntime;if(-not[IO.Path]::IsPathRooted($InstallRoot)){Throw-CcodInstallFileError 'CCOD_INSTALL_ROOT_INVALID' 'Install root must be absolute' $InstallRoot};$properties=@($ReadyEvidence.PSObject.Properties);$names=@('phase','runtimeId','runtimeGeneration','manifestSha256','packageSha256');if($properties.Count-ne$names.Count-or(@($properties.Name)-join'|')-cne($names-join'|')-or$ReadyEvidence.phase-cne'Ready'-or$ReadyEvidence.runtimeId-isnot[string]-or[uint64]$ReadyEvidence.runtimeGeneration-lt1-or$ReadyEvidence.manifestSha256-cnotmatch'^[0-9a-f]{64}$'-or$ReadyEvidence.packageSha256-cnotmatch'^[0-9a-f]{64}$'){Throw-CcodInstallFileError 'CCOD_INSTALL_PRODUCT_SCOPE' 'Product transaction requires exact Ready evidence' $ReadyEvidence};$root=[IO.Path]::GetFullPath($InstallRoot);$pointerRoot=Join-Path $root 'state\active-generation';$pointerLeaves=@(Get-ChildItem -LiteralPath $pointerRoot -File -Force -ErrorAction Stop|Sort-Object Name);if($pointerLeaves.Count-eq0-or$pointerLeaves[-1].Name-cne('{0:D20}.json'-f[uint64]$ReadyEvidence.runtimeGeneration)){Throw-CcodInstallFileError 'CCOD_INSTALL_PRODUCT_SCOPE' 'Ready evidence is not the latest selected generation' $ReadyEvidence};$pointerPath=Join-Path $root ('state\active-generation\{0:D20}.json'-f[uint64]$ReadyEvidence.runtimeGeneration);$manifestPath=Join-Path (Join-Path (Join-Path $root 'runtime') $ReadyEvidence.runtimeId) 'manifest.json';try{$pointer=Get-Content -LiteralPath $pointerPath -Raw -ErrorAction Stop|ConvertFrom-Json -ErrorAction Stop;if([string]$pointer.activeRuntime-cne[string]$ReadyEvidence.runtimeId-or[uint64]$pointer.generation-ne[uint64]$ReadyEvidence.runtimeGeneration-or(Get-FileHash -LiteralPath $manifestPath -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()-cne[string]$ReadyEvidence.manifestSha256){throw 'selected pointer or manifest mismatch'};$matches=@();foreach($file in @(Get-ChildItem -LiteralPath (Join-Path $root 'state\install-transactions') -File -Force -ErrorAction Stop)){try{$record=Get-Content -LiteralPath $file.FullName -Raw -ErrorAction Stop|ConvertFrom-Json -ErrorAction Stop;if($record.phase-ceq'Ready'-and$record.newRuntimeId-ceq$ReadyEvidence.runtimeId-and[uint64]$record.newGeneration-eq[uint64]$ReadyEvidence.runtimeGeneration-and$record.newManifestSha256-ceq$ReadyEvidence.manifestSha256-and$record.sealedPackageSha256-ceq$ReadyEvidence.packageSha256){$matches+=,$record}}catch{throw}};if($matches.Count-ne1){throw 'matching Ready record missing or ambiguous'}}catch{Throw-CcodInstallFileError 'CCOD_INSTALL_PRODUCT_SCOPE' 'Selected pointer manifest and Ready record could not be proven' $ReadyEvidence};$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=$ReadyEvidence.runtimeId;RootToken=$token;StateOnly=$false;ProductOnly=$true;ReadyEvidence=$ReadyEvidence;Closed=$false;CleanupFailed=$false;Retired=$false;RetirementResult=$null;Disposition=$null});$script:CcodScopes.Add($transaction,[pscustomobject]@{Transaction=$transaction;Token=$token;Kind='ProductTransaction';ReadOnly=$true;RuntimeId=$ReadyEvidence.runtimeId});$transaction} +function Open-CcodInstallProductRegistrationTransaction {param([Parameter(Mandatory)][string]$InstallRoot,[Parameter(Mandatory)]$ReadyTransaction)$strictNames=@('schemaVersion','transactionId','oldRuntimeId','oldGeneration','oldManifestSha256','newRuntimeId','newGeneration','newManifestSha256','sealedPackageSha256','ownedObjectNames','phase','errorCode');if((@($ReadyTransaction.PSObject.Properties.Name)-join'|')-cne($strictNames-join'|')){Throw-CcodInstallFileError 'CCOD_INSTALL_PRODUCT_SCOPE' 'Complete Ready transaction identity is required' $ReadyTransaction};$ReadyEvidence=[pscustomobject][ordered]@{phase=$ReadyTransaction.phase;runtimeId=$ReadyTransaction.newRuntimeId;runtimeGeneration=[uint64]$ReadyTransaction.newGeneration;manifestSha256=$ReadyTransaction.newManifestSha256;packageSha256=$ReadyTransaction.sealedPackageSha256};Initialize-CcodInstallRuntime;if(-not[IO.Path]::IsPathRooted($InstallRoot)){Throw-CcodInstallFileError 'CCOD_INSTALL_ROOT_INVALID' 'Install root must be absolute' $InstallRoot};$properties=@($ReadyEvidence.PSObject.Properties);$names=@('phase','runtimeId','runtimeGeneration','manifestSha256','packageSha256');if($properties.Count-ne$names.Count-or(@($properties.Name)-join'|')-cne($names-join'|')-or$ReadyEvidence.phase-cne'Ready'-or$ReadyEvidence.runtimeId-isnot[string]-or[uint64]$ReadyEvidence.runtimeGeneration-lt1-or$ReadyEvidence.manifestSha256-cnotmatch'^[0-9a-f]{64}$'-or$ReadyEvidence.packageSha256-cnotmatch'^[0-9a-f]{64}$'){Throw-CcodInstallFileError 'CCOD_INSTALL_PRODUCT_SCOPE' 'Product transaction requires exact Ready evidence' $ReadyEvidence};$root=[IO.Path]::GetFullPath($InstallRoot);$pointerRoot=Join-Path $root 'state\active-generation';$pointerLeaves=@(Get-ChildItem -LiteralPath $pointerRoot -File -Force -ErrorAction Stop|Sort-Object Name);if($pointerLeaves.Count-eq0-or$pointerLeaves[-1].Name-cne('{0:D20}.json'-f[uint64]$ReadyEvidence.runtimeGeneration)){Throw-CcodInstallFileError 'CCOD_INSTALL_PRODUCT_SCOPE' 'Ready evidence is not the latest selected generation' $ReadyEvidence};$pointerPath=Join-Path $root ('state\active-generation\{0:D20}.json'-f[uint64]$ReadyEvidence.runtimeGeneration);$manifestPath=Join-Path (Join-Path (Join-Path $root 'runtime') $ReadyEvidence.runtimeId) 'manifest.json';try{$pointer=Get-Content -LiteralPath $pointerPath -Raw -ErrorAction Stop|ConvertFrom-Json -ErrorAction Stop;if([string]$pointer.activeRuntime-cne[string]$ReadyEvidence.runtimeId-or[uint64]$pointer.generation-ne[uint64]$ReadyEvidence.runtimeGeneration-or(Get-FileHash -LiteralPath $manifestPath -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()-cne[string]$ReadyEvidence.manifestSha256){throw 'selected pointer or manifest mismatch'};$matches=@();foreach($file in @(Get-ChildItem -LiteralPath (Join-Path $root 'state\install-transactions') -File -Force -ErrorAction Stop)){try{$record=Get-Content -LiteralPath $file.FullName -Raw -ErrorAction Stop|ConvertFrom-Json -ErrorAction Stop;$recordNames=@($record.PSObject.Properties.Name);$strictNames=@('schemaVersion','transactionId','oldRuntimeId','oldGeneration','oldManifestSha256','newRuntimeId','newGeneration','newManifestSha256','sealedPackageSha256','ownedObjectNames','phase','errorCode');if(($recordNames-join'|')-cne($strictNames-join'|')){throw 'noncanonical transaction record'};if($record.phase-ceq'Ready'-and$record.newRuntimeId-ceq$ReadyEvidence.runtimeId-and[uint64]$record.newGeneration-eq[uint64]$ReadyEvidence.runtimeGeneration-and$record.newManifestSha256-ceq$ReadyEvidence.manifestSha256-and$record.sealedPackageSha256-ceq$ReadyEvidence.packageSha256){$matches+=,$record}}catch{throw}};if($matches.Count-ne1){throw 'matching Ready record missing or ambiguous'}}catch{Throw-CcodInstallFileError 'CCOD_INSTALL_PRODUCT_SCOPE' 'Selected pointer manifest and Ready record could not be proven' $ReadyEvidence};$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=$ReadyEvidence.runtimeId;RootToken=$token;StateOnly=$false;ProductOnly=$true;ReadyEvidence=$ReadyEvidence;ReadyCanonical=($ReadyTransaction|ConvertTo-Json -Depth 8 -Compress);Closed=$false;CleanupFailed=$false;Retired=$false;RetirementResult=$null;Disposition=$null});$script:CcodScopes.Add($transaction,[pscustomobject]@{Transaction=$transaction;Token=$token;Kind='ProductTransaction';ReadOnly=$true;RuntimeId=$ReadyEvidence.runtimeId});$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 } -function Open-CcodInstallRetainedFile {param([Parameter(Mandatory)]$Generation,[Parameter(Mandatory)][string]$RelativePath,[Parameter(Mandatory)]$ReadyEvidence)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';if($ReadyEvidence.phase-cne'Ready'-or$ReadyEvidence.runtimeId-cne$scope.Record.RuntimeId-or[uint64]$ReadyEvidence.runtimeGeneration-lt1-or$ReadyEvidence.manifestSha256-cnotmatch'^[0-9a-f]{64}$'-or$ReadyEvidence.packageSha256-cnotmatch'^[0-9a-f]{64}$'){Throw-CcodInstallFileError 'CCOD_INSTALL_PRODUCT_SCOPE' 'Retained shortcut source lacks exact Ready selection evidence' $ReadyEvidence};$root=$scope.State.InstallRoot;$pointer=Get-Content -LiteralPath (Join-Path $root ('state\active-generation\{0:D20}.json'-f[uint64]$ReadyEvidence.runtimeGeneration)) -Raw -ErrorAction Stop|ConvertFrom-Json -ErrorAction Stop;if($pointer.activeRuntime-cne$ReadyEvidence.runtimeId-or[uint64]$pointer.generation-ne[uint64]$ReadyEvidence.runtimeGeneration){Throw-CcodInstallFileError 'CCOD_INSTALL_PRODUCT_SCOPE' 'Retained shortcut source is not selected' $ReadyEvidence};$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 Open-CcodInstallRetainedFile {param([Parameter(Mandatory)]$Generation,[Parameter(Mandatory)][string]$RelativePath,[Parameter(Mandatory)]$ReadyTransaction)$canonical=$ReadyTransaction|ConvertTo-Json -Depth 8 -Compress;$ReadyEvidence=[pscustomobject][ordered]@{phase=$ReadyTransaction.phase;runtimeId=$ReadyTransaction.newRuntimeId;runtimeGeneration=[uint64]$ReadyTransaction.newGeneration;manifestSha256=$ReadyTransaction.newManifestSha256;packageSha256=$ReadyTransaction.sealedPackageSha256};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';if($scope.State.ReadyCanonical-cne$canonical-or$ReadyEvidence.phase-cne'Ready'-or$ReadyEvidence.runtimeId-cne$scope.Record.RuntimeId-or[uint64]$ReadyEvidence.runtimeGeneration-lt1-or$ReadyEvidence.manifestSha256-cnotmatch'^[0-9a-f]{64}$'-or$ReadyEvidence.packageSha256-cnotmatch'^[0-9a-f]{64}$'){Throw-CcodInstallFileError 'CCOD_INSTALL_PRODUCT_SCOPE' 'Retained shortcut source lacks exact Ready selection evidence' $ReadyEvidence};$root=$scope.State.InstallRoot;$pointer=Get-Content -LiteralPath (Join-Path $root ('state\active-generation\{0:D20}.json'-f[uint64]$ReadyEvidence.runtimeGeneration)) -Raw -ErrorAction Stop|ConvertFrom-Json -ErrorAction Stop;if($pointer.activeRuntime-cne$ReadyEvidence.runtimeId-or[uint64]$pointer.generation-ne[uint64]$ReadyEvidence.runtimeGeneration){Throw-CcodInstallFileError 'CCOD_INSTALL_PRODUCT_SCOPE' 'Retained shortcut source is not selected' $ReadyEvidence};$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 } diff --git a/src/persistence/modules/InstallLifecycle.psm1 b/src/persistence/modules/InstallLifecycle.psm1 index dd3745a..b06af5c 100644 --- a/src/persistence/modules/InstallLifecycle.psm1 +++ b/src/persistence/modules/InstallLifecycle.psm1 @@ -1954,9 +1954,9 @@ function Get-CcodLifecycleAdapters { $module=Import-Module (Join-Path $PSScriptRoot 'ProductRegistration.psm1') -Force -PassThru -ErrorAction Stop $ownedTransaction=$null;$sourceCapability=$FileTransaction try{ - if($null-eq$sourceCapability){Import-Module (Join-Path $PSScriptRoot 'InstallFileTransaction.psm1') -ErrorAction Stop;$fileReady=[pscustomobject][ordered]@{phase='Ready';runtimeId=$RuntimeId;runtimeGeneration=[uint64]$TransactionRecord.newGeneration;manifestSha256=[string]$TransactionRecord.newManifestSha256;packageSha256=$PackageSha256};$ownedTransaction=Open-CcodInstallProductRegistrationTransaction -InstallRoot $InstallRoot -ReadyEvidence $fileReady;$sourceCapability=Open-CcodInstallRetainedGeneration -InstallRoot $InstallRoot -RuntimeId $RuntimeId -ExpectedManifestSha256 ([string]$TransactionRecord.newManifestSha256) -FileTransaction $ownedTransaction} + if($null-eq$sourceCapability){Import-Module (Join-Path $PSScriptRoot 'InstallFileTransaction.psm1') -ErrorAction Stop;$fileReady=[pscustomobject][ordered]@{phase='Ready';runtimeId=$RuntimeId;runtimeGeneration=[uint64]$TransactionRecord.newGeneration;manifestSha256=[string]$TransactionRecord.newManifestSha256;packageSha256=$PackageSha256};$ownedTransaction=Open-CcodInstallProductRegistrationTransaction -InstallRoot $InstallRoot -ReadyTransaction $TransactionRecord;$sourceCapability=Open-CcodInstallRetainedGeneration -InstallRoot $InstallRoot -RuntimeId $RuntimeId -ExpectedManifestSha256 ([string]$TransactionRecord.newManifestSha256) -FileTransaction $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"';bootstrapPath=$registration.bootstrapPath;uninstallerPath=$registration.uninstallerPath} + $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} $receipt=Commit-CcodProductRegistration -Registration $registration -FileTransaction $sourceCapability -Adapters @{GetReadyProof={param($Ignored)$proof}.GetNewClosure()} Remove-CcodLegacyProductRegistration -ExpectedAppId '{2B9E9F2E-7A32-4A7E-9C1D-9F5B5C6D7E8F}' [pscustomobject]@{verified=[bool]$receipt.verified;legacyRemoved=$true} diff --git a/src/persistence/modules/ProductRegistration.psm1 b/src/persistence/modules/ProductRegistration.psm1 index 3de4b65..ee3f7bc 100644 --- a/src/persistence/modules/ProductRegistration.psm1 +++ b/src/persistence/modules/ProductRegistration.psm1 @@ -8,7 +8,7 @@ $script:CcodProductFields = @( 'productKeyName','startMenuShortcut','desktopShortcut' ) $script:CcodShortcutFields = @('kind','leaf','relativePath','candidatePath','bootstrapPath','installRoot') -$script:CcodReadyFields = @('phase','runtimeId','version','packageSha256','runtimeGeneration','manifestSha256','startMenuSha256','desktopSha256','targetPath','arguments','bootstrapPath','uninstallerPath') +$script:CcodReadyFields = @('phase','runtimeId','version','packageSha256','runtimeGeneration','manifestSha256','startMenuSha256','desktopSha256','targetPath','arguments','transactionRecord','bootstrapPath','uninstallerPath') $script:CcodLegacyShortcutNames = @( 'Programs\Codex Control other devices\Codex Control other devices for Windows.lnk', 'Programs\Codex Control other devices\Open the tray supervisor.lnk', @@ -125,7 +125,7 @@ function Test-CcodProductReadyProof { $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 + [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 } @@ -208,7 +208,7 @@ 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'}) - $fileReady=[pscustomobject][ordered]@{phase='Ready';runtimeId=[string]$ReadyEvidence.runtimeId;runtimeGeneration=[uint64]$ReadyEvidence.runtimeGeneration;manifestSha256=[string]$ReadyEvidence.manifestSha256;packageSha256=[string]$ReadyEvidence.packageSha256};$source=Open-CcodInstallRetainedFile -Generation $FileTransaction -RelativePath $relative -ReadyEvidence $fileReady + $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 } @@ -248,7 +248,7 @@ 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(($names-join'|')-cne(($allowed|Sort-Object)-join'|')){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})}} + try{$allowed=@('CcodDesktopSha256','CcodPackageSha256','CcodRuntimeId','CcodRuntimeGeneration','CcodManifestSha256','CcodShortcutArguments','CcodShortcutTarget','CcodStartMenuSha256','DisplayName','DisplayVersion','InstallLocation','NoModify','NoRepair','Publisher','QuietUninstallString','UninstallString');$names=@($key.GetValueNames()|Sort-Object);if(($names-join'|')-cne(($allowed|Sort-Object)-join'|')-or$key.GetSubKeyNames().Count-ne0){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=@()}} } @@ -326,7 +326,7 @@ function Remove-CcodLegacyProductRegistration { $snapshot=&$adapter.ReadLegacySnapshot $ExpectedAppId if($null-eq$snapshot-or$snapshot.appId-cne$ExpectedAppId-or@($snapshot.entries).Count-ne9){Throw-CcodProductRegistrationError 'CCOD_LEGACY_PRODUCT_REGISTRATION_INVALID' 'Legacy snapshot is incomplete or ambiguous' $ExpectedAppId} $removed=[Collections.Generic.List[object]]::new() - try{foreach($entry in @($snapshot.entries)){&$adapter.RemoveLegacyEntry $entry;$removed.Add($entry)}}catch{$unresolved=[Collections.Generic.List[string]]::new();for($index=$removed.Count-1;$index-ge0;$index--){$entry=$removed[$index];$name=if($entry-is[string]){[string]$entry}elseif($entry.kind-ceq'Registry'){'Registry'}else{[string]$entry.name};try{if($null-ne(&$adapter.ReadLegacyEntry $entry)){$unresolved.Add($name);continue};&$adapter.RestoreLegacyEntry $entry;if($null-eq(&$adapter.ReadLegacyEntry $entry)){$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} + try{foreach($entry in @($snapshot.entries)){&$adapter.RemoveLegacyEntry $entry;$removed.Add($entry)}}catch{$unresolved=[Collections.Generic.List[string]]::new();$failedEntry=$entry;$failedName=if($failedEntry-is[string]){[string]$failedEntry}elseif($failedEntry.kind-ceq'Registry'){'Registry'}else{[string]$failedEntry.name};try{if($null-eq(&$adapter.ReadLegacyEntry $failedEntry)){&$adapter.RestoreLegacyEntry $failedEntry;if($null-eq(&$adapter.ReadLegacyEntry $failedEntry)){$unresolved.Add($failedName)}}}catch{$unresolved.Add($failedName)};for($index=$removed.Count-1;$index-ge0;$index--){$entry=$removed[$index];$name=if($entry-is[string]){[string]$entry}elseif($entry.kind-ceq'Registry'){'Registry'}else{[string]$entry.name};try{if($null-ne(&$adapter.ReadLegacyEntry $entry)){$unresolved.Add($name);continue};&$adapter.RestoreLegacyEntry $entry;if($null-eq(&$adapter.ReadLegacyEntry $entry)){$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 diff --git a/tests/persistence/InstallFileTransaction.SelfTest.ps1 b/tests/persistence/InstallFileTransaction.SelfTest.ps1 index 0649f44..fb7dd45 100644 --- a/tests/persistence/InstallFileTransaction.SelfTest.ps1 +++ b/tests/persistence/InstallFileTransaction.SelfTest.ps1 @@ -150,12 +150,12 @@ Invoke-CcodTest 'product registration transaction exposes only a selected retain $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 - $ready=[pscustomobject][ordered]@{phase='Ready';runtimeId=$runtimeId;runtimeGeneration=[uint64]1;manifestSha256=$manifest.Sha256;packageSha256=('d'*64)};$transactions=Join-Path $fixture.Install 'state\install-transactions';[IO.Directory]::CreateDirectory($transactions)|Out-Null;[IO.File]::WriteAllText((Join-Path $transactions 'ready.json'),([ordered]@{phase='Ready';newRuntimeId=$runtimeId;newGeneration=[uint64]1;newManifestSha256=$manifest.Sha256;sealedPackageSha256=('d'*64)}|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new($false));$product=Open-CcodInstallProductRegistrationTransaction -InstallRoot $fixture.Install -ReadyEvidence $ready;$fixture.Transactions.Add($product);$retained=Open-CcodInstallRetainedGeneration -InstallRoot $fixture.Install -RuntimeId $runtimeId -ExpectedManifestSha256 $manifest.Sha256 -FileTransaction $product;$file=Open-CcodInstallRetainedFile -Generation $retained -RelativePath 'registration/StartMenu.CodexRemote-fix.lnk' -ReadyEvidence $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};$transactions=Join-Path $fixture.Install 'state\install-transactions';[IO.Directory]::CreateDirectory($transactions)|Out-Null;[IO.File]::WriteAllText((Join-Path $transactions 'ready.json'),($readyRecord|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new($false));$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;$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' -ReadyEvidence $ready|Out-Null} 'CCOD_INSTALL_PRODUCT_SHORTCUT_INVALID' + 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=$ready.PSObject.Copy();$preReady.phase='ProtectionReady';Assert-CcodThrows {Open-CcodInstallProductRegistrationTransaction -InstallRoot $fixture.Install -ReadyEvidence $preReady|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-CcodInstallProductRegistrationTransaction -InstallRoot $fixture.Install -ReadyEvidence $ready|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-CcodInstallProductRegistrationTransaction -InstallRoot $fixture.Install -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} } diff --git a/tests/persistence/ProductRegistration.SelfTest.ps1 b/tests/persistence/ProductRegistration.SelfTest.ps1 index 7015e29..cf43f4a 100644 --- a/tests/persistence/ProductRegistration.SelfTest.ps1 +++ b/tests/persistence/ProductRegistration.SelfTest.ps1 @@ -13,6 +13,7 @@ $packageSha256 = '3' * 64 $appId = '{2B9E9F2E-7A32-4A7E-9C1D-9F5B5C6D7E8F}' $canonicalTaskTarget=[IO.Path]::GetFullPath((Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::System)) 'schtasks.exe')) $cleanupReady=[pscustomobject][ordered]@{phase='Ready';installRoot='C:\fixture\CodexControlOtherDevices';runtimeId=$runtimeId;runtimeGeneration=[uint64]7;packageSha256=$packageSha256;manifestSha256=('a'*64);startMenuSha256=('b'*64);desktopSha256=('c'*64);targetPath=$canonicalTaskTarget;arguments='/Run /TN "Codex Control Other Devices Supervisor"'} +$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} function New-CcodRegistrationWorld { param([string]$InstallRoot = 'C:\fixture\CodexControlOtherDevices') @@ -32,6 +33,7 @@ function New-CcodRegistrationWorld { desktopSha256 = 'c'*64 targetPath = $canonicalTaskTarget arguments = '/Run /TN "Codex Control Other Devices Supervisor"' + transactionRecord = $fullReadyRecord bootstrapPath = $registration.bootstrapPath uninstallerPath = $registration.uninstallerPath } @@ -204,7 +206,7 @@ $results += Invoke-CcodTest 'legacy migration rejects AppId and shortcut-name mi # 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','ShortcutHash','ShortcutTarget','ShortcutReparse','Ambiguous','CoherentReplacement')){ + 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" @@ -243,4 +245,10 @@ $results += Invoke-CcodTest 'legacy restore failure is explicit and records the Assert-CcodTrue (@($world.LegacyUnresolvedRecords[0].entries)-ccontains'StartMenu2') '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;$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)if([string]$Entry-ceq'Registry'){[void]$world.LegacyEntries.Remove('Registry');throw 'partial registry failure'};&$originalRemove $Entry}.GetNewClosure() + Assert-CcodThrows {Remove-CcodLegacyProductRegistration -ExpectedAppId $appId -Adapters $world.Adapters} 'CCOD_LEGACY_PRODUCT_COMPENSATION_FAILED' + Assert-CcodTrue (@($world.LegacyUnresolvedRecords[0].entries)-ccontains'Registry') 'current partially removed registry entry is recorded unresolved' +} + Write-Output "Product registration self-tests passed: $($results.Count)" diff --git a/tests/persistence/UninstallBootstrap.SelfTest.ps1 b/tests/persistence/UninstallBootstrap.SelfTest.ps1 index 5b83382..ce49d65 100644 --- a/tests/persistence/UninstallBootstrap.SelfTest.ps1 +++ b/tests/persistence/UninstallBootstrap.SelfTest.ps1 @@ -615,5 +615,12 @@ $results += Invoke-CcodTest 'noncanonical Ready shortcut target is rejected befo Assert-CcodEqual 0 @($world.Calls|Where-Object{$_-ceq'Cleanup'}).Count 'invalid target evidence reaches no cleanup deletion' } +$results += Invoke-CcodTest 'default installed finalizer validates and removes only the exact selected generation fixture' { + $local=Join-Path ([IO.Path]::GetTempPath()) ('ccod-installed-finalizer-'+[guid]::NewGuid().ToString('N'));$previous=[Environment]::GetEnvironmentVariable('LOCALAPPDATA','Process') + try{[Environment]::SetEnvironmentVariable('LOCALAPPDATA',$local,'Process');$install=Join-Path $local 'CodexControlOtherDevices';$fixture=New-CcodVerifiedUninstallRuntimeFixture -InstallRoot $install -AppendOnly;$identity=Get-CcodUninstallBootstrapCurrentIdentity;$transactionRoot=Get-CcodUninstallBootstrapDefaultTransactionRoot;$id='11111111-2222-3333-4444-555555555555';$directory=New-CcodUninstallBootstrapTransactionDirectory -TransactionRoot $transactionRoot -TransactionId $id -UserSid $identity.userSid;$tx=New-CcodUninstallBootstrapTestTransaction -Phase 'TaskRemoved';$tx.runtimeId=$fixture.RuntimeId;$tx.runtimeGeneration=[uint64]7;$tx.userSid=$identity.userSid;$tx.sessionId=$identity.sessionId;$tx.readyEvidence.runtimeId=$fixture.RuntimeId;$tx.readyEvidence.runtimeGeneration=[uint64]7;$tx.readyEvidence.installRoot=$install;$manifest=(Get-FileHash (Join-Path $fixture.RuntimeRoot 'manifest.json') -Algorithm SHA256).Hash.ToLowerInvariant();$tx.readyEvidence.manifestSha256=$manifest;$tx.installedBinding=[pscustomobject][ordered]@{selectedRuntimeRoot=$fixture.RuntimeRoot;runtimeManifestSha256=$manifest;wrapperPid=42;wrapperCreationTimeUtc='2030-02-03T03:04:05.0000000Z';wrapperSessionId=$identity.sessionId;wrapperUserSid=$identity.userSid};Write-CcodUninstallBootstrapStoredTransaction -TransactionDirectory $directory -Transaction $tx;Publish-CcodUninstallBootstrapCurrentTransaction -TransactionRoot $transactionRoot -TransactionId $id -UserSid $identity.userSid + $payload=Join-Path $directory 'payload';[IO.Directory]::CreateDirectory((Join-Path $payload 'src\persistence'))|Out-Null;Copy-Item $bootstrapScript (Join-Path $payload 'src\persistence\UninstallBootstrap.ps1');$adapters=Get-CcodInstalledFinalizerAdapters -Adapters @{WaitWrapperExit={param($I,$T)[pscustomobject]@{verifiedAtStart=$true;exited=$true}};RemoveMatchedProductRegistration={param($T)};FinalizeReceipt={param($T)[pscustomobject]@{phase='Completed'}}} -TransactionRoot $transactionRoot -PayloadRoot $payload;$receipt=Invoke-CcodInstalledUninstallFinalizer -TransactionId $id -RuntimeRoot $fixture.RuntimeRoot -InstallRoot $install -WrapperIdentity ([pscustomobject]@{pid=42;creationTimeUtc='2030-02-03T03:04:05.0000000Z';sessionId=$identity.sessionId;userSid=$identity.userSid}) -Adapters $adapters;Assert-CcodEqual 'Completed' $receipt.phase 'default finalizer reaches completion';Assert-CcodTrue (-not(Test-Path $fixture.RuntimeRoot)) 'default finalizer deletes exact selected runtime';Assert-CcodTrue (Test-Path (Join-Path $install 'state')) 'default finalizer preserves sibling install state' + }finally{[Environment]::SetEnvironmentVariable('LOCALAPPDATA',$previous,'Process');if(Test-Path $local){Remove-Item $local -Recurse -Force}} +} + $results | ForEach-Object { "PASS $($_.Name)" } Write-Output "Uninstall bootstrap self-tests passed: $($results.Count)" From a83840bfaffd595c38aebd9daf8daf26d3b9b3ef Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:49:31 +0800 Subject: [PATCH 104/187] docs: record strict retained authority evidence --- .../task-4-report.md | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) 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 index b879f27..6145121 100644 --- 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 @@ -244,3 +244,57 @@ resume transactions, and rejects before cleanup. Fresh UninstallBootstrap is - 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`). From 3cd891221acfd569fd29e2244b5200fa28e43106 Mon Sep 17 00:00:00 2001 From: naipi11 <101078423+naipi11@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:16:13 +0800 Subject: [PATCH 105/187] fix: prove durable Ready product authority --- .../InstalledUninstallFinalizer.ps1 | 18 ++++- src/persistence/UninstallBootstrap.ps1 | 76 ++++++++++++++----- .../modules/InstallFileTransaction.psm1 | 51 ++++++++++++- src/persistence/modules/InstallLifecycle.psm1 | 2 +- .../modules/ProductRegistration.psm1 | 36 ++++++++- .../InstallFileTransaction.SelfTest.ps1 | 64 +++++++++++++++- .../ProductRegistration.SelfTest.ps1 | 25 +++++- .../UninstallBootstrap.SelfTest.ps1 | 51 ++++++++++++- 8 files changed, 285 insertions(+), 38 deletions(-) diff --git a/src/persistence/InstalledUninstallFinalizer.ps1 b/src/persistence/InstalledUninstallFinalizer.ps1 index edf7b19..980aca1 100644 --- a/src/persistence/InstalledUninstallFinalizer.ps1 +++ b/src/persistence/InstalledUninstallFinalizer.ps1 @@ -15,13 +15,23 @@ function Throw-CcodInstalledFinalizerError { 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 Get-CcodInstalledFinalizerAdapters { param([hashtable]$Adapters,[string]$TransactionRoot,[string]$PayloadRoot) $bootstrap=Join-Path $PayloadRoot 'src\persistence\UninstallBootstrap.ps1' $lifecycle=Join-Path $PayloadRoot 'src\persistence\modules\InstallLifecycle.psm1' $defaults=@{ WaitWrapperExit={param($Identity,$Timeout);$process=Get-Process -Id $Identity.pid -ErrorAction SilentlyContinue;if($null-eq$process){return [pscustomobject]@{verifiedAtStart=$false;exited=$false}};try{if($process.StartTime.ToUniversalTime().ToString('o',[Globalization.CultureInfo]::InvariantCulture)-cne$Identity.creationTimeUtc-or[int]$process.SessionId-ne[int]$Identity.sessionId){return [pscustomobject]@{verifiedAtStart=$false;exited=$false}}}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=Get-Process -Id $Identity.pid -ErrorAction SilentlyContinue;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);. $bootstrap;$identity=Get-CcodUninstallBootstrapCurrentIdentity;$value=Read-CcodUninstallBootstrapStoredTransaction -TransactionRoot $TransactionRoot -ExpectedUserSid $identity.userSid -IncludeCompleted;if($null-eq$value-or$value.transactionId-cne$Id){return $null};$value}.GetNewClosure() + 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() 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);. $bootstrap;$record=Read-CcodUninstallBootstrapJson -Path (Join-Path $Root 'state\lifecycle-epoch.json') -Kind 'Lifecycle epoch';[uint64]$record.epoch}.GetNewClosure() RemoveSelectedGeneration={param($SelectedRuntime,$Transaction);. $bootstrap;$expected=[IO.Path]::GetFullPath([string]$Transaction.installedBinding.selectedRuntimeRoot);if([IO.Path]::GetFullPath($SelectedRuntime)-cne$expected){throw 'selected root changed'};$item=Get-Item -LiteralPath $expected -Force -ErrorAction Stop;if(-not$item.PSIsContainer-or($item.Attributes-band[IO.FileAttributes]::ReparsePoint)-ne0){throw 'selected root unsafe'};Remove-Item -LiteralPath $expected -Recurse -Force -ErrorAction Stop;$Transaction.phase='ReadyForInno';$Transaction.resumePhase='ReadyForInno';Write-CcodUninstallBootstrapStoredTransaction -TransactionDirectory (Join-Path $TransactionRoot $Transaction.transactionId) -Transaction $Transaction;$Transaction}.GetNewClosure() @@ -35,9 +45,9 @@ function Invoke-CcodInstalledUninstallFinalizer { param([Parameter(Mandatory)][string]$TransactionId,[Parameter(Mandatory)][string]$RuntimeRoot,[Parameter(Mandatory)][string]$InstallRoot,[Parameter(Mandatory)]$WrapperIdentity,[hashtable]$Adapters) if($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(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);$expected=[IO.Path]::GetFullPath((Join-Path (Join-Path $install 'runtime') ([IO.Path]::GetFileName($runtime))));if($runtime-cne$expected){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Runtime root is outside the fixed install runtime parent' $runtime} - $local=[Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData);$transactionRoot=[IO.Path]::GetFullPath((Join-Path $local 'CodexRemote-fix-uninstall'));$payloadRoot=[IO.Path]::GetFullPath((Join-Path (Join-Path $transactionRoot $TransactionId) 'payload'));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};$self=Get-Item -LiteralPath $expectedSelf -Force -ErrorAction Stop;if($self.PSIsContainer-or($self.Attributes-band[IO.FileAttributes]::ReparsePoint)-ne0){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Installed finalizer path is unsafe' $expectedSelf}};$adapter=Get-CcodInstalledFinalizerAdapters -Adapters $Adapters -TransactionRoot $transactionRoot -PayloadRoot $payloadRoot + $local=Get-CcodInstalledFinalizerLocalAppData;$transactionRoot=[IO.Path]::GetFullPath((Join-Path $local 'CodexRemote-fix-uninstall'));$payloadRoot=[IO.Path]::GetFullPath((Join-Path (Join-Path $transactionRoot $TransactionId) 'payload'));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};$self=Get-Item -LiteralPath $expectedSelf -Force -ErrorAction Stop;if($self.PSIsContainer-or($self.Attributes-band[IO.FileAttributes]::ReparsePoint)-ne0){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Installed finalizer path is unsafe' $expectedSelf}};$adapter=Get-CcodInstalledFinalizerAdapters -Adapters $Adapters -TransactionRoot $transactionRoot -PayloadRoot $payloadRoot $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} - $transaction=&$adapter.ReadPreparedTransaction $TransactionId + $transaction=&$adapter.ReadPreparedTransaction $TransactionId $install $binding=if($null-ne$transaction){$transaction.installedBinding}else{$null};if($null-eq$transaction-or$transaction.transactionId-cne$TransactionId-or$transaction.phase-cne'TaskRemoved'-or$transaction.resumePhase-cne'TaskRemoved'-or$transaction.runtimeId-isnot[string]-or[IO.Path]::GetFileName($runtime)-cne[string]$transaction.runtimeId-or$null-eq$binding-or[IO.Path]::GetFullPath([string]$binding.selectedRuntimeRoot)-cne$runtime-or$binding.runtimeManifestSha256-isnot[string]-or$binding.runtimeManifestSha256-cnotmatch'^[0-9a-f]{64}$'-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' 'Durable installed uninstall transaction identity is invalid' $transaction} if([uint64](&$adapter.ReadCurrentEpoch $install)-ne[uint64]$transaction.leaseEpoch-or-not(&$adapter.ValidateSelectedGeneration $runtime $install $transaction)){Throw-CcodInstalledFinalizerError 'CCOD_INSTALLED_FINALIZER_INVALID' 'Selected generation manifest or epoch does not match the transaction' $runtime} $ready=&$adapter.RemoveSelectedGeneration $runtime $transaction @@ -50,5 +60,5 @@ function Invoke-CcodInstalledUninstallFinalizer { if($MyInvocation.InvocationName-ne'.'){ $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|Out-Null;exit 0}catch{Write-Error ([string]$_.FullyQualifiedErrorId);exit 3} + try{Invoke-CcodInstalledUninstallFinalizer -TransactionId $TransactionId -RuntimeRoot $RuntimeRoot -InstallRoot $InstallRoot -WrapperIdentity $identity|Out-Null;exit 0}catch{[Console]::Error.WriteLine([string]$_.FullyQualifiedErrorId);exit 3} } diff --git a/src/persistence/UninstallBootstrap.ps1 b/src/persistence/UninstallBootstrap.ps1 index 1989ba7..fbed4ae 100644 --- a/src/persistence/UninstallBootstrap.ps1 +++ b/src/persistence/UninstallBootstrap.ps1 @@ -325,7 +325,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 } @@ -604,7 +604,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' @@ -622,7 +623,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 @@ -726,7 +727,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' @@ -791,8 +792,38 @@ 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 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 @@ -803,7 +834,8 @@ function Assert-CcodUninstallBootstrapContext { } [void](ConvertTo-CcodUninstallBootstrapUInt64 $Context.runtimeGeneration 'runtimeGeneration') [void](ConvertTo-CcodUninstallBootstrapUInt64 $Context.leaseEpoch 'leaseEpoch') - $ready=$Context.readyEvidence;$canonicalTarget=[IO.Path]::GetFullPath((Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::System)) 'schtasks.exe'));if(-not(Test-CcodUninstallBootstrapExactProperties $ready @('phase','installRoot','runtimeId','runtimeGeneration','packageSha256','manifestSha256','startMenuSha256','desktopSha256','targetPath','arguments'))-or$ready.phase-cne'Ready'-or$ready.installRoot-isnot[string]-or$ready.runtimeId-cne$Context.runtimeId-or[uint64]$ready.runtimeGeneration-ne[uint64]$Context.runtimeGeneration-or$ready.packageSha256-cnotmatch'^[0-9a-f]{64}$'-or$ready.manifestSha256-cnotmatch'^[0-9a-f]{64}$'-or$ready.startMenuSha256-cnotmatch'^[0-9a-f]{64}$'-or$ready.desktopSha256-cnotmatch'^[0-9a-f]{64}$'-or$ready.targetPath-isnot[string]-or[IO.Path]::GetFullPath([string]$ready.targetPath)-ine$canonicalTarget-or$ready.arguments-cne'/Run /TN "Codex Control Other Devices Supervisor"'){Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_BOOTSTRAP_INVALID' 'Ready cleanup evidence is invalid' $ready} + $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++) { @@ -819,7 +851,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 @@ -835,7 +867,8 @@ function Assert-CcodUninstallBootstrapTransaction { } [void](ConvertTo-CcodUninstallBootstrapUInt64 $Transaction.runtimeGeneration 'runtimeGeneration') [void](ConvertTo-CcodUninstallBootstrapUInt64 $Transaction.leaseEpoch 'leaseEpoch') - $transactionReady=$Transaction.readyEvidence;$canonicalTaskTarget=[IO.Path]::GetFullPath((Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::System)) 'schtasks.exe'));if($null-eq$transactionReady-or$transactionReady.phase-cne'Ready'-or$transactionReady.installRoot-isnot[string]-or-not[IO.Path]::IsPathRooted($transactionReady.installRoot)-or$transactionReady.runtimeId-cne$Transaction.runtimeId-or[uint64]$transactionReady.runtimeGeneration-ne[uint64]$Transaction.runtimeGeneration-or$transactionReady.packageSha256-cnotmatch'^[0-9a-f]{64}$'-or$transactionReady.manifestSha256-cnotmatch'^[0-9a-f]{64}$'-or$transactionReady.startMenuSha256-cnotmatch'^[0-9a-f]{64}$'-or$transactionReady.desktopSha256-cnotmatch'^[0-9a-f]{64}$'-or$transactionReady.targetPath-isnot[string]-or[IO.Path]::GetFullPath([string]$transactionReady.targetPath)-ine$canonicalTaskTarget-or$transactionReady.arguments-cne'/Run /TN "Codex Control Other Devices Supervisor"'){Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_TRANSACTION_INVALID' 'Ready transaction evidence is missing or mismatched' $transactionReady} + $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;if(-not(Test-CcodUninstallBootstrapExactProperties $binding @('selectedRuntimeRoot','runtimeManifestSha256','wrapperPid','wrapperCreationTimeUtc','wrapperSessionId','wrapperUserSid'))-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.wrapperUserSid-isnot[string]-or$binding.wrapperUserSid-cnotmatch'^S-'){Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_TRANSACTION_INVALID' 'Installed finalization binding is invalid' $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) @@ -871,11 +904,12 @@ 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 } } @@ -891,8 +925,8 @@ function Get-CcodUninstallBootstrapAdapters { Get-CcodUninstallBootstrapDefaultTransactionRoot } 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) @@ -999,9 +1033,9 @@ 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 + Assert-CcodUninstallBootstrapTransaction $transaction -ExpectedInstallRoot $InstallRoot if ($transaction.phase -ne 'ReadyForInno') { 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 } @@ -1032,7 +1066,7 @@ function Invoke-CcodUninstallBootstrap { $validationFailure = $null try { $context = & $adapter.ValidateInvocation $InstallerRoot $InstallRoot - Assert-CcodUninstallBootstrapContext $context + Assert-CcodUninstallBootstrapContext $context -ExpectedInstallRoot $InstallRoot } catch { $validationFailure = $_ $context = $null @@ -1044,7 +1078,7 @@ function Invoke-CcodUninstallBootstrap { Get-CcodUninstallBootstrapCurrentIdentity } $transactionLock = Enter-CcodUninstallBootstrapTransactionLock -UserSid $identity.userSid - $existing = & $adapter.ReadTransaction $transactionRoot $identity.userSid $true + $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 @@ -1065,7 +1099,7 @@ function Invoke-CcodUninstallBootstrap { & $adapter.PublishTransaction $transactionRoot $transactionId $context.userSid ([bool]$replaceCompletedLocator) $stagePayload = $true } else { - Assert-CcodUninstallBootstrapTransactionMatchesContext $transaction $context + Assert-CcodUninstallBootstrapTransactionMatchesContext $transaction $context $InstallRoot if($Mode-ceq'PrepareInstalled'){$binding=&$adapter.GetInstalledBinding $InstallerRoot $InstallRoot $context $WrapperIdentity;if($null-eq$transaction.installedBinding-or($binding|ConvertTo-Json -Compress)-cne($transaction.installedBinding|ConvertTo-Json -Compress)){Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_TRANSACTION_MISMATCH' 'Installed wrapper binding changed during resume' $binding}} $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 } @@ -1074,7 +1108,7 @@ function Invoke-CcodUninstallBootstrap { if ($stagePayload) { & $adapter.StagePayload $InstallerRoot $InstallRoot $context $stageRoot } $result = & $adapter.RunCleanup $InstallerRoot $InstallRoot $stageRoot $transaction $adapter.WriteTransaction $Mode if ($null -eq $result) { Throw-CcodUninstallBootstrapError 'CCOD_UNINSTALL_PREPARE_FAILED' 'The staged cleanup returned no transaction receipt' $stageRoot } - Assert-CcodUninstallBootstrapTransactionMatchesContext $result $context + 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} @@ -1083,7 +1117,7 @@ function Invoke-CcodUninstallBootstrap { } 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 ($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 { diff --git a/src/persistence/modules/InstallFileTransaction.psm1 b/src/persistence/modules/InstallFileTransaction.psm1 index 08775ad..5461485 100644 --- a/src/persistence/modules/InstallFileTransaction.psm1 +++ b/src/persistence/modules/InstallFileTransaction.psm1 @@ -299,6 +299,7 @@ internal sealed class CcodInstallGenerationRuntimeV4 : IDisposable 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");} + 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