From 39690e56c2f24c33322573e19c8f98ed4a946bd2 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Wed, 14 Oct 2026 20:40:06 -0700 Subject: [PATCH 1/2] Add failing journal publication regression tests for #826 (RED) Tests only: concurrent worker-publisher/supervisor-reader stress, numeric highest selection, corrupt/foreign highest refusal, bounded FileNotFound re-list, terminal verification, and real-command release cleanup proof. Production sources are unchanged so these fail against the check-then-open/Replace journal. Refs #826 Refs #822 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9f32f381-95ba-4648-a71a-c021bcfa8aa3 Signed-off-by: Colin Neilens --- Tools/windows/Tests/UiCaptureDriver.Tests.ps1 | 548 ++++++++++++++++++ 1 file changed, 548 insertions(+) diff --git a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 index 344f2e76..1e053030 100644 --- a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 +++ b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 @@ -79,6 +79,67 @@ function New-CapHarmlessTarget { #include #include #include +static int cap_run_directory(const wchar_t *output, wchar_t *run, size_t capacity) { + wchar_t pattern[1040]; + WIN32_FIND_DATAW data; + HANDLE handle; + int found = 0; + if (swprintf_s(pattern, 1040, L"%ls\\..\\runs\\*", output) < 0) return 0; + handle = FindFirstFileW(pattern, &data); + if (handle == INVALID_HANDLE_VALUE) return 0; + do { + if ((data.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0 && + wcscmp(data.cFileName, L".") != 0 && wcscmp(data.cFileName, L"..") != 0) { + found = swprintf_s(run, capacity, L"%ls\\..\\runs\\%ls", output, data.cFileName) >= 0; + break; + } + } while (FindNextFileW(handle, &data)); + FindClose(handle); + return found; +} +static int cap_highest_snapshot(const wchar_t *run) { + wchar_t pattern[1040]; + WIN32_FIND_DATAW data; + HANDLE handle; + int highest = 0, value = 0; + if (swprintf_s(pattern, 1040, L"%ls\\process-journal.*.json", run) < 0) return -1; + handle = FindFirstFileW(pattern, &data); + if (handle == INVALID_HANDLE_VALUE) return 0; + do { + if (swscanf_s(data.cFileName, L"process-journal.%d.json", &value) == 1 && value > highest) highest = value; + } while (FindNextFileW(handle, &data)); + FindClose(handle); + return highest; +} +static int cap_plant_file(const wchar_t *path, const char *text) { + DWORD written = 0; + DWORD length = (DWORD)strlen(text); + BOOL ok; + HANDLE file = CreateFileW(path, GENERIC_WRITE, 0, NULL, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, NULL); + if (file == INVALID_HANDLE_VALUE) return 0; + ok = WriteFile(file, text, length, &written, NULL); + CloseHandle(file); + return ok && written == length; +} +static int cap_journal_sabotage(const wchar_t *mode) { + wchar_t output[1040] = {0}, run[1040] = {0}, journal[1100] = {0}; + int highest = 0, attempt; + if (GetEnvironmentVariableW(L"CAP_REHEARSAL_OUTPUT", output, 1040) == 0) return 90; + if (!cap_run_directory(output, run, 1040)) return 91; + /* The worker publishes its first snapshot after this process starts; wait for that real event. */ + for (attempt = 0; attempt < 2000 && highest < 1; ++attempt) { + highest = cap_highest_snapshot(run); + if (highest < 1) Sleep(10); + } + if (highest < 1) return 94; + if (wcscmp(mode, L"journal-readonly-snapshot") == 0) { + if (swprintf_s(journal, 1100, L"%ls\\process-journal.1.json", run) < 0) return 92; + return SetFileAttributesW(journal, FILE_ATTRIBUTE_READONLY) ? 0 : 93; + } + if (swprintf_s(journal, 1100, L"%ls\\process-journal.%d.json", run, + wcscmp(mode, L"journal-corrupt-highest") == 0 ? 9999 : highest + 1) < 0) return 95; + return cap_plant_file(journal, "{\"corrupt\":") ? 0 : 96; +} int wmain(int argc, wchar_t **argv) { char behavior[64] = {0}; wchar_t output[32768] = {0}, path[32768] = {0}; @@ -97,6 +158,10 @@ int wmain(int argc, wchar_t **argv) { puts("CAP_STDIN_EOF"); fputs("CAP_STDERR_EOF\n", stderr); fflush(stdout); fflush(stderr); + if (argc > 1 && (wcscmp(argv[1], L"journal-corrupt-highest") == 0 || + wcscmp(argv[1], L"journal-plant-collision") == 0 || wcscmp(argv[1], L"journal-readonly-snapshot") == 0)) { + return cap_journal_sabotage(argv[1]); + } if (strcmp(behavior, "trace-readonly") == 0) { if (GetEnvironmentVariableW(L"CAP_REHEARSAL_OUTPUT", output, 32768) == 0) return 96; if (swprintf_s(path, 32768, L"%ls\\supervisor-calls.jsonl", output) < 0) return 97; @@ -409,6 +474,109 @@ function Assert-CapRehearsalSuccess { targetArguments = @($Arguments); actualTargetLaunches = 1; images = 0; desktop = 'NotExecuted' } } +$script:journalSelf = $null +$script:journalHighestSeen = 0 +$journalSupervisorFunctions = @('Open-CapJournalRead', 'Test-CapFileNotFoundError', 'Get-CapJournalSnapshotList', + 'Read-CapJournalSnapshotBytes', 'Convert-CapJournalSnapshot', 'Read-CapJournalHighest', 'Refresh-CapJournal', + 'Assert-CapJournalTerminal', 'Add-CapTrackedIdentity') + +function Get-CapActualFunctionText { + param([string]$Path, [string[]]$Names) + $ast = [Management.Automation.Language.Parser]::ParseInput([IO.File]::ReadAllText($Path), [ref]$null, [ref]$null) + $text = foreach ($name in $Names) { + $function = $ast.Find({ param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq $name + }, $true) + if ($function) { $function.Extent.Text } + } + Assert-CapTest (@($text).Count -gt 0) "No actual journal function was found in $Path." + return ($text -join [Environment]::NewLine) +} + +function Set-CapJournalReaderContext { + $self = [Diagnostics.Process]::GetCurrentProcess() + if (-not $script:journalSelf) { + $script:journalSelf = Get-CapProcessIdentity -Process $self -ExpectedImagePath $self.MainModule.FileName ` + -Role 'journal-fixture-self' + } + Set-Variable -Scope 1 -Name lease -Value ([pscustomobject]@{ leaseId = [guid]::NewGuid().ToString() }) + Set-Variable -Scope 1 -Name jobHash -Value ('A' * 64) + Set-Variable -Scope 1 -Name moduleHash -Value $sourceHashesBefore[$modulePath] + Set-Variable -Scope 1 -Name workerHash -Value $sourceHashesBefore[$workerPath] + Set-Variable -Scope 1 -Name tracked -Value ([Collections.Generic.Dictionary[string, object]]::new([StringComparer]::OrdinalIgnoreCase)) + Set-Variable -Scope 1 -Name untrackedJournalEntries -Value ([Collections.Generic.List[object]]::new()) + $script:journalHighestSeen = 0 +} + +function New-CapJournalFixtureDirectory { + param([string]$Name) + $directory = Join-Path $scratch "journal-fixtures\$Name" + $null = New-Item -ItemType Directory -Path $directory + return $directory +} + +function Write-CapJournalFixtureSnapshot { + param( + [Parameter(Mandatory)][string]$Directory, [Parameter(Mandatory)][int]$Sequence, + [string]$Phase = 'fixture', [string]$PreviousSha256, [switch]$Terminal, + [hashtable]$Override = @{}, [string[]]$Remove = @(), [string]$FileName, [string]$RawText + ) + $name = if ($FileName) { $FileName } else { "process-journal.$Sequence.json" } + $path = Join-Path $Directory $name + if ($PSBoundParameters.ContainsKey('RawText')) { + $bytes = [Text.UTF8Encoding]::new($false).GetBytes($RawText) + } else { + $self = $script:journalSelf + $body = [ordered]@{ + schemaVersion = 1; sequence = $Sequence + previousSha256 = $(if ($PreviousSha256) { $PreviousSha256 } else { $null }); terminal = [bool]$Terminal + leaseId = $lease.leaseId; jobSha256 = $jobHash; workerSha256 = $workerHash; moduleSha256 = $moduleHash + updatedUtc = [DateTime]::UtcNow.ToString('o') + entries = @([ordered]@{ + role = 'journal-fixture-self'; phase = $Phase; pid = [int]$self.Pid + creationFileTimeUtc = [long]$self.CreationFileTimeUtc; expectedImagePath = [string]$self.ExpectedImagePath + expectedParentPid = [int]$self.ParentPid; expectedSessionId = [int]$self.SessionId + windowHandle = 0; registeredUtc = [DateTime]::UtcNow.ToString('o') + }) + } + if ($Terminal) { $body.ownedProcessSurvivors = @(); $body.pendingLaunchesRemaining = @(); $body.cleanupFailures = @() } + foreach ($key in $Override.Keys) { $body[$key] = $Override[$key] } + foreach ($key in $Remove) { $body.Remove($key) } + $bytes = [Text.UTF8Encoding]::new($false).GetBytes((ConvertTo-Json -InputObject $body -Depth 40)) + } + [IO.File]::WriteAllBytes($path, $bytes) + return [pscustomobject]@{ Path = $path; Sequence = $Sequence + Sha256 = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bytes)) } +} + +function Write-CapJournalFixtureChain { + param([string]$Directory, [int]$Count, [switch]$WithTerminal) + $records = [Collections.Generic.List[object]]::new() + $previous = $null + for ($sequence = 1; $sequence -le $Count; $sequence++) { + $record = Write-CapJournalFixtureSnapshot -Directory $Directory -Sequence $sequence -Phase "chain-$sequence" ` + -PreviousSha256 $previous -Terminal:($WithTerminal -and $sequence -eq $Count) + $records.Add($record) + $previous = $record.Sha256 + } + return @($records) +} + +function Get-CapUnwrappedException { + param([Parameter(Mandatory)]$ErrorRecord) + $exception = $ErrorRecord.Exception + while ($exception.InnerException -and $exception -isnot [IO.IOException]) { $exception = $exception.InnerException } + return $exception +} + +function Assert-CapJournalReleaseProof { + param($Run, [string]$State) + Assert-CapTest ($null -ne $Run.receipt -and $Run.receipt.PSObject.Properties['journal'] -and $null -ne $Run.receipt.journal) ` + 'The official release carries no journal snapshot cleanup proof.' + Assert-CapTest ($Run.receipt.journal.state -ceq $State) "Journal proof state was '$($Run.receipt.journal.state)', expected '$State'." + return $Run.receipt.journal +} + $target = $null try { $target = New-CapHarmlessTarget @@ -762,6 +930,386 @@ try { Assert-CapTest ($cleanupFailures.Count -eq 0) 'Actual guard refused complete empty cleanup arrays.' return 'Actual release predicate, eight negative data controls and one complete empty-array control; no unsafe child or process/lease/worker mock was executed.' } + Invoke-CapTest 'actual journal reader selects the highest numeric published snapshot and ignores unpublished temporaries' { + . ([scriptblock]::Create((Get-CapActualFunctionText $supervisorPath $journalSupervisorFunctions))) + Set-CapJournalReaderContext + $directory = New-CapJournalFixtureDirectory 'numeric-highest' + $null = Write-CapJournalFixtureChain -Directory $directory -Count 11 + $null = Write-CapJournalFixtureSnapshot -Directory $directory -Sequence 12 -RawText '{"unpublished":' ` + -FileName ('process-journal.pending-{0}.tmp' -f ('a' * 32)) + try { + Refresh-CapJournal -Path (Join-Path $directory 'process-journal.json') + $phases = @($tracked.Keys | ForEach-Object { $_.Split(':')[2] }) + Assert-CapTest ($phases.Count -eq 1 -and $phases[0] -ceq 'chain-11') ` + "The reader did not select sequence 11 over 9/10 (lexicographic) or the unpublished temporary: [$($phases -join ',')]" + Assert-CapTest ($script:journalHighestSeen -eq 11) 'The highest observed published sequence was not recorded.' + } finally { foreach ($record in $tracked.Values) { $record.Identity.Process.Dispose() } } + return 'Actual supervisor reader over eleven numbered snapshots plus an unpublished temporary; real file listing, open and parse.' + } + Invoke-CapTest 'actual journal reader refuses a corrupt, foreign or mismatched highest snapshot without falling back' { + . ([scriptblock]::Create((Get-CapActualFunctionText $supervisorPath $journalSupervisorFunctions))) + $mutations = [ordered]@{ + 'truncated-json' = @{ Expected = 'is malformed'; Raw = '{"schemaVersion":1,"sequence":4,' } + 'invalid-utf8' = @{ Expected = 'is malformed'; Bytes = [byte[]](0x7B, 0xFF, 0xFE, 0x7D) } + 'missing-sequence' = @{ Expected = 'is malformed'; Remove = @('sequence') } + 'name-body-sequence' = @{ Expected = 'differs from its published name'; Override = @{ sequence = 3 } } + 'foreign-lease' = @{ Expected = 'binding differs'; Override = @{ leaseId = [guid]::NewGuid().ToString() } } + 'foreign-job' = @{ Expected = 'binding differs'; Override = @{ jobSha256 = 'B' * 64 } } + 'foreign-worker' = @{ Expected = 'binding differs'; Override = @{ workerSha256 = 'B' * 64 } } + 'foreign-name' = @{ Expected = 'Unexpected file in the worker journal namespace'; Name = 'process-journal.007.json' } + } + foreach ($mutation in $mutations.Keys) { + Set-CapJournalReaderContext + $directory = New-CapJournalFixtureDirectory "refuse-$mutation" + $chain = Write-CapJournalFixtureChain -Directory $directory -Count 3 + $spec = $mutations[$mutation] + if ($spec.ContainsKey('Bytes')) { + [IO.File]::WriteAllBytes((Join-Path $directory 'process-journal.4.json'), $spec.Bytes) + } elseif ($spec.ContainsKey('Raw')) { + $null = Write-CapJournalFixtureSnapshot -Directory $directory -Sequence 4 -RawText $spec.Raw + } elseif ($spec.ContainsKey('Name')) { + $null = Write-CapJournalFixtureSnapshot -Directory $directory -Sequence 7 -FileName $spec.Name -PreviousSha256 $chain[2].Sha256 + } else { + $parameters = @{ Directory = $directory; Sequence = 4; Phase = 'mutated-highest'; PreviousSha256 = $chain[2].Sha256 } + if ($spec.ContainsKey('Override')) { $parameters.Override = $spec.Override } + if ($spec.ContainsKey('Remove')) { $parameters.Remove = $spec.Remove } + $null = Write-CapJournalFixtureSnapshot @parameters + } + try { + $null = Assert-CapRejects { Refresh-CapJournal -Path (Join-Path $directory 'process-journal.json') } $spec.Expected + Assert-CapTest ($tracked.Count -eq 0) "The reader fell back to an older snapshot after refusing $mutation." + } finally { foreach ($record in $tracked.Values) { $record.Identity.Process.Dispose() } } + } + return [ordered]@{ refusedMutations = @($mutations.Keys); data = 'Real files and the actual reader; no fallback to the older valid snapshot 3.' } + } + Invoke-CapTest 'actual journal reader re-lists only for a just-listed FileNotFoundException within a bounded attempt budget' { + . ([scriptblock]::Create((Get-CapActualFunctionText $supervisorPath $journalSupervisorFunctions))) + Copy-Item -LiteralPath Function:\Open-CapJournalRead -Destination Function:\Open-CapJournalReadActual + function Open-CapJournalRead { + param([Parameter(Mandatory)][string]$Path) + $script:openCalls.Add($Path) + & $script:openHook $Path + return Open-CapJournalReadActual -Path $Path + } + $evidence = [ordered]@{} + # 1. The just-listed name vanishes and a newer snapshot is published: one bounded re-list succeeds. + Set-CapJournalReaderContext + $directory = New-CapJournalFixtureDirectory 'relist-supersede' + $base = Join-Path $directory 'process-journal.json' + $null = Write-CapJournalFixtureSnapshot -Directory $directory -Sequence 1 -Phase 'one' + $script:openCalls = [Collections.Generic.List[string]]::new() + $script:openHook = { param($Path) + if ($script:openCalls.Count -eq 1) { + Remove-Item -LiteralPath $Path -Force + $null = Write-CapJournalFixtureSnapshot -Directory $directory -Sequence 2 -Phase 'two' + } + } + try { + Refresh-CapJournal -Path $base + $phases = @($tracked.Keys | ForEach-Object { $_.Split(':')[2] }) + Assert-CapTest ($script:openCalls.Count -eq 2 -and $script:openCalls[0].EndsWith('process-journal.1.json') -and + $script:openCalls[1].EndsWith('process-journal.2.json') -and $phases.Count -eq 1 -and $phases[0] -ceq 'two') ` + 'A vanished just-listed snapshot was not recovered by exactly one bounded re-list.' + $evidence.supersede = @($script:openCalls) + } finally { foreach ($record in $tracked.Values) { $record.Identity.Process.Dispose() } } + # 2. A sharing violation is a real I/O failure, never a missing file, and is not re-listed. + Set-CapJournalReaderContext + $directory = New-CapJournalFixtureDirectory 'relist-denied' + $base = Join-Path $directory 'process-journal.json' + $null = Write-CapJournalFixtureSnapshot -Directory $directory -Sequence 1 -Phase 'one' + $script:openCalls = [Collections.Generic.List[string]]::new() + $script:lockHandle = $null + $script:openHook = { param($Path) + $script:lockHandle = [IO.File]::Open($Path, [IO.FileMode]::Open, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) + } + $thrown = $null + try { Refresh-CapJournal -Path $base } catch { $thrown = Get-CapUnwrappedException $_ } + finally { + if ($script:lockHandle) { $script:lockHandle.Dispose() } + foreach ($record in $tracked.Values) { $record.Identity.Process.Dispose() } + } + Assert-CapTest ($thrown -is [IO.IOException] -and $thrown -isnot [IO.FileNotFoundException] -and + $script:openCalls.Count -eq 1 -and $tracked.Count -eq 0) ` + 'A non-FileNotFound open failure was swallowed or re-listed as a missing file.' + $evidence.sharingViolation = $thrown.GetType().FullName + # 3. The highest vanishes with no successor: explicit failure, never an older snapshot. + Set-CapJournalReaderContext + $directory = New-CapJournalFixtureDirectory 'relist-vanished' + $base = Join-Path $directory 'process-journal.json' + $null = Write-CapJournalFixtureChain -Directory $directory -Count 2 + $script:openCalls = [Collections.Generic.List[string]]::new() + $script:openHook = { param($Path) Remove-Item -LiteralPath $Path -Force } + try { + $null = Assert-CapRejects { Refresh-CapJournal -Path $base } 'regressed or vanished' + Assert-CapTest ($tracked.Count -eq 0 -and $script:openCalls.Count -eq 1) 'A vanished highest snapshot fell back to an older one.' + } finally { foreach ($record in $tracked.Values) { $record.Identity.Process.Dispose() } } + # 4. Endless supersession exhausts the bounded budget instead of looping. + Set-CapJournalReaderContext + $directory = New-CapJournalFixtureDirectory 'relist-exhausted' + $base = Join-Path $directory 'process-journal.json' + $null = Write-CapJournalFixtureSnapshot -Directory $directory -Sequence 1 -Phase 'seed' + $script:openCalls = [Collections.Generic.List[string]]::new() + $script:openHook = { param($Path) + $next = [int](([IO.Path]::GetFileName($Path) -split '\.')[1]) + 1 + Remove-Item -LiteralPath $Path -Force + $null = Write-CapJournalFixtureSnapshot -Directory $directory -Sequence $next -Phase "again-$next" + } + try { + $null = Assert-CapRejects { Refresh-CapJournal -Path $base } 'budget exhausted' + Assert-CapTest ($script:openCalls.Count -ge 2 -and $script:openCalls.Count -le 16) ` + "The re-list budget was not bounded: $($script:openCalls.Count) opens." + $evidence.exhaustedOpens = $script:openCalls.Count + } finally { foreach ($record in $tracked.Values) { $record.Identity.Process.Dispose() } } + return $evidence + } + Invoke-CapTest 'actual worker publisher and supervisor reader stay consistent through many concurrent publications with a held old reader' { + . ([scriptblock]::Create((Get-CapActualFunctionText $supervisorPath $journalSupervisorFunctions))) + Set-CapJournalReaderContext + $publications = 300 + $directory = New-CapJournalFixtureDirectory 'concurrent-publications' + $base = Join-Path $directory 'process-journal.json' + $id = [guid]::NewGuid().ToString('N').Substring(0, 12) + $names = @{ ready = "Local\gcj-$id-ready"; go = "Local\gcj-$id-go"; mid = "Local\gcj-$id-mid"; cont = "Local\gcj-$id-cont" } + $events = @{} + foreach ($key in $names.Keys) { + $events[$key] = [Threading.EventWaitHandle]::new($false, [Threading.EventResetMode]::ManualReset, $names[$key]) + } + $childScript = @' +$ErrorActionPreference = 'Stop' +Import-Module $env:CAP_J_MODULE -Force -WarningAction SilentlyContinue +. $env:CAP_J_WORKER -OwnedRoot $env:CAP_J_ROOT -DefinitionsOnly +$script:journalPath = $env:CAP_J_JOURNAL +$script:auth = [pscustomobject]@{ leaseId = $env:CAP_J_LEASE } +$script:jobHash = $env:CAP_J_JOB; $script:workerHash = $env:CAP_J_WORKERHASH; $script:moduleHash = $env:CAP_J_MODULEHASH +$ev = @{} +foreach ($key in @('ready', 'go', 'mid', 'cont')) { + $ev[$key] = [Threading.EventWaitHandle]::OpenExisting([Environment]::GetEnvironmentVariable('CAP_J_EV_' + $key.ToUpperInvariant())) +} +$count = [int]$env:CAP_J_COUNT +$process = [Diagnostics.Process]::GetCurrentProcess() +$identity = Get-CapProcessIdentity -Process $process -ExpectedImagePath $process.MainModule.FileName -Role 'journal-concurrency-writer' +Write-CapProcessJournal -Identity $identity -Phase 'p0' +$null = $ev.ready.Set() +if (-not $ev.go.WaitOne(60000)) { throw 'go barrier timed out' } +for ($i = 1; $i -lt $count; $i++) { + Write-CapProcessJournal -Identity $identity -Phase ('p{0}' -f ($i % 10)) + if ($i -eq [int]($count / 2)) { + $null = $ev.mid.Set() + if (-not $ev.cont.WaitOne(60000)) { throw 'cont barrier timed out' } + } +} +Write-Output "WRITER_DONE publications=$count" +'@ + $run = Start-CapManagedProcess -FilePath (Join-Path $PSHOME 'pwsh.exe') -ArgumentList @( + '-NoProfile', '-Command', '. ([scriptblock]::Create($env:CAP_J_CHILD))') ` + -WorkingDirectory $directory -Role 'journal-concurrency-writer' -Environment @{ + CAP_J_CHILD = $childScript; CAP_J_MODULE = $modulePath; CAP_J_WORKER = $workerPath + CAP_J_ROOT = $scratch; CAP_J_JOURNAL = $base; CAP_J_LEASE = $lease.leaseId + CAP_J_JOB = $jobHash; CAP_J_WORKERHASH = $workerHash; CAP_J_MODULEHASH = $moduleHash + CAP_J_COUNT = [string]$publications; CAP_J_EV_READY = $names.ready; CAP_J_EV_GO = $names.go + CAP_J_EV_MID = $names.mid; CAP_J_EV_CONT = $names.cont + } + $errors = [Collections.Generic.List[string]]::new() + $reads = 0; $midRead = $false; $held = $null; $heldBytes = $null; $heldFileName = $null + try { + $wait = $events.ready.WaitOne(60000) + if (-not $wait) { + $null = $run.Identity.Process.WaitForExit(2000) + $null = $run.StdoutTask.Wait(2000); $null = $run.StderrTask.Wait(2000) + throw "The writer process never published its first snapshot: stderr=$($run.StderrTask.Result)" + } + $firstSnapshot = @(Get-ChildItem -LiteralPath $directory -Filter 'process-journal*.json' -File)[0] + $heldFileName = $firstSnapshot.Name + $held = Open-CapJournalRead -Path $firstSnapshot.FullName + $null = $events.go.Set() + while (-not $run.Identity.Process.HasExited) { + try { Refresh-CapJournal -Path $base; $reads++ } catch { $errors.Add($_.Exception.Message) } + if (-not $midRead -and $events.mid.WaitOne(0)) { + try { Refresh-CapJournal -Path $base; $reads++; $midRead = $true } catch { $errors.Add($_.Exception.Message) } + $null = $events.cont.Set() + } + } + try { Refresh-CapJournal -Path $base; $reads++ } catch { $errors.Add($_.Exception.Message) } + $heldReader = [IO.StreamReader]::new($held) + try { $heldBytes = $heldReader.ReadToEnd() } finally { $heldReader.Dispose() } + Assert-CapTest ($run.StdoutTask.Wait(5000) -and $run.StderrTask.Wait(5000)) 'Writer EOF was incomplete.' + } finally { + $null = $events.cont.Set() + if (-not $run.Identity.Process.HasExited) { $null = Stop-CapManagedProcess -Identity $run.Identity -TimeoutMilliseconds 5000 } + if ($held) { $held.Dispose() } + foreach ($record in $tracked.Values) { $record.Identity.Process.Dispose() } + foreach ($event in $events.Values) { $event.Dispose() } + $run.Identity.Process.Dispose() + } + Assert-CapTest ($errors.Count -eq 0) "The supervisor reader failed $($errors.Count) of $reads reads during concurrent publication; first: $($errors | Select-Object -First 2)" + Assert-CapTest ($run.Identity.Process.ExitCode -eq 0 -and ([string]$run.StdoutTask.Result).Contains('WRITER_DONE')) ` + "The real worker publisher failed: $($run.StderrTask.Result)" + Assert-CapTest ($reads -gt 0 -and $midRead) 'No positive concurrent read count or mid-stream checkpoint was observed.' + $snapshots = @(Get-ChildItem -LiteralPath $directory -Filter 'process-journal*.json' -File) + Assert-CapTest ($snapshots.Count -eq $publications) "Expected $publications immutable snapshots, found $($snapshots.Count)." + Assert-CapTest ($script:journalHighestSeen -eq $publications) "The reader's final highest sequence was $($script:journalHighestSeen)." + Assert-CapTest ($heldBytes.Contains('"sequence": 1,') -and $heldFileName -ceq 'process-journal.1.json') ` + 'The held old reader did not keep reading its immutable first snapshot.' + return [ordered]@{ publications = $publications; reads = $reads; readerErrors = 0; midCheckpoint = $midRead + snapshots = $snapshots.Count; highest = $script:journalHighestSeen + data = 'Actual worker publisher in a distinct console child and actual supervisor reader here; named-event barriers; no process/lease mocks.' } + } + Invoke-CapTest 'actual terminal verification binds the exact final journal to the final manifest and refuses stale or incomplete proof' { + . ([scriptblock]::Create((Get-CapActualFunctionText $supervisorPath $journalSupervisorFunctions))) + Set-CapJournalReaderContext + $expected = @{ LeaseId = $lease.leaseId; JobSha256 = $jobHash; ModuleSha256 = $moduleHash; WorkerSha256 = $workerHash } + $newManifest = { + param($Records, [hashtable]$Change = @{}) + $last = $Records[$Records.Count - 1] + $manifest = [ordered]@{ + leaseId = $lease.leaseId; jobManifestSha256 = $jobHash; moduleSha256 = $moduleHash; workerSha256 = $workerHash + processJournal = [ordered]@{ terminalSequence = $last.Sequence; terminalSha256 = $last.Sha256; snapshotCount = $Records.Count } + ownedProcessSurvivors = @(); pendingLaunchesRemaining = @(); cleanupFailures = @() + } + foreach ($key in $Change.Keys) { $manifest[$key] = $Change[$key] } + return ([pscustomobject]$manifest | ConvertTo-Json -Depth 10 | ConvertFrom-Json -DateKind String) + } + $directory = New-CapJournalFixtureDirectory 'terminal-valid' + $base = Join-Path $directory 'process-journal.json' + $records = Write-CapJournalFixtureChain -Directory $directory -Count 4 -WithTerminal + $proof = Assert-CapJournalTerminal -BasePath $base -Manifest (& $newManifest $records) -Expected $expected -TerminalRequired + Assert-CapTest ($proof.Inventory.Count -eq 4 -and $proof.TerminalSequence -eq 4 -and $proof.TerminalSha256 -ceq $records[3].Sha256 -and + $proof.TerminalPresent -and $proof.TerminalBoundToManifest) 'A complete chained terminal journal was not proven.' + $directory = New-CapJournalFixtureDirectory 'terminal-not-required' + $base = Join-Path $directory 'process-journal.json' + $records = Write-CapJournalFixtureChain -Directory $directory -Count 3 + $notFinal = Assert-CapJournalTerminal -BasePath $base -Manifest $null -Expected $expected + Assert-CapTest ($notFinal.Inventory.Count -eq 3 -and -not $notFinal.TerminalPresent) 'A failed-worker non-terminal chain was not inventoried without a terminal claim.' + $negatives = [ordered]@{ + 'stale-final' = @{ Expected = 'terminal snapshot is missing'; Build = { param($d) Write-CapJournalFixtureChain -Directory $d -Count 4 } } + 'no-snapshots' = @{ Expected = 'has no published snapshot'; Build = { param($d) @() } } + 'post-terminal-version' = @{ Expected = 'after its terminal snapshot'; Build = { param($d) + $chain = @(Write-CapJournalFixtureChain -Directory $d -Count 4 -WithTerminal) + $chain + (Write-CapJournalFixtureSnapshot -Directory $d -Sequence 5 -Phase 'late' -PreviousSha256 $chain[3].Sha256) } } + 'null-survivors' = @{ Expected = 'terminal arrays are incomplete'; Build = { param($d) + Write-CapJournalFixtureChain -Directory $d -Count 3 + Write-CapJournalFixtureSnapshot -Directory $d -Sequence 4 -Terminal -Override @{ ownedProcessSurvivors = $null } -PreviousSha256 (Get-FileHash (Join-Path $d 'process-journal.3.json')).Hash } } + 'missing-pending' = @{ Expected = 'terminal arrays are incomplete'; Build = { param($d) + Write-CapJournalFixtureChain -Directory $d -Count 3 + Write-CapJournalFixtureSnapshot -Directory $d -Sequence 4 -Terminal -Remove @('pendingLaunchesRemaining') -PreviousSha256 (Get-FileHash (Join-Path $d 'process-journal.3.json')).Hash } } + 'scalar-cleanup' = @{ Expected = 'terminal arrays are incomplete'; Build = { param($d) + Write-CapJournalFixtureChain -Directory $d -Count 3 + Write-CapJournalFixtureSnapshot -Directory $d -Sequence 4 -Terminal -Override @{ cleanupFailures = 0 } -PreviousSha256 (Get-FileHash (Join-Path $d 'process-journal.3.json')).Hash } } + 'sequence-gap' = @{ Expected = 'sequence gap'; Build = { param($d) + $first = Write-CapJournalFixtureChain -Directory $d -Count 2 + Write-CapJournalFixtureSnapshot -Directory $d -Sequence 4 -Terminal -PreviousSha256 $first[1].Sha256 } } + 'broken-hash-chain' = @{ Expected = 'hash chain is broken'; Build = { param($d) + Write-CapJournalFixtureChain -Directory $d -Count 3 + Write-CapJournalFixtureSnapshot -Directory $d -Sequence 4 -Terminal -PreviousSha256 ('0' * 64) } } + 'foreign-older-snapshot' = @{ Expected = 'binding differs'; Build = { param($d) + $one = Write-CapJournalFixtureSnapshot -Directory $d -Sequence 1 + $two = Write-CapJournalFixtureSnapshot -Directory $d -Sequence 2 -PreviousSha256 $one.Sha256 -Override @{ leaseId = [guid]::NewGuid().ToString() } + Write-CapJournalFixtureSnapshot -Directory $d -Sequence 3 -Terminal -PreviousSha256 $two.Sha256 } } + } + foreach ($name in $negatives.Keys) { + $directory = New-CapJournalFixtureDirectory "terminal-$name" + $base = Join-Path $directory 'process-journal.json' + $built = @(& $negatives[$name].Build $directory) + $records = @($built | Where-Object { $_ -and $_.PSObject.Properties['Sha256'] }) + $manifest = if ($records.Count) { & $newManifest $records } else { $null } + try { + $null = Assert-CapRejects { Assert-CapJournalTerminal -BasePath $base -Manifest $manifest -Expected $expected -TerminalRequired } $negatives[$name].Expected + } catch { throw "Terminal negative '$name': $($_.Exception.Message)" } + } + $directory = New-CapJournalFixtureDirectory 'terminal-binding' + $base = Join-Path $directory 'process-journal.json' + $records = Write-CapJournalFixtureChain -Directory $directory -Count 4 -WithTerminal + $bindingMutations = [ordered]@{ + 'wrong-sequence' = @{ processJournal = [ordered]@{ terminalSequence = 3; terminalSha256 = $records[3].Sha256; snapshotCount = 4 } } + 'wrong-hash' = @{ processJournal = [ordered]@{ terminalSequence = 4; terminalSha256 = ('0' * 64); snapshotCount = 4 } } + 'wrong-count' = @{ processJournal = [ordered]@{ terminalSequence = 4; terminalSha256 = $records[3].Sha256; snapshotCount = 5 } } + 'missing-binding' = @{ processJournal = $null } + 'manifest-cleanup-differs' = @{ cleanupFailures = @('unreported cleanup failure') } + 'foreign-manifest-lease' = @{ leaseId = [guid]::NewGuid().ToString() } + } + foreach ($name in $bindingMutations.Keys) { + $manifest = & $newManifest $records $bindingMutations[$name] + try { + $null = Assert-CapRejects { Assert-CapJournalTerminal -BasePath $base -Manifest $manifest -Expected $expected -TerminalRequired } 'not bound to the final manifest' + } catch { throw "Manifest binding negative '$name': $($_.Exception.Message)" } + } + return [ordered]@{ negatives = @($negatives.Keys); bindingNegatives = @($bindingMutations.Keys) + data = 'Actual terminal verifier over real chained files; no worker/process mock.' } + } + Invoke-CapTest 'real capture command publishes immutable journal snapshots and proves their removal at release' { + $run = Invoke-CapCaptureCommand -Name 'journal-release' + $null = Assert-CapRehearsalSuccess -Run $run -Arguments @() + $journal = Assert-CapJournalReleaseProof -Run $run -State 'SNAPSHOTS_REMOVED_AND_VERIFIED' + $inventory = @($journal.preCleanupInventory) + Assert-CapTest ($journal.positiveInventoryRequired -and $inventory.Count -ge 2 -and $journal.inventoryCount -eq $inventory.Count -and + $journal.removedCount -eq $inventory.Count -and @($journal.removed).Count -eq $inventory.Count -and + @($journal.remainingAfterCleanup).Count -eq 0 -and $journal.terminalVerified -and $journal.terminalBoundToManifest -and + $journal.terminalSequence -eq $inventory.Count) 'The release lacks a positive pre-cleanup inventory, exact removal and terminal binding.' + for ($i = 0; $i -lt $inventory.Count; $i++) { + Assert-CapTest ($inventory[$i].sequence -eq ($i + 1) -and $inventory[$i].name -ceq "process-journal.$($i + 1).json" -and + $inventory[$i].sha256 -match '^[A-F0-9]{64}$' -and $journal.removed[$i].name -ceq $inventory[$i].name -and + $journal.removed[$i].sha256 -ceq $inventory[$i].sha256) "Inventory/removal metadata for snapshot $($i + 1) is not exact." + } + $runDirectories = @(Get-ChildItem -LiteralPath (Join-Path $run.owned 'runs') -Directory) + Assert-CapTest ($runDirectories.Count -eq 1 -and @(Get-ChildItem -LiteralPath $runDirectories[0].FullName -Filter 'process-journal*' -Force).Count -eq 0) ` + 'Journal snapshots or temporaries remain in the owned run directory after release.' + $record = $journal.inventoryRecord + Assert-CapTest ((Test-Path -LiteralPath $record.path -PathType Leaf) -and + (Get-FileHash -LiteralPath $record.path -Algorithm SHA256).Hash -ceq $record.sha256) 'The preserved pre-removal inventory record is missing or altered.' + $manifest = Get-Content -LiteralPath (Join-Path $run.output 'manifest.json') -Raw | ConvertFrom-Json -DateKind String + Assert-CapTest ($manifest.processJournal.terminalSequence -eq $journal.terminalSequence -and + $manifest.processJournal.terminalSha256 -ceq $journal.terminalSha256 -and + $manifest.processJournal.snapshotCount -eq $inventory.Count) 'The final manifest does not bind the exact terminal journal.' + return [ordered]@{ release = $run.receiptPath; releaseSha256 = $run.receiptSha256; snapshots = $inventory.Count + terminalSequence = $journal.terminalSequence; inventoryRecord = $record } + } + Invoke-CapTest 'no-worker refusal reports a distinct journal state instead of a fake cleanup zero' { + $run = Invoke-CapCaptureCommand -Name 'journal-no-worker' -LeaseMutation 'hash' + Assert-CapTest ($run.child.exitCode -ne 0 -and $null -ne $run.receipt -and $run.receipt.status -ceq 'REFUSED_NO_WORKER') 'Invalid lease did not produce a no-worker release.' + $journal = Assert-CapJournalReleaseProof -Run $run -State 'NO_WORKER_STARTED' + Assert-CapTest (-not $journal.positiveInventoryRequired -and $journal.inventoryCount -eq 0 -and $journal.removedCount -eq 0 -and + -not $journal.terminalVerified) 'A no-worker refusal claimed journal cleanup.' + return [ordered]@{ release = $run.receiptPath; releaseSha256 = $run.receiptSha256; state = $journal.state } + } + Invoke-CapTest 'real capture command refuses a planted corrupt highest journal snapshot and retains it' { + $run = Invoke-CapCaptureCommand -Name 'journal-corrupt-highest' -Arguments @('journal-corrupt-highest') + Assert-CapTest ($run.child.exitCode -ne 0 -and $null -ne $run.receipt -and $run.receipt.status -ceq 'CLEANUP_UNPROVEN') ` + "A corrupt highest published journal snapshot was accepted: exit=$($run.child.exitCode) status=$($run.receipt.status)" + Assert-CapTest ((@($run.receipt.primaryFailure) + @($run.receipt.cleanupFailures) -join "`n").Contains('Worker process journal')) ` + 'The release does not attribute the failure to the corrupt journal snapshot.' + $journal = Assert-CapJournalReleaseProof -Run $run -State 'RETAINED_VERIFICATION_FAILED' + Assert-CapTest (-not $journal.terminalVerified -and $journal.removedCount -eq 0) 'Verification failure still claimed terminal proof or removed evidence.' + $planted = @(Get-ChildItem -LiteralPath (Join-Path $run.owned 'runs') -Recurse -Filter 'process-journal.9999.json' -File) + Assert-CapTest ($planted.Count -eq 1 -and [IO.File]::ReadAllText($planted[0].FullName) -ceq '{"corrupt":') 'The planted corrupt snapshot was not retained unchanged.' + return [ordered]@{ release = $run.receiptPath; releaseSha256 = $run.receiptSha256; state = $journal.state } + } + Invoke-CapTest 'real worker never overwrites a colliding foreign journal snapshot name' { + $run = Invoke-CapCaptureCommand -Name 'journal-collision' -Arguments @('journal-plant-collision') + Assert-CapTest ($run.child.exitCode -ne 0 -and $null -ne $run.receipt -and $run.receipt.status -ceq 'CLEANUP_UNPROVEN') ` + "A colliding foreign snapshot name did not fail closed: exit=$($run.child.exitCode) status=$($run.receipt.status)" + $manifest = Get-Content -LiteralPath (Join-Path $run.output 'manifest.partial.json') -Raw | ConvertFrom-Json -DateKind String + Assert-CapTest ((@($manifest.cleanupFailures) -join "`n").Contains('Final process journal update failed')) ` + 'The worker did not report that its terminal publication collided.' + $planted = @(Get-ChildItem -LiteralPath (Join-Path $run.owned 'runs') -Recurse -Filter 'process-journal.*.json' -File | + Where-Object { [IO.File]::ReadAllText($_.FullName) -ceq '{"corrupt":' }) + Assert-CapTest ($planted.Count -eq 1) 'The foreign snapshot at the colliding name was overwritten, replaced or removed.' + $journal = Assert-CapJournalReleaseProof -Run $run -State 'RETAINED_VERIFICATION_FAILED' + return [ordered]@{ release = $run.receiptPath; releaseSha256 = $run.receiptSha256; state = $journal.state; foreign = $planted[0].Name } + } + Invoke-CapTest 'real release stays cleanup-unproven when a journal snapshot cannot be removed' { + $run = Invoke-CapCaptureCommand -Name 'journal-readonly' -Arguments @('journal-readonly-snapshot') + $locked = @(Get-ChildItem -LiteralPath (Join-Path $run.owned 'runs') -Recurse -Filter 'process-journal.1.json' -File) + try { + Assert-CapTest ($run.child.exitCode -ne 0 -and $null -ne $run.receipt -and $run.receipt.status -ceq 'CLEANUP_UNPROVEN') ` + "A snapshot removal failure did not stay cleanup-unproven: exit=$($run.child.exitCode) status=$($run.receipt.status)" + $journal = Assert-CapJournalReleaseProof -Run $run -State 'REMOVAL_UNPROVEN' + Assert-CapTest ($locked.Count -eq 1 -and @($journal.remainingAfterCleanup) -contains 'process-journal.1.json' -and + -not (@($journal.removed | ForEach-Object name) -contains 'process-journal.1.json') -and + $journal.removedCount -eq ($journal.inventoryCount - 1) -and $journal.terminalVerified) 'The proof overstated the removal of a snapshot that remains on disk.' + Assert-CapTest ((@($run.receipt.cleanupFailures) -join "`n").Contains('process-journal.1.json')) 'The removal failure is not in the release cleanup failures.' + } finally { foreach ($file in $locked) { Set-ItemProperty -LiteralPath $file.FullName -Name IsReadOnly -Value $false } } + return [ordered]@{ release = $run.receiptPath; releaseSha256 = $run.receiptSha256; state = $journal.state } + } # Same frozen five-scene/two-phase mocks: component regression only, never launch/UI proof. # Test-CapCaptureOffline.ps1 SHA256 6C08A2029D9256405552BBA9B82B15A8A19359544433FD0E5928B4A7EA6C2592. try { From 7b77706122ac54b3c66ce4e52f20ffbba94b6074 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Wed, 14 Oct 2026 20:59:46 -0700 Subject: [PATCH 2/2] Publish immutable numbered capture journal snapshots and prove their removal The supervisor polled process-journal.json with Test-Path then Open while the worker replaced it with File.Replace, so the name could be briefly missing and the hosted UiCaptureDriver cases failed intermittently (49 run, 47 passed). The worker now writes each complete, hash-chained snapshot to a same-directory unique temporary and publishes it with a non-overwriting File.Move to process-journal.N.json. The supervisor lists the numeric snapshots, opens the highest directly, and re-lists within a bounded budget only for FileNotFoundException on the just-listed name; parse, binding, access and sequence failures never fall back to an older snapshot. After the worker exits with EOF it verifies the contiguous hash chain, binds the terminal snapshot (sequence, hash, count, identity, cleanup arrays) to the final manifest, preserves a pre-removal inventory, removes exactly the owned snapshots and proves their absence in the official release. Failures stay cleanup-unproven. Refs #826 Refs #822 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9f32f381-95ba-4648-a71a-c021bcfa8aa3 Signed-off-by: Colin Neilens --- Tools/windows/Invoke-UiCapture.ps1 | 450 +++++++++++++++++- Tools/windows/Invoke-UiCaptureWorker.ps1 | 77 ++- Tools/windows/Tests/UiCaptureDriver.Tests.ps1 | 120 +++-- 3 files changed, 584 insertions(+), 63 deletions(-) diff --git a/Tools/windows/Invoke-UiCapture.ps1 b/Tools/windows/Invoke-UiCapture.ps1 index b15d0995..a0d4891a 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 +$journalHighestSeen = 0 +$journalHighestSha256 = $null +$journalRelistCount = 0 +$journalRelease = $null $safeOutputInitialized = $false $primaryFailure = $null $recoveryReceipts = [Collections.Generic.List[object]]::new() @@ -408,30 +412,433 @@ 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. + # Published snapshots are immutable; ReadWrite|Delete sharing still lets the worker and release proceed. return [IO.File]::Open($Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, ([IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete)) } +function Test-CapFileNotFoundError { + param([Parameter(Mandatory)]$ErrorRecord) + $exception = $ErrorRecord.Exception + if ($exception -is [Management.Automation.MethodInvocationException] -and $exception.InnerException) { + $exception = $exception.InnerException + } + return $exception -is [IO.FileNotFoundException] +} + +function Get-CapJournalSnapshotList { + param([Parameter(Mandatory)][string]$BasePath) + if (Get-Command Write-CapTrace -ErrorAction SilentlyContinue) { Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters } + + $directory = [IO.Path]::GetDirectoryName($BasePath) + $stem = [IO.Path]::GetFileNameWithoutExtension($BasePath) + $options = [Text.RegularExpressions.RegexOptions]::CultureInvariant + $published = [regex]::new('^' + [regex]::Escape($stem) + '\.([1-9][0-9]{0,8})\.json$', $options) + $unpublished = [regex]::new('^' + [regex]::Escape($stem) + '\.pending-[0-9a-f]{32}\.tmp$', $options) + $snapshots = [Collections.Generic.List[object]]::new() + $temporaries = [Collections.Generic.List[string]]::new() + foreach ($path in [IO.Directory]::EnumerateFiles($directory, "$stem.*")) { + $name = [IO.Path]::GetFileName($path) + $match = $published.Match($name) + if ($match.Success) { + $snapshots.Add([pscustomobject]@{ Sequence = [int]$match.Groups[1].Value; Name = $name; Path = $path }) + } elseif ($unpublished.IsMatch($name)) { + $temporaries.Add($name) + } else { + throw "Unexpected file in the worker journal namespace: $name" + } + } + return [pscustomobject]@{ + Snapshots = @($snapshots | Sort-Object Sequence) + Temporaries = @($temporaries | Sort-Object) + } +} + +function Read-CapJournalSnapshotBytes { + param([Parameter(Mandatory)][string]$Path) + if (Get-Command Write-CapTrace -ErrorAction SilentlyContinue) { Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters } + + $stream = Open-CapJournalRead -Path $Path + try { + $memory = [IO.MemoryStream]::new() + try { $stream.CopyTo($memory); return , $memory.ToArray() } + finally { $memory.Dispose() } + } finally { $stream.Dispose() } +} + +function Convert-CapJournalSnapshot { + param( + [Parameter(Mandatory)][AllowEmptyCollection()][byte[]]$Bytes, + [Parameter(Mandatory)]$Entry, + [Parameter(Mandatory)][hashtable]$Expected + ) + if (Get-Command Write-CapTrace -ErrorAction SilentlyContinue) { Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters } + + $hash = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($Bytes)) + $malformed = "Worker process journal snapshot $($Entry.Name) is malformed" + try { + $journal = [Text.UTF8Encoding]::new($false, $true).GetString($Bytes) | + ConvertFrom-Json -DateKind String -ErrorAction Stop + } catch { throw "${malformed}: $($_.Exception.Message)" } + if ($journal -isnot [pscustomobject]) { throw "${malformed}: the document is not a JSON object." } + foreach ($property in @('schemaVersion', 'sequence', 'previousSha256', 'terminal', 'leaseId', 'jobSha256', + 'moduleSha256', 'workerSha256', 'updatedUtc', 'entries')) { + if (-not $journal.PSObject.Properties[$property]) { throw "${malformed}: it lacks '$property'." } + } + if ($journal.schemaVersion -ne 1 -or ($journal.sequence -isnot [int] -and $journal.sequence -isnot [long]) -or + $journal.terminal -isnot [bool] -or $journal.entries -isnot [array] -or + ($Entry.Sequence -eq 1 -and $null -ne $journal.previousSha256) -or + ($Entry.Sequence -gt 1 -and $journal.previousSha256 -cnotmatch '^[A-F0-9]{64}$')) { + throw "${malformed}: its schema, sequence type, previous hash or entries are invalid." + } + if ([long]$journal.sequence -ne $Entry.Sequence) { + throw "Worker process journal snapshot $($Entry.Name) body sequence $($journal.sequence) differs from its published name." + } + if ($journal.leaseId -ne $Expected.LeaseId -or $journal.jobSha256 -ne $Expected.JobSha256 -or + $journal.moduleSha256 -ne $Expected.ModuleSha256 -or $journal.workerSha256 -ne $Expected.WorkerSha256) { + throw 'Worker process journal hash/lease binding differs from the coordinator-approved run.' + } + return [pscustomobject]@{ + Sequence = [int]$Entry.Sequence; Name = $Entry.Name; Path = $Entry.Path; Sha256 = $hash + Length = $Bytes.Length; Terminal = [bool]$journal.terminal + PreviousSha256 = $journal.previousSha256; Journal = $journal + } +} + +function Read-CapJournalHighest { + param( + [Parameter(Mandatory)][string]$BasePath, + [Parameter(Mandatory)][hashtable]$Expected, + [int]$MinimumSequence = 0, + [DateTime]$DeadlineUtc = [DateTime]::UtcNow.AddSeconds(10), + [int]$MaximumAttempts = 8 + ) + if (Get-Command Write-CapTrace -ErrorAction SilentlyContinue) { Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters } + + $relists = 0 + for ($attempt = 1; $attempt -le $MaximumAttempts -and [DateTime]::UtcNow -lt $DeadlineUtc; $attempt++) { + $snapshots = @((Get-CapJournalSnapshotList -BasePath $BasePath).Snapshots) + if ($snapshots.Count -eq 0) { + if ($MinimumSequence -gt 0) { + throw "Worker process journal highest published sequence regressed or vanished: sequence $MinimumSequence was observed and none is published." + } + return $null + } + $highest = $snapshots[$snapshots.Count - 1] + if ($highest.Sequence -lt $MinimumSequence) { + throw "Worker process journal highest published sequence regressed or vanished: $($highest.Sequence) is below the observed $MinimumSequence." + } + try { $bytes = Read-CapJournalSnapshotBytes -Path $highest.Path } + catch { + # Only a missing just-listed name means "list again"; every other failure is real evidence. + if (-not (Test-CapFileNotFoundError $_)) { throw } + $relists++ + $MinimumSequence = [Math]::Max($MinimumSequence, $highest.Sequence) + continue + } + $snapshot = Convert-CapJournalSnapshot -Bytes $bytes -Entry $highest -Expected $Expected + $snapshot | Add-Member -NotePropertyName Relists -NotePropertyValue $relists + return $snapshot + } + throw "Worker process journal re-list budget exhausted after $relists missing just-listed snapshot(s)." +} + function Refresh-CapJournal { param([Parameter(Mandatory)][string]$Path) if (Get-Command Write-CapTrace -ErrorAction SilentlyContinue) { Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters } - if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return } + $snapshot = Read-CapJournalHighest -BasePath $Path -MinimumSequence $script:journalHighestSeen ` + -DeadlineUtc ([DateTime]::UtcNow.AddSeconds(10)) -Expected @{ + LeaseId = [string]$lease.leaseId; JobSha256 = $jobHash; ModuleSha256 = $moduleHash; WorkerSha256 = $workerHash + } + if ($null -eq $snapshot) { return } + if ($snapshot.Sequence -eq $script:journalHighestSeen -and $script:journalHighestSha256 -and + $snapshot.Sha256 -cne $script:journalHighestSha256) { + throw "Published worker process journal snapshot $($snapshot.Name) changed after it was first observed." + } + $script:journalHighestSeen = $snapshot.Sequence + $script:journalHighestSha256 = $snapshot.Sha256 + $script:journalRelistCount += $snapshot.Relists + foreach ($entry in $snapshot.Journal.entries) { $null = Add-CapTrackedIdentity -Entry $entry } +} + +function Assert-CapJournalTerminal { + param( + [Parameter(Mandatory)][string]$BasePath, + [AllowNull()]$Manifest, + [Parameter(Mandatory)][hashtable]$Expected, + [switch]$TerminalRequired, + [int]$MinimumSequence = 0, + [string]$ObservedSha256, + [DateTime]$DeadlineUtc = [DateTime]::UtcNow.AddSeconds(15), + [int]$MaximumAttempts = 8 + ) + if (Get-Command Write-CapTrace -ErrorAction SilentlyContinue) { Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters } + + $records = @() + $temporaries = @() + $complete = $false + for ($attempt = 1; $attempt -le $MaximumAttempts -and [DateTime]::UtcNow -lt $DeadlineUtc -and -not $complete; $attempt++) { + $listing = Get-CapJournalSnapshotList -BasePath $BasePath + $loaded = [Collections.Generic.List[object]]::new() + $complete = $true + foreach ($entry in @($listing.Snapshots)) { + try { $bytes = Read-CapJournalSnapshotBytes -Path $entry.Path } + catch { + if (-not (Test-CapFileNotFoundError $_)) { throw } + $complete = $false + break + } + $loaded.Add((Convert-CapJournalSnapshot -Bytes $bytes -Entry $entry -Expected $Expected)) + } + $records = @($loaded) + $temporaries = @($listing.Temporaries) + } + if (-not $complete) { throw 'Worker process journal re-list budget exhausted during terminal verification.' } + if ($records.Count -eq 0) { + if ($TerminalRequired -or $MinimumSequence -gt 0) { + throw 'Worker process journal has no published snapshot although the worker exited successfully or one was observed.' + } + return [pscustomobject]@{ Inventory = @(); Temporaries = $temporaries; TerminalPresent = $false + TerminalSequence = $null; TerminalSha256 = $null; TerminalBoundToManifest = $false; HighestSequence = 0 } + } + for ($i = 0; $i -lt $records.Count; $i++) { + $record = $records[$i] + if ($record.Sequence -ne ($i + 1)) { + throw "Worker process journal sequence gap: expected $($i + 1) but found $($record.Sequence)." + } + $previous = if ($i -eq 0) { $null } else { $records[$i - 1].Sha256 } + if ($record.PreviousSha256 -cne $previous) { + throw "Worker process journal hash chain is broken at sequence $($record.Sequence)." + } + if ($i -lt ($records.Count - 1) -and $record.Terminal) { + throw "Worker process journal has a published snapshot after its terminal snapshot (sequence $($record.Sequence))." + } + } + $last = $records[$records.Count - 1] + if ($last.Sequence -lt $MinimumSequence) { + throw "Worker process journal highest published sequence regressed or vanished: $($last.Sequence) is below the observed $MinimumSequence." + } + if ($MinimumSequence -gt 0 -and $ObservedSha256 -and $records[$MinimumSequence - 1].Sha256 -cne $ObservedSha256) { + throw "Published worker process journal snapshot $($records[$MinimumSequence - 1].Name) changed after it was first observed." + } + if ($TerminalRequired -and -not $last.Terminal) { + throw "Worker process journal terminal snapshot is missing: the highest published snapshot $($last.Sequence) is not terminal." + } + $bound = $false + if ($last.Terminal) { + $arrayNames = @('ownedProcessSurvivors', 'pendingLaunchesRemaining', 'cleanupFailures') + foreach ($name in $arrayNames) { + if (-not $last.Journal.PSObject.Properties[$name] -or $last.Journal.$name -isnot [array]) { + throw "Worker process journal terminal arrays are incomplete: '$name' is absent or not an array." + } + } + $binding = if ($null -ne $Manifest -and $Manifest.PSObject.Properties['processJournal']) { $Manifest.processJournal } else { $null } + $notBound = 'Worker process journal terminal snapshot is not bound to the final manifest' + if ($null -eq $binding) { + if ($TerminalRequired) { throw "${notBound}: the manifest carries no journal binding." } + } else { + $boundValues = $binding | Select-Object terminalSequence, terminalSha256, snapshotCount + $manifestValues = $Manifest | Select-Object leaseId, jobManifestSha256, moduleSha256, workerSha256 + $same = $boundValues.terminalSequence -eq $last.Sequence -and $boundValues.terminalSha256 -ceq $last.Sha256 -and + $boundValues.snapshotCount -eq $records.Count -and + $manifestValues.leaseId -eq $Expected.LeaseId -and $manifestValues.jobManifestSha256 -eq $Expected.JobSha256 -and + $manifestValues.moduleSha256 -eq $Expected.ModuleSha256 -and $manifestValues.workerSha256 -eq $Expected.WorkerSha256 + foreach ($name in $arrayNames) { + if (-not $Manifest.PSObject.Properties[$name] -or + (ConvertTo-Json -InputObject @($Manifest.$name) -Depth 40 -Compress) -cne + (ConvertTo-Json -InputObject @($last.Journal.$name) -Depth 40 -Compress)) { $same = $false } + } + if (-not $same) { throw "${notBound}: sequence, hash, count, identity or cleanup arrays differ." } + $bound = $true + } + } + return [pscustomobject]@{ + Inventory = @($records | ForEach-Object { + [pscustomobject]@{ Sequence = $_.Sequence; Name = $_.Name; Path = $_.Path; Sha256 = $_.Sha256 + Length = $_.Length; Terminal = $_.Terminal } + }) + Temporaries = $temporaries + TerminalPresent = [bool]$last.Terminal + TerminalSequence = if ($last.Terminal) { $last.Sequence } else { $null } + TerminalSha256 = if ($last.Terminal) { $last.Sha256 } else { $null } + TerminalBoundToManifest = $bound + HighestSequence = $last.Sequence + } +} + +function Get-CapJournalRawInventory { + param([Parameter(Mandatory)][string]$BasePath) + if (Get-Command Write-CapTrace -ErrorAction SilentlyContinue) { Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters } + + $items = [Collections.Generic.List[object]]::new() + $stem = [IO.Path]::GetFileNameWithoutExtension($BasePath) + foreach ($path in [IO.Directory]::EnumerateFiles([IO.Path]::GetDirectoryName($BasePath), "$stem.*")) { + $item = [ordered]@{ name = [IO.Path]::GetFileName($path); length = $null; sha256 = $null; readError = $null } + try { + $bytes = Read-CapJournalSnapshotBytes -Path $path + $item.length = $bytes.Length + $item.sha256 = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bytes)) + } catch { $item.readError = $_.Exception.Message } + $items.Add([pscustomobject]$item) + } + return @($items | Sort-Object name) +} + +function Write-CapJournalInventoryRecord { + param([Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)]$Value) + if (Get-Command Write-CapTrace -ErrorAction SilentlyContinue) { Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters } + + $bytes = [Text.UTF8Encoding]::new($false).GetBytes((ConvertTo-Json -InputObject $Value -Depth 40)) + $stream = [IO.File]::Open($Path, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) + try { $stream.Write($bytes, 0, $bytes.Length); $stream.Flush($true) } + finally { $stream.Dispose() } + return [ordered]@{ path = $Path; sha256 = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bytes)) } +} + +function Invoke-CapJournalRelease { + if (Get-Command Write-CapTrace -ErrorAction SilentlyContinue) { Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters } + + $terminalRequired = [bool]($workerWait -and $workerWait.State -eq 'Exited' -and $workerWait.ExitCode -eq 0) + $record = [ordered]@{ + state = 'UNPROVEN' + workerLaunchAttempted = $workerLaunchAttempted + positiveInventoryRequired = $terminalRequired + terminalRequired = $terminalRequired + basePath = $processJournalPath + observedHighestSequence = $script:journalHighestSeen + relistCount = $script:journalRelistCount + preCleanupInventory = @() + inventoryCount = 0 + retainedPendingTemporaries = @() + terminalVerified = $false + terminalBoundToManifest = $false + terminalSequence = $null + terminalSha256 = $null + verificationFailure = $null + inventoryRecord = $null + removedCount = 0 + removed = @() + remainingAfterCleanup = @() + removalFailures = @() + } + $script:journalRelease = $record + $expected = @{ LeaseId = [string]$lease.leaseId; JobSha256 = $jobHash; ModuleSha256 = $moduleHash; WorkerSha256 = $workerHash } + $proof = $null try { - $stream = Open-CapJournalRead -Path $Path + $manifest = $null + foreach ($name in @('manifest.json', 'manifest.partial.json')) { + $manifestPath = Join-Path $outputDirectory $name + if (Test-Path -LiteralPath $manifestPath -PathType Leaf) { + $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json -DateKind String + break + } + } + $proof = Assert-CapJournalTerminal -BasePath $processJournalPath -Manifest $manifest -Expected $expected ` + -TerminalRequired:$terminalRequired -MinimumSequence $script:journalHighestSeen ` + -ObservedSha256 $script:journalHighestSha256 + } catch { + $record.verificationFailure = Get-CapSupervisorFailureText $_ + $cleanupFailures.Add("Worker process journal verification failed: $($_.Exception.Message)") + } + $items = @() + if ($proof) { + $items = @($proof.Inventory) + $record.terminalVerified = [bool]$proof.TerminalPresent + $record.terminalBoundToManifest = [bool]$proof.TerminalBoundToManifest + $record.terminalSequence = $proof.TerminalSequence + $record.terminalSha256 = $proof.TerminalSha256 + $record.preCleanupInventory = @($items | ForEach-Object { + [ordered]@{ sequence = $_.Sequence; name = $_.Name; sha256 = $_.Sha256; length = $_.Length; terminal = $_.Terminal } + }) + $record.retainedPendingTemporaries = @($proof.Temporaries) + } else { + try { $record.preCleanupInventory = @(Get-CapJournalRawInventory -BasePath $processJournalPath) } + catch { $cleanupFailures.Add("Worker process journal forensic inventory failed: $($_.Exception.Message)") } + } + $record.inventoryCount = @($record.preCleanupInventory).Count + if ($proof -and $items.Count -eq 0) { + $record.state = 'NONE_PUBLISHED_WORKER_FAILED' + Write-CapSupervisorLog 'JOURNAL_RELEASE state=NONE_PUBLISHED_WORKER_FAILED inventory=0 (the worker did not succeed; no snapshot was published)' + return + } + $evidenceFailed = $false + try { + $record.inventoryRecord = Write-CapJournalInventoryRecord -Path (Join-Path $outputDirectory 'journal-snapshot-inventory.json') -Value ([ordered]@{ + schemaVersion = 1; kind = 'JOURNAL_SNAPSHOT_INVENTORY_BEFORE_REMOVAL' + leaseId = $lease.leaseId; jobSha256 = $jobHash; moduleSha256 = $moduleHash; workerSha256 = $workerHash + supervisorSha256 = $supervisorHash; runDirectory = $runDirectory; basePath = $processJournalPath + capturedUtc = [DateTime]::UtcNow.ToString('o'); terminalRequired = $terminalRequired + verificationPassed = $null -ne $proof; verificationFailure = $record.verificationFailure + terminalSequence = $record.terminalSequence; terminalSha256 = $record.terminalSha256 + terminalBoundToManifest = $record.terminalBoundToManifest + snapshots = @($record.preCleanupInventory); retainedPendingTemporaries = @($record.retainedPendingTemporaries) + removalIntended = $null -ne $proof + }) + } catch { + $evidenceFailed = $true + $cleanupFailures.Add("Worker process journal pre-removal evidence could not be preserved: $($_.Exception.Message)") + } + if (-not $proof) { + $record.state = 'RETAINED_VERIFICATION_FAILED' + Write-CapSupervisorLog "JOURNAL_RELEASE state=RETAINED_VERIFICATION_FAILED inventory=$($record.inventoryCount) removed=0" + return + } + if ($evidenceFailed) { + $record.state = 'REMOVAL_UNPROVEN' + return + } + $stem = [Regex]::Escape([IO.Path]::GetFileNameWithoutExtension($processJournalPath)) + $ownedDirectory = [IO.Path]::GetFullPath($runDirectory).TrimEnd('\') + $removed = [Collections.Generic.List[object]]::new() + $removalFailures = [Collections.Generic.List[string]]::new() + foreach ($item in $items) { try { - $reader = [IO.StreamReader]::new($stream, [Text.UTF8Encoding]::new($false, $true), $true) - try { $journal = $reader.ReadToEnd() | ConvertFrom-Json } - finally { $reader.Dispose() } - } finally { $stream.Dispose() } - } - 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.' + $null = Assert-CapSupervisorPath $item.Path + if ([IO.Path]::GetDirectoryName($item.Path) -ine $ownedDirectory -or + $item.Name -cnotmatch "^$stem\.[1-9][0-9]{0,8}\.json$") { + throw 'it is not an exact snapshot in the owned run directory' + } + if ((Get-FileHash -Algorithm SHA256 -LiteralPath $item.Path).Hash -cne $item.Sha256) { + throw 'its bytes changed after verification' + } + [IO.File]::Delete($item.Path) + if (Test-Path -LiteralPath $item.Path) { throw 'it still exists after removal' } + $removed.Add([ordered]@{ sequence = $item.Sequence; name = $item.Name; sha256 = $item.Sha256 + removedUtc = [DateTime]::UtcNow.ToString('o') }) + } catch { + $message = "Worker process journal snapshot $($item.Name) removal failed: $($_.Exception.Message)" + $removalFailures.Add($message) + $cleanupFailures.Add($message) + } } - foreach ($entry in $journal.entries) { $null = Add-CapTrackedIdentity -Entry $entry } + $remaining = @() + try { + $after = Get-CapJournalSnapshotList -BasePath $processJournalPath + $remaining = @($after.Snapshots | ForEach-Object Name) + $record.retainedPendingTemporaries = @($after.Temporaries) + } catch { + $message = "Worker process journal post-removal inventory failed: $($_.Exception.Message)" + $removalFailures.Add($message) + $cleanupFailures.Add($message) + } + $inventoryNames = @($items | ForEach-Object Name) + foreach ($name in @($remaining | Where-Object { $_ -cnotin $inventoryNames })) { + $message = "Worker process journal snapshot $name appeared after the verified inventory." + $removalFailures.Add($message) + $cleanupFailures.Add($message) + } + if ($terminalRequired -and $record.retainedPendingTemporaries.Count -gt 0) { + $message = "Worker process journal unpublished temporaries remain after a successful worker: $($record.retainedPendingTemporaries -join ', ')." + $removalFailures.Add($message) + $cleanupFailures.Add($message) + } + $record.removed = @($removed) + $record.removedCount = $removed.Count + $record.remainingAfterCleanup = $remaining + $record.removalFailures = @($removalFailures) + $record.state = if ($removalFailures.Count -eq 0 -and $remaining.Count -eq 0) { 'SNAPSHOTS_REMOVED_AND_VERIFIED' } else { 'REMOVAL_UNPROVEN' } + Write-CapSupervisorLog "JOURNAL_RELEASE state=$($record.state) inventory=$($record.inventoryCount) removed=$($record.removedCount) terminal=$($record.terminalSequence)" } function Get-CapAppSessionPrefix { @@ -861,7 +1268,8 @@ try { }, { Stop-CapWorkerIfNeeded }, { Write-CapProcessOutput -Run $workerRun -Prefix 'supervisor-worker' }, - { Assert-CapNoTrackedSurvivors } + { Assert-CapNoTrackedSurvivors }, + { Invoke-CapJournalRelease } )) { try { & $step } catch { @@ -870,6 +1278,10 @@ try { } } $cleanupProven = $cleanupFailures.Count -eq 0 + } elseif ($workerLaunchAttempted -and $processJournalPath) { + # No process handle was returned, so no worker can have been proven; still account for any snapshot. + try { Invoke-CapJournalRelease } + catch { $cleanupFailures.Add((Get-CapSupervisorFailureText $_)) } } foreach ($record in $tracked.Values) { try { $record.Identity.Process.Dispose() } @@ -939,6 +1351,16 @@ try { workerStdoutEof = if ($workerRun) { $workerRun.StdoutTask.IsCompleted } else { $false } workerStderrEof = if ($workerRun) { $workerRun.StderrTask.IsCompleted } else { $false } journalEntriesWithoutHeldKernelHandle = @($untrackedJournalEntries) + journal = if ($journalRelease) { $journalRelease } else { + [ordered]@{ + state = if ($workerLaunchAttempted) { 'UNPROVEN' } else { 'NO_WORKER_STARTED' } + workerLaunchAttempted = $workerLaunchAttempted + positiveInventoryRequired = $false; terminalRequired = $false + inventoryCount = 0; preCleanupInventory = @(); removedCount = 0; removed = @() + remainingAfterCleanup = @(); terminalVerified = $false; terminalBoundToManifest = $false + inventoryRecord = $null + } + } kernelVerifiedSurvivors = @($survivors) recovery = @($recoveryReceipts) cleanupFailures = @($cleanupFailures) diff --git a/Tools/windows/Invoke-UiCaptureWorker.ps1 b/Tools/windows/Invoke-UiCaptureWorker.ps1 index 666b9513..d60d5f45 100644 --- a/Tools/windows/Invoke-UiCaptureWorker.ps1 +++ b/Tools/windows/Invoke-UiCaptureWorker.ps1 @@ -29,6 +29,9 @@ $seedRuns = [Collections.Generic.List[object]]::new() $applicationRuns = [Collections.Generic.List[object]]::new() $descendantIdentities = [Collections.Generic.List[object]]::new() $journalEntries = [Collections.Generic.List[object]]::new() +$journalSequence = 0 +$journalPreviousSha256 = $null +$journalTerminal = $null $manifestPath = $null $logPath = $null $workerLog = $null @@ -85,6 +88,42 @@ function Assert-CapOwnedPath { return Assert-CapArtifactPath -Root $root -Path $Path } +function Publish-CapJournalSnapshot { + param( + [Parameter(Mandatory)][string]$BasePath, + [Parameter(Mandatory)][System.Collections.IDictionary]$Fields, + [switch]$Terminal + ) + Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters + + if ($script:journalTerminal) { throw 'The worker process journal is sealed by its terminal snapshot; refusing a newer version.' } + $directory = [IO.Path]::GetDirectoryName($BasePath) + $stem = [IO.Path]::GetFileNameWithoutExtension($BasePath) + $sequence = $script:journalSequence + 1 + $snapshot = [ordered]@{ + schemaVersion = 1 + sequence = $sequence + previousSha256 = $script:journalPreviousSha256 + terminal = [bool]$Terminal + } + foreach ($key in $Fields.Keys) { $snapshot[$key] = $Fields[$key] } + $bytes = [Text.UTF8Encoding]::new($false).GetBytes((ConvertTo-Json -InputObject $snapshot -Depth 40)) + $hash = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bytes)) + $temporary = Join-Path $directory ('{0}.pending-{1}.tmp' -f $stem, [guid]::NewGuid().ToString('N')) + $published = Join-Path $directory ('{0}.{1}.json' -f $stem, $sequence) + # The complete closed snapshot reaches disk under an unpublished name first; the non-overwriting + # move then makes it visible atomically, so no reader can see a missing or partial published file. + $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, $published) + $script:journalSequence = $sequence + $script:journalPreviousSha256 = $hash + $receipt = [pscustomobject]@{ Sequence = $sequence; Sha256 = $hash; Path = $published } + if ($Terminal) { $script:journalTerminal = $receipt } + return $receipt +} + function Write-CapProcessJournal { param( [Parameter(Mandatory)]$Identity, @@ -114,8 +153,7 @@ function Write-CapProcessJournal { } } if ($index -ge 0) { $journalEntries[$index] = $entry } else { $journalEntries.Add($entry) } - Write-CapJsonAtomic -Path $journalPath -Value ([ordered]@{ - schemaVersion = 1 + $null = Publish-CapJournalSnapshot -BasePath $journalPath -Fields ([ordered]@{ leaseId = $auth.leaseId jobSha256 = $jobHash workerSha256 = $workerHash @@ -125,6 +163,22 @@ function Write-CapProcessJournal { }) } +function Write-CapTerminalProcessJournal { + Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters + + $null = Publish-CapJournalSnapshot -BasePath $journalPath -Terminal -Fields ([ordered]@{ + leaseId = $auth.leaseId + jobSha256 = $jobHash + workerSha256 = $workerHash + moduleSha256 = $moduleHash + updatedUtc = [DateTime]::UtcNow.ToString('o') + entries = @($journalEntries) + ownedProcessSurvivors = @($ownedProcessSurvivors) + pendingLaunchesRemaining = @($pendingLaunchesRemaining) + cleanupFailures = @($cleanupFailures) + }) +} + function Assert-CapAcceptedInputs { Write-CapTrace -Command $MyInvocation.MyCommand.Name -Parameters $PSBoundParameters @@ -544,6 +598,13 @@ function New-CapManifest { pendingLaunchesRemaining = @($pendingLaunchesRemaining) failures = @($cleanupFailures) ownedProcessSurvivors = @($ownedProcessSurvivors) + processJournal = if ($journalTerminal) { + [ordered]@{ + terminalSequence = [long]$journalTerminal.Sequence + terminalSha256 = [string]$journalTerminal.Sha256 + snapshotCount = [long]$journalTerminal.Sequence + } + } else { $null } privacyRequirement = 'Every image requires an individual privacy check before reviewer handoff.' } } @@ -1153,16 +1214,8 @@ try { creationFileTimeUtc = $_.CreationFileTimeUtc; expectedImagePath = $_.ExpectedPath; state = 'KERNEL_LIVE_UNRETURNED_LAUNCH' } }) ) if ($journalPath -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) - }) - } catch { $cleanupFailures.Add("Final process journal update failed: $($_.Exception.Message)") } + try { Write-CapTerminalProcessJournal } + 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() } diff --git a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 index 1e053030..b8361de8 100644 --- a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 +++ b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 @@ -476,6 +476,8 @@ function Assert-CapRehearsalSuccess { $script:journalSelf = $null $script:journalHighestSeen = 0 +$script:journalHighestSha256 = $null +$script:journalRelistCount = 0 $journalSupervisorFunctions = @('Open-CapJournalRead', 'Test-CapFileNotFoundError', 'Get-CapJournalSnapshotList', 'Read-CapJournalSnapshotBytes', 'Convert-CapJournalSnapshot', 'Read-CapJournalHighest', 'Refresh-CapJournal', 'Assert-CapJournalTerminal', 'Add-CapTrackedIdentity') @@ -506,6 +508,8 @@ function Set-CapJournalReaderContext { Set-Variable -Scope 1 -Name tracked -Value ([Collections.Generic.Dictionary[string, object]]::new([StringComparer]::OrdinalIgnoreCase)) Set-Variable -Scope 1 -Name untrackedJournalEntries -Value ([Collections.Generic.List[object]]::new()) $script:journalHighestSeen = 0 + $script:journalHighestSha256 = $null + $script:journalRelistCount = 0 } function New-CapJournalFixtureDirectory { @@ -836,43 +840,44 @@ try { 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)) - } + # Historical case name kept: the worker no longer replaces a mutable path (File.Replace left a + # window where the name was missing). It now publishes immutable numbered snapshots, so a held + # reader keeps its exact snapshot and a fresh read sees the newer published one. + . ([scriptblock]::Create((Get-CapActualFunctionText $supervisorPath @('Open-CapJournalRead')))) + . ([scriptblock]::Create((Get-CapActualFunctionText $workerPath @('Publish-CapJournalSnapshot')))) + $script:journalSequence = 0; $script:journalPreviousSha256 = $null; $script:journalTerminal = $null $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 + $base = Join-Path $directory 'process-journal.json' 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 + $first = Publish-CapJournalSnapshot -BasePath $base -Fields ([ordered]@{ version = 1; componentOnly = $true; cleanupComplete = $false }) + $stream = Open-CapJournalRead -Path $first.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.' + $second = Publish-CapJournalSnapshot -BasePath $base -Fields ([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 $second.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) ` + 'Publication lost the held snapshot or a fresh read mistook it for completed cleanup.' + Assert-CapTest ($first.Path -cne $second.Path -and $snapshot.sequence -eq 1 -and $current.sequence -eq 2 -and + $current.previousSha256 -ceq $first.Sha256 -and + (Get-FileHash -LiteralPath $first.Path -Algorithm SHA256).Hash -ceq $first.Sha256 -and + @(Get-ChildItem -LiteralPath $directory -File).Count -eq 2) ` + 'The first published snapshot was replaced instead of staying immutable.' + } finally { $stream.Dispose() } + } finally { $script:journalSequence = 0; $script:journalPreviousSha256 = $null; $script:journalTerminal = $null } + return 'Actual supervisor reader open and actual worker publisher; held file I/O only, no process, lease or GUI mock.' } Invoke-CapTest 'actual rehearsal data satisfies the repaired report gate and refuses stale proof' { $source = [IO.File]::ReadAllText($supervisorPath) @@ -1001,7 +1006,7 @@ try { $script:openHook = { param($Path) if ($script:openCalls.Count -eq 1) { Remove-Item -LiteralPath $Path -Force - $null = Write-CapJournalFixtureSnapshot -Directory $directory -Sequence 2 -Phase 'two' + $null = Write-CapJournalFixtureSnapshot -Directory $directory -Sequence 2 -Phase 'two' -PreviousSha256 ('A' * 64) } } try { @@ -1052,7 +1057,7 @@ try { $script:openHook = { param($Path) $next = [int](([IO.Path]::GetFileName($Path) -split '\.')[1]) + 1 Remove-Item -LiteralPath $Path -Force - $null = Write-CapJournalFixtureSnapshot -Directory $directory -Sequence $next -Phase "again-$next" + $null = Write-CapJournalFixtureSnapshot -Directory $directory -Sequence $next -Phase "again-$next" -PreviousSha256 ('A' * 64) } try { $null = Assert-CapRejects { Refresh-CapJournal -Path $base } 'budget exhausted' @@ -1133,6 +1138,9 @@ Write-Output "WRITER_DONE publications=$count" $heldReader = [IO.StreamReader]::new($held) try { $heldBytes = $heldReader.ReadToEnd() } finally { $heldReader.Dispose() } Assert-CapTest ($run.StdoutTask.Wait(5000) -and $run.StderrTask.Wait(5000)) 'Writer EOF was incomplete.' + $writerExit = $run.Identity.Process.ExitCode + $writerStdout = [string]$run.StdoutTask.Result + $writerStderr = [string]$run.StderrTask.Result } finally { $null = $events.cont.Set() if (-not $run.Identity.Process.HasExited) { $null = Stop-CapManagedProcess -Identity $run.Identity -TimeoutMilliseconds 5000 } @@ -1142,8 +1150,8 @@ Write-Output "WRITER_DONE publications=$count" $run.Identity.Process.Dispose() } Assert-CapTest ($errors.Count -eq 0) "The supervisor reader failed $($errors.Count) of $reads reads during concurrent publication; first: $($errors | Select-Object -First 2)" - Assert-CapTest ($run.Identity.Process.ExitCode -eq 0 -and ([string]$run.StdoutTask.Result).Contains('WRITER_DONE')) ` - "The real worker publisher failed: $($run.StderrTask.Result)" + Assert-CapTest ($writerExit -eq 0 -and $writerStdout.Contains('WRITER_DONE')) ` + "The real worker publisher failed: exit=$writerExit stderr=$writerStderr" Assert-CapTest ($reads -gt 0 -and $midRead) 'No positive concurrent read count or mid-stream checkpoint was observed.' $snapshots = @(Get-ChildItem -LiteralPath $directory -Filter 'process-journal*.json' -File) Assert-CapTest ($snapshots.Count -eq $publications) "Expected $publications immutable snapshots, found $($snapshots.Count)." @@ -1233,9 +1241,47 @@ Write-Output "WRITER_DONE publications=$count" $null = Assert-CapRejects { Assert-CapJournalTerminal -BasePath $base -Manifest $manifest -Expected $expected -TerminalRequired } 'not bound to the final manifest' } catch { throw "Manifest binding negative '$name': $($_.Exception.Message)" } } + $null = Assert-CapRejects { + Assert-CapJournalTerminal -BasePath $base -Manifest (& $newManifest $records) -Expected $expected -TerminalRequired ` + -MinimumSequence 3 -ObservedSha256 ('0' * 64) + } 'changed after it was first observed' + $null = Assert-CapRejects { + Assert-CapJournalTerminal -BasePath $base -Manifest (& $newManifest $records) -Expected $expected -TerminalRequired -MinimumSequence 5 + } 'regressed or vanished' return [ordered]@{ negatives = @($negatives.Keys); bindingNegatives = @($bindingMutations.Keys) data = 'Actual terminal verifier over real chained files; no worker/process mock.' } } + Invoke-CapTest 'actual worker publisher never overwrites a published name and seals after its terminal snapshot' { + . ([scriptblock]::Create((Get-CapActualFunctionText $workerPath @('Publish-CapJournalSnapshot')))) + . ([scriptblock]::Create((Get-CapActualFunctionText $supervisorPath @('Get-CapJournalSnapshotList')))) + $script:journalSequence = 0; $script:journalPreviousSha256 = $null; $script:journalTerminal = $null + $directory = New-CapJournalFixtureDirectory 'publisher-collision' + $base = Join-Path $directory 'process-journal.json' + $foreignPath = Join-Path $directory 'process-journal.1.json' + [IO.File]::WriteAllText($foreignPath, 'FOREIGN') + try { + $thrown = $null + try { $null = Publish-CapJournalSnapshot -BasePath $base -Fields ([ordered]@{ marker = 'first' }) } + catch { $thrown = Get-CapUnwrappedException $_ } + Assert-CapTest ($thrown -is [IO.IOException] -and [IO.File]::ReadAllText($foreignPath) -ceq 'FOREIGN' -and + $script:journalSequence -eq 0 -and $null -eq $script:journalPreviousSha256) ` + 'A colliding published name was overwritten, or the sequence advanced past a failed publication.' + $leftover = @(Get-ChildItem -LiteralPath $directory -Filter 'process-journal.pending-*.tmp' -File) + Assert-CapTest ($leftover.Count -eq 1 -and ([IO.File]::ReadAllText($leftover[0].FullName) | ConvertFrom-Json).marker -ceq 'first') ` + 'The unpublished complete snapshot was not retained for forensics.' + $listing = Get-CapJournalSnapshotList -BasePath $base + Assert-CapTest (@($listing.Snapshots).Count -eq 1 -and @($listing.Temporaries).Count -eq 1) 'The unpublished temporary was treated as a published snapshot.' + Remove-Item -LiteralPath $foreignPath, $leftover[0].FullName -Force + $one = Publish-CapJournalSnapshot -BasePath $base -Fields ([ordered]@{ marker = 'one' }) + $final = Publish-CapJournalSnapshot -BasePath $base -Terminal -Fields ([ordered]@{ marker = 'terminal' }) + Assert-CapTest ($one.Sequence -eq 1 -and $final.Sequence -eq 2 -and + (([IO.File]::ReadAllText($final.Path) | ConvertFrom-Json).previousSha256 -ceq $one.Sha256) -and + ([IO.File]::ReadAllText($final.Path) | ConvertFrom-Json).terminal) 'Sequence, hash chain or terminal flag was not published.' + $null = Assert-CapRejects { Publish-CapJournalSnapshot -BasePath $base -Fields ([ordered]@{ marker = 'late' }) } 'sealed by its terminal snapshot' + Assert-CapTest (@(Get-ChildItem -LiteralPath $directory -File).Count -eq 2) 'A version was published after the terminal snapshot.' + } finally { $script:journalSequence = 0; $script:journalPreviousSha256 = $null; $script:journalTerminal = $null } + return 'Actual worker publisher over real files; collision, forensic temporary and post-terminal sealing.' + } Invoke-CapTest 'real capture command publishes immutable journal snapshots and proves their removal at release' { $run = Invoke-CapCaptureCommand -Name 'journal-release' $null = Assert-CapRehearsalSuccess -Run $run -Arguments @()