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 344f2e76..b8361de8 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,113 @@ function Assert-CapRehearsalSuccess { targetArguments = @($Arguments); actualTargetLaunches = 1; images = 0; desktop = 'NotExecuted' } } +$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') + +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 + $script:journalHighestSha256 = $null + $script:journalRelistCount = 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 @@ -668,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) @@ -762,6 +935,427 @@ 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' -PreviousSha256 ('A' * 64) + } + } + 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" -PreviousSha256 ('A' * 64) + } + 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.' + $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 } + 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 ($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)." + 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)" } + } + $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 @() + $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 {