From 2be43cc6b717c6c1cc9ae2a4156792efd5069898 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:20:50 +0000 Subject: [PATCH 1/2] fix(ai): Resolve issue #2052 - Make Windows ownership capture authority durable b Implemented by ProPR AI using gpt-5.6-sol model. Implementation completed successfully. --- .../scripts/cleanup-installed-windows-app.ps1 | 108 ++++-- .../run-installed-windows-app-harness.ps1 | 56 +++- ...stalled-windows-app-supervisor-fixture.ps1 | 118 ++++++- .../test-installed-windows-app-supervisor.ps1 | 107 +++++- .../scripts/test-installed-windows-app.ps1 | 316 +++++++++++++++++- apps/desktop/src/release-workflow.test.ts | 19 +- 6 files changed, 635 insertions(+), 89 deletions(-) diff --git a/apps/desktop/scripts/cleanup-installed-windows-app.ps1 b/apps/desktop/scripts/cleanup-installed-windows-app.ps1 index 414edbefa..9f4bbb69f 100644 --- a/apps/desktop/scripts/cleanup-installed-windows-app.ps1 +++ b/apps/desktop/scripts/cleanup-installed-windows-app.ps1 @@ -18,7 +18,7 @@ $authorizedRunId = $null $cleanupValidationPhase = 'HANDSHAKE' $cleanupValidationPhases = @( 'HANDSHAKE','FILE_AUTHORITY','UTF8_DECODE','JSON_PARSE','EXACT_KEY_SET', - 'BOOLEAN_TYPES','TRANSACTION_ENUM','SCHEMA_TYPE_STATE','RUN_ID_FORMAT', + 'GENERATION','BOOLEAN_TYPES','TRANSACTION_ENUM','SCHEMA_TYPE_STATE','RUN_ID_FORMAT', 'INSTALLER_ENTRY_ID_FORMAT','INSTALLER_SHA256_FORMAT','INSTALLER_PRODUCT_CODE_FORMAT', 'LIFETIME','RUN_ID','INSTALLER_PATH','FIXTURE_SCOPE','INITIAL_ACTIVE_MATCH', 'INITIAL_INSTALLER_AUTHORITY_RECHECK','EMPTY_RECEIPT_WRITE' @@ -1049,37 +1049,67 @@ function Restore-OwnedRegistryValue($Record) { function Write-DurableOwnershipManifest([string]$Path, $Manifest) { $temporaryPath = "$Path.new" - $replacementCompleted = $false + $previousGeneration = [int64]$Manifest.Generation + if ($previousGeneration -lt 0 -or $previousGeneration -eq [int64]::MaxValue) { + throw 'ownership receipt generation cannot advance' + } + $Manifest.Generation = $previousGeneration + 1 try { - $bytes = [Text.Encoding]::UTF8.GetBytes(( - $Manifest | ConvertTo-Json -Depth 6 -Compress - )) - $stream = [IO.FileStream]::new( - $temporaryPath, - [IO.FileMode]::Create, - [IO.FileAccess]::Write, - [IO.FileShare]::None, - 4096, - [IO.FileOptions]::WriteThrough - ) - try { - $stream.Write($bytes, 0, $bytes.Length) - $stream.Flush($true) - } finally { - $stream.Dispose() - } - if ($PSVersionTable.PSEdition -ceq 'Core') { - # Native pwsh provides the atomic same-directory overwrite overload. - [IO.File]::Move($temporaryPath, $Path, $true) + $json = $Manifest | ConvertTo-Json -Depth 6 -Compress + $roundTrip = ConvertFrom-Json -InputObject $json -ErrorAction Stop + if (($roundTrip.Generation -isnot [long] -and + $roundTrip.Generation -isnot [int]) -or + [int64]$roundTrip.Generation -ne [int64]$Manifest.Generation -or + [string]$roundTrip.AuthorityState -cnotin @('PROVISIONAL','NONPROVISIONAL')) { + throw 'ownership receipt generation round trip failed' + } + $bytes = [Text.Encoding]::UTF8.GetBytes($json) + if (Test-Path -LiteralPath $temporaryPath) { + # A prior cleanup may have died after FlushFileBuffers but before rename. + # Resume only the byte-identical next-generation candidate; a partial, + # malformed, foreign, or stale collision remains retained and fail closed. + $candidate = Get-Item -LiteralPath $temporaryPath -Force -ErrorAction Stop + if ($candidate.PSIsContainer -or + ($candidate.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + $candidate.Length -ne $bytes.Length -or + [IO.File]::ReadAllText($temporaryPath, [Text.Encoding]::UTF8) -cne $json) { + throw 'ownership receipt replacement collision is invalid' + } } else { - # .NET Framework File.Replace is unsuitable for the real PS5.1 reader - # flow. Use one same-directory Windows rename with no cross-volume-copy - # flag, replacing the existing pathname and waiting for durable completion. - [ProPRAtomicFile]::ReplaceSameDirectory($temporaryPath, $Path) + $stream = [IO.FileStream]::new( + $temporaryPath, + [IO.FileMode]::CreateNew, + [IO.FileAccess]::Write, + [IO.FileShare]::None, + 4096, + [IO.FileOptions]::WriteThrough + ) + try { + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush($true) + } finally { + $stream.Dispose() + } } - $replacementCompleted = $true - } finally { - if (!$replacementCompleted) { [IO.File]::Delete($temporaryPath) } + $currentJson = [IO.File]::ReadAllText($Path, [Text.Encoding]::UTF8) + $current = ConvertFrom-Json -InputObject $currentJson -ErrorAction Stop + if (($current.Generation -isnot [long] -and $current.Generation -isnot [int]) -or + [int64]$current.Generation -ne $previousGeneration) { + throw 'ownership receipt generation is stale' + } + # MoveFileEx with REPLACE_EXISTING and WRITE_THROUGH gives both PowerShell + # hosts the same atomic, same-directory durable publication boundary. + [ProPRAtomicFile]::ReplaceSameDirectory($temporaryPath, $Path) + $publishedJson = [IO.File]::ReadAllText($Path, [Text.Encoding]::UTF8) + $published = ConvertFrom-Json -InputObject $publishedJson -ErrorAction Stop + if ($publishedJson -cne $json -or + ($published.Generation -isnot [long] -and $published.Generation -isnot [int]) -or + [int64]$published.Generation -ne [int64]$Manifest.Generation) { + throw 'ownership receipt durable publication re-read failed' + } + } catch { + $Manifest.Generation = $previousGeneration + throw } } @@ -1088,6 +1118,7 @@ function Write-EmptyOwnershipReceipt([string]$Path, $Manifest) { # fails, the caller and canonical pathname both retain ACTIVE authority. $emptyReceipt = $Manifest.PSObject.Copy() $emptyReceipt.State = 'EMPTY' + $emptyReceipt.AuthorityState = 'NONPROVISIONAL' $emptyReceipt.BaselineClean = $false $emptyReceipt.InstallAttempted = $false $emptyReceipt.MsiTransactionState = 'NONE' @@ -1343,7 +1374,8 @@ try { $cleanupValidationPhase = 'EXACT_KEY_SET' $manifestKeys = @($manifest.PSObject.Properties | ForEach-Object { $_.Name }) $expectedManifestKeys = @( - 'SchemaVersion','ManifestType','State','RunId','CreatedUtcTicks','ExpiresUtcTicks', + 'SchemaVersion','ManifestType','State','Generation','AuthorityState', + 'RunId','CreatedUtcTicks','ExpiresUtcTicks', 'InstallerPath','InstallerEntryIdentity','InstallerSha256','InstallerProductCode','Fixture', 'FixtureRoot','BaselineClean','InstallAttempted','MsiTransactionState', 'Directories','Files','RegistryKeys', @@ -1356,6 +1388,14 @@ try { throw 'ownership manifest key set is invalid' } + $cleanupValidationPhase = 'GENERATION' + if (($manifest.Generation -isnot [long] -and $manifest.Generation -isnot [int]) -or + $manifest.Generation -lt 0 -or + [string]$manifest.AuthorityState -cnotin @('PROVISIONAL','NONPROVISIONAL')) { + throw 'ownership manifest generation or authority state is invalid' + } + $manifest.Generation = [int64]$manifest.Generation + $cleanupValidationPhase = 'BOOLEAN_TYPES' # Windows PowerShell 5.1 can retain an incidental PSObject wrapper around a # JSON primitive. Inspect the explicit base object while still rejecting @@ -1375,6 +1415,14 @@ try { )) { throw 'ownership manifest transaction enum is invalid' } + if (([string]$manifest.MsiTransactionState -in @('COMMITTED','ROLLED_BACK_CLEAN') -and + [string]$manifest.AuthorityState -cne 'NONPROVISIONAL') -or + ([string]$manifest.MsiTransactionState -ceq 'PENDING' -and + [string]$manifest.AuthorityState -cne 'PROVISIONAL') -or + ([string]$manifest.State -ceq 'EMPTY' -and + [string]$manifest.AuthorityState -cne 'NONPROVISIONAL')) { + throw 'ownership manifest publication state is inconsistent' + } $cleanupValidationPhase = 'SCHEMA_TYPE_STATE' if ( diff --git a/apps/desktop/scripts/run-installed-windows-app-harness.ps1 b/apps/desktop/scripts/run-installed-windows-app-harness.ps1 index 5d623555e..40747edbe 100644 --- a/apps/desktop/scripts/run-installed-windows-app-harness.ps1 +++ b/apps/desktop/scripts/run-installed-windows-app-harness.ps1 @@ -24,6 +24,7 @@ $watchdogSubstages = @( 'PATHS', 'BASELINE', 'MSI_INSTALL', + 'AUTHORITY_PUBLICATION', 'OWNERSHIP_CAPTURE', 'INSTALL_TREE_SCAN', 'APPLICATION_IMAGE', @@ -259,6 +260,19 @@ public static class ProPRInstallerEntryIdentity private static extern bool GetFileInformationByHandle( SafeFileHandle handle, out BY_HANDLE_FILE_INFORMATION information); + public static string ReadHandle(SafeFileHandle handle) + { + if (handle == null || handle.IsInvalid) + throw new InvalidOperationException("installer identity handle is invalid"); + BY_HANDLE_FILE_INFORMATION information; + if (!GetFileInformationByHandle(handle, out information)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "installer identity read failed"); + if ((information.FileAttributes & (0x10 | 0x400)) != 0) + throw new InvalidOperationException("installer entry is not an ordinary file"); + return string.Format("{0:x8}{1:x8}{2:x8}", information.VolumeSerialNumber, + information.FileIndexHigh, information.FileIndexLow); + } + public static string Read(string path) { using (SafeFileHandle handle = CreateFile( @@ -266,13 +280,7 @@ public static class ProPRInstallerEntryIdentity { if (handle == null || handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error(), "installer identity open failed"); - BY_HANDLE_FILE_INFORMATION information; - if (!GetFileInformationByHandle(handle, out information)) - throw new Win32Exception(Marshal.GetLastWin32Error(), "installer identity read failed"); - if ((information.FileAttributes & (0x10 | 0x400)) != 0) - throw new InvalidOperationException("installer entry is not an ordinary file"); - return string.Format("{0:x8}{1:x8}{2:x8}", information.VolumeSerialNumber, - information.FileIndexHigh, information.FileIndexLow); + return ReadHandle(handle); } } } @@ -702,6 +710,8 @@ function Write-InitialOwnershipManifest( SchemaVersion = 3 ManifestType = 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP' State = 'ACTIVE' + Generation = [int64]0 + AuthorityState = 'PROVISIONAL' RunId = $identifiers.RunId CreatedUtcTicks = $createdUtcTicks ExpiresUtcTicks = $createdUtcTicks + ([TimeSpan]::TicksPerHour * 3) @@ -724,6 +734,9 @@ function Write-InitialOwnershipManifest( $manifestJson = $manifest | ConvertTo-Json -Depth 6 -Compress $roundTrip = ConvertFrom-Json -InputObject $manifestJson -ErrorAction Stop if ([string]$roundTrip.RunId -cne $identifiers.RunId -or + ($roundTrip.Generation -isnot [long] -and $roundTrip.Generation -isnot [int]) -or + $roundTrip.Generation -ne 0 -or + [string]$roundTrip.AuthorityState -cne 'PROVISIONAL' -or [string]$roundTrip.InstallerEntryIdentity -cne $identifiers.InstallerEntryIdentity -or [string]$roundTrip.InstallerSha256 -cne $identifiers.InstallerSha256 -or @@ -750,7 +763,9 @@ function Write-InitialOwnershipManifest( function Test-MsiCriticalMarker($Marker) { return $null -ne $Marker -and [string]$Marker.Stage -ceq 'INSTALL' -and - [string]$Marker.Substage -in @('MSI_INSTALL','OWNERSHIP_CAPTURE') -and + [string]$Marker.Substage -in @( + 'MSI_INSTALL','AUTHORITY_PUBLICATION','OWNERSHIP_CAPTURE' + ) -and !([string]$Marker.Substage -ceq 'OWNERSHIP_CAPTURE' -and [string]$Marker.Status -ceq 'COMPLETE') } @@ -770,6 +785,8 @@ function Get-DurableMsiTransactionReceipt { [IO.FileOptions]::SequentialScan ) try { + $manifestEntryIdentity = [ProPRInstallerEntryIdentity]::ReadHandle( + $stream.SafeFileHandle) $offset = 0 while ($offset -lt $bytes.Length) { $read = $stream.Read($bytes, $offset, $bytes.Length - $offset) @@ -777,6 +794,12 @@ function Get-DurableMsiTransactionReceipt { $offset += $read } if ($stream.ReadByte() -ne -1) { return 'UNAVAILABLE' } + $currentItem = Get-Item -LiteralPath $ownershipManifestPath -Force -ErrorAction Stop + if ($currentItem.Length -ne $bytes.Length -or + [ProPRInstallerEntryIdentity]::Read($ownershipManifestPath) -cne + $manifestEntryIdentity) { + return 'UNAVAILABLE' + } } finally { $stream.Dispose() } @@ -785,7 +808,8 @@ function Get-DurableMsiTransactionReceipt { -ErrorAction Stop $manifestKeys = @($manifest.PSObject.Properties | ForEach-Object { $_.Name }) $expectedManifestKeys = @( - 'SchemaVersion','ManifestType','State','RunId','CreatedUtcTicks','ExpiresUtcTicks', + 'SchemaVersion','ManifestType','State','Generation','AuthorityState', + 'RunId','CreatedUtcTicks','ExpiresUtcTicks', 'InstallerPath','InstallerEntryIdentity','InstallerSha256','InstallerProductCode', 'Fixture','FixtureRoot','BaselineClean','InstallAttempted','MsiTransactionState', 'Directories','Files','RegistryKeys','RegistryValues','Users','Profiles' @@ -795,13 +819,20 @@ function Get-DurableMsiTransactionReceipt { $manifestKeys -cnotcontains $_ }).Count -ne 0 -or $manifest.SchemaVersion -ne 3 -or + ($manifest.Generation -isnot [long] -and $manifest.Generation -isnot [int]) -or + $manifest.Generation -lt 0 -or + [string]$manifest.AuthorityState -cnotin @('PROVISIONAL','NONPROVISIONAL') -or [string]$manifest.RunId -cne $ownershipRunId -or !(Test-InstallerArtifactAuthority $manifest) -or [string]$manifest.State -notin @('ACTIVE','EMPTY')) { return 'UNAVAILABLE' } if ([string]$manifest.State -ceq 'EMPTY' -and [string]$manifest.MsiTransactionState -ceq 'NONE' -and - !$manifest.InstallAttempted) { return 'ROLLED_BACK_CLEAN' } + !$manifest.InstallAttempted -and + [string]$manifest.AuthorityState -ceq 'NONPROVISIONAL') { + return 'ROLLED_BACK_CLEAN' + } if ([string]$manifest.MsiTransactionState -ceq 'ROLLED_BACK_CLEAN' -and + [string]$manifest.AuthorityState -ceq 'NONPROVISIONAL' -and @($manifest.Directories).Count -eq 0 -and @($manifest.Files).Count -eq 0 -and @($manifest.RegistryKeys).Count -eq 0 -and (($manifest.Fixture -and @($manifest.RegistryValues).Count -eq 0) -or @@ -809,7 +840,10 @@ function Get-DurableMsiTransactionReceipt { !$manifest.RegistryValues[0].Owned))) { return 'ROLLED_BACK_CLEAN' } - if ([string]$manifest.MsiTransactionState -cne 'COMMITTED') { return 'UNAVAILABLE' } + if ([string]$manifest.MsiTransactionState -cne 'COMMITTED' -or + [string]$manifest.AuthorityState -cne 'NONPROVISIONAL') { + return 'UNAVAILABLE' + } $ownedDirectories = @($manifest.Directories | Where-Object { $_.Owned -and [string]$_.Kind -in @('INSTALL_ROOT','SHORTCUT_FOLDER') -and !$_.Provisional -and diff --git a/apps/desktop/scripts/test-installed-windows-app-supervisor-fixture.ps1 b/apps/desktop/scripts/test-installed-windows-app-supervisor-fixture.ps1 index ee1de9bb9..2005fc1b6 100644 --- a/apps/desktop/scripts/test-installed-windows-app-supervisor-fixture.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app-supervisor-fixture.ps1 @@ -59,6 +59,32 @@ public static class ProPRFixtureDirectoryIdentity } public static string Read(string path) { return ReadEntry(path, true); } } + +public static class ProPRFixtureAtomicFile +{ + private const uint MOVEFILE_REPLACE_EXISTING = 0x1; + private const uint MOVEFILE_WRITE_THROUGH = 0x8; + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true, + EntryPoint = "MoveFileExW")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool MoveFileExW( + string existingFileName, string newFileName, uint flags); + public static void ReplaceSameDirectory(string temporaryPath, string destinationPath) + { + string temporaryFullPath = System.IO.Path.GetFullPath(temporaryPath); + string destinationFullPath = System.IO.Path.GetFullPath(destinationPath); + if (!String.Equals(System.IO.Path.GetDirectoryName(temporaryFullPath), + System.IO.Path.GetDirectoryName(destinationFullPath), + StringComparison.OrdinalIgnoreCase) || + !System.IO.File.Exists(temporaryFullPath) || + !System.IO.File.Exists(destinationFullPath)) + throw new InvalidOperationException("fixture ownership publication precondition failed"); + if (!MoveFileExW(temporaryFullPath, destinationFullPath, + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) + throw new Win32Exception(Marshal.GetLastWin32Error(), + "fixture ownership publication replacement failed"); + } +} '@ } $scenario = $env:PROPR_SUPERVISOR_FIXTURE_SCENARIO @@ -118,23 +144,66 @@ function Write-FixtureMarker([string]$Record) { } function Write-FixtureOwnershipManifest($Manifest) { + Initialize-FixtureDirectoryIdentity $temporaryManifest = "$OwnershipManifest.new" - $bytes = [Text.Encoding]::UTF8.GetBytes(($Manifest | ConvertTo-Json -Depth 6 -Compress)) - $stream = [IO.FileStream]::new( - $temporaryManifest, - [IO.FileMode]::Create, - [IO.FileAccess]::Write, - [IO.FileShare]::None, - 4096, - [IO.FileOptions]::WriteThrough + $expectedKeys = @( + 'SchemaVersion','ManifestType','State','Generation','AuthorityState', + 'RunId','CreatedUtcTicks','ExpiresUtcTicks','InstallerPath', + 'InstallerEntryIdentity','InstallerSha256','InstallerProductCode','Fixture', + 'FixtureRoot','BaselineClean','InstallAttempted','MsiTransactionState', + 'Directories','Files','RegistryKeys','RegistryValues','Users','Profiles' ) + $keys = @($Manifest.PSObject.Properties | ForEach-Object { $_.Name }) + if ($keys.Count -ne $expectedKeys.Count -or + @($expectedKeys | Where-Object { $keys -cnotcontains $_ }).Count -ne 0 -or + ($Manifest.Generation -isnot [long] -and $Manifest.Generation -isnot [int]) -or + $Manifest.Generation -lt 0 -or + [string]$Manifest.AuthorityState -cnotin @('PROVISIONAL','NONPROVISIONAL')) { + throw 'fixture ownership publication envelope is invalid' + } + $previousGeneration = [int64]$Manifest.Generation + if ($previousGeneration -eq [int64]::MaxValue) { + throw 'fixture ownership publication generation is exhausted' + } + $Manifest.Generation = $previousGeneration + 1 try { - $stream.Write($bytes, 0, $bytes.Length) - $stream.Flush($true) - } finally { - $stream.Dispose() + $json = $Manifest | ConvertTo-Json -Depth 6 -Compress + $roundTrip = ConvertFrom-Json -InputObject $json -ErrorAction Stop + if (($roundTrip.Generation -isnot [long] -and + $roundTrip.Generation -isnot [int]) -or + [int64]$roundTrip.Generation -ne [int64]$Manifest.Generation -or + [string]$roundTrip.AuthorityState -cne [string]$Manifest.AuthorityState) { + throw 'fixture ownership publication round trip failed' + } + $bytes = [Text.Encoding]::UTF8.GetBytes($json) + $stream = [IO.FileStream]::new( + $temporaryManifest, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, + [IO.FileShare]::None, 4096, [IO.FileOptions]::WriteThrough) + try { + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush($true) + } finally { + $stream.Dispose() + } + $current = [IO.File]::ReadAllText($OwnershipManifest, [Text.Encoding]::UTF8) | + ConvertFrom-Json -ErrorAction Stop + if (($current.Generation -isnot [long] -and $current.Generation -isnot [int]) -or + [int64]$current.Generation -ne $previousGeneration) { + throw 'fixture ownership publication generation is stale' + } + [ProPRFixtureAtomicFile]::ReplaceSameDirectory( + $temporaryManifest, $OwnershipManifest) + $publishedJson = [IO.File]::ReadAllText($OwnershipManifest, [Text.Encoding]::UTF8) + $published = ConvertFrom-Json -InputObject $publishedJson -ErrorAction Stop + if ($publishedJson -cne $json -or + ($published.Generation -isnot [long] -and $published.Generation -isnot [int]) -or + [int64]$published.Generation -ne [int64]$Manifest.Generation) { + throw 'fixture ownership durable publication re-read failed' + } + } catch { + $Manifest.Generation = $previousGeneration + throw } - [IO.File]::Move($temporaryManifest, $OwnershipManifest, $true) } function Write-FixtureCriticalGate([string]$Name) { @@ -256,6 +325,9 @@ function New-OwnedFixtureResources( $manifest = [IO.File]::ReadAllText($OwnershipManifest, [Text.Encoding]::UTF8) | ConvertFrom-Json -ErrorAction Stop if (!$manifest.Fixture -or $manifest.SchemaVersion -ne 3 -or + ($manifest.Generation -isnot [long] -and $manifest.Generation -isnot [int]) -or + $manifest.Generation -lt 0 -or + [string]$manifest.AuthorityState -cnotin @('PROVISIONAL','NONPROVISIONAL') -or [string]$manifest.InstallerEntryIdentity -notmatch '^[a-f0-9]{24}$' -or [string]$manifest.InstallerSha256 -notmatch '^[a-f0-9]{64}$' -or [string]$manifest.InstallerProductCode -notmatch @@ -399,7 +471,10 @@ function New-OwnedFixtureResources( } $manifest.Profiles = @() $manifest.InstallAttempted = $true - if ($PublishCommittedReceipt) { $manifest.MsiTransactionState = 'COMMITTED' } + if ($PublishCommittedReceipt) { + $manifest.MsiTransactionState = 'COMMITTED' + $manifest.AuthorityState = 'NONPROVISIONAL' + } if ($env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_PROFILE_SID) { $manifest.Profiles += [ordered]@{ Sid = $env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_PROFILE_SID @@ -516,6 +591,9 @@ function New-SmokeCheckpointFixtureResources( $manifest = [IO.File]::ReadAllText($OwnershipManifest, [Text.Encoding]::UTF8) | ConvertFrom-Json -ErrorAction Stop if (!$manifest.Fixture -or $manifest.SchemaVersion -ne 3 -or + ($manifest.Generation -isnot [long] -and $manifest.Generation -isnot [int]) -or + $manifest.Generation -lt 0 -or + [string]$manifest.AuthorityState -cnotin @('PROVISIONAL','NONPROVISIONAL') -or [string]$manifest.InstallerEntryIdentity -notmatch '^[a-f0-9]{24}$' -or [string]$manifest.InstallerSha256 -notmatch '^[a-f0-9]{64}$' -or [string]$manifest.InstallerProductCode -notmatch @@ -862,6 +940,7 @@ switch ($scenario) { $manifest.RegistryKeys = @() $manifest.RegistryValues = @() $manifest.MsiTransactionState = 'ROLLED_BACK_CLEAN' + $manifest.AuthorityState = 'NONPROVISIONAL' Write-FixtureOwnershipManifest $manifest Write-FixtureMarker ('{0}|INSTALL|OWNERSHIP_CAPTURE|COMPLETE' -f ` [DateTime]::UtcNow.AddSeconds(60).Ticks) @@ -876,15 +955,20 @@ switch ($scenario) { $manifest.InstallAttempted = $true $manifest.MsiTransactionState = 'PENDING' Write-FixtureOwnershipManifest $manifest - Write-FixtureMarker ('{0}|INSTALL|OWNERSHIP_CAPTURE|BEGIN' -f ` + Write-FixtureMarker ('{0}|INSTALL|AUTHORITY_PUBLICATION|BEGIN' -f ` [DateTime]::UtcNow.AddSeconds(60).Ticks) - Write-FixtureCriticalGate 'DURING_OWNERSHIP_CAPTURE' - Start-Sleep -Milliseconds 750 New-OwnedFixtureResources -PublishCommittedReceipt $false $manifest = [IO.File]::ReadAllText($OwnershipManifest, [Text.Encoding]::UTF8) | ConvertFrom-Json -ErrorAction Stop $manifest.MsiTransactionState = 'COMMITTED' + $manifest.AuthorityState = 'NONPROVISIONAL' Write-FixtureOwnershipManifest $manifest + Write-FixtureMarker ('{0}|INSTALL|AUTHORITY_PUBLICATION|COMPLETE' -f ` + [DateTime]::UtcNow.AddSeconds(60).Ticks) + Write-FixtureMarker ('{0}|INSTALL|OWNERSHIP_CAPTURE|BEGIN' -f ` + [DateTime]::UtcNow.AddSeconds(60).Ticks) + Write-FixtureCriticalGate 'DURING_OWNERSHIP_CAPTURE' + Start-Sleep -Milliseconds 750 Write-FixtureMarker ('{0}|INSTALL|OWNERSHIP_CAPTURE|COMPLETE' -f ` [DateTime]::UtcNow.AddSeconds(60).Ticks) Start-Sleep -Seconds 300 diff --git a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 index e76a10ecb..ac463045c 100644 --- a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 @@ -300,7 +300,7 @@ function Get-SanitizedSupervisorMarkerDiagnostic($Result) { [string]$Result.Output, '(?m)^PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:CLEANUP_VALIDATION_PHASE:' + '(HANDSHAKE|FILE_AUTHORITY|UTF8_DECODE|JSON_PARSE|EXACT_KEY_SET|' + - 'BOOLEAN_TYPES|TRANSACTION_ENUM|SCHEMA_TYPE_STATE|RUN_ID_FORMAT|' + + 'GENERATION|BOOLEAN_TYPES|TRANSACTION_ENUM|SCHEMA_TYPE_STATE|RUN_ID_FORMAT|' + 'INSTALLER_ENTRY_ID_FORMAT|INSTALLER_SHA256_FORMAT|INSTALLER_PRODUCT_CODE_FORMAT|' + 'LIFETIME|RUN_ID|INSTALLER_PATH|FIXTURE_SCOPE|INITIAL_ACTIVE_MATCH|' + 'INITIAL_INSTALLER_AUTHORITY_RECHECK|EMPTY_RECEIPT_WRITE)\r?$' @@ -372,6 +372,7 @@ function Get-SanitizedCriticalCancellationDiagnostic($Result) { '^PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:LAST_VALID:' + '(INITIALIZATION|INSTALL|VALIDATION|USER_SETUP|APP_LAUNCH|APP_EXIT|UNINSTALL|CLEANUP):' + '(PATHS|BASELINE|MSI_INSTALL|OWNERSHIP_CAPTURE|INSTALL_TREE_SCAN|' + + 'AUTHORITY_PUBLICATION|' + 'APPLICATION_IMAGE|PROTOCOL_ASSERTION|APP_PATH_ASSERTION|' + 'HKCU_INSTALLED_ASSERTION|SHORTCUT_ASSERTION|USER_CREATE|USER_SID|' + 'SMOKE_DATA_CREATE|SHORTCUT_PRESENT_PROBE|ALTERNATE_USER_START|' + @@ -411,7 +412,9 @@ function Get-SanitizedCriticalCancellationDiagnostic($Result) { if ($match.Groups[1].Value -ceq 'INSTALL' -and $match.Groups[2].Value -ceq 'OWNERSHIP_CAPTURE') { $authorityEvent = @(switch ($match.Groups[3].Value) { - 'BEGIN' { 'PROVISIONAL' } + # OWNERSHIP_CAPTURE is now published only after the complete + # authority generation was durably replaced and re-read. + 'BEGIN' { 'NONPROVISIONAL' } 'COMPLETE' { 'NONPROVISIONAL' } 'FAILED' { 'FAILED' } }) @@ -1772,6 +1775,8 @@ function Test-PreExistingCleanupOwnership { Assert-True ($normalReceipt.SchemaVersion -eq 3 -and $normalReceipt.ManifestType -ceq 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP' -and $normalReceipt.State -ceq 'EMPTY' -and + $normalReceipt.Generation -is [long] -and $normalReceipt.Generation -gt 0 -and + $normalReceipt.AuthorityState -ceq 'NONPROVISIONAL' -and $normalReceipt.InstallerEntryIdentity -ceq $dummyInstallerEntryIdentity -and $normalReceipt.InstallerSha256 -ceq $dummyInstallerSha256 -and $normalReceipt.InstallerProductCode -ceq $dummyInstallerProductCode -and @@ -1795,17 +1800,23 @@ function Test-PreExistingCleanupOwnership { $normalSupervisor.Dispose() } - foreach ($manifestCase in @('MISSING','MALFORMED','STALE')) { + foreach ($manifestCase in @('MISSING','MALFORMED','STALE','STALE_GENERATION')) { $badRunId = [Guid]::NewGuid().ToString('N') $badManifest = Join-Path ([IO.Path]::GetTempPath()) ` "propr-installed-app-ownership-$badRunId.json" if ($manifestCase -eq 'MALFORMED') { [IO.File]::WriteAllText($badManifest, '{not-json', [Text.Encoding]::UTF8) - } elseif ($manifestCase -eq 'STALE') { - $createdTicks = [DateTime]::UtcNow.AddHours(-4).Ticks + } elseif ($manifestCase -in @('STALE','STALE_GENERATION')) { + $createdTicks = if ($manifestCase -eq 'STALE') { + [DateTime]::UtcNow.AddHours(-4).Ticks + } else { [DateTime]::UtcNow.Ticks } $staleManifest = [ordered]@{ SchemaVersion = 3 ManifestType = 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP'; State = 'ACTIVE' + Generation = if ($manifestCase -eq 'STALE_GENERATION') { + [int64]-1 + } else { [int64]0 } + AuthorityState = 'PROVISIONAL' RunId = $badRunId CreatedUtcTicks = $createdTicks ExpiresUtcTicks = $createdTicks + ([TimeSpan]::TicksPerHour * 3) @@ -1843,6 +1854,87 @@ function Test-PreExistingCleanupOwnership { } } + # A same-directory replacement collision must retain the authenticated + # generation. Once the untrusted/partial candidate is removed, the exact + # same cleanup request must be retryable to a durable EMPTY receipt. + $collisionRunId = [Guid]::NewGuid().ToString('N') + $collisionManifest = Join-Path ([IO.Path]::GetTempPath()) ` + "propr-installed-app-ownership-$collisionRunId.json" + $collisionCreatedTicks = [DateTime]::UtcNow.Ticks + $collisionAuthority = [ordered]@{ + SchemaVersion = 3 + ManifestType = 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP'; State = 'ACTIVE' + Generation = [int64]0; AuthorityState = 'PROVISIONAL' + RunId = $collisionRunId + CreatedUtcTicks = $collisionCreatedTicks + ExpiresUtcTicks = $collisionCreatedTicks + ([TimeSpan]::TicksPerHour * 3) + InstallerPath = $dummyInstaller + InstallerEntryIdentity = $dummyInstallerEntryIdentity + InstallerSha256 = $dummyInstallerSha256 + InstallerProductCode = $dummyInstallerProductCode + Fixture = $true; FixtureRoot = $workflowStateDirectory + BaselineClean = $false; InstallAttempted = $false; MsiTransactionState = 'NONE' + Directories = @(); Files = @(); RegistryKeys = @(); RegistryValues = @() + Users = @(); Profiles = @() + } + Write-TestOwnershipManifest $collisionManifest $collisionAuthority + [IO.File]::WriteAllText( + "$collisionManifest.new", '{"partial":', [Text.Encoding]::UTF8) + $collisionCleanup = Invoke-WorkflowCleanupController ` + $collisionManifest $collisionRunId $workflowStateDirectory + Assert-True ($collisionCleanup.ExitCode -eq 21 -and + $collisionCleanup.ReportedExitCode -eq 21 -and + $collisionCleanup.ControllerStatus -ceq 'OWNED_RESOURCE_CLEANUP_FAILURE') ` + 'replacement collision did not fail closed after manifest validation' + $retainedCollisionAuthority = Get-Content -LiteralPath $collisionManifest ` + -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop + Assert-True ($retainedCollisionAuthority.Generation -eq 0 -and + $retainedCollisionAuthority.State -ceq 'ACTIVE') ` + 'replacement collision changed the authenticated generation' + Remove-Item -LiteralPath "$collisionManifest.new" -Force -ErrorAction Stop + $collisionRetry = Invoke-WorkflowCleanupController ` + $collisionManifest $collisionRunId $workflowStateDirectory + Assert-True ($collisionRetry.ExitCode -eq 0 -and + $collisionRetry.ReportedExitCode -eq 0 -and + $collisionRetry.ControllerStatus -ceq 'EMPTY_OR_CLEANED') ` + 'cleanup retry did not complete after the invalid collision was cleared' + Assert-True (!(Test-Path -LiteralPath $collisionManifest)) ` + 'cleanup retry retained its consumed empty receipt' + + $resumeRunId = [Guid]::NewGuid().ToString('N') + $resumeManifest = Join-Path ([IO.Path]::GetTempPath()) ` + "propr-installed-app-ownership-$resumeRunId.json" + $resumeAuthority = $collisionAuthority.Clone() + $resumeAuthority.RunId = $resumeRunId + $resumeAuthority.CreatedUtcTicks = [DateTime]::UtcNow.Ticks + $resumeAuthority.ExpiresUtcTicks = $resumeAuthority.CreatedUtcTicks + ` + ([TimeSpan]::TicksPerHour * 3) + Write-TestOwnershipManifest $resumeManifest $resumeAuthority + $flushedReceipt = $resumeAuthority.Clone() + $flushedReceipt.State = 'EMPTY' + $flushedReceipt.Generation = [int64]1 + $flushedReceipt.AuthorityState = 'NONPROVISIONAL' + $flushedReceipt.BaselineClean = $false + $flushedReceipt.InstallAttempted = $false + $flushedReceipt.MsiTransactionState = 'NONE' + $flushedReceipt.Directories = @(); $flushedReceipt.Files = @() + $flushedReceipt.RegistryKeys = @(); $flushedReceipt.RegistryValues = @() + $flushedReceipt.Users = @(); $flushedReceipt.Profiles = @() + [IO.File]::WriteAllText( + "$resumeManifest.new", + ($flushedReceipt | ConvertTo-Json -Depth 6 -Compress), + [Text.Encoding]::UTF8 + ) + $resumeCleanup = Invoke-WorkflowCleanupController ` + $resumeManifest $resumeRunId $workflowStateDirectory + Assert-True ($resumeCleanup.ExitCode -eq 0 -and + $resumeCleanup.ReportedExitCode -eq 0 -and + $resumeCleanup.ControllerStatus -ceq 'EMPTY_OR_CLEANED') ` + 'cleanup did not resume a byte-identical flushed next-generation receipt' + Assert-True (!(Test-Path -LiteralPath $resumeManifest) -and + !(Test-Path -LiteralPath "$resumeManifest.new")) ` + 'resumed cleanup retained receipt publication files' + Assert-True ((Get-Content -LiteralPath (Join-Path $conflictInstallRoot 'pre-existing.txt') -Raw).Trim() -ceq ` 'owned-before-run') 'external cleanup changed the pre-existing install tree' Assert-True ((Get-ItemPropertyValue -LiteralPath $conflictRegistryPath -Name 'PreExisting') -ceq ` @@ -2028,6 +2120,7 @@ function Test-PreExistingAppPathsAuthority { $mismatchState = [ordered]@{ SchemaVersion = 3 ManifestType = 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP'; State = 'ACTIVE' + Generation = [int64]0; AuthorityState = 'NONPROVISIONAL' RunId = $mismatchRunId CreatedUtcTicks = $createdTicks ExpiresUtcTicks = $createdTicks + ([TimeSpan]::TicksPerHour * 3) @@ -2123,6 +2216,8 @@ function Test-HkcuInstalledValueOwnership { SchemaVersion = 3 ManifestType = 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP' State = 'ACTIVE' + Generation = [int64]0 + AuthorityState = 'PROVISIONAL' RunId = $runId CreatedUtcTicks = $createdTicks ExpiresUtcTicks = $createdTicks + ([TimeSpan]::TicksPerHour * 3) @@ -2274,6 +2369,8 @@ function Test-ProvisionalUserMarkerOwnership { SchemaVersion = 3 ManifestType = 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP' State = 'ACTIVE' + Generation = [int64]0 + AuthorityState = 'PROVISIONAL' RunId = $runId CreatedUtcTicks = $createdTicks ExpiresUtcTicks = $createdTicks + ([TimeSpan]::TicksPerHour * 3) diff --git a/apps/desktop/scripts/test-installed-windows-app.ps1 b/apps/desktop/scripts/test-installed-windows-app.ps1 index 557dda2d4..a012fe91c 100644 --- a/apps/desktop/scripts/test-installed-windows-app.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app.ps1 @@ -223,7 +223,8 @@ $initialOwnershipState = ConvertFrom-Json ` -InputObject $strictUtf8.GetString($initialManifestBytes) -ErrorAction Stop $initialManifestKeys = @($initialOwnershipState.PSObject.Properties | ForEach-Object { $_.Name }) $expectedInitialManifestKeys = @( - 'SchemaVersion','ManifestType','State','RunId','CreatedUtcTicks','ExpiresUtcTicks', + 'SchemaVersion','ManifestType','State','Generation','AuthorityState', + 'RunId','CreatedUtcTicks','ExpiresUtcTicks', 'InstallerPath','InstallerEntryIdentity','InstallerSha256','InstallerProductCode', 'Fixture','FixtureRoot','BaselineClean','InstallAttempted','MsiTransactionState', 'Directories','Files','RegistryKeys','RegistryValues','Users','Profiles' @@ -233,6 +234,10 @@ if ($initialManifestKeys.Count -ne $expectedInitialManifestKeys.Count -or $initialManifestKeys -cnotcontains $_ }).Count -ne 0 -or $initialOwnershipState.SchemaVersion -ne 3 -or + ($initialOwnershipState.Generation -isnot [long] -and + $initialOwnershipState.Generation -isnot [int]) -or + $initialOwnershipState.Generation -ne 0 -or + [string]$initialOwnershipState.AuthorityState -cne 'PROVISIONAL' -or [string]$initialOwnershipState.ManifestType -cne 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP' -or [string]$initialOwnershipState.State -cne 'ACTIVE' -or @@ -266,6 +271,8 @@ $ownershipState = [ordered]@{ SchemaVersion = 3 ManifestType = 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP' State = 'ACTIVE' + Generation = [int64]$initialOwnershipState.Generation + AuthorityState = 'PROVISIONAL' RunId = $ownershipRunId CreatedUtcTicks = [int64]$initialOwnershipState.CreatedUtcTicks ExpiresUtcTicks = [int64]$initialOwnershipState.ExpiresUtcTicks @@ -286,25 +293,282 @@ $ownershipState = [ordered]@{ Profiles = @() } -function Write-OwnershipManifest { - $temporaryManifest = "$ownershipManifestPath.new" - $bytes = [Text.Encoding]::UTF8.GetBytes( - ($ownershipState | ConvertTo-Json -Depth 6 -Compress)) +Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +public static class ProPROwnershipAtomicFile +{ + private const uint MOVEFILE_REPLACE_EXISTING = 0x1; + private const uint MOVEFILE_WRITE_THROUGH = 0x8; + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true, + EntryPoint = "MoveFileExW")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool MoveFileExW( + string existingFileName, string newFileName, uint flags); + + [StructLayout(LayoutKind.Sequential)] + private struct BY_HANDLE_FILE_INFORMATION + { + public uint FileAttributes; + public System.Runtime.InteropServices.ComTypes.FILETIME CreationTime; + public System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime; + public System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime; + public uint VolumeSerialNumber; + public uint FileSizeHigh; + public uint FileSizeLow; + public uint NumberOfLinks; + public uint FileIndexHigh; + public uint FileIndexLow; + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetFileInformationByHandle( + SafeFileHandle handle, out BY_HANDLE_FILE_INFORMATION information); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFile( + string path, uint access, uint share, IntPtr security, uint creation, + uint flags, IntPtr template); + + public static string ReadHandle(SafeFileHandle handle) + { + BY_HANDLE_FILE_INFORMATION information; + if (handle == null || handle.IsInvalid || + !GetFileInformationByHandle(handle, out information)) + throw new Win32Exception(Marshal.GetLastWin32Error(), + "ownership manifest identity read failed"); + if ((information.FileAttributes & (0x10 | 0x400)) != 0) + throw new InvalidOperationException("ownership manifest entry is invalid"); + return String.Format("{0:x8}{1:x8}{2:x8}", information.VolumeSerialNumber, + information.FileIndexHigh, information.FileIndexLow); + } + + public static string ReadEntry(string path) + { + using (SafeFileHandle handle = CreateFile( + path, 0x80, 0x7, IntPtr.Zero, 3, 0x00200000, IntPtr.Zero)) + { + if (handle == null || handle.IsInvalid) + throw new Win32Exception(Marshal.GetLastWin32Error(), + "ownership manifest identity open failed"); + return ReadHandle(handle); + } + } + + public static void ReplaceSameDirectory(string temporaryPath, string destinationPath) + { + string temporaryFullPath = System.IO.Path.GetFullPath(temporaryPath); + string destinationFullPath = System.IO.Path.GetFullPath(destinationPath); + if (!String.Equals(System.IO.Path.GetDirectoryName(temporaryFullPath), + System.IO.Path.GetDirectoryName(destinationFullPath), + StringComparison.OrdinalIgnoreCase) || + !System.IO.File.Exists(temporaryFullPath) || + !System.IO.File.Exists(destinationFullPath)) + throw new InvalidOperationException("ownership publication precondition failed"); + if (!MoveFileExW(temporaryFullPath, destinationFullPath, + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) + throw new Win32Exception(Marshal.GetLastWin32Error(), + "ownership publication replacement failed"); + } +} +'@ + +function Read-OwnershipManifestSnapshot([string]$Path) { + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if ($item.PSIsContainer -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + $item.Length -le 0 -or $item.Length -gt 65536) { + throw 'ownership manifest snapshot metadata is invalid' + } + $bytes = [byte[]]::new([int]$item.Length) $stream = [IO.FileStream]::new( - $temporaryManifest, - [IO.FileMode]::Create, - [IO.FileAccess]::Write, - [IO.FileShare]::None, - 4096, - [IO.FileOptions]::WriteThrough - ) + $item.FullName, [IO.FileMode]::Open, [IO.FileAccess]::Read, + [IO.FileShare]'ReadWrite, Delete', 4096, [IO.FileOptions]::SequentialScan) try { - $stream.Write($bytes, 0, $bytes.Length) - $stream.Flush($true) + $entryIdentity = [ProPROwnershipAtomicFile]::ReadHandle($stream.SafeFileHandle) + $offset = 0 + while ($offset -lt $bytes.Length) { + $read = $stream.Read($bytes, $offset, $bytes.Length - $offset) + if ($read -eq 0) { throw 'ownership manifest snapshot read was incomplete' } + $offset += $read + } + if ($stream.ReadByte() -ne -1) { throw 'ownership manifest snapshot changed during read' } + $currentItem = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if ($currentItem.Length -ne $bytes.Length -or + [ProPROwnershipAtomicFile]::ReadEntry($Path) -cne $entryIdentity) { + throw 'ownership manifest pathname changed during read' + } } finally { $stream.Dispose() } - [IO.File]::Move($temporaryManifest, $ownershipManifestPath, $true) + $json = [Text.UTF8Encoding]::new($false, $true).GetString($bytes) + return [PSCustomObject]@{ + Json = $json + Record = ConvertFrom-Json -InputObject $json -ErrorAction Stop + } +} + +function Assert-OwnershipManifestEnvelope($Record) { + $keys = if ($Record -is [Collections.IDictionary]) { + @($Record.Keys) + } else { + @($Record.PSObject.Properties | ForEach-Object { $_.Name }) + } + $expectedKeys = @( + 'SchemaVersion','ManifestType','State','Generation','AuthorityState', + 'RunId','CreatedUtcTicks','ExpiresUtcTicks','InstallerPath', + 'InstallerEntryIdentity','InstallerSha256','InstallerProductCode','Fixture', + 'FixtureRoot','BaselineClean','InstallAttempted','MsiTransactionState', + 'Directories','Files','RegistryKeys','RegistryValues','Users','Profiles' + ) + if ($keys.Count -ne $expectedKeys.Count -or + @($expectedKeys | Where-Object { $keys -cnotcontains $_ }).Count -ne 0 -or + $Record.SchemaVersion -ne 3 -or + [string]$Record.ManifestType -cne 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP' -or + [string]$Record.State -cnotin @('ACTIVE','EMPTY') -or + ($Record.Generation -isnot [long] -and $Record.Generation -isnot [int]) -or + $Record.Generation -lt 0 -or + [string]$Record.AuthorityState -cnotin @('PROVISIONAL','NONPROVISIONAL') -or + [string]$Record.RunId -cne $ownershipRunId -or + [string]$Record.InstallerEntryIdentity -notmatch '^[a-f0-9]{24}$' -or + [string]$Record.InstallerSha256 -notmatch '^[a-f0-9]{64}$' -or + [string]$Record.InstallerProductCode -notmatch + '^\{[A-F0-9]{8}(?:-[A-F0-9]{4}){3}-[A-F0-9]{12}\}$' -or + $Record.Fixture -isnot [bool] -or $Record.Fixture -or + $Record.BaselineClean -isnot [bool] -or + $Record.InstallAttempted -isnot [bool]) { + throw 'ownership manifest envelope is invalid' + } +} + +function Assert-CompleteMsiOwnershipAuthority { + Assert-OwnershipManifestEnvelope $ownershipState + if ([string]$ownershipState.State -cne 'ACTIVE' -or + [string]$ownershipState.AuthorityState -cne 'NONPROVISIONAL' -or + [string]$ownershipState.MsiTransactionState -cne 'COMMITTED' -or + !$ownershipState.BaselineClean -or !$ownershipState.InstallAttempted -or + @($ownershipState.Directories).Count -ne 2 -or + @($ownershipState.Files).Count -ne 1 -or + @($ownershipState.RegistryKeys).Count -ne 2 -or + @($ownershipState.RegistryValues).Count -ne 1) { + throw 'complete MSI ownership authority is structurally invalid' + } + $directoryKinds = @($ownershipState.Directories | ForEach-Object { [string]$_.Kind }) + $registryKinds = @($ownershipState.RegistryKeys | ForEach-Object { [string]$_.Kind }) + if (@($directoryKinds | Select-Object -Unique).Count -ne 2 -or + $directoryKinds -cnotcontains 'INSTALL_ROOT' -or + $directoryKinds -cnotcontains 'SHORTCUT_FOLDER' -or + @($registryKinds | Select-Object -Unique).Count -ne 2 -or + $registryKinds -cnotcontains 'PROTOCOL' -or $registryKinds -cnotcontains 'APP_PATH') { + throw 'complete MSI ownership authority identity set is invalid' + } + foreach ($record in @($ownershipState.Directories)) { + if ($record.Owned -isnot [bool] -or !$record.Owned -or + $record.Provisional -isnot [bool] -or $record.Provisional -or + [string]$record.Identity -notmatch '^[a-f0-9]{24}$' -or + [string]$record.TreeIdentity -notmatch '^[a-f0-9]{64}$') { + throw 'complete MSI directory authority is invalid' + } + } + foreach ($record in @($ownershipState.Files)) { + if ([string]$record.Kind -cne 'SHORTCUT_FILE' -or + $record.Owned -isnot [bool] -or !$record.Owned -or + $record.Provisional -isnot [bool] -or $record.Provisional -or + [string]$record.Identity -notmatch '^[a-f0-9]{64}$' -or + [string]$record.EntryIdentity -notmatch '^[a-f0-9]{24}$') { + throw 'complete MSI file authority is invalid' + } + } + foreach ($record in @($ownershipState.RegistryKeys)) { + if ($record.Owned -isnot [bool] -or !$record.Owned -or + $record.Provisional -isnot [bool] -or $record.Provisional -or + [string]$record.Identity -notmatch '^[a-f0-9]{64}$') { + throw 'complete MSI registry authority is invalid' + } + } + $value = $ownershipState.RegistryValues[0] + if ([string]$value.Kind -cne 'HKCU_INSTALLED' -or + $value.Owned -isnot [bool] -or !$value.Owned -or + $value.Provisional -isnot [bool] -or $value.Provisional -or + $value.BaselineKeyExisted -isnot [bool] -or + $value.BaselineValueExisted -isnot [bool] -or + $value.KeyCreatedByRun -isnot [bool] -or + [string]::IsNullOrEmpty([string]$value.IdentityValueKind) -or + [string]::IsNullOrEmpty([string]$value.IdentityValueData)) { + throw 'complete MSI registry-value authority is invalid' + } + Assert-InstallerArtifactAuthority +} + +function Assert-PublishedMsiOwnershipAuthority([string]$ExpectedTransactionState) { + if ($ExpectedTransactionState -cnotin @('COMMITTED','ROLLED_BACK_CLEAN')) { + throw 'published MSI authority expectation is invalid' + } + $published = Read-OwnershipManifestSnapshot $ownershipManifestPath + Assert-OwnershipManifestEnvelope $published.Record + if ([int64]$published.Record.Generation -ne [int64]$ownershipState.Generation -or + [string]$published.Record.AuthorityState -cne 'NONPROVISIONAL' -or + [string]$published.Record.MsiTransactionState -cne $ExpectedTransactionState -or + [string]$published.Record.RunId -cne $ownershipRunId) { + throw 'published MSI authority generation is invalid' + } + if ($ExpectedTransactionState -ceq 'COMMITTED') { + Assert-CompleteMsiOwnershipAuthority + } elseif (@($published.Record.Directories).Count -ne 0 -or + @($published.Record.Files).Count -ne 0 -or + @($published.Record.RegistryKeys).Count -ne 0) { + throw 'published rollback authority is not clean' + } + Assert-InstallerArtifactAuthority +} + +function Write-OwnershipManifest { + $temporaryManifest = "$ownershipManifestPath.new" + Assert-OwnershipManifestEnvelope $ownershipState + $previousGeneration = [int64]$ownershipState.Generation + if ($previousGeneration -eq [int64]::MaxValue) { + throw 'ownership manifest generation is exhausted' + } + $ownershipState.Generation = $previousGeneration + 1 + try { + Assert-OwnershipManifestEnvelope $ownershipState + $json = $ownershipState | ConvertTo-Json -Depth 6 -Compress + $roundTrip = ConvertFrom-Json -InputObject $json -ErrorAction Stop + Assert-OwnershipManifestEnvelope $roundTrip + if ([int64]$roundTrip.Generation -ne [int64]$ownershipState.Generation) { + throw 'ownership manifest generation round trip failed' + } + $bytes = [Text.Encoding]::UTF8.GetBytes($json) + $stream = [IO.FileStream]::new( + $temporaryManifest, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, + [IO.FileShare]::None, 4096, [IO.FileOptions]::WriteThrough) + try { + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush($true) + } finally { + $stream.Dispose() + } + $current = Read-OwnershipManifestSnapshot $ownershipManifestPath + Assert-OwnershipManifestEnvelope $current.Record + if ([int64]$current.Record.Generation -ne $previousGeneration) { + throw 'ownership manifest generation is stale' + } + [ProPROwnershipAtomicFile]::ReplaceSameDirectory( + $temporaryManifest, $ownershipManifestPath) + $published = Read-OwnershipManifestSnapshot $ownershipManifestPath + Assert-OwnershipManifestEnvelope $published.Record + if ($published.Json -cne $json -or + [int64]$published.Record.Generation -ne [int64]$ownershipState.Generation) { + throw 'ownership manifest durable publication re-read failed' + } + } catch { + $ownershipState.Generation = $previousGeneration + throw + } } function Test-SamePath([string]$Left, [string]$Right) { @@ -804,6 +1068,7 @@ function Write-WatchdogMarker( 'PATHS', 'BASELINE', 'MSI_INSTALL', + 'AUTHORITY_PUBLICATION', 'OWNERSHIP_CAPTURE', 'INSTALL_TREE_SCAN', 'APPLICATION_IMAGE', @@ -1800,7 +2065,7 @@ try { if ($null -ne $msiTransactionFailure) { Invoke-BoundedExternalOperation ` -Stage 'INSTALL' ` - -Substage 'OWNERSHIP_CAPTURE' ` + -Substage 'AUTHORITY_PUBLICATION' ` -TimeoutMilliseconds $externalOperationTimeoutMilliseconds ` -Operation { Wait-ExactCleanMsiBaselineAfterRollback @@ -1818,13 +2083,21 @@ try { KeyCreatedByRun = $false }) $ownershipState.MsiTransactionState = 'ROLLED_BACK_CLEAN' + $ownershipState.AuthorityState = 'NONPROVISIONAL' Write-OwnershipManifest } + Invoke-BoundedExternalOperation ` + -Stage 'INSTALL' ` + -Substage 'OWNERSHIP_CAPTURE' ` + -TimeoutMilliseconds $externalOperationTimeoutMilliseconds ` + -Operation { + Assert-PublishedMsiOwnershipAuthority 'ROLLED_BACK_CLEAN' + } throw $msiTransactionFailure } else { Invoke-BoundedExternalOperation ` -Stage 'INSTALL' ` - -Substage 'OWNERSHIP_CAPTURE' ` + -Substage 'AUTHORITY_PUBLICATION' ` -TimeoutMilliseconds $externalOperationTimeoutMilliseconds ` -Operation { if (!$script:msiInstallCompleted) { @@ -1940,8 +2213,17 @@ try { KeyCreatedByRun = $script:hkcuDesktopKeyCreatedByRun }) $ownershipState.MsiTransactionState = 'COMMITTED' + $ownershipState.AuthorityState = 'NONPROVISIONAL' + Assert-CompleteMsiOwnershipAuthority Write-OwnershipManifest } + Invoke-BoundedExternalOperation ` + -Stage 'INSTALL' ` + -Substage 'OWNERSHIP_CAPTURE' ` + -TimeoutMilliseconds $externalOperationTimeoutMilliseconds ` + -Operation { + Assert-PublishedMsiOwnershipAuthority 'COMMITTED' + } } Write-Stage 'INSTALL' 'COMPLETE' } catch { diff --git a/apps/desktop/src/release-workflow.test.ts b/apps/desktop/src/release-workflow.test.ts index bf0a28a03..03a11c709 100644 --- a/apps/desktop/src/release-workflow.test.ts +++ b/apps/desktop/src/release-workflow.test.ts @@ -734,6 +734,7 @@ describe('desktop trusted release workflow', () => { 'installer authority must be captured before the worker starts', ); for (const field of [ + 'Generation', 'AuthorityState', 'InstallerEntryIdentity', 'InstallerSha256', 'InstallerProductCode', ]) { assert.match(installedWindowsAppSupervisor, new RegExp(field)); @@ -753,7 +754,7 @@ describe('desktop trusted release workflow', () => { ); assert.match( installedWindowsAppCleanup, - /HANDSHAKE','FILE_AUTHORITY','UTF8_DECODE','JSON_PARSE','EXACT_KEY_SET',[\s\S]*'BOOLEAN_TYPES','TRANSACTION_ENUM','SCHEMA_TYPE_STATE','RUN_ID_FORMAT',[\s\S]*'INSTALLER_ENTRY_ID_FORMAT','INSTALLER_SHA256_FORMAT','INSTALLER_PRODUCT_CODE_FORMAT',[\s\S]*'INITIAL_ACTIVE_MATCH',[\s\S]*'INITIAL_INSTALLER_AUTHORITY_RECHECK','EMPTY_RECEIPT_WRITE'/, + /HANDSHAKE','FILE_AUTHORITY','UTF8_DECODE','JSON_PARSE','EXACT_KEY_SET',[\s\S]*'GENERATION','BOOLEAN_TYPES','TRANSACTION_ENUM','SCHEMA_TYPE_STATE','RUN_ID_FORMAT',[\s\S]*'INSTALLER_ENTRY_ID_FORMAT','INSTALLER_SHA256_FORMAT','INSTALLER_PRODUCT_CODE_FORMAT',[\s\S]*'INITIAL_ACTIVE_MATCH',[\s\S]*'INITIAL_INSTALLER_AUTHORITY_RECHECK','EMPTY_RECEIPT_WRITE'/, ); assert.match( installedWindowsAppCleanup, @@ -769,20 +770,19 @@ describe('desktop trusted release workflow', () => { ); assert.match( installedWindowsAppCleanup, - /if \(\$PSVersionTable\.PSEdition -ceq 'Core'\) \{[\s\S]*\[IO\.File\]::Move\(\$temporaryPath, \$Path, \$true\)[\s\S]*\} else \{[\s\S]*\[ProPRAtomicFile\]::ReplaceSameDirectory\(\$temporaryPath, \$Path\)/, + /\[IO\.FileMode\]::CreateNew[\s\S]*\$current\.Generation[\s\S]*-ne \$previousGeneration[\s\S]*\[ProPRAtomicFile\]::ReplaceSameDirectory\(\$temporaryPath, \$Path\)[\s\S]*ownership receipt durable publication re-read failed/, ); assert.match( installedWindowsAppCleanup, /class ProPRAtomicFile[\s\S]*String\.Equals\(temporaryDirectory, destinationDirectory,[\s\S]*StringComparison\.OrdinalIgnoreCase\)[\s\S]*MoveFileExW\(temporaryFullPath, destinationFullPath,[\s\S]*MOVEFILE_REPLACE_EXISTING \| MOVEFILE_WRITE_THROUGH\)[\s\S]*Marshal\.GetLastWin32Error\(\)[\s\S]*new Win32Exception\(error/, ); assert.doesNotMatch(installedWindowsAppCleanup, /\[IO\.File\]::Replace\(/); + assert.doesNotMatch(installedWindowsAppCleanup, /\[IO\.File\]::Move\(\$temporaryPath/); + assert.match(installedWindowsAppCleanup, /MOVEFILE_WRITE_THROUGH/); + assert.match(installedWindowsAppTest, /AUTHORITY_PUBLICATION/); assert.match( - installedWindowsAppCleanup, - /\[IO\.File\]::Move\(\$temporaryPath, \$Path, \$true\)/, - ); - assert.match( - installedWindowsAppCleanup, - /\$replacementCompleted = \$false[\s\S]*\$replacementCompleted = \$true\n\s+\} finally \{\n\s+if \(!\$replacementCompleted\) \{ \[IO\.File\]::Delete\(\$temporaryPath\) \}/, + installedWindowsAppTest, + /AuthorityState = 'NONPROVISIONAL'[\s\S]*Assert-CompleteMsiOwnershipAuthority[\s\S]*Write-OwnershipManifest[\s\S]*Substage 'OWNERSHIP_CAPTURE'[\s\S]*Assert-PublishedMsiOwnershipAuthority 'COMMITTED'/, ); assert.match( installedWindowsAppCleanup, @@ -843,7 +843,7 @@ describe('desktop trusted release workflow', () => { ); assert.match( installedWindowsAppSupervisorBehaviorTest, - /CLEANUP_VALIDATION_PHASE:[\s\S]*HANDSHAKE\|FILE_AUTHORITY\|UTF8_DECODE\|JSON_PARSE\|EXACT_KEY_SET\|[\s\S]*BOOLEAN_TYPES\|TRANSACTION_ENUM\|SCHEMA_TYPE_STATE\|RUN_ID_FORMAT\|[\s\S]*INSTALLER_ENTRY_ID_FORMAT\|INSTALLER_SHA256_FORMAT\|INSTALLER_PRODUCT_CODE_FORMAT\|[\s\S]*INITIAL_INSTALLER_AUTHORITY_RECHECK\|EMPTY_RECEIPT_WRITE/, + /CLEANUP_VALIDATION_PHASE:[\s\S]*HANDSHAKE\|FILE_AUTHORITY\|UTF8_DECODE\|JSON_PARSE\|EXACT_KEY_SET\|[\s\S]*GENERATION\|BOOLEAN_TYPES\|TRANSACTION_ENUM\|SCHEMA_TYPE_STATE\|RUN_ID_FORMAT\|[\s\S]*INSTALLER_ENTRY_ID_FORMAT\|INSTALLER_SHA256_FORMAT\|INSTALLER_PRODUCT_CODE_FORMAT\|[\s\S]*INITIAL_INSTALLER_AUTHORITY_RECHECK\|EMPTY_RECEIPT_WRITE/, ); assert.match( installedWindowsAppSupervisor, @@ -1233,6 +1233,7 @@ describe('desktop trusted release workflow', () => { 'PATHS', 'BASELINE', 'MSI_INSTALL', + 'AUTHORITY_PUBLICATION', 'OWNERSHIP_CAPTURE', 'INSTALL_TREE_SCAN', 'APPLICATION_IMAGE', From 66cffbe79c0af9fdfd32ef5751e9f8a6c98d905b Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:36:47 +0000 Subject: [PATCH 2/2] feat(ai): Implemented the deterministic publication-death proof without committing. Implemented the deterministic publication-death proof without committing. Key changes: - Added real cleanup-worker process-death gates for `BEFORE`, `DURING`, and `AFTER` publication in [cleanup-installed-windows-app.ps1](/tmp/git-processor/worktrees/integry/propr/pr-2055-followup-2026-09-01T22-25-38/apps/desktop/scripts/cleanup-installed-windows-app.ps1). - DURING signals only after `FileStream` `WriteThrough`, `Flush(true)`, and close, before atomic replacement. - Added byte-identical recovery and partial/foreign/stale/generation-mismatched `.new` rejection tests in [test-installed-windows-app-supervisor.ps1](/tmp/git-processor/worktrees/integry/propr/pr-2055-followup-2026-09-01T22-25-38/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1). - Added static ordering and matrix guards in [release-workflow.test.ts](/tmp/git-processor/worktrees/integry/propr/pr-2055-followup-2026-09-01T22-25-38/apps/desktop/src/release-workflow.test.ts). - Existing workflow coverage already runs the deterministic suite on native Windows x64 and ARM64. Validation: - Desktop suite: 177 passed, 6 platform-specific skips. - Release workflow tests: 23/23 passed. - Desktop typecheck: passed. - `git diff --check`: passed. - Native Windows execution awaits the configured x64/ARM64 CI runners. Per instruction, no commit was created. Exact current HEAD remains `2be43cc6b717c6c1cc9ae2a4156792efd5069898`; the system-generated new head is not available until it commits these changes. PR: #2055 Comment by: @integry (ID: 5501253656) Model: gpt-5.6-sol --- .../scripts/cleanup-installed-windows-app.ps1 | 35 ++ .../test-installed-windows-app-supervisor.ps1 | 380 ++++++++++++++---- apps/desktop/src/release-workflow.test.ts | 47 +++ 3 files changed, 382 insertions(+), 80 deletions(-) diff --git a/apps/desktop/scripts/cleanup-installed-windows-app.ps1 b/apps/desktop/scripts/cleanup-installed-windows-app.ps1 index 9f4bbb69f..4477845d0 100644 --- a/apps/desktop/scripts/cleanup-installed-windows-app.ps1 +++ b/apps/desktop/scripts/cleanup-installed-windows-app.ps1 @@ -1047,6 +1047,34 @@ function Restore-OwnedRegistryValue($Record) { } } +function Wait-FixtureDurablePublicationDeathGate( + [ValidateSet('BEFORE','DURING','AFTER')][string]$Checkpoint +) { + $requestedCheckpoint = [string]$env:PROPR_SUPERVISOR_FIXTURE_PUBLICATION_CHECKPOINT + $eventName = [string]$env:PROPR_SUPERVISOR_FIXTURE_PUBLICATION_EVENT + if ([string]::IsNullOrEmpty($requestedCheckpoint) -and + [string]::IsNullOrEmpty($eventName)) { + return + } + if (!$FixtureRoot -or $requestedCheckpoint -cnotin @('BEFORE','DURING','AFTER') -or + $eventName -notmatch + '^Local\\ProPRInstalledAppPublication-[a-f0-9]{32}$') { + throw 'fixture durable publication death gate is invalid' + } + if ($Checkpoint -cne $requestedCheckpoint) { return } + + $publicationEvent = [Threading.EventWaitHandle]::OpenExisting($eventName) + try { + [void]$publicationEvent.Set() + # The parent terminates this real cleanup process tree. Reaching this bound + # means the deterministic process-death fixture did not take ownership. + Start-Sleep -Seconds 300 + throw 'fixture durable publication death gate was not terminated' + } finally { + $publicationEvent.Dispose() + } +} + function Write-DurableOwnershipManifest([string]$Path, $Manifest) { $temporaryPath = "$Path.new" $previousGeneration = [int64]$Manifest.Generation @@ -1064,6 +1092,7 @@ function Write-DurableOwnershipManifest([string]$Path, $Manifest) { throw 'ownership receipt generation round trip failed' } $bytes = [Text.Encoding]::UTF8.GetBytes($json) + Wait-FixtureDurablePublicationDeathGate 'BEFORE' if (Test-Path -LiteralPath $temporaryPath) { # A prior cleanup may have died after FlushFileBuffers but before rename. # Resume only the byte-identical next-generation candidate; a partial, @@ -1090,6 +1119,11 @@ function Write-DurableOwnershipManifest([string]$Path, $Manifest) { } finally { $stream.Dispose() } + # The DURING fixture gate is deliberately after the write-through stream's + # durable flush and close, but before either the generation recheck or the + # atomic replacement. A test parent can therefore prove recovery from real + # process death at the only resumable publication boundary. + Wait-FixtureDurablePublicationDeathGate 'DURING' } $currentJson = [IO.File]::ReadAllText($Path, [Text.Encoding]::UTF8) $current = ConvertFrom-Json -InputObject $currentJson -ErrorAction Stop @@ -1107,6 +1141,7 @@ function Write-DurableOwnershipManifest([string]$Path, $Manifest) { [int64]$published.Generation -ne [int64]$Manifest.Generation) { throw 'ownership receipt durable publication re-read failed' } + Wait-FixtureDurablePublicationDeathGate 'AFTER' } catch { $Manifest.Generation = $previousGeneration throw diff --git a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 index ac463045c..1bf41ade5 100644 --- a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 @@ -791,6 +791,302 @@ function Invoke-WorkflowCleanupController( } } +function Get-ExpectedEmptyReceipt($Manifest) { + $receipt = $Manifest.PSObject.Copy() + $receipt.State = 'EMPTY' + $receipt.Generation = [int64]$Manifest.Generation + 1 + $receipt.AuthorityState = 'NONPROVISIONAL' + $receipt.BaselineClean = $false + $receipt.InstallAttempted = $false + $receipt.MsiTransactionState = 'NONE' + $receipt.Directories = @() + $receipt.Files = @() + $receipt.RegistryKeys = @() + $receipt.RegistryValues = @() + $receipt.Users = @() + $receipt.Profiles = @() + $json = $receipt | ConvertTo-Json -Depth 6 -Compress + return [PSCustomObject]@{ + Record = $receipt + Json = $json + Bytes = [Text.Encoding]::UTF8.GetBytes($json) + } +} + +function Test-ByteIdentical([byte[]]$Left, [byte[]]$Right) { + return $Left.Length -eq $Right.Length -and + [Convert]::ToBase64String($Left) -ceq [Convert]::ToBase64String($Right) +} + +function Write-DurableTestCandidate([string]$Path, [byte[]]$Bytes) { + $stream = [IO.FileStream]::new( + $Path, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, + [IO.FileShare]::None, 4096, [IO.FileOptions]::WriteThrough) + try { + $stream.Write($Bytes, 0, $Bytes.Length) + $stream.Flush($true) + } finally { + $stream.Dispose() + } +} + +function Start-WorkflowCleanupPublicationDeathFixture( + [ValidateSet('BEFORE','DURING','AFTER')][string]$Checkpoint, + [string]$ManifestPath, + [string]$RunId, + [string]$FixtureRoot +) { + $eventName = "Local\ProPRInstalledAppPublication-$([Guid]::NewGuid().ToString('N'))" + $publicationEvent = [Threading.EventWaitHandle]::new( + $false, [Threading.EventResetMode]::ManualReset, $eventName) + $ownershipReadyEventName = + "Local\ProPRInstalledAppCleanup-$([Guid]::NewGuid().ToString('N'))" + $ownershipReadyEvent = [Threading.EventWaitHandle]::new( + $false, [Threading.EventResetMode]::ManualReset, $ownershipReadyEventName) + $cleanupWorkerPath = Join-Path $PSScriptRoot 'cleanup-installed-windows-app.ps1' + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $hostPath + $startInfo.UseShellExecute = $false + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + foreach ($argument in @( + '-NoLogo', '-NoProfile', '-NonInteractive', '-File', $cleanupWorkerPath, + '-OwnershipManifest', $ManifestPath, + '-Installer', $dummyInstaller, + '-ExpectedRunId', $RunId, + '-OwnershipReadyEvent', $ownershipReadyEventName, + '-FixtureRoot', $FixtureRoot + )) { + $startInfo.ArgumentList.Add([string]$argument) + } + $startInfo.Environment['PROPR_SUPERVISOR_FIXTURE_PUBLICATION_CHECKPOINT'] = $Checkpoint + $startInfo.Environment['PROPR_SUPERVISOR_FIXTURE_PUBLICATION_EVENT'] = $eventName + $process = [Diagnostics.Process]::new() + $process.StartInfo = $startInfo + try { + if (!$process.Start()) { throw 'durable publication cleanup helper did not start' } + [void]$ownershipReadyEvent.Set() + Assert-True ($publicationEvent.WaitOne(90000)) ` + "$Checkpoint durable publication cleanup helper did not reach its death gate" + Assert-True (!$process.HasExited) ` + "$Checkpoint durable publication cleanup helper exited before process death" + return [PSCustomObject]@{ + Process = $process + Event = $publicationEvent + OwnershipReadyEvent = $ownershipReadyEvent + } + } catch { + if (!$process.HasExited) { + try { $process.Kill($true) } catch {} + try { [void]$process.WaitForExit(5000) } catch {} + } + $process.Dispose() + $publicationEvent.Dispose() + $ownershipReadyEvent.Dispose() + throw + } +} + +function Stop-WorkflowCleanupPublicationDeathFixture($Fixture, [string]$Checkpoint) { + try { + Assert-True (!$Fixture.Process.HasExited) ` + "$Checkpoint cleanup helper was not alive at its process-death gate" + $Fixture.Process.Kill($true) + Assert-True ($Fixture.Process.WaitForExit(5000)) ` + "$Checkpoint cleanup helper process tree did not die within the bound" + } finally { + $Fixture.Process.Dispose() + $Fixture.Event.Dispose() + $Fixture.OwnershipReadyEvent.Dispose() + } +} + +function New-InterruptedOwnedWorkflowFixture([string]$Name) { + $stateDirectory = New-StateDirectory $Name + $runId = [Guid]::NewGuid().ToString('N') + $manifestPath = Join-Path ([IO.Path]::GetTempPath()) ` + "propr-installed-app-ownership-$runId.json" + $supervisor = [Diagnostics.Process]::new() + $supervisor.StartInfo = New-SupervisorStartInfo ` + 'OWNED_RESOURCES_FOR_INTERRUPTION' $stateDirectory '' $false ` + $manifestPath $runId + try { + if (!$supervisor.Start()) { throw 'publication matrix supervisor did not start' } + $processState = Read-FixtureProcessState $stateDirectory + $owned = Read-FixtureResourceState $stateDirectory + $supervisor.Kill($false) + Assert-True ($supervisor.WaitForExit(5000)) ` + 'publication matrix supervisor did not die within the bound' + Assert-ProcessTreeGone $processState + Assert-True (Test-Path -LiteralPath $manifestPath -PathType Leaf) ` + 'publication matrix process death lost canonical authority' + return [PSCustomObject]@{ + StateDirectory = $stateDirectory + RunId = $runId + ManifestPath = $manifestPath + Owned = $owned + } + } finally { + if (!$supervisor.HasExited) { try { $supervisor.Kill($true) } catch {} } + $supervisor.Dispose() + } +} + +function New-InitialFixtureAuthority( + [string]$RunId, + [string]$FixtureRoot +) { + $createdTicks = [DateTime]::UtcNow.Ticks + return [ordered]@{ + SchemaVersion = 3 + ManifestType = 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP' + State = 'ACTIVE' + Generation = [int64]0 + AuthorityState = 'PROVISIONAL' + RunId = $RunId + CreatedUtcTicks = $createdTicks + ExpiresUtcTicks = $createdTicks + ([TimeSpan]::TicksPerHour * 3) + InstallerPath = $dummyInstaller + InstallerEntryIdentity = $dummyInstallerEntryIdentity + InstallerSha256 = $dummyInstallerSha256 + InstallerProductCode = $dummyInstallerProductCode + Fixture = $true + FixtureRoot = $FixtureRoot + BaselineClean = $false + InstallAttempted = $false + MsiTransactionState = 'NONE' + Directories = @() + Files = @() + RegistryKeys = @() + RegistryValues = @() + Users = @() + Profiles = @() + } +} + +function Test-DurablePublicationProcessDeathMatrix { + foreach ($checkpoint in @('BEFORE','DURING','AFTER')) { + $fixture = New-InterruptedOwnedWorkflowFixture ` + "durable-publication-$($checkpoint.ToLowerInvariant())" + $canonicalBytes = [IO.File]::ReadAllBytes($fixture.ManifestPath) + $canonical = [Text.UTF8Encoding]::new($false, $true).GetString($canonicalBytes) | + ConvertFrom-Json -ErrorAction Stop + $expected = Get-ExpectedEmptyReceipt $canonical + $deathFixture = Start-WorkflowCleanupPublicationDeathFixture ` + $checkpoint $fixture.ManifestPath $fixture.RunId $fixture.StateDirectory + try { + # Every checkpoint is reached only after the real cleanup worker has + # removed the exact authenticated tree and preserved all non-owned records. + Assert-OwnedResourcesGone $fixture.Owned + switch ($checkpoint) { + 'BEFORE' { + Assert-True (!(Test-Path -LiteralPath "$($fixture.ManifestPath).new")) ` + 'BEFORE death unexpectedly created a replacement authority record' + Assert-True (Test-ByteIdentical ` + $canonicalBytes ([IO.File]::ReadAllBytes($fixture.ManifestPath)) ` + ) 'BEFORE death changed canonical authority' + } + 'DURING' { + $candidatePath = "$($fixture.ManifestPath).new" + Assert-True (Test-Path -LiteralPath $candidatePath -PathType Leaf) ` + 'DURING death did not leave the flushed replacement authority record' + Assert-True (Test-ByteIdentical ` + $canonicalBytes ([IO.File]::ReadAllBytes($fixture.ManifestPath)) ` + ) 'DURING death changed canonical authority before atomic replacement' + Assert-True (Test-ByteIdentical ` + $expected.Bytes ([IO.File]::ReadAllBytes($candidatePath)) ` + ) 'DURING death candidate was not the byte-identical exact next generation' + } + 'AFTER' { + Assert-True (!(Test-Path -LiteralPath "$($fixture.ManifestPath).new")) ` + 'AFTER death retained a replacement pathname after committed publication' + Assert-True (Test-ByteIdentical ` + $expected.Bytes ([IO.File]::ReadAllBytes($fixture.ManifestPath)) ` + ) 'AFTER death canonical authority was not the committed exact next generation' + } + } + } finally { + Stop-WorkflowCleanupPublicationDeathFixture $deathFixture $checkpoint + } + + $retry = Invoke-WorkflowCleanupController ` + $fixture.ManifestPath $fixture.RunId $fixture.StateDirectory + Assert-True ($retry.ExitCode -eq 0 -and $retry.ReportedExitCode -eq 0 -and + $retry.Result -ceq 'COMPLETE' -and + $retry.ControllerStatus -ceq 'EMPTY_OR_CLEANED') ` + "$checkpoint process death did not resume through the real cleanup controller" + Assert-True (!(Test-Path -LiteralPath $fixture.ManifestPath) -and + !(Test-Path -LiteralPath "$($fixture.ManifestPath).new")) ` + "$checkpoint retry retained consumed publication authority" + Assert-OwnedResourcesGone $fixture.Owned + } +} + +function Test-InvalidDurablePublicationCandidates([string]$FixtureRoot) { + foreach ($candidateCase in @('PARTIAL','FOREIGN','STALE','GENERATION_MISMATCH')) { + $runId = [Guid]::NewGuid().ToString('N') + $manifestPath = Join-Path ([IO.Path]::GetTempPath()) ` + "propr-installed-app-ownership-$runId.json" + $authority = New-InitialFixtureAuthority $runId $FixtureRoot + Write-TestOwnershipManifest $manifestPath $authority + $canonicalBytes = [IO.File]::ReadAllBytes($manifestPath) + $canonical = [Text.UTF8Encoding]::new($false, $true).GetString($canonicalBytes) | + ConvertFrom-Json -ErrorAction Stop + $expected = Get-ExpectedEmptyReceipt $canonical + $candidateBytes = switch ($candidateCase) { + 'PARTIAL' { + [byte[]]$expected.Bytes[0..([Math]::Min(16, $expected.Bytes.Length - 1))] + break + } + 'FOREIGN' { + $foreign = $expected.Record.PSObject.Copy() + $foreign.RunId = [Guid]::NewGuid().ToString('N') + [Text.Encoding]::UTF8.GetBytes( + ($foreign | ConvertTo-Json -Depth 6 -Compress)) + break + } + 'STALE' { + $stale = $expected.Record.PSObject.Copy() + $stale.CreatedUtcTicks = [DateTime]::UtcNow.AddHours(-4).Ticks + $stale.ExpiresUtcTicks = $stale.CreatedUtcTicks + + ([TimeSpan]::TicksPerHour * 3) + [Text.Encoding]::UTF8.GetBytes( + ($stale | ConvertTo-Json -Depth 6 -Compress)) + break + } + 'GENERATION_MISMATCH' { + $mismatched = $expected.Record.PSObject.Copy() + $mismatched.Generation = [int64]$expected.Record.Generation + 1 + [Text.Encoding]::UTF8.GetBytes( + ($mismatched | ConvertTo-Json -Depth 6 -Compress)) + break + } + } + $candidatePath = "$manifestPath.new" + Write-DurableTestCandidate $candidatePath $candidateBytes + $failed = Invoke-WorkflowCleanupController $manifestPath $runId $FixtureRoot + Assert-True ($failed.ExitCode -eq 21 -and $failed.ReportedExitCode -eq 21 -and + $failed.Result -ceq 'FAILED' -and + $failed.ControllerStatus -ceq 'OWNED_RESOURCE_CLEANUP_FAILURE') ` + "$candidateCase .new authority was not rejected" + Assert-True (Test-ByteIdentical ` + $canonicalBytes ([IO.File]::ReadAllBytes($manifestPath)) ` + ) "$candidateCase .new authority changed canonical authority" + Assert-True (Test-ByteIdentical ` + $candidateBytes ([IO.File]::ReadAllBytes($candidatePath)) ` + ) "$candidateCase .new authority was not retained byte-identically" + + Remove-Item -LiteralPath $candidatePath -Force -ErrorAction Stop + $retry = Invoke-WorkflowCleanupController $manifestPath $runId $FixtureRoot + Assert-True ($retry.ExitCode -eq 0 -and $retry.ReportedExitCode -eq 0 -and + $retry.ControllerStatus -ceq 'EMPTY_OR_CLEANED') ` + "$candidateCase .new rejection did not preserve retry authority" + Assert-True (!(Test-Path -LiteralPath $manifestPath) -and + !(Test-Path -LiteralPath $candidatePath)) ` + "$candidateCase .new retry retained consumed authority" + } +} + function Test-WorkflowCleanupStartupProtocol { foreach ($failureClass in @('PARSER','PARAMETER_BINDING','TYPE_LOAD','OTHER')) { $result = Invoke-WorkflowCleanupController ` @@ -1854,86 +2150,10 @@ function Test-PreExistingCleanupOwnership { } } - # A same-directory replacement collision must retain the authenticated - # generation. Once the untrusted/partial candidate is removed, the exact - # same cleanup request must be retryable to a durable EMPTY receipt. - $collisionRunId = [Guid]::NewGuid().ToString('N') - $collisionManifest = Join-Path ([IO.Path]::GetTempPath()) ` - "propr-installed-app-ownership-$collisionRunId.json" - $collisionCreatedTicks = [DateTime]::UtcNow.Ticks - $collisionAuthority = [ordered]@{ - SchemaVersion = 3 - ManifestType = 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP'; State = 'ACTIVE' - Generation = [int64]0; AuthorityState = 'PROVISIONAL' - RunId = $collisionRunId - CreatedUtcTicks = $collisionCreatedTicks - ExpiresUtcTicks = $collisionCreatedTicks + ([TimeSpan]::TicksPerHour * 3) - InstallerPath = $dummyInstaller - InstallerEntryIdentity = $dummyInstallerEntryIdentity - InstallerSha256 = $dummyInstallerSha256 - InstallerProductCode = $dummyInstallerProductCode - Fixture = $true; FixtureRoot = $workflowStateDirectory - BaselineClean = $false; InstallAttempted = $false; MsiTransactionState = 'NONE' - Directories = @(); Files = @(); RegistryKeys = @(); RegistryValues = @() - Users = @(); Profiles = @() - } - Write-TestOwnershipManifest $collisionManifest $collisionAuthority - [IO.File]::WriteAllText( - "$collisionManifest.new", '{"partial":', [Text.Encoding]::UTF8) - $collisionCleanup = Invoke-WorkflowCleanupController ` - $collisionManifest $collisionRunId $workflowStateDirectory - Assert-True ($collisionCleanup.ExitCode -eq 21 -and - $collisionCleanup.ReportedExitCode -eq 21 -and - $collisionCleanup.ControllerStatus -ceq 'OWNED_RESOURCE_CLEANUP_FAILURE') ` - 'replacement collision did not fail closed after manifest validation' - $retainedCollisionAuthority = Get-Content -LiteralPath $collisionManifest ` - -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop - Assert-True ($retainedCollisionAuthority.Generation -eq 0 -and - $retainedCollisionAuthority.State -ceq 'ACTIVE') ` - 'replacement collision changed the authenticated generation' - Remove-Item -LiteralPath "$collisionManifest.new" -Force -ErrorAction Stop - $collisionRetry = Invoke-WorkflowCleanupController ` - $collisionManifest $collisionRunId $workflowStateDirectory - Assert-True ($collisionRetry.ExitCode -eq 0 -and - $collisionRetry.ReportedExitCode -eq 0 -and - $collisionRetry.ControllerStatus -ceq 'EMPTY_OR_CLEANED') ` - 'cleanup retry did not complete after the invalid collision was cleared' - Assert-True (!(Test-Path -LiteralPath $collisionManifest)) ` - 'cleanup retry retained its consumed empty receipt' - - $resumeRunId = [Guid]::NewGuid().ToString('N') - $resumeManifest = Join-Path ([IO.Path]::GetTempPath()) ` - "propr-installed-app-ownership-$resumeRunId.json" - $resumeAuthority = $collisionAuthority.Clone() - $resumeAuthority.RunId = $resumeRunId - $resumeAuthority.CreatedUtcTicks = [DateTime]::UtcNow.Ticks - $resumeAuthority.ExpiresUtcTicks = $resumeAuthority.CreatedUtcTicks + ` - ([TimeSpan]::TicksPerHour * 3) - Write-TestOwnershipManifest $resumeManifest $resumeAuthority - $flushedReceipt = $resumeAuthority.Clone() - $flushedReceipt.State = 'EMPTY' - $flushedReceipt.Generation = [int64]1 - $flushedReceipt.AuthorityState = 'NONPROVISIONAL' - $flushedReceipt.BaselineClean = $false - $flushedReceipt.InstallAttempted = $false - $flushedReceipt.MsiTransactionState = 'NONE' - $flushedReceipt.Directories = @(); $flushedReceipt.Files = @() - $flushedReceipt.RegistryKeys = @(); $flushedReceipt.RegistryValues = @() - $flushedReceipt.Users = @(); $flushedReceipt.Profiles = @() - [IO.File]::WriteAllText( - "$resumeManifest.new", - ($flushedReceipt | ConvertTo-Json -Depth 6 -Compress), - [Text.Encoding]::UTF8 - ) - $resumeCleanup = Invoke-WorkflowCleanupController ` - $resumeManifest $resumeRunId $workflowStateDirectory - Assert-True ($resumeCleanup.ExitCode -eq 0 -and - $resumeCleanup.ReportedExitCode -eq 0 -and - $resumeCleanup.ControllerStatus -ceq 'EMPTY_OR_CLEANED') ` - 'cleanup did not resume a byte-identical flushed next-generation receipt' - Assert-True (!(Test-Path -LiteralPath $resumeManifest) -and - !(Test-Path -LiteralPath "$resumeManifest.new")) ` - 'resumed cleanup retained receipt publication files' + # Exercise real helper-process death around the cleanup worker's durable + # publication boundary, then prove every non-exact .new candidate fails closed. + Test-DurablePublicationProcessDeathMatrix + Test-InvalidDurablePublicationCandidates $workflowStateDirectory Assert-True ((Get-Content -LiteralPath (Join-Path $conflictInstallRoot 'pre-existing.txt') -Raw).Trim() -ceq ` 'owned-before-run') 'external cleanup changed the pre-existing install tree' diff --git a/apps/desktop/src/release-workflow.test.ts b/apps/desktop/src/release-workflow.test.ts index 03a11c709..29416dc28 100644 --- a/apps/desktop/src/release-workflow.test.ts +++ b/apps/desktop/src/release-workflow.test.ts @@ -772,6 +772,53 @@ describe('desktop trusted release workflow', () => { installedWindowsAppCleanup, /\[IO\.FileMode\]::CreateNew[\s\S]*\$current\.Generation[\s\S]*-ne \$previousGeneration[\s\S]*\[ProPRAtomicFile\]::ReplaceSameDirectory\(\$temporaryPath, \$Path\)[\s\S]*ownership receipt durable publication re-read failed/, ); + const durableReceiptPublisher = installedWindowsAppCleanup.slice( + installedWindowsAppCleanup.indexOf('function Write-DurableOwnershipManifest'), + installedWindowsAppCleanup.indexOf('function Write-EmptyOwnershipReceipt'), + ); + const durableFlush = durableReceiptPublisher.indexOf('$stream.Flush($true)'); + const writeThrough = durableReceiptPublisher.indexOf('[IO.FileOptions]::WriteThrough'); + const duringDeathGate = durableReceiptPublisher.indexOf( + "Wait-FixtureDurablePublicationDeathGate 'DURING'", + ); + const atomicPublication = durableReceiptPublisher.indexOf( + '[ProPRAtomicFile]::ReplaceSameDirectory($temporaryPath, $Path)', + ); + const committedReread = durableReceiptPublisher.indexOf( + '$publishedJson = [IO.File]::ReadAllText($Path, [Text.Encoding]::UTF8)', + ); + const afterDeathGate = durableReceiptPublisher.indexOf( + "Wait-FixtureDurablePublicationDeathGate 'AFTER'", + ); + assert.ok( + writeThrough !== -1 + && writeThrough < durableFlush + && durableFlush < duringDeathGate + && duringDeathGate < atomicPublication + && atomicPublication < committedReread + && committedReread < afterDeathGate, + 'DURING process death must be signaled after durable flush and before atomic publication', + ); + assert.match( + installedWindowsAppCleanup, + /function Wait-FixtureDurablePublicationDeathGate[\s\S]*!\$FixtureRoot[\s\S]*ProPRInstalledAppPublication-/, + ); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /foreach \(\$checkpoint in @\('BEFORE','DURING','AFTER'\)\)/, + ); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /'-File', \$cleanupWorkerPath[\s\S]*\[void\]\$ownershipReadyEvent\.Set\(\)[\s\S]*\$publicationEvent\.WaitOne\(90000\)/, + ); + assert.match(installedWindowsAppSupervisorBehaviorTest, /\$Fixture\.Process\.Kill\(\$true\)/); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /DURING death candidate was not the byte-identical exact next generation/, + ); + for (const candidate of ['PARTIAL', 'FOREIGN', 'STALE', 'GENERATION_MISMATCH']) { + assert.match(installedWindowsAppSupervisorBehaviorTest, new RegExp(`['"]${candidate}['"]`)); + } assert.match( installedWindowsAppCleanup, /class ProPRAtomicFile[\s\S]*String\.Equals\(temporaryDirectory, destinationDirectory,[\s\S]*StringComparison\.OrdinalIgnoreCase\)[\s\S]*MoveFileExW\(temporaryFullPath, destinationFullPath,[\s\S]*MOVEFILE_REPLACE_EXISTING \| MOVEFILE_WRITE_THROUGH\)[\s\S]*Marshal\.GetLastWin32Error\(\)[\s\S]*new Win32Exception\(error/,