From 7ce730709e4ab770a1201caf63d215c107baa814 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Thu, 15 Oct 2026 00:27:38 -0700 Subject: [PATCH 1/2] Gate capture attestation on retained exit and EOF Transfer the frozen Phase A candidate and correct only the two authorized harness defects. Preserve strict live-incomplete refusal and distinguish decision-seam discrimination from real-process endpoint evidence. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 91082413-872d-4f52-ac8c-a2ecd66fb126 Signed-off-by: Colin Neilens --- Tools/windows/Tests/UiCaptureDriver.Tests.ps1 | 231 +++++++++++++++++- Tools/windows/UiCaptureDriver.psm1 | 139 +++++++++-- 2 files changed, 346 insertions(+), 24 deletions(-) diff --git a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 index 344f2e76..12c89484 100644 --- a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 +++ b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 @@ -130,8 +130,16 @@ int wmain(int argc, wchar_t **argv) { $version = [IO.File]::ReadAllText((Join-Path $installation 'VC\Auxiliary\Build\Microsoft.VCToolsVersion.default.txt')).Trim() $compiler = Join-Path $installation "VC\Tools\MSVC\$version\bin\Hostx64\x64\cl.exe" $exe = Join-Path $fixture 'capture-target.exe' + $compileStdout = Join-Path $fixture 'compile-stdout.log' + $compileStderr = Join-Path $fixture 'compile-stderr.log' $command = 'call "{0}" -vcvars_ver={1} >nul && "{2}" /nologo /W4 /WX /O2 /utf-8 /Fe:"{3}" "{4}" /link /SUBSYSTEM:CONSOLE' -f $vcvars, $version, $compiler, $exe, $sourcePath - [IO.File]::WriteAllText((Join-Path $fixture 'compile.cmd'), "@echo off`r`n$command`r`nexit /b %errorlevel%`r`n", [Text.UTF8Encoding]::new($false)) + # Redirect the whole compile group's stdout/stderr to owned files so the real + # compiler and any toolchain telemetry helpers it spawns inherit file handles, + # never the module's redirected pipe. Only the short-lived cmd.exe then holds + # that pipe, so it reaches bounded EOF promptly on exit. This is a fixture setup + # robustness fix; it does not touch any runtime/kernel/EOF/release guard. + $redirectedCommand = '({0}) > "{1}" 2> "{2}"' -f $command, $compileStdout, $compileStderr + [IO.File]::WriteAllText((Join-Path $fixture 'compile.cmd'), "@echo off`r`n$redirectedCommand`r`nexit /b %errorlevel%`r`n", [Text.UTF8Encoding]::new($false)) $compilerEnvironment = @{ PATH = (Split-Path -Parent $vswhere) + ';' + $env:PATH } foreach ($key in @('VSCMD_VER', '__VSCMD_PREINIT_PATH', 'VSINSTALLDIR', 'VCINSTALLDIR', 'VCToolsInstallDir', 'VCToolsVersion', 'WindowsSdkDir', 'WindowsSDKVersion', 'INCLUDE', 'LIB', 'LIBPATH')) { @@ -139,14 +147,19 @@ int wmain(int argc, wchar_t **argv) { } $build = Invoke-CapBoundedChild -FilePath $env:ComSpec -Arguments @('/d', '/c', '.\compile.cmd') ` -Directory $fixture -Role 'harmless-fixture-compile' -Seconds 90 -Environment $compilerEnvironment - [IO.File]::WriteAllText((Join-Path $fixture 'compiler-stdout.log'), $build.stdout) - [IO.File]::WriteAllText((Join-Path $fixture 'compiler-stderr.log'), $build.stderr) - Assert-CapTest ($build.exitCode -eq 0 -and (Test-Path -LiteralPath $exe -PathType Leaf)) ` - "Harmless target compilation failed (not launch evidence): $($build.stdout) $($build.stderr)" + # Preserve the actual compiler exit before reading the owned compile logs. + $compilerExitCode = $build.exitCode + $compileStdoutText = if (Test-Path -LiteralPath $compileStdout -PathType Leaf) { [IO.File]::ReadAllText($compileStdout) } else { '' } + $compileStderrText = if (Test-Path -LiteralPath $compileStderr -PathType Leaf) { [IO.File]::ReadAllText($compileStderr) } else { '' } + Assert-CapTest ($compilerExitCode -eq 0 -and (Test-Path -LiteralPath $exe -PathType Leaf)) ` + "Harmless target compilation failed (not launch evidence): $compileStdoutText $compileStderrText" $provenance = [ordered]@{ compiler = $compiler; compilerVersion = (Get-Item -LiteralPath $compiler).VersionInfo.FileVersion compilerSha256 = (Get-FileHash -LiteralPath $compiler -Algorithm SHA256).Hash - compilerCommand = $command; compilerExitCode = $build.exitCode + compilerCommand = $command; compilerExitCode = $compilerExitCode + compileStdoutLog = $compileStdout; compileStderrLog = $compileStderr + compileStdoutSha256 = if (Test-Path -LiteralPath $compileStdout -PathType Leaf) { (Get-FileHash -LiteralPath $compileStdout -Algorithm SHA256).Hash } else { $null } + compileStderrSha256 = if (Test-Path -LiteralPath $compileStderr -PathType Leaf) { (Get-FileHash -LiteralPath $compileStderr -Algorithm SHA256).Hash } else { $null } source = $sourcePath; sourceSha256 = (Get-FileHash -LiteralPath $sourcePath -Algorithm SHA256).Hash executable = $exe; executableSha256 = (Get-FileHash -LiteralPath $exe -Algorithm SHA256).Hash } @@ -154,6 +167,93 @@ int wmain(int argc, wchar_t **argv) { return [pscustomobject]$provenance } +function New-CapCimRaceTarget { + # A harmless console child that optionally zeroes its OWN PEB command line + # (self only), so Win32_Process.CommandLine reads empty while the kernel still + # reports it live, and blocks on a release file the test owns (barrier). This + # deterministically produces a live-incomplete owned child without any mock. + $fixture = Join-Path $scratch 'cim-race-fixture' + $null = New-Item -ItemType Directory -Path $fixture + $source = @' +#include +#include +#include +#include +#include + +static void clear_own_command_line(void) { + PPEB peb = (PPEB)NtCurrentTeb()->ProcessEnvironmentBlock; + if (peb == NULL || peb->ProcessParameters == NULL) return; + peb->ProcessParameters->CommandLine.Length = 0; + peb->ProcessParameters->CommandLine.MaximumLength = 0; + peb->ProcessParameters->CommandLine.Buffer = NULL; +} + +int wmain(void) { + char behavior[64] = {0}; + wchar_t releaseFile[32768] = {0}; + int i; + GetEnvironmentVariableA("CAP_CIM_BEHAVIOR", behavior, (DWORD)sizeof(behavior)); + if (strstr(behavior, "clearcmd") != NULL) clear_own_command_line(); + puts("CAP_READY"); + fflush(stdout); + if (strstr(behavior, "fileblock") != NULL) { + /* Barrier: stay live until the owning test creates the release file. */ + if (GetEnvironmentVariableW(L"CAP_CIM_RELEASE_FILE", releaseFile, 32768) == 0) return 91; + for (i = 0; i < 1200; ++i) { + if (GetFileAttributesW(releaseFile) != INVALID_FILE_ATTRIBUTES) break; + Sleep(50); + } + } + fputs("CAP_STDERR_EOF\n", stderr); + fflush(stderr); + return 0; +} +'@ + $sourcePath = Join-Path $fixture 'cim-race-target.c' + [IO.File]::WriteAllText($sourcePath, $source, [Text.UTF8Encoding]::new($false)) + $vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' + if (-not (Test-Path -LiteralPath $vswhere -PathType Leaf)) { + throw 'Existing MSVC discovery is unavailable; this offline suite does not install a compiler.' + } + $discovery = Invoke-CapBoundedChild -FilePath $vswhere -Arguments @( + '-latest', '-products', '*', '-requires', 'Microsoft.VisualStudio.Component.VC.Tools.x86.x64', + '-property', 'installationPath' + ) -Directory $fixture -Role 'cim-race-compiler-discovery' + Assert-CapTest ($discovery.exitCode -eq 0 -and -not [string]::IsNullOrWhiteSpace($discovery.stdout)) 'Existing MSVC was not found.' + $installation = $discovery.stdout.Trim() + $vcvars = Join-Path $installation 'VC\Auxiliary\Build\vcvars64.bat' + $version = [IO.File]::ReadAllText((Join-Path $installation 'VC\Auxiliary\Build\Microsoft.VCToolsVersion.default.txt')).Trim() + $compiler = Join-Path $installation "VC\Tools\MSVC\$version\bin\Hostx64\x64\cl.exe" + $exe = Join-Path $fixture 'cim-race-target.exe' + $compileStdout = Join-Path $fixture 'compile-stdout.log' + $compileStderr = Join-Path $fixture 'compile-stderr.log' + $command = 'call "{0}" -vcvars_ver={1} >nul && "{2}" /nologo /W3 /O2 /utf-8 /Fe:"{3}" "{4}" /link /SUBSYSTEM:CONSOLE' -f $vcvars, $version, $compiler, $exe, $sourcePath + # Same owned-file redirection as New-CapHarmlessTarget: the compiler and any + # telemetry helpers inherit file handles, so only the short-lived cmd.exe holds + # the module's redirected pipe and it reaches bounded EOF on exit. + $redirectedCommand = '({0}) > "{1}" 2> "{2}"' -f $command, $compileStdout, $compileStderr + [IO.File]::WriteAllText((Join-Path $fixture 'compile.cmd'), "@echo off`r`n$redirectedCommand`r`nexit /b %errorlevel%`r`n", [Text.UTF8Encoding]::new($false)) + $compilerEnvironment = @{ PATH = (Split-Path -Parent $vswhere) + ';' + $env:PATH } + foreach ($key in @('VSCMD_VER', '__VSCMD_PREINIT_PATH', 'VSINSTALLDIR', 'VCINSTALLDIR', + 'VCToolsInstallDir', 'VCToolsVersion', 'WindowsSdkDir', 'WindowsSDKVersion', 'INCLUDE', 'LIB', 'LIBPATH')) { + $compilerEnvironment[$key] = '' + } + $build = Invoke-CapBoundedChild -FilePath $env:ComSpec -Arguments @('/d', '/c', '.\compile.cmd') ` + -Directory $fixture -Role 'cim-race-fixture-compile' -Seconds 90 -Environment $compilerEnvironment + # Preserve the actual compiler exit before reading the owned compile logs. + $compilerExitCode = $build.exitCode + $compileStdoutText = if (Test-Path -LiteralPath $compileStdout -PathType Leaf) { [IO.File]::ReadAllText($compileStdout) } else { '' } + $compileStderrText = if (Test-Path -LiteralPath $compileStderr -PathType Leaf) { [IO.File]::ReadAllText($compileStderr) } else { '' } + Assert-CapTest ($compilerExitCode -eq 0 -and (Test-Path -LiteralPath $exe -PathType Leaf)) ` + "CIM race target compilation failed (not launch evidence): $compileStdoutText $compileStderrText" + return [pscustomobject]@{ + executable = $exe + sourceSha256 = (Get-FileHash -LiteralPath $sourcePath -Algorithm SHA256).Hash + executableSha256 = (Get-FileHash -LiteralPath $exe -Algorithm SHA256).Hash + } +} + function Invoke-CapCaptureCommand { param( [string]$Name, @@ -412,6 +512,7 @@ function Assert-CapRehearsalSuccess { $target = $null try { $target = New-CapHarmlessTarget + $cimRaceTarget = New-CapCimRaceTarget if (-not $LaunchContractOnly) { foreach ($mutation in @('accepted', 'lane-d', 'red-ci', 'binary-hash', 'product-diff', 'ci-head', 'empty-ci', 'source-bytes', 'lease-base', 'duplicate-ci', 'duplicate-json', 'null-build', 'stale-build', @@ -595,6 +696,124 @@ try { $run.Identity.Process.Dispose() } } + Invoke-CapTest 'attestation decision seam: fix matrix is correct over all rows and a faithful main mutant discriminates on the torn-row/just-exited and exited-without-EOF rows' { + # TEST-ONLY faithful transcription of baseline main (D4579E92) decision logic: + # - Get-CapProcessIdentity read CIM first and THREW on an Incomplete row before + # consulting the retained kernel exit (so a torn row refused even once the child + # had genuinely exited); Absent retried while live and broke when exited; Complete + # used metadata. + # - Start-CapManagedProcessCore had NO both-stream EOF gate, so an exited generation + # recognized during attestation was accepted gone without EOF. + # This is a decision-level transcription of the THREE selected counterexamples, NOT a + # complete model of main (main also skips CIM when its initial snapshot is already + # exited and still identity-validates a Complete row) and NOT main's code executing; + # it is DISTINCT from the historical observed main RED (J3 baseline 49/48/1, CIM). + function Resolve-MainAttestationDispositionMutant { + param([string]$State, [bool]$KernelExited, [bool]$BothStreamsEofReached) + if ($State -eq 'Incomplete') { return 'MainRefusesIncomplete' } + if ($KernelExited) { return 'MainGoneNoEofGate' } + if ($State -eq 'Complete') { return 'MainUseMetadata' } + return 'MainRetryAbsent' + } + $rows = @( + @{ s = 'Complete'; k = $false; e = $false; fix = 'UseMetadata' } + @{ s = 'Complete'; k = $false; e = $true; fix = 'UseMetadata' } + @{ s = 'Incomplete'; k = $false; e = $false; fix = 'RefuseLiveIncomplete' } + @{ s = 'Incomplete'; k = $false; e = $true; fix = 'RefuseLiveIncomplete' } + @{ s = 'Absent'; k = $false; e = $false; fix = 'RetryAbsent' } + @{ s = 'Absent'; k = $false; e = $true; fix = 'RetryAbsent' } + @{ s = 'Complete'; k = $true; e = $false; fix = 'RequireBothEofBeforeGone' } + @{ s = 'Complete'; k = $true; e = $true; fix = 'GoneByKernelExit' } + @{ s = 'Incomplete'; k = $true; e = $false; fix = 'RequireBothEofBeforeGone' } + @{ s = 'Incomplete'; k = $true; e = $true; fix = 'GoneByKernelExit' } + @{ s = 'Absent'; k = $true; e = $false; fix = 'RequireBothEofBeforeGone' } + @{ s = 'Absent'; k = $true; e = $true; fix = 'GoneByKernelExit' } + ) + foreach ($r in $rows) { + $got = Resolve-CapAttestationDisposition -State $r.s -KernelExited $r.k -BothStreamsEofReached $r.e + Assert-CapTest ($got -ceq $r.fix) "Fix disposition($($r.s),exit=$($r.k),eof=$($r.e))=$got; expected $($r.fix)." + } + $discriminators = @( + @{ s = 'Incomplete'; k = $true; e = $true; fixCorrect = 'GoneByKernelExit'; mainWrong = 'MainRefusesIncomplete' } + @{ s = 'Complete'; k = $true; e = $false; fixCorrect = 'RequireBothEofBeforeGone'; mainWrong = 'MainGoneNoEofGate' } + @{ s = 'Absent'; k = $true; e = $false; fixCorrect = 'RequireBothEofBeforeGone'; mainWrong = 'MainGoneNoEofGate' } + ) + $discriminationCount = 0 + foreach ($d in $discriminators) { + $fixGot = Resolve-CapAttestationDisposition -State $d.s -KernelExited $d.k -BothStreamsEofReached $d.e + $mainGot = Resolve-MainAttestationDispositionMutant -State $d.s -KernelExited $d.k -BothStreamsEofReached $d.e + Assert-CapTest ($fixGot -ceq $d.fixCorrect) "Fix must be correct on discriminator ($($d.s),$($d.k),$($d.e)): got $fixGot." + Assert-CapTest ($mainGot -ceq $d.mainWrong) "Main mutant must reproduce the wrong disposition: got $mainGot." + Assert-CapTest ($fixGot -cne $mainGot) 'Fix and main mutant must differ on this row.' + $discriminationCount++ + } + Assert-CapTest ($discriminationCount -eq 3) "Expected 3 discriminating rows; observed $discriminationCount." + return [ordered]@{ fixRowsProven = $rows.Count; discriminatingRows = $discriminationCount + note = 'Deterministic: real fix helper GREEN over 12 rows; test-only faithful main mutant RED on 3 rows (CIM torn-row refusal + missing EOF gate); distinct from the historical J3 49/48/1 CIM RED.' } + } + Invoke-CapTest 'live-incomplete child is refused with UNPROVEN rollback; test release records it genuinely gone' { + $owned = Join-Path $scratch 'cim-incomplete' + $null = New-Item -ItemType Directory -Path $owned + $releaseFile = Join-Path $owned 'release.flag' + # Outer try/finally covers launch + refusal + ALL assertions so the owned barrier is + # always released and the owned pending resolved even if an early assertion fails. + try { + $refusal = $null + try { + $null = Start-CapManagedProcess -FilePath $cimRaceTarget.executable -ArgumentList @() ` + -WorkingDirectory (Split-Path -Parent $cimRaceTarget.executable) -Role 'cim-incomplete-live-refused' ` + -Environment @{ CAP_CIM_BEHAVIOR = 'clearcmd-fileblock'; CAP_CIM_RELEASE_FILE = $releaseFile } + throw 'A live child with incomplete CIM was not refused.' + } catch { + $refusal = $_.Exception.Message + if ($refusal -eq 'A live child with incomplete CIM was not refused.') { throw $refusal } + Assert-CapTest ($refusal -match 'incomplete current CIM metadata; refusing') "Refusal was not the live-incomplete guard: $refusal" + } + $pending = @((Get-CapPendingLaunches) | Where-Object { $_.Role -ceq 'cim-incomplete-live-refused' }) + Assert-CapTest ($pending.Count -eq 1 -and $null -ne $pending[0].RollbackFailure -and $pending[0].KernelExited -eq $false) ` + 'Live-incomplete child was not left as an UNPROVEN/retained pending launch (strict production contract).' + $childPid = $pending[0].Pid + $clsLive = Read-CapProcessMetadataState -Pid $childPid + Assert-CapTest ($clsLive.State -ceq 'Incomplete') "Live refused child classified $($clsLive.State); expected Incomplete." + # Test graceful assistance: release the test's OWN known child via a private + # release file it owns (never a kill of a foreign/generic pid). + [IO.File]::WriteAllText($releaseFile, 'release') + $deadline = [DateTime]::UtcNow.AddSeconds(30) + while ([DateTime]::UtcNow -lt $deadline) { + $p = @((Get-CapPendingLaunches) | Where-Object { $_.Pid -eq $childPid }) + if (-not $p -or $p.KernelExited -eq $true) { break } + Start-Sleep -Milliseconds 50 + } + $clsGone = Read-CapProcessMetadataState -Pid $childPid + Assert-CapTest ($clsGone.State -ceq 'Absent') "Exited child classified $($clsGone.State); expected Absent." + # Resolve-CapPendingLaunches processes all module-owned pending records; at this + # point only this control's own child should remain pending. + $resolved = @(Resolve-CapPendingLaunches) + $me = $resolved | Where-Object { $_.Pid -eq $childPid } + Assert-CapTest ($me -and $me.Result -ceq 'SKIP_GENUINELY_EXITED_KERNEL' -and $me.KernelExited -and + $me.StdoutEof -and $me.StderrEof -and + @((Get-CapPendingLaunches) | Where-Object { $_.Role -ceq 'cim-incomplete-live-refused' }).Count -eq 0) ` + 'Test-release cleanup did not record genuinely gone + both EOF + zero pending.' + return [ordered]@{ refusal = $refusal + productionRollback = 'UNPROVEN_RETAINED (strict live-unknown contract preserved; no held-handle metadata bypass)' + incompleteClassificationObserved = $true + cleanup = 'TEST_GRACEFUL_RELEASE_OF_OWN_CHILD (not production live-unknown rollback success)' + resolvedResult = $me.Result } + } finally { + # Leak-prevention for THIS control's own child only (never a foreign kill): + # release the owned barrier file, wait for the owned generation's kernel exit, + # then resolve its own pending record. No bare catch that could mask a fault. + if (-not (Test-Path -LiteralPath $releaseFile)) { [IO.File]::WriteAllText($releaseFile, 'release') } + if (@((Get-CapPendingLaunches) | Where-Object { $_.Role -ceq 'cim-incomplete-live-refused' })) { + $deadline = [DateTime]::UtcNow.AddSeconds(20) + while ([DateTime]::UtcNow -lt $deadline -and + @((Get-CapPendingLaunches) | Where-Object { $_.Role -ceq 'cim-incomplete-live-refused' -and $_.KernelExited -ne $true })) { + Start-Sleep -Milliseconds 50 + } + $null = Resolve-CapPendingLaunches + } + } + } Invoke-CapTest 'current metadata and saved parent lifetime refuse unknown generations' { $identity = [pscustomobject]@{ Pid = 123; CreationFileTimeUtc = 100; ExpectedImagePath = 'C:\owned\target.exe'; ParentPid = 1; SessionId = 1 } $null = Assert-CapRejects { Assert-CapMetadataRecord -Identity $identity -Metadata $null } 'missing for live PID' diff --git a/Tools/windows/UiCaptureDriver.psm1 b/Tools/windows/UiCaptureDriver.psm1 index 37ac42fd..83852abb 100644 --- a/Tools/windows/UiCaptureDriver.psm1 +++ b/Tools/windows/UiCaptureDriver.psm1 @@ -822,15 +822,35 @@ function Get-CapProcessIdentity { throw "Live $Role executable mismatch: $($snapshot.ImagePath) != $expected." } $metadata = $null + # Observability (records the ACTUAL terminal classification and whether the + # conclusion was governed by a retained kernel exit; never a mere alias of the + # metadata-observed flag). + $terminalState = if ($snapshot.IsExited) { 'KernelExitedBeforeQuery' } else { $null } + $concludedGoneByKernelExit = [bool]$snapshot.IsExited if (-not $snapshot.IsExited) { for ($attempt = 0; $attempt -lt 20; $attempt++) { - $metadata = Get-CapCurrentMetadata -Pid $Process.Id - if ($metadata) { break } + $state = Read-CapProcessMetadataState -Pid $Process.Id + # The retained kernel handle governs an Absent/Incomplete reading. EOF is + # proven later by the launch caller, so the loop passes BothStreamsEofReached + # = $false and treats either exited disposition as "break and defer the EOF + # gate to the caller". $snapshot = [CapNative]::Snapshot($Process.Handle, $Process.Id) - if ($snapshot.IsExited) { break } + $disposition = Resolve-CapAttestationDisposition -State $state.State ` + -KernelExited ([bool]$snapshot.IsExited) -BothStreamsEofReached $false + $terminalState = if ($snapshot.IsExited) { "KernelExitedWith$($state.State)" } else { $state.State } + if ($disposition -eq 'UseMetadata') { $metadata = $state.Metadata; break } + if ($disposition -eq 'GoneByKernelExit' -or $disposition -eq 'RequireBothEofBeforeGone') { + $concludedGoneByKernelExit = $true; break + } + if ($disposition -eq 'RefuseLiveIncomplete') { + # A torn-down row on a still-live owned child is refused outright, + # exactly as main did; it is never retried into a passing outcome. + throw "Live $Role PID $($Process.Id) has incomplete current CIM metadata; refusing ownership inference." + } + # RetryAbsent: not yet listed on a live child -> bounded startup re-poll. Start-Sleep -Milliseconds 50 } - if ($null -eq $metadata -and -not $snapshot.IsExited -and + if ($null -eq $metadata -and -not $concludedGoneByKernelExit -and -not $snapshot.IsExited -and ($ExpectedParentPid -lt 0 -or $ExpectedSessionId -lt 0)) { throw "Live $Role PID $($Process.Id) has no current CIM metadata." } @@ -850,6 +870,8 @@ function Get-CapProcessIdentity { SessionId = if ($ExpectedSessionId -ge 0) { $ExpectedSessionId } elseif ($metadata) { $metadata.SessionId } else { $null } InitialMetadataObserved = $null -ne $metadata InitialMetadataMatchesExpected = $expectedMetadataMatches + AttestationTerminalState = $terminalState + ConcludedGoneByKernelExit = $concludedGoneByKernelExit } if ($metadata) { $null = Assert-CapMetadataRecord -Identity $identity -Metadata $metadata } return $identity @@ -892,27 +914,83 @@ function Test-CapGraphAppRootRole { return $Role -match '^graphcode-app-(before|after)-(normal|actions)$' } -function Get-CapCurrentMetadata { +function Read-CapProcessMetadataState { [CmdletBinding()] param([Parameter(Mandatory)][int]$Pid) Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters + # Classify the current Win32_Process reading without inferring liveness: + # Absent - no single matching row (the PID is not currently listed) + # Incomplete - a row exists but a required field is torn down/unreadable + # Complete - a fully populated owned-process row + # The caller decides what Absent/Incomplete means by consulting the retained + # kernel handle; this function never treats a missing field as proof of death. $rows = @(Get-CimInstance -ClassName Win32_Process -Filter "ProcessId = $Pid" -ErrorAction Stop) - if ($rows.Count -ne 1) { return $null } + if ($rows.Count -ne 1) { return [pscustomobject]@{ State = 'Absent'; Metadata = $null } } $row = $rows[0] if ($null -eq $row.CreationDate -or [string]::IsNullOrWhiteSpace([string]$row.ExecutablePath) -or [string]::IsNullOrWhiteSpace([string]$row.CommandLine) -or $null -eq $row.ParentProcessId) { - throw "Live PID $Pid has incomplete current CIM metadata; refusing ownership inference." + return [pscustomobject]@{ State = 'Incomplete'; Metadata = $null } } return [pscustomobject]@{ - Pid = [int]$row.ProcessId - ParentPid = [int]$row.ParentProcessId - SessionId = [int]$row.SessionId - CreationFileTimeUtc = Convert-CapCimCreationTime $row.CreationDate - ImagePath = ConvertTo-CapImagePath -Path ([string]$row.ExecutablePath) - CommandLine = [string]$row.CommandLine + State = 'Complete' + Metadata = [pscustomobject]@{ + Pid = [int]$row.ProcessId + ParentPid = [int]$row.ParentProcessId + SessionId = [int]$row.SessionId + CreationFileTimeUtc = Convert-CapCimCreationTime $row.CreationDate + ImagePath = ConvertTo-CapImagePath -Path ([string]$row.ExecutablePath) + CommandLine = [string]$row.CommandLine + } + } +} + +function Resolve-CapAttestationDisposition { + [CmdletBinding()] + param( + [Parameter(Mandatory)][ValidateSet('Complete', 'Incomplete', 'Absent')][string]$State, + [Parameter(Mandatory)][bool]$KernelExited, + [Parameter(Mandatory)][bool]$BothStreamsEofReached + ) + Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters + + + # Pure decision rule bound to the (observed CIM classification x retained-kernel + # exit x both-stream EOF) matrix. No process, I/O or timing. It encodes two + # invariants: the retained kernel handle governs an Absent/Incomplete reading, + # and a genuinely-gone conclusion requires both redirected streams to have + # reached bounded EOF - a kernel exit alone never suffices. + # UseMetadata - live child, complete row; attest with it. + # RefuseLiveIncomplete - live child, torn-down row; refuse immediately. + # RetryAbsent - live child, not yet listed; bounded startup re-poll. + # GoneByKernelExit - kernel exited AND both streams reached EOF; gone. + # RequireBothEofBeforeGone - kernel exited but EOF not yet proven; not gone. + if (-not $KernelExited) { + switch ($State) { + 'Complete' { return 'UseMetadata' } + 'Incomplete' { return 'RefuseLiveIncomplete' } + 'Absent' { return 'RetryAbsent' } + } } + if ($BothStreamsEofReached) { return 'GoneByKernelExit' } + return 'RequireBothEofBeforeGone' +} + +function Get-CapCurrentMetadata { + [CmdletBinding()] + param([Parameter(Mandatory)][int]$Pid) + Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters + + + # Strict contract (unchanged): a complete reading returns the metadata record, + # an absent reading returns $null, and an incomplete reading is refused. Callers + # that must distinguish incomplete from absent use Read-CapProcessMetadataState. + $state = Read-CapProcessMetadataState -Pid $Pid + if ($state.State -eq 'Incomplete') { + throw "Live PID $Pid has incomplete current CIM metadata; refusing ownership inference." + } + return $state.Metadata } function Assert-CapMetadataRecord { @@ -958,11 +1036,16 @@ function Assert-CapProcessIdentity { (ConvertTo-CapImagePath -Path $Identity.ExpectedImagePath)) { throw "Kernel process identity changed for live PID $($Identity.Pid); refusing." } - $metadata = Get-CapCurrentMetadata -Pid $Identity.Pid - if ($null -eq $metadata) { + $state = Read-CapProcessMetadataState -Pid $Identity.Pid + if ($state.State -ne 'Complete') { + # A missing or incomplete reading is governed by the retained kernel handle, + # never treated as proof of death on its own. If the owned generation has + # exited, report it exited; if it is still live, an incomplete/absent row + # is a refusal, not a silent pass. if ([CapNative]::IsExited($Identity.Process.Handle, $Identity.Pid)) { return [pscustomobject]@{ State = 'Exited'; Snapshot = $snapshot; Metadata = $null } } - throw "Live PID $($Identity.Pid) has no current CIM row; refusing." + throw "Live PID $($Identity.Pid) has no complete current CIM row; refusing." } + $metadata = $state.Metadata $null = Assert-CapMetadataRecord -Identity $Identity -Metadata $metadata return [pscustomobject]@{ State = 'Live'; Snapshot = $snapshot; Metadata = $metadata } } @@ -1160,11 +1243,29 @@ function Start-CapManagedProcessCore { if ($identity.Pid -ne $process.Id -or $identity.CreationFileTimeUtc -ne $launchRecord.Snapshot.CreationFileTimeUtc) { throw "Identity attestor returned a different kernel generation for $Role." } - if (-not [CapNative]::IsExited($process.Handle, $process.Id)) { + $exitedDuringAttestation = [CapNative]::IsExited($process.Handle, $process.Id) + if (-not $exitedDuringAttestation) { if (-not $identity.InitialMetadataObserved -or -not $identity.InitialMetadataMatchesExpected) { throw "Live $Role PID $($process.Id) lacks a complete expected-parent/session attestation." } - $null = Assert-CapProcessIdentity -Identity $identity + # Consume the verification result: the child can exit during this final + # assertion, and that exit must also be routed through the shared EOF gate + # rather than silently accepted. + $verified = Assert-CapProcessIdentity -Identity $identity + if ($verified.State -eq 'Exited') { $exitedDuringAttestation = $true } + } + if ($exitedDuringAttestation) { + # The owned generation exited during attestation. Route the final gone + # decision through the same pure decision seam: a retained kernel exit is + # accepted as genuinely gone ONLY when both redirected streams have reached + # bounded EOF, never from a missing or incomplete CIM reading alone. Every + # attestation-time exit, including one discovered inside the final assertion, + # passes through this one gate. + $bothEof = $launchRecord.StdoutTask.Wait(2000) -and $launchRecord.StderrTask.Wait(2000) + $final = Resolve-CapAttestationDisposition -State 'Absent' -KernelExited $true -BothStreamsEofReached $bothEof + if ($final -cne 'GoneByKernelExit') { + throw "Exited $Role PID $($process.Id) did not reach bounded stdout/stderr EOF; cannot attest it genuinely gone." + } } $launchRecord.Identity = $identity if ($CloseStdin) { $process.StandardInput.Close() } @@ -1966,6 +2067,8 @@ Export-ModuleMember -Function @( 'Find-CapTopLevelWindow', 'Capture-CapWindow', 'Get-CapProcessIdentity', + 'Read-CapProcessMetadataState', + 'Resolve-CapAttestationDisposition', 'Get-CapCurrentMetadata', 'Assert-CapMetadataRecord', 'Assert-CapProcessIdentity', From 944ee565ed2e36885b8cf1087fc5fbe2d05d38b6 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Thu, 15 Oct 2026 10:28:37 -0700 Subject: [PATCH 2/2] Publish immutable capture journal snapshots Replace the mutable process journal with numbered non-overwriting snapshots, bounded fail-closed observation and exact quiescent terminal bindings. Preserve strict process identity, kernel/EOF cleanup and controlled failure outcomes. Local focused10 and full59 evidence was recorded at the original96950 head with these exact source bytes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 91082413-872d-4f52-ac8c-a2ecd66fb126 Signed-off-by: Colin Neilens --- Tools/windows/Invoke-UiCapture.ps1 | 158 ++-- Tools/windows/Invoke-UiCaptureWorker.ps1 | 128 +++- Tools/windows/Tests/UiCaptureDriver.Tests.ps1 | 596 +++++++++++++-- Tools/windows/UiCaptureDriver.psm1 | 690 ++++++++++++++++++ 4 files changed, 1444 insertions(+), 128 deletions(-) diff --git a/Tools/windows/Invoke-UiCapture.ps1 b/Tools/windows/Invoke-UiCapture.ps1 index b15d0995..c9f0c9f2 100644 --- a/Tools/windows/Invoke-UiCapture.ps1 +++ b/Tools/windows/Invoke-UiCapture.ps1 @@ -37,6 +37,10 @@ $releaseFailure = $null $releaseWritten = $false $cleanupProven = $false $processJournalPath = $null +$journalState = $null +$journalProof = $null +$boundWorkerManifest = $null +$journalFailure = $null $safeOutputInitialized = $false $primaryFailure = $null $recoveryReceipts = [Collections.Generic.List[object]]::new() @@ -188,7 +192,7 @@ function Assert-CapPreflightReport { 'lease-bound admission omitted-product-diff through actual capture command', 'lease-bound admission phase-swap through actual capture command', 'lease-bound admission unrelated-base through actual capture command', - 'actual journal reader permits worker atomic replacement while retaining its snapshot' + 'actual immutable journal reader preserves held snapshots across concurrent numbered publications' )) { if (@($preflight.cases | Where-Object name -CEQ $required).Count -ne 1) { throw "Repaired-core preflight is missing a unique required actual-command case: $required" @@ -235,27 +239,26 @@ function Assert-CapPreflightReport { throw 'Offline preflight lacks two fresh, isolated, no-backend zmx seed cleanup proofs.' } $seedEvidenceRoot = Assert-CapSupervisorPath ([string]$preflight.actualSeedEvidenceReuse.sourceRoot) - $seedJournalPath = Assert-CapSupervisorPath ([string]$preflight.actualSeedEvidenceReuse.processJournalPath) + if (-not $preflight.actualSeedEvidenceReuse.PSObject.Properties['journalProof']) { + throw 'LegacyJournalProtocol: mutable seed journal proof must be regenerated as an exact numbered terminal reference.' + } + $seedReference = $preflight.actualSeedEvidenceReuse.journalProof + $seedJournalPath = Assert-CapSupervisorPath ([string]$seedReference.directory) if ($preflight.actualSeedEvidenceReuse.mode -cne $preflight.actualSeedEvidenceMode -or $preflight.actualSeedEvidenceReuse.sourceRunId -cne $preflight.actualPhaseSeedLifecycle[0].evidenceRunId -or - $seedJournalPath -cne (Join-Path $seedEvidenceRoot 'process-journal.json') -or - -not (Test-Path -LiteralPath $seedJournalPath -PathType Leaf) -or - (Get-FileHash -Algorithm SHA256 -LiteralPath $seedJournalPath).Hash -ne - $preflight.actualSeedEvidenceReuse.processJournalSha256) { + $seedReference.binding.runId -cne $preflight.actualSeedEvidenceReuse.sourceRunId) { throw 'Offline actual-seed evidence source root or process journal does not match its hash-bound receipt.' } - $seedJournal = Get-Content -LiteralPath $seedJournalPath -Raw | ConvertFrom-Json - if ($seedJournal.jobSha256 -ne $jobHash -or $seedJournal.moduleSha256 -ne $moduleHash -or - $seedJournal.workerSha256 -ne $workerHash) { - throw 'Offline actual-seed process journal was recorded against different current controls.' - } + $seedProof = Read-CapNumberedSeedProof -Root $root -Reference $seedReference ` + -JobSha256 $jobHash -ModuleSha256 $moduleHash -WorkerSha256 $workerHash -DeadlineUtc $leaseExpiresUtc + $seedJournal = $seedProof.Terminal.Payload.data $seedArguments = @($job.binaries.offlineZmx.seedArgumentOrder) foreach ($seedEvidence in $preflight.actualPhaseSeedLifecycle) { $receiptPath = Assert-CapSupervisorPath ([string]$seedEvidence.receiptPath) if (-not $receiptPath.StartsWith($seedEvidenceRoot.TrimEnd('\') + '\', [StringComparison]::OrdinalIgnoreCase) -or $seedEvidence.processJournalPath -cne $seedJournalPath -or - $seedEvidence.processJournalSha256 -ne $preflight.actualSeedEvidenceReuse.processJournalSha256 -or + $seedEvidence.processJournalSha256 -cne $seedReference.record.terminal.sha256 -or -not (Test-Path -LiteralPath $receiptPath -PathType Leaf) -or (Get-FileHash -Algorithm SHA256 -LiteralPath $receiptPath).Hash -ne $seedEvidence.receiptSha256) { throw "Actual $($seedEvidence.phase) zmx seed receipt is outside or inconsistent with its bound evidence root." @@ -404,34 +407,76 @@ function Add-CapTrackedIdentity { } } -function Open-CapJournalRead { - param([Parameter(Mandatory)][string]$Path) - if (Get-Command Write-CapTrace -ErrorAction SilentlyContinue) { Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters } - - # A polling reader must not prevent the worker from atomically replacing its journal. - return [IO.File]::Open($Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, - ([IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete)) -} - function Refresh-CapJournal { - param([Parameter(Mandatory)][string]$Path) + param([Parameter(Mandatory)][string]$Path, [DateTime]$ReadDeadlineUtc = $deadlineUtc) if (Get-Command Write-CapTrace -ErrorAction SilentlyContinue) { Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters } - - if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return } + if (-not $journalState -or $journalState.Directory -ine $Path) { + throw 'JournalIdentity: polling state is not bound to the authorized numbered directory.' + } + $journalState.DeadlineUtc = $ReadDeadlineUtc try { - $stream = Open-CapJournalRead -Path $Path - try { - $reader = [IO.StreamReader]::new($stream, [Text.UTF8Encoding]::new($false, $true), $true) - try { $journal = $reader.ReadToEnd() | ConvertFrom-Json } - finally { $reader.Dispose() } - } finally { $stream.Dispose() } + $reading = Read-CapJournalSnapshot -State $journalState + if ($reading.State -ceq 'PendingFirstPublication') { return } + foreach ($entry in $reading.Snapshot.Payload.data.entries) { $null = Add-CapTrackedIdentity -Entry $entry } + } catch { + if ($null -eq $journalFailure) { + $script:journalFailure = $_.Exception + $cleanupFailures.Add("Journal observation failed: $($_.Exception.Message)") + } + throw + } +} + +function Read-CapQuiescentWorkerProof { + if (-not [CapNative]::IsExited($workerRun.Identity.Process.Handle, $workerRun.Identity.Pid) -or + -not $workerRun.StdoutTask.IsCompleted -or -not $workerRun.StderrTask.IsCompleted -or + $workerRun.StdoutTask.IsFaulted -or $workerRun.StderrTask.IsFaulted) { + throw 'JournalCleanupUnproven: worker kernel exit and successful both-stream EOF are required first.' + } + $journalState.DeadlineUtc = $leaseExpiresUtc + $record = Read-CapJournalFinalRecord -Stdout ([string]$workerRun.StdoutTask.Result) -Binding $journalState.Binding + if ($record.manifest.name -cne 'manifest.json') { throw 'JournalFinalBinding: final manifest name differs.' } + $script:journalProof = Read-CapJournalFinalProof -State $journalState ` + -OutputDirectory $outputDirectory -Record $record -RetainStreams + $script:boundWorkerManifest = $journalProof.Manifest + if ($boundWorkerManifest.ownedProcessSurvivors.Count -ne 0 -or + $boundWorkerManifest.pendingLaunchesRemaining.Count -ne 0 -or + $boundWorkerManifest.cleanupFailures.Count -ne 0) { + throw 'JournalCleanupUnproven: final owned cleanup arrays are not empty.' + } + Assert-CapDataRecord $boundWorkerManifest @{ + leaseId = [string]; jobManifestSha256 = [string]; moduleSha256 = [string] + workerSha256 = [string]; supervisorSha256 = [string]; leaseReceiptSha256 = [string] + } 'Proven worker manifest identity' + if ($boundWorkerManifest.leaseId -cne $journalState.Binding.leaseId -or + $boundWorkerManifest.jobManifestSha256 -cne $journalState.Binding.jobSha256 -or + $boundWorkerManifest.moduleSha256 -cne $journalState.Binding.moduleSha256 -or + $boundWorkerManifest.workerSha256 -cne $journalState.Binding.workerSha256 -or + $boundWorkerManifest.supervisorSha256 -cne $journalState.Binding.supervisorSha256 -or + $boundWorkerManifest.leaseReceiptSha256 -cne $leaseHash) { + throw 'JournalIdentity: final manifest source/lease fields differ from the proven binding.' + } + if ($workerRun.Identity.Process.ExitCode -ne 0 -and $boundWorkerManifest.status -ceq 'COMPLETE') { + throw 'JournalFinalBinding: failed worker cannot supply a COMPLETE final manifest.' + } + $null = Assert-CapNoWorkerTransientArtifacts -Directories @($runDirectory, $outputDirectory) + if (-not $primaryFailure -and $workerRun.Identity.Process.ExitCode -eq 0) { + $jobFailure = Get-CapWorkerJobFailure + if ($null -ne $jobFailure) { + $script:primaryFailure = $jobFailure + Write-CapSupervisorLog "WORKER_JOB_ASSERTION_FAILURE=$($jobFailure.Exception.ToString())" + } } - catch { throw "Worker process journal is malformed: $($_.Exception.Message)" } - if ($journal.leaseId -ne $lease.leaseId -or $journal.jobSha256 -ne $jobHash -or - $journal.moduleSha256 -ne $moduleHash -or $journal.workerSha256 -ne $workerHash) { - throw 'Worker process journal hash/lease binding differs from the coordinator-approved run.' +} + +function Get-CapWorkerJobFailure { + param() + if (Get-Command Write-CapTrace -ErrorAction SilentlyContinue) { + Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters } - foreach ($entry in $journal.entries) { $null = Add-CapTrackedIdentity -Entry $entry } + try { Assert-CapWorkerManifest } + catch { return $_ } + return $null } function Get-CapAppSessionPrefix { @@ -554,11 +599,10 @@ function Assert-CapNoTrackedSurvivors { function Assert-CapWorkerManifest { if (Get-Command Write-CapTrace -ErrorAction SilentlyContinue) { Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters } - $path = Join-Path $outputDirectory 'manifest.json' - if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { - throw 'Worker exited successfully without a final capture manifest.' + if ($null -eq $boundWorkerManifest -or $null -eq $journalProof) { + throw 'Worker exited without exact quiescent manifest/terminal binding.' } - $manifest = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json + $manifest = $boundWorkerManifest if ($rehearsal) { if ($manifest.status -cne 'COMPLETE' -or $manifest.executionMode -cne $ExecutionMode -or $manifest.guiLaunches.Count -ne 0 -or $manifest.images.Count -ne 0 -or @@ -747,14 +791,17 @@ try { $shortLease = $lease.leaseId.Replace('-', '').Substring(0, 8) $runDirectory = Join-Path $root ('runs\' + [guid]::NewGuid().ToString('N')) $authorizationPath = Join-Path $runDirectory 'supervisor-authorization.json' - $processJournalPath = Join-Path $runDirectory 'process-journal.json' - $manifestPath = Join-Path $outputDirectory 'manifest.partial.json' + $processJournalPath = Join-Path $runDirectory 'process-journal' + $manifestPath = Join-Path $outputDirectory 'manifest.json' $workerLogPath = Join-Path $outputDirectory 'worker.log' $releaseReceiptPath = Join-Path $outputDirectory 'lease-release.json' foreach ($path in @($runDirectory)) { if (Test-Path -LiteralPath $path) { throw "Refusing to reuse prior capture run path: $path" } } New-Item -ItemType Directory -Path $runDirectory -Force | Out-Null + $null = New-Item -ItemType Directory -Path $processJournalPath + $diagnosticsDirectory = Join-Path $outputDirectory 'diagnostics' + $null = New-Item -ItemType Directory -Path $diagnosticsDirectory $supervisorIdentity = Get-CapProcessIdentity -Process $thisProcess ` -ExpectedImagePath $supervisorExecutable -Role 'b-capture-supervisor' $auth = [ordered]@{ @@ -802,6 +849,11 @@ try { '-AuthorizationPath', $authorizationPath, '-AuthorizationSha256', $authorizationHash, '-OwnedRoot', $root ) -WorkingDirectory $runDirectory -Role 'b-capture-worker' + $journalBinding = New-CapJournalBinding -RunId ([IO.Path]::GetFileName($runDirectory)) ` + -LeaseId $lease.leaseId -JobSha256 $jobHash -ModuleSha256 $moduleHash ` + -WorkerSha256 $workerHash -SupervisorSha256 $supervisorHash -Identity $workerRun.Identity + $journalState = New-CapJournalState -Root $root -Directory $processJournalPath -Binding $journalBinding ` + -DeadlineUtc $deadlineUtc -ObservationPath (Join-Path $diagnosticsDirectory 'journal-read-observations.jsonl') $lastJournalRead = [DateTime]::MinValue while ([DateTime]::UtcNow -lt $deadlineUtc) { if (@(Get-CapTraceFailures).Count -gt 0) { throw 'Capture tracing failed; supervisor is beginning guarded cleanup.' } @@ -816,7 +868,7 @@ try { if ($workerWait.State -ne 'Exited') { $primaryFailure = [TimeoutException]::new('Worker reached the bounded capture deadline; supervisor is beginning guarded cleanup.') Write-CapSupervisorLog "WORKER_DEADLINE=REACHED utc=$([DateTime]::UtcNow.ToString('o'))" - Refresh-CapJournal -Path $processJournalPath + Refresh-CapJournal -Path $processJournalPath -ReadDeadlineUtc $leaseExpiresUtc if (-not $rehearsal) { Recover-CapGuiRoots -CleanupDeadlineUtc $leaseExpiresUtc Stop-CapTrackedPeers @@ -826,10 +878,9 @@ try { if ($workerGrace.State -eq 'Exited') { $workerWait = $workerGrace } else { Stop-CapWorkerIfNeeded } } else { - Refresh-CapJournal -Path $processJournalPath + Refresh-CapJournal -Path $processJournalPath -ReadDeadlineUtc $leaseExpiresUtc $workerExitCode = $workerWait.ExitCode if ($workerExitCode -ne 0) { $primaryFailure = [InvalidOperationException]::new("Capture worker exited with code $workerExitCode.") } - elseif (-not $primaryFailure) { Assert-CapWorkerManifest } } } catch { if (-not $primaryFailure) { $primaryFailure = $_ } @@ -844,7 +895,7 @@ try { if ($workerRun) { # A journal or log failure must not bypass the remaining guarded stops. foreach ($step in @( - { if ($processJournalPath) { Refresh-CapJournal -Path $processJournalPath } }, + { if ($journalState) { Refresh-CapJournal -Path $processJournalPath -ReadDeadlineUtc $leaseExpiresUtc } }, { if (-not $rehearsal -and $tracked.Count -gt 0) { Recover-CapGuiRoots -CleanupDeadlineUtc ([DateTime]::UtcNow.AddSeconds(30)) @@ -861,7 +912,8 @@ try { }, { Stop-CapWorkerIfNeeded }, { Write-CapProcessOutput -Run $workerRun -Prefix 'supervisor-worker' }, - { Assert-CapNoTrackedSurvivors } + { Assert-CapNoTrackedSurvivors }, + { Read-CapQuiescentWorkerProof } )) { try { & $step } catch { @@ -886,15 +938,7 @@ try { try { Write-CapTrace -Command 'Write-CapJsonAtomic' -Parameters @{ Path = $releaseReceiptPath; FinalRelease = $true } } catch { $cleanupFailures.Add("Release trace write failed: $(Get-CapSupervisorFailureText $_)") } } - $manifest = $null - foreach ($name in @('manifest.json', 'manifest.partial.json')) { - $path = Join-Path $outputDirectory $name - if (Test-Path -LiteralPath $path -PathType Leaf) { - try { $manifest = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json -DateKind String } - catch { $cleanupFailures.Add("Release manifest read failed: $(Get-CapSupervisorFailureText $_)") } - break - } - } + $manifest = $boundWorkerManifest if ($workerRun -and (-not $manifest -or -not $manifest.PSObject.Properties['ownedProcessSurvivors'] -or -not $manifest.PSObject.Properties['pendingLaunchesRemaining'] -or @@ -948,6 +992,8 @@ try { workerCleanupFailures = if ($manifest -and $manifest.PSObject.Properties['cleanupFailures'] -and $manifest.cleanupFailures -is [array]) { ,@($manifest.cleanupFailures) } else { $null } workerPendingLaunchesRemaining = if ($manifest -and $manifest.PSObject.Properties['pendingLaunchesRemaining'] -and $manifest.pendingLaunchesRemaining -is [array]) { ,@($manifest.pendingLaunchesRemaining) } else { $null } workerOwnedProcessSurvivors = if ($manifest -and $manifest.PSObject.Properties['ownedProcessSurvivors'] -and $manifest.ownedProcessSurvivors -is [array]) { ,@($manifest.ownedProcessSurvivors) } else { $null } + journalFinalBinding = if ($journalProof) { $journalProof.Record } else { $null } + journalPublishedInventory = if ($journalProof) { ,@($journalProof.Inventory) } else { $null } captures = if ($manifest) { $manifest.images.Count } else { 0 } sourceHashesBeforeImport = [ordered]@{ module = $moduleHash; worker = $workerHash; supervisor = $supervisorHash } } @@ -960,6 +1006,8 @@ try { } } +if ($journalProof) { foreach ($stream in $journalProof.HeldStreams) { $stream.Dispose() } } + if ($cleanupFailures.Count -or $releaseFailure -or -not $releaseWritten) { throw "Capture supervisor failed. Primary=$(Get-CapSupervisorFailureText $primaryFailure); cleanup=$($cleanupFailures -join '; '); release=$(Get-CapSupervisorFailureText $releaseFailure); releaseWritten=$releaseWritten" } diff --git a/Tools/windows/Invoke-UiCaptureWorker.ps1 b/Tools/windows/Invoke-UiCaptureWorker.ps1 index 666b9513..88f75bd2 100644 --- a/Tools/windows/Invoke-UiCaptureWorker.ps1 +++ b/Tools/windows/Invoke-UiCaptureWorker.ps1 @@ -14,6 +14,11 @@ $rehearsalLaunches = [Collections.Generic.List[object]]::new() $peerScript = Join-Path $root 'inputs\offline-peer-source.ps1' $fixturePath = Join-Path $root 'fixtures\fixture.json' $journalPath = $null +$journalState = $null +$journalFinalLine = $null +$journalFinalCleanupCount = -1 +$ownedProcessReceipts = @() +$leaseExpiresUtc = [DateTime]::MinValue $outputDirectory = $null $runDirectory = $null $auth = $null @@ -114,17 +119,66 @@ function Write-CapProcessJournal { } } if ($index -ge 0) { $journalEntries[$index] = $entry } else { $journalEntries.Add($entry) } - Write-CapJsonAtomic -Path $journalPath -Value ([ordered]@{ - schemaVersion = 1 - leaseId = $auth.leaseId - jobSha256 = $jobHash - workerSha256 = $workerHash - moduleSha256 = $moduleHash - updatedUtc = [DateTime]::UtcNow.ToString('o') - entries = @($journalEntries) + Write-CapWorkerJournalSnapshot -Kind 'Progress' +} + +function Write-CapWorkerJournalSnapshot { + param([ValidateSet('Bootstrap','Progress','Terminal')][string]$Kind, + [string]$Outcome = 'CAPTURING') + if ($null -eq $journalState) { throw 'Worker journal was not initialized before publication.' } + $null = Publish-CapJournalSnapshot -State $journalState -Kind $Kind -Value ([pscustomobject]@{ + entries = @($journalEntries.ToArray()) + outcome = $Outcome + ownedProcesses = @($ownedProcessReceipts) + ownedProcessSurvivors = @($ownedProcessSurvivors) + pendingLaunchesRemaining = @($pendingLaunchesRemaining) + cleanupFailures = @($cleanupFailures.ToArray()) + progressManifest = [pscustomobject](New-CapManifest -Status $Outcome) }) } +function Get-CapWorkerProcessReceipts { + $records = [Collections.Generic.List[object]]::new() + $roots = @{} + foreach ($run in $runs) { + $identity = $run.Identity + $kernelExited = [CapNative]::IsExited($identity.Process.Handle, $identity.Pid) + $stdoutEof = $run.StdoutTask.IsCompleted -and -not $run.StdoutTask.IsFaulted -and -not $run.StdoutTask.IsCanceled + $stderrEof = $run.StderrTask.IsCompleted -and -not $run.StderrTask.IsFaulted -and -not $run.StderrTask.IsCanceled + $record = [pscustomobject]@{ + pid = [long]$identity.Pid; creationFileTimeUtc = [long]$identity.CreationFileTimeUtc + expectedImagePath = $identity.ExpectedImagePath; role = $identity.Role + parentPid = [long]$identity.ParentPid; sessionId = [long]$identity.SessionId + kernelExited = $kernelExited; stdoutEof = $stdoutEof; stderrEof = $stderrEof + streamOwnerPid = [long]$identity.Pid + streamOwnerCreationFileTimeUtc = [long]$identity.CreationFileTimeUtc + } + if ($roots.ContainsKey($identity.Pid)) { throw 'Duplicate managed-root process generation.' } + $roots[$identity.Pid] = $record + $records.Add($record) + } + foreach ($identity in $descendantIdentities) { + $parentPid = $identity.ParentPid + $visited = [Collections.Generic.HashSet[int]]::new() + while (-not $roots.ContainsKey($parentPid)) { + if (-not $visited.Add([int]$parentPid)) { throw 'Descendant ownership cycle.' } + $parents = @($descendantIdentities | Where-Object { $_.Pid -eq $parentPid }) + if ($parents.Count -ne 1) { throw 'Descendant has no unique owning managed stream root.' } + $parentPid = $parents[0].ParentPid + } + $owner = $roots[$parentPid] + $records.Add([pscustomobject]@{ + pid = [long]$identity.Pid; creationFileTimeUtc = [long]$identity.CreationFileTimeUtc + expectedImagePath = $identity.ExpectedImagePath; role = $identity.Role + parentPid = [long]$identity.ParentPid; sessionId = [long]$identity.SessionId + kernelExited = [CapNative]::IsExited($identity.Process.Handle, $identity.Pid) + stdoutEof = $owner.stdoutEof; stderrEof = $owner.stderrEof + streamOwnerPid = $owner.pid; streamOwnerCreationFileTimeUtc = $owner.creationFileTimeUtc + }) + } + return @($records.ToArray()) +} + function Assert-CapAcceptedInputs { Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters @@ -505,7 +559,7 @@ function Save-CapSceneImage { privacyReview = $null } $images.Add($record) - Write-CapJsonAtomic -Path $manifestPath -Value (New-CapManifest -Status 'CAPTURING') + Write-CapWorkerJournalSnapshot -Kind 'Progress' Write-CapWorkerLog "IMAGE phase=$Phase scene=$SceneName sha256=$($capture.Sha256) size=$($capture.Width)x$($capture.Height) dpi=$($capture.Dpi) colors=$($capture.SampledColors)" } @@ -544,6 +598,7 @@ function New-CapManifest { pendingLaunchesRemaining = @($pendingLaunchesRemaining) failures = @($cleanupFailures) ownedProcessSurvivors = @($ownedProcessSurvivors) + ownedProcesses = @($ownedProcessReceipts) privacyRequirement = 'Every image requires an individual privacy check before reviewer handoff.' } } @@ -910,7 +965,7 @@ function Invoke-CapActionsScene { $null = Test-CapPipeUnserved -PipeName $pipeName Save-CapSceneImage -AppRun $app -SceneName 'actions' -ZoomEvidence $zoom -Phase $Phase $script:images[$script:images.Count - 1] | Add-Member -NotePropertyName actionObservations -NotePropertyValue $observations - Write-CapJsonAtomic -Path $manifestPath -Value (New-CapManifest -Status 'CAPTURING') + Write-CapWorkerJournalSnapshot -Kind 'Progress' Close-CapGraphCodeApp -AppRun $app -NormalExit if (-not [CapNative]::IsExited($app.Run.Identity.Process.Handle, $app.Run.Identity.Pid) -or $app.Run.Identity.Process.ExitCode -ne 0) { @@ -1032,6 +1087,7 @@ try { } $leaseWindow = Get-CapLeaseWindow -GrantedUtc $lease.grantedUtc -ExpiresUtc $lease.expiresUtc ` -MaximumMinutes ([double]$lease.maxMinutes) -NowUtc ([DateTime]::UtcNow) + $leaseExpiresUtc = $leaseWindow.ExpiresUtc $leaseDeadlineUtc = Get-CapAuthorizedWorkerDeadline -WorkerDeadlineUtc $auth.workerDeadlineUtc ` -LeaseWindow $leaseWindow -NowUtc $leaseWindow.NowUtc $selfProcess = [Diagnostics.Process]::GetCurrentProcess() @@ -1046,6 +1102,16 @@ try { if ($supervisorIdentity.CreationFileTimeUtc -ne [long]$auth.supervisorCreationFileTimeUtc) { throw 'Supervisor kernel generation differs from the run authorization.' } + if ($journalPath -ine (Join-Path $runDirectory 'process-journal') -or + $manifestPath -ine (Join-Path $outputDirectory 'manifest.json')) { + throw 'Worker journal/final manifest paths differ from the numbered protocol authorization.' + } + $journalBinding = New-CapJournalBinding -RunId ([IO.Path]::GetFileName($runDirectory)) ` + -LeaseId $auth.leaseId -JobSha256 $jobHash -ModuleSha256 $moduleHash ` + -WorkerSha256 $workerHash -SupervisorSha256 $supervisorHash -Identity $selfIdentity + $journalState = New-CapJournalState -Root $root -Directory $journalPath ` + -Binding $journalBinding -DeadlineUtc $leaseExpiresUtc + Write-CapWorkerJournalSnapshot -Kind 'Bootstrap' if (-not $rehearsal -or $null -ne $job.PSObject.Properties['admission']) { $identity = Assert-CapJobIdentity -Root $root -Job $job -Lease $lease -JobSha256 $jobHash -OfflineTestData:$rehearsal if (($identity | ConvertTo-Json -Depth 15 -Compress) -cne ($auth.jobIdentity | ConvertTo-Json -Depth 15 -Compress)) { @@ -1152,42 +1218,50 @@ try { ForEach-Object { [pscustomobject]@{ role = $_.Role; pid = $_.Pid creationFileTimeUtc = $_.CreationFileTimeUtc; expectedImagePath = $_.ExpectedPath; state = 'KERNEL_LIVE_UNRETURNED_LAUNCH' } }) ) - if ($journalPath -and $auth) { + try { $ownedProcessReceipts = @(Get-CapWorkerProcessReceipts) } + catch { $cleanupFailures.Add("Owned process/stream inventory failed: $($_.Exception.Message)") } + if ($journalState -and $auth) { try { - Write-CapJsonAtomic -Path $journalPath -Value ([ordered]@{ - schemaVersion = 1; leaseId = $auth.leaseId; jobSha256 = $jobHash - workerSha256 = $workerHash; moduleSha256 = $moduleHash - updatedUtc = [DateTime]::UtcNow.ToString('o'); entries = @($journalEntries) - ownedProcessSurvivors = @($ownedProcessSurvivors) - pendingLaunchesRemaining = @($pendingLaunchesRemaining) - cleanupFailures = @($cleanupFailures) - }) + $null = Assert-CapNoWorkerTransientArtifacts -Directories @($runDirectory, $outputDirectory) + if (@(Get-CapTraceFailures).Count -ne 0) { throw 'Worker trace evidence failed.' } } catch { $cleanupFailures.Add("Final process journal update failed: $($_.Exception.Message)") } } foreach ($run in $runs) { $run.Identity.Process.Dispose() } foreach ($identity in $descendantIdentities) { $identity.Process.Dispose() } - if ($manifestPath -and $job -and $auth) { + if ($manifestPath -and $job -and $auth -and $journalState) { $finalStatus = if (-not $primaryFailure -and $cleanupFailures.Count -eq 0 -and (($rehearsal -and $rehearsalLaunches.Count -eq 1 -and $images.Count -eq 0) -or (-not $rehearsal -and $images.Count -eq 10))) { 'COMPLETE' } else { 'FAILED' } try { + Write-CapWorkerJournalSnapshot -Kind 'Terminal' -Outcome $finalStatus $manifest = New-CapManifest -Status $finalStatus if ($primaryFailure) { $manifest.failures += "Worker failure: $($primaryFailure.Exception.ToString())" } - if ($finalStatus -eq 'COMPLETE') { - $finalManifest = Join-Path $outputDirectory 'manifest.json' - if (Test-Path -LiteralPath $finalManifest) { throw "Refusing to replace prior final manifest: $finalManifest" } - Write-CapJsonAtomic -Path $finalManifest -Value $manifest - } else { - Write-CapJsonAtomic -Path $manifestPath -Value $manifest + $manifest['journal'] = [pscustomobject]@{ + protocolVersion = 2L; binding = $journalState.Binding; directory = $journalPath + terminal = $journalState.Terminal; published = @($journalState.Published.ToArray()) } + $manifestReference = Write-CapImmutableJson -Root $root -Path $manifestPath -Value $manifest + $null = Assert-CapNoWorkerTransientArtifacts -Directories @($runDirectory, $outputDirectory) + $journalFinalLine = Format-CapJournalFinalRecord -Binding $journalState.Binding ` + -Manifest $manifestReference -Terminal $journalState.Terminal + $journalFinalCleanupCount = $cleanupFailures.Count } catch { $cleanupFailures.Add("Final manifest write failed: $($_.Exception.Message)") } } } +if ($journalFinalLine) { + if (-not $primaryFailure -and $cleanupFailures.Count -eq 0) { + try { Write-CapWorkerLog "WORKER_COMPLETE images=$($images.Count) lease=$($auth.leaseId)" } + catch { $cleanupFailures.Add("Completion logging failed: $($_.Exception.Message)") } + } + if (@(Get-CapTraceFailures).Count -eq 0 -and $cleanupFailures.Count -eq $journalFinalCleanupCount) { + [Console]::Out.WriteLine($journalFinalLine) + [Console]::Out.Flush() + } else { $cleanupFailures.Add('Final worker trace evidence failed; final record withheld.') } +} if ($cleanupFailures.Count -gt 0) { throw "Capture worker cleanup was incomplete: $($cleanupFailures -join '; ')" } if ($primaryFailure) { throw $primaryFailure } -Write-CapWorkerLog "WORKER_COMPLETE images=$($images.Count) lease=$($auth.leaseId)" diff --git a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 index 12c89484..b57f578b 100644 --- a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 +++ b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 @@ -1,6 +1,9 @@ param( [string]$ScratchRoot = (Join-Path ([IO.Path]::GetTempPath()) ('gc-capture-' + [guid]::NewGuid().ToString('N'))), - [switch]$LaunchContractOnly + [switch]$LaunchContractOnly, + [switch]$JournalContractOnly, + [ValidateSet('','Writer','Reader')][string]$JournalActor = '', + [string]$JournalActorConfig = '' ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' @@ -8,6 +11,118 @@ $driverDirectory = Split-Path -Parent $PSScriptRoot $modulePath = Join-Path $driverDirectory 'UiCaptureDriver.psm1' $supervisorPath = Join-Path $driverDirectory 'Invoke-UiCapture.ps1' $workerPath = Join-Path $driverDirectory 'Invoke-UiCaptureWorker.ps1' + +function Write-JournalContractJson { + param([string]$Path, $Value) + $bytes = [Text.UTF8Encoding]::new($false).GetBytes((ConvertTo-Json $Value -Depth 40 -Compress) + "`n") + $file = [IO.File]::Open($Path, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) + try { $file.Write($bytes, 0, $bytes.Length); $file.Flush($true) } finally { $file.Dispose() } +} + +function Read-JournalContractJson { + param([string]$Path) + try { $file = [IO.File]::Open($Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) } + catch { + $exception = $_.Exception + while ($exception.InnerException) { $exception = $exception.InnerException } + $native = '{0:X8}' -f ([long]$exception.HResult -band 4294967295L) + if ($native -in @('80070002','80070003','80070020','80070021')) { return $null } + throw + } + try { + $reader = [IO.StreamReader]::new($file, [Text.UTF8Encoding]::new($false, $true), $false) + try { return ConvertFrom-Json $reader.ReadToEnd() -Depth 40 -DateKind String } + finally { $reader.Dispose() } + } finally { $file.Dispose() } +} + +function Wait-JournalContractFile { + param([string]$Path, [DateTime]$Deadline) + while ([DateTime]::UtcNow -lt $Deadline) { + foreach ($failureName in @('Writer-failed.json','Reader-failed.json')) { + $failure = Read-JournalContractJson (Join-Path (Split-Path -Parent $Path) $failureName) + if ($failure) { throw "HARNESSFAIL: contract actor: $($failure.error)" } + } + $value = Read-JournalContractJson $Path + if ($null -ne $value) { return $value } + Start-Sleep -Milliseconds 5 + } + throw "HARNESSFAIL: barrier/observer deadline at $Path" +} + +function New-JournalContractStamp { + param([string]$Role, [string]$Event) + return [ordered]@{ + role = $Role; event = $Event; pid = $PID; utc = [DateTime]::UtcNow.ToString('o') + monotonicTimestamp = [Diagnostics.Stopwatch]::GetTimestamp() + monotonicFrequency = [Diagnostics.Stopwatch]::Frequency + } +} + +if ($JournalActor) { + $contractConfiguration = Read-JournalContractJson $JournalActorConfig + Import-Module $modulePath -Force -WarningAction SilentlyContinue + $contractDeadline = [DateTime]::Parse($contractConfiguration.deadlineUtc, + [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::RoundtripKind).ToUniversalTime() + $contractHome = $contractConfiguration.root + Set-CapTracePath -Path (Join-Path $contractHome ($JournalActor + '-calls.jsonl')) + try { + if ($JournalActor -ceq 'Writer') { + $self = [Diagnostics.Process]::GetCurrentProcess() + $identity = Get-CapProcessIdentity -Process $self -ExpectedImagePath $self.MainModule.FileName -Role 'journal-contract-writer' + $binding = New-CapJournalBinding -RunId $contractConfiguration.runId ` + -LeaseId $contractConfiguration.leaseId -JobSha256 ('A' * 64) ` + -ModuleSha256 $contractConfiguration.moduleSha256 -WorkerSha256 $contractConfiguration.workerSha256 ` + -SupervisorSha256 $contractConfiguration.supervisorSha256 -Identity $identity + $state = New-CapJournalState -Root $contractHome -Directory (Join-Path $contractHome 'snapshots') ` + -Binding $binding -DeadlineUtc $contractDeadline + Write-JournalContractJson (Join-Path $contractHome 'writer-ready.json') @{ + stamp = New-JournalContractStamp 'Writer' 'ready'; binding = $binding + } + $null = Wait-JournalContractFile (Join-Path $contractHome 'publish-one.json') $contractDeadline + $first = Publish-CapJournalSnapshot -State $state -Kind Bootstrap -Value ([pscustomobject]@{ entries = @(); counter = 1L }) + Write-JournalContractJson (Join-Path $contractHome 'published-one.json') @{ + stamp = New-JournalContractStamp 'Writer' 'published1'; reference = $first + } + $null = Wait-JournalContractFile (Join-Path $contractHome 'publish-rest.json') $contractDeadline + Write-JournalContractJson (Join-Path $contractHome 'writer-attempt.json') @{ + stamp = New-JournalContractStamp 'Writer' 'attempt2' + } + for ($sequence = 2; $sequence -le $contractConfiguration.publications; $sequence++) { + $last = Publish-CapJournalSnapshot -State $state -Kind Progress -Value ([pscustomobject]@{ + entries = @(); counter = [long]$sequence + }) + } + Write-JournalContractJson (Join-Path $contractHome 'published-rest.json') @{ + stamp = New-JournalContractStamp 'Writer' 'publishedLast'; reference = $last + } + } else { + $binding = (Wait-JournalContractFile (Join-Path $contractHome 'writer-ready.json') $contractDeadline).binding + $state = New-CapJournalState -Root $contractHome -Directory (Join-Path $contractHome 'snapshots') ` + -Binding $binding -DeadlineUtc $contractDeadline ` + -ObservationPath (Join-Path $contractHome 'journal-read-observations.jsonl') + Write-JournalContractJson (Join-Path $contractHome 'reader-ready.json') @{ + stamp = New-JournalContractStamp 'Reader' 'ready' + } + $null = Wait-JournalContractFile (Join-Path $contractHome 'read-one.json') $contractDeadline + $reading = Read-CapJournalSnapshot $state + Write-JournalContractJson (Join-Path $contractHome 'reader-accepted.json') @{ + stamp = New-JournalContractStamp 'Reader' 'accepted'; reading = $reading + } + } + $null = Wait-JournalContractFile (Join-Path $contractHome 'finish.json') $contractDeadline + if (@(Get-CapTraceFailures).Count -ne 0) { throw 'Contract actor trace failed.' } + exit 0 + } catch { + [Console]::Error.WriteLine($_.Exception.ToString()) + Write-JournalContractJson (Join-Path $contractHome ($JournalActor + '-failed.json')) @{ + stamp = New-JournalContractStamp $JournalActor 'HARNESSFAIL' + error = $_.Exception.ToString() + } + exit 1 + } +} + $mode = 'NO_DESKTOP_REAL_LAUNCH_REHEARSAL' $results = [Collections.Generic.List[object]]::new() $scratch = [IO.Path]::GetFullPath($ScratchRoot) @@ -47,6 +162,399 @@ function Assert-CapRejects { throw "Expected refusal containing '$Expected'." } + function New-JournalContractContext { + param([string]$Name) + $contractRoot = Join-Path $scratch ('journal-contracts\' + $Name) + $null = New-Item -ItemType Directory -Path (Join-Path $contractRoot 'snapshots') + $self = [Diagnostics.Process]::GetCurrentProcess() + try { + $identity = Get-CapProcessIdentity -Process $self -ExpectedImagePath $self.MainModule.FileName -Role 'journal-contract' + $binding = New-CapJournalBinding -RunId ([guid]::NewGuid().ToString()) -LeaseId ([guid]::NewGuid().ToString()) ` + -JobSha256 ('A' * 64) -ModuleSha256 $sourceHashesBefore[$modulePath] ` + -WorkerSha256 $sourceHashesBefore[$workerPath] -SupervisorSha256 $sourceHashesBefore[$supervisorPath] ` + -Identity $identity + } finally { $self.Dispose() } + $state = New-CapJournalState -Root $contractRoot -Directory (Join-Path $contractRoot 'snapshots') ` + -Binding $binding -DeadlineUtc ([DateTime]::UtcNow.AddMinutes(2)) + return [pscustomobject]@{ Root = $contractRoot; State = $state; Binding = $binding } + } + + function Invoke-JournalConcurrentContract { + param([string]$Name, [switch]$LockedFirst, [int]$Publications = 128) + $contractRoot = Join-Path $scratch ('journal-contracts\' + $Name) + $null = New-Item -ItemType Directory -Path (Join-Path $contractRoot 'snapshots') + $configuration = Join-Path $contractRoot 'actors.json' + $deadline = [DateTime]::UtcNow.AddSeconds(90) + Write-JournalContractJson $configuration @{ + root = $contractRoot; deadlineUtc = $deadline.ToString('o'); runId = [guid]::NewGuid().ToString() + leaseId = [guid]::NewGuid().ToString(); publications = $Publications + moduleSha256 = $sourceHashesBefore[$modulePath]; workerSha256 = $sourceHashesBefore[$workerPath] + supervisorSha256 = $sourceHashesBefore[$supervisorPath] + } + $actors = [Collections.Generic.List[object]]::new() + $cleanup = [Collections.Generic.List[object]]::new() + $cleanupErrors = [Collections.Generic.List[string]]::new() + $held = $null + $setupLock = $null + try { + foreach ($childKind in @('Writer','Reader')) { + $actor = Start-CapManagedProcess -FilePath (Join-Path $PSHOME 'pwsh.exe') -ArgumentList @( + '-NoProfile','-File',$PSCommandPath,'-JournalActor',$childKind,'-JournalActorConfig',$configuration + ) -WorkingDirectory $contractRoot -Role ('journal-contract-' + $childKind.ToLowerInvariant()) -CloseStdin + $actors.Add($actor) + } + $writerReady = Wait-JournalContractFile (Join-Path $contractRoot 'writer-ready.json') $deadline + $readerReady = Wait-JournalContractFile (Join-Path $contractRoot 'reader-ready.json') $deadline + Assert-CapTest ($writerReady.binding.workerPid -eq $actors[0].Identity.Pid -and + $writerReady.binding.workerCreationFileTimeUtc -eq $actors[0].Identity.CreationFileTimeUtc) 'Actual writer binding differs from held generation.' + Write-JournalContractJson (Join-Path $contractRoot 'publish-one.json') (New-JournalContractStamp 'Observer' 'publish1') + $first = Wait-JournalContractFile (Join-Path $contractRoot 'published-one.json') $deadline + $path = Join-Path $contractRoot 'snapshots\000001.json' + $held = [IO.File]::Open($path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) + $beforeHash = (Get-FileHash -InputStream $held -Algorithm SHA256).Hash + Assert-CapTest ($beforeHash -ceq $first.reference.sha256) 'Consumer first bytes differ from publisher expected bytes.' + $held.Position = 0 + $lockStamp = New-JournalContractStamp 'Observer' 'lock' + if ($LockedFirst) { + $held.Dispose(); $held = $null + $setupLock = [IO.File]::Open($path, [IO.FileMode]::Open, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) + } + Write-JournalContractJson (Join-Path $contractRoot 'read-one.json') (New-JournalContractStamp 'Observer' 'read') + $nativeObservation = $null + if ($LockedFirst) { + while ([DateTime]::UtcNow -lt $deadline) { + $failure = Read-JournalContractJson (Join-Path $contractRoot 'Reader-failed.json') + if ($failure) { throw "HARNESSFAIL: observer/reader missed fixed retry window: $($failure.error)" } + $observationPath = Join-Path $contractRoot 'journal-read-observations.jsonl' + try { + $stream = [IO.File]::Open($observationPath, [IO.FileMode]::Open, [IO.FileAccess]::Read, + ([IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete)) + } catch { + $inner = $_.Exception.GetBaseException() + if ($inner -isnot [IO.FileNotFoundException]) { throw } + Start-Sleep -Milliseconds 5 + continue + } + try { + $textReader = [IO.StreamReader]::new($stream) + try { $text = $textReader.ReadToEnd() } finally { $textReader.Dispose() } + } finally { $stream.Dispose() } + $lastLine = $text.LastIndexOf("`n") + if ($lastLine -ge 0) { + $observations = @($text.Substring(0, $lastLine + 1) -split "`n" | Where-Object { $_ } | + ForEach-Object { ConvertFrom-Json $_ -DateKind String }) + $nativeObservation = @($observations | Where-Object { + $_.sequence -eq 1 -and $_.attempt -eq 1 -and $_.decision -ceq 'OpenFailed' -and + $_.nativeHresult -in @('80070020','80070021') + }) | Select-Object -First 1 + if ($nativeObservation) { break } + } + Start-Sleep -Milliseconds 5 + } + Assert-CapTest ($null -ne $nativeObservation) 'HARNESSFAIL: no actual first-sequence native Open error.' + } + $observerSeen = New-JournalContractStamp 'Observer' 'error-seen' + Write-JournalContractJson (Join-Path $contractRoot 'publish-rest.json') $observerSeen + $published = Wait-JournalContractFile (Join-Path $contractRoot 'published-rest.json') $deadline + $accepted = Wait-JournalContractFile (Join-Path $contractRoot 'reader-accepted.json') $deadline + if ($LockedFirst) { + Assert-CapTest ($accepted.reading.State -ceq 'Published' -and + $accepted.reading.Snapshot.Reference.sequence -eq 2 -and + $accepted.reading.Snapshot.Reference.sha256 -ceq $published.reference.sha256 -and + $accepted.reading.Snapshot.Reference.length -eq $published.reference.length -and + $null -ne $setupLock) 'Reader did not verify actual2 under held lock1.' + $observations = @(Get-Content -LiteralPath (Join-Path $contractRoot 'journal-read-observations.jsonl') | + ForEach-Object { ConvertFrom-Json $_ -DateKind String }) + Assert-CapTest (@($observations | Where-Object { + $_.sequence -eq 2 -and $_.decision -ceq 'AcceptedLiveSequenceOnly' -and $_.retries -ge 1 + }).Count -eq 1) 'Actual retry-to2 telemetry missing.' + } else { + $afterHash = (Get-FileHash -InputStream $held -Algorithm SHA256).Hash + Assert-CapTest ($afterHash -ceq $beforeHash) 'Held immutable first snapshot changed.' + } + $readerState = New-CapJournalState -Root $contractRoot -Directory (Join-Path $contractRoot 'snapshots') ` + -Binding $writerReady.binding -DeadlineUtc $deadline + $latest = Read-CapJournalSnapshot $readerState + Assert-CapTest ($latest.Snapshot.Reference.sequence -eq $Publications -and + $latest.Snapshot.Reference.sha256 -ceq $published.reference.sha256 -and + @(Get-ChildItem -LiteralPath (Join-Path $contractRoot 'snapshots') -Filter '*.json' -File).Count -eq $Publications) 'Positive publication count/latest state differs.' + return [ordered]@{ + publications = $Publications; nativeObservation = $nativeObservation + writerReady = $writerReady; readerReady = $readerReady; observerLock = $lockStamp + observerSeen = $observerSeen; writerPublished = $published; readerAccepted = $accepted + evidence = 'Actual immutable publisher/reader, external setup barriers, no production hook or UI.' + } + } finally { + if ($held) { $held.Dispose() } + if ($setupLock) { $setupLock.Dispose() } + Write-JournalContractJson (Join-Path $contractRoot 'finish.json') (New-JournalContractStamp 'Observer' 'unlock-finish') + foreach ($actor in $actors) { + try { + $wait = Wait-CapManagedProcess -Run $actor -DeadlineUtc ([DateTime]::UtcNow.AddSeconds(10)) + if ($wait.State -cne 'Exited') { $null = Stop-CapManagedProcess -Identity $actor.Identity; throw 'HARNESSFAIL: actor required guarded stop.' } + $stdout = $actor.StdoutTask.Wait(2000); $stderr = $actor.StderrTask.Wait(2000) + Assert-CapTest ($wait.ExitCode -eq 0 -and $stdout -and $stderr -and + [CapNative]::IsExited($actor.Identity.Process.Handle, $actor.Identity.Pid)) 'Actor normal exit/kernel/bothEOF not proven.' + $cleanup.Add([pscustomobject]@{ pid = $actor.Identity.Pid; creation = $actor.Identity.CreationFileTimeUtc + kernelExited = $true; stdoutEof = $stdout; stderrEof = $stderr; exitCode = $wait.ExitCode }) + } catch { $cleanupErrors.Add($_.Exception.ToString()) } + finally { + try { + if ([CapNative]::IsExited($actor.Identity.Process.Handle, $actor.Identity.Pid)) { + $actor.Identity.Process.Dispose() + } else { $cleanupErrors.Add("Owned actor remains live: $($actor.Identity.Pid)") } + } catch { $cleanupErrors.Add($_.Exception.ToString()) } + } + } + Write-JournalContractJson (Join-Path $contractRoot 'actor-cleanup.json') @{ + receipts = @($cleanup.ToArray()); expected = 2; failures = @($cleanupErrors.ToArray()) + } + Assert-CapTest ($cleanup.Count -eq 2 -and $cleanupErrors.Count -eq 0) ` + "Empty/incomplete actual actor cleanup: $($cleanupErrors -join '; ')" + } + } + + function New-JournalTerminalFixture { + param([string]$Name, [ValidateSet('COMPLETE','FAILED')][string]$Outcome = 'COMPLETE', + [switch]$InvalidJobContract) + $context = New-JournalContractContext $Name + $data = [pscustomobject]@{ entries = @(); ownedProcesses = @(); outcome = $Outcome + ownedProcessSurvivors = @(); pendingLaunchesRemaining = @(); cleanupFailures = @() } + $null = Publish-CapJournalSnapshot $context.State Bootstrap $data + $terminal = Publish-CapJournalSnapshot $context.State Terminal $data + $manifest = [pscustomobject]@{ status = $Outcome; ownedProcesses = @(); ownedProcessSurvivors = @(); pendingLaunchesRemaining = @() + cleanupFailures = @(); journal = [pscustomobject]@{ protocolVersion = 2L; binding = $context.Binding + directory = $context.State.Directory; terminal = $terminal; published = @($context.State.Published.ToArray()) } } + if ($InvalidJobContract) { + $manifest | Add-Member -NotePropertyMembers @{ + leaseId = $context.Binding.leaseId; leaseReceiptSha256 = ('B' * 64) + jobManifestSha256 = $context.Binding.jobSha256 + moduleSha256 = $context.Binding.moduleSha256 + workerSha256 = $context.Binding.workerSha256 + supervisorSha256 = $context.Binding.supervisorSha256 + executionMode = 'NO_DESKTOP_REAL_LAUNCH_REHEARSAL' + guiLaunches = @(); images = @(); rehearsalLaunches = @(); failures = @() + } + } + $reference = Write-CapImmutableJson -Root $context.Root -Path (Join-Path $context.Root 'manifest.json') -Value $manifest + $line = Format-CapJournalFinalRecord $context.Binding $reference $terminal + return [pscustomobject]@{ Context = $context; Line = $line; Record = (Read-CapJournalFinalRecord $line $context.Binding) } + } + + function Invoke-JournalContractGroups { + param([switch]$IncludeConcurrent) + if ($IncludeConcurrent) { + Invoke-CapTest 'actual immutable journal reader preserves held snapshots across concurrent numbered publications' { + Invoke-JournalConcurrentContract -Name 'concurrent128' -Publications 128 + } + } + Invoke-CapTest 'numbered journal publisher refuses collisions without overwriting prior evidence' { + $c = New-JournalContractContext 'collision' + $reference = Publish-CapJournalSnapshot $c.State Bootstrap ([pscustomobject]@{ entries = @() }) + $path = Join-Path $c.State.Directory $reference.name + $null = Assert-CapRejects { Write-CapImmutableJson -Root $c.Root -Path $path -Value @{ invalid = $true } } 'SequenceConflict' + Assert-CapTest ((Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash -ceq $reference.sha256) 'Collision changed prior bytes.' + return @{ subcases = 1; priorBytesUnchanged = $true; retainedStagingFault = $true } + } + Invoke-CapTest 'numbered journal reader retries only transient Open failures within count and deadline' { + $progress = Invoke-JournalConcurrentContract -Name 'retry-to2' -LockedFirst -Publications 2 + $c = New-JournalContractContext 'retry-exhaustion' + $null = Publish-CapJournalSnapshot $c.State Bootstrap ([pscustomobject]@{ entries = @() }) + $lock = [IO.File]::Open((Join-Path $c.State.Directory '000001.json'), [IO.FileMode]::Open, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) + try { $null = Assert-CapRejects { Read-CapJournalSnapshot $c.State } 'JournalUnavailable' } + finally { $lock.Dispose() } + return @{ subcases = 2; realRetry = $progress; exhaustion = 'Observed actual held-file Open retry exhaustion, not a mock.' } + } + Invoke-CapTest 'numbered journal reader never regresses below its highest observed sequence' { + $c = New-JournalContractContext 'floor' + $null = Publish-CapJournalSnapshot $c.State Bootstrap ([pscustomobject]@{ entries = @() }) + $null = Publish-CapJournalSnapshot $c.State Progress ([pscustomobject]@{ entries = @() }) + $read = Read-CapJournalSnapshot $c.State + Assert-CapTest ($read.Snapshot.Reference.sequence -eq 2) 'Floor2 was not actually observed.' + [IO.File]::Move((Join-Path $c.State.Directory '000002.json'), (Join-Path $c.Root 'fault-removed-000002.json')) + $null = Assert-CapRejects { Read-CapJournalSnapshot $c.State } 'JournalUnavailable' + Assert-CapTest ($c.State.HighestObserved -eq 2 -and $c.State.LastApplied -eq 2) 'Reader returned older state.' + return @{ subcases = 1; fault = 'Owned fixture publication removed for negative control, never a publisher action.' } + } + Invoke-CapTest 'numbered journal rejects corrupt encoding hashes schema and foreign identities' { + $executed = 0 + foreach ($fault in @('encoding','parse','hash','schema','identity','lease','gap','alias','case','zero','expiry','acl')) { + $c = New-JournalContractContext ('fault-' + $fault) + $null = Publish-CapJournalSnapshot $c.State Bootstrap ([pscustomobject]@{ entries = @() }) + $path = Join-Path $c.State.Directory '000001.json' + $expected = 'Journal' + $originalAcl = $null + try { + switch ($fault) { + 'encoding' { [IO.File]::WriteAllBytes($path, [byte[]]@(255)); $expected = 'JournalEncoding' } + 'parse' { [IO.File]::WriteAllText($path, '{"x":1,"x":2}'); $expected = 'JournalParse' } + 'hash' { + $value = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json + $value.payloadLength++ + [IO.File]::WriteAllText($path, (ConvertTo-Json $value -Compress)); $expected = 'JournalHash' + } + 'schema' { [IO.File]::WriteAllText($path, '{}'); $expected = 'JournalSchema' } + 'identity' { $c.State.Binding = $c.State.Binding.PSObject.Copy(); $c.State.Binding.runId = [guid]::NewGuid().ToString(); $expected = 'JournalIdentity' } + 'lease' { $c.State.Binding = $c.State.Binding.PSObject.Copy(); $c.State.Binding.leaseId = [guid]::NewGuid().ToString(); $expected = 'JournalIdentity' } + 'gap' { + [IO.File]::Move($path, (Join-Path $c.State.Directory '000002.json')); $expected = 'JournalSchema' + } + 'alias' { [IO.File]::Move($path, (Join-Path $c.State.Directory '1.json')); $expected = 'JournalNamespace' } + 'case' { + [IO.File]::Move($path, (Join-Path $c.Root 'fault-case.json')) + [IO.File]::Move((Join-Path $c.Root 'fault-case.json'), (Join-Path $c.State.Directory '000001.JSON')) + $expected = 'JournalNamespace' + } + 'zero' { [IO.File]::Move($path, (Join-Path $c.State.Directory '000000.json')); $expected = 'SequenceConflict' } + 'expiry' { $c.State.DeadlineUtc = [DateTime]::UtcNow.AddSeconds(-1); $expected = 'JournalUnavailable' } + 'acl' { + $originalAcl = Get-Acl -LiteralPath $path + $denied = Get-Acl -LiteralPath $path + $sid = [Security.Principal.WindowsIdentity]::GetCurrent().User + $denied.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($sid, + [Security.AccessControl.FileSystemRights]::ReadData, [Security.AccessControl.AccessControlType]::Deny)) + Set-Acl -LiteralPath $path -AclObject $denied + $expected = 'JournalAccessDenied' + } + } + $null = Assert-CapRejects { Read-CapJournalSnapshot $c.State } $expected + $executed++ + } finally { if ($null -ne $originalAcl) { Set-Acl -LiteralPath $path -AclObject $originalAcl } } + } + Assert-CapTest ($executed -eq 12) 'Immediate-fault inventory was empty or incomplete.' + return @{ subcases = $executed; mode = 'Actual owned filesystem inputs/ACL, no reader endpoint mock.' } + } + Invoke-CapTest 'final manifest and release bind the exact quiescent terminal journal' { + $executed = 0 + foreach ($outcome in @('COMPLETE','FAILED')) { + $f = New-JournalTerminalFixture ('terminal-' + $outcome) $outcome + $proof = Read-CapJournalFinalProof $f.Context.State $f.Context.Root $f.Record + Assert-CapTest ($proof.Manifest.status -ceq $outcome) 'Failed manifest was accepted as COMPLETE.' + $null = Assert-CapRejects { Read-CapJournalFinalRecord ($f.Line + "`n" + $f.Line) $f.Context.Binding } 'JournalFinalRecord' + $null = Assert-CapRejects { Read-CapJournalFinalRecord ($f.Line + "`nAFTER") $f.Context.Binding } 'JournalFinalRecord' + $null = Assert-CapRejects { Read-CapJournalFinalRecord 'ordinary log only' $f.Context.Binding } 'JournalFinalRecord' + $null = Assert-CapRejects { Read-CapJournalFinalRecord 'CAP_JOURNAL_FINAL_V2 partial!' $f.Context.Binding } 'JournalFinalRecord' + $foreign = $f.Context.Binding.PSObject.Copy() + $foreign.leaseId = [guid]::NewGuid().ToString() + $null = Assert-CapRejects { Read-CapJournalFinalRecord $f.Line $foreign } 'JournalFinalRecord' + $wrongHash = $f.Record.PSObject.Copy() + $wrongHash.manifest = $f.Record.manifest.PSObject.Copy() + $wrongHash.manifest.sha256 = '0' * 64 + $null = Assert-CapRejects { Read-CapJournalFinalProof $f.Context.State $f.Context.Root $wrongHash } 'JournalHash' + $null = Assert-CapRejects { Publish-CapJournalSnapshot $f.Context.State Progress ([pscustomobject]@{ entries = @() }) } 'JournalPublicationClosed' + $executed += 8 + } + $f = New-JournalTerminalFixture 'post-terminal' + [IO.File]::Copy((Join-Path $f.Context.State.Directory '000002.json'), (Join-Path $f.Context.State.Directory '000003.json')) + $null = Assert-CapRejects { Read-CapJournalFinalProof $f.Context.State $f.Context.Root $f.Record } 'PostTerminalPublication' + $executed++ + $f = New-JournalTerminalFixture 'sealed-gap' + [IO.File]::Move((Join-Path $f.Context.State.Directory '000001.json'), (Join-Path $f.Context.Root 'fault-removed-first.json')) + $null = Assert-CapRejects { Read-CapJournalFinalProof $f.Context.State $f.Context.Root $f.Record } 'SequenceGap' + $executed++ + $classification = & { + $fixture = New-JournalTerminalFixture 'valid-cleanup-invalid-job' -InvalidJobContract + $journalProof = Read-CapJournalFinalProof $fixture.Context.State $fixture.Context.Root $fixture.Record + $boundWorkerManifest = $journalProof.Manifest + $rehearsal = $true + $ExecutionMode = 'NO_DESKTOP_REAL_LAUNCH_REHEARSAL' + $ast = [Management.Automation.Language.Parser]::ParseInput( + [IO.File]::ReadAllText($supervisorPath), [ref]$null, [ref]$null) + foreach ($functionName in @('Assert-CapWorkerManifest','Get-CapWorkerJobFailure')) { + $definition = $ast.Find({ param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq $functionName + }, $true) + Assert-CapTest ($null -ne $definition) "Actual job assertion function missing: $functionName" + . ([scriptblock]::Create($definition.Extent.Text)) + } + $statusGuards = @($ast.FindAll({ param($node) + $node -is [Management.Automation.Language.IfStatementAst] -and + $node.Clauses[0].Item1.Extent.Text.Contains('$workerLaunchAttempted') -and + $node.Clauses[0].Item1.Extent.Text.Contains('$cleanupFailures.Count -eq 0') -and + $node.Extent.Text.Contains("'FAILED_ZERO_SURVIVORS'") + }, $true)) + Assert-CapTest ($statusGuards.Count -eq 1) 'Actual release status expression missing or duplicated.' + $actualStatus = [scriptblock]::Create($statusGuards[0].Extent.Text) + $workerLaunchAttempted = $true + $cleanupFailures = [Collections.Generic.List[string]]::new() + $survivors = [Collections.Generic.List[object]]::new() + $cleanupProven = $true + $primaryFailure = Get-CapWorkerJobFailure + Assert-CapTest ($null -ne $primaryFailure -and + $primaryFailure.Exception.Message.Contains('Offline real-launch manifest is incomplete') -and + $cleanupFailures.Count -eq 0) 'Invalid job assertion was lost or misclassified as cleanup failure.' + $jobStatus = & $actualStatus + Assert-CapTest ($jobStatus -ceq 'FAILED_ZERO_SURVIVORS') 'Valid cleanup plus failed job did not preserve primary failure status.' + $invalid = $fixture.Record.PSObject.Copy() + $invalid.manifest = $fixture.Record.manifest.PSObject.Copy() + $invalid.manifest.sha256 = '0' * 64 + $bindingFailure = Assert-CapRejects { + Read-CapJournalFinalProof $fixture.Context.State $fixture.Context.Root $invalid + } 'JournalHash' + $cleanupFailures.Add($bindingFailure) + $cleanupProven = $false + $bindingStatus = & $actualStatus + Assert-CapTest ($bindingStatus -ceq 'CLEANUP_UNPROVEN') 'Invalid cleanup binding became an ordinary failed job.' + return [pscustomobject]@{ + label = 'valid-cleanup-invalid-job versus invalid-cleanup-binding' + validCleanupInvalidJob = $jobStatus + invalidCleanupBinding = $bindingStatus + mode = 'Actual source assertion/status expressions and real bound-file negatives; no product/GUI or native worker mock.' + } + } + $executed += 2 + Assert-CapTest ($executed -eq 20) 'Terminal positive/negative subcase inventory differs.' + return @{ subcases = $executed; jobCleanupClassification = $classification + mode = 'Exact file/data binding controls; real worker quiescence is tested by actual capture commands.' } + } + Invoke-CapTest 'journal proof inventory retains publications and refuses residual temporary artifacts' { + $f = New-JournalTerminalFixture 'retention' + $before = @(Get-ChildItem -LiteralPath $f.Context.State.Directory -File | ForEach-Object { $_.Name }) + $null = Read-CapJournalFinalProof $f.Context.State $f.Context.Root $f.Record + Assert-CapTest (@(Get-ChildItem -LiteralPath $f.Context.State.Directory -File).Count -eq 2) 'Published evidence was deleted.' + $tmp = Join-Path $f.Context.Root 'legacy-seed.json.tmp' + [IO.File]::WriteAllText($tmp, 'retained negative diagnostic') + $null = Assert-CapRejects { Assert-CapNoWorkerTransientArtifacts @($f.Context.Root) } 'ResidualJournalArtifact' + Assert-CapTest ([IO.File]::Exists($tmp) -and $before.Count -eq 2) 'Residual diagnostic was deleted to pass.' + return @{ subcases = 2; retainedPublications = $before; residualPreserved = $true } + } + Invoke-CapTest 'journal native bindings match the exact sixteen-declaration contract' { + $expected = @( + 'GraphCodeUiaGateState|PostMessage|user32.dll|PostMessageA', + 'GraphCodeUiaGateState|EnumWindows|user32.dll|EnumWindows', + 'GraphCodeUiaGateState|GetWindowThreadProcessId|user32.dll|GetWindowThreadProcessId', + 'GraphCodeUiaGateState|GetClassName|user32.dll|GetClassNameW', + 'CapNative|GetProcessId|kernel32.dll|GetProcessId', + 'CapNative|GetProcessTimes|kernel32.dll|GetProcessTimes', + 'CapNative|QueryFullProcessImageNameW|kernel32.dll|QueryFullProcessImageNameW', + 'CapNative|WaitForSingleObject|kernel32.dll|WaitForSingleObject', + 'CapNative|TerminateProcess|kernel32.dll|TerminateProcess', + 'CapNative|WaitNamedPipeW|kernel32.dll|WaitNamedPipeW', + 'CapNative|GetWindowThreadProcessId|user32.dll|GetWindowThreadProcessId', + 'CapNative|GetWindowRect|user32.dll|GetWindowRect', + 'CapNative|GetClientRect|user32.dll|GetClientRect', + 'CapNative|GetDpiForWindow|user32.dll|GetDpiForWindow', + 'CapNative|SetWindowPos|user32.dll|SetWindowPos', + 'CapNative|PrintWindow|user32.dll|PrintWindow' + ) + $actual = @(Test-CapNativeBindings | ForEach-Object { "$($_.Type)|$($_.Method)|$($_.Library)|$($_.EntryPoint)" }) + Assert-CapTest ($actual.Count -eq 16 -and @(Compare-Object $expected $actual).Count -eq 0) 'Exact static16 export contract differs.' + return @{ subcases = 16; evidence = 'API export resolution only, no native window or GUI action.' } + } + Invoke-CapTest 'legacy mutable seed journal proof is refused and exact numbered terminal proof is accepted' { + $f = New-JournalTerminalFixture 'seed-reference' + $legacy = [pscustomobject]@{ processJournalPath = (Join-Path $f.Context.Root 'process-journal.json') } + $null = Assert-CapRejects { Read-CapNumberedSeedProof $f.Context.Root $legacy ('A' * 64) ` + $sourceHashesBefore[$modulePath] $sourceHashesBefore[$workerPath] ([DateTime]::UtcNow.AddMinutes(1)) } 'LegacyJournalProtocol' + $reference = [pscustomobject]@{ protocolVersion = 2L; directory = $f.Context.State.Directory + outputDirectory = $f.Context.Root; binding = $f.Context.Binding; record = $f.Record } + $proof = Read-CapNumberedSeedProof $f.Context.Root $reference ('A' * 64) ` + $sourceHashesBefore[$modulePath] $sourceHashesBefore[$workerPath] ([DateTime]::UtcNow.AddMinutes(1)) + Assert-CapTest ($proof.Manifest.status -ceq 'COMPLETE') 'Exact new seed reference was not verified.' + return @{ subcases = 2; actualLegacyRefused = $true; exactReferenceVerified = $true } + } + } function Invoke-CapBoundedChild { param([string]$FilePath, [string[]]$Arguments, [string]$Directory, [string]$Role, [int]$Seconds = 45, [hashtable]$Environment = @{}) @@ -512,6 +1020,17 @@ function Assert-CapRehearsalSuccess { $target = $null try { $target = New-CapHarmlessTarget + if ($JournalContractOnly) { + Invoke-JournalContractGroups -IncludeConcurrent + Invoke-CapTest 'real harmless managed-worker journal integration' { + $run = Invoke-CapCaptureCommand -Name 'journal-integration' + $null = Assert-CapRehearsalSuccess -Run $run -Arguments @() + Assert-CapTest ($run.receipt.journalPublishedInventory.Count -ge 2 -and + $null -ne $run.receipt.journalFinalBinding) 'Actual quiescent worker final binding is missing.' + return @{ status = $run.receipt.status; binding = $run.receipt.journalFinalBinding + inventory = $run.receipt.journalPublishedInventory; gui = 'NotExecuted' } + } + } else { $cimRaceTarget = New-CapCimRaceTarget if (-not $LaunchContractOnly) { foreach ($mutation in @('accepted', 'lane-d', 'red-ci', 'binary-hash', 'product-diff', @@ -660,7 +1179,12 @@ try { Assert-CapTest ($run.receipt.traceFailures.Count -gt 0) 'Trace I/O failure was not preserved in the actual release.' } if ($behavior -eq 'timeout') { - $manifest = Get-Content -LiteralPath (Join-Path $run.output 'manifest.partial.json') -Raw | ConvertFrom-Json + $reference = $run.receipt.journalFinalBinding.manifest + $manifestPath = Join-Path $run.output $reference.name + Assert-CapTest ((Get-FileHash -LiteralPath $manifestPath -Algorithm SHA256).Hash -ceq $reference.sha256) ` + 'Timeout evidence is not the exact bound immutable FAILED manifest.' + $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json + Assert-CapTest ($manifest.status -ceq 'FAILED') 'Timed-out target was not recorded as FAILED.' Assert-CapTest ($manifest.rehearsalLaunches[0].cleanupResult -ceq 'TERMINATED_GUARDED_HANDLE') 'Timed-out target was not stopped by held kernel identity.' } } @@ -886,45 +1410,10 @@ try { $null = Assert-CapRejects { Assert-CapFixtureMapping @parameters } 'owned project path' return 'Actual shared root/source/mapping predicates only; no product binary, HWND, native compatibility, or live capture was executed.' } - Invoke-CapTest 'actual journal reader permits worker atomic replacement while retaining its snapshot' { - foreach ($definition in @( - @($supervisorPath, 'Open-CapJournalRead'), - @($workerPath, 'Write-CapJsonAtomic') - )) { - $ast = [Management.Automation.Language.Parser]::ParseInput( - [IO.File]::ReadAllText($definition[0]), [ref]$null, [ref]$null) - $function = $ast.Find({ param($node) - $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq $definition[1] - }, $true) - Assert-CapTest ($null -ne $function) "Actual journal function missing: $($definition[1])" - . ([scriptblock]::Create($function.Extent.Text)) - } - $directory = Join-Path $scratch 'journal-sharing-component' - $null = New-Item -ItemType Directory -Path $directory - $path = Join-Path $directory 'process-journal.json' - Write-CapJsonAtomic -Path $path -Value ([ordered]@{ version = 1; componentOnly = $true; cleanupComplete = $false }) - $stream = Open-CapJournalRead -Path $path - try { - Write-CapJsonAtomic -Path $path -Value ([ordered]@{ - version = 2; componentOnly = $true; cleanupComplete = $true - ownedProcessSurvivors = @(); pendingLaunchesRemaining = @(); cleanupFailures = @() - }) - $reader = [IO.StreamReader]::new($stream) - try { $snapshot = $reader.ReadToEnd() | ConvertFrom-Json } - finally { $reader.Dispose() } - $freshStream = Open-CapJournalRead -Path $path - try { - $freshReader = [IO.StreamReader]::new($freshStream) - try { $current = $freshReader.ReadToEnd() | ConvertFrom-Json } - finally { $freshReader.Dispose() } - } finally { $freshStream.Dispose() } - Assert-CapTest ($snapshot.version -eq 1 -and -not $snapshot.cleanupComplete -and - $current.version -eq 2 -and $current.cleanupComplete -and $current.ownedProcessSurvivors.Count -eq 0 -and - $current.pendingLaunchesRemaining.Count -eq 0 -and $current.cleanupFailures.Count -eq 0) ` - 'Atomic replacement lost the held snapshot or a fresh read mistook it for completed cleanup.' - } finally { $stream.Dispose() } - return 'Actual supervisor reader and actual worker atomic writer; held file I/O only, no process, lease or GUI mock.' + Invoke-CapTest 'actual immutable journal reader preserves held snapshots across concurrent numbered publications' { + Invoke-JournalConcurrentContract -Name 'concurrent128' -Publications 128 } + Invoke-JournalContractGroups Invoke-CapTest 'actual rehearsal data satisfies the repaired report gate and refuses stale proof' { $source = [IO.File]::ReadAllText($supervisorPath) $ast = [Management.Automation.Language.Parser]::ParseInput($source, [ref]$null, [ref]$null) @@ -1015,9 +1504,19 @@ try { $script:runDirectory = $sceneTestRoot $script:outputDirectory = Join-Path $sceneTestRoot 'mock-output' $script:workerLog = Join-Path $sceneTestRoot 'mock-worker.log' - $script:journalPath = Join-Path $sceneTestRoot 'mock-process-journal.json' - $script:manifestPath = Join-Path $sceneTestRoot 'mock-manifest.partial.json' + $script:journalPath = Join-Path $sceneTestRoot 'mock-process-journal' + $script:manifestPath = Join-Path $sceneTestRoot 'mock-manifest.json' New-Item -ItemType Directory -Path $script:outputDirectory -Force | Out-Null + $null = New-Item -ItemType Directory -Path $script:journalPath + $self = [Diagnostics.Process]::GetCurrentProcess() + try { + $identity = Get-CapProcessIdentity -Process $self -ExpectedImagePath $self.MainModule.FileName -Role 'mock-scene-file-contract' + $binding = New-CapJournalBinding -RunId ([guid]::NewGuid().ToString()) -LeaseId $script:auth.leaseId ` + -JobSha256 $script:jobHash -ModuleSha256 $script:moduleHash -WorkerSha256 $script:workerHash ` + -SupervisorSha256 $sourceHashesBefore[$supervisorPath] -Identity $identity + } finally { $self.Dispose() } + $script:journalState = New-CapJournalState -Root $root -Directory $script:journalPath ` + -Binding $binding -DeadlineUtc $script:leaseDeadlineUtc [IO.File]::WriteAllText($script:workerLog, '', [Text.UTF8Encoding]::new($false)) $script:images = [Collections.Generic.List[object]]::new() $script:runs = [Collections.Generic.List[object]]::new() @@ -1231,7 +1730,9 @@ try { $null = Invoke-CapNormalScenes -Phase $phase $null = Invoke-CapActionsScene -Phase $phase } - $mockManifest = Get-Content -LiteralPath $script:manifestPath -Raw | ConvertFrom-Json + $readingState = New-CapJournalState -Root $root -Directory $script:journalPath ` + -Binding $binding -DeadlineUtc $script:leaseDeadlineUtc + $mockManifest = (Read-CapJournalSnapshot $readingState).Snapshot.Payload.data.progressManifest $expectedScenePairs = @( 'before:project-alpha', 'before:project-beta', 'before:overview', 'before:workspace', 'before:actions', 'after:project-alpha', 'after:project-beta', 'after:overview', 'after:workspace', 'after:actions' @@ -1336,6 +1837,7 @@ try { } } + } } catch { $results.Add([ordered]@{ name = 'suite setup/execution'; passed = $false; error = $_.Exception.ToString() }) Write-Host "FAIL suite setup/execution: $($_.Exception.Message)" @@ -1349,7 +1851,8 @@ try { } $passed = @($results | Where-Object passed -EQ $true).Count $failed = @($results | Where-Object passed -EQ $false).Count - $report = [ordered]@{ suite = 'UiCaptureDriver'; executed = $results.Count; passed = $passed; failed = $failed + $suiteName = if ($JournalContractOnly) { 'UiCaptureJournalContracts' } else { 'UiCaptureDriver' } + $report = [ordered]@{ suite = $suiteName; executed = $results.Count; passed = $passed; failed = $failed mode = $mode; gui = 'NotExecuted'; nativeCompatibilityPreflight = 'NotExecuted'; images = 0 backendTurns = 0; fixture = $target; sourceHashesBeforeImport = $sourceHashesBefore controls = [ordered]@{ moduleSha256 = $sourceHashesBefore[$modulePath] @@ -1357,7 +1860,8 @@ try { cases = @($results) } $report | ConvertTo-Json -Depth 25 | Set-Content -LiteralPath (Join-Path $scratch 'report.json') -Encoding utf8NoBOM } -[ordered]@{ suite = 'UiCaptureDriver'; executed = $results.Count; passed = $passed; failed = $failed; artifacts = $scratch } | +[ordered]@{ suite = $suiteName; executed = $results.Count; passed = $passed; failed = $failed; artifacts = $scratch } | ConvertTo-Json -Compress -if ($results.Count -le 0 -or $failed -ne 0 -or $passed -ne $results.Count) { exit 1 } +$expectedCount = if ($JournalContractOnly) { 10 } elseif ($LaunchContractOnly) { 2 } else { 59 } +if ($results.Count -ne $expectedCount -or $failed -ne 0 -or $passed -ne $results.Count) { exit 1 } exit 0 diff --git a/Tools/windows/UiCaptureDriver.psm1 b/Tools/windows/UiCaptureDriver.psm1 index 83852abb..e6de59a7 100644 --- a/Tools/windows/UiCaptureDriver.psm1 +++ b/Tools/windows/UiCaptureDriver.psm1 @@ -2040,7 +2040,697 @@ function Get-CapAuthorizedWorkerDeadline { return $deadline } +function New-CapJournalFailure { + param([string]$Class, [string]$Message, [AllowNull()][Exception]$Inner = $null) + $failure = [IO.IOException]::new("$Class`: $Message", $Inner) + $failure.Data['CapJournalFailure'] = $Class + return $failure +} + +function Get-CapJournalNativeError { + param([Exception]$Exception) + for ($current = $Exception; $null -ne $current; $current = $current.InnerException) { + if ($current -is [IO.IOException] -or $current -is [UnauthorizedAccessException]) { + return '{0:X8}' -f ([long]$current.HResult -band 4294967295L) + } + } + return '{0:X8}' -f ([long]$Exception.HResult -band 4294967295L) +} + +function ConvertFrom-CapJournalBytes { + param([byte[]]$Bytes) + if ($Bytes.Length -ge 3 -and $Bytes[0] -eq 239 -and $Bytes[1] -eq 187 -and $Bytes[2] -eq 191) { + throw (New-CapJournalFailure 'JournalEncoding' 'Published JSON must be UTF-8 without BOM.') + } + try { $json = [Text.UTF8Encoding]::new($false, $true).GetString($Bytes) } + catch { throw (New-CapJournalFailure 'JournalEncoding' $_.Exception.Message $_.Exception) } + try { + $document = [Text.Json.JsonDocument]::Parse($json) + try { + $visit = { + param([Text.Json.JsonElement]$Element) + if ($Element.ValueKind -eq [Text.Json.JsonValueKind]::Object) { + $names = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($property in $Element.EnumerateObject()) { + if (-not $names.Add($property.Name)) { throw "Duplicate JSON field: $($property.Name)" } + & $visit $property.Value + } + } elseif ($Element.ValueKind -eq [Text.Json.JsonValueKind]::Array) { + foreach ($item in $Element.EnumerateArray()) { & $visit $item } + } + } + & $visit $document.RootElement + } finally { $document.Dispose() } + return ConvertFrom-Json -InputObject $json -DateKind String -Depth 60 + } catch { throw (New-CapJournalFailure 'JournalParse' $_.Exception.Message $_.Exception) } +} + +function New-CapJournalBinding { + [CmdletBinding()] + param([string]$RunId, [string]$LeaseId, [string]$JobSha256, [string]$ModuleSha256, + [string]$WorkerSha256, [string]$SupervisorSha256, [Parameter(Mandatory)]$Identity) + Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters + $null = [guid]::Parse($RunId) + $null = [guid]::Parse($LeaseId) + foreach ($hash in @($JobSha256, $ModuleSha256, $WorkerSha256, $SupervisorSha256)) { + if ($hash -cnotmatch '\A[A-F0-9]{64}\z') { throw 'Journal binding requires exact source digests.' } + } + return [pscustomobject][ordered]@{ + runId = $RunId; leaseId = $LeaseId; jobSha256 = $JobSha256 + moduleSha256 = $ModuleSha256; workerSha256 = $WorkerSha256 + supervisorSha256 = $SupervisorSha256 + workerPid = [long]$Identity.Pid + workerCreationFileTimeUtc = [long]$Identity.CreationFileTimeUtc + workerImagePath = [IO.Path]::GetFullPath($Identity.ExpectedImagePath).ToUpperInvariant() + workerExecutableSha256 = (Get-FileHash -LiteralPath $Identity.ExpectedImagePath -Algorithm SHA256).Hash + workerParentPid = [long]$Identity.ParentPid + workerSessionId = [long]$Identity.SessionId + workerRole = [string]$Identity.Role + } +} + +function Assert-CapJournalBinding { + param($Actual, $Expected) + $fields = @{ + runId = [string]; leaseId = [string]; jobSha256 = [string]; moduleSha256 = [string] + workerSha256 = [string]; supervisorSha256 = [string]; workerPid = [long] + workerCreationFileTimeUtc = [long]; workerImagePath = [string] + workerExecutableSha256 = [string]; workerParentPid = [long]; workerSessionId = [long] + workerRole = [string] + } + try { + Assert-CapDataRecord $Actual $fields 'Journal binding' + if (@($Actual.PSObject.Properties).Count -ne $fields.Count) { throw 'Unexpected journal identity field.' } + $null = [guid]::Parse($Actual.runId) + $null = [guid]::Parse($Actual.leaseId) + if ($Actual.workerPid -le 0 -or $Actual.workerCreationFileTimeUtc -le 0 -or + $Actual.workerParentPid -le 0 -or $Actual.workerSessionId -lt 0 -or + -not [IO.Path]::IsPathFullyQualified($Actual.workerImagePath)) { + throw 'Invalid worker generation identity.' + } + foreach ($name in @('jobSha256','moduleSha256','workerSha256','supervisorSha256','workerExecutableSha256')) { + if ($Actual.$name -cnotmatch '\A[A-F0-9]{64}\z') { throw "Invalid identity digest: $name" } + } + foreach ($name in $fields.Keys) { + if ($Actual.$name -cne $Expected.$name) { throw "Journal identity differs: $name" } + } + } catch { throw (New-CapJournalFailure 'JournalIdentity' $_.Exception.Message $_.Exception) } +} + +function New-CapJournalState { + [CmdletBinding()] + param([string]$Root, [string]$Directory, [Parameter(Mandatory)]$Binding, + [Parameter(Mandatory)][DateTime]$DeadlineUtc, [string]$ObservationPath = '') + Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters + $directoryPath = Assert-CapArtifactPath -Root $Root -Path $Directory + if (-not ([IO.File]::GetAttributes($directoryPath) -band [IO.FileAttributes]::Directory)) { + throw 'Journal requires an existing dedicated directory.' + } + if ($ObservationPath) { + $ObservationPath = Assert-CapArtifactPath -Root $Root -Path $ObservationPath + } + Assert-CapJournalBinding $Binding $Binding + return [pscustomobject]@{ + Root = [IO.Path]::GetFullPath($Root); Directory = $directoryPath; Binding = $Binding + DeadlineUtc = $DeadlineUtc; ObservationPath = $ObservationPath + HighestObserved = 0L; LastApplied = 0L; Sequence = 0L; PreviousHash = ('0' * 64) + Published = [Collections.Generic.List[object]]::new() + Accepted = [Collections.Generic.Dictionary[long,string]]::new() + Terminal = $null; PublicationFailed = $false; FatalError = $null + } +} + +function Write-CapJournalObservation { + param($State, [long]$Sequence, [int]$Attempt, [string]$Decision, + [AllowNull()][string]$NativeError, $Transaction) + if (-not $State.ObservationPath) { return } + $record = [ordered]@{ + runId = $State.Binding.runId; readerPid = $PID; sequence = $Sequence; attempt = $Attempt + utc = [DateTime]::UtcNow.ToString('o') + monotonicTimestamp = [Diagnostics.Stopwatch]::GetTimestamp() + monotonicFrequency = [Diagnostics.Stopwatch]::Frequency + nativeHresult = $NativeError; decision = $Decision + deadlineUtc = $Transaction.DeadlineUtc.ToString('o'); retries = $Transaction.Retries + } + $bytes = [Text.UTF8Encoding]::new($false).GetBytes( + (ConvertTo-Json -InputObject $record -Compress -Depth 8) + "`n") + try { + $stream = [IO.File]::Open($State.ObservationPath, [IO.FileMode]::Append, + [IO.FileAccess]::Write, [IO.FileShare]::Read) + try { $stream.Write($bytes, 0, $bytes.Length); $stream.Flush($true) } + finally { $stream.Dispose() } + } catch { throw (New-CapJournalFailure 'JournalObservationFailure' $_.Exception.Message $_.Exception) } +} + +function Write-CapImmutableJson { + [CmdletBinding()] + param([string]$Root, [string]$Path, [Parameter(Mandatory)]$Value, + [string]$TemporaryName = ('.cap-final-' + [guid]::NewGuid().ToString('N') + '.tmp')) + Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters + $path = Assert-CapArtifactPath -Root $Root -Path $Path + if ($TemporaryName -cnotmatch '\A(?:\.cap-journal-[0-9]{6}-[0-9a-f]{32}|\.cap-final-[0-9a-f]{32})\.tmp\z') { + throw (New-CapJournalFailure 'JournalNamespace' 'Invalid unpublished staging name.') + } + $temporary = Join-Path (Split-Path -Parent $path) $TemporaryName + $bytes = [Text.UTF8Encoding]::new($false, $true).GetBytes( + (ConvertTo-Json -InputObject $Value -Depth 60 -Compress)) + try { + $stream = [IO.File]::Open($temporary, [IO.FileMode]::CreateNew, + [IO.FileAccess]::Write, [IO.FileShare]::None) + try { $stream.Write($bytes, 0, $bytes.Length); $stream.Flush($true) } + finally { $stream.Dispose() } + [IO.File]::Move($temporary, $path) + } catch { + $native = Get-CapJournalNativeError $_.Exception + $class = if ($native -in @('80070050', '800700B7')) { 'SequenceConflict' } else { 'JournalPublicationFailure' } + throw (New-CapJournalFailure $class "$path HRESULT=$native; staging artifact retained. $($_.Exception.Message)" $_.Exception) + } + return [pscustomobject]@{ + name = [IO.Path]::GetFileName($path); length = [long]$bytes.Length + sha256 = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bytes)) + } +} + +function Publish-CapJournalSnapshot { + [CmdletBinding()] + param([Parameter(Mandatory)]$State, + [ValidateSet('Bootstrap','Progress','Terminal')][string]$Kind, + [Parameter(Mandatory)]$Value) + Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters + if ($State.PublicationFailed -or $null -ne $State.Terminal) { + throw (New-CapJournalFailure 'JournalPublicationClosed' 'No publication after failure or Terminal.') + } + if ([DateTime]::UtcNow -ge $State.DeadlineUtc) { + throw (New-CapJournalFailure 'JournalExpired' 'Publication deadline reached.') + } + $sequence = [long]($State.Sequence + 1) + if ($sequence -gt 999999) { throw (New-CapJournalFailure 'SequenceConflict' 'Sequence overflow.') } + $payload = [pscustomobject][ordered]@{ + binding = $State.Binding; sequence = $sequence; previousSequence = [long]$State.Sequence + previousSha256 = $State.PreviousHash; kind = $Kind; data = $Value + } + $bytes = [Text.UTF8Encoding]::new($false, $true).GetBytes( + (ConvertTo-Json -InputObject $payload -Depth 55 -Compress)) + $envelope = [ordered]@{ + schemaVersion = 2L; sequence = $sequence; payloadLength = [long]$bytes.Length + payloadSha256 = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bytes)) + payloadBase64 = [Convert]::ToBase64String($bytes) + } + $name = $sequence.ToString('D6', [Globalization.CultureInfo]::InvariantCulture) + '.json' + try { + $reference = Write-CapImmutableJson -Root $State.Root -Path (Join-Path $State.Directory $name) ` + -Value $envelope -TemporaryName ('.cap-journal-' + $sequence.ToString('D6') + '-' + + [guid]::NewGuid().ToString('N') + '.tmp') + } catch { $State.PublicationFailed = $true; throw } + $reference | Add-Member -NotePropertyName sequence -NotePropertyValue $sequence + $State.Sequence = $sequence + $State.PreviousHash = $reference.sha256 + $State.Published.Add($reference) + if ($Kind -ceq 'Terminal') { $State.Terminal = $reference } + return $reference +} + +function Get-CapJournalNames { + param($State, [switch]$Final) + $numbers = [Collections.Generic.Dictionary[long,string]]::new() + try { $paths = @([IO.Directory]::EnumerateFileSystemEntries($State.Directory)) } + catch { throw (New-CapJournalFailure 'JournalNamespace' $_.Exception.Message $_.Exception) } + foreach ($path in $paths) { + $name = [IO.Path]::GetFileName($path) + if ($name -cmatch '\A(?:\.cap-journal-[0-9]{6}-[0-9a-f]{32}|\.cap-final-[0-9a-f]{32})\.tmp\z') { + if ($Final) { throw (New-CapJournalFailure 'ResidualJournalArtifact' $path) } + continue + } + try { $attributes = [IO.File]::GetAttributes($path) } + catch { + $class = if ((Get-CapJournalNativeError $_.Exception) -eq '80070005') { 'JournalAccessDenied' } else { 'JournalNamespace' } + throw (New-CapJournalFailure $class $_.Exception.Message $_.Exception) + } + if ($name -cnotmatch '\A[0-9]{6}\.json\z' -or + ($attributes -band ([IO.FileAttributes]::Directory -bor [IO.FileAttributes]::ReparsePoint))) { + throw (New-CapJournalFailure 'JournalNamespace' "Noncanonical publication: $name") + } + $number = [long]::Parse($name.Substring(0, 6), [Globalization.CultureInfo]::InvariantCulture) + if ($number -lt 1 -or -not $numbers.TryAdd($number, $name)) { + throw (New-CapJournalFailure 'SequenceConflict' "Invalid or duplicate publication: $name") + } + } + return ,$numbers +} + +function New-CapJournalTransaction { + param($State) + $remaining = [Math]::Min(1000.0, ($State.DeadlineUtc - [DateTime]::UtcNow).TotalMilliseconds) + return [pscustomobject]@{ + DeadlineUtc = [DateTime]::UtcNow.AddMilliseconds([Math]::Max(0, $remaining)) + Clock = [Diagnostics.Stopwatch]::StartNew() + BudgetMilliseconds = [Math]::Max(0, $remaining) + Retries = 0 + Attempts = [Collections.Generic.Dictionary[long,int]]::new() + } +} + +function Assert-CapJournalTime { + param($Transaction, [long]$Sequence, [AllowNull()][string]$NativeError = $null) + if ([DateTime]::UtcNow -ge $Transaction.DeadlineUtc -or + $Transaction.Clock.Elapsed.TotalMilliseconds -ge $Transaction.BudgetMilliseconds) { + throw (New-CapJournalFailure 'JournalUnavailable' ( + "sequence=$Sequence HRESULT=$NativeError retries=$($Transaction.Retries); deadline/exhaustion, no late success.")) + } +} + +function Read-CapJournalObject { + param($State, [long]$Sequence, $Transaction, [switch]$Advance, [switch]$RetainStream) + $native = $null + while ($true) { + Assert-CapJournalTime $Transaction $Sequence $native + if (-not $Transaction.Attempts.ContainsKey($Sequence)) { $Transaction.Attempts[$Sequence] = 0 } + if ($Transaction.Attempts[$Sequence] -ge 8) { + throw (New-CapJournalFailure 'JournalUnavailable' "sequence=$Sequence HRESULT=$native; eight Open attempts.") + } + $Transaction.Attempts[$Sequence]++ + $attempt = $Transaction.Attempts[$Sequence] + $name = $Sequence.ToString('D6', [Globalization.CultureInfo]::InvariantCulture) + '.json' + $retained = $false + try { + $stream = [IO.File]::Open((Join-Path $State.Directory $name), [IO.FileMode]::Open, + [IO.FileAccess]::Read, [IO.FileShare]::Read) + } catch { + $native = Get-CapJournalNativeError $_.Exception + Write-CapJournalObservation $State $Sequence $attempt 'OpenFailed' $native $Transaction + if ($native -notin @('80070002','80070003','80070020','80070021')) { + $class = if ($native -eq '80070005') { 'JournalAccessDenied' } else { 'JournalOpenFailure' } + throw (New-CapJournalFailure $class "sequence=$Sequence HRESULT=$native" $_.Exception) + } + if ($Transaction.Retries -ge 7) { + throw (New-CapJournalFailure 'JournalUnavailable' "sequence=$Sequence HRESULT=$native; global retry limit.") + } + $Transaction.Retries++ + Assert-CapJournalTime $Transaction $Sequence $native + $remaining = [Math]::Min(($Transaction.DeadlineUtc - [DateTime]::UtcNow).TotalMilliseconds, + $Transaction.BudgetMilliseconds - $Transaction.Clock.Elapsed.TotalMilliseconds) + if ($remaining -gt 0) { Start-Sleep -Milliseconds ([int][Math]::Min(100, [Math]::Floor($remaining))) } + Assert-CapJournalTime $Transaction $Sequence $native + if ($Advance) { + $names = Get-CapJournalNames $State + foreach ($number in $names.Keys) { $State.HighestObserved = [Math]::Max($State.HighestObserved, $number) } + $Sequence = [Math]::Max($Sequence, $State.HighestObserved) + } + continue + } + try { + $buffer = [IO.MemoryStream]::new() + try { $stream.CopyTo($buffer); $bytes = $buffer.ToArray() } + finally { $buffer.Dispose() } + Assert-CapJournalTime $Transaction $Sequence + $envelope = ConvertFrom-CapJournalBytes $bytes + try { + Assert-CapDataRecord $envelope @{ schemaVersion = [long]; sequence = [long] + payloadLength = [long]; payloadSha256 = [string]; payloadBase64 = [string] } 'Journal envelope' + if (@($envelope.PSObject.Properties).Count -ne 5 -or $envelope.schemaVersion -ne 2 -or + $envelope.sequence -ne $Sequence) { throw 'Envelope version/name/sequence mismatch.' } + $payloadBytes = [Convert]::FromBase64String($envelope.payloadBase64) + if ([Convert]::ToBase64String($payloadBytes) -cne $envelope.payloadBase64) { + throw 'Noncanonical payload base64.' + } + } catch { throw (New-CapJournalFailure 'JournalSchema' $_.Exception.Message $_.Exception) } + if ($payloadBytes.Length -ne $envelope.payloadLength -or + [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($payloadBytes)) -cne $envelope.payloadSha256) { + throw (New-CapJournalFailure 'JournalHash' "Payload hash/length differs at $Sequence.") + } + $payload = ConvertFrom-CapJournalBytes $payloadBytes + try { + Assert-CapDataRecord $payload @{ binding = [pscustomobject]; sequence = [long] + previousSequence = [long]; previousSha256 = [string]; kind = [string]; data = [pscustomobject] } 'Journal payload' + if (@($payload.PSObject.Properties).Count -ne 6 -or + $payload.previousSha256 -cnotmatch '\A[A-F0-9]{64}\z' -or + $payload.kind -cnotin @('Bootstrap','Progress','Terminal')) { throw 'Invalid journal payload schema.' } + Assert-CapDataRecord $payload.data @{ entries = [array] } 'Journal entries' + } catch { throw (New-CapJournalFailure 'JournalSchema' $_.Exception.Message $_.Exception) } + Assert-CapJournalBinding $payload.binding $State.Binding + if ($payload.sequence -ne $Sequence -or $payload.previousSequence -ne ($Sequence - 1)) { + throw (New-CapJournalFailure 'SequenceGap' "Predecessor/sequence mismatch at $Sequence.") + } + Assert-CapJournalTime $Transaction $Sequence + $reference = [pscustomobject]@{ + sequence = $Sequence; name = $name; length = [long]$bytes.Length + sha256 = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bytes)) + } + Write-CapJournalObservation $State $Sequence $attempt 'ReadVerified' $null $Transaction + Assert-CapJournalTime $Transaction $Sequence + $result = [pscustomobject]@{ Reference = $reference; Payload = $payload; Stream = $null } + if ($RetainStream) { $result.Stream = $stream; $retained = $true } + return $result + } finally { if (-not $retained) { $stream.Dispose() } } + } +} + +function Read-CapJournalSnapshot { + [CmdletBinding()] + param([Parameter(Mandatory)]$State) + Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters + if ($null -ne $State.FatalError) { throw $State.FatalError } + try { + $transaction = New-CapJournalTransaction $State + Assert-CapJournalTime $transaction $State.HighestObserved + $names = Get-CapJournalNames $State + foreach ($number in $names.Keys) { $State.HighestObserved = [Math]::Max($State.HighestObserved, $number) } + if ($State.HighestObserved -eq 0) { + return [pscustomobject]@{ State = 'PendingFirstPublication'; Snapshot = $null; HashChainVerified = $false } + } + while ($true) { + $snapshot = Read-CapJournalObject $State $State.HighestObserved $transaction -Advance + $names = Get-CapJournalNames $State + foreach ($number in $names.Keys) { $State.HighestObserved = [Math]::Max($State.HighestObserved, $number) } + if ($snapshot.Reference.sequence -lt $State.HighestObserved) { continue } + for ($number = 1L; $number -le $snapshot.Reference.sequence; $number++) { + if (-not $names.ContainsKey($number)) { + $null = Read-CapJournalObject $State $number $transaction + } + } + Assert-CapJournalTime $transaction $snapshot.Reference.sequence + if ($snapshot.Reference.sequence -lt $State.LastApplied) { + throw (New-CapJournalFailure 'SequenceRegression' 'Cannot apply older journal state.') + } + if ($State.Accepted.ContainsKey($snapshot.Reference.sequence) -and + $State.Accepted[$snapshot.Reference.sequence] -cne $snapshot.Reference.sha256) { + throw (New-CapJournalFailure 'JournalHash' 'An accepted immutable publication changed.') + } + $State.Accepted[$snapshot.Reference.sequence] = $snapshot.Reference.sha256 + $State.LastApplied = $snapshot.Reference.sequence + Write-CapJournalObservation $State $snapshot.Reference.sequence ` + $transaction.Attempts[$snapshot.Reference.sequence] 'AcceptedLiveSequenceOnly' $null $transaction + Assert-CapJournalTime $transaction $snapshot.Reference.sequence + return [pscustomobject]@{ State = 'Published'; Snapshot = $snapshot; HashChainVerified = $false } + } + } catch { $State.FatalError = $_.Exception; throw } +} + +function Assert-CapJournalReference { + param($Reference, [switch]$Numbered) + try { + Assert-CapDataRecord $Reference @{ name = [string]; length = [long]; sha256 = [string] } 'Journal reference' + $expectedProperties = if ($Numbered) { 4 } else { 3 } + if (@($Reference.PSObject.Properties).Count -ne $expectedProperties) { throw 'Unexpected byte reference field.' } + if ($Reference.length -le 0 -or $Reference.sha256 -cnotmatch '\A[A-F0-9]{64}\z' -or + [IO.Path]::GetFileName($Reference.name) -cne $Reference.name -or + $Reference.name.Contains(':')) { throw 'Noncanonical byte reference.' } + if ($Numbered) { + Assert-CapDataRecord $Reference @{ sequence = [long] } 'Numbered reference' + if ($Reference.sequence -lt 1 -or $Reference.sequence -gt 999999 -or + $Reference.name -cne ($Reference.sequence.ToString('D6') + '.json')) { + throw 'Numbered reference differs from its sequence.' + } + } + } catch { throw (New-CapJournalFailure 'JournalSchema' $_.Exception.Message $_.Exception) } +} + +function Format-CapJournalFinalRecord { + [CmdletBinding()] + param($Binding, $Manifest, $Terminal) + Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters + Assert-CapJournalReference $Manifest + Assert-CapJournalReference $Terminal -Numbered + $record = [ordered]@{ schemaVersion = 2L; binding = $Binding; manifest = $Manifest; terminal = $Terminal } + $bytes = [Text.UTF8Encoding]::new($false).GetBytes( + (ConvertTo-Json -InputObject $record -Depth 15 -Compress)) + return 'CAP_JOURNAL_FINAL_V2 ' + [Convert]::ToBase64String($bytes) +} + +function Read-CapJournalFinalRecord { + [CmdletBinding()] + param([string]$Stdout, $Binding) + Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters + $lines = @([regex]::Split($Stdout, '\r?\n') | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + $records = @($lines | Where-Object { $_.StartsWith('CAP_JOURNAL_FINAL', [StringComparison]::Ordinal) }) + if ($records.Count -ne 1 -or $lines[-1] -cne $records[0] -or + $records[0] -cnotmatch '\ACAP_JOURNAL_FINAL_V2 ([A-Za-z0-9+/]+={0,2})\z') { + throw (New-CapJournalFailure 'JournalFinalRecord' 'Missing, duplicate, partial or nonfinal stdout binding.') + } + try { + $encoded = $records[0].Substring('CAP_JOURNAL_FINAL_V2 '.Length) + $bytes = [Convert]::FromBase64String($encoded) + if ([Convert]::ToBase64String($bytes) -cne $encoded) { throw 'Noncanonical final base64.' } + $record = ConvertFrom-CapJournalBytes $bytes + Assert-CapDataRecord $record @{ schemaVersion = [long]; binding = [pscustomobject] + manifest = [pscustomobject]; terminal = [pscustomobject] } 'Final binding' + if ($record.schemaVersion -ne 2 -or @($record.PSObject.Properties).Count -ne 4) { throw 'Final binding schema differs.' } + Assert-CapJournalBinding $record.binding $Binding + Assert-CapJournalReference $record.manifest + Assert-CapJournalReference $record.terminal -Numbered + if ((Format-CapJournalFinalRecord $Binding $record.manifest $record.terminal) -cne $records[0]) { + throw 'Final record is not the canonical compact encoding.' + } + return $record + } catch { throw (New-CapJournalFailure 'JournalFinalRecord' $_.Exception.Message $_.Exception) } +} + +function Read-CapJournalFinalProof { + [CmdletBinding()] + param([Parameter(Mandatory)]$State, [string]$OutputDirectory, + [Parameter(Mandatory)]$Record, [switch]$RetainStreams) + Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters + if ($null -ne $State.FatalError) { throw $State.FatalError } + Assert-CapJournalBinding $Record.binding $State.Binding + Assert-CapJournalReference $Record.terminal -Numbered + Assert-CapJournalReference $Record.manifest + if ($State.HighestObserved -gt $Record.terminal.sequence) { + throw (New-CapJournalFailure 'PostTerminalPublication' 'Observed floor exceeds final binding.') + } + $transaction = New-CapJournalTransaction $State + Assert-CapJournalTime $transaction $Record.terminal.sequence + $manifestPath = Assert-CapArtifactPath -Root $State.Root -Path (Join-Path $OutputDirectory $Record.manifest.name) + $stream = $null + $held = [Collections.Generic.List[IO.Stream]]::new() + $keepStreams = $false + try { + $stream = Open-CapJournalManifestStream $State $manifestPath $transaction $Record.terminal.sequence + $held.Add($stream) + $memory = [IO.MemoryStream]::new() + try { $stream.CopyTo($memory); $bytes = $memory.ToArray() } finally { $memory.Dispose() } + if ($bytes.Length -ne $Record.manifest.length -or + [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bytes)) -cne $Record.manifest.sha256) { + throw (New-CapJournalFailure 'JournalHash' 'Final manifest bytes differ.') + } + $manifest = ConvertFrom-CapJournalBytes $bytes + Assert-CapDataRecord $manifest @{ journal = [pscustomobject]; status = [string] + ownedProcesses = [array]; ownedProcessSurvivors = [array] + pendingLaunchesRemaining = [array]; cleanupFailures = [array] } 'Final manifest' + Assert-CapDataRecord $manifest.journal @{ protocolVersion = [long]; binding = [pscustomobject] + terminal = [pscustomobject]; published = [array]; directory = [string] } 'Final journal inventory' + Assert-CapJournalBinding $manifest.journal.binding $State.Binding + if ($manifest.journal.protocolVersion -ne 2 -or $manifest.status -cnotin @('COMPLETE','FAILED') -or + $manifest.journal.directory -ine $State.Directory -or + (ConvertTo-Json $manifest.journal.terminal -Compress) -cne (ConvertTo-Json $Record.terminal -Compress)) { + throw (New-CapJournalFailure 'JournalFinalBinding' 'Terminal manifest binding differs.') + } + Assert-CapJournalTime $transaction $Record.terminal.sequence + $names = Get-CapJournalNames $State -Final + $published = @($manifest.journal.published) + if (@($names.Keys | Where-Object { $_ -gt $Record.terminal.sequence }).Count -ne 0) { + throw (New-CapJournalFailure 'PostTerminalPublication' 'Publication above exact terminal sequence.') + } + if ($published.Count -ne $Record.terminal.sequence -or $names.Count -ne $published.Count) { + throw (New-CapJournalFailure 'SequenceGap' 'Final inventory has missing or extra publications.') + } + $previous = '0' * 64 + $terminal = $null + for ($index = 0; $index -lt $published.Count; $index++) { + $reference = $published[$index] + Assert-CapJournalReference $reference -Numbered + $number = [long]($index + 1) + if ($reference.sequence -ne $number -or -not $names.ContainsKey($number)) { + throw (New-CapJournalFailure 'SequenceGap' 'Final inventory is not exactly contiguous.') + } + $object = Read-CapJournalObject $State $number (New-CapJournalTransaction $State) -RetainStream + $held.Add($object.Stream) + if ($object.Reference.sha256 -cne $reference.sha256 -or $object.Reference.length -ne $reference.length -or + $object.Payload.previousSha256 -cne $previous) { + throw (New-CapJournalFailure 'JournalHash' "Final predecessor chain or bytes differ at $number.") + } + if (($number -eq 1 -and $object.Payload.kind -cne 'Bootstrap') -or + ($number -gt 1 -and $object.Payload.kind -ceq 'Bootstrap')) { + throw (New-CapJournalFailure 'JournalSchema' 'Bootstrap position differs.') + } + if ($number -lt $Record.terminal.sequence -and $object.Payload.kind -ceq 'Terminal') { + throw (New-CapJournalFailure 'PostTerminalPublication' 'A publication follows an earlier Terminal.') + } + $previous = $object.Reference.sha256 + $terminal = $object + } + if ($null -eq $terminal -or $terminal.Payload.kind -cne 'Terminal' -or + $terminal.Reference.sha256 -cne $Record.terminal.sha256 -or + $terminal.Reference.length -ne $Record.terminal.length) { + throw (New-CapJournalFailure 'JournalFinalBinding' 'Exact Terminal snapshot missing.') + } + $data = $terminal.Payload.data + Assert-CapDataRecord $data @{ entries = [array]; ownedProcesses = [array]; outcome = [string] + ownedProcessSurvivors = [array]; pendingLaunchesRemaining = [array]; cleanupFailures = [array] } 'Terminal cleanup' + if ($data.outcome -cne $manifest.status -or + (ConvertTo-Json $data.ownedProcesses -Depth 15 -Compress) -cne + (ConvertTo-Json $manifest.ownedProcesses -Depth 15 -Compress) -or + (ConvertTo-Json $data.ownedProcessSurvivors -Depth 12 -Compress) -cne + (ConvertTo-Json $manifest.ownedProcessSurvivors -Depth 12 -Compress) -or + (ConvertTo-Json $data.pendingLaunchesRemaining -Depth 12 -Compress) -cne + (ConvertTo-Json $manifest.pendingLaunchesRemaining -Depth 12 -Compress) -or + (ConvertTo-Json $data.cleanupFailures -Depth 12 -Compress) -cne + (ConvertTo-Json $manifest.cleanupFailures -Depth 12 -Compress)) { + throw (New-CapJournalFailure 'JournalFinalBinding' 'Manifest and terminal cleanup differ.') + } + $processKeys = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + $entryKeys = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + foreach ($process in $data.ownedProcesses) { + Assert-CapDataRecord $process @{ pid = [long]; creationFileTimeUtc = [long]; expectedImagePath = [string] + role = [string]; parentPid = [long]; sessionId = [long]; kernelExited = [bool] + stdoutEof = [bool]; stderrEof = [bool]; streamOwnerPid = [long] + streamOwnerCreationFileTimeUtc = [long] } 'Owned process receipt' + if (-not $process.kernelExited -or -not $process.stdoutEof -or -not $process.stderrEof) { + throw (New-CapJournalFailure 'JournalCleanupUnproven' 'Kernel exit or owning-stream EOF is unproved.') + } + if ($process.pid -le 0 -or $process.creationFileTimeUtc -le 0 -or $process.parentPid -le 0 -or + $process.sessionId -lt 0 -or $process.streamOwnerPid -le 0 -or + $process.streamOwnerCreationFileTimeUtc -le 0 -or -not [IO.Path]::IsPathFullyQualified($process.expectedImagePath)) { + throw (New-CapJournalFailure 'JournalIdentity' 'Invalid cleanup generation identity.') + } + if (-not $processKeys.Add("$($process.pid):$($process.creationFileTimeUtc):$($process.role)")) { + throw (New-CapJournalFailure 'JournalCleanupUnproven' 'Duplicate owned process receipt.') + } + $owners = @($data.ownedProcesses | Where-Object { + $_.pid -eq $process.streamOwnerPid -and + $_.creationFileTimeUtc -eq $process.streamOwnerCreationFileTimeUtc -and + $_.streamOwnerPid -eq $_.pid + }) + if ($owners.Count -ne 1) { + throw (New-CapJournalFailure 'JournalCleanupUnproven' 'Owning managed-root streams are unbound.') + } + } + foreach ($entry in $data.entries) { + Assert-CapDataRecord $entry @{ pid = [long]; creationFileTimeUtc = [long]; role = [string] + expectedImagePath = [string]; expectedParentPid = [long]; expectedSessionId = [long] } 'Registered generation' + $matches = @($data.ownedProcesses | Where-Object { + $_.pid -eq $entry.pid -and $_.creationFileTimeUtc -eq $entry.creationFileTimeUtc -and + $_.role -ceq $entry.role -and $_.expectedImagePath -ieq $entry.expectedImagePath -and + $_.parentPid -eq $entry.expectedParentPid -and $_.sessionId -eq $entry.expectedSessionId + }) + if ($matches.Count -ne 1) { throw (New-CapJournalFailure 'JournalCleanupUnproven' 'Registered generation lacks exact cleanup receipt.') } + $null = $entryKeys.Add("$($entry.pid):$($entry.creationFileTimeUtc):$($entry.role)") + } + if (-not $entryKeys.SetEquals($processKeys)) { + throw (New-CapJournalFailure 'JournalCleanupUnproven' 'Owned and registered generation inventories differ.') + } + $namesAfter = Get-CapJournalNames $State -Final + if ($namesAfter.Count -ne $names.Count -or + @($namesAfter.Keys | Where-Object { -not $names.ContainsKey($_) }).Count -ne 0) { + throw (New-CapJournalFailure 'PostTerminalPublication' 'Namespace changed after final inventory.') + } + if ([DateTime]::UtcNow -ge $State.DeadlineUtc) { + throw (New-CapJournalFailure 'JournalUnavailable' 'Final inventory exceeded cleanup hard deadline.') + } + $result = [pscustomobject]@{ + Manifest = $manifest; Terminal = $terminal; Record = $Record; Inventory = $published + HeldStreams = @(); HashChainVerified = $true + } + if ($RetainStreams) { $result.HeldStreams = @($held.ToArray()); $keepStreams = $true } + return $result + } finally { if (-not $keepStreams) { foreach ($item in $held) { $item.Dispose() } } } +} + +function Read-CapNumberedSeedProof { + [CmdletBinding()] + param([string]$Root, [Parameter(Mandatory)]$Reference, [string]$JobSha256, + [string]$ModuleSha256, [string]$WorkerSha256, [DateTime]$DeadlineUtc) + Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters + if ($Reference -isnot [pscustomobject] -or -not $Reference.PSObject.Properties['protocolVersion'] -or + $Reference.protocolVersion -ne 2) { + throw (New-CapJournalFailure 'LegacyJournalProtocol' 'Exact numbered terminal proof is required.') + } + Assert-CapDataRecord $Reference @{ directory = [string]; outputDirectory = [string] + binding = [pscustomobject]; record = [pscustomobject] } 'Numbered seed proof' + if ($Reference.directory.EndsWith('.json', [StringComparison]::OrdinalIgnoreCase) -or + $Reference.binding.jobSha256 -cne $JobSha256 -or + $Reference.binding.moduleSha256 -cne $ModuleSha256 -or + $Reference.binding.workerSha256 -cne $WorkerSha256) { + throw (New-CapJournalFailure 'JournalIdentity' 'Seed proof layout or exact source binding differs.') + } + $state = New-CapJournalState -Root $Root -Directory $Reference.directory ` + -Binding $Reference.binding -DeadlineUtc $DeadlineUtc + $proof = Read-CapJournalFinalProof -State $state -OutputDirectory $Reference.outputDirectory -Record $Reference.record + if ($proof.Manifest.status -cne 'COMPLETE' -or $proof.Terminal.Payload.data.ownedProcessSurvivors.Count -ne 0 -or + $proof.Terminal.Payload.data.pendingLaunchesRemaining.Count -ne 0 -or + $proof.Terminal.Payload.data.cleanupFailures.Count -ne 0) { + throw (New-CapJournalFailure 'JournalCleanupUnproven' 'Seed proof is not a completed clean terminal.') + } + return $proof +} + +function Open-CapJournalManifestStream { + param($State, [string]$Path, $Transaction, [long]$Sequence) + $native = $null + for ($attempt = 1; $attempt -le 8; $attempt++) { + Assert-CapJournalTime $Transaction $Sequence $native + try { + return [IO.File]::Open($Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) + } catch { + $native = Get-CapJournalNativeError $_.Exception + Write-CapJournalObservation $State $Sequence $attempt 'FinalManifestOpenFailed' $native $Transaction + if ($native -notin @('80070002','80070003','80070020','80070021')) { + throw (New-CapJournalFailure 'JournalOpenFailure' "Final manifest HRESULT=$native" $_.Exception) + } + if ($Transaction.Retries -ge 7) { break } + $Transaction.Retries++ + Assert-CapJournalTime $Transaction $Sequence $native + $remaining = [Math]::Min(($Transaction.DeadlineUtc - [DateTime]::UtcNow).TotalMilliseconds, + $Transaction.BudgetMilliseconds - $Transaction.Clock.Elapsed.TotalMilliseconds) + if ($remaining -gt 0) { Start-Sleep -Milliseconds ([int][Math]::Min(100, [Math]::Floor($remaining))) } + } + } + throw (New-CapJournalFailure 'JournalUnavailable' "Final manifest sequence=$Sequence HRESULT=$native; retry limit.") +} + +function Assert-CapNoWorkerTransientArtifacts { + [CmdletBinding()] + param([string[]]$Directories) + Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters + if ($Directories.Count -eq 0) { throw 'Worker artifact inventory requires explicit roots.' } + $files = [Collections.Generic.List[string]]::new() + $pending = [Collections.Generic.Stack[string]]::new() + foreach ($directory in $Directories) { $pending.Push($directory) } + while ($pending.Count -gt 0) { + $directory = $pending.Pop() + if ([IO.File]::GetAttributes($directory) -band [IO.FileAttributes]::ReparsePoint) { + throw (New-CapJournalFailure 'JournalNamespace' "Reparse-point inventory root: $directory") + } + foreach ($path in [IO.Directory]::EnumerateFileSystemEntries($directory)) { + $attributes = [IO.File]::GetAttributes($path) + if ($attributes -band [IO.FileAttributes]::ReparsePoint) { + throw (New-CapJournalFailure 'JournalNamespace' "Reparse-point artifact preserved: $path") + } + if ($attributes -band [IO.FileAttributes]::Directory) { $pending.Push($path) } + else { + $files.Add($path) + if ($path.EndsWith('.tmp', [StringComparison]::OrdinalIgnoreCase) -or + $path.EndsWith('.partial', [StringComparison]::OrdinalIgnoreCase) -or + $path.EndsWith('.partial.json', [StringComparison]::OrdinalIgnoreCase)) { + throw (New-CapJournalFailure 'ResidualJournalArtifact' "$path retained, never deleted.") + } + } + } + } + if ($files.Count -eq 0) { throw 'Worker artifact inventory is empty; cannot prove an executed run.' } + return @($files.ToArray()) +} + Export-ModuleMember -Function @( + 'New-CapJournalBinding', + 'New-CapJournalState', + 'Publish-CapJournalSnapshot', + 'Read-CapJournalSnapshot', + 'Write-CapImmutableJson', + 'Format-CapJournalFinalRecord', + 'Read-CapJournalFinalRecord', + 'Read-CapJournalFinalProof', + 'Read-CapNumberedSeedProof', + 'Assert-CapNoWorkerTransientArtifacts', 'Set-CapTracePath', 'Write-CapTrace', 'Get-CapTraceFailures',