From 30b33a80802262d60e7c7687837236817e7f455f Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:57:46 +0000 Subject: [PATCH 01/29] fix(ai): Resolve issue #2041 - Bound every Windows installed-app harness stage an Implemented by ProPR AI using gpt-5.6-sol model. Implementation completed successfully. --- .github/workflows/desktop-release-guard.yml | 4 +- .../run-installed-windows-app-harness.ps1 | 217 +++++++ .../scripts/test-installed-windows-app.ps1 | 543 ++++++++++++++---- apps/desktop/src/release-workflow.test.ts | 147 ++++- 4 files changed, 787 insertions(+), 124 deletions(-) create mode 100644 apps/desktop/scripts/run-installed-windows-app-harness.ps1 diff --git a/.github/workflows/desktop-release-guard.yml b/.github/workflows/desktop-release-guard.yml index 3b5f93e63..b45fb07b7 100644 --- a/.github/workflows/desktop-release-guard.yml +++ b/.github/workflows/desktop-release-guard.yml @@ -186,7 +186,7 @@ jobs: run: | $installers = @(Get-ChildItem apps/desktop/out/make -Recurse -File -Filter '*Machine-Setup.msi') if ($installers.Count -ne 1) { throw 'Machine-wide Windows installer is missing or ambiguous' } - & apps/desktop/scripts/test-installed-windows-app.ps1 ` + & apps/desktop/scripts/run-installed-windows-app-harness.ps1 ` -Installer $installers[0].FullName ` -Architecture '${{ matrix.arch }}' "PROPR_DESKTOP_WINDOWS_INSTALLED_APP=1" | Out-File -FilePath $env:GITHUB_ENV -Append @@ -624,7 +624,7 @@ jobs: run: | $installers = @(Get-ChildItem apps/desktop/out/make -Recurse -File -Filter '*Machine-Setup.msi') if ($installers.Count -ne 1) { throw 'Signed machine-wide Windows installer is missing or ambiguous' } - & apps/desktop/scripts/test-installed-windows-app.ps1 ` + & apps/desktop/scripts/run-installed-windows-app-harness.ps1 ` -Installer $installers[0].FullName ` -Architecture '${{ matrix.arch }}' "PROPR_DESKTOP_WINDOWS_INSTALLED_APP=1" | Out-File -FilePath $env:GITHUB_ENV -Append diff --git a/apps/desktop/scripts/run-installed-windows-app-harness.ps1 b/apps/desktop/scripts/run-installed-windows-app-harness.ps1 new file mode 100644 index 000000000..21ae498a4 --- /dev/null +++ b/apps/desktop/scripts/run-installed-windows-app-harness.ps1 @@ -0,0 +1,217 @@ +param( + [Parameter(Mandatory=$true)][string]$Installer, + [Parameter(Mandatory=$true)][ValidateSet('x64','arm64')][string]$Architecture +) + +$ErrorActionPreference = 'Stop' +$watchdogPollMilliseconds = 250 +$watchdogTerminationMilliseconds = 30 * 1000 +$markerName = "propr-installed-app-watchdog-$([Guid]::NewGuid().ToString('N')).marker" +$markerPath = Join-Path ([IO.Path]::GetTempPath()) $markerName +$ownershipReadyEventName = "Local\ProPRInstalledApp-$([Guid]::NewGuid().ToString('N'))" +$workerPath = Join-Path $PSScriptRoot 'test-installed-windows-app.ps1' +$worker = $null +$job = $null +$ownershipReadyEvent = $null + +Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +public sealed class ProPRKillOnCloseJob : IDisposable +{ + [StructLayout(LayoutKind.Sequential)] + private struct JOBOBJECT_BASIC_LIMIT_INFORMATION + { + public long PerProcessUserTimeLimit; + public long PerJobUserTimeLimit; + public uint LimitFlags; + public UIntPtr MinimumWorkingSetSize; + public UIntPtr MaximumWorkingSetSize; + public uint ActiveProcessLimit; + public UIntPtr Affinity; + public uint PriorityClass; + public uint SchedulingClass; + } + + [StructLayout(LayoutKind.Sequential)] + private struct IO_COUNTERS + { + public ulong ReadOperationCount; + public ulong WriteOperationCount; + public ulong OtherOperationCount; + public ulong ReadTransferCount; + public ulong WriteTransferCount; + public ulong OtherTransferCount; + } + + [StructLayout(LayoutKind.Sequential)] + private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION + { + public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation; + public IO_COUNTERS IoInfo; + public UIntPtr ProcessMemoryLimit; + public UIntPtr JobMemoryLimit; + public UIntPtr PeakProcessMemoryUsed; + public UIntPtr PeakJobMemoryUsed; + } + + private const int JobObjectExtendedLimitInformation = 9; + private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000; + private SafeFileHandle handle; + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateJobObject(IntPtr attributes, string name); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool SetInformationJobObject( + SafeFileHandle job, + int informationClass, + IntPtr information, + uint informationLength); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool AssignProcessToJobObject(SafeFileHandle job, IntPtr process); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool TerminateJobObject(SafeFileHandle job, uint exitCode); + + public ProPRKillOnCloseJob() + { + handle = CreateJobObject(IntPtr.Zero, null); + if (handle == null || handle.IsInvalid) + throw new Win32Exception(Marshal.GetLastWin32Error(), "job creation failed"); + + var limits = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION(); + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + int size = Marshal.SizeOf(typeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION)); + IntPtr buffer = Marshal.AllocHGlobal(size); + try + { + Marshal.StructureToPtr(limits, buffer, false); + if (!SetInformationJobObject(handle, JobObjectExtendedLimitInformation, buffer, (uint)size)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "job configuration failed"); + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + + public void AddProcess(IntPtr processHandle) + { + if (!AssignProcessToJobObject(handle, processHandle)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "worker ownership failed"); + } + + public void Terminate(uint exitCode) + { + if (!handle.IsInvalid && !TerminateJobObject(handle, exitCode)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "job termination failed"); + } + + public void Dispose() + { + if (handle != null) handle.Dispose(); + } +} +'@ + +function Read-WatchdogMarker([string]$Path) { + try { + if (![IO.File]::Exists($Path)) { return $null } + $record = [IO.File]::ReadAllText($Path, [Text.Encoding]::ASCII) + if ($record -notmatch + '^(?[0-9]+)\|(?[A-Z_]+)\|(?[A-Z_]+)\|(?BEGIN|COMPLETE|FAILED)$') { + return $null + } + return [PSCustomObject]@{ + Deadline = [int64]$Matches.Deadline + Stage = $Matches.Stage + Substage = $Matches.Substage + Status = $Matches.Status + } + } catch { + return $null + } +} + +try { + $installerPath = (Resolve-Path -LiteralPath $Installer -ErrorAction Stop).Path + $workerPath = (Resolve-Path -LiteralPath $workerPath -ErrorAction Stop).Path + $hostPath = (Get-Process -Id $PID -ErrorAction Stop).Path + if ([IO.Path]::GetFileName($hostPath) -notin @('pwsh.exe', 'powershell.exe')) { + throw 'PowerShell host resolution failed' + } + + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $hostPath + $startInfo.UseShellExecute = $false + $ownershipReadyEvent = [Threading.EventWaitHandle]::new( + $false, + [Threading.EventResetMode]::ManualReset, + $ownershipReadyEventName + ) + foreach ($argument in @( + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-File', $workerPath, + '-Installer', $installerPath, + '-Architecture', $Architecture, + '-WatchdogMarker', $markerPath, + '-OwnershipReadyEvent', $ownershipReadyEventName + )) { + $startInfo.ArgumentList.Add($argument) + } + + $job = [ProPRKillOnCloseJob]::new() + $worker = [Diagnostics.Process]::new() + $worker.StartInfo = $startInfo + if (!$worker.Start()) { throw 'installed-app worker did not start' } + try { + $job.AddProcess($worker.Handle) + [void]$ownershipReadyEvent.Set() + } catch { + try { $worker.Kill($true) } catch {} + throw 'installed-app worker ownership failed' + } + + while (!$worker.WaitForExit($watchdogPollMilliseconds)) { + $marker = Read-WatchdogMarker $markerPath + if ($null -ne $marker -and [DateTime]::UtcNow.Ticks -gt $marker.Deadline) { + Write-Host ('PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:{0}:{1}:{2}:TIMED_OUT' -f ` + $marker.Stage, $marker.Substage, $marker.Status) + [Console]::Out.Flush() + $job.Terminate(124) + if (!$worker.WaitForExit($watchdogTerminationMilliseconds)) { + throw 'installed-app worker termination timed out' + } + exit 124 + } + } + + exit $worker.ExitCode +} catch { + $lastMarker = Read-WatchdogMarker $markerPath + if ($null -ne $lastMarker) { + Write-Host ('PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:{0}:{1}:{2}:ABORTED' -f ` + $lastMarker.Stage, $lastMarker.Substage, $lastMarker.Status) + [Console]::Out.Flush() + } + Write-Host 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:SUPERVISOR:FAILED' + [Console]::Out.Flush() + if ($null -ne $job) { + try { $job.Terminate(125) } catch {} + } + throw 'installed-app harness supervision failed' +} finally { + if ($null -ne $worker) { $worker.Dispose() } + if ($null -ne $job) { $job.Dispose() } + if ($null -ne $ownershipReadyEvent) { $ownershipReadyEvent.Dispose() } + try { + if ([IO.File]::Exists($markerPath)) { [IO.File]::Delete($markerPath) } + } catch {} +} diff --git a/apps/desktop/scripts/test-installed-windows-app.ps1 b/apps/desktop/scripts/test-installed-windows-app.ps1 index dc9b440e7..96d5e2072 100644 --- a/apps/desktop/scripts/test-installed-windows-app.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app.ps1 @@ -1,6 +1,8 @@ param( [Parameter(Mandatory=$true)][string]$Installer, - [Parameter(Mandatory=$true)][ValidateSet('x64','arm64')][string]$Architecture + [Parameter(Mandatory=$true)][ValidateSet('x64','arm64')][string]$Architecture, + [Parameter(Mandatory=$true)][string]$WatchdogMarker, + [Parameter(Mandatory=$true)][string]$OwnershipReadyEvent ) enum SmokeEvidenceInspectionPhase { @@ -14,6 +16,51 @@ enum SmokeEvidenceInspectionPhase { } $ErrorActionPreference = 'Stop' +$bootstrapWatchdogTimeoutMilliseconds = 60 * 1000 +$markerTransitionTimeoutMilliseconds = 30 * 1000 +$ownershipHandshakeTimeoutMilliseconds = 5 * 1000 +if ($OwnershipReadyEvent -notmatch '^Local\\ProPRInstalledApp-[a-f0-9]{32}$') { + throw 'worker ownership event name is invalid' +} +$ownershipReady = [Threading.EventWaitHandle]::OpenExisting($OwnershipReadyEvent) +try { + if (!$ownershipReady.WaitOne($ownershipHandshakeTimeoutMilliseconds)) { + throw 'worker ownership was not established' + } +} finally { + $ownershipReady.Dispose() +} +$watchdogMarkerPath = [IO.Path]::GetFullPath($WatchdogMarker) +$watchdogMarkerParent = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') +if ((Split-Path -Leaf $watchdogMarkerPath) -notmatch + '^propr-installed-app-watchdog-[a-f0-9]{32}\.marker$' -or + ![string]::Equals( + (Split-Path -Parent $watchdogMarkerPath).TrimEnd('\'), + $watchdogMarkerParent, + [StringComparison]::OrdinalIgnoreCase + )) { + throw 'watchdog marker path is invalid' +} +$bootstrapDeadline = [DateTime]::UtcNow.AddMilliseconds($bootstrapWatchdogTimeoutMilliseconds).Ticks +$bootstrapRecord = '{0}|INITIALIZATION|PATHS|BEGIN' -f $bootstrapDeadline +$bootstrapBytes = [Text.Encoding]::ASCII.GetBytes($bootstrapRecord) +$bootstrapStream = [IO.FileStream]::new( + $watchdogMarkerPath, + [IO.FileMode]::CreateNew, + [IO.FileAccess]::Write, + [IO.FileShare]::Read, + 4096, + [IO.FileOptions]::WriteThrough +) +try { + $bootstrapStream.Write($bootstrapBytes, 0, $bootstrapBytes.Length) + $bootstrapStream.Flush($true) +} finally { + $bootstrapStream.Dispose() +} +Write-Host 'PROPR_WINDOWS_INSTALLED_SMOKE:OPERATION:INITIALIZATION:PATHS:BEGIN' +[Console]::Out.Flush() + $primaryFailure = $null try { $installerPath = (Resolve-Path -LiteralPath $Installer -ErrorAction Stop).Path @@ -25,14 +72,23 @@ $application = Join-Path $installRoot 'propr-desktop.exe' $testUser = "propr-ci-$([Guid]::NewGuid().ToString('N').Substring(0,8))" $passwordText = "P!$([Guid]::NewGuid().ToString('N'))a7" $password = ConvertTo-SecureString $passwordText -AsPlainText -Force +$passwordText = $null $credential = New-Object Management.Automation.PSCredential("$env:COMPUTERNAME\$testUser", $password) $installAttempted = $false +$testUserCreatedByRun = $false $testUserSid = $null $smokeUserDataDirectory = $null +$installRootExistedBeforeInstall = $false +$protocolExistedBeforeInstall = $false +$installRootCreatedByRun = $false +$protocolCreatedByRun = $false $msiTimeoutMilliseconds = 10 * 60 * 1000 $applicationTimeoutMilliseconds = 5 * 60 * 1000 $terminationTimeoutMilliseconds = 30 * 1000 $redirectedStreamDrainTimeoutMilliseconds = 30 * 1000 +$externalOperationTimeoutMilliseconds = 60 * 1000 +$recursiveOperationTimeoutMilliseconds = 90 * 1000 +$alternateUserLaunchTimeoutMilliseconds = 90 * 1000 $smokeEvidenceFileByteCap = 64 * 1024 $smokeEvidenceOpenRetryDeadlineMilliseconds = 2 * 1000 $smokeEvidenceOpenRetryDelayMilliseconds = 50 @@ -84,12 +140,99 @@ if (!$commonPrograms -or ![IO.Path]::IsPathRooted($commonPrograms)) { $commonPrograms = (Resolve-Path -LiteralPath $commonPrograms -ErrorAction Stop).Path $startMenuShortcutFolder = Join-Path $commonPrograms 'ProPR Desktop' $startMenuShortcut = Join-Path $startMenuShortcutFolder 'ProPR Desktop.lnk' +$installRootExistedBeforeInstall = Test-Path -LiteralPath $installRoot +$protocolExistedBeforeInstall = + Test-Path -LiteralPath 'Registry::HKEY_LOCAL_MACHINE\Software\Classes\propr' $startMenuShortcutExistedBeforeInstall = Test-Path -LiteralPath $startMenuShortcut $startMenuShortcutFolderExistedBeforeInstall = Test-Path -LiteralPath $startMenuShortcutFolder $startMenuShortcutCreatedByRun = $false $startMenuShortcutFolderCreatedByRun = $false $shortcutFileByteCap = 64 * 1024 +function Write-WatchdogMarker( + [ValidateSet('INITIALIZATION','INSTALL','VALIDATION','USER_SETUP','APP_LAUNCH','APP_EXIT','UNINSTALL','CLEANUP')] + [string]$Stage, + [ValidateSet( + 'PATHS', + 'BASELINE', + 'MSI_INSTALL', + 'OWNERSHIP_CAPTURE', + 'INSTALL_TREE_SCAN', + 'APPLICATION_IMAGE', + 'PROTOCOL_ASSERTION', + 'SHORTCUT_ASSERTION', + 'USER_CREATE', + 'USER_SID', + 'SMOKE_DATA_CREATE', + 'SHORTCUT_PRESENT_PROBE', + 'ALTERNATE_USER_START', + 'APPLICATION_WAIT', + 'STREAM_DRAIN', + 'EVIDENCE_INSPECTION', + 'MSI_UNINSTALL', + 'INSTALL_TREE_ASSERTION', + 'PROTOCOL_ABSENCE_ASSERTION', + 'SHORTCUT_FILE_ASSERTION', + 'SHORTCUT_FOLDER_ASSERTION', + 'SHORTCUT_ABSENCE_PROBE', + 'SMOKE_DATA_REMOVE', + 'PROFILE_LOOKUP', + 'PROFILE_REMOVE', + 'USER_LOOKUP', + 'USER_REMOVE', + 'INSTALL_ROOT_FALLBACK', + 'PROTOCOL_FALLBACK', + 'SHORTCUT_FALLBACK' + )][string]$Substage, + [int]$TimeoutMilliseconds, + [ValidateSet('BEGIN','COMPLETE','FAILED')][string]$Status +) { + $deadline = if ($Status -eq 'BEGIN') { + [DateTime]::UtcNow.AddMilliseconds($TimeoutMilliseconds).Ticks + } else { + [DateTime]::UtcNow.AddMilliseconds($markerTransitionTimeoutMilliseconds).Ticks + } + $record = '{0}|{1}|{2}|{3}' -f $deadline, $Stage, $Substage, $Status + $temporaryMarker = "$watchdogMarkerPath.$PID.new" + $bytes = [Text.Encoding]::ASCII.GetBytes($record) + $stream = $null + try { + $stream = [IO.FileStream]::new( + $temporaryMarker, + [IO.FileMode]::Create, + [IO.FileAccess]::Write, + [IO.FileShare]::None, + 4096, + [IO.FileOptions]::WriteThrough + ) + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush($true) + } finally { + if ($null -ne $stream) { $stream.Dispose() } + } + [IO.File]::Move($temporaryMarker, $watchdogMarkerPath, $true) + Write-Host ('PROPR_WINDOWS_INSTALLED_SMOKE:OPERATION:{0}:{1}:{2}' -f ` + $Stage, $Substage, $Status) + [Console]::Out.Flush() +} + +function Invoke-BoundedExternalOperation( + [string]$Stage, + [string]$Substage, + [int]$TimeoutMilliseconds, + [scriptblock]$Operation +) { + Write-WatchdogMarker $Stage $Substage $TimeoutMilliseconds 'BEGIN' + try { + $result = & $Operation + Write-WatchdogMarker $Stage $Substage $TimeoutMilliseconds 'COMPLETE' + return $result + } catch { + Write-WatchdogMarker $Stage $Substage $TimeoutMilliseconds 'FAILED' + throw + } +} + Add-Type -TypeDefinition @' using System; using System.Runtime.InteropServices; @@ -113,11 +256,25 @@ public static class ProPRWindowsLogon } '@ +Write-WatchdogMarker 'INITIALIZATION' 'PATHS' $bootstrapWatchdogTimeoutMilliseconds 'COMPLETE' +Write-WatchdogMarker 'INITIALIZATION' 'BASELINE' $externalOperationTimeoutMilliseconds 'BEGIN' +try { + if ($installRootExistedBeforeInstall -or $protocolExistedBeforeInstall -or + $startMenuShortcutExistedBeforeInstall -or $startMenuShortcutFolderExistedBeforeInstall) { + throw 'installed-app harness requires an unowned clean machine baseline' + } + Write-WatchdogMarker 'INITIALIZATION' 'BASELINE' $externalOperationTimeoutMilliseconds 'COMPLETE' +} catch { + Write-WatchdogMarker 'INITIALIZATION' 'BASELINE' $externalOperationTimeoutMilliseconds 'FAILED' + throw +} + function Write-Stage( [ValidateSet('INSTALL','VALIDATION','USER_SETUP','APP_LAUNCH','APP_EXIT','UNINSTALL','CLEANUP')][string]$Stage, [ValidateSet('BEGIN','COMPLETE','FAILED')][string]$Status ) { Write-Host ('PROPR_WINDOWS_INSTALLED_SMOKE:{0}:{1}' -f $Stage, $Status) + [Console]::Out.Flush() } function Write-CleanupSubstage( @@ -140,6 +297,7 @@ function Write-CleanupSubstage( [ValidateSet('BEGIN','COMPLETE','FAILED','SKIPPED')][string]$Status ) { Write-Host ('PROPR_WINDOWS_INSTALLED_SMOKE:{0}:{1}:{2}' -f $Scope, $Substage, $Status) + [Console]::Out.Flush() } function Stop-SpawnedProcessTree( @@ -496,8 +654,13 @@ function Test-StartMenuShortcutAsOrdinaryUser( function New-SmokeUserDataDirectory([Security.Principal.SecurityIdentifier]$UserSid) { $path = Join-Path $machineTemp "propr-desktop-smoke-$([Guid]::NewGuid().ToString('N'))" - New-Item -ItemType Directory -Path $path | Out-Null + $createdByRun = $false try { + if (Test-Path -LiteralPath $path) { + throw 'refusing to replace a pre-existing smoke user-data directory' + } + New-Item -ItemType Directory -Path $path -ErrorAction Stop | Out-Null + $createdByRun = $true $administratorsSid = New-Object Security.Principal.SecurityIdentifier('S-1-5-32-544') $systemSid = New-Object Security.Principal.SecurityIdentifier('S-1-5-18') $acl = New-Object Security.AccessControl.DirectorySecurity @@ -534,7 +697,9 @@ function New-SmokeUserDataDirectory([Security.Principal.SecurityIdentifier]$User } return $path } catch { - Remove-Item -LiteralPath $path -Recurse -Force -ErrorAction SilentlyContinue + if ($createdByRun) { + Remove-Item -LiteralPath $path -Recurse -Force -ErrorAction SilentlyContinue + } throw } } @@ -546,6 +711,13 @@ function Remove-SmokeUserDataDirectory([string]$Path) { ![string]::Equals((Split-Path -Parent $fullPath), $machineTemp, [StringComparison]::OrdinalIgnoreCase)) { throw 'refusing to clean a directory outside the bounded smoke user-data scope' } + if (Test-Path -LiteralPath $fullPath) { + $ownedDirectory = Get-Item -LiteralPath $fullPath -Force -ErrorAction Stop + if (!$ownedDirectory.PSIsContainer -or + ($ownedDirectory.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'refusing to clean an invalid smoke user-data directory' + } + } for ($attempt = 0; $attempt -lt 3; $attempt += 1) { if (!(Test-Path -LiteralPath $fullPath)) { return } try { @@ -708,12 +880,30 @@ try { try { $installAttempted = $true try { - Invoke-Msi @('/i', "`"$installerPath`"", '/qn', '/norestart') 'machine install' + Invoke-BoundedExternalOperation ` + -Stage 'INSTALL' ` + -Substage 'MSI_INSTALL' ` + -TimeoutMilliseconds ($msiTimeoutMilliseconds + $terminationTimeoutMilliseconds + 5000) ` + -Operation { + Invoke-Msi @('/i', "`"$installerPath`"", '/qn', '/norestart') 'machine install' + } } finally { - $startMenuShortcutCreatedByRun = - !$startMenuShortcutExistedBeforeInstall -and (Test-Path -LiteralPath $startMenuShortcut) - $startMenuShortcutFolderCreatedByRun = - !$startMenuShortcutFolderExistedBeforeInstall -and (Test-Path -LiteralPath $startMenuShortcutFolder) + Invoke-BoundedExternalOperation ` + -Stage 'INSTALL' ` + -Substage 'OWNERSHIP_CAPTURE' ` + -TimeoutMilliseconds $externalOperationTimeoutMilliseconds ` + -Operation { + $script:installRootCreatedByRun = + !$installRootExistedBeforeInstall -and (Test-Path -LiteralPath $installRoot) + $script:protocolCreatedByRun = + !$protocolExistedBeforeInstall -and + (Test-Path -LiteralPath 'Registry::HKEY_LOCAL_MACHINE\Software\Classes\propr') + $script:startMenuShortcutCreatedByRun = + !$startMenuShortcutExistedBeforeInstall -and (Test-Path -LiteralPath $startMenuShortcut) + $script:startMenuShortcutFolderCreatedByRun = + !$startMenuShortcutFolderExistedBeforeInstall -and + (Test-Path -LiteralPath $startMenuShortcutFolder) + } } Write-Stage 'INSTALL' 'COMPLETE' } catch { @@ -723,36 +913,53 @@ try { Write-Stage 'VALIDATION' 'BEGIN' try { - if (!(Test-Path -LiteralPath $application -PathType Leaf)) { - throw 'machine installer did not install the canonical application' - } - $forbidden = @(Get-ChildItem -LiteralPath $installRoot -Recurse -Force | Where-Object { - $_.Name -match '^propr-windows-(authority|launcher|bootstrap)' -or - $_.Name -in @('windows-authority', 'windows-update-authority') - }) - if ($forbidden.Count -ne 0) { throw 'installed MVP contains a deferred Windows update authority resource' } + Invoke-BoundedExternalOperation 'VALIDATION' 'INSTALL_TREE_SCAN' ` + $recursiveOperationTimeoutMilliseconds { + if (!(Test-Path -LiteralPath $application -PathType Leaf)) { + throw 'machine installer did not install the canonical application' + } + $forbidden = @(Get-ChildItem -LiteralPath $installRoot -Recurse -Force | Where-Object { + $_.Name -match '^propr-windows-(authority|launcher|bootstrap)' -or + $_.Name -in @('windows-authority', 'windows-update-authority') + }) + if ($forbidden.Count -ne 0) { + throw 'installed MVP contains a deferred Windows update authority resource' + } + } - $image = New-Object byte[] 4096 - $stream = [IO.File]::OpenRead($application) - try { $imageLength = $stream.Read($image, 0, $image.Length) } finally { $stream.Dispose() } - $pe = if ($imageLength -ge 64) { [BitConverter]::ToUInt32($image, 0x3c) } else { 0 } - $expectedMachine = if ($Architecture -eq 'arm64') { 0xaa64 } else { 0x8664 } - if ($imageLength -lt 512 -or [BitConverter]::ToUInt16($image, 0) -ne 0x5a4d -or - $pe + 6 -gt $imageLength -or [Text.Encoding]::ASCII.GetString($image, [int]$pe, 4) -cne "PE`0`0" -or - [BitConverter]::ToUInt16($image, [int]$pe + 4) -ne $expectedMachine) { - throw 'installed application architecture does not match the matrix target' - } + Invoke-BoundedExternalOperation 'VALIDATION' 'APPLICATION_IMAGE' ` + $externalOperationTimeoutMilliseconds { + $image = New-Object byte[] 4096 + $stream = [IO.File]::OpenRead($application) + try { $imageLength = $stream.Read($image, 0, $image.Length) } finally { $stream.Dispose() } + $pe = if ($imageLength -ge 64) { [BitConverter]::ToUInt32($image, 0x3c) } else { 0 } + $expectedMachine = if ($Architecture -eq 'arm64') { 0xaa64 } else { 0x8664 } + if ($imageLength -lt 512 -or [BitConverter]::ToUInt16($image, 0) -ne 0x5a4d -or + $pe + 6 -gt $imageLength -or + [Text.Encoding]::ASCII.GetString($image, [int]$pe, 4) -cne "PE`0`0" -or + [BitConverter]::ToUInt16($image, [int]$pe + 4) -ne $expectedMachine) { + throw 'installed application architecture does not match the matrix target' + } + } - $protocolCommand = (Get-Item -LiteralPath 'Registry::HKEY_LOCAL_MACHINE\Software\Classes\propr\shell\open\command').GetValue('') - if ($protocolCommand -cne "`"$application`" `"%1`"") { - throw 'machine installer did not register canonical ProPR Connect protocol discovery' - } - $shortcutItem = Get-Item -LiteralPath $startMenuShortcut -Force -ErrorAction Stop - if (!($shortcutItem -is [IO.FileInfo]) -or - ($shortcutItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or - $shortcutItem.Length -le 0) { - throw 'machine installer did not create the common Start Menu shortcut' - } + Invoke-BoundedExternalOperation 'VALIDATION' 'PROTOCOL_ASSERTION' ` + $externalOperationTimeoutMilliseconds { + $protocolCommand = (Get-Item -LiteralPath ` + 'Registry::HKEY_LOCAL_MACHINE\Software\Classes\propr\shell\open\command').GetValue('') + if ($protocolCommand -cne "`"$application`" `"%1`"") { + throw 'machine installer did not register canonical ProPR Connect protocol discovery' + } + } + + Invoke-BoundedExternalOperation 'VALIDATION' 'SHORTCUT_ASSERTION' ` + $externalOperationTimeoutMilliseconds { + $shortcutItem = Get-Item -LiteralPath $startMenuShortcut -Force -ErrorAction Stop + if (!($shortcutItem -is [IO.FileInfo]) -or + ($shortcutItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + $shortcutItem.Length -le 0) { + throw 'machine installer did not create the common Start Menu shortcut' + } + } Write-Stage 'VALIDATION' 'COMPLETE' } catch { Write-Stage 'VALIDATION' 'FAILED' @@ -761,16 +968,33 @@ try { Write-Stage 'USER_SETUP' 'BEGIN' try { - New-LocalUser -Name $testUser -Password $password -AccountNeverExpires -PasswordNeverExpires | Out-Null - $testUserSid = (Get-LocalUser -Name $testUser).SID - $smokeUserDataDirectory = New-SmokeUserDataDirectory $testUserSid - Test-StartMenuShortcutAsOrdinaryUser ` - -Credential $credential ` - -Domain $env:COMPUTERNAME ` - -UserName $testUser ` - -UserSid $testUserSid ` - -ShortcutPath $startMenuShortcut ` - -ExpectedPresent $true + Invoke-BoundedExternalOperation 'USER_SETUP' 'USER_CREATE' ` + $externalOperationTimeoutMilliseconds { + if (Get-LocalUser -Name $testUser -ErrorAction SilentlyContinue) { + throw 'refusing to replace a pre-existing local user' + } + New-LocalUser -Name $testUser -Password $password ` + -AccountNeverExpires -PasswordNeverExpires | Out-Null + $script:testUserCreatedByRun = $true + } + $testUserSid = Invoke-BoundedExternalOperation 'USER_SETUP' 'USER_SID' ` + $externalOperationTimeoutMilliseconds { + (Get-LocalUser -Name $testUser -ErrorAction Stop).SID + } + $smokeUserDataDirectory = Invoke-BoundedExternalOperation ` + 'USER_SETUP' 'SMOKE_DATA_CREATE' $recursiveOperationTimeoutMilliseconds { + New-SmokeUserDataDirectory $testUserSid + } + Invoke-BoundedExternalOperation ` + 'USER_SETUP' 'SHORTCUT_PRESENT_PROBE' $externalOperationTimeoutMilliseconds { + Test-StartMenuShortcutAsOrdinaryUser ` + -Credential $credential ` + -Domain $env:COMPUTERNAME ` + -UserName $testUser ` + -UserSid $testUserSid ` + -ShortcutPath $startMenuShortcut ` + -ExpectedPresent $true + } Write-Stage 'USER_SETUP' 'COMPLETE' } catch { Write-Stage 'USER_SETUP' 'FAILED' @@ -786,18 +1010,21 @@ try { Write-Stage 'APP_LAUNCH' 'BEGIN' $applicationLaunch = $null try { - $applicationLaunch = Start-AlternateCredentialApplication ` - -FilePath $application ` - -Arguments $arguments ` - -Credential $credential ` - -Domain $env:COMPUTERNAME ` - -UserName $testUser ` - -WorkingDirectory $env:ProgramFiles ` - -SmokeDirectory $smokeUserDataDirectory ` - -WindowsDirectory $windowsDirectory ` - -StandardOutputPath (Join-Path $smokeUserDataDirectory 'application.stdout.log') ` - -StandardErrorPath (Join-Path $smokeUserDataDirectory 'application.stderr.log') ` - -Operation 'ordinary-user installed application launch/render/profile smoke' + $applicationLaunch = Invoke-BoundedExternalOperation ` + 'APP_LAUNCH' 'ALTERNATE_USER_START' $alternateUserLaunchTimeoutMilliseconds { + Start-AlternateCredentialApplication ` + -FilePath $application ` + -Arguments $arguments ` + -Credential $credential ` + -Domain $env:COMPUTERNAME ` + -UserName $testUser ` + -WorkingDirectory $env:ProgramFiles ` + -SmokeDirectory $smokeUserDataDirectory ` + -WindowsDirectory $windowsDirectory ` + -StandardOutputPath (Join-Path $smokeUserDataDirectory 'application.stdout.log') ` + -StandardErrorPath (Join-Path $smokeUserDataDirectory 'application.stderr.log') ` + -Operation 'ordinary-user installed application launch/render/profile smoke' + } Write-Stage 'APP_LAUNCH' 'COMPLETE' } catch { Write-Stage 'APP_LAUNCH' 'FAILED' @@ -807,17 +1034,24 @@ try { try { $waitFailure = $null try { - [void](Wait-BoundedProcess ` - -Process $applicationLaunch.Process ` - -TimeoutMilliseconds $applicationTimeoutMilliseconds ` - -AllowedExitCodes @(0) ` - -Operation 'ordinary-user installed application launch/render/profile smoke') + Invoke-BoundedExternalOperation ` + 'APP_EXIT' 'APPLICATION_WAIT' ` + ($applicationTimeoutMilliseconds + $terminationTimeoutMilliseconds + 5000) { + [void](Wait-BoundedProcess ` + -Process $applicationLaunch.Process ` + -TimeoutMilliseconds $applicationTimeoutMilliseconds ` + -AllowedExitCodes @(0) ` + -Operation 'ordinary-user installed application launch/render/profile smoke') + } } catch { $waitFailure = $_ } finally { try { - Close-RedirectedApplicationStreams $applicationLaunch ` - 'ordinary-user installed application launch/render/profile smoke' + Invoke-BoundedExternalOperation ` + 'APP_EXIT' 'STREAM_DRAIN' ($redirectedStreamDrainTimeoutMilliseconds + 5000) { + Close-RedirectedApplicationStreams $applicationLaunch ` + 'ordinary-user installed application launch/render/profile smoke' + } } catch { if ($null -eq $waitFailure) { $waitFailure = $_ } } finally { @@ -825,7 +1059,10 @@ try { $applicationLaunch = $null } } - $smokeEvidence = Get-SmokeEventEvidence $smokeUserDataDirectory $testUserSid + $smokeEvidence = Invoke-BoundedExternalOperation ` + 'APP_EXIT' 'EVIDENCE_INSPECTION' $externalOperationTimeoutMilliseconds { + Get-SmokeEventEvidence $smokeUserDataDirectory $testUserSid + } if ($null -ne $waitFailure) { throw $waitFailure } if (@($requiredSmokeEvents | Where-Object { !$smokeEvidence[$_] }).Count -ne 0) { throw 'SMOKE_REQUIRED_EVENTS_MISSING' @@ -836,8 +1073,13 @@ try { throw } finally { if ($null -ne $applicationLaunch) { - try { Close-RedirectedApplicationStreams $applicationLaunch ` - 'ordinary-user installed application launch/render/profile smoke' } finally { + try { + Invoke-BoundedExternalOperation ` + 'APP_EXIT' 'STREAM_DRAIN' ($redirectedStreamDrainTimeoutMilliseconds + 5000) { + Close-RedirectedApplicationStreams $applicationLaunch ` + 'ordinary-user installed application launch/render/profile smoke' + } + } finally { $applicationLaunch.Process.Dispose() } } @@ -853,7 +1095,11 @@ try { Write-CleanupSubstage 'UNINSTALL' 'MSI_UNINSTALL' 'BEGIN' try { - Invoke-Msi @('/x', "`"$installerPath`"", '/qn', '/norestart') 'machine uninstall' + Invoke-BoundedExternalOperation ` + 'UNINSTALL' 'MSI_UNINSTALL' ` + ($msiTimeoutMilliseconds + $terminationTimeoutMilliseconds + 5000) { + Invoke-Msi @('/x', "`"$installerPath`"", '/qn', '/norestart') 'machine uninstall' + } Write-CleanupSubstage 'UNINSTALL' 'MSI_UNINSTALL' 'COMPLETE' } catch { Write-CleanupSubstage 'UNINSTALL' 'MSI_UNINSTALL' 'FAILED' @@ -862,7 +1108,12 @@ try { Write-CleanupSubstage 'UNINSTALL' 'INSTALL_TREE' 'BEGIN' try { - if (Test-Path -LiteralPath $installRoot) { throw 'machine uninstall left the canonical install tree behind' } + Invoke-BoundedExternalOperation ` + 'UNINSTALL' 'INSTALL_TREE_ASSERTION' $externalOperationTimeoutMilliseconds { + if (Test-Path -LiteralPath $installRoot) { + throw 'machine uninstall left the canonical install tree behind' + } + } Write-CleanupSubstage 'UNINSTALL' 'INSTALL_TREE' 'COMPLETE' } catch { Write-CleanupSubstage 'UNINSTALL' 'INSTALL_TREE' 'FAILED' @@ -871,9 +1122,12 @@ try { Write-CleanupSubstage 'UNINSTALL' 'PROTOCOL' 'BEGIN' try { - if (Test-Path -LiteralPath 'Registry::HKEY_LOCAL_MACHINE\Software\Classes\propr') { - throw 'machine uninstall left protocol discovery metadata behind' - } + Invoke-BoundedExternalOperation ` + 'UNINSTALL' 'PROTOCOL_ABSENCE_ASSERTION' $externalOperationTimeoutMilliseconds { + if (Test-Path -LiteralPath 'Registry::HKEY_LOCAL_MACHINE\Software\Classes\propr') { + throw 'machine uninstall left protocol discovery metadata behind' + } + } Write-CleanupSubstage 'UNINSTALL' 'PROTOCOL' 'COMPLETE' } catch { Write-CleanupSubstage 'UNINSTALL' 'PROTOCOL' 'FAILED' @@ -882,9 +1136,12 @@ try { Write-CleanupSubstage 'UNINSTALL' 'SHORTCUT_FILE' 'BEGIN' try { - if (Test-Path -LiteralPath $startMenuShortcut) { - throw 'machine uninstall left the common Start Menu shortcut behind' - } + Invoke-BoundedExternalOperation ` + 'UNINSTALL' 'SHORTCUT_FILE_ASSERTION' $externalOperationTimeoutMilliseconds { + if (Test-Path -LiteralPath $startMenuShortcut) { + throw 'machine uninstall left the common Start Menu shortcut behind' + } + } Write-CleanupSubstage 'UNINSTALL' 'SHORTCUT_FILE' 'COMPLETE' } catch { Write-CleanupSubstage 'UNINSTALL' 'SHORTCUT_FILE' 'FAILED' @@ -893,9 +1150,12 @@ try { Write-CleanupSubstage 'UNINSTALL' 'SHORTCUT_FOLDER' 'BEGIN' try { - if (Test-Path -LiteralPath $startMenuShortcutFolder) { - throw 'machine uninstall left the common Start Menu folder behind' - } + Invoke-BoundedExternalOperation ` + 'UNINSTALL' 'SHORTCUT_FOLDER_ASSERTION' $externalOperationTimeoutMilliseconds { + if (Test-Path -LiteralPath $startMenuShortcutFolder) { + throw 'machine uninstall left the common Start Menu folder behind' + } + } Write-CleanupSubstage 'UNINSTALL' 'SHORTCUT_FOLDER' 'COMPLETE' } catch { Write-CleanupSubstage 'UNINSTALL' 'SHORTCUT_FOLDER' 'FAILED' @@ -905,13 +1165,16 @@ try { if ($null -ne $testUserSid) { Write-CleanupSubstage 'UNINSTALL' 'ORDINARY_USER_ABSENCE_PROBE' 'BEGIN' try { - Test-StartMenuShortcutAsOrdinaryUser ` - -Credential $credential ` - -Domain $env:COMPUTERNAME ` - -UserName $testUser ` - -UserSid $testUserSid ` - -ShortcutPath $startMenuShortcut ` - -ExpectedPresent $false + Invoke-BoundedExternalOperation ` + 'UNINSTALL' 'SHORTCUT_ABSENCE_PROBE' $externalOperationTimeoutMilliseconds { + Test-StartMenuShortcutAsOrdinaryUser ` + -Credential $credential ` + -Domain $env:COMPUTERNAME ` + -UserName $testUser ` + -UserSid $testUserSid ` + -ShortcutPath $startMenuShortcut ` + -ExpectedPresent $false + } Write-CleanupSubstage 'UNINSTALL' 'ORDINARY_USER_ABSENCE_PROBE' 'COMPLETE' } catch { Write-CleanupSubstage 'UNINSTALL' 'ORDINARY_USER_ABSENCE_PROBE' 'FAILED' @@ -932,7 +1195,10 @@ try { Write-Stage 'CLEANUP' 'BEGIN' Write-CleanupSubstage 'CLEANUP' 'SMOKE_DATA' 'BEGIN' try { - Remove-SmokeUserDataDirectory $smokeUserDataDirectory + Invoke-BoundedExternalOperation ` + 'CLEANUP' 'SMOKE_DATA_REMOVE' $recursiveOperationTimeoutMilliseconds { + Remove-SmokeUserDataDirectory $smokeUserDataDirectory + } Write-CleanupSubstage 'CLEANUP' 'SMOKE_DATA' 'COMPLETE' } catch { Write-CleanupSubstage 'CLEANUP' 'SMOKE_DATA' 'FAILED' @@ -941,11 +1207,22 @@ try { Write-CleanupSubstage 'CLEANUP' 'PROFILE' 'BEGIN' try { - if ($null -ne $testUserSid) { - $profiles = @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop | Where-Object { - $_.SID -eq $testUserSid.Value - }) - foreach ($profile in $profiles) { Remove-CimInstance -InputObject $profile -ErrorAction Stop } + if ($testUserCreatedByRun -and $null -ne $testUserSid) { + $profiles = @(Invoke-BoundedExternalOperation ` + 'CLEANUP' 'PROFILE_LOOKUP' $externalOperationTimeoutMilliseconds { + @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop | Where-Object { + $_.SID -eq $testUserSid.Value + }) + }) + Invoke-BoundedExternalOperation ` + 'CLEANUP' 'PROFILE_REMOVE' $recursiveOperationTimeoutMilliseconds { + foreach ($profile in $profiles) { + if ($profile.SID -ne $testUserSid.Value) { + throw 'refusing to remove a profile not owned by the test user' + } + Remove-CimInstance -InputObject $profile -ErrorAction Stop + } + } } Write-CleanupSubstage 'CLEANUP' 'PROFILE' 'COMPLETE' } catch { @@ -955,8 +1232,23 @@ try { Write-CleanupSubstage 'CLEANUP' 'USER' 'BEGIN' try { - if (Get-LocalUser -Name $testUser -ErrorAction SilentlyContinue) { - Remove-LocalUser -Name $testUser -ErrorAction Stop + if ($testUserCreatedByRun -and $null -ne $testUserSid) { + $ownedUser = Invoke-BoundedExternalOperation ` + 'CLEANUP' 'USER_LOOKUP' $externalOperationTimeoutMilliseconds { + Get-LocalUser -Name $testUser -ErrorAction SilentlyContinue + } + if ($null -ne $ownedUser) { + if (!$ownedUser.SID.Equals($testUserSid)) { + throw 'refusing to remove a local user with a mismatched SID' + } + Invoke-BoundedExternalOperation ` + 'CLEANUP' 'USER_REMOVE' $externalOperationTimeoutMilliseconds { + Remove-LocalUser -Name $testUser -ErrorAction Stop + if (Get-LocalUser -Name $testUser -ErrorAction SilentlyContinue) { + throw 'test local user cleanup did not complete' + } + } + } } Write-CleanupSubstage 'CLEANUP' 'USER' 'COMPLETE' } catch { @@ -966,9 +1258,17 @@ try { Write-CleanupSubstage 'CLEANUP' 'INSTALL_ROOT_FALLBACK' 'BEGIN' try { - if (Test-Path -LiteralPath $installRoot) { - Remove-Item -LiteralPath $installRoot -Recurse -Force -ErrorAction Stop - } + Invoke-BoundedExternalOperation ` + 'CLEANUP' 'INSTALL_ROOT_FALLBACK' $recursiveOperationTimeoutMilliseconds { + if ($installRootCreatedByRun -and (Test-Path -LiteralPath $installRoot)) { + $ownedInstallRoot = Get-Item -LiteralPath $installRoot -Force -ErrorAction Stop + if (!$ownedInstallRoot.PSIsContainer -or + ($ownedInstallRoot.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'refusing to remove an invalid owned install tree' + } + Remove-Item -LiteralPath $installRoot -Recurse -Force -ErrorAction Stop + } + } Write-CleanupSubstage 'CLEANUP' 'INSTALL_ROOT_FALLBACK' 'COMPLETE' } catch { Write-CleanupSubstage 'CLEANUP' 'INSTALL_ROOT_FALLBACK' 'FAILED' @@ -977,9 +1277,15 @@ try { Write-CleanupSubstage 'CLEANUP' 'PROTOCOL_FALLBACK' 'BEGIN' try { - if (Test-Path -LiteralPath 'Registry::HKEY_LOCAL_MACHINE\Software\Classes\propr') { - Remove-Item -LiteralPath 'Registry::HKEY_LOCAL_MACHINE\Software\Classes\propr' -Recurse -Force -ErrorAction Stop - } + Invoke-BoundedExternalOperation ` + 'CLEANUP' 'PROTOCOL_FALLBACK' $externalOperationTimeoutMilliseconds { + if ($protocolCreatedByRun -and + (Test-Path -LiteralPath 'Registry::HKEY_LOCAL_MACHINE\Software\Classes\propr')) { + Remove-Item -LiteralPath ` + 'Registry::HKEY_LOCAL_MACHINE\Software\Classes\propr' ` + -Recurse -Force -ErrorAction Stop + } + } Write-CleanupSubstage 'CLEANUP' 'PROTOCOL_FALLBACK' 'COMPLETE' } catch { Write-CleanupSubstage 'CLEANUP' 'PROTOCOL_FALLBACK' 'FAILED' @@ -989,24 +1295,27 @@ try { Write-CleanupSubstage 'CLEANUP' 'SHORTCUT_FALLBACK' 'BEGIN' $shortcutFallbackFailed = $false try { - if ($startMenuShortcutCreatedByRun -and (Test-Path -LiteralPath $startMenuShortcut)) { - Remove-Item -LiteralPath $startMenuShortcut -Force -ErrorAction Stop - } - } catch { - $shortcutFallbackFailed = $true - } - try { - if ($startMenuShortcutFolderCreatedByRun -and (Test-Path -LiteralPath $startMenuShortcutFolder)) { - $ownedShortcutFolder = Get-Item -LiteralPath $startMenuShortcutFolder -Force -ErrorAction Stop - if (!$ownedShortcutFolder.PSIsContainer -or - ($ownedShortcutFolder.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { - throw 'owned common Start Menu folder is invalid' - } - $ownedShortcutFolderContents = @(Get-ChildItem -LiteralPath $startMenuShortcutFolder -Force -ErrorAction Stop) - if ($ownedShortcutFolderContents.Count -eq 0) { - Remove-Item -LiteralPath $startMenuShortcutFolder -Force -ErrorAction Stop + Invoke-BoundedExternalOperation ` + 'CLEANUP' 'SHORTCUT_FALLBACK' $externalOperationTimeoutMilliseconds { + if ($startMenuShortcutCreatedByRun -and (Test-Path -LiteralPath $startMenuShortcut)) { + Remove-Item -LiteralPath $startMenuShortcut -Force -ErrorAction Stop + } + if ($startMenuShortcutFolderCreatedByRun -and + (Test-Path -LiteralPath $startMenuShortcutFolder)) { + $ownedShortcutFolder = Get-Item ` + -LiteralPath $startMenuShortcutFolder -Force -ErrorAction Stop + if (!$ownedShortcutFolder.PSIsContainer -or + ($ownedShortcutFolder.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'owned common Start Menu folder is invalid' + } + $ownedShortcutFolderContents = @( + Get-ChildItem -LiteralPath $startMenuShortcutFolder -Force -ErrorAction Stop + ) + if ($ownedShortcutFolderContents.Count -eq 0) { + Remove-Item -LiteralPath $startMenuShortcutFolder -Force -ErrorAction Stop + } + } } - } } catch { $shortcutFallbackFailed = $true } diff --git a/apps/desktop/src/release-workflow.test.ts b/apps/desktop/src/release-workflow.test.ts index 13f7ca1a1..eb9332904 100644 --- a/apps/desktop/src/release-workflow.test.ts +++ b/apps/desktop/src/release-workflow.test.ts @@ -42,6 +42,10 @@ const installedWindowsAppTest = normalizeWorkflowText(readFileSync( fileURLToPath(new URL('../scripts/test-installed-windows-app.ps1', import.meta.url)), 'utf8', )); +const installedWindowsAppSupervisor = normalizeWorkflowText(readFileSync( + fileURLToPath(new URL('../scripts/run-installed-windows-app-harness.ps1', import.meta.url)), + 'utf8', +)); const preflightAppTokenPermissions = (preflight: string): string[] => ( [...preflight.matchAll(/^\s+permission-([a-z-]+): (read|write)$/gm)] @@ -323,7 +327,7 @@ describe('desktop trusted release workflow', () => { `${jobName} retained a deferred Windows authority gate`); } assert.equal(workflow.match(/\*Machine-Setup\.msi/g)?.length, 3); - assert.equal(workflow.match(/test-installed-windows-app\.ps1/g)?.length, 2); + assert.equal(workflow.match(/run-installed-windows-app-harness\.ps1/g)?.length, 2); assert.equal(workflow.match(/PROPR_DESKTOP_WINDOWS_INSTALLED_APP=1/g)?.length, 2); assert.doesNotMatch(forgeConfig, /extraResource|windows-authority|postPackage/); assert.match(forgeConfig, /buildWindowsMachineInstaller/); @@ -496,7 +500,7 @@ describe('desktop trusted release workflow', () => { ); assert.match( applicationExitSection, - /catch \{\n\s+\$waitFailure = \$_\n\s+\} finally \{\n\s+try \{\n\s+Close-RedirectedApplicationStreams \$applicationLaunch[\s\S]*?\} finally \{\n\s+\$applicationLaunch\.Process\.Dispose\(\)\n\s+\$applicationLaunch = \$null/, + /catch \{\n\s+\$waitFailure = \$_\n\s+\} finally \{\n\s+try \{[\s\S]*?Close-RedirectedApplicationStreams \$applicationLaunch[\s\S]*?\} finally \{\n\s+\$applicationLaunch\.Process\.Dispose\(\)\n\s+\$applicationLaunch = \$null/, ); assert.ok( applicationExitSection.indexOf('Wait-BoundedProcess `') @@ -538,8 +542,141 @@ describe('desktop trusted release workflow', () => { for (const section of [job('package', 'finalize'), job('release-package', 'release-finalize')]) { assert.match(section, /- platform: win32\n\s+arch: x64\n/); assert.match(section, /- platform: win32\n\s+arch: arm64\n/); - assert.equal(section.match(/test-installed-windows-app\.ps1/g)?.length, 1); + assert.equal(section.match(/run-installed-windows-app-harness\.ps1/g)?.length, 1); + } + }); + + test('supervises every installed-app external operation and preserves cancellation evidence', () => { + assert.match(installedWindowsAppSupervisor, /JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000/); + assert.match(installedWindowsAppSupervisor, /AssignProcessToJobObject\(handle, processHandle\)/); + assert.match(installedWindowsAppSupervisor, /TerminateJobObject\(handle, exitCode\)/); + assert.match(installedWindowsAppSupervisor, /\$job\.AddProcess\(\$worker\.Handle\)/); + assert.match(installedWindowsAppSupervisor, /\[void\]\$ownershipReadyEvent\.Set\(\)/); + assert.ok( + installedWindowsAppSupervisor.indexOf('$job.AddProcess($worker.Handle)') + < installedWindowsAppSupervisor.indexOf('[void]$ownershipReadyEvent.Set()'), + ); + assert.match(installedWindowsAppTest, /\$ownershipHandshakeTimeoutMilliseconds = 5 \* 1000/); + assert.match(installedWindowsAppTest, /\$ownershipReady\.WaitOne\(\$ownershipHandshakeTimeoutMilliseconds\)/); + assert.match(installedWindowsAppSupervisor, /\$worker\.WaitForExit\(\$watchdogPollMilliseconds\)/); + assert.match(installedWindowsAppSupervisor, /\[DateTime\]::UtcNow\.Ticks -gt \$marker\.Deadline/); + assert.match(installedWindowsAppSupervisor, /\$job\.Terminate\(124\)/); + assert.match(installedWindowsAppSupervisor, /exit 124/); + assert.match(installedWindowsAppSupervisor, /if \(\$null -ne \$job\) \{ \$job\.Dispose\(\) \}/); + + assert.match(installedWindowsAppTest, /\[IO\.FileOptions\]::WriteThrough/); + assert.equal(installedWindowsAppTest.match(/\.Flush\(\$true\)/g)?.length, 2); + assert.match( + installedWindowsAppTest, + /\$record = '\{0\}\|\{1\}\|\{2\}\|\{3\}' -f \$deadline, \$Stage, \$Substage, \$Status/, + ); + assert.match( + installedWindowsAppSupervisor, + /\(\?BEGIN\|COMPLETE\|FAILED\)/, + ); + assert.match( + installedWindowsAppSupervisor, + /PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:\{0\}:\{1\}:\{2\}:TIMED_OUT/, + ); + assert.match( + installedWindowsAppSupervisor, + /PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:\{0\}:\{1\}:\{2\}:ABORTED/, + ); + assert.match( + installedWindowsAppTest, + /PROPR_WINDOWS_INSTALLED_SMOKE:OPERATION:\{0\}:\{1\}:\{2\}' -f `[\s\S]{0,100}\[Console\]::Out\.Flush\(\)/, + ); + assert.match( + installedWindowsAppSupervisor, + /PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:\{0\}:\{1\}:\{2\}:TIMED_OUT'[\s\S]{0,150}\[Console\]::Out\.Flush\(\)[\s\S]{0,100}\$job\.Terminate\(124\)/, + ); + + const markerWriter = installedWindowsAppTest.match( + /function Write-WatchdogMarker\(([\s\S]*?)\n\}/, + ); + assert.ok(markerWriter); + const operationAllowlist = markerWriter[1].match( + /\[ValidateSet\(\n([\s\S]*?)\n\s+\)\]\[string\]\$Substage/, + ); + assert.ok(operationAllowlist); + const operations = [...operationAllowlist[1].matchAll(/'([A-Z_]+)'/g)] + .map(match => match[1]); + assert.deepEqual(operations, [ + 'PATHS', + 'BASELINE', + 'MSI_INSTALL', + 'OWNERSHIP_CAPTURE', + 'INSTALL_TREE_SCAN', + 'APPLICATION_IMAGE', + 'PROTOCOL_ASSERTION', + 'SHORTCUT_ASSERTION', + 'USER_CREATE', + 'USER_SID', + 'SMOKE_DATA_CREATE', + 'SHORTCUT_PRESENT_PROBE', + 'ALTERNATE_USER_START', + 'APPLICATION_WAIT', + 'STREAM_DRAIN', + 'EVIDENCE_INSPECTION', + 'MSI_UNINSTALL', + 'INSTALL_TREE_ASSERTION', + 'PROTOCOL_ABSENCE_ASSERTION', + 'SHORTCUT_FILE_ASSERTION', + 'SHORTCUT_FOLDER_ASSERTION', + 'SHORTCUT_ABSENCE_PROBE', + 'SMOKE_DATA_REMOVE', + 'PROFILE_LOOKUP', + 'PROFILE_REMOVE', + 'USER_LOOKUP', + 'USER_REMOVE', + 'INSTALL_ROOT_FALLBACK', + 'PROTOCOL_FALLBACK', + 'SHORTCUT_FALLBACK', + ]); + for (const operation of operations) { + assert.ok( + installedWindowsAppTest.match(new RegExp(`'${operation}'`, 'g'))!.length >= 2, + `${operation} must be allowlisted and reached by a bounded marker path`, + ); } + assert.match( + installedWindowsAppTest, + /Write-WatchdogMarker \$Stage \$Substage \$TimeoutMilliseconds 'BEGIN'[\s\S]*Write-WatchdogMarker \$Stage \$Substage \$TimeoutMilliseconds 'COMPLETE'[\s\S]*Write-WatchdogMarker \$Stage \$Substage \$TimeoutMilliseconds 'FAILED'/, + ); + + const diagnosticSources = `${installedWindowsAppSupervisor}\n${installedWindowsAppTest}`; + assert.doesNotMatch( + diagnosticSources, + /Write-(?:Host|Warning|Error|Verbose|Debug|Information)[^\n]*(?:\$password|\$credential|\$Installer|\$installerPath|\$testUser|\$UserName|\$Domain|\$Arguments|\$record|\$bytes)/i, + ); + }); + + test('keeps all destructive installed-app cleanup fail-closed to run-owned resources', () => { + assert.match( + installedWindowsAppTest, + /if \(\$installRootExistedBeforeInstall -or \$protocolExistedBeforeInstall -or[\s\S]*\$startMenuShortcutFolderExistedBeforeInstall\) \{\n\s+throw 'installed-app harness requires an unowned clean machine baseline'/, + ); + assert.match(installedWindowsAppTest, /\$script:testUserCreatedByRun = \$true/); + assert.match( + installedWindowsAppTest, + /if \(\$testUserCreatedByRun -and \$null -ne \$testUserSid\)[\s\S]*!\$ownedUser\.SID\.Equals\(\$testUserSid\)[\s\S]*Remove-LocalUser/, + ); + assert.match( + installedWindowsAppTest, + /if \(\$profile\.SID -ne \$testUserSid\.Value\)[\s\S]*Remove-CimInstance -InputObject \$profile/, + ); + assert.match( + installedWindowsAppTest, + /if \(\$installRootCreatedByRun -and \(Test-Path -LiteralPath \$installRoot\)\)[\s\S]*Remove-Item -LiteralPath \$installRoot -Recurse/, + ); + assert.match( + installedWindowsAppTest, + /if \(\$protocolCreatedByRun -and[\s\S]*Remove-Item -LiteralPath `[\s\S]*Registry::HKEY_LOCAL_MACHINE\\Software\\Classes\\propr/, + ); + assert.match( + installedWindowsAppTest, + /if \(\$createdByRun\) \{\n\s+Remove-Item -LiteralPath \$path -Recurse/, + ); }); test('uses bounded network logon impersonation with secure native credential cleanup', () => { @@ -762,11 +899,11 @@ describe('desktop trusted release workflow', () => { ); assert.match( installedWindowsAppTest, - /\$startMenuShortcutCreatedByRun =\n\s+!\$startMenuShortcutExistedBeforeInstall -and \(Test-Path -LiteralPath \$startMenuShortcut\)/, + /\$script:startMenuShortcutCreatedByRun =\n\s+!\$startMenuShortcutExistedBeforeInstall -and \(Test-Path -LiteralPath \$startMenuShortcut\)/, ); assert.match( installedWindowsAppTest, - /\$startMenuShortcutFolderCreatedByRun =\n\s+!\$startMenuShortcutFolderExistedBeforeInstall -and \(Test-Path -LiteralPath \$startMenuShortcutFolder\)/, + /\$script:startMenuShortcutFolderCreatedByRun =\n\s+!\$startMenuShortcutFolderExistedBeforeInstall -and[\s\S]{0,40}\(Test-Path -LiteralPath \$startMenuShortcutFolder\)/, ); const cleanupStart = installedWindowsAppTest.indexOf("Write-Stage 'CLEANUP' 'BEGIN'"); From 436cefa675d782bfefe343c16175a0f2ba1e9fc9 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:16:52 +0000 Subject: [PATCH 02/29] feat(ai): Implemented only F1/F2 on exact head `30b33a80802262d60e7c7687837236817e7f455f`. Implemented only F1/F2 on exact head `30b33a80802262d60e7c7687837236817e7f455f`. Key changes: - Added supervisor-owned monotonic bootstrap deadline immediately after worker start. - Added bounded, size-capped marker reading with malformed, torn, inaccessible, stale, and unknown markers failing closed. - Added fixed redacted bootstrap, accepted-transition, cancellation, timeout, and last-valid-marker output. - Ensured timeout/cancellation terminates the owned Job Object tree and performs safe cleanup. - Added executable Windows tests covering all requested scenarios, including PID-based worker/descendant termination and real pre-existing user/profile/install/registry/shortcut ownership checks. - Wired focused tests into both x64 and ARM64 Windows matrices without changing workflow/product timeouts. - Kept source inspection only as supplementary lint. Files: - [Supervisor](/home/node/workspace/apps/desktop/scripts/run-installed-windows-app-harness.ps1) - [Executable behavior tests](/home/node/workspace/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1) - [Fixture worker](/home/node/workspace/apps/desktop/scripts/test-installed-windows-app-supervisor-fixture.ps1) - [Workflow](/home/node/workspace/.github/workflows/desktop-release-guard.yml) - [Supplementary contracts](/home/node/workspace/apps/desktop/src/release-workflow.test.ts) Validation completed: - Desktop tests: 177 passed, 6 platform skips - Desktop/UI typechecks passed - Focused workflow contracts passed - Workflow YAML parsed successfully - Docker-independent Validate Changes tests and CLI packaging passed - `git diff --check` passed, including new files Windows-native x64/ARM64 tests and ordinary-user MSI flows cannot execute in this Linux container; they are mandatory in both Windows workflow matrix paths. Full Suite was blocked at Redis startup because Docker is unavailable. No commit was created. PR: #2042 Comment by: @integry (ID: 5486941518) Model: gpt-5.6-sol --- .github/workflows/desktop-release-guard.yml | 14 + .../run-installed-windows-app-harness.ps1 | 354 ++++++++++++++--- ...stalled-windows-app-supervisor-fixture.ps1 | 128 +++++++ .../test-installed-windows-app-supervisor.ps1 | 360 ++++++++++++++++++ apps/desktop/src/release-workflow.test.ts | 48 ++- 5 files changed, 846 insertions(+), 58 deletions(-) create mode 100644 apps/desktop/scripts/test-installed-windows-app-supervisor-fixture.ps1 create mode 100644 apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 diff --git a/.github/workflows/desktop-release-guard.yml b/.github/workflows/desktop-release-guard.yml index b45fb07b7..1987863a2 100644 --- a/.github/workflows/desktop-release-guard.yml +++ b/.github/workflows/desktop-release-guard.yml @@ -98,6 +98,13 @@ jobs: EXPECTED_ARCH: ${{ matrix.arch }} run: node -e 'if(process.platform!==process.env.EXPECTED_PLATFORM||process.arch!==process.env.EXPECTED_ARCH) throw new Error(`Expected ${process.env.EXPECTED_PLATFORM}-${process.env.EXPECTED_ARCH}, got ${process.platform}-${process.arch}`)' + - name: Run focused Windows supervisor behavior tests + if: matrix.platform == 'win32' + shell: pwsh + run: | + & apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 ` + -Architecture '${{ matrix.arch }}' + - name: Audit committed dependency resolution shell: bash run: | @@ -422,6 +429,13 @@ jobs: EXPECTED_ARCH: ${{ matrix.arch }} run: node -e 'if(process.platform!==process.env.EXPECTED_PLATFORM||process.arch!==process.env.EXPECTED_ARCH) throw new Error(`Expected ${process.env.EXPECTED_PLATFORM}-${process.env.EXPECTED_ARCH}, got ${process.platform}-${process.arch}`)' + - name: Run focused Windows supervisor behavior tests + if: matrix.platform == 'win32' + shell: pwsh + run: | + & apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 ` + -Architecture '${{ matrix.arch }}' + - name: Audit committed dependency resolution shell: bash run: | diff --git a/apps/desktop/scripts/run-installed-windows-app-harness.ps1 b/apps/desktop/scripts/run-installed-windows-app-harness.ps1 index 21ae498a4..2e4dffed3 100644 --- a/apps/desktop/scripts/run-installed-windows-app-harness.ps1 +++ b/apps/desktop/scripts/run-installed-windows-app-harness.ps1 @@ -1,23 +1,72 @@ param( [Parameter(Mandatory=$true)][string]$Installer, - [Parameter(Mandatory=$true)][ValidateSet('x64','arm64')][string]$Architecture + [Parameter(Mandatory=$true)][ValidateSet('x64','arm64')][string]$Architecture, + [string]$WorkerPath, + [ValidateRange(1,60000)][int]$BootstrapTimeoutMilliseconds = 60 * 1000, + [ValidateRange(1,10000)][int]$WatchdogPollMilliseconds = 250, + [ValidateRange(1,30000)][int]$WatchdogTerminationMilliseconds = 30 * 1000, + [ValidateRange(1,5000)][int]$MarkerReadTimeoutMilliseconds = 250, + [string]$CancellationEventName ) $ErrorActionPreference = 'Stop' -$watchdogPollMilliseconds = 250 -$watchdogTerminationMilliseconds = 30 * 1000 +$maximumMarkerDeadlineMilliseconds = 11 * 60 * 1000 +$watchdogStages = @( + 'INITIALIZATION','INSTALL','VALIDATION','USER_SETUP','APP_LAUNCH','APP_EXIT','UNINSTALL','CLEANUP' +) +$watchdogSubstages = @( + 'PATHS', + 'BASELINE', + 'MSI_INSTALL', + 'OWNERSHIP_CAPTURE', + 'INSTALL_TREE_SCAN', + 'APPLICATION_IMAGE', + 'PROTOCOL_ASSERTION', + 'SHORTCUT_ASSERTION', + 'USER_CREATE', + 'USER_SID', + 'SMOKE_DATA_CREATE', + 'SHORTCUT_PRESENT_PROBE', + 'ALTERNATE_USER_START', + 'APPLICATION_WAIT', + 'STREAM_DRAIN', + 'EVIDENCE_INSPECTION', + 'MSI_UNINSTALL', + 'INSTALL_TREE_ASSERTION', + 'PROTOCOL_ABSENCE_ASSERTION', + 'SHORTCUT_FILE_ASSERTION', + 'SHORTCUT_FOLDER_ASSERTION', + 'SHORTCUT_ABSENCE_PROBE', + 'SMOKE_DATA_REMOVE', + 'PROFILE_LOOKUP', + 'PROFILE_REMOVE', + 'USER_LOOKUP', + 'USER_REMOVE', + 'INSTALL_ROOT_FALLBACK', + 'PROTOCOL_FALLBACK', + 'SHORTCUT_FALLBACK' +) $markerName = "propr-installed-app-watchdog-$([Guid]::NewGuid().ToString('N')).marker" $markerPath = Join-Path ([IO.Path]::GetTempPath()) $markerName $ownershipReadyEventName = "Local\ProPRInstalledApp-$([Guid]::NewGuid().ToString('N'))" -$workerPath = Join-Path $PSScriptRoot 'test-installed-windows-app.ps1' +$productionWorkerPath = Join-Path $PSScriptRoot 'test-installed-windows-app.ps1' $worker = $null $job = $null $ownershipReadyEvent = $null +$cancellationEvent = $null +$lastValidMarker = $null +$exitCode = 125 +$terminateOwnedTree = $false Add-Type -TypeDefinition @' using System; using System.ComponentModel; +using System.Globalization; +using System.IO; using System.Runtime.InteropServices; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; using Microsoft.Win32.SafeHandles; public sealed class ProPRKillOnCloseJob : IDisposable @@ -117,34 +166,167 @@ public sealed class ProPRKillOnCloseJob : IDisposable if (handle != null) handle.Dispose(); } } -'@ -function Read-WatchdogMarker([string]$Path) { - try { - if (![IO.File]::Exists($Path)) { return $null } - $record = [IO.File]::ReadAllText($Path, [Text.Encoding]::ASCII) - if ($record -notmatch - '^(?[0-9]+)\|(?[A-Z_]+)\|(?[A-Z_]+)\|(?BEGIN|COMPLETE|FAILED)$') { - return $null - } - return [PSCustomObject]@{ - Deadline = [int64]$Matches.Deadline - Stage = $Matches.Stage - Substage = $Matches.Substage - Status = $Matches.Status +public enum ProPRMarkerReadState +{ + Missing, + Valid, + Invalid, + Inaccessible +} + +public sealed class ProPRMarkerReadResult +{ + public ProPRMarkerReadState State; + public long Deadline; + public string Stage; + public string Substage; + public string Status; +} + +public static class ProPRBoundedMarkerReader +{ + private const int MaximumMarkerBytes = 256; + private static readonly Regex MarkerPattern = new Regex( + "^(?[0-9]+)\\|(?[A-Z_]+)\\|(?[A-Z_]+)\\|(?BEGIN|COMPLETE|FAILED)$", + RegexOptions.CultureInvariant | RegexOptions.Compiled); + + public static Task ReadAsync(string path) + { + return Task.Run(() => Read(path)); } - } catch { - return $null + + private static ProPRMarkerReadResult Result(ProPRMarkerReadState state) + { + return new ProPRMarkerReadResult { State = state }; + } + + private static ProPRMarkerReadResult Read(string path) + { + try + { + var item = new FileInfo(path); + item.Refresh(); + if (!item.Exists) return Result(ProPRMarkerReadState.Missing); + if ((item.Attributes & FileAttributes.ReparsePoint) != 0 || item.Length <= 0 || + item.Length > MaximumMarkerBytes) + return Result(ProPRMarkerReadState.Invalid); + + int length = checked((int)item.Length); + var bytes = new byte[length]; + using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete, 256, FileOptions.SequentialScan)) + { + int offset = 0; + while (offset < length) + { + int read = stream.Read(bytes, offset, length - offset); + if (read == 0) return Result(ProPRMarkerReadState.Invalid); + offset += read; + } + if (stream.ReadByte() != -1) return Result(ProPRMarkerReadState.Invalid); + } + + for (int index = 0; index < bytes.Length; index++) + if (bytes[index] > 0x7f) return Result(ProPRMarkerReadState.Invalid); + string text = Encoding.ASCII.GetString(bytes); + Match match = MarkerPattern.Match(text); + long deadline; + if (!match.Success || !long.TryParse(match.Groups["Deadline"].Value, + NumberStyles.None, CultureInfo.InvariantCulture, out deadline)) + return Result(ProPRMarkerReadState.Invalid); + return new ProPRMarkerReadResult { + State = ProPRMarkerReadState.Valid, + Deadline = deadline, + Stage = match.Groups["Stage"].Value, + Substage = match.Groups["Substage"].Value, + Status = match.Groups["Status"].Value + }; + } + catch (FileNotFoundException) { return Result(ProPRMarkerReadState.Missing); } + catch (DirectoryNotFoundException) { return Result(ProPRMarkerReadState.Missing); } + catch (UnauthorizedAccessException) { return Result(ProPRMarkerReadState.Inaccessible); } + catch (IOException) { return Result(ProPRMarkerReadState.Inaccessible); } + catch { return Result(ProPRMarkerReadState.Invalid); } + } +} +'@ + +function Write-WatchdogLine([string]$Line) { + Write-Host $Line + [Console]::Out.Flush() +} + +function Read-WatchdogMarker([string]$Path, [int]$TimeoutMilliseconds) { + $readTask = [ProPRBoundedMarkerReader]::ReadAsync($Path) + if (!$readTask.Wait($TimeoutMilliseconds)) { + return [PSCustomObject]@{ State = 'TimedOut' } + } + $result = $readTask.Result + if ($result.State -ne [ProPRMarkerReadState]::Valid) { + return [PSCustomObject]@{ State = $result.State.ToString() } + } + return [PSCustomObject]@{ + State = 'Valid' + Deadline = $result.Deadline + Stage = $result.Stage + Substage = $result.Substage + Status = $result.Status + } +} + +function Test-FreshMarker($Marker) { + $now = [DateTime]::UtcNow.Ticks + if ($Marker.Deadline -le $now) { return $false } + return ($Marker.Deadline - $now) -le + ([int64]$maximumMarkerDeadlineMilliseconds * [TimeSpan]::TicksPerMillisecond) +} + +function Test-WatchdogMarkerSchema($Marker) { + return $watchdogStages -ccontains $Marker.Stage -and + $watchdogSubstages -ccontains $Marker.Substage +} + +function Accept-WatchdogMarker($Marker) { + $identity = '{0}:{1}:{2}:{3}' -f $Marker.Deadline, $Marker.Stage, $Marker.Substage, $Marker.Status + $previousIdentity = if ($null -eq $script:lastValidMarker) { $null } else { + '{0}:{1}:{2}:{3}' -f $script:lastValidMarker.Deadline, $script:lastValidMarker.Stage, + $script:lastValidMarker.Substage, $script:lastValidMarker.Status + } + $script:lastValidMarker = $Marker + if ($identity -cne $previousIdentity) { + Write-WatchdogLine ('PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:ACCEPTED:{0}:{1}:{2}' -f ` + $Marker.Stage, $Marker.Substage, $Marker.Status) + } +} + +function Stop-OwnedWorker([uint32]$TerminationExitCode) { + if ($null -ne $job) { + try { $job.Terminate($TerminationExitCode) } catch {} + } + if ($null -ne $worker) { + try { + if (!$worker.HasExited) { + [void]$worker.WaitForExit($WatchdogTerminationMilliseconds) + } + } catch {} } } try { $installerPath = (Resolve-Path -LiteralPath $Installer -ErrorAction Stop).Path - $workerPath = (Resolve-Path -LiteralPath $workerPath -ErrorAction Stop).Path + $selectedWorkerPath = if ($WorkerPath) { $WorkerPath } else { $productionWorkerPath } + $selectedWorkerPath = (Resolve-Path -LiteralPath $selectedWorkerPath -ErrorAction Stop).Path $hostPath = (Get-Process -Id $PID -ErrorAction Stop).Path if ([IO.Path]::GetFileName($hostPath) -notin @('pwsh.exe', 'powershell.exe')) { throw 'PowerShell host resolution failed' } + if ($CancellationEventName) { + if ($CancellationEventName -notmatch '^Local\\ProPRInstalledAppCancellation-[a-f0-9]{32}$') { + throw 'supervisor cancellation event name is invalid' + } + $cancellationEvent = [Threading.EventWaitHandle]::OpenExisting($CancellationEventName) + } $startInfo = [Diagnostics.ProcessStartInfo]::new() $startInfo.FileName = $hostPath @@ -158,7 +340,7 @@ try { '-NoLogo', '-NoProfile', '-NonInteractive', - '-File', $workerPath, + '-File', $selectedWorkerPath, '-Installer', $installerPath, '-Architecture', $Architecture, '-WatchdogMarker', $markerPath, @@ -171,6 +353,7 @@ try { $worker = [Diagnostics.Process]::new() $worker.StartInfo = $startInfo if (!$worker.Start()) { throw 'installed-app worker did not start' } + $bootstrapStopwatch = [Diagnostics.Stopwatch]::StartNew() try { $job.AddProcess($worker.Handle) [void]$ownershipReadyEvent.Set() @@ -179,39 +362,116 @@ try { throw 'installed-app worker ownership failed' } - while (!$worker.WaitForExit($watchdogPollMilliseconds)) { - $marker = Read-WatchdogMarker $markerPath - if ($null -ne $marker -and [DateTime]::UtcNow.Ticks -gt $marker.Deadline) { - Write-Host ('PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:{0}:{1}:{2}:TIMED_OUT' -f ` - $marker.Stage, $marker.Substage, $marker.Status) - [Console]::Out.Flush() - $job.Terminate(124) - if (!$worker.WaitForExit($watchdogTerminationMilliseconds)) { - throw 'installed-app worker termination timed out' + $firstMarkerAccepted = $false + while ($true) { + if ($null -ne $cancellationEvent -and $cancellationEvent.WaitOne(0)) { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:SUPERVISOR:CANCELLED' + $exitCode = 125 + $terminateOwnedTree = $true + break + } + + $waitMilliseconds = $WatchdogPollMilliseconds + if (!$firstMarkerAccepted) { + $remainingBootstrapMilliseconds = $BootstrapTimeoutMilliseconds - + [int]$bootstrapStopwatch.ElapsedMilliseconds + if ($remainingBootstrapMilliseconds -le 0) { $waitMilliseconds = 1 } + else { $waitMilliseconds = [Math]::Min($waitMilliseconds, $remainingBootstrapMilliseconds) } + } + $workerExited = $worker.WaitForExit($waitMilliseconds) + + $readTimeout = $MarkerReadTimeoutMilliseconds + if (!$firstMarkerAccepted) { + $remainingBootstrapMilliseconds = $BootstrapTimeoutMilliseconds - + [int]$bootstrapStopwatch.ElapsedMilliseconds + if ($remainingBootstrapMilliseconds -gt 0) { + $readTimeout = [Math]::Min($readTimeout, $remainingBootstrapMilliseconds) + } else { + $readTimeout = 1 } - exit 124 } - } + $marker = Read-WatchdogMarker $markerPath ([Math]::Max(1, $readTimeout)) + if ($marker.State -eq 'Valid' -and !(Test-WatchdogMarkerSchema $marker)) { + $marker = [PSCustomObject]@{ State = 'Invalid' } + } - exit $worker.ExitCode -} catch { - $lastMarker = Read-WatchdogMarker $markerPath - if ($null -ne $lastMarker) { - Write-Host ('PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:{0}:{1}:{2}:ABORTED' -f ` - $lastMarker.Stage, $lastMarker.Substage, $lastMarker.Status) - [Console]::Out.Flush() - } - Write-Host 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:SUPERVISOR:FAILED' - [Console]::Out.Flush() - if ($null -ne $job) { - try { $job.Terminate(125) } catch {} + if ($marker.State -eq 'Valid') { + if (!$firstMarkerAccepted) { + if ($bootstrapStopwatch.ElapsedMilliseconds -gt $BootstrapTimeoutMilliseconds -or + !(Test-FreshMarker $marker)) { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:BOOTSTRAP:FAILED' + $exitCode = 124 + $terminateOwnedTree = $true + break + } + $firstMarkerAccepted = $true + } elseif (!(Test-FreshMarker $marker)) { + Write-WatchdogLine ('PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:{0}:{1}:{2}:TIMED_OUT' -f ` + $marker.Stage, $marker.Substage, $marker.Status) + $exitCode = 124 + $terminateOwnedTree = $true + break + } + Accept-WatchdogMarker $marker + } elseif (!$firstMarkerAccepted) { + if ($marker.State -in @('Invalid','Inaccessible','TimedOut')) { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:BOOTSTRAP:FAILED' + $exitCode = 124 + $terminateOwnedTree = $true + break + } + if ($workerExited) { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:BOOTSTRAP:FAILED' + $exitCode = 124 + $terminateOwnedTree = $true + break + } + if ($bootstrapStopwatch.ElapsedMilliseconds -ge $BootstrapTimeoutMilliseconds) { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:BOOTSTRAP:TIMED_OUT' + $exitCode = 124 + $terminateOwnedTree = $true + break + } + } else { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:MARKER:FAILED' + $exitCode = 124 + $terminateOwnedTree = $true + break + } + + if ($workerExited) { + $exitCode = $worker.ExitCode + break + } } - throw 'installed-app harness supervision failed' +} catch { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:SUPERVISOR:FAILED' + $exitCode = 125 + $terminateOwnedTree = $true } finally { - if ($null -ne $worker) { $worker.Dispose() } + if ($terminateOwnedTree) { Stop-OwnedWorker ([uint32]$exitCode) } + + try { + $finalMarker = Read-WatchdogMarker $markerPath $MarkerReadTimeoutMilliseconds + if ($finalMarker.State -eq 'Valid' -and (Test-WatchdogMarkerSchema $finalMarker) -and + (Test-FreshMarker $finalMarker)) { + $lastValidMarker = $finalMarker + } + } catch {} + if ($null -ne $lastValidMarker) { + Write-WatchdogLine ('PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:LAST_VALID:{0}:{1}:{2}' -f ` + $lastValidMarker.Stage, $lastValidMarker.Substage, $lastValidMarker.Status) + } else { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:LAST_VALID:NONE' + } + if ($null -ne $job) { $job.Dispose() } + if ($null -ne $worker) { $worker.Dispose() } if ($null -ne $ownershipReadyEvent) { $ownershipReadyEvent.Dispose() } + if ($null -ne $cancellationEvent) { $cancellationEvent.Dispose() } try { if ([IO.File]::Exists($markerPath)) { [IO.File]::Delete($markerPath) } } catch {} } + +exit $exitCode diff --git a/apps/desktop/scripts/test-installed-windows-app-supervisor-fixture.ps1 b/apps/desktop/scripts/test-installed-windows-app-supervisor-fixture.ps1 new file mode 100644 index 000000000..c33855501 --- /dev/null +++ b/apps/desktop/scripts/test-installed-windows-app-supervisor-fixture.ps1 @@ -0,0 +1,128 @@ +param( + [Parameter(Mandatory=$true)][string]$Installer, + [Parameter(Mandatory=$true)][ValidateSet('x64','arm64')][string]$Architecture, + [Parameter(Mandatory=$true)][string]$WatchdogMarker, + [Parameter(Mandatory=$true)][string]$OwnershipReadyEvent +) + +$ErrorActionPreference = 'Stop' +$scenario = $env:PROPR_SUPERVISOR_FIXTURE_SCENARIO +$stateDirectory = $env:PROPR_SUPERVISOR_FIXTURE_STATE_DIRECTORY +if ($scenario -notin @( + 'NO_MARKER', + 'VALID_THEN_DEADLINE', + 'MALFORMED_MARKER', + 'TORN_MARKER', + 'STALE_MARKER', + 'INACCESSIBLE_MARKER', + 'CANCELLATION' + )) { + throw 'fixture scenario is invalid' +} +if (!$stateDirectory -or !(Test-Path -LiteralPath $stateDirectory -PathType Container)) { + throw 'fixture state directory is invalid' +} + +function Write-FixtureMarker([string]$Record) { + $temporaryMarker = "$WatchdogMarker.$PID.new" + $bytes = [Text.Encoding]::ASCII.GetBytes($Record) + $stream = [IO.FileStream]::new( + $temporaryMarker, + [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() + } + [IO.File]::Move($temporaryMarker, $WatchdogMarker, $true) +} + +function Start-FixtureDescendant { + $hostPath = (Get-Process -Id $PID -ErrorAction Stop).Path + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $hostPath + $startInfo.UseShellExecute = $false + foreach ($argument in @( + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-Command', + 'Start-Sleep -Seconds 300' + )) { + $startInfo.ArgumentList.Add($argument) + } + $process = [Diagnostics.Process]::new() + $process.StartInfo = $startInfo + if (!$process.Start()) { throw 'fixture descendant did not start' } + return $process +} + +$ownershipReady = [Threading.EventWaitHandle]::OpenExisting($OwnershipReadyEvent) +try { + if (!$ownershipReady.WaitOne(5000)) { throw 'fixture ownership was not established' } +} finally { + $ownershipReady.Dispose() +} + +$descendant = Start-FixtureDescendant +$state = [ordered]@{ WorkerPid = $PID; DescendantPid = $descendant.Id } +$state | ConvertTo-Json -Compress | Set-Content -LiteralPath ` + (Join-Path $stateDirectory 'processes.json') -Encoding ASCII + +switch ($scenario) { + 'NO_MARKER' { + Start-Sleep -Seconds 300 + } + 'VALID_THEN_DEADLINE' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(10).Ticks) + Start-Sleep -Milliseconds 300 + Write-FixtureMarker ('{0}|INSTALL|MSI_INSTALL|BEGIN' -f [DateTime]::UtcNow.AddMilliseconds(350).Ticks) + Start-Sleep -Seconds 300 + } + 'MALFORMED_MARKER' { + Write-FixtureMarker 'not-a-watchdog-record' + Start-Sleep -Seconds 300 + } + 'TORN_MARKER' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS' -f [DateTime]::UtcNow.AddSeconds(10).Ticks) + Start-Sleep -Seconds 300 + } + 'STALE_MARKER' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(-1).Ticks) + Start-Sleep -Seconds 300 + } + 'INACCESSIBLE_MARKER' { + $record = '{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(10).Ticks + $bytes = [Text.Encoding]::ASCII.GetBytes($record) + $stream = [IO.FileStream]::new( + $WatchdogMarker, + [IO.FileMode]::CreateNew, + [IO.FileAccess]::ReadWrite, + [IO.FileShare]::None, + 4096, + [IO.FileOptions]::WriteThrough + ) + try { + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush($true) + Start-Sleep -Seconds 300 + } finally { + $stream.Dispose() + } + } + 'CANCELLATION' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(10).Ticks) + Start-Sleep -Milliseconds 300 + Write-FixtureMarker ('{0}|VALIDATION|INSTALL_TREE_SCAN|BEGIN' -f ` + [DateTime]::UtcNow.AddSeconds(10).Ticks) + Start-Sleep -Seconds 300 + } +} + +$descendant.Dispose() diff --git a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 new file mode 100644 index 000000000..fe7c9a320 --- /dev/null +++ b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 @@ -0,0 +1,360 @@ +param( + [Parameter(Mandatory=$true)][ValidateSet('x64','arm64')][string]$Architecture +) + +$ErrorActionPreference = 'Stop' +$supervisorPath = Join-Path $PSScriptRoot 'run-installed-windows-app-harness.ps1' +$fixtureWorkerPath = Join-Path $PSScriptRoot 'test-installed-windows-app-supervisor-fixture.ps1' +$hostPath = (Get-Process -Id $PID -ErrorAction Stop).Path +$testRoot = Join-Path ([IO.Path]::GetTempPath()) ` + "propr-supervisor-tests-$([Guid]::NewGuid().ToString('N'))" +$dummyInstaller = Join-Path $testRoot 'fixture.msi' +$secretNeedle = 'C:\Users\fixture-user\token=fixture-credential' + +function Assert-True([bool]$Condition, [string]$Message) { + if (!$Condition) { throw $Message } +} + +function Assert-Contains([string]$Text, [string]$Expected, [string]$Message) { + Assert-True ($Text.Contains($Expected, [StringComparison]::Ordinal)) $Message +} + +function Assert-NotContains([string]$Text, [string]$Forbidden, [string]$Message) { + Assert-True (!$Text.Contains($Forbidden, [StringComparison]::OrdinalIgnoreCase)) $Message +} + +function New-StateDirectory([string]$Name) { + $path = Join-Path $testRoot $Name + [void](New-Item -ItemType Directory -Path $path -ErrorAction Stop) + return $path +} + +function New-SupervisorStartInfo( + [string]$Scenario, + [string]$StateDirectory, + [string]$CancellationEventName, + [bool]$UseProductionWorker +) { + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $hostPath + $startInfo.UseShellExecute = $false + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + foreach ($argument in @( + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-File', $supervisorPath, + '-Installer', $dummyInstaller, + '-Architecture', $Architecture, + '-BootstrapTimeoutMilliseconds', $(if ($UseProductionWorker) { '10000' } else { '2000' }), + '-WatchdogPollMilliseconds', '25', + '-WatchdogTerminationMilliseconds', '3000', + '-MarkerReadTimeoutMilliseconds', '200' + )) { + $startInfo.ArgumentList.Add([string]$argument) + } + if (!$UseProductionWorker) { + $startInfo.ArgumentList.Add('-WorkerPath') + $startInfo.ArgumentList.Add($fixtureWorkerPath) + $startInfo.Environment['PROPR_SUPERVISOR_FIXTURE_SCENARIO'] = $Scenario + $startInfo.Environment['PROPR_SUPERVISOR_FIXTURE_STATE_DIRECTORY'] = $StateDirectory + $startInfo.Environment['PROPR_SUPERVISOR_FIXTURE_SECRET'] = $secretNeedle + } + if ($CancellationEventName) { + $startInfo.ArgumentList.Add('-CancellationEventName') + $startInfo.ArgumentList.Add($CancellationEventName) + } + return $startInfo +} + +function Read-FixtureProcessState([string]$StateDirectory) { + $statePath = Join-Path $StateDirectory 'processes.json' + $stopwatch = [Diagnostics.Stopwatch]::StartNew() + while (!(Test-Path -LiteralPath $statePath -PathType Leaf)) { + if ($stopwatch.ElapsedMilliseconds -ge 5000) { + throw 'fixture did not publish process state' + } + Start-Sleep -Milliseconds 25 + } + return Get-Content -LiteralPath $statePath -Raw -Encoding ASCII | ConvertFrom-Json +} + +function Assert-ProcessTreeGone($State) { + $stopwatch = [Diagnostics.Stopwatch]::StartNew() + do { + $worker = Get-Process -Id ([int]$State.WorkerPid) -ErrorAction SilentlyContinue + $descendant = Get-Process -Id ([int]$State.DescendantPid) -ErrorAction SilentlyContinue + if ($null -eq $worker -and $null -eq $descendant) { return } + Start-Sleep -Milliseconds 25 + } while ($stopwatch.ElapsedMilliseconds -lt 3000) + throw 'owned worker process tree survived supervisor completion' +} + +function Invoke-FixtureScenario([string]$Scenario) { + $stateDirectory = New-StateDirectory $Scenario.ToLowerInvariant() + $process = [Diagnostics.Process]::new() + $process.StartInfo = New-SupervisorStartInfo $Scenario $stateDirectory '' $false + $stopwatch = [Diagnostics.Stopwatch]::StartNew() + if (!$process.Start()) { throw 'supervisor test process did not start' } + try { + if (!$process.WaitForExit(10000)) { + try { $process.Kill($true) } catch {} + throw 'supervisor exceeded the executable test completion bound' + } + $stopwatch.Stop() + $standardOutput = $process.StandardOutput.ReadToEnd() + $standardError = $process.StandardError.ReadToEnd() + $state = Read-FixtureProcessState $stateDirectory + Assert-ProcessTreeGone $state + return [PSCustomObject]@{ + ExitCode = $process.ExitCode + ElapsedMilliseconds = $stopwatch.ElapsedMilliseconds + Output = $standardOutput + Error = $standardError + } + } finally { + $process.Dispose() + } +} + +function Test-BootstrapTimeout { + $result = Invoke-FixtureScenario 'NO_MARKER' + Assert-True ($result.ExitCode -eq 124) 'missing-marker bootstrap did not fail with the watchdog code' + Assert-True ($result.ElapsedMilliseconds -ge 1800) 'bootstrap timeout ignored the injected deadline' + Assert-True ($result.ElapsedMilliseconds -lt 10000) 'missing-marker bootstrap completion was not bounded' + Assert-Contains $result.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:BOOTSTRAP:TIMED_OUT' ` + 'missing-marker bootstrap did not emit the fixed timeout line' + Assert-Contains $result.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:LAST_VALID:NONE' ` + 'missing-marker bootstrap did not emit the fixed empty last-stage line' +} + +function Test-OperationDeadlineAndTreeTermination { + $result = Invoke-FixtureScenario 'VALID_THEN_DEADLINE' + Assert-True ($result.ExitCode -eq 124) 'operation deadline did not fail with the watchdog code' + Assert-Contains $result.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:ACCEPTED:INSTALL:MSI_INSTALL:BEGIN' ` + 'operation transition was not accepted and flushed by the supervisor' + Assert-Contains $result.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:INSTALL:MSI_INSTALL:BEGIN:TIMED_OUT' ` + 'operation deadline did not emit the fixed redacted timeout line' +} + +function Test-FailClosedMarkers { + foreach ($testCase in @( + @{ Scenario = 'MALFORMED_MARKER'; Label = 'malformed' }, + @{ Scenario = 'TORN_MARKER'; Label = 'torn' }, + @{ Scenario = 'STALE_MARKER'; Label = 'stale' }, + @{ Scenario = 'INACCESSIBLE_MARKER'; Label = 'inaccessible' } + )) { + $result = Invoke-FixtureScenario $testCase.Scenario + Assert-True ($result.ExitCode -eq 124) "$($testCase.Label) marker did not fail closed" + Assert-Contains $result.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:BOOTSTRAP:FAILED' ` + "$($testCase.Label) marker did not emit the fixed bootstrap failure line" + Assert-NotContains $result.Output $secretNeedle ` + "$($testCase.Label) marker diagnostics exposed fixture-sensitive data" + } +} + +function Test-LiveCancellationAndRedaction { + $stateDirectory = New-StateDirectory 'cancellation' + $eventName = "Local\ProPRInstalledAppCancellation-$([Guid]::NewGuid().ToString('N'))" + $cancellationEvent = [Threading.EventWaitHandle]::new( + $false, + [Threading.EventResetMode]::ManualReset, + $eventName + ) + $process = [Diagnostics.Process]::new() + $process.StartInfo = New-SupervisorStartInfo 'CANCELLATION' $stateDirectory $eventName $false + $lines = [Collections.Generic.List[string]]::new() + try { + if (!$process.Start()) { throw 'cancellation supervisor did not start' } + $liveAccepted = $false + $readStopwatch = [Diagnostics.Stopwatch]::StartNew() + while (!$liveAccepted -and $readStopwatch.ElapsedMilliseconds -lt 8000) { + $lineTask = $process.StandardOutput.ReadLineAsync() + if (!$lineTask.Wait(8000 - [int]$readStopwatch.ElapsedMilliseconds)) { break } + $line = $lineTask.Result + if ($null -eq $line) { break } + $lines.Add($line) + if ($line -ceq 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:ACCEPTED:INITIALIZATION:PATHS:BEGIN') { + $liveAccepted = $true + } + } + Assert-True $liveAccepted 'accepted transition was not observable live before cancellation' + Assert-True (!$process.HasExited) 'supervisor exited before simulated cancellation' + [void]$cancellationEvent.Set() + Assert-True ($process.WaitForExit(8000)) 'cancelled supervisor did not complete within the bound' + $remainingOutput = $process.StandardOutput.ReadToEnd() + if ($remainingOutput) { $lines.Add($remainingOutput) } + $standardError = $process.StandardError.ReadToEnd() + $output = $lines -join "`n" + Assert-True ($process.ExitCode -eq 125) 'simulated cancellation did not use the supervisor failure code' + Assert-Contains $output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:SUPERVISOR:CANCELLED' ` + 'simulated cancellation did not emit the fixed cancellation line' + Assert-True ($output -match ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:LAST_VALID:(?:INITIALIZATION:PATHS|VALIDATION:INSTALL_TREE_SCAN):BEGIN') ` + 'simulated cancellation did not emit a fixed last-valid-marker line' + foreach ($forbidden in @($secretNeedle, $stateDirectory, $testRoot, 'fixture-user', 'credential')) { + Assert-NotContains $output $forbidden 'live supervisor diagnostics were not redacted' + } + $state = Read-FixtureProcessState $stateDirectory + Assert-ProcessTreeGone $state + Assert-True ([string]::IsNullOrEmpty($standardError)) 'fixture cancellation wrote unexpected stderr' + } finally { + if (!$process.HasExited) { try { $process.Kill($true) } catch {} } + $process.Dispose() + $cancellationEvent.Dispose() + } +} + +Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; +using System.Text; + +public static class ProPRSupervisorOwnershipProfileFixture +{ + [DllImport("userenv.dll", CharSet = CharSet.Unicode, SetLastError = true)] + public static extern int CreateProfile( + string userSid, + string userName, + StringBuilder profilePath, + uint profilePathLength); + + [DllImport("userenv.dll", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool DeleteProfile(string userSid, string profilePath, string computerName); +} +'@ + +function Test-PreExistingCleanupOwnership { + $installRoot = Join-Path $env:ProgramFiles 'ProPR Desktop' + $protocolRoot = 'Registry::HKEY_LOCAL_MACHINE\Software\Classes\propr' + $commonPrograms = [Environment]::GetFolderPath([Environment+SpecialFolder]::CommonPrograms) + $shortcutFolder = Join-Path $commonPrograms 'ProPR Desktop' + $shortcut = Join-Path $shortcutFolder 'ProPR Desktop.lnk' + foreach ($path in @($installRoot, $protocolRoot, $shortcutFolder)) { + Assert-True (!(Test-Path -LiteralPath $path)) ` + 'ownership behavior test requires the same clean baseline as the installed-app harness' + } + + $userName = "prpr$([Guid]::NewGuid().ToString('N').Substring(0,8))" + $password = ConvertTo-SecureString "P!$([Guid]::NewGuid().ToString('N'))z9" -AsPlainText -Force + $userCreated = $false + $profileCreated = $false + $installCreated = $false + $protocolCreated = $false + $shortcutCreated = $false + $userSid = $null + $profilePath = $null + try { + New-LocalUser -Name $userName -Password $password -AccountNeverExpires -PasswordNeverExpires | Out-Null + $userCreated = $true + $userSid = (Get-LocalUser -Name $userName -ErrorAction Stop).SID + $profileBuffer = [Text.StringBuilder]::new(1024) + $createProfileResult = [ProPRSupervisorOwnershipProfileFixture]::CreateProfile( + $userSid.Value, + $userName, + $profileBuffer, + [uint32]$profileBuffer.Capacity + ) + if ($createProfileResult -ne 0) { + [Runtime.InteropServices.Marshal]::ThrowExceptionForHR($createProfileResult) + } + $profilePath = $profileBuffer.ToString() + $profileCreated = $true + + [void](New-Item -ItemType Directory -Path $installRoot -ErrorAction Stop) + $installCreated = $true + Set-Content -LiteralPath (Join-Path $installRoot 'pre-existing.txt') -Value 'owned-before-run' + [void](New-Item -Path $protocolRoot -Force -ErrorAction Stop) + $protocolCreated = $true + Set-ItemProperty -LiteralPath $protocolRoot -Name 'PreExisting' -Value 'owned-before-run' + [void](New-Item -ItemType Directory -Path $shortcutFolder -ErrorAction Stop) + Set-Content -LiteralPath $shortcut -Value 'owned-before-run' + $shortcutCreated = $true + + $stateDirectory = New-StateDirectory 'ownership' + $process = [Diagnostics.Process]::new() + $process.StartInfo = New-SupervisorStartInfo '' $stateDirectory '' $true + if (!$process.Start()) { throw 'production ownership probe did not start' } + try { + Assert-True ($process.WaitForExit(20000)) 'production ownership probe did not complete within the bound' + $output = $process.StandardOutput.ReadToEnd() + $standardError = $process.StandardError.ReadToEnd() + Assert-True ($process.ExitCode -ne 0) 'production worker accepted a pre-existing resource baseline' + Assert-Contains $output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:OPERATION:INITIALIZATION:BASELINE:FAILED' ` + 'production worker did not execute its pre-existing-resource rejection path' + } finally { + if (!$process.HasExited) { try { $process.Kill($true) } catch {} } + $process.Dispose() + } + + Assert-True ((Get-Content -LiteralPath (Join-Path $installRoot 'pre-existing.txt') -Raw).Trim() -ceq ` + 'owned-before-run') 'pre-existing install tree was removed or changed' + Assert-True ((Get-ItemPropertyValue -LiteralPath $protocolRoot -Name 'PreExisting') -ceq ` + 'owned-before-run') 'pre-existing registry tree was removed or changed' + Assert-True ((Get-Content -LiteralPath $shortcut -Raw).Trim() -ceq ` + 'owned-before-run') 'pre-existing shortcut was removed or changed' + $remainingUser = Get-LocalUser -Name $userName -ErrorAction Stop + Assert-True ($remainingUser.SID.Equals($userSid)) 'pre-existing local user was removed or replaced' + Assert-True (Test-Path -LiteralPath $profilePath -PathType Container) ` + 'pre-existing user profile was removed' + } finally { + if ($shortcutCreated -and (Test-Path -LiteralPath $shortcutFolder)) { + Remove-Item -LiteralPath $shortcutFolder -Recurse -Force -ErrorAction SilentlyContinue + } + if ($protocolCreated -and (Test-Path -LiteralPath $protocolRoot)) { + Remove-Item -LiteralPath $protocolRoot -Recurse -Force -ErrorAction SilentlyContinue + } + if ($installCreated -and (Test-Path -LiteralPath $installRoot)) { + Remove-Item -LiteralPath $installRoot -Recurse -Force -ErrorAction SilentlyContinue + } + $profileDeleted = !$profileCreated + if ($profileCreated) { + $profileDeleted = [ProPRSupervisorOwnershipProfileFixture]::DeleteProfile( + $userSid.Value, + $null, + $null + ) + } + if ($userCreated -and (Get-LocalUser -Name $userName -ErrorAction SilentlyContinue)) { + Remove-LocalUser -Name $userName -ErrorAction SilentlyContinue + } + if (!$profileDeleted) { + $profileDeleted = [ProPRSupervisorOwnershipProfileFixture]::DeleteProfile( + $userSid.Value, + $null, + $null + ) + } + if (!$profileDeleted) { throw 'ownership profile fixture cleanup failed' } + } +} + +if (![OperatingSystem]::IsWindows()) { throw 'supervisor behavior tests require Windows' } +$actualArchitecture = [Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString().ToLowerInvariant() +Assert-True ($actualArchitecture -ceq $Architecture) ` + "supervisor behavior tests expected $Architecture but are running on $actualArchitecture" + +[void](New-Item -ItemType Directory -Path $testRoot -ErrorAction Stop) +[IO.File]::WriteAllBytes($dummyInstaller, [byte[]](0)) +try { + Test-BootstrapTimeout + Test-OperationDeadlineAndTreeTermination + Test-FailClosedMarkers + Test-LiveCancellationAndRedaction + Test-PreExistingCleanupOwnership + Write-Host "PROPR_WINDOWS_SUPERVISOR_TESTS:${Architecture}:PASSED" + [Console]::Out.Flush() +} finally { + if (Test-Path -LiteralPath $testRoot) { + Remove-Item -LiteralPath $testRoot -Recurse -Force -ErrorAction SilentlyContinue + } +} diff --git a/apps/desktop/src/release-workflow.test.ts b/apps/desktop/src/release-workflow.test.ts index eb9332904..540ce8242 100644 --- a/apps/desktop/src/release-workflow.test.ts +++ b/apps/desktop/src/release-workflow.test.ts @@ -46,6 +46,14 @@ const installedWindowsAppSupervisor = normalizeWorkflowText(readFileSync( fileURLToPath(new URL('../scripts/run-installed-windows-app-harness.ps1', import.meta.url)), 'utf8', )); +const installedWindowsAppSupervisorBehaviorTest = normalizeWorkflowText(readFileSync( + fileURLToPath(new URL('../scripts/test-installed-windows-app-supervisor.ps1', import.meta.url)), + 'utf8', +)); +const installedWindowsAppSupervisorFixture = normalizeWorkflowText(readFileSync( + fileURLToPath(new URL('../scripts/test-installed-windows-app-supervisor-fixture.ps1', import.meta.url)), + 'utf8', +)); const preflightAppTokenPermissions = (preflight: string): string[] => ( [...preflight.matchAll(/^\s+permission-([a-z-]+): (read|write)$/gm)] @@ -398,7 +406,7 @@ describe('desktop trusted release workflow', () => { assert.doesNotMatch(releaseArchitecture, /electron-winstaller|7z-(?:x64|arm64)\.exe/); }); - test('bounds and diagnoses installed Windows process lifecycles on x64 and ARM64', () => { + test('supplementary lint retains installed Windows worker lifecycle contracts', () => { assert.doesNotMatch(installedWindowsAppTest, /(?:^|\s)-Wait(?:\s|$)/); assert.equal(installedWindowsAppTest.match(/Start-Process/g)?.length, 1); assert.match(installedWindowsAppTest, /\$msiTimeoutMilliseconds = 10 \* 60 \* 1000/); @@ -543,10 +551,19 @@ describe('desktop trusted release workflow', () => { assert.match(section, /- platform: win32\n\s+arch: x64\n/); assert.match(section, /- platform: win32\n\s+arch: arm64\n/); assert.equal(section.match(/run-installed-windows-app-harness\.ps1/g)?.length, 1); + assert.equal(section.match(/test-installed-windows-app-supervisor\.ps1/g)?.length, 1); } }); - test('supervises every installed-app external operation and preserves cancellation evidence', () => { + test('runs executable supervisor acceptance on both Windows architectures and keeps supplementary contracts', () => { + assert.match(installedWindowsAppSupervisorBehaviorTest, /Test-BootstrapTimeout/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Test-OperationDeadlineAndTreeTermination/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Test-FailClosedMarkers/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Test-LiveCancellationAndRedaction/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Test-PreExistingCleanupOwnership/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Assert-ProcessTreeGone/); + assert.match(installedWindowsAppSupervisorFixture, /Start-FixtureDescendant/); + assert.match(installedWindowsAppSupervisor, /JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000/); assert.match(installedWindowsAppSupervisor, /AssignProcessToJobObject\(handle, processHandle\)/); assert.match(installedWindowsAppSupervisor, /TerminateJobObject\(handle, exitCode\)/); @@ -558,10 +575,11 @@ describe('desktop trusted release workflow', () => { ); assert.match(installedWindowsAppTest, /\$ownershipHandshakeTimeoutMilliseconds = 5 \* 1000/); assert.match(installedWindowsAppTest, /\$ownershipReady\.WaitOne\(\$ownershipHandshakeTimeoutMilliseconds\)/); - assert.match(installedWindowsAppSupervisor, /\$worker\.WaitForExit\(\$watchdogPollMilliseconds\)/); - assert.match(installedWindowsAppSupervisor, /\[DateTime\]::UtcNow\.Ticks -gt \$marker\.Deadline/); - assert.match(installedWindowsAppSupervisor, /\$job\.Terminate\(124\)/); - assert.match(installedWindowsAppSupervisor, /exit 124/); + assert.match(installedWindowsAppSupervisor, /if \(!\$worker\.Start\(\)\)[^\n]+\n\s+\$bootstrapStopwatch = \[Diagnostics\.Stopwatch\]::StartNew\(\)/); + assert.match(installedWindowsAppSupervisor, /\[ProPRBoundedMarkerReader\]::ReadAsync\(\$Path\)/); + assert.match(installedWindowsAppSupervisor, /\$readTask\.Wait\(\$TimeoutMilliseconds\)/); + assert.match(installedWindowsAppSupervisor, /\$job\.Terminate\(\$TerminationExitCode\)/); + assert.match(installedWindowsAppSupervisor, /exit \$exitCode/); assert.match(installedWindowsAppSupervisor, /if \(\$null -ne \$job\) \{ \$job\.Dispose\(\) \}/); assert.match(installedWindowsAppTest, /\[IO\.FileOptions\]::WriteThrough/); @@ -580,15 +598,23 @@ describe('desktop trusted release workflow', () => { ); assert.match( installedWindowsAppSupervisor, - /PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:\{0\}:\{1\}:\{2\}:ABORTED/, + /PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:BOOTSTRAP:TIMED_OUT/, ); assert.match( - installedWindowsAppTest, - /PROPR_WINDOWS_INSTALLED_SMOKE:OPERATION:\{0\}:\{1\}:\{2\}' -f `[\s\S]{0,100}\[Console\]::Out\.Flush\(\)/, + installedWindowsAppSupervisor, + /PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:BOOTSTRAP:FAILED/, ); assert.match( installedWindowsAppSupervisor, - /PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:\{0\}:\{1\}:\{2\}:TIMED_OUT'[\s\S]{0,150}\[Console\]::Out\.Flush\(\)[\s\S]{0,100}\$job\.Terminate\(124\)/, + /PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:ACCEPTED:\{0\}:\{1\}:\{2\}/, + ); + assert.match( + installedWindowsAppSupervisor, + /PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:LAST_VALID:\{0\}:\{1\}:\{2\}/, + ); + assert.match( + installedWindowsAppTest, + /PROPR_WINDOWS_INSTALLED_SMOKE:OPERATION:\{0\}:\{1\}:\{2\}' -f `[\s\S]{0,100}\[Console\]::Out\.Flush\(\)/, ); const markerWriter = installedWindowsAppTest.match( @@ -651,7 +677,7 @@ describe('desktop trusted release workflow', () => { ); }); - test('keeps all destructive installed-app cleanup fail-closed to run-owned resources', () => { + test('supplementary lint retains fail-closed installed-app cleanup guards', () => { assert.match( installedWindowsAppTest, /if \(\$installRootExistedBeforeInstall -or \$protocolExistedBeforeInstall -or[\s\S]*\$startMenuShortcutFolderExistedBeforeInstall\) \{\n\s+throw 'installed-app harness requires an unowned clean machine baseline'/, From 9e9f37525aede717287b2cc12fd9fd65b9bd144c Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:34:56 +0000 Subject: [PATCH 03/29] feat(ai): Implemented the fixture-only correction on exact HEAD `436cefa675d782bfefe343c16175a0f2ba1e9fc9`. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented the fixture-only correction on exact HEAD `436cefa675d782bfefe343c16175a0f2ba1e9fc9`. - Removed all `CreateProfile`/`DeleteProfile` P/Invoke code. - Added fail-closed runner identity and `Win32_UserProfile` snapshot validation, including canonical path, reparse checks, ACL owner, existence, and stable CIM metadata. These are documented `Win32_UserProfile` fields in [Microsoft’s class reference](https://learn.microsoft.com/en-us/previous-versions/windows/desktop/legacy/ee886409%28v%3Dvs.85%29). - Proved the profile remains identical and no profile lookup/removal marker was entered. - Kept a profile-less local user solely for preservation proof, with exact SID-guarded cleanup. - Added fixed redacted evidence and regression contracts. - Did not modify supervisor/bootstrap/tree logic, timeouts, production behavior, or workflows. Changed: - [test-installed-windows-app-supervisor.ps1](/tmp/git-processor/worktrees/integry/propr/pr-2042-followup-2026-09-01T01-23-26/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1:215) - [release-workflow.test.ts](/tmp/git-processor/worktrees/integry/propr/pr-2042-followup-2026-09-01T01-23-26/apps/desktop/src/release-workflow.test.ts:558) Validation passed: - Desktop tests: 177 passed, 6 platform skips - Focused workflow contracts: 23 passed - Validate Changes Node gates: release metadata, 278 unit tests, 316 hosted-tunnel tests, 66 UI tests, CLI package verification - `git diff --check` Native Windows x64/ARM64 fixture and ordinary-user MSI tests could not run on this Linux host, which has no Windows/PowerShell runner. Docker-based actionlint/shellcheck was also unavailable because Docker is not installed. PR: #2042 Comment by: @integry (ID: 5487142136) Model: gpt-5.6-sol --- .../test-installed-windows-app-supervisor.ps1 | 201 +++++++++++++----- apps/desktop/src/release-workflow.test.ts | 16 ++ 2 files changed, 164 insertions(+), 53 deletions(-) diff --git a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 index fe7c9a320..7f216932c 100644 --- a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 @@ -212,27 +212,114 @@ function Test-LiveCancellationAndRedaction { } } -Add-Type -TypeDefinition @' -using System; -using System.Runtime.InteropServices; -using System.Text; +function Get-RunnerProfileSnapshot { + try { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + Assert-True ($null -ne $identity -and $null -ne $identity.User) ` + 'runner profile authority validation failed' + $identitySid = $identity.User.Value + Assert-True (![string]::IsNullOrWhiteSpace($identitySid)) ` + 'runner profile authority validation failed' + + $profiles = @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop | Where-Object { + $_.SID -ceq $identitySid + }) + Assert-True ($profiles.Count -eq 1) 'runner profile authority validation failed' + $profile = $profiles[0] + Assert-True (!$profile.Special -and $profile.Loaded) ` + 'runner profile authority validation failed' + Assert-True (![string]::IsNullOrWhiteSpace([string]$profile.LocalPath) -and + [IO.Path]::IsPathRooted([string]$profile.LocalPath)) ` + 'runner profile authority validation failed' + + $rawCimLocalPath = [string]$profile.LocalPath + $cimLocalPath = $rawCimLocalPath.TrimEnd('\') + Assert-True ($rawCimLocalPath -ceq $cimLocalPath) ` + 'runner profile authority validation failed' + $canonicalLocalPath = [IO.Path]::GetFullPath($cimLocalPath).TrimEnd('\') + Assert-True ([string]::Equals( + $cimLocalPath, + $canonicalLocalPath, + [StringComparison]::Ordinal + )) 'runner profile authority validation failed' + $resolvedProfilePath = Resolve-Path -LiteralPath $canonicalLocalPath -ErrorAction Stop + $resolvedLocalPath = $resolvedProfilePath.ProviderPath.TrimEnd('\') + Assert-True ([string]::Equals( + $resolvedLocalPath, + $canonicalLocalPath, + [StringComparison]::Ordinal + )) 'runner profile authority validation failed' + + $profileDirectory = Get-Item -LiteralPath $canonicalLocalPath -Force -ErrorAction Stop + Assert-True ($profileDirectory.PSIsContainer) 'runner profile authority validation failed' + $pathCursor = $profileDirectory + while ($null -ne $pathCursor) { + Assert-True (($pathCursor.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) ` + 'runner profile authority validation failed' + $parentPath = Split-Path -Parent $pathCursor.FullName + if ([string]::IsNullOrEmpty($parentPath) -or + [string]::Equals($parentPath, $pathCursor.FullName, [StringComparison]::OrdinalIgnoreCase)) { + break + } + $pathCursor = Get-Item -LiteralPath $parentPath -Force -ErrorAction Stop + } + + $profileOwner = (Get-Acl -LiteralPath $canonicalLocalPath -ErrorAction Stop).Owner + Assert-True (![string]::IsNullOrWhiteSpace($profileOwner)) ` + 'runner profile authority validation failed' + $profileOwnerSid = if ($profileOwner -match '^S-\d+(?:-\d+)+$') { + [Security.Principal.SecurityIdentifier]::new($profileOwner).Value + } else { + $profileOwnerAccount = [Security.Principal.NTAccount]::new($profileOwner) + $profileOwnerAccount.Translate([Security.Principal.SecurityIdentifier]).Value + } -public static class ProPRSupervisorOwnershipProfileFixture -{ - [DllImport("userenv.dll", CharSet = CharSet.Unicode, SetLastError = true)] - public static extern int CreateProfile( - string userSid, - string userName, - StringBuilder profilePath, - uint profilePathLength); + return [PSCustomObject]@{ + ProfileExists = $true + DirectoryExists = $true + IdentitySid = $identitySid + ProfileSid = [string]$profile.SID + CimLocalPath = $cimLocalPath + CanonicalLocalPath = $canonicalLocalPath + DirectoryOwnerSid = $profileOwnerSid + DirectoryAttributes = [int64]$profileDirectory.Attributes + Loaded = [bool]$profile.Loaded + Special = [bool]$profile.Special + Status = [uint32]$profile.Status + HealthStatus = [uint32]$profile.HealthStatus + RoamingConfigured = [bool]$profile.RoamingConfigured + RoamingPath = [string]$profile.RoamingPath + RoamingPreference = [bool]$profile.RoamingPreference + } + } catch { + throw 'runner profile authority validation failed' + } finally { + if ($null -ne $identity) { $identity.Dispose() } + } +} - [DllImport("userenv.dll", CharSet = CharSet.Unicode, SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - public static extern bool DeleteProfile(string userSid, string profilePath, string computerName); +function Assert-RunnerProfileUnchanged($Before) { + $after = Get-RunnerProfileSnapshot + $unchanged = $after.ProfileExists -and $Before.ProfileExists -and + $after.DirectoryExists -and $Before.DirectoryExists -and + $after.IdentitySid -ceq $Before.IdentitySid -and + $after.ProfileSid -ceq $Before.ProfileSid -and + $after.CimLocalPath -ceq $Before.CimLocalPath -and + $after.CanonicalLocalPath -ceq $Before.CanonicalLocalPath -and + $after.DirectoryOwnerSid -ceq $Before.DirectoryOwnerSid -and + $after.DirectoryAttributes -eq $Before.DirectoryAttributes -and + $after.Loaded -eq $Before.Loaded -and + $after.Special -eq $Before.Special -and + $after.Status -eq $Before.Status -and + $after.HealthStatus -eq $Before.HealthStatus -and + $after.RoamingConfigured -eq $Before.RoamingConfigured -and + $after.RoamingPath -ceq $Before.RoamingPath -and + $after.RoamingPreference -eq $Before.RoamingPreference + Assert-True $unchanged 'runner profile authority changed during ownership test' } -'@ function Test-PreExistingCleanupOwnership { + $runnerProfileBefore = Get-RunnerProfileSnapshot $installRoot = Join-Path $env:ProgramFiles 'ProPR Desktop' $protocolRoot = 'Registry::HKEY_LOCAL_MACHINE\Software\Classes\propr' $commonPrograms = [Environment]::GetFolderPath([Environment+SpecialFolder]::CommonPrograms) @@ -246,28 +333,25 @@ function Test-PreExistingCleanupOwnership { $userName = "prpr$([Guid]::NewGuid().ToString('N').Substring(0,8))" $password = ConvertTo-SecureString "P!$([Guid]::NewGuid().ToString('N'))z9" -AsPlainText -Force $userCreated = $false - $profileCreated = $false $installCreated = $false $protocolCreated = $false $shortcutCreated = $false $userSid = $null - $profilePath = $null try { - New-LocalUser -Name $userName -Password $password -AccountNeverExpires -PasswordNeverExpires | Out-Null + Assert-True ($null -eq (Get-LocalUser -Name $userName -ErrorAction SilentlyContinue)) ` + 'pre-existing local user fixture baseline was not clean' + $createdUser = New-LocalUser -Name $userName -Password $password ` + -AccountNeverExpires -PasswordNeverExpires $userCreated = $true - $userSid = (Get-LocalUser -Name $userName -ErrorAction Stop).SID - $profileBuffer = [Text.StringBuilder]::new(1024) - $createProfileResult = [ProPRSupervisorOwnershipProfileFixture]::CreateProfile( - $userSid.Value, - $userName, - $profileBuffer, - [uint32]$profileBuffer.Capacity - ) - if ($createProfileResult -ne 0) { - [Runtime.InteropServices.Marshal]::ThrowExceptionForHR($createProfileResult) - } - $profilePath = $profileBuffer.ToString() - $profileCreated = $true + $userSid = $createdUser.SID + Assert-True ($null -ne $userSid) 'pre-existing local user fixture ownership capture failed' + $capturedUser = Get-LocalUser -Name $userName -ErrorAction Stop + Assert-True ($capturedUser.SID.Equals($userSid)) ` + 'pre-existing local user fixture ownership capture failed' + $fixtureUserProfiles = @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop | + Where-Object { $_.SID -ceq $userSid.Value }) + Assert-True ($fixtureUserProfiles.Count -eq 0) ` + 'pre-existing local user fixture unexpectedly acquired a profile' [void](New-Item -ItemType Directory -Path $installRoot -ErrorAction Stop) $installCreated = $true @@ -276,8 +360,8 @@ function Test-PreExistingCleanupOwnership { $protocolCreated = $true Set-ItemProperty -LiteralPath $protocolRoot -Name 'PreExisting' -Value 'owned-before-run' [void](New-Item -ItemType Directory -Path $shortcutFolder -ErrorAction Stop) - Set-Content -LiteralPath $shortcut -Value 'owned-before-run' $shortcutCreated = $true + Set-Content -LiteralPath $shortcut -Value 'owned-before-run' $stateDirectory = New-StateDirectory 'ownership' $process = [Diagnostics.Process]::new() @@ -291,6 +375,21 @@ function Test-PreExistingCleanupOwnership { Assert-Contains $output ` 'PROPR_WINDOWS_INSTALLED_SMOKE:OPERATION:INITIALIZATION:BASELINE:FAILED' ` 'production worker did not execute its pre-existing-resource rejection path' + Assert-NotContains $output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:OPERATION:CLEANUP:PROFILE_LOOKUP:BEGIN' ` + 'production worker selected a pre-existing profile for lookup' + Assert-NotContains $output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:OPERATION:CLEANUP:PROFILE_REMOVE:BEGIN' ` + 'production worker selected a pre-existing profile for deletion' + $redactedEvidence = "$output`n$standardError" + Assert-NotContains $redactedEvidence $runnerProfileBefore.IdentitySid ` + 'ownership evidence exposed the runner identity SID' + Assert-NotContains $redactedEvidence $runnerProfileBefore.CanonicalLocalPath ` + 'ownership evidence exposed the runner profile path' + Assert-NotContains $redactedEvidence $userName ` + 'ownership evidence exposed the fixture local-user name' + Assert-NotContains $redactedEvidence $userSid.Value ` + 'ownership evidence exposed the fixture local-user SID' } finally { if (!$process.HasExited) { try { $process.Kill($true) } catch {} } $process.Dispose() @@ -304,8 +403,10 @@ function Test-PreExistingCleanupOwnership { 'owned-before-run') 'pre-existing shortcut was removed or changed' $remainingUser = Get-LocalUser -Name $userName -ErrorAction Stop Assert-True ($remainingUser.SID.Equals($userSid)) 'pre-existing local user was removed or replaced' - Assert-True (Test-Path -LiteralPath $profilePath -PathType Container) ` - 'pre-existing user profile was removed' + $fixtureUserProfiles = @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop | + Where-Object { $_.SID -ceq $userSid.Value }) + Assert-True ($fixtureUserProfiles.Count -eq 0) ` + 'pre-existing local user fixture unexpectedly acquired a profile' } finally { if ($shortcutCreated -and (Test-Path -LiteralPath $shortcutFolder)) { Remove-Item -LiteralPath $shortcutFolder -Recurse -Force -ErrorAction SilentlyContinue @@ -316,26 +417,20 @@ function Test-PreExistingCleanupOwnership { if ($installCreated -and (Test-Path -LiteralPath $installRoot)) { Remove-Item -LiteralPath $installRoot -Recurse -Force -ErrorAction SilentlyContinue } - $profileDeleted = !$profileCreated - if ($profileCreated) { - $profileDeleted = [ProPRSupervisorOwnershipProfileFixture]::DeleteProfile( - $userSid.Value, - $null, - $null - ) - } - if ($userCreated -and (Get-LocalUser -Name $userName -ErrorAction SilentlyContinue)) { - Remove-LocalUser -Name $userName -ErrorAction SilentlyContinue - } - if (!$profileDeleted) { - $profileDeleted = [ProPRSupervisorOwnershipProfileFixture]::DeleteProfile( - $userSid.Value, - $null, - $null - ) + if ($userCreated) { + $ownedUser = Get-LocalUser -Name $userName -ErrorAction SilentlyContinue + if ($null -ne $ownedUser) { + Assert-True ($null -ne $userSid -and $ownedUser.SID.Equals($userSid)) ` + 'refusing to remove a local user not owned by the fixture' + Remove-LocalUser -Name $userName -ErrorAction Stop + Assert-True ($null -eq (Get-LocalUser -Name $userName -ErrorAction SilentlyContinue)) ` + 'ownership local-user fixture cleanup failed' + } } - if (!$profileDeleted) { throw 'ownership profile fixture cleanup failed' } + Assert-RunnerProfileUnchanged $runnerProfileBefore } + Write-Host 'PROPR_WINDOWS_SUPERVISOR_OWNERSHIP:PRE_EXISTING_AUTHORITIES:PRESERVED' + [Console]::Out.Flush() } if (![OperatingSystem]::IsWindows()) { throw 'supervisor behavior tests require Windows' } diff --git a/apps/desktop/src/release-workflow.test.ts b/apps/desktop/src/release-workflow.test.ts index 540ce8242..589a25332 100644 --- a/apps/desktop/src/release-workflow.test.ts +++ b/apps/desktop/src/release-workflow.test.ts @@ -562,6 +562,22 @@ describe('desktop trusted release workflow', () => { assert.match(installedWindowsAppSupervisorBehaviorTest, /Test-LiveCancellationAndRedaction/); assert.match(installedWindowsAppSupervisorBehaviorTest, /Test-PreExistingCleanupOwnership/); assert.match(installedWindowsAppSupervisorBehaviorTest, /Assert-ProcessTreeGone/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /WindowsIdentity\]::GetCurrent\(\)/); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop/, + ); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Get-Acl -LiteralPath \$canonicalLocalPath/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /FileAttributes\]::ReparsePoint/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Assert-RunnerProfileUnchanged/); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /PROPR_WINDOWS_SUPERVISOR_OWNERSHIP:PRE_EXISTING_AUTHORITIES:PRESERVED/, + ); + assert.doesNotMatch( + installedWindowsAppSupervisorBehaviorTest, + /CreateProfile|DeleteProfile|Remove-CimInstance|userenv\.dll/, + ); assert.match(installedWindowsAppSupervisorFixture, /Start-FixtureDescendant/); assert.match(installedWindowsAppSupervisor, /JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000/); From a064d8fbdc99e50d6cf5fef5773a497db1ae58da Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:53:04 +0000 Subject: [PATCH 04/29] feat(ai): Implemented only F1 and F2. Implemented only F1 and F2. - Added durable ownership manifests and a separately supervised, bounded post-termination cleanup controller in [run-installed-windows-app-harness.ps1](/tmp/git-processor/worktrees/integry/propr/pr-2042-followup-2026-09-01T01-35-21/apps/desktop/scripts/run-installed-windows-app-harness.ps1). - Added ownership-safe cleanup for MSI resources, registry, shortcuts, smoke data, profiles, and users in [cleanup-installed-windows-app.ps1](/tmp/git-processor/worktrees/integry/propr/pr-2042-followup-2026-09-01T01-35-21/apps/desktop/scripts/cleanup-installed-windows-app.ps1). - Replaced the vacuous baseline-rejection fixture with a real timeout case that creates run-owned resources and conflicting pre-existing identities, then verifies owned resources are removed and conflicts remain unchanged. - Preserved fixed, redacted cleanup completion/failure/timeout diagnostics. Validation passed: - Desktop tests: 177 passed, 6 skipped - Desktop TypeScript typecheck - Focused workflow tests: 23 passed - `git diff --check` Native Windows acceptance remains wired for both x64 and ARM64 CI jobs; it could not run in this Linux container. No commit was created. PR: #2042 Comment by: @integry (ID: 5487345104) Model: gpt-5.6-sol --- .../scripts/cleanup-installed-windows-app.ps1 | 295 ++++++++++++++++++ .../run-installed-windows-app-harness.ps1 | 132 +++++++- ...stalled-windows-app-supervisor-fixture.ps1 | 194 +++++++++++- .../test-installed-windows-app-supervisor.ps1 | 204 ++++++++---- .../scripts/test-installed-windows-app.ps1 | 163 +++++++++- apps/desktop/src/release-workflow.test.ts | 16 +- 6 files changed, 927 insertions(+), 77 deletions(-) create mode 100644 apps/desktop/scripts/cleanup-installed-windows-app.ps1 diff --git a/apps/desktop/scripts/cleanup-installed-windows-app.ps1 b/apps/desktop/scripts/cleanup-installed-windows-app.ps1 new file mode 100644 index 000000000..449c1738d --- /dev/null +++ b/apps/desktop/scripts/cleanup-installed-windows-app.ps1 @@ -0,0 +1,295 @@ +param( + [Parameter(Mandatory=$true)][string]$OwnershipManifest, + [Parameter(Mandatory=$true)][string]$Installer, + [Parameter(Mandatory=$true)][string]$OwnershipReadyEvent, + [string]$FixtureRoot +) + +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +$ownerFileName = '.propr-installed-app-owner' +$ownerRegistryValue = 'ProPRInstalledAppOwner' +$cleanupFailed = $false +$authorizedRunId = $null + +try { + if ($OwnershipReadyEvent -notmatch '^Local\\ProPRInstalledAppCleanup-[a-f0-9]{32}$') { + exit 1 + } + $ownershipReady = [Threading.EventWaitHandle]::OpenExisting($OwnershipReadyEvent) + try { + if (!$ownershipReady.WaitOne(5000)) { exit 1 } + } finally { + $ownershipReady.Dispose() + } +} catch { + exit 1 +} + +function Test-SamePath([string]$Left, [string]$Right) { + return [string]::Equals( + [IO.Path]::GetFullPath($Left).TrimEnd('\'), + [IO.Path]::GetFullPath($Right).TrimEnd('\'), + [StringComparison]::OrdinalIgnoreCase + ) +} + +function Test-PathWithin([string]$Path, [string]$Root) { + $fullPath = [IO.Path]::GetFullPath($Path) + $fullRoot = [IO.Path]::GetFullPath($Root).TrimEnd('\') + return $fullPath.StartsWith("$fullRoot\", [StringComparison]::OrdinalIgnoreCase) +} + +function Test-OwnerFile([string]$Directory, [string]$Token) { + if (!$Token -or !(Test-Path -LiteralPath $Directory -PathType Container)) { return $false } + $marker = Join-Path $Directory $ownerFileName + if (!(Test-Path -LiteralPath $marker -PathType Leaf)) { return $false } + $item = Get-Item -LiteralPath $marker -Force -ErrorAction Stop + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or $item.Length -gt 128) { + return $false + } + return ([IO.File]::ReadAllText($marker, [Text.Encoding]::ASCII) -ceq $Token) +} + +function Test-AllowedFileSystemPath([string]$Kind, [string]$Path) { + if ($FixtureRoot) { return Test-PathWithin $Path $FixtureRoot } + $installRoot = Join-Path $env:ProgramFiles 'ProPR Desktop' + $commonPrograms = [Environment]::GetFolderPath([Environment+SpecialFolder]::CommonPrograms) + $shortcutFolder = Join-Path $commonPrograms 'ProPR Desktop' + $shortcut = Join-Path $shortcutFolder 'ProPR Desktop.lnk' + if ($Kind -eq 'INSTALL_ROOT') { return Test-SamePath $Path $installRoot } + if ($Kind -eq 'SHORTCUT_FOLDER') { return Test-SamePath $Path $shortcutFolder } + if ($Kind -eq 'SHORTCUT_FILE') { return Test-SamePath $Path $shortcut } + if ($Kind -eq 'SMOKE_DATA') { + $machineTempValue = [Environment]::GetEnvironmentVariable( + 'TEMP', [EnvironmentVariableTarget]::Machine) + if (!$machineTempValue) { return $false } + $machineTemp = [Environment]::ExpandEnvironmentVariables($machineTempValue) + return (Split-Path -Leaf $Path) -match '^propr-desktop-smoke-[a-f0-9]{32}$' -and + (Test-SamePath (Split-Path -Parent $Path) $machineTemp) + } + return $false +} + +function Remove-OwnedDirectory($Record, [bool]$AllowProvisionalProductOwnership) { + if (!$Record.Owned) { return } + $path = [string]$Record.Path + $kind = [string]$Record.Kind + if (!(Test-AllowedFileSystemPath $kind $path)) { throw 'directory cleanup scope is invalid' } + if (!(Test-Path -LiteralPath $path)) { return } + $item = Get-Item -LiteralPath $path -Force -ErrorAction Stop + if (!$item.PSIsContainer -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'owned directory identity is invalid' + } + $provisional = [bool]$Record.Provisional -or + ($AllowProvisionalProductOwnership -and $kind -in @('INSTALL_ROOT','SHORTCUT_FOLDER')) + if (!$provisional -and !(Test-OwnerFile $path ([string]$Record.Token))) { + throw 'owned directory token does not match' + } + Remove-Item -LiteralPath $path -Recurse -Force -ErrorAction Stop + if (Test-Path -LiteralPath $path) { throw 'owned directory cleanup did not complete' } +} + +function Remove-OwnedFile($Record, [bool]$AllowProvisionalProductOwnership) { + if (!$Record.Owned) { return } + $path = [string]$Record.Path + $kind = [string]$Record.Kind + if (!(Test-AllowedFileSystemPath $kind $path)) { throw 'file cleanup scope is invalid' } + if (!(Test-Path -LiteralPath $path)) { return } + $item = Get-Item -LiteralPath $path -Force -ErrorAction Stop + if (!($item -is [IO.FileInfo]) -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'owned file identity is invalid' + } + $provisional = $AllowProvisionalProductOwnership -and $kind -eq 'SHORTCUT_FILE' + if (!$provisional -and !(Test-OwnerFile (Split-Path -Parent $path) ([string]$Record.Token))) { + throw 'owned file token does not match' + } + Remove-Item -LiteralPath $path -Force -ErrorAction Stop + if (Test-Path -LiteralPath $path) { throw 'owned file cleanup did not complete' } +} + +function Remove-OwnedRegistryKey($Record, [bool]$AllowProvisionalProductOwnership) { + if (!$Record.Owned) { return } + $path = [string]$Record.Path + $productionPath = 'Registry::HKEY_LOCAL_MACHINE\Software\Classes\propr' + if ($FixtureRoot) { + $expectedPath = "Registry::HKEY_LOCAL_MACHINE\Software\ProPRSupervisorFixture\$authorizedRunId\owned" + if (![string]::Equals($path, $expectedPath, [StringComparison]::OrdinalIgnoreCase)) { + throw 'registry cleanup scope is invalid' + } + } elseif (![string]::Equals($path, $productionPath, [StringComparison]::OrdinalIgnoreCase)) { + throw 'registry cleanup scope is invalid' + } + if (!(Test-Path -LiteralPath $path)) { return } + $provisional = $AllowProvisionalProductOwnership -and + [string]::Equals($path, $productionPath, [StringComparison]::OrdinalIgnoreCase) + if (!$provisional) { + $token = Get-ItemPropertyValue -LiteralPath $path -Name $ownerRegistryValue -ErrorAction Stop + if ([string]$token -cne [string]$Record.Token) { throw 'owned registry token does not match' } + } + Remove-Item -LiteralPath $path -Recurse -Force -ErrorAction Stop + if (Test-Path -LiteralPath $path) { throw 'owned registry cleanup did not complete' } + if ($FixtureRoot) { + $runRoot = Split-Path -Parent $path + if ((Test-Path -LiteralPath $runRoot) -and + @(Get-ChildItem -LiteralPath $runRoot -Force -ErrorAction Stop).Count -eq 0) { + Remove-Item -LiteralPath $runRoot -Force -ErrorAction Stop + } + } +} + +function Remove-OwnedProfiles($UserRecord) { + if (!$UserRecord.Owned) { return } + $name = [string]$UserRecord.Name + if ($name -notmatch '^(?:propr-ci-|prpr)[a-f0-9]{8}$') { + throw 'owned local-user identity is invalid' + } + $sid = [string]$UserRecord.Sid + if ($sid -notmatch '^S-\d+(?:-\d+)+$') { + if (!$UserRecord.Provisional) { throw 'owned user SID is invalid' } + $provisionalUser = Get-LocalUser -Name $name -ErrorAction SilentlyContinue + if ($null -eq $provisionalUser) { return } + $sid = $provisionalUser.SID.Value + } + for ($attempt = 0; $attempt -lt 10; $attempt += 1) { + $profiles = @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop | Where-Object { + $_.SID -ceq $sid + }) + if ($profiles.Count -eq 0) { return } + try { + foreach ($profile in $profiles) { + if ($profile.SID -cne $sid) { throw 'profile SID ownership changed' } + Remove-CimInstance -InputObject $profile -ErrorAction Stop + } + } catch { + if ($attempt -eq 9) { throw } + Start-Sleep -Milliseconds 500 + } + } + throw 'owned profile cleanup did not complete' +} + +function Remove-ExplicitOwnedProfile($Record) { + if (!$Record.Owned) { return } + $sid = [string]$Record.Sid + $localPath = [string]$Record.LocalPath + if ($sid -notmatch '^S-\d+(?:-\d+)+$' -or ![IO.Path]::IsPathRooted($localPath)) { + throw 'profile cleanup identity is invalid' + } + $profiles = @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop | Where-Object { + $_.SID -ceq $sid + }) + foreach ($profile in $profiles) { + if ($profile.SID -cne $sid -or !(Test-SamePath ([string]$profile.LocalPath) $localPath)) { + throw 'profile path ownership changed' + } + Remove-CimInstance -InputObject $profile -ErrorAction Stop + } +} + +function Remove-OwnedUser($Record) { + if (!$Record.Owned) { return } + $name = [string]$Record.Name + $sid = [string]$Record.Sid + if ($name -notmatch '^(?:propr-ci-|prpr)[a-f0-9]{8}$') { + throw 'owned local-user identity is invalid' + } + $user = Get-LocalUser -Name $name -ErrorAction SilentlyContinue + if ($null -eq $user) { return } + if ($sid -notmatch '^S-\d+(?:-\d+)+$') { + if (!$Record.Provisional) { throw 'owned local-user identity is invalid' } + $sid = $user.SID.Value + } + if ($user.SID.Value -cne $sid) { throw 'local-user SID ownership changed' } + Remove-LocalUser -Name $name -ErrorAction Stop + if (Get-LocalUser -Name $name -ErrorAction SilentlyContinue) { + throw 'owned local-user cleanup did not complete' + } +} + +try { + $manifestPath = [IO.Path]::GetFullPath($OwnershipManifest) + $tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + if ((Split-Path -Leaf $manifestPath) -notmatch + '^propr-installed-app-ownership-[a-f0-9]{32}\.json$' -or + !(Test-SamePath (Split-Path -Parent $manifestPath) $tempRoot)) { + throw 'ownership manifest path is invalid' + } + $manifestItem = Get-Item -LiteralPath $manifestPath -Force -ErrorAction Stop + if (($manifestItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + $manifestItem.Length -le 0 -or $manifestItem.Length -gt 65536) { + throw 'ownership manifest metadata is invalid' + } + $manifest = [IO.File]::ReadAllText($manifestPath, [Text.Encoding]::UTF8) | + ConvertFrom-Json -ErrorAction Stop + if ($manifest.SchemaVersion -ne 1 -or + [string]$manifest.RunId -notmatch '^[a-f0-9]{32}$') { + throw 'ownership manifest schema is invalid' + } + $authorizedRunId = [string]$manifest.RunId + $pathRunId = [IO.Path]::GetFileNameWithoutExtension($manifestPath).Substring( + 'propr-installed-app-ownership-'.Length) + if ($authorizedRunId -cne $pathRunId) { throw 'ownership manifest run identity is invalid' } + $resolvedInstaller = (Resolve-Path -LiteralPath $Installer -ErrorAction Stop).Path + if (!(Test-SamePath ([string]$manifest.InstallerPath) $resolvedInstaller)) { + throw 'ownership manifest installer identity is invalid' + } + if ($FixtureRoot) { + $FixtureRoot = (Resolve-Path -LiteralPath $FixtureRoot -ErrorAction Stop).Path + if (!$manifest.Fixture -or !(Test-SamePath ([string]$manifest.FixtureRoot) $FixtureRoot)) { + throw 'ownership manifest fixture scope is invalid' + } + } elseif ($manifest.Fixture) { + throw 'fixture ownership manifest was not authorized' + } + + $allowProvisionalProductOwnership = !$manifest.Fixture -and + [bool]$manifest.BaselineClean -and [bool]$manifest.InstallAttempted + if ($allowProvisionalProductOwnership) { + $msiExitCode = 1618 + for ($attempt = 0; $attempt -lt 12 -and $msiExitCode -eq 1618; $attempt += 1) { + if ($attempt -ne 0) { Start-Sleep -Seconds 2 } + $msi = Start-Process msiexec.exe -ArgumentList @( + '/x', "`"$resolvedInstaller`"", '/qn', '/norestart' + ) -PassThru -WindowStyle Hidden -ErrorAction Stop + try { + [void]$msi.WaitForExit() + $msiExitCode = $msi.ExitCode + } finally { + $msi.Dispose() + } + } + if ($msiExitCode -notin @(0, 1605, 1614, 1641, 3010)) { $cleanupFailed = $true } + } + + foreach ($record in @($manifest.Files)) { + try { Remove-OwnedFile $record $allowProvisionalProductOwnership } catch { $cleanupFailed = $true } + } + foreach ($record in @($manifest.RegistryKeys)) { + try { Remove-OwnedRegistryKey $record $allowProvisionalProductOwnership } catch { $cleanupFailed = $true } + } + foreach ($record in @($manifest.Profiles)) { + try { Remove-ExplicitOwnedProfile $record } catch { $cleanupFailed = $true } + } + foreach ($record in @($manifest.Users)) { + try { Remove-OwnedProfiles $record } catch { $cleanupFailed = $true } + } + foreach ($record in @($manifest.Users)) { + try { Remove-OwnedUser $record } catch { $cleanupFailed = $true } + } + $directories = @($manifest.Directories) | Sort-Object { + ([string]$_.Path).Length + } -Descending + foreach ($record in $directories) { + try { Remove-OwnedDirectory $record $allowProvisionalProductOwnership } catch { + $cleanupFailed = $true + } + } +} catch { + $cleanupFailed = $true +} + +if ($cleanupFailed) { exit 1 } +exit 0 diff --git a/apps/desktop/scripts/run-installed-windows-app-harness.ps1 b/apps/desktop/scripts/run-installed-windows-app-harness.ps1 index 2e4dffed3..9c6e8516f 100644 --- a/apps/desktop/scripts/run-installed-windows-app-harness.ps1 +++ b/apps/desktop/scripts/run-installed-windows-app-harness.ps1 @@ -5,8 +5,10 @@ param( [ValidateRange(1,60000)][int]$BootstrapTimeoutMilliseconds = 60 * 1000, [ValidateRange(1,10000)][int]$WatchdogPollMilliseconds = 250, [ValidateRange(1,30000)][int]$WatchdogTerminationMilliseconds = 30 * 1000, + [ValidateRange(1000,600000)][int]$PostTerminationCleanupMilliseconds = 4 * 60 * 1000, [ValidateRange(1,5000)][int]$MarkerReadTimeoutMilliseconds = 250, - [string]$CancellationEventName + [string]$CancellationEventName, + [string]$FixtureCleanupRoot ) $ErrorActionPreference = 'Stop' @@ -48,8 +50,11 @@ $watchdogSubstages = @( ) $markerName = "propr-installed-app-watchdog-$([Guid]::NewGuid().ToString('N')).marker" $markerPath = Join-Path ([IO.Path]::GetTempPath()) $markerName +$ownershipManifestName = "propr-installed-app-ownership-$([Guid]::NewGuid().ToString('N')).json" +$ownershipManifestPath = Join-Path ([IO.Path]::GetTempPath()) $ownershipManifestName $ownershipReadyEventName = "Local\ProPRInstalledApp-$([Guid]::NewGuid().ToString('N'))" $productionWorkerPath = Join-Path $PSScriptRoot 'test-installed-windows-app.ps1' +$cleanupWorkerPath = Join-Path $PSScriptRoot 'cleanup-installed-windows-app.ps1' $worker = $null $job = $null $ownershipReadyEvent = $null @@ -313,10 +318,122 @@ function Stop-OwnedWorker([uint32]$TerminationExitCode) { } } +function Write-InitialOwnershipManifest( + [string]$Path, + [string]$InstallerPath, + [bool]$Fixture, + [string]$AuthorizedFixtureRoot +) { + $runId = [IO.Path]::GetFileNameWithoutExtension($Path).Substring( + 'propr-installed-app-ownership-'.Length) + $manifest = [ordered]@{ + SchemaVersion = 1 + RunId = $runId + InstallerPath = $InstallerPath + Fixture = $Fixture + FixtureRoot = if ($Fixture) { $AuthorizedFixtureRoot } else { $null } + BaselineClean = $false + InstallAttempted = $false + Directories = @() + Files = @() + RegistryKeys = @() + Users = @() + Profiles = @() + } + $bytes = [Text.Encoding]::UTF8.GetBytes(($manifest | ConvertTo-Json -Depth 6 -Compress)) + $stream = [IO.FileStream]::new( + $Path, + [IO.FileMode]::CreateNew, + [IO.FileAccess]::Write, + [IO.FileShare]::Read, + 4096, + [IO.FileOptions]::WriteThrough + ) + try { + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush($true) + } finally { + $stream.Dispose() + } +} + +function Invoke-PostTerminationCleanup([string]$InstallerPath, [string]$AuthorizedFixtureRoot) { + $cleanupJob = $null + $cleanupProcess = $null + $cleanupReadyEvent = $null + try { + $cleanupReadyEventName = "Local\ProPRInstalledAppCleanup-$([Guid]::NewGuid().ToString('N'))" + $cleanupReadyEvent = [Threading.EventWaitHandle]::new( + $false, + [Threading.EventResetMode]::ManualReset, + $cleanupReadyEventName + ) + $cleanupStartInfo = [Diagnostics.ProcessStartInfo]::new() + $cleanupStartInfo.FileName = $hostPath + $cleanupStartInfo.UseShellExecute = $false + $cleanupStartInfo.CreateNoWindow = $true + foreach ($argument in @( + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-File', $cleanupWorkerPath, + '-OwnershipManifest', $ownershipManifestPath, + '-Installer', $InstallerPath, + '-OwnershipReadyEvent', $cleanupReadyEventName + )) { + $cleanupStartInfo.ArgumentList.Add($argument) + } + if ($AuthorizedFixtureRoot) { + $cleanupStartInfo.ArgumentList.Add('-FixtureRoot') + $cleanupStartInfo.ArgumentList.Add($AuthorizedFixtureRoot) + } + + $cleanupJob = [ProPRKillOnCloseJob]::new() + $cleanupProcess = [Diagnostics.Process]::new() + $cleanupProcess.StartInfo = $cleanupStartInfo + if (!$cleanupProcess.Start()) { throw 'post-termination cleanup did not start' } + try { + $cleanupJob.AddProcess($cleanupProcess.Handle) + [void]$cleanupReadyEvent.Set() + } catch { + try { $cleanupProcess.Kill($true) } catch {} + throw 'post-termination cleanup ownership failed' + } + if (!$cleanupProcess.WaitForExit($PostTerminationCleanupMilliseconds)) { + try { $cleanupJob.Terminate(125) } catch {} + try { [void]$cleanupProcess.WaitForExit($WatchdogTerminationMilliseconds) } catch {} + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:TIMED_OUT' + return $false + } + if ($cleanupProcess.ExitCode -ne 0) { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:FAILED' + return $false + } + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:COMPLETE' + return $true + } catch { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:FAILED' + return $false + } finally { + if ($null -ne $cleanupJob) { $cleanupJob.Dispose() } + if ($null -ne $cleanupProcess) { $cleanupProcess.Dispose() } + if ($null -ne $cleanupReadyEvent) { $cleanupReadyEvent.Dispose() } + } +} + try { $installerPath = (Resolve-Path -LiteralPath $Installer -ErrorAction Stop).Path $selectedWorkerPath = if ($WorkerPath) { $WorkerPath } else { $productionWorkerPath } $selectedWorkerPath = (Resolve-Path -LiteralPath $selectedWorkerPath -ErrorAction Stop).Path + $cleanupWorkerPath = (Resolve-Path -LiteralPath $cleanupWorkerPath -ErrorAction Stop).Path + $usingProductionWorker = [string]::Equals( + $selectedWorkerPath, $productionWorkerPath, [StringComparison]::OrdinalIgnoreCase) + if ($FixtureCleanupRoot) { + if ($usingProductionWorker) { throw 'production worker cannot use a fixture cleanup scope' } + $FixtureCleanupRoot = (Resolve-Path -LiteralPath $FixtureCleanupRoot -ErrorAction Stop).Path + } elseif (!$usingProductionWorker) { + throw 'injected workers require a fixture cleanup scope' + } $hostPath = (Get-Process -Id $PID -ErrorAction Stop).Path if ([IO.Path]::GetFileName($hostPath) -notin @('pwsh.exe', 'powershell.exe')) { throw 'PowerShell host resolution failed' @@ -327,6 +444,8 @@ try { } $cancellationEvent = [Threading.EventWaitHandle]::OpenExisting($CancellationEventName) } + Write-InitialOwnershipManifest ` + $ownershipManifestPath $installerPath (!$usingProductionWorker) $FixtureCleanupRoot $startInfo = [Diagnostics.ProcessStartInfo]::new() $startInfo.FileName = $hostPath @@ -344,7 +463,8 @@ try { '-Installer', $installerPath, '-Architecture', $Architecture, '-WatchdogMarker', $markerPath, - '-OwnershipReadyEvent', $ownershipReadyEventName + '-OwnershipReadyEvent', $ownershipReadyEventName, + '-OwnershipManifest', $ownershipManifestPath )) { $startInfo.ArgumentList.Add($argument) } @@ -449,7 +569,10 @@ try { $exitCode = 125 $terminateOwnedTree = $true } finally { - if ($terminateOwnedTree) { Stop-OwnedWorker ([uint32]$exitCode) } + if ($terminateOwnedTree) { + Stop-OwnedWorker ([uint32]$exitCode) + if (!(Invoke-PostTerminationCleanup $installerPath $FixtureCleanupRoot)) { $exitCode = 125 } + } try { $finalMarker = Read-WatchdogMarker $markerPath $MarkerReadTimeoutMilliseconds @@ -472,6 +595,9 @@ try { try { if ([IO.File]::Exists($markerPath)) { [IO.File]::Delete($markerPath) } } catch {} + foreach ($path in @($ownershipManifestPath, "$ownershipManifestPath.new")) { + try { if ([IO.File]::Exists($path)) { [IO.File]::Delete($path) } } catch {} + } } exit $exitCode 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 c33855501..bc763d11f 100644 --- a/apps/desktop/scripts/test-installed-windows-app-supervisor-fixture.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app-supervisor-fixture.ps1 @@ -2,7 +2,8 @@ param( [Parameter(Mandatory=$true)][string]$Installer, [Parameter(Mandatory=$true)][ValidateSet('x64','arm64')][string]$Architecture, [Parameter(Mandatory=$true)][string]$WatchdogMarker, - [Parameter(Mandatory=$true)][string]$OwnershipReadyEvent + [Parameter(Mandatory=$true)][string]$OwnershipReadyEvent, + [Parameter(Mandatory=$true)][string]$OwnershipManifest ) $ErrorActionPreference = 'Stop' @@ -15,7 +16,8 @@ if ($scenario -notin @( 'TORN_MARKER', 'STALE_MARKER', 'INACCESSIBLE_MARKER', - 'CANCELLATION' + 'CANCELLATION', + 'OWNED_RESOURCES_THEN_DEADLINE' )) { throw 'fixture scenario is invalid' } @@ -43,6 +45,187 @@ function Write-FixtureMarker([string]$Record) { [IO.File]::Move($temporaryMarker, $WatchdogMarker, $true) } +function Write-FixtureOwnershipManifest($Manifest) { + $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 + ) + try { + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush($true) + } finally { + $stream.Dispose() + } + [IO.File]::Move($temporaryManifest, $OwnershipManifest, $true) +} + +function Write-FixtureOwnershipToken([string]$Path, [string]$Token) { + $bytes = [Text.Encoding]::ASCII.GetBytes($Token) + $stream = [IO.FileStream]::new( + $Path, + [IO.FileMode]::CreateNew, + [IO.FileAccess]::Write, + [IO.FileShare]::Read, + 4096, + [IO.FileOptions]::WriteThrough + ) + try { + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush($true) + } finally { + $stream.Dispose() + } +} + +function New-OwnedFixtureResources { + $manifest = [IO.File]::ReadAllText($OwnershipManifest, [Text.Encoding]::UTF8) | + ConvertFrom-Json -ErrorAction Stop + if (!$manifest.Fixture -or $manifest.SchemaVersion -ne 1) { + throw 'fixture ownership manifest was not initialized' + } + $token = [Guid]::NewGuid().ToString('N') + $ownedRoot = Join-Path $stateDirectory 'owned' + $installRoot = Join-Path $ownedRoot 'install-tree' + $shortcutFolder = Join-Path $ownedRoot 'shortcut-folder' + $shortcut = Join-Path $shortcutFolder 'ProPR Desktop.lnk' + $smokeDirectory = Join-Path $ownedRoot 'smoke-data' + [void](New-Item -ItemType Directory -Path $ownedRoot -Force -ErrorAction Stop) + Write-FixtureOwnershipToken (Join-Path $ownedRoot '.propr-installed-app-owner') $token + foreach ($directory in @($installRoot, $shortcutFolder, $smokeDirectory)) { + [void](New-Item -ItemType Directory -Path $directory -Force -ErrorAction Stop) + Write-FixtureOwnershipToken (Join-Path $directory '.propr-installed-app-owner') $token + } + [IO.File]::WriteAllText((Join-Path $installRoot 'installed.txt'), 'owned', [Text.Encoding]::ASCII) + [IO.File]::WriteAllText($shortcut, 'owned-shortcut', [Text.Encoding]::ASCII) + [IO.File]::WriteAllText((Join-Path $smokeDirectory 'smoke.txt'), 'owned', [Text.Encoding]::ASCII) + + $registryPath = "Registry::HKEY_LOCAL_MACHINE\Software\ProPRSupervisorFixture\$($manifest.RunId)\owned" + [void](New-Item -Path $registryPath -Force -ErrorAction Stop) + Set-ItemProperty -LiteralPath $registryPath -Name 'ProPRInstalledAppOwner' -Value $token + Set-ItemProperty -LiteralPath $registryPath -Name 'Payload' -Value 'owned' + + $userName = $env:PROPR_SUPERVISOR_FIXTURE_OWNED_USER + $passwordText = $env:PROPR_SUPERVISOR_FIXTURE_OWNED_PASSWORD + if ($userName -notmatch '^prpr[a-f0-9]{8}$' -or !$passwordText) { + throw 'fixture owned-user identity is invalid' + } + $password = ConvertTo-SecureString $passwordText -AsPlainText -Force + if (Get-LocalUser -Name $userName -ErrorAction SilentlyContinue) { + throw 'fixture owned-user baseline was not clean' + } + New-LocalUser -Name $userName -Password $password ` + -AccountNeverExpires -PasswordNeverExpires | Out-Null + $userSid = (Get-LocalUser -Name $userName -ErrorAction Stop).SID.Value + + $ownedDirectories = @( + [ordered]@{ Kind = 'FIXTURE_ROOT'; Path = $ownedRoot; Owned = $true; Token = $token }, + [ordered]@{ Kind = 'INSTALL_ROOT'; Path = $installRoot; Owned = $true; Token = $token }, + [ordered]@{ Kind = 'SHORTCUT_FOLDER'; Path = $shortcutFolder; Owned = $true; Token = $token }, + [ordered]@{ Kind = 'SMOKE_DATA'; Path = $smokeDirectory; Owned = $true; Token = $token } + ) + $conflictingDirectories = @( + $env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_DIRECTORIES -split '\|' | Where-Object { $_ } + ) | ForEach-Object { + [ordered]@{ Kind = 'CONFLICT'; Path = $_; Owned = $false; Token = $null } + } + $manifest.Directories = @($ownedDirectories) + @($conflictingDirectories) + $manifest.Files = @( + [ordered]@{ Kind = 'SHORTCUT_FILE'; Path = $shortcut; Owned = $true; Token = $token } + ) + if ($env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_SHORTCUT) { + $manifest.Files += [ordered]@{ + Kind = 'CONFLICT'; Path = $env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_SHORTCUT + Owned = $false; Token = $null + } + } + $manifest.RegistryKeys = @( + [ordered]@{ Kind = 'PROTOCOL'; Path = $registryPath; Owned = $true; Token = $token } + ) + if ($env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_REGISTRY) { + $manifest.RegistryKeys += [ordered]@{ + Kind = 'CONFLICT'; Path = $env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_REGISTRY + Owned = $false; Token = $null + } + } + $manifest.Users = @( + [ordered]@{ Name = $userName; Sid = $userSid; Owned = $true } + ) + if ($env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_USER) { + $manifest.Users += [ordered]@{ + Name = $env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_USER + Sid = $env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_USER_SID + Owned = $false + } + } + $manifest.Profiles = @() + if ($env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_PROFILE_SID) { + $manifest.Profiles += [ordered]@{ + Sid = $env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_PROFILE_SID + LocalPath = $env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_PROFILE_PATH + Owned = $false + } + } + Write-FixtureOwnershipManifest $manifest + + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = (Get-Process -Id $PID -ErrorAction Stop).Path + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.UserName = $userName + $startInfo.Domain = $env:COMPUTERNAME + $startInfo.Password = $password + $startInfo.LoadUserProfile = $true + $startInfo.WorkingDirectory = $env:SystemRoot + foreach ($argument in @('-NoLogo','-NoProfile','-NonInteractive','-Command','exit 0')) { + $startInfo.ArgumentList.Add($argument) + } + $profileProcess = [Diagnostics.Process]::new() + $profileProcess.StartInfo = $startInfo + $profileProcessStarted = $false + try { + $profileProcessStarted = $profileProcess.Start() + if (!$profileProcessStarted -or !$profileProcess.WaitForExit(30000) -or + $profileProcess.ExitCode -ne 0) { + throw 'fixture owned profile creation failed' + } + } finally { + if ($profileProcessStarted -and !$profileProcess.HasExited) { + try { $profileProcess.Kill($true) } catch {} + } + $profileProcess.Dispose() + } + $profiles = @() + $profileLookupStopwatch = [Diagnostics.Stopwatch]::StartNew() + do { + $profiles = @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop | Where-Object { + $_.SID -ceq $userSid + }) + if ($profiles.Count -eq 1) { break } + Start-Sleep -Milliseconds 250 + } while ($profileLookupStopwatch.ElapsedMilliseconds -lt 10000) + if ($profiles.Count -ne 1) { throw 'fixture owned profile was not created' } + $resourceState = [ordered]@{ + OwnedRoot = $ownedRoot + InstallRoot = $installRoot + ShortcutFolder = $shortcutFolder + Shortcut = $shortcut + SmokeDirectory = $smokeDirectory + RegistryPath = $registryPath + RegistryRoot = Split-Path -Parent $registryPath + UserName = $userName + UserSid = $userSid + ProfilePath = [string]$profiles[0].LocalPath + } + $resourceState | ConvertTo-Json -Compress | Set-Content -LiteralPath ` + (Join-Path $stateDirectory 'resources.json') -Encoding ASCII +} + function Start-FixtureDescendant { $hostPath = (Get-Process -Id $PID -ErrorAction Stop).Path $startInfo = [Diagnostics.ProcessStartInfo]::new() @@ -123,6 +306,13 @@ switch ($scenario) { [DateTime]::UtcNow.AddSeconds(10).Ticks) Start-Sleep -Seconds 300 } + 'OWNED_RESOURCES_THEN_DEADLINE' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + New-OwnedFixtureResources + Write-FixtureMarker ('{0}|CLEANUP|SMOKE_DATA_REMOVE|BEGIN' -f ` + [DateTime]::UtcNow.AddMilliseconds(500).Ticks) + Start-Sleep -Seconds 300 + } } $descendant.Dispose() diff --git a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 index 7f216932c..dad405373 100644 --- a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 @@ -10,6 +10,15 @@ $testRoot = Join-Path ([IO.Path]::GetTempPath()) ` "propr-supervisor-tests-$([Guid]::NewGuid().ToString('N'))" $dummyInstaller = Join-Path $testRoot 'fixture.msi' $secretNeedle = 'C:\Users\fixture-user\token=fixture-credential' +$ownedFixtureUserName = "prpr$([Guid]::NewGuid().ToString('N').Substring(0,8))" +$ownedFixturePassword = "P!$([Guid]::NewGuid().ToString('N'))x7" +$conflictingFixtureUserName = $null +$conflictingFixtureUserSid = $null +$conflictingFixtureProfileSid = $null +$conflictingFixtureProfilePath = $null +$conflictingFixtureDirectories = $null +$conflictingFixtureShortcut = $null +$conflictingFixtureRegistryPath = $null function Assert-True([bool]$Condition, [string]$Message) { if (!$Condition) { throw $Message } @@ -50,6 +59,7 @@ function New-SupervisorStartInfo( '-BootstrapTimeoutMilliseconds', $(if ($UseProductionWorker) { '10000' } else { '2000' }), '-WatchdogPollMilliseconds', '25', '-WatchdogTerminationMilliseconds', '3000', + '-PostTerminationCleanupMilliseconds', '30000', '-MarkerReadTimeoutMilliseconds', '200' )) { $startInfo.ArgumentList.Add([string]$argument) @@ -57,9 +67,29 @@ function New-SupervisorStartInfo( if (!$UseProductionWorker) { $startInfo.ArgumentList.Add('-WorkerPath') $startInfo.ArgumentList.Add($fixtureWorkerPath) + $startInfo.ArgumentList.Add('-FixtureCleanupRoot') + $startInfo.ArgumentList.Add($StateDirectory) $startInfo.Environment['PROPR_SUPERVISOR_FIXTURE_SCENARIO'] = $Scenario $startInfo.Environment['PROPR_SUPERVISOR_FIXTURE_STATE_DIRECTORY'] = $StateDirectory $startInfo.Environment['PROPR_SUPERVISOR_FIXTURE_SECRET'] = $secretNeedle + $startInfo.Environment['PROPR_SUPERVISOR_FIXTURE_OWNED_USER'] = $ownedFixtureUserName + $startInfo.Environment['PROPR_SUPERVISOR_FIXTURE_OWNED_PASSWORD'] = $ownedFixturePassword + if ($conflictingFixtureUserName) { + $startInfo.Environment['PROPR_SUPERVISOR_FIXTURE_CONFLICT_USER'] = + $conflictingFixtureUserName + $startInfo.Environment['PROPR_SUPERVISOR_FIXTURE_CONFLICT_USER_SID'] = + $conflictingFixtureUserSid + $startInfo.Environment['PROPR_SUPERVISOR_FIXTURE_CONFLICT_PROFILE_SID'] = + $conflictingFixtureProfileSid + $startInfo.Environment['PROPR_SUPERVISOR_FIXTURE_CONFLICT_PROFILE_PATH'] = + $conflictingFixtureProfilePath + $startInfo.Environment['PROPR_SUPERVISOR_FIXTURE_CONFLICT_DIRECTORIES'] = + $conflictingFixtureDirectories + $startInfo.Environment['PROPR_SUPERVISOR_FIXTURE_CONFLICT_SHORTCUT'] = + $conflictingFixtureShortcut + $startInfo.Environment['PROPR_SUPERVISOR_FIXTURE_CONFLICT_REGISTRY'] = + $conflictingFixtureRegistryPath + } } if ($CancellationEventName) { $startInfo.ArgumentList.Add('-CancellationEventName') @@ -80,6 +110,13 @@ function Read-FixtureProcessState([string]$StateDirectory) { return Get-Content -LiteralPath $statePath -Raw -Encoding ASCII | ConvertFrom-Json } +function Read-FixtureResourceState([string]$StateDirectory) { + $statePath = Join-Path $StateDirectory 'resources.json' + Assert-True (Test-Path -LiteralPath $statePath -PathType Leaf) ` + 'fixture did not publish owned resource state' + return Get-Content -LiteralPath $statePath -Raw -Encoding ASCII | ConvertFrom-Json +} + function Assert-ProcessTreeGone($State) { $stopwatch = [Diagnostics.Stopwatch]::StartNew() do { @@ -91,14 +128,19 @@ function Assert-ProcessTreeGone($State) { throw 'owned worker process tree survived supervisor completion' } -function Invoke-FixtureScenario([string]$Scenario) { - $stateDirectory = New-StateDirectory $Scenario.ToLowerInvariant() +function Invoke-FixtureScenario([string]$Scenario, [string]$ExistingStateDirectory = '') { + $stateDirectory = if ($ExistingStateDirectory) { + $ExistingStateDirectory + } else { + New-StateDirectory $Scenario.ToLowerInvariant() + } $process = [Diagnostics.Process]::new() $process.StartInfo = New-SupervisorStartInfo $Scenario $stateDirectory '' $false $stopwatch = [Diagnostics.Stopwatch]::StartNew() if (!$process.Start()) { throw 'supervisor test process did not start' } try { - if (!$process.WaitForExit(10000)) { + $completionBound = if ($Scenario -eq 'OWNED_RESOURCES_THEN_DEADLINE') { 90000 } else { 10000 } + if (!$process.WaitForExit($completionBound)) { try { $process.Kill($true) } catch {} throw 'supervisor exceeded the executable test completion bound' } @@ -112,6 +154,7 @@ function Invoke-FixtureScenario([string]$Scenario) { ElapsedMilliseconds = $stopwatch.ElapsedMilliseconds Output = $standardOutput Error = $standardError + StateDirectory = $stateDirectory } } finally { $process.Dispose() @@ -320,22 +363,17 @@ function Assert-RunnerProfileUnchanged($Before) { function Test-PreExistingCleanupOwnership { $runnerProfileBefore = Get-RunnerProfileSnapshot - $installRoot = Join-Path $env:ProgramFiles 'ProPR Desktop' - $protocolRoot = 'Registry::HKEY_LOCAL_MACHINE\Software\Classes\propr' - $commonPrograms = [Environment]::GetFolderPath([Environment+SpecialFolder]::CommonPrograms) - $shortcutFolder = Join-Path $commonPrograms 'ProPR Desktop' - $shortcut = Join-Path $shortcutFolder 'ProPR Desktop.lnk' - foreach ($path in @($installRoot, $protocolRoot, $shortcutFolder)) { - Assert-True (!(Test-Path -LiteralPath $path)) ` - 'ownership behavior test requires the same clean baseline as the installed-app harness' - } - + $stateDirectory = New-StateDirectory 'ownership' + $conflictRoot = Join-Path $stateDirectory 'pre-existing' + $conflictInstallRoot = Join-Path $conflictRoot 'install-tree' + $conflictShortcutFolder = Join-Path $conflictRoot 'shortcut-folder' + $conflictShortcut = Join-Path $conflictShortcutFolder 'ProPR Desktop.lnk' + $conflictSmokeDirectory = Join-Path $conflictRoot 'smoke-data' + $conflictRegistryPath = "Registry::HKEY_LOCAL_MACHINE\Software\ProPRSupervisorFixture\conflict-$([Guid]::NewGuid().ToString('N'))" $userName = "prpr$([Guid]::NewGuid().ToString('N').Substring(0,8))" $password = ConvertTo-SecureString "P!$([Guid]::NewGuid().ToString('N'))z9" -AsPlainText -Force $userCreated = $false - $installCreated = $false - $protocolCreated = $false - $shortcutCreated = $false + $registryCreated = $false $userSid = $null try { Assert-True ($null -eq (Get-LocalUser -Name $userName -ErrorAction SilentlyContinue)) ` @@ -353,54 +391,75 @@ function Test-PreExistingCleanupOwnership { Assert-True ($fixtureUserProfiles.Count -eq 0) ` 'pre-existing local user fixture unexpectedly acquired a profile' - [void](New-Item -ItemType Directory -Path $installRoot -ErrorAction Stop) - $installCreated = $true - Set-Content -LiteralPath (Join-Path $installRoot 'pre-existing.txt') -Value 'owned-before-run' - [void](New-Item -Path $protocolRoot -Force -ErrorAction Stop) - $protocolCreated = $true - Set-ItemProperty -LiteralPath $protocolRoot -Name 'PreExisting' -Value 'owned-before-run' - [void](New-Item -ItemType Directory -Path $shortcutFolder -ErrorAction Stop) - $shortcutCreated = $true - Set-Content -LiteralPath $shortcut -Value 'owned-before-run' + foreach ($directory in @( + $conflictInstallRoot, $conflictShortcutFolder, $conflictSmokeDirectory + )) { + [void](New-Item -ItemType Directory -Path $directory -Force -ErrorAction Stop) + Set-Content -LiteralPath (Join-Path $directory 'pre-existing.txt') -Value 'owned-before-run' + } + Set-Content -LiteralPath $conflictShortcut -Value 'owned-before-run' + [void](New-Item -Path $conflictRegistryPath -Force -ErrorAction Stop) + $registryCreated = $true + Set-ItemProperty -LiteralPath $conflictRegistryPath -Name 'PreExisting' -Value 'owned-before-run' + + $script:conflictingFixtureUserName = $userName + $script:conflictingFixtureUserSid = $userSid.Value + $script:conflictingFixtureProfileSid = $runnerProfileBefore.ProfileSid + $script:conflictingFixtureProfilePath = $runnerProfileBefore.CanonicalLocalPath + $script:conflictingFixtureDirectories = @( + $conflictInstallRoot, $conflictShortcutFolder, $conflictSmokeDirectory + ) -join '|' + $script:conflictingFixtureShortcut = $conflictShortcut + $script:conflictingFixtureRegistryPath = $conflictRegistryPath + + $result = Invoke-FixtureScenario 'OWNED_RESOURCES_THEN_DEADLINE' $stateDirectory + Assert-True ($result.ExitCode -eq 124) 'owned-resource timeout did not preserve watchdog status' + Assert-Contains $result.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:CLEANUP:SMOKE_DATA_REMOVE:BEGIN:TIMED_OUT' ` + 'owned-resource fixture did not reach the forced timeout boundary' + Assert-Contains $result.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:COMPLETE' ` + 'forced timeout did not execute bounded post-termination cleanup' + $redactedEvidence = "$($result.Output)`n$($result.Error)" + foreach ($forbidden in @( + $runnerProfileBefore.IdentitySid, + $runnerProfileBefore.CanonicalLocalPath, + $userName, + $userSid.Value, + $ownedFixtureUserName, + $ownedFixturePassword + )) { + Assert-NotContains $redactedEvidence $forbidden ` + 'ownership cleanup evidence exposed an identity or credential' + } - $stateDirectory = New-StateDirectory 'ownership' - $process = [Diagnostics.Process]::new() - $process.StartInfo = New-SupervisorStartInfo '' $stateDirectory '' $true - if (!$process.Start()) { throw 'production ownership probe did not start' } - try { - Assert-True ($process.WaitForExit(20000)) 'production ownership probe did not complete within the bound' - $output = $process.StandardOutput.ReadToEnd() - $standardError = $process.StandardError.ReadToEnd() - Assert-True ($process.ExitCode -ne 0) 'production worker accepted a pre-existing resource baseline' - Assert-Contains $output ` - 'PROPR_WINDOWS_INSTALLED_SMOKE:OPERATION:INITIALIZATION:BASELINE:FAILED' ` - 'production worker did not execute its pre-existing-resource rejection path' - Assert-NotContains $output ` - 'PROPR_WINDOWS_INSTALLED_SMOKE:OPERATION:CLEANUP:PROFILE_LOOKUP:BEGIN' ` - 'production worker selected a pre-existing profile for lookup' - Assert-NotContains $output ` - 'PROPR_WINDOWS_INSTALLED_SMOKE:OPERATION:CLEANUP:PROFILE_REMOVE:BEGIN' ` - 'production worker selected a pre-existing profile for deletion' - $redactedEvidence = "$output`n$standardError" - Assert-NotContains $redactedEvidence $runnerProfileBefore.IdentitySid ` - 'ownership evidence exposed the runner identity SID' - Assert-NotContains $redactedEvidence $runnerProfileBefore.CanonicalLocalPath ` - 'ownership evidence exposed the runner profile path' - Assert-NotContains $redactedEvidence $userName ` - 'ownership evidence exposed the fixture local-user name' - Assert-NotContains $redactedEvidence $userSid.Value ` - 'ownership evidence exposed the fixture local-user SID' - } finally { - if (!$process.HasExited) { try { $process.Kill($true) } catch {} } - $process.Dispose() + $owned = Read-FixtureResourceState $stateDirectory + foreach ($ownedPath in @( + $owned.OwnedRoot, $owned.InstallRoot, $owned.ShortcutFolder, + $owned.Shortcut, $owned.SmokeDirectory + )) { + Assert-True (!(Test-Path -LiteralPath $ownedPath)) ` + 'post-termination cleanup left a run-owned file-system resource behind' } + Assert-True (!(Test-Path -LiteralPath $owned.RegistryPath)) ` + 'post-termination cleanup left a run-owned registry resource behind' + Assert-True (!(Test-Path -LiteralPath $owned.RegistryRoot)) ` + 'post-termination cleanup left the run-owned registry root behind' + Assert-True ($null -eq (Get-LocalUser -Name $owned.UserName -ErrorAction SilentlyContinue)) ` + 'post-termination cleanup left the run-owned local user behind' + $ownedProfiles = @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop | + Where-Object { $_.SID -ceq $owned.UserSid }) + Assert-True ($ownedProfiles.Count -eq 0) ` + 'post-termination cleanup left the run-owned profile behind' - Assert-True ((Get-Content -LiteralPath (Join-Path $installRoot 'pre-existing.txt') -Raw).Trim() -ceq ` + Assert-True ((Get-Content -LiteralPath (Join-Path $conflictInstallRoot 'pre-existing.txt') -Raw).Trim() -ceq ` 'owned-before-run') 'pre-existing install tree was removed or changed' - Assert-True ((Get-ItemPropertyValue -LiteralPath $protocolRoot -Name 'PreExisting') -ceq ` + Assert-True ((Get-ItemPropertyValue -LiteralPath $conflictRegistryPath -Name 'PreExisting') -ceq ` 'owned-before-run') 'pre-existing registry tree was removed or changed' - Assert-True ((Get-Content -LiteralPath $shortcut -Raw).Trim() -ceq ` + Assert-True ((Get-Content -LiteralPath $conflictShortcut -Raw).Trim() -ceq ` 'owned-before-run') 'pre-existing shortcut was removed or changed' + Assert-True ((Get-Content -LiteralPath (Join-Path $conflictSmokeDirectory 'pre-existing.txt') -Raw).Trim() -ceq ` + 'owned-before-run') 'pre-existing smoke data was removed or changed' $remainingUser = Get-LocalUser -Name $userName -ErrorAction Stop Assert-True ($remainingUser.SID.Equals($userSid)) 'pre-existing local user was removed or replaced' $fixtureUserProfiles = @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop | @@ -408,14 +467,20 @@ function Test-PreExistingCleanupOwnership { Assert-True ($fixtureUserProfiles.Count -eq 0) ` 'pre-existing local user fixture unexpectedly acquired a profile' } finally { - if ($shortcutCreated -and (Test-Path -LiteralPath $shortcutFolder)) { - Remove-Item -LiteralPath $shortcutFolder -Recurse -Force -ErrorAction SilentlyContinue - } - if ($protocolCreated -and (Test-Path -LiteralPath $protocolRoot)) { - Remove-Item -LiteralPath $protocolRoot -Recurse -Force -ErrorAction SilentlyContinue + $script:conflictingFixtureUserName = $null + $script:conflictingFixtureUserSid = $null + $script:conflictingFixtureProfileSid = $null + $script:conflictingFixtureProfilePath = $null + $script:conflictingFixtureDirectories = $null + $script:conflictingFixtureShortcut = $null + $script:conflictingFixtureRegistryPath = $null + if ($registryCreated -and (Test-Path -LiteralPath $conflictRegistryPath)) { + Remove-Item -LiteralPath $conflictRegistryPath -Recurse -Force -ErrorAction SilentlyContinue } - if ($installCreated -and (Test-Path -LiteralPath $installRoot)) { - Remove-Item -LiteralPath $installRoot -Recurse -Force -ErrorAction SilentlyContinue + $fixtureRegistryRoot = 'Registry::HKEY_LOCAL_MACHINE\Software\ProPRSupervisorFixture' + if ((Test-Path -LiteralPath $fixtureRegistryRoot) -and + @(Get-ChildItem -LiteralPath $fixtureRegistryRoot -Force -ErrorAction SilentlyContinue).Count -eq 0) { + Remove-Item -LiteralPath $fixtureRegistryRoot -Force -ErrorAction SilentlyContinue } if ($userCreated) { $ownedUser = Get-LocalUser -Name $userName -ErrorAction SilentlyContinue @@ -427,6 +492,15 @@ function Test-PreExistingCleanupOwnership { 'ownership local-user fixture cleanup failed' } } + $ownedUser = Get-LocalUser -Name $ownedFixtureUserName -ErrorAction SilentlyContinue + if ($null -ne $ownedUser) { + $ownedProfiles = @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction SilentlyContinue | + Where-Object { $_.SID -ceq $ownedUser.SID.Value }) + foreach ($profile in $ownedProfiles) { + Remove-CimInstance -InputObject $profile -ErrorAction SilentlyContinue + } + Remove-LocalUser -Name $ownedFixtureUserName -ErrorAction SilentlyContinue + } Assert-RunnerProfileUnchanged $runnerProfileBefore } Write-Host 'PROPR_WINDOWS_SUPERVISOR_OWNERSHIP:PRE_EXISTING_AUTHORITIES:PRESERVED' diff --git a/apps/desktop/scripts/test-installed-windows-app.ps1 b/apps/desktop/scripts/test-installed-windows-app.ps1 index 96d5e2072..d88cddbc2 100644 --- a/apps/desktop/scripts/test-installed-windows-app.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app.ps1 @@ -2,7 +2,8 @@ param( [Parameter(Mandatory=$true)][string]$Installer, [Parameter(Mandatory=$true)][ValidateSet('x64','arm64')][string]$Architecture, [Parameter(Mandatory=$true)][string]$WatchdogMarker, - [Parameter(Mandatory=$true)][string]$OwnershipReadyEvent + [Parameter(Mandatory=$true)][string]$OwnershipReadyEvent, + [Parameter(Mandatory=$true)][string]$OwnershipManifest ) enum SmokeEvidenceInspectionPhase { @@ -41,6 +42,16 @@ if ((Split-Path -Leaf $watchdogMarkerPath) -notmatch )) { throw 'watchdog marker path is invalid' } +$ownershipManifestPath = [IO.Path]::GetFullPath($OwnershipManifest) +if ((Split-Path -Leaf $ownershipManifestPath) -notmatch + '^propr-installed-app-ownership-[a-f0-9]{32}\.json$' -or + ![string]::Equals( + (Split-Path -Parent $ownershipManifestPath).TrimEnd('\'), + $watchdogMarkerParent, + [StringComparison]::OrdinalIgnoreCase + )) { + throw 'ownership manifest path is invalid' +} $bootstrapDeadline = [DateTime]::UtcNow.AddMilliseconds($bootstrapWatchdogTimeoutMilliseconds).Ticks $bootstrapRecord = '{0}|INITIALIZATION|PATHS|BEGIN' -f $bootstrapDeadline $bootstrapBytes = [Text.Encoding]::ASCII.GetBytes($bootstrapRecord) @@ -148,6 +159,64 @@ $startMenuShortcutFolderExistedBeforeInstall = Test-Path -LiteralPath $startMenu $startMenuShortcutCreatedByRun = $false $startMenuShortcutFolderCreatedByRun = $false $shortcutFileByteCap = 64 * 1024 +$ownershipRunId = [IO.Path]::GetFileNameWithoutExtension($ownershipManifestPath).Substring( + 'propr-installed-app-ownership-'.Length) +$ownershipToken = [Guid]::NewGuid().ToString('N') +$ownershipState = [ordered]@{ + SchemaVersion = 1 + RunId = $ownershipRunId + InstallerPath = $installerPath + Fixture = $false + FixtureRoot = $null + BaselineClean = $false + InstallAttempted = $false + Directories = @() + Files = @() + RegistryKeys = @() + Users = @() + Profiles = @() +} + +function Write-OwnershipManifest { + $temporaryManifest = "$ownershipManifestPath.new" + $bytes = [Text.Encoding]::UTF8.GetBytes( + ($ownershipState | ConvertTo-Json -Depth 6 -Compress)) + $stream = [IO.FileStream]::new( + $temporaryManifest, + [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() + } + [IO.File]::Move($temporaryManifest, $ownershipManifestPath, $true) +} + +function Write-DurableOwnershipToken([string]$Path, [string]$Token) { + $bytes = [Text.Encoding]::ASCII.GetBytes($Token) + $stream = [IO.FileStream]::new( + $Path, + [IO.FileMode]::CreateNew, + [IO.FileAccess]::Write, + [IO.FileShare]::Read, + 4096, + [IO.FileOptions]::WriteThrough + ) + try { + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush($true) + } finally { + $stream.Dispose() + } +} + +Write-OwnershipManifest function Write-WatchdogMarker( [ValidateSet('INITIALIZATION','INSTALL','VALIDATION','USER_SETUP','APP_LAUNCH','APP_EXIT','UNINSTALL','CLEANUP')] @@ -263,6 +332,8 @@ try { $startMenuShortcutExistedBeforeInstall -or $startMenuShortcutFolderExistedBeforeInstall) { throw 'installed-app harness requires an unowned clean machine baseline' } + $ownershipState.BaselineClean = $true + Write-OwnershipManifest Write-WatchdogMarker 'INITIALIZATION' 'BASELINE' $externalOperationTimeoutMilliseconds 'COMPLETE' } catch { Write-WatchdogMarker 'INITIALIZATION' 'BASELINE' $externalOperationTimeoutMilliseconds 'FAILED' @@ -652,8 +723,16 @@ function Test-StartMenuShortcutAsOrdinaryUser( throw 'ordinary-user shortcut probe failed' } -function New-SmokeUserDataDirectory([Security.Principal.SecurityIdentifier]$UserSid) { - $path = Join-Path $machineTemp "propr-desktop-smoke-$([Guid]::NewGuid().ToString('N'))" +function New-SmokeUserDataDirectory( + [Security.Principal.SecurityIdentifier]$UserSid, + [string]$Path +) { + $path = [IO.Path]::GetFullPath($Path) + if ((Split-Path -Leaf $path) -notmatch '^propr-desktop-smoke-[a-f0-9]{32}$' -or + ![string]::Equals( + (Split-Path -Parent $path), $machineTemp, [StringComparison]::OrdinalIgnoreCase)) { + throw 'smoke user-data directory path is invalid' + } $createdByRun = $false try { if (Test-Path -LiteralPath $path) { @@ -879,6 +958,26 @@ try { Write-Stage 'INSTALL' 'BEGIN' try { $installAttempted = $true + $ownershipState.InstallAttempted = $true + # The clean baseline plus the durable install-attempt transition owns any + # canonical product resource that appears before MSI returns or hangs. + $ownershipState.Directories = @( + [ordered]@{ + Kind = 'INSTALL_ROOT'; Path = $installRoot; Owned = $true; Token = $null + }, + [ordered]@{ + Kind = 'SHORTCUT_FOLDER'; Path = $startMenuShortcutFolder + Owned = $true; Token = $null + } + ) + $ownershipState.Files = @([ordered]@{ + Kind = 'SHORTCUT_FILE'; Path = $startMenuShortcut; Owned = $true; Token = $null + }) + $ownershipState.RegistryKeys = @([ordered]@{ + Kind = 'PROTOCOL'; Path = 'Registry::HKEY_LOCAL_MACHINE\Software\Classes\propr' + Owned = $true; Token = $null + }) + Write-OwnershipManifest try { Invoke-BoundedExternalOperation ` -Stage 'INSTALL' ` @@ -903,6 +1002,31 @@ try { $script:startMenuShortcutFolderCreatedByRun = !$startMenuShortcutFolderExistedBeforeInstall -and (Test-Path -LiteralPath $startMenuShortcutFolder) + $ownedDirectories = @() + if ($script:installRootCreatedByRun) { + $ownedDirectories += [ordered]@{ + Kind = 'INSTALL_ROOT'; Path = $installRoot; Owned = $true; Token = $null + } + } + if ($script:startMenuShortcutFolderCreatedByRun) { + $ownedDirectories += [ordered]@{ + Kind = 'SHORTCUT_FOLDER'; Path = $startMenuShortcutFolder + Owned = $true; Token = $null + } + } + $ownershipState.Directories = $ownedDirectories + $ownershipState.Files = if ($script:startMenuShortcutCreatedByRun) { + @([ordered]@{ + Kind = 'SHORTCUT_FILE'; Path = $startMenuShortcut; Owned = $true; Token = $null + }) + } else { @() } + $ownershipState.RegistryKeys = if ($script:protocolCreatedByRun) { + @([ordered]@{ + Kind = 'PROTOCOL'; Path = 'Registry::HKEY_LOCAL_MACHINE\Software\Classes\propr' + Owned = $true; Token = $null + }) + } else { @() } + Write-OwnershipManifest } } Write-Stage 'INSTALL' 'COMPLETE' @@ -973,17 +1097,46 @@ try { if (Get-LocalUser -Name $testUser -ErrorAction SilentlyContinue) { throw 'refusing to replace a pre-existing local user' } + $provisionalUser = [ordered]@{ + Name = $testUser + Sid = $null + Owned = $true + Provisional = $true + } + $ownershipState.Users = @($provisionalUser) + Write-OwnershipManifest New-LocalUser -Name $testUser -Password $password ` -AccountNeverExpires -PasswordNeverExpires | Out-Null $script:testUserCreatedByRun = $true + $script:testUserSid = (Get-LocalUser -Name $testUser -ErrorAction Stop).SID + $provisionalUser.Sid = $script:testUserSid.Value + $provisionalUser.Provisional = $false + Write-OwnershipManifest } $testUserSid = Invoke-BoundedExternalOperation 'USER_SETUP' 'USER_SID' ` $externalOperationTimeoutMilliseconds { - (Get-LocalUser -Name $testUser -ErrorAction Stop).SID + $script:testUserSid } + $smokeUserDataCandidate = Join-Path ` + $machineTemp "propr-desktop-smoke-$([Guid]::NewGuid().ToString('N'))" + if (Test-Path -LiteralPath $smokeUserDataCandidate) { + throw 'refusing to replace a pre-existing smoke user-data directory' + } + $smokeOwnershipRecord = [ordered]@{ + Kind = 'SMOKE_DATA'; Path = $smokeUserDataCandidate + Owned = $true; Token = $ownershipToken; Provisional = $true + } + $ownershipState.Directories = @($ownershipState.Directories) + @($smokeOwnershipRecord) + Write-OwnershipManifest $smokeUserDataDirectory = Invoke-BoundedExternalOperation ` 'USER_SETUP' 'SMOKE_DATA_CREATE' $recursiveOperationTimeoutMilliseconds { - New-SmokeUserDataDirectory $testUserSid + $ownedSmokeDirectory = New-SmokeUserDataDirectory $testUserSid $smokeUserDataCandidate + Write-DurableOwnershipToken ` + -Path (Join-Path $ownedSmokeDirectory '.propr-installed-app-owner') ` + -Token $ownershipToken + $smokeOwnershipRecord.Provisional = $false + Write-OwnershipManifest + $ownedSmokeDirectory } Invoke-BoundedExternalOperation ` 'USER_SETUP' 'SHORTCUT_PRESENT_PROBE' $externalOperationTimeoutMilliseconds { diff --git a/apps/desktop/src/release-workflow.test.ts b/apps/desktop/src/release-workflow.test.ts index 589a25332..c58194df1 100644 --- a/apps/desktop/src/release-workflow.test.ts +++ b/apps/desktop/src/release-workflow.test.ts @@ -46,6 +46,10 @@ const installedWindowsAppSupervisor = normalizeWorkflowText(readFileSync( fileURLToPath(new URL('../scripts/run-installed-windows-app-harness.ps1', import.meta.url)), 'utf8', )); +const installedWindowsAppCleanup = normalizeWorkflowText(readFileSync( + fileURLToPath(new URL('../scripts/cleanup-installed-windows-app.ps1', import.meta.url)), + 'utf8', +)); const installedWindowsAppSupervisorBehaviorTest = normalizeWorkflowText(readFileSync( fileURLToPath(new URL('../scripts/test-installed-windows-app-supervisor.ps1', import.meta.url)), 'utf8', @@ -576,7 +580,7 @@ describe('desktop trusted release workflow', () => { ); assert.doesNotMatch( installedWindowsAppSupervisorBehaviorTest, - /CreateProfile|DeleteProfile|Remove-CimInstance|userenv\.dll/, + /CreateProfile|DeleteProfile|userenv\.dll/, ); assert.match(installedWindowsAppSupervisorFixture, /Start-FixtureDescendant/); @@ -595,11 +599,19 @@ describe('desktop trusted release workflow', () => { assert.match(installedWindowsAppSupervisor, /\[ProPRBoundedMarkerReader\]::ReadAsync\(\$Path\)/); assert.match(installedWindowsAppSupervisor, /\$readTask\.Wait\(\$TimeoutMilliseconds\)/); assert.match(installedWindowsAppSupervisor, /\$job\.Terminate\(\$TerminationExitCode\)/); + assert.match(installedWindowsAppSupervisor, /Invoke-PostTerminationCleanup/); + assert.match( + installedWindowsAppSupervisor, + /PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:COMPLETE/, + ); + assert.match(installedWindowsAppCleanup, /Remove-OwnedProfiles/); + assert.match(installedWindowsAppCleanup, /Remove-OwnedRegistryKey/); + assert.match(installedWindowsAppCleanup, /Remove-OwnedDirectory/); assert.match(installedWindowsAppSupervisor, /exit \$exitCode/); assert.match(installedWindowsAppSupervisor, /if \(\$null -ne \$job\) \{ \$job\.Dispose\(\) \}/); assert.match(installedWindowsAppTest, /\[IO\.FileOptions\]::WriteThrough/); - assert.equal(installedWindowsAppTest.match(/\.Flush\(\$true\)/g)?.length, 2); + assert.equal(installedWindowsAppTest.match(/\.Flush\(\$true\)/g)?.length, 4); assert.match( installedWindowsAppTest, /\$record = '\{0\}\|\{1\}\|\{2\}\|\{3\}' -f \$deadline, \$Stage, \$Substage, \$Status/, From b2bfe4780bd75e70fd80c006e3cdd33210e4d15d Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 02:22:35 +0000 Subject: [PATCH 05/29] feat(ai): Implemented the complete #2042 follow-up on exact head `a064d8fbdc99e50d6cf5fef5773a497db1ae58da`. Implemented the complete #2042 follow-up on exact head `a064d8fbdc99e50d6cf5fef5773a497db1ae58da`. Key changes: - Added durable, workflow-addressable ownership manifests and bounded `if: always()` cleanup for both Windows matrix jobs. - Supervisor now derives cleanup necessity from started/live/incomplete workers, terminates the Job first, and only removes internally managed manifests after fixed cleanup results. - Added graceful external interruption and killed-supervisor workflow cleanup fixtures, including malformed/stale/missing manifest rejection and conflict preservation. - Added complete App Paths registry authority across baseline, manifest, validation, uninstall, fallback cleanup, and owned/pre-existing/mismatched-identity tests. - Changed only `VALID_THEN_DEADLINE` to a 2.5-second operation deadline with exact accepted-before-timeout and measured bounds. - Preserved production watchdog deadlines and polling settings. Local validation passed: - Desktop suite: 177 passed, 6 platform skips - Desktop/UI typechecks - Fast unit suite: 278 passed - Focused workflow contracts: 23 passed - Workflow YAML parsing - `git diff --check`, including the new cleanup script Windows-native x64/ARM64 fixtures and the Redis-backed Full Suite could not run in this Linux workspace because PowerShell, Windows registry/CIM, Docker, and Redis services are unavailable. Both Windows architectures remain mandatory in the updated workflow. No commit was created. PR: #2042 Comment by: @integry (ID: 5487638811) Model: gpt-5.6-sol --- .github/workflows/desktop-release-guard.yml | 38 +- .../scripts/cleanup-installed-windows-app.ps1 | 212 +++++++++++- .../run-installed-windows-app-harness.ps1 | 58 +++- ...installed-windows-app-workflow-cleanup.ps1 | 194 +++++++++++ ...stalled-windows-app-supervisor-fixture.ps1 | 12 +- .../test-installed-windows-app-supervisor.ps1 | 324 +++++++++++++++++- .../scripts/test-installed-windows-app.ps1 | 221 +++++++++++- apps/desktop/src/release-workflow.test.ts | 51 ++- 8 files changed, 1064 insertions(+), 46 deletions(-) create mode 100644 apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 diff --git a/.github/workflows/desktop-release-guard.yml b/.github/workflows/desktop-release-guard.yml index 1987863a2..d121602e4 100644 --- a/.github/workflows/desktop-release-guard.yml +++ b/.github/workflows/desktop-release-guard.yml @@ -193,11 +193,28 @@ jobs: run: | $installers = @(Get-ChildItem apps/desktop/out/make -Recurse -File -Filter '*Machine-Setup.msi') if ($installers.Count -ne 1) { throw 'Machine-wide Windows installer is missing or ambiguous' } + $runId = [Guid]::NewGuid().ToString('N') + $ownershipManifest = Join-Path ([IO.Path]::GetTempPath()) ` + "propr-installed-app-ownership-$runId.json" + "PROPR_WINDOWS_INSTALLED_APP_RUN_ID=$runId" | Out-File -FilePath $env:GITHUB_ENV -Append + "PROPR_WINDOWS_INSTALLED_APP_MANIFEST=$ownershipManifest" | Out-File -FilePath $env:GITHUB_ENV -Append + "PROPR_WINDOWS_INSTALLED_APP_INSTALLER=$($installers[0].FullName)" | Out-File -FilePath $env:GITHUB_ENV -Append & apps/desktop/scripts/run-installed-windows-app-harness.ps1 ` -Installer $installers[0].FullName ` - -Architecture '${{ matrix.arch }}' + -Architecture '${{ matrix.arch }}' ` + -OwnershipManifest $ownershipManifest ` + -ExpectedRunId $runId "PROPR_DESKTOP_WINDOWS_INSTALLED_APP=1" | Out-File -FilePath $env:GITHUB_ENV -Append + - name: Always clean Windows installed-app ownership + if: always() && matrix.platform == 'win32' && env.PROPR_WINDOWS_INSTALLED_APP_RUN_ID != '' + shell: pwsh + run: | + & apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 ` + -OwnershipManifest $env:PROPR_WINDOWS_INSTALLED_APP_MANIFEST ` + -Installer $env:PROPR_WINDOWS_INSTALLED_APP_INSTALLER ` + -ExpectedRunId $env:PROPR_WINDOWS_INSTALLED_APP_RUN_ID + - name: Launch packaged Linux application if: matrix.platform == 'linux' shell: bash @@ -638,11 +655,28 @@ jobs: run: | $installers = @(Get-ChildItem apps/desktop/out/make -Recurse -File -Filter '*Machine-Setup.msi') if ($installers.Count -ne 1) { throw 'Signed machine-wide Windows installer is missing or ambiguous' } + $runId = [Guid]::NewGuid().ToString('N') + $ownershipManifest = Join-Path ([IO.Path]::GetTempPath()) ` + "propr-installed-app-ownership-$runId.json" + "PROPR_WINDOWS_INSTALLED_APP_RUN_ID=$runId" | Out-File -FilePath $env:GITHUB_ENV -Append + "PROPR_WINDOWS_INSTALLED_APP_MANIFEST=$ownershipManifest" | Out-File -FilePath $env:GITHUB_ENV -Append + "PROPR_WINDOWS_INSTALLED_APP_INSTALLER=$($installers[0].FullName)" | Out-File -FilePath $env:GITHUB_ENV -Append & apps/desktop/scripts/run-installed-windows-app-harness.ps1 ` -Installer $installers[0].FullName ` - -Architecture '${{ matrix.arch }}' + -Architecture '${{ matrix.arch }}' ` + -OwnershipManifest $ownershipManifest ` + -ExpectedRunId $runId "PROPR_DESKTOP_WINDOWS_INSTALLED_APP=1" | Out-File -FilePath $env:GITHUB_ENV -Append + - name: Always clean signed Windows installed-app ownership + if: always() && matrix.platform == 'win32' && env.PROPR_WINDOWS_INSTALLED_APP_RUN_ID != '' + shell: pwsh + run: | + & apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 ` + -OwnershipManifest $env:PROPR_WINDOWS_INSTALLED_APP_MANIFEST ` + -Installer $env:PROPR_WINDOWS_INSTALLED_APP_INSTALLER ` + -ExpectedRunId $env:PROPR_WINDOWS_INSTALLED_APP_RUN_ID + - name: Launch packaged Linux application if: matrix.platform == 'linux' shell: bash diff --git a/apps/desktop/scripts/cleanup-installed-windows-app.ps1 b/apps/desktop/scripts/cleanup-installed-windows-app.ps1 index 449c1738d..bd58ec016 100644 --- a/apps/desktop/scripts/cleanup-installed-windows-app.ps1 +++ b/apps/desktop/scripts/cleanup-installed-windows-app.ps1 @@ -1,6 +1,7 @@ param( [Parameter(Mandatory=$true)][string]$OwnershipManifest, [Parameter(Mandatory=$true)][string]$Installer, + [Parameter(Mandatory=$true)][string]$ExpectedRunId, [Parameter(Mandatory=$true)][string]$OwnershipReadyEvent, [string]$FixtureRoot ) @@ -13,6 +14,7 @@ $cleanupFailed = $false $authorizedRunId = $null try { + if ($ExpectedRunId -notmatch '^[a-f0-9]{32}$') { exit 1 } if ($OwnershipReadyEvent -notmatch '^Local\\ProPRInstalledAppCleanup-[a-f0-9]{32}$') { exit 1 } @@ -51,6 +53,78 @@ function Test-OwnerFile([string]$Directory, [string]$Token) { return ([IO.File]::ReadAllText($marker, [Text.Encoding]::ASCII) -ceq $Token) } +function Get-RegistryTreeIdentity([string]$Path) { + if (!(Test-Path -LiteralPath $Path)) { return $null } + $root = Get-Item -LiteralPath $Path -ErrorAction Stop + $records = [Collections.Generic.List[string]]::new() + $pending = [Collections.Generic.Queue[object]]::new() + $pending.Enqueue([PSCustomObject]@{ Key = $root; Relative = '' }) + while ($pending.Count -ne 0) { + $entry = $pending.Dequeue() + $records.Add(('K|{0}' -f [Convert]::ToBase64String( + [Text.Encoding]::UTF8.GetBytes([string]$entry.Relative)))) + foreach ($valueName in @($entry.Key.GetValueNames() | Sort-Object -CaseSensitive)) { + $value = $entry.Key.GetValue( + $valueName, + $null, + [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames + ) + $valueBytes = if ($value -is [byte[]]) { + $value + } elseif ($value -is [string[]]) { + [Text.Encoding]::UTF8.GetBytes(($value | ConvertTo-Json -Compress)) + } else { + [Text.Encoding]::UTF8.GetBytes([Convert]::ToString( + $value, + [Globalization.CultureInfo]::InvariantCulture + )) + } + $records.Add(('V|{0}|{1}|{2}' -f + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([string]$valueName)), + $entry.Key.GetValueKind($valueName).ToString(), + [Convert]::ToBase64String($valueBytes))) + } + foreach ($child in @(Get-ChildItem -LiteralPath $entry.Key.PSPath -ErrorAction Stop | + Sort-Object -Property PSChildName -CaseSensitive)) { + $relative = if ($entry.Relative) { + '{0}\{1}' -f $entry.Relative, $child.PSChildName + } else { [string]$child.PSChildName } + $pending.Enqueue([PSCustomObject]@{ Key = $child; Relative = $relative }) + } + } + $payload = [Text.Encoding]::UTF8.GetBytes(($records -join "`n")) + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + return [BitConverter]::ToString($sha256.ComputeHash($payload)).Replace('-', '').ToLowerInvariant() + } + finally { $sha256.Dispose() } +} + +function Test-ProvisionalRegistryIdentity([string]$Kind, [string]$Path, [string]$Application) { + if ($Kind -eq 'APP_PATH') { + $key = Get-Item -LiteralPath $Path -ErrorAction Stop + return @($key.GetSubKeyNames()).Count -eq 0 -and + @($key.GetValueNames()).Count -eq 1 -and + @($key.GetValueNames())[0] -ceq '' -and + [string]$key.GetValue('') -ceq $Application + } + if ($Kind -ne 'PROTOCOL') { return $false } + $root = Get-Item -LiteralPath $Path -ErrorAction Stop + $shell = Get-Item -LiteralPath "$Path\shell" -ErrorAction Stop + $open = Get-Item -LiteralPath "$Path\shell\open" -ErrorAction Stop + $command = Get-Item -LiteralPath "$Path\shell\open\command" -ErrorAction Stop + return @($root.GetSubKeyNames()).Count -eq 1 -and $root.GetSubKeyNames()[0] -ceq 'shell' -and + (@($root.GetValueNames() | Sort-Object -CaseSensitive) -join '|') -ceq '|URL Protocol' -and + [string]$root.GetValue('') -ceq 'URL:ProPR Protocol' -and + [string]$root.GetValue('URL Protocol') -ceq '' -and + @($shell.GetSubKeyNames()).Count -eq 1 -and $shell.GetSubKeyNames()[0] -ceq 'open' -and + @($shell.GetValueNames()).Count -eq 0 -and + @($open.GetSubKeyNames()).Count -eq 1 -and $open.GetSubKeyNames()[0] -ceq 'command' -and + @($open.GetValueNames()).Count -eq 0 -and @($command.GetSubKeyNames()).Count -eq 0 -and + @($command.GetValueNames()).Count -eq 1 -and $command.GetValueNames()[0] -ceq '' -and + [string]$command.GetValue('') -ceq "`"$Application`" `"%1`"" +} + function Test-AllowedFileSystemPath([string]$Kind, [string]$Path) { if ($FixtureRoot) { return Test-PathWithin $Path $FixtureRoot } $installRoot = Join-Path $env:ProgramFiles 'ProPR Desktop' @@ -113,21 +187,32 @@ function Remove-OwnedFile($Record, [bool]$AllowProvisionalProductOwnership) { function Remove-OwnedRegistryKey($Record, [bool]$AllowProvisionalProductOwnership) { if (!$Record.Owned) { return } $path = [string]$Record.Path - $productionPath = 'Registry::HKEY_LOCAL_MACHINE\Software\Classes\propr' + $kind = [string]$Record.Kind + $productionPaths = @{ + PROTOCOL = 'Registry::HKEY_LOCAL_MACHINE\Software\Classes\propr' + APP_PATH = 'Registry::HKEY_LOCAL_MACHINE\Software\Microsoft\Windows\CurrentVersion\App Paths\propr-desktop.exe' + } if ($FixtureRoot) { $expectedPath = "Registry::HKEY_LOCAL_MACHINE\Software\ProPRSupervisorFixture\$authorizedRunId\owned" if (![string]::Equals($path, $expectedPath, [StringComparison]::OrdinalIgnoreCase)) { throw 'registry cleanup scope is invalid' } - } elseif (![string]::Equals($path, $productionPath, [StringComparison]::OrdinalIgnoreCase)) { + } elseif (!$productionPaths.ContainsKey($kind) -or + ![string]::Equals($path, $productionPaths[$kind], [StringComparison]::OrdinalIgnoreCase)) { throw 'registry cleanup scope is invalid' } if (!(Test-Path -LiteralPath $path)) { return } - $provisional = $AllowProvisionalProductOwnership -and - [string]::Equals($path, $productionPath, [StringComparison]::OrdinalIgnoreCase) + $provisional = $AllowProvisionalProductOwnership -and [bool]$Record.Provisional if (!$provisional) { - $token = Get-ItemPropertyValue -LiteralPath $path -Name $ownerRegistryValue -ErrorAction Stop - if ([string]$token -cne [string]$Record.Token) { throw 'owned registry token does not match' } + if ($FixtureRoot) { + $token = Get-ItemPropertyValue -LiteralPath $path -Name $ownerRegistryValue -ErrorAction Stop + if ([string]$token -cne [string]$Record.Token) { throw 'owned registry token does not match' } + } elseif ([string]$Record.Identity -notmatch '^[a-f0-9]{64}$' -or + (Get-RegistryTreeIdentity $path) -cne [string]$Record.Identity) { + throw 'owned registry identity does not match' + } + } elseif (!(Test-ProvisionalRegistryIdentity $kind $path $script:authorizedApplication)) { + throw 'provisional registry identity does not match' } Remove-Item -LiteralPath $path -Recurse -Force -ErrorAction Stop if (Test-Path -LiteralPath $path) { throw 'owned registry cleanup did not complete' } @@ -222,16 +307,59 @@ try { $manifestItem.Length -le 0 -or $manifestItem.Length -gt 65536) { throw 'ownership manifest metadata is invalid' } - $manifest = [IO.File]::ReadAllText($manifestPath, [Text.Encoding]::UTF8) | - ConvertFrom-Json -ErrorAction Stop - if ($manifest.SchemaVersion -ne 1 -or + $manifestBytes = [byte[]]::new([int]$manifestItem.Length) + $manifestStream = [IO.File]::Open( + $manifestPath, + [IO.FileMode]::Open, + [IO.FileAccess]::Read, + [IO.FileShare]::Read + ) + try { + $manifestOffset = 0 + while ($manifestOffset -lt $manifestBytes.Length) { + $read = $manifestStream.Read( + $manifestBytes, + $manifestOffset, + $manifestBytes.Length - $manifestOffset + ) + if ($read -eq 0) { throw 'ownership manifest read was incomplete' } + $manifestOffset += $read + } + if ($manifestStream.ReadByte() -ne -1) { throw 'ownership manifest changed during read' } + } finally { + $manifestStream.Dispose() + } + $strictUtf8 = [Text.UTF8Encoding]::new($false, $true) + $manifest = ConvertFrom-Json -InputObject $strictUtf8.GetString($manifestBytes) -ErrorAction Stop + $manifestKeys = @($manifest.PSObject.Properties | ForEach-Object { $_.Name }) + $expectedManifestKeys = @( + 'SchemaVersion','RunId','CreatedUtcTicks','ExpiresUtcTicks','InstallerPath','Fixture', + 'FixtureRoot','BaselineClean','InstallAttempted','Directories','Files','RegistryKeys', + 'Users','Profiles' + ) + if ($manifestKeys.Count -ne $expectedManifestKeys.Count -or + @($expectedManifestKeys | Where-Object { $manifestKeys -cnotcontains $_ }).Count -ne 0 -or + $manifest.Fixture -isnot [bool] -or $manifest.BaselineClean -isnot [bool] -or + $manifest.InstallAttempted -isnot [bool] -or + $manifest.SchemaVersion -ne 1 -or [string]$manifest.RunId -notmatch '^[a-f0-9]{32}$') { throw 'ownership manifest schema is invalid' } $authorizedRunId = [string]$manifest.RunId $pathRunId = [IO.Path]::GetFileNameWithoutExtension($manifestPath).Substring( 'propr-installed-app-ownership-'.Length) - if ($authorizedRunId -cne $pathRunId) { throw 'ownership manifest run identity is invalid' } + if ($authorizedRunId -cne $pathRunId -or $authorizedRunId -cne $ExpectedRunId) { + throw 'ownership manifest run identity is invalid' + } + $createdUtcTicks = [int64]$manifest.CreatedUtcTicks + $expiresUtcTicks = [int64]$manifest.ExpiresUtcTicks + $nowUtcTicks = [DateTime]::UtcNow.Ticks + if ($createdUtcTicks -le 0 -or $expiresUtcTicks -le $createdUtcTicks -or + $expiresUtcTicks - $createdUtcTicks -gt ([TimeSpan]::TicksPerHour * 3) -or + $createdUtcTicks -gt $nowUtcTicks + ([TimeSpan]::TicksPerMinute * 5) -or + $expiresUtcTicks -lt $nowUtcTicks) { + throw 'ownership manifest lifetime is invalid' + } $resolvedInstaller = (Resolve-Path -LiteralPath $Installer -ErrorAction Stop).Path if (!(Test-SamePath ([string]$manifest.InstallerPath) $resolvedInstaller)) { throw 'ownership manifest installer identity is invalid' @@ -245,8 +373,72 @@ try { throw 'fixture ownership manifest was not authorized' } + $script:authorizedApplication = Join-Path $env:ProgramFiles 'ProPR Desktop\propr-desktop.exe' + foreach ($record in @($manifest.Directories)) { + if ($record.Owned -and + !(Test-AllowedFileSystemPath ([string]$record.Kind) ([string]$record.Path))) { + throw 'directory manifest scope is invalid' + } + } + foreach ($record in @($manifest.Files)) { + if ($record.Owned -and + !(Test-AllowedFileSystemPath ([string]$record.Kind) ([string]$record.Path))) { + throw 'file manifest scope is invalid' + } + } + foreach ($record in @($manifest.Users)) { + if ($record.Owned -and [string]$record.Name -notmatch '^(?:propr-ci-|prpr)[a-f0-9]{8}$') { + throw 'user manifest identity is invalid' + } + if ($record.Owned -and !$record.Provisional -and + [string]$record.Sid -notmatch '^S-\d+(?:-\d+)+$') { + throw 'user manifest SID is invalid' + } + } + foreach ($record in @($manifest.Profiles)) { + if ($record.Owned -and ([string]$record.Sid -notmatch '^S-\d+(?:-\d+)+$' -or + ![IO.Path]::IsPathRooted([string]$record.LocalPath))) { + throw 'profile manifest identity is invalid' + } + } + $allowProvisionalProductOwnership = !$manifest.Fixture -and [bool]$manifest.BaselineClean -and [bool]$manifest.InstallAttempted + foreach ($record in @($manifest.RegistryKeys)) { + if (!$record.Owned) { continue } + $path = [string]$record.Path + $kind = [string]$record.Kind + if ($FixtureRoot) { + $expectedPath = "Registry::HKEY_LOCAL_MACHINE\Software\ProPRSupervisorFixture\$authorizedRunId\owned" + if (![string]::Equals($path, $expectedPath, [StringComparison]::OrdinalIgnoreCase)) { + throw 'registry manifest scope is invalid' + } + if (!(Test-Path -LiteralPath $path)) { continue } + if ([string](Get-ItemPropertyValue -LiteralPath $path -Name $ownerRegistryValue ` + -ErrorAction Stop) -cne [string]$record.Token) { + throw 'registry manifest token is invalid' + } + } else { + $expectedPath = if ($kind -eq 'PROTOCOL') { + 'Registry::HKEY_LOCAL_MACHINE\Software\Classes\propr' + } elseif ($kind -eq 'APP_PATH') { + 'Registry::HKEY_LOCAL_MACHINE\Software\Microsoft\Windows\CurrentVersion\App Paths\propr-desktop.exe' + } else { $null } + if (!$expectedPath -or + ![string]::Equals($path, $expectedPath, [StringComparison]::OrdinalIgnoreCase)) { + throw 'registry manifest scope is invalid' + } + if (!(Test-Path -LiteralPath $path)) { continue } + if ($allowProvisionalProductOwnership -and [bool]$record.Provisional) { + if (!(Test-ProvisionalRegistryIdentity $kind $path $script:authorizedApplication)) { + throw 'registry manifest provisional identity is invalid' + } + } elseif ([string]$record.Identity -notmatch '^[a-f0-9]{64}$' -or + (Get-RegistryTreeIdentity $path) -cne [string]$record.Identity) { + throw 'registry manifest ownership identity is invalid' + } + } + } if ($allowProvisionalProductOwnership) { $msiExitCode = 1618 for ($attempt = 0; $attempt -lt 12 -and $msiExitCode -eq 1618; $attempt += 1) { diff --git a/apps/desktop/scripts/run-installed-windows-app-harness.ps1 b/apps/desktop/scripts/run-installed-windows-app-harness.ps1 index 9c6e8516f..6456dc9de 100644 --- a/apps/desktop/scripts/run-installed-windows-app-harness.ps1 +++ b/apps/desktop/scripts/run-installed-windows-app-harness.ps1 @@ -8,7 +8,9 @@ param( [ValidateRange(1000,600000)][int]$PostTerminationCleanupMilliseconds = 4 * 60 * 1000, [ValidateRange(1,5000)][int]$MarkerReadTimeoutMilliseconds = 250, [string]$CancellationEventName, - [string]$FixtureCleanupRoot + [string]$FixtureCleanupRoot, + [string]$OwnershipManifest, + [string]$ExpectedRunId ) $ErrorActionPreference = 'Stop' @@ -24,6 +26,7 @@ $watchdogSubstages = @( 'INSTALL_TREE_SCAN', 'APPLICATION_IMAGE', 'PROTOCOL_ASSERTION', + 'APP_PATH_ASSERTION', 'SHORTCUT_ASSERTION', 'USER_CREATE', 'USER_SID', @@ -36,6 +39,7 @@ $watchdogSubstages = @( 'MSI_UNINSTALL', 'INSTALL_TREE_ASSERTION', 'PROTOCOL_ABSENCE_ASSERTION', + 'APP_PATH_ABSENCE_ASSERTION', 'SHORTCUT_FILE_ASSERTION', 'SHORTCUT_FOLDER_ASSERTION', 'SHORTCUT_ABSENCE_PROBE', @@ -46,12 +50,15 @@ $watchdogSubstages = @( 'USER_REMOVE', 'INSTALL_ROOT_FALLBACK', 'PROTOCOL_FALLBACK', + 'APP_PATH_FALLBACK', 'SHORTCUT_FALLBACK' ) $markerName = "propr-installed-app-watchdog-$([Guid]::NewGuid().ToString('N')).marker" $markerPath = Join-Path ([IO.Path]::GetTempPath()) $markerName -$ownershipManifestName = "propr-installed-app-ownership-$([Guid]::NewGuid().ToString('N')).json" +$generatedRunId = [Guid]::NewGuid().ToString('N') +$ownershipManifestName = "propr-installed-app-ownership-$generatedRunId.json" $ownershipManifestPath = Join-Path ([IO.Path]::GetTempPath()) $ownershipManifestName +$workflowManagedManifest = $false $ownershipReadyEventName = "Local\ProPRInstalledApp-$([Guid]::NewGuid().ToString('N'))" $productionWorkerPath = Join-Path $PSScriptRoot 'test-installed-windows-app.ps1' $cleanupWorkerPath = Join-Path $PSScriptRoot 'cleanup-installed-windows-app.ps1' @@ -62,6 +69,8 @@ $cancellationEvent = $null $lastValidMarker = $null $exitCode = 125 $terminateOwnedTree = $false +$workerStarted = $false +$supervisorOutcomeComplete = $false Add-Type -TypeDefinition @' using System; @@ -326,9 +335,12 @@ function Write-InitialOwnershipManifest( ) { $runId = [IO.Path]::GetFileNameWithoutExtension($Path).Substring( 'propr-installed-app-ownership-'.Length) + $createdUtcTicks = [DateTime]::UtcNow.Ticks $manifest = [ordered]@{ SchemaVersion = 1 RunId = $runId + CreatedUtcTicks = $createdUtcTicks + ExpiresUtcTicks = $createdUtcTicks + ([TimeSpan]::TicksPerHour * 3) InstallerPath = $InstallerPath Fixture = $Fixture FixtureRoot = if ($Fixture) { $AuthorizedFixtureRoot } else { $null } @@ -379,6 +391,7 @@ function Invoke-PostTerminationCleanup([string]$InstallerPath, [string]$Authoriz '-File', $cleanupWorkerPath, '-OwnershipManifest', $ownershipManifestPath, '-Installer', $InstallerPath, + '-ExpectedRunId', $ownershipRunId, '-OwnershipReadyEvent', $cleanupReadyEventName )) { $cleanupStartInfo.ArgumentList.Add($argument) @@ -423,6 +436,27 @@ function Invoke-PostTerminationCleanup([string]$InstallerPath, [string]$Authoriz try { $installerPath = (Resolve-Path -LiteralPath $Installer -ErrorAction Stop).Path + if ($OwnershipManifest -or $ExpectedRunId) { + if (!$OwnershipManifest -or $ExpectedRunId -notmatch '^[a-f0-9]{32}$') { + throw 'workflow ownership authority is invalid' + } + $candidateManifestPath = [IO.Path]::GetFullPath($OwnershipManifest) + $tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + if ((Split-Path -Leaf $candidateManifestPath) -cne + "propr-installed-app-ownership-$ExpectedRunId.json" -or + ![string]::Equals( + (Split-Path -Parent $candidateManifestPath).TrimEnd('\'), + $tempRoot, + [StringComparison]::OrdinalIgnoreCase + )) { + throw 'workflow ownership manifest path is invalid' + } + $ownershipManifestPath = $candidateManifestPath + $ownershipRunId = $ExpectedRunId + $workflowManagedManifest = $true + } else { + $ownershipRunId = $generatedRunId + } $selectedWorkerPath = if ($WorkerPath) { $WorkerPath } else { $productionWorkerPath } $selectedWorkerPath = (Resolve-Path -LiteralPath $selectedWorkerPath -ErrorAction Stop).Path $cleanupWorkerPath = (Resolve-Path -LiteralPath $cleanupWorkerPath -ErrorAction Stop).Path @@ -473,6 +507,7 @@ try { $worker = [Diagnostics.Process]::new() $worker.StartInfo = $startInfo if (!$worker.Start()) { throw 'installed-app worker did not start' } + $workerStarted = $true $bootstrapStopwatch = [Diagnostics.Stopwatch]::StartNew() try { $job.AddProcess($worker.Handle) @@ -561,6 +596,7 @@ try { if ($workerExited) { $exitCode = $worker.ExitCode + $supervisorOutcomeComplete = $exitCode -eq 0 break } } @@ -569,9 +605,17 @@ try { $exitCode = 125 $terminateOwnedTree = $true } finally { - if ($terminateOwnedTree) { + $workerLive = $false + if ($workerStarted -and $null -ne $worker) { + try { $workerLive = !$worker.HasExited } catch { $workerLive = $true } + } + $cleanupRequired = $terminateOwnedTree -or $workerStarted -or $workerLive -or + !$supervisorOutcomeComplete + $fixedCleanupResult = $null + if ($cleanupRequired -and $installerPath -and $ownershipRunId) { Stop-OwnedWorker ([uint32]$exitCode) - if (!(Invoke-PostTerminationCleanup $installerPath $FixtureCleanupRoot)) { $exitCode = 125 } + $fixedCleanupResult = Invoke-PostTerminationCleanup $installerPath $FixtureCleanupRoot + if (!$fixedCleanupResult) { $exitCode = 125 } } try { @@ -595,8 +639,10 @@ try { try { if ([IO.File]::Exists($markerPath)) { [IO.File]::Delete($markerPath) } } catch {} - foreach ($path in @($ownershipManifestPath, "$ownershipManifestPath.new")) { - try { if ([IO.File]::Exists($path)) { [IO.File]::Delete($path) } } catch {} + if ($null -ne $fixedCleanupResult -and !$workflowManagedManifest) { + foreach ($path in @($ownershipManifestPath, "$ownershipManifestPath.new")) { + try { if ([IO.File]::Exists($path)) { [IO.File]::Delete($path) } } catch {} + } } } diff --git a/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 b/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 new file mode 100644 index 000000000..ae078c252 --- /dev/null +++ b/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 @@ -0,0 +1,194 @@ +param( + [Parameter(Mandatory=$true)][string]$OwnershipManifest, + [Parameter(Mandatory=$true)][string]$Installer, + [Parameter(Mandatory=$true)][string]$ExpectedRunId, + [ValidateRange(1000,600000)][int]$CleanupTimeoutMilliseconds = 4 * 60 * 1000, + [ValidateRange(1,30000)][int]$TerminationTimeoutMilliseconds = 30 * 1000, + [string]$FixtureRoot +) + +$ErrorActionPreference = 'Stop' +$cleanupProcess = $null +$cleanupJob = $null +$cleanupReadyEvent = $null +$fixedResult = 'FAILED' +$validatedManifestPath = $null + +Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +public sealed class ProPRWorkflowCleanupJob : IDisposable +{ + [StructLayout(LayoutKind.Sequential)] + private struct JOBOBJECT_BASIC_LIMIT_INFORMATION + { + public long PerProcessUserTimeLimit; + public long PerJobUserTimeLimit; + public uint LimitFlags; + public UIntPtr MinimumWorkingSetSize; + public UIntPtr MaximumWorkingSetSize; + public uint ActiveProcessLimit; + public UIntPtr Affinity; + public uint PriorityClass; + public uint SchedulingClass; + } + + [StructLayout(LayoutKind.Sequential)] + private struct IO_COUNTERS + { + public ulong ReadOperationCount; + public ulong WriteOperationCount; + public ulong OtherOperationCount; + public ulong ReadTransferCount; + public ulong WriteTransferCount; + public ulong OtherTransferCount; + } + + [StructLayout(LayoutKind.Sequential)] + private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION + { + public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation; + public IO_COUNTERS IoInfo; + public UIntPtr ProcessMemoryLimit; + public UIntPtr JobMemoryLimit; + public UIntPtr PeakProcessMemoryUsed; + public UIntPtr PeakJobMemoryUsed; + } + + private const int JobObjectExtendedLimitInformation = 9; + private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000; + private SafeFileHandle handle; + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateJobObject(IntPtr attributes, string name); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool SetInformationJobObject( + SafeFileHandle job, int informationClass, IntPtr information, uint informationLength); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool AssignProcessToJobObject(SafeFileHandle job, IntPtr process); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool TerminateJobObject(SafeFileHandle job, uint exitCode); + + public ProPRWorkflowCleanupJob() + { + handle = CreateJobObject(IntPtr.Zero, null); + if (handle == null || handle.IsInvalid) + throw new Win32Exception(Marshal.GetLastWin32Error(), "job creation failed"); + var limits = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION(); + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + int size = Marshal.SizeOf(typeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION)); + IntPtr buffer = Marshal.AllocHGlobal(size); + try + { + Marshal.StructureToPtr(limits, buffer, false); + if (!SetInformationJobObject(handle, JobObjectExtendedLimitInformation, buffer, (uint)size)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "job configuration failed"); + } + finally { Marshal.FreeHGlobal(buffer); } + } + + public void AddProcess(IntPtr processHandle) + { + if (!AssignProcessToJobObject(handle, processHandle)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "cleanup ownership failed"); + } + + public void Terminate(uint exitCode) + { + if (!handle.IsInvalid && !TerminateJobObject(handle, exitCode)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "cleanup termination failed"); + } + + public void Dispose() { if (handle != null) handle.Dispose(); } +} +'@ + +function Write-FixedResult([ValidateSet('COMPLETE','FAILED','TIMED_OUT')][string]$Result) { + Write-Host "PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:$Result" + [Console]::Out.Flush() +} + +try { + if ($ExpectedRunId -notmatch '^[a-f0-9]{32}$') { throw 'cleanup run identity is invalid' } + $manifestPath = [IO.Path]::GetFullPath($OwnershipManifest) + $tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + if ((Split-Path -Leaf $manifestPath) -cne + "propr-installed-app-ownership-$ExpectedRunId.json" -or + ![string]::Equals( + (Split-Path -Parent $manifestPath).TrimEnd('\'), + $tempRoot, + [StringComparison]::OrdinalIgnoreCase + )) { + throw 'cleanup manifest path is invalid' + } + $validatedManifestPath = $manifestPath + $installerPath = (Resolve-Path -LiteralPath $Installer -ErrorAction Stop).Path + $cleanupWorkerPath = (Resolve-Path -LiteralPath + (Join-Path $PSScriptRoot 'cleanup-installed-windows-app.ps1') -ErrorAction Stop).Path + $hostPath = (Get-Process -Id $PID -ErrorAction Stop).Path + if ([IO.Path]::GetFileName($hostPath) -notin @('pwsh.exe', 'powershell.exe')) { + throw 'PowerShell host resolution failed' + } + $cleanupReadyEventName = "Local\ProPRInstalledAppCleanup-$([Guid]::NewGuid().ToString('N'))" + $cleanupReadyEvent = [Threading.EventWaitHandle]::new( + $false, + [Threading.EventResetMode]::ManualReset, + $cleanupReadyEventName + ) + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $hostPath + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + foreach ($argument in @( + '-NoLogo', '-NoProfile', '-NonInteractive', '-File', $cleanupWorkerPath, + '-OwnershipManifest', $manifestPath, + '-Installer', $installerPath, + '-ExpectedRunId', $ExpectedRunId, + '-OwnershipReadyEvent', $cleanupReadyEventName + )) { + $startInfo.ArgumentList.Add($argument) + } + if ($FixtureRoot) { + $startInfo.ArgumentList.Add('-FixtureRoot') + $startInfo.ArgumentList.Add((Resolve-Path -LiteralPath $FixtureRoot -ErrorAction Stop).Path) + } + $cleanupJob = [ProPRWorkflowCleanupJob]::new() + $cleanupProcess = [Diagnostics.Process]::new() + $cleanupProcess.StartInfo = $startInfo + if (!$cleanupProcess.Start()) { throw 'workflow cleanup did not start' } + try { + $cleanupJob.AddProcess($cleanupProcess.Handle) + [void]$cleanupReadyEvent.Set() + } catch { + try { $cleanupProcess.Kill($true) } catch {} + throw 'workflow cleanup ownership failed' + } + if (!$cleanupProcess.WaitForExit($CleanupTimeoutMilliseconds)) { + try { $cleanupJob.Terminate(125) } catch {} + try { [void]$cleanupProcess.WaitForExit($TerminationTimeoutMilliseconds) } catch {} + $fixedResult = 'TIMED_OUT' + } elseif ($cleanupProcess.ExitCode -eq 0) { + $fixedResult = 'COMPLETE' + } +} catch { + $fixedResult = 'FAILED' +} finally { + Write-FixedResult $fixedResult + if ($null -ne $cleanupJob) { $cleanupJob.Dispose() } + if ($null -ne $cleanupProcess) { $cleanupProcess.Dispose() } + if ($null -ne $cleanupReadyEvent) { $cleanupReadyEvent.Dispose() } + if ($validatedManifestPath) { + foreach ($path in @($validatedManifestPath, "$validatedManifestPath.new")) { + try { if ([IO.File]::Exists($path)) { [IO.File]::Delete($path) } } catch {} + } + } +} + +if ($fixedResult -ne 'COMPLETE') { exit 1 } +exit 0 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 bc763d11f..886e10110 100644 --- a/apps/desktop/scripts/test-installed-windows-app-supervisor-fixture.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app-supervisor-fixture.ps1 @@ -17,6 +17,7 @@ if ($scenario -notin @( 'STALE_MARKER', 'INACCESSIBLE_MARKER', 'CANCELLATION', + 'OWNED_RESOURCES_FOR_INTERRUPTION', 'OWNED_RESOURCES_THEN_DEADLINE' )) { throw 'fixture scenario is invalid' @@ -264,8 +265,8 @@ switch ($scenario) { } 'VALID_THEN_DEADLINE' { Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(10).Ticks) - Start-Sleep -Milliseconds 300 - Write-FixtureMarker ('{0}|INSTALL|MSI_INSTALL|BEGIN' -f [DateTime]::UtcNow.AddMilliseconds(350).Ticks) + Start-Sleep -Milliseconds 500 + Write-FixtureMarker ('{0}|INSTALL|MSI_INSTALL|BEGIN' -f [DateTime]::UtcNow.AddMilliseconds(2500).Ticks) Start-Sleep -Seconds 300 } 'MALFORMED_MARKER' { @@ -313,6 +314,13 @@ switch ($scenario) { [DateTime]::UtcNow.AddMilliseconds(500).Ticks) Start-Sleep -Seconds 300 } + 'OWNED_RESOURCES_FOR_INTERRUPTION' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + New-OwnedFixtureResources + Write-FixtureMarker ('{0}|CLEANUP|SMOKE_DATA_REMOVE|BEGIN' -f ` + [DateTime]::UtcNow.AddSeconds(60).Ticks) + Start-Sleep -Seconds 300 + } } $descendant.Dispose() diff --git a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 index dad405373..eff9639ee 100644 --- a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 @@ -4,6 +4,7 @@ param( $ErrorActionPreference = 'Stop' $supervisorPath = Join-Path $PSScriptRoot 'run-installed-windows-app-harness.ps1' +$workflowCleanupPath = Join-Path $PSScriptRoot 'run-installed-windows-app-workflow-cleanup.ps1' $fixtureWorkerPath = Join-Path $PSScriptRoot 'test-installed-windows-app-supervisor-fixture.ps1' $hostPath = (Get-Process -Id $PID -ErrorAction Stop).Path $testRoot = Join-Path ([IO.Path]::GetTempPath()) ` @@ -42,7 +43,9 @@ function New-SupervisorStartInfo( [string]$Scenario, [string]$StateDirectory, [string]$CancellationEventName, - [bool]$UseProductionWorker + [bool]$UseProductionWorker, + [string]$WorkflowManifest = '', + [string]$ExpectedRunId = '' ) { $startInfo = [Diagnostics.ProcessStartInfo]::new() $startInfo.FileName = $hostPath @@ -95,6 +98,12 @@ function New-SupervisorStartInfo( $startInfo.ArgumentList.Add('-CancellationEventName') $startInfo.ArgumentList.Add($CancellationEventName) } + if ($WorkflowManifest) { + $startInfo.ArgumentList.Add('-OwnershipManifest') + $startInfo.ArgumentList.Add($WorkflowManifest) + $startInfo.ArgumentList.Add('-ExpectedRunId') + $startInfo.ArgumentList.Add($ExpectedRunId) + } return $startInfo } @@ -112,8 +121,13 @@ function Read-FixtureProcessState([string]$StateDirectory) { function Read-FixtureResourceState([string]$StateDirectory) { $statePath = Join-Path $StateDirectory 'resources.json' - Assert-True (Test-Path -LiteralPath $statePath -PathType Leaf) ` - 'fixture did not publish owned resource state' + $stopwatch = [Diagnostics.Stopwatch]::StartNew() + while (!(Test-Path -LiteralPath $statePath -PathType Leaf)) { + if ($stopwatch.ElapsedMilliseconds -ge 45000) { + throw 'fixture did not publish owned resource state' + } + Start-Sleep -Milliseconds 25 + } return Get-Content -LiteralPath $statePath -Raw -Encoding ASCII | ConvertFrom-Json } @@ -128,6 +142,116 @@ function Assert-ProcessTreeGone($State) { throw 'owned worker process tree survived supervisor completion' } +function Assert-OwnedResourcesGone($Owned) { + foreach ($ownedPath in @( + $Owned.OwnedRoot, $Owned.InstallRoot, $Owned.ShortcutFolder, + $Owned.Shortcut, $Owned.SmokeDirectory + )) { + Assert-True (!(Test-Path -LiteralPath $ownedPath)) ` + 'external cleanup left a run-owned file-system resource behind' + } + Assert-True (!(Test-Path -LiteralPath $Owned.RegistryPath)) ` + 'external cleanup left a run-owned registry resource behind' + Assert-True (!(Test-Path -LiteralPath $Owned.RegistryRoot)) ` + 'external cleanup left the run-owned registry root behind' + Assert-True ($null -eq (Get-LocalUser -Name $Owned.UserName -ErrorAction SilentlyContinue)) ` + 'external cleanup left the run-owned local user behind' + $ownedProfiles = @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop | + Where-Object { $_.SID -ceq $Owned.UserSid }) + Assert-True ($ownedProfiles.Count -eq 0) ` + 'external cleanup left the run-owned profile behind' +} + +function Invoke-WorkflowCleanupController( + [string]$ManifestPath, + [string]$RunId, + [string]$FixtureRoot +) { + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $hostPath + $startInfo.UseShellExecute = $false + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + foreach ($argument in @( + '-NoLogo', '-NoProfile', '-NonInteractive', '-File', $workflowCleanupPath, + '-OwnershipManifest', $ManifestPath, + '-Installer', $dummyInstaller, + '-ExpectedRunId', $RunId, + '-CleanupTimeoutMilliseconds', '30000', + '-TerminationTimeoutMilliseconds', '3000' + )) { + $startInfo.ArgumentList.Add($argument) + } + if ($FixtureRoot) { + $startInfo.ArgumentList.Add('-FixtureRoot') + $startInfo.ArgumentList.Add($FixtureRoot) + } + $process = [Diagnostics.Process]::new() + $process.StartInfo = $startInfo + try { + if (!$process.Start()) { throw 'workflow cleanup fixture did not start' } + Assert-True ($process.WaitForExit(40000)) 'workflow cleanup fixture exceeded its bound' + return [PSCustomObject]@{ + ExitCode = $process.ExitCode + Output = $process.StandardOutput.ReadToEnd() + Error = $process.StandardError.ReadToEnd() + } + } finally { + if (!$process.HasExited) { try { $process.Kill($true) } catch {} } + $process.Dispose() + } +} + +function Start-ExternallyInterruptibleSupervisor([string]$StateDirectory) { + $scriptText = @' +param($SupervisorPath, $Installer, $Architecture, $FixtureWorker, $Scenario, + $StateDirectory, $Secret, $OwnedUser, $OwnedPassword, + $ConflictUser, $ConflictUserSid, $ConflictProfileSid, $ConflictProfilePath, + $ConflictDirectories, $ConflictShortcut, $ConflictRegistry) +$env:PROPR_SUPERVISOR_FIXTURE_SCENARIO = $Scenario +$env:PROPR_SUPERVISOR_FIXTURE_STATE_DIRECTORY = $StateDirectory +$env:PROPR_SUPERVISOR_FIXTURE_SECRET = $Secret +$env:PROPR_SUPERVISOR_FIXTURE_OWNED_USER = $OwnedUser +$env:PROPR_SUPERVISOR_FIXTURE_OWNED_PASSWORD = $OwnedPassword +$env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_USER = $ConflictUser +$env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_USER_SID = $ConflictUserSid +$env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_PROFILE_SID = $ConflictProfileSid +$env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_PROFILE_PATH = $ConflictProfilePath +$env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_DIRECTORIES = $ConflictDirectories +$env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_SHORTCUT = $ConflictShortcut +$env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_REGISTRY = $ConflictRegistry +& $SupervisorPath -Installer $Installer -Architecture $Architecture ` + -WorkerPath $FixtureWorker -FixtureCleanupRoot $StateDirectory ` + -BootstrapTimeoutMilliseconds 2000 -WatchdogPollMilliseconds 25 ` + -WatchdogTerminationMilliseconds 3000 -PostTerminationCleanupMilliseconds 30000 ` + -MarkerReadTimeoutMilliseconds 200 +'@ + $pipeline = [Management.Automation.PowerShell]::Create() + [void]$pipeline.AddScript($scriptText) + foreach ($argument in @( + $supervisorPath, + $dummyInstaller, + $Architecture, + $fixtureWorkerPath, + 'OWNED_RESOURCES_FOR_INTERRUPTION', + $StateDirectory, + $secretNeedle, + $ownedFixtureUserName, + $ownedFixturePassword, + $conflictingFixtureUserName, + $conflictingFixtureUserSid, + $conflictingFixtureProfileSid, + $conflictingFixtureProfilePath, + $conflictingFixtureDirectories, + $conflictingFixtureShortcut, + $conflictingFixtureRegistryPath + )) { + [void]$pipeline.AddArgument($argument) + } + $asyncResult = $pipeline.BeginInvoke() + return [PSCustomObject]@{ Pipeline = $pipeline; AsyncResult = $asyncResult } +} + function Invoke-FixtureScenario([string]$Scenario, [string]$ExistingStateDirectory = '') { $stateDirectory = if ($ExistingStateDirectory) { $ExistingStateDirectory @@ -177,6 +301,10 @@ function Test-BootstrapTimeout { function Test-OperationDeadlineAndTreeTermination { $result = Invoke-FixtureScenario 'VALID_THEN_DEADLINE' Assert-True ($result.ExitCode -eq 124) 'operation deadline did not fail with the watchdog code' + Assert-True ($result.ElapsedMilliseconds -ge 2200) ` + 'operation deadline did not retain the injected observable interval' + Assert-True ($result.ElapsedMilliseconds -lt 10000) ` + 'operation deadline completion was not bounded' Assert-Contains $result.Output ` 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:ACCEPTED:INSTALL:MSI_INSTALL:BEGIN' ` 'operation transition was not accepted and flushed by the supervisor' @@ -466,6 +594,92 @@ function Test-PreExistingCleanupOwnership { Where-Object { $_.SID -ceq $userSid.Value }) Assert-True ($fixtureUserProfiles.Count -eq 0) ` 'pre-existing local user fixture unexpectedly acquired a profile' + + $gracefulStateDirectory = New-StateDirectory 'graceful-interruption' + $graceful = Start-ExternallyInterruptibleSupervisor $gracefulStateDirectory + try { + $gracefulProcessState = Read-FixtureProcessState $gracefulStateDirectory + $gracefulOwned = Read-FixtureResourceState $gracefulStateDirectory + $graceful.Pipeline.Stop() + try { [void]$graceful.Pipeline.EndInvoke($graceful.AsyncResult) } catch {} + Assert-ProcessTreeGone $gracefulProcessState + Assert-OwnedResourcesGone $gracefulOwned + } finally { + $graceful.Pipeline.Dispose() + } + + $workflowStateDirectory = New-StateDirectory 'workflow-cleanup' + $workflowRunId = [Guid]::NewGuid().ToString('N') + $workflowManifest = Join-Path ([IO.Path]::GetTempPath()) ` + "propr-installed-app-ownership-$workflowRunId.json" + $workflowSupervisor = [Diagnostics.Process]::new() + $workflowSupervisor.StartInfo = New-SupervisorStartInfo ` + 'OWNED_RESOURCES_FOR_INTERRUPTION' $workflowStateDirectory '' $false ` + $workflowManifest $workflowRunId + try { + if (!$workflowSupervisor.Start()) { throw 'workflow supervisor fixture did not start' } + $workflowProcessState = Read-FixtureProcessState $workflowStateDirectory + $workflowOwned = Read-FixtureResourceState $workflowStateDirectory + $workflowSupervisor.Kill($false) + Assert-True ($workflowSupervisor.WaitForExit(5000)) ` + 'killed workflow supervisor did not exit within the bound' + Assert-ProcessTreeGone $workflowProcessState + Assert-True (Test-Path -LiteralPath $workflowManifest -PathType Leaf) ` + 'killed supervisor did not preserve the durable ownership manifest' + $workflowCleanup = Invoke-WorkflowCleanupController ` + $workflowManifest $workflowRunId $workflowStateDirectory + Assert-True ($workflowCleanup.ExitCode -eq 0) 'workflow cleanup controller failed' + Assert-Contains $workflowCleanup.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:COMPLETE' ` + 'workflow cleanup controller did not emit fixed completion evidence' + Assert-OwnedResourcesGone $workflowOwned + Assert-True (!(Test-Path -LiteralPath $workflowManifest)) ` + 'workflow cleanup did not consume the ownership manifest' + } finally { + if (!$workflowSupervisor.HasExited) { try { $workflowSupervisor.Kill($true) } catch {} } + $workflowSupervisor.Dispose() + } + + foreach ($manifestCase in @('MISSING','MALFORMED','STALE')) { + $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 + $staleManifest = [ordered]@{ + SchemaVersion = 1; RunId = $badRunId + CreatedUtcTicks = $createdTicks + ExpiresUtcTicks = $createdTicks + ([TimeSpan]::TicksPerHour * 3) + InstallerPath = $dummyInstaller; Fixture = $true + FixtureRoot = $workflowStateDirectory; BaselineClean = $false + InstallAttempted = $false; Directories = @(); Files = @() + RegistryKeys = @(); Users = @(); Profiles = @() + } + [IO.File]::WriteAllText( + $badManifest, + ($staleManifest | ConvertTo-Json -Depth 6 -Compress), + [Text.Encoding]::UTF8 + ) + } + $failedCleanup = Invoke-WorkflowCleanupController ` + $badManifest $badRunId $workflowStateDirectory + Assert-True ($failedCleanup.ExitCode -ne 0) ` + "$manifestCase workflow manifest did not fail closed" + Assert-Contains $failedCleanup.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:FAILED' ` + "$manifestCase workflow manifest did not emit fixed failure evidence" + } + + 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 ` + 'owned-before-run') 'external cleanup changed the pre-existing registry tree' + Assert-True ((Get-Content -LiteralPath $conflictShortcut -Raw).Trim() -ceq ` + 'owned-before-run') 'external cleanup changed the pre-existing shortcut' + Assert-True ((Get-LocalUser -Name $userName -ErrorAction Stop).SID.Equals($userSid)) ` + 'external cleanup changed the pre-existing local user' } finally { $script:conflictingFixtureUserName = $null $script:conflictingFixtureUserSid = $null @@ -507,6 +721,109 @@ function Test-PreExistingCleanupOwnership { [Console]::Out.Flush() } +function Test-PreExistingAppPathsAuthority { + $appPaths = ` + 'Registry::HKEY_LOCAL_MACHINE\Software\Microsoft\Windows\CurrentVersion\App Paths\propr-desktop.exe' + $protocol = 'Registry::HKEY_LOCAL_MACHINE\Software\Classes\propr' + $sentinelApplication = 'C:\pre-existing\propr-desktop.exe' + $sentinelProtocol = 'pre-existing-protocol' + Assert-True (!(Test-Path -LiteralPath $appPaths)) ` + 'pre-existing App Paths fixture baseline was not clean' + Assert-True (!(Test-Path -LiteralPath $protocol)) ` + 'pre-existing protocol fixture baseline was not clean' + try { + [void](New-Item -Path $appPaths -Force -ErrorAction Stop) + Set-Item -LiteralPath $appPaths -Value $sentinelApplication + Set-ItemProperty -LiteralPath $appPaths -Name 'Path' -Value 'C:\pre-existing' + [void](New-Item -Path $protocol -Force -ErrorAction Stop) + Set-Item -LiteralPath $protocol -Value $sentinelProtocol + Set-ItemProperty -LiteralPath $protocol -Name 'URL Protocol' -Value 'do-not-remove' + + $process = [Diagnostics.Process]::new() + $process.StartInfo = New-SupervisorStartInfo ` + 'PRE_EXISTING_APP_PATHS' $testRoot '' $true + try { + if (!$process.Start()) { throw 'pre-existing registry supervisor did not start' } + Assert-True ($process.WaitForExit(20000)) ` + 'pre-existing registry supervisor exceeded its bound' + $output = $process.StandardOutput.ReadToEnd() + $errorOutput = $process.StandardError.ReadToEnd() + Assert-True ($process.ExitCode -ne 0) ` + 'pre-existing App Paths authority was not rejected' + Assert-Contains $output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:COMPLETE' ` + 'pre-existing App Paths rejection did not finish bounded cleanup' + Assert-NotContains "$output`n$errorOutput" $sentinelApplication ` + 'pre-existing App Paths evidence was not redacted' + } finally { + if (!$process.HasExited) { try { $process.Kill($true) } catch {} } + $process.Dispose() + } + Assert-True ((Get-Item -LiteralPath $appPaths).GetValue('') -ceq $sentinelApplication) ` + 'pre-existing App Paths executable was removed or changed' + Assert-True ((Get-Item -LiteralPath $appPaths).GetValue('Path') -ceq 'C:\pre-existing') ` + 'pre-existing App Paths values were removed or changed' + Assert-True ((Get-Item -LiteralPath $protocol).GetValue('') -ceq $sentinelProtocol) ` + 'pre-existing protocol key was removed or changed' + Assert-True ((Get-Item -LiteralPath $protocol).GetValue('URL Protocol') -ceq 'do-not-remove') ` + 'pre-existing protocol values were removed or changed' + + $mismatchRunId = [Guid]::NewGuid().ToString('N') + $mismatchManifest = Join-Path ([IO.Path]::GetTempPath()) ` + "propr-installed-app-ownership-$mismatchRunId.json" + $createdTicks = [DateTime]::UtcNow.Ticks + $mismatchState = [ordered]@{ + SchemaVersion = 1; RunId = $mismatchRunId + CreatedUtcTicks = $createdTicks + ExpiresUtcTicks = $createdTicks + ([TimeSpan]::TicksPerHour * 3) + InstallerPath = $dummyInstaller; Fixture = $false; FixtureRoot = $null + BaselineClean = $true; InstallAttempted = $true + Directories = @(); Files = @(); Users = @(); Profiles = @() + RegistryKeys = @( + [ordered]@{ + Kind = 'PROTOCOL'; Path = $protocol; Owned = $true; Token = $null + Identity = ('0' * 64); Provisional = $false + }, + [ordered]@{ + Kind = 'APP_PATH'; Path = $appPaths; Owned = $true; Token = $null + Identity = ('0' * 64); Provisional = $false + } + ) + } + [IO.File]::WriteAllText( + $mismatchManifest, + ($mismatchState | ConvertTo-Json -Depth 6 -Compress), + [Text.Encoding]::UTF8 + ) + $mismatchCleanup = Invoke-WorkflowCleanupController ` + $mismatchManifest $mismatchRunId '' + Assert-True ($mismatchCleanup.ExitCode -ne 0) ` + 'mismatched App Paths ownership identity did not fail closed' + Assert-Contains $mismatchCleanup.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:FAILED' ` + 'mismatched App Paths ownership did not emit fixed failure evidence' + Assert-True ((Get-Item -LiteralPath $appPaths).GetValue('') -ceq $sentinelApplication) ` + 'mismatched App Paths ownership removed the pre-existing executable value' + Assert-True ((Get-Item -LiteralPath $appPaths).GetValue('Path') -ceq 'C:\pre-existing') ` + 'mismatched App Paths ownership removed pre-existing values' + Assert-True ((Get-Item -LiteralPath $protocol).GetValue('') -ceq $sentinelProtocol) ` + 'mismatched protocol ownership removed the pre-existing key' + Assert-True ((Get-Item -LiteralPath $protocol).GetValue('URL Protocol') -ceq 'do-not-remove') ` + 'mismatched protocol ownership removed pre-existing values' + } finally { + if ((Test-Path -LiteralPath $appPaths) -and + (Get-Item -LiteralPath $appPaths).GetValue('') -ceq $sentinelApplication) { + Remove-Item -LiteralPath $appPaths -Recurse -Force -ErrorAction SilentlyContinue + } + if ((Test-Path -LiteralPath $protocol) -and + (Get-Item -LiteralPath $protocol).GetValue('') -ceq $sentinelProtocol) { + Remove-Item -LiteralPath $protocol -Recurse -Force -ErrorAction SilentlyContinue + } + } + Write-Host 'PROPR_WINDOWS_SUPERVISOR_OWNERSHIP:APP_PATHS_PRE_EXISTING:PRESERVED' + [Console]::Out.Flush() +} + if (![OperatingSystem]::IsWindows()) { throw 'supervisor behavior tests require Windows' } $actualArchitecture = [Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString().ToLowerInvariant() Assert-True ($actualArchitecture -ceq $Architecture) ` @@ -520,6 +837,7 @@ try { Test-FailClosedMarkers Test-LiveCancellationAndRedaction Test-PreExistingCleanupOwnership + Test-PreExistingAppPathsAuthority Write-Host "PROPR_WINDOWS_SUPERVISOR_TESTS:${Architecture}:PASSED" [Console]::Out.Flush() } finally { diff --git a/apps/desktop/scripts/test-installed-windows-app.ps1 b/apps/desktop/scripts/test-installed-windows-app.ps1 index d88cddbc2..67259db0e 100644 --- a/apps/desktop/scripts/test-installed-windows-app.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app.ps1 @@ -80,6 +80,9 @@ try { } $installRoot = Join-Path $env:ProgramFiles 'ProPR Desktop' $application = Join-Path $installRoot 'propr-desktop.exe' +$protocolRegistryPath = 'Registry::HKEY_LOCAL_MACHINE\Software\Classes\propr' +$appPathsRegistryPath = ` + 'Registry::HKEY_LOCAL_MACHINE\Software\Microsoft\Windows\CurrentVersion\App Paths\propr-desktop.exe' $testUser = "propr-ci-$([Guid]::NewGuid().ToString('N').Substring(0,8))" $passwordText = "P!$([Guid]::NewGuid().ToString('N'))a7" $password = ConvertTo-SecureString $passwordText -AsPlainText -Force @@ -91,8 +94,12 @@ $testUserSid = $null $smokeUserDataDirectory = $null $installRootExistedBeforeInstall = $false $protocolExistedBeforeInstall = $false +$appPathsExistedBeforeInstall = $false $installRootCreatedByRun = $false $protocolCreatedByRun = $false +$appPathsCreatedByRun = $false +$protocolOwnedIdentity = $null +$appPathsOwnedIdentity = $null $msiTimeoutMilliseconds = 10 * 60 * 1000 $applicationTimeoutMilliseconds = 5 * 60 * 1000 $terminationTimeoutMilliseconds = 30 * 1000 @@ -153,7 +160,8 @@ $startMenuShortcutFolder = Join-Path $commonPrograms 'ProPR Desktop' $startMenuShortcut = Join-Path $startMenuShortcutFolder 'ProPR Desktop.lnk' $installRootExistedBeforeInstall = Test-Path -LiteralPath $installRoot $protocolExistedBeforeInstall = - Test-Path -LiteralPath 'Registry::HKEY_LOCAL_MACHINE\Software\Classes\propr' + Test-Path -LiteralPath $protocolRegistryPath +$appPathsExistedBeforeInstall = Test-Path -LiteralPath $appPathsRegistryPath $startMenuShortcutExistedBeforeInstall = Test-Path -LiteralPath $startMenuShortcut $startMenuShortcutFolderExistedBeforeInstall = Test-Path -LiteralPath $startMenuShortcutFolder $startMenuShortcutCreatedByRun = $false @@ -161,10 +169,53 @@ $startMenuShortcutFolderCreatedByRun = $false $shortcutFileByteCap = 64 * 1024 $ownershipRunId = [IO.Path]::GetFileNameWithoutExtension($ownershipManifestPath).Substring( 'propr-installed-app-ownership-'.Length) +$initialManifestItem = Get-Item -LiteralPath $ownershipManifestPath -Force -ErrorAction Stop +if (($initialManifestItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + $initialManifestItem.Length -le 0 -or $initialManifestItem.Length -gt 65536) { + throw 'initial ownership manifest metadata is invalid' +} +$initialManifestBytes = [byte[]]::new([int]$initialManifestItem.Length) +$initialManifestStream = [IO.File]::Open( + $ownershipManifestPath, + [IO.FileMode]::Open, + [IO.FileAccess]::Read, + [IO.FileShare]::Read +) +try { + $initialManifestOffset = 0 + while ($initialManifestOffset -lt $initialManifestBytes.Length) { + $read = $initialManifestStream.Read( + $initialManifestBytes, + $initialManifestOffset, + $initialManifestBytes.Length - $initialManifestOffset + ) + if ($read -eq 0) { throw 'initial ownership manifest read was incomplete' } + $initialManifestOffset += $read + } + if ($initialManifestStream.ReadByte() -ne -1) { + throw 'initial ownership manifest changed during read' + } +} finally { + $initialManifestStream.Dispose() +} +$strictUtf8 = [Text.UTF8Encoding]::new($false, $true) +$initialOwnershipState = ConvertFrom-Json ` + -InputObject $strictUtf8.GetString($initialManifestBytes) -ErrorAction Stop +if ($initialOwnershipState.SchemaVersion -ne 1 -or + [string]$initialOwnershipState.RunId -cne $ownershipRunId -or + ![string]::Equals( + [IO.Path]::GetFullPath([string]$initialOwnershipState.InstallerPath), + $installerPath, + [StringComparison]::OrdinalIgnoreCase + )) { + throw 'initial ownership manifest identity is invalid' +} $ownershipToken = [Guid]::NewGuid().ToString('N') $ownershipState = [ordered]@{ SchemaVersion = 1 RunId = $ownershipRunId + CreatedUtcTicks = [int64]$initialOwnershipState.CreatedUtcTicks + ExpiresUtcTicks = [int64]$initialOwnershipState.ExpiresUtcTicks InstallerPath = $installerPath Fixture = $false FixtureRoot = $null @@ -216,6 +267,53 @@ function Write-DurableOwnershipToken([string]$Path, [string]$Token) { } } +function Get-RegistryTreeIdentity([string]$Path) { + if (!(Test-Path -LiteralPath $Path)) { return $null } + $root = Get-Item -LiteralPath $Path -ErrorAction Stop + $records = [Collections.Generic.List[string]]::new() + $pending = [Collections.Generic.Queue[object]]::new() + $pending.Enqueue([PSCustomObject]@{ Key = $root; Relative = '' }) + while ($pending.Count -ne 0) { + $entry = $pending.Dequeue() + $records.Add(('K|{0}' -f [Convert]::ToBase64String( + [Text.Encoding]::UTF8.GetBytes([string]$entry.Relative)))) + foreach ($valueName in @($entry.Key.GetValueNames() | Sort-Object -CaseSensitive)) { + $value = $entry.Key.GetValue( + $valueName, + $null, + [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames + ) + $valueBytes = if ($value -is [byte[]]) { + $value + } elseif ($value -is [string[]]) { + [Text.Encoding]::UTF8.GetBytes(($value | ConvertTo-Json -Compress)) + } else { + [Text.Encoding]::UTF8.GetBytes([Convert]::ToString( + $value, + [Globalization.CultureInfo]::InvariantCulture + )) + } + $records.Add(('V|{0}|{1}|{2}' -f + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([string]$valueName)), + $entry.Key.GetValueKind($valueName).ToString(), + [Convert]::ToBase64String($valueBytes))) + } + foreach ($child in @(Get-ChildItem -LiteralPath $entry.Key.PSPath -ErrorAction Stop | + Sort-Object -Property PSChildName -CaseSensitive)) { + $relative = if ($entry.Relative) { + '{0}\{1}' -f $entry.Relative, $child.PSChildName + } else { [string]$child.PSChildName } + $pending.Enqueue([PSCustomObject]@{ Key = $child; Relative = $relative }) + } + } + $payload = [Text.Encoding]::UTF8.GetBytes(($records -join "`n")) + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + return [BitConverter]::ToString($sha256.ComputeHash($payload)).Replace('-', '').ToLowerInvariant() + } + finally { $sha256.Dispose() } +} + Write-OwnershipManifest function Write-WatchdogMarker( @@ -229,6 +327,7 @@ function Write-WatchdogMarker( 'INSTALL_TREE_SCAN', 'APPLICATION_IMAGE', 'PROTOCOL_ASSERTION', + 'APP_PATH_ASSERTION', 'SHORTCUT_ASSERTION', 'USER_CREATE', 'USER_SID', @@ -241,6 +340,7 @@ function Write-WatchdogMarker( 'MSI_UNINSTALL', 'INSTALL_TREE_ASSERTION', 'PROTOCOL_ABSENCE_ASSERTION', + 'APP_PATH_ABSENCE_ASSERTION', 'SHORTCUT_FILE_ASSERTION', 'SHORTCUT_FOLDER_ASSERTION', 'SHORTCUT_ABSENCE_PROBE', @@ -251,6 +351,7 @@ function Write-WatchdogMarker( 'USER_REMOVE', 'INSTALL_ROOT_FALLBACK', 'PROTOCOL_FALLBACK', + 'APP_PATH_FALLBACK', 'SHORTCUT_FALLBACK' )][string]$Substage, [int]$TimeoutMilliseconds, @@ -329,6 +430,7 @@ Write-WatchdogMarker 'INITIALIZATION' 'PATHS' $bootstrapWatchdogTimeoutMilliseco Write-WatchdogMarker 'INITIALIZATION' 'BASELINE' $externalOperationTimeoutMilliseconds 'BEGIN' try { if ($installRootExistedBeforeInstall -or $protocolExistedBeforeInstall -or + $appPathsExistedBeforeInstall -or $startMenuShortcutExistedBeforeInstall -or $startMenuShortcutFolderExistedBeforeInstall) { throw 'installed-app harness requires an unowned clean machine baseline' } @@ -354,6 +456,7 @@ function Write-CleanupSubstage( 'MSI_UNINSTALL', 'INSTALL_TREE', 'PROTOCOL', + 'APP_PATH', 'SHORTCUT_FILE', 'SHORTCUT_FOLDER', 'ORDINARY_USER_ABSENCE_PROBE', @@ -362,6 +465,7 @@ function Write-CleanupSubstage( 'USER', 'INSTALL_ROOT_FALLBACK', 'PROTOCOL_FALLBACK', + 'APP_PATH_FALLBACK', 'SHORTCUT_FALLBACK', 'FINAL_AGGREGATION' )][string]$Substage, @@ -973,10 +1077,16 @@ try { $ownershipState.Files = @([ordered]@{ Kind = 'SHORTCUT_FILE'; Path = $startMenuShortcut; Owned = $true; Token = $null }) - $ownershipState.RegistryKeys = @([ordered]@{ - Kind = 'PROTOCOL'; Path = 'Registry::HKEY_LOCAL_MACHINE\Software\Classes\propr' - Owned = $true; Token = $null - }) + $ownershipState.RegistryKeys = @( + [ordered]@{ + Kind = 'PROTOCOL'; Path = $protocolRegistryPath + Owned = $true; Token = $null; Identity = $null; Provisional = $true + }, + [ordered]@{ + Kind = 'APP_PATH'; Path = $appPathsRegistryPath + Owned = $true; Token = $null; Identity = $null; Provisional = $true + } + ) Write-OwnershipManifest try { Invoke-BoundedExternalOperation ` @@ -996,7 +1106,9 @@ try { !$installRootExistedBeforeInstall -and (Test-Path -LiteralPath $installRoot) $script:protocolCreatedByRun = !$protocolExistedBeforeInstall -and - (Test-Path -LiteralPath 'Registry::HKEY_LOCAL_MACHINE\Software\Classes\propr') + (Test-Path -LiteralPath $protocolRegistryPath) + $script:appPathsCreatedByRun = + !$appPathsExistedBeforeInstall -and (Test-Path -LiteralPath $appPathsRegistryPath) $script:startMenuShortcutCreatedByRun = !$startMenuShortcutExistedBeforeInstall -and (Test-Path -LiteralPath $startMenuShortcut) $script:startMenuShortcutFolderCreatedByRun = @@ -1020,12 +1132,24 @@ try { Kind = 'SHORTCUT_FILE'; Path = $startMenuShortcut; Owned = $true; Token = $null }) } else { @() } - $ownershipState.RegistryKeys = if ($script:protocolCreatedByRun) { - @([ordered]@{ - Kind = 'PROTOCOL'; Path = 'Registry::HKEY_LOCAL_MACHINE\Software\Classes\propr' - Owned = $true; Token = $null - }) - } else { @() } + $ownedRegistryKeys = @() + if ($script:protocolCreatedByRun) { + $script:protocolOwnedIdentity = Get-RegistryTreeIdentity $protocolRegistryPath + $ownedRegistryKeys += [ordered]@{ + Kind = 'PROTOCOL'; Path = $protocolRegistryPath + Owned = $true; Token = $null; Identity = $script:protocolOwnedIdentity + Provisional = $false + } + } + if ($script:appPathsCreatedByRun) { + $script:appPathsOwnedIdentity = Get-RegistryTreeIdentity $appPathsRegistryPath + $ownedRegistryKeys += [ordered]@{ + Kind = 'APP_PATH'; Path = $appPathsRegistryPath + Owned = $true; Token = $null; Identity = $script:appPathsOwnedIdentity + Provisional = $false + } + } + $ownershipState.RegistryKeys = $ownedRegistryKeys Write-OwnershipManifest } } @@ -1069,12 +1193,20 @@ try { Invoke-BoundedExternalOperation 'VALIDATION' 'PROTOCOL_ASSERTION' ` $externalOperationTimeoutMilliseconds { $protocolCommand = (Get-Item -LiteralPath ` - 'Registry::HKEY_LOCAL_MACHINE\Software\Classes\propr\shell\open\command').GetValue('') + "$protocolRegistryPath\shell\open\command").GetValue('') if ($protocolCommand -cne "`"$application`" `"%1`"") { throw 'machine installer did not register canonical ProPR Connect protocol discovery' } } + Invoke-BoundedExternalOperation 'VALIDATION' 'APP_PATH_ASSERTION' ` + $externalOperationTimeoutMilliseconds { + $appPathApplication = (Get-Item -LiteralPath $appPathsRegistryPath).GetValue('') + if ($appPathApplication -cne $application) { + throw 'machine installer did not register canonical executable discovery' + } + } + Invoke-BoundedExternalOperation 'VALIDATION' 'SHORTCUT_ASSERTION' ` $externalOperationTimeoutMilliseconds { $shortcutItem = Get-Item -LiteralPath $startMenuShortcut -Force -ErrorAction Stop @@ -1251,6 +1383,16 @@ try { Invoke-BoundedExternalOperation ` 'UNINSTALL' 'MSI_UNINSTALL' ` ($msiTimeoutMilliseconds + $terminationTimeoutMilliseconds + 5000) { + if ($protocolCreatedByRun -and (Test-Path -LiteralPath $protocolRegistryPath) -and + (!$protocolOwnedIdentity -or + (Get-RegistryTreeIdentity $protocolRegistryPath) -cne $protocolOwnedIdentity)) { + throw 'refusing to uninstall over protocol metadata with a mismatched ownership identity' + } + if ($appPathsCreatedByRun -and (Test-Path -LiteralPath $appPathsRegistryPath) -and + (!$appPathsOwnedIdentity -or + (Get-RegistryTreeIdentity $appPathsRegistryPath) -cne $appPathsOwnedIdentity)) { + throw 'refusing to uninstall over executable metadata with a mismatched ownership identity' + } Invoke-Msi @('/x', "`"$installerPath`"", '/qn', '/norestart') 'machine uninstall' } Write-CleanupSubstage 'UNINSTALL' 'MSI_UNINSTALL' 'COMPLETE' @@ -1277,7 +1419,7 @@ try { try { Invoke-BoundedExternalOperation ` 'UNINSTALL' 'PROTOCOL_ABSENCE_ASSERTION' $externalOperationTimeoutMilliseconds { - if (Test-Path -LiteralPath 'Registry::HKEY_LOCAL_MACHINE\Software\Classes\propr') { + if (Test-Path -LiteralPath $protocolRegistryPath) { throw 'machine uninstall left protocol discovery metadata behind' } } @@ -1287,6 +1429,20 @@ try { $uninstallFailed = $true } + Write-CleanupSubstage 'UNINSTALL' 'APP_PATH' 'BEGIN' + try { + Invoke-BoundedExternalOperation ` + 'UNINSTALL' 'APP_PATH_ABSENCE_ASSERTION' $externalOperationTimeoutMilliseconds { + if (Test-Path -LiteralPath $appPathsRegistryPath) { + throw 'machine uninstall left executable discovery metadata behind' + } + } + Write-CleanupSubstage 'UNINSTALL' 'APP_PATH' 'COMPLETE' + } catch { + Write-CleanupSubstage 'UNINSTALL' 'APP_PATH' 'FAILED' + $uninstallFailed = $true + } + Write-CleanupSubstage 'UNINSTALL' 'SHORTCUT_FILE' 'BEGIN' try { Invoke-BoundedExternalOperation ` @@ -1432,11 +1588,12 @@ try { try { Invoke-BoundedExternalOperation ` 'CLEANUP' 'PROTOCOL_FALLBACK' $externalOperationTimeoutMilliseconds { - if ($protocolCreatedByRun -and - (Test-Path -LiteralPath 'Registry::HKEY_LOCAL_MACHINE\Software\Classes\propr')) { - Remove-Item -LiteralPath ` - 'Registry::HKEY_LOCAL_MACHINE\Software\Classes\propr' ` - -Recurse -Force -ErrorAction Stop + if ($protocolCreatedByRun -and (Test-Path -LiteralPath $protocolRegistryPath)) { + if (!$protocolOwnedIdentity -or + (Get-RegistryTreeIdentity $protocolRegistryPath) -cne $protocolOwnedIdentity) { + throw 'refusing to remove protocol metadata with a mismatched ownership identity' + } + Remove-Item -LiteralPath $protocolRegistryPath -Recurse -Force -ErrorAction Stop } } Write-CleanupSubstage 'CLEANUP' 'PROTOCOL_FALLBACK' 'COMPLETE' @@ -1445,6 +1602,24 @@ try { $cleanupFailed = $true } + Write-CleanupSubstage 'CLEANUP' 'APP_PATH_FALLBACK' 'BEGIN' + try { + Invoke-BoundedExternalOperation ` + 'CLEANUP' 'APP_PATH_FALLBACK' $externalOperationTimeoutMilliseconds { + if ($appPathsCreatedByRun -and (Test-Path -LiteralPath $appPathsRegistryPath)) { + if (!$appPathsOwnedIdentity -or + (Get-RegistryTreeIdentity $appPathsRegistryPath) -cne $appPathsOwnedIdentity) { + throw 'refusing to remove executable metadata with a mismatched ownership identity' + } + Remove-Item -LiteralPath $appPathsRegistryPath -Recurse -Force -ErrorAction Stop + } + } + Write-CleanupSubstage 'CLEANUP' 'APP_PATH_FALLBACK' 'COMPLETE' + } catch { + Write-CleanupSubstage 'CLEANUP' 'APP_PATH_FALLBACK' 'FAILED' + $cleanupFailed = $true + } + Write-CleanupSubstage 'CLEANUP' 'SHORTCUT_FALLBACK' 'BEGIN' $shortcutFallbackFailed = $false try { @@ -1487,6 +1662,14 @@ try { throw 'installed Windows cleanup did not complete' } } else { + $ownershipState.BaselineClean = $false + $ownershipState.InstallAttempted = $false + $ownershipState.Directories = @() + $ownershipState.Files = @() + $ownershipState.RegistryKeys = @() + $ownershipState.Users = @() + $ownershipState.Profiles = @() + Write-OwnershipManifest Write-CleanupSubstage 'CLEANUP' 'FINAL_AGGREGATION' 'COMPLETE' Write-Stage 'CLEANUP' 'COMPLETE' } diff --git a/apps/desktop/src/release-workflow.test.ts b/apps/desktop/src/release-workflow.test.ts index c58194df1..4805613e2 100644 --- a/apps/desktop/src/release-workflow.test.ts +++ b/apps/desktop/src/release-workflow.test.ts @@ -50,6 +50,10 @@ const installedWindowsAppCleanup = normalizeWorkflowText(readFileSync( fileURLToPath(new URL('../scripts/cleanup-installed-windows-app.ps1', import.meta.url)), 'utf8', )); +const installedWindowsAppWorkflowCleanup = normalizeWorkflowText(readFileSync( + fileURLToPath(new URL('../scripts/run-installed-windows-app-workflow-cleanup.ps1', import.meta.url)), + 'utf8', +)); const installedWindowsAppSupervisorBehaviorTest = normalizeWorkflowText(readFileSync( fileURLToPath(new URL('../scripts/test-installed-windows-app-supervisor.ps1', import.meta.url)), 'utf8', @@ -556,6 +560,10 @@ describe('desktop trusted release workflow', () => { assert.match(section, /- platform: win32\n\s+arch: arm64\n/); assert.equal(section.match(/run-installed-windows-app-harness\.ps1/g)?.length, 1); assert.equal(section.match(/test-installed-windows-app-supervisor\.ps1/g)?.length, 1); + assert.equal(section.match(/run-installed-windows-app-workflow-cleanup\.ps1/g)?.length, 1); + assert.match(section, /if: always\(\) && matrix\.platform == 'win32'/); + assert.match(section, /-OwnershipManifest \$env:PROPR_WINDOWS_INSTALLED_APP_MANIFEST/); + assert.match(section, /-ExpectedRunId \$env:PROPR_WINDOWS_INSTALLED_APP_RUN_ID/); } }); @@ -565,6 +573,9 @@ describe('desktop trusted release workflow', () => { assert.match(installedWindowsAppSupervisorBehaviorTest, /Test-FailClosedMarkers/); assert.match(installedWindowsAppSupervisorBehaviorTest, /Test-LiveCancellationAndRedaction/); assert.match(installedWindowsAppSupervisorBehaviorTest, /Test-PreExistingCleanupOwnership/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Start-ExternallyInterruptibleSupervisor/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Invoke-WorkflowCleanupController/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Test-PreExistingAppPathsAuthority/); assert.match(installedWindowsAppSupervisorBehaviorTest, /Assert-ProcessTreeGone/); assert.match(installedWindowsAppSupervisorBehaviorTest, /WindowsIdentity\]::GetCurrent\(\)/); assert.match( @@ -595,11 +606,12 @@ describe('desktop trusted release workflow', () => { ); assert.match(installedWindowsAppTest, /\$ownershipHandshakeTimeoutMilliseconds = 5 \* 1000/); assert.match(installedWindowsAppTest, /\$ownershipReady\.WaitOne\(\$ownershipHandshakeTimeoutMilliseconds\)/); - assert.match(installedWindowsAppSupervisor, /if \(!\$worker\.Start\(\)\)[^\n]+\n\s+\$bootstrapStopwatch = \[Diagnostics\.Stopwatch\]::StartNew\(\)/); + assert.match(installedWindowsAppSupervisor, /if \(!\$worker\.Start\(\)\)[^\n]+\n\s+\$workerStarted = \$true\n\s+\$bootstrapStopwatch = \[Diagnostics\.Stopwatch\]::StartNew\(\)/); assert.match(installedWindowsAppSupervisor, /\[ProPRBoundedMarkerReader\]::ReadAsync\(\$Path\)/); assert.match(installedWindowsAppSupervisor, /\$readTask\.Wait\(\$TimeoutMilliseconds\)/); assert.match(installedWindowsAppSupervisor, /\$job\.Terminate\(\$TerminationExitCode\)/); assert.match(installedWindowsAppSupervisor, /Invoke-PostTerminationCleanup/); + assert.match(installedWindowsAppSupervisor, /\$cleanupRequired = \$terminateOwnedTree -or \$workerStarted/); assert.match( installedWindowsAppSupervisor, /PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:COMPLETE/, @@ -607,6 +619,27 @@ describe('desktop trusted release workflow', () => { assert.match(installedWindowsAppCleanup, /Remove-OwnedProfiles/); assert.match(installedWindowsAppCleanup, /Remove-OwnedRegistryKey/); assert.match(installedWindowsAppCleanup, /Remove-OwnedDirectory/); + assert.match(installedWindowsAppCleanup, /APP_PATH/); + assert.match(installedWindowsAppCleanup, /Get-RegistryTreeIdentity/); + assert.match( + installedWindowsAppTest, + /Registry::HKEY_LOCAL_MACHINE\\Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\propr-desktop\.exe/, + ); + assert.match(installedWindowsAppTest, /APP_PATH_ASSERTION/); + assert.match(installedWindowsAppTest, /APP_PATH_ABSENCE_ASSERTION/); + assert.match(installedWindowsAppTest, /APP_PATH_FALLBACK/); + assert.match( + installedWindowsAppTest, + /Get-RegistryTreeIdentity \$appPathsRegistryPath[\s\S]*refusing to uninstall over executable metadata[\s\S]*Invoke-Msi @\('\/x'/, + ); + assert.match(installedWindowsAppSupervisorBehaviorTest, /mismatched App Paths ownership identity did not fail closed/); + assert.match(installedWindowsAppWorkflowCleanup, /ProPRWorkflowCleanupJob/); + for (const result of ['COMPLETE', 'FAILED', 'TIMED_OUT']) { + assert.match( + installedWindowsAppWorkflowCleanup, + new RegExp(`PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:\\$Result|["']${result}["']`), + ); + } assert.match(installedWindowsAppSupervisor, /exit \$exitCode/); assert.match(installedWindowsAppSupervisor, /if \(\$null -ne \$job\) \{ \$job\.Dispose\(\) \}/); @@ -663,6 +696,7 @@ describe('desktop trusted release workflow', () => { 'INSTALL_TREE_SCAN', 'APPLICATION_IMAGE', 'PROTOCOL_ASSERTION', + 'APP_PATH_ASSERTION', 'SHORTCUT_ASSERTION', 'USER_CREATE', 'USER_SID', @@ -675,6 +709,7 @@ describe('desktop trusted release workflow', () => { 'MSI_UNINSTALL', 'INSTALL_TREE_ASSERTION', 'PROTOCOL_ABSENCE_ASSERTION', + 'APP_PATH_ABSENCE_ASSERTION', 'SHORTCUT_FILE_ASSERTION', 'SHORTCUT_FOLDER_ASSERTION', 'SHORTCUT_ABSENCE_PROBE', @@ -685,6 +720,7 @@ describe('desktop trusted release workflow', () => { 'USER_REMOVE', 'INSTALL_ROOT_FALLBACK', 'PROTOCOL_FALLBACK', + 'APP_PATH_FALLBACK', 'SHORTCUT_FALLBACK', ]); for (const operation of operations) { @@ -708,7 +744,7 @@ describe('desktop trusted release workflow', () => { test('supplementary lint retains fail-closed installed-app cleanup guards', () => { assert.match( installedWindowsAppTest, - /if \(\$installRootExistedBeforeInstall -or \$protocolExistedBeforeInstall -or[\s\S]*\$startMenuShortcutFolderExistedBeforeInstall\) \{\n\s+throw 'installed-app harness requires an unowned clean machine baseline'/, + /if \(\$installRootExistedBeforeInstall -or \$protocolExistedBeforeInstall -or[\s\S]*\$appPathsExistedBeforeInstall -or[\s\S]*\$startMenuShortcutFolderExistedBeforeInstall\) \{\n\s+throw 'installed-app harness requires an unowned clean machine baseline'/, ); assert.match(installedWindowsAppTest, /\$script:testUserCreatedByRun = \$true/); assert.match( @@ -725,7 +761,11 @@ describe('desktop trusted release workflow', () => { ); assert.match( installedWindowsAppTest, - /if \(\$protocolCreatedByRun -and[\s\S]*Remove-Item -LiteralPath `[\s\S]*Registry::HKEY_LOCAL_MACHINE\\Software\\Classes\\propr/, + /if \(\$protocolCreatedByRun -and[\s\S]*Get-RegistryTreeIdentity \$protocolRegistryPath[\s\S]*Remove-Item -LiteralPath \$protocolRegistryPath -Recurse/, + ); + assert.match( + installedWindowsAppTest, + /if \(\$appPathsCreatedByRun -and[\s\S]*Get-RegistryTreeIdentity \$appPathsRegistryPath[\s\S]*Remove-Item -LiteralPath \$appPathsRegistryPath -Recurse/, ); assert.match( installedWindowsAppTest, @@ -867,6 +907,7 @@ describe('desktop trusted release workflow', () => { 'MSI_UNINSTALL', 'INSTALL_TREE', 'PROTOCOL', + 'APP_PATH', 'SHORTCUT_FILE', 'SHORTCUT_FOLDER', 'ORDINARY_USER_ABSENCE_PROBE', @@ -875,6 +916,7 @@ describe('desktop trusted release workflow', () => { 'USER', 'INSTALL_ROOT_FALLBACK', 'PROTOCOL_FALLBACK', + 'APP_PATH_FALLBACK', 'SHORTCUT_FALLBACK', 'FINAL_AGGREGATION', ]); @@ -898,7 +940,7 @@ describe('desktop trusted release workflow', () => { assert.ok(substages.includes(substage)); assert.ok(['BEGIN', 'COMPLETE', 'FAILED', 'SKIPPED'].includes(status)); } - for (const substage of ['MSI_UNINSTALL', 'INSTALL_TREE', 'PROTOCOL', 'SHORTCUT_FILE', 'SHORTCUT_FOLDER']) { + for (const substage of ['MSI_UNINSTALL', 'INSTALL_TREE', 'PROTOCOL', 'APP_PATH', 'SHORTCUT_FILE', 'SHORTCUT_FOLDER']) { for (const status of ['BEGIN', 'COMPLETE', 'FAILED']) { assert.ok(cleanupCalls.some(match => match[1] === 'UNINSTALL' && match[2] === substage && match[3] === status)); } @@ -914,6 +956,7 @@ describe('desktop trusted release workflow', () => { 'USER', 'INSTALL_ROOT_FALLBACK', 'PROTOCOL_FALLBACK', + 'APP_PATH_FALLBACK', 'SHORTCUT_FALLBACK', 'FINAL_AGGREGATION', ]) { From a966e3a2348eea399ea528be46a52f22f188763c Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 02:44:57 +0000 Subject: [PATCH 06/29] feat(ai): Implemented the requested #2042 follow-up without committing. Implemented the requested #2042 follow-up without committing. Key changes: - Added HKCU `Software\ProPR\Desktop` / `installed` baseline capture, value-level manifest ownership, uninstall checks, exact restoration, conflict preservation, and empty-key-only removal. - Added schema-v2 typed `ACTIVE` and authenticated `EMPTY` manifest states. - Added fixed controller classifications and exit codes for manifest validation, resource cleanup, timeout, and controller failures; raw child output is discarded. - Added executable HKCU ownership/conflict coverage and distinct normal-success/already-cleaned receipt coverage. - Kept the acceptance fixture required for both x64 and ARM64 workflow jobs. Primary files: [cleanup-installed-windows-app.ps1](/tmp/git-processor/worktrees/integry/propr/pr-2042-followup-2026-09-01T02-30-18/apps/desktop/scripts/cleanup-installed-windows-app.ps1), [test-installed-windows-app.ps1](/tmp/git-processor/worktrees/integry/propr/pr-2042-followup-2026-09-01T02-30-18/apps/desktop/scripts/test-installed-windows-app.ps1), and [test-installed-windows-app-supervisor.ps1](/tmp/git-processor/worktrees/integry/propr/pr-2042-followup-2026-09-01T02-30-18/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1). Validation: - Focused workflow contracts: 23/23 passed - Full desktop suite: 177 passed, 6 platform skips - Desktop TypeScript typecheck: passed - `git diff --check`: passed Windows-native x64/ARM64 fixtures remain CI-only because this environment is Linux. PR: #2042 Comment by: @integry (ID: 5487863465) Comment by: @integry (ID: 5487872316) Comment by: @integry (ID: 5487880305) Model: gpt-5.6-sol --- .../scripts/cleanup-installed-windows-app.ps1 | 249 +++++++++++++++++- .../run-installed-windows-app-harness.ps1 | 8 +- ...installed-windows-app-workflow-cleanup.ps1 | 26 +- ...stalled-windows-app-supervisor-fixture.ps1 | 12 +- .../test-installed-windows-app-supervisor.ps1 | 217 ++++++++++++++- .../scripts/test-installed-windows-app.ps1 | 200 +++++++++++++- apps/desktop/src/release-workflow.test.ts | 38 ++- 7 files changed, 732 insertions(+), 18 deletions(-) diff --git a/apps/desktop/scripts/cleanup-installed-windows-app.ps1 b/apps/desktop/scripts/cleanup-installed-windows-app.ps1 index bd58ec016..dbc433ed7 100644 --- a/apps/desktop/scripts/cleanup-installed-windows-app.ps1 +++ b/apps/desktop/scripts/cleanup-installed-windows-app.ps1 @@ -11,6 +11,7 @@ $ProgressPreference = 'SilentlyContinue' $ownerFileName = '.propr-installed-app-owner' $ownerRegistryValue = 'ProPRInstalledAppOwner' $cleanupFailed = $false +$manifestValidated = $false $authorizedRunId = $null try { @@ -125,6 +126,52 @@ function Test-ProvisionalRegistryIdentity([string]$Kind, [string]$Path, [string] [string]$command.GetValue('') -ceq "`"$Application`" `"%1`"" } +function Convert-RegistryValueToBytes( + [Microsoft.Win32.RegistryValueKind]$Kind, + $Value +) { + switch ($Kind) { + 'DWord' { return [BitConverter]::GetBytes([int32]$Value) } + 'QWord' { return [BitConverter]::GetBytes([int64]$Value) } + 'String' { return [Text.Encoding]::UTF8.GetBytes([string]$Value) } + 'ExpandString' { return [Text.Encoding]::UTF8.GetBytes([string]$Value) } + 'MultiString' { + return [Text.Encoding]::UTF8.GetBytes( + (ConvertTo-Json -InputObject @([string[]]$Value) -Compress)) + } + 'Binary' { return [byte[]]$Value } + 'None' { return [byte[]]$Value } + default { throw 'registry value kind is unsupported' } + } +} + +function Get-RegistryValueSnapshot([string]$Path, [string]$Name) { + if (!(Test-Path -LiteralPath $Path)) { + return [PSCustomObject]@{ Exists = $false; Kind = $null; Data = $null } + } + $key = Get-Item -LiteralPath $Path -ErrorAction Stop + if (@($key.GetValueNames()) -cnotcontains $Name) { + return [PSCustomObject]@{ Exists = $false; Kind = $null; Data = $null } + } + $kind = $key.GetValueKind($Name) + $value = $key.GetValue( + $Name, + $null, + [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames + ) + return [PSCustomObject]@{ + Exists = $true + Kind = $kind.ToString() + Data = [Convert]::ToBase64String((Convert-RegistryValueToBytes $kind $value)) + } +} + +function Test-MsiInstalledValue([string]$Path, [string]$Name) { + $snapshot = Get-RegistryValueSnapshot $Path $Name + return $snapshot.Exists -and $snapshot.Kind -ceq 'DWord' -and + $snapshot.Data -ceq [Convert]::ToBase64String([BitConverter]::GetBytes([int32]1)) +} + function Test-AllowedFileSystemPath([string]$Kind, [string]$Path) { if ($FixtureRoot) { return Test-PathWithin $Path $FixtureRoot } $installRoot = Join-Path $env:ProgramFiles 'ProPR Desktop' @@ -225,6 +272,101 @@ function Remove-OwnedRegistryKey($Record, [bool]$AllowProvisionalProductOwnershi } } +function Restore-OwnedRegistryValue($Record) { + if (!$Record.Owned) { return } + $path = [string]$Record.Path + $name = [string]$Record.Name + if ([string]$Record.Kind -cne 'HKCU_INSTALLED' -or + ![string]::Equals( + $path, + 'Registry::HKEY_CURRENT_USER\Software\ProPR\Desktop', + [StringComparison]::OrdinalIgnoreCase + ) -or $name -cne 'installed') { + throw 'registry value cleanup scope is invalid' + } + + $current = Get-RegistryValueSnapshot $path $name + $baselineValueExists = [bool]$Record.BaselineValueExisted + $baselineKind = [string]$Record.BaselineValueKind + $baselineData = [string]$Record.BaselineValueData + $matchesBaseline = $baselineValueExists -and $current.Exists -and + $current.Kind -ceq $baselineKind -and $current.Data -ceq $baselineData + if ($current.Exists -and !$matchesBaseline -and !(Test-MsiInstalledValue $path $name)) { + throw 'registry value ownership changed' + } + + if ($baselineValueExists) { + if (!(Test-Path -LiteralPath $path)) { + [void](New-Item -Path $path -Force -ErrorAction Stop) + } + if (!$matchesBaseline) { + $kind = [Enum]::Parse([Microsoft.Win32.RegistryValueKind], $baselineKind, $false) + $bytes = [Convert]::FromBase64String($baselineData) + $value = switch ($kind) { + 'DWord' { [BitConverter]::ToInt32($bytes, 0); break } + 'QWord' { [BitConverter]::ToInt64($bytes, 0); break } + 'String' { [Text.Encoding]::UTF8.GetString($bytes); break } + 'ExpandString' { [Text.Encoding]::UTF8.GetString($bytes); break } + 'MultiString' { + @([string[]](ConvertFrom-Json -InputObject ([Text.Encoding]::UTF8.GetString($bytes)))) + break + } + 'Binary' { $bytes; break } + 'None' { $bytes; break } + default { throw 'registry baseline kind is unsupported' } + } + (Get-Item -LiteralPath $path -ErrorAction Stop).SetValue($name, $value, $kind) + } + } elseif ($current.Exists) { + Remove-ItemProperty -LiteralPath $path -Name $name -Force -ErrorAction Stop + } + + if ([bool]$Record.KeyCreatedByRun -and (Test-Path -LiteralPath $path)) { + $key = Get-Item -LiteralPath $path -ErrorAction Stop + if (@($key.GetValueNames()).Count -eq 0 -and @($key.GetSubKeyNames()).Count -eq 0) { + Remove-Item -LiteralPath $path -Force -ErrorAction Stop + } + } + + $after = Get-RegistryValueSnapshot $path $name + if ($baselineValueExists) { + if (!$after.Exists -or $after.Kind -cne $baselineKind -or $after.Data -cne $baselineData) { + throw 'registry baseline restoration did not complete' + } + } elseif ($after.Exists) { + throw 'owned registry value cleanup did not complete' + } +} + +function Write-EmptyOwnershipReceipt([string]$Path, $Manifest) { + $Manifest.State = 'EMPTY' + $Manifest.BaselineClean = $false + $Manifest.InstallAttempted = $false + $Manifest.Directories = @() + $Manifest.Files = @() + $Manifest.RegistryKeys = @() + $Manifest.RegistryValues = @() + $Manifest.Users = @() + $Manifest.Profiles = @() + $temporaryPath = "$Path.new" + $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() + } + [IO.File]::Move($temporaryPath, $Path, $true) +} + function Remove-OwnedProfiles($UserRecord) { if (!$UserRecord.Owned) { return } $name = [string]$UserRecord.Name @@ -333,15 +475,18 @@ try { $manifest = ConvertFrom-Json -InputObject $strictUtf8.GetString($manifestBytes) -ErrorAction Stop $manifestKeys = @($manifest.PSObject.Properties | ForEach-Object { $_.Name }) $expectedManifestKeys = @( - 'SchemaVersion','RunId','CreatedUtcTicks','ExpiresUtcTicks','InstallerPath','Fixture', + 'SchemaVersion','ManifestType','State','RunId','CreatedUtcTicks','ExpiresUtcTicks', + 'InstallerPath','Fixture', 'FixtureRoot','BaselineClean','InstallAttempted','Directories','Files','RegistryKeys', - 'Users','Profiles' + 'RegistryValues','Users','Profiles' ) if ($manifestKeys.Count -ne $expectedManifestKeys.Count -or @($expectedManifestKeys | Where-Object { $manifestKeys -cnotcontains $_ }).Count -ne 0 -or $manifest.Fixture -isnot [bool] -or $manifest.BaselineClean -isnot [bool] -or $manifest.InstallAttempted -isnot [bool] -or - $manifest.SchemaVersion -ne 1 -or + $manifest.SchemaVersion -ne 2 -or + [string]$manifest.ManifestType -cne 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP' -or + [string]$manifest.State -notin @('ACTIVE','EMPTY') -or [string]$manifest.RunId -notmatch '^[a-f0-9]{32}$') { throw 'ownership manifest schema is invalid' } @@ -373,6 +518,17 @@ try { throw 'fixture ownership manifest was not authorized' } + if ([string]$manifest.State -ceq 'EMPTY') { + if ($manifest.BaselineClean -or $manifest.InstallAttempted -or + @($manifest.Directories).Count -ne 0 -or @($manifest.Files).Count -ne 0 -or + @($manifest.RegistryKeys).Count -ne 0 -or @($manifest.RegistryValues).Count -ne 0 -or + @($manifest.Users).Count -ne 0 -or @($manifest.Profiles).Count -ne 0) { + throw 'empty ownership receipt is invalid' + } + $manifestValidated = $true + exit 0 + } + $script:authorizedApplication = Join-Path $env:ProgramFiles 'ProPR Desktop\propr-desktop.exe' foreach ($record in @($manifest.Directories)) { if ($record.Owned -and @@ -439,7 +595,83 @@ try { } } } - if ($allowProvisionalProductOwnership) { + foreach ($record in @($manifest.RegistryValues)) { + $recordKeys = @($record.PSObject.Properties | ForEach-Object { $_.Name }) + $expectedRecordKeys = @( + 'Kind','Path','Name','Owned','Provisional','BaselineKeyExisted', + 'BaselineValueExisted','BaselineValueKind','BaselineValueData','KeyCreatedByRun' + ) + if ($recordKeys.Count -ne $expectedRecordKeys.Count -or + @($expectedRecordKeys | Where-Object { $recordKeys -cnotcontains $_ }).Count -ne 0 -or + $record.Owned -isnot [bool] -or $record.Provisional -isnot [bool] -or + $record.BaselineKeyExisted -isnot [bool] -or + $record.BaselineValueExisted -isnot [bool] -or + $record.KeyCreatedByRun -isnot [bool] -or + [string]$record.Kind -cne 'HKCU_INSTALLED' -or + ![string]::Equals( + [string]$record.Path, + 'Registry::HKEY_CURRENT_USER\Software\ProPR\Desktop', + [StringComparison]::OrdinalIgnoreCase + ) -or [string]$record.Name -cne 'installed' -or + ([bool]$record.KeyCreatedByRun -and [bool]$record.BaselineKeyExisted)) { + throw 'registry value manifest scope is invalid' + } + if ([bool]$record.BaselineValueExisted) { + if (![bool]$record.BaselineKeyExisted -or + [string]$record.BaselineValueKind -notin @( + 'DWord','QWord','String','ExpandString','MultiString','Binary','None' + ) -or [string]$record.BaselineValueData -notmatch '^[A-Za-z0-9+/]*={0,2}$') { + throw 'registry value baseline is invalid' + } + try { + $baselineBytes = [Convert]::FromBase64String([string]$record.BaselineValueData) + if (([string]$record.BaselineValueKind -ceq 'DWord' -and + $baselineBytes.Length -ne 4) -or + ([string]$record.BaselineValueKind -ceq 'QWord' -and + $baselineBytes.Length -ne 8)) { + throw 'invalid baseline width' + } + if ([string]$record.BaselineValueKind -in @('String','ExpandString')) { + [void]([Text.UTF8Encoding]::new($false, $true).GetString($baselineBytes)) + } elseif ([string]$record.BaselineValueKind -ceq 'MultiString') { + $multiStringJson = [Text.UTF8Encoding]::new($false, $true).GetString($baselineBytes) + $multiStringValue = ConvertFrom-Json -InputObject $multiStringJson ` + -NoEnumerate -ErrorAction Stop + if ($multiStringValue -isnot [array] -or + @($multiStringValue | Where-Object { $_ -isnot [string] }).Count -ne 0) { + throw 'invalid multi-string baseline' + } + } + } catch { + throw 'registry value baseline is invalid' + } + } elseif ($null -ne $record.BaselineValueKind -or + $null -ne $record.BaselineValueData) { + throw 'registry value empty baseline is invalid' + } + } + if (@($manifest.RegistryValues).Count -gt 1 -or + (!$manifest.Fixture -and $manifest.InstallAttempted -and + @($manifest.RegistryValues).Count -ne 1) -or + ($manifest.Fixture -and @($manifest.RegistryValues).Count -ne 0)) { + throw 'registry value manifest cardinality is invalid' + } + $manifestValidated = $true + $skipMsiUninstall = $false + foreach ($record in @($manifest.RegistryValues)) { + if (!$record.Owned) { continue } + $current = Get-RegistryValueSnapshot ([string]$record.Path) ([string]$record.Name) + $matchesBaseline = [bool]$record.BaselineValueExisted -and $current.Exists -and + $current.Kind -ceq [string]$record.BaselineValueKind -and + $current.Data -ceq [string]$record.BaselineValueData + if ($matchesBaseline) { + $skipMsiUninstall = $true + } elseif ($current.Exists -and + !(Test-MsiInstalledValue ([string]$record.Path) ([string]$record.Name))) { + $cleanupFailed = $true + } + } + if ($allowProvisionalProductOwnership -and !$skipMsiUninstall -and !$cleanupFailed) { $msiExitCode = 1618 for ($attempt = 0; $attempt -lt 12 -and $msiExitCode -eq 1618; $attempt += 1) { if ($attempt -ne 0) { Start-Sleep -Seconds 2 } @@ -462,6 +694,9 @@ try { foreach ($record in @($manifest.RegistryKeys)) { try { Remove-OwnedRegistryKey $record $allowProvisionalProductOwnership } catch { $cleanupFailed = $true } } + foreach ($record in @($manifest.RegistryValues)) { + try { Restore-OwnedRegistryValue $record } catch { $cleanupFailed = $true } + } foreach ($record in @($manifest.Profiles)) { try { Remove-ExplicitOwnedProfile $record } catch { $cleanupFailed = $true } } @@ -479,9 +714,13 @@ try { $cleanupFailed = $true } } + if (!$cleanupFailed) { Write-EmptyOwnershipReceipt $manifestPath $manifest } } catch { $cleanupFailed = $true } -if ($cleanupFailed) { exit 1 } +if ($cleanupFailed) { + if ($manifestValidated) { exit 21 } + exit 20 +} exit 0 diff --git a/apps/desktop/scripts/run-installed-windows-app-harness.ps1 b/apps/desktop/scripts/run-installed-windows-app-harness.ps1 index 6456dc9de..df2a2d15e 100644 --- a/apps/desktop/scripts/run-installed-windows-app-harness.ps1 +++ b/apps/desktop/scripts/run-installed-windows-app-harness.ps1 @@ -27,6 +27,7 @@ $watchdogSubstages = @( 'APPLICATION_IMAGE', 'PROTOCOL_ASSERTION', 'APP_PATH_ASSERTION', + 'HKCU_INSTALLED_ASSERTION', 'SHORTCUT_ASSERTION', 'USER_CREATE', 'USER_SID', @@ -40,6 +41,7 @@ $watchdogSubstages = @( 'INSTALL_TREE_ASSERTION', 'PROTOCOL_ABSENCE_ASSERTION', 'APP_PATH_ABSENCE_ASSERTION', + 'HKCU_INSTALLED_ABSENCE_ASSERTION', 'SHORTCUT_FILE_ASSERTION', 'SHORTCUT_FOLDER_ASSERTION', 'SHORTCUT_ABSENCE_PROBE', @@ -51,6 +53,7 @@ $watchdogSubstages = @( 'INSTALL_ROOT_FALLBACK', 'PROTOCOL_FALLBACK', 'APP_PATH_FALLBACK', + 'HKCU_INSTALLED_FALLBACK', 'SHORTCUT_FALLBACK' ) $markerName = "propr-installed-app-watchdog-$([Guid]::NewGuid().ToString('N')).marker" @@ -337,7 +340,9 @@ function Write-InitialOwnershipManifest( 'propr-installed-app-ownership-'.Length) $createdUtcTicks = [DateTime]::UtcNow.Ticks $manifest = [ordered]@{ - SchemaVersion = 1 + SchemaVersion = 2 + ManifestType = 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP' + State = 'ACTIVE' RunId = $runId CreatedUtcTicks = $createdUtcTicks ExpiresUtcTicks = $createdUtcTicks + ([TimeSpan]::TicksPerHour * 3) @@ -349,6 +354,7 @@ function Write-InitialOwnershipManifest( Directories = @() Files = @() RegistryKeys = @() + RegistryValues = @() Users = @() Profiles = @() } diff --git a/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 b/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 index ae078c252..943bf81e6 100644 --- a/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 +++ b/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 @@ -12,6 +12,8 @@ $cleanupProcess = $null $cleanupJob = $null $cleanupReadyEvent = $null $fixedResult = 'FAILED' +$fixedStatus = 'CONTROLLER_FAILURE' +$fixedExitCode = 125 $validatedManifestPath = $null Add-Type -TypeDefinition @' @@ -111,6 +113,9 @@ public sealed class ProPRWorkflowCleanupJob : IDisposable function Write-FixedResult([ValidateSet('COMPLETE','FAILED','TIMED_OUT')][string]$Result) { Write-Host "PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:$Result" + Write-Host ( + 'PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:STATUS:{0}:EXIT_CODE:{1}' -f ` + $script:fixedStatus, $script:fixedExitCode) [Console]::Out.Flush() } @@ -145,6 +150,8 @@ try { $startInfo.FileName = $hostPath $startInfo.UseShellExecute = $false $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true foreach ($argument in @( '-NoLogo', '-NoProfile', '-NonInteractive', '-File', $cleanupWorkerPath, '-OwnershipManifest', $manifestPath, @@ -162,6 +169,10 @@ try { $cleanupProcess = [Diagnostics.Process]::new() $cleanupProcess.StartInfo = $startInfo if (!$cleanupProcess.Start()) { throw 'workflow cleanup did not start' } + $cleanupProcess.add_OutputDataReceived({}) + $cleanupProcess.add_ErrorDataReceived({}) + $cleanupProcess.BeginOutputReadLine() + $cleanupProcess.BeginErrorReadLine() try { $cleanupJob.AddProcess($cleanupProcess.Handle) [void]$cleanupReadyEvent.Set() @@ -173,11 +184,23 @@ try { try { $cleanupJob.Terminate(125) } catch {} try { [void]$cleanupProcess.WaitForExit($TerminationTimeoutMilliseconds) } catch {} $fixedResult = 'TIMED_OUT' + $fixedStatus = 'TIMEOUT' + $fixedExitCode = 124 } elseif ($cleanupProcess.ExitCode -eq 0) { $fixedResult = 'COMPLETE' + $fixedStatus = 'EMPTY_OR_CLEANED' + $fixedExitCode = 0 + } elseif ($cleanupProcess.ExitCode -eq 20) { + $fixedStatus = 'MANIFEST_VALIDATION_FAILURE' + $fixedExitCode = 20 + } elseif ($cleanupProcess.ExitCode -eq 21) { + $fixedStatus = 'OWNED_RESOURCE_CLEANUP_FAILURE' + $fixedExitCode = 21 } } catch { $fixedResult = 'FAILED' + $fixedStatus = 'CONTROLLER_FAILURE' + $fixedExitCode = 125 } finally { Write-FixedResult $fixedResult if ($null -ne $cleanupJob) { $cleanupJob.Dispose() } @@ -190,5 +213,4 @@ try { } } -if ($fixedResult -ne 'COMPLETE') { exit 1 } -exit 0 +exit $fixedExitCode 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 886e10110..325057eae 100644 --- a/apps/desktop/scripts/test-installed-windows-app-supervisor-fixture.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app-supervisor-fixture.ps1 @@ -17,6 +17,7 @@ if ($scenario -notin @( 'STALE_MARKER', 'INACCESSIBLE_MARKER', 'CANCELLATION', + 'OWNED_RESOURCES_NORMAL_SUCCESS', 'OWNED_RESOURCES_FOR_INTERRUPTION', 'OWNED_RESOURCES_THEN_DEADLINE' )) { @@ -87,7 +88,9 @@ function Write-FixtureOwnershipToken([string]$Path, [string]$Token) { function New-OwnedFixtureResources { $manifest = [IO.File]::ReadAllText($OwnershipManifest, [Text.Encoding]::UTF8) | ConvertFrom-Json -ErrorAction Stop - if (!$manifest.Fixture -or $manifest.SchemaVersion -ne 1) { + if (!$manifest.Fixture -or $manifest.SchemaVersion -ne 2 -or + $manifest.ManifestType -cne 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP' -or + $manifest.State -cne 'ACTIVE') { throw 'fixture ownership manifest was not initialized' } $token = [Guid]::NewGuid().ToString('N') @@ -148,6 +151,7 @@ function New-OwnedFixtureResources { $manifest.RegistryKeys = @( [ordered]@{ Kind = 'PROTOCOL'; Path = $registryPath; Owned = $true; Token = $token } ) + $manifest.RegistryValues = @() if ($env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_REGISTRY) { $manifest.RegistryKeys += [ordered]@{ Kind = 'CONFLICT'; Path = $env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_REGISTRY @@ -321,6 +325,12 @@ switch ($scenario) { [DateTime]::UtcNow.AddSeconds(60).Ticks) Start-Sleep -Seconds 300 } + 'OWNED_RESOURCES_NORMAL_SUCCESS' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + New-OwnedFixtureResources + Write-FixtureMarker ('{0}|CLEANUP|SMOKE_DATA_REMOVE|COMPLETE' -f ` + [DateTime]::UtcNow.AddSeconds(60).Ticks) + } } $descendant.Dispose() diff --git a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 index eff9639ee..cc0f957af 100644 --- a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 @@ -191,10 +191,27 @@ function Invoke-WorkflowCleanupController( try { if (!$process.Start()) { throw 'workflow cleanup fixture did not start' } Assert-True ($process.WaitForExit(40000)) 'workflow cleanup fixture exceeded its bound' + $output = $process.StandardOutput.ReadToEnd() + $errorOutput = $process.StandardError.ReadToEnd() + Assert-True ($output.Length -le 512) 'workflow cleanup fixture output exceeded its fixed bound' + Assert-True ($errorOutput.Length -eq 0) ` + 'workflow cleanup fixture emitted non-fixed error output' + $outputLines = @($output -split '\r?\n' | Where-Object { $_ }) + Assert-True ($outputLines.Count -eq 2) ` + 'workflow cleanup fixture did not emit exactly two fixed result lines' + Assert-True ($outputLines[0] -match + '^PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:(COMPLETE|FAILED|TIMED_OUT)$') ` + 'workflow cleanup fixture emitted an invalid fixed result' + $resultName = $Matches[1] + Assert-True ($outputLines[1] -match + '^PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:STATUS:([A-Z_]+):EXIT_CODE:([0-9]+)$') ` + 'workflow cleanup fixture emitted an invalid fixed status' return [PSCustomObject]@{ ExitCode = $process.ExitCode - Output = $process.StandardOutput.ReadToEnd() - Error = $process.StandardError.ReadToEnd() + Result = $resultName + ControllerStatus = $Matches[1] + ReportedExitCode = [int]$Matches[2] + Output = $output } } finally { if (!$process.HasExited) { try { $process.Kill($true) } catch {} } @@ -628,7 +645,10 @@ function Test-PreExistingCleanupOwnership { 'killed supervisor did not preserve the durable ownership manifest' $workflowCleanup = Invoke-WorkflowCleanupController ` $workflowManifest $workflowRunId $workflowStateDirectory - Assert-True ($workflowCleanup.ExitCode -eq 0) 'workflow cleanup controller failed' + Assert-True ($workflowCleanup.ExitCode -eq 0 -and + $workflowCleanup.ReportedExitCode -eq 0 -and + $workflowCleanup.ControllerStatus -ceq 'EMPTY_OR_CLEANED') ` + 'workflow cleanup controller did not report fixed cleanup success' Assert-Contains $workflowCleanup.Output ` 'PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:COMPLETE' ` 'workflow cleanup controller did not emit fixed completion evidence' @@ -640,6 +660,49 @@ function Test-PreExistingCleanupOwnership { $workflowSupervisor.Dispose() } + $normalStateDirectory = New-StateDirectory 'workflow-normal-already-cleaned' + $normalRunId = [Guid]::NewGuid().ToString('N') + $normalManifest = Join-Path ([IO.Path]::GetTempPath()) ` + "propr-installed-app-ownership-$normalRunId.json" + $normalSupervisor = [Diagnostics.Process]::new() + $normalSupervisor.StartInfo = New-SupervisorStartInfo ` + 'OWNED_RESOURCES_NORMAL_SUCCESS' $normalStateDirectory '' $false ` + $normalManifest $normalRunId + try { + if (!$normalSupervisor.Start()) { throw 'normal workflow supervisor fixture did not start' } + $normalOwned = Read-FixtureResourceState $normalStateDirectory + Assert-True ($normalSupervisor.WaitForExit(40000)) ` + 'normal workflow supervisor fixture exceeded its bound' + Assert-True ($normalSupervisor.ExitCode -eq 0) ` + 'normal workflow supervisor fixture did not complete successfully' + Assert-OwnedResourcesGone $normalOwned + Assert-True (Test-Path -LiteralPath $normalManifest -PathType Leaf) ` + 'normal supervisor did not preserve its empty ownership receipt' + $normalReceipt = Get-Content -LiteralPath $normalManifest -Raw -Encoding UTF8 | + ConvertFrom-Json -ErrorAction Stop + Assert-True ($normalReceipt.SchemaVersion -eq 2 -and + $normalReceipt.ManifestType -ceq 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP' -and + $normalReceipt.State -ceq 'EMPTY' -and + @($normalReceipt.Directories).Count -eq 0 -and + @($normalReceipt.Files).Count -eq 0 -and + @($normalReceipt.RegistryKeys).Count -eq 0 -and + @($normalReceipt.RegistryValues).Count -eq 0 -and + @($normalReceipt.Users).Count -eq 0 -and + @($normalReceipt.Profiles).Count -eq 0) ` + 'normal supervisor did not produce a typed authenticated empty-state receipt' + $normalCleanup = Invoke-WorkflowCleanupController ` + $normalManifest $normalRunId $normalStateDirectory + Assert-True ($normalCleanup.ExitCode -eq 0 -and + $normalCleanup.ReportedExitCode -eq 0 -and + $normalCleanup.ControllerStatus -ceq 'EMPTY_OR_CLEANED') ` + 'always cleanup did not accept the normal already-cleaned receipt' + Assert-True (!(Test-Path -LiteralPath $normalManifest)) ` + 'always cleanup did not consume the normal empty-state receipt' + } finally { + if (!$normalSupervisor.HasExited) { try { $normalSupervisor.Kill($true) } catch {} } + $normalSupervisor.Dispose() + } + foreach ($manifestCase in @('MISSING','MALFORMED','STALE')) { $badRunId = [Guid]::NewGuid().ToString('N') $badManifest = Join-Path ([IO.Path]::GetTempPath()) ` @@ -649,13 +712,15 @@ function Test-PreExistingCleanupOwnership { } elseif ($manifestCase -eq 'STALE') { $createdTicks = [DateTime]::UtcNow.AddHours(-4).Ticks $staleManifest = [ordered]@{ - SchemaVersion = 1; RunId = $badRunId + SchemaVersion = 2 + ManifestType = 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP'; State = 'ACTIVE' + RunId = $badRunId CreatedUtcTicks = $createdTicks ExpiresUtcTicks = $createdTicks + ([TimeSpan]::TicksPerHour * 3) InstallerPath = $dummyInstaller; Fixture = $true FixtureRoot = $workflowStateDirectory; BaselineClean = $false InstallAttempted = $false; Directories = @(); Files = @() - RegistryKeys = @(); Users = @(); Profiles = @() + RegistryKeys = @(); RegistryValues = @(); Users = @(); Profiles = @() } [IO.File]::WriteAllText( $badManifest, @@ -667,6 +732,10 @@ function Test-PreExistingCleanupOwnership { $badManifest $badRunId $workflowStateDirectory Assert-True ($failedCleanup.ExitCode -ne 0) ` "$manifestCase workflow manifest did not fail closed" + Assert-True ($failedCleanup.ExitCode -eq 20 -and + $failedCleanup.ReportedExitCode -eq 20 -and + $failedCleanup.ControllerStatus -ceq 'MANIFEST_VALIDATION_FAILURE') ` + "$manifestCase workflow manifest did not report fixed validation status" Assert-Contains $failedCleanup.Output ` 'PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:FAILED' ` "$manifestCase workflow manifest did not emit fixed failure evidence" @@ -773,12 +842,21 @@ function Test-PreExistingAppPathsAuthority { "propr-installed-app-ownership-$mismatchRunId.json" $createdTicks = [DateTime]::UtcNow.Ticks $mismatchState = [ordered]@{ - SchemaVersion = 1; RunId = $mismatchRunId + SchemaVersion = 2 + ManifestType = 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP'; State = 'ACTIVE' + RunId = $mismatchRunId CreatedUtcTicks = $createdTicks ExpiresUtcTicks = $createdTicks + ([TimeSpan]::TicksPerHour * 3) InstallerPath = $dummyInstaller; Fixture = $false; FixtureRoot = $null BaselineClean = $true; InstallAttempted = $true Directories = @(); Files = @(); Users = @(); Profiles = @() + RegistryValues = @([ordered]@{ + Kind = 'HKCU_INSTALLED' + Path = 'Registry::HKEY_CURRENT_USER\Software\ProPR\Desktop' + Name = 'installed'; Owned = $false; Provisional = $false + BaselineKeyExisted = $false; BaselineValueExisted = $false + BaselineValueKind = $null; BaselineValueData = $null; KeyCreatedByRun = $false + }) RegistryKeys = @( [ordered]@{ Kind = 'PROTOCOL'; Path = $protocol; Owned = $true; Token = $null @@ -799,6 +877,10 @@ function Test-PreExistingAppPathsAuthority { $mismatchManifest $mismatchRunId '' Assert-True ($mismatchCleanup.ExitCode -ne 0) ` 'mismatched App Paths ownership identity did not fail closed' + Assert-True ($mismatchCleanup.ExitCode -eq 20 -and + $mismatchCleanup.ReportedExitCode -eq 20 -and + $mismatchCleanup.ControllerStatus -ceq 'MANIFEST_VALIDATION_FAILURE') ` + 'mismatched App Paths ownership did not report fixed validation status' Assert-Contains $mismatchCleanup.Output ` 'PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:FAILED' ` 'mismatched App Paths ownership did not emit fixed failure evidence' @@ -824,6 +906,128 @@ function Test-PreExistingAppPathsAuthority { [Console]::Out.Flush() } +function Test-HkcuInstalledValueOwnership { + $desktopKey = 'Registry::HKEY_CURRENT_USER\Software\ProPR\Desktop' + $installedName = 'installed' + $sentinelInstalled = 'pre-existing-installed' + $sentinelUnrelated = 'preserve-unrelated' + Assert-True (!(Test-Path -LiteralPath $desktopKey)) ` + 'HKCU installed-value fixture baseline was not clean' + + function New-HkcuManifest( + [bool]$BaselineKeyExisted, + [bool]$BaselineValueExisted, + [AllowNull()][string]$BaselineKind, + [AllowNull()][string]$BaselineData, + [bool]$KeyCreatedByRun + ) { + $runId = [Guid]::NewGuid().ToString('N') + $path = Join-Path ([IO.Path]::GetTempPath()) ` + "propr-installed-app-ownership-$runId.json" + $createdTicks = [DateTime]::UtcNow.Ticks + $manifest = [ordered]@{ + SchemaVersion = 2 + ManifestType = 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP' + State = 'ACTIVE' + RunId = $runId + CreatedUtcTicks = $createdTicks + ExpiresUtcTicks = $createdTicks + ([TimeSpan]::TicksPerHour * 3) + InstallerPath = $dummyInstaller + Fixture = $false + FixtureRoot = $null + BaselineClean = $false + InstallAttempted = $false + Directories = @() + Files = @() + RegistryKeys = @() + RegistryValues = @([ordered]@{ + Kind = 'HKCU_INSTALLED'; Path = $desktopKey; Name = $installedName + Owned = $true; Provisional = $false + BaselineKeyExisted = $BaselineKeyExisted + BaselineValueExisted = $BaselineValueExisted + BaselineValueKind = $BaselineKind + BaselineValueData = $BaselineData + KeyCreatedByRun = $KeyCreatedByRun + }) + Users = @() + Profiles = @() + } + [IO.File]::WriteAllText( + $path, + ($manifest | ConvertTo-Json -Depth 6 -Compress), + [Text.Encoding]::UTF8 + ) + return [PSCustomObject]@{ RunId = $runId; Path = $path } + } + + try { + [void](New-Item -Path $desktopKey -Force -ErrorAction Stop) + (Get-Item -LiteralPath $desktopKey).SetValue( + $installedName, $sentinelInstalled, [Microsoft.Win32.RegistryValueKind]::String) + (Get-Item -LiteralPath $desktopKey).SetValue( + 'Unrelated', $sentinelUnrelated, [Microsoft.Win32.RegistryValueKind]::String) + $baselineData = [Convert]::ToBase64String( + [Text.Encoding]::UTF8.GetBytes($sentinelInstalled)) + (Get-Item -LiteralPath $desktopKey).SetValue( + $installedName, [int]1, [Microsoft.Win32.RegistryValueKind]::DWord) + $restoreManifest = New-HkcuManifest $true $true 'String' $baselineData $false + $restore = Invoke-WorkflowCleanupController $restoreManifest.Path $restoreManifest.RunId '' + Assert-True ($restore.ExitCode -eq 0 -and + $restore.ControllerStatus -ceq 'EMPTY_OR_CLEANED') ` + 'pre-existing HKCU installed value restoration did not complete' + $restoredKey = Get-Item -LiteralPath $desktopKey -ErrorAction Stop + Assert-True ($restoredKey.GetValueKind($installedName).ToString() -ceq 'String' -and + [string]$restoredKey.GetValue($installedName) -ceq $sentinelInstalled) ` + 'pre-existing HKCU installed value was not restored exactly' + Assert-True ([string]$restoredKey.GetValue('Unrelated') -ceq $sentinelUnrelated) ` + 'unrelated HKCU value was changed during baseline restoration' + + Remove-Item -LiteralPath $desktopKey -Recurse -Force -ErrorAction Stop + [void](New-Item -Path $desktopKey -Force -ErrorAction Stop) + (Get-Item -LiteralPath $desktopKey).SetValue( + $installedName, [int]1, [Microsoft.Win32.RegistryValueKind]::DWord) + (Get-Item -LiteralPath $desktopKey).SetValue( + 'Unrelated', $sentinelUnrelated, [Microsoft.Win32.RegistryValueKind]::String) + $nonemptyManifest = New-HkcuManifest $false $false $null $null $true + $nonempty = Invoke-WorkflowCleanupController $nonemptyManifest.Path $nonemptyManifest.RunId '' + Assert-True ($nonempty.ExitCode -eq 0) ` + 'run-owned HKCU value cleanup with unrelated values failed' + $nonemptyKey = Get-Item -LiteralPath $desktopKey -ErrorAction Stop + Assert-True (@($nonemptyKey.GetValueNames()) -cnotcontains $installedName -and + [string]$nonemptyKey.GetValue('Unrelated') -ceq $sentinelUnrelated) ` + 'run-owned HKCU cleanup removed its nonempty key or unrelated value' + + Remove-Item -LiteralPath $desktopKey -Recurse -Force -ErrorAction Stop + [void](New-Item -Path $desktopKey -Force -ErrorAction Stop) + (Get-Item -LiteralPath $desktopKey).SetValue( + $installedName, [int]1, [Microsoft.Win32.RegistryValueKind]::DWord) + $emptyManifest = New-HkcuManifest $false $false $null $null $true + $empty = Invoke-WorkflowCleanupController $emptyManifest.Path $emptyManifest.RunId '' + Assert-True ($empty.ExitCode -eq 0 -and !(Test-Path -LiteralPath $desktopKey)) ` + 'run-created empty HKCU key was not removed' + + [void](New-Item -Path $desktopKey -Force -ErrorAction Stop) + (Get-Item -LiteralPath $desktopKey).SetValue( + $installedName, 'foreign-conflict', [Microsoft.Win32.RegistryValueKind]::String) + $conflictManifest = New-HkcuManifest $false $false $null $null $true + $conflict = Invoke-WorkflowCleanupController ` + $conflictManifest.Path $conflictManifest.RunId '' + Assert-True ($conflict.ExitCode -eq 21 -and + $conflict.ReportedExitCode -eq 21 -and + $conflict.ControllerStatus -ceq 'OWNED_RESOURCE_CLEANUP_FAILURE') ` + 'conflicting HKCU installed value did not fail with fixed resource-cleanup status' + $conflictingKey = Get-Item -LiteralPath $desktopKey -ErrorAction Stop + Assert-True ([string]$conflictingKey.GetValue($installedName) -ceq 'foreign-conflict') ` + 'conflicting HKCU installed value was removed or changed' + } finally { + if (Test-Path -LiteralPath $desktopKey) { + Remove-Item -LiteralPath $desktopKey -Recurse -Force -ErrorAction SilentlyContinue + } + } + Write-Host 'PROPR_WINDOWS_SUPERVISOR_OWNERSHIP:HKCU_INSTALLED_VALUE:PRESERVED' + [Console]::Out.Flush() +} + if (![OperatingSystem]::IsWindows()) { throw 'supervisor behavior tests require Windows' } $actualArchitecture = [Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString().ToLowerInvariant() Assert-True ($actualArchitecture -ceq $Architecture) ` @@ -838,6 +1042,7 @@ try { Test-LiveCancellationAndRedaction Test-PreExistingCleanupOwnership Test-PreExistingAppPathsAuthority + Test-HkcuInstalledValueOwnership Write-Host "PROPR_WINDOWS_SUPERVISOR_TESTS:${Architecture}:PASSED" [Console]::Out.Flush() } finally { diff --git a/apps/desktop/scripts/test-installed-windows-app.ps1 b/apps/desktop/scripts/test-installed-windows-app.ps1 index 67259db0e..19ad2d1b2 100644 --- a/apps/desktop/scripts/test-installed-windows-app.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app.ps1 @@ -83,6 +83,8 @@ $application = Join-Path $installRoot 'propr-desktop.exe' $protocolRegistryPath = 'Registry::HKEY_LOCAL_MACHINE\Software\Classes\propr' $appPathsRegistryPath = ` 'Registry::HKEY_LOCAL_MACHINE\Software\Microsoft\Windows\CurrentVersion\App Paths\propr-desktop.exe' +$hkcuDesktopRegistryPath = 'Registry::HKEY_CURRENT_USER\Software\ProPR\Desktop' +$hkcuInstalledValueName = 'installed' $testUser = "propr-ci-$([Guid]::NewGuid().ToString('N').Substring(0,8))" $passwordText = "P!$([Guid]::NewGuid().ToString('N'))a7" $password = ConvertTo-SecureString $passwordText -AsPlainText -Force @@ -95,11 +97,16 @@ $smokeUserDataDirectory = $null $installRootExistedBeforeInstall = $false $protocolExistedBeforeInstall = $false $appPathsExistedBeforeInstall = $false +$hkcuDesktopKeyExistedBeforeInstall = $false +$hkcuInstalledValueExistedBeforeInstall = $false +$hkcuInstalledBaselineKind = $null +$hkcuInstalledBaselineData = $null $installRootCreatedByRun = $false $protocolCreatedByRun = $false $appPathsCreatedByRun = $false $protocolOwnedIdentity = $null $appPathsOwnedIdentity = $null +$hkcuDesktopKeyCreatedByRun = $false $msiTimeoutMilliseconds = 10 * 60 * 1000 $applicationTimeoutMilliseconds = 5 * 60 * 1000 $terminationTimeoutMilliseconds = 30 * 1000 @@ -162,6 +169,7 @@ $installRootExistedBeforeInstall = Test-Path -LiteralPath $installRoot $protocolExistedBeforeInstall = Test-Path -LiteralPath $protocolRegistryPath $appPathsExistedBeforeInstall = Test-Path -LiteralPath $appPathsRegistryPath +$hkcuDesktopKeyExistedBeforeInstall = Test-Path -LiteralPath $hkcuDesktopRegistryPath $startMenuShortcutExistedBeforeInstall = Test-Path -LiteralPath $startMenuShortcut $startMenuShortcutFolderExistedBeforeInstall = Test-Path -LiteralPath $startMenuShortcutFolder $startMenuShortcutCreatedByRun = $false @@ -201,7 +209,10 @@ try { $strictUtf8 = [Text.UTF8Encoding]::new($false, $true) $initialOwnershipState = ConvertFrom-Json ` -InputObject $strictUtf8.GetString($initialManifestBytes) -ErrorAction Stop -if ($initialOwnershipState.SchemaVersion -ne 1 -or +if ($initialOwnershipState.SchemaVersion -ne 2 -or + [string]$initialOwnershipState.ManifestType -cne + 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP' -or + [string]$initialOwnershipState.State -cne 'ACTIVE' -or [string]$initialOwnershipState.RunId -cne $ownershipRunId -or ![string]::Equals( [IO.Path]::GetFullPath([string]$initialOwnershipState.InstallerPath), @@ -212,7 +223,9 @@ if ($initialOwnershipState.SchemaVersion -ne 1 -or } $ownershipToken = [Guid]::NewGuid().ToString('N') $ownershipState = [ordered]@{ - SchemaVersion = 1 + SchemaVersion = 2 + ManifestType = 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP' + State = 'ACTIVE' RunId = $ownershipRunId CreatedUtcTicks = [int64]$initialOwnershipState.CreatedUtcTicks ExpiresUtcTicks = [int64]$initialOwnershipState.ExpiresUtcTicks @@ -224,6 +237,7 @@ $ownershipState = [ordered]@{ Directories = @() Files = @() RegistryKeys = @() + RegistryValues = @() Users = @() Profiles = @() } @@ -314,6 +328,117 @@ function Get-RegistryTreeIdentity([string]$Path) { finally { $sha256.Dispose() } } +function Convert-RegistryValueToBytes( + [Microsoft.Win32.RegistryValueKind]$Kind, + $Value +) { + switch ($Kind) { + 'DWord' { return [BitConverter]::GetBytes([int32]$Value) } + 'QWord' { return [BitConverter]::GetBytes([int64]$Value) } + 'String' { return [Text.Encoding]::UTF8.GetBytes([string]$Value) } + 'ExpandString' { return [Text.Encoding]::UTF8.GetBytes([string]$Value) } + 'MultiString' { + return [Text.Encoding]::UTF8.GetBytes( + (ConvertTo-Json -InputObject @([string[]]$Value) -Compress)) + } + 'Binary' { return [byte[]]$Value } + 'None' { return [byte[]]$Value } + default { throw 'registry value kind is unsupported' } + } +} + +function Get-RegistryValueSnapshot([string]$Path, [string]$Name) { + if (!(Test-Path -LiteralPath $Path)) { + return [PSCustomObject]@{ Exists = $false; Kind = $null; Data = $null } + } + $key = Get-Item -LiteralPath $Path -ErrorAction Stop + if (@($key.GetValueNames()) -cnotcontains $Name) { + return [PSCustomObject]@{ Exists = $false; Kind = $null; Data = $null } + } + $kind = $key.GetValueKind($Name) + $value = $key.GetValue( + $Name, + $null, + [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames + ) + return [PSCustomObject]@{ + Exists = $true + Kind = $kind.ToString() + Data = [Convert]::ToBase64String((Convert-RegistryValueToBytes $kind $value)) + } +} + +function Test-MsiInstalledValue([string]$Path, [string]$Name) { + $snapshot = Get-RegistryValueSnapshot $Path $Name + return $snapshot.Exists -and $snapshot.Kind -ceq 'DWord' -and + $snapshot.Data -ceq [Convert]::ToBase64String([BitConverter]::GetBytes([int32]1)) +} + +function Restore-HkcuInstalledBaseline { + $current = Get-RegistryValueSnapshot $hkcuDesktopRegistryPath $hkcuInstalledValueName + $matchesBaseline = $hkcuInstalledValueExistedBeforeInstall -and $current.Exists -and + $current.Kind -ceq $hkcuInstalledBaselineKind -and + $current.Data -ceq $hkcuInstalledBaselineData + if ($current.Exists -and !$matchesBaseline -and + !(Test-MsiInstalledValue $hkcuDesktopRegistryPath $hkcuInstalledValueName)) { + throw 'refusing to replace a conflicting current-user installed value' + } + + if ($hkcuInstalledValueExistedBeforeInstall) { + if (!(Test-Path -LiteralPath $hkcuDesktopRegistryPath)) { + [void](New-Item -Path $hkcuDesktopRegistryPath -Force -ErrorAction Stop) + } + if (!$matchesBaseline) { + $kind = [Enum]::Parse( + [Microsoft.Win32.RegistryValueKind], $hkcuInstalledBaselineKind, $false) + $bytes = [Convert]::FromBase64String($hkcuInstalledBaselineData) + $value = switch ($kind) { + 'DWord' { [BitConverter]::ToInt32($bytes, 0); break } + 'QWord' { [BitConverter]::ToInt64($bytes, 0); break } + 'String' { [Text.Encoding]::UTF8.GetString($bytes); break } + 'ExpandString' { [Text.Encoding]::UTF8.GetString($bytes); break } + 'MultiString' { + @([string[]](ConvertFrom-Json -InputObject ([Text.Encoding]::UTF8.GetString($bytes)))) + break + } + 'Binary' { $bytes; break } + 'None' { $bytes; break } + default { throw 'registry baseline kind is unsupported' } + } + (Get-Item -LiteralPath $hkcuDesktopRegistryPath -ErrorAction Stop).SetValue( + $hkcuInstalledValueName, $value, $kind) + } + } elseif ($current.Exists) { + Remove-ItemProperty -LiteralPath $hkcuDesktopRegistryPath ` + -Name $hkcuInstalledValueName -Force -ErrorAction Stop + } + + if ($hkcuDesktopKeyCreatedByRun -and (Test-Path -LiteralPath $hkcuDesktopRegistryPath)) { + $key = Get-Item -LiteralPath $hkcuDesktopRegistryPath -ErrorAction Stop + if (@($key.GetValueNames()).Count -eq 0 -and @($key.GetSubKeyNames()).Count -eq 0) { + Remove-Item -LiteralPath $hkcuDesktopRegistryPath -Force -ErrorAction Stop + } + } +} + +$hkcuInstalledSnapshot = Get-RegistryValueSnapshot ` + $hkcuDesktopRegistryPath $hkcuInstalledValueName +$hkcuInstalledValueExistedBeforeInstall = [bool]$hkcuInstalledSnapshot.Exists +$hkcuInstalledBaselineKind = $hkcuInstalledSnapshot.Kind +$hkcuInstalledBaselineData = $hkcuInstalledSnapshot.Data +$ownershipState.RegistryValues = @([ordered]@{ + Kind = 'HKCU_INSTALLED' + Path = $hkcuDesktopRegistryPath + Name = $hkcuInstalledValueName + Owned = $false + Provisional = $false + BaselineKeyExisted = $hkcuDesktopKeyExistedBeforeInstall + BaselineValueExisted = $hkcuInstalledValueExistedBeforeInstall + BaselineValueKind = $hkcuInstalledBaselineKind + BaselineValueData = $hkcuInstalledBaselineData + KeyCreatedByRun = $false +}) + Write-OwnershipManifest function Write-WatchdogMarker( @@ -328,6 +453,7 @@ function Write-WatchdogMarker( 'APPLICATION_IMAGE', 'PROTOCOL_ASSERTION', 'APP_PATH_ASSERTION', + 'HKCU_INSTALLED_ASSERTION', 'SHORTCUT_ASSERTION', 'USER_CREATE', 'USER_SID', @@ -341,6 +467,7 @@ function Write-WatchdogMarker( 'INSTALL_TREE_ASSERTION', 'PROTOCOL_ABSENCE_ASSERTION', 'APP_PATH_ABSENCE_ASSERTION', + 'HKCU_INSTALLED_ABSENCE_ASSERTION', 'SHORTCUT_FILE_ASSERTION', 'SHORTCUT_FOLDER_ASSERTION', 'SHORTCUT_ABSENCE_PROBE', @@ -352,6 +479,7 @@ function Write-WatchdogMarker( 'INSTALL_ROOT_FALLBACK', 'PROTOCOL_FALLBACK', 'APP_PATH_FALLBACK', + 'HKCU_INSTALLED_FALLBACK', 'SHORTCUT_FALLBACK' )][string]$Substage, [int]$TimeoutMilliseconds, @@ -457,6 +585,7 @@ function Write-CleanupSubstage( 'INSTALL_TREE', 'PROTOCOL', 'APP_PATH', + 'HKCU_INSTALLED', 'SHORTCUT_FILE', 'SHORTCUT_FOLDER', 'ORDINARY_USER_ABSENCE_PROBE', @@ -466,6 +595,7 @@ function Write-CleanupSubstage( 'INSTALL_ROOT_FALLBACK', 'PROTOCOL_FALLBACK', 'APP_PATH_FALLBACK', + 'HKCU_INSTALLED_FALLBACK', 'SHORTCUT_FALLBACK', 'FINAL_AGGREGATION' )][string]$Substage, @@ -1087,6 +1217,18 @@ try { Owned = $true; Token = $null; Identity = $null; Provisional = $true } ) + $ownershipState.RegistryValues = @([ordered]@{ + Kind = 'HKCU_INSTALLED' + Path = $hkcuDesktopRegistryPath + Name = $hkcuInstalledValueName + Owned = $true + Provisional = $true + BaselineKeyExisted = $hkcuDesktopKeyExistedBeforeInstall + BaselineValueExisted = $hkcuInstalledValueExistedBeforeInstall + BaselineValueKind = $hkcuInstalledBaselineKind + BaselineValueData = $hkcuInstalledBaselineData + KeyCreatedByRun = $false + }) Write-OwnershipManifest try { Invoke-BoundedExternalOperation ` @@ -1109,6 +1251,9 @@ try { (Test-Path -LiteralPath $protocolRegistryPath) $script:appPathsCreatedByRun = !$appPathsExistedBeforeInstall -and (Test-Path -LiteralPath $appPathsRegistryPath) + $script:hkcuDesktopKeyCreatedByRun = + !$hkcuDesktopKeyExistedBeforeInstall -and + (Test-Path -LiteralPath $hkcuDesktopRegistryPath) $script:startMenuShortcutCreatedByRun = !$startMenuShortcutExistedBeforeInstall -and (Test-Path -LiteralPath $startMenuShortcut) $script:startMenuShortcutFolderCreatedByRun = @@ -1150,6 +1295,18 @@ try { } } $ownershipState.RegistryKeys = $ownedRegistryKeys + $ownershipState.RegistryValues = @([ordered]@{ + Kind = 'HKCU_INSTALLED' + Path = $hkcuDesktopRegistryPath + Name = $hkcuInstalledValueName + Owned = $true + Provisional = $false + BaselineKeyExisted = $hkcuDesktopKeyExistedBeforeInstall + BaselineValueExisted = $hkcuInstalledValueExistedBeforeInstall + BaselineValueKind = $hkcuInstalledBaselineKind + BaselineValueData = $hkcuInstalledBaselineData + KeyCreatedByRun = $script:hkcuDesktopKeyCreatedByRun + }) Write-OwnershipManifest } } @@ -1207,6 +1364,13 @@ try { } } + Invoke-BoundedExternalOperation 'VALIDATION' 'HKCU_INSTALLED_ASSERTION' ` + $externalOperationTimeoutMilliseconds { + if (!(Test-MsiInstalledValue $hkcuDesktopRegistryPath $hkcuInstalledValueName)) { + throw 'machine installer did not author the current-user installed value' + } + } + Invoke-BoundedExternalOperation 'VALIDATION' 'SHORTCUT_ASSERTION' ` $externalOperationTimeoutMilliseconds { $shortcutItem = Get-Item -LiteralPath $startMenuShortcut -Force -ErrorAction Stop @@ -1393,6 +1557,9 @@ try { (Get-RegistryTreeIdentity $appPathsRegistryPath) -cne $appPathsOwnedIdentity)) { throw 'refusing to uninstall over executable metadata with a mismatched ownership identity' } + if (!(Test-MsiInstalledValue $hkcuDesktopRegistryPath $hkcuInstalledValueName)) { + throw 'refusing to uninstall over current-user metadata with mismatched ownership' + } Invoke-Msi @('/x', "`"$installerPath`"", '/qn', '/norestart') 'machine uninstall' } Write-CleanupSubstage 'UNINSTALL' 'MSI_UNINSTALL' 'COMPLETE' @@ -1443,6 +1610,21 @@ try { $uninstallFailed = $true } + Write-CleanupSubstage 'UNINSTALL' 'HKCU_INSTALLED' 'BEGIN' + try { + Invoke-BoundedExternalOperation ` + 'UNINSTALL' 'HKCU_INSTALLED_ABSENCE_ASSERTION' $externalOperationTimeoutMilliseconds { + if ((Get-RegistryValueSnapshot ` + $hkcuDesktopRegistryPath $hkcuInstalledValueName).Exists) { + throw 'machine uninstall left current-user installed metadata behind' + } + } + Write-CleanupSubstage 'UNINSTALL' 'HKCU_INSTALLED' 'COMPLETE' + } catch { + Write-CleanupSubstage 'UNINSTALL' 'HKCU_INSTALLED' 'FAILED' + $uninstallFailed = $true + } + Write-CleanupSubstage 'UNINSTALL' 'SHORTCUT_FILE' 'BEGIN' try { Invoke-BoundedExternalOperation ` @@ -1620,6 +1802,18 @@ try { $cleanupFailed = $true } + Write-CleanupSubstage 'CLEANUP' 'HKCU_INSTALLED_FALLBACK' 'BEGIN' + try { + Invoke-BoundedExternalOperation ` + 'CLEANUP' 'HKCU_INSTALLED_FALLBACK' $externalOperationTimeoutMilliseconds { + Restore-HkcuInstalledBaseline + } + Write-CleanupSubstage 'CLEANUP' 'HKCU_INSTALLED_FALLBACK' 'COMPLETE' + } catch { + Write-CleanupSubstage 'CLEANUP' 'HKCU_INSTALLED_FALLBACK' 'FAILED' + $cleanupFailed = $true + } + Write-CleanupSubstage 'CLEANUP' 'SHORTCUT_FALLBACK' 'BEGIN' $shortcutFallbackFailed = $false try { @@ -1662,11 +1856,13 @@ try { throw 'installed Windows cleanup did not complete' } } else { + $ownershipState.State = 'EMPTY' $ownershipState.BaselineClean = $false $ownershipState.InstallAttempted = $false $ownershipState.Directories = @() $ownershipState.Files = @() $ownershipState.RegistryKeys = @() + $ownershipState.RegistryValues = @() $ownershipState.Users = @() $ownershipState.Profiles = @() Write-OwnershipManifest diff --git a/apps/desktop/src/release-workflow.test.ts b/apps/desktop/src/release-workflow.test.ts index 4805613e2..57b2553b2 100644 --- a/apps/desktop/src/release-workflow.test.ts +++ b/apps/desktop/src/release-workflow.test.ts @@ -620,6 +620,9 @@ describe('desktop trusted release workflow', () => { assert.match(installedWindowsAppCleanup, /Remove-OwnedRegistryKey/); assert.match(installedWindowsAppCleanup, /Remove-OwnedDirectory/); assert.match(installedWindowsAppCleanup, /APP_PATH/); + assert.match(installedWindowsAppCleanup, /HKEY_CURRENT_USER\\Software\\ProPR\\Desktop/); + assert.match(installedWindowsAppCleanup, /Restore-OwnedRegistryValue/); + assert.match(installedWindowsAppCleanup, /Write-EmptyOwnershipReceipt/); assert.match(installedWindowsAppCleanup, /Get-RegistryTreeIdentity/); assert.match( installedWindowsAppTest, @@ -628,12 +631,31 @@ describe('desktop trusted release workflow', () => { assert.match(installedWindowsAppTest, /APP_PATH_ASSERTION/); assert.match(installedWindowsAppTest, /APP_PATH_ABSENCE_ASSERTION/); assert.match(installedWindowsAppTest, /APP_PATH_FALLBACK/); + assert.match(installedWindowsAppTest, /HKCU_INSTALLED_ASSERTION/); + assert.match(installedWindowsAppTest, /HKCU_INSTALLED_ABSENCE_ASSERTION/); + assert.match(installedWindowsAppTest, /HKCU_INSTALLED_FALLBACK/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Test-HkcuInstalledValueOwnership/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /OWNED_RESOURCES_NORMAL_SUCCESS/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /typed authenticated empty-state receipt/); assert.match( installedWindowsAppTest, /Get-RegistryTreeIdentity \$appPathsRegistryPath[\s\S]*refusing to uninstall over executable metadata[\s\S]*Invoke-Msi @\('\/x'/, ); assert.match(installedWindowsAppSupervisorBehaviorTest, /mismatched App Paths ownership identity did not fail closed/); assert.match(installedWindowsAppWorkflowCleanup, /ProPRWorkflowCleanupJob/); + assert.match(installedWindowsAppWorkflowCleanup, /MANIFEST_VALIDATION_FAILURE/); + assert.match(installedWindowsAppWorkflowCleanup, /OWNED_RESOURCE_CLEANUP_FAILURE/); + assert.ok( + installedWindowsAppWorkflowCleanup.indexOf('Write-FixedResult $fixedResult') + < installedWindowsAppWorkflowCleanup.indexOf( + 'foreach ($path in @($validatedManifestPath, "$validatedManifestPath.new"))', + ), + 'failed manifest must remain available until fixed controller evidence is emitted', + ); + assert.doesNotMatch( + installedWindowsAppSupervisorBehaviorTest, + /workflowCleanup\.(?:Error|StandardError)|failedCleanup\.(?:Error|StandardError)/, + ); for (const result of ['COMPLETE', 'FAILED', 'TIMED_OUT']) { assert.match( installedWindowsAppWorkflowCleanup, @@ -697,6 +719,7 @@ describe('desktop trusted release workflow', () => { 'APPLICATION_IMAGE', 'PROTOCOL_ASSERTION', 'APP_PATH_ASSERTION', + 'HKCU_INSTALLED_ASSERTION', 'SHORTCUT_ASSERTION', 'USER_CREATE', 'USER_SID', @@ -710,6 +733,7 @@ describe('desktop trusted release workflow', () => { 'INSTALL_TREE_ASSERTION', 'PROTOCOL_ABSENCE_ASSERTION', 'APP_PATH_ABSENCE_ASSERTION', + 'HKCU_INSTALLED_ABSENCE_ASSERTION', 'SHORTCUT_FILE_ASSERTION', 'SHORTCUT_FOLDER_ASSERTION', 'SHORTCUT_ABSENCE_PROBE', @@ -721,6 +745,7 @@ describe('desktop trusted release workflow', () => { 'INSTALL_ROOT_FALLBACK', 'PROTOCOL_FALLBACK', 'APP_PATH_FALLBACK', + 'HKCU_INSTALLED_FALLBACK', 'SHORTCUT_FALLBACK', ]); for (const operation of operations) { @@ -908,6 +933,7 @@ describe('desktop trusted release workflow', () => { 'INSTALL_TREE', 'PROTOCOL', 'APP_PATH', + 'HKCU_INSTALLED', 'SHORTCUT_FILE', 'SHORTCUT_FOLDER', 'ORDINARY_USER_ABSENCE_PROBE', @@ -917,6 +943,7 @@ describe('desktop trusted release workflow', () => { 'INSTALL_ROOT_FALLBACK', 'PROTOCOL_FALLBACK', 'APP_PATH_FALLBACK', + 'HKCU_INSTALLED_FALLBACK', 'SHORTCUT_FALLBACK', 'FINAL_AGGREGATION', ]); @@ -940,7 +967,15 @@ describe('desktop trusted release workflow', () => { assert.ok(substages.includes(substage)); assert.ok(['BEGIN', 'COMPLETE', 'FAILED', 'SKIPPED'].includes(status)); } - for (const substage of ['MSI_UNINSTALL', 'INSTALL_TREE', 'PROTOCOL', 'APP_PATH', 'SHORTCUT_FILE', 'SHORTCUT_FOLDER']) { + for (const substage of [ + 'MSI_UNINSTALL', + 'INSTALL_TREE', + 'PROTOCOL', + 'APP_PATH', + 'HKCU_INSTALLED', + 'SHORTCUT_FILE', + 'SHORTCUT_FOLDER', + ]) { for (const status of ['BEGIN', 'COMPLETE', 'FAILED']) { assert.ok(cleanupCalls.some(match => match[1] === 'UNINSTALL' && match[2] === substage && match[3] === status)); } @@ -957,6 +992,7 @@ describe('desktop trusted release workflow', () => { 'INSTALL_ROOT_FALLBACK', 'PROTOCOL_FALLBACK', 'APP_PATH_FALLBACK', + 'HKCU_INSTALLED_FALLBACK', 'SHORTCUT_FALLBACK', 'FINAL_AGGREGATION', ]) { From 35acc0e6debb34cddb5aff0d5c0efdae5cedd129 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 03:10:13 +0000 Subject: [PATCH 07/29] feat(ai): Implemented the requested PR #2042 follow-ups without committing. Implemented the requested PR #2042 follow-ups without committing. - F5: provisional ownership now authorizes only bounded MSI uninstall. Manual filesystem, shortcut, HKCU, and registry cleanup requires durable tokens or exact captured identities in [cleanup-installed-windows-app.ps1](/tmp/git-processor/worktrees/integry/propr/pr-2042-followup-2026-09-01T02-51-19/apps/desktop/scripts/cleanup-installed-windows-app.ps1:227). - F6: manifests are deleted only after exact `COMPLETE`/`true` results in [workflow cleanup](/tmp/git-processor/worktrees/integry/propr/pr-2042-followup-2026-09-01T02-51-19/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1:217) and [standalone cleanup](/tmp/git-processor/worktrees/integry/propr/pr-2042-followup-2026-09-01T02-51-19/apps/desktop/scripts/run-installed-windows-app-harness.ps1:648). - Replaced unsafe PowerShell async callbacks with a C# stream drain. - Made process-state publication atomic and increased the fixture-only bootstrap allowance. - Added executable replacement/collision, FAILED/TIMED_OUT retention, and retry-to-COMPLETE coverage in [test-installed-windows-app-supervisor.ps1](/tmp/git-processor/worktrees/integry/propr/pr-2042-followup-2026-09-01T02-51-19/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1:639). Validation: - Desktop tests: 177 passed, 6 platform skips. - Desktop typecheck: passed. - Focused release-workflow contracts: 23 passed. - `git diff --check`: passed. The Windows-native x64/ARM64 fixture could not run in this Linux environment and remains required in CI. PR: #2042 Comment by: @integry (ID: 5488111133) Comment by: @integry (ID: 5488155740) Model: gpt-5.6-sol --- .../scripts/cleanup-installed-windows-app.ps1 | 169 ++++++++++++++---- .../run-installed-windows-app-harness.ps1 | 2 +- ...installed-windows-app-workflow-cleanup.ps1 | 16 +- ...stalled-windows-app-supervisor-fixture.ps1 | 70 +++++++- .../test-installed-windows-app-supervisor.ps1 | 143 +++++++++++++-- .../scripts/test-installed-windows-app.ps1 | 157 ++++++++++++++-- apps/desktop/src/release-workflow.test.ts | 55 ++++-- 7 files changed, 536 insertions(+), 76 deletions(-) diff --git a/apps/desktop/scripts/cleanup-installed-windows-app.ps1 b/apps/desktop/scripts/cleanup-installed-windows-app.ps1 index dbc433ed7..c82a3e6d1 100644 --- a/apps/desktop/scripts/cleanup-installed-windows-app.ps1 +++ b/apps/desktop/scripts/cleanup-installed-windows-app.ps1 @@ -14,6 +14,55 @@ $cleanupFailed = $false $manifestValidated = $false $authorizedRunId = $null +Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +public static class ProPRDirectoryIdentity +{ + [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", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFile( + string path, uint access, uint share, IntPtr security, uint creation, + uint flags, IntPtr template); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetFileInformationByHandle( + SafeFileHandle handle, out BY_HANDLE_FILE_INFORMATION information); + + public static string Read(string path) + { + using (SafeFileHandle handle = CreateFile( + path, 0x80, 0x7, IntPtr.Zero, 3, 0x02000000, IntPtr.Zero)) + { + if (handle == null || handle.IsInvalid) + throw new Win32Exception(Marshal.GetLastWin32Error(), "directory identity open failed"); + BY_HANDLE_FILE_INFORMATION information; + if (!GetFileInformationByHandle(handle, out information)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "directory identity read failed"); + return string.Format("{0:x8}{1:x8}{2:x8}", information.VolumeSerialNumber, + information.FileIndexHigh, information.FileIndexLow); + } + } +} +'@ + try { if ($ExpectedRunId -notmatch '^[a-f0-9]{32}$') { exit 1 } if ($OwnershipReadyEvent -notmatch '^Local\\ProPRInstalledAppCleanup-[a-f0-9]{32}$') { @@ -54,6 +103,35 @@ function Test-OwnerFile([string]$Directory, [string]$Token) { return ([IO.File]::ReadAllText($marker, [Text.Encoding]::ASCII) -ceq $Token) } +function Get-FileIdentity([string]$Path) { + if (!(Test-Path -LiteralPath $Path -PathType Leaf)) { return $null } + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + $item.Length -gt 65536) { + return $null + } + $stream = [IO.File]::Open( + $Path, + [IO.FileMode]::Open, + [IO.FileAccess]::Read, + [IO.FileShare]::Read + ) + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + return [BitConverter]::ToString($sha256.ComputeHash($stream)).Replace('-', '').ToLowerInvariant() + } finally { + $sha256.Dispose() + $stream.Dispose() + } +} + +function Get-DirectoryIdentity([string]$Path) { + if (!(Test-Path -LiteralPath $Path -PathType Container)) { return $null } + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { return $null } + return [ProPRDirectoryIdentity]::Read($item.FullName) +} + function Get-RegistryTreeIdentity([string]$Path) { if (!(Test-Path -LiteralPath $Path)) { return $null } $root = Get-Item -LiteralPath $Path -ErrorAction Stop @@ -172,6 +250,16 @@ function Test-MsiInstalledValue([string]$Path, [string]$Name) { $snapshot.Data -ceq [Convert]::ToBase64String([BitConverter]::GetBytes([int32]1)) } +function Test-RegistryValueIdentity($Record, $Snapshot) { + return $Snapshot.Exists -and + [string]$Record.IdentityValueKind -in @( + 'DWord','QWord','String','ExpandString','MultiString','Binary','None' + ) -and + [string]$Record.IdentityValueData -match '^[A-Za-z0-9+/]*={0,2}$' -and + $Snapshot.Kind -ceq [string]$Record.IdentityValueKind -and + $Snapshot.Data -ceq [string]$Record.IdentityValueData +} + function Test-AllowedFileSystemPath([string]$Kind, [string]$Path) { if ($FixtureRoot) { return Test-PathWithin $Path $FixtureRoot } $installRoot = Join-Path $env:ProgramFiles 'ProPR Desktop' @@ -192,7 +280,7 @@ function Test-AllowedFileSystemPath([string]$Kind, [string]$Path) { return $false } -function Remove-OwnedDirectory($Record, [bool]$AllowProvisionalProductOwnership) { +function Remove-OwnedDirectory($Record) { if (!$Record.Owned) { return } $path = [string]$Record.Path $kind = [string]$Record.Kind @@ -203,16 +291,20 @@ function Remove-OwnedDirectory($Record, [bool]$AllowProvisionalProductOwnership) ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'owned directory identity is invalid' } - $provisional = [bool]$Record.Provisional -or - ($AllowProvisionalProductOwnership -and $kind -in @('INSTALL_ROOT','SHORTCUT_FOLDER')) - if (!$provisional -and !(Test-OwnerFile $path ([string]$Record.Token))) { - throw 'owned directory token does not match' + if ([bool]$Record.Provisional) { + throw 'provisional directory evidence cannot authorize manual cleanup' + } + $tokenMatches = Test-OwnerFile $path ([string]$Record.Token) + $identityMatches = [string]$Record.Identity -match '^[a-f0-9]{24}$' -and + (Get-DirectoryIdentity $path) -ceq [string]$Record.Identity + if (!$tokenMatches -and !$identityMatches) { + throw 'owned directory identity does not match' } Remove-Item -LiteralPath $path -Recurse -Force -ErrorAction Stop if (Test-Path -LiteralPath $path) { throw 'owned directory cleanup did not complete' } } -function Remove-OwnedFile($Record, [bool]$AllowProvisionalProductOwnership) { +function Remove-OwnedFile($Record) { if (!$Record.Owned) { return } $path = [string]$Record.Path $kind = [string]$Record.Kind @@ -223,15 +315,18 @@ function Remove-OwnedFile($Record, [bool]$AllowProvisionalProductOwnership) { ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'owned file identity is invalid' } - $provisional = $AllowProvisionalProductOwnership -and $kind -eq 'SHORTCUT_FILE' - if (!$provisional -and !(Test-OwnerFile (Split-Path -Parent $path) ([string]$Record.Token))) { - throw 'owned file token does not match' + if ([bool]$Record.Provisional) { + throw 'provisional file evidence cannot authorize manual cleanup' + } + if ([string]$Record.Identity -notmatch '^[a-f0-9]{64}$' -or + (Get-FileIdentity $path) -cne [string]$Record.Identity) { + throw 'owned file identity does not match' } Remove-Item -LiteralPath $path -Force -ErrorAction Stop if (Test-Path -LiteralPath $path) { throw 'owned file cleanup did not complete' } } -function Remove-OwnedRegistryKey($Record, [bool]$AllowProvisionalProductOwnership) { +function Remove-OwnedRegistryKey($Record) { if (!$Record.Owned) { return } $path = [string]$Record.Path $kind = [string]$Record.Kind @@ -249,17 +344,15 @@ function Remove-OwnedRegistryKey($Record, [bool]$AllowProvisionalProductOwnershi throw 'registry cleanup scope is invalid' } if (!(Test-Path -LiteralPath $path)) { return } - $provisional = $AllowProvisionalProductOwnership -and [bool]$Record.Provisional - if (!$provisional) { - if ($FixtureRoot) { - $token = Get-ItemPropertyValue -LiteralPath $path -Name $ownerRegistryValue -ErrorAction Stop - if ([string]$token -cne [string]$Record.Token) { throw 'owned registry token does not match' } - } elseif ([string]$Record.Identity -notmatch '^[a-f0-9]{64}$' -or - (Get-RegistryTreeIdentity $path) -cne [string]$Record.Identity) { - throw 'owned registry identity does not match' - } - } elseif (!(Test-ProvisionalRegistryIdentity $kind $path $script:authorizedApplication)) { - throw 'provisional registry identity does not match' + if ([bool]$Record.Provisional) { + throw 'provisional registry evidence cannot authorize manual cleanup' + } + if ($FixtureRoot) { + $token = Get-ItemPropertyValue -LiteralPath $path -Name $ownerRegistryValue -ErrorAction Stop + if ([string]$token -cne [string]$Record.Token) { throw 'owned registry token does not match' } + } elseif ([string]$Record.Identity -notmatch '^[a-f0-9]{64}$' -or + (Get-RegistryTreeIdentity $path) -cne [string]$Record.Identity) { + throw 'owned registry identity does not match' } Remove-Item -LiteralPath $path -Recurse -Force -ErrorAction Stop if (Test-Path -LiteralPath $path) { throw 'owned registry cleanup did not complete' } @@ -291,7 +384,11 @@ function Restore-OwnedRegistryValue($Record) { $baselineData = [string]$Record.BaselineValueData $matchesBaseline = $baselineValueExists -and $current.Exists -and $current.Kind -ceq $baselineKind -and $current.Data -ceq $baselineData - if ($current.Exists -and !$matchesBaseline -and !(Test-MsiInstalledValue $path $name)) { + if ([bool]$Record.Provisional -and $current.Exists -and !$matchesBaseline) { + throw 'provisional registry evidence cannot authorize manual cleanup' + } + if ($current.Exists -and !$matchesBaseline -and + !(Test-RegistryValueIdentity $Record $current)) { throw 'registry value ownership changed' } @@ -558,7 +655,7 @@ try { } } - $allowProvisionalProductOwnership = !$manifest.Fixture -and + $allowProvisionalMsiUninstall = !$manifest.Fixture -and [bool]$manifest.BaselineClean -and [bool]$manifest.InstallAttempted foreach ($record in @($manifest.RegistryKeys)) { if (!$record.Owned) { continue } @@ -585,7 +682,7 @@ try { throw 'registry manifest scope is invalid' } if (!(Test-Path -LiteralPath $path)) { continue } - if ($allowProvisionalProductOwnership -and [bool]$record.Provisional) { + if ($allowProvisionalMsiUninstall -and [bool]$record.Provisional) { if (!(Test-ProvisionalRegistryIdentity $kind $path $script:authorizedApplication)) { throw 'registry manifest provisional identity is invalid' } @@ -599,7 +696,8 @@ try { $recordKeys = @($record.PSObject.Properties | ForEach-Object { $_.Name }) $expectedRecordKeys = @( 'Kind','Path','Name','Owned','Provisional','BaselineKeyExisted', - 'BaselineValueExisted','BaselineValueKind','BaselineValueData','KeyCreatedByRun' + 'BaselineValueExisted','BaselineValueKind','BaselineValueData', + 'IdentityValueKind','IdentityValueData','KeyCreatedByRun' ) if ($recordKeys.Count -ne $expectedRecordKeys.Count -or @($expectedRecordKeys | Where-Object { $recordKeys -cnotcontains $_ }).Count -ne 0 -or @@ -649,6 +747,15 @@ try { $null -ne $record.BaselineValueData) { throw 'registry value empty baseline is invalid' } + if ($record.Owned -and !$record.Provisional) { + if ([string]$record.IdentityValueKind -notin @( + 'DWord','QWord','String','ExpandString','MultiString','Binary','None' + ) -or [string]$record.IdentityValueData -notmatch '^[A-Za-z0-9+/]*={0,2}$') { + throw 'registry value ownership identity is invalid' + } + } elseif ($null -ne $record.IdentityValueKind -or $null -ne $record.IdentityValueData) { + throw 'provisional registry value identity is invalid' + } } if (@($manifest.RegistryValues).Count -gt 1 -or (!$manifest.Fixture -and $manifest.InstallAttempted -and @@ -667,11 +774,13 @@ try { if ($matchesBaseline) { $skipMsiUninstall = $true } elseif ($current.Exists -and - !(Test-MsiInstalledValue ([string]$record.Path) ([string]$record.Name))) { + (([bool]$record.Provisional -and + !(Test-MsiInstalledValue ([string]$record.Path) ([string]$record.Name))) -or + (![bool]$record.Provisional -and !(Test-RegistryValueIdentity $record $current)))) { $cleanupFailed = $true } } - if ($allowProvisionalProductOwnership -and !$skipMsiUninstall -and !$cleanupFailed) { + if ($allowProvisionalMsiUninstall -and !$skipMsiUninstall -and !$cleanupFailed) { $msiExitCode = 1618 for ($attempt = 0; $attempt -lt 12 -and $msiExitCode -eq 1618; $attempt += 1) { if ($attempt -ne 0) { Start-Sleep -Seconds 2 } @@ -689,10 +798,10 @@ try { } foreach ($record in @($manifest.Files)) { - try { Remove-OwnedFile $record $allowProvisionalProductOwnership } catch { $cleanupFailed = $true } + try { Remove-OwnedFile $record } catch { $cleanupFailed = $true } } foreach ($record in @($manifest.RegistryKeys)) { - try { Remove-OwnedRegistryKey $record $allowProvisionalProductOwnership } catch { $cleanupFailed = $true } + try { Remove-OwnedRegistryKey $record } catch { $cleanupFailed = $true } } foreach ($record in @($manifest.RegistryValues)) { try { Restore-OwnedRegistryValue $record } catch { $cleanupFailed = $true } @@ -710,7 +819,7 @@ try { ([string]$_.Path).Length } -Descending foreach ($record in $directories) { - try { Remove-OwnedDirectory $record $allowProvisionalProductOwnership } catch { + try { Remove-OwnedDirectory $record } catch { $cleanupFailed = $true } } diff --git a/apps/desktop/scripts/run-installed-windows-app-harness.ps1 b/apps/desktop/scripts/run-installed-windows-app-harness.ps1 index df2a2d15e..9d2cc1244 100644 --- a/apps/desktop/scripts/run-installed-windows-app-harness.ps1 +++ b/apps/desktop/scripts/run-installed-windows-app-harness.ps1 @@ -645,7 +645,7 @@ try { try { if ([IO.File]::Exists($markerPath)) { [IO.File]::Delete($markerPath) } } catch {} - if ($null -ne $fixedCleanupResult -and !$workflowManagedManifest) { + if ($fixedCleanupResult -eq $true -and !$workflowManagedManifest) { foreach ($path in @($ownershipManifestPath, "$ownershipManifestPath.new")) { try { if ([IO.File]::Exists($path)) { [IO.File]::Delete($path) } } catch {} } diff --git a/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 b/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 index 943bf81e6..91eac67aa 100644 --- a/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 +++ b/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 @@ -2,7 +2,7 @@ param( [Parameter(Mandatory=$true)][string]$OwnershipManifest, [Parameter(Mandatory=$true)][string]$Installer, [Parameter(Mandatory=$true)][string]$ExpectedRunId, - [ValidateRange(1000,600000)][int]$CleanupTimeoutMilliseconds = 4 * 60 * 1000, + [ValidateRange(1,600000)][int]$CleanupTimeoutMilliseconds = 4 * 60 * 1000, [ValidateRange(1,30000)][int]$TerminationTimeoutMilliseconds = 30 * 1000, [string]$FixtureRoot ) @@ -109,6 +109,15 @@ public sealed class ProPRWorkflowCleanupJob : IDisposable public void Dispose() { if (handle != null) handle.Dispose(); } } + +public static class ProPRWorkflowCleanupOutputDrain +{ + public static void Attach(System.Diagnostics.Process process) + { + process.OutputDataReceived += delegate(object sender, System.Diagnostics.DataReceivedEventArgs args) { }; + process.ErrorDataReceived += delegate(object sender, System.Diagnostics.DataReceivedEventArgs args) { }; + } +} '@ function Write-FixedResult([ValidateSet('COMPLETE','FAILED','TIMED_OUT')][string]$Result) { @@ -169,8 +178,7 @@ try { $cleanupProcess = [Diagnostics.Process]::new() $cleanupProcess.StartInfo = $startInfo if (!$cleanupProcess.Start()) { throw 'workflow cleanup did not start' } - $cleanupProcess.add_OutputDataReceived({}) - $cleanupProcess.add_ErrorDataReceived({}) + [ProPRWorkflowCleanupOutputDrain]::Attach($cleanupProcess) $cleanupProcess.BeginOutputReadLine() $cleanupProcess.BeginErrorReadLine() try { @@ -206,7 +214,7 @@ try { if ($null -ne $cleanupJob) { $cleanupJob.Dispose() } if ($null -ne $cleanupProcess) { $cleanupProcess.Dispose() } if ($null -ne $cleanupReadyEvent) { $cleanupReadyEvent.Dispose() } - if ($validatedManifestPath) { + if ($fixedResult -ceq 'COMPLETE' -and $validatedManifestPath) { foreach ($path in @($validatedManifestPath, "$validatedManifestPath.new")) { try { if ([IO.File]::Exists($path)) { [IO.File]::Delete($path) } } catch {} } 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 325057eae..b6a609a8f 100644 --- a/apps/desktop/scripts/test-installed-windows-app-supervisor-fixture.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app-supervisor-fixture.ps1 @@ -19,6 +19,7 @@ if ($scenario -notin @( 'CANCELLATION', 'OWNED_RESOURCES_NORMAL_SUCCESS', 'OWNED_RESOURCES_FOR_INTERRUPTION', + 'OWNED_RESOURCES_REPLACED_THEN_DEADLINE', 'OWNED_RESOURCES_THEN_DEADLINE' )) { throw 'fixture scenario is invalid' @@ -85,6 +86,17 @@ function Write-FixtureOwnershipToken([string]$Path, [string]$Token) { } } +function Get-FixtureFileIdentity([string]$Path) { + $stream = [IO.File]::OpenRead($Path) + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + return [BitConverter]::ToString($sha256.ComputeHash($stream)).Replace('-', '').ToLowerInvariant() + } finally { + $sha256.Dispose() + $stream.Dispose() + } +} + function New-OwnedFixtureResources { $manifest = [IO.File]::ReadAllText($OwnershipManifest, [Text.Encoding]::UTF8) | ConvertFrom-Json -ErrorAction Stop @@ -140,7 +152,10 @@ function New-OwnedFixtureResources { } $manifest.Directories = @($ownedDirectories) + @($conflictingDirectories) $manifest.Files = @( - [ordered]@{ Kind = 'SHORTCUT_FILE'; Path = $shortcut; Owned = $true; Token = $token } + [ordered]@{ + Kind = 'SHORTCUT_FILE'; Path = $shortcut; Owned = $true; Token = $token + Identity = (Get-FixtureFileIdentity $shortcut); Provisional = $false + } ) if ($env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_SHORTCUT) { $manifest.Files += [ordered]@{ @@ -226,11 +241,36 @@ function New-OwnedFixtureResources { UserName = $userName UserSid = $userSid ProfilePath = [string]$profiles[0].LocalPath + ManifestPath = $OwnershipManifest + RunId = [string]$manifest.RunId + Token = $token } $resourceState | ConvertTo-Json -Compress | Set-Content -LiteralPath ` (Join-Path $stateDirectory 'resources.json') -Encoding ASCII } +function Replace-FixtureOwnedResources { + $state = Get-Content -LiteralPath (Join-Path $stateDirectory 'resources.json') ` + -Raw -Encoding ASCII | ConvertFrom-Json -ErrorAction Stop + foreach ($directory in @($state.OwnedRoot, $state.ShortcutFolder)) { + [IO.File]::WriteAllText( + (Join-Path $directory '.propr-installed-app-owner'), + 'foreign-owner', + [Text.Encoding]::ASCII + ) + } + Remove-Item -LiteralPath $state.InstallRoot -Recurse -Force -ErrorAction Stop + [void](New-Item -ItemType Directory -Path $state.InstallRoot -ErrorAction Stop) + [IO.File]::WriteAllText( + (Join-Path $state.InstallRoot 'foreign.txt'), + 'foreign-install-tree', + [Text.Encoding]::ASCII + ) + [IO.File]::WriteAllText($state.Shortcut, 'foreign-shortcut', [Text.Encoding]::ASCII) + Set-ItemProperty -LiteralPath $state.RegistryPath ` + -Name 'ProPRInstalledAppOwner' -Value 'foreign-owner' +} + function Start-FixtureDescendant { $hostPath = (Get-Process -Id $PID -ErrorAction Stop).Path $startInfo = [Diagnostics.ProcessStartInfo]::new() @@ -260,8 +300,24 @@ try { $descendant = Start-FixtureDescendant $state = [ordered]@{ WorkerPid = $PID; DescendantPid = $descendant.Id } -$state | ConvertTo-Json -Compress | Set-Content -LiteralPath ` - (Join-Path $stateDirectory 'processes.json') -Encoding ASCII +$processStatePath = Join-Path $stateDirectory 'processes.json' +$processStateTemporaryPath = "$processStatePath.$PID.new" +$processStateBytes = [Text.Encoding]::ASCII.GetBytes(($state | ConvertTo-Json -Compress)) +$processStateStream = [IO.FileStream]::new( + $processStateTemporaryPath, + [IO.FileMode]::CreateNew, + [IO.FileAccess]::Write, + [IO.FileShare]::Read, + 4096, + [IO.FileOptions]::WriteThrough +) +try { + $processStateStream.Write($processStateBytes, 0, $processStateBytes.Length) + $processStateStream.Flush($true) +} finally { + $processStateStream.Dispose() +} +[IO.File]::Move($processStateTemporaryPath, $processStatePath) switch ($scenario) { 'NO_MARKER' { @@ -325,6 +381,14 @@ switch ($scenario) { [DateTime]::UtcNow.AddSeconds(60).Ticks) Start-Sleep -Seconds 300 } + 'OWNED_RESOURCES_REPLACED_THEN_DEADLINE' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + New-OwnedFixtureResources + Replace-FixtureOwnedResources + Write-FixtureMarker ('{0}|CLEANUP|SMOKE_DATA_REMOVE|BEGIN' -f ` + [DateTime]::UtcNow.AddMilliseconds(500).Ticks) + Start-Sleep -Seconds 300 + } 'OWNED_RESOURCES_NORMAL_SUCCESS' { Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) New-OwnedFixtureResources diff --git a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 index cc0f957af..1fb7487dd 100644 --- a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 @@ -59,7 +59,7 @@ function New-SupervisorStartInfo( '-File', $supervisorPath, '-Installer', $dummyInstaller, '-Architecture', $Architecture, - '-BootstrapTimeoutMilliseconds', $(if ($UseProductionWorker) { '10000' } else { '2000' }), + '-BootstrapTimeoutMilliseconds', '10000', '-WatchdogPollMilliseconds', '25', '-WatchdogTerminationMilliseconds', '3000', '-PostTerminationCleanupMilliseconds', '30000', @@ -111,7 +111,7 @@ function Read-FixtureProcessState([string]$StateDirectory) { $statePath = Join-Path $StateDirectory 'processes.json' $stopwatch = [Diagnostics.Stopwatch]::StartNew() while (!(Test-Path -LiteralPath $statePath -PathType Leaf)) { - if ($stopwatch.ElapsedMilliseconds -ge 5000) { + if ($stopwatch.ElapsedMilliseconds -ge 15000) { throw 'fixture did not publish process state' } Start-Sleep -Milliseconds 25 @@ -162,10 +162,47 @@ function Assert-OwnedResourcesGone($Owned) { 'external cleanup left the run-owned profile behind' } +function Restore-ReplacedFixtureAuthority($Owned) { + [IO.File]::WriteAllText( + (Join-Path $Owned.OwnedRoot '.propr-installed-app-owner'), + [string]$Owned.Token, + [Text.Encoding]::ASCII + ) + if (Test-Path -LiteralPath $Owned.InstallRoot) { + Remove-Item -LiteralPath $Owned.InstallRoot -Recurse -Force -ErrorAction Stop + } + [void](New-Item -ItemType Directory -Path $Owned.InstallRoot -ErrorAction Stop) + [IO.File]::WriteAllText( + (Join-Path $Owned.InstallRoot '.propr-installed-app-owner'), + [string]$Owned.Token, + [Text.Encoding]::ASCII + ) + [IO.File]::WriteAllText( + (Join-Path $Owned.ShortcutFolder '.propr-installed-app-owner'), + [string]$Owned.Token, + [Text.Encoding]::ASCII + ) + [IO.File]::WriteAllText($Owned.Shortcut, 'owned-shortcut', [Text.Encoding]::ASCII) + Set-ItemProperty -LiteralPath $Owned.RegistryPath ` + -Name 'ProPRInstalledAppOwner' -Value ([string]$Owned.Token) +} + +function Assert-ReplacedFixtureResourcesSurvive($Owned) { + Assert-True ((Get-Content -LiteralPath (Join-Path $Owned.InstallRoot 'foreign.txt') -Raw).Trim() ` + -ceq 'foreign-install-tree') ` + 'replacement install tree was removed or changed' + Assert-True ((Get-Content -LiteralPath $Owned.Shortcut -Raw).Trim() -ceq 'foreign-shortcut') ` + 'replacement shortcut was removed or changed' + Assert-True ((Get-ItemPropertyValue -LiteralPath $Owned.RegistryPath ` + -Name 'ProPRInstalledAppOwner') -ceq 'foreign-owner') ` + 'replacement registry authority was removed or changed' +} + function Invoke-WorkflowCleanupController( [string]$ManifestPath, [string]$RunId, - [string]$FixtureRoot + [string]$FixtureRoot, + [int]$CleanupTimeoutMilliseconds = 30000 ) { $startInfo = [Diagnostics.ProcessStartInfo]::new() $startInfo.FileName = $hostPath @@ -177,7 +214,7 @@ function Invoke-WorkflowCleanupController( '-OwnershipManifest', $ManifestPath, '-Installer', $dummyInstaller, '-ExpectedRunId', $RunId, - '-CleanupTimeoutMilliseconds', '30000', + '-CleanupTimeoutMilliseconds', [string]$CleanupTimeoutMilliseconds, '-TerminationTimeoutMilliseconds', '3000' )) { $startInfo.ArgumentList.Add($argument) @@ -239,7 +276,7 @@ $env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_SHORTCUT = $ConflictShortcut $env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_REGISTRY = $ConflictRegistry & $SupervisorPath -Installer $Installer -Architecture $Architecture ` -WorkerPath $FixtureWorker -FixtureCleanupRoot $StateDirectory ` - -BootstrapTimeoutMilliseconds 2000 -WatchdogPollMilliseconds 25 ` + -BootstrapTimeoutMilliseconds 10000 -WatchdogPollMilliseconds 25 ` -WatchdogTerminationMilliseconds 3000 -PostTerminationCleanupMilliseconds 30000 ` -MarkerReadTimeoutMilliseconds 200 '@ @@ -280,7 +317,9 @@ function Invoke-FixtureScenario([string]$Scenario, [string]$ExistingStateDirecto $stopwatch = [Diagnostics.Stopwatch]::StartNew() if (!$process.Start()) { throw 'supervisor test process did not start' } try { - $completionBound = if ($Scenario -eq 'OWNED_RESOURCES_THEN_DEADLINE') { 90000 } else { 10000 } + $completionBound = if ($Scenario -in @( + 'OWNED_RESOURCES_THEN_DEADLINE','OWNED_RESOURCES_REPLACED_THEN_DEADLINE' + )) { 90000 } else { 20000 } if (!$process.WaitForExit($completionBound)) { try { $process.Kill($true) } catch {} throw 'supervisor exceeded the executable test completion bound' @@ -305,8 +344,8 @@ function Invoke-FixtureScenario([string]$Scenario, [string]$ExistingStateDirecto function Test-BootstrapTimeout { $result = Invoke-FixtureScenario 'NO_MARKER' Assert-True ($result.ExitCode -eq 124) 'missing-marker bootstrap did not fail with the watchdog code' - Assert-True ($result.ElapsedMilliseconds -ge 1800) 'bootstrap timeout ignored the injected deadline' - Assert-True ($result.ElapsedMilliseconds -lt 10000) 'missing-marker bootstrap completion was not bounded' + Assert-True ($result.ElapsedMilliseconds -ge 9000) 'bootstrap timeout ignored the injected deadline' + Assert-True ($result.ElapsedMilliseconds -lt 20000) 'missing-marker bootstrap completion was not bounded' Assert-Contains $result.Output ` 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:BOOTSTRAP:TIMED_OUT' ` 'missing-marker bootstrap did not emit the fixed timeout line' @@ -597,6 +636,28 @@ function Test-PreExistingCleanupOwnership { Assert-True ($ownedProfiles.Count -eq 0) ` 'post-termination cleanup left the run-owned profile behind' + $replacementStateDirectory = New-StateDirectory 'replacement-collision' + $replacementResult = Invoke-FixtureScenario ` + 'OWNED_RESOURCES_REPLACED_THEN_DEADLINE' $replacementStateDirectory + Assert-True ($replacementResult.ExitCode -eq 125) ` + 'replacement collision did not fail the standalone cleanup' + Assert-Contains $replacementResult.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:FAILED' ` + 'replacement collision did not emit fixed cleanup failure evidence' + $replacementOwned = Read-FixtureResourceState $replacementStateDirectory + Assert-ReplacedFixtureResourcesSurvive $replacementOwned + Assert-True (Test-Path -LiteralPath $replacementOwned.ManifestPath -PathType Leaf) ` + 'false standalone cleanup result discarded authenticated recovery authority' + Restore-ReplacedFixtureAuthority $replacementOwned + $replacementRetry = Invoke-WorkflowCleanupController ` + $replacementOwned.ManifestPath $replacementOwned.RunId $replacementStateDirectory + Assert-True ($replacementRetry.ExitCode -eq 0 -and + $replacementRetry.Result -ceq 'COMPLETE') ` + 'standalone cleanup did not retry to exact success after authority restoration' + Assert-OwnedResourcesGone $replacementOwned + Assert-True (!(Test-Path -LiteralPath $replacementOwned.ManifestPath)) ` + 'successful standalone cleanup retry did not consume recovery authority' + Assert-True ((Get-Content -LiteralPath (Join-Path $conflictInstallRoot 'pre-existing.txt') -Raw).Trim() -ceq ` 'owned-before-run') 'pre-existing install tree was removed or changed' Assert-True ((Get-ItemPropertyValue -LiteralPath $conflictRegistryPath -Name 'PreExisting') -ceq ` @@ -643,12 +704,38 @@ function Test-PreExistingCleanupOwnership { Assert-ProcessTreeGone $workflowProcessState Assert-True (Test-Path -LiteralPath $workflowManifest -PathType Leaf) ` 'killed supervisor did not preserve the durable ownership manifest' + $timedOutCleanup = Invoke-WorkflowCleanupController ` + $workflowManifest $workflowRunId $workflowStateDirectory 1 + Assert-True ($timedOutCleanup.ExitCode -eq 124 -and + $timedOutCleanup.ReportedExitCode -eq 124 -and + $timedOutCleanup.Result -ceq 'TIMED_OUT') ` + 'workflow cleanup did not report its injected fixed timeout' + Assert-True (Test-Path -LiteralPath $workflowManifest -PathType Leaf) ` + 'timed-out workflow cleanup discarded authenticated recovery authority' + + Set-ItemProperty -LiteralPath $workflowOwned.RegistryPath ` + -Name 'ProPRInstalledAppOwner' -Value 'foreign-owner' + $failedWorkflowCleanup = Invoke-WorkflowCleanupController ` + $workflowManifest $workflowRunId $workflowStateDirectory + Assert-True ($failedWorkflowCleanup.ExitCode -eq 21 -and + $failedWorkflowCleanup.ReportedExitCode -eq 21 -and + $failedWorkflowCleanup.Result -ceq 'FAILED' -and + $failedWorkflowCleanup.ControllerStatus -ceq 'OWNED_RESOURCE_CLEANUP_FAILURE') ` + 'workflow cleanup did not report a fixed replacement-collision failure' + Assert-True ((Get-ItemPropertyValue -LiteralPath $workflowOwned.RegistryPath ` + -Name 'ProPRInstalledAppOwner') -ceq 'foreign-owner') ` + 'workflow cleanup removed a replacement registry object' + Assert-True (Test-Path -LiteralPath $workflowManifest -PathType Leaf) ` + 'failed workflow cleanup discarded authenticated recovery authority' + + Set-ItemProperty -LiteralPath $workflowOwned.RegistryPath ` + -Name 'ProPRInstalledAppOwner' -Value ([string]$workflowOwned.Token) $workflowCleanup = Invoke-WorkflowCleanupController ` $workflowManifest $workflowRunId $workflowStateDirectory Assert-True ($workflowCleanup.ExitCode -eq 0 -and $workflowCleanup.ReportedExitCode -eq 0 -and $workflowCleanup.ControllerStatus -ceq 'EMPTY_OR_CLEANED') ` - 'workflow cleanup controller did not report fixed cleanup success' + 'workflow cleanup controller did not retry to fixed cleanup success' Assert-Contains $workflowCleanup.Output ` 'PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:COMPLETE' ` 'workflow cleanup controller did not emit fixed completion evidence' @@ -739,6 +826,11 @@ function Test-PreExistingCleanupOwnership { Assert-Contains $failedCleanup.Output ` 'PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:FAILED' ` "$manifestCase workflow manifest did not emit fixed failure evidence" + if ($manifestCase -ne 'MISSING') { + Assert-True (Test-Path -LiteralPath $badManifest -PathType Leaf) ` + "$manifestCase workflow failure discarded authenticated recovery authority" + Remove-Item -LiteralPath $badManifest -Force -ErrorAction Stop + } } Assert-True ((Get-Content -LiteralPath (Join-Path $conflictInstallRoot 'pre-existing.txt') -Raw).Trim() -ceq ` @@ -855,7 +947,8 @@ function Test-PreExistingAppPathsAuthority { Path = 'Registry::HKEY_CURRENT_USER\Software\ProPR\Desktop' Name = 'installed'; Owned = $false; Provisional = $false BaselineKeyExisted = $false; BaselineValueExisted = $false - BaselineValueKind = $null; BaselineValueData = $null; KeyCreatedByRun = $false + BaselineValueKind = $null; BaselineValueData = $null + IdentityValueKind = $null; IdentityValueData = $null; KeyCreatedByRun = $false }) RegistryKeys = @( [ordered]@{ @@ -919,12 +1012,15 @@ function Test-HkcuInstalledValueOwnership { [bool]$BaselineValueExisted, [AllowNull()][string]$BaselineKind, [AllowNull()][string]$BaselineData, - [bool]$KeyCreatedByRun + [bool]$KeyCreatedByRun, + [bool]$Provisional = $false ) { $runId = [Guid]::NewGuid().ToString('N') $path = Join-Path ([IO.Path]::GetTempPath()) ` "propr-installed-app-ownership-$runId.json" $createdTicks = [DateTime]::UtcNow.Ticks + $installedIdentityData = [Convert]::ToBase64String( + [BitConverter]::GetBytes([int32]1)) $manifest = [ordered]@{ SchemaVersion = 2 ManifestType = 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP' @@ -942,11 +1038,13 @@ function Test-HkcuInstalledValueOwnership { RegistryKeys = @() RegistryValues = @([ordered]@{ Kind = 'HKCU_INSTALLED'; Path = $desktopKey; Name = $installedName - Owned = $true; Provisional = $false + Owned = $true; Provisional = $Provisional BaselineKeyExisted = $BaselineKeyExisted BaselineValueExisted = $BaselineValueExisted BaselineValueKind = $BaselineKind BaselineValueData = $BaselineData + IdentityValueKind = if ($Provisional) { $null } else { 'DWord' } + IdentityValueData = if ($Provisional) { $null } else { $installedIdentityData } KeyCreatedByRun = $KeyCreatedByRun }) Users = @() @@ -1019,6 +1117,27 @@ function Test-HkcuInstalledValueOwnership { $conflictingKey = Get-Item -LiteralPath $desktopKey -ErrorAction Stop Assert-True ([string]$conflictingKey.GetValue($installedName) -ceq 'foreign-conflict') ` 'conflicting HKCU installed value was removed or changed' + Assert-True (Test-Path -LiteralPath $conflictManifest.Path -PathType Leaf) ` + 'conflicting HKCU cleanup discarded authenticated recovery authority' + Remove-Item -LiteralPath $conflictManifest.Path -Force -ErrorAction Stop + + Remove-Item -LiteralPath $desktopKey -Recurse -Force -ErrorAction Stop + [void](New-Item -Path $desktopKey -Force -ErrorAction Stop) + (Get-Item -LiteralPath $desktopKey).SetValue( + $installedName, [int]1, [Microsoft.Win32.RegistryValueKind]::DWord) + $provisionalManifest = New-HkcuManifest $false $false $null $null $true $true + $provisional = Invoke-WorkflowCleanupController ` + $provisionalManifest.Path $provisionalManifest.RunId '' + Assert-True ($provisional.ExitCode -eq 21 -and + $provisional.ControllerStatus -ceq 'OWNED_RESOURCE_CLEANUP_FAILURE') ` + 'provisional HKCU evidence authorized manual registry deletion' + Assert-True ((Get-Item -LiteralPath $desktopKey).GetValueKind($installedName).ToString() ` + -ceq 'DWord' -and + [int](Get-ItemPropertyValue -LiteralPath $desktopKey -Name $installedName) -eq 1) ` + 'provisional HKCU installed value was removed or changed' + Assert-True (Test-Path -LiteralPath $provisionalManifest.Path -PathType Leaf) ` + 'provisional HKCU failure discarded authenticated recovery authority' + Remove-Item -LiteralPath $provisionalManifest.Path -Force -ErrorAction Stop } finally { if (Test-Path -LiteralPath $desktopKey) { Remove-Item -LiteralPath $desktopKey -Recurse -Force -ErrorAction SilentlyContinue diff --git a/apps/desktop/scripts/test-installed-windows-app.ps1 b/apps/desktop/scripts/test-installed-windows-app.ps1 index 19ad2d1b2..7ce89f0e4 100644 --- a/apps/desktop/scripts/test-installed-windows-app.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app.ps1 @@ -91,6 +91,7 @@ $password = ConvertTo-SecureString $passwordText -AsPlainText -Force $passwordText = $null $credential = New-Object Management.Automation.PSCredential("$env:COMPUTERNAME\$testUser", $password) $installAttempted = $false +$msiInstallCompleted = $false $testUserCreatedByRun = $false $testUserSid = $null $smokeUserDataDirectory = $null @@ -106,6 +107,11 @@ $protocolCreatedByRun = $false $appPathsCreatedByRun = $false $protocolOwnedIdentity = $null $appPathsOwnedIdentity = $null +$installRootOwnedIdentity = $null +$shortcutFolderOwnedIdentity = $null +$hkcuInstalledOwnedKind = $null +$hkcuInstalledOwnedData = $null +$shortcutOwnedIdentity = $null $hkcuDesktopKeyCreatedByRun = $false $msiTimeoutMilliseconds = 10 * 60 * 1000 $applicationTimeoutMilliseconds = 5 * 60 * 1000 @@ -281,6 +287,84 @@ function Write-DurableOwnershipToken([string]$Path, [string]$Token) { } } +Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +public static class ProPRDirectoryIdentity +{ + [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", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFile( + string path, uint access, uint share, IntPtr security, uint creation, + uint flags, IntPtr template); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetFileInformationByHandle( + SafeFileHandle handle, out BY_HANDLE_FILE_INFORMATION information); + + public static string Read(string path) + { + using (SafeFileHandle handle = CreateFile( + path, 0x80, 0x7, IntPtr.Zero, 3, 0x02000000, IntPtr.Zero)) + { + if (handle == null || handle.IsInvalid) + throw new Win32Exception(Marshal.GetLastWin32Error(), "directory identity open failed"); + BY_HANDLE_FILE_INFORMATION information; + if (!GetFileInformationByHandle(handle, out information)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "directory identity read failed"); + return string.Format("{0:x8}{1:x8}{2:x8}", information.VolumeSerialNumber, + information.FileIndexHigh, information.FileIndexLow); + } + } +} +'@ + +function Get-FileIdentity([string]$Path) { + if (!(Test-Path -LiteralPath $Path -PathType Leaf)) { return $null } + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + $item.Length -gt $shortcutFileByteCap) { + return $null + } + $stream = [IO.File]::Open( + $Path, + [IO.FileMode]::Open, + [IO.FileAccess]::Read, + [IO.FileShare]::Read + ) + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + return [BitConverter]::ToString($sha256.ComputeHash($stream)).Replace('-', '').ToLowerInvariant() + } finally { + $sha256.Dispose() + $stream.Dispose() + } +} + +function Get-DirectoryIdentity([string]$Path) { + if (!(Test-Path -LiteralPath $Path -PathType Container)) { return $null } + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { return $null } + return [ProPRDirectoryIdentity]::Read($item.FullName) +} + function Get-RegistryTreeIdentity([string]$Path) { if (!(Test-Path -LiteralPath $Path)) { return $null } $root = Get-Item -LiteralPath $Path -ErrorAction Stop @@ -379,8 +463,10 @@ function Restore-HkcuInstalledBaseline { $matchesBaseline = $hkcuInstalledValueExistedBeforeInstall -and $current.Exists -and $current.Kind -ceq $hkcuInstalledBaselineKind -and $current.Data -ceq $hkcuInstalledBaselineData - if ($current.Exists -and !$matchesBaseline -and - !(Test-MsiInstalledValue $hkcuDesktopRegistryPath $hkcuInstalledValueName)) { + $matchesOwnedIdentity = $current.Exists -and $hkcuInstalledOwnedKind -and + $hkcuInstalledOwnedData -and $current.Kind -ceq $hkcuInstalledOwnedKind -and + $current.Data -ceq $hkcuInstalledOwnedData + if ($current.Exists -and !$matchesBaseline -and !$matchesOwnedIdentity) { throw 'refusing to replace a conflicting current-user installed value' } @@ -436,6 +522,8 @@ $ownershipState.RegistryValues = @([ordered]@{ BaselineValueExisted = $hkcuInstalledValueExistedBeforeInstall BaselineValueKind = $hkcuInstalledBaselineKind BaselineValueData = $hkcuInstalledBaselineData + IdentityValueKind = $null + IdentityValueData = $null KeyCreatedByRun = $false }) @@ -1193,19 +1281,21 @@ try { try { $installAttempted = $true $ownershipState.InstallAttempted = $true - # The clean baseline plus the durable install-attempt transition owns any - # canonical product resource that appears before MSI returns or hangs. + # The clean baseline plus install-attempt transition is only provisional + # evidence for a bounded MSI uninstall until exact ownership is captured. $ownershipState.Directories = @( [ordered]@{ - Kind = 'INSTALL_ROOT'; Path = $installRoot; Owned = $true; Token = $null + Kind = 'INSTALL_ROOT'; Path = $installRoot; Owned = $true + Token = $null; Identity = $null; Provisional = $true }, [ordered]@{ Kind = 'SHORTCUT_FOLDER'; Path = $startMenuShortcutFolder - Owned = $true; Token = $null + Owned = $true; Token = $null; Identity = $null; Provisional = $true } ) $ownershipState.Files = @([ordered]@{ - Kind = 'SHORTCUT_FILE'; Path = $startMenuShortcut; Owned = $true; Token = $null + Kind = 'SHORTCUT_FILE'; Path = $startMenuShortcut; Owned = $true + Token = $null; Identity = $null; Provisional = $true }) $ownershipState.RegistryKeys = @( [ordered]@{ @@ -1227,6 +1317,8 @@ try { BaselineValueExisted = $hkcuInstalledValueExistedBeforeInstall BaselineValueKind = $hkcuInstalledBaselineKind BaselineValueData = $hkcuInstalledBaselineData + IdentityValueKind = $null + IdentityValueData = $null KeyCreatedByRun = $false }) Write-OwnershipManifest @@ -1237,6 +1329,7 @@ try { -TimeoutMilliseconds ($msiTimeoutMilliseconds + $terminationTimeoutMilliseconds + 5000) ` -Operation { Invoke-Msi @('/i', "`"$installerPath`"", '/qn', '/norestart') 'machine install' + $script:msiInstallCompleted = $true } } finally { Invoke-BoundedExternalOperation ` @@ -1244,6 +1337,7 @@ try { -Substage 'OWNERSHIP_CAPTURE' ` -TimeoutMilliseconds $externalOperationTimeoutMilliseconds ` -Operation { + if (!$script:msiInstallCompleted) { return } $script:installRootCreatedByRun = !$installRootExistedBeforeInstall -and (Test-Path -LiteralPath $installRoot) $script:protocolCreatedByRun = @@ -1261,20 +1355,37 @@ try { (Test-Path -LiteralPath $startMenuShortcutFolder) $ownedDirectories = @() if ($script:installRootCreatedByRun) { + $script:installRootOwnedIdentity = Get-DirectoryIdentity $installRoot + if (!$script:installRootOwnedIdentity) { + throw 'installed tree identity could not be captured' + } $ownedDirectories += [ordered]@{ - Kind = 'INSTALL_ROOT'; Path = $installRoot; Owned = $true; Token = $null + Kind = 'INSTALL_ROOT'; Path = $installRoot; Owned = $true + Token = $null; Identity = $script:installRootOwnedIdentity + Provisional = $false } } if ($script:startMenuShortcutFolderCreatedByRun) { + $script:shortcutFolderOwnedIdentity = Get-DirectoryIdentity $startMenuShortcutFolder + if (!$script:shortcutFolderOwnedIdentity) { + throw 'installed shortcut folder identity could not be captured' + } $ownedDirectories += [ordered]@{ Kind = 'SHORTCUT_FOLDER'; Path = $startMenuShortcutFolder - Owned = $true; Token = $null + Owned = $true; Token = $null; Identity = $script:shortcutFolderOwnedIdentity + Provisional = $false } } $ownershipState.Directories = $ownedDirectories $ownershipState.Files = if ($script:startMenuShortcutCreatedByRun) { + $script:shortcutOwnedIdentity = Get-FileIdentity $startMenuShortcut + if (!$script:shortcutOwnedIdentity) { + throw 'installed shortcut identity could not be captured' + } @([ordered]@{ - Kind = 'SHORTCUT_FILE'; Path = $startMenuShortcut; Owned = $true; Token = $null + Kind = 'SHORTCUT_FILE'; Path = $startMenuShortcut; Owned = $true + Token = $null; Identity = $script:shortcutOwnedIdentity + Provisional = $false }) } else { @() } $ownedRegistryKeys = @() @@ -1295,6 +1406,13 @@ try { } } $ownershipState.RegistryKeys = $ownedRegistryKeys + $ownedHkcuInstalled = Get-RegistryValueSnapshot ` + $hkcuDesktopRegistryPath $hkcuInstalledValueName + if (!$ownedHkcuInstalled.Exists) { + throw 'installed current-user value identity could not be captured' + } + $script:hkcuInstalledOwnedKind = $ownedHkcuInstalled.Kind + $script:hkcuInstalledOwnedData = $ownedHkcuInstalled.Data $ownershipState.RegistryValues = @([ordered]@{ Kind = 'HKCU_INSTALLED' Path = $hkcuDesktopRegistryPath @@ -1305,6 +1423,8 @@ try { BaselineValueExisted = $hkcuInstalledValueExistedBeforeInstall BaselineValueKind = $hkcuInstalledBaselineKind BaselineValueData = $hkcuInstalledBaselineData + IdentityValueKind = $script:hkcuInstalledOwnedKind + IdentityValueData = $script:hkcuInstalledOwnedData KeyCreatedByRun = $script:hkcuDesktopKeyCreatedByRun }) Write-OwnershipManifest @@ -1757,6 +1877,10 @@ try { ($ownedInstallRoot.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'refusing to remove an invalid owned install tree' } + if (!$installRootOwnedIdentity -or + (Get-DirectoryIdentity $installRoot) -cne $installRootOwnedIdentity) { + throw 'refusing to remove an install tree with a mismatched ownership identity' + } Remove-Item -LiteralPath $installRoot -Recurse -Force -ErrorAction Stop } } @@ -1820,6 +1944,10 @@ try { Invoke-BoundedExternalOperation ` 'CLEANUP' 'SHORTCUT_FALLBACK' $externalOperationTimeoutMilliseconds { if ($startMenuShortcutCreatedByRun -and (Test-Path -LiteralPath $startMenuShortcut)) { + if (!$shortcutOwnedIdentity -or + (Get-FileIdentity $startMenuShortcut) -cne $shortcutOwnedIdentity) { + throw 'refusing to remove a shortcut with a mismatched ownership identity' + } Remove-Item -LiteralPath $startMenuShortcut -Force -ErrorAction Stop } if ($startMenuShortcutFolderCreatedByRun -and @@ -1830,12 +1958,11 @@ try { ($ownedShortcutFolder.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'owned common Start Menu folder is invalid' } - $ownedShortcutFolderContents = @( - Get-ChildItem -LiteralPath $startMenuShortcutFolder -Force -ErrorAction Stop - ) - if ($ownedShortcutFolderContents.Count -eq 0) { - Remove-Item -LiteralPath $startMenuShortcutFolder -Force -ErrorAction Stop + if (!$shortcutFolderOwnedIdentity -or + (Get-DirectoryIdentity $startMenuShortcutFolder) -cne $shortcutFolderOwnedIdentity) { + throw 'refusing to remove a shortcut folder with a mismatched ownership identity' } + Remove-Item -LiteralPath $startMenuShortcutFolder -Recurse -Force -ErrorAction Stop } } } catch { diff --git a/apps/desktop/src/release-workflow.test.ts b/apps/desktop/src/release-workflow.test.ts index 57b2553b2..4ad474d49 100644 --- a/apps/desktop/src/release-workflow.test.ts +++ b/apps/desktop/src/release-workflow.test.ts @@ -624,6 +624,21 @@ describe('desktop trusted release workflow', () => { assert.match(installedWindowsAppCleanup, /Restore-OwnedRegistryValue/); assert.match(installedWindowsAppCleanup, /Write-EmptyOwnershipReceipt/); assert.match(installedWindowsAppCleanup, /Get-RegistryTreeIdentity/); + assert.match(installedWindowsAppCleanup, /Get-FileIdentity/); + assert.match(installedWindowsAppCleanup, /Get-DirectoryIdentity/); + assert.doesNotMatch(installedWindowsAppCleanup, /AllowProvisionalProductOwnership/); + assert.match( + installedWindowsAppCleanup, + /\$allowProvisionalMsiUninstall[\s\S]*Start-Process msiexec\.exe/, + ); + assert.match( + installedWindowsAppCleanup, + /provisional registry evidence cannot authorize manual cleanup/, + ); + assert.match( + installedWindowsAppTest, + /if \(!\$script:msiInstallCompleted\) \{ return \}[\s\S]*Get-DirectoryIdentity \$installRoot/, + ); assert.match( installedWindowsAppTest, /Registry::HKEY_LOCAL_MACHINE\\Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\propr-desktop\.exe/, @@ -645,6 +660,28 @@ describe('desktop trusted release workflow', () => { assert.match(installedWindowsAppWorkflowCleanup, /ProPRWorkflowCleanupJob/); assert.match(installedWindowsAppWorkflowCleanup, /MANIFEST_VALIDATION_FAILURE/); assert.match(installedWindowsAppWorkflowCleanup, /OWNED_RESOURCE_CLEANUP_FAILURE/); + assert.match(installedWindowsAppWorkflowCleanup, /ProPRWorkflowCleanupOutputDrain/); + assert.doesNotMatch( + installedWindowsAppWorkflowCleanup, + /add_(?:Output|Error)DataReceived\(\{\}\)/, + ); + assert.match( + installedWindowsAppWorkflowCleanup, + /if \(\$fixedResult -ceq 'COMPLETE' -and \$validatedManifestPath\)/, + ); + assert.match( + installedWindowsAppSupervisor, + /if \(\$fixedCleanupResult -eq \$true -and !\$workflowManagedManifest\)/, + ); + assert.match(installedWindowsAppSupervisorBehaviorTest, /OWNED_RESOURCES_REPLACED_THEN_DEADLINE/); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /replacement install tree was removed or changed/, + ); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /timed-out workflow cleanup discarded authenticated recovery authority[\s\S]*failed workflow cleanup discarded authenticated recovery authority[\s\S]*retry to fixed cleanup success/, + ); assert.ok( installedWindowsAppWorkflowCleanup.indexOf('Write-FixedResult $fixedResult') < installedWindowsAppWorkflowCleanup.indexOf( @@ -799,9 +836,9 @@ describe('desktop trusted release workflow', () => { }); test('uses bounded network logon impersonation with secure native credential cleanup', () => { - const nativeLogon = installedWindowsAppTest.match( - /Add-Type -TypeDefinition @'\n([\s\S]*?)\n'@/, - ); + const nativeLogon = [...installedWindowsAppTest.matchAll( + /Add-Type -TypeDefinition @'\n([\s\S]*?)\n'@/g, + )].find((match) => match[1].includes('public static class ProPRWindowsLogon')); assert.ok(nativeLogon); assert.match(nativeLogon[1], /using Microsoft\.Win32\.SafeHandles;/); assert.match(nativeLogon[1], /public const int LOGON32_LOGON_NETWORK = 3;/); @@ -1010,7 +1047,7 @@ describe('desktop trusted release workflow', () => { ); }); - test('keeps the canonical common shortcut and ownership-aware nonrecursive cleanup', () => { + test('keeps the canonical common shortcut and exact-identity cleanup', () => { const probeStart = installedWindowsAppTest.indexOf('function Test-StartMenuShortcutAsOrdinaryUser('); const probeEnd = installedWindowsAppTest.indexOf('function New-SmokeUserDataDirectory(', probeStart); assert.ok(probeStart >= 0 && probeEnd > probeStart); @@ -1044,19 +1081,15 @@ describe('desktop trusted release workflow', () => { const cleanup = installedWindowsAppTest.slice(cleanupStart); assert.match( cleanup, - /if \(\$startMenuShortcutCreatedByRun -and \(Test-Path -LiteralPath \$startMenuShortcut\)\) \{\n\s+Remove-Item -LiteralPath \$startMenuShortcut -Force -ErrorAction Stop/, + /if \(\$startMenuShortcutCreatedByRun -and \(Test-Path -LiteralPath \$startMenuShortcut\)\)[\s\S]*Get-FileIdentity \$startMenuShortcut[\s\S]*Remove-Item -LiteralPath \$startMenuShortcut -Force -ErrorAction Stop/, ); assert.match( cleanup, - /if \(\$startMenuShortcutFolderCreatedByRun[\s\S]*\$ownedShortcutFolderContents\.Count -eq 0\) \{\n\s+Remove-Item -LiteralPath \$startMenuShortcutFolder -Force -ErrorAction Stop/, - ); - assert.doesNotMatch( - cleanup, - /Remove-Item -LiteralPath \$startMenuShortcut(?:Folder)?[^\n]*-Recurse/, + /if \(\$startMenuShortcutFolderCreatedByRun[\s\S]*Get-DirectoryIdentity \$startMenuShortcutFolder[\s\S]*Remove-Item -LiteralPath \$startMenuShortcutFolder -Recurse -Force -ErrorAction Stop/, ); assert.doesNotMatch( installedWindowsAppTest, - /Remove-Item[^\n]*(?:\$commonPrograms|\$startMenuShortcut(?:Folder)?)[^\n]*-Recurse|Remove-Item[^\n]*-Recurse[^\n]*(?:\$commonPrograms|\$startMenuShortcut(?:Folder)?)/, + /Remove-Item[^\n]*\$commonPrograms[^\n]*-Recurse|Remove-Item[^\n]*-Recurse[^\n]*\$commonPrograms/, ); assert.match(installedWindowsAppTest, /machine uninstall left the common Start Menu shortcut behind/); assert.match(installedWindowsAppTest, /machine uninstall left the common Start Menu folder behind/); From 0a1e94f805b582d294c09d4fdf36618d5534ea57 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 03:32:57 +0000 Subject: [PATCH 08/29] =?UTF-8?q?feat(ai):=20Implemented=20the=20complete?= =?UTF-8?q?=20F10=E2=80=93F13=20correction=20on=20exact=20head=20`35acc0e6?= =?UTF-8?q?debb34cddb5aff0d5c0efdae5cedd129`.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented the complete F10–F13 correction on exact head `35acc0e6debb34cddb5aff0d5c0efdae5cedd129`. Key changes: - Verified Job worker-tree termination now gates all cleanup; failures preserve ACTIVE recovery authority. - Removed unchanged-HKCU MSI-uninstall bypass. - Added marker-bound provisional-user SID adoption with durable ACTIVE-manifest persistence. - Replaced recursive owned-directory deletion with authenticated file/marker cleanup and empty-only, deepest-first removal. - Added termination-failure, unchanged-HKCU uninstall, provisional replacement, and in-place foreign-child coverage. - Replaced async PowerShell callbacks with bounded C# stream pumps and fixed finalization diagnostics; fixed result lines are emitted last. - Preserved typed EMPTY receipts, ARM deadline, HKCU value restoration, and both `if: always()` seams. Validation: - Desktop tests: 177 passed, 6 skipped. - Focused workflow contracts: 23 passed. - Desktop typecheck passed. - `git diff --check` passed. - Native x64/ARM64 fixture could not run locally because this environment is Linux without PowerShell or Windows runners; it remains wired for both PR workflow architectures. Modified scripts are under [apps/desktop/scripts](/tmp/git-processor/worktrees/integry/propr/pr-2042-followup-2026-09-01T03-17-55/apps/desktop/scripts), with contract updates in [release-workflow.test.ts](/tmp/git-processor/worktrees/integry/propr/pr-2042-followup-2026-09-01T03-17-55/apps/desktop/src/release-workflow.test.ts). No commit was created. PR: #2042 Comment by: @integry (ID: 5488384761) Model: gpt-5.6-sol --- .../scripts/cleanup-installed-windows-app.ps1 | 100 +++++++-- .../run-installed-windows-app-harness.ps1 | 114 ++++++++-- ...installed-windows-app-workflow-cleanup.ps1 | 160 ++++++++++++-- ...stalled-windows-app-supervisor-fixture.ps1 | 49 ++++- .../test-installed-windows-app-supervisor.ps1 | 196 +++++++++++++++++- .../scripts/test-installed-windows-app.ps1 | 4 + apps/desktop/src/release-workflow.test.ts | 49 ++++- 7 files changed, 595 insertions(+), 77 deletions(-) diff --git a/apps/desktop/scripts/cleanup-installed-windows-app.ps1 b/apps/desktop/scripts/cleanup-installed-windows-app.ps1 index c82a3e6d1..1cbbaf7b2 100644 --- a/apps/desktop/scripts/cleanup-installed-windows-app.ps1 +++ b/apps/desktop/scripts/cleanup-installed-windows-app.ps1 @@ -300,7 +300,24 @@ function Remove-OwnedDirectory($Record) { if (!$tokenMatches -and !$identityMatches) { throw 'owned directory identity does not match' } - Remove-Item -LiteralPath $path -Recurse -Force -ErrorAction Stop + $markerPath = Join-Path $path $ownerFileName + $children = @(Get-ChildItem -LiteralPath $path -Force -ErrorAction Stop) + $unexpectedChildren = @($children | Where-Object { + ![string]::Equals($_.FullName, $markerPath, [StringComparison]::OrdinalIgnoreCase) + }) + if ($unexpectedChildren.Count -ne 0) { + throw 'owned directory contains an unexpected descendant' + } + if ($children.Count -ne 0) { + if (!$tokenMatches -or $children.Count -ne 1) { + throw 'owned directory marker identity does not match' + } + Remove-Item -LiteralPath $markerPath -Force -ErrorAction Stop + } + if (@(Get-ChildItem -LiteralPath $path -Force -ErrorAction Stop).Count -ne 0) { + throw 'owned directory is not empty' + } + Remove-Item -LiteralPath $path -Force -ErrorAction Stop if (Test-Path -LiteralPath $path) { throw 'owned directory cleanup did not complete' } } @@ -435,16 +452,7 @@ function Restore-OwnedRegistryValue($Record) { } } -function Write-EmptyOwnershipReceipt([string]$Path, $Manifest) { - $Manifest.State = 'EMPTY' - $Manifest.BaselineClean = $false - $Manifest.InstallAttempted = $false - $Manifest.Directories = @() - $Manifest.Files = @() - $Manifest.RegistryKeys = @() - $Manifest.RegistryValues = @() - $Manifest.Users = @() - $Manifest.Profiles = @() +function Write-DurableOwnershipManifest([string]$Path, $Manifest) { $temporaryPath = "$Path.new" $bytes = [Text.Encoding]::UTF8.GetBytes(($Manifest | ConvertTo-Json -Depth 6 -Compress)) $stream = [IO.FileStream]::new( @@ -464,6 +472,38 @@ function Write-EmptyOwnershipReceipt([string]$Path, $Manifest) { [IO.File]::Move($temporaryPath, $Path, $true) } +function Write-EmptyOwnershipReceipt([string]$Path, $Manifest) { + $Manifest.State = 'EMPTY' + $Manifest.BaselineClean = $false + $Manifest.InstallAttempted = $false + $Manifest.Directories = @() + $Manifest.Files = @() + $Manifest.RegistryKeys = @() + $Manifest.RegistryValues = @() + $Manifest.Users = @() + $Manifest.Profiles = @() + Write-DurableOwnershipManifest $Path $Manifest +} + +function Resolve-ProvisionalOwnedUser($Record) { + if (!$Record.Owned -or [string]$Record.Sid -match '^S-\d+(?:-\d+)+$') { + return $false + } + if (!$Record.Provisional) { throw 'owned user SID is invalid' } + $name = [string]$Record.Name + $ownershipMarker = [string]$Record.OwnershipMarker + $user = Get-LocalUser -Name $name -ErrorAction SilentlyContinue + if ($null -eq $user) { return $false } + if ($ownershipMarker -notmatch '^prpr-own-[a-f0-9]{32}$' -or + [string]$user.Description -cne $ownershipMarker -or + [string]$user.SID.Value -notmatch '^S-\d+(?:-\d+)+$') { + throw 'provisional local-user ownership marker does not match' + } + $Record.Sid = [string]$user.SID.Value + $Record.Provisional = $false + return $true +} + function Remove-OwnedProfiles($UserRecord) { if (!$UserRecord.Owned) { return } $name = [string]$UserRecord.Name @@ -472,10 +512,9 @@ function Remove-OwnedProfiles($UserRecord) { } $sid = [string]$UserRecord.Sid if ($sid -notmatch '^S-\d+(?:-\d+)+$') { - if (!$UserRecord.Provisional) { throw 'owned user SID is invalid' } - $provisionalUser = Get-LocalUser -Name $name -ErrorAction SilentlyContinue - if ($null -eq $provisionalUser) { return } - $sid = $provisionalUser.SID.Value + if ($UserRecord.Provisional -and + $null -eq (Get-LocalUser -Name $name -ErrorAction SilentlyContinue)) { return } + throw 'owned user SID was not durably resolved' } for ($attempt = 0; $attempt -lt 10; $attempt += 1) { $profiles = @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop | Where-Object { @@ -522,9 +561,13 @@ function Remove-OwnedUser($Record) { } $user = Get-LocalUser -Name $name -ErrorAction SilentlyContinue if ($null -eq $user) { return } + $ownershipMarker = [string]$Record.OwnershipMarker + if ($ownershipMarker -notmatch '^prpr-own-[a-f0-9]{32}$' -or + [string]$user.Description -cne $ownershipMarker) { + throw 'local-user ownership marker does not match' + } if ($sid -notmatch '^S-\d+(?:-\d+)+$') { - if (!$Record.Provisional) { throw 'owned local-user identity is invalid' } - $sid = $user.SID.Value + throw 'owned local-user SID was not durably resolved' } if ($user.SID.Value -cne $sid) { throw 'local-user SID ownership changed' } Remove-LocalUser -Name $name -ErrorAction Stop @@ -640,6 +683,10 @@ try { } } foreach ($record in @($manifest.Users)) { + if ($record.Owned -and ($record.Owned -isnot [bool] -or + $record.Provisional -isnot [bool])) { + throw 'user manifest ownership state is invalid' + } if ($record.Owned -and [string]$record.Name -notmatch '^(?:propr-ci-|prpr)[a-f0-9]{8}$') { throw 'user manifest identity is invalid' } @@ -647,6 +694,11 @@ try { [string]$record.Sid -notmatch '^S-\d+(?:-\d+)+$') { throw 'user manifest SID is invalid' } + if ($record.Owned -and + [string]$record.OwnershipMarker -notmatch + '^prpr-own-[a-f0-9]{32}$') { + throw 'user manifest ownership marker is invalid' + } } foreach ($record in @($manifest.Profiles)) { if ($record.Owned -and ([string]$record.Sid -notmatch '^S-\d+(?:-\d+)+$' -or @@ -764,23 +816,27 @@ try { throw 'registry value manifest cardinality is invalid' } $manifestValidated = $true - $skipMsiUninstall = $false + $adoptedProvisionalUser = $false + foreach ($record in @($manifest.Users)) { + if (Resolve-ProvisionalOwnedUser $record) { $adoptedProvisionalUser = $true } + } + if ($adoptedProvisionalUser) { + Write-DurableOwnershipManifest $manifestPath $manifest + } foreach ($record in @($manifest.RegistryValues)) { if (!$record.Owned) { continue } $current = Get-RegistryValueSnapshot ([string]$record.Path) ([string]$record.Name) $matchesBaseline = [bool]$record.BaselineValueExisted -and $current.Exists -and $current.Kind -ceq [string]$record.BaselineValueKind -and $current.Data -ceq [string]$record.BaselineValueData - if ($matchesBaseline) { - $skipMsiUninstall = $true - } elseif ($current.Exists -and + if (!$matchesBaseline -and $current.Exists -and (([bool]$record.Provisional -and !(Test-MsiInstalledValue ([string]$record.Path) ([string]$record.Name))) -or (![bool]$record.Provisional -and !(Test-RegistryValueIdentity $record $current)))) { $cleanupFailed = $true } } - if ($allowProvisionalMsiUninstall -and !$skipMsiUninstall -and !$cleanupFailed) { + if ($allowProvisionalMsiUninstall -and !$cleanupFailed) { $msiExitCode = 1618 for ($attempt = 0; $attempt -lt 12 -and $msiExitCode -eq 1618; $attempt += 1) { if ($attempt -ne 0) { Start-Sleep -Seconds 2 } diff --git a/apps/desktop/scripts/run-installed-windows-app-harness.ps1 b/apps/desktop/scripts/run-installed-windows-app-harness.ps1 index 9d2cc1244..3803a3acd 100644 --- a/apps/desktop/scripts/run-installed-windows-app-harness.ps1 +++ b/apps/desktop/scripts/run-installed-windows-app-harness.ps1 @@ -10,7 +10,8 @@ param( [string]$CancellationEventName, [string]$FixtureCleanupRoot, [string]$OwnershipManifest, - [string]$ExpectedRunId + [string]$ExpectedRunId, + [switch]$InjectTerminationFailure ) $ErrorActionPreference = 'Stop' @@ -144,6 +145,27 @@ public sealed class ProPRKillOnCloseJob : IDisposable [DllImport("kernel32.dll", SetLastError = true)] private static extern bool TerminateJobObject(SafeFileHandle job, uint exitCode); + [StructLayout(LayoutKind.Sequential)] + private struct JOBOBJECT_BASIC_ACCOUNTING_INFORMATION + { + public long TotalUserTime; + public long TotalKernelTime; + public long ThisPeriodTotalUserTime; + public long ThisPeriodTotalKernelTime; + public uint TotalPageFaultCount; + public uint TotalProcesses; + public uint ActiveProcesses; + public uint TotalTerminatedProcesses; + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool QueryInformationJobObject( + SafeFileHandle job, + int informationClass, + out JOBOBJECT_BASIC_ACCOUNTING_INFORMATION information, + uint informationLength, + IntPtr returnLength); + public ProPRKillOnCloseJob() { handle = CreateJobObject(IntPtr.Zero, null); @@ -172,10 +194,29 @@ public sealed class ProPRKillOnCloseJob : IDisposable throw new Win32Exception(Marshal.GetLastWin32Error(), "worker ownership failed"); } - public void Terminate(uint exitCode) + private uint ReadActiveProcessCount() + { + JOBOBJECT_BASIC_ACCOUNTING_INFORMATION information; + uint size = (uint)Marshal.SizeOf(typeof(JOBOBJECT_BASIC_ACCOUNTING_INFORMATION)); + if (!QueryInformationJobObject(handle, 1, out information, size, IntPtr.Zero)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "job accounting failed"); + return information.ActiveProcesses; + } + + public bool TerminateAndWait(uint exitCode, int timeoutMilliseconds) { - if (!handle.IsInvalid && !TerminateJobObject(handle, exitCode)) + if (handle == null || handle.IsInvalid) + throw new InvalidOperationException("job handle is unavailable"); + if (!TerminateJobObject(handle, exitCode)) throw new Win32Exception(Marshal.GetLastWin32Error(), "job termination failed"); + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + do + { + if (ReadActiveProcessCount() == 0) return true; + System.Threading.Thread.Sleep(25); + } + while (stopwatch.ElapsedMilliseconds < timeoutMilliseconds); + return ReadActiveProcessCount() == 0; } public void Dispose() @@ -318,15 +359,39 @@ function Accept-WatchdogMarker($Marker) { } function Stop-OwnedWorker([uint32]$TerminationExitCode) { - if ($null -ne $job) { - try { $job.Terminate($TerminationExitCode) } catch {} + if ($null -eq $job) { return $false } + if ($InjectTerminationFailure) { + try { + $job.Dispose() + $script:job = $null + if ($null -ne $worker) { + [void]$worker.WaitForExit($WatchdogTerminationMilliseconds) + } + } catch {} + return $false } - if ($null -ne $worker) { + try { + if (!$job.TerminateAndWait($TerminationExitCode, $WatchdogTerminationMilliseconds)) { + return $false + } + $job.Dispose() + $script:job = $null + if ($null -eq $worker) { return !$workerStarted } + if (!$worker.WaitForExit($WatchdogTerminationMilliseconds) -or !$worker.HasExited) { + return $false + } + return $true + } catch { try { - if (!$worker.HasExited) { + if ($null -ne $job) { + $job.Dispose() + $script:job = $null + } + if ($null -ne $worker) { [void]$worker.WaitForExit($WatchdogTerminationMilliseconds) } } catch {} + return $false } } @@ -419,8 +484,14 @@ function Invoke-PostTerminationCleanup([string]$InstallerPath, [string]$Authoriz throw 'post-termination cleanup ownership failed' } if (!$cleanupProcess.WaitForExit($PostTerminationCleanupMilliseconds)) { - try { $cleanupJob.Terminate(125) } catch {} - try { [void]$cleanupProcess.WaitForExit($WatchdogTerminationMilliseconds) } catch {} + $cleanupTreeGone = $false + try { + $cleanupTreeGone = $cleanupJob.TerminateAndWait( + 125, + $WatchdogTerminationMilliseconds + ) -and $cleanupProcess.WaitForExit($WatchdogTerminationMilliseconds) -and + $cleanupProcess.HasExited + } catch {} Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:TIMED_OUT' return $false } @@ -474,6 +545,9 @@ try { } elseif (!$usingProductionWorker) { throw 'injected workers require a fixture cleanup scope' } + if ($InjectTerminationFailure -and $usingProductionWorker) { + throw 'termination failure injection requires an authorized fixture worker' + } $hostPath = (Get-Process -Id $PID -ErrorAction Stop).Path if ([IO.Path]::GetFileName($hostPath) -notin @('pwsh.exe', 'powershell.exe')) { throw 'PowerShell host resolution failed' @@ -619,9 +693,14 @@ try { !$supervisorOutcomeComplete $fixedCleanupResult = $null if ($cleanupRequired -and $installerPath -and $ownershipRunId) { - Stop-OwnedWorker ([uint32]$exitCode) - $fixedCleanupResult = Invoke-PostTerminationCleanup $installerPath $FixtureCleanupRoot - if (!$fixedCleanupResult) { $exitCode = 125 } + $workerTreeTerminated = Stop-OwnedWorker ([uint32]$exitCode) + if ($workerTreeTerminated) { + $fixedCleanupResult = Invoke-PostTerminationCleanup $installerPath $FixtureCleanupRoot + } else { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:FAILED' + $fixedCleanupResult = $false + } + if ($fixedCleanupResult -ne $true) { $exitCode = 125 } } try { @@ -638,10 +717,13 @@ try { Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:LAST_VALID:NONE' } - if ($null -ne $job) { $job.Dispose() } - if ($null -ne $worker) { $worker.Dispose() } - if ($null -ne $ownershipReadyEvent) { $ownershipReadyEvent.Dispose() } - if ($null -ne $cancellationEvent) { $cancellationEvent.Dispose() } + foreach ($resource in @($job, $worker, $ownershipReadyEvent, $cancellationEvent)) { + if ($null -eq $resource) { continue } + try { $resource.Dispose() } catch { + $fixedCleanupResult = $false + $exitCode = 125 + } + } try { if ([IO.File]::Exists($markerPath)) { [IO.File]::Delete($markerPath) } } catch {} diff --git a/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 b/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 index 91eac67aa..db3dc1bf7 100644 --- a/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 +++ b/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 @@ -11,6 +11,7 @@ $ErrorActionPreference = 'Stop' $cleanupProcess = $null $cleanupJob = $null $cleanupReadyEvent = $null +$outputDrain = $null $fixedResult = 'FAILED' $fixedStatus = 'CONTROLLER_FAILURE' $fixedExitCode = 125 @@ -19,7 +20,11 @@ $validatedManifestPath = $null Add-Type -TypeDefinition @' using System; using System.ComponentModel; +using System.Diagnostics; +using System.IO; using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; using Microsoft.Win32.SafeHandles; public sealed class ProPRWorkflowCleanupJob : IDisposable @@ -110,12 +115,59 @@ public sealed class ProPRWorkflowCleanupJob : IDisposable public void Dispose() { if (handle != null) handle.Dispose(); } } -public static class ProPRWorkflowCleanupOutputDrain +public sealed class ProPRWorkflowCleanupDrainResult { - public static void Attach(System.Diagnostics.Process process) + public long StandardOutputCharacters; + public long StandardErrorCharacters; +} + +public sealed class ProPRWorkflowCleanupOutputDrain : IDisposable +{ + private const long CharacterLimit = 4096; + private readonly CancellationTokenSource cancellation = new CancellationTokenSource(); + private Task standardOutputTask; + private Task standardErrorTask; + + private static async Task Pump(StreamReader reader, CancellationToken token) { - process.OutputDataReceived += delegate(object sender, System.Diagnostics.DataReceivedEventArgs args) { }; - process.ErrorDataReceived += delegate(object sender, System.Diagnostics.DataReceivedEventArgs args) { }; + var buffer = new char[1024]; + long characters = 0; + while (true) + { + int count = await reader.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false); + if (count == 0) return characters; + token.ThrowIfCancellationRequested(); + characters = Math.Min(CharacterLimit + 1, characters + count); + } + } + + public void Start(Process process) + { + if (standardOutputTask != null || standardErrorTask != null) + throw new InvalidOperationException("stream drain was already started"); + standardOutputTask = Pump(process.StandardOutput, cancellation.Token); + standardErrorTask = Pump(process.StandardError, cancellation.Token); + } + + public ProPRWorkflowCleanupDrainResult Finish(int timeoutMilliseconds) + { + if (standardOutputTask == null || standardErrorTask == null) + throw new InvalidOperationException("stream drain was not started"); + Task all = Task.WhenAll(standardOutputTask, standardErrorTask); + if (!all.Wait(timeoutMilliseconds)) return null; + if (standardOutputTask.IsFaulted || standardOutputTask.IsCanceled || + standardErrorTask.IsFaulted || standardErrorTask.IsCanceled) + throw new InvalidOperationException("stream drain failed"); + return new ProPRWorkflowCleanupDrainResult { + StandardOutputCharacters = standardOutputTask.Result, + StandardErrorCharacters = standardErrorTask.Result + }; + } + + public void Dispose() + { + cancellation.Cancel(); + cancellation.Dispose(); } } '@ @@ -178,9 +230,8 @@ try { $cleanupProcess = [Diagnostics.Process]::new() $cleanupProcess.StartInfo = $startInfo if (!$cleanupProcess.Start()) { throw 'workflow cleanup did not start' } - [ProPRWorkflowCleanupOutputDrain]::Attach($cleanupProcess) - $cleanupProcess.BeginOutputReadLine() - $cleanupProcess.BeginErrorReadLine() + $outputDrain = [ProPRWorkflowCleanupOutputDrain]::new() + $outputDrain.Start($cleanupProcess) try { $cleanupJob.AddProcess($cleanupProcess.Handle) [void]$cleanupReadyEvent.Set() @@ -189,11 +240,21 @@ try { throw 'workflow cleanup ownership failed' } if (!$cleanupProcess.WaitForExit($CleanupTimeoutMilliseconds)) { - try { $cleanupJob.Terminate(125) } catch {} - try { [void]$cleanupProcess.WaitForExit($TerminationTimeoutMilliseconds) } catch {} - $fixedResult = 'TIMED_OUT' - $fixedStatus = 'TIMEOUT' - $fixedExitCode = 124 + $terminationVerified = $false + try { + $cleanupJob.Terminate(125) + $terminationVerified = $cleanupProcess.WaitForExit($TerminationTimeoutMilliseconds) -and + $cleanupProcess.HasExited + } catch {} + if ($terminationVerified) { + $fixedResult = 'TIMED_OUT' + $fixedStatus = 'TIMEOUT' + $fixedExitCode = 124 + } else { + $fixedResult = 'FAILED' + $fixedStatus = 'TERMINATION_FAILURE' + $fixedExitCode = 125 + } } elseif ($cleanupProcess.ExitCode -eq 0) { $fixedResult = 'COMPLETE' $fixedStatus = 'EMPTY_OR_CLEANED' @@ -209,16 +270,75 @@ try { $fixedResult = 'FAILED' $fixedStatus = 'CONTROLLER_FAILURE' $fixedExitCode = 125 -} finally { - Write-FixedResult $fixedResult - if ($null -ne $cleanupJob) { $cleanupJob.Dispose() } - if ($null -ne $cleanupProcess) { $cleanupProcess.Dispose() } - if ($null -ne $cleanupReadyEvent) { $cleanupReadyEvent.Dispose() } - if ($fixedResult -ceq 'COMPLETE' -and $validatedManifestPath) { - foreach ($path in @($validatedManifestPath, "$validatedManifestPath.new")) { - try { if ([IO.File]::Exists($path)) { [IO.File]::Delete($path) } } catch {} +} + +try { + if ($null -ne $cleanupProcess -and !$cleanupProcess.HasExited) { + if ($null -ne $cleanupJob) { + $cleanupJob.Dispose() + $cleanupJob = $null + } + if (!$cleanupProcess.WaitForExit($TerminationTimeoutMilliseconds) -or + !$cleanupProcess.HasExited) { + $fixedResult = 'FAILED' + $fixedStatus = 'PROCESS_FINALIZATION_TIMEOUT' + $fixedExitCode = 125 } } +} catch { + $fixedResult = 'FAILED' + $fixedStatus = 'PROCESS_FINALIZATION_FAILURE' + $fixedExitCode = 125 +} + +try { + if ($null -ne $outputDrain) { + $drainResult = $outputDrain.Finish($TerminationTimeoutMilliseconds) + if ($null -eq $drainResult) { + $fixedResult = 'FAILED' + $fixedStatus = 'STREAM_DRAIN_TIMEOUT' + $fixedExitCode = 125 + } elseif ($drainResult.StandardErrorCharacters -ne 0) { + $fixedResult = 'FAILED' + $fixedStatus = if ($drainResult.StandardErrorCharacters -gt 4096) { + 'CHILD_STDERR_LIMIT' + } else { 'CHILD_STDERR' } + $fixedExitCode = 123 + } elseif ($drainResult.StandardOutputCharacters -ne 0) { + $fixedResult = 'FAILED' + $fixedStatus = if ($drainResult.StandardOutputCharacters -gt 4096) { + 'CHILD_STDOUT_LIMIT' + } else { 'CHILD_STDOUT' } + $fixedExitCode = 122 + } + } +} catch { + $fixedResult = 'FAILED' + $fixedStatus = 'STREAM_DRAIN_FAILURE' + $fixedExitCode = 125 } +foreach ($resource in @($outputDrain, $cleanupJob, $cleanupProcess, $cleanupReadyEvent)) { + if ($null -eq $resource) { continue } + try { $resource.Dispose() } catch { + $fixedResult = 'FAILED' + $fixedStatus = 'RESOURCE_FINALIZATION_FAILURE' + $fixedExitCode = 125 + } +} + +if ($fixedResult -ceq 'COMPLETE' -and $validatedManifestPath) { + try { + foreach ($path in @("$validatedManifestPath.new", $validatedManifestPath)) { + if ([IO.File]::Exists($path)) { [IO.File]::Delete($path) } + } + } catch { + $fixedResult = 'FAILED' + $fixedStatus = 'AUTHORITY_FINALIZATION_FAILURE' + $fixedExitCode = 125 + } +} + +Write-FixedResult $fixedResult + exit $fixedExitCode 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 b6a609a8f..b082424b2 100644 --- a/apps/desktop/scripts/test-installed-windows-app-supervisor-fixture.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app-supervisor-fixture.ps1 @@ -19,6 +19,7 @@ if ($scenario -notin @( 'CANCELLATION', 'OWNED_RESOURCES_NORMAL_SUCCESS', 'OWNED_RESOURCES_FOR_INTERRUPTION', + 'OWNED_RESOURCES_FOREIGN_CHILD_THEN_DEADLINE', 'OWNED_RESOURCES_REPLACED_THEN_DEADLINE', 'OWNED_RESOURCES_THEN_DEADLINE' )) { @@ -135,9 +136,23 @@ function New-OwnedFixtureResources { if (Get-LocalUser -Name $userName -ErrorAction SilentlyContinue) { throw 'fixture owned-user baseline was not clean' } + $userOwnershipMarker = + "prpr-own-$([Guid]::NewGuid().ToString('N'))" + $provisionalUserRecord = [ordered]@{ + Name = $userName + Sid = $null + Owned = $true + Provisional = $true + OwnershipMarker = $userOwnershipMarker + } + $manifest.Users = @($provisionalUserRecord) + Write-FixtureOwnershipManifest $manifest New-LocalUser -Name $userName -Password $password ` + -Description $userOwnershipMarker ` -AccountNeverExpires -PasswordNeverExpires | Out-Null $userSid = (Get-LocalUser -Name $userName -ErrorAction Stop).SID.Value + $provisionalUserRecord.Sid = $userSid + $provisionalUserRecord.Provisional = $false $ownedDirectories = @( [ordered]@{ Kind = 'FIXTURE_ROOT'; Path = $ownedRoot; Owned = $true; Token = $token }, @@ -152,9 +167,21 @@ function New-OwnedFixtureResources { } $manifest.Directories = @($ownedDirectories) + @($conflictingDirectories) $manifest.Files = @( + [ordered]@{ + Kind = 'FIXTURE_FILE'; Path = (Join-Path $installRoot 'installed.txt') + Owned = $true; Token = $null + Identity = (Get-FixtureFileIdentity (Join-Path $installRoot 'installed.txt')) + Provisional = $false + }, [ordered]@{ Kind = 'SHORTCUT_FILE'; Path = $shortcut; Owned = $true; Token = $token Identity = (Get-FixtureFileIdentity $shortcut); Provisional = $false + }, + [ordered]@{ + Kind = 'FIXTURE_FILE'; Path = (Join-Path $smokeDirectory 'smoke.txt') + Owned = $true; Token = $null + Identity = (Get-FixtureFileIdentity (Join-Path $smokeDirectory 'smoke.txt')) + Provisional = $false } ) if ($env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_SHORTCUT) { @@ -173,9 +200,7 @@ function New-OwnedFixtureResources { Owned = $false; Token = $null } } - $manifest.Users = @( - [ordered]@{ Name = $userName; Sid = $userSid; Owned = $true } - ) + $manifest.Users = @($provisionalUserRecord) if ($env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_USER) { $manifest.Users += [ordered]@{ Name = $env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_USER @@ -271,6 +296,16 @@ function Replace-FixtureOwnedResources { -Name 'ProPRInstalledAppOwner' -Value 'foreign-owner' } +function Add-FixtureForeignChild { + $state = Get-Content -LiteralPath (Join-Path $stateDirectory 'resources.json') ` + -Raw -Encoding ASCII | ConvertFrom-Json -ErrorAction Stop + [IO.File]::WriteAllText( + (Join-Path $state.InstallRoot 'foreign-in-place.txt'), + 'foreign-in-place', + [Text.Encoding]::ASCII + ) +} + function Start-FixtureDescendant { $hostPath = (Get-Process -Id $PID -ErrorAction Stop).Path $startInfo = [Diagnostics.ProcessStartInfo]::new() @@ -389,6 +424,14 @@ switch ($scenario) { [DateTime]::UtcNow.AddMilliseconds(500).Ticks) Start-Sleep -Seconds 300 } + 'OWNED_RESOURCES_FOREIGN_CHILD_THEN_DEADLINE' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + New-OwnedFixtureResources + Add-FixtureForeignChild + Write-FixtureMarker ('{0}|CLEANUP|SMOKE_DATA_REMOVE|BEGIN' -f ` + [DateTime]::UtcNow.AddMilliseconds(500).Ticks) + Start-Sleep -Seconds 300 + } 'OWNED_RESOURCES_NORMAL_SUCCESS' { Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) New-OwnedFixtureResources diff --git a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 index 1fb7487dd..cebc9512a 100644 --- a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 @@ -45,7 +45,8 @@ function New-SupervisorStartInfo( [string]$CancellationEventName, [bool]$UseProductionWorker, [string]$WorkflowManifest = '', - [string]$ExpectedRunId = '' + [string]$ExpectedRunId = '', + [bool]$InjectTerminationFailure = $false ) { $startInfo = [Diagnostics.ProcessStartInfo]::new() $startInfo.FileName = $hostPath @@ -94,6 +95,9 @@ function New-SupervisorStartInfo( $conflictingFixtureRegistryPath } } + if ($InjectTerminationFailure) { + $startInfo.ArgumentList.Add('-InjectTerminationFailure') + } if ($CancellationEventName) { $startInfo.ArgumentList.Add('-CancellationEventName') $startInfo.ArgumentList.Add($CancellationEventName) @@ -231,8 +235,12 @@ function Invoke-WorkflowCleanupController( $output = $process.StandardOutput.ReadToEnd() $errorOutput = $process.StandardError.ReadToEnd() Assert-True ($output.Length -le 512) 'workflow cleanup fixture output exceeded its fixed bound' - Assert-True ($errorOutput.Length -eq 0) ` - 'workflow cleanup fixture emitted non-fixed error output' + if ($errorOutput.Length -ne 0) { + $stderrCode = if ($errorOutput.Length -gt 4096) { + 'PROPR_WORKFLOW_CLEANUP_FIXTURE:CONTROLLER_STDERR_LIMIT' + } else { 'PROPR_WORKFLOW_CLEANUP_FIXTURE:CONTROLLER_STDERR_PRESENT' } + throw $stderrCode + } $outputLines = @($output -split '\r?\n' | Where-Object { $_ }) Assert-True ($outputLines.Count -eq 2) ` 'workflow cleanup fixture did not emit exactly two fixed result lines' @@ -306,19 +314,26 @@ $env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_REGISTRY = $ConflictRegistry return [PSCustomObject]@{ Pipeline = $pipeline; AsyncResult = $asyncResult } } -function Invoke-FixtureScenario([string]$Scenario, [string]$ExistingStateDirectory = '') { +function Invoke-FixtureScenario( + [string]$Scenario, + [string]$ExistingStateDirectory = '', + [bool]$InjectTerminationFailure = $false +) { $stateDirectory = if ($ExistingStateDirectory) { $ExistingStateDirectory } else { New-StateDirectory $Scenario.ToLowerInvariant() } $process = [Diagnostics.Process]::new() - $process.StartInfo = New-SupervisorStartInfo $Scenario $stateDirectory '' $false + $process.StartInfo = New-SupervisorStartInfo ` + $Scenario $stateDirectory '' $false '' '' $InjectTerminationFailure $stopwatch = [Diagnostics.Stopwatch]::StartNew() if (!$process.Start()) { throw 'supervisor test process did not start' } try { $completionBound = if ($Scenario -in @( - 'OWNED_RESOURCES_THEN_DEADLINE','OWNED_RESOURCES_REPLACED_THEN_DEADLINE' + 'OWNED_RESOURCES_THEN_DEADLINE', + 'OWNED_RESOURCES_REPLACED_THEN_DEADLINE', + 'OWNED_RESOURCES_FOREIGN_CHILD_THEN_DEADLINE' )) { 90000 } else { 20000 } if (!$process.WaitForExit($completionBound)) { try { $process.Kill($true) } catch {} @@ -658,6 +673,59 @@ function Test-PreExistingCleanupOwnership { Assert-True (!(Test-Path -LiteralPath $replacementOwned.ManifestPath)) ` 'successful standalone cleanup retry did not consume recovery authority' + $foreignChildStateDirectory = New-StateDirectory 'in-place-foreign-child' + $foreignChildResult = Invoke-FixtureScenario ` + 'OWNED_RESOURCES_FOREIGN_CHILD_THEN_DEADLINE' $foreignChildStateDirectory + Assert-True ($foreignChildResult.ExitCode -eq 125) ` + 'in-place foreign child did not fail the standalone cleanup' + Assert-Contains $foreignChildResult.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:FAILED' ` + 'in-place foreign child did not emit fixed cleanup failure evidence' + $foreignChildOwned = Read-FixtureResourceState $foreignChildStateDirectory + $foreignChildPath = Join-Path $foreignChildOwned.InstallRoot 'foreign-in-place.txt' + Assert-True ((Get-Content -LiteralPath $foreignChildPath -Raw).Trim() -ceq ` + 'foreign-in-place') 'in-place foreign child was removed or changed' + Assert-True (Test-Path -LiteralPath $foreignChildOwned.ManifestPath -PathType Leaf) ` + 'in-place foreign-child failure discarded authenticated recovery authority' + $foreignChildManifest = Get-Content -LiteralPath $foreignChildOwned.ManifestPath ` + -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop + Assert-True ($foreignChildManifest.State -ceq 'ACTIVE') ` + 'in-place foreign-child failure did not preserve the ACTIVE manifest' + Remove-Item -LiteralPath $foreignChildPath -Force -ErrorAction Stop + $foreignChildRetry = Invoke-WorkflowCleanupController ` + $foreignChildOwned.ManifestPath $foreignChildOwned.RunId $foreignChildStateDirectory + Assert-True ($foreignChildRetry.ExitCode -eq 0 -and + $foreignChildRetry.Result -ceq 'COMPLETE') ` + 'in-place foreign-child cleanup did not retry to exact success' + Assert-OwnedResourcesGone $foreignChildOwned + + $terminationFailureStateDirectory = New-StateDirectory 'termination-failure' + $terminationFailureResult = Invoke-FixtureScenario ` + 'OWNED_RESOURCES_THEN_DEADLINE' $terminationFailureStateDirectory $true + Assert-True ($terminationFailureResult.ExitCode -eq 125) ` + 'unverified worker-tree termination did not fail closed' + Assert-Contains $terminationFailureResult.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:FAILED' ` + 'unverified worker-tree termination did not emit fixed failure evidence' + $terminationFailureOwned = Read-FixtureResourceState $terminationFailureStateDirectory + Assert-ProcessTreeGone (Read-FixtureProcessState $terminationFailureStateDirectory) + Assert-True (Test-Path -LiteralPath $terminationFailureOwned.ManifestPath -PathType Leaf) ` + 'termination failure discarded authenticated recovery authority' + $terminationFailureManifest = Get-Content ` + -LiteralPath $terminationFailureOwned.ManifestPath -Raw -Encoding UTF8 | + ConvertFrom-Json -ErrorAction Stop + Assert-True ($terminationFailureManifest.State -ceq 'ACTIVE') ` + 'termination failure did not preserve the ACTIVE manifest' + Assert-True (Test-Path -LiteralPath $terminationFailureOwned.InstallRoot -PathType Container) ` + 'cleanup mutated resources before worker-tree termination was verified' + $terminationRetry = Invoke-WorkflowCleanupController ` + $terminationFailureOwned.ManifestPath $terminationFailureOwned.RunId ` + $terminationFailureStateDirectory + Assert-True ($terminationRetry.ExitCode -eq 0 -and + $terminationRetry.Result -ceq 'COMPLETE') ` + 'termination-failure authority did not retry to exact cleanup success' + Assert-OwnedResourcesGone $terminationFailureOwned + Assert-True ((Get-Content -LiteralPath (Join-Path $conflictInstallRoot 'pre-existing.txt') -Raw).Trim() -ceq ` 'owned-before-run') 'pre-existing install tree was removed or changed' Assert-True ((Get-ItemPropertyValue -LiteralPath $conflictRegistryPath -Name 'PreExisting') -ceq ` @@ -1013,7 +1081,8 @@ function Test-HkcuInstalledValueOwnership { [AllowNull()][string]$BaselineKind, [AllowNull()][string]$BaselineData, [bool]$KeyCreatedByRun, - [bool]$Provisional = $false + [bool]$Provisional = $false, + [bool]$InstallAttempted = $false ) { $runId = [Guid]::NewGuid().ToString('N') $path = Join-Path ([IO.Path]::GetTempPath()) ` @@ -1031,8 +1100,8 @@ function Test-HkcuInstalledValueOwnership { InstallerPath = $dummyInstaller Fixture = $false FixtureRoot = $null - BaselineClean = $false - InstallAttempted = $false + BaselineClean = $InstallAttempted + InstallAttempted = $InstallAttempted Directories = @() Files = @() RegistryKeys = @() @@ -1080,6 +1149,21 @@ function Test-HkcuInstalledValueOwnership { Assert-True ([string]$restoredKey.GetValue('Unrelated') -ceq $sentinelUnrelated) ` 'unrelated HKCU value was changed during baseline restoration' + $unchangedManifest = New-HkcuManifest ` + $true $true 'String' $baselineData $false $false $true + $unchanged = Invoke-WorkflowCleanupController ` + $unchangedManifest.Path $unchangedManifest.RunId '' + Assert-True ($unchanged.ExitCode -eq 21 -and + $unchanged.ControllerStatus -ceq 'OWNED_RESOURCE_CLEANUP_FAILURE') ` + 'unchanged HKCU baseline incorrectly bypassed the MSI uninstall attempt' + $unchangedKey = Get-Item -LiteralPath $desktopKey -ErrorAction Stop + Assert-True ($unchangedKey.GetValueKind($installedName).ToString() -ceq 'String' -and + [string]$unchangedKey.GetValue($installedName) -ceq $sentinelInstalled) ` + 'failed MSI uninstall changed the unchanged HKCU baseline' + Assert-True (Test-Path -LiteralPath $unchangedManifest.Path -PathType Leaf) ` + 'failed unchanged-HKCU uninstall discarded authenticated recovery authority' + Remove-Item -LiteralPath $unchangedManifest.Path -Force -ErrorAction Stop + Remove-Item -LiteralPath $desktopKey -Recurse -Force -ErrorAction Stop [void](New-Item -Path $desktopKey -Force -ErrorAction Stop) (Get-Item -LiteralPath $desktopKey).SetValue( @@ -1147,6 +1231,99 @@ function Test-HkcuInstalledValueOwnership { [Console]::Out.Flush() } +function Test-ProvisionalUserMarkerOwnership { + function New-ProvisionalUserManifest([string]$UserName, [string]$OwnershipMarker) { + $runId = [Guid]::NewGuid().ToString('N') + $path = Join-Path ([IO.Path]::GetTempPath()) ` + "propr-installed-app-ownership-$runId.json" + $createdTicks = [DateTime]::UtcNow.Ticks + $manifest = [ordered]@{ + SchemaVersion = 2 + ManifestType = 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP' + State = 'ACTIVE' + RunId = $runId + CreatedUtcTicks = $createdTicks + ExpiresUtcTicks = $createdTicks + ([TimeSpan]::TicksPerHour * 3) + InstallerPath = $dummyInstaller + Fixture = $true + FixtureRoot = $testRoot + BaselineClean = $false + InstallAttempted = $false + Directories = @() + Files = @() + RegistryKeys = @() + RegistryValues = @() + Users = @([ordered]@{ + Name = $UserName + Sid = $null + Owned = $true + Provisional = $true + OwnershipMarker = $OwnershipMarker + }) + Profiles = @() + } + [IO.File]::WriteAllText( + $path, + ($manifest | ConvertTo-Json -Depth 6 -Compress), + [Text.Encoding]::UTF8 + ) + return [PSCustomObject]@{ RunId = $runId; Path = $path } + } + + $password = ConvertTo-SecureString "P!$([Guid]::NewGuid().ToString('N'))u8" ` + -AsPlainText -Force + $positiveName = "prpr$([Guid]::NewGuid().ToString('N').Substring(0,8))" + $positiveMarker = "prpr-own-$([Guid]::NewGuid().ToString('N'))" + $replacementName = "prpr$([Guid]::NewGuid().ToString('N').Substring(0,8))" + $replacementMarker = "prpr-own-$([Guid]::NewGuid().ToString('N'))" + $positiveManifest = $null + $replacementManifest = $null + try { + $positiveManifest = New-ProvisionalUserManifest $positiveName $positiveMarker + New-LocalUser -Name $positiveName -Password $password ` + -Description $positiveMarker -AccountNeverExpires -PasswordNeverExpires | Out-Null + $positive = Invoke-WorkflowCleanupController ` + $positiveManifest.Path $positiveManifest.RunId $testRoot + Assert-True ($positive.ExitCode -eq 0 -and + $positive.Result -ceq 'COMPLETE') ` + 'marker-bound provisional local-user recovery did not complete' + Assert-True ($null -eq (Get-LocalUser -Name $positiveName -ErrorAction SilentlyContinue)) ` + 'marker-bound provisional local-user recovery left its account behind' + + $replacementManifest = New-ProvisionalUserManifest $replacementName $replacementMarker + New-LocalUser -Name $replacementName -Password $password ` + -Description "prpr-own-$([Guid]::NewGuid().ToString('N'))" ` + -AccountNeverExpires -PasswordNeverExpires | Out-Null + $replacementSid = (Get-LocalUser -Name $replacementName -ErrorAction Stop).SID.Value + $replacement = Invoke-WorkflowCleanupController ` + $replacementManifest.Path $replacementManifest.RunId $testRoot + Assert-True ($replacement.ExitCode -eq 21 -and + $replacement.ControllerStatus -ceq 'OWNED_RESOURCE_CLEANUP_FAILURE') ` + 'provisional username authorized replacement-account deletion' + $survivingReplacement = Get-LocalUser -Name $replacementName -ErrorAction Stop + Assert-True ($survivingReplacement.SID.Value -ceq $replacementSid) ` + 'replacement account identity changed during provisional cleanup' + Assert-True (Test-Path -LiteralPath $replacementManifest.Path -PathType Leaf) ` + 'provisional replacement failure discarded authenticated recovery authority' + $replacementAuthority = Get-Content -LiteralPath $replacementManifest.Path ` + -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop + Assert-True ($replacementAuthority.State -ceq 'ACTIVE') ` + 'provisional replacement failure did not preserve the ACTIVE manifest' + } finally { + foreach ($name in @($positiveName, $replacementName)) { + $user = Get-LocalUser -Name $name -ErrorAction SilentlyContinue + if ($null -ne $user) { Remove-LocalUser -Name $name -ErrorAction SilentlyContinue } + } + foreach ($manifest in @($positiveManifest, $replacementManifest)) { + if ($null -ne $manifest -and (Test-Path -LiteralPath $manifest.Path)) { + Remove-Item -LiteralPath $manifest.Path -Force -ErrorAction SilentlyContinue + } + } + } + Write-Host 'PROPR_WINDOWS_SUPERVISOR_OWNERSHIP:PROVISIONAL_USER_MARKER:PRESERVED' + [Console]::Out.Flush() +} + if (![OperatingSystem]::IsWindows()) { throw 'supervisor behavior tests require Windows' } $actualArchitecture = [Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString().ToLowerInvariant() Assert-True ($actualArchitecture -ceq $Architecture) ` @@ -1162,6 +1339,7 @@ try { Test-PreExistingCleanupOwnership Test-PreExistingAppPathsAuthority Test-HkcuInstalledValueOwnership + Test-ProvisionalUserMarkerOwnership Write-Host "PROPR_WINDOWS_SUPERVISOR_TESTS:${Architecture}:PASSED" [Console]::Out.Flush() } finally { diff --git a/apps/desktop/scripts/test-installed-windows-app.ps1 b/apps/desktop/scripts/test-installed-windows-app.ps1 index 7ce89f0e4..cec226b69 100644 --- a/apps/desktop/scripts/test-installed-windows-app.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app.ps1 @@ -1513,15 +1513,19 @@ try { if (Get-LocalUser -Name $testUser -ErrorAction SilentlyContinue) { throw 'refusing to replace a pre-existing local user' } + $userOwnershipMarker = + "prpr-own-$([Guid]::NewGuid().ToString('N'))" $provisionalUser = [ordered]@{ Name = $testUser Sid = $null Owned = $true Provisional = $true + OwnershipMarker = $userOwnershipMarker } $ownershipState.Users = @($provisionalUser) Write-OwnershipManifest New-LocalUser -Name $testUser -Password $password ` + -Description $userOwnershipMarker ` -AccountNeverExpires -PasswordNeverExpires | Out-Null $script:testUserCreatedByRun = $true $script:testUserSid = (Get-LocalUser -Name $testUser -ErrorAction Stop).SID diff --git a/apps/desktop/src/release-workflow.test.ts b/apps/desktop/src/release-workflow.test.ts index 4ad474d49..ac804b25b 100644 --- a/apps/desktop/src/release-workflow.test.ts +++ b/apps/desktop/src/release-workflow.test.ts @@ -609,7 +609,12 @@ describe('desktop trusted release workflow', () => { assert.match(installedWindowsAppSupervisor, /if \(!\$worker\.Start\(\)\)[^\n]+\n\s+\$workerStarted = \$true\n\s+\$bootstrapStopwatch = \[Diagnostics\.Stopwatch\]::StartNew\(\)/); assert.match(installedWindowsAppSupervisor, /\[ProPRBoundedMarkerReader\]::ReadAsync\(\$Path\)/); assert.match(installedWindowsAppSupervisor, /\$readTask\.Wait\(\$TimeoutMilliseconds\)/); - assert.match(installedWindowsAppSupervisor, /\$job\.Terminate\(\$TerminationExitCode\)/); + assert.match( + installedWindowsAppSupervisor, + /\$job\.TerminateAndWait\(\$TerminationExitCode, \$WatchdogTerminationMilliseconds\)/, + ); + assert.match(installedWindowsAppSupervisor, /\$worker\.WaitForExit\(\$WatchdogTerminationMilliseconds\)/); + assert.match(installedWindowsAppSupervisor, /if \(\$workerTreeTerminated\) \{[\s\S]*Invoke-PostTerminationCleanup/); assert.match(installedWindowsAppSupervisor, /Invoke-PostTerminationCleanup/); assert.match(installedWindowsAppSupervisor, /\$cleanupRequired = \$terminateOwnedTree -or \$workerStarted/); assert.match( @@ -661,9 +666,13 @@ describe('desktop trusted release workflow', () => { assert.match(installedWindowsAppWorkflowCleanup, /MANIFEST_VALIDATION_FAILURE/); assert.match(installedWindowsAppWorkflowCleanup, /OWNED_RESOURCE_CLEANUP_FAILURE/); assert.match(installedWindowsAppWorkflowCleanup, /ProPRWorkflowCleanupOutputDrain/); + assert.match(installedWindowsAppWorkflowCleanup, /StreamReader reader/); + assert.match(installedWindowsAppWorkflowCleanup, /reader\.ReadAsync/); + assert.match(installedWindowsAppWorkflowCleanup, /STREAM_DRAIN_(?:TIMEOUT|FAILURE)/); + assert.match(installedWindowsAppWorkflowCleanup, /CHILD_STDERR/); assert.doesNotMatch( installedWindowsAppWorkflowCleanup, - /add_(?:Output|Error)DataReceived\(\{\}\)/, + /add_(?:Output|Error)DataReceived|Begin(?:Output|Error)ReadLine/, ); assert.match( installedWindowsAppWorkflowCleanup, @@ -674,6 +683,25 @@ describe('desktop trusted release workflow', () => { /if \(\$fixedCleanupResult -eq \$true -and !\$workflowManagedManifest\)/, ); assert.match(installedWindowsAppSupervisorBehaviorTest, /OWNED_RESOURCES_REPLACED_THEN_DEADLINE/); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /OWNED_RESOURCES_FOREIGN_CHILD_THEN_DEADLINE/, + ); + assert.match(installedWindowsAppSupervisorBehaviorTest, /in-place foreign child was removed or changed/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /InjectTerminationFailure/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /termination failure discarded authenticated recovery authority/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Test-ProvisionalUserMarkerOwnership/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /provisional username authorized replacement-account deletion/); + assert.match(installedWindowsAppTest, /-Description \$userOwnershipMarker/); + assert.match(installedWindowsAppCleanup, /provisional local-user ownership marker does not match/); + assert.doesNotMatch(installedWindowsAppCleanup, /\$skipMsiUninstall/); + const ownedDirectoryCleanup = installedWindowsAppCleanup.slice( + installedWindowsAppCleanup.indexOf('function Remove-OwnedDirectory'), + installedWindowsAppCleanup.indexOf('function Remove-OwnedFile'), + ); + assert.doesNotMatch(ownedDirectoryCleanup, /Remove-Item[^\n]*-Recurse/); + assert.match(ownedDirectoryCleanup, /owned directory contains an unexpected descendant/); + assert.match(ownedDirectoryCleanup, /Get-ChildItem[^\n]*-Force/); assert.match( installedWindowsAppSupervisorBehaviorTest, /replacement install tree was removed or changed/, @@ -682,12 +710,15 @@ describe('desktop trusted release workflow', () => { installedWindowsAppSupervisorBehaviorTest, /timed-out workflow cleanup discarded authenticated recovery authority[\s\S]*failed workflow cleanup discarded authenticated recovery authority[\s\S]*retry to fixed cleanup success/, ); + const fixedResultWrite = installedWindowsAppWorkflowCleanup.indexOf( + 'Write-FixedResult $fixedResult', + ); assert.ok( - installedWindowsAppWorkflowCleanup.indexOf('Write-FixedResult $fixedResult') - < installedWindowsAppWorkflowCleanup.indexOf( - 'foreach ($path in @($validatedManifestPath, "$validatedManifestPath.new"))', + fixedResultWrite > installedWindowsAppWorkflowCleanup.indexOf('$resource.Dispose()') + && fixedResultWrite > installedWindowsAppWorkflowCleanup.indexOf( + 'if ($fixedResult -ceq \'COMPLETE\' -and $validatedManifestPath)', ), - 'failed manifest must remain available until fixed controller evidence is emitted', + 'fixed controller evidence must be emitted after bounded finalization', ); assert.doesNotMatch( installedWindowsAppSupervisorBehaviorTest, @@ -700,7 +731,11 @@ describe('desktop trusted release workflow', () => { ); } assert.match(installedWindowsAppSupervisor, /exit \$exitCode/); - assert.match(installedWindowsAppSupervisor, /if \(\$null -ne \$job\) \{ \$job\.Dispose\(\) \}/); + assert.match( + installedWindowsAppSupervisor, + /foreach \(\$resource in @\(\$job, \$worker, \$ownershipReadyEvent, \$cancellationEvent\)\)/, + ); + assert.match(installedWindowsAppSupervisor, /try \{ \$resource\.Dispose\(\) \} catch/); assert.match(installedWindowsAppTest, /\[IO\.FileOptions\]::WriteThrough/); assert.equal(installedWindowsAppTest.match(/\.Flush\(\$true\)/g)?.length, 4); From 390a7217d63cf703dc266e309abc0ea9aef19f14 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 04:08:15 +0000 Subject: [PATCH 09/29] =?UTF-8?q?feat(ai):=20Implemented=20the=20exact-hea?= =?UTF-8?q?d=20F14=E2=80=93F16=20follow-up=20without=20committing.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented the exact-head F14–F16 follow-up without committing. Key changes: - Primary install/shortcut fallbacks now remove directories non-recursively and only when empty. - Smoke cleanup uses exact token, SID, owner, ACL, reparse, and object-identity validation with bounded traversal. - Provisional smoke roots are durably promoted before further mutation; missing/mismatched tokens fail closed. - Added interruption fixtures before/after promotion, after Electron/log creation, token mismatch/missing, and foreign descendants. - Added top-level fixed controller phase/line classification and bounded stream finalization. - Raised only `NO_MARKER`’s fixture ceiling to 60 seconds. Validation: - Desktop tests: 177 passed, 6 skipped - Desktop typecheck: passed - Focused workflow contracts: 23 passed - `git diff --check`: passed Native x64/ARM64 execution requires Windows CI; the workflow continues to require the focused fixture on both architectures. PR: #2042 Comment by: @integry (ID: 5488566692) Model: gpt-5.6-sol --- .../scripts/cleanup-installed-windows-app.ps1 | 248 +++++++++++++- ...installed-windows-app-workflow-cleanup.ps1 | 192 +++++++++-- ...stalled-windows-app-supervisor-fixture.ps1 | 317 +++++++++++++++++- .../test-installed-windows-app-supervisor.ps1 | 108 +++++- .../scripts/test-installed-windows-app.ps1 | 271 +++++++++++++-- apps/desktop/src/release-workflow.test.ts | 75 ++++- 6 files changed, 1141 insertions(+), 70 deletions(-) diff --git a/apps/desktop/scripts/cleanup-installed-windows-app.ps1 b/apps/desktop/scripts/cleanup-installed-windows-app.ps1 index 1cbbaf7b2..5872a84e9 100644 --- a/apps/desktop/scripts/cleanup-installed-windows-app.ps1 +++ b/apps/desktop/scripts/cleanup-installed-windows-app.ps1 @@ -46,20 +46,25 @@ public static class ProPRDirectoryIdentity private static extern bool GetFileInformationByHandle( SafeFileHandle handle, out BY_HANDLE_FILE_INFORMATION information); - public static string Read(string path) + public static string ReadEntry(string path, bool expectDirectory) { using (SafeFileHandle handle = CreateFile( - path, 0x80, 0x7, IntPtr.Zero, 3, 0x02000000, IntPtr.Zero)) + path, 0x80, 0x7, IntPtr.Zero, 3, 0x02200000, IntPtr.Zero)) { if (handle == null || handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error(), "directory identity open failed"); BY_HANDLE_FILE_INFORMATION information; if (!GetFileInformationByHandle(handle, out information)) throw new Win32Exception(Marshal.GetLastWin32Error(), "directory identity read failed"); + bool isDirectory = (information.FileAttributes & 0x10) != 0; + if ((information.FileAttributes & 0x400) != 0 || isDirectory != expectDirectory) + throw new InvalidOperationException("file-system object identity changed"); return string.Format("{0:x8}{1:x8}{2:x8}", information.VolumeSerialNumber, information.FileIndexHigh, information.FileIndexLow); } } + + public static string Read(string path) { return ReadEntry(path, true); } } '@ @@ -132,6 +137,15 @@ function Get-DirectoryIdentity([string]$Path) { return [ProPRDirectoryIdentity]::Read($item.FullName) } +function Get-FileSystemEntryIdentity([string]$Path, [bool]$Directory) { + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if ($item.PSIsContainer -ne $Directory -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'file-system object identity is invalid' + } + return [ProPRDirectoryIdentity]::ReadEntry($item.FullName, $Directory) +} + function Get-RegistryTreeIdentity([string]$Path) { if (!(Test-Path -LiteralPath $Path)) { return $null } $root = Get-Item -LiteralPath $Path -ErrorAction Stop @@ -280,8 +294,235 @@ function Test-AllowedFileSystemPath([string]$Kind, [string]$Path) { return $false } +function Assert-SmokeAccessControl($Item, $Record, [bool]$Root) { + $userSid = [string]$Record.UserSid + $creatorSid = [string]$Record.CreatorSid + $rootOwnerSid = [string]$Record.RootOwnerSid + if ($userSid -notmatch '^S-\d+(?:-\d+)+$' -or + $creatorSid -notmatch '^S-\d+(?:-\d+)+$' -or + $rootOwnerSid -cne 'S-1-5-32-544') { + throw 'smoke user-data manifest security authority is invalid' + } + $systemSid = 'S-1-5-18' + $expectedAccessSids = @($userSid, $systemSid, $rootOwnerSid) | Sort-Object -Unique + if ($expectedAccessSids.Count -ne 3) { + throw 'smoke user-data manifest security authority is invalid' + } + $acl = Get-Acl -LiteralPath $Item.FullName -ErrorAction Stop + $ownerSid = $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value + $allowedOwnerSids = @($userSid, $creatorSid, $rootOwnerSid) | Sort-Object -Unique + if ($allowedOwnerSids -cnotcontains $ownerSid) { + throw 'smoke user-data object owner is not authorized' + } + $rules = @($acl.Access) + $actualAccessSids = @($rules | ForEach-Object { + ($_.IdentityReference.Translate([Security.Principal.SecurityIdentifier])).Value + }) | Sort-Object -Unique + $fullControl = [Security.AccessControl.FileSystemRights]::FullControl + $expectedInheritance = [Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit' + $invalidRules = if ($Root) { + @($rules | Where-Object { + $_.IsInherited -or + $_.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow -or + ($_.FileSystemRights -band $fullControl) -ne $fullControl -or + $_.InheritanceFlags -ne $expectedInheritance -or + $_.PropagationFlags -ne [Security.AccessControl.PropagationFlags]::None + }) + } else { + $inheritedFlags = if ($Item.PSIsContainer) { + $expectedInheritance + } else { [Security.AccessControl.InheritanceFlags]::None } + @($rules | Where-Object { + !$_.IsInherited -or + $_.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow -or + ($_.FileSystemRights -band $fullControl) -ne $fullControl -or + $_.InheritanceFlags -ne $inheritedFlags -or + $_.PropagationFlags -ne [Security.AccessControl.PropagationFlags]::None + }) + } + if (($Root -and (!$acl.AreAccessRulesProtected -or $ownerSid -cne $rootOwnerSid)) -or + (!$Root -and $acl.AreAccessRulesProtected) -or + $rules.Count -ne 3 -or $invalidRules.Count -ne 0 -or + @(Compare-Object $expectedAccessSids $actualAccessSids).Count -ne 0) { + throw 'smoke user-data object ACL is not authorized' + } +} + +function Assert-OwnedSmokeRoot($Record) { + $path = [IO.Path]::GetFullPath([string]$Record.Path) + if (!(Test-AllowedFileSystemPath 'SMOKE_DATA' $path) -or + [string]$Record.Token -notmatch '^[a-f0-9]{32}$') { + throw 'smoke user-data cleanup scope is invalid' + } + $item = Get-Item -LiteralPath $path -Force -ErrorAction Stop + if (!$item.PSIsContainer -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'smoke user-data root identity is invalid' + } + $markerPath = Join-Path $path $ownerFileName + $marker = Get-Item -LiteralPath $markerPath -Force -ErrorAction Stop + if (!($marker -is [IO.FileInfo]) -or + ($marker.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'smoke user-data ownership token does not match' + } + $markerIdentity = Get-FileSystemEntryIdentity $marker.FullName $false + $markerStream = [IO.File]::Open( + $markerPath, + [IO.FileMode]::Open, + [IO.FileAccess]::Read, + [IO.FileShare]::Read + ) + try { + if ($markerStream.Length -le 0 -or $markerStream.Length -gt 128) { + throw 'smoke user-data ownership token does not match' + } + $markerBytes = [byte[]]::new([int]$markerStream.Length) + $markerOffset = 0 + while ($markerOffset -lt $markerBytes.Length) { + $markerRead = $markerStream.Read( + $markerBytes, $markerOffset, $markerBytes.Length - $markerOffset) + if ($markerRead -eq 0) { throw 'smoke user-data ownership token does not match' } + $markerOffset += $markerRead + } + if ($markerStream.ReadByte() -ne -1 -or + [Text.Encoding]::ASCII.GetString($markerBytes) -cne [string]$Record.Token) { + throw 'smoke user-data ownership token does not match' + } + } finally { + $markerStream.Dispose() + } + Assert-SmokeAccessControl $item $Record $true + Assert-SmokeAccessControl $marker $Record $false + if ((Get-FileSystemEntryIdentity $marker.FullName $false) -cne $markerIdentity) { + throw 'smoke user-data ownership token identity changed' + } + return $item +} + +function Resolve-SmokeDirectoryAuthority($Record, $Manifest, [string]$ManifestPath) { + if (!$Record.Owned -or [string]$Record.Kind -cne 'SMOKE_DATA') { return $false } + $recordKeys = @($Record.PSObject.Properties | ForEach-Object { $_.Name }) + $expectedKeys = @( + 'Kind','Path','Owned','Token','Identity','Provisional', + 'UserSid','CreatorSid','RootOwnerSid' + ) + if ($recordKeys.Count -ne $expectedKeys.Count -or + @($expectedKeys | Where-Object { $recordKeys -cnotcontains $_ }).Count -ne 0 -or + $Record.Owned -isnot [bool] -or $Record.Provisional -isnot [bool] -or + [string]$Record.Token -notmatch '^[a-f0-9]{32}$' -or + [string]$Record.UserSid -notmatch '^S-\d+(?:-\d+)+$' -or + [string]$Record.CreatorSid -notmatch '^S-\d+(?:-\d+)+$' -or + [string]$Record.RootOwnerSid -cne 'S-1-5-32-544' -or + (![bool]$Record.Provisional -and [string]$Record.Identity -notmatch '^[a-f0-9]{24}$') -or + ([bool]$Record.Provisional -and $null -ne $Record.Identity)) { + throw 'smoke user-data manifest authority is invalid' + } + $ownedUsers = @($Manifest.Users | Where-Object { $_.Owned }) + if ($ownedUsers.Count -ne 1 -or [bool]$ownedUsers[0].Provisional -or + [string]$ownedUsers[0].Sid -cne [string]$Record.UserSid) { + throw 'smoke user-data SID is not the exact run-owned user SID' + } + if (!(Test-Path -LiteralPath ([string]$Record.Path))) { return $false } + $root = Assert-OwnedSmokeRoot $Record + $identity = Get-FileSystemEntryIdentity $root.FullName $true + if ([bool]$Record.Provisional) { + $Record.Identity = $identity + $Record.Provisional = $false + Write-DurableOwnershipManifest $ManifestPath $Manifest + return $true + } + if ([string]$Record.Identity -cne $identity) { + throw 'smoke user-data root identity does not match' + } + return $false +} + +function Remove-OwnedSmokeDirectory($Record) { + if (!$Record.Owned -or !(Test-Path -LiteralPath ([string]$Record.Path))) { return } + if ([bool]$Record.Provisional) { + throw 'provisional smoke user-data authority was not durably promoted' + } + $root = Assert-OwnedSmokeRoot $Record + if ((Get-FileSystemEntryIdentity $root.FullName $true) -cne [string]$Record.Identity) { + throw 'smoke user-data root identity does not match' + } + $rootPath = $root.FullName.TrimEnd('\') + $pending = [Collections.Generic.Queue[object]]::new() + $pending.Enqueue([PSCustomObject]@{ + Path = $root.FullName + Identity = [string]$Record.Identity + Root = $true + }) + $entries = [Collections.Generic.List[object]]::new() + while ($pending.Count -ne 0) { + $queuedDirectory = $pending.Dequeue() + $directory = Get-Item -LiteralPath $queuedDirectory.Path -Force -ErrorAction Stop + Assert-SmokeAccessControl $directory $Record ([bool]$queuedDirectory.Root) + if ((Get-FileSystemEntryIdentity $directory.FullName $true) -cne + [string]$queuedDirectory.Identity) { + throw 'smoke user-data directory identity changed during traversal' + } + foreach ($child in @(Get-ChildItem -LiteralPath $directory.FullName -Force -ErrorAction Stop)) { + if ($entries.Count -ge 50000) { throw 'smoke user-data cleanup entry bound was exceeded' } + $childPath = [IO.Path]::GetFullPath($child.FullName) + if (!$childPath.StartsWith("$rootPath\", [StringComparison]::OrdinalIgnoreCase) -or + ($child.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'smoke user-data descendant scope is invalid' + } + Assert-SmokeAccessControl $child $Record $false + $identity = Get-FileSystemEntryIdentity $childPath ([bool]$child.PSIsContainer) + $entries.Add([PSCustomObject]@{ + Path = $childPath + Directory = [bool]$child.PSIsContainer + Identity = $identity + }) + if ($child.PSIsContainer) { + $pending.Enqueue([PSCustomObject]@{ + Path = $childPath + Identity = $identity + Root = $false + }) + } + } + } + + foreach ($entry in @($entries | Where-Object { !$_.Directory })) { + $item = Get-Item -LiteralPath $entry.Path -Force -ErrorAction Stop + Assert-SmokeAccessControl $item $Record $false + if ((Get-FileSystemEntryIdentity $entry.Path $false) -cne [string]$entry.Identity) { + throw 'smoke user-data file identity changed during cleanup' + } + Remove-Item -LiteralPath $entry.Path -Force -ErrorAction Stop + } + foreach ($entry in @($entries | Where-Object { $_.Directory } | + Sort-Object { ([string]$_.Path).Length } -Descending)) { + $item = Get-Item -LiteralPath $entry.Path -Force -ErrorAction Stop + Assert-SmokeAccessControl $item $Record $false + if ((Get-FileSystemEntryIdentity $entry.Path $true) -cne [string]$entry.Identity -or + @(Get-ChildItem -LiteralPath $entry.Path -Force -ErrorAction Stop).Count -ne 0) { + throw 'smoke user-data directory identity changed or is not empty' + } + Remove-Item -LiteralPath $entry.Path -Force -ErrorAction Stop + } + $root = Get-Item -LiteralPath $rootPath -Force -ErrorAction Stop + if (!$root.PSIsContainer -or + ($root.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'smoke user-data root identity changed during cleanup' + } + Assert-SmokeAccessControl $root $Record $true + if ((Get-FileSystemEntryIdentity $root.FullName $true) -cne [string]$Record.Identity -or + @(Get-ChildItem -LiteralPath $root.FullName -Force -ErrorAction Stop).Count -ne 0) { + throw 'smoke user-data root changed or is not empty' + } + Remove-Item -LiteralPath $root.FullName -Force -ErrorAction Stop +} + function Remove-OwnedDirectory($Record) { if (!$Record.Owned) { return } + if ([string]$Record.Kind -ceq 'SMOKE_DATA') { + Remove-OwnedSmokeDirectory $Record + return + } $path = [string]$Record.Path $kind = [string]$Record.Kind if (!(Test-AllowedFileSystemPath $kind $path)) { throw 'directory cleanup scope is invalid' } @@ -675,6 +916,9 @@ try { !(Test-AllowedFileSystemPath ([string]$record.Kind) ([string]$record.Path))) { throw 'directory manifest scope is invalid' } + if ($record.Owned -and [string]$record.Kind -ceq 'SMOKE_DATA') { + [void](Resolve-SmokeDirectoryAuthority $record $manifest $manifestPath) + } } foreach ($record in @($manifest.Files)) { if ($record.Owned -and diff --git a/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 b/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 index db3dc1bf7..f6b184676 100644 --- a/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 +++ b/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 @@ -1,12 +1,38 @@ param( - [Parameter(Mandatory=$true)][string]$OwnershipManifest, - [Parameter(Mandatory=$true)][string]$Installer, - [Parameter(Mandatory=$true)][string]$ExpectedRunId, - [ValidateRange(1,600000)][int]$CleanupTimeoutMilliseconds = 4 * 60 * 1000, - [ValidateRange(1,30000)][int]$TerminationTimeoutMilliseconds = 30 * 1000, - [string]$FixtureRoot + [object]$OwnershipManifest, + [object]$Installer, + [object]$ExpectedRunId, + [object]$CleanupTimeoutMilliseconds = 4 * 60 * 1000, + [object]$TerminationTimeoutMilliseconds = 30 * 1000, + [object]$FixtureRoot ) +enum WorkflowCleanupControllerPhase { + INITIALIZATION + PARAMETER_VALIDATION + PATH_VALIDATION + PROCESS_START + PROCESS_WAIT + PROCESS_FINALIZATION + STREAM_FINALIZATION + RESOURCE_FINALIZATION + AUTHORITY_FINALIZATION + RESULT_EMISSION +} + +enum WorkflowCleanupControllerLine { + TYPE_LOAD + PARAMETERS + PATHS + START + WAIT + TERMINATE + DRAIN + DISPOSE + AUTHORITY + EMIT +} + $ErrorActionPreference = 'Stop' $cleanupProcess = $null $cleanupJob = $null @@ -16,7 +42,76 @@ $fixedResult = 'FAILED' $fixedStatus = 'CONTROLLER_FAILURE' $fixedExitCode = 125 $validatedManifestPath = $null +[WorkflowCleanupControllerPhase]$controllerPhase = 'INITIALIZATION' +[WorkflowCleanupControllerLine]$controllerLine = 'TYPE_LOAD' +$controllerBodyActive = $false +function Write-FixedResult([ValidateSet('COMPLETE','FAILED','TIMED_OUT')][string]$Result) { + Write-Host "PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:$Result" + Write-Host ( + 'PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:STATUS:{0}:EXIT_CODE:{1}' -f ` + $script:fixedStatus, $script:fixedExitCode) + [Console]::Out.Flush() +} + +function Set-CaughtControllerFailure($ErrorRecord) { + $phases = @( + 'INITIALIZATION','PARAMETER_VALIDATION','PATH_VALIDATION','PROCESS_START', + 'PROCESS_WAIT','PROCESS_FINALIZATION','STREAM_FINALIZATION', + 'RESOURCE_FINALIZATION','AUTHORITY_FINALIZATION','RESULT_EMISSION' + ) + $lines = @( + 'TYPE_LOAD','PARAMETERS','PATHS','START','WAIT','TERMINATE','DRAIN', + 'DISPOSE','AUTHORITY','EMIT' + ) + $categories = @{ + AuthenticationError = 'AUTHENTICATION' + CloseError = 'CLOSE' + InvalidArgument = 'INVALID_ARGUMENT' + InvalidData = 'INVALID_DATA' + InvalidOperation = 'INVALID_OPERATION' + LimitsExceeded = 'LIMIT' + NotEnabled = 'NOT_ENABLED' + ObjectNotFound = 'NOT_FOUND' + OpenError = 'OPEN' + OperationStopped = 'STOPPED' + PermissionDenied = 'PERMISSION' + ReadError = 'READ' + ResourceBusy = 'BUSY' + ResourceUnavailable = 'UNAVAILABLE' + SecurityError = 'SECURITY' + WriteError = 'WRITE' + } + $phase = if ($phases -ccontains [string]$script:controllerPhase) { + [string]$script:controllerPhase + } else { 'INITIALIZATION' } + $line = if ($lines -ccontains [string]$script:controllerLine) { + [string]$script:controllerLine + } else { 'TYPE_LOAD' } + $categoryName = [string]$ErrorRecord.CategoryInfo.Category + $category = if ($categories.ContainsKey($categoryName)) { + $categories[$categoryName] + } else { 'UNCLASSIFIED' } + $script:fixedResult = 'FAILED' + $script:fixedStatus = 'CONTROLLER_{0}_{1}_{2}' -f $phase, $line, $category + $script:fixedExitCode = 125 +} + +# Producer-boundary trap: every uncaught controller error is reduced to the +# allowlisted phase/line/category tuple and execution continues only into the +# next bounded finalization statement. While the controller body is active the +# trap exits that labeled phase first, so a type-load or body failure cannot +# continue into process setup. +trap { + Set-CaughtControllerFailure $_ + if ($script:controllerBodyActive) { + break controllerBody + } + continue +} + +$controllerBodyActive = $true +:controllerBody do { Add-Type -TypeDefinition @' using System; using System.ComponentModel; @@ -125,6 +220,8 @@ public sealed class ProPRWorkflowCleanupOutputDrain : IDisposable { private const long CharacterLimit = 4096; private readonly CancellationTokenSource cancellation = new CancellationTokenSource(); + private StreamReader standardOutputReader; + private StreamReader standardErrorReader; private Task standardOutputTask; private Task standardErrorTask; @@ -145,8 +242,10 @@ public sealed class ProPRWorkflowCleanupOutputDrain : IDisposable { if (standardOutputTask != null || standardErrorTask != null) throw new InvalidOperationException("stream drain was already started"); - standardOutputTask = Pump(process.StandardOutput, cancellation.Token); - standardErrorTask = Pump(process.StandardError, cancellation.Token); + standardOutputReader = process.StandardOutput; + standardErrorReader = process.StandardError; + standardOutputTask = Pump(standardOutputReader, cancellation.Token); + standardErrorTask = Pump(standardErrorReader, cancellation.Token); } public ProPRWorkflowCleanupDrainResult Finish(int timeoutMilliseconds) @@ -164,23 +263,56 @@ public sealed class ProPRWorkflowCleanupOutputDrain : IDisposable }; } - public void Dispose() + public bool CancelAndFinish(int timeoutMilliseconds) { cancellation.Cancel(); + try { if (standardOutputReader != null) standardOutputReader.Dispose(); } catch { } + try { if (standardErrorReader != null) standardErrorReader.Dispose(); } catch { } + if (standardOutputTask == null || standardErrorTask == null) return true; + try { Task.WhenAll(standardOutputTask, standardErrorTask).Wait(timeoutMilliseconds); } + catch { } + return standardOutputTask.IsCompleted && standardErrorTask.IsCompleted; + } + + public void Dispose() + { + CancelAndFinish(1000); cancellation.Dispose(); } } '@ -function Write-FixedResult([ValidateSet('COMPLETE','FAILED','TIMED_OUT')][string]$Result) { - Write-Host "PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:$Result" - Write-Host ( - 'PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:STATUS:{0}:EXIT_CODE:{1}' -f ` - $script:fixedStatus, $script:fixedExitCode) - [Console]::Out.Flush() +$controllerPhase = 'PARAMETER_VALIDATION' +$controllerLine = 'PARAMETERS' +$cleanupTimeout = 0 +$terminationTimeout = 0 +if ([string]::IsNullOrWhiteSpace([string]$OwnershipManifest) -or + [string]::IsNullOrWhiteSpace([string]$Installer) -or + [string]::IsNullOrWhiteSpace([string]$ExpectedRunId) -or + ![int]::TryParse( + [string]$CleanupTimeoutMilliseconds, + [Globalization.NumberStyles]::None, + [Globalization.CultureInfo]::InvariantCulture, + [ref]$cleanupTimeout + ) -or $cleanupTimeout -lt 1 -or $cleanupTimeout -gt 600000 -or + ![int]::TryParse( + [string]$TerminationTimeoutMilliseconds, + [Globalization.NumberStyles]::None, + [Globalization.CultureInfo]::InvariantCulture, + [ref]$terminationTimeout + ) -or $terminationTimeout -lt 1 -or $terminationTimeout -gt 30000) { + throw 'workflow cleanup controller parameters are invalid' } +$OwnershipManifest = [string]$OwnershipManifest +$Installer = [string]$Installer +$ExpectedRunId = [string]$ExpectedRunId +$FixtureRoot = if ($null -eq $FixtureRoot) { $null } else { [string]$FixtureRoot } +$CleanupTimeoutMilliseconds = $cleanupTimeout +$TerminationTimeoutMilliseconds = $terminationTimeout try { + $controllerPhase = 'PATH_VALIDATION' + $controllerLine = 'PATHS' if ($ExpectedRunId -notmatch '^[a-f0-9]{32}$') { throw 'cleanup run identity is invalid' } $manifestPath = [IO.Path]::GetFullPath($OwnershipManifest) $tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') @@ -227,6 +359,8 @@ try { $startInfo.ArgumentList.Add((Resolve-Path -LiteralPath $FixtureRoot -ErrorAction Stop).Path) } $cleanupJob = [ProPRWorkflowCleanupJob]::new() + $controllerPhase = 'PROCESS_START' + $controllerLine = 'START' $cleanupProcess = [Diagnostics.Process]::new() $cleanupProcess.StartInfo = $startInfo if (!$cleanupProcess.Start()) { throw 'workflow cleanup did not start' } @@ -239,7 +373,10 @@ try { try { $cleanupProcess.Kill($true) } catch {} throw 'workflow cleanup ownership failed' } + $controllerPhase = 'PROCESS_WAIT' + $controllerLine = 'WAIT' if (!$cleanupProcess.WaitForExit($CleanupTimeoutMilliseconds)) { + $controllerLine = 'TERMINATE' $terminationVerified = $false try { $cleanupJob.Terminate(125) @@ -267,12 +404,15 @@ try { $fixedExitCode = 21 } } catch { - $fixedResult = 'FAILED' - $fixedStatus = 'CONTROLLER_FAILURE' - $fixedExitCode = 125 + Set-CaughtControllerFailure $_ } +} while ($false) +$controllerBodyActive = $false + try { + $controllerPhase = 'PROCESS_FINALIZATION' + $controllerLine = 'TERMINATE' if ($null -ne $cleanupProcess -and !$cleanupProcess.HasExited) { if ($null -ne $cleanupJob) { $cleanupJob.Dispose() @@ -292,9 +432,12 @@ try { } try { + $controllerPhase = 'STREAM_FINALIZATION' + $controllerLine = 'DRAIN' if ($null -ne $outputDrain) { $drainResult = $outputDrain.Finish($TerminationTimeoutMilliseconds) if ($null -eq $drainResult) { + [void]$outputDrain.CancelAndFinish($TerminationTimeoutMilliseconds) $fixedResult = 'FAILED' $fixedStatus = 'STREAM_DRAIN_TIMEOUT' $fixedExitCode = 125 @@ -318,6 +461,8 @@ try { $fixedExitCode = 125 } +$controllerPhase = 'RESOURCE_FINALIZATION' +$controllerLine = 'DISPOSE' foreach ($resource in @($outputDrain, $cleanupJob, $cleanupProcess, $cleanupReadyEvent)) { if ($null -eq $resource) { continue } try { $resource.Dispose() } catch { @@ -329,6 +474,8 @@ foreach ($resource in @($outputDrain, $cleanupJob, $cleanupProcess, $cleanupRead if ($fixedResult -ceq 'COMPLETE' -and $validatedManifestPath) { try { + $controllerPhase = 'AUTHORITY_FINALIZATION' + $controllerLine = 'AUTHORITY' foreach ($path in @("$validatedManifestPath.new", $validatedManifestPath)) { if ([IO.File]::Exists($path)) { [IO.File]::Delete($path) } } @@ -339,6 +486,13 @@ if ($fixedResult -ceq 'COMPLETE' -and $validatedManifestPath) { } } -Write-FixedResult $fixedResult +try { + $controllerPhase = 'RESULT_EMISSION' + $controllerLine = 'EMIT' + Write-FixedResult $fixedResult +} catch { + Set-CaughtControllerFailure $_ + exit 125 +} exit $fixedExitCode 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 b082424b2..c3e9eeff7 100644 --- a/apps/desktop/scripts/test-installed-windows-app-supervisor-fixture.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app-supervisor-fixture.ps1 @@ -7,6 +7,55 @@ param( ) $ErrorActionPreference = 'Stop' + +Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +public static class ProPRFixtureDirectoryIdentity +{ + [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", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFile( + string path, uint access, uint share, IntPtr security, uint creation, + uint flags, IntPtr template); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetFileInformationByHandle( + SafeFileHandle handle, out BY_HANDLE_FILE_INFORMATION information); + public static string Read(string path) + { + using (SafeFileHandle handle = CreateFile( + path, 0x80, 0x7, IntPtr.Zero, 3, 0x02200000, IntPtr.Zero)) + { + if (handle == null || handle.IsInvalid) + throw new Win32Exception(Marshal.GetLastWin32Error()); + BY_HANDLE_FILE_INFORMATION information; + if (!GetFileInformationByHandle(handle, out information)) + throw new Win32Exception(Marshal.GetLastWin32Error()); + if ((information.FileAttributes & 0x400) != 0 || + (information.FileAttributes & 0x10) == 0) + throw new InvalidOperationException("fixture directory identity changed"); + return string.Format("{0:x8}{1:x8}{2:x8}", information.VolumeSerialNumber, + information.FileIndexHigh, information.FileIndexLow); + } + } +} +'@ $scenario = $env:PROPR_SUPERVISOR_FIXTURE_SCENARIO $stateDirectory = $env:PROPR_SUPERVISOR_FIXTURE_STATE_DIRECTORY if ($scenario -notin @( @@ -19,6 +68,12 @@ if ($scenario -notin @( 'CANCELLATION', 'OWNED_RESOURCES_NORMAL_SUCCESS', 'OWNED_RESOURCES_FOR_INTERRUPTION', + 'SMOKE_BEFORE_PROMOTION_THEN_DEADLINE', + 'SMOKE_AFTER_PROMOTION_THEN_DEADLINE', + 'SMOKE_AFTER_ARTIFACTS_THEN_DEADLINE', + 'SMOKE_FOREIGN_DESCENDANT_THEN_DEADLINE', + 'SMOKE_TOKEN_MISMATCH_THEN_DEADLINE', + 'PRIMARY_FALLBACK_FOREIGN_DESCENDANTS', 'OWNED_RESOURCES_FOREIGN_CHILD_THEN_DEADLINE', 'OWNED_RESOURCES_REPLACED_THEN_DEADLINE', 'OWNED_RESOURCES_THEN_DEADLINE' @@ -98,7 +153,46 @@ function Get-FixtureFileIdentity([string]$Path) { } } -function New-OwnedFixtureResources { +function Set-FixtureSmokeAcl([string]$Path, [string]$UserSid) { + $administratorsSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') + $systemSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-18') + $acl = [Security.AccessControl.DirectorySecurity]::new() + $acl.SetAccessRuleProtection($true, $false) + $acl.SetOwner($administratorsSid) + $inheritance = [Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit' + foreach ($sid in @( + [Security.Principal.SecurityIdentifier]::new($UserSid), + $systemSid, + $administratorsSid + )) { + $rule = [Security.AccessControl.FileSystemAccessRule]::new( + $sid, + [Security.AccessControl.FileSystemRights]::FullControl, + $inheritance, + [Security.AccessControl.PropagationFlags]::None, + [Security.AccessControl.AccessControlType]::Allow + ) + [void]$acl.AddAccessRule($rule) + } + Set-Acl -LiteralPath $Path -AclObject $acl -ErrorAction Stop +} + +function New-FixtureSmokeArtifacts([string]$Path) { + $electronData = Join-Path $Path 'profile\AppData\Local\ProPR' + [void](New-Item -ItemType Directory -Path $electronData -Force -ErrorAction Stop) + [IO.File]::WriteAllText( + (Join-Path $Path 'application.stdout.log'), 'owned-log', [Text.Encoding]::ASCII) + [IO.File]::WriteAllText( + (Join-Path $Path 'application.smoke-evidence.jsonl'), + '{"event":"desktop.smoke.authorized"}', [Text.Encoding]::UTF8) + [IO.File]::WriteAllText( + (Join-Path $electronData 'electron-data.json'), 'owned-electron-data', [Text.Encoding]::ASCII) +} + +function New-OwnedFixtureResources( + [ValidateSet('BEFORE_PROMOTION','AFTER_PROMOTION','AFTER_ARTIFACTS')] + [string]$SmokeCheckpoint = 'AFTER_ARTIFACTS' +) { $manifest = [IO.File]::ReadAllText($OwnershipManifest, [Text.Encoding]::UTF8) | ConvertFrom-Json -ErrorAction Stop if (!$manifest.Fixture -or $manifest.SchemaVersion -ne 2 -or @@ -114,13 +208,12 @@ function New-OwnedFixtureResources { $smokeDirectory = Join-Path $ownedRoot 'smoke-data' [void](New-Item -ItemType Directory -Path $ownedRoot -Force -ErrorAction Stop) Write-FixtureOwnershipToken (Join-Path $ownedRoot '.propr-installed-app-owner') $token - foreach ($directory in @($installRoot, $shortcutFolder, $smokeDirectory)) { + foreach ($directory in @($installRoot, $shortcutFolder)) { [void](New-Item -ItemType Directory -Path $directory -Force -ErrorAction Stop) Write-FixtureOwnershipToken (Join-Path $directory '.propr-installed-app-owner') $token } [IO.File]::WriteAllText((Join-Path $installRoot 'installed.txt'), 'owned', [Text.Encoding]::ASCII) [IO.File]::WriteAllText($shortcut, 'owned-shortcut', [Text.Encoding]::ASCII) - [IO.File]::WriteAllText((Join-Path $smokeDirectory 'smoke.txt'), 'owned', [Text.Encoding]::ASCII) $registryPath = "Registry::HKEY_LOCAL_MACHINE\Software\ProPRSupervisorFixture\$($manifest.RunId)\owned" [void](New-Item -Path $registryPath -Force -ErrorAction Stop) @@ -154,11 +247,37 @@ function New-OwnedFixtureResources { $provisionalUserRecord.Sid = $userSid $provisionalUserRecord.Provisional = $false + $smokeRecord = [ordered]@{ + Kind = 'SMOKE_DATA' + Path = $smokeDirectory + Owned = $true + Token = $token + Identity = $null + Provisional = $true + UserSid = $userSid + CreatorSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value + RootOwnerSid = 'S-1-5-32-544' + } + $manifest.Directories = @($smokeRecord) + $manifest.Users = @($provisionalUserRecord) + Write-FixtureOwnershipManifest $manifest + [void](New-Item -ItemType Directory -Path $smokeDirectory -ErrorAction Stop) + Set-FixtureSmokeAcl $smokeDirectory $userSid + Write-FixtureOwnershipToken (Join-Path $smokeDirectory '.propr-installed-app-owner') $token + if ($SmokeCheckpoint -ne 'BEFORE_PROMOTION') { + $smokeRecord.Identity = [ProPRFixtureDirectoryIdentity]::Read($smokeDirectory) + $smokeRecord.Provisional = $false + Write-FixtureOwnershipManifest $manifest + if ($SmokeCheckpoint -eq 'AFTER_ARTIFACTS') { + New-FixtureSmokeArtifacts $smokeDirectory + } + } + $ownedDirectories = @( [ordered]@{ Kind = 'FIXTURE_ROOT'; Path = $ownedRoot; Owned = $true; Token = $token }, [ordered]@{ Kind = 'INSTALL_ROOT'; Path = $installRoot; Owned = $true; Token = $token }, [ordered]@{ Kind = 'SHORTCUT_FOLDER'; Path = $shortcutFolder; Owned = $true; Token = $token }, - [ordered]@{ Kind = 'SMOKE_DATA'; Path = $smokeDirectory; Owned = $true; Token = $token } + $smokeRecord ) $conflictingDirectories = @( $env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_DIRECTORIES -split '\|' | Where-Object { $_ } @@ -176,12 +295,6 @@ function New-OwnedFixtureResources { [ordered]@{ Kind = 'SHORTCUT_FILE'; Path = $shortcut; Owned = $true; Token = $token Identity = (Get-FixtureFileIdentity $shortcut); Provisional = $false - }, - [ordered]@{ - Kind = 'FIXTURE_FILE'; Path = (Join-Path $smokeDirectory 'smoke.txt') - Owned = $true; Token = $null - Identity = (Get-FixtureFileIdentity (Join-Path $smokeDirectory 'smoke.txt')) - Provisional = $false } ) if ($env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_SHORTCUT) { @@ -274,6 +387,91 @@ function New-OwnedFixtureResources { (Join-Path $stateDirectory 'resources.json') -Encoding ASCII } +function New-SmokeCheckpointFixtureResources( + [ValidateSet('BEFORE_PROMOTION','AFTER_PROMOTION','AFTER_ARTIFACTS')] + [string]$Checkpoint +) { + $manifest = [IO.File]::ReadAllText($OwnershipManifest, [Text.Encoding]::UTF8) | + ConvertFrom-Json -ErrorAction Stop + if (!$manifest.Fixture -or $manifest.SchemaVersion -ne 2 -or + $manifest.State -cne 'ACTIVE') { + throw 'smoke checkpoint manifest was not initialized' + } + $token = [Guid]::NewGuid().ToString('N') + $userName = $env:PROPR_SUPERVISOR_FIXTURE_OWNED_USER + $passwordText = $env:PROPR_SUPERVISOR_FIXTURE_OWNED_PASSWORD + if ($userName -notmatch '^prpr[a-f0-9]{8}$' -or !$passwordText -or + (Get-LocalUser -Name $userName -ErrorAction SilentlyContinue)) { + throw 'smoke checkpoint user baseline is invalid' + } + $password = ConvertTo-SecureString $passwordText -AsPlainText -Force + $userMarker = "prpr-own-$([Guid]::NewGuid().ToString('N'))" + $userRecord = [ordered]@{ + Name = $userName + Sid = $null + Owned = $true + Provisional = $true + OwnershipMarker = $userMarker + } + $manifest.Users = @($userRecord) + Write-FixtureOwnershipManifest $manifest + New-LocalUser -Name $userName -Password $password -Description $userMarker ` + -AccountNeverExpires -PasswordNeverExpires | Out-Null + $userSid = (Get-LocalUser -Name $userName -ErrorAction Stop).SID.Value + $userRecord.Sid = $userSid + $userRecord.Provisional = $false + + $smokeDirectory = Join-Path $stateDirectory 'smoke-data' + $smokeRecord = [ordered]@{ + Kind = 'SMOKE_DATA' + Path = $smokeDirectory + Owned = $true + Token = $token + Identity = $null + Provisional = $true + UserSid = $userSid + CreatorSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value + RootOwnerSid = 'S-1-5-32-544' + } + $manifest.Directories = @($smokeRecord) + $manifest.Files = @() + $manifest.RegistryKeys = @() + $manifest.RegistryValues = @() + $manifest.Users = @($userRecord) + $manifest.Profiles = @() + Write-FixtureOwnershipManifest $manifest + + $resourceState = [ordered]@{ + OwnedRoot = $smokeDirectory + InstallRoot = Join-Path $stateDirectory 'absent-install-root' + ShortcutFolder = Join-Path $stateDirectory 'absent-shortcut-folder' + Shortcut = Join-Path $stateDirectory 'absent-shortcut.lnk' + SmokeDirectory = $smokeDirectory + RegistryPath = "Registry::HKEY_LOCAL_MACHINE\Software\ProPRSupervisorFixture\$($manifest.RunId)\absent" + RegistryRoot = "Registry::HKEY_LOCAL_MACHINE\Software\ProPRSupervisorFixture\$($manifest.RunId)" + UserName = $userName + UserSid = $userSid + ProfilePath = '' + ManifestPath = $OwnershipManifest + RunId = [string]$manifest.RunId + Token = $token + } + $resourceState | ConvertTo-Json -Compress | Set-Content -LiteralPath ` + (Join-Path $stateDirectory 'resources.json') -Encoding ASCII + + [void](New-Item -ItemType Directory -Path $smokeDirectory -ErrorAction Stop) + Set-FixtureSmokeAcl $smokeDirectory $userSid + Write-FixtureOwnershipToken (Join-Path $smokeDirectory '.propr-installed-app-owner') $token + if ($Checkpoint -eq 'BEFORE_PROMOTION') { return } + + $smokeRecord.Identity = [ProPRFixtureDirectoryIdentity]::Read($smokeDirectory) + $smokeRecord.Provisional = $false + Write-FixtureOwnershipManifest $manifest + if ($Checkpoint -eq 'AFTER_PROMOTION') { return } + + New-FixtureSmokeArtifacts $smokeDirectory +} + function Replace-FixtureOwnedResources { $state = Get-Content -LiteralPath (Join-Path $stateDirectory 'resources.json') ` -Raw -Encoding ASCII | ConvertFrom-Json -ErrorAction Stop @@ -306,6 +504,56 @@ function Add-FixtureForeignChild { ) } +function Add-FixtureForeignSmokeDescendant { + $state = Get-Content -LiteralPath (Join-Path $stateDirectory 'resources.json') ` + -Raw -Encoding ASCII | ConvertFrom-Json -ErrorAction Stop + $foreignPath = Join-Path $state.SmokeDirectory 'foreign-in-place.txt' + [IO.File]::WriteAllText($foreignPath, 'foreign-smoke-in-place', [Text.Encoding]::ASCII) + $currentSid = [Security.Principal.WindowsIdentity]::GetCurrent().User + $acl = [Security.AccessControl.FileSecurity]::new() + $acl.SetAccessRuleProtection($true, $false) + $acl.SetOwner($currentSid) + $rule = [Security.AccessControl.FileSystemAccessRule]::new( + $currentSid, + [Security.AccessControl.FileSystemRights]::FullControl, + [Security.AccessControl.AccessControlType]::Allow + ) + [void]$acl.AddAccessRule($rule) + Set-Acl -LiteralPath $foreignPath -AclObject $acl -ErrorAction Stop + $state | Add-Member -NotePropertyName ForeignSmokePath -NotePropertyValue $foreignPath + $state | ConvertTo-Json -Compress | Set-Content -LiteralPath ` + (Join-Path $stateDirectory 'resources.json') -Encoding ASCII +} + +function Test-PrimaryFallbackForeignDescendants { + $installRoot = Join-Path $stateDirectory 'primary-install-root' + $shortcutFolder = Join-Path $stateDirectory 'primary-shortcut-folder' + [void](New-Item -ItemType Directory -Path $installRoot -ErrorAction Stop) + [void](New-Item -ItemType Directory -Path $shortcutFolder -ErrorAction Stop) + $installForeign = Join-Path $installRoot 'foreign-in-place.txt' + $shortcutForeign = Join-Path $shortcutFolder 'foreign-in-place.txt' + [IO.File]::WriteAllText($installForeign, 'foreign-install', [Text.Encoding]::ASCII) + [IO.File]::WriteAllText($shortcutForeign, 'foreign-shortcut', [Text.Encoding]::ASCII) + foreach ($directory in @($installRoot, $shortcutFolder)) { + $identity = [ProPRFixtureDirectoryIdentity]::Read($directory) + if ([ProPRFixtureDirectoryIdentity]::Read($directory) -cne $identity) { + throw 'primary fallback directory identity changed' + } + if (@(Get-ChildItem -LiteralPath $directory -Force -ErrorAction Stop).Count -eq 0) { + Remove-Item -LiteralPath $directory -Force -ErrorAction Stop + throw 'primary fallback fixture did not contain a foreign descendant' + } + if (!(Test-Path -LiteralPath $directory -PathType Container)) { + throw 'primary fallback removed a nonempty owned directory' + } + } + [ordered]@{ + InstallForeign = $installForeign + ShortcutForeign = $shortcutForeign + } | ConvertTo-Json -Compress | Set-Content -LiteralPath ` + (Join-Path $stateDirectory 'primary-fallback.json') -Encoding ASCII +} + function Start-FixtureDescendant { $hostPath = (Get-Process -Id $PID -ErrorAction Stop).Path $startInfo = [Diagnostics.ProcessStartInfo]::new() @@ -416,6 +664,55 @@ switch ($scenario) { [DateTime]::UtcNow.AddSeconds(60).Ticks) Start-Sleep -Seconds 300 } + 'SMOKE_BEFORE_PROMOTION_THEN_DEADLINE' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + New-SmokeCheckpointFixtureResources 'BEFORE_PROMOTION' + Write-FixtureMarker ('{0}|USER_SETUP|SMOKE_DATA_CREATE|BEGIN' -f ` + [DateTime]::UtcNow.AddMilliseconds(500).Ticks) + Start-Sleep -Seconds 300 + } + 'SMOKE_AFTER_PROMOTION_THEN_DEADLINE' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + New-SmokeCheckpointFixtureResources 'AFTER_PROMOTION' + Write-FixtureMarker ('{0}|USER_SETUP|SMOKE_DATA_CREATE|COMPLETE' -f ` + [DateTime]::UtcNow.AddMilliseconds(500).Ticks) + Start-Sleep -Seconds 300 + } + 'SMOKE_AFTER_ARTIFACTS_THEN_DEADLINE' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + New-SmokeCheckpointFixtureResources 'AFTER_ARTIFACTS' + Write-FixtureMarker ('{0}|APP_EXIT|EVIDENCE_INSPECTION|BEGIN' -f ` + [DateTime]::UtcNow.AddMilliseconds(500).Ticks) + Start-Sleep -Seconds 300 + } + 'SMOKE_FOREIGN_DESCENDANT_THEN_DEADLINE' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + New-SmokeCheckpointFixtureResources 'AFTER_ARTIFACTS' + Add-FixtureForeignSmokeDescendant + Write-FixtureMarker ('{0}|CLEANUP|SMOKE_DATA_REMOVE|BEGIN' -f ` + [DateTime]::UtcNow.AddMilliseconds(500).Ticks) + Start-Sleep -Seconds 300 + } + 'SMOKE_TOKEN_MISMATCH_THEN_DEADLINE' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + New-SmokeCheckpointFixtureResources 'BEFORE_PROMOTION' + $owned = Get-Content -LiteralPath (Join-Path $stateDirectory 'resources.json') ` + -Raw -Encoding ASCII | ConvertFrom-Json -ErrorAction Stop + [IO.File]::WriteAllText( + (Join-Path $owned.SmokeDirectory '.propr-installed-app-owner'), + 'foreign-owner', + [Text.Encoding]::ASCII + ) + Write-FixtureMarker ('{0}|USER_SETUP|SMOKE_DATA_CREATE|BEGIN' -f ` + [DateTime]::UtcNow.AddMilliseconds(500).Ticks) + Start-Sleep -Seconds 300 + } + 'PRIMARY_FALLBACK_FOREIGN_DESCENDANTS' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + Test-PrimaryFallbackForeignDescendants + Write-FixtureMarker ('{0}|CLEANUP|SHORTCUT_FALLBACK|COMPLETE' -f ` + [DateTime]::UtcNow.AddSeconds(60).Ticks) + } 'OWNED_RESOURCES_REPLACED_THEN_DEADLINE' { Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) New-OwnedFixtureResources diff --git a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 index cebc9512a..02f2ac96f 100644 --- a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 @@ -206,7 +206,7 @@ function Invoke-WorkflowCleanupController( [string]$ManifestPath, [string]$RunId, [string]$FixtureRoot, - [int]$CleanupTimeoutMilliseconds = 30000 + [object]$CleanupTimeoutMilliseconds = 30000 ) { $startInfo = [Diagnostics.ProcessStartInfo]::new() $startInfo.FileName = $hostPath @@ -330,10 +330,17 @@ function Invoke-FixtureScenario( $stopwatch = [Diagnostics.Stopwatch]::StartNew() if (!$process.Start()) { throw 'supervisor test process did not start' } try { - $completionBound = if ($Scenario -in @( + $completionBound = if ($Scenario -ceq 'NO_MARKER') { + 60000 + } elseif ($Scenario -in @( 'OWNED_RESOURCES_THEN_DEADLINE', 'OWNED_RESOURCES_REPLACED_THEN_DEADLINE', - 'OWNED_RESOURCES_FOREIGN_CHILD_THEN_DEADLINE' + 'OWNED_RESOURCES_FOREIGN_CHILD_THEN_DEADLINE', + 'SMOKE_BEFORE_PROMOTION_THEN_DEADLINE', + 'SMOKE_AFTER_PROMOTION_THEN_DEADLINE', + 'SMOKE_AFTER_ARTIFACTS_THEN_DEADLINE', + 'SMOKE_FOREIGN_DESCENDANT_THEN_DEADLINE', + 'SMOKE_TOKEN_MISMATCH_THEN_DEADLINE' )) { 90000 } else { 20000 } if (!$process.WaitForExit($completionBound)) { try { $process.Kill($true) } catch {} @@ -360,7 +367,7 @@ function Test-BootstrapTimeout { $result = Invoke-FixtureScenario 'NO_MARKER' Assert-True ($result.ExitCode -eq 124) 'missing-marker bootstrap did not fail with the watchdog code' Assert-True ($result.ElapsedMilliseconds -ge 9000) 'bootstrap timeout ignored the injected deadline' - Assert-True ($result.ElapsedMilliseconds -lt 20000) 'missing-marker bootstrap completion was not bounded' + Assert-True ($result.ElapsedMilliseconds -lt 60000) 'missing-marker bootstrap completion was not bounded' Assert-Contains $result.Output ` 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:BOOTSTRAP:TIMED_OUT' ` 'missing-marker bootstrap did not emit the fixed timeout line' @@ -772,6 +779,16 @@ function Test-PreExistingCleanupOwnership { Assert-ProcessTreeGone $workflowProcessState Assert-True (Test-Path -LiteralPath $workflowManifest -PathType Leaf) ` 'killed supervisor did not preserve the durable ownership manifest' + $parameterFailure = Invoke-WorkflowCleanupController ` + $workflowManifest $workflowRunId $workflowStateDirectory -1 + Assert-True ($parameterFailure.ExitCode -eq 125 -and + $parameterFailure.Result -ceq 'FAILED' -and + $parameterFailure.ControllerStatus.StartsWith( + 'CONTROLLER_PARAMETER_VALIDATION_PARAMETERS_', + [StringComparison]::Ordinal + )) 'controller parameter failure was not caught and phase-classified' + Assert-True (Test-Path -LiteralPath $workflowManifest -PathType Leaf) ` + 'controller parameter failure discarded authenticated recovery authority' $timedOutCleanup = Invoke-WorkflowCleanupController ` $workflowManifest $workflowRunId $workflowStateDirectory 1 Assert-True ($timedOutCleanup.ExitCode -eq 124 -and @@ -950,6 +967,87 @@ function Test-PreExistingCleanupOwnership { [Console]::Out.Flush() } +function Test-SmokePromotionInterruptionAuthority { + foreach ($testCase in @( + @{ Scenario = 'SMOKE_BEFORE_PROMOTION_THEN_DEADLINE'; Label = 'before promotion' }, + @{ Scenario = 'SMOKE_AFTER_PROMOTION_THEN_DEADLINE'; Label = 'after promotion' }, + @{ Scenario = 'SMOKE_AFTER_ARTIFACTS_THEN_DEADLINE'; Label = 'after artifact creation' } + )) { + $stateDirectory = New-StateDirectory ( + 'smoke-' + $testCase.Scenario.ToLowerInvariant().Replace('_', '-')) + $result = Invoke-FixtureScenario $testCase.Scenario $stateDirectory + Assert-True ($result.ExitCode -eq 124) ` + "smoke interruption $($testCase.Label) did not preserve watchdog status" + Assert-Contains $result.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:COMPLETE' ` + "smoke interruption $($testCase.Label) did not complete recovery cleanup" + $owned = Read-FixtureResourceState $stateDirectory + Assert-OwnedResourcesGone $owned + } + + $foreignStateDirectory = New-StateDirectory 'smoke-in-place-foreign-descendant' + $foreignResult = Invoke-FixtureScenario ` + 'SMOKE_FOREIGN_DESCENDANT_THEN_DEADLINE' $foreignStateDirectory + Assert-True ($foreignResult.ExitCode -eq 125) ` + 'smoke foreign descendant did not fail closed' + Assert-Contains $foreignResult.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:FAILED' ` + 'smoke foreign descendant did not emit fixed cleanup failure evidence' + $foreignOwned = Read-FixtureResourceState $foreignStateDirectory + Assert-True ((Get-Content -LiteralPath $foreignOwned.ForeignSmokePath -Raw).Trim() -ceq ` + 'foreign-smoke-in-place') 'smoke foreign descendant was removed or changed' + Assert-True (Test-Path -LiteralPath $foreignOwned.ManifestPath -PathType Leaf) ` + 'smoke foreign descendant discarded authenticated recovery authority' + $foreignManifest = Get-Content -LiteralPath $foreignOwned.ManifestPath -Raw -Encoding UTF8 | + ConvertFrom-Json -ErrorAction Stop + Assert-True ($foreignManifest.State -ceq 'ACTIVE') ` + 'smoke foreign descendant did not preserve ACTIVE recovery authority' + Remove-Item -LiteralPath $foreignOwned.ForeignSmokePath -Force -ErrorAction Stop + $retry = Invoke-WorkflowCleanupController ` + $foreignOwned.ManifestPath $foreignOwned.RunId $foreignStateDirectory + Assert-True ($retry.ExitCode -eq 0 -and $retry.Result -ceq 'COMPLETE') ` + 'smoke foreign-descendant recovery did not retry to exact success' + Assert-OwnedResourcesGone $foreignOwned + + $tokenStateDirectory = New-StateDirectory 'smoke-token-mismatch' + $tokenResult = Invoke-FixtureScenario ` + 'SMOKE_TOKEN_MISMATCH_THEN_DEADLINE' $tokenStateDirectory + Assert-True ($tokenResult.ExitCode -eq 125) ` + 'mismatched smoke ownership token did not fail closed' + $tokenOwned = Read-FixtureResourceState $tokenStateDirectory + $tokenPath = Join-Path $tokenOwned.SmokeDirectory '.propr-installed-app-owner' + Assert-True ((Get-Content -LiteralPath $tokenPath -Raw).Trim() -ceq 'foreign-owner') ` + 'mismatched smoke ownership token was removed or changed' + Assert-True (Test-Path -LiteralPath $tokenOwned.ManifestPath -PathType Leaf) ` + 'mismatched smoke ownership token discarded recovery authority' + Remove-Item -LiteralPath $tokenPath -Force -ErrorAction Stop + $missingToken = Invoke-WorkflowCleanupController ` + $tokenOwned.ManifestPath $tokenOwned.RunId $tokenStateDirectory + Assert-True ($missingToken.ExitCode -eq 20 -and $missingToken.Result -ceq 'FAILED') ` + 'missing smoke ownership token did not fail manifest validation closed' + Assert-True (Test-Path -LiteralPath $tokenOwned.ManifestPath -PathType Leaf) ` + 'missing smoke ownership token discarded recovery authority' + [IO.File]::WriteAllText($tokenPath, [string]$tokenOwned.Token, [Text.Encoding]::ASCII) + $tokenRetry = Invoke-WorkflowCleanupController ` + $tokenOwned.ManifestPath $tokenOwned.RunId $tokenStateDirectory + Assert-True ($tokenRetry.ExitCode -eq 0 -and $tokenRetry.Result -ceq 'COMPLETE') ` + 'restored exact smoke ownership token did not retry to cleanup success' + Assert-OwnedResourcesGone $tokenOwned +} + +function Test-PrimaryWorkerFallbackForeignDescendants { + $stateDirectory = New-StateDirectory 'primary-fallback-foreign-descendants' + $result = Invoke-FixtureScenario 'PRIMARY_FALLBACK_FOREIGN_DESCENDANTS' $stateDirectory + Assert-True ($result.ExitCode -eq 0) ` + 'primary worker fallback foreign-descendant fixture did not complete' + $state = Get-Content -LiteralPath (Join-Path $stateDirectory 'primary-fallback.json') ` + -Raw -Encoding ASCII | ConvertFrom-Json -ErrorAction Stop + Assert-True ((Get-Content -LiteralPath $state.InstallForeign -Raw).Trim() -ceq ` + 'foreign-install') 'primary install fallback removed or changed a foreign descendant' + Assert-True ((Get-Content -LiteralPath $state.ShortcutForeign -Raw).Trim() -ceq ` + 'foreign-shortcut') 'primary shortcut fallback removed or changed a foreign descendant' +} + function Test-PreExistingAppPathsAuthority { $appPaths = ` 'Registry::HKEY_LOCAL_MACHINE\Software\Microsoft\Windows\CurrentVersion\App Paths\propr-desktop.exe' @@ -1336,7 +1434,9 @@ try { Test-OperationDeadlineAndTreeTermination Test-FailClosedMarkers Test-LiveCancellationAndRedaction + Test-PrimaryWorkerFallbackForeignDescendants Test-PreExistingCleanupOwnership + Test-SmokePromotionInterruptionAuthority Test-PreExistingAppPathsAuthority Test-HkcuInstalledValueOwnership Test-ProvisionalUserMarkerOwnership diff --git a/apps/desktop/scripts/test-installed-windows-app.ps1 b/apps/desktop/scripts/test-installed-windows-app.ps1 index cec226b69..179b8e78a 100644 --- a/apps/desktop/scripts/test-installed-windows-app.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app.ps1 @@ -95,6 +95,7 @@ $msiInstallCompleted = $false $testUserCreatedByRun = $false $testUserSid = $null $smokeUserDataDirectory = $null +$smokeOwnershipRecord = $null $installRootExistedBeforeInstall = $false $protocolExistedBeforeInstall = $false $appPathsExistedBeforeInstall = $false @@ -319,20 +320,25 @@ public static class ProPRDirectoryIdentity private static extern bool GetFileInformationByHandle( SafeFileHandle handle, out BY_HANDLE_FILE_INFORMATION information); - public static string Read(string path) + public static string ReadEntry(string path, bool expectDirectory) { using (SafeFileHandle handle = CreateFile( - path, 0x80, 0x7, IntPtr.Zero, 3, 0x02000000, IntPtr.Zero)) + path, 0x80, 0x7, IntPtr.Zero, 3, 0x02200000, IntPtr.Zero)) { if (handle == null || handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error(), "directory identity open failed"); BY_HANDLE_FILE_INFORMATION information; if (!GetFileInformationByHandle(handle, out information)) throw new Win32Exception(Marshal.GetLastWin32Error(), "directory identity read failed"); + bool isDirectory = (information.FileAttributes & 0x10) != 0; + if ((information.FileAttributes & 0x400) != 0 || isDirectory != expectDirectory) + throw new InvalidOperationException("file-system object identity changed"); return string.Format("{0:x8}{1:x8}{2:x8}", information.VolumeSerialNumber, information.FileIndexHigh, information.FileIndexLow); } } + + public static string Read(string path) { return ReadEntry(path, true); } } '@ @@ -365,6 +371,15 @@ function Get-DirectoryIdentity([string]$Path) { return [ProPRDirectoryIdentity]::Read($item.FullName) } +function Get-FileSystemEntryIdentity([string]$Path, [bool]$Directory) { + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if ($item.PSIsContainer -ne $Directory -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'file-system object identity is invalid' + } + return [ProPRDirectoryIdentity]::ReadEntry($item.FullName, $Directory) +} + function Get-RegistryTreeIdentity([string]$Path) { if (!(Test-Path -LiteralPath $Path)) { return $null } $root = Get-Item -LiteralPath $Path -ErrorAction Stop @@ -1090,45 +1105,234 @@ function New-SmokeUserDataDirectory( $invalidRules = @($actualRules | Where-Object { $_.IsInherited -or $_.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow -or ($_.FileSystemRights -band [Security.AccessControl.FileSystemRights]::FullControl) -ne - [Security.AccessControl.FileSystemRights]::FullControl + [Security.AccessControl.FileSystemRights]::FullControl -or + $_.InheritanceFlags -ne $inheritance -or $_.PropagationFlags -ne $propagation }) - if (!$appliedAcl.AreAccessRulesProtected -or $actualRules.Count -ne 3 -or + $appliedOwnerSid = $appliedAcl.GetOwner( + [Security.Principal.SecurityIdentifier]).Value + if ($appliedOwnerSid -cne $administratorsSid.Value -or + !$appliedAcl.AreAccessRulesProtected -or $actualRules.Count -ne 3 -or $invalidRules.Count -ne 0 -or (Compare-Object $expectedSids $actualSids)) { throw 'smoke user-data directory ACL is not restricted to the test user, SYSTEM, and Administrators' } return $path } catch { if ($createdByRun) { - Remove-Item -LiteralPath $path -Recurse -Force -ErrorAction SilentlyContinue + try { + if ((Test-Path -LiteralPath $path -PathType Container) -and + @(Get-ChildItem -LiteralPath $path -Force -ErrorAction Stop).Count -eq 0) { + Remove-Item -LiteralPath $path -Force -ErrorAction Stop + } + } catch {} } throw } } -function Remove-SmokeUserDataDirectory([string]$Path) { - if (!$Path) { return } - $fullPath = [IO.Path]::GetFullPath($Path) +function Assert-SmokeAccessControl($Item, $Record, [bool]$Root) { + $userSid = [string]$Record.UserSid + $creatorSid = [string]$Record.CreatorSid + $rootOwnerSid = [string]$Record.RootOwnerSid + if ($userSid -notmatch '^S-\d+(?:-\d+)+$' -or + $creatorSid -notmatch '^S-\d+(?:-\d+)+$' -or + $rootOwnerSid -cne 'S-1-5-32-544') { + throw 'smoke user-data manifest security authority is invalid' + } + $systemSid = 'S-1-5-18' + $expectedAccessSids = @($userSid, $systemSid, $rootOwnerSid) | Sort-Object -Unique + if ($expectedAccessSids.Count -ne 3) { + throw 'smoke user-data manifest security authority is invalid' + } + $acl = Get-Acl -LiteralPath $Item.FullName -ErrorAction Stop + $ownerSid = $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value + $allowedOwnerSids = @($userSid, $creatorSid, $rootOwnerSid) | Sort-Object -Unique + if ($allowedOwnerSids -cnotcontains $ownerSid) { + throw 'smoke user-data object owner is not authorized' + } + $rules = @($acl.Access) + $actualAccessSids = @($rules | ForEach-Object { + ($_.IdentityReference.Translate([Security.Principal.SecurityIdentifier])).Value + }) | Sort-Object -Unique + $fullControl = [Security.AccessControl.FileSystemRights]::FullControl + $expectedInheritance = [Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit' + $invalidRules = if ($Root) { + @($rules | Where-Object { + $_.IsInherited -or + $_.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow -or + ($_.FileSystemRights -band $fullControl) -ne $fullControl -or + $_.InheritanceFlags -ne $expectedInheritance -or + $_.PropagationFlags -ne [Security.AccessControl.PropagationFlags]::None + }) + } else { + $inheritedFlags = if ($Item.PSIsContainer) { + $expectedInheritance + } else { [Security.AccessControl.InheritanceFlags]::None } + @($rules | Where-Object { + !$_.IsInherited -or + $_.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow -or + ($_.FileSystemRights -band $fullControl) -ne $fullControl -or + $_.InheritanceFlags -ne $inheritedFlags -or + $_.PropagationFlags -ne [Security.AccessControl.PropagationFlags]::None + }) + } + if (($Root -and (!$acl.AreAccessRulesProtected -or $ownerSid -cne $rootOwnerSid)) -or + (!$Root -and $acl.AreAccessRulesProtected) -or + $rules.Count -ne 3 -or $invalidRules.Count -ne 0 -or + @(Compare-Object $expectedAccessSids $actualAccessSids).Count -ne 0) { + throw 'smoke user-data object ACL is not authorized' + } +} + +function Assert-OwnedSmokeRoot($Record) { + $fullPath = [IO.Path]::GetFullPath([string]$Record.Path) if ((Split-Path -Leaf $fullPath) -notmatch '^propr-desktop-smoke-[a-f0-9]{32}$' -or ![string]::Equals((Split-Path -Parent $fullPath), $machineTemp, [StringComparison]::OrdinalIgnoreCase)) { - throw 'refusing to clean a directory outside the bounded smoke user-data scope' + throw 'smoke user-data cleanup scope is invalid' } - if (Test-Path -LiteralPath $fullPath) { - $ownedDirectory = Get-Item -LiteralPath $fullPath -Force -ErrorAction Stop - if (!$ownedDirectory.PSIsContainer -or - ($ownedDirectory.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { - throw 'refusing to clean an invalid smoke user-data directory' + $item = Get-Item -LiteralPath $fullPath -Force -ErrorAction Stop + if (!$item.PSIsContainer -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + [string]$Record.Token -notmatch '^[a-f0-9]{32}$') { + throw 'smoke user-data root identity is invalid' + } + $markerPath = Join-Path $fullPath '.propr-installed-app-owner' + $marker = Get-Item -LiteralPath $markerPath -Force -ErrorAction Stop + if (!($marker -is [IO.FileInfo]) -or + ($marker.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'smoke user-data ownership token does not match' + } + $markerIdentity = Get-FileSystemEntryIdentity $marker.FullName $false + $markerStream = [IO.File]::Open( + $markerPath, + [IO.FileMode]::Open, + [IO.FileAccess]::Read, + [IO.FileShare]::Read + ) + try { + if ($markerStream.Length -le 0 -or $markerStream.Length -gt 128) { + throw 'smoke user-data ownership token does not match' + } + $markerBytes = [byte[]]::new([int]$markerStream.Length) + $markerOffset = 0 + while ($markerOffset -lt $markerBytes.Length) { + $markerRead = $markerStream.Read( + $markerBytes, $markerOffset, $markerBytes.Length - $markerOffset) + if ($markerRead -eq 0) { throw 'smoke user-data ownership token does not match' } + $markerOffset += $markerRead } + if ($markerStream.ReadByte() -ne -1 -or + [Text.Encoding]::ASCII.GetString($markerBytes) -cne [string]$Record.Token) { + throw 'smoke user-data ownership token does not match' + } + } finally { + $markerStream.Dispose() } - for ($attempt = 0; $attempt -lt 3; $attempt += 1) { - if (!(Test-Path -LiteralPath $fullPath)) { return } - try { - Remove-Item -LiteralPath $fullPath -Recurse -Force - } catch { - if ($attempt -eq 2) { throw } - Start-Sleep -Milliseconds 250 + Assert-SmokeAccessControl $item $Record $true + Assert-SmokeAccessControl $marker $Record $false + if ((Get-FileSystemEntryIdentity $marker.FullName $false) -cne $markerIdentity) { + throw 'smoke user-data ownership token identity changed' + } + return $item +} + +function Promote-SmokeOwnershipRecord($Record) { + if ($null -eq $testUserSid -or + [string]$Record.UserSid -cne [string]$testUserSid.Value) { + throw 'smoke user-data SID is not the exact run-owned user SID' + } + if (!(Test-Path -LiteralPath ([string]$Record.Path))) { return $false } + $root = Assert-OwnedSmokeRoot $Record + $identity = Get-FileSystemEntryIdentity $root.FullName $true + if ([bool]$Record.Provisional) { + $Record.Identity = $identity + $Record.Provisional = $false + Write-OwnershipManifest + } elseif ([string]$Record.Identity -notmatch '^[a-f0-9]{24}$' -or + [string]$Record.Identity -cne $identity) { + throw 'smoke user-data root identity does not match' + } + return $true +} + +function Remove-SmokeUserDataDirectory($Record) { + if ($null -eq $Record -or !(Test-Path -LiteralPath ([string]$Record.Path))) { return } + if ([bool]$Record.Provisional) { + throw 'provisional smoke user-data authority was not durably promoted' + } + $root = Assert-OwnedSmokeRoot $Record + if ([string]$Record.Identity -notmatch '^[a-f0-9]{24}$' -or + (Get-FileSystemEntryIdentity $root.FullName $true) -cne [string]$Record.Identity) { + throw 'smoke user-data root identity does not match' + } + $rootPath = $root.FullName.TrimEnd('\') + $pending = [Collections.Generic.Queue[object]]::new() + $pending.Enqueue([PSCustomObject]@{ + Path = $root.FullName + Identity = [string]$Record.Identity + Root = $true + }) + $entries = [Collections.Generic.List[object]]::new() + while ($pending.Count -ne 0) { + $queuedDirectory = $pending.Dequeue() + $directory = Get-Item -LiteralPath $queuedDirectory.Path -Force -ErrorAction Stop + Assert-SmokeAccessControl $directory $Record ([bool]$queuedDirectory.Root) + if ((Get-FileSystemEntryIdentity $directory.FullName $true) -cne + [string]$queuedDirectory.Identity) { + throw 'smoke user-data directory identity changed during traversal' } + foreach ($child in @(Get-ChildItem -LiteralPath $directory.FullName -Force -ErrorAction Stop)) { + if ($entries.Count -ge 50000) { throw 'smoke user-data cleanup entry bound was exceeded' } + $childPath = [IO.Path]::GetFullPath($child.FullName) + if (!$childPath.StartsWith("$rootPath\", [StringComparison]::OrdinalIgnoreCase) -or + ($child.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'smoke user-data descendant scope is invalid' + } + Assert-SmokeAccessControl $child $Record $false + $identity = Get-FileSystemEntryIdentity $childPath ([bool]$child.PSIsContainer) + $entries.Add([PSCustomObject]@{ + Path = $childPath + Directory = [bool]$child.PSIsContainer + Identity = $identity + }) + if ($child.PSIsContainer) { + $pending.Enqueue([PSCustomObject]@{ + Path = $childPath + Identity = $identity + Root = $false + }) + } + } + } + + foreach ($entry in @($entries | Where-Object { !$_.Directory })) { + $item = Get-Item -LiteralPath $entry.Path -Force -ErrorAction Stop + Assert-SmokeAccessControl $item $Record $false + if ((Get-FileSystemEntryIdentity $entry.Path $false) -cne [string]$entry.Identity) { + throw 'smoke user-data file identity changed during cleanup' + } + Remove-Item -LiteralPath $entry.Path -Force -ErrorAction Stop + } + foreach ($entry in @($entries | Where-Object { $_.Directory } | + Sort-Object { ([string]$_.Path).Length } -Descending)) { + $item = Get-Item -LiteralPath $entry.Path -Force -ErrorAction Stop + Assert-SmokeAccessControl $item $Record $false + if ((Get-FileSystemEntryIdentity $entry.Path $true) -cne [string]$entry.Identity -or + @(Get-ChildItem -LiteralPath $entry.Path -Force -ErrorAction Stop).Count -ne 0) { + throw 'smoke user-data directory identity changed or is not empty' + } + Remove-Item -LiteralPath $entry.Path -Force -ErrorAction Stop + } + $root = Get-Item -LiteralPath $rootPath -Force -ErrorAction Stop + if (!$root.PSIsContainer -or + ($root.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'smoke user-data root identity changed during cleanup' } - if (Test-Path -LiteralPath $fullPath) { throw 'smoke user-data directory cleanup did not complete' } + Assert-SmokeAccessControl $root $Record $true + if ((Get-FileSystemEntryIdentity $root.FullName $true) -cne [string]$Record.Identity -or + @(Get-ChildItem -LiteralPath $root.FullName -Force -ErrorAction Stop).Count -ne 0) { + throw 'smoke user-data root changed or is not empty' + } + Remove-Item -LiteralPath $root.FullName -Force -ErrorAction Stop } function Get-SmokeEventEvidence( @@ -1544,7 +1748,10 @@ try { } $smokeOwnershipRecord = [ordered]@{ Kind = 'SMOKE_DATA'; Path = $smokeUserDataCandidate - Owned = $true; Token = $ownershipToken; Provisional = $true + Owned = $true; Token = $ownershipToken; Identity = $null; Provisional = $true + UserSid = $testUserSid.Value + CreatorSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value + RootOwnerSid = 'S-1-5-32-544' } $ownershipState.Directories = @($ownershipState.Directories) + @($smokeOwnershipRecord) Write-OwnershipManifest @@ -1554,8 +1761,9 @@ try { Write-DurableOwnershipToken ` -Path (Join-Path $ownedSmokeDirectory '.propr-installed-app-owner') ` -Token $ownershipToken - $smokeOwnershipRecord.Provisional = $false - Write-OwnershipManifest + if (!(Promote-SmokeOwnershipRecord $smokeOwnershipRecord)) { + throw 'smoke user-data ownership promotion did not complete' + } $ownedSmokeDirectory } Invoke-BoundedExternalOperation ` @@ -1812,7 +2020,7 @@ try { try { Invoke-BoundedExternalOperation ` 'CLEANUP' 'SMOKE_DATA_REMOVE' $recursiveOperationTimeoutMilliseconds { - Remove-SmokeUserDataDirectory $smokeUserDataDirectory + Remove-SmokeUserDataDirectory $smokeOwnershipRecord } Write-CleanupSubstage 'CLEANUP' 'SMOKE_DATA' 'COMPLETE' } catch { @@ -1885,7 +2093,10 @@ try { (Get-DirectoryIdentity $installRoot) -cne $installRootOwnedIdentity) { throw 'refusing to remove an install tree with a mismatched ownership identity' } - Remove-Item -LiteralPath $installRoot -Recurse -Force -ErrorAction Stop + if (@(Get-ChildItem -LiteralPath $installRoot -Force -ErrorAction Stop).Count -ne 0) { + throw 'owned install tree is not empty' + } + Remove-Item -LiteralPath $installRoot -Force -ErrorAction Stop } } Write-CleanupSubstage 'CLEANUP' 'INSTALL_ROOT_FALLBACK' 'COMPLETE' @@ -1966,7 +2177,11 @@ try { (Get-DirectoryIdentity $startMenuShortcutFolder) -cne $shortcutFolderOwnedIdentity) { throw 'refusing to remove a shortcut folder with a mismatched ownership identity' } - Remove-Item -LiteralPath $startMenuShortcutFolder -Recurse -Force -ErrorAction Stop + if (@(Get-ChildItem -LiteralPath $startMenuShortcutFolder -Force ` + -ErrorAction Stop).Count -ne 0) { + throw 'owned common Start Menu folder is not empty' + } + Remove-Item -LiteralPath $startMenuShortcutFolder -Force -ErrorAction Stop } } } catch { diff --git a/apps/desktop/src/release-workflow.test.ts b/apps/desktop/src/release-workflow.test.ts index ac804b25b..12cac72ff 100644 --- a/apps/desktop/src/release-workflow.test.ts +++ b/apps/desktop/src/release-workflow.test.ts @@ -383,7 +383,7 @@ describe('desktop trusted release workflow', () => { assert.match(installedWindowsAppTest, /SetAccessRuleProtection\(\$true, \$false\)/); assert.match(installedWindowsAppTest, /S-1-5-18/); assert.match(installedWindowsAppTest, /S-1-5-32-544/); - assert.match(installedWindowsAppTest, /Remove-SmokeUserDataDirectory \$smokeUserDataDirectory/); + assert.match(installedWindowsAppTest, /Remove-SmokeUserDataDirectory \$smokeOwnershipRecord/); assert.match(installedWindowsAppTest, /propr:\/\/connect/); assert.match(installedWindowsAppTest, /deferred Windows update authority resource/); assert.match(installedWindowsAppTest, /\[Environment\]::GetFolderPath\(\[Environment\+SpecialFolder\]::CommonPrograms\)/); @@ -473,7 +473,11 @@ describe('desktop trusted release workflow', () => { assert.match(installedWindowsAppTest, /\$item\.Attributes -band \[IO\.FileAttributes\]::ReparsePoint/); assert.match(installedWindowsAppTest, /\[IO\.FileStream\]::new\(/); assert.doesNotMatch(installedWindowsAppTest, /New-Object IO\.FileStream\(/); - assert.doesNotMatch(installedWindowsAppTest, /Get-ChildItem[^\n]*smoke|ReadAll|ReadToEnd/); + const evidenceReader = installedWindowsAppTest.slice( + installedWindowsAppTest.indexOf('function Get-SmokeEventEvidence'), + installedWindowsAppTest.indexOf("Write-Stage 'INSTALL' 'BEGIN'"), + ); + assert.doesNotMatch(evidenceReader, /Get-ChildItem[^\n]*smoke|ReadAll|ReadToEnd/); const smokeEventAllowlist = installedWindowsAppTest.match( /\$smokeEventCodes = \[ordered\]@\{([\s\S]*?)\n\}/, ); @@ -549,11 +553,14 @@ describe('desktop trusted release workflow', () => { assert.match( installedWindowsAppTest, - /\} catch \{\n\s+\$primaryFailure = \$_\n\s+throw\n\} finally \{\n\s+\$cleanupFailed = \$false[\s\S]*Invoke-Msi @\('\/x'[\s\S]*Remove-SmokeUserDataDirectory \$smokeUserDataDirectory/, + /\} catch \{\n\s+\$primaryFailure = \$_\n\s+throw\n\} finally \{\n\s+\$cleanupFailed = \$false[\s\S]*Invoke-Msi @\('\/x'[\s\S]*Remove-SmokeUserDataDirectory \$smokeOwnershipRecord/, ); assert.match(installedWindowsAppTest, /Get-CimInstance -ClassName Win32_UserProfile/); assert.match(installedWindowsAppTest, /Remove-LocalUser -Name \$testUser -ErrorAction Stop/); - assert.match(installedWindowsAppTest, /Remove-Item -LiteralPath \$installRoot -Recurse -Force -ErrorAction Stop/); + assert.match( + installedWindowsAppTest, + /Get-ChildItem -LiteralPath \$installRoot -Force -ErrorAction Stop[\s\S]*Remove-Item -LiteralPath \$installRoot -Force -ErrorAction Stop/, + ); for (const section of [job('package', 'finalize'), job('release-package', 'release-finalize')]) { assert.match(section, /- platform: win32\n\s+arch: x64\n/); @@ -670,6 +677,14 @@ describe('desktop trusted release workflow', () => { assert.match(installedWindowsAppWorkflowCleanup, /reader\.ReadAsync/); assert.match(installedWindowsAppWorkflowCleanup, /STREAM_DRAIN_(?:TIMEOUT|FAILURE)/); assert.match(installedWindowsAppWorkflowCleanup, /CHILD_STDERR/); + assert.match(installedWindowsAppWorkflowCleanup, /WorkflowCleanupControllerPhase/); + assert.match(installedWindowsAppWorkflowCleanup, /WorkflowCleanupControllerLine/); + assert.match(installedWindowsAppWorkflowCleanup, /Set-CaughtControllerFailure/); + assert.match( + installedWindowsAppWorkflowCleanup, + /trap \{[\s\S]*if \(\$script:controllerBodyActive\)[\s\S]*break controllerBody[\s\S]*:controllerBody do \{/, + ); + assert.match(installedWindowsAppWorkflowCleanup, /CancelAndFinish/); assert.doesNotMatch( installedWindowsAppWorkflowCleanup, /add_(?:Output|Error)DataReceived|Begin(?:Output|Error)ReadLine/, @@ -688,6 +703,22 @@ describe('desktop trusted release workflow', () => { /OWNED_RESOURCES_FOREIGN_CHILD_THEN_DEADLINE/, ); assert.match(installedWindowsAppSupervisorBehaviorTest, /in-place foreign child was removed or changed/); + for (const checkpoint of [ + 'SMOKE_BEFORE_PROMOTION_THEN_DEADLINE', + 'SMOKE_AFTER_PROMOTION_THEN_DEADLINE', + 'SMOKE_AFTER_ARTIFACTS_THEN_DEADLINE', + 'SMOKE_FOREIGN_DESCENDANT_THEN_DEADLINE', + 'SMOKE_TOKEN_MISMATCH_THEN_DEADLINE', + ]) { + assert.match(installedWindowsAppSupervisorBehaviorTest, new RegExp(checkpoint)); + assert.match(installedWindowsAppSupervisorFixture, new RegExp(checkpoint)); + } + assert.match(installedWindowsAppSupervisorBehaviorTest, /foreign-smoke-in-place/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Test-PrimaryWorkerFallbackForeignDescendants/); + assert.match(installedWindowsAppSupervisorFixture, /PRIMARY_FALLBACK_FOREIGN_DESCENDANTS/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /primary install fallback removed or changed/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /primary shortcut fallback removed or changed/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /CONTROLLER_PARAMETER_VALIDATION_PARAMETERS_/); assert.match(installedWindowsAppSupervisorBehaviorTest, /InjectTerminationFailure/); assert.match(installedWindowsAppSupervisorBehaviorTest, /termination failure discarded authenticated recovery authority/); assert.match(installedWindowsAppSupervisorBehaviorTest, /Test-ProvisionalUserMarkerOwnership/); @@ -702,6 +733,26 @@ describe('desktop trusted release workflow', () => { assert.doesNotMatch(ownedDirectoryCleanup, /Remove-Item[^\n]*-Recurse/); assert.match(ownedDirectoryCleanup, /owned directory contains an unexpected descendant/); assert.match(ownedDirectoryCleanup, /Get-ChildItem[^\n]*-Force/); + assert.match(installedWindowsAppCleanup, /Resolve-SmokeDirectoryAuthority/); + assert.match(installedWindowsAppCleanup, /Remove-OwnedSmokeDirectory/); + assert.match(installedWindowsAppCleanup, /Get-FileSystemEntryIdentity/); + assert.match(installedWindowsAppCleanup, /smoke user-data object owner is not authorized/); + assert.match(installedWindowsAppCleanup, /smoke user-data object ACL is not authorized/); + assert.match(installedWindowsAppCleanup, /entries\.Count -ge 50000/); + const smokeCleanup = installedWindowsAppCleanup.slice( + installedWindowsAppCleanup.indexOf('function Remove-OwnedSmokeDirectory'), + installedWindowsAppCleanup.indexOf('function Remove-OwnedDirectory'), + ); + assert.doesNotMatch(smokeCleanup, /Remove-Item[^\n]*-Recurse/); + assert.match(smokeCleanup, /Get-ChildItem[^\n]*-Force/); + assert.match( + installedWindowsAppTest, + /Write-DurableOwnershipToken[\s\S]*Promote-SmokeOwnershipRecord[\s\S]*SHORTCUT_PRESENT_PROBE/, + ); + assert.match( + installedWindowsAppTest, + /CreatorSid = \[Security\.Principal\.WindowsIdentity\]::GetCurrent\(\)\.User\.Value/, + ); assert.match( installedWindowsAppSupervisorBehaviorTest, /replacement install tree was removed or changed/, @@ -854,7 +905,7 @@ describe('desktop trusted release workflow', () => { ); assert.match( installedWindowsAppTest, - /if \(\$installRootCreatedByRun -and \(Test-Path -LiteralPath \$installRoot\)\)[\s\S]*Remove-Item -LiteralPath \$installRoot -Recurse/, + /if \(\$installRootCreatedByRun -and \(Test-Path -LiteralPath \$installRoot\)\)[\s\S]*Get-ChildItem -LiteralPath \$installRoot -Force[\s\S]*Remove-Item -LiteralPath \$installRoot -Force/, ); assert.match( installedWindowsAppTest, @@ -866,7 +917,7 @@ describe('desktop trusted release workflow', () => { ); assert.match( installedWindowsAppTest, - /if \(\$createdByRun\) \{\n\s+Remove-Item -LiteralPath \$path -Recurse/, + /if \(\$createdByRun\) \{[\s\S]*Get-ChildItem -LiteralPath \$path -Force[\s\S]*Remove-Item -LiteralPath \$path -Force/, ); }); @@ -1120,8 +1171,18 @@ describe('desktop trusted release workflow', () => { ); assert.match( cleanup, - /if \(\$startMenuShortcutFolderCreatedByRun[\s\S]*Get-DirectoryIdentity \$startMenuShortcutFolder[\s\S]*Remove-Item -LiteralPath \$startMenuShortcutFolder -Recurse -Force -ErrorAction Stop/, + /if \(\$startMenuShortcutFolderCreatedByRun[\s\S]*Get-DirectoryIdentity \$startMenuShortcutFolder[\s\S]*Get-ChildItem -LiteralPath \$startMenuShortcutFolder -Force[\s\S]*Remove-Item -LiteralPath \$startMenuShortcutFolder -Force -ErrorAction Stop/, + ); + const installFallback = cleanup.slice( + cleanup.indexOf("'CLEANUP' 'INSTALL_ROOT_FALLBACK' 'BEGIN'"), + cleanup.indexOf("'CLEANUP' 'PROTOCOL_FALLBACK' 'BEGIN'"), + ); + const shortcutFallback = cleanup.slice( + cleanup.indexOf("'CLEANUP' 'SHORTCUT_FALLBACK' 'BEGIN'"), + cleanup.indexOf("'CLEANUP' 'FINAL_AGGREGATION' 'BEGIN'"), ); + assert.doesNotMatch(installFallback, /Remove-Item[^\n]*-Recurse/); + assert.doesNotMatch(shortcutFallback, /Remove-Item[^\n]*-Recurse/); assert.doesNotMatch( installedWindowsAppTest, /Remove-Item[^\n]*\$commonPrograms[^\n]*-Recurse|Remove-Item[^\n]*-Recurse[^\n]*\$commonPrograms/, From a30d8bf312197d08c65f6cef461d5720b64d43f4 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 04:30:25 +0000 Subject: [PATCH 10/29] feat(ai): Implemented the exact-head F17/F18 correction without committing. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented the exact-head F17/F18 correction without committing. Key changes: - Added durable install-tree, descendant, shortcut-folder, and shortcut object identities, revalidated immediately before every MSI `/x` invocation. - Replacement or provisional authority now aborts before MSI or cleanup mutation, preserves resources, and retains ACTIVE recovery authority. - Added distinct replaced-executable and replaced-shortcut retry fixtures. - Replaced the unsafe signed-exit-to-`uint32` cast with fixed termination code `125`; added a negative-exit tree-cleanup fixture. - Deferred fixture `Add-Type` until after process state and the first valid marker. - Parsed fixed controller stdout before stderr classification and suppressed raw controller stderr before cold type loading; child stderr remains bounded and classified. - Added static contracts covering F17/F18 while retaining F10–F16. Validation: - Desktop suite: 177 passed, 6 platform skips. - Desktop typecheck: passed. - Focused workflow contracts: passed. - `git diff --check`: passed. The native x64/ARM64 supervisor fixture requires the Windows CI matrix; it cannot run in this Linux workspace. PR: #2042 Comment by: @integry (ID: 5488805055) Model: gpt-5.6-sol --- .../scripts/cleanup-installed-windows-app.ps1 | 120 ++++++++++++++++ .../run-installed-windows-app-harness.ps1 | 5 +- ...installed-windows-app-workflow-cleanup.ps1 | 5 + ...stalled-windows-app-supervisor-fixture.ps1 | 133 ++++++++++++++++-- .../test-installed-windows-app-supervisor.ps1 | 132 ++++++++++++++--- .../scripts/test-installed-windows-app.ps1 | 91 +++++++++++- apps/desktop/src/release-workflow.test.ts | 56 ++++++++ 7 files changed, 503 insertions(+), 39 deletions(-) diff --git a/apps/desktop/scripts/cleanup-installed-windows-app.ps1 b/apps/desktop/scripts/cleanup-installed-windows-app.ps1 index 5872a84e9..ea389ea76 100644 --- a/apps/desktop/scripts/cleanup-installed-windows-app.ps1 +++ b/apps/desktop/scripts/cleanup-installed-windows-app.ps1 @@ -146,6 +146,122 @@ function Get-FileSystemEntryIdentity([string]$Path, [bool]$Directory) { return [ProPRDirectoryIdentity]::ReadEntry($item.FullName, $Directory) } +function Get-FileSystemTreeIdentity([string]$Path) { + $root = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if (!$root.PSIsContainer -or + ($root.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'file-system tree root identity is invalid' + } + $rootPath = $root.FullName.TrimEnd('\') + $records = [Collections.Generic.List[string]]::new() + $records.Add(('D||{0}' -f (Get-FileSystemEntryIdentity $rootPath $true))) + foreach ($entry in @(Get-ChildItem -LiteralPath $rootPath -Recurse -Force -ErrorAction Stop)) { + if (($entry.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'file-system tree contains a reparse point' + } + $relativePath = $entry.FullName.Substring($rootPath.Length).TrimStart('\') + if (!$relativePath -or [IO.Path]::IsPathRooted($relativePath)) { + throw 'file-system tree relative path is invalid' + } + $kind = if ($entry.PSIsContainer) { 'D' } else { 'F' } + $relative = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($relativePath)) + $identity = Get-FileSystemEntryIdentity $entry.FullName ([bool]$entry.PSIsContainer) + $records.Add(('{0}|{1}|{2}' -f $kind, $relative, $identity)) + } + $recordArray = $records.ToArray() + [Array]::Sort($recordArray, [StringComparer]::Ordinal) + $payload = [Text.Encoding]::UTF8.GetBytes(($recordArray -join "`n")) + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + return [BitConverter]::ToString($sha256.ComputeHash($payload)).Replace('-', '').ToLowerInvariant() + } finally { + $sha256.Dispose() + } +} + +function Assert-MsiManagedFileSystemAuthority($Manifest) { + $installRootPath = if ($FixtureRoot) { $null } else { + Join-Path $env:ProgramFiles 'ProPR Desktop' + } + $installRoot = if ($FixtureRoot) { + @($Manifest.Directories | Where-Object { + $_.Owned -and [string]$_.Kind -ceq 'INSTALL_ROOT' + }) + } else { + @($Manifest.Directories | Where-Object { + $_.Owned -and [string]$_.Kind -ceq 'INSTALL_ROOT' -and + (Test-SamePath ([string]$_.Path) $installRootPath) + }) + } + $shortcutFolderPath = if ($FixtureRoot) { $null } else { + Join-Path ([Environment]::GetFolderPath( + [Environment+SpecialFolder]::CommonPrograms)) 'ProPR Desktop' + } + $shortcutFolder = if ($FixtureRoot) { + @($Manifest.Directories | Where-Object { + $_.Owned -and [string]$_.Kind -ceq 'SHORTCUT_FOLDER' + }) + } else { + @($Manifest.Directories | Where-Object { + $_.Owned -and [string]$_.Kind -ceq 'SHORTCUT_FOLDER' -and + (Test-SamePath ([string]$_.Path) $shortcutFolderPath) + }) + } + $shortcutPath = if ($FixtureRoot) { $null } else { + Join-Path $shortcutFolderPath 'ProPR Desktop.lnk' + } + $shortcut = if ($FixtureRoot) { + @($Manifest.Files | Where-Object { + $_.Owned -and [string]$_.Kind -ceq 'SHORTCUT_FILE' + }) + } else { + @($Manifest.Files | Where-Object { + $_.Owned -and [string]$_.Kind -ceq 'SHORTCUT_FILE' -and + (Test-SamePath ([string]$_.Path) $shortcutPath) + }) + } + + foreach ($candidate in @( + [PSCustomObject]@{ + Records = $installRoot; Path = $installRootPath; Directory = $true; Tree = $true + }, + [PSCustomObject]@{ + Records = $shortcutFolder; Path = $shortcutFolderPath; Directory = $true; Tree = $true + }, + [PSCustomObject]@{ + Records = $shortcut; Path = $shortcutPath; Directory = $false; Tree = $false + } + )) { + $candidatePath = if ($FixtureRoot -and $candidate.Records.Count -eq 1) { + [string]$candidate.Records[0].Path + } else { [string]$candidate.Path } + if (!$candidatePath -or !(Test-Path -LiteralPath $candidatePath)) { continue } + if ($candidate.Records.Count -ne 1) { + throw 'MSI-managed file-system authority is missing or ambiguous' + } + $record = $candidate.Records[0] + $entryIdentity = if ($candidate.Directory) { + [string]$record.Identity + } else { [string]$record.EntryIdentity } + if ([bool]$record.Provisional -or + $entryIdentity -notmatch '^[a-f0-9]{24}$' -or + (Get-FileSystemEntryIdentity $candidatePath $candidate.Directory) -cne + $entryIdentity) { + throw 'MSI-managed file-system object identity does not match' + } + if ($candidate.Tree) { + if ([string]$record.TreeIdentity -notmatch '^[a-f0-9]{64}$' -or + (Get-FileSystemTreeIdentity $candidatePath) -cne + [string]$record.TreeIdentity) { + throw 'MSI-managed file-system tree identity does not match' + } + } elseif ([string]$record.Identity -notmatch '^[a-f0-9]{64}$' -or + (Get-FileIdentity $candidatePath) -cne [string]$record.Identity) { + throw 'MSI-managed shortcut content identity does not match' + } + } +} + function Get-RegistryTreeIdentity([string]$Path) { if (!(Test-Path -LiteralPath $Path)) { return $null } $root = Get-Item -LiteralPath $Path -ErrorAction Stop @@ -1080,10 +1196,14 @@ try { $cleanupFailed = $true } } + if ([bool]$manifest.InstallAttempted) { + Assert-MsiManagedFileSystemAuthority $manifest + } if ($allowProvisionalMsiUninstall -and !$cleanupFailed) { $msiExitCode = 1618 for ($attempt = 0; $attempt -lt 12 -and $msiExitCode -eq 1618; $attempt += 1) { if ($attempt -ne 0) { Start-Sleep -Seconds 2 } + Assert-MsiManagedFileSystemAuthority $manifest $msi = Start-Process msiexec.exe -ArgumentList @( '/x', "`"$resolvedInstaller`"", '/qn', '/norestart' ) -PassThru -WindowStyle Hidden -ErrorAction Stop diff --git a/apps/desktop/scripts/run-installed-windows-app-harness.ps1 b/apps/desktop/scripts/run-installed-windows-app-harness.ps1 index 3803a3acd..7693caf73 100644 --- a/apps/desktop/scripts/run-installed-windows-app-harness.ps1 +++ b/apps/desktop/scripts/run-installed-windows-app-harness.ps1 @@ -693,7 +693,10 @@ try { !$supervisorOutcomeComplete $fixedCleanupResult = $null if ($cleanupRequired -and $installerPath -and $ownershipRunId) { - $workerTreeTerminated = Stop-OwnedWorker ([uint32]$exitCode) + # Process.ExitCode is signed and can be negative after a native crash. The + # Job Object API requires a valid uint32, so finalization always uses this + # fixed supervisor-owned termination code instead of casting worker status. + $workerTreeTerminated = Stop-OwnedWorker 125 if ($workerTreeTerminated) { $fixedCleanupResult = Invoke-PostTerminationCleanup $installerPath $FixtureCleanupRoot } else { diff --git a/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 b/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 index f6b184676..d5439ccb1 100644 --- a/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 +++ b/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 @@ -112,6 +112,11 @@ trap { $controllerBodyActive = $true :controllerBody do { +# The controller has a fixed stdout protocol and maps every caught failure to +# that protocol. Suppress the host's architecture-specific raw error rendering +# before cold type load; child stdout/stderr remain separately pumped, bounded, +# and classified below. +[Console]::SetError([IO.TextWriter]::Null) Add-Type -TypeDefinition @' using System; using System.ComponentModel; 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 c3e9eeff7..4268fa095 100644 --- a/apps/desktop/scripts/test-installed-windows-app-supervisor-fixture.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app-supervisor-fixture.ps1 @@ -8,7 +8,8 @@ param( $ErrorActionPreference = 'Stop' -Add-Type -TypeDefinition @' +function Initialize-FixtureDirectoryIdentity { + Add-Type -TypeDefinition @' using System; using System.ComponentModel; using System.Runtime.InteropServices; @@ -37,7 +38,7 @@ public static class ProPRFixtureDirectoryIdentity [DllImport("kernel32.dll", SetLastError = true)] private static extern bool GetFileInformationByHandle( SafeFileHandle handle, out BY_HANDLE_FILE_INFORMATION information); - public static string Read(string path) + public static string ReadEntry(string path, bool expectDirectory) { using (SafeFileHandle handle = CreateFile( path, 0x80, 0x7, IntPtr.Zero, 3, 0x02200000, IntPtr.Zero)) @@ -47,15 +48,18 @@ public static class ProPRFixtureDirectoryIdentity BY_HANDLE_FILE_INFORMATION information; if (!GetFileInformationByHandle(handle, out information)) throw new Win32Exception(Marshal.GetLastWin32Error()); + bool isDirectory = (information.FileAttributes & 0x10) != 0; if ((information.FileAttributes & 0x400) != 0 || - (information.FileAttributes & 0x10) == 0) - throw new InvalidOperationException("fixture directory identity changed"); + isDirectory != expectDirectory) + throw new InvalidOperationException("fixture entry identity changed"); return string.Format("{0:x8}{1:x8}{2:x8}", information.VolumeSerialNumber, information.FileIndexHigh, information.FileIndexLow); } } + public static string Read(string path) { return ReadEntry(path, true); } } '@ +} $scenario = $env:PROPR_SUPERVISOR_FIXTURE_SCENARIO $stateDirectory = $env:PROPR_SUPERVISOR_FIXTURE_STATE_DIRECTORY if ($scenario -notin @( @@ -65,6 +69,7 @@ if ($scenario -notin @( 'TORN_MARKER', 'STALE_MARKER', 'INACCESSIBLE_MARKER', + 'NEGATIVE_EXIT', 'CANCELLATION', 'OWNED_RESOURCES_NORMAL_SUCCESS', 'OWNED_RESOURCES_FOR_INTERRUPTION', @@ -75,6 +80,8 @@ if ($scenario -notin @( 'SMOKE_TOKEN_MISMATCH_THEN_DEADLINE', 'PRIMARY_FALLBACK_FOREIGN_DESCENDANTS', 'OWNED_RESOURCES_FOREIGN_CHILD_THEN_DEADLINE', + 'OWNED_EXECUTABLE_REPLACED_THEN_DEADLINE', + 'OWNED_SHORTCUT_REPLACED_THEN_DEADLINE', 'OWNED_RESOURCES_REPLACED_THEN_DEADLINE', 'OWNED_RESOURCES_THEN_DEADLINE' )) { @@ -153,6 +160,43 @@ function Get-FixtureFileIdentity([string]$Path) { } } +function Get-FixtureEntryIdentity([string]$Path, [bool]$Directory) { + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if ($item.PSIsContainer -ne $Directory -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'fixture file-system object identity is invalid' + } + return [ProPRFixtureDirectoryIdentity]::ReadEntry($item.FullName, $Directory) +} + +function Get-FixtureTreeIdentity([string]$Path) { + $root = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if (!$root.PSIsContainer -or + ($root.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'fixture tree root identity is invalid' + } + $rootPath = $root.FullName.TrimEnd('\') + $records = [Collections.Generic.List[string]]::new() + $records.Add(('D||{0}' -f (Get-FixtureEntryIdentity $rootPath $true))) + foreach ($entry in @(Get-ChildItem -LiteralPath $rootPath -Recurse -Force -ErrorAction Stop)) { + if (($entry.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'fixture tree contains a reparse point' + } + $relativePath = $entry.FullName.Substring($rootPath.Length).TrimStart('\') + $kind = if ($entry.PSIsContainer) { 'D' } else { 'F' } + $relative = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($relativePath)) + $identity = Get-FixtureEntryIdentity $entry.FullName ([bool]$entry.PSIsContainer) + $records.Add(('{0}|{1}|{2}' -f $kind, $relative, $identity)) + } + $recordArray = $records.ToArray() + [Array]::Sort($recordArray, [StringComparer]::Ordinal) + $payload = [Text.Encoding]::UTF8.GetBytes(($recordArray -join "`n")) + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + return [BitConverter]::ToString($sha256.ComputeHash($payload)).Replace('-', '').ToLowerInvariant() + } finally { $sha256.Dispose() } +} + function Set-FixtureSmokeAcl([string]$Path, [string]$UserSid) { $administratorsSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') $systemSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-18') @@ -193,6 +237,7 @@ function New-OwnedFixtureResources( [ValidateSet('BEFORE_PROMOTION','AFTER_PROMOTION','AFTER_ARTIFACTS')] [string]$SmokeCheckpoint = 'AFTER_ARTIFACTS' ) { + Initialize-FixtureDirectoryIdentity $manifest = [IO.File]::ReadAllText($OwnershipManifest, [Text.Encoding]::UTF8) | ConvertFrom-Json -ErrorAction Stop if (!$manifest.Fixture -or $manifest.SchemaVersion -ne 2 -or @@ -203,6 +248,7 @@ function New-OwnedFixtureResources( $token = [Guid]::NewGuid().ToString('N') $ownedRoot = Join-Path $stateDirectory 'owned' $installRoot = Join-Path $ownedRoot 'install-tree' + $executable = Join-Path $installRoot 'propr-desktop.exe' $shortcutFolder = Join-Path $ownedRoot 'shortcut-folder' $shortcut = Join-Path $shortcutFolder 'ProPR Desktop.lnk' $smokeDirectory = Join-Path $ownedRoot 'smoke-data' @@ -212,7 +258,7 @@ function New-OwnedFixtureResources( [void](New-Item -ItemType Directory -Path $directory -Force -ErrorAction Stop) Write-FixtureOwnershipToken (Join-Path $directory '.propr-installed-app-owner') $token } - [IO.File]::WriteAllText((Join-Path $installRoot 'installed.txt'), 'owned', [Text.Encoding]::ASCII) + [IO.File]::WriteAllText($executable, 'owned-executable', [Text.Encoding]::ASCII) [IO.File]::WriteAllText($shortcut, 'owned-shortcut', [Text.Encoding]::ASCII) $registryPath = "Registry::HKEY_LOCAL_MACHINE\Software\ProPRSupervisorFixture\$($manifest.RunId)\owned" @@ -275,8 +321,16 @@ function New-OwnedFixtureResources( $ownedDirectories = @( [ordered]@{ Kind = 'FIXTURE_ROOT'; Path = $ownedRoot; Owned = $true; Token = $token }, - [ordered]@{ Kind = 'INSTALL_ROOT'; Path = $installRoot; Owned = $true; Token = $token }, - [ordered]@{ Kind = 'SHORTCUT_FOLDER'; Path = $shortcutFolder; Owned = $true; Token = $token }, + [ordered]@{ + Kind = 'INSTALL_ROOT'; Path = $installRoot; Owned = $true; Token = $token + Identity = (Get-FixtureEntryIdentity $installRoot $true) + TreeIdentity = (Get-FixtureTreeIdentity $installRoot); Provisional = $false + }, + [ordered]@{ + Kind = 'SHORTCUT_FOLDER'; Path = $shortcutFolder; Owned = $true; Token = $token + Identity = (Get-FixtureEntryIdentity $shortcutFolder $true) + TreeIdentity = (Get-FixtureTreeIdentity $shortcutFolder); Provisional = $false + }, $smokeRecord ) $conflictingDirectories = @( @@ -287,14 +341,17 @@ function New-OwnedFixtureResources( $manifest.Directories = @($ownedDirectories) + @($conflictingDirectories) $manifest.Files = @( [ordered]@{ - Kind = 'FIXTURE_FILE'; Path = (Join-Path $installRoot 'installed.txt') + Kind = 'FIXTURE_FILE'; Path = $executable Owned = $true; Token = $null - Identity = (Get-FixtureFileIdentity (Join-Path $installRoot 'installed.txt')) + Identity = (Get-FixtureFileIdentity $executable) + EntryIdentity = (Get-FixtureEntryIdentity $executable $false) Provisional = $false }, [ordered]@{ Kind = 'SHORTCUT_FILE'; Path = $shortcut; Owned = $true; Token = $token - Identity = (Get-FixtureFileIdentity $shortcut); Provisional = $false + Identity = (Get-FixtureFileIdentity $shortcut) + EntryIdentity = (Get-FixtureEntryIdentity $shortcut $false) + Provisional = $false } ) if ($env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_SHORTCUT) { @@ -322,6 +379,7 @@ function New-OwnedFixtureResources( } } $manifest.Profiles = @() + $manifest.InstallAttempted = $true if ($env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_PROFILE_SID) { $manifest.Profiles += [ordered]@{ Sid = $env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_PROFILE_SID @@ -371,6 +429,7 @@ function New-OwnedFixtureResources( $resourceState = [ordered]@{ OwnedRoot = $ownedRoot InstallRoot = $installRoot + Executable = $executable ShortcutFolder = $shortcutFolder Shortcut = $shortcut SmokeDirectory = $smokeDirectory @@ -391,6 +450,7 @@ function New-SmokeCheckpointFixtureResources( [ValidateSet('BEFORE_PROMOTION','AFTER_PROMOTION','AFTER_ARTIFACTS')] [string]$Checkpoint ) { + Initialize-FixtureDirectoryIdentity $manifest = [IO.File]::ReadAllText($OwnershipManifest, [Text.Encoding]::UTF8) | ConvertFrom-Json -ErrorAction Stop if (!$manifest.Fixture -or $manifest.SchemaVersion -ne 2 -or @@ -482,16 +542,45 @@ function Replace-FixtureOwnedResources { [Text.Encoding]::ASCII ) } - Remove-Item -LiteralPath $state.InstallRoot -Recurse -Force -ErrorAction Stop + $installRootBackup = Join-Path $stateDirectory 'original-install-tree' + $shortcutBackup = Join-Path $stateDirectory 'original-shortcut.lnk' + Move-Item -LiteralPath $state.InstallRoot -Destination $installRootBackup -ErrorAction Stop [void](New-Item -ItemType Directory -Path $state.InstallRoot -ErrorAction Stop) [IO.File]::WriteAllText( (Join-Path $state.InstallRoot 'foreign.txt'), 'foreign-install-tree', [Text.Encoding]::ASCII ) + Move-Item -LiteralPath $state.Shortcut -Destination $shortcutBackup -ErrorAction Stop [IO.File]::WriteAllText($state.Shortcut, 'foreign-shortcut', [Text.Encoding]::ASCII) Set-ItemProperty -LiteralPath $state.RegistryPath ` -Name 'ProPRInstalledAppOwner' -Value 'foreign-owner' + $state | Add-Member -NotePropertyName InstallRootBackup -NotePropertyValue $installRootBackup + $state | Add-Member -NotePropertyName ShortcutBackup -NotePropertyValue $shortcutBackup + $state | ConvertTo-Json -Compress | Set-Content -LiteralPath ` + (Join-Path $stateDirectory 'resources.json') -Encoding ASCII +} + +function Replace-FixtureExecutable { + $state = Get-Content -LiteralPath (Join-Path $stateDirectory 'resources.json') ` + -Raw -Encoding ASCII | ConvertFrom-Json -ErrorAction Stop + $backup = Join-Path $stateDirectory 'original-executable.exe' + Move-Item -LiteralPath $state.Executable -Destination $backup -ErrorAction Stop + [IO.File]::WriteAllText($state.Executable, 'foreign-executable', [Text.Encoding]::ASCII) + $state | Add-Member -NotePropertyName ExecutableBackup -NotePropertyValue $backup + $state | ConvertTo-Json -Compress | Set-Content -LiteralPath ` + (Join-Path $stateDirectory 'resources.json') -Encoding ASCII +} + +function Replace-FixtureShortcut { + $state = Get-Content -LiteralPath (Join-Path $stateDirectory 'resources.json') ` + -Raw -Encoding ASCII | ConvertFrom-Json -ErrorAction Stop + $backup = Join-Path $stateDirectory 'original-shortcut.lnk' + Move-Item -LiteralPath $state.Shortcut -Destination $backup -ErrorAction Stop + [IO.File]::WriteAllText($state.Shortcut, 'foreign-shortcut', [Text.Encoding]::ASCII) + $state | Add-Member -NotePropertyName ShortcutBackup -NotePropertyValue $backup + $state | ConvertTo-Json -Compress | Set-Content -LiteralPath ` + (Join-Path $stateDirectory 'resources.json') -Encoding ASCII } function Add-FixtureForeignChild { @@ -526,6 +615,7 @@ function Add-FixtureForeignSmokeDescendant { } function Test-PrimaryFallbackForeignDescendants { + Initialize-FixtureDirectoryIdentity $installRoot = Join-Path $stateDirectory 'primary-install-root' $shortcutFolder = Join-Path $stateDirectory 'primary-shortcut-folder' [void](New-Item -ItemType Directory -Path $installRoot -ErrorAction Stop) @@ -650,6 +740,11 @@ switch ($scenario) { [DateTime]::UtcNow.AddSeconds(10).Ticks) Start-Sleep -Seconds 300 } + 'NEGATIVE_EXIT' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(10).Ticks) + Start-Sleep -Milliseconds 500 + exit -1 + } 'OWNED_RESOURCES_THEN_DEADLINE' { Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) New-OwnedFixtureResources @@ -721,6 +816,22 @@ switch ($scenario) { [DateTime]::UtcNow.AddMilliseconds(500).Ticks) Start-Sleep -Seconds 300 } + 'OWNED_EXECUTABLE_REPLACED_THEN_DEADLINE' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + New-OwnedFixtureResources + Replace-FixtureExecutable + Write-FixtureMarker ('{0}|CLEANUP|SMOKE_DATA_REMOVE|BEGIN' -f ` + [DateTime]::UtcNow.AddMilliseconds(500).Ticks) + Start-Sleep -Seconds 300 + } + 'OWNED_SHORTCUT_REPLACED_THEN_DEADLINE' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + New-OwnedFixtureResources + Replace-FixtureShortcut + Write-FixtureMarker ('{0}|CLEANUP|SMOKE_DATA_REMOVE|BEGIN' -f ` + [DateTime]::UtcNow.AddMilliseconds(500).Ticks) + Start-Sleep -Seconds 300 + } 'OWNED_RESOURCES_FOREIGN_CHILD_THEN_DEADLINE' { Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) New-OwnedFixtureResources diff --git a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 index 02f2ac96f..01bdc7928 100644 --- a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 @@ -172,21 +172,25 @@ function Restore-ReplacedFixtureAuthority($Owned) { [string]$Owned.Token, [Text.Encoding]::ASCII ) - if (Test-Path -LiteralPath $Owned.InstallRoot) { + if ($Owned.PSObject.Properties['InstallRootBackup']) { Remove-Item -LiteralPath $Owned.InstallRoot -Recurse -Force -ErrorAction Stop + Move-Item -LiteralPath $Owned.InstallRootBackup -Destination $Owned.InstallRoot ` + -ErrorAction Stop + } elseif ($Owned.PSObject.Properties['ExecutableBackup']) { + Remove-Item -LiteralPath $Owned.Executable -Force -ErrorAction Stop + Move-Item -LiteralPath $Owned.ExecutableBackup -Destination $Owned.Executable ` + -ErrorAction Stop } - [void](New-Item -ItemType Directory -Path $Owned.InstallRoot -ErrorAction Stop) - [IO.File]::WriteAllText( - (Join-Path $Owned.InstallRoot '.propr-installed-app-owner'), - [string]$Owned.Token, - [Text.Encoding]::ASCII - ) [IO.File]::WriteAllText( (Join-Path $Owned.ShortcutFolder '.propr-installed-app-owner'), [string]$Owned.Token, [Text.Encoding]::ASCII ) - [IO.File]::WriteAllText($Owned.Shortcut, 'owned-shortcut', [Text.Encoding]::ASCII) + if ($Owned.PSObject.Properties['ShortcutBackup']) { + Remove-Item -LiteralPath $Owned.Shortcut -Force -ErrorAction Stop + Move-Item -LiteralPath $Owned.ShortcutBackup -Destination $Owned.Shortcut ` + -ErrorAction Stop + } Set-ItemProperty -LiteralPath $Owned.RegistryPath ` -Name 'ProPRInstalledAppOwner' -Value ([string]$Owned.Token) } @@ -202,6 +206,28 @@ function Assert-ReplacedFixtureResourcesSurvive($Owned) { 'replacement registry authority was removed or changed' } +function Assert-ReplacedExecutableSurvives($Owned) { + Assert-True ((Get-Content -LiteralPath $Owned.Executable -Raw).Trim() -ceq + 'foreign-executable') 'replacement executable was removed or changed' +} + +function Assert-ReplacedShortcutSurvives($Owned) { + Assert-True ((Get-Content -LiteralPath $Owned.Shortcut -Raw).Trim() -ceq + 'foreign-shortcut') 'replacement shortcut was removed or changed' +} + +function Assert-MsiPreflightPreservedResources($Owned) { + foreach ($path in @( + $Owned.OwnedRoot, $Owned.InstallRoot, $Owned.ShortcutFolder, + $Owned.Shortcut, $Owned.SmokeDirectory, $Owned.RegistryPath + )) { + Assert-True (Test-Path -LiteralPath $path) ` + 'MSI file-system preflight failure mutated a run resource' + } + Assert-True ($null -ne (Get-LocalUser -Name $Owned.UserName -ErrorAction SilentlyContinue)) ` + 'MSI file-system preflight failure removed the run-owned user' +} + function Invoke-WorkflowCleanupController( [string]$ManifestPath, [string]$RunId, @@ -235,27 +261,36 @@ function Invoke-WorkflowCleanupController( $output = $process.StandardOutput.ReadToEnd() $errorOutput = $process.StandardError.ReadToEnd() Assert-True ($output.Length -le 512) 'workflow cleanup fixture output exceeded its fixed bound' - if ($errorOutput.Length -ne 0) { - $stderrCode = if ($errorOutput.Length -gt 4096) { - 'PROPR_WORKFLOW_CLEANUP_FIXTURE:CONTROLLER_STDERR_LIMIT' - } else { 'PROPR_WORKFLOW_CLEANUP_FIXTURE:CONTROLLER_STDERR_PRESENT' } - throw $stderrCode - } $outputLines = @($output -split '\r?\n' | Where-Object { $_ }) Assert-True ($outputLines.Count -eq 2) ` 'workflow cleanup fixture did not emit exactly two fixed result lines' - Assert-True ($outputLines[0] -match - '^PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:(COMPLETE|FAILED|TIMED_OUT)$') ` + $resultMatch = [regex]::Match( + $outputLines[0], + '^PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:(COMPLETE|FAILED|TIMED_OUT)$' + ) + Assert-True $resultMatch.Success ` 'workflow cleanup fixture emitted an invalid fixed result' - $resultName = $Matches[1] - Assert-True ($outputLines[1] -match - '^PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:STATUS:([A-Z_]+):EXIT_CODE:([0-9]+)$') ` + $resultName = $resultMatch.Groups[1].Value + $statusMatch = [regex]::Match( + $outputLines[1], + '^PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:STATUS:([A-Z_]+):EXIT_CODE:([0-9]+)$' + ) + Assert-True $statusMatch.Success ` 'workflow cleanup fixture emitted an invalid fixed status' + $controllerStatus = $statusMatch.Groups[1].Value + $reportedExitCode = [int]$statusMatch.Groups[2].Value + if ($errorOutput.Length -ne 0) { + $stderrCode = if ($errorOutput.Length -gt 4096) { + 'CONTROLLER_STDERR_LIMIT' + } else { 'CONTROLLER_STDERR_PRESENT' } + throw ('PROPR_WORKFLOW_CLEANUP_FIXTURE:{0}:STATUS:{1}:EXIT_CODE:{2}' -f ` + $stderrCode, $controllerStatus, $reportedExitCode) + } return [PSCustomObject]@{ ExitCode = $process.ExitCode Result = $resultName - ControllerStatus = $Matches[1] - ReportedExitCode = [int]$Matches[2] + ControllerStatus = $controllerStatus + ReportedExitCode = $reportedExitCode Output = $output } } finally { @@ -335,6 +370,8 @@ function Invoke-FixtureScenario( } elseif ($Scenario -in @( 'OWNED_RESOURCES_THEN_DEADLINE', 'OWNED_RESOURCES_REPLACED_THEN_DEADLINE', + 'OWNED_EXECUTABLE_REPLACED_THEN_DEADLINE', + 'OWNED_SHORTCUT_REPLACED_THEN_DEADLINE', 'OWNED_RESOURCES_FOREIGN_CHILD_THEN_DEADLINE', 'SMOKE_BEFORE_PROMOTION_THEN_DEADLINE', 'SMOKE_AFTER_PROMOTION_THEN_DEADLINE', @@ -391,6 +428,18 @@ function Test-OperationDeadlineAndTreeTermination { 'operation deadline did not emit the fixed redacted timeout line' } +function Test-NegativeWorkerExitFinalization { + $result = Invoke-FixtureScenario 'NEGATIVE_EXIT' + Assert-True ($result.ExitCode -eq -1) ` + 'negative worker exit status was not preserved after bounded finalization' + Assert-Contains $result.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:ACCEPTED:INITIALIZATION:PATHS:BEGIN' ` + 'negative-exit fixture did not publish a valid marker before crashing' + Assert-Contains $result.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:COMPLETE' ` + 'negative worker exit did not enter bounded tree termination and cleanup' +} + function Test-FailClosedMarkers { foreach ($testCase in @( @{ Scenario = 'MALFORMED_MARKER'; Label = 'malformed' }, @@ -680,6 +729,46 @@ function Test-PreExistingCleanupOwnership { Assert-True (!(Test-Path -LiteralPath $replacementOwned.ManifestPath)) ` 'successful standalone cleanup retry did not consume recovery authority' + foreach ($replacementCase in @( + [PSCustomObject]@{ + Scenario = 'OWNED_EXECUTABLE_REPLACED_THEN_DEADLINE' + Directory = 'replaced-executable' + Label = 'executable' + }, + [PSCustomObject]@{ + Scenario = 'OWNED_SHORTCUT_REPLACED_THEN_DEADLINE' + Directory = 'replaced-shortcut' + Label = 'shortcut' + } + )) { + $replacedStateDirectory = New-StateDirectory $replacementCase.Directory + $replacedResult = Invoke-FixtureScenario ` + $replacementCase.Scenario $replacedStateDirectory + Assert-True ($replacedResult.ExitCode -eq 125) ` + "replacement $($replacementCase.Label) did not fail before cleanup" + Assert-Contains $replacedResult.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:FAILED' ` + "replacement $($replacementCase.Label) did not emit fixed cleanup failure evidence" + $replacedOwned = Read-FixtureResourceState $replacedStateDirectory + if ($replacementCase.Label -ceq 'executable') { + Assert-ReplacedExecutableSurvives $replacedOwned + } else { + Assert-ReplacedShortcutSurvives $replacedOwned + } + Assert-MsiPreflightPreservedResources $replacedOwned + $replacedManifest = Get-Content -LiteralPath $replacedOwned.ManifestPath ` + -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop + Assert-True ($replacedManifest.State -ceq 'ACTIVE') ` + "replacement $($replacementCase.Label) discarded ACTIVE recovery authority" + Restore-ReplacedFixtureAuthority $replacedOwned + $replacedRetry = Invoke-WorkflowCleanupController ` + $replacedOwned.ManifestPath $replacedOwned.RunId $replacedStateDirectory + Assert-True ($replacedRetry.ExitCode -eq 0 -and + $replacedRetry.Result -ceq 'COMPLETE') ` + "replacement $($replacementCase.Label) authority did not retry to success" + Assert-OwnedResourcesGone $replacedOwned + } + $foreignChildStateDirectory = New-StateDirectory 'in-place-foreign-child' $foreignChildResult = Invoke-FixtureScenario ` 'OWNED_RESOURCES_FOREIGN_CHILD_THEN_DEADLINE' $foreignChildStateDirectory @@ -1432,6 +1521,7 @@ Assert-True ($actualArchitecture -ceq $Architecture) ` try { Test-BootstrapTimeout Test-OperationDeadlineAndTreeTermination + Test-NegativeWorkerExitFinalization Test-FailClosedMarkers Test-LiveCancellationAndRedaction Test-PrimaryWorkerFallbackForeignDescendants diff --git a/apps/desktop/scripts/test-installed-windows-app.ps1 b/apps/desktop/scripts/test-installed-windows-app.ps1 index 179b8e78a..3d2fc9b65 100644 --- a/apps/desktop/scripts/test-installed-windows-app.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app.ps1 @@ -109,10 +109,13 @@ $appPathsCreatedByRun = $false $protocolOwnedIdentity = $null $appPathsOwnedIdentity = $null $installRootOwnedIdentity = $null +$installRootOwnedTreeIdentity = $null $shortcutFolderOwnedIdentity = $null +$shortcutFolderOwnedTreeIdentity = $null $hkcuInstalledOwnedKind = $null $hkcuInstalledOwnedData = $null $shortcutOwnedIdentity = $null +$shortcutOwnedEntryIdentity = $null $hkcuDesktopKeyCreatedByRun = $false $msiTimeoutMilliseconds = 10 * 60 * 1000 $applicationTimeoutMilliseconds = 5 * 60 * 1000 @@ -380,6 +383,71 @@ function Get-FileSystemEntryIdentity([string]$Path, [bool]$Directory) { return [ProPRDirectoryIdentity]::ReadEntry($item.FullName, $Directory) } +function Get-FileSystemTreeIdentity([string]$Path) { + $root = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if (!$root.PSIsContainer -or + ($root.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'file-system tree root identity is invalid' + } + $rootPath = $root.FullName.TrimEnd('\') + $records = [Collections.Generic.List[string]]::new() + $records.Add(('D||{0}' -f (Get-FileSystemEntryIdentity $rootPath $true))) + foreach ($entry in @(Get-ChildItem -LiteralPath $rootPath -Recurse -Force -ErrorAction Stop)) { + if (($entry.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'file-system tree contains a reparse point' + } + $relativePath = $entry.FullName.Substring($rootPath.Length).TrimStart('\') + if (!$relativePath -or [IO.Path]::IsPathRooted($relativePath)) { + throw 'file-system tree relative path is invalid' + } + $kind = if ($entry.PSIsContainer) { 'D' } else { 'F' } + $relative = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($relativePath)) + $identity = Get-FileSystemEntryIdentity $entry.FullName ([bool]$entry.PSIsContainer) + $records.Add(('{0}|{1}|{2}' -f $kind, $relative, $identity)) + } + $recordArray = $records.ToArray() + [Array]::Sort($recordArray, [StringComparer]::Ordinal) + $payload = [Text.Encoding]::UTF8.GetBytes(($recordArray -join "`n")) + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + return [BitConverter]::ToString($sha256.ComputeHash($payload)).Replace('-', '').ToLowerInvariant() + } finally { + $sha256.Dispose() + } +} + +function Assert-MsiManagedFileSystemAuthority { + if (Test-Path -LiteralPath $installRoot) { + if (!$installRootCreatedByRun -or + [string]$installRootOwnedIdentity -notmatch '^[a-f0-9]{24}$' -or + [string]$installRootOwnedTreeIdentity -notmatch '^[a-f0-9]{64}$' -or + (Get-DirectoryIdentity $installRoot) -cne $installRootOwnedIdentity -or + (Get-FileSystemTreeIdentity $installRoot) -cne $installRootOwnedTreeIdentity) { + throw 'refusing to uninstall over an install tree with mismatched ownership identity' + } + } + if (Test-Path -LiteralPath $startMenuShortcutFolder) { + if (!$startMenuShortcutFolderCreatedByRun -or + [string]$shortcutFolderOwnedIdentity -notmatch '^[a-f0-9]{24}$' -or + [string]$shortcutFolderOwnedTreeIdentity -notmatch '^[a-f0-9]{64}$' -or + (Get-DirectoryIdentity $startMenuShortcutFolder) -cne $shortcutFolderOwnedIdentity -or + (Get-FileSystemTreeIdentity $startMenuShortcutFolder) -cne + $shortcutFolderOwnedTreeIdentity) { + throw 'refusing to uninstall over a shortcut folder with mismatched ownership identity' + } + } + if (Test-Path -LiteralPath $startMenuShortcut) { + if (!$startMenuShortcutCreatedByRun -or + [string]$shortcutOwnedIdentity -notmatch '^[a-f0-9]{64}$' -or + [string]$shortcutOwnedEntryIdentity -notmatch '^[a-f0-9]{24}$' -or + (Get-FileIdentity $startMenuShortcut) -cne $shortcutOwnedIdentity -or + (Get-FileSystemEntryIdentity $startMenuShortcut $false) -cne + $shortcutOwnedEntryIdentity) { + throw 'refusing to uninstall over a shortcut with mismatched ownership identity' + } + } +} + function Get-RegistryTreeIdentity([string]$Path) { if (!(Test-Path -LiteralPath $Path)) { return $null } $root = Get-Item -LiteralPath $Path -ErrorAction Stop @@ -1490,16 +1558,17 @@ try { $ownershipState.Directories = @( [ordered]@{ Kind = 'INSTALL_ROOT'; Path = $installRoot; Owned = $true - Token = $null; Identity = $null; Provisional = $true + Token = $null; Identity = $null; TreeIdentity = $null; Provisional = $true }, [ordered]@{ Kind = 'SHORTCUT_FOLDER'; Path = $startMenuShortcutFolder - Owned = $true; Token = $null; Identity = $null; Provisional = $true + Owned = $true; Token = $null; Identity = $null; TreeIdentity = $null + Provisional = $true } ) $ownershipState.Files = @([ordered]@{ Kind = 'SHORTCUT_FILE'; Path = $startMenuShortcut; Owned = $true - Token = $null; Identity = $null; Provisional = $true + Token = $null; Identity = $null; EntryIdentity = $null; Provisional = $true }) $ownershipState.RegistryKeys = @( [ordered]@{ @@ -1560,35 +1629,44 @@ try { $ownedDirectories = @() if ($script:installRootCreatedByRun) { $script:installRootOwnedIdentity = Get-DirectoryIdentity $installRoot - if (!$script:installRootOwnedIdentity) { + $script:installRootOwnedTreeIdentity = Get-FileSystemTreeIdentity $installRoot + if (!$script:installRootOwnedIdentity -or !$script:installRootOwnedTreeIdentity) { throw 'installed tree identity could not be captured' } $ownedDirectories += [ordered]@{ Kind = 'INSTALL_ROOT'; Path = $installRoot; Owned = $true Token = $null; Identity = $script:installRootOwnedIdentity + TreeIdentity = $script:installRootOwnedTreeIdentity Provisional = $false } } if ($script:startMenuShortcutFolderCreatedByRun) { $script:shortcutFolderOwnedIdentity = Get-DirectoryIdentity $startMenuShortcutFolder - if (!$script:shortcutFolderOwnedIdentity) { + $script:shortcutFolderOwnedTreeIdentity = + Get-FileSystemTreeIdentity $startMenuShortcutFolder + if (!$script:shortcutFolderOwnedIdentity -or + !$script:shortcutFolderOwnedTreeIdentity) { throw 'installed shortcut folder identity could not be captured' } $ownedDirectories += [ordered]@{ Kind = 'SHORTCUT_FOLDER'; Path = $startMenuShortcutFolder Owned = $true; Token = $null; Identity = $script:shortcutFolderOwnedIdentity + TreeIdentity = $script:shortcutFolderOwnedTreeIdentity Provisional = $false } } $ownershipState.Directories = $ownedDirectories $ownershipState.Files = if ($script:startMenuShortcutCreatedByRun) { $script:shortcutOwnedIdentity = Get-FileIdentity $startMenuShortcut - if (!$script:shortcutOwnedIdentity) { + $script:shortcutOwnedEntryIdentity = + Get-FileSystemEntryIdentity $startMenuShortcut $false + if (!$script:shortcutOwnedIdentity -or !$script:shortcutOwnedEntryIdentity) { throw 'installed shortcut identity could not be captured' } @([ordered]@{ Kind = 'SHORTCUT_FILE'; Path = $startMenuShortcut; Owned = $true Token = $null; Identity = $script:shortcutOwnedIdentity + EntryIdentity = $script:shortcutOwnedEntryIdentity Provisional = $false }) } else { @() } @@ -1879,6 +1957,7 @@ try { Invoke-BoundedExternalOperation ` 'UNINSTALL' 'MSI_UNINSTALL' ` ($msiTimeoutMilliseconds + $terminationTimeoutMilliseconds + 5000) { + Assert-MsiManagedFileSystemAuthority if ($protocolCreatedByRun -and (Test-Path -LiteralPath $protocolRegistryPath) -and (!$protocolOwnedIdentity -or (Get-RegistryTreeIdentity $protocolRegistryPath) -cne $protocolOwnedIdentity)) { diff --git a/apps/desktop/src/release-workflow.test.ts b/apps/desktop/src/release-workflow.test.ts index 12cac72ff..1ef11a169 100644 --- a/apps/desktop/src/release-workflow.test.ts +++ b/apps/desktop/src/release-workflow.test.ts @@ -577,6 +577,7 @@ describe('desktop trusted release workflow', () => { test('runs executable supervisor acceptance on both Windows architectures and keeps supplementary contracts', () => { assert.match(installedWindowsAppSupervisorBehaviorTest, /Test-BootstrapTimeout/); assert.match(installedWindowsAppSupervisorBehaviorTest, /Test-OperationDeadlineAndTreeTermination/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Test-NegativeWorkerExitFinalization/); assert.match(installedWindowsAppSupervisorBehaviorTest, /Test-FailClosedMarkers/); assert.match(installedWindowsAppSupervisorBehaviorTest, /Test-LiveCancellationAndRedaction/); assert.match(installedWindowsAppSupervisorBehaviorTest, /Test-PreExistingCleanupOwnership/); @@ -620,6 +621,9 @@ describe('desktop trusted release workflow', () => { installedWindowsAppSupervisor, /\$job\.TerminateAndWait\(\$TerminationExitCode, \$WatchdogTerminationMilliseconds\)/, ); + assert.match(installedWindowsAppSupervisor, /\$workerTreeTerminated = Stop-OwnedWorker 125/); + assert.doesNotMatch(installedWindowsAppSupervisor, /Stop-OwnedWorker \(\[uint32\]\$exitCode\)/); + assert.match(installedWindowsAppSupervisorFixture, /'NEGATIVE_EXIT'[\s\S]*exit -1/); assert.match(installedWindowsAppSupervisor, /\$worker\.WaitForExit\(\$WatchdogTerminationMilliseconds\)/); assert.match(installedWindowsAppSupervisor, /if \(\$workerTreeTerminated\) \{[\s\S]*Invoke-PostTerminationCleanup/); assert.match(installedWindowsAppSupervisor, /Invoke-PostTerminationCleanup/); @@ -638,6 +642,12 @@ describe('desktop trusted release workflow', () => { assert.match(installedWindowsAppCleanup, /Get-RegistryTreeIdentity/); assert.match(installedWindowsAppCleanup, /Get-FileIdentity/); assert.match(installedWindowsAppCleanup, /Get-DirectoryIdentity/); + assert.match(installedWindowsAppCleanup, /Get-FileSystemTreeIdentity/); + assert.match(installedWindowsAppCleanup, /Assert-MsiManagedFileSystemAuthority/); + assert.match( + installedWindowsAppCleanup, + /Assert-MsiManagedFileSystemAuthority \$manifest\n\s+\$msi = Start-Process msiexec\.exe/, + ); assert.doesNotMatch(installedWindowsAppCleanup, /AllowProvisionalProductOwnership/); assert.match( installedWindowsAppCleanup, @@ -668,6 +678,12 @@ describe('desktop trusted release workflow', () => { installedWindowsAppTest, /Get-RegistryTreeIdentity \$appPathsRegistryPath[\s\S]*refusing to uninstall over executable metadata[\s\S]*Invoke-Msi @\('\/x'/, ); + assert.match( + installedWindowsAppTest, + /Assert-MsiManagedFileSystemAuthority[\s\S]*Invoke-Msi @\('\/x'/, + ); + assert.match(installedWindowsAppTest, /TreeIdentity = \$script:installRootOwnedTreeIdentity/); + assert.match(installedWindowsAppTest, /EntryIdentity = \$script:shortcutOwnedEntryIdentity/); assert.match(installedWindowsAppSupervisorBehaviorTest, /mismatched App Paths ownership identity did not fail closed/); assert.match(installedWindowsAppWorkflowCleanup, /ProPRWorkflowCleanupJob/); assert.match(installedWindowsAppWorkflowCleanup, /MANIFEST_VALIDATION_FAILURE/); @@ -680,6 +696,12 @@ describe('desktop trusted release workflow', () => { assert.match(installedWindowsAppWorkflowCleanup, /WorkflowCleanupControllerPhase/); assert.match(installedWindowsAppWorkflowCleanup, /WorkflowCleanupControllerLine/); assert.match(installedWindowsAppWorkflowCleanup, /Set-CaughtControllerFailure/); + assert.match(installedWindowsAppWorkflowCleanup, /\[Console\]::SetError\(\[IO\.TextWriter\]::Null\)/); + assert.ok( + installedWindowsAppWorkflowCleanup.indexOf('[Console]::SetError([IO.TextWriter]::Null)') + < installedWindowsAppWorkflowCleanup.indexOf("Add-Type -TypeDefinition @'"), + 'controller raw stderr must be suppressed before cold type loading', + ); assert.match( installedWindowsAppWorkflowCleanup, /trap \{[\s\S]*if \(\$script:controllerBodyActive\)[\s\S]*break controllerBody[\s\S]*:controllerBody do \{/, @@ -698,6 +720,40 @@ describe('desktop trusted release workflow', () => { /if \(\$fixedCleanupResult -eq \$true -and !\$workflowManagedManifest\)/, ); assert.match(installedWindowsAppSupervisorBehaviorTest, /OWNED_RESOURCES_REPLACED_THEN_DEADLINE/); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /OWNED_EXECUTABLE_REPLACED_THEN_DEADLINE/, + ); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /OWNED_SHORTCUT_REPLACED_THEN_DEADLINE/, + ); + assert.match(installedWindowsAppSupervisorBehaviorTest, /replacement executable was removed or changed/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /replacement shortcut was removed or changed/); + assert.match( + installedWindowsAppSupervisorFixture, + /function Initialize-FixtureDirectoryIdentity \{\n\s+Add-Type -TypeDefinition/, + ); + assert.doesNotMatch( + installedWindowsAppSupervisorFixture.slice( + 0, + installedWindowsAppSupervisorFixture.indexOf('function Initialize-FixtureDirectoryIdentity'), + ), + /Add-Type/, + ); + assert.match( + installedWindowsAppSupervisorFixture, + /'OWNED_RESOURCES_THEN_DEADLINE' \{[\s\S]*Write-FixtureMarker[\s\S]*New-OwnedFixtureResources/, + ); + assert.ok( + installedWindowsAppSupervisorBehaviorTest.indexOf('$statusMatch = [regex]::Match(') + < installedWindowsAppSupervisorBehaviorTest.indexOf('if ($errorOutput.Length -ne 0)'), + 'controller fixed stdout must be parsed before bounded stderr classification', + ); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /PROPR_WORKFLOW_CLEANUP_FIXTURE:\{0\}:STATUS:\{1\}:EXIT_CODE:\{2\}/, + ); assert.match( installedWindowsAppSupervisorBehaviorTest, /OWNED_RESOURCES_FOREIGN_CHILD_THEN_DEADLINE/, From 41cd874ada64900292e7f8ecc86da99f7e942905 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 04:56:05 +0000 Subject: [PATCH 11/29] feat(ai): Implemented the exact-head F19/F20 correction on `a30d8bf312197d08c65f6cef461d5720b64d43f4`. Implemented the exact-head F19/F20 correction on `a30d8bf312197d08c65f6cef461d5720b64d43f4`. Key changes: - F20: `Remove-OwnedFile` now requires and rechecks durable `EntryIdentity` immediately before deletion, alongside content identity. - Added byte-identical replace-via-move coverage proving foreign survival, ACTIVE authority retention, and successful retry only after restoring the owned entry. - F19: introduced explicit `PENDING`, `COMMITTED`, and `ROLLED_BACK_CLEAN` MSI receipts. - Path-only provisional evidence can no longer authorize `/x`. - Added fixed 30-second cancellation grace, exact filesystem/registry/product-registration rollback checks, durable identity publication, and `DURING_MSI` / `DURING_OWNERSHIP_CAPTURE` gates. - Controller now emits exactly two lines using flushed `Console.Out`, with no `Write-Host`. - Protocol failures expose only bounded line/stderr counts. Validation: - Full desktop suite: **177 passed, 6 skipped** - `git diff --check`: passed - Release workflow contracts: passed - No commit created. The native focused PowerShell fixture cannot run in this Linux environment; x64 and ARM64 fixture success remains required in CI before merge. PR: #2042 Comment by: @integry (ID: 5488967936) Model: gpt-5.6-sol --- .../scripts/cleanup-installed-windows-app.ps1 | 190 ++++++++++++++---- .../run-installed-windows-app-harness.ps1 | 113 ++++++++++- ...installed-windows-app-workflow-cleanup.ps1 | 4 +- ...stalled-windows-app-supervisor-fixture.ps1 | 114 ++++++++++- .../test-installed-windows-app-supervisor.ps1 | 136 +++++++++++-- .../scripts/test-installed-windows-app.ps1 | 139 ++++++++++++- apps/desktop/src/release-workflow.test.ts | 41 +++- 7 files changed, 674 insertions(+), 63 deletions(-) diff --git a/apps/desktop/scripts/cleanup-installed-windows-app.ps1 b/apps/desktop/scripts/cleanup-installed-windows-app.ps1 index ea389ea76..d3ee1d57d 100644 --- a/apps/desktop/scripts/cleanup-installed-windows-app.ps1 +++ b/apps/desktop/scripts/cleanup-installed-windows-app.ps1 @@ -235,10 +235,10 @@ function Assert-MsiManagedFileSystemAuthority($Manifest) { $candidatePath = if ($FixtureRoot -and $candidate.Records.Count -eq 1) { [string]$candidate.Records[0].Path } else { [string]$candidate.Path } - if (!$candidatePath -or !(Test-Path -LiteralPath $candidatePath)) { continue } if ($candidate.Records.Count -ne 1) { throw 'MSI-managed file-system authority is missing or ambiguous' } + if (!$candidatePath -or !(Test-Path -LiteralPath $candidatePath)) { continue } $record = $candidate.Records[0] $entryIdentity = if ($candidate.Directory) { [string]$record.Identity @@ -309,29 +309,83 @@ function Get-RegistryTreeIdentity([string]$Path) { finally { $sha256.Dispose() } } -function Test-ProvisionalRegistryIdentity([string]$Kind, [string]$Path, [string]$Application) { - if ($Kind -eq 'APP_PATH') { - $key = Get-Item -LiteralPath $Path -ErrorAction Stop - return @($key.GetSubKeyNames()).Count -eq 0 -and - @($key.GetValueNames()).Count -eq 1 -and - @($key.GetValueNames())[0] -ceq '' -and - [string]$key.GetValue('') -ceq $Application - } - if ($Kind -ne 'PROTOCOL') { return $false } - $root = Get-Item -LiteralPath $Path -ErrorAction Stop - $shell = Get-Item -LiteralPath "$Path\shell" -ErrorAction Stop - $open = Get-Item -LiteralPath "$Path\shell\open" -ErrorAction Stop - $command = Get-Item -LiteralPath "$Path\shell\open\command" -ErrorAction Stop - return @($root.GetSubKeyNames()).Count -eq 1 -and $root.GetSubKeyNames()[0] -ceq 'shell' -and - (@($root.GetValueNames() | Sort-Object -CaseSensitive) -join '|') -ceq '|URL Protocol' -and - [string]$root.GetValue('') -ceq 'URL:ProPR Protocol' -and - [string]$root.GetValue('URL Protocol') -ceq '' -and - @($shell.GetSubKeyNames()).Count -eq 1 -and $shell.GetSubKeyNames()[0] -ceq 'open' -and - @($shell.GetValueNames()).Count -eq 0 -and - @($open.GetSubKeyNames()).Count -eq 1 -and $open.GetSubKeyNames()[0] -ceq 'command' -and - @($open.GetValueNames()).Count -eq 0 -and @($command.GetSubKeyNames()).Count -eq 0 -and - @($command.GetValueNames()).Count -eq 1 -and $command.GetValueNames()[0] -ceq '' -and - [string]$command.GetValue('') -ceq "`"$Application`" `"%1`"" +function Get-MsiProductCode([string]$Path) { + $installerCom = $null + $database = $null + $view = $null + $record = $null + try { + $installerCom = New-Object -ComObject WindowsInstaller.Installer + $database = $installerCom.OpenDatabase($Path, 0) + $view = $database.OpenView( + "SELECT ``Value`` FROM ``Property`` WHERE ``Property`` = 'ProductCode'") + $view.Execute() + $record = $view.Fetch() + $productCode = if ($null -eq $record) { $null } else { [string]$record.StringData(1) } + if ($productCode -notmatch '^\{[A-Fa-f0-9]{8}(?:-[A-Fa-f0-9]{4}){3}-[A-Fa-f0-9]{12}\}$') { + throw 'MSI product identity is invalid' + } + return $productCode.ToUpperInvariant() + } finally { + foreach ($resource in @($record, $view, $database, $installerCom)) { + if ($null -ne $resource -and [Runtime.InteropServices.Marshal]::IsComObject($resource)) { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($resource) + } + } + } +} + +function Assert-MsiProductIsUnregistered([string]$Path) { + $installerCom = $null + try { + $productCode = Get-MsiProductCode $Path + $installerCom = New-Object -ComObject WindowsInstaller.Installer + if ([int]$installerCom.ProductState($productCode) -ne -1) { + throw 'Windows Installer product registration is not at the clean baseline' + } + } finally { + if ($null -ne $installerCom -and + [Runtime.InteropServices.Marshal]::IsComObject($installerCom)) { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($installerCom) + } + } +} + +function Assert-MsiRolledBackCleanBaseline($Manifest) { + if ($FixtureRoot -or [string]$Manifest.MsiTransactionState -cne 'ROLLED_BACK_CLEAN') { + return + } + foreach ($path in @( + (Join-Path $env:ProgramFiles 'ProPR Desktop'), + (Join-Path ([Environment]::GetFolderPath( + [Environment+SpecialFolder]::CommonPrograms)) 'ProPR Desktop'), + 'Registry::HKEY_LOCAL_MACHINE\Software\Classes\propr', + 'Registry::HKEY_LOCAL_MACHINE\Software\Microsoft\Windows\CurrentVersion\App Paths\propr-desktop.exe' + )) { + if (Test-Path -LiteralPath $path) { + throw 'MSI rollback did not restore the exact clean baseline' + } + } + if (@($Manifest.Directories).Count -ne 0 -or @($Manifest.Files).Count -ne 0 -or + @($Manifest.RegistryKeys).Count -ne 0) { + throw 'MSI rollback receipt contains file-system or machine-registry authority' + } + $installedRecords = @($Manifest.RegistryValues) + if ($installedRecords.Count -ne 1) { + throw 'MSI rollback current-user baseline receipt is missing or ambiguous' + } + $record = $installedRecords[0] + $current = Get-RegistryValueSnapshot ([string]$record.Path) ([string]$record.Name) + $matchesBaseline = if ([bool]$record.BaselineValueExisted) { + $current.Exists -and $current.Kind -ceq [string]$record.BaselineValueKind -and + $current.Data -ceq [string]$record.BaselineValueData + } else { !$current.Exists } + $keyMatchesBaseline = (Test-Path -LiteralPath ([string]$record.Path)) -eq + [bool]$record.BaselineKeyExisted + if (!$matchesBaseline -or !$keyMatchesBaseline) { + throw 'MSI rollback did not restore the exact current-user baseline' + } + Assert-MsiProductIsUnregistered ([string]$Manifest.InstallerPath) } function Convert-RegistryValueToBytes( @@ -694,7 +748,11 @@ function Remove-OwnedFile($Record) { } if ([string]$Record.Identity -notmatch '^[a-f0-9]{64}$' -or (Get-FileIdentity $path) -cne [string]$Record.Identity) { - throw 'owned file identity does not match' + throw 'owned file content identity does not match' + } + if ([string]$Record.EntryIdentity -notmatch '^[a-f0-9]{24}$' -or + (Get-FileSystemEntryIdentity $path $false) -cne [string]$Record.EntryIdentity) { + throw 'owned file entry identity does not match' } Remove-Item -LiteralPath $path -Force -ErrorAction Stop if (Test-Path -LiteralPath $path) { throw 'owned file cleanup did not complete' } @@ -833,6 +891,7 @@ function Write-EmptyOwnershipReceipt([string]$Path, $Manifest) { $Manifest.State = 'EMPTY' $Manifest.BaselineClean = $false $Manifest.InstallAttempted = $false + $Manifest.MsiTransactionState = 'NONE' $Manifest.Directories = @() $Manifest.Files = @() $Manifest.RegistryKeys = @() @@ -974,19 +1033,32 @@ try { $expectedManifestKeys = @( 'SchemaVersion','ManifestType','State','RunId','CreatedUtcTicks','ExpiresUtcTicks', 'InstallerPath','Fixture', - 'FixtureRoot','BaselineClean','InstallAttempted','Directories','Files','RegistryKeys', + 'FixtureRoot','BaselineClean','InstallAttempted','MsiTransactionState', + 'Directories','Files','RegistryKeys', 'RegistryValues','Users','Profiles' ) if ($manifestKeys.Count -ne $expectedManifestKeys.Count -or @($expectedManifestKeys | Where-Object { $manifestKeys -cnotcontains $_ }).Count -ne 0 -or $manifest.Fixture -isnot [bool] -or $manifest.BaselineClean -isnot [bool] -or $manifest.InstallAttempted -isnot [bool] -or + [string]$manifest.MsiTransactionState -notin @( + 'NONE','PENDING','COMMITTED','ROLLED_BACK_CLEAN' + ) -or $manifest.SchemaVersion -ne 2 -or [string]$manifest.ManifestType -cne 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP' -or [string]$manifest.State -notin @('ACTIVE','EMPTY') -or [string]$manifest.RunId -notmatch '^[a-f0-9]{32}$') { throw 'ownership manifest schema is invalid' } + if (!$manifest.Fixture -and ( + ([string]$manifest.MsiTransactionState -ceq 'NONE' -and + [bool]$manifest.InstallAttempted) -or + ([string]$manifest.MsiTransactionState -in @( + 'PENDING','COMMITTED','ROLLED_BACK_CLEAN' + ) -and (!([bool]$manifest.BaselineClean) -or + !([bool]$manifest.InstallAttempted))))) { + throw 'MSI transaction receipt state is inconsistent' + } $authorizedRunId = [string]$manifest.RunId $pathRunId = [IO.Path]::GetFileNameWithoutExtension($manifestPath).Substring( 'propr-installed-app-ownership-'.Length) @@ -1017,6 +1089,7 @@ try { if ([string]$manifest.State -ceq 'EMPTY') { if ($manifest.BaselineClean -or $manifest.InstallAttempted -or + [string]$manifest.MsiTransactionState -cne 'NONE' -or @($manifest.Directories).Count -ne 0 -or @($manifest.Files).Count -ne 0 -or @($manifest.RegistryKeys).Count -ne 0 -or @($manifest.RegistryValues).Count -ne 0 -or @($manifest.Users).Count -ne 0 -or @($manifest.Profiles).Count -ne 0) { @@ -1026,7 +1099,6 @@ try { exit 0 } - $script:authorizedApplication = Join-Path $env:ProgramFiles 'ProPR Desktop\propr-desktop.exe' foreach ($record in @($manifest.Directories)) { if ($record.Owned -and !(Test-AllowedFileSystemPath ([string]$record.Kind) ([string]$record.Path))) { @@ -1041,6 +1113,11 @@ try { !(Test-AllowedFileSystemPath ([string]$record.Kind) ([string]$record.Path))) { throw 'file manifest scope is invalid' } + if ($record.Owned -and !$record.Provisional -and + ([string]$record.Identity -notmatch '^[a-f0-9]{64}$' -or + [string]$record.EntryIdentity -notmatch '^[a-f0-9]{24}$')) { + throw 'file manifest durable identity is invalid' + } } foreach ($record in @($manifest.Users)) { if ($record.Owned -and ($record.Owned -isnot [bool] -or @@ -1067,8 +1144,9 @@ try { } } - $allowProvisionalMsiUninstall = !$manifest.Fixture -and - [bool]$manifest.BaselineClean -and [bool]$manifest.InstallAttempted + $allowAuthenticatedMsiUninstall = !$manifest.Fixture -and + [bool]$manifest.BaselineClean -and [bool]$manifest.InstallAttempted -and + [string]$manifest.MsiTransactionState -ceq 'COMMITTED' foreach ($record in @($manifest.RegistryKeys)) { if (!$record.Owned) { continue } $path = [string]$record.Path @@ -1094,11 +1172,8 @@ try { throw 'registry manifest scope is invalid' } if (!(Test-Path -LiteralPath $path)) { continue } - if ($allowProvisionalMsiUninstall -and [bool]$record.Provisional) { - if (!(Test-ProvisionalRegistryIdentity $kind $path $script:authorizedApplication)) { - throw 'registry manifest provisional identity is invalid' - } - } elseif ([string]$record.Identity -notmatch '^[a-f0-9]{64}$' -or + if ([bool]$record.Provisional -or + [string]$record.Identity -notmatch '^[a-f0-9]{64}$' -or (Get-RegistryTreeIdentity $path) -cne [string]$record.Identity) { throw 'registry manifest ownership identity is invalid' } @@ -1175,7 +1250,50 @@ try { ($manifest.Fixture -and @($manifest.RegistryValues).Count -ne 0)) { throw 'registry value manifest cardinality is invalid' } + if (!$manifest.Fixture -and + [string]$manifest.MsiTransactionState -ceq 'COMMITTED') { + $ownedDirectoryKinds = @($manifest.Directories | Where-Object { + $_.Owned -and [string]$_.Kind -in @('INSTALL_ROOT','SHORTCUT_FOLDER') + } | ForEach-Object { [string]$_.Kind }) + $ownedFileKinds = @($manifest.Files | Where-Object { + $_.Owned -and [string]$_.Kind -ceq 'SHORTCUT_FILE' + } | ForEach-Object { [string]$_.Kind }) + $ownedRegistryKinds = @($manifest.RegistryKeys | Where-Object { + $_.Owned -and [string]$_.Kind -in @('PROTOCOL','APP_PATH') + } | ForEach-Object { [string]$_.Kind }) + if ($ownedDirectoryKinds.Count -ne 2 -or + @($ownedDirectoryKinds | Where-Object { + $_ -notin @('INSTALL_ROOT','SHORTCUT_FOLDER') + }).Count -ne 0 -or + @($ownedDirectoryKinds | Select-Object -Unique).Count -ne 2 -or + $ownedFileKinds.Count -ne 1 -or $ownedFileKinds[0] -cne 'SHORTCUT_FILE' -or + $ownedRegistryKinds.Count -ne 2 -or + @($ownedRegistryKinds | Where-Object { + $_ -notin @('PROTOCOL','APP_PATH') + }).Count -ne 0 -or + @($ownedRegistryKinds | Select-Object -Unique).Count -ne 2 -or + @($manifest.Directories | Where-Object { $_.Owned -and $_.Provisional }).Count -ne 0 -or + @($manifest.Files | Where-Object { $_.Owned -and $_.Provisional }).Count -ne 0 -or + @($manifest.RegistryKeys | Where-Object { $_.Owned -and $_.Provisional }).Count -ne 0 -or + @($manifest.RegistryValues | Where-Object { + !$_.Owned -or $_.Provisional + }).Count -ne 0) { + throw 'committed MSI transaction receipt is incomplete or provisional' + } + } $manifestValidated = $true + if (!$manifest.Fixture) { + if ([string]$manifest.MsiTransactionState -ceq 'PENDING') { + throw 'MSI transaction has no durable cleanup authority receipt' + } + if ([string]$manifest.MsiTransactionState -ceq 'NONE' -and + [bool]$manifest.InstallAttempted) { + throw 'MSI install attempt has no transaction receipt' + } + if ([string]$manifest.MsiTransactionState -ceq 'ROLLED_BACK_CLEAN') { + Assert-MsiRolledBackCleanBaseline $manifest + } + } $adoptedProvisionalUser = $false foreach ($record in @($manifest.Users)) { if (Resolve-ProvisionalOwnedUser $record) { $adoptedProvisionalUser = $true } @@ -1196,10 +1314,10 @@ try { $cleanupFailed = $true } } - if ([bool]$manifest.InstallAttempted) { + if ([string]$manifest.MsiTransactionState -ceq 'COMMITTED') { Assert-MsiManagedFileSystemAuthority $manifest } - if ($allowProvisionalMsiUninstall -and !$cleanupFailed) { + if ($allowAuthenticatedMsiUninstall -and !$cleanupFailed) { $msiExitCode = 1618 for ($attempt = 0; $attempt -lt 12 -and $msiExitCode -eq 1618; $attempt += 1) { if ($attempt -ne 0) { Start-Sleep -Seconds 2 } diff --git a/apps/desktop/scripts/run-installed-windows-app-harness.ps1 b/apps/desktop/scripts/run-installed-windows-app-harness.ps1 index 7693caf73..c608a3286 100644 --- a/apps/desktop/scripts/run-installed-windows-app-harness.ps1 +++ b/apps/desktop/scripts/run-installed-windows-app-harness.ps1 @@ -16,6 +16,7 @@ param( $ErrorActionPreference = 'Stop' $maximumMarkerDeadlineMilliseconds = 11 * 60 * 1000 +$msiCriticalTransactionGraceMilliseconds = 30 * 1000 $watchdogStages = @( 'INITIALIZATION','INSTALL','VALIDATION','USER_SETUP','APP_LAUNCH','APP_EXIT','UNINSTALL','CLEANUP' ) @@ -75,6 +76,7 @@ $exitCode = 125 $terminateOwnedTree = $false $workerStarted = $false $supervisorOutcomeComplete = $false +$postTerminationCleanupAuthorized = $true Add-Type -TypeDefinition @' using System; @@ -416,6 +418,7 @@ function Write-InitialOwnershipManifest( FixtureRoot = if ($Fixture) { $AuthorizedFixtureRoot } else { $null } BaselineClean = $false InstallAttempted = $false + MsiTransactionState = 'NONE' Directories = @() Files = @() RegistryKeys = @() @@ -440,6 +443,101 @@ 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 -ceq 'OWNERSHIP_CAPTURE' -and + [string]$Marker.Status -ceq 'COMPLETE') +} + +function Get-DurableMsiTransactionReceipt { + try { + $item = Get-Item -LiteralPath $ownershipManifestPath -Force -ErrorAction Stop + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + $item.Length -le 0 -or $item.Length -gt 65536) { return 'UNAVAILABLE' } + $bytes = [byte[]]::new([int]$item.Length) + $stream = [IO.FileStream]::new( + $item.FullName, + [IO.FileMode]::Open, + [IO.FileAccess]::Read, + [IO.FileShare]'ReadWrite, Delete', + 4096, + [IO.FileOptions]::SequentialScan + ) + try { + $offset = 0 + while ($offset -lt $bytes.Length) { + $read = $stream.Read($bytes, $offset, $bytes.Length - $offset) + if ($read -eq 0) { return 'UNAVAILABLE' } + $offset += $read + } + if ($stream.ReadByte() -ne -1) { return 'UNAVAILABLE' } + } finally { + $stream.Dispose() + } + $manifest = ConvertFrom-Json ` + -InputObject ([Text.UTF8Encoding]::new($false, $true).GetString($bytes)) ` + -ErrorAction Stop + if ([string]$manifest.RunId -cne $ownershipRunId -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' } + if ([string]$manifest.MsiTransactionState -ceq 'ROLLED_BACK_CLEAN' -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 + (!$manifest.Fixture -and @($manifest.RegistryValues).Count -eq 1 -and + !$manifest.RegistryValues[0].Owned))) { + return 'ROLLED_BACK_CLEAN' + } + if ([string]$manifest.MsiTransactionState -cne 'COMMITTED') { return 'UNAVAILABLE' } + $ownedDirectories = @($manifest.Directories | Where-Object { + $_.Owned -and [string]$_.Kind -in @('INSTALL_ROOT','SHORTCUT_FOLDER') -and + !$_.Provisional -and + [string]$_.Identity -match '^[a-f0-9]{24}$' -and + [string]$_.TreeIdentity -match '^[a-f0-9]{64}$' + }) + $ownedFiles = @($manifest.Files | Where-Object { + $_.Owned -and [string]$_.Kind -ceq 'SHORTCUT_FILE' -and !$_.Provisional -and + [string]$_.Identity -match '^[a-f0-9]{64}$' -and + [string]$_.EntryIdentity -match '^[a-f0-9]{24}$' + }) + $ownedRegistryKeys = @($manifest.RegistryKeys | Where-Object { + $_.Owned -and [string]$_.Kind -in @('PROTOCOL','APP_PATH') -and + !$_.Provisional -and [string]$_.Identity -match '^[a-f0-9]{64}$' + }) + $ownedRegistryValues = @($manifest.RegistryValues | Where-Object { + $_.Owned -and [string]$_.Kind -ceq 'HKCU_INSTALLED' -and !$_.Provisional -and + [string]$_.IdentityValueKind -and [string]$_.IdentityValueData + }) + if ($ownedDirectories.Count -ne 2 -or $ownedFiles.Count -ne 1 -or + (!$manifest.Fixture -and + ($ownedRegistryKeys.Count -ne 2 -or $ownedRegistryValues.Count -ne 1))) { + return 'UNAVAILABLE' + } + return 'COMMITTED' + } catch { + return 'UNAVAILABLE' + } +} + +function Wait-MsiCriticalTransactionReceipt { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:MSI_TRANSACTION:GRACE' + $stopwatch = [Diagnostics.Stopwatch]::StartNew() + do { + $receipt = Get-DurableMsiTransactionReceipt + if ($receipt -in @('COMMITTED','ROLLED_BACK_CLEAN')) { + Write-WatchdogLine ` + "PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:MSI_TRANSACTION:$receipt" + return $true + } + Start-Sleep -Milliseconds 25 + } while ($stopwatch.ElapsedMilliseconds -lt $msiCriticalTransactionGraceMilliseconds) + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:MSI_TRANSACTION:UNPROVEN' + return $false +} + function Invoke-PostTerminationCleanup([string]$InstallerPath, [string]$AuthorizedFixtureRoot) { $cleanupJob = $null $cleanupProcess = $null @@ -600,7 +698,17 @@ try { $firstMarkerAccepted = $false while ($true) { if ($null -ne $cancellationEvent -and $cancellationEvent.WaitOne(0)) { + try { + $cancellationMarker = Read-WatchdogMarker $markerPath $MarkerReadTimeoutMilliseconds + if ($cancellationMarker.State -eq 'Valid' -and + (Test-WatchdogMarkerSchema $cancellationMarker)) { + $lastValidMarker = $cancellationMarker + } + } catch {} Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:SUPERVISOR:CANCELLED' + if (Test-MsiCriticalMarker $lastValidMarker) { + $postTerminationCleanupAuthorized = Wait-MsiCriticalTransactionReceipt + } $exitCode = 125 $terminateOwnedTree = $true break @@ -644,6 +752,9 @@ try { Write-WatchdogLine ('PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:{0}:{1}:{2}:TIMED_OUT' -f ` $marker.Stage, $marker.Substage, $marker.Status) $exitCode = 124 + if (Test-MsiCriticalMarker $marker) { + $postTerminationCleanupAuthorized = Wait-MsiCriticalTransactionReceipt + } $terminateOwnedTree = $true break } @@ -697,7 +808,7 @@ try { # Job Object API requires a valid uint32, so finalization always uses this # fixed supervisor-owned termination code instead of casting worker status. $workerTreeTerminated = Stop-OwnedWorker 125 - if ($workerTreeTerminated) { + if ($workerTreeTerminated -and $postTerminationCleanupAuthorized) { $fixedCleanupResult = Invoke-PostTerminationCleanup $installerPath $FixtureCleanupRoot } else { Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:FAILED' diff --git a/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 b/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 index d5439ccb1..9bc915c9d 100644 --- a/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 +++ b/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 @@ -47,8 +47,8 @@ $validatedManifestPath = $null $controllerBodyActive = $false function Write-FixedResult([ValidateSet('COMPLETE','FAILED','TIMED_OUT')][string]$Result) { - Write-Host "PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:$Result" - Write-Host ( + [Console]::Out.WriteLine("PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:$Result") + [Console]::Out.WriteLine( 'PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:STATUS:{0}:EXIT_CODE:{1}' -f ` $script:fixedStatus, $script:fixedExitCode) [Console]::Out.Flush() 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 4268fa095..c8e4b483b 100644 --- a/apps/desktop/scripts/test-installed-windows-app-supervisor-fixture.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app-supervisor-fixture.ps1 @@ -9,6 +9,7 @@ param( $ErrorActionPreference = 'Stop' function Initialize-FixtureDirectoryIdentity { + if ('ProPRFixtureDirectoryIdentity' -as [type]) { return } Add-Type -TypeDefinition @' using System; using System.ComponentModel; @@ -71,6 +72,8 @@ if ($scenario -notin @( 'INACCESSIBLE_MARKER', 'NEGATIVE_EXIT', 'CANCELLATION', + 'DURING_MSI', + 'DURING_OWNERSHIP_CAPTURE', 'OWNED_RESOURCES_NORMAL_SUCCESS', 'OWNED_RESOURCES_FOR_INTERRUPTION', 'SMOKE_BEFORE_PROMOTION_THEN_DEADLINE', @@ -81,6 +84,7 @@ if ($scenario -notin @( 'PRIMARY_FALLBACK_FOREIGN_DESCENDANTS', 'OWNED_RESOURCES_FOREIGN_CHILD_THEN_DEADLINE', 'OWNED_EXECUTABLE_REPLACED_THEN_DEADLINE', + 'OWNED_EXECUTABLE_BYTE_IDENTICAL_REPLACED_THEN_DEADLINE', 'OWNED_SHORTCUT_REPLACED_THEN_DEADLINE', 'OWNED_RESOURCES_REPLACED_THEN_DEADLINE', 'OWNED_RESOURCES_THEN_DEADLINE' @@ -131,6 +135,14 @@ function Write-FixtureOwnershipManifest($Manifest) { [IO.File]::Move($temporaryManifest, $OwnershipManifest, $true) } +function Write-FixtureCriticalGate([string]$Name) { + [IO.File]::WriteAllText( + (Join-Path $stateDirectory 'critical-gate.txt'), + $Name, + [Text.Encoding]::ASCII + ) +} + function Write-FixtureOwnershipToken([string]$Path, [string]$Token) { $bytes = [Text.Encoding]::ASCII.GetBytes($Token) $stream = [IO.FileStream]::new( @@ -235,7 +247,8 @@ function New-FixtureSmokeArtifacts([string]$Path) { function New-OwnedFixtureResources( [ValidateSet('BEFORE_PROMOTION','AFTER_PROMOTION','AFTER_ARTIFACTS')] - [string]$SmokeCheckpoint = 'AFTER_ARTIFACTS' + [string]$SmokeCheckpoint = 'AFTER_ARTIFACTS', + [bool]$PublishCommittedReceipt = $true ) { Initialize-FixtureDirectoryIdentity $manifest = [IO.File]::ReadAllText($OwnershipManifest, [Text.Encoding]::UTF8) | @@ -380,6 +393,7 @@ function New-OwnedFixtureResources( } $manifest.Profiles = @() $manifest.InstallAttempted = $true + if ($PublishCommittedReceipt) { $manifest.MsiTransactionState = 'COMMITTED' } if ($env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_PROFILE_SID) { $manifest.Profiles += [ordered]@{ Sid = $env:PROPR_SUPERVISOR_FIXTURE_CONFLICT_PROFILE_SID @@ -446,6 +460,38 @@ function New-OwnedFixtureResources( (Join-Path $stateDirectory 'resources.json') -Encoding ASCII } +function New-ByteIdenticalOwnedFileFixture { + Initialize-FixtureDirectoryIdentity + $manifest = [IO.File]::ReadAllText($OwnershipManifest, [Text.Encoding]::UTF8) | + ConvertFrom-Json -ErrorAction Stop + $root = Join-Path $stateDirectory 'byte-identical-file-root' + $executable = Join-Path $root 'owned-file.exe' + [void](New-Item -ItemType Directory -Path $root -ErrorAction Stop) + [IO.File]::WriteAllText($executable, 'owned-executable', [Text.Encoding]::ASCII) + $manifest.BaselineClean = $false + $manifest.InstallAttempted = $false + $manifest.MsiTransactionState = 'NONE' + $manifest.Directories = @() + $manifest.Files = @([ordered]@{ + Kind = 'FIXTURE_FILE'; Path = $executable; Owned = $true; Token = $null + Identity = (Get-FixtureFileIdentity $executable) + EntryIdentity = (Get-FixtureEntryIdentity $executable $false) + Provisional = $false + }) + $manifest.RegistryKeys = @() + $manifest.RegistryValues = @() + $manifest.Users = @() + $manifest.Profiles = @() + Write-FixtureOwnershipManifest $manifest + [ordered]@{ + Executable = $executable + ManifestPath = $OwnershipManifest + RunId = [string]$manifest.RunId + ByteIdenticalReplacement = $true + } | ConvertTo-Json -Compress | Set-Content -LiteralPath ` + (Join-Path $stateDirectory 'resources.json') -Encoding ASCII +} + function New-SmokeCheckpointFixtureResources( [ValidateSet('BEFORE_PROMOTION','AFTER_PROMOTION','AFTER_ARTIFACTS')] [string]$Checkpoint @@ -572,6 +618,21 @@ function Replace-FixtureExecutable { (Join-Path $stateDirectory 'resources.json') -Encoding ASCII } +function Replace-FixtureExecutableByteIdenticallyViaMove { + $state = Get-Content -LiteralPath (Join-Path $stateDirectory 'resources.json') ` + -Raw -Encoding ASCII | ConvertFrom-Json -ErrorAction Stop + $backup = Join-Path $stateDirectory 'original-byte-identical-executable.exe' + $replacement = Join-Path $stateDirectory 'foreign-byte-identical-executable.exe' + [IO.File]::Copy($state.Executable, $replacement, $false) + Move-Item -LiteralPath $state.Executable -Destination $backup -ErrorAction Stop + Move-Item -LiteralPath $replacement -Destination $state.Executable -ErrorAction Stop + $state | Add-Member -NotePropertyName ExecutableBackup -NotePropertyValue $backup + $state | Add-Member -NotePropertyName ByteIdenticalReplacement ` + -NotePropertyValue $true + $state | ConvertTo-Json -Compress | Set-Content -LiteralPath ` + (Join-Path $stateDirectory 'resources.json') -Encoding ASCII +} + function Replace-FixtureShortcut { $state = Get-Content -LiteralPath (Join-Path $stateDirectory 'resources.json') ` -Raw -Encoding ASCII | ConvertFrom-Json -ErrorAction Stop @@ -740,6 +801,49 @@ switch ($scenario) { [DateTime]::UtcNow.AddSeconds(10).Ticks) Start-Sleep -Seconds 300 } + 'DURING_MSI' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + $manifest = [IO.File]::ReadAllText($OwnershipManifest, [Text.Encoding]::UTF8) | + ConvertFrom-Json -ErrorAction Stop + $manifest.BaselineClean = $true + $manifest.InstallAttempted = $true + $manifest.MsiTransactionState = 'PENDING' + Write-FixtureOwnershipManifest $manifest + Write-FixtureMarker ('{0}|INSTALL|MSI_INSTALL|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + Write-FixtureCriticalGate 'DURING_MSI' + Start-Sleep -Milliseconds 750 + $manifest.Directories = @() + $manifest.Files = @() + $manifest.RegistryKeys = @() + $manifest.RegistryValues = @() + $manifest.MsiTransactionState = 'ROLLED_BACK_CLEAN' + Write-FixtureOwnershipManifest $manifest + Write-FixtureMarker ('{0}|INSTALL|OWNERSHIP_CAPTURE|COMPLETE' -f ` + [DateTime]::UtcNow.AddSeconds(60).Ticks) + Start-Sleep -Seconds 300 + } + 'DURING_OWNERSHIP_CAPTURE' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + Initialize-FixtureDirectoryIdentity + $manifest = [IO.File]::ReadAllText($OwnershipManifest, [Text.Encoding]::UTF8) | + ConvertFrom-Json -ErrorAction Stop + $manifest.BaselineClean = $true + $manifest.InstallAttempted = $true + $manifest.MsiTransactionState = 'PENDING' + Write-FixtureOwnershipManifest $manifest + Write-FixtureMarker ('{0}|INSTALL|OWNERSHIP_CAPTURE|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' + Write-FixtureOwnershipManifest $manifest + Write-FixtureMarker ('{0}|INSTALL|OWNERSHIP_CAPTURE|COMPLETE' -f ` + [DateTime]::UtcNow.AddSeconds(60).Ticks) + Start-Sleep -Seconds 300 + } 'NEGATIVE_EXIT' { Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(10).Ticks) Start-Sleep -Milliseconds 500 @@ -824,6 +928,14 @@ switch ($scenario) { [DateTime]::UtcNow.AddMilliseconds(500).Ticks) Start-Sleep -Seconds 300 } + 'OWNED_EXECUTABLE_BYTE_IDENTICAL_REPLACED_THEN_DEADLINE' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + New-ByteIdenticalOwnedFileFixture + Replace-FixtureExecutableByteIdenticallyViaMove + Write-FixtureMarker ('{0}|CLEANUP|SMOKE_DATA_REMOVE|BEGIN' -f ` + [DateTime]::UtcNow.AddMilliseconds(500).Ticks) + Start-Sleep -Seconds 300 + } 'OWNED_SHORTCUT_REPLACED_THEN_DEADLINE' { Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) New-OwnedFixtureResources diff --git a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 index 01bdc7928..b885568a7 100644 --- a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 @@ -207,8 +207,11 @@ function Assert-ReplacedFixtureResourcesSurvive($Owned) { } function Assert-ReplacedExecutableSurvives($Owned) { + $expected = if ($Owned.PSObject.Properties['ByteIdenticalReplacement']) { + 'owned-executable' + } else { 'foreign-executable' } Assert-True ((Get-Content -LiteralPath $Owned.Executable -Raw).Trim() -ceq - 'foreign-executable') 'replacement executable was removed or changed' + $expected) 'replacement executable was removed or changed' } function Assert-ReplacedShortcutSurvives($Owned) { @@ -260,31 +263,39 @@ function Invoke-WorkflowCleanupController( Assert-True ($process.WaitForExit(40000)) 'workflow cleanup fixture exceeded its bound' $output = $process.StandardOutput.ReadToEnd() $errorOutput = $process.StandardError.ReadToEnd() - Assert-True ($output.Length -le 512) 'workflow cleanup fixture output exceeded its fixed bound' $outputLines = @($output -split '\r?\n' | Where-Object { $_ }) - Assert-True ($outputLines.Count -eq 2) ` - 'workflow cleanup fixture did not emit exactly two fixed result lines' + $lineCount = if ($outputLines.Count -ge 3) { '3+' } else { [string]$outputLines.Count } + $stderrCount = [Math]::Min(4096, $errorOutput.Length) + if ($output.Length -gt 512 -or $outputLines.Count -ne 2) { + throw ('PROPR_WORKFLOW_CLEANUP_FIXTURE:PROTOCOL_MISMATCH:LINE_COUNT:{0}:STDERR_COUNT:{1}' -f ` + $lineCount, $stderrCount) + } $resultMatch = [regex]::Match( $outputLines[0], '^PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:(COMPLETE|FAILED|TIMED_OUT)$' ) - Assert-True $resultMatch.Success ` - 'workflow cleanup fixture emitted an invalid fixed result' + if (!$resultMatch.Success) { + throw ('PROPR_WORKFLOW_CLEANUP_FIXTURE:PROTOCOL_MISMATCH:LINE_COUNT:{0}:STDERR_COUNT:{1}' -f ` + $lineCount, $stderrCount) + } $resultName = $resultMatch.Groups[1].Value $statusMatch = [regex]::Match( $outputLines[1], '^PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:STATUS:([A-Z_]+):EXIT_CODE:([0-9]+)$' ) - Assert-True $statusMatch.Success ` - 'workflow cleanup fixture emitted an invalid fixed status' + if (!$statusMatch.Success) { + throw ('PROPR_WORKFLOW_CLEANUP_FIXTURE:PROTOCOL_MISMATCH:LINE_COUNT:{0}:STDERR_COUNT:{1}' -f ` + $lineCount, $stderrCount) + } $controllerStatus = $statusMatch.Groups[1].Value $reportedExitCode = [int]$statusMatch.Groups[2].Value if ($errorOutput.Length -ne 0) { $stderrCode = if ($errorOutput.Length -gt 4096) { 'CONTROLLER_STDERR_LIMIT' } else { 'CONTROLLER_STDERR_PRESENT' } - throw ('PROPR_WORKFLOW_CLEANUP_FIXTURE:{0}:STATUS:{1}:EXIT_CODE:{2}' -f ` - $stderrCode, $controllerStatus, $reportedExitCode) + throw ('PROPR_WORKFLOW_CLEANUP_FIXTURE:{0}:STATUS:{1}:EXIT_CODE:{2}:' + + 'LINE_COUNT:{3}:STDERR_COUNT:{4}' -f ` + $stderrCode, $controllerStatus, $reportedExitCode, $lineCount, $stderrCount) } return [PSCustomObject]@{ ExitCode = $process.ExitCode @@ -371,6 +382,7 @@ function Invoke-FixtureScenario( 'OWNED_RESOURCES_THEN_DEADLINE', 'OWNED_RESOURCES_REPLACED_THEN_DEADLINE', 'OWNED_EXECUTABLE_REPLACED_THEN_DEADLINE', + 'OWNED_EXECUTABLE_BYTE_IDENTICAL_REPLACED_THEN_DEADLINE', 'OWNED_SHORTCUT_REPLACED_THEN_DEADLINE', 'OWNED_RESOURCES_FOREIGN_CHILD_THEN_DEADLINE', 'SMOKE_BEFORE_PROMOTION_THEN_DEADLINE', @@ -400,6 +412,74 @@ function Invoke-FixtureScenario( } } +function Invoke-CriticalCancellationScenario([string]$Scenario) { + $stateDirectory = New-StateDirectory $Scenario.ToLowerInvariant() + $eventName = "Local\ProPRInstalledAppCancellation-$([Guid]::NewGuid().ToString('N'))" + $cancellation = [Threading.EventWaitHandle]::new( + $false, [Threading.EventResetMode]::ManualReset, $eventName) + $process = [Diagnostics.Process]::new() + $process.StartInfo = New-SupervisorStartInfo ` + $Scenario $stateDirectory $eventName $false + try { + if (!$process.Start()) { throw 'critical-cancellation supervisor did not start' } + $gatePath = Join-Path $stateDirectory 'critical-gate.txt' + $gateWait = [Diagnostics.Stopwatch]::StartNew() + while (!(Test-Path -LiteralPath $gatePath -PathType Leaf)) { + if ($gateWait.ElapsedMilliseconds -ge 45000) { + throw 'critical-cancellation fixture did not reach its interruption gate' + } + Start-Sleep -Milliseconds 25 + } + Assert-True ((Get-Content -LiteralPath $gatePath -Raw -Encoding ASCII) -ceq $Scenario) ` + 'critical-cancellation fixture published the wrong interruption gate' + [void]$cancellation.Set() + Assert-True ($process.WaitForExit(90000)) ` + 'critical-cancellation supervisor exceeded its fixed completion bound' + $output = $process.StandardOutput.ReadToEnd() + $errorOutput = $process.StandardError.ReadToEnd() + Assert-ProcessTreeGone (Read-FixtureProcessState $stateDirectory) + return [PSCustomObject]@{ + ExitCode = $process.ExitCode + Output = $output + Error = $errorOutput + StateDirectory = $stateDirectory + } + } finally { + if (!$process.HasExited) { try { $process.Kill($true) } catch {} } + $process.Dispose() + $cancellation.Dispose() + } +} + +function Test-MsiTransactionInterruptionGates { + $duringMsi = Invoke-CriticalCancellationScenario 'DURING_MSI' + Assert-True ($duringMsi.ExitCode -eq 125) ` + 'DURING_MSI cancellation did not preserve the supervisor cancellation status' + Assert-Contains $duringMsi.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:MSI_TRANSACTION:GRACE' ` + 'DURING_MSI cancellation did not enter the fixed transaction grace' + Assert-Contains $duringMsi.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:MSI_TRANSACTION:ROLLED_BACK_CLEAN' ` + 'DURING_MSI cancellation did not prove the exact clean rollback receipt' + Assert-Contains $duringMsi.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:COMPLETE' ` + 'DURING_MSI clean rollback did not complete bounded cleanup' + Assert-True (!(Test-Path -LiteralPath (Join-Path $duringMsi.StateDirectory 'owned'))) ` + 'DURING_MSI rollback did not retain the exact clean fixture baseline' + + $duringCapture = Invoke-CriticalCancellationScenario 'DURING_OWNERSHIP_CAPTURE' + Assert-True ($duringCapture.ExitCode -eq 125) ` + 'DURING_OWNERSHIP_CAPTURE cancellation did not preserve cancellation status' + Assert-Contains $duringCapture.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:MSI_TRANSACTION:COMMITTED' ` + 'DURING_OWNERSHIP_CAPTURE did not publish durable nonprovisional authority' + Assert-Contains $duringCapture.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:COMPLETE' ` + 'DURING_OWNERSHIP_CAPTURE durable authority did not complete cleanup' + $capturedOwned = Read-FixtureResourceState $duringCapture.StateDirectory + Assert-OwnedResourcesGone $capturedOwned +} + function Test-BootstrapTimeout { $result = Invoke-FixtureScenario 'NO_MARKER' Assert-True ($result.ExitCode -eq 124) 'missing-marker bootstrap did not fail with the watchdog code' @@ -769,6 +849,29 @@ function Test-PreExistingCleanupOwnership { Assert-OwnedResourcesGone $replacedOwned } + $byteIdenticalDirectory = New-StateDirectory 'byte-identical-replaced-executable' + $byteIdenticalResult = Invoke-FixtureScenario ` + 'OWNED_EXECUTABLE_BYTE_IDENTICAL_REPLACED_THEN_DEADLINE' $byteIdenticalDirectory + Assert-True ($byteIdenticalResult.ExitCode -eq 125) ` + 'byte-identical replace-via-move did not fail closed on entry identity' + $byteIdenticalOwned = Read-FixtureResourceState $byteIdenticalDirectory + Assert-ReplacedExecutableSurvives $byteIdenticalOwned + $byteIdenticalManifest = Get-Content -LiteralPath $byteIdenticalOwned.ManifestPath ` + -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop + Assert-True ($byteIdenticalManifest.State -ceq 'ACTIVE') ` + 'byte-identical replace-via-move discarded ACTIVE recovery authority' + Remove-Item -LiteralPath $byteIdenticalOwned.Executable -Force -ErrorAction Stop + Move-Item -LiteralPath $byteIdenticalOwned.ExecutableBackup ` + -Destination $byteIdenticalOwned.Executable -ErrorAction Stop + $byteIdenticalRetry = Invoke-WorkflowCleanupController ` + $byteIdenticalOwned.ManifestPath $byteIdenticalOwned.RunId $byteIdenticalDirectory + Assert-True ($byteIdenticalRetry.ExitCode -eq 0 -and + $byteIdenticalRetry.Result -ceq 'COMPLETE') ` + 'byte-identical file cleanup did not succeed after exact entry identity restoration' + Assert-True (!(Test-Path -LiteralPath $byteIdenticalOwned.Executable) -and + !(Test-Path -LiteralPath $byteIdenticalOwned.ManifestPath)) ` + 'byte-identical file retry did not consume the exact owned entry and authority' + $foreignChildStateDirectory = New-StateDirectory 'in-place-foreign-child' $foreignChildResult = Invoke-FixtureScenario ` 'OWNED_RESOURCES_FOREIGN_CHILD_THEN_DEADLINE' $foreignChildStateDirectory @@ -980,7 +1083,8 @@ function Test-PreExistingCleanupOwnership { ExpiresUtcTicks = $createdTicks + ([TimeSpan]::TicksPerHour * 3) InstallerPath = $dummyInstaller; Fixture = $true FixtureRoot = $workflowStateDirectory; BaselineClean = $false - InstallAttempted = $false; Directories = @(); Files = @() + InstallAttempted = $false; MsiTransactionState = 'NONE' + Directories = @(); Files = @() RegistryKeys = @(); RegistryValues = @(); Users = @(); Profiles = @() } [IO.File]::WriteAllText( @@ -1196,6 +1300,7 @@ function Test-PreExistingAppPathsAuthority { ExpiresUtcTicks = $createdTicks + ([TimeSpan]::TicksPerHour * 3) InstallerPath = $dummyInstaller; Fixture = $false; FixtureRoot = $null BaselineClean = $true; InstallAttempted = $true + MsiTransactionState = 'COMMITTED' Directories = @(); Files = @(); Users = @(); Profiles = @() RegistryValues = @([ordered]@{ Kind = 'HKCU_INSTALLED' @@ -1289,6 +1394,7 @@ function Test-HkcuInstalledValueOwnership { FixtureRoot = $null BaselineClean = $InstallAttempted InstallAttempted = $InstallAttempted + MsiTransactionState = if ($InstallAttempted) { 'PENDING' } else { 'NONE' } Directories = @() Files = @() RegistryKeys = @() @@ -1342,13 +1448,13 @@ function Test-HkcuInstalledValueOwnership { $unchangedManifest.Path $unchangedManifest.RunId '' Assert-True ($unchanged.ExitCode -eq 21 -and $unchanged.ControllerStatus -ceq 'OWNED_RESOURCE_CLEANUP_FAILURE') ` - 'unchanged HKCU baseline incorrectly bypassed the MSI uninstall attempt' + 'path-only pending MSI receipt was not rejected before uninstall' $unchangedKey = Get-Item -LiteralPath $desktopKey -ErrorAction Stop Assert-True ($unchangedKey.GetValueKind($installedName).ToString() -ceq 'String' -and [string]$unchangedKey.GetValue($installedName) -ceq $sentinelInstalled) ` - 'failed MSI uninstall changed the unchanged HKCU baseline' + 'rejected pending MSI receipt changed the unchanged HKCU baseline' Assert-True (Test-Path -LiteralPath $unchangedManifest.Path -PathType Leaf) ` - 'failed unchanged-HKCU uninstall discarded authenticated recovery authority' + 'rejected pending MSI receipt discarded authenticated recovery authority' Remove-Item -LiteralPath $unchangedManifest.Path -Force -ErrorAction Stop Remove-Item -LiteralPath $desktopKey -Recurse -Force -ErrorAction Stop @@ -1436,6 +1542,7 @@ function Test-ProvisionalUserMarkerOwnership { FixtureRoot = $testRoot BaselineClean = $false InstallAttempted = $false + MsiTransactionState = 'NONE' Directories = @() Files = @() RegistryKeys = @() @@ -1524,6 +1631,7 @@ try { Test-NegativeWorkerExitFinalization Test-FailClosedMarkers Test-LiveCancellationAndRedaction + Test-MsiTransactionInterruptionGates Test-PrimaryWorkerFallbackForeignDescendants Test-PreExistingCleanupOwnership Test-SmokePromotionInterruptionAuthority diff --git a/apps/desktop/scripts/test-installed-windows-app.ps1 b/apps/desktop/scripts/test-installed-windows-app.ps1 index 3d2fc9b65..984d597a8 100644 --- a/apps/desktop/scripts/test-installed-windows-app.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app.ps1 @@ -118,6 +118,7 @@ $shortcutOwnedIdentity = $null $shortcutOwnedEntryIdentity = $null $hkcuDesktopKeyCreatedByRun = $false $msiTimeoutMilliseconds = 10 * 60 * 1000 +$msiCaptureRollbackGraceMilliseconds = 30 * 1000 $applicationTimeoutMilliseconds = 5 * 60 * 1000 $terminationTimeoutMilliseconds = 30 * 1000 $redirectedStreamDrainTimeoutMilliseconds = 30 * 1000 @@ -244,6 +245,7 @@ $ownershipState = [ordered]@{ FixtureRoot = $null BaselineClean = $false InstallAttempted = $false + MsiTransactionState = 'NONE' Directories = @() Files = @() RegistryKeys = @() @@ -535,6 +537,87 @@ function Get-RegistryValueSnapshot([string]$Path, [string]$Name) { } } +function Get-MsiProductCode([string]$Path) { + $installerCom = $null + $database = $null + $view = $null + $record = $null + try { + $installerCom = New-Object -ComObject WindowsInstaller.Installer + $database = $installerCom.OpenDatabase($Path, 0) + $view = $database.OpenView( + "SELECT ``Value`` FROM ``Property`` WHERE ``Property`` = 'ProductCode'") + $view.Execute() + $record = $view.Fetch() + $productCode = if ($null -eq $record) { $null } else { [string]$record.StringData(1) } + if ($productCode -notmatch '^\{[A-Fa-f0-9]{8}(?:-[A-Fa-f0-9]{4}){3}-[A-Fa-f0-9]{12}\}$') { + throw 'MSI product identity is invalid' + } + return $productCode.ToUpperInvariant() + } finally { + foreach ($resource in @($record, $view, $database, $installerCom)) { + if ($null -ne $resource -and [Runtime.InteropServices.Marshal]::IsComObject($resource)) { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($resource) + } + } + } +} + +function Assert-MsiProductIsUnregistered([string]$Path) { + $installerCom = $null + try { + $productCode = Get-MsiProductCode $Path + $installerCom = New-Object -ComObject WindowsInstaller.Installer + if ([int]$installerCom.ProductState($productCode) -ne -1) { + throw 'Windows Installer product registration is not at the clean baseline' + } + } finally { + if ($null -ne $installerCom -and + [Runtime.InteropServices.Marshal]::IsComObject($installerCom)) { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($installerCom) + } + } +} + +function Assert-ExactCleanMsiBaselineAfterRollback { + foreach ($path in @( + $installRoot, + $startMenuShortcutFolder, + $protocolRegistryPath, + $appPathsRegistryPath + )) { + if (Test-Path -LiteralPath $path) { + throw 'Windows Installer rollback did not restore the exact clean baseline' + } + } + $current = Get-RegistryValueSnapshot $hkcuDesktopRegistryPath $hkcuInstalledValueName + $valueMatches = if ($hkcuInstalledValueExistedBeforeInstall) { + $current.Exists -and $current.Kind -ceq $hkcuInstalledBaselineKind -and + $current.Data -ceq $hkcuInstalledBaselineData + } else { !$current.Exists } + $keyMatches = (Test-Path -LiteralPath $hkcuDesktopRegistryPath) -eq + $hkcuDesktopKeyExistedBeforeInstall + if (!$valueMatches -or !$keyMatches) { + throw 'Windows Installer rollback did not restore the exact current-user baseline' + } + Assert-MsiProductIsUnregistered $installerPath +} + +function Wait-ExactCleanMsiBaselineAfterRollback { + $stopwatch = [Diagnostics.Stopwatch]::StartNew() + do { + try { + Assert-ExactCleanMsiBaselineAfterRollback + return + } catch { + if ($stopwatch.ElapsedMilliseconds -ge $msiCaptureRollbackGraceMilliseconds) { + throw 'Windows Installer rollback clean-baseline grace expired' + } + } + Start-Sleep -Milliseconds 100 + } while ($true) +} + function Test-MsiInstalledValue([string]$Path, [string]$Name) { $snapshot = Get-RegistryValueSnapshot $Path $Name return $snapshot.Exists -and $snapshot.Kind -ceq 'DWord' -and @@ -733,6 +816,7 @@ try { $startMenuShortcutExistedBeforeInstall -or $startMenuShortcutFolderExistedBeforeInstall) { throw 'installed-app harness requires an unowned clean machine baseline' } + Assert-MsiProductIsUnregistered $installerPath $ownershipState.BaselineClean = $true Write-OwnershipManifest Write-WatchdogMarker 'INITIALIZATION' 'BASELINE' $externalOperationTimeoutMilliseconds 'COMPLETE' @@ -1553,8 +1637,9 @@ try { try { $installAttempted = $true $ownershipState.InstallAttempted = $true - # The clean baseline plus install-attempt transition is only provisional - # evidence for a bounded MSI uninstall until exact ownership is captured. + $ownershipState.MsiTransactionState = 'PENDING' + # PENDING is a recovery signal only. It never authorizes MSI uninstall or + # path-based reconstruction/deletion; only a durable transaction receipt can. $ownershipState.Directories = @( [ordered]@{ Kind = 'INSTALL_ROOT'; Path = $installRoot; Owned = $true @@ -1595,6 +1680,7 @@ try { KeyCreatedByRun = $false }) Write-OwnershipManifest + $msiTransactionFailure = $null try { Invoke-BoundedExternalOperation ` -Stage 'INSTALL' ` @@ -1604,13 +1690,42 @@ try { Invoke-Msi @('/i', "`"$installerPath`"", '/qn', '/norestart') 'machine install' $script:msiInstallCompleted = $true } - } finally { + } catch { + $msiTransactionFailure = $_ + } + if ($null -ne $msiTransactionFailure) { + Invoke-BoundedExternalOperation ` + -Stage 'INSTALL' ` + -Substage 'OWNERSHIP_CAPTURE' ` + -TimeoutMilliseconds $externalOperationTimeoutMilliseconds ` + -Operation { + Wait-ExactCleanMsiBaselineAfterRollback + $ownershipState.Directories = @() + $ownershipState.Files = @() + $ownershipState.RegistryKeys = @() + $ownershipState.RegistryValues = @([ordered]@{ + Kind = 'HKCU_INSTALLED'; Path = $hkcuDesktopRegistryPath + Name = $hkcuInstalledValueName; Owned = $false; Provisional = $false + BaselineKeyExisted = $hkcuDesktopKeyExistedBeforeInstall + BaselineValueExisted = $hkcuInstalledValueExistedBeforeInstall + BaselineValueKind = $hkcuInstalledBaselineKind + BaselineValueData = $hkcuInstalledBaselineData + IdentityValueKind = $null; IdentityValueData = $null + KeyCreatedByRun = $false + }) + $ownershipState.MsiTransactionState = 'ROLLED_BACK_CLEAN' + Write-OwnershipManifest + } + throw $msiTransactionFailure + } else { Invoke-BoundedExternalOperation ` -Stage 'INSTALL' ` -Substage 'OWNERSHIP_CAPTURE' ` -TimeoutMilliseconds $externalOperationTimeoutMilliseconds ` -Operation { - if (!$script:msiInstallCompleted) { return } + if (!$script:msiInstallCompleted) { + throw 'MSI transaction commit status is unavailable' + } $script:installRootCreatedByRun = !$installRootExistedBeforeInstall -and (Test-Path -LiteralPath $installRoot) $script:protocolCreatedByRun = @@ -1626,6 +1741,11 @@ try { $script:startMenuShortcutFolderCreatedByRun = !$startMenuShortcutFolderExistedBeforeInstall -and (Test-Path -LiteralPath $startMenuShortcutFolder) + if (!$script:installRootCreatedByRun -or !$script:protocolCreatedByRun -or + !$script:appPathsCreatedByRun -or !$script:startMenuShortcutCreatedByRun -or + !$script:startMenuShortcutFolderCreatedByRun) { + throw 'MSI commit did not create every canonical managed resource' + } $ownedDirectories = @() if ($script:installRootCreatedByRun) { $script:installRootOwnedIdentity = Get-DirectoryIdentity $installRoot @@ -1673,6 +1793,9 @@ try { $ownedRegistryKeys = @() if ($script:protocolCreatedByRun) { $script:protocolOwnedIdentity = Get-RegistryTreeIdentity $protocolRegistryPath + if ([string]$script:protocolOwnedIdentity -notmatch '^[a-f0-9]{64}$') { + throw 'installed protocol identity could not be captured' + } $ownedRegistryKeys += [ordered]@{ Kind = 'PROTOCOL'; Path = $protocolRegistryPath Owned = $true; Token = $null; Identity = $script:protocolOwnedIdentity @@ -1681,6 +1804,9 @@ try { } if ($script:appPathsCreatedByRun) { $script:appPathsOwnedIdentity = Get-RegistryTreeIdentity $appPathsRegistryPath + if ([string]$script:appPathsOwnedIdentity -notmatch '^[a-f0-9]{64}$') { + throw 'installed App Paths identity could not be captured' + } $ownedRegistryKeys += [ordered]@{ Kind = 'APP_PATH'; Path = $appPathsRegistryPath Owned = $true; Token = $null; Identity = $script:appPathsOwnedIdentity @@ -1709,6 +1835,7 @@ try { IdentityValueData = $script:hkcuInstalledOwnedData KeyCreatedByRun = $script:hkcuDesktopKeyCreatedByRun }) + $ownershipState.MsiTransactionState = 'COMMITTED' Write-OwnershipManifest } } @@ -1948,7 +2075,8 @@ try { throw } finally { $cleanupFailed = $false - if ($installAttempted) { + if ($installAttempted -and + [string]$ownershipState.MsiTransactionState -ceq 'COMMITTED') { Write-Stage 'UNINSTALL' 'BEGIN' $uninstallFailed = $false @@ -2284,6 +2412,7 @@ try { $ownershipState.State = 'EMPTY' $ownershipState.BaselineClean = $false $ownershipState.InstallAttempted = $false + $ownershipState.MsiTransactionState = 'NONE' $ownershipState.Directories = @() $ownershipState.Files = @() $ownershipState.RegistryKeys = @() diff --git a/apps/desktop/src/release-workflow.test.ts b/apps/desktop/src/release-workflow.test.ts index 1ef11a169..bac69893f 100644 --- a/apps/desktop/src/release-workflow.test.ts +++ b/apps/desktop/src/release-workflow.test.ts @@ -625,7 +625,10 @@ describe('desktop trusted release workflow', () => { assert.doesNotMatch(installedWindowsAppSupervisor, /Stop-OwnedWorker \(\[uint32\]\$exitCode\)/); assert.match(installedWindowsAppSupervisorFixture, /'NEGATIVE_EXIT'[\s\S]*exit -1/); assert.match(installedWindowsAppSupervisor, /\$worker\.WaitForExit\(\$WatchdogTerminationMilliseconds\)/); - assert.match(installedWindowsAppSupervisor, /if \(\$workerTreeTerminated\) \{[\s\S]*Invoke-PostTerminationCleanup/); + assert.match( + installedWindowsAppSupervisor, + /if \(\$workerTreeTerminated -and \$postTerminationCleanupAuthorized\) \{[\s\S]*Invoke-PostTerminationCleanup/, + ); assert.match(installedWindowsAppSupervisor, /Invoke-PostTerminationCleanup/); assert.match(installedWindowsAppSupervisor, /\$cleanupRequired = \$terminateOwnedTree -or \$workerStarted/); assert.match( @@ -649,9 +652,10 @@ describe('desktop trusted release workflow', () => { /Assert-MsiManagedFileSystemAuthority \$manifest\n\s+\$msi = Start-Process msiexec\.exe/, ); assert.doesNotMatch(installedWindowsAppCleanup, /AllowProvisionalProductOwnership/); + assert.doesNotMatch(installedWindowsAppCleanup, /allowProvisionalMsiUninstall/); assert.match( installedWindowsAppCleanup, - /\$allowProvisionalMsiUninstall[\s\S]*Start-Process msiexec\.exe/, + /\$allowAuthenticatedMsiUninstall[\s\S]*MsiTransactionState -ceq 'COMMITTED'[\s\S]*Start-Process msiexec\.exe/, ); assert.match( installedWindowsAppCleanup, @@ -659,8 +663,16 @@ describe('desktop trusted release workflow', () => { ); assert.match( installedWindowsAppTest, - /if \(!\$script:msiInstallCompleted\) \{ return \}[\s\S]*Get-DirectoryIdentity \$installRoot/, + /MsiTransactionState = 'PENDING'[\s\S]*if \(!\$script:msiInstallCompleted\)[\s\S]*Get-DirectoryIdentity \$installRoot/, ); + assert.match(installedWindowsAppTest, /MsiTransactionState = 'ROLLED_BACK_CLEAN'/); + assert.match(installedWindowsAppTest, /MsiTransactionState = 'COMMITTED'/); + assert.match(installedWindowsAppTest, /Assert-ExactCleanMsiBaselineAfterRollback/); + assert.match(installedWindowsAppTest, /Assert-MsiProductIsUnregistered \$installerPath/); + assert.match(installedWindowsAppCleanup, /Assert-MsiProductIsUnregistered/); + assert.match(installedWindowsAppSupervisor, /Wait-MsiCriticalTransactionReceipt/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /DURING_MSI/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /DURING_OWNERSHIP_CAPTURE/); assert.match( installedWindowsAppTest, /Registry::HKEY_LOCAL_MACHINE\\Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\propr-desktop\.exe/, @@ -697,6 +709,9 @@ describe('desktop trusted release workflow', () => { assert.match(installedWindowsAppWorkflowCleanup, /WorkflowCleanupControllerLine/); assert.match(installedWindowsAppWorkflowCleanup, /Set-CaughtControllerFailure/); assert.match(installedWindowsAppWorkflowCleanup, /\[Console\]::SetError\(\[IO\.TextWriter\]::Null\)/); + assert.match(installedWindowsAppWorkflowCleanup, /\[Console\]::Out\.WriteLine/); + assert.equal(installedWindowsAppWorkflowCleanup.match(/\[Console\]::Out\.WriteLine/g)?.length, 2); + assert.doesNotMatch(installedWindowsAppWorkflowCleanup, /Write-Host/); assert.ok( installedWindowsAppWorkflowCleanup.indexOf('[Console]::SetError([IO.TextWriter]::Null)') < installedWindowsAppWorkflowCleanup.indexOf("Add-Type -TypeDefinition @'"), @@ -732,7 +747,7 @@ describe('desktop trusted release workflow', () => { assert.match(installedWindowsAppSupervisorBehaviorTest, /replacement shortcut was removed or changed/); assert.match( installedWindowsAppSupervisorFixture, - /function Initialize-FixtureDirectoryIdentity \{\n\s+Add-Type -TypeDefinition/, + /function Initialize-FixtureDirectoryIdentity \{[\s\S]*?Add-Type -TypeDefinition/, ); assert.doesNotMatch( installedWindowsAppSupervisorFixture.slice( @@ -792,6 +807,24 @@ describe('desktop trusted release workflow', () => { assert.match(installedWindowsAppCleanup, /Resolve-SmokeDirectoryAuthority/); assert.match(installedWindowsAppCleanup, /Remove-OwnedSmokeDirectory/); assert.match(installedWindowsAppCleanup, /Get-FileSystemEntryIdentity/); + const ownedFileCleanup = installedWindowsAppCleanup.slice( + installedWindowsAppCleanup.indexOf('function Remove-OwnedFile'), + installedWindowsAppCleanup.indexOf('function Remove-OwnedRegistryKey'), + ); + assert.match( + ownedFileCleanup, + /Record\.EntryIdentity[\s\S]*Get-FileSystemEntryIdentity \$path \$false/, + ); + assert.ok( + ownedFileCleanup.indexOf('Get-FileSystemEntryIdentity $path $false') + < ownedFileCleanup.indexOf('Remove-Item -LiteralPath $path'), + 'owned file entry identity must be checked immediately before deletion', + ); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /OWNED_EXECUTABLE_BYTE_IDENTICAL_REPLACED_THEN_DEADLINE/, + ); + assert.match(installedWindowsAppSupervisorBehaviorTest, /LINE_COUNT:\{0\}:STDERR_COUNT:\{1\}/); assert.match(installedWindowsAppCleanup, /smoke user-data object owner is not authorized/); assert.match(installedWindowsAppCleanup, /smoke user-data object ACL is not authorized/); assert.match(installedWindowsAppCleanup, /entries\.Count -ge 50000/); From d2ba71eb795fd0a5d74d8601ed871535c24646a6 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 05:13:12 +0000 Subject: [PATCH 12/29] feat(ai): Implemented the exact-head F21 correction on `41cd874ada64900292e7f8ecc86da99f7e942905`. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented the exact-head F21 correction on `41cd874ada64900292e7f8ecc86da99f7e942905`. - Cleanup worker now handshakes before `Add-Type`. - Controller assigns the worker to its Job Object immediately after start, before drains and release. - Completion, timeout, success, and manifest deletion require Job active-process count zero. - Added early-initialization child-spawn timeout coverage with recovery-authority retention. - Changed only `VALID_THEN_DEADLINE` to `VALIDATION|INSTALL_TREE_SCAN|BEGIN`. - Preserved production/generic bounds and F10–F20. Validation: - Desktop suite: 177 passed, 6 platform skips. - Desktop typecheck: passed. - Focused workflow contract: 23 passed. - `git diff --check`: passed. Native x64/ARM64 execution was unavailable in this Linux environment, but the mandatory dual-architecture workflow fixture remains enforced. No commit was created. PR: #2042 Comment by: @integry (ID: 5489175076) Model: gpt-5.6-sol --- .../scripts/cleanup-installed-windows-app.ps1 | 81 ++++++++--- ...installed-windows-app-workflow-cleanup.ps1 | 128 ++++++++++++++---- ...stalled-windows-app-supervisor-fixture.ps1 | 2 +- .../test-installed-windows-app-supervisor.ps1 | 22 ++- apps/desktop/src/release-workflow.test.ts | 22 ++- 5 files changed, 207 insertions(+), 48 deletions(-) diff --git a/apps/desktop/scripts/cleanup-installed-windows-app.ps1 b/apps/desktop/scripts/cleanup-installed-windows-app.ps1 index d3ee1d57d..d8b03f300 100644 --- a/apps/desktop/scripts/cleanup-installed-windows-app.ps1 +++ b/apps/desktop/scripts/cleanup-installed-windows-app.ps1 @@ -3,7 +3,8 @@ param( [Parameter(Mandatory=$true)][string]$Installer, [Parameter(Mandatory=$true)][string]$ExpectedRunId, [Parameter(Mandatory=$true)][string]$OwnershipReadyEvent, - [string]$FixtureRoot + [string]$FixtureRoot, + [switch]$FixtureEarlyInitializationChild ) $ErrorActionPreference = 'Stop' @@ -14,6 +15,69 @@ $cleanupFailed = $false $manifestValidated = $false $authorizedRunId = $null +try { + if ($ExpectedRunId -notmatch '^[a-f0-9]{32}$') { exit 1 } + if ($OwnershipReadyEvent -notmatch '^Local\\ProPRInstalledAppCleanup-[a-f0-9]{32}$') { + exit 1 + } + $ownershipReady = [Threading.EventWaitHandle]::OpenExisting($OwnershipReadyEvent) + try { + if (!$ownershipReady.WaitOne(5000)) { exit 1 } + } finally { + $ownershipReady.Dispose() + } +} catch { + exit 1 +} + +# This fixture runs after the ownership release but before cold type loading so +# the controller test covers descendants created at the earliest worker phase. +if ($FixtureEarlyInitializationChild) { + try { + if (!$FixtureRoot) { exit 1 } + $fixtureEarlyRoot = (Resolve-Path -LiteralPath $FixtureRoot -ErrorAction Stop).Path + $fixtureHostPath = (Get-Process -Id $PID -ErrorAction Stop).Path + if ([IO.Path]::GetFileName($fixtureHostPath) -notin @('pwsh.exe', 'powershell.exe')) { + exit 1 + } + $fixtureChildStartInfo = [Diagnostics.ProcessStartInfo]::new() + $fixtureChildStartInfo.FileName = $fixtureHostPath + $fixtureChildStartInfo.UseShellExecute = $false + foreach ($argument in @( + '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', 'Start-Sleep -Seconds 300' + )) { + $fixtureChildStartInfo.ArgumentList.Add($argument) + } + $fixtureChild = [Diagnostics.Process]::new() + $fixtureChild.StartInfo = $fixtureChildStartInfo + if (!$fixtureChild.Start()) { exit 1 } + $fixtureStatePath = Join-Path $fixtureEarlyRoot 'workflow-cleanup-early-processes.json' + $fixtureStateTemporaryPath = "$fixtureStatePath.$PID.new" + $fixtureStateBytes = [Text.Encoding]::ASCII.GetBytes(( + [ordered]@{ WorkerPid = $PID; DescendantPid = $fixtureChild.Id } | + ConvertTo-Json -Compress + )) + $fixtureStateStream = [IO.FileStream]::new( + $fixtureStateTemporaryPath, + [IO.FileMode]::CreateNew, + [IO.FileAccess]::Write, + [IO.FileShare]::Read, + 4096, + [IO.FileOptions]::WriteThrough + ) + try { + $fixtureStateStream.Write($fixtureStateBytes, 0, $fixtureStateBytes.Length) + $fixtureStateStream.Flush($true) + } finally { + $fixtureStateStream.Dispose() + } + [IO.File]::Move($fixtureStateTemporaryPath, $fixtureStatePath) + Start-Sleep -Seconds 300 + } catch { + exit 1 + } +} + Add-Type -TypeDefinition @' using System; using System.ComponentModel; @@ -68,21 +132,6 @@ public static class ProPRDirectoryIdentity } '@ -try { - if ($ExpectedRunId -notmatch '^[a-f0-9]{32}$') { exit 1 } - if ($OwnershipReadyEvent -notmatch '^Local\\ProPRInstalledAppCleanup-[a-f0-9]{32}$') { - exit 1 - } - $ownershipReady = [Threading.EventWaitHandle]::OpenExisting($OwnershipReadyEvent) - try { - if (!$ownershipReady.WaitOne(5000)) { exit 1 } - } finally { - $ownershipReady.Dispose() - } -} catch { - exit 1 -} - function Test-SamePath([string]$Left, [string]$Right) { return [string]::Equals( [IO.Path]::GetFullPath($Left).TrimEnd('\'), diff --git a/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 b/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 index 9bc915c9d..b5bc1f404 100644 --- a/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 +++ b/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 @@ -4,7 +4,8 @@ param( [object]$ExpectedRunId, [object]$CleanupTimeoutMilliseconds = 4 * 60 * 1000, [object]$TerminationTimeoutMilliseconds = 30 * 1000, - [object]$FixtureRoot + [object]$FixtureRoot, + [switch]$FixtureEarlyInitializationChild ) enum WorkflowCleanupControllerPhase { @@ -45,6 +46,7 @@ $validatedManifestPath = $null [WorkflowCleanupControllerPhase]$controllerPhase = 'INITIALIZATION' [WorkflowCleanupControllerLine]$controllerLine = 'TYPE_LOAD' $controllerBodyActive = $false +$cleanupTreeZeroVerified = $false function Write-FixedResult([ValidateSet('COMPLETE','FAILED','TIMED_OUT')][string]$Result) { [Console]::Out.WriteLine("PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:$Result") @@ -182,6 +184,25 @@ public sealed class ProPRWorkflowCleanupJob : IDisposable [DllImport("kernel32.dll", SetLastError = true)] private static extern bool TerminateJobObject(SafeFileHandle job, uint exitCode); + [StructLayout(LayoutKind.Sequential)] + private struct JOBOBJECT_BASIC_ACCOUNTING_INFORMATION + { + public long TotalUserTime; + public long TotalKernelTime; + public long ThisPeriodTotalUserTime; + public long ThisPeriodTotalKernelTime; + public uint TotalPageFaultCount; + public uint TotalProcesses; + public uint ActiveProcesses; + public uint TotalTerminatedProcesses; + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool QueryInformationJobObject( + SafeFileHandle job, int informationClass, + out JOBOBJECT_BASIC_ACCOUNTING_INFORMATION information, + uint informationLength, IntPtr returnLength); + public ProPRWorkflowCleanupJob() { handle = CreateJobObject(IntPtr.Zero, null); @@ -206,10 +227,41 @@ public sealed class ProPRWorkflowCleanupJob : IDisposable throw new Win32Exception(Marshal.GetLastWin32Error(), "cleanup ownership failed"); } - public void Terminate(uint exitCode) + private uint ReadActiveProcessCount() + { + if (handle == null || handle.IsInvalid) + throw new InvalidOperationException("job handle is unavailable"); + JOBOBJECT_BASIC_ACCOUNTING_INFORMATION information; + uint size = (uint)Marshal.SizeOf(typeof(JOBOBJECT_BASIC_ACCOUNTING_INFORMATION)); + if (!QueryInformationJobObject(handle, 1, out information, size, IntPtr.Zero)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "cleanup accounting failed"); + return information.ActiveProcesses; + } + + public bool WaitForNoActiveProcesses(int timeoutMilliseconds) + { + var stopwatch = Stopwatch.StartNew(); + do + { + if (ReadActiveProcessCount() == 0) return true; + Thread.Sleep(25); + } + while (stopwatch.ElapsedMilliseconds < timeoutMilliseconds); + return ReadActiveProcessCount() == 0; + } + + public bool HasNoActiveProcesses() + { + return ReadActiveProcessCount() == 0; + } + + public bool TerminateAndWait(uint exitCode, int timeoutMilliseconds) { - if (!handle.IsInvalid && !TerminateJobObject(handle, exitCode)) + if (handle == null || handle.IsInvalid) + throw new InvalidOperationException("job handle is unavailable"); + if (!TerminateJobObject(handle, exitCode)) throw new Win32Exception(Marshal.GetLastWin32Error(), "cleanup termination failed"); + return WaitForNoActiveProcesses(timeoutMilliseconds); } public void Dispose() { if (handle != null) handle.Dispose(); } @@ -363,19 +415,32 @@ try { $startInfo.ArgumentList.Add('-FixtureRoot') $startInfo.ArgumentList.Add((Resolve-Path -LiteralPath $FixtureRoot -ErrorAction Stop).Path) } + if ($FixtureEarlyInitializationChild) { + if (!$FixtureRoot) { throw 'early initialization fixture requires a fixture scope' } + $startInfo.ArgumentList.Add('-FixtureEarlyInitializationChild') + } $cleanupJob = [ProPRWorkflowCleanupJob]::new() $controllerPhase = 'PROCESS_START' $controllerLine = 'START' $cleanupProcess = [Diagnostics.Process]::new() $cleanupProcess.StartInfo = $startInfo if (!$cleanupProcess.Start()) { throw 'workflow cleanup did not start' } - $outputDrain = [ProPRWorkflowCleanupOutputDrain]::new() - $outputDrain.Start($cleanupProcess) try { $cleanupJob.AddProcess($cleanupProcess.Handle) + $outputDrain = [ProPRWorkflowCleanupOutputDrain]::new() + $outputDrain.Start($cleanupProcess) [void]$cleanupReadyEvent.Set() } catch { - try { $cleanupProcess.Kill($true) } catch {} + try { + $cleanupTreeZeroVerified = $cleanupJob.TerminateAndWait( + 125, $TerminationTimeoutMilliseconds) + } catch {} + try { + if (!$cleanupProcess.HasExited) { + $cleanupProcess.Kill($true) + [void]$cleanupProcess.WaitForExit($TerminationTimeoutMilliseconds) + } + } catch {} throw 'workflow cleanup ownership failed' } $controllerPhase = 'PROCESS_WAIT' @@ -384,11 +449,11 @@ try { $controllerLine = 'TERMINATE' $terminationVerified = $false try { - $cleanupJob.Terminate(125) - $terminationVerified = $cleanupProcess.WaitForExit($TerminationTimeoutMilliseconds) -and - $cleanupProcess.HasExited + $terminationVerified = $cleanupJob.TerminateAndWait( + 125, $TerminationTimeoutMilliseconds) } catch {} if ($terminationVerified) { + $cleanupTreeZeroVerified = $true $fixedResult = 'TIMED_OUT' $fixedStatus = 'TIMEOUT' $fixedExitCode = 124 @@ -397,16 +462,27 @@ try { $fixedStatus = 'TERMINATION_FAILURE' $fixedExitCode = 125 } - } elseif ($cleanupProcess.ExitCode -eq 0) { - $fixedResult = 'COMPLETE' - $fixedStatus = 'EMPTY_OR_CLEANED' - $fixedExitCode = 0 - } elseif ($cleanupProcess.ExitCode -eq 20) { - $fixedStatus = 'MANIFEST_VALIDATION_FAILURE' - $fixedExitCode = 20 - } elseif ($cleanupProcess.ExitCode -eq 21) { - $fixedStatus = 'OWNED_RESOURCE_CLEANUP_FAILURE' - $fixedExitCode = 21 + } else { + $cleanupTreeZeroVerified = $cleanupJob.HasNoActiveProcesses() + if (!$cleanupTreeZeroVerified) { + try { + $cleanupTreeZeroVerified = $cleanupJob.TerminateAndWait( + 125, $TerminationTimeoutMilliseconds) + } catch {} + $fixedResult = 'FAILED' + $fixedStatus = 'ACTIVE_PROCESS_AFTER_ROOT_EXIT' + $fixedExitCode = 125 + } elseif ($cleanupProcess.ExitCode -eq 0) { + $fixedResult = 'COMPLETE' + $fixedStatus = 'EMPTY_OR_CLEANED' + $fixedExitCode = 0 + } elseif ($cleanupProcess.ExitCode -eq 20) { + $fixedStatus = 'MANIFEST_VALIDATION_FAILURE' + $fixedExitCode = 20 + } elseif ($cleanupProcess.ExitCode -eq 21) { + $fixedStatus = 'OWNED_RESOURCE_CLEANUP_FAILURE' + $fixedExitCode = 21 + } } } catch { Set-CaughtControllerFailure $_ @@ -418,13 +494,10 @@ $controllerBodyActive = $false try { $controllerPhase = 'PROCESS_FINALIZATION' $controllerLine = 'TERMINATE' - if ($null -ne $cleanupProcess -and !$cleanupProcess.HasExited) { - if ($null -ne $cleanupJob) { - $cleanupJob.Dispose() - $cleanupJob = $null - } - if (!$cleanupProcess.WaitForExit($TerminationTimeoutMilliseconds) -or - !$cleanupProcess.HasExited) { + if ($null -ne $cleanupJob -and !$cleanupTreeZeroVerified) { + $cleanupTreeZeroVerified = $cleanupJob.TerminateAndWait( + 125, $TerminationTimeoutMilliseconds) + if (!$cleanupTreeZeroVerified) { $fixedResult = 'FAILED' $fixedStatus = 'PROCESS_FINALIZATION_TIMEOUT' $fixedExitCode = 125 @@ -477,7 +550,8 @@ foreach ($resource in @($outputDrain, $cleanupJob, $cleanupProcess, $cleanupRead } } -if ($fixedResult -ceq 'COMPLETE' -and $validatedManifestPath) { +if ($fixedResult -ceq 'COMPLETE' -and $cleanupTreeZeroVerified -and + $validatedManifestPath) { try { $controllerPhase = 'AUTHORITY_FINALIZATION' $controllerLine = 'AUTHORITY' 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 c8e4b483b..ed39eafd9 100644 --- a/apps/desktop/scripts/test-installed-windows-app-supervisor-fixture.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app-supervisor-fixture.ps1 @@ -760,7 +760,7 @@ switch ($scenario) { 'VALID_THEN_DEADLINE' { Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(10).Ticks) Start-Sleep -Milliseconds 500 - Write-FixtureMarker ('{0}|INSTALL|MSI_INSTALL|BEGIN' -f [DateTime]::UtcNow.AddMilliseconds(2500).Ticks) + Write-FixtureMarker ('{0}|VALIDATION|INSTALL_TREE_SCAN|BEGIN' -f [DateTime]::UtcNow.AddMilliseconds(2500).Ticks) Start-Sleep -Seconds 300 } 'MALFORMED_MARKER' { diff --git a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 index b885568a7..67a341466 100644 --- a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 @@ -235,7 +235,8 @@ function Invoke-WorkflowCleanupController( [string]$ManifestPath, [string]$RunId, [string]$FixtureRoot, - [object]$CleanupTimeoutMilliseconds = 30000 + [object]$CleanupTimeoutMilliseconds = 30000, + [bool]$FixtureEarlyInitializationChild = $false ) { $startInfo = [Diagnostics.ProcessStartInfo]::new() $startInfo.FileName = $hostPath @@ -256,6 +257,9 @@ function Invoke-WorkflowCleanupController( $startInfo.ArgumentList.Add('-FixtureRoot') $startInfo.ArgumentList.Add($FixtureRoot) } + if ($FixtureEarlyInitializationChild) { + $startInfo.ArgumentList.Add('-FixtureEarlyInitializationChild') + } $process = [Diagnostics.Process]::new() $process.StartInfo = $startInfo try { @@ -501,10 +505,10 @@ function Test-OperationDeadlineAndTreeTermination { Assert-True ($result.ElapsedMilliseconds -lt 10000) ` 'operation deadline completion was not bounded' Assert-Contains $result.Output ` - 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:ACCEPTED:INSTALL:MSI_INSTALL:BEGIN' ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:ACCEPTED:VALIDATION:INSTALL_TREE_SCAN:BEGIN' ` 'operation transition was not accepted and flushed by the supervisor' Assert-Contains $result.Output ` - 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:INSTALL:MSI_INSTALL:BEGIN:TIMED_OUT' ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:VALIDATION:INSTALL_TREE_SCAN:BEGIN:TIMED_OUT' ` 'operation deadline did not emit the fixed redacted timeout line' } @@ -981,6 +985,18 @@ function Test-PreExistingCleanupOwnership { )) 'controller parameter failure was not caught and phase-classified' Assert-True (Test-Path -LiteralPath $workflowManifest -PathType Leaf) ` 'controller parameter failure discarded authenticated recovery authority' + $earlyInitializationTimeout = Invoke-WorkflowCleanupController ` + $workflowManifest $workflowRunId $workflowStateDirectory 5000 $true + Assert-True ($earlyInitializationTimeout.ExitCode -eq 124 -and + $earlyInitializationTimeout.ReportedExitCode -eq 124 -and + $earlyInitializationTimeout.Result -ceq 'TIMED_OUT') ` + 'early-initialization child cleanup did not report its fixed timeout' + $earlyInitializationState = Get-Content -LiteralPath ` + (Join-Path $workflowStateDirectory 'workflow-cleanup-early-processes.json') ` + -Raw -Encoding ASCII | ConvertFrom-Json -ErrorAction Stop + Assert-ProcessTreeGone $earlyInitializationState + Assert-True (Test-Path -LiteralPath $workflowManifest -PathType Leaf) ` + 'early-initialization timeout discarded authenticated recovery authority' $timedOutCleanup = Invoke-WorkflowCleanupController ` $workflowManifest $workflowRunId $workflowStateDirectory 1 Assert-True ($timedOutCleanup.ExitCode -eq 124 -and diff --git a/apps/desktop/src/release-workflow.test.ts b/apps/desktop/src/release-workflow.test.ts index bac69893f..b6b8b49b7 100644 --- a/apps/desktop/src/release-workflow.test.ts +++ b/apps/desktop/src/release-workflow.test.ts @@ -698,6 +698,26 @@ describe('desktop trusted release workflow', () => { assert.match(installedWindowsAppTest, /EntryIdentity = \$script:shortcutOwnedEntryIdentity/); assert.match(installedWindowsAppSupervisorBehaviorTest, /mismatched App Paths ownership identity did not fail closed/); assert.match(installedWindowsAppWorkflowCleanup, /ProPRWorkflowCleanupJob/); + assert.match(installedWindowsAppWorkflowCleanup, /QueryInformationJobObject/); + assert.match(installedWindowsAppWorkflowCleanup, /WaitForNoActiveProcesses/); + assert.match(installedWindowsAppWorkflowCleanup, /TerminateAndWait/); + assert.ok( + installedWindowsAppWorkflowCleanup.indexOf('$cleanupJob.AddProcess($cleanupProcess.Handle)') + < installedWindowsAppWorkflowCleanup.indexOf('$outputDrain.Start($cleanupProcess)'), + 'cleanup root must enter the Job Object before redirected output drains begin', + ); + assert.ok( + installedWindowsAppWorkflowCleanup.indexOf('$cleanupJob.AddProcess($cleanupProcess.Handle)') + < installedWindowsAppWorkflowCleanup.indexOf('[void]$cleanupReadyEvent.Set()'), + 'cleanup root must enter the Job Object before worker ownership is released', + ); + assert.ok( + installedWindowsAppCleanup.indexOf('$ownershipReady.WaitOne(5000)') + < installedWindowsAppCleanup.indexOf("Add-Type -TypeDefinition @'"), + 'cleanup worker ownership handshake must precede cold type loading', + ); + assert.match(installedWindowsAppSupervisorBehaviorTest, /early-initialization child cleanup/); + assert.match(installedWindowsAppCleanup, /workflow-cleanup-early-processes\.json/); assert.match(installedWindowsAppWorkflowCleanup, /MANIFEST_VALIDATION_FAILURE/); assert.match(installedWindowsAppWorkflowCleanup, /OWNED_RESOURCE_CLEANUP_FAILURE/); assert.match(installedWindowsAppWorkflowCleanup, /ProPRWorkflowCleanupOutputDrain/); @@ -728,7 +748,7 @@ describe('desktop trusted release workflow', () => { ); assert.match( installedWindowsAppWorkflowCleanup, - /if \(\$fixedResult -ceq 'COMPLETE' -and \$validatedManifestPath\)/, + /if \(\$fixedResult -ceq 'COMPLETE' -and \$cleanupTreeZeroVerified -and/, ); assert.match( installedWindowsAppSupervisor, From de947aac8e3f0dd04e54e097394fb88f85d0a0c9 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 05:30:33 +0000 Subject: [PATCH 13/29] feat(ai): Implemented the exact-head correction on `d2ba71eb795fd0a5d74d8601ed871535c24646a6`. Implemented the exact-head correction on `d2ba71eb795fd0a5d74d8601ed871535c24646a6`. - F22 now requires an exact durable SID plus freshly canonicalized LocalPath before every production `Remove-CimInstance`. - Uncaptured profiles are promoted only through authenticated marker/SID authority, with the canonical path durably written before deletion. - Path mismatches preserve the profile, local-user authentication authority, and ACTIVE manifest. Added retry coverage after restoring the exact path. - Controller no longer uses `Console.SetError`, `trap`, or labeled flow. Add-Type and the controller body use an ordinary outer try/catch, followed by bounded finalization and exactly two flushed stdout lines. - Updated focused x64/ARM64 fixture contracts in [test-installed-windows-app-supervisor.ps1](/tmp/git-processor/worktrees/integry/propr/pr-2042-followup-2026-09-01T05-20-24/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1). Validation: - Focused release workflow tests: 23/23 passed. - Desktop tests: 177 passed, 6 platform skips. - Desktop TypeScript typecheck passed. - `git diff --check` passed. Native x64/ARM64 execution requires Windows CI and could not be run on this Linux host. No commit was created. PR: #2042 Comment by: @integry (ID: 5489285503) Model: gpt-5.6-sol --- .../scripts/cleanup-installed-windows-app.ps1 | 108 ++++++++++++++++-- ...installed-windows-app-workflow-cleanup.ps1 | 29 +---- ...stalled-windows-app-supervisor-fixture.ps1 | 43 ++++++- .../test-installed-windows-app-supervisor.ps1 | 74 ++++++++++++ .../scripts/test-installed-windows-app.ps1 | 68 ++++++++++- apps/desktop/src/release-workflow.test.ts | 20 ++-- 6 files changed, 295 insertions(+), 47 deletions(-) diff --git a/apps/desktop/scripts/cleanup-installed-windows-app.ps1 b/apps/desktop/scripts/cleanup-installed-windows-app.ps1 index d8b03f300..bad24b46b 100644 --- a/apps/desktop/scripts/cleanup-installed-windows-app.ps1 +++ b/apps/desktop/scripts/cleanup-installed-windows-app.ps1 @@ -140,6 +140,21 @@ function Test-SamePath([string]$Left, [string]$Right) { ) } +function Resolve-CanonicalProfileLocalPath([string]$LocalPath) { + if ([string]::IsNullOrWhiteSpace($LocalPath) -or + ![IO.Path]::IsPathRooted($LocalPath)) { + throw 'profile local path is invalid' + } + $fullPath = [IO.Path]::GetFullPath($LocalPath).TrimEnd('\') + $resolved = (Resolve-Path -LiteralPath $fullPath -ErrorAction Stop).ProviderPath.TrimEnd('\') + $item = Get-Item -LiteralPath $resolved -Force -ErrorAction Stop + if (!$item.PSIsContainer -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'profile local path is not a canonical directory' + } + return [IO.Path]::GetFullPath($resolved).TrimEnd('\') +} + function Test-PathWithin([string]$Path, [string]$Root) { $fullPath = [IO.Path]::GetFullPath($Path) $fullRoot = [IO.Path]::GetFullPath($Root).TrimEnd('\') @@ -969,7 +984,58 @@ function Resolve-ProvisionalOwnedUser($Record) { return $true } -function Remove-OwnedProfiles($UserRecord) { +function Promote-UncapturedOwnedProfiles($UserRecord, $Manifest) { + if (!$UserRecord.Owned) { return $false } + $name = [string]$UserRecord.Name + $sid = [string]$UserRecord.Sid + $ownershipMarker = [string]$UserRecord.OwnershipMarker + if ($sid -notmatch '^S-\d+(?:-\d+)+$' -and $UserRecord.Provisional) { + return $false + } + if ($name -notmatch '^(?:propr-ci-|prpr)[a-f0-9]{8}$' -or + $sid -notmatch '^S-\d+(?:-\d+)+$' -or + $ownershipMarker -notmatch '^prpr-own-[a-f0-9]{32}$') { + throw 'profile promotion identity is invalid' + } + $durableProfiles = @($Manifest.Profiles | Where-Object { + $_.Owned -and [string]$_.Sid -ceq $sid + }) + if ($durableProfiles.Count -ne 0) { return $false } + + $profiles = @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop | + Where-Object { $_.SID -ceq $sid }) + if ($profiles.Count -eq 0) { return $false } + + # An absent profile record can be promoted only while the exact run-created + # account still authenticates both the marker and SID. A durable path record + # is published by the caller before any profile deletion is attempted. + $user = Get-LocalUser -Name $name -ErrorAction SilentlyContinue + if ($null -eq $user -or [string]$user.Description -cne $ownershipMarker -or + [string]$user.SID.Value -cne $sid) { + throw 'uncaptured profile lacks authenticated marker and SID authority' + } + $promoted = @() + foreach ($profile in $profiles) { + if ([string]$profile.SID -cne $sid) { + throw 'profile SID changed during ownership promotion' + } + $canonicalLocalPath = Resolve-CanonicalProfileLocalPath ([string]$profile.LocalPath) + if (@($promoted | Where-Object { + Test-SamePath ([string]$_.LocalPath) $canonicalLocalPath + }).Count -ne 0) { + throw 'profile ownership promotion is ambiguous' + } + $promoted += [ordered]@{ + Sid = $sid + LocalPath = $canonicalLocalPath + Owned = $true + } + } + $Manifest.Profiles = @($Manifest.Profiles) + @($promoted) + return $true +} + +function Remove-OwnedProfiles($UserRecord, $ProfileRecords) { if (!$UserRecord.Owned) { return } $name = [string]$UserRecord.Name if ($name -notmatch '^(?:propr-ci-|prpr)[a-f0-9]{8}$') { @@ -988,7 +1054,14 @@ function Remove-OwnedProfiles($UserRecord) { if ($profiles.Count -eq 0) { return } try { foreach ($profile in $profiles) { - if ($profile.SID -cne $sid) { throw 'profile SID ownership changed' } + $canonicalLocalPath = Resolve-CanonicalProfileLocalPath ([string]$profile.LocalPath) + $matchingRecords = @($ProfileRecords | Where-Object { + $_.Owned -and [string]$_.Sid -ceq $sid -and + (Test-SamePath ([string]$_.LocalPath) $canonicalLocalPath) + }) + if ([string]$profile.SID -cne $sid -or $matchingRecords.Count -ne 1) { + throw 'profile lacks exact durable SID and path ownership' + } Remove-CimInstance -InputObject $profile -ErrorAction Stop } } catch { @@ -1010,7 +1083,10 @@ function Remove-ExplicitOwnedProfile($Record) { $_.SID -ceq $sid }) foreach ($profile in $profiles) { - if ($profile.SID -cne $sid -or !(Test-SamePath ([string]$profile.LocalPath) $localPath)) { + $canonicalRecordPath = Resolve-CanonicalProfileLocalPath $localPath + $canonicalCurrentPath = Resolve-CanonicalProfileLocalPath ([string]$profile.LocalPath) + if ($profile.SID -cne $sid -or + !(Test-SamePath $canonicalCurrentPath $canonicalRecordPath)) { throw 'profile path ownership changed' } Remove-CimInstance -InputObject $profile -ErrorAction Stop @@ -1343,11 +1419,14 @@ try { Assert-MsiRolledBackCleanBaseline $manifest } } - $adoptedProvisionalUser = $false + $ownershipPromoted = $false foreach ($record in @($manifest.Users)) { - if (Resolve-ProvisionalOwnedUser $record) { $adoptedProvisionalUser = $true } + if (Resolve-ProvisionalOwnedUser $record) { $ownershipPromoted = $true } + if (Promote-UncapturedOwnedProfiles $record $manifest) { + $ownershipPromoted = $true + } } - if ($adoptedProvisionalUser) { + if ($ownershipPromoted) { Write-DurableOwnershipManifest $manifestPath $manifest } foreach ($record in @($manifest.RegistryValues)) { @@ -1393,14 +1472,23 @@ try { foreach ($record in @($manifest.RegistryValues)) { try { Restore-OwnedRegistryValue $record } catch { $cleanupFailed = $true } } + $profileCleanupFailed = $false foreach ($record in @($manifest.Profiles)) { - try { Remove-ExplicitOwnedProfile $record } catch { $cleanupFailed = $true } + try { Remove-ExplicitOwnedProfile $record } catch { + $profileCleanupFailed = $true + $cleanupFailed = $true + } } foreach ($record in @($manifest.Users)) { - try { Remove-OwnedProfiles $record } catch { $cleanupFailed = $true } + try { Remove-OwnedProfiles $record $manifest.Profiles } catch { + $profileCleanupFailed = $true + $cleanupFailed = $true + } } - foreach ($record in @($manifest.Users)) { - try { Remove-OwnedUser $record } catch { $cleanupFailed = $true } + if (!$profileCleanupFailed) { + foreach ($record in @($manifest.Users)) { + try { Remove-OwnedUser $record } catch { $cleanupFailed = $true } + } } $directories = @($manifest.Directories) | Sort-Object { ([string]$_.Path).Length diff --git a/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 b/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 index b5bc1f404..f0e9d2d34 100644 --- a/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 +++ b/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 @@ -45,7 +45,6 @@ $fixedExitCode = 125 $validatedManifestPath = $null [WorkflowCleanupControllerPhase]$controllerPhase = 'INITIALIZATION' [WorkflowCleanupControllerLine]$controllerLine = 'TYPE_LOAD' -$controllerBodyActive = $false $cleanupTreeZeroVerified = $false function Write-FixedResult([ValidateSet('COMPLETE','FAILED','TIMED_OUT')][string]$Result) { @@ -99,26 +98,10 @@ function Set-CaughtControllerFailure($ErrorRecord) { $script:fixedExitCode = 125 } -# Producer-boundary trap: every uncaught controller error is reduced to the -# allowlisted phase/line/category tuple and execution continues only into the -# next bounded finalization statement. While the controller body is active the -# trap exits that labeled phase first, so a type-load or body failure cannot -# continue into process setup. -trap { - Set-CaughtControllerFailure $_ - if ($script:controllerBodyActive) { - break controllerBody - } - continue -} - -$controllerBodyActive = $true -:controllerBody do { -# The controller has a fixed stdout protocol and maps every caught failure to -# that protocol. Suppress the host's architecture-specific raw error rendering -# before cold type load; child stdout/stderr remain separately pumped, bounded, -# and classified below. -[Console]::SetError([IO.TextWriter]::Null) +# The ordinary outer catch covers cold type loading and every controller-body +# phase. It consumes PowerShell error records without host rendering and maps +# them to the fixed protocol before bounded finalization runs. +try { Add-Type -TypeDefinition @' using System; using System.ComponentModel; @@ -367,7 +350,6 @@ $FixtureRoot = if ($null -eq $FixtureRoot) { $null } else { [string]$FixtureRoot $CleanupTimeoutMilliseconds = $cleanupTimeout $TerminationTimeoutMilliseconds = $terminationTimeout -try { $controllerPhase = 'PATH_VALIDATION' $controllerLine = 'PATHS' if ($ExpectedRunId -notmatch '^[a-f0-9]{32}$') { throw 'cleanup run identity is invalid' } @@ -488,9 +470,6 @@ try { Set-CaughtControllerFailure $_ } -} while ($false) -$controllerBodyActive = $false - try { $controllerPhase = 'PROCESS_FINALIZATION' $controllerLine = 'TERMINATE' 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 ed39eafd9..5270f018e 100644 --- a/apps/desktop/scripts/test-installed-windows-app-supervisor-fixture.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app-supervisor-fixture.ps1 @@ -86,6 +86,7 @@ if ($scenario -notin @( 'OWNED_EXECUTABLE_REPLACED_THEN_DEADLINE', 'OWNED_EXECUTABLE_BYTE_IDENTICAL_REPLACED_THEN_DEADLINE', 'OWNED_SHORTCUT_REPLACED_THEN_DEADLINE', + 'OWNED_PROFILE_PATH_MISMATCH_THEN_DEADLINE', 'OWNED_RESOURCES_REPLACED_THEN_DEADLINE', 'OWNED_RESOURCES_THEN_DEADLINE' )) { @@ -440,6 +441,16 @@ function New-OwnedFixtureResources( Start-Sleep -Milliseconds 250 } while ($profileLookupStopwatch.ElapsedMilliseconds -lt 10000) if ($profiles.Count -ne 1) { throw 'fixture owned profile was not created' } + $canonicalProfilePath = (Resolve-Path -LiteralPath ([string]$profiles[0].LocalPath) ` + -ErrorAction Stop).ProviderPath.TrimEnd('\') + $manifest = [IO.File]::ReadAllText($OwnershipManifest, [Text.Encoding]::UTF8) | + ConvertFrom-Json -ErrorAction Stop + $manifest.Profiles = @($manifest.Profiles) + @([ordered]@{ + Sid = $userSid + LocalPath = $canonicalProfilePath + Owned = $true + }) + Write-FixtureOwnershipManifest $manifest $resourceState = [ordered]@{ OwnedRoot = $ownedRoot InstallRoot = $installRoot @@ -451,7 +462,7 @@ function New-OwnedFixtureResources( RegistryRoot = Split-Path -Parent $registryPath UserName = $userName UserSid = $userSid - ProfilePath = [string]$profiles[0].LocalPath + ProfilePath = $canonicalProfilePath ManifestPath = $OwnershipManifest RunId = [string]$manifest.RunId Token = $token @@ -644,6 +655,28 @@ function Replace-FixtureShortcut { (Join-Path $stateDirectory 'resources.json') -Encoding ASCII } +function Replace-FixtureProfilePath { + $state = Get-Content -LiteralPath (Join-Path $stateDirectory 'resources.json') ` + -Raw -Encoding ASCII | ConvertFrom-Json -ErrorAction Stop + $mismatchedPath = Join-Path $stateDirectory 'mismatched-profile-path' + [void](New-Item -ItemType Directory -Path $mismatchedPath -ErrorAction Stop) + $canonicalMismatch = (Resolve-Path -LiteralPath $mismatchedPath -ErrorAction Stop).ProviderPath + $manifest = [IO.File]::ReadAllText($OwnershipManifest, [Text.Encoding]::UTF8) | + ConvertFrom-Json -ErrorAction Stop + $ownedProfile = @($manifest.Profiles | Where-Object { + $_.Owned -and [string]$_.Sid -ceq [string]$state.UserSid + }) + if ($ownedProfile.Count -ne 1) { + throw 'fixture durable profile ownership record is missing' + } + $ownedProfile[0].LocalPath = $canonicalMismatch + Write-FixtureOwnershipManifest $manifest + $state | Add-Member -NotePropertyName MismatchedProfilePath ` + -NotePropertyValue $canonicalMismatch + $state | ConvertTo-Json -Compress | Set-Content -LiteralPath ` + (Join-Path $stateDirectory 'resources.json') -Encoding ASCII +} + function Add-FixtureForeignChild { $state = Get-Content -LiteralPath (Join-Path $stateDirectory 'resources.json') ` -Raw -Encoding ASCII | ConvertFrom-Json -ErrorAction Stop @@ -944,6 +977,14 @@ switch ($scenario) { [DateTime]::UtcNow.AddMilliseconds(500).Ticks) Start-Sleep -Seconds 300 } + 'OWNED_PROFILE_PATH_MISMATCH_THEN_DEADLINE' { + Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) + New-OwnedFixtureResources + Replace-FixtureProfilePath + Write-FixtureMarker ('{0}|CLEANUP|PROFILE_REMOVE|BEGIN' -f ` + [DateTime]::UtcNow.AddMilliseconds(500).Ticks) + Start-Sleep -Seconds 300 + } 'OWNED_RESOURCES_FOREIGN_CHILD_THEN_DEADLINE' { Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(60).Ticks) New-OwnedFixtureResources diff --git a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 index 67a341466..e119e32c0 100644 --- a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 @@ -39,6 +39,27 @@ function New-StateDirectory([string]$Name) { return $path } +function Write-TestOwnershipManifest([string]$Path, $Manifest) { + $temporaryPath = "$Path.test.new" + $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() + } + [IO.File]::Move($temporaryPath, $Path, $true) +} + function New-SupervisorStartInfo( [string]$Scenario, [string]$StateDirectory, @@ -388,6 +409,7 @@ function Invoke-FixtureScenario( 'OWNED_EXECUTABLE_REPLACED_THEN_DEADLINE', 'OWNED_EXECUTABLE_BYTE_IDENTICAL_REPLACED_THEN_DEADLINE', 'OWNED_SHORTCUT_REPLACED_THEN_DEADLINE', + 'OWNED_PROFILE_PATH_MISMATCH_THEN_DEADLINE', 'OWNED_RESOURCES_FOREIGN_CHILD_THEN_DEADLINE', 'SMOKE_BEFORE_PROMOTION_THEN_DEADLINE', 'SMOKE_AFTER_PROMOTION_THEN_DEADLINE', @@ -853,6 +875,58 @@ function Test-PreExistingCleanupOwnership { Assert-OwnedResourcesGone $replacedOwned } + $profileMismatchDirectory = New-StateDirectory 'profile-path-mismatch' + $profileMismatchResult = Invoke-FixtureScenario ` + 'OWNED_PROFILE_PATH_MISMATCH_THEN_DEADLINE' $profileMismatchDirectory + Assert-True ($profileMismatchResult.ExitCode -eq 125) ` + 'mismatched durable profile path did not fail closed' + Assert-Contains $profileMismatchResult.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:FAILED' ` + 'mismatched durable profile path did not emit fixed cleanup failure evidence' + $profileMismatchOwned = Read-FixtureResourceState $profileMismatchDirectory + $survivingProfiles = @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop | + Where-Object { $_.SID -ceq [string]$profileMismatchOwned.UserSid }) + Assert-True ($survivingProfiles.Count -eq 1) ` + 'mismatched durable path selected the owned profile for deletion' + $survivingProfilePath = (Resolve-Path -LiteralPath ` + ([string]$survivingProfiles[0].LocalPath) -ErrorAction Stop).ProviderPath.TrimEnd('\') + Assert-True ([string]::Equals( + $survivingProfilePath, + ([string]$profileMismatchOwned.ProfilePath).TrimEnd('\'), + [StringComparison]::OrdinalIgnoreCase + )) 'mismatched-path regression did not preserve the exact live profile' + $profileMismatchManifest = Get-Content -LiteralPath $profileMismatchOwned.ManifestPath ` + -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop + Assert-True ($profileMismatchManifest.State -ceq 'ACTIVE') ` + 'mismatched profile path discarded ACTIVE recovery authority' + $profileMismatchUsers = @($profileMismatchManifest.Users | Where-Object { + $_.Owned -and [string]$_.Sid -ceq [string]$profileMismatchOwned.UserSid + }) + $remainingProfileUser = Get-LocalUser -Name $profileMismatchOwned.UserName ` + -ErrorAction Stop + Assert-True ($profileMismatchUsers.Count -eq 1 -and + [string]$remainingProfileUser.SID.Value -ceq [string]$profileMismatchOwned.UserSid -and + [string]$remainingProfileUser.Description -ceq + [string]$profileMismatchUsers[0].OwnershipMarker) ` + 'mismatched profile path discarded authenticated marker and SID authority' + $ownedProfileRecords = @($profileMismatchManifest.Profiles | Where-Object { + $_.Owned -and [string]$_.Sid -ceq [string]$profileMismatchOwned.UserSid + }) + Assert-True ($ownedProfileRecords.Count -eq 1 -and + [string]::Equals( + [string]$ownedProfileRecords[0].LocalPath, + [string]$profileMismatchOwned.MismatchedProfilePath, + [StringComparison]::OrdinalIgnoreCase + )) 'mismatched durable profile record was silently re-authorized' + $ownedProfileRecords[0].LocalPath = [string]$profileMismatchOwned.ProfilePath + Write-TestOwnershipManifest $profileMismatchOwned.ManifestPath $profileMismatchManifest + $profileMismatchRetry = Invoke-WorkflowCleanupController ` + $profileMismatchOwned.ManifestPath $profileMismatchOwned.RunId $profileMismatchDirectory + Assert-True ($profileMismatchRetry.ExitCode -eq 0 -and + $profileMismatchRetry.Result -ceq 'COMPLETE') ` + 'profile cleanup did not succeed after exact durable path restoration' + Assert-OwnedResourcesGone $profileMismatchOwned + $byteIdenticalDirectory = New-StateDirectory 'byte-identical-replaced-executable' $byteIdenticalResult = Invoke-FixtureScenario ` 'OWNED_EXECUTABLE_BYTE_IDENTICAL_REPLACED_THEN_DEADLINE' $byteIdenticalDirectory diff --git a/apps/desktop/scripts/test-installed-windows-app.ps1 b/apps/desktop/scripts/test-installed-windows-app.ps1 index 984d597a8..32feb951b 100644 --- a/apps/desktop/scripts/test-installed-windows-app.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app.ps1 @@ -275,6 +275,21 @@ function Write-OwnershipManifest { [IO.File]::Move($temporaryManifest, $ownershipManifestPath, $true) } +function Resolve-CanonicalProfileLocalPath([string]$LocalPath) { + if ([string]::IsNullOrWhiteSpace($LocalPath) -or + ![IO.Path]::IsPathRooted($LocalPath)) { + throw 'profile local path is invalid' + } + $fullPath = [IO.Path]::GetFullPath($LocalPath).TrimEnd('\') + $resolved = (Resolve-Path -LiteralPath $fullPath -ErrorAction Stop).ProviderPath.TrimEnd('\') + $item = Get-Item -LiteralPath $resolved -Force -ErrorAction Stop + if (!$item.PSIsContainer -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'profile local path is not a canonical directory' + } + return [IO.Path]::GetFullPath($resolved).TrimEnd('\') +} + function Write-DurableOwnershipToken([string]$Path, [string]$Token) { $bytes = [Text.Encoding]::ASCII.GetBytes($Token) $stream = [IO.FileStream]::new( @@ -2075,6 +2090,7 @@ try { throw } finally { $cleanupFailed = $false + $profileCleanupFailed = $false if ($installAttempted -and [string]$ownershipState.MsiTransactionState -ceq 'COMMITTED') { Write-Stage 'UNINSTALL' 'BEGIN' @@ -2241,14 +2257,56 @@ try { $profiles = @(Invoke-BoundedExternalOperation ` 'CLEANUP' 'PROFILE_LOOKUP' $externalOperationTimeoutMilliseconds { @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop | Where-Object { - $_.SID -eq $testUserSid.Value + $_.SID -ceq $testUserSid.Value + }) + }) + $ownedUserRecords = @($ownershipState.Users | Where-Object { + $_.Owned -and [string]$_.Sid -ceq $testUserSid.Value + }) + if ($ownedUserRecords.Count -ne 1) { + throw 'durable profile owner identity is missing' + } + $ownedProfileRecords = @($ownershipState.Profiles | Where-Object { + $_.Owned -and [string]$_.Sid -ceq $testUserSid.Value + }) + if ($profiles.Count -ne 0 -and $ownedProfileRecords.Count -eq 0) { + $currentOwnedUser = Get-LocalUser -Name $testUser -ErrorAction Stop + if ([string]$currentOwnedUser.SID.Value -cne $testUserSid.Value -or + [string]$currentOwnedUser.Description -cne + [string]$ownedUserRecords[0].OwnershipMarker) { + throw 'uncaptured profile lacks authenticated marker and SID authority' + } + foreach ($profile in $profiles) { + if ([string]$profile.SID -cne $testUserSid.Value) { + throw 'profile SID changed during ownership promotion' + } + $ownershipState.Profiles = @($ownershipState.Profiles) + @([ordered]@{ + Sid = $testUserSid.Value + LocalPath = Resolve-CanonicalProfileLocalPath ([string]$profile.LocalPath) + Owned = $true }) + } + Write-OwnershipManifest + $ownedProfileRecords = @($ownershipState.Profiles | Where-Object { + $_.Owned -and [string]$_.Sid -ceq $testUserSid.Value }) + } Invoke-BoundedExternalOperation ` 'CLEANUP' 'PROFILE_REMOVE' $recursiveOperationTimeoutMilliseconds { foreach ($profile in $profiles) { - if ($profile.SID -ne $testUserSid.Value) { - throw 'refusing to remove a profile not owned by the test user' + $canonicalLocalPath = Resolve-CanonicalProfileLocalPath ` + ([string]$profile.LocalPath) + $matchingRecords = @($ownedProfileRecords | Where-Object { + $_.Owned -and [string]$_.Sid -ceq $testUserSid.Value -and + [string]::Equals( + [IO.Path]::GetFullPath([string]$_.LocalPath).TrimEnd('\'), + $canonicalLocalPath, + [StringComparison]::OrdinalIgnoreCase + ) + }) + if ([string]$profile.SID -cne $testUserSid.Value -or + $matchingRecords.Count -ne 1) { + throw 'refusing to remove a profile without exact durable SID and path ownership' } Remove-CimInstance -InputObject $profile -ErrorAction Stop } @@ -2257,11 +2315,15 @@ try { Write-CleanupSubstage 'CLEANUP' 'PROFILE' 'COMPLETE' } catch { Write-CleanupSubstage 'CLEANUP' 'PROFILE' 'FAILED' + $profileCleanupFailed = $true $cleanupFailed = $true } Write-CleanupSubstage 'CLEANUP' 'USER' 'BEGIN' try { + if ($profileCleanupFailed) { + throw 'profile cleanup failed; retaining authenticated local-user authority' + } if ($testUserCreatedByRun -and $null -ne $testUserSid) { $ownedUser = Invoke-BoundedExternalOperation ` 'CLEANUP' 'USER_LOOKUP' $externalOperationTimeoutMilliseconds { diff --git a/apps/desktop/src/release-workflow.test.ts b/apps/desktop/src/release-workflow.test.ts index b6b8b49b7..561748466 100644 --- a/apps/desktop/src/release-workflow.test.ts +++ b/apps/desktop/src/release-workflow.test.ts @@ -636,6 +636,15 @@ describe('desktop trusted release workflow', () => { /PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:COMPLETE/, ); assert.match(installedWindowsAppCleanup, /Remove-OwnedProfiles/); + assert.match(installedWindowsAppCleanup, /Promote-UncapturedOwnedProfiles/); + assert.match( + installedWindowsAppCleanup, + /\$matchingRecords = @\(\$ProfileRecords[\s\S]*Test-SamePath[\s\S]*\$matchingRecords\.Count -ne 1[\s\S]*Remove-CimInstance/, + ); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /mismatched durable profile path did not fail closed[\s\S]*mismatched profile path discarded ACTIVE recovery authority/, + ); assert.match(installedWindowsAppCleanup, /Remove-OwnedRegistryKey/); assert.match(installedWindowsAppCleanup, /Remove-OwnedDirectory/); assert.match(installedWindowsAppCleanup, /APP_PATH/); @@ -728,18 +737,13 @@ describe('desktop trusted release workflow', () => { assert.match(installedWindowsAppWorkflowCleanup, /WorkflowCleanupControllerPhase/); assert.match(installedWindowsAppWorkflowCleanup, /WorkflowCleanupControllerLine/); assert.match(installedWindowsAppWorkflowCleanup, /Set-CaughtControllerFailure/); - assert.match(installedWindowsAppWorkflowCleanup, /\[Console\]::SetError\(\[IO\.TextWriter\]::Null\)/); + assert.doesNotMatch(installedWindowsAppWorkflowCleanup, /Console\]::SetError|\btrap\b|controllerBody/); assert.match(installedWindowsAppWorkflowCleanup, /\[Console\]::Out\.WriteLine/); assert.equal(installedWindowsAppWorkflowCleanup.match(/\[Console\]::Out\.WriteLine/g)?.length, 2); assert.doesNotMatch(installedWindowsAppWorkflowCleanup, /Write-Host/); - assert.ok( - installedWindowsAppWorkflowCleanup.indexOf('[Console]::SetError([IO.TextWriter]::Null)') - < installedWindowsAppWorkflowCleanup.indexOf("Add-Type -TypeDefinition @'"), - 'controller raw stderr must be suppressed before cold type loading', - ); assert.match( installedWindowsAppWorkflowCleanup, - /trap \{[\s\S]*if \(\$script:controllerBodyActive\)[\s\S]*break controllerBody[\s\S]*:controllerBody do \{/, + /try \{\nAdd-Type -TypeDefinition @'[\s\S]*\$controllerPhase = 'PROCESS_WAIT'[\s\S]*\} catch \{\n\s+Set-CaughtControllerFailure \$_\n\}/, ); assert.match(installedWindowsAppWorkflowCleanup, /CancelAndFinish/); assert.doesNotMatch( @@ -1010,7 +1014,7 @@ describe('desktop trusted release workflow', () => { ); assert.match( installedWindowsAppTest, - /if \(\$profile\.SID -ne \$testUserSid\.Value\)[\s\S]*Remove-CimInstance -InputObject \$profile/, + /\$matchingRecords = @\(\$ownedProfileRecords[\s\S]*\$matchingRecords\.Count -ne 1[\s\S]*Remove-CimInstance -InputObject \$profile/, ); assert.match( installedWindowsAppTest, From b9415f52f8a50288c9424235155bb86b7b506842 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 05:49:38 +0000 Subject: [PATCH 14/29] feat(ai): Implemented the exact-head correction on `de947aac8e3f0dd04e54e097394fb88f85d0a0c9`. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented the exact-head correction on `de947aac8e3f0dd04e54e097394fb88f85d0a0c9`. Key changes: - F23 profile paths now require canonical, non-reparse ancestry under the registry-defined system `ProfilesDirectory`, exact direct-child depth, exact username leaf, SID, and durable-record agreement before promotion and immediately before deletion. - Added out-of-root and alternate-leaf regressions preserving profile, account, and ACTIVE recovery authority. - Reworked the x64 cleanup controller into a stable scriptblock launcher with a small top-level catch. - Added sanitized startup diagnostics: allowlisted classification, signed exit, and numeric line only. - Removed cold fixture `Add-Type` from the ARM primary-fallback measured path and added allowlisted supervisor/marker diagnostics. - Updated supplementary contracts without changing F10–F22 behavior. Validation passed: - Focused release workflow: 23/23 - Full desktop suite: 177 passed, 6 skipped - Desktop TypeScript typecheck - `git diff --check` Native x64/ARM64 execution requires Windows CI and was unavailable locally. Changes remain uncommitted as requested. PR: #2042 Comment by: @integry (ID: 5489417497) Model: gpt-5.6-sol --- .../scripts/cleanup-installed-windows-app.ps1 | 124 ++++++++++++++---- ...installed-windows-app-workflow-cleanup.ps1 | 12 +- ...stalled-windows-app-supervisor-fixture.ps1 | 8 +- .../test-installed-windows-app-supervisor.ps1 | 93 ++++++++++++- .../scripts/test-installed-windows-app.ps1 | 109 +++++++++++---- apps/desktop/src/release-workflow.test.ts | 44 ++++++- 6 files changed, 326 insertions(+), 64 deletions(-) diff --git a/apps/desktop/scripts/cleanup-installed-windows-app.ps1 b/apps/desktop/scripts/cleanup-installed-windows-app.ps1 index bad24b46b..9ee1837e2 100644 --- a/apps/desktop/scripts/cleanup-installed-windows-app.ps1 +++ b/apps/desktop/scripts/cleanup-installed-windows-app.ps1 @@ -140,19 +140,60 @@ function Test-SamePath([string]$Left, [string]$Right) { ) } -function Resolve-CanonicalProfileLocalPath([string]$LocalPath) { - if ([string]::IsNullOrWhiteSpace($LocalPath) -or - ![IO.Path]::IsPathRooted($LocalPath)) { - throw 'profile local path is invalid' +function Resolve-CanonicalNonReparseDirectory([string]$Path, [string]$Label) { + if ([string]::IsNullOrWhiteSpace($Path) -or ![IO.Path]::IsPathRooted($Path)) { + throw "$Label path is invalid" + } + $fullPath = [IO.Path]::GetFullPath($Path).TrimEnd('\') + $pathRoot = [IO.Path]::GetPathRoot($fullPath) + if ([string]::IsNullOrWhiteSpace($pathRoot)) { throw "$Label path root is invalid" } + $rootItem = Get-Item -LiteralPath $pathRoot -Force -ErrorAction Stop + if (!$rootItem.PSIsContainer -or + ($rootItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "$Label path root is invalid" + } + $currentPath = $pathRoot + $components = @($fullPath.Substring($pathRoot.Length) -split '\\' | + Where-Object { $_.Length -ne 0 }) + foreach ($component in $components) { + $currentPath = Join-Path $currentPath $component + $item = Get-Item -LiteralPath $currentPath -Force -ErrorAction Stop + if (!$item.PSIsContainer -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "$Label path has invalid ancestry" + } } - $fullPath = [IO.Path]::GetFullPath($LocalPath).TrimEnd('\') $resolved = (Resolve-Path -LiteralPath $fullPath -ErrorAction Stop).ProviderPath.TrimEnd('\') - $item = Get-Item -LiteralPath $resolved -Force -ErrorAction Stop - if (!$item.PSIsContainer -or - ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { - throw 'profile local path is not a canonical directory' + if (![string]::Equals( + [IO.Path]::GetFullPath($resolved).TrimEnd('\'), + $fullPath, + [StringComparison]::OrdinalIgnoreCase + )) { + throw "$Label path is not canonical" } - return [IO.Path]::GetFullPath($resolved).TrimEnd('\') + return $fullPath +} + +function Resolve-SystemProfilesDirectory { + $profileListPath = 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList' + $configured = [string](Get-ItemPropertyValue -LiteralPath $profileListPath ` + -Name 'ProfilesDirectory' -ErrorAction Stop) + $expanded = [Environment]::ExpandEnvironmentVariables($configured) + return Resolve-CanonicalNonReparseDirectory $expanded 'system profiles directory' +} + +function Resolve-ValidatedOwnedProfilePath([string]$LocalPath, [string]$UserName) { + if ($UserName -notmatch '^(?:propr-ci-|prpr)[a-f0-9]{8}$') { + throw 'owned profile username is invalid' + } + $profilesDirectory = Resolve-SystemProfilesDirectory + $canonicalLocalPath = Resolve-CanonicalNonReparseDirectory $LocalPath 'profile local' + $parent = Split-Path -Parent $canonicalLocalPath + $leaf = Split-Path -Leaf $canonicalLocalPath + if (!(Test-SamePath $parent $profilesDirectory) -or $leaf -cne $UserName) { + throw 'profile local path is not the exact owned direct child of ProfilesDirectory' + } + return $canonicalLocalPath } function Test-PathWithin([string]$Path, [string]$Root) { @@ -1019,7 +1060,8 @@ function Promote-UncapturedOwnedProfiles($UserRecord, $Manifest) { if ([string]$profile.SID -cne $sid) { throw 'profile SID changed during ownership promotion' } - $canonicalLocalPath = Resolve-CanonicalProfileLocalPath ([string]$profile.LocalPath) + $canonicalLocalPath = Resolve-ValidatedOwnedProfilePath ` + ([string]$profile.LocalPath) $name if (@($promoted | Where-Object { Test-SamePath ([string]$_.LocalPath) $canonicalLocalPath }).Count -ne 0) { @@ -1054,14 +1096,34 @@ function Remove-OwnedProfiles($UserRecord, $ProfileRecords) { if ($profiles.Count -eq 0) { return } try { foreach ($profile in $profiles) { - $canonicalLocalPath = Resolve-CanonicalProfileLocalPath ([string]$profile.LocalPath) - $matchingRecords = @($ProfileRecords | Where-Object { - $_.Owned -and [string]$_.Sid -ceq $sid -and - (Test-SamePath ([string]$_.LocalPath) $canonicalLocalPath) - }) - if ([string]$profile.SID -cne $sid -or $matchingRecords.Count -ne 1) { + if ([string]$profile.SID -cne $sid) { + throw 'profile lacks exact durable SID and path ownership' + } + $canonicalLocalPath = Resolve-ValidatedOwnedProfilePath ` + ([string]$profile.LocalPath) $name + $matchingRecords = @() + foreach ($record in @($ProfileRecords | Where-Object { + $_.Owned -and [string]$_.Sid -ceq $sid + })) { + $canonicalRecordPath = Resolve-ValidatedOwnedProfilePath ` + ([string]$record.LocalPath) $name + if (Test-SamePath $canonicalRecordPath $canonicalLocalPath) { + $matchingRecords += $record + } + } + if ($matchingRecords.Count -ne 1) { throw 'profile lacks exact durable SID and path ownership' } + # Re-resolve the live path and its one durable record at the deletion + # boundary so a changed root, ancestor, depth, leaf, SID, or path fails closed. + $canonicalLocalPath = Resolve-ValidatedOwnedProfilePath ` + ([string]$profile.LocalPath) $name + $canonicalRecordPath = Resolve-ValidatedOwnedProfilePath ` + ([string]$matchingRecords[0].LocalPath) $name + if ([string]$profile.SID -cne $sid -or + !(Test-SamePath $canonicalRecordPath $canonicalLocalPath)) { + throw 'profile ownership changed immediately before deletion' + } Remove-CimInstance -InputObject $profile -ErrorAction Stop } } catch { @@ -1072,23 +1134,33 @@ function Remove-OwnedProfiles($UserRecord, $ProfileRecords) { throw 'owned profile cleanup did not complete' } -function Remove-ExplicitOwnedProfile($Record) { +function Remove-ExplicitOwnedProfile($Record, $UserRecord) { if (!$Record.Owned) { return } $sid = [string]$Record.Sid $localPath = [string]$Record.LocalPath - if ($sid -notmatch '^S-\d+(?:-\d+)+$' -or ![IO.Path]::IsPathRooted($localPath)) { + $name = [string]$UserRecord.Name + if (!$UserRecord.Owned -or [string]$UserRecord.Sid -cne $sid -or + $sid -notmatch '^S-\d+(?:-\d+)+$' -or ![IO.Path]::IsPathRooted($localPath)) { throw 'profile cleanup identity is invalid' } $profiles = @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop | Where-Object { $_.SID -ceq $sid }) foreach ($profile in $profiles) { - $canonicalRecordPath = Resolve-CanonicalProfileLocalPath $localPath - $canonicalCurrentPath = Resolve-CanonicalProfileLocalPath ([string]$profile.LocalPath) + $canonicalRecordPath = Resolve-ValidatedOwnedProfilePath $localPath $name + $canonicalCurrentPath = Resolve-ValidatedOwnedProfilePath ` + ([string]$profile.LocalPath) $name if ($profile.SID -cne $sid -or !(Test-SamePath $canonicalCurrentPath $canonicalRecordPath)) { throw 'profile path ownership changed' } + $canonicalRecordPath = Resolve-ValidatedOwnedProfilePath $localPath $name + $canonicalCurrentPath = Resolve-ValidatedOwnedProfilePath ` + ([string]$profile.LocalPath) $name + if ($profile.SID -cne $sid -or + !(Test-SamePath $canonicalCurrentPath $canonicalRecordPath)) { + throw 'profile ownership changed immediately before deletion' + } Remove-CimInstance -InputObject $profile -ErrorAction Stop } } @@ -1474,7 +1546,15 @@ try { } $profileCleanupFailed = $false foreach ($record in @($manifest.Profiles)) { - try { Remove-ExplicitOwnedProfile $record } catch { + try { + $profileOwners = @($manifest.Users | Where-Object { + $_.Owned -and [string]$_.Sid -ceq [string]$record.Sid + }) + if ($record.Owned -and $profileOwners.Count -ne 1) { + throw 'profile durable owner identity is ambiguous' + } + if ($record.Owned) { Remove-ExplicitOwnedProfile $record $profileOwners[0] } + } catch { $profileCleanupFailed = $true $cleanupFailed = $true } diff --git a/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 b/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 index f0e9d2d34..a7010255d 100644 --- a/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 +++ b/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 @@ -98,10 +98,7 @@ function Set-CaughtControllerFailure($ErrorRecord) { $script:fixedExitCode = 125 } -# The ordinary outer catch covers cold type loading and every controller-body -# phase. It consumes PowerShell error records without host rendering and maps -# them to the fixed protocol before bounded finalization runs. -try { +$invokeController = { Add-Type -TypeDefinition @' using System; using System.ComponentModel; @@ -466,6 +463,13 @@ $TerminationTimeoutMilliseconds = $terminationTimeout $fixedExitCode = 21 } } +} + +# Keep the top-level launcher syntactically small and stable. Dot-sourcing the +# body preserves script scope while the catch consumes type-load and body errors +# without allowing the host to render raw diagnostics. +try { + . $invokeController } catch { Set-CaughtControllerFailure $_ } 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 5270f018e..f5657c998 100644 --- a/apps/desktop/scripts/test-installed-windows-app-supervisor-fixture.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app-supervisor-fixture.ps1 @@ -709,7 +709,6 @@ function Add-FixtureForeignSmokeDescendant { } function Test-PrimaryFallbackForeignDescendants { - Initialize-FixtureDirectoryIdentity $installRoot = Join-Path $stateDirectory 'primary-install-root' $shortcutFolder = Join-Path $stateDirectory 'primary-shortcut-folder' [void](New-Item -ItemType Directory -Path $installRoot -ErrorAction Stop) @@ -719,9 +718,10 @@ function Test-PrimaryFallbackForeignDescendants { [IO.File]::WriteAllText($installForeign, 'foreign-install', [Text.Encoding]::ASCII) [IO.File]::WriteAllText($shortcutForeign, 'foreign-shortcut', [Text.Encoding]::ASCII) foreach ($directory in @($installRoot, $shortcutFolder)) { - $identity = [ProPRFixtureDirectoryIdentity]::Read($directory) - if ([ProPRFixtureDirectoryIdentity]::Read($directory) -cne $identity) { - throw 'primary fallback directory identity changed' + $item = Get-Item -LiteralPath $directory -Force -ErrorAction Stop + if (!$item.PSIsContainer -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'primary fallback fixture directory is invalid' } if (@(Get-ChildItem -LiteralPath $directory -Force -ErrorAction Stop).Count -eq 0) { Remove-Item -LiteralPath $directory -Force -ErrorAction Stop diff --git a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 index e119e32c0..d51ab3d79 100644 --- a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 @@ -167,6 +167,21 @@ function Assert-ProcessTreeGone($State) { throw 'owned worker process tree survived supervisor completion' } +function Get-SanitizedSupervisorMarkerDiagnostic($Result) { + $lastValidPresent = [regex]::IsMatch( + [string]$Result.Output, + '(?m)^PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:LAST_VALID:(?:NONE|[A-Z_]+:[A-Z_]+:(?:BEGIN|COMPLETE|FAILED))\r?$' + ) + $postTerminationPresent = [regex]::IsMatch( + [string]$Result.Output, + '(?m)^PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:(?:COMPLETE|FAILED|TIMED_OUT)\r?$' + ) + $signedExit = ([int]$Result.ExitCode).ToString( + [Globalization.CultureInfo]::InvariantCulture) + return 'SUPERVISOR_EXIT:{0}:LAST_VALID:{1}:POST_TERMINATION:{2}' -f ` + $signedExit, ([int]$lastValidPresent), ([int]$postTerminationPresent) +} + function Assert-OwnedResourcesGone($Owned) { foreach ($ownedPath in @( $Owned.OwnedRoot, $Owned.InstallRoot, $Owned.ShortcutFolder, @@ -252,6 +267,44 @@ function Assert-MsiPreflightPreservedResources($Owned) { 'MSI file-system preflight failure removed the run-owned user' } +function Get-SanitizedControllerStartupDiagnostic( + [string]$ErrorText, + [int]$ProcessExitCode +) { + $classification = if ($ErrorText -match + '(?im)\bParserError\b|\bMissingEndCurlyBrace\b|\bUnexpectedToken\b|\bParseException\b') { + 'PARSER' + } elseif ($ErrorText -match + '(?im)\bParameterBinding(?:Exception|ValidationException)?\b|cannot bind (?:argument|parameter)|parameter cannot be processed') { + 'PARAMETER_BINDING' + } elseif ($ErrorText -match + '(?im)\bAdd-Type\b|\bTypeNotFound\b|unable to find type|error CS[0-9]{4}') { + 'TYPE_LOAD' + } else { + 'OTHER' + } + $lineNumber = 0 + $lineMatch = [regex]::Match( + $ErrorText, + '(?im)^\s*at .+?:(\d+)\s+char:\d+\s*$' + ) + if (!$lineMatch.Success) { + $lineMatch = [regex]::Match($ErrorText, '(?im)\bline\s+(\d+)\b') + } + if ($lineMatch.Success) { + [void]([int]::TryParse( + $lineMatch.Groups[1].Value, + [Globalization.NumberStyles]::None, + [Globalization.CultureInfo]::InvariantCulture, + [ref]$lineNumber + )) + } + $signedExit = $ProcessExitCode.ToString([Globalization.CultureInfo]::InvariantCulture) + $numericLine = $lineNumber.ToString([Globalization.CultureInfo]::InvariantCulture) + return 'STARTUP_CLASS:{0}:PROCESS_EXIT:{1}:LINE:{2}' -f ` + $classification, $signedExit, $numericLine +} + function Invoke-WorkflowCleanupController( [string]$ManifestPath, [string]$RunId, @@ -292,16 +345,20 @@ function Invoke-WorkflowCleanupController( $lineCount = if ($outputLines.Count -ge 3) { '3+' } else { [string]$outputLines.Count } $stderrCount = [Math]::Min(4096, $errorOutput.Length) if ($output.Length -gt 512 -or $outputLines.Count -ne 2) { - throw ('PROPR_WORKFLOW_CLEANUP_FIXTURE:PROTOCOL_MISMATCH:LINE_COUNT:{0}:STDERR_COUNT:{1}' -f ` - $lineCount, $stderrCount) + $startupDiagnostic = Get-SanitizedControllerStartupDiagnostic ` + $errorOutput ([int]$process.ExitCode) + throw ('PROPR_WORKFLOW_CLEANUP_FIXTURE:PROTOCOL_MISMATCH:LINE_COUNT:{0}:STDERR_COUNT:{1}:{2}' -f ` + $lineCount, $stderrCount, $startupDiagnostic) } $resultMatch = [regex]::Match( $outputLines[0], '^PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:(COMPLETE|FAILED|TIMED_OUT)$' ) if (!$resultMatch.Success) { - throw ('PROPR_WORKFLOW_CLEANUP_FIXTURE:PROTOCOL_MISMATCH:LINE_COUNT:{0}:STDERR_COUNT:{1}' -f ` - $lineCount, $stderrCount) + $startupDiagnostic = Get-SanitizedControllerStartupDiagnostic ` + $errorOutput ([int]$process.ExitCode) + throw ('PROPR_WORKFLOW_CLEANUP_FIXTURE:PROTOCOL_MISMATCH:LINE_COUNT:{0}:STDERR_COUNT:{1}:{2}' -f ` + $lineCount, $stderrCount, $startupDiagnostic) } $resultName = $resultMatch.Groups[1].Value $statusMatch = [regex]::Match( @@ -309,8 +366,10 @@ function Invoke-WorkflowCleanupController( '^PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:STATUS:([A-Z_]+):EXIT_CODE:([0-9]+)$' ) if (!$statusMatch.Success) { - throw ('PROPR_WORKFLOW_CLEANUP_FIXTURE:PROTOCOL_MISMATCH:LINE_COUNT:{0}:STDERR_COUNT:{1}' -f ` - $lineCount, $stderrCount) + $startupDiagnostic = Get-SanitizedControllerStartupDiagnostic ` + $errorOutput ([int]$process.ExitCode) + throw ('PROPR_WORKFLOW_CLEANUP_FIXTURE:PROTOCOL_MISMATCH:LINE_COUNT:{0}:STDERR_COUNT:{1}:{2}' -f ` + $lineCount, $stderrCount, $startupDiagnostic) } $controllerStatus = $statusMatch.Groups[1].Value $reportedExitCode = [int]$statusMatch.Groups[2].Value @@ -918,6 +977,25 @@ function Test-PreExistingCleanupOwnership { [string]$profileMismatchOwned.MismatchedProfilePath, [StringComparison]::OrdinalIgnoreCase )) 'mismatched durable profile record was silently re-authorized' + + # A canonical profile belonging to another direct child is still not an + # owned path: its leaf is not the authenticated run username. + $ownedProfileRecords[0].LocalPath = $runnerProfileBefore.CanonicalLocalPath + Write-TestOwnershipManifest $profileMismatchOwned.ManifestPath $profileMismatchManifest + $alternateLeafCleanup = Invoke-WorkflowCleanupController ` + $profileMismatchOwned.ManifestPath $profileMismatchOwned.RunId $profileMismatchDirectory + Assert-True ($alternateLeafCleanup.ExitCode -eq 21 -and + $alternateLeafCleanup.Result -ceq 'FAILED') ` + 'alternate ProfilesDirectory leaf did not fail closed' + $alternateLeafProfiles = @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop | + Where-Object { $_.SID -ceq [string]$profileMismatchOwned.UserSid }) + Assert-True ($alternateLeafProfiles.Count -eq 1) ` + 'alternate ProfilesDirectory leaf selected the owned profile for deletion' + $alternateLeafManifest = Get-Content -LiteralPath $profileMismatchOwned.ManifestPath ` + -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop + Assert-True ($alternateLeafManifest.State -ceq 'ACTIVE') ` + 'alternate ProfilesDirectory leaf discarded ACTIVE recovery authority' + $ownedProfileRecords[0].LocalPath = [string]$profileMismatchOwned.ProfilePath Write-TestOwnershipManifest $profileMismatchOwned.ManifestPath $profileMismatchManifest $profileMismatchRetry = Invoke-WorkflowCleanupController ` @@ -1321,8 +1399,9 @@ function Test-SmokePromotionInterruptionAuthority { function Test-PrimaryWorkerFallbackForeignDescendants { $stateDirectory = New-StateDirectory 'primary-fallback-foreign-descendants' $result = Invoke-FixtureScenario 'PRIMARY_FALLBACK_FOREIGN_DESCENDANTS' $stateDirectory + $diagnostic = Get-SanitizedSupervisorMarkerDiagnostic $result Assert-True ($result.ExitCode -eq 0) ` - 'primary worker fallback foreign-descendant fixture did not complete' + "primary worker fallback foreign-descendant fixture did not complete:$diagnostic" $state = Get-Content -LiteralPath (Join-Path $stateDirectory 'primary-fallback.json') ` -Raw -Encoding ASCII | ConvertFrom-Json -ErrorAction Stop Assert-True ((Get-Content -LiteralPath $state.InstallForeign -Raw).Trim() -ceq ` diff --git a/apps/desktop/scripts/test-installed-windows-app.ps1 b/apps/desktop/scripts/test-installed-windows-app.ps1 index 32feb951b..9ea2da312 100644 --- a/apps/desktop/scripts/test-installed-windows-app.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app.ps1 @@ -275,19 +275,67 @@ function Write-OwnershipManifest { [IO.File]::Move($temporaryManifest, $ownershipManifestPath, $true) } -function Resolve-CanonicalProfileLocalPath([string]$LocalPath) { - if ([string]::IsNullOrWhiteSpace($LocalPath) -or - ![IO.Path]::IsPathRooted($LocalPath)) { - throw 'profile local path is invalid' +function Test-SamePath([string]$Left, [string]$Right) { + return [string]::Equals( + [IO.Path]::GetFullPath($Left).TrimEnd('\'), + [IO.Path]::GetFullPath($Right).TrimEnd('\'), + [StringComparison]::OrdinalIgnoreCase + ) +} + +function Resolve-CanonicalNonReparseDirectory([string]$Path, [string]$Label) { + if ([string]::IsNullOrWhiteSpace($Path) -or ![IO.Path]::IsPathRooted($Path)) { + throw "$Label path is invalid" + } + $fullPath = [IO.Path]::GetFullPath($Path).TrimEnd('\') + $pathRoot = [IO.Path]::GetPathRoot($fullPath) + if ([string]::IsNullOrWhiteSpace($pathRoot)) { throw "$Label path root is invalid" } + $rootItem = Get-Item -LiteralPath $pathRoot -Force -ErrorAction Stop + if (!$rootItem.PSIsContainer -or + ($rootItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "$Label path root is invalid" + } + $currentPath = $pathRoot + $components = @($fullPath.Substring($pathRoot.Length) -split '\\' | + Where-Object { $_.Length -ne 0 }) + foreach ($component in $components) { + $currentPath = Join-Path $currentPath $component + $item = Get-Item -LiteralPath $currentPath -Force -ErrorAction Stop + if (!$item.PSIsContainer -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "$Label path has invalid ancestry" + } } - $fullPath = [IO.Path]::GetFullPath($LocalPath).TrimEnd('\') $resolved = (Resolve-Path -LiteralPath $fullPath -ErrorAction Stop).ProviderPath.TrimEnd('\') - $item = Get-Item -LiteralPath $resolved -Force -ErrorAction Stop - if (!$item.PSIsContainer -or - ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { - throw 'profile local path is not a canonical directory' + if (![string]::Equals( + [IO.Path]::GetFullPath($resolved).TrimEnd('\'), + $fullPath, + [StringComparison]::OrdinalIgnoreCase + )) { + throw "$Label path is not canonical" } - return [IO.Path]::GetFullPath($resolved).TrimEnd('\') + return $fullPath +} + +function Resolve-SystemProfilesDirectory { + $profileListPath = 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList' + $configured = [string](Get-ItemPropertyValue -LiteralPath $profileListPath ` + -Name 'ProfilesDirectory' -ErrorAction Stop) + $expanded = [Environment]::ExpandEnvironmentVariables($configured) + return Resolve-CanonicalNonReparseDirectory $expanded 'system profiles directory' +} + +function Resolve-ValidatedOwnedProfilePath([string]$LocalPath, [string]$UserName) { + if ($UserName -notmatch '^(?:propr-ci-|prpr)[a-f0-9]{8}$') { + throw 'owned profile username is invalid' + } + $profilesDirectory = Resolve-SystemProfilesDirectory + $canonicalLocalPath = Resolve-CanonicalNonReparseDirectory $LocalPath 'profile local' + if (!(Test-SamePath (Split-Path -Parent $canonicalLocalPath) $profilesDirectory) -or + (Split-Path -Leaf $canonicalLocalPath) -cne $UserName) { + throw 'profile local path is not the exact owned direct child of ProfilesDirectory' + } + return $canonicalLocalPath } function Write-DurableOwnershipToken([string]$Path, [string]$Token) { @@ -2282,7 +2330,8 @@ try { } $ownershipState.Profiles = @($ownershipState.Profiles) + @([ordered]@{ Sid = $testUserSid.Value - LocalPath = Resolve-CanonicalProfileLocalPath ([string]$profile.LocalPath) + LocalPath = Resolve-ValidatedOwnedProfilePath ` + ([string]$profile.LocalPath) $testUser Owned = $true }) } @@ -2294,20 +2343,34 @@ try { Invoke-BoundedExternalOperation ` 'CLEANUP' 'PROFILE_REMOVE' $recursiveOperationTimeoutMilliseconds { foreach ($profile in $profiles) { - $canonicalLocalPath = Resolve-CanonicalProfileLocalPath ` - ([string]$profile.LocalPath) - $matchingRecords = @($ownedProfileRecords | Where-Object { - $_.Owned -and [string]$_.Sid -ceq $testUserSid.Value -and - [string]::Equals( - [IO.Path]::GetFullPath([string]$_.LocalPath).TrimEnd('\'), - $canonicalLocalPath, - [StringComparison]::OrdinalIgnoreCase - ) - }) - if ([string]$profile.SID -cne $testUserSid.Value -or - $matchingRecords.Count -ne 1) { + if ([string]$profile.SID -cne $testUserSid.Value) { throw 'refusing to remove a profile without exact durable SID and path ownership' } + $canonicalLocalPath = Resolve-ValidatedOwnedProfilePath ` + ([string]$profile.LocalPath) $testUser + $matchingRecords = @() + foreach ($record in $ownedProfileRecords) { + if (!$record.Owned -or [string]$record.Sid -cne $testUserSid.Value) { + continue + } + $canonicalRecordPath = Resolve-ValidatedOwnedProfilePath ` + ([string]$record.LocalPath) $testUser + if (Test-SamePath $canonicalRecordPath $canonicalLocalPath) { + $matchingRecords += $record + } + } + if ($matchingRecords.Count -ne 1) { + throw 'refusing to remove a profile without exact durable SID and path ownership' + } + # Repeat every live/durable path check at the deletion boundary. + $canonicalLocalPath = Resolve-ValidatedOwnedProfilePath ` + ([string]$profile.LocalPath) $testUser + $canonicalRecordPath = Resolve-ValidatedOwnedProfilePath ` + ([string]$matchingRecords[0].LocalPath) $testUser + if ([string]$profile.SID -cne $testUserSid.Value -or + !(Test-SamePath $canonicalRecordPath $canonicalLocalPath)) { + throw 'profile ownership changed immediately before deletion' + } Remove-CimInstance -InputObject $profile -ErrorAction Stop } } diff --git a/apps/desktop/src/release-workflow.test.ts b/apps/desktop/src/release-workflow.test.ts index 561748466..ae6bd9397 100644 --- a/apps/desktop/src/release-workflow.test.ts +++ b/apps/desktop/src/release-workflow.test.ts @@ -639,12 +639,29 @@ describe('desktop trusted release workflow', () => { assert.match(installedWindowsAppCleanup, /Promote-UncapturedOwnedProfiles/); assert.match( installedWindowsAppCleanup, - /\$matchingRecords = @\(\$ProfileRecords[\s\S]*Test-SamePath[\s\S]*\$matchingRecords\.Count -ne 1[\s\S]*Remove-CimInstance/, - ); + /\$matchingRecords = @\(\)[\s\S]*Resolve-ValidatedOwnedProfilePath[\s\S]*\$matchingRecords\.Count -ne 1[\s\S]*Remove-CimInstance/, + ); + for (const script of [installedWindowsAppTest, installedWindowsAppCleanup]) { + assert.match(script, /Resolve-SystemProfilesDirectory/); + assert.match(script, /-Name 'ProfilesDirectory' -ErrorAction Stop/); + assert.match(script, /Resolve-CanonicalNonReparseDirectory/); + assert.match(script, /FileAttributes\]::ReparsePoint/); + assert.match(script, /Split-Path -Parent \$canonicalLocalPath/); + assert.match(script, /Split-Path -Leaf \$canonicalLocalPath/); + assert.match(script, /profile local path is not the exact owned direct child of ProfilesDirectory/); + assert.match( + script, + /Resolve-ValidatedOwnedProfilePath[\s\S]*profile ownership changed immediately before deletion[\s\S]*Remove-CimInstance/, + ); + } assert.match( installedWindowsAppSupervisorBehaviorTest, /mismatched durable profile path did not fail closed[\s\S]*mismatched profile path discarded ACTIVE recovery authority/, ); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /alternate ProfilesDirectory leaf did not fail closed[\s\S]*alternate ProfilesDirectory leaf discarded ACTIVE recovery authority/, + ); assert.match(installedWindowsAppCleanup, /Remove-OwnedRegistryKey/); assert.match(installedWindowsAppCleanup, /Remove-OwnedDirectory/); assert.match(installedWindowsAppCleanup, /APP_PATH/); @@ -743,7 +760,7 @@ describe('desktop trusted release workflow', () => { assert.doesNotMatch(installedWindowsAppWorkflowCleanup, /Write-Host/); assert.match( installedWindowsAppWorkflowCleanup, - /try \{\nAdd-Type -TypeDefinition @'[\s\S]*\$controllerPhase = 'PROCESS_WAIT'[\s\S]*\} catch \{\n\s+Set-CaughtControllerFailure \$_\n\}/, + /\$invokeController = \{\nAdd-Type -TypeDefinition @'[\s\S]*\$controllerPhase = 'PROCESS_WAIT'[\s\S]*\n\}\n\n#[^\n]+[\s\S]*try \{\n\s+\. \$invokeController\n\} catch \{\n\s+Set-CaughtControllerFailure \$_\n\}/, ); assert.match(installedWindowsAppWorkflowCleanup, /CancelAndFinish/); assert.doesNotMatch( @@ -793,6 +810,15 @@ describe('desktop trusted release workflow', () => { installedWindowsAppSupervisorBehaviorTest, /PROPR_WORKFLOW_CLEANUP_FIXTURE:\{0\}:STATUS:\{1\}:EXIT_CODE:\{2\}/, ); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Get-SanitizedControllerStartupDiagnostic/); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /STARTUP_CLASS:\{0\}:PROCESS_EXIT:\{1\}:LINE:\{2\}/, + ); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /'PARSER'[\s\S]*'PARAMETER_BINDING'[\s\S]*'TYPE_LOAD'[\s\S]*'OTHER'/, + ); assert.match( installedWindowsAppSupervisorBehaviorTest, /OWNED_RESOURCES_FOREIGN_CHILD_THEN_DEADLINE/, @@ -813,6 +839,16 @@ describe('desktop trusted release workflow', () => { assert.match(installedWindowsAppSupervisorFixture, /PRIMARY_FALLBACK_FOREIGN_DESCENDANTS/); assert.match(installedWindowsAppSupervisorBehaviorTest, /primary install fallback removed or changed/); assert.match(installedWindowsAppSupervisorBehaviorTest, /primary shortcut fallback removed or changed/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Get-SanitizedSupervisorMarkerDiagnostic/); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /SUPERVISOR_EXIT:\{0\}:LAST_VALID:\{1\}:POST_TERMINATION:\{2\}/, + ); + const primaryFallbackFixture = installedWindowsAppSupervisorFixture.slice( + installedWindowsAppSupervisorFixture.indexOf('function Test-PrimaryFallbackForeignDescendants'), + installedWindowsAppSupervisorFixture.indexOf('function Start-FixtureDescendant'), + ); + assert.doesNotMatch(primaryFallbackFixture, /Initialize-FixtureDirectoryIdentity|Add-Type/); assert.match(installedWindowsAppSupervisorBehaviorTest, /CONTROLLER_PARAMETER_VALIDATION_PARAMETERS_/); assert.match(installedWindowsAppSupervisorBehaviorTest, /InjectTerminationFailure/); assert.match(installedWindowsAppSupervisorBehaviorTest, /termination failure discarded authenticated recovery authority/); @@ -1014,7 +1050,7 @@ describe('desktop trusted release workflow', () => { ); assert.match( installedWindowsAppTest, - /\$matchingRecords = @\(\$ownedProfileRecords[\s\S]*\$matchingRecords\.Count -ne 1[\s\S]*Remove-CimInstance -InputObject \$profile/, + /\$matchingRecords = @\(\)[\s\S]*foreach \(\$record in \$ownedProfileRecords\)[\s\S]*\$matchingRecords\.Count -ne 1[\s\S]*Remove-CimInstance -InputObject \$profile/, ); assert.match( installedWindowsAppTest, From d0450fa4651946553863214c62e9334266490566 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 06:47:06 +0000 Subject: [PATCH 15/29] feat(ai): Implemented the exact-head correction on `b9415f52f8a50288c9424235155bb86b7b506842` without committing. Implemented the exact-head correction on `b9415f52f8a50288c9424235155bb86b7b506842` without committing. Key changes: - Added strict schema-v3 installer authority: canonical path, filesystem entry identity, SHA-256, and validated MSI ProductCode. - Revalidates identity/digest before artifact consultation and uses only `msiexec /x {GUID}`. - Same-path replacement fails closed, preserves foreign content and ACTIVE recovery authority, and avoids destructive cleanup. - Preserved validated EMPTY receipt behavior. - Split the cleanup controller into a minimal [entry wrapper](/tmp/git-processor/worktrees/integry/propr/pr-2042-followup-2026-09-01T05-57-48/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1) and separate [controller body](/tmp/git-processor/worktrees/integry/propr/pr-2042-followup-2026-09-01T05-57-48/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup-body.ps1). - Added fixed two-line, zero-stderr startup classification for PARSER, PARAMETER_BINDING, TYPE_LOAD, and OTHER. - Added direct startup-class and native same-path replacement fixtures. Validation passed: - Focused release workflow: 23/23 - Desktop suite: 177 passed, 6 platform skips - Desktop typecheck - PowerShell syntax audit - Full suite: all 332 test files plus UI workspace; UI 501/501 - `git diff --check` Native win32-x64/ARM64 execution is unavailable on this Linux host; the architecture-specific fixtures and workflow gates are in place for CI. PR: #2042 Comment by: @integry (ID: 5489577926) Model: gpt-5.6-sol --- .../scripts/cleanup-installed-windows-app.ps1 | 69 +- .../run-installed-windows-app-harness.ps1 | 156 ++++- ...lled-windows-app-workflow-cleanup-body.ps1 | 553 ++++++++++++++++ ...installed-windows-app-workflow-cleanup.ps1 | 595 ++---------------- ...stalled-windows-app-supervisor-fixture.ps1 | 12 +- .../test-installed-windows-app-supervisor.ps1 | 182 +++++- .../scripts/test-installed-windows-app.ps1 | 110 +++- apps/desktop/src/release-workflow.test.ts | 72 ++- 8 files changed, 1132 insertions(+), 617 deletions(-) create mode 100644 apps/desktop/scripts/run-installed-windows-app-workflow-cleanup-body.ps1 diff --git a/apps/desktop/scripts/cleanup-installed-windows-app.ps1 b/apps/desktop/scripts/cleanup-installed-windows-app.ps1 index 9ee1837e2..b724c3619 100644 --- a/apps/desktop/scripts/cleanup-installed-windows-app.ps1 +++ b/apps/desktop/scripts/cleanup-installed-windows-app.ps1 @@ -414,38 +414,39 @@ function Get-RegistryTreeIdentity([string]$Path) { finally { $sha256.Dispose() } } -function Get-MsiProductCode([string]$Path) { - $installerCom = $null - $database = $null - $view = $null - $record = $null +function Get-InstallerSha256([string]$Path) { + $stream = [IO.File]::Open( + $Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) + $sha256 = [Security.Cryptography.SHA256]::Create() try { - $installerCom = New-Object -ComObject WindowsInstaller.Installer - $database = $installerCom.OpenDatabase($Path, 0) - $view = $database.OpenView( - "SELECT ``Value`` FROM ``Property`` WHERE ``Property`` = 'ProductCode'") - $view.Execute() - $record = $view.Fetch() - $productCode = if ($null -eq $record) { $null } else { [string]$record.StringData(1) } - if ($productCode -notmatch '^\{[A-Fa-f0-9]{8}(?:-[A-Fa-f0-9]{4}){3}-[A-Fa-f0-9]{12}\}$') { - throw 'MSI product identity is invalid' - } - return $productCode.ToUpperInvariant() + return [BitConverter]::ToString($sha256.ComputeHash($stream)).Replace('-', '').ToLowerInvariant() } finally { - foreach ($resource in @($record, $view, $database, $installerCom)) { - if ($null -ne $resource -and [Runtime.InteropServices.Marshal]::IsComObject($resource)) { - [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($resource) - } - } + $sha256.Dispose() + $stream.Dispose() + } +} + +function Assert-InstallerArtifactAuthority($Manifest) { + $path = [string]$Manifest.InstallerPath + if ([string]$Manifest.InstallerEntryIdentity -notmatch '^[a-f0-9]{24}$' -or + [string]$Manifest.InstallerSha256 -notmatch '^[a-f0-9]{64}$' -or + [string]$Manifest.InstallerProductCode -notmatch + '^\{[A-F0-9]{8}(?:-[A-F0-9]{4}){3}-[A-F0-9]{12}\}$' -or + (Get-FileSystemEntryIdentity $path $false) -cne + [string]$Manifest.InstallerEntryIdentity -or + (Get-InstallerSha256 $path) -cne [string]$Manifest.InstallerSha256) { + throw 'installer artifact no longer matches durable authority' } } -function Assert-MsiProductIsUnregistered([string]$Path) { +function Assert-MsiProductIsUnregistered([string]$ProductCode) { $installerCom = $null try { - $productCode = Get-MsiProductCode $Path + if ($ProductCode -notmatch '^\{[A-F0-9]{8}(?:-[A-F0-9]{4}){3}-[A-F0-9]{12}\}$') { + throw 'MSI product identity is invalid' + } $installerCom = New-Object -ComObject WindowsInstaller.Installer - if ([int]$installerCom.ProductState($productCode) -ne -1) { + if ([int]$installerCom.ProductState($ProductCode) -ne -1) { throw 'Windows Installer product registration is not at the clean baseline' } } finally { @@ -490,7 +491,8 @@ function Assert-MsiRolledBackCleanBaseline($Manifest) { if (!$matchesBaseline -or !$keyMatchesBaseline) { throw 'MSI rollback did not restore the exact current-user baseline' } - Assert-MsiProductIsUnregistered ([string]$Manifest.InstallerPath) + Assert-InstallerArtifactAuthority $Manifest + Assert-MsiProductIsUnregistered ([string]$Manifest.InstallerProductCode) } function Convert-RegistryValueToBytes( @@ -1229,7 +1231,7 @@ try { $manifestKeys = @($manifest.PSObject.Properties | ForEach-Object { $_.Name }) $expectedManifestKeys = @( 'SchemaVersion','ManifestType','State','RunId','CreatedUtcTicks','ExpiresUtcTicks', - 'InstallerPath','Fixture', + 'InstallerPath','InstallerEntryIdentity','InstallerSha256','InstallerProductCode','Fixture', 'FixtureRoot','BaselineClean','InstallAttempted','MsiTransactionState', 'Directories','Files','RegistryKeys', 'RegistryValues','Users','Profiles' @@ -1241,10 +1243,14 @@ try { [string]$manifest.MsiTransactionState -notin @( 'NONE','PENDING','COMMITTED','ROLLED_BACK_CLEAN' ) -or - $manifest.SchemaVersion -ne 2 -or + $manifest.SchemaVersion -ne 3 -or [string]$manifest.ManifestType -cne 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP' -or [string]$manifest.State -notin @('ACTIVE','EMPTY') -or - [string]$manifest.RunId -notmatch '^[a-f0-9]{32}$') { + [string]$manifest.RunId -notmatch '^[a-f0-9]{32}$' -or + [string]$manifest.InstallerEntryIdentity -notmatch '^[a-f0-9]{24}$' -or + [string]$manifest.InstallerSha256 -notmatch '^[a-f0-9]{64}$' -or + [string]$manifest.InstallerProductCode -notmatch + '^\{[A-F0-9]{8}(?:-[A-F0-9]{4}){3}-[A-F0-9]{12}\}$') { throw 'ownership manifest schema is invalid' } if (!$manifest.Fixture -and ( @@ -1479,6 +1485,10 @@ try { } } $manifestValidated = $true + # ACTIVE authority is inseparable from the exact installer entry captured by + # the supervisor. A same-path replacement blocks every cleanup mutation, + # including fixture/manual fallbacks that do not otherwise need Windows Installer. + Assert-InstallerArtifactAuthority $manifest if (!$manifest.Fixture) { if ([string]$manifest.MsiTransactionState -ceq 'PENDING') { throw 'MSI transaction has no durable cleanup authority receipt' @@ -1522,8 +1532,9 @@ try { for ($attempt = 0; $attempt -lt 12 -and $msiExitCode -eq 1618; $attempt += 1) { if ($attempt -ne 0) { Start-Sleep -Seconds 2 } Assert-MsiManagedFileSystemAuthority $manifest + Assert-InstallerArtifactAuthority $manifest $msi = Start-Process msiexec.exe -ArgumentList @( - '/x', "`"$resolvedInstaller`"", '/qn', '/norestart' + '/x', [string]$manifest.InstallerProductCode, '/qn', '/norestart' ) -PassThru -WindowStyle Hidden -ErrorAction Stop try { [void]$msi.WaitForExit() diff --git a/apps/desktop/scripts/run-installed-windows-app-harness.ps1 b/apps/desktop/scripts/run-installed-windows-app-harness.ps1 index c608a3286..7dde3ed09 100644 --- a/apps/desktop/scripts/run-installed-windows-app-harness.ps1 +++ b/apps/desktop/scripts/run-installed-windows-app-harness.ps1 @@ -227,6 +227,50 @@ public sealed class ProPRKillOnCloseJob : IDisposable } } +public static class ProPRInstallerEntryIdentity +{ + [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", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFile( + string path, uint access, uint share, IntPtr security, uint creation, + uint flags, IntPtr template); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetFileInformationByHandle( + SafeFileHandle handle, out BY_HANDLE_FILE_INFORMATION information); + + public static string Read(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(), "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); + } + } +} + public enum ProPRMarkerReadState { Missing, @@ -312,6 +356,89 @@ public static class ProPRBoundedMarkerReader } '@ +function Get-InstallerSha256([string]$Path) { + $stream = [IO.File]::Open( + $Path, + [IO.FileMode]::Open, + [IO.FileAccess]::Read, + [IO.FileShare]::Read + ) + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + return [BitConverter]::ToString($sha256.ComputeHash($stream)).Replace('-', '').ToLowerInvariant() + } finally { + $sha256.Dispose() + $stream.Dispose() + } +} + +function Get-MsiProductCode([string]$Path) { + $installerCom = $null + $database = $null + $view = $null + $record = $null + try { + $installerCom = New-Object -ComObject WindowsInstaller.Installer + $database = $installerCom.OpenDatabase($Path, 0) + $view = $database.OpenView( + "SELECT ``Value`` FROM ``Property`` WHERE ``Property`` = 'ProductCode'") + $view.Execute() + $record = $view.Fetch() + $productCode = if ($null -eq $record) { $null } else { [string]$record.StringData(1) } + if ($productCode -notmatch '^\{[A-Fa-f0-9]{8}(?:-[A-Fa-f0-9]{4}){3}-[A-Fa-f0-9]{12}\}$') { + throw 'MSI product identity is invalid' + } + return $productCode.ToUpperInvariant() + } finally { + foreach ($resource in @($record, $view, $database, $installerCom)) { + if ($null -ne $resource -and [Runtime.InteropServices.Marshal]::IsComObject($resource)) { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($resource) + } + } + } +} + +function Get-InstallerAuthority([string]$Path) { + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if ($item.PSIsContainer -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'installer artifact is not an ordinary file' + } + $canonicalPath = (Resolve-Path -LiteralPath $item.FullName -ErrorAction Stop).ProviderPath + $entryIdentity = [ProPRInstallerEntryIdentity]::Read($canonicalPath) + $sha256 = Get-InstallerSha256 $canonicalPath + if ([ProPRInstallerEntryIdentity]::Read($canonicalPath) -cne $entryIdentity -or + (Get-InstallerSha256 $canonicalPath) -cne $sha256) { + throw 'installer artifact changed before product identity capture' + } + $productCode = Get-MsiProductCode $canonicalPath + if ([ProPRInstallerEntryIdentity]::Read($canonicalPath) -cne $entryIdentity -or + (Get-InstallerSha256 $canonicalPath) -cne $sha256) { + throw 'installer artifact changed during authority capture' + } + return [PSCustomObject]@{ + Path = $canonicalPath + EntryIdentity = $entryIdentity + Sha256 = $sha256 + ProductCode = $productCode + } +} + +function Test-InstallerArtifactAuthority($Record) { + try { + return [string]$Record.InstallerEntryIdentity -match '^[a-f0-9]{24}$' -and + [string]$Record.InstallerSha256 -match '^[a-f0-9]{64}$' -and + [string]$Record.InstallerProductCode -match + '^\{[A-F0-9]{8}(?:-[A-F0-9]{4}){3}-[A-F0-9]{12}\}$' -and + [ProPRInstallerEntryIdentity]::Read([string]$Record.InstallerPath) -ceq + [string]$Record.InstallerEntryIdentity -and + (Get-InstallerSha256 ([string]$Record.InstallerPath)) -ceq + [string]$Record.InstallerSha256 + } catch { + return $false + } +} + function Write-WatchdogLine([string]$Line) { Write-Host $Line [Console]::Out.Flush() @@ -399,7 +526,7 @@ function Stop-OwnedWorker([uint32]$TerminationExitCode) { function Write-InitialOwnershipManifest( [string]$Path, - [string]$InstallerPath, + $InstallerAuthority, [bool]$Fixture, [string]$AuthorizedFixtureRoot ) { @@ -407,13 +534,16 @@ function Write-InitialOwnershipManifest( 'propr-installed-app-ownership-'.Length) $createdUtcTicks = [DateTime]::UtcNow.Ticks $manifest = [ordered]@{ - SchemaVersion = 2 + SchemaVersion = 3 ManifestType = 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP' State = 'ACTIVE' RunId = $runId CreatedUtcTicks = $createdUtcTicks ExpiresUtcTicks = $createdUtcTicks + ([TimeSpan]::TicksPerHour * 3) - InstallerPath = $InstallerPath + InstallerPath = [string]$InstallerAuthority.Path + InstallerEntryIdentity = [string]$InstallerAuthority.EntryIdentity + InstallerSha256 = [string]$InstallerAuthority.Sha256 + InstallerProductCode = [string]$InstallerAuthority.ProductCode Fixture = $Fixture FixtureRoot = if ($Fixture) { $AuthorizedFixtureRoot } else { $null } BaselineClean = $false @@ -478,7 +608,20 @@ function Get-DurableMsiTransactionReceipt { $manifest = ConvertFrom-Json ` -InputObject ([Text.UTF8Encoding]::new($false, $true).GetString($bytes)) ` -ErrorAction Stop - if ([string]$manifest.RunId -cne $ownershipRunId -or + $manifestKeys = @($manifest.PSObject.Properties | ForEach-Object { $_.Name }) + $expectedManifestKeys = @( + 'SchemaVersion','ManifestType','State','RunId','CreatedUtcTicks','ExpiresUtcTicks', + 'InstallerPath','InstallerEntryIdentity','InstallerSha256','InstallerProductCode', + 'Fixture','FixtureRoot','BaselineClean','InstallAttempted','MsiTransactionState', + 'Directories','Files','RegistryKeys','RegistryValues','Users','Profiles' + ) + if ($manifestKeys.Count -ne $expectedManifestKeys.Count -or + @($expectedManifestKeys | Where-Object { + $manifestKeys -cnotcontains $_ + }).Count -ne 0 -or + $manifest.SchemaVersion -ne 3 -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 @@ -610,7 +753,8 @@ function Invoke-PostTerminationCleanup([string]$InstallerPath, [string]$Authoriz } try { - $installerPath = (Resolve-Path -LiteralPath $Installer -ErrorAction Stop).Path + $installerAuthority = Get-InstallerAuthority $Installer + $installerPath = [string]$installerAuthority.Path if ($OwnershipManifest -or $ExpectedRunId) { if (!$OwnershipManifest -or $ExpectedRunId -notmatch '^[a-f0-9]{32}$') { throw 'workflow ownership authority is invalid' @@ -657,7 +801,7 @@ try { $cancellationEvent = [Threading.EventWaitHandle]::OpenExisting($CancellationEventName) } Write-InitialOwnershipManifest ` - $ownershipManifestPath $installerPath (!$usingProductionWorker) $FixtureCleanupRoot + $ownershipManifestPath $installerAuthority (!$usingProductionWorker) $FixtureCleanupRoot $startInfo = [Diagnostics.ProcessStartInfo]::new() $startInfo.FileName = $hostPath diff --git a/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup-body.ps1 b/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup-body.ps1 new file mode 100644 index 000000000..2f331ff0d --- /dev/null +++ b/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup-body.ps1 @@ -0,0 +1,553 @@ +param( + [object]$OwnershipManifest, + [object]$Installer, + [object]$ExpectedRunId, + [object]$CleanupTimeoutMilliseconds = 4 * 60 * 1000, + [object]$TerminationTimeoutMilliseconds = 30 * 1000, + [object]$FixtureRoot, + [switch]$FixtureEarlyInitializationChild +) + +enum WorkflowCleanupControllerPhase { + INITIALIZATION + PARAMETER_VALIDATION + PATH_VALIDATION + PROCESS_START + PROCESS_WAIT + PROCESS_FINALIZATION + STREAM_FINALIZATION + RESOURCE_FINALIZATION + AUTHORITY_FINALIZATION + RESULT_EMISSION +} + +enum WorkflowCleanupControllerLine { + TYPE_LOAD + PARAMETERS + PATHS + START + WAIT + TERMINATE + DRAIN + DISPOSE + AUTHORITY + EMIT +} + +$ErrorActionPreference = 'Stop' +$cleanupProcess = $null +$cleanupJob = $null +$cleanupReadyEvent = $null +$outputDrain = $null +$fixedResult = 'FAILED' +$fixedStatus = 'CONTROLLER_FAILURE' +$fixedExitCode = 125 +$validatedManifestPath = $null +[WorkflowCleanupControllerPhase]$controllerPhase = 'INITIALIZATION' +[WorkflowCleanupControllerLine]$controllerLine = 'TYPE_LOAD' +$cleanupTreeZeroVerified = $false + +function Write-FixedResult([ValidateSet('COMPLETE','FAILED','TIMED_OUT')][string]$Result) { + [Console]::Out.WriteLine("PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:$Result") + [Console]::Out.WriteLine( + 'PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:STATUS:{0}:EXIT_CODE:{1}' -f ` + $script:fixedStatus, $script:fixedExitCode) + [Console]::Out.Flush() +} + +function Set-CaughtControllerFailure($ErrorRecord) { + $phases = @( + 'INITIALIZATION','PARAMETER_VALIDATION','PATH_VALIDATION','PROCESS_START', + 'PROCESS_WAIT','PROCESS_FINALIZATION','STREAM_FINALIZATION', + 'RESOURCE_FINALIZATION','AUTHORITY_FINALIZATION','RESULT_EMISSION' + ) + $lines = @( + 'TYPE_LOAD','PARAMETERS','PATHS','START','WAIT','TERMINATE','DRAIN', + 'DISPOSE','AUTHORITY','EMIT' + ) + $categories = @{ + AuthenticationError = 'AUTHENTICATION' + CloseError = 'CLOSE' + InvalidArgument = 'INVALID_ARGUMENT' + InvalidData = 'INVALID_DATA' + InvalidOperation = 'INVALID_OPERATION' + LimitsExceeded = 'LIMIT' + NotEnabled = 'NOT_ENABLED' + ObjectNotFound = 'NOT_FOUND' + OpenError = 'OPEN' + OperationStopped = 'STOPPED' + PermissionDenied = 'PERMISSION' + ReadError = 'READ' + ResourceBusy = 'BUSY' + ResourceUnavailable = 'UNAVAILABLE' + SecurityError = 'SECURITY' + WriteError = 'WRITE' + } + $phase = if ($phases -ccontains [string]$script:controllerPhase) { + [string]$script:controllerPhase + } else { 'INITIALIZATION' } + $line = if ($lines -ccontains [string]$script:controllerLine) { + [string]$script:controllerLine + } else { 'TYPE_LOAD' } + $categoryName = [string]$ErrorRecord.CategoryInfo.Category + $category = if ($categories.ContainsKey($categoryName)) { + $categories[$categoryName] + } else { 'UNCLASSIFIED' } + $script:fixedResult = 'FAILED' + $script:fixedStatus = 'CONTROLLER_{0}_{1}_{2}' -f $phase, $line, $category + $script:fixedExitCode = 125 +} + +Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Win32.SafeHandles; + +public sealed class ProPRWorkflowCleanupJob : IDisposable +{ + [StructLayout(LayoutKind.Sequential)] + private struct JOBOBJECT_BASIC_LIMIT_INFORMATION + { + public long PerProcessUserTimeLimit; + public long PerJobUserTimeLimit; + public uint LimitFlags; + public UIntPtr MinimumWorkingSetSize; + public UIntPtr MaximumWorkingSetSize; + public uint ActiveProcessLimit; + public UIntPtr Affinity; + public uint PriorityClass; + public uint SchedulingClass; + } + + [StructLayout(LayoutKind.Sequential)] + private struct IO_COUNTERS + { + public ulong ReadOperationCount; + public ulong WriteOperationCount; + public ulong OtherOperationCount; + public ulong ReadTransferCount; + public ulong WriteTransferCount; + public ulong OtherTransferCount; + } + + [StructLayout(LayoutKind.Sequential)] + private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION + { + public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation; + public IO_COUNTERS IoInfo; + public UIntPtr ProcessMemoryLimit; + public UIntPtr JobMemoryLimit; + public UIntPtr PeakProcessMemoryUsed; + public UIntPtr PeakJobMemoryUsed; + } + + private const int JobObjectExtendedLimitInformation = 9; + private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000; + private SafeFileHandle handle; + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateJobObject(IntPtr attributes, string name); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool SetInformationJobObject( + SafeFileHandle job, int informationClass, IntPtr information, uint informationLength); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool AssignProcessToJobObject(SafeFileHandle job, IntPtr process); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool TerminateJobObject(SafeFileHandle job, uint exitCode); + + [StructLayout(LayoutKind.Sequential)] + private struct JOBOBJECT_BASIC_ACCOUNTING_INFORMATION + { + public long TotalUserTime; + public long TotalKernelTime; + public long ThisPeriodTotalUserTime; + public long ThisPeriodTotalKernelTime; + public uint TotalPageFaultCount; + public uint TotalProcesses; + public uint ActiveProcesses; + public uint TotalTerminatedProcesses; + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool QueryInformationJobObject( + SafeFileHandle job, int informationClass, + out JOBOBJECT_BASIC_ACCOUNTING_INFORMATION information, + uint informationLength, IntPtr returnLength); + + public ProPRWorkflowCleanupJob() + { + handle = CreateJobObject(IntPtr.Zero, null); + if (handle == null || handle.IsInvalid) + throw new Win32Exception(Marshal.GetLastWin32Error(), "job creation failed"); + var limits = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION(); + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + int size = Marshal.SizeOf(typeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION)); + IntPtr buffer = Marshal.AllocHGlobal(size); + try + { + Marshal.StructureToPtr(limits, buffer, false); + if (!SetInformationJobObject(handle, JobObjectExtendedLimitInformation, buffer, (uint)size)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "job configuration failed"); + } + finally { Marshal.FreeHGlobal(buffer); } + } + + public void AddProcess(IntPtr processHandle) + { + if (!AssignProcessToJobObject(handle, processHandle)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "cleanup ownership failed"); + } + + private uint ReadActiveProcessCount() + { + if (handle == null || handle.IsInvalid) + throw new InvalidOperationException("job handle is unavailable"); + JOBOBJECT_BASIC_ACCOUNTING_INFORMATION information; + uint size = (uint)Marshal.SizeOf(typeof(JOBOBJECT_BASIC_ACCOUNTING_INFORMATION)); + if (!QueryInformationJobObject(handle, 1, out information, size, IntPtr.Zero)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "cleanup accounting failed"); + return information.ActiveProcesses; + } + + public bool WaitForNoActiveProcesses(int timeoutMilliseconds) + { + var stopwatch = Stopwatch.StartNew(); + do + { + if (ReadActiveProcessCount() == 0) return true; + Thread.Sleep(25); + } + while (stopwatch.ElapsedMilliseconds < timeoutMilliseconds); + return ReadActiveProcessCount() == 0; + } + + public bool HasNoActiveProcesses() + { + return ReadActiveProcessCount() == 0; + } + + public bool TerminateAndWait(uint exitCode, int timeoutMilliseconds) + { + if (handle == null || handle.IsInvalid) + throw new InvalidOperationException("job handle is unavailable"); + if (!TerminateJobObject(handle, exitCode)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "cleanup termination failed"); + return WaitForNoActiveProcesses(timeoutMilliseconds); + } + + public void Dispose() { if (handle != null) handle.Dispose(); } +} + +public sealed class ProPRWorkflowCleanupDrainResult +{ + public long StandardOutputCharacters; + public long StandardErrorCharacters; +} + +public sealed class ProPRWorkflowCleanupOutputDrain : IDisposable +{ + private const long CharacterLimit = 4096; + private readonly CancellationTokenSource cancellation = new CancellationTokenSource(); + private StreamReader standardOutputReader; + private StreamReader standardErrorReader; + private Task standardOutputTask; + private Task standardErrorTask; + + private static async Task Pump(StreamReader reader, CancellationToken token) + { + var buffer = new char[1024]; + long characters = 0; + while (true) + { + int count = await reader.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false); + if (count == 0) return characters; + token.ThrowIfCancellationRequested(); + characters = Math.Min(CharacterLimit + 1, characters + count); + } + } + + public void Start(Process process) + { + if (standardOutputTask != null || standardErrorTask != null) + throw new InvalidOperationException("stream drain was already started"); + standardOutputReader = process.StandardOutput; + standardErrorReader = process.StandardError; + standardOutputTask = Pump(standardOutputReader, cancellation.Token); + standardErrorTask = Pump(standardErrorReader, cancellation.Token); + } + + public ProPRWorkflowCleanupDrainResult Finish(int timeoutMilliseconds) + { + if (standardOutputTask == null || standardErrorTask == null) + throw new InvalidOperationException("stream drain was not started"); + Task all = Task.WhenAll(standardOutputTask, standardErrorTask); + if (!all.Wait(timeoutMilliseconds)) return null; + if (standardOutputTask.IsFaulted || standardOutputTask.IsCanceled || + standardErrorTask.IsFaulted || standardErrorTask.IsCanceled) + throw new InvalidOperationException("stream drain failed"); + return new ProPRWorkflowCleanupDrainResult { + StandardOutputCharacters = standardOutputTask.Result, + StandardErrorCharacters = standardErrorTask.Result + }; + } + + public bool CancelAndFinish(int timeoutMilliseconds) + { + cancellation.Cancel(); + try { if (standardOutputReader != null) standardOutputReader.Dispose(); } catch { } + try { if (standardErrorReader != null) standardErrorReader.Dispose(); } catch { } + if (standardOutputTask == null || standardErrorTask == null) return true; + try { Task.WhenAll(standardOutputTask, standardErrorTask).Wait(timeoutMilliseconds); } + catch { } + return standardOutputTask.IsCompleted && standardErrorTask.IsCompleted; + } + + public void Dispose() + { + CancelAndFinish(1000); + cancellation.Dispose(); + } +} +'@ + +try { +$controllerPhase = 'PARAMETER_VALIDATION' +$controllerLine = 'PARAMETERS' +$cleanupTimeout = 0 +$terminationTimeout = 0 +if ([string]::IsNullOrWhiteSpace([string]$OwnershipManifest) -or + [string]::IsNullOrWhiteSpace([string]$Installer) -or + [string]::IsNullOrWhiteSpace([string]$ExpectedRunId) -or + ![int]::TryParse( + [string]$CleanupTimeoutMilliseconds, + [Globalization.NumberStyles]::None, + [Globalization.CultureInfo]::InvariantCulture, + [ref]$cleanupTimeout + ) -or $cleanupTimeout -lt 1 -or $cleanupTimeout -gt 600000 -or + ![int]::TryParse( + [string]$TerminationTimeoutMilliseconds, + [Globalization.NumberStyles]::None, + [Globalization.CultureInfo]::InvariantCulture, + [ref]$terminationTimeout + ) -or $terminationTimeout -lt 1 -or $terminationTimeout -gt 30000) { + throw 'workflow cleanup controller parameters are invalid' +} +$OwnershipManifest = [string]$OwnershipManifest +$Installer = [string]$Installer +$ExpectedRunId = [string]$ExpectedRunId +$FixtureRoot = if ($null -eq $FixtureRoot) { $null } else { [string]$FixtureRoot } +$CleanupTimeoutMilliseconds = $cleanupTimeout +$TerminationTimeoutMilliseconds = $terminationTimeout + + $controllerPhase = 'PATH_VALIDATION' + $controllerLine = 'PATHS' + if ($ExpectedRunId -notmatch '^[a-f0-9]{32}$') { throw 'cleanup run identity is invalid' } + $manifestPath = [IO.Path]::GetFullPath($OwnershipManifest) + $tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + if ((Split-Path -Leaf $manifestPath) -cne + "propr-installed-app-ownership-$ExpectedRunId.json" -or + ![string]::Equals( + (Split-Path -Parent $manifestPath).TrimEnd('\'), + $tempRoot, + [StringComparison]::OrdinalIgnoreCase + )) { + throw 'cleanup manifest path is invalid' + } + $validatedManifestPath = $manifestPath + $installerPath = (Resolve-Path -LiteralPath $Installer -ErrorAction Stop).Path + $cleanupWorkerPath = (Resolve-Path -LiteralPath + (Join-Path $PSScriptRoot 'cleanup-installed-windows-app.ps1') -ErrorAction Stop).Path + $hostPath = (Get-Process -Id $PID -ErrorAction Stop).Path + if ([IO.Path]::GetFileName($hostPath) -notin @('pwsh.exe', 'powershell.exe')) { + throw 'PowerShell host resolution failed' + } + $cleanupReadyEventName = "Local\ProPRInstalledAppCleanup-$([Guid]::NewGuid().ToString('N'))" + $cleanupReadyEvent = [Threading.EventWaitHandle]::new( + $false, + [Threading.EventResetMode]::ManualReset, + $cleanupReadyEventName + ) + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $hostPath + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + foreach ($argument in @( + '-NoLogo', '-NoProfile', '-NonInteractive', '-File', $cleanupWorkerPath, + '-OwnershipManifest', $manifestPath, + '-Installer', $installerPath, + '-ExpectedRunId', $ExpectedRunId, + '-OwnershipReadyEvent', $cleanupReadyEventName + )) { + $startInfo.ArgumentList.Add($argument) + } + if ($FixtureRoot) { + $startInfo.ArgumentList.Add('-FixtureRoot') + $startInfo.ArgumentList.Add((Resolve-Path -LiteralPath $FixtureRoot -ErrorAction Stop).Path) + } + if ($FixtureEarlyInitializationChild) { + if (!$FixtureRoot) { throw 'early initialization fixture requires a fixture scope' } + $startInfo.ArgumentList.Add('-FixtureEarlyInitializationChild') + } + $cleanupJob = [ProPRWorkflowCleanupJob]::new() + $controllerPhase = 'PROCESS_START' + $controllerLine = 'START' + $cleanupProcess = [Diagnostics.Process]::new() + $cleanupProcess.StartInfo = $startInfo + if (!$cleanupProcess.Start()) { throw 'workflow cleanup did not start' } + try { + $cleanupJob.AddProcess($cleanupProcess.Handle) + $outputDrain = [ProPRWorkflowCleanupOutputDrain]::new() + $outputDrain.Start($cleanupProcess) + [void]$cleanupReadyEvent.Set() + } catch { + try { + $cleanupTreeZeroVerified = $cleanupJob.TerminateAndWait( + 125, $TerminationTimeoutMilliseconds) + } catch {} + try { + if (!$cleanupProcess.HasExited) { + $cleanupProcess.Kill($true) + [void]$cleanupProcess.WaitForExit($TerminationTimeoutMilliseconds) + } + } catch {} + throw 'workflow cleanup ownership failed' + } + $controllerPhase = 'PROCESS_WAIT' + $controllerLine = 'WAIT' + if (!$cleanupProcess.WaitForExit($CleanupTimeoutMilliseconds)) { + $controllerLine = 'TERMINATE' + $terminationVerified = $false + try { + $terminationVerified = $cleanupJob.TerminateAndWait( + 125, $TerminationTimeoutMilliseconds) + } catch {} + if ($terminationVerified) { + $cleanupTreeZeroVerified = $true + $fixedResult = 'TIMED_OUT' + $fixedStatus = 'TIMEOUT' + $fixedExitCode = 124 + } else { + $fixedResult = 'FAILED' + $fixedStatus = 'TERMINATION_FAILURE' + $fixedExitCode = 125 + } + } else { + $cleanupTreeZeroVerified = $cleanupJob.HasNoActiveProcesses() + if (!$cleanupTreeZeroVerified) { + try { + $cleanupTreeZeroVerified = $cleanupJob.TerminateAndWait( + 125, $TerminationTimeoutMilliseconds) + } catch {} + $fixedResult = 'FAILED' + $fixedStatus = 'ACTIVE_PROCESS_AFTER_ROOT_EXIT' + $fixedExitCode = 125 + } elseif ($cleanupProcess.ExitCode -eq 0) { + $fixedResult = 'COMPLETE' + $fixedStatus = 'EMPTY_OR_CLEANED' + $fixedExitCode = 0 + } elseif ($cleanupProcess.ExitCode -eq 20) { + $fixedStatus = 'MANIFEST_VALIDATION_FAILURE' + $fixedExitCode = 20 + } elseif ($cleanupProcess.ExitCode -eq 21) { + $fixedStatus = 'OWNED_RESOURCE_CLEANUP_FAILURE' + $fixedExitCode = 21 + } + } +} catch { + Set-CaughtControllerFailure $_ +} + +try { + $controllerPhase = 'PROCESS_FINALIZATION' + $controllerLine = 'TERMINATE' + if ($null -ne $cleanupJob -and !$cleanupTreeZeroVerified) { + $cleanupTreeZeroVerified = $cleanupJob.TerminateAndWait( + 125, $TerminationTimeoutMilliseconds) + if (!$cleanupTreeZeroVerified) { + $fixedResult = 'FAILED' + $fixedStatus = 'PROCESS_FINALIZATION_TIMEOUT' + $fixedExitCode = 125 + } + } +} catch { + $fixedResult = 'FAILED' + $fixedStatus = 'PROCESS_FINALIZATION_FAILURE' + $fixedExitCode = 125 +} + +try { + $controllerPhase = 'STREAM_FINALIZATION' + $controllerLine = 'DRAIN' + if ($null -ne $outputDrain) { + $drainResult = $outputDrain.Finish($TerminationTimeoutMilliseconds) + if ($null -eq $drainResult) { + [void]$outputDrain.CancelAndFinish($TerminationTimeoutMilliseconds) + $fixedResult = 'FAILED' + $fixedStatus = 'STREAM_DRAIN_TIMEOUT' + $fixedExitCode = 125 + } elseif ($drainResult.StandardErrorCharacters -ne 0) { + $fixedResult = 'FAILED' + $fixedStatus = if ($drainResult.StandardErrorCharacters -gt 4096) { + 'CHILD_STDERR_LIMIT' + } else { 'CHILD_STDERR' } + $fixedExitCode = 123 + } elseif ($drainResult.StandardOutputCharacters -ne 0) { + $fixedResult = 'FAILED' + $fixedStatus = if ($drainResult.StandardOutputCharacters -gt 4096) { + 'CHILD_STDOUT_LIMIT' + } else { 'CHILD_STDOUT' } + $fixedExitCode = 122 + } + } +} catch { + $fixedResult = 'FAILED' + $fixedStatus = 'STREAM_DRAIN_FAILURE' + $fixedExitCode = 125 +} + +$controllerPhase = 'RESOURCE_FINALIZATION' +$controllerLine = 'DISPOSE' +foreach ($resource in @($outputDrain, $cleanupJob, $cleanupProcess, $cleanupReadyEvent)) { + if ($null -eq $resource) { continue } + try { $resource.Dispose() } catch { + $fixedResult = 'FAILED' + $fixedStatus = 'RESOURCE_FINALIZATION_FAILURE' + $fixedExitCode = 125 + } +} + +if ($fixedResult -ceq 'COMPLETE' -and $cleanupTreeZeroVerified -and + $validatedManifestPath) { + try { + $controllerPhase = 'AUTHORITY_FINALIZATION' + $controllerLine = 'AUTHORITY' + foreach ($path in @("$validatedManifestPath.new", $validatedManifestPath)) { + if ([IO.File]::Exists($path)) { [IO.File]::Delete($path) } + } + } catch { + $fixedResult = 'FAILED' + $fixedStatus = 'AUTHORITY_FINALIZATION_FAILURE' + $fixedExitCode = 125 + } +} + +try { + $controllerPhase = 'RESULT_EMISSION' + $controllerLine = 'EMIT' + Write-FixedResult $fixedResult +} catch { + Set-CaughtControllerFailure $_ + exit 125 +} + +exit $fixedExitCode diff --git a/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 b/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 index a7010255d..203b0f2af 100644 --- a/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 +++ b/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 @@ -5,556 +5,85 @@ param( [object]$CleanupTimeoutMilliseconds = 4 * 60 * 1000, [object]$TerminationTimeoutMilliseconds = 30 * 1000, [object]$FixtureRoot, - [switch]$FixtureEarlyInitializationChild + [object]$FixtureEarlyInitializationChild, + [object]$StartupFailureClass ) -enum WorkflowCleanupControllerPhase { - INITIALIZATION - PARAMETER_VALIDATION - PATH_VALIDATION - PROCESS_START - PROCESS_WAIT - PROCESS_FINALIZATION - STREAM_FINALIZATION - RESOURCE_FINALIZATION - AUTHORITY_FINALIZATION - RESULT_EMISSION -} - -enum WorkflowCleanupControllerLine { - TYPE_LOAD - PARAMETERS - PATHS - START - WAIT - TERMINATE - DRAIN - DISPOSE - AUTHORITY - EMIT -} - $ErrorActionPreference = 'Stop' -$cleanupProcess = $null -$cleanupJob = $null -$cleanupReadyEvent = $null -$outputDrain = $null -$fixedResult = 'FAILED' -$fixedStatus = 'CONTROLLER_FAILURE' -$fixedExitCode = 125 -$validatedManifestPath = $null -[WorkflowCleanupControllerPhase]$controllerPhase = 'INITIALIZATION' -[WorkflowCleanupControllerLine]$controllerLine = 'TYPE_LOAD' -$cleanupTreeZeroVerified = $false - -function Write-FixedResult([ValidateSet('COMPLETE','FAILED','TIMED_OUT')][string]$Result) { - [Console]::Out.WriteLine("PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:$Result") - [Console]::Out.WriteLine( - 'PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:STATUS:{0}:EXIT_CODE:{1}' -f ` - $script:fixedStatus, $script:fixedExitCode) - [Console]::Out.Flush() -} - -function Set-CaughtControllerFailure($ErrorRecord) { - $phases = @( - 'INITIALIZATION','PARAMETER_VALIDATION','PATH_VALIDATION','PROCESS_START', - 'PROCESS_WAIT','PROCESS_FINALIZATION','STREAM_FINALIZATION', - 'RESOURCE_FINALIZATION','AUTHORITY_FINALIZATION','RESULT_EMISSION' - ) - $lines = @( - 'TYPE_LOAD','PARAMETERS','PATHS','START','WAIT','TERMINATE','DRAIN', - 'DISPOSE','AUTHORITY','EMIT' - ) - $categories = @{ - AuthenticationError = 'AUTHENTICATION' - CloseError = 'CLOSE' - InvalidArgument = 'INVALID_ARGUMENT' - InvalidData = 'INVALID_DATA' - InvalidOperation = 'INVALID_OPERATION' - LimitsExceeded = 'LIMIT' - NotEnabled = 'NOT_ENABLED' - ObjectNotFound = 'NOT_FOUND' - OpenError = 'OPEN' - OperationStopped = 'STOPPED' - PermissionDenied = 'PERMISSION' - ReadError = 'READ' - ResourceBusy = 'BUSY' - ResourceUnavailable = 'UNAVAILABLE' - SecurityError = 'SECURITY' - WriteError = 'WRITE' - } - $phase = if ($phases -ccontains [string]$script:controllerPhase) { - [string]$script:controllerPhase - } else { 'INITIALIZATION' } - $line = if ($lines -ccontains [string]$script:controllerLine) { - [string]$script:controllerLine - } else { 'TYPE_LOAD' } - $categoryName = [string]$ErrorRecord.CategoryInfo.Category - $category = if ($categories.ContainsKey($categoryName)) { - $categories[$categoryName] - } else { 'UNCLASSIFIED' } - $script:fixedResult = 'FAILED' - $script:fixedStatus = 'CONTROLLER_{0}_{1}_{2}' -f $phase, $line, $category - $script:fixedExitCode = 125 -} - -$invokeController = { -Add-Type -TypeDefinition @' -using System; -using System.ComponentModel; -using System.Diagnostics; -using System.IO; -using System.Runtime.InteropServices; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Win32.SafeHandles; - -public sealed class ProPRWorkflowCleanupJob : IDisposable -{ - [StructLayout(LayoutKind.Sequential)] - private struct JOBOBJECT_BASIC_LIMIT_INFORMATION - { - public long PerProcessUserTimeLimit; - public long PerJobUserTimeLimit; - public uint LimitFlags; - public UIntPtr MinimumWorkingSetSize; - public UIntPtr MaximumWorkingSetSize; - public uint ActiveProcessLimit; - public UIntPtr Affinity; - public uint PriorityClass; - public uint SchedulingClass; - } - - [StructLayout(LayoutKind.Sequential)] - private struct IO_COUNTERS - { - public ulong ReadOperationCount; - public ulong WriteOperationCount; - public ulong OtherOperationCount; - public ulong ReadTransferCount; - public ulong WriteTransferCount; - public ulong OtherTransferCount; - } - - [StructLayout(LayoutKind.Sequential)] - private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION - { - public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation; - public IO_COUNTERS IoInfo; - public UIntPtr ProcessMemoryLimit; - public UIntPtr JobMemoryLimit; - public UIntPtr PeakProcessMemoryUsed; - public UIntPtr PeakJobMemoryUsed; - } - - private const int JobObjectExtendedLimitInformation = 9; - private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000; - private SafeFileHandle handle; - - [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - private static extern SafeFileHandle CreateJobObject(IntPtr attributes, string name); - - [DllImport("kernel32.dll", SetLastError = true)] - private static extern bool SetInformationJobObject( - SafeFileHandle job, int informationClass, IntPtr information, uint informationLength); - - [DllImport("kernel32.dll", SetLastError = true)] - private static extern bool AssignProcessToJobObject(SafeFileHandle job, IntPtr process); - - [DllImport("kernel32.dll", SetLastError = true)] - private static extern bool TerminateJobObject(SafeFileHandle job, uint exitCode); - - [StructLayout(LayoutKind.Sequential)] - private struct JOBOBJECT_BASIC_ACCOUNTING_INFORMATION - { - public long TotalUserTime; - public long TotalKernelTime; - public long ThisPeriodTotalUserTime; - public long ThisPeriodTotalKernelTime; - public uint TotalPageFaultCount; - public uint TotalProcesses; - public uint ActiveProcesses; - public uint TotalTerminatedProcesses; - } - - [DllImport("kernel32.dll", SetLastError = true)] - private static extern bool QueryInformationJobObject( - SafeFileHandle job, int informationClass, - out JOBOBJECT_BASIC_ACCOUNTING_INFORMATION information, - uint informationLength, IntPtr returnLength); - - public ProPRWorkflowCleanupJob() - { - handle = CreateJobObject(IntPtr.Zero, null); - if (handle == null || handle.IsInvalid) - throw new Win32Exception(Marshal.GetLastWin32Error(), "job creation failed"); - var limits = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION(); - limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; - int size = Marshal.SizeOf(typeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION)); - IntPtr buffer = Marshal.AllocHGlobal(size); - try - { - Marshal.StructureToPtr(limits, buffer, false); - if (!SetInformationJobObject(handle, JobObjectExtendedLimitInformation, buffer, (uint)size)) - throw new Win32Exception(Marshal.GetLastWin32Error(), "job configuration failed"); - } - finally { Marshal.FreeHGlobal(buffer); } - } - - public void AddProcess(IntPtr processHandle) - { - if (!AssignProcessToJobObject(handle, processHandle)) - throw new Win32Exception(Marshal.GetLastWin32Error(), "cleanup ownership failed"); - } - - private uint ReadActiveProcessCount() - { - if (handle == null || handle.IsInvalid) - throw new InvalidOperationException("job handle is unavailable"); - JOBOBJECT_BASIC_ACCOUNTING_INFORMATION information; - uint size = (uint)Marshal.SizeOf(typeof(JOBOBJECT_BASIC_ACCOUNTING_INFORMATION)); - if (!QueryInformationJobObject(handle, 1, out information, size, IntPtr.Zero)) - throw new Win32Exception(Marshal.GetLastWin32Error(), "cleanup accounting failed"); - return information.ActiveProcesses; - } - - public bool WaitForNoActiveProcesses(int timeoutMilliseconds) - { - var stopwatch = Stopwatch.StartNew(); - do - { - if (ReadActiveProcessCount() == 0) return true; - Thread.Sleep(25); - } - while (stopwatch.ElapsedMilliseconds < timeoutMilliseconds); - return ReadActiveProcessCount() == 0; - } - - public bool HasNoActiveProcesses() - { - return ReadActiveProcessCount() == 0; - } - - public bool TerminateAndWait(uint exitCode, int timeoutMilliseconds) - { - if (handle == null || handle.IsInvalid) - throw new InvalidOperationException("job handle is unavailable"); - if (!TerminateJobObject(handle, exitCode)) - throw new Win32Exception(Marshal.GetLastWin32Error(), "cleanup termination failed"); - return WaitForNoActiveProcesses(timeoutMilliseconds); - } - - public void Dispose() { if (handle != null) handle.Dispose(); } -} - -public sealed class ProPRWorkflowCleanupDrainResult -{ - public long StandardOutputCharacters; - public long StandardErrorCharacters; -} - -public sealed class ProPRWorkflowCleanupOutputDrain : IDisposable -{ - private const long CharacterLimit = 4096; - private readonly CancellationTokenSource cancellation = new CancellationTokenSource(); - private StreamReader standardOutputReader; - private StreamReader standardErrorReader; - private Task standardOutputTask; - private Task standardErrorTask; +$bodyPath = Join-Path $PSScriptRoot 'run-installed-windows-app-workflow-cleanup-body.ps1' - private static async Task Pump(StreamReader reader, CancellationToken token) - { - var buffer = new char[1024]; - long characters = 0; - while (true) - { - int count = await reader.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false); - if (count == 0) return characters; - token.ThrowIfCancellationRequested(); - characters = Math.Min(CharacterLimit + 1, characters + count); - } - } - - public void Start(Process process) - { - if (standardOutputTask != null || standardErrorTask != null) - throw new InvalidOperationException("stream drain was already started"); - standardOutputReader = process.StandardOutput; - standardErrorReader = process.StandardError; - standardOutputTask = Pump(standardOutputReader, cancellation.Token); - standardErrorTask = Pump(standardErrorReader, cancellation.Token); - } - - public ProPRWorkflowCleanupDrainResult Finish(int timeoutMilliseconds) - { - if (standardOutputTask == null || standardErrorTask == null) - throw new InvalidOperationException("stream drain was not started"); - Task all = Task.WhenAll(standardOutputTask, standardErrorTask); - if (!all.Wait(timeoutMilliseconds)) return null; - if (standardOutputTask.IsFaulted || standardOutputTask.IsCanceled || - standardErrorTask.IsFaulted || standardErrorTask.IsCanceled) - throw new InvalidOperationException("stream drain failed"); - return new ProPRWorkflowCleanupDrainResult { - StandardOutputCharacters = standardOutputTask.Result, - StandardErrorCharacters = standardErrorTask.Result - }; - } - - public bool CancelAndFinish(int timeoutMilliseconds) - { - cancellation.Cancel(); - try { if (standardOutputReader != null) standardOutputReader.Dispose(); } catch { } - try { if (standardErrorReader != null) standardErrorReader.Dispose(); } catch { } - if (standardOutputTask == null || standardErrorTask == null) return true; - try { Task.WhenAll(standardOutputTask, standardErrorTask).Wait(timeoutMilliseconds); } - catch { } - return standardOutputTask.IsCompleted && standardErrorTask.IsCompleted; +function Get-StartupFailureClass($ErrorRecord) { + $exception = $ErrorRecord.Exception + while ($null -ne $exception) { + if ($exception -is [Management.Automation.ParseException]) { return 'PARSER' } + if ($exception -is [Management.Automation.ParameterBindingException]) { + return 'PARAMETER_BINDING' } - - public void Dispose() - { - CancelAndFinish(1000); - cancellation.Dispose(); - } -} -'@ - -$controllerPhase = 'PARAMETER_VALIDATION' -$controllerLine = 'PARAMETERS' -$cleanupTimeout = 0 -$terminationTimeout = 0 -if ([string]::IsNullOrWhiteSpace([string]$OwnershipManifest) -or - [string]::IsNullOrWhiteSpace([string]$Installer) -or - [string]::IsNullOrWhiteSpace([string]$ExpectedRunId) -or - ![int]::TryParse( - [string]$CleanupTimeoutMilliseconds, - [Globalization.NumberStyles]::None, - [Globalization.CultureInfo]::InvariantCulture, - [ref]$cleanupTimeout - ) -or $cleanupTimeout -lt 1 -or $cleanupTimeout -gt 600000 -or - ![int]::TryParse( - [string]$TerminationTimeoutMilliseconds, - [Globalization.NumberStyles]::None, - [Globalization.CultureInfo]::InvariantCulture, - [ref]$terminationTimeout - ) -or $terminationTimeout -lt 1 -or $terminationTimeout -gt 30000) { - throw 'workflow cleanup controller parameters are invalid' -} -$OwnershipManifest = [string]$OwnershipManifest -$Installer = [string]$Installer -$ExpectedRunId = [string]$ExpectedRunId -$FixtureRoot = if ($null -eq $FixtureRoot) { $null } else { [string]$FixtureRoot } -$CleanupTimeoutMilliseconds = $cleanupTimeout -$TerminationTimeoutMilliseconds = $terminationTimeout - - $controllerPhase = 'PATH_VALIDATION' - $controllerLine = 'PATHS' - if ($ExpectedRunId -notmatch '^[a-f0-9]{32}$') { throw 'cleanup run identity is invalid' } - $manifestPath = [IO.Path]::GetFullPath($OwnershipManifest) - $tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') - if ((Split-Path -Leaf $manifestPath) -cne - "propr-installed-app-ownership-$ExpectedRunId.json" -or - ![string]::Equals( - (Split-Path -Parent $manifestPath).TrimEnd('\'), - $tempRoot, - [StringComparison]::OrdinalIgnoreCase - )) { - throw 'cleanup manifest path is invalid' - } - $validatedManifestPath = $manifestPath - $installerPath = (Resolve-Path -LiteralPath $Installer -ErrorAction Stop).Path - $cleanupWorkerPath = (Resolve-Path -LiteralPath - (Join-Path $PSScriptRoot 'cleanup-installed-windows-app.ps1') -ErrorAction Stop).Path - $hostPath = (Get-Process -Id $PID -ErrorAction Stop).Path - if ([IO.Path]::GetFileName($hostPath) -notin @('pwsh.exe', 'powershell.exe')) { - throw 'PowerShell host resolution failed' - } - $cleanupReadyEventName = "Local\ProPRInstalledAppCleanup-$([Guid]::NewGuid().ToString('N'))" - $cleanupReadyEvent = [Threading.EventWaitHandle]::new( - $false, - [Threading.EventResetMode]::ManualReset, - $cleanupReadyEventName - ) - $startInfo = [Diagnostics.ProcessStartInfo]::new() - $startInfo.FileName = $hostPath - $startInfo.UseShellExecute = $false - $startInfo.CreateNoWindow = $true - $startInfo.RedirectStandardOutput = $true - $startInfo.RedirectStandardError = $true - foreach ($argument in @( - '-NoLogo', '-NoProfile', '-NonInteractive', '-File', $cleanupWorkerPath, - '-OwnershipManifest', $manifestPath, - '-Installer', $installerPath, - '-ExpectedRunId', $ExpectedRunId, - '-OwnershipReadyEvent', $cleanupReadyEventName - )) { - $startInfo.ArgumentList.Add($argument) - } - if ($FixtureRoot) { - $startInfo.ArgumentList.Add('-FixtureRoot') - $startInfo.ArgumentList.Add((Resolve-Path -LiteralPath $FixtureRoot -ErrorAction Stop).Path) - } - if ($FixtureEarlyInitializationChild) { - if (!$FixtureRoot) { throw 'early initialization fixture requires a fixture scope' } - $startInfo.ArgumentList.Add('-FixtureEarlyInitializationChild') - } - $cleanupJob = [ProPRWorkflowCleanupJob]::new() - $controllerPhase = 'PROCESS_START' - $controllerLine = 'START' - $cleanupProcess = [Diagnostics.Process]::new() - $cleanupProcess.StartInfo = $startInfo - if (!$cleanupProcess.Start()) { throw 'workflow cleanup did not start' } - try { - $cleanupJob.AddProcess($cleanupProcess.Handle) - $outputDrain = [ProPRWorkflowCleanupOutputDrain]::new() - $outputDrain.Start($cleanupProcess) - [void]$cleanupReadyEvent.Set() - } catch { - try { - $cleanupTreeZeroVerified = $cleanupJob.TerminateAndWait( - 125, $TerminationTimeoutMilliseconds) - } catch {} - try { - if (!$cleanupProcess.HasExited) { - $cleanupProcess.Kill($true) - [void]$cleanupProcess.WaitForExit($TerminationTimeoutMilliseconds) - } - } catch {} - throw 'workflow cleanup ownership failed' - } - $controllerPhase = 'PROCESS_WAIT' - $controllerLine = 'WAIT' - if (!$cleanupProcess.WaitForExit($CleanupTimeoutMilliseconds)) { - $controllerLine = 'TERMINATE' - $terminationVerified = $false - try { - $terminationVerified = $cleanupJob.TerminateAndWait( - 125, $TerminationTimeoutMilliseconds) - } catch {} - if ($terminationVerified) { - $cleanupTreeZeroVerified = $true - $fixedResult = 'TIMED_OUT' - $fixedStatus = 'TIMEOUT' - $fixedExitCode = 124 - } else { - $fixedResult = 'FAILED' - $fixedStatus = 'TERMINATION_FAILURE' - $fixedExitCode = 125 - } - } else { - $cleanupTreeZeroVerified = $cleanupJob.HasNoActiveProcesses() - if (!$cleanupTreeZeroVerified) { - try { - $cleanupTreeZeroVerified = $cleanupJob.TerminateAndWait( - 125, $TerminationTimeoutMilliseconds) - } catch {} - $fixedResult = 'FAILED' - $fixedStatus = 'ACTIVE_PROCESS_AFTER_ROOT_EXIT' - $fixedExitCode = 125 - } elseif ($cleanupProcess.ExitCode -eq 0) { - $fixedResult = 'COMPLETE' - $fixedStatus = 'EMPTY_OR_CLEANED' - $fixedExitCode = 0 - } elseif ($cleanupProcess.ExitCode -eq 20) { - $fixedStatus = 'MANIFEST_VALIDATION_FAILURE' - $fixedExitCode = 20 - } elseif ($cleanupProcess.ExitCode -eq 21) { - $fixedStatus = 'OWNED_RESOURCE_CLEANUP_FAILURE' - $fixedExitCode = 21 + if ($exception -is [TypeLoadException] -or + $exception -is [TypeInitializationException] -or + $exception -is [IO.FileLoadException]) { + return 'TYPE_LOAD' } + $exception = $exception.InnerException } + return 'OTHER' } -# Keep the top-level launcher syntactically small and stable. Dot-sourcing the -# body preserves script scope while the catch consumes type-load and body errors -# without allowing the host to render raw diagnostics. -try { - . $invokeController -} catch { - Set-CaughtControllerFailure $_ +function Write-StartupFailure($ErrorRecord) { + $failureClass = Get-StartupFailureClass $ErrorRecord + $line = 0 + try { + $candidateLine = [int64]$ErrorRecord.InvocationInfo.ScriptLineNumber + if ($candidateLine -ge 0 -and $candidateLine -le 999999) { $line = $candidateLine } + } catch {} + [Console]::Out.WriteLine('PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:FAILED') + [Console]::Out.WriteLine( + ('PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:STATUS:STARTUP_FAILURE:' + + 'EXIT_CODE:125:STARTUP_CLASS:{0}:PROCESS_EXIT:125:LINE:{1}') -f ` + $failureClass, $line) + [Console]::Out.Flush() } try { - $controllerPhase = 'PROCESS_FINALIZATION' - $controllerLine = 'TERMINATE' - if ($null -ne $cleanupJob -and !$cleanupTreeZeroVerified) { - $cleanupTreeZeroVerified = $cleanupJob.TerminateAndWait( - 125, $TerminationTimeoutMilliseconds) - if (!$cleanupTreeZeroVerified) { - $fixedResult = 'FAILED' - $fixedStatus = 'PROCESS_FINALIZATION_TIMEOUT' - $fixedExitCode = 125 + if ($null -ne $StartupFailureClass) { + switch ([string]$StartupFailureClass) { + 'PARSER' { [void][scriptblock]::Create('{') } + 'PARAMETER_BINDING' { + function Invoke-StartupBindingProbe { + param([Parameter(Mandatory=$true)][int]$Value) + } + Invoke-StartupBindingProbe -Value ([object]::new()) + } + 'TYPE_LOAD' { throw [TypeLoadException]::new('startup type-load fixture') } + 'OTHER' { throw [InvalidOperationException]::new('startup other fixture') } + default { throw [InvalidOperationException]::new('startup fixture class is invalid') } } } -} catch { - $fixedResult = 'FAILED' - $fixedStatus = 'PROCESS_FINALIZATION_FAILURE' - $fixedExitCode = 125 -} - -try { - $controllerPhase = 'STREAM_FINALIZATION' - $controllerLine = 'DRAIN' - if ($null -ne $outputDrain) { - $drainResult = $outputDrain.Finish($TerminationTimeoutMilliseconds) - if ($null -eq $drainResult) { - [void]$outputDrain.CancelAndFinish($TerminationTimeoutMilliseconds) - $fixedResult = 'FAILED' - $fixedStatus = 'STREAM_DRAIN_TIMEOUT' - $fixedExitCode = 125 - } elseif ($drainResult.StandardErrorCharacters -ne 0) { - $fixedResult = 'FAILED' - $fixedStatus = if ($drainResult.StandardErrorCharacters -gt 4096) { - 'CHILD_STDERR_LIMIT' - } else { 'CHILD_STDERR' } - $fixedExitCode = 123 - } elseif ($drainResult.StandardOutputCharacters -ne 0) { - $fixedResult = 'FAILED' - $fixedStatus = if ($drainResult.StandardOutputCharacters -gt 4096) { - 'CHILD_STDOUT_LIMIT' - } else { 'CHILD_STDOUT' } - $fixedExitCode = 122 - } + $bodyParameters = @{ + OwnershipManifest = $OwnershipManifest + Installer = $Installer + ExpectedRunId = $ExpectedRunId + CleanupTimeoutMilliseconds = $CleanupTimeoutMilliseconds + TerminationTimeoutMilliseconds = $TerminationTimeoutMilliseconds + FixtureRoot = $FixtureRoot } -} catch { - $fixedResult = 'FAILED' - $fixedStatus = 'STREAM_DRAIN_FAILURE' - $fixedExitCode = 125 -} - -$controllerPhase = 'RESOURCE_FINALIZATION' -$controllerLine = 'DISPOSE' -foreach ($resource in @($outputDrain, $cleanupJob, $cleanupProcess, $cleanupReadyEvent)) { - if ($null -eq $resource) { continue } - try { $resource.Dispose() } catch { - $fixedResult = 'FAILED' - $fixedStatus = 'RESOURCE_FINALIZATION_FAILURE' - $fixedExitCode = 125 + if ([bool]$FixtureEarlyInitializationChild) { + $bodyParameters.FixtureEarlyInitializationChild = $true } -} - -if ($fixedResult -ceq 'COMPLETE' -and $cleanupTreeZeroVerified -and - $validatedManifestPath) { - try { - $controllerPhase = 'AUTHORITY_FINALIZATION' - $controllerLine = 'AUTHORITY' - foreach ($path in @("$validatedManifestPath.new", $validatedManifestPath)) { - if ([IO.File]::Exists($path)) { [IO.File]::Delete($path) } - } - } catch { - $fixedResult = 'FAILED' - $fixedStatus = 'AUTHORITY_FINALIZATION_FAILURE' - $fixedExitCode = 125 + $LASTEXITCODE = $null + & $bodyPath @bodyParameters + $bodyExitCode = 0 + if ($null -eq $LASTEXITCODE -or + ![int]::TryParse( + [string]$LASTEXITCODE, + [Globalization.NumberStyles]::AllowLeadingSign, + [Globalization.CultureInfo]::InvariantCulture, + [ref]$bodyExitCode + ) -or $bodyExitCode -notin @(0,20,21,122,123,124,125)) { + throw [InvalidOperationException]::new('workflow cleanup body returned without a fixed exit') } -} - -try { - $controllerPhase = 'RESULT_EMISSION' - $controllerLine = 'EMIT' - Write-FixedResult $fixedResult + exit $bodyExitCode } catch { - Set-CaughtControllerFailure $_ + Write-StartupFailure $_ exit 125 } - -exit $fixedExitCode 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 f5657c998..1f79cf68e 100644 --- a/apps/desktop/scripts/test-installed-windows-app-supervisor-fixture.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app-supervisor-fixture.ps1 @@ -254,7 +254,11 @@ function New-OwnedFixtureResources( Initialize-FixtureDirectoryIdentity $manifest = [IO.File]::ReadAllText($OwnershipManifest, [Text.Encoding]::UTF8) | ConvertFrom-Json -ErrorAction Stop - if (!$manifest.Fixture -or $manifest.SchemaVersion -ne 2 -or + if (!$manifest.Fixture -or $manifest.SchemaVersion -ne 3 -or + [string]$manifest.InstallerEntryIdentity -notmatch '^[a-f0-9]{24}$' -or + [string]$manifest.InstallerSha256 -notmatch '^[a-f0-9]{64}$' -or + [string]$manifest.InstallerProductCode -notmatch + '^\{[A-F0-9]{8}(?:-[A-F0-9]{4}){3}-[A-F0-9]{12}\}$' -or $manifest.ManifestType -cne 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP' -or $manifest.State -cne 'ACTIVE') { throw 'fixture ownership manifest was not initialized' @@ -510,7 +514,11 @@ function New-SmokeCheckpointFixtureResources( Initialize-FixtureDirectoryIdentity $manifest = [IO.File]::ReadAllText($OwnershipManifest, [Text.Encoding]::UTF8) | ConvertFrom-Json -ErrorAction Stop - if (!$manifest.Fixture -or $manifest.SchemaVersion -ne 2 -or + if (!$manifest.Fixture -or $manifest.SchemaVersion -ne 3 -or + [string]$manifest.InstallerEntryIdentity -notmatch '^[a-f0-9]{24}$' -or + [string]$manifest.InstallerSha256 -notmatch '^[a-f0-9]{64}$' -or + [string]$manifest.InstallerProductCode -notmatch + '^\{[A-F0-9]{8}(?:-[A-F0-9]{4}){3}-[A-F0-9]{12}\}$' -or $manifest.State -cne 'ACTIVE') { throw 'smoke checkpoint manifest was not initialized' } diff --git a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 index d51ab3d79..53ddcd9e3 100644 --- a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 @@ -20,6 +20,9 @@ $conflictingFixtureProfilePath = $null $conflictingFixtureDirectories = $null $conflictingFixtureShortcut = $null $conflictingFixtureRegistryPath = $null +$dummyInstallerProductCode = ('{' + [Guid]::NewGuid().ToString().ToUpperInvariant() + '}') +$dummyInstallerEntryIdentity = $null +$dummyInstallerSha256 = $null function Assert-True([bool]$Condition, [string]$Message) { if (!$Condition) { throw $Message } @@ -60,6 +63,82 @@ function Write-TestOwnershipManifest([string]$Path, $Manifest) { [IO.File]::Move($temporaryPath, $Path, $true) } +function Initialize-TestInstaller { + $installerCom = $null + $database = $null + $view = $null + try { + $installerCom = New-Object -ComObject WindowsInstaller.Installer + $database = $installerCom.OpenDatabase($dummyInstaller, 3) + $view = $database.OpenView( + 'CREATE TABLE `Property` (`Property` CHAR(72) NOT NULL, ' + + '`Value` CHAR(0) LOCALIZABLE PRIMARY KEY `Property`)') + $view.Execute() + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($view) + $view = $null + $view = $database.OpenView( + "INSERT INTO ``Property`` (``Property``, ``Value``) VALUES ('ProductCode', '$dummyInstallerProductCode')") + $view.Execute() + $database.Commit() + } finally { + foreach ($resource in @($view, $database, $installerCom)) { + if ($null -ne $resource -and [Runtime.InteropServices.Marshal]::IsComObject($resource)) { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($resource) + } + } + } + + if (-not ('ProPRSupervisorInstallerIdentity' -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; +public static class ProPRSupervisorInstallerIdentity +{ + [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", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFile(string path, uint access, uint share, + IntPtr security, uint creation, uint flags, IntPtr template); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetFileInformationByHandle( + SafeFileHandle handle, out BY_HANDLE_FILE_INFORMATION information); + public static string Read(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()); + BY_HANDLE_FILE_INFORMATION information; + if (!GetFileInformationByHandle(handle, out information)) + throw new Win32Exception(Marshal.GetLastWin32Error()); + return string.Format("{0:x8}{1:x8}{2:x8}", information.VolumeSerialNumber, + information.FileIndexHigh, information.FileIndexLow); + } + } +} +'@ + } + $script:dummyInstallerEntryIdentity = + [ProPRSupervisorInstallerIdentity]::Read($dummyInstaller) + $script:dummyInstallerSha256 = + (Get-FileHash -LiteralPath $dummyInstaller -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() +} + function New-SupervisorStartInfo( [string]$Scenario, [string]$StateDirectory, @@ -310,7 +389,8 @@ function Invoke-WorkflowCleanupController( [string]$RunId, [string]$FixtureRoot, [object]$CleanupTimeoutMilliseconds = 30000, - [bool]$FixtureEarlyInitializationChild = $false + [bool]$FixtureEarlyInitializationChild = $false, + [string]$StartupFailureClass = '' ) { $startInfo = [Diagnostics.ProcessStartInfo]::new() $startInfo.FileName = $hostPath @@ -334,6 +414,10 @@ function Invoke-WorkflowCleanupController( if ($FixtureEarlyInitializationChild) { $startInfo.ArgumentList.Add('-FixtureEarlyInitializationChild') } + if ($StartupFailureClass) { + $startInfo.ArgumentList.Add('-StartupFailureClass') + $startInfo.ArgumentList.Add($StartupFailureClass) + } $process = [Diagnostics.Process]::new() $process.StartInfo = $startInfo try { @@ -363,7 +447,10 @@ function Invoke-WorkflowCleanupController( $resultName = $resultMatch.Groups[1].Value $statusMatch = [regex]::Match( $outputLines[1], - '^PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:STATUS:([A-Z_]+):EXIT_CODE:([0-9]+)$' + ('^PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:STATUS:([A-Z_]+):' + + 'EXIT_CODE:([0-9]+)(?::STARTUP_CLASS:' + + '(PARSER|PARAMETER_BINDING|TYPE_LOAD|OTHER):PROCESS_EXIT:(-?[0-9]+):' + + 'LINE:([0-9]+))?$') ) if (!$statusMatch.Success) { $startupDiagnostic = Get-SanitizedControllerStartupDiagnostic ` @@ -386,6 +473,9 @@ function Invoke-WorkflowCleanupController( Result = $resultName ControllerStatus = $controllerStatus ReportedExitCode = $reportedExitCode + StartupClass = [string]$statusMatch.Groups[3].Value + StartupProcessExit = [string]$statusMatch.Groups[4].Value + StartupLine = [string]$statusMatch.Groups[5].Value Output = $output } } finally { @@ -394,6 +484,24 @@ function Invoke-WorkflowCleanupController( } } +function Test-WorkflowCleanupStartupProtocol { + foreach ($failureClass in @('PARSER','PARAMETER_BINDING','TYPE_LOAD','OTHER')) { + $result = Invoke-WorkflowCleanupController ` + $dummyInstaller $([Guid]::NewGuid().ToString('N')) $testRoot 30000 $false ` + $failureClass + Assert-True ($result.ExitCode -eq 125 -and + $result.ReportedExitCode -eq 125 -and + $result.Result -ceq 'FAILED' -and + $result.ControllerStatus -ceq 'STARTUP_FAILURE' -and + $result.StartupClass -ceq $failureClass -and + $result.StartupProcessExit -match '^-?[0-9]+$' -and + $result.StartupLine -match '^[0-9]+$') ` + "native $failureClass startup fixture did not emit the fixed two-line protocol" + } + Write-Host 'PROPR_WINDOWS_SUPERVISOR_CONTROLLER_STARTUP:FIXED_PROTOCOL:PASSED' + [Console]::Out.Flush() +} + function Start-ExternallyInterruptibleSupervisor([string]$StateDirectory) { $scriptText = @' param($SupervisorPath, $Installer, $Architecture, $FixtureWorker, $Scenario, @@ -1158,6 +1266,42 @@ function Test-PreExistingCleanupOwnership { Assert-True (Test-Path -LiteralPath $workflowManifest -PathType Leaf) ` 'timed-out workflow cleanup discarded authenticated recovery authority' + $installerBackup = Join-Path $testRoot 'fixture-owned-entry.msi' + Move-Item -LiteralPath $dummyInstaller -Destination $installerBackup -ErrorAction Stop + [IO.File]::WriteAllBytes($dummyInstaller, [Text.Encoding]::ASCII.GetBytes( + 'foreign same-path MSI replacement must never be consulted')) + $foreignInstallerDigest = + (Get-FileHash -LiteralPath $dummyInstaller -Algorithm SHA256).Hash + try { + $replacedInstallerCleanup = Invoke-WorkflowCleanupController ` + $workflowManifest $workflowRunId $workflowStateDirectory + Assert-True ($replacedInstallerCleanup.ExitCode -eq 21 -and + $replacedInstallerCleanup.ReportedExitCode -eq 21 -and + $replacedInstallerCleanup.Result -ceq 'FAILED' -and + $replacedInstallerCleanup.ControllerStatus -ceq + 'OWNED_RESOURCE_CLEANUP_FAILURE') ` + 'same-path installer replacement did not fail closed' + Assert-MsiPreflightPreservedResources $workflowOwned + $retainedAuthority = Get-Content -LiteralPath $workflowManifest -Raw -Encoding UTF8 | + ConvertFrom-Json -ErrorAction Stop + Assert-True ($retainedAuthority.State -ceq 'ACTIVE') ` + 'same-path installer replacement discarded ACTIVE recovery authority' + Assert-True ((Get-FileHash -LiteralPath $dummyInstaller -Algorithm SHA256).Hash -ceq + $foreignInstallerDigest) ` + 'foreign same-path installer was executed or changed' + } finally { + if (Test-Path -LiteralPath $dummyInstaller) { + Remove-Item -LiteralPath $dummyInstaller -Force -ErrorAction SilentlyContinue + } + Move-Item -LiteralPath $installerBackup -Destination $dummyInstaller -ErrorAction Stop + } + Assert-True ( + [ProPRSupervisorInstallerIdentity]::Read($dummyInstaller) -ceq + $dummyInstallerEntryIdentity -and + (Get-FileHash -LiteralPath $dummyInstaller -Algorithm SHA256).Hash.ToLowerInvariant() -ceq + $dummyInstallerSha256 + ) 'exact installer authority was not restored for cleanup retry' + Set-ItemProperty -LiteralPath $workflowOwned.RegistryPath ` -Name 'ProPRInstalledAppOwner' -Value 'foreign-owner' $failedWorkflowCleanup = Invoke-WorkflowCleanupController ` @@ -1212,9 +1356,12 @@ function Test-PreExistingCleanupOwnership { 'normal supervisor did not preserve its empty ownership receipt' $normalReceipt = Get-Content -LiteralPath $normalManifest -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop - Assert-True ($normalReceipt.SchemaVersion -eq 2 -and + Assert-True ($normalReceipt.SchemaVersion -eq 3 -and $normalReceipt.ManifestType -ceq 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP' -and $normalReceipt.State -ceq 'EMPTY' -and + $normalReceipt.InstallerEntryIdentity -ceq $dummyInstallerEntryIdentity -and + $normalReceipt.InstallerSha256 -ceq $dummyInstallerSha256 -and + $normalReceipt.InstallerProductCode -ceq $dummyInstallerProductCode -and @($normalReceipt.Directories).Count -eq 0 -and @($normalReceipt.Files).Count -eq 0 -and @($normalReceipt.RegistryKeys).Count -eq 0 -and @@ -1244,12 +1391,16 @@ function Test-PreExistingCleanupOwnership { } elseif ($manifestCase -eq 'STALE') { $createdTicks = [DateTime]::UtcNow.AddHours(-4).Ticks $staleManifest = [ordered]@{ - SchemaVersion = 2 + SchemaVersion = 3 ManifestType = 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP'; State = 'ACTIVE' RunId = $badRunId CreatedUtcTicks = $createdTicks ExpiresUtcTicks = $createdTicks + ([TimeSpan]::TicksPerHour * 3) - InstallerPath = $dummyInstaller; Fixture = $true + InstallerPath = $dummyInstaller + InstallerEntryIdentity = $dummyInstallerEntryIdentity + InstallerSha256 = $dummyInstallerSha256 + InstallerProductCode = $dummyInstallerProductCode + Fixture = $true FixtureRoot = $workflowStateDirectory; BaselineClean = $false InstallAttempted = $false; MsiTransactionState = 'NONE' Directories = @(); Files = @() @@ -1462,12 +1613,16 @@ function Test-PreExistingAppPathsAuthority { "propr-installed-app-ownership-$mismatchRunId.json" $createdTicks = [DateTime]::UtcNow.Ticks $mismatchState = [ordered]@{ - SchemaVersion = 2 + SchemaVersion = 3 ManifestType = 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP'; State = 'ACTIVE' RunId = $mismatchRunId CreatedUtcTicks = $createdTicks ExpiresUtcTicks = $createdTicks + ([TimeSpan]::TicksPerHour * 3) - InstallerPath = $dummyInstaller; Fixture = $false; FixtureRoot = $null + InstallerPath = $dummyInstaller + InstallerEntryIdentity = $dummyInstallerEntryIdentity + InstallerSha256 = $dummyInstallerSha256 + InstallerProductCode = $dummyInstallerProductCode + Fixture = $false; FixtureRoot = $null BaselineClean = $true; InstallAttempted = $true MsiTransactionState = 'COMMITTED' Directories = @(); Files = @(); Users = @(); Profiles = @() @@ -1552,13 +1707,16 @@ function Test-HkcuInstalledValueOwnership { $installedIdentityData = [Convert]::ToBase64String( [BitConverter]::GetBytes([int32]1)) $manifest = [ordered]@{ - SchemaVersion = 2 + SchemaVersion = 3 ManifestType = 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP' State = 'ACTIVE' RunId = $runId CreatedUtcTicks = $createdTicks ExpiresUtcTicks = $createdTicks + ([TimeSpan]::TicksPerHour * 3) InstallerPath = $dummyInstaller + InstallerEntryIdentity = $dummyInstallerEntryIdentity + InstallerSha256 = $dummyInstallerSha256 + InstallerProductCode = $dummyInstallerProductCode Fixture = $false FixtureRoot = $null BaselineClean = $InstallAttempted @@ -1700,13 +1858,16 @@ function Test-ProvisionalUserMarkerOwnership { "propr-installed-app-ownership-$runId.json" $createdTicks = [DateTime]::UtcNow.Ticks $manifest = [ordered]@{ - SchemaVersion = 2 + SchemaVersion = 3 ManifestType = 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP' State = 'ACTIVE' RunId = $runId CreatedUtcTicks = $createdTicks ExpiresUtcTicks = $createdTicks + ([TimeSpan]::TicksPerHour * 3) InstallerPath = $dummyInstaller + InstallerEntryIdentity = $dummyInstallerEntryIdentity + InstallerSha256 = $dummyInstallerSha256 + InstallerProductCode = $dummyInstallerProductCode Fixture = $true FixtureRoot = $testRoot BaselineClean = $false @@ -1793,8 +1954,9 @@ Assert-True ($actualArchitecture -ceq $Architecture) ` "supervisor behavior tests expected $Architecture but are running on $actualArchitecture" [void](New-Item -ItemType Directory -Path $testRoot -ErrorAction Stop) -[IO.File]::WriteAllBytes($dummyInstaller, [byte[]](0)) +Initialize-TestInstaller try { + Test-WorkflowCleanupStartupProtocol Test-BootstrapTimeout Test-OperationDeadlineAndTreeTermination Test-NegativeWorkerExitFinalization diff --git a/apps/desktop/scripts/test-installed-windows-app.ps1 b/apps/desktop/scripts/test-installed-windows-app.ps1 index 9ea2da312..557dda2d4 100644 --- a/apps/desktop/scripts/test-installed-windows-app.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app.ps1 @@ -92,6 +92,7 @@ $passwordText = $null $credential = New-Object Management.Automation.PSCredential("$env:COMPUTERNAME\$testUser", $password) $installAttempted = $false $msiInstallCompleted = $false +$installerArtifactAuthorityValid = $true $testUserCreatedByRun = $false $testUserSid = $null $smokeUserDataDirectory = $null @@ -220,7 +221,18 @@ try { $strictUtf8 = [Text.UTF8Encoding]::new($false, $true) $initialOwnershipState = ConvertFrom-Json ` -InputObject $strictUtf8.GetString($initialManifestBytes) -ErrorAction Stop -if ($initialOwnershipState.SchemaVersion -ne 2 -or +$initialManifestKeys = @($initialOwnershipState.PSObject.Properties | ForEach-Object { $_.Name }) +$expectedInitialManifestKeys = @( + 'SchemaVersion','ManifestType','State','RunId','CreatedUtcTicks','ExpiresUtcTicks', + 'InstallerPath','InstallerEntryIdentity','InstallerSha256','InstallerProductCode', + 'Fixture','FixtureRoot','BaselineClean','InstallAttempted','MsiTransactionState', + 'Directories','Files','RegistryKeys','RegistryValues','Users','Profiles' +) +if ($initialManifestKeys.Count -ne $expectedInitialManifestKeys.Count -or + @($expectedInitialManifestKeys | Where-Object { + $initialManifestKeys -cnotcontains $_ + }).Count -ne 0 -or + $initialOwnershipState.SchemaVersion -ne 3 -or [string]$initialOwnershipState.ManifestType -cne 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP' -or [string]$initialOwnershipState.State -cne 'ACTIVE' -or @@ -229,18 +241,38 @@ if ($initialOwnershipState.SchemaVersion -ne 2 -or [IO.Path]::GetFullPath([string]$initialOwnershipState.InstallerPath), $installerPath, [StringComparison]::OrdinalIgnoreCase - )) { + ) -or + [string]$initialOwnershipState.InstallerEntryIdentity -notmatch '^[a-f0-9]{24}$' -or + [string]$initialOwnershipState.InstallerSha256 -notmatch '^[a-f0-9]{64}$' -or + [string]$initialOwnershipState.InstallerProductCode -notmatch + '^\{[A-F0-9]{8}(?:-[A-F0-9]{4}){3}-[A-F0-9]{12}\}$' -or + $initialOwnershipState.Fixture -isnot [bool] -or $initialOwnershipState.Fixture -or + $null -ne $initialOwnershipState.FixtureRoot -or + $initialOwnershipState.BaselineClean -isnot [bool] -or + $initialOwnershipState.BaselineClean -or + $initialOwnershipState.InstallAttempted -isnot [bool] -or + $initialOwnershipState.InstallAttempted -or + [string]$initialOwnershipState.MsiTransactionState -cne 'NONE' -or + @($initialOwnershipState.Directories).Count -ne 0 -or + @($initialOwnershipState.Files).Count -ne 0 -or + @($initialOwnershipState.RegistryKeys).Count -ne 0 -or + @($initialOwnershipState.RegistryValues).Count -ne 0 -or + @($initialOwnershipState.Users).Count -ne 0 -or + @($initialOwnershipState.Profiles).Count -ne 0) { throw 'initial ownership manifest identity is invalid' } $ownershipToken = [Guid]::NewGuid().ToString('N') $ownershipState = [ordered]@{ - SchemaVersion = 2 + SchemaVersion = 3 ManifestType = 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP' State = 'ACTIVE' RunId = $ownershipRunId CreatedUtcTicks = [int64]$initialOwnershipState.CreatedUtcTicks ExpiresUtcTicks = [int64]$initialOwnershipState.ExpiresUtcTicks InstallerPath = $installerPath + InstallerEntryIdentity = [string]$initialOwnershipState.InstallerEntryIdentity + InstallerSha256 = [string]$initialOwnershipState.InstallerSha256 + InstallerProductCode = [string]$initialOwnershipState.InstallerProductCode Fixture = $false FixtureRoot = $null BaselineClean = $false @@ -600,38 +632,44 @@ function Get-RegistryValueSnapshot([string]$Path, [string]$Name) { } } -function Get-MsiProductCode([string]$Path) { - $installerCom = $null - $database = $null - $view = $null - $record = $null +function Get-InstallerSha256([string]$Path) { + $stream = [IO.File]::Open( + $Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) + $sha256 = [Security.Cryptography.SHA256]::Create() try { - $installerCom = New-Object -ComObject WindowsInstaller.Installer - $database = $installerCom.OpenDatabase($Path, 0) - $view = $database.OpenView( - "SELECT ``Value`` FROM ``Property`` WHERE ``Property`` = 'ProductCode'") - $view.Execute() - $record = $view.Fetch() - $productCode = if ($null -eq $record) { $null } else { [string]$record.StringData(1) } - if ($productCode -notmatch '^\{[A-Fa-f0-9]{8}(?:-[A-Fa-f0-9]{4}){3}-[A-Fa-f0-9]{12}\}$') { - throw 'MSI product identity is invalid' - } - return $productCode.ToUpperInvariant() + return [BitConverter]::ToString($sha256.ComputeHash($stream)).Replace('-', '').ToLowerInvariant() } finally { - foreach ($resource in @($record, $view, $database, $installerCom)) { - if ($null -ne $resource -and [Runtime.InteropServices.Marshal]::IsComObject($resource)) { - [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($resource) - } - } + $sha256.Dispose() + $stream.Dispose() + } +} + +function Assert-InstallerArtifactAuthority { + $matches = $false + try { + $matches = (Test-SamePath $installerPath ([string]$ownershipState.InstallerPath)) -and + [string]$ownershipState.InstallerEntryIdentity -match '^[a-f0-9]{24}$' -and + [string]$ownershipState.InstallerSha256 -match '^[a-f0-9]{64}$' -and + [string]$ownershipState.InstallerProductCode -match + '^\{[A-F0-9]{8}(?:-[A-F0-9]{4}){3}-[A-F0-9]{12}\}$' -and + (Get-FileSystemEntryIdentity $installerPath $false) -ceq + [string]$ownershipState.InstallerEntryIdentity -and + (Get-InstallerSha256 $installerPath) -ceq [string]$ownershipState.InstallerSha256 + } catch {} + if (!$matches) { + $script:installerArtifactAuthorityValid = $false + throw 'installer artifact no longer matches durable authority' } } -function Assert-MsiProductIsUnregistered([string]$Path) { +function Assert-MsiProductIsUnregistered([string]$ProductCode) { $installerCom = $null try { - $productCode = Get-MsiProductCode $Path + if ($ProductCode -notmatch '^\{[A-F0-9]{8}(?:-[A-F0-9]{4}){3}-[A-F0-9]{12}\}$') { + throw 'MSI product identity is invalid' + } $installerCom = New-Object -ComObject WindowsInstaller.Installer - if ([int]$installerCom.ProductState($productCode) -ne -1) { + if ([int]$installerCom.ProductState($ProductCode) -ne -1) { throw 'Windows Installer product registration is not at the clean baseline' } } finally { @@ -663,7 +701,8 @@ function Assert-ExactCleanMsiBaselineAfterRollback { if (!$valueMatches -or !$keyMatches) { throw 'Windows Installer rollback did not restore the exact current-user baseline' } - Assert-MsiProductIsUnregistered $installerPath + Assert-InstallerArtifactAuthority + Assert-MsiProductIsUnregistered ([string]$ownershipState.InstallerProductCode) } function Wait-ExactCleanMsiBaselineAfterRollback { @@ -879,7 +918,8 @@ try { $startMenuShortcutExistedBeforeInstall -or $startMenuShortcutFolderExistedBeforeInstall) { throw 'installed-app harness requires an unowned clean machine baseline' } - Assert-MsiProductIsUnregistered $installerPath + Assert-InstallerArtifactAuthority + Assert-MsiProductIsUnregistered ([string]$ownershipState.InstallerProductCode) $ownershipState.BaselineClean = $true Write-OwnershipManifest Write-WatchdogMarker 'INITIALIZATION' 'BASELINE' $externalOperationTimeoutMilliseconds 'COMPLETE' @@ -1750,6 +1790,7 @@ try { -Substage 'MSI_INSTALL' ` -TimeoutMilliseconds ($msiTimeoutMilliseconds + $terminationTimeoutMilliseconds + 5000) ` -Operation { + Assert-InstallerArtifactAuthority Invoke-Msi @('/i', "`"$installerPath`"", '/qn', '/norestart') 'machine install' $script:msiInstallCompleted = $true } @@ -2139,6 +2180,8 @@ try { } finally { $cleanupFailed = $false $profileCleanupFailed = $false + if ($installerArtifactAuthorityValid) { + Assert-InstallerArtifactAuthority if ($installAttempted -and [string]$ownershipState.MsiTransactionState -ceq 'COMMITTED') { Write-Stage 'UNINSTALL' 'BEGIN' @@ -2163,13 +2206,19 @@ try { if (!(Test-MsiInstalledValue $hkcuDesktopRegistryPath $hkcuInstalledValueName)) { throw 'refusing to uninstall over current-user metadata with mismatched ownership' } - Invoke-Msi @('/x', "`"$installerPath`"", '/qn', '/norestart') 'machine uninstall' + Assert-InstallerArtifactAuthority + Invoke-Msi @( + '/x', [string]$ownershipState.InstallerProductCode, '/qn', '/norestart' + ) 'machine uninstall' } Write-CleanupSubstage 'UNINSTALL' 'MSI_UNINSTALL' 'COMPLETE' } catch { Write-CleanupSubstage 'UNINSTALL' 'MSI_UNINSTALL' 'FAILED' $uninstallFailed = $true } + if (!$installerArtifactAuthorityValid) { + throw 'installer authority changed before uninstall; ACTIVE recovery authority retained' + } Write-CleanupSubstage 'UNINSTALL' 'INSTALL_TREE' 'BEGIN' try { @@ -2548,4 +2597,5 @@ try { Write-CleanupSubstage 'CLEANUP' 'FINAL_AGGREGATION' 'COMPLETE' Write-Stage 'CLEANUP' 'COMPLETE' } + } } diff --git a/apps/desktop/src/release-workflow.test.ts b/apps/desktop/src/release-workflow.test.ts index ae6bd9397..d1bc37c54 100644 --- a/apps/desktop/src/release-workflow.test.ts +++ b/apps/desktop/src/release-workflow.test.ts @@ -50,10 +50,14 @@ const installedWindowsAppCleanup = normalizeWorkflowText(readFileSync( fileURLToPath(new URL('../scripts/cleanup-installed-windows-app.ps1', import.meta.url)), 'utf8', )); -const installedWindowsAppWorkflowCleanup = normalizeWorkflowText(readFileSync( +const installedWindowsAppWorkflowCleanupWrapper = normalizeWorkflowText(readFileSync( fileURLToPath(new URL('../scripts/run-installed-windows-app-workflow-cleanup.ps1', import.meta.url)), 'utf8', )); +const installedWindowsAppWorkflowCleanup = normalizeWorkflowText(readFileSync( + fileURLToPath(new URL('../scripts/run-installed-windows-app-workflow-cleanup-body.ps1', import.meta.url)), + 'utf8', +)); const installedWindowsAppSupervisorBehaviorTest = normalizeWorkflowText(readFileSync( fileURLToPath(new URL('../scripts/test-installed-windows-app-supervisor.ps1', import.meta.url)), 'utf8', @@ -553,7 +557,7 @@ describe('desktop trusted release workflow', () => { assert.match( installedWindowsAppTest, - /\} catch \{\n\s+\$primaryFailure = \$_\n\s+throw\n\} finally \{\n\s+\$cleanupFailed = \$false[\s\S]*Invoke-Msi @\('\/x'[\s\S]*Remove-SmokeUserDataDirectory \$smokeOwnershipRecord/, + /\} catch \{\n\s+\$primaryFailure = \$_\n\s+throw\n\} finally \{\n\s+\$cleanupFailed = \$false[\s\S]*Assert-InstallerArtifactAuthority[\s\S]*Invoke-Msi @\([\s\S]*'\/x', \[string\]\$ownershipState\.InstallerProductCode[\s\S]*Remove-SmokeUserDataDirectory \$smokeOwnershipRecord/, ); assert.match(installedWindowsAppTest, /Get-CimInstance -ClassName Win32_UserProfile/); assert.match(installedWindowsAppTest, /Remove-LocalUser -Name \$testUser -ErrorAction Stop/); @@ -675,7 +679,7 @@ describe('desktop trusted release workflow', () => { assert.match(installedWindowsAppCleanup, /Assert-MsiManagedFileSystemAuthority/); assert.match( installedWindowsAppCleanup, - /Assert-MsiManagedFileSystemAuthority \$manifest\n\s+\$msi = Start-Process msiexec\.exe/, + /Assert-MsiManagedFileSystemAuthority \$manifest\n\s+Assert-InstallerArtifactAuthority \$manifest\n\s+\$msi = Start-Process msiexec\.exe/, ); assert.doesNotMatch(installedWindowsAppCleanup, /AllowProvisionalProductOwnership/); assert.doesNotMatch(installedWindowsAppCleanup, /allowProvisionalMsiUninstall/); @@ -694,7 +698,10 @@ describe('desktop trusted release workflow', () => { assert.match(installedWindowsAppTest, /MsiTransactionState = 'ROLLED_BACK_CLEAN'/); assert.match(installedWindowsAppTest, /MsiTransactionState = 'COMMITTED'/); assert.match(installedWindowsAppTest, /Assert-ExactCleanMsiBaselineAfterRollback/); - assert.match(installedWindowsAppTest, /Assert-MsiProductIsUnregistered \$installerPath/); + assert.match( + installedWindowsAppTest, + /Assert-MsiProductIsUnregistered \(\[string\]\$ownershipState\.InstallerProductCode\)/, + ); assert.match(installedWindowsAppCleanup, /Assert-MsiProductIsUnregistered/); assert.match(installedWindowsAppSupervisor, /Wait-MsiCriticalTransactionReceipt/); assert.match(installedWindowsAppSupervisorBehaviorTest, /DURING_MSI/); @@ -714,12 +721,41 @@ describe('desktop trusted release workflow', () => { assert.match(installedWindowsAppSupervisorBehaviorTest, /typed authenticated empty-state receipt/); assert.match( installedWindowsAppTest, - /Get-RegistryTreeIdentity \$appPathsRegistryPath[\s\S]*refusing to uninstall over executable metadata[\s\S]*Invoke-Msi @\('\/x'/, + /Get-RegistryTreeIdentity \$appPathsRegistryPath[\s\S]*refusing to uninstall over executable metadata[\s\S]*Invoke-Msi @\([\s\S]*'\/x', \[string\]\$ownershipState\.InstallerProductCode/, ); assert.match( installedWindowsAppTest, - /Assert-MsiManagedFileSystemAuthority[\s\S]*Invoke-Msi @\('\/x'/, + /Assert-MsiManagedFileSystemAuthority[\s\S]*Assert-InstallerArtifactAuthority[\s\S]*Invoke-Msi @\([\s\S]*'\/x', \[string\]\$ownershipState\.InstallerProductCode/, + ); + assert.match(installedWindowsAppSupervisor, /Get-InstallerAuthority \$Installer/); + assert.ok( + installedWindowsAppSupervisor.indexOf('Get-InstallerAuthority $Installer') + < installedWindowsAppSupervisor.indexOf('if (!$worker.Start())'), + 'installer authority must be captured before the worker starts', ); + for (const field of [ + 'InstallerEntryIdentity', 'InstallerSha256', 'InstallerProductCode', + ]) { + assert.match(installedWindowsAppSupervisor, new RegExp(field)); + assert.match(installedWindowsAppTest, new RegExp(field)); + assert.match(installedWindowsAppCleanup, new RegExp(field)); + } + assert.match(installedWindowsAppSupervisor, /SchemaVersion = 3/); + assert.match(installedWindowsAppTest, /SchemaVersion = 3/); + assert.match(installedWindowsAppCleanup, /SchemaVersion -ne 3/); + assert.doesNotMatch( + installedWindowsAppCleanup, + /Start-Process msiexec\.exe[\s\S]{0,180}`"\$resolvedInstaller`"/, + ); + assert.match( + installedWindowsAppCleanup, + /Start-Process msiexec\.exe -ArgumentList @\(\n\s+'\/x', \[string\]\$manifest\.InstallerProductCode/, + ); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /same-path installer replacement did not fail closed/, + ); + assert.match(installedWindowsAppSupervisorBehaviorTest, /ACTIVE recovery authority/); assert.match(installedWindowsAppTest, /TreeIdentity = \$script:installRootOwnedTreeIdentity/); assert.match(installedWindowsAppTest, /EntryIdentity = \$script:shortcutOwnedEntryIdentity/); assert.match(installedWindowsAppSupervisorBehaviorTest, /mismatched App Paths ownership identity did not fail closed/); @@ -760,7 +796,29 @@ describe('desktop trusted release workflow', () => { assert.doesNotMatch(installedWindowsAppWorkflowCleanup, /Write-Host/); assert.match( installedWindowsAppWorkflowCleanup, - /\$invokeController = \{\nAdd-Type -TypeDefinition @'[\s\S]*\$controllerPhase = 'PROCESS_WAIT'[\s\S]*\n\}\n\n#[^\n]+[\s\S]*try \{\n\s+\. \$invokeController\n\} catch \{\n\s+Set-CaughtControllerFailure \$_\n\}/, + /Add-Type -TypeDefinition @'[\s\S]*'@\n\ntry \{\n\$controllerPhase = 'PARAMETER_VALIDATION'[\s\S]*\$controllerPhase = 'PROCESS_WAIT'[\s\S]*\n\} catch \{\n\s+Set-CaughtControllerFailure \$_\n\}/, + ); + assert.doesNotMatch(installedWindowsAppWorkflowCleanup, /\$invokeController|StartupFailureClass/); + assert.match( + installedWindowsAppWorkflowCleanupWrapper, + /run-installed-windows-app-workflow-cleanup-body\.ps1/, + ); + assert.match( + installedWindowsAppWorkflowCleanupWrapper, + /\[object\]\$OwnershipManifest[\s\S]*\[object\]\$Installer[\s\S]*\[object\]\$ExpectedRunId/, + ); + assert.match( + installedWindowsAppWorkflowCleanupWrapper, + /'PARSER'[\s\S]*'PARAMETER_BINDING'[\s\S]*'TYPE_LOAD'[\s\S]*'OTHER'/, + ); + assert.match(installedWindowsAppWorkflowCleanupWrapper, /Write-StartupFailure \$_/); + assert.equal( + installedWindowsAppWorkflowCleanupWrapper.match(/\[Console\]::Out\.WriteLine/g)?.length, + 2, + ); + assert.doesNotMatch( + installedWindowsAppWorkflowCleanupWrapper, + /Console\]::SetError|Write-(?:Error|Host)|\btrap\b/, ); assert.match(installedWindowsAppWorkflowCleanup, /CancelAndFinish/); assert.doesNotMatch( From b2d2b5403faa0524900694f11f52dfdedda39fa0 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:06:06 +0000 Subject: [PATCH 16/29] =?UTF-8?q?feat(ai):=20Implemented=20the=20exact=20n?= =?UTF-8?q?ative=20protocol=20correction=20on=20`d0450fa=E2=80=A6`.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented the exact native protocol correction on `d0450fa…`. - Wrapped the complete `-f $failureClass, $line` expression so `Console.Out.WriteLine` receives one evaluated string. - Changed only [run-installed-windows-app-workflow-cleanup.ps1](/tmp/git-processor/worktrees/integry/propr/pr-2042-followup-2026-09-01T17-04-00/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1:39). - Preserved all F10–F24 implementation files and behavior. - `git diff --check`: passed. - Focused release workflow contract: 23/23 passed, including mandatory x64/ARM64 native supervisor gates. Native Windows execution was unavailable on this Linux host. No commit was created. PR: #2042 Comment by: @integry (ID: 5497532979) Model: gpt-5.6-sol --- .../scripts/run-installed-windows-app-workflow-cleanup.ps1 | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 b/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 index 203b0f2af..e96daa0e8 100644 --- a/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 +++ b/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 @@ -37,10 +37,11 @@ function Write-StartupFailure($ErrorRecord) { if ($candidateLine -ge 0 -and $candidateLine -le 999999) { $line = $candidateLine } } catch {} [Console]::Out.WriteLine('PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:FAILED') - [Console]::Out.WriteLine( + [Console]::Out.WriteLine(( ('PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:STATUS:STARTUP_FAILURE:' + 'EXIT_CODE:125:STARTUP_CLASS:{0}:PROCESS_EXIT:125:LINE:{1}') -f ` - $failureClass, $line) + $failureClass, $line + )) [Console]::Out.Flush() } From 06b8844df87f8d52bd5e7b1db2422cb9cb28194b Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:23:13 +0000 Subject: [PATCH 17/29] feat(ai): Implemented the bounded NO_MARKER correction. Implemented the bounded NO_MARKER correction. Key changes: - Added allowlisted diagnostics for supervisor exit, required marker presence/outcome, worker-tree termination, and cleanup exit category. - Authenticated initial schema-v3 ACTIVE/no-resource fixture manifests now revalidate installer authority and atomically become exact EMPTY receipts. - NO_MARKER now requires verified tree termination, cleanup exit `0`, cleanup `COMPLETE`, and preserved exit `124`. - F24 installer identity, digest, ProductCode, and ProductCode-only uninstall checks remain unchanged. Validation: - Desktop tests: 177 passed, 6 platform-specific skipped. - Desktop TypeScript typecheck passed. - Diff checks passed. - Native x64/ARM64 execution remains enforced by the existing CI matrix but is unavailable in this Linux environment. No commit was created. PR: #2042 Comment by: @integry (ID: 5497648125) Model: gpt-5.6-sol --- .../scripts/cleanup-installed-windows-app.ps1 | 20 +++++++ .../run-installed-windows-app-harness.ps1 | 25 +++++++++ .../test-installed-windows-app-supervisor.ps1 | 56 ++++++++++++++++--- apps/desktop/src/release-workflow.test.ts | 19 ++++++- 4 files changed, 112 insertions(+), 8 deletions(-) diff --git a/apps/desktop/scripts/cleanup-installed-windows-app.ps1 b/apps/desktop/scripts/cleanup-installed-windows-app.ps1 index b724c3619..a433d4259 100644 --- a/apps/desktop/scripts/cleanup-installed-windows-app.ps1 +++ b/apps/desktop/scripts/cleanup-installed-windows-app.ps1 @@ -1290,6 +1290,26 @@ try { throw 'fixture ownership manifest was not authorized' } + # A worker that is terminated before its first marker cannot promote any + # resource authority. Accept only the exact supervisor-created fixture state: + # authenticated schema-v3 ACTIVE authority, no baseline or install attempt, + # transaction NONE, and no resource records. Revalidate the durable installer + # authority before atomically converting it to the ordinary EMPTY receipt. + $initialActiveFixtureManifest = $manifest.Fixture -and + [string]$manifest.State -ceq 'ACTIVE' -and + !$manifest.BaselineClean -and !$manifest.InstallAttempted -and + [string]$manifest.MsiTransactionState -ceq 'NONE' -and + @($manifest.Directories).Count -eq 0 -and @($manifest.Files).Count -eq 0 -and + @($manifest.RegistryKeys).Count -eq 0 -and + @($manifest.RegistryValues).Count -eq 0 -and @($manifest.Users).Count -eq 0 -and + @($manifest.Profiles).Count -eq 0 + if ($initialActiveFixtureManifest) { + $manifestValidated = $true + Assert-InstallerArtifactAuthority $manifest + Write-EmptyOwnershipReceipt $manifestPath $manifest + exit 0 + } + if ([string]$manifest.State -ceq 'EMPTY') { if ($manifest.BaselineClean -or $manifest.InstallAttempted -or [string]$manifest.MsiTransactionState -cne 'NONE' -or diff --git a/apps/desktop/scripts/run-installed-windows-app-harness.ps1 b/apps/desktop/scripts/run-installed-windows-app-harness.ps1 index 7dde3ed09..3a7c09aea 100644 --- a/apps/desktop/scripts/run-installed-windows-app-harness.ps1 +++ b/apps/desktop/scripts/run-installed-windows-app-harness.ps1 @@ -77,6 +77,9 @@ $terminateOwnedTree = $false $workerStarted = $false $supervisorOutcomeComplete = $false $postTerminationCleanupAuthorized = $true +$fixtureNoMarkerDiagnostic = $false +$fixtureWorkerTreeTerminationOutcome = 'FAILED' +$fixtureCleanupChildExitCategory = 'OTHER' Add-Type -TypeDefinition @' using System; @@ -736,6 +739,10 @@ function Invoke-PostTerminationCleanup([string]$InstallerPath, [string]$Authoriz Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:TIMED_OUT' return $false } + $script:fixtureCleanupChildExitCategory = if ($cleanupProcess.ExitCode -in @(0,20,21)) { + ([int]$cleanupProcess.ExitCode).ToString( + [Globalization.CultureInfo]::InvariantCulture) + } else { 'OTHER' } if ($cleanupProcess.ExitCode -ne 0) { Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:FAILED' return $false @@ -784,6 +791,8 @@ try { if ($FixtureCleanupRoot) { if ($usingProductionWorker) { throw 'production worker cannot use a fixture cleanup scope' } $FixtureCleanupRoot = (Resolve-Path -LiteralPath $FixtureCleanupRoot -ErrorAction Stop).Path + $fixtureNoMarkerDiagnostic = + [string]$env:PROPR_SUPERVISOR_FIXTURE_SCENARIO -ceq 'NO_MARKER' } elseif (!$usingProductionWorker) { throw 'injected workers require a fixture cleanup scope' } @@ -952,6 +961,11 @@ try { # Job Object API requires a valid uint32, so finalization always uses this # fixed supervisor-owned termination code instead of casting worker status. $workerTreeTerminated = Stop-OwnedWorker 125 + if ($fixtureNoMarkerDiagnostic) { + $fixtureWorkerTreeTerminationOutcome = if ($workerTreeTerminated) { + 'COMPLETE' + } else { 'FAILED' } + } if ($workerTreeTerminated -and $postTerminationCleanupAuthorized) { $fixedCleanupResult = Invoke-PostTerminationCleanup $installerPath $FixtureCleanupRoot } else { @@ -961,6 +975,17 @@ try { if ($fixedCleanupResult -ne $true) { $exitCode = 125 } } + if ($fixtureNoMarkerDiagnostic) { + Write-WatchdogLine (( + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:FIXTURE_FINALIZATION:' + + 'WORKER_TREE_TERMINATION:{0}') -f $fixtureWorkerTreeTerminationOutcome) + if ($fixtureWorkerTreeTerminationOutcome -ceq 'COMPLETE') { + Write-WatchdogLine (( + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:FIXTURE_FINALIZATION:' + + 'CLEANUP_CHILD_EXIT:{0}') -f $fixtureCleanupChildExitCategory) + } + } + try { $finalMarker = Read-WatchdogMarker $markerPath $MarkerReadTimeoutMilliseconds if ($finalMarker.State -eq 'Valid' -and (Test-WatchdogMarkerSchema $finalMarker) -and diff --git a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 index 53ddcd9e3..7958992e4 100644 --- a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 @@ -247,18 +247,47 @@ function Assert-ProcessTreeGone($State) { } function Get-SanitizedSupervisorMarkerDiagnostic($Result) { - $lastValidPresent = [regex]::IsMatch( + $bootstrapTimedOutPresent = [regex]::IsMatch( [string]$Result.Output, - '(?m)^PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:LAST_VALID:(?:NONE|[A-Z_]+:[A-Z_]+:(?:BEGIN|COMPLETE|FAILED))\r?$' + '(?m)^PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:BOOTSTRAP:TIMED_OUT\r?$' ) - $postTerminationPresent = [regex]::IsMatch( + $lastValidNonePresent = [regex]::IsMatch( [string]$Result.Output, - '(?m)^PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:(?:COMPLETE|FAILED|TIMED_OUT)\r?$' + '(?m)^PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:LAST_VALID:NONE\r?$' ) + $postTerminationMatch = [regex]::Match( + [string]$Result.Output, + '(?m)^PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:' + + 'POST_TERMINATION_CLEANUP:(COMPLETE|FAILED|TIMED_OUT)\r?$' + ) + $postTerminationOutcome = if ($postTerminationMatch.Success) { + $postTerminationMatch.Groups[1].Value + } else { 'NONE' } + $workerTreeMatch = [regex]::Match( + [string]$Result.Output, + '(?m)^PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:FIXTURE_FINALIZATION:' + + 'WORKER_TREE_TERMINATION:(COMPLETE|FAILED)\r?$' + ) + $cleanupChildMatch = [regex]::Match( + [string]$Result.Output, + '(?m)^PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:FIXTURE_FINALIZATION:' + + 'CLEANUP_CHILD_EXIT:(0|20|21|OTHER)\r?$' + ) + $subphase = if ($workerTreeMatch.Success -and + $workerTreeMatch.Groups[1].Value -ceq 'FAILED') { + 'WORKER_TREE_TERMINATION' + } elseif ($cleanupChildMatch.Success) { + 'CLEANUP_CHILD_EXIT' + } else { 'NONE' } + $cleanupChildExit = if ($cleanupChildMatch.Success) { + $cleanupChildMatch.Groups[1].Value + } else { 'OTHER' } $signedExit = ([int]$Result.ExitCode).ToString( [Globalization.CultureInfo]::InvariantCulture) - return 'SUPERVISOR_EXIT:{0}:LAST_VALID:{1}:POST_TERMINATION:{2}' -f ` - $signedExit, ([int]$lastValidPresent), ([int]$postTerminationPresent) + return ('SUPERVISOR_EXIT:{0}:BOOTSTRAP_TIMED_OUT:{1}:LAST_VALID_NONE:{2}:' + + 'POST_TERMINATION_CLEANUP:{3}:SUBPHASE:{4}:CLEANUP_CHILD_EXIT:{5}') -f ` + $signedExit, ([int]$bootstrapTimedOutPresent), ([int]$lastValidNonePresent), + $postTerminationOutcome, $subphase, $cleanupChildExit } function Assert-OwnedResourcesGone($Owned) { @@ -675,7 +704,9 @@ function Test-MsiTransactionInterruptionGates { function Test-BootstrapTimeout { $result = Invoke-FixtureScenario 'NO_MARKER' - Assert-True ($result.ExitCode -eq 124) 'missing-marker bootstrap did not fail with the watchdog code' + $diagnostic = Get-SanitizedSupervisorMarkerDiagnostic $result + Assert-True ($result.ExitCode -eq 124) ` + "missing-marker bootstrap did not fail with the watchdog code:$diagnostic" Assert-True ($result.ElapsedMilliseconds -ge 9000) 'bootstrap timeout ignored the injected deadline' Assert-True ($result.ElapsedMilliseconds -lt 60000) 'missing-marker bootstrap completion was not bounded' Assert-Contains $result.Output ` @@ -684,6 +715,17 @@ function Test-BootstrapTimeout { Assert-Contains $result.Output ` 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:LAST_VALID:NONE' ` 'missing-marker bootstrap did not emit the fixed empty last-stage line' + Assert-Contains $result.Output ` + ('PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:FIXTURE_FINALIZATION:' + + 'WORKER_TREE_TERMINATION:COMPLETE') ` + 'missing-marker bootstrap did not verify worker-tree termination' + Assert-Contains $result.Output ` + ('PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:FIXTURE_FINALIZATION:' + + 'CLEANUP_CHILD_EXIT:0') ` + 'missing-marker bootstrap cleanup child did not consume the empty authority' + Assert-Contains $result.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:COMPLETE' ` + 'missing-marker bootstrap did not complete bounded cleanup' } function Test-OperationDeadlineAndTreeTermination { diff --git a/apps/desktop/src/release-workflow.test.ts b/apps/desktop/src/release-workflow.test.ts index d1bc37c54..2e02d50f8 100644 --- a/apps/desktop/src/release-workflow.test.ts +++ b/apps/desktop/src/release-workflow.test.ts @@ -900,7 +900,24 @@ describe('desktop trusted release workflow', () => { assert.match(installedWindowsAppSupervisorBehaviorTest, /Get-SanitizedSupervisorMarkerDiagnostic/); assert.match( installedWindowsAppSupervisorBehaviorTest, - /SUPERVISOR_EXIT:\{0\}:LAST_VALID:\{1\}:POST_TERMINATION:\{2\}/, + /SUPERVISOR_EXIT:\{0\}:BOOTSTRAP_TIMED_OUT:\{1\}:LAST_VALID_NONE:\{2\}/, + ); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /POST_TERMINATION_CLEANUP:\{3\}:SUBPHASE:\{4\}:CLEANUP_CHILD_EXIT:\{5\}/, + ); + assert.match( + installedWindowsAppSupervisor, + /FIXTURE_FINALIZATION:' \+\s*'WORKER_TREE_TERMINATION:\{0\}'\) -f/, + ); + assert.match( + installedWindowsAppSupervisor, + /FIXTURE_FINALIZATION:' \+\s*'CLEANUP_CHILD_EXIT:\{0\}'\) -f/, + ); + assert.match(installedWindowsAppCleanup, /\$initialActiveFixtureManifest/); + assert.match( + installedWindowsAppCleanup, + /Write-EmptyOwnershipReceipt \$manifestPath \$manifest/, ); const primaryFallbackFixture = installedWindowsAppSupervisorFixture.slice( installedWindowsAppSupervisorFixture.indexOf('function Test-PrimaryFallbackForeignDescendants'), From d4dfd384abd0dd81caa5f7b23f624c3ec571639c Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:38:46 +0000 Subject: [PATCH 18/29] =?UTF-8?q?feat(ai):=20Implemented=20the=20pre-branc?= =?UTF-8?q?h=20correction=20on=20`06b8844=E2=80=A6`.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented the pre-branch correction on `06b8844…`. Key changes: - Corrected manifest `FILE_AUTHORITY` validation using a single authenticated file handle compatible with atomic replacement, with entry-identity revalidation. - Added fixture-only exit-20 validation phases restricted to the requested enum. - Preserved installer entry/digest/ProductCode revalidation, exact EMPTY receipt conversion, watchdog exit 124, and production cleanup behavior. - Extended supervisor diagnostics and static contract coverage. Validation: - Desktop suite: 177 passed, 6 skipped. - Release-workflow tests: 23 passed. - Touched PowerShell scripts parse successfully. - `git diff --check` passed. Native x64/ARM64 execution remains CI-only in this Linux environment. No commit was created. PR: #2042 Comment by: @integry (ID: 5497806473) Model: gpt-5.6-sol --- .../scripts/cleanup-installed-windows-app.ps1 | 95 +++++++++++++++---- .../run-installed-windows-app-harness.ps1 | 3 + .../test-installed-windows-app-supervisor.ps1 | 14 ++- apps/desktop/src/release-workflow.test.ts | 20 ++++ 4 files changed, 110 insertions(+), 22 deletions(-) diff --git a/apps/desktop/scripts/cleanup-installed-windows-app.ps1 b/apps/desktop/scripts/cleanup-installed-windows-app.ps1 index a433d4259..f3040152a 100644 --- a/apps/desktop/scripts/cleanup-installed-windows-app.ps1 +++ b/apps/desktop/scripts/cleanup-installed-windows-app.ps1 @@ -4,6 +4,7 @@ param( [Parameter(Mandatory=$true)][string]$ExpectedRunId, [Parameter(Mandatory=$true)][string]$OwnershipReadyEvent, [string]$FixtureRoot, + [switch]$FixtureValidationDiagnostic, [switch]$FixtureEarlyInitializationChild ) @@ -14,20 +15,42 @@ $ownerRegistryValue = 'ProPRInstalledAppOwner' $cleanupFailed = $false $manifestValidated = $false $authorizedRunId = $null +$cleanupValidationPhase = 'HANDSHAKE' +$cleanupValidationPhases = @( + 'HANDSHAKE','FILE_AUTHORITY','UTF8_SCHEMA','LIFETIME','RUN_ID', + 'INSTALLER_PATH','FIXTURE_SCOPE','INITIAL_ACTIVE_MATCH' +) + +function Write-FixtureCleanupValidationPhase([string]$Phase) { + if (!$FixtureValidationDiagnostic -or !$FixtureRoot -or + $cleanupValidationPhases -cnotcontains $Phase) { + return + } + [Console]::Out.WriteLine( + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:CLEANUP_VALIDATION_PHASE:' + $Phase + ) + [Console]::Out.Flush() +} + +function Exit-CleanupHandshakeFailure { + Write-FixtureCleanupValidationPhase 'HANDSHAKE' + if ($FixtureValidationDiagnostic -and $FixtureRoot) { exit 20 } + exit 1 +} try { - if ($ExpectedRunId -notmatch '^[a-f0-9]{32}$') { exit 1 } + if ($ExpectedRunId -notmatch '^[a-f0-9]{32}$') { Exit-CleanupHandshakeFailure } if ($OwnershipReadyEvent -notmatch '^Local\\ProPRInstalledAppCleanup-[a-f0-9]{32}$') { - exit 1 + Exit-CleanupHandshakeFailure } $ownershipReady = [Threading.EventWaitHandle]::OpenExisting($OwnershipReadyEvent) try { - if (!$ownershipReady.WaitOne(5000)) { exit 1 } + if (!$ownershipReady.WaitOne(5000)) { Exit-CleanupHandshakeFailure } } finally { $ownershipReady.Dispose() } } catch { - exit 1 + Exit-CleanupHandshakeFailure } # This fixture runs after the ownership release but before cold type loading so @@ -110,6 +133,20 @@ public static class ProPRDirectoryIdentity private static extern bool GetFileInformationByHandle( SafeFileHandle handle, out BY_HANDLE_FILE_INFORMATION information); + public static string ReadHandle(SafeFileHandle handle, bool expectDirectory) + { + if (handle == null || handle.IsInvalid) + throw new InvalidOperationException("file-system identity handle is invalid"); + BY_HANDLE_FILE_INFORMATION information; + if (!GetFileInformationByHandle(handle, out information)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "directory identity read failed"); + bool isDirectory = (information.FileAttributes & 0x10) != 0; + if ((information.FileAttributes & 0x400) != 0 || isDirectory != expectDirectory) + throw new InvalidOperationException("file-system object identity changed"); + return string.Format("{0:x8}{1:x8}{2:x8}", information.VolumeSerialNumber, + information.FileIndexHigh, information.FileIndexLow); + } + public static string ReadEntry(string path, bool expectDirectory) { using (SafeFileHandle handle = CreateFile( @@ -117,14 +154,7 @@ public static class ProPRDirectoryIdentity { if (handle == null || handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error(), "directory identity open failed"); - BY_HANDLE_FILE_INFORMATION information; - if (!GetFileInformationByHandle(handle, out information)) - throw new Win32Exception(Marshal.GetLastWin32Error(), "directory identity read failed"); - bool isDirectory = (information.FileAttributes & 0x10) != 0; - if ((information.FileAttributes & 0x400) != 0 || isDirectory != expectDirectory) - throw new InvalidOperationException("file-system object identity changed"); - return string.Format("{0:x8}{1:x8}{2:x8}", information.VolumeSerialNumber, - information.FileIndexHigh, information.FileIndexLow); + return ReadHandle(handle, expectDirectory); } } @@ -1192,6 +1222,7 @@ function Remove-OwnedUser($Record) { } try { + $cleanupValidationPhase = 'FILE_AUTHORITY' $manifestPath = [IO.Path]::GetFullPath($OwnershipManifest) $tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') if ((Split-Path -Leaf $manifestPath) -notmatch @@ -1199,19 +1230,26 @@ try { !(Test-SamePath (Split-Path -Parent $manifestPath) $tempRoot)) { throw 'ownership manifest path is invalid' } - $manifestItem = Get-Item -LiteralPath $manifestPath -Force -ErrorAction Stop - if (($manifestItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or - $manifestItem.Length -le 0 -or $manifestItem.Length -gt 65536) { - throw 'ownership manifest metadata is invalid' - } - $manifestBytes = [byte[]]::new([int]$manifestItem.Length) - $manifestStream = [IO.File]::Open( + # Durable manifests are replaced atomically. Read from one authenticated + # ordinary-file handle while permitting that protocol's delete sharing, then + # prove the pathname still names the same entry before trusting the bytes. + $manifestStream = [IO.FileStream]::new( $manifestPath, [IO.FileMode]::Open, [IO.FileAccess]::Read, - [IO.FileShare]::Read + [IO.FileShare]'ReadWrite, Delete', + 4096, + [IO.FileOptions]::SequentialScan ) try { + if ($manifestStream.Length -le 0 -or $manifestStream.Length -gt 65536) { + throw 'ownership manifest metadata is invalid' + } + $manifestEntryIdentity = [ProPRDirectoryIdentity]::ReadHandle( + $manifestStream.SafeFileHandle, + $false + ) + $manifestBytes = [byte[]]::new([int]$manifestStream.Length) $manifestOffset = 0 while ($manifestOffset -lt $manifestBytes.Length) { $read = $manifestStream.Read( @@ -1223,9 +1261,17 @@ try { $manifestOffset += $read } if ($manifestStream.ReadByte() -ne -1) { throw 'ownership manifest changed during read' } + $manifestItem = Get-Item -LiteralPath $manifestPath -Force -ErrorAction Stop + if (($manifestItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + $manifestItem.Length -ne $manifestBytes.Length -or + [ProPRDirectoryIdentity]::ReadEntry($manifestPath, $false) -cne + $manifestEntryIdentity) { + throw 'ownership manifest entry changed during read' + } } finally { $manifestStream.Dispose() } + $cleanupValidationPhase = 'UTF8_SCHEMA' $strictUtf8 = [Text.UTF8Encoding]::new($false, $true) $manifest = ConvertFrom-Json -InputObject $strictUtf8.GetString($manifestBytes) -ErrorAction Stop $manifestKeys = @($manifest.PSObject.Properties | ForEach-Object { $_.Name }) @@ -1262,12 +1308,14 @@ try { !([bool]$manifest.InstallAttempted))))) { throw 'MSI transaction receipt state is inconsistent' } + $cleanupValidationPhase = 'RUN_ID' $authorizedRunId = [string]$manifest.RunId $pathRunId = [IO.Path]::GetFileNameWithoutExtension($manifestPath).Substring( 'propr-installed-app-ownership-'.Length) if ($authorizedRunId -cne $pathRunId -or $authorizedRunId -cne $ExpectedRunId) { throw 'ownership manifest run identity is invalid' } + $cleanupValidationPhase = 'LIFETIME' $createdUtcTicks = [int64]$manifest.CreatedUtcTicks $expiresUtcTicks = [int64]$manifest.ExpiresUtcTicks $nowUtcTicks = [DateTime]::UtcNow.Ticks @@ -1277,10 +1325,12 @@ try { $expiresUtcTicks -lt $nowUtcTicks) { throw 'ownership manifest lifetime is invalid' } + $cleanupValidationPhase = 'INSTALLER_PATH' $resolvedInstaller = (Resolve-Path -LiteralPath $Installer -ErrorAction Stop).Path if (!(Test-SamePath ([string]$manifest.InstallerPath) $resolvedInstaller)) { throw 'ownership manifest installer identity is invalid' } + $cleanupValidationPhase = 'FIXTURE_SCOPE' if ($FixtureRoot) { $FixtureRoot = (Resolve-Path -LiteralPath $FixtureRoot -ErrorAction Stop).Path if (!$manifest.Fixture -or !(Test-SamePath ([string]$manifest.FixtureRoot) $FixtureRoot)) { @@ -1295,6 +1345,7 @@ try { # authenticated schema-v3 ACTIVE authority, no baseline or install attempt, # transaction NONE, and no resource records. Revalidate the durable installer # authority before atomically converting it to the ordinary EMPTY receipt. + $cleanupValidationPhase = 'INITIAL_ACTIVE_MATCH' $initialActiveFixtureManifest = $manifest.Fixture -and [string]$manifest.State -ceq 'ACTIVE' -and !$manifest.BaselineClean -and !$manifest.InstallAttempted -and @@ -1303,6 +1354,9 @@ try { @($manifest.RegistryKeys).Count -eq 0 -and @($manifest.RegistryValues).Count -eq 0 -and @($manifest.Users).Count -eq 0 -and @($manifest.Profiles).Count -eq 0 + if ($FixtureValidationDiagnostic -and !$initialActiveFixtureManifest) { + throw 'initial fixture ownership authority does not match' + } if ($initialActiveFixtureManifest) { $manifestValidated = $true Assert-InstallerArtifactAuthority $manifest @@ -1616,6 +1670,7 @@ try { if ($cleanupFailed) { if ($manifestValidated) { exit 21 } + Write-FixtureCleanupValidationPhase $cleanupValidationPhase exit 20 } exit 0 diff --git a/apps/desktop/scripts/run-installed-windows-app-harness.ps1 b/apps/desktop/scripts/run-installed-windows-app-harness.ps1 index 3a7c09aea..5ce9c8783 100644 --- a/apps/desktop/scripts/run-installed-windows-app-harness.ps1 +++ b/apps/desktop/scripts/run-installed-windows-app-harness.ps1 @@ -715,6 +715,9 @@ function Invoke-PostTerminationCleanup([string]$InstallerPath, [string]$Authoriz $cleanupStartInfo.ArgumentList.Add('-FixtureRoot') $cleanupStartInfo.ArgumentList.Add($AuthorizedFixtureRoot) } + if ($fixtureNoMarkerDiagnostic) { + $cleanupStartInfo.ArgumentList.Add('-FixtureValidationDiagnostic') + } $cleanupJob = [ProPRKillOnCloseJob]::new() $cleanupProcess = [Diagnostics.Process]::new() diff --git a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 index 7958992e4..72ca11ad9 100644 --- a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 @@ -282,12 +282,22 @@ function Get-SanitizedSupervisorMarkerDiagnostic($Result) { $cleanupChildExit = if ($cleanupChildMatch.Success) { $cleanupChildMatch.Groups[1].Value } else { 'OTHER' } + $cleanupValidationPhaseMatch = [regex]::Match( + [string]$Result.Output, + '(?m)^PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:CLEANUP_VALIDATION_PHASE:' + + '(HANDSHAKE|FILE_AUTHORITY|UTF8_SCHEMA|LIFETIME|RUN_ID|INSTALLER_PATH|' + + 'FIXTURE_SCOPE|INITIAL_ACTIVE_MATCH)\r?$' + ) + $cleanupValidationPhase = if ($cleanupValidationPhaseMatch.Success) { + $cleanupValidationPhaseMatch.Groups[1].Value + } else { 'NONE' } $signedExit = ([int]$Result.ExitCode).ToString( [Globalization.CultureInfo]::InvariantCulture) return ('SUPERVISOR_EXIT:{0}:BOOTSTRAP_TIMED_OUT:{1}:LAST_VALID_NONE:{2}:' + - 'POST_TERMINATION_CLEANUP:{3}:SUBPHASE:{4}:CLEANUP_CHILD_EXIT:{5}') -f ` + 'POST_TERMINATION_CLEANUP:{3}:SUBPHASE:{4}:CLEANUP_CHILD_EXIT:{5}:' + + 'CLEANUP_VALIDATION_PHASE:{6}') -f ` $signedExit, ([int]$bootstrapTimedOutPresent), ([int]$lastValidNonePresent), - $postTerminationOutcome, $subphase, $cleanupChildExit + $postTerminationOutcome, $subphase, $cleanupChildExit, $cleanupValidationPhase } function Assert-OwnedResourcesGone($Owned) { diff --git a/apps/desktop/src/release-workflow.test.ts b/apps/desktop/src/release-workflow.test.ts index 2e02d50f8..6373887b1 100644 --- a/apps/desktop/src/release-workflow.test.ts +++ b/apps/desktop/src/release-workflow.test.ts @@ -743,6 +743,26 @@ describe('desktop trusted release workflow', () => { assert.match(installedWindowsAppSupervisor, /SchemaVersion = 3/); assert.match(installedWindowsAppTest, /SchemaVersion = 3/); assert.match(installedWindowsAppCleanup, /SchemaVersion -ne 3/); + assert.match( + installedWindowsAppCleanup, + /\[IO\.FileShare\]'ReadWrite, Delete'[\s\S]*ReadHandle\(\s*\$manifestStream\.SafeFileHandle,/, + ); + assert.match( + installedWindowsAppCleanup, + /ReadEntry\(\$manifestPath, \$false\) -cne\s+\$manifestEntryIdentity/, + ); + assert.match( + installedWindowsAppCleanup, + /HANDSHAKE','FILE_AUTHORITY','UTF8_SCHEMA','LIFETIME','RUN_ID',[\s\S]*'INSTALLER_PATH','FIXTURE_SCOPE','INITIAL_ACTIVE_MATCH'/, + ); + assert.match( + installedWindowsAppSupervisor, + /if \(\$fixtureNoMarkerDiagnostic\)[\s\S]*-FixtureValidationDiagnostic/, + ); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /CLEANUP_VALIDATION_PHASE:[\s\S]*HANDSHAKE\|FILE_AUTHORITY\|UTF8_SCHEMA\|LIFETIME\|RUN_ID\|INSTALLER_PATH\|/, + ); assert.doesNotMatch( installedWindowsAppCleanup, /Start-Process msiexec\.exe[\s\S]{0,180}`"\$resolvedInstaller`"/, From 59893c8fa5c605ec8313af61b5a89feff350cfc4 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:51:00 +0000 Subject: [PATCH 19/29] fix(ai): Resolve issue #2048 - Patch runtime desktop packaging browserslist advis Implemented by ProPR AI using gpt-5.6-sol model. Implementation completed successfully. --- package-lock.json | 44 +++++++++++++++++++++++++++++++------------- 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index 77e374f58..4786b9d93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3821,11 +3821,16 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.9.7", + "version": "2.11.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", + "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/before-after-hook": { @@ -4010,7 +4015,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", "dev": true, "funding": [ { @@ -4028,11 +4035,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" @@ -4156,7 +4163,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001760", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "dev": true, "funding": [ { @@ -4966,7 +4975,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.267", + "version": "1.5.418", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.418.tgz", + "integrity": "sha512-UzS26r3AEbG5wSoGVpJKqwHIU9zwQN7LHdVIThDrJpS0I5KdlXFMEb8543fhc9dVnIIAST6ar8rhwa00AL5MlA==", "dev": true, "license": "ISC" }, @@ -9339,9 +9350,14 @@ } }, "node_modules/node-releases": { - "version": "2.0.27", + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/normalize-path": { "version": "3.0.0", @@ -12276,7 +12292,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.2", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", "dev": true, "funding": [ { From 4eb7dcd95023b3198ae9131a3a38d2de6d3ddde0 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:55:09 +0000 Subject: [PATCH 20/29] feat(ai): Implemented the fixture-only diagnostic transport: Implemented the fixture-only diagnostic transport: - Redirects cleanup-child stdout/stderr, assigns the Job Object, releases the handshake, then starts bounded drains. - Enforces 96-byte/one-line stdout, zero stderr, strict allowlisted enum parsing, and zero-output success. - Re-emits only the parsed enum through the existing supervisor diagnostic. - Rejects malformed, extra, raw, or timed-out output while retaining recovery authority. - Added static ordering and protocol contracts. Files: [harness](/tmp/git-processor/worktrees/integry/propr/pr-2042-followup-2026-09-01T17-44-03/apps/desktop/scripts/run-installed-windows-app-harness.ps1:372), [cleanup worker](/tmp/git-processor/worktrees/integry/propr/pr-2042-followup-2026-09-01T17-44-03/apps/desktop/scripts/cleanup-installed-windows-app.ps1:29), [contracts](/tmp/git-processor/worktrees/integry/propr/pr-2042-followup-2026-09-01T17-44-03/apps/desktop/src/release-workflow.test.ts:769). Validation: - Desktop tests: 177 passed, 6 skipped. - Touched PowerShell scripts parse successfully. - Embedded C# compiles successfully. - Bounded drain behavior verified. - `git diff --check` passed. - No lockfiles or ancestry changed. The existing logs cannot identify the failing phase because that stream was lost. I therefore did not add another speculative authority change; the next native x64/ARM64 run will now expose the exact enum or prove cleanup exit 0/COMPLETE. PR: #2042 Comment by: @integry (ID: 5497992679) Model: gpt-5.6-sol --- .../scripts/cleanup-installed-windows-app.ps1 | 4 +- .../run-installed-windows-app-harness.ps1 | 188 +++++++++++++++++- apps/desktop/src/release-workflow.test.ts | 31 +++ 3 files changed, 219 insertions(+), 4 deletions(-) diff --git a/apps/desktop/scripts/cleanup-installed-windows-app.ps1 b/apps/desktop/scripts/cleanup-installed-windows-app.ps1 index f3040152a..a9f5c484e 100644 --- a/apps/desktop/scripts/cleanup-installed-windows-app.ps1 +++ b/apps/desktop/scripts/cleanup-installed-windows-app.ps1 @@ -26,8 +26,10 @@ function Write-FixtureCleanupValidationPhase([string]$Phase) { $cleanupValidationPhases -cnotcontains $Phase) { return } + # Diagnostic success is deliberately silent; only validation exit 20 emits + # this single bounded child-protocol line for supervisor parsing. [Console]::Out.WriteLine( - 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:CLEANUP_VALIDATION_PHASE:' + $Phase + 'CLEANUP_VALIDATION_PHASE:' + $Phase ) [Console]::Out.Flush() } diff --git a/apps/desktop/scripts/run-installed-windows-app-harness.ps1 b/apps/desktop/scripts/run-installed-windows-app-harness.ps1 index 5ce9c8783..acca6040e 100644 --- a/apps/desktop/scripts/run-installed-windows-app-harness.ps1 +++ b/apps/desktop/scripts/run-installed-windows-app-harness.ps1 @@ -84,11 +84,13 @@ $fixtureCleanupChildExitCategory = 'OTHER' Add-Type -TypeDefinition @' using System; using System.ComponentModel; +using System.Diagnostics; using System.Globalization; using System.IO; using System.Runtime.InteropServices; using System.Text; using System.Text.RegularExpressions; +using System.Threading; using System.Threading.Tasks; using Microsoft.Win32.SafeHandles; @@ -357,6 +359,125 @@ public static class ProPRBoundedMarkerReader catch { return Result(ProPRMarkerReadState.Invalid); } } } + +public sealed class ProPRCleanupDiagnosticDrainResult +{ + public long StandardOutputBytes; + public long StandardOutputLines; + public byte[] StandardOutput; + public long StandardErrorBytes; + public long StandardErrorLines; +} + +public sealed class ProPRCleanupDiagnosticDrain : IDisposable +{ + public const int StandardOutputByteLimit = 96; + public const int StandardOutputLineLimit = 1; + public const int StandardErrorByteLimit = 0; + public const int StandardErrorLineLimit = 0; + + private sealed class PumpResult + { + public long Bytes; + public long Lines; + public byte[] Captured; + } + + private readonly CancellationTokenSource cancellation = new CancellationTokenSource(); + private Stream standardOutput; + private Stream standardError; + private Task standardOutputTask; + private Task standardErrorTask; + + private static async Task Pump( + Stream stream, + int byteLimit, + int lineLimit, + CancellationToken token) + { + var buffer = new byte[64]; + using (var captured = new MemoryStream(byteLimit + 1)) + { + long bytes = 0; + long lines = 0; + while (true) + { + int count = await stream.ReadAsync( + buffer, 0, buffer.Length, token).ConfigureAwait(false); + if (count == 0) + { + return new PumpResult { + Bytes = bytes, + Lines = lines, + Captured = captured.ToArray() + }; + } + bytes = Math.Min((long)byteLimit + 1, bytes + count); + for (int index = 0; index < count; index++) + if (buffer[index] == (byte)'\n') + lines = Math.Min((long)lineLimit + 1, lines + 1); + int remaining = byteLimit + 1 - checked((int)captured.Length); + if (remaining > 0) + captured.Write(buffer, 0, Math.Min(remaining, count)); + } + } + } + + public void Start(Process process) + { + if (standardOutputTask != null || standardErrorTask != null) + throw new InvalidOperationException("diagnostic drain was already started"); + standardOutput = process.StandardOutput.BaseStream; + standardError = process.StandardError.BaseStream; + standardOutputTask = Pump( + standardOutput, + StandardOutputByteLimit, + StandardOutputLineLimit, + cancellation.Token); + standardErrorTask = Pump( + standardError, + StandardErrorByteLimit, + StandardErrorLineLimit, + cancellation.Token); + } + + public ProPRCleanupDiagnosticDrainResult Finish(int timeoutMilliseconds) + { + if (standardOutputTask == null || standardErrorTask == null) + throw new InvalidOperationException("diagnostic drain was not started"); + Task all = Task.WhenAll(standardOutputTask, standardErrorTask); + if (!all.Wait(timeoutMilliseconds)) return null; + if (standardOutputTask.IsFaulted || standardOutputTask.IsCanceled || + standardErrorTask.IsFaulted || standardErrorTask.IsCanceled) + throw new InvalidOperationException("diagnostic drain failed"); + PumpResult output = standardOutputTask.Result; + PumpResult error = standardErrorTask.Result; + return new ProPRCleanupDiagnosticDrainResult { + StandardOutputBytes = output.Bytes, + StandardOutputLines = output.Lines, + StandardOutput = output.Captured, + StandardErrorBytes = error.Bytes, + StandardErrorLines = error.Lines + }; + } + + public bool CancelAndFinish(int timeoutMilliseconds) + { + cancellation.Cancel(); + try { if (standardOutput != null) standardOutput.Dispose(); } catch { } + try { if (standardError != null) standardError.Dispose(); } catch { } + if (standardOutputTask == null || standardErrorTask == null) return true; + try { Task.WhenAll(standardOutputTask, standardErrorTask).Wait(timeoutMilliseconds); } + catch { } + return standardOutputTask.IsCompleted && standardErrorTask.IsCompleted; + } + + public void Dispose() + { + CancelAndFinish(1000); + cancellation.Dispose(); + } +} '@ function Get-InstallerSha256([string]$Path) { @@ -688,6 +809,7 @@ function Invoke-PostTerminationCleanup([string]$InstallerPath, [string]$Authoriz $cleanupJob = $null $cleanupProcess = $null $cleanupReadyEvent = $null + $cleanupDiagnosticDrain = $null try { $cleanupReadyEventName = "Local\ProPRInstalledAppCleanup-$([Guid]::NewGuid().ToString('N'))" $cleanupReadyEvent = [Threading.EventWaitHandle]::new( @@ -717,15 +839,23 @@ function Invoke-PostTerminationCleanup([string]$InstallerPath, [string]$Authoriz } if ($fixtureNoMarkerDiagnostic) { $cleanupStartInfo.ArgumentList.Add('-FixtureValidationDiagnostic') + $cleanupStartInfo.RedirectStandardOutput = $true + $cleanupStartInfo.RedirectStandardError = $true } $cleanupJob = [ProPRKillOnCloseJob]::new() + if ($fixtureNoMarkerDiagnostic) { + $cleanupDiagnosticDrain = [ProPRCleanupDiagnosticDrain]::new() + } $cleanupProcess = [Diagnostics.Process]::new() $cleanupProcess.StartInfo = $cleanupStartInfo if (!$cleanupProcess.Start()) { throw 'post-termination cleanup did not start' } try { $cleanupJob.AddProcess($cleanupProcess.Handle) [void]$cleanupReadyEvent.Set() + if ($fixtureNoMarkerDiagnostic) { + $cleanupDiagnosticDrain.Start($cleanupProcess) + } } catch { try { $cleanupProcess.Kill($true) } catch {} throw 'post-termination cleanup ownership failed' @@ -746,6 +876,56 @@ function Invoke-PostTerminationCleanup([string]$InstallerPath, [string]$Authoriz ([int]$cleanupProcess.ExitCode).ToString( [Globalization.CultureInfo]::InvariantCulture) } else { 'OTHER' } + if ($fixtureNoMarkerDiagnostic) { + # The fixture protocol permits exactly one bounded phase line for exit 20. + # Exit 0 is the explicitly defined zero-byte success protocol. Any other + # child output leaves recovery authority in place and fails closed. + $diagnosticDrainResult = $cleanupDiagnosticDrain.Finish( + $WatchdogTerminationMilliseconds) + if ($null -eq $diagnosticDrainResult -or + $diagnosticDrainResult.StandardErrorBytes -ne 0 -or + $diagnosticDrainResult.StandardErrorLines -ne 0 -or + $diagnosticDrainResult.StandardOutputBytes -gt + [ProPRCleanupDiagnosticDrain]::StandardOutputByteLimit -or + $diagnosticDrainResult.StandardOutputLines -gt + [ProPRCleanupDiagnosticDrain]::StandardOutputLineLimit) { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:FAILED' + return $false + } + if ($cleanupProcess.ExitCode -eq 0) { + if ($diagnosticDrainResult.StandardOutputBytes -ne 0 -or + $diagnosticDrainResult.StandardOutputLines -ne 0) { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:FAILED' + return $false + } + } elseif ($cleanupProcess.ExitCode -eq 20) { + $diagnosticBytes = [byte[]]$diagnosticDrainResult.StandardOutput + if ($diagnosticDrainResult.StandardOutputLines -ne 1 -or + @($diagnosticBytes | Where-Object { $_ -gt 0x7f }).Count -ne 0) { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:FAILED' + return $false + } + $diagnosticMatch = [regex]::Match( + [Text.Encoding]::ASCII.GetString($diagnosticBytes), + ('\ACLEANUP_VALIDATION_PHASE:' + + '(HANDSHAKE|FILE_AUTHORITY|UTF8_SCHEMA|LIFETIME|RUN_ID|' + + 'INSTALLER_PATH|FIXTURE_SCOPE|INITIAL_ACTIVE_MATCH)\r?\n\z'), + [Text.RegularExpressions.RegexOptions]::CultureInvariant + ) + if (!$diagnosticMatch.Success) { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:FAILED' + return $false + } + Write-WatchdogLine ( + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:CLEANUP_VALIDATION_PHASE:' + + $diagnosticMatch.Groups[1].Value + ) + } elseif ($diagnosticDrainResult.StandardOutputBytes -ne 0 -or + $diagnosticDrainResult.StandardOutputLines -ne 0) { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:FAILED' + return $false + } + } if ($cleanupProcess.ExitCode -ne 0) { Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:FAILED' return $false @@ -756,9 +936,11 @@ function Invoke-PostTerminationCleanup([string]$InstallerPath, [string]$Authoriz Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:FAILED' return $false } finally { - if ($null -ne $cleanupJob) { $cleanupJob.Dispose() } - if ($null -ne $cleanupProcess) { $cleanupProcess.Dispose() } - if ($null -ne $cleanupReadyEvent) { $cleanupReadyEvent.Dispose() } + foreach ($resource in @( + $cleanupDiagnosticDrain, $cleanupJob, $cleanupProcess, $cleanupReadyEvent + )) { + if ($null -ne $resource) { try { $resource.Dispose() } catch {} } + } } } diff --git a/apps/desktop/src/release-workflow.test.ts b/apps/desktop/src/release-workflow.test.ts index 6373887b1..6f9c67eef 100644 --- a/apps/desktop/src/release-workflow.test.ts +++ b/apps/desktop/src/release-workflow.test.ts @@ -759,6 +759,37 @@ describe('desktop trusted release workflow', () => { installedWindowsAppSupervisor, /if \(\$fixtureNoMarkerDiagnostic\)[\s\S]*-FixtureValidationDiagnostic/, ); + assert.match( + installedWindowsAppSupervisor, + /if \(\$fixtureNoMarkerDiagnostic\) \{[\s\S]*RedirectStandardOutput = \$true[\s\S]*RedirectStandardError = \$true/, + ); + assert.ok( + installedWindowsAppSupervisor.indexOf('$cleanupJob.AddProcess($cleanupProcess.Handle)') + < installedWindowsAppSupervisor.indexOf('[void]$cleanupReadyEvent.Set()'), + 'cleanup diagnostic child must enter its Job Object before ownership release', + ); + assert.ok( + installedWindowsAppSupervisor.indexOf('[void]$cleanupReadyEvent.Set()') + < installedWindowsAppSupervisor.indexOf('$cleanupDiagnosticDrain.Start($cleanupProcess)'), + 'cleanup diagnostic ownership must be released before redirected stream drains begin', + ); + assert.match(installedWindowsAppSupervisor, /class ProPRCleanupDiagnosticDrain/); + assert.match(installedWindowsAppSupervisor, /StandardOutputByteLimit = 96/); + assert.match(installedWindowsAppSupervisor, /StandardOutputLineLimit = 1/); + assert.match(installedWindowsAppSupervisor, /StandardErrorByteLimit = 0/); + assert.match(installedWindowsAppSupervisor, /StandardErrorLineLimit = 0/); + assert.match( + installedWindowsAppSupervisor, + /\\ACLEANUP_VALIDATION_PHASE:[\s\S]*INITIAL_ACTIVE_MATCH\)\\r\?\\n\\z/, + ); + assert.match( + installedWindowsAppCleanup, + /\[Console\]::Out\.WriteLine\(\s*'CLEANUP_VALIDATION_PHASE:' \+ \$Phase/, + ); + assert.doesNotMatch( + installedWindowsAppCleanup, + /\[Console\]::Out\.WriteLine\([\s\S]{0,120}PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:CLEANUP_VALIDATION_PHASE/, + ); assert.match( installedWindowsAppSupervisorBehaviorTest, /CLEANUP_VALIDATION_PHASE:[\s\S]*HANDSHAKE\|FILE_AUTHORITY\|UTF8_SCHEMA\|LIFETIME\|RUN_ID\|INSTALLER_PATH\|/, From 3af480032d479db162e7c23a5024a57382bc989e Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:11:21 +0000 Subject: [PATCH 21/29] feat(ai): Implemented the schema-v3 follow-up on exact head `4eb7dcd9`. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented the schema-v3 follow-up on exact head `4eb7dcd9`. - Split `UTF8_SCHEMA` into seven fixed phases: UTF-8 decode, JSON parse, exact keys, Boolean types, transaction enum, schema/type/state, and identifier formats. - Corrected Boolean validation to inspect the PSObject base type, still rejecting strings/numbers. - NO_MARKER now exercises the real supervisor writer → Windows PowerShell 5.1 cleanup reader path. - Replaced the unavailable .NET Framework `File.Move(..., overwrite)` overload with atomic `File.Replace`. - Preserved the 96-byte, one-line, zero-stderr, Job Object, timeout, strict-enum, and fail-closed parser contracts. - Added static regression contracts covering the new phases and PowerShell 5.1 path. Validation: - Desktop suite: 177 passed, 6 platform skips. - Modified PowerShell scripts parse successfully. - `git diff --check` passed. - No lockfiles, ancestry, or unrelated files changed. Native x64/ARM64 execution remains for the Windows CI matrix; the existing NO_MARKER acceptance now requires exit `124`, cleanup `COMPLETE`, and cleanup-child exit `0` through PowerShell 5.1. PR: #2042 Comment by: @integry (ID: 5498161017) Model: gpt-5.6-sol --- .../scripts/cleanup-installed-windows-app.ps1 | 69 ++++++++++++++----- .../run-installed-windows-app-harness.ps1 | 18 ++++- .../test-installed-windows-app-supervisor.ps1 | 5 +- apps/desktop/src/release-workflow.test.ts | 20 +++++- 4 files changed, 88 insertions(+), 24 deletions(-) diff --git a/apps/desktop/scripts/cleanup-installed-windows-app.ps1 b/apps/desktop/scripts/cleanup-installed-windows-app.ps1 index a9f5c484e..2a33651a9 100644 --- a/apps/desktop/scripts/cleanup-installed-windows-app.ps1 +++ b/apps/desktop/scripts/cleanup-installed-windows-app.ps1 @@ -17,8 +17,9 @@ $manifestValidated = $false $authorizedRunId = $null $cleanupValidationPhase = 'HANDSHAKE' $cleanupValidationPhases = @( - 'HANDSHAKE','FILE_AUTHORITY','UTF8_SCHEMA','LIFETIME','RUN_ID', - 'INSTALLER_PATH','FIXTURE_SCOPE','INITIAL_ACTIVE_MATCH' + 'HANDSHAKE','FILE_AUTHORITY','UTF8_DECODE','JSON_PARSE','EXACT_KEY_SET', + 'BOOLEAN_TYPES','TRANSACTION_ENUM','SCHEMA_TYPE_STATE','IDENTIFIER_FORMATS', + 'LIFETIME','RUN_ID','INSTALLER_PATH','FIXTURE_SCOPE','INITIAL_ACTIVE_MATCH' ) function Write-FixtureCleanupValidationPhase([string]$Phase) { @@ -1023,7 +1024,10 @@ function Write-DurableOwnershipManifest([string]$Path, $Manifest) { } finally { $stream.Dispose() } - [IO.File]::Move($temporaryPath, $Path, $true) + # File.Move(source, destination, overwrite) is not available on the .NET + # Framework used by Windows PowerShell 5.1. The canonical manifest exists, + # so File.Replace retains the same atomic same-volume replacement contract. + [IO.File]::Replace($temporaryPath, $Path, $null, $true) } function Write-EmptyOwnershipReceipt([string]$Path, $Manifest) { @@ -1273,9 +1277,14 @@ try { } finally { $manifestStream.Dispose() } - $cleanupValidationPhase = 'UTF8_SCHEMA' + $cleanupValidationPhase = 'UTF8_DECODE' $strictUtf8 = [Text.UTF8Encoding]::new($false, $true) - $manifest = ConvertFrom-Json -InputObject $strictUtf8.GetString($manifestBytes) -ErrorAction Stop + $manifestJson = $strictUtf8.GetString($manifestBytes) + + $cleanupValidationPhase = 'JSON_PARSE' + $manifest = ConvertFrom-Json -InputObject $manifestJson -ErrorAction Stop + + $cleanupValidationPhase = 'EXACT_KEY_SET' $manifestKeys = @($manifest.PSObject.Properties | ForEach-Object { $_.Name }) $expectedManifestKeys = @( 'SchemaVersion','ManifestType','State','RunId','CreatedUtcTicks','ExpiresUtcTicks', @@ -1285,21 +1294,47 @@ try { 'RegistryValues','Users','Profiles' ) if ($manifestKeys.Count -ne $expectedManifestKeys.Count -or - @($expectedManifestKeys | Where-Object { $manifestKeys -cnotcontains $_ }).Count -ne 0 -or - $manifest.Fixture -isnot [bool] -or $manifest.BaselineClean -isnot [bool] -or - $manifest.InstallAttempted -isnot [bool] -or - [string]$manifest.MsiTransactionState -notin @( - 'NONE','PENDING','COMMITTED','ROLLED_BACK_CLEAN' - ) -or + @($expectedManifestKeys | Where-Object { + $manifestKeys -cnotcontains $_ + }).Count -ne 0) { + throw 'ownership manifest key set is invalid' + } + + $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 + # strings, numbers, and every other truthy value. + if ($null -eq $manifest.Fixture -or + $manifest.Fixture.PSObject.BaseObject.GetType() -ne [bool] -or + $null -eq $manifest.BaselineClean -or + $manifest.BaselineClean.PSObject.BaseObject.GetType() -ne [bool] -or + $null -eq $manifest.InstallAttempted -or + $manifest.InstallAttempted.PSObject.BaseObject.GetType() -ne [bool]) { + throw 'ownership manifest Boolean types are invalid' + } + + $cleanupValidationPhase = 'TRANSACTION_ENUM' + if ([string]$manifest.MsiTransactionState -cnotin @( + 'NONE','PENDING','COMMITTED','ROLLED_BACK_CLEAN' + )) { + throw 'ownership manifest transaction enum is invalid' + } + + $cleanupValidationPhase = 'SCHEMA_TYPE_STATE' + if ( $manifest.SchemaVersion -ne 3 -or [string]$manifest.ManifestType -cne 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP' -or - [string]$manifest.State -notin @('ACTIVE','EMPTY') -or - [string]$manifest.RunId -notmatch '^[a-f0-9]{32}$' -or - [string]$manifest.InstallerEntryIdentity -notmatch '^[a-f0-9]{24}$' -or - [string]$manifest.InstallerSha256 -notmatch '^[a-f0-9]{64}$' -or - [string]$manifest.InstallerProductCode -notmatch + [string]$manifest.State -cnotin @('ACTIVE','EMPTY')) { + throw 'ownership manifest schema version, type, or state is invalid' + } + + $cleanupValidationPhase = 'IDENTIFIER_FORMATS' + if ([string]$manifest.RunId -cnotmatch '^[a-f0-9]{32}$' -or + [string]$manifest.InstallerEntryIdentity -cnotmatch '^[a-f0-9]{24}$' -or + [string]$manifest.InstallerSha256 -cnotmatch '^[a-f0-9]{64}$' -or + [string]$manifest.InstallerProductCode -cnotmatch '^\{[A-F0-9]{8}(?:-[A-F0-9]{4}){3}-[A-F0-9]{12}\}$') { - throw 'ownership manifest schema is invalid' + throw 'ownership manifest durable identifier formats are invalid' } if (!$manifest.Fixture -and ( ([string]$manifest.MsiTransactionState -ceq 'NONE' -and diff --git a/apps/desktop/scripts/run-installed-windows-app-harness.ps1 b/apps/desktop/scripts/run-installed-windows-app-harness.ps1 index acca6040e..3616da722 100644 --- a/apps/desktop/scripts/run-installed-windows-app-harness.ps1 +++ b/apps/desktop/scripts/run-installed-windows-app-harness.ps1 @@ -818,7 +818,17 @@ function Invoke-PostTerminationCleanup([string]$InstallerPath, [string]$Authoriz $cleanupReadyEventName ) $cleanupStartInfo = [Diagnostics.ProcessStartInfo]::new() - $cleanupStartInfo.FileName = $hostPath + $cleanupHostPath = $hostPath + if ($fixtureNoMarkerDiagnostic) { + # Exercise the supervisor writer and production cleanup reader across the + # Windows PowerShell 5.1 boundary in the focused native fixture only. + $cleanupHostPath = Join-Path $env:SystemRoot ` + 'System32\WindowsPowerShell\v1.0\powershell.exe' + if (!(Test-Path -LiteralPath $cleanupHostPath -PathType Leaf)) { + throw 'Windows PowerShell 5.1 fixture host is unavailable' + } + } + $cleanupStartInfo.FileName = $cleanupHostPath $cleanupStartInfo.UseShellExecute = $false $cleanupStartInfo.CreateNoWindow = $true foreach ($argument in @( @@ -908,8 +918,10 @@ function Invoke-PostTerminationCleanup([string]$InstallerPath, [string]$Authoriz $diagnosticMatch = [regex]::Match( [Text.Encoding]::ASCII.GetString($diagnosticBytes), ('\ACLEANUP_VALIDATION_PHASE:' + - '(HANDSHAKE|FILE_AUTHORITY|UTF8_SCHEMA|LIFETIME|RUN_ID|' + - 'INSTALLER_PATH|FIXTURE_SCOPE|INITIAL_ACTIVE_MATCH)\r?\n\z'), + '(HANDSHAKE|FILE_AUTHORITY|UTF8_DECODE|JSON_PARSE|EXACT_KEY_SET|' + + 'BOOLEAN_TYPES|TRANSACTION_ENUM|SCHEMA_TYPE_STATE|' + + 'IDENTIFIER_FORMATS|LIFETIME|RUN_ID|INSTALLER_PATH|FIXTURE_SCOPE|' + + 'INITIAL_ACTIVE_MATCH)\r?\n\z'), [Text.RegularExpressions.RegexOptions]::CultureInvariant ) if (!$diagnosticMatch.Success) { diff --git a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 index 72ca11ad9..9fcdf5d41 100644 --- a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 @@ -285,8 +285,9 @@ function Get-SanitizedSupervisorMarkerDiagnostic($Result) { $cleanupValidationPhaseMatch = [regex]::Match( [string]$Result.Output, '(?m)^PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:CLEANUP_VALIDATION_PHASE:' + - '(HANDSHAKE|FILE_AUTHORITY|UTF8_SCHEMA|LIFETIME|RUN_ID|INSTALLER_PATH|' + - 'FIXTURE_SCOPE|INITIAL_ACTIVE_MATCH)\r?$' + '(HANDSHAKE|FILE_AUTHORITY|UTF8_DECODE|JSON_PARSE|EXACT_KEY_SET|' + + 'BOOLEAN_TYPES|TRANSACTION_ENUM|SCHEMA_TYPE_STATE|IDENTIFIER_FORMATS|' + + 'LIFETIME|RUN_ID|INSTALLER_PATH|FIXTURE_SCOPE|INITIAL_ACTIVE_MATCH)\r?$' ) $cleanupValidationPhase = if ($cleanupValidationPhaseMatch.Success) { $cleanupValidationPhaseMatch.Groups[1].Value diff --git a/apps/desktop/src/release-workflow.test.ts b/apps/desktop/src/release-workflow.test.ts index 6f9c67eef..72366be07 100644 --- a/apps/desktop/src/release-workflow.test.ts +++ b/apps/desktop/src/release-workflow.test.ts @@ -753,7 +753,19 @@ describe('desktop trusted release workflow', () => { ); assert.match( installedWindowsAppCleanup, - /HANDSHAKE','FILE_AUTHORITY','UTF8_SCHEMA','LIFETIME','RUN_ID',[\s\S]*'INSTALLER_PATH','FIXTURE_SCOPE','INITIAL_ACTIVE_MATCH'/, + /HANDSHAKE','FILE_AUTHORITY','UTF8_DECODE','JSON_PARSE','EXACT_KEY_SET',[\s\S]*'BOOLEAN_TYPES','TRANSACTION_ENUM','SCHEMA_TYPE_STATE','IDENTIFIER_FORMATS',[\s\S]*'INITIAL_ACTIVE_MATCH'/, + ); + assert.match( + installedWindowsAppCleanup, + /\$manifest\.Fixture\.PSObject\.BaseObject\.GetType\(\) -ne \[bool\][\s\S]*\$manifest\.BaselineClean\.PSObject\.BaseObject\.GetType\(\) -ne \[bool\][\s\S]*\$manifest\.InstallAttempted\.PSObject\.BaseObject\.GetType\(\) -ne \[bool\]/, + ); + assert.match( + installedWindowsAppCleanup, + /\[IO\.File\]::Replace\(\$temporaryPath, \$Path, \$null, \$true\)/, + ); + assert.doesNotMatch( + installedWindowsAppCleanup, + /\[IO\.File\]::Move\(\$temporaryPath, \$Path, \$true\)/, ); assert.match( installedWindowsAppSupervisor, @@ -782,6 +794,10 @@ describe('desktop trusted release workflow', () => { installedWindowsAppSupervisor, /\\ACLEANUP_VALIDATION_PHASE:[\s\S]*INITIAL_ACTIVE_MATCH\)\\r\?\\n\\z/, ); + assert.match( + installedWindowsAppSupervisor, + /System32\\WindowsPowerShell\\v1\.0\\powershell\.exe/, + ); assert.match( installedWindowsAppCleanup, /\[Console\]::Out\.WriteLine\(\s*'CLEANUP_VALIDATION_PHASE:' \+ \$Phase/, @@ -792,7 +808,7 @@ describe('desktop trusted release workflow', () => { ); assert.match( installedWindowsAppSupervisorBehaviorTest, - /CLEANUP_VALIDATION_PHASE:[\s\S]*HANDSHAKE\|FILE_AUTHORITY\|UTF8_SCHEMA\|LIFETIME\|RUN_ID\|INSTALLER_PATH\|/, + /CLEANUP_VALIDATION_PHASE:[\s\S]*HANDSHAKE\|FILE_AUTHORITY\|UTF8_DECODE\|JSON_PARSE\|EXACT_KEY_SET\|[\s\S]*BOOLEAN_TYPES\|TRANSACTION_ENUM\|SCHEMA_TYPE_STATE\|IDENTIFIER_FORMATS\|/, ); assert.doesNotMatch( installedWindowsAppCleanup, From 1a40502b4c0df8f508642de892eafb5f8e341ca3 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:26:29 +0000 Subject: [PATCH 22/29] feat(ai): Implemented the #2042 follow-up on exact head `3af480032d479db162e7c23a5024a57382bc989e`. Implemented the #2042 follow-up on exact head `3af480032d479db162e7c23a5024a57382bc989e`. Key changes: - Split `IDENTIFIER_FORMATS` into the four requested bounded phases. - Corrected the capture/display-object versus canonical JSON wire-string mismatch. - Enforced exact lowercase RunId/entry/SHA and uppercase braced ProductCode representations, with immediate JSON round-trip validation. - Main `NO_MARKER` now uses the actual native `pwsh` host; a separate fixture retains PowerShell 5.1 coverage. - Updated parent enums/parsers and regressions, including zero-stderr, cleanup exit `0`, cleanup `COMPLETE`, and watchdog exit `124` assertions. - Preserved schema v3, BaseObject type proofs, F24 authority, Job Object ordering, retention behavior, and startup protocol. Validation passed: - PowerShell parsing: passed - Focused workflow tests: 23/23 - Desktop suite: 177 passed, 6 platform skips - Desktop typecheck - `git diff --check` - No lockfiles or ancestry changed; no commit created Native x64/ARM64 execution remains CI-only. PR: #2042 Comment by: @integry (ID: 5498348786) Model: gpt-5.6-sol --- .../scripts/cleanup-installed-windows-app.ps1 | 57 ++++++++++--- .../run-installed-windows-app-harness.ps1 | 79 ++++++++++++++++--- ...stalled-windows-app-supervisor-fixture.ps1 | 4 + .../test-installed-windows-app-supervisor.ps1 | 26 +++++- apps/desktop/src/release-workflow.test.ts | 30 ++++++- 5 files changed, 171 insertions(+), 25 deletions(-) diff --git a/apps/desktop/scripts/cleanup-installed-windows-app.ps1 b/apps/desktop/scripts/cleanup-installed-windows-app.ps1 index 2a33651a9..f4d325478 100644 --- a/apps/desktop/scripts/cleanup-installed-windows-app.ps1 +++ b/apps/desktop/scripts/cleanup-installed-windows-app.ps1 @@ -18,7 +18,8 @@ $authorizedRunId = $null $cleanupValidationPhase = 'HANDSHAKE' $cleanupValidationPhases = @( 'HANDSHAKE','FILE_AUTHORITY','UTF8_DECODE','JSON_PARSE','EXACT_KEY_SET', - 'BOOLEAN_TYPES','TRANSACTION_ENUM','SCHEMA_TYPE_STATE','IDENTIFIER_FORMATS', + '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' ) @@ -1328,14 +1329,52 @@ try { throw 'ownership manifest schema version, type, or state is invalid' } - $cleanupValidationPhase = 'IDENTIFIER_FORMATS' - if ([string]$manifest.RunId -cnotmatch '^[a-f0-9]{32}$' -or - [string]$manifest.InstallerEntryIdentity -cnotmatch '^[a-f0-9]{24}$' -or - [string]$manifest.InstallerSha256 -cnotmatch '^[a-f0-9]{64}$' -or - [string]$manifest.InstallerProductCode -cnotmatch - '^\{[A-F0-9]{8}(?:-[A-F0-9]{4}){3}-[A-F0-9]{12}\}$') { - throw 'ownership manifest durable identifier formats are invalid' - } + $cleanupValidationPhase = 'RUN_ID_FORMAT' + $runIdBaseObject = if ($null -eq $manifest.RunId) { + $null + } else { $manifest.RunId.PSObject.BaseObject } + if ($null -eq $runIdBaseObject -or + $runIdBaseObject.GetType() -ne [string] -or + [string]$runIdBaseObject -cnotmatch '^[a-f0-9]{32}$') { + throw 'ownership manifest run identifier format is invalid' + } + + $cleanupValidationPhase = 'INSTALLER_ENTRY_ID_FORMAT' + $installerEntryIdBaseObject = if ($null -eq $manifest.InstallerEntryIdentity) { + $null + } else { $manifest.InstallerEntryIdentity.PSObject.BaseObject } + if ($null -eq $installerEntryIdBaseObject -or + $installerEntryIdBaseObject.GetType() -ne [string] -or + [string]$installerEntryIdBaseObject -cnotmatch '^[a-f0-9]{24}$') { + throw 'ownership manifest installer entry identifier format is invalid' + } + + $cleanupValidationPhase = 'INSTALLER_SHA256_FORMAT' + $installerSha256BaseObject = if ($null -eq $manifest.InstallerSha256) { + $null + } else { $manifest.InstallerSha256.PSObject.BaseObject } + if ($null -eq $installerSha256BaseObject -or + $installerSha256BaseObject.GetType() -ne [string] -or + [string]$installerSha256BaseObject -cnotmatch '^[a-f0-9]{64}$') { + throw 'ownership manifest installer digest format is invalid' + } + + $cleanupValidationPhase = 'INSTALLER_PRODUCT_CODE_FORMAT' + $installerProductCodeBaseObject = if ($null -eq $manifest.InstallerProductCode) { + $null + } else { $manifest.InstallerProductCode.PSObject.BaseObject } + if ($null -eq $installerProductCodeBaseObject -or + $installerProductCodeBaseObject.GetType() -ne [string] -or + [string]$installerProductCodeBaseObject -cnotmatch + '^\{[A-F0-9]{8}(?:-[A-F0-9]{4}){3}-[A-F0-9]{12}\}$') { + throw 'ownership manifest installer product-code format is invalid' + } + # Keep the validated JSON wire strings, not host-specific PSObject display + # representations, for every downstream authority comparison and receipt. + $manifest.RunId = [string]$runIdBaseObject + $manifest.InstallerEntryIdentity = [string]$installerEntryIdBaseObject + $manifest.InstallerSha256 = [string]$installerSha256BaseObject + $manifest.InstallerProductCode = [string]$installerProductCodeBaseObject if (!$manifest.Fixture -and ( ([string]$manifest.MsiTransactionState -ceq 'NONE' -and [bool]$manifest.InstallAttempted) -or diff --git a/apps/desktop/scripts/run-installed-windows-app-harness.ps1 b/apps/desktop/scripts/run-installed-windows-app-harness.ps1 index 3616da722..a0353b0e1 100644 --- a/apps/desktop/scripts/run-installed-windows-app-harness.ps1 +++ b/apps/desktop/scripts/run-installed-windows-app-harness.ps1 @@ -78,6 +78,7 @@ $workerStarted = $false $supervisorOutcomeComplete = $false $postTerminationCleanupAuthorized = $true $fixtureNoMarkerDiagnostic = $false +$fixtureWindowsPowerShellCleanup = $false $fixtureWorkerTreeTerminationOutcome = 'FAILED' $fixtureCleanupChildExitCategory = 'OTHER' @@ -648,6 +649,45 @@ function Stop-OwnedWorker([uint32]$TerminationExitCode) { } } +function Get-CanonicalManifestIdentifiers([string]$RunId, $InstallerAuthority) { + if ($RunId -cnotmatch '^[a-f0-9]{32}$') { + throw 'manifest run identifier is not canonical' + } + + $entryIdentity = [string]$InstallerAuthority.EntryIdentity + if ($entryIdentity -notmatch '^[A-Fa-f0-9]{24}$') { + throw 'installer entry identifier cannot be represented canonically' + } + $entryIdentity = $entryIdentity.ToLowerInvariant() + + $sha256 = [string]$InstallerAuthority.Sha256 + if ($sha256 -notmatch '^[A-Fa-f0-9]{64}$') { + throw 'installer digest cannot be represented canonically' + } + $sha256 = $sha256.ToLowerInvariant() + + $productCodeText = [string]$InstallerAuthority.ProductCode + $productCode = [Guid]::Empty + if (![Guid]::TryParseExact($productCodeText, 'B', [ref]$productCode)) { + throw 'installer product code cannot be represented canonically' + } + $productCodeText = $productCode.ToString('B').ToUpperInvariant() + + if ($entryIdentity -cnotmatch '^[a-f0-9]{24}$' -or + $sha256 -cnotmatch '^[a-f0-9]{64}$' -or + $productCodeText -cnotmatch + '^\{[A-F0-9]{8}(?:-[A-F0-9]{4}){3}-[A-F0-9]{12}\}$') { + throw 'canonical manifest identifier construction failed' + } + + return [PSCustomObject]@{ + RunId = $RunId + InstallerEntryIdentity = $entryIdentity + InstallerSha256 = $sha256 + InstallerProductCode = $productCodeText + } +} + function Write-InitialOwnershipManifest( [string]$Path, $InstallerAuthority, @@ -656,18 +696,19 @@ function Write-InitialOwnershipManifest( ) { $runId = [IO.Path]::GetFileNameWithoutExtension($Path).Substring( 'propr-installed-app-ownership-'.Length) + $identifiers = Get-CanonicalManifestIdentifiers $runId $InstallerAuthority $createdUtcTicks = [DateTime]::UtcNow.Ticks $manifest = [ordered]@{ SchemaVersion = 3 ManifestType = 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP' State = 'ACTIVE' - RunId = $runId + RunId = $identifiers.RunId CreatedUtcTicks = $createdUtcTicks ExpiresUtcTicks = $createdUtcTicks + ([TimeSpan]::TicksPerHour * 3) InstallerPath = [string]$InstallerAuthority.Path - InstallerEntryIdentity = [string]$InstallerAuthority.EntryIdentity - InstallerSha256 = [string]$InstallerAuthority.Sha256 - InstallerProductCode = [string]$InstallerAuthority.ProductCode + InstallerEntryIdentity = $identifiers.InstallerEntryIdentity + InstallerSha256 = $identifiers.InstallerSha256 + InstallerProductCode = $identifiers.InstallerProductCode Fixture = $Fixture FixtureRoot = if ($Fixture) { $AuthorizedFixtureRoot } else { $null } BaselineClean = $false @@ -680,7 +721,17 @@ function Write-InitialOwnershipManifest( Users = @() Profiles = @() } - $bytes = [Text.Encoding]::UTF8.GetBytes(($manifest | ConvertTo-Json -Depth 6 -Compress)) + $manifestJson = $manifest | ConvertTo-Json -Depth 6 -Compress + $roundTrip = ConvertFrom-Json -InputObject $manifestJson -ErrorAction Stop + if ([string]$roundTrip.RunId -cne $identifiers.RunId -or + [string]$roundTrip.InstallerEntryIdentity -cne + $identifiers.InstallerEntryIdentity -or + [string]$roundTrip.InstallerSha256 -cne $identifiers.InstallerSha256 -or + [string]$roundTrip.InstallerProductCode -cne + $identifiers.InstallerProductCode) { + throw 'canonical manifest identifier round trip failed' + } + $bytes = [Text.Encoding]::UTF8.GetBytes($manifestJson) $stream = [IO.FileStream]::new( $Path, [IO.FileMode]::CreateNew, @@ -818,10 +869,11 @@ function Invoke-PostTerminationCleanup([string]$InstallerPath, [string]$Authoriz $cleanupReadyEventName ) $cleanupStartInfo = [Diagnostics.ProcessStartInfo]::new() + # Production and the principal fixture use the exact host that launched the + # supervisor. A separate fixture retains Windows PowerShell 5.1 coverage + # without attributing native pwsh 7 evidence to that compatibility host. $cleanupHostPath = $hostPath - if ($fixtureNoMarkerDiagnostic) { - # Exercise the supervisor writer and production cleanup reader across the - # Windows PowerShell 5.1 boundary in the focused native fixture only. + if ($fixtureWindowsPowerShellCleanup) { $cleanupHostPath = Join-Path $env:SystemRoot ` 'System32\WindowsPowerShell\v1.0\powershell.exe' if (!(Test-Path -LiteralPath $cleanupHostPath -PathType Leaf)) { @@ -920,7 +972,8 @@ function Invoke-PostTerminationCleanup([string]$InstallerPath, [string]$Authoriz ('\ACLEANUP_VALIDATION_PHASE:' + '(HANDSHAKE|FILE_AUTHORITY|UTF8_DECODE|JSON_PARSE|EXACT_KEY_SET|' + 'BOOLEAN_TYPES|TRANSACTION_ENUM|SCHEMA_TYPE_STATE|' + - 'IDENTIFIER_FORMATS|LIFETIME|RUN_ID|INSTALLER_PATH|FIXTURE_SCOPE|' + + 'RUN_ID_FORMAT|INSTALLER_ENTRY_ID_FORMAT|INSTALLER_SHA256_FORMAT|' + + 'INSTALLER_PRODUCT_CODE_FORMAT|LIFETIME|RUN_ID|INSTALLER_PATH|FIXTURE_SCOPE|' + 'INITIAL_ACTIVE_MATCH)\r?\n\z'), [Text.RegularExpressions.RegexOptions]::CultureInvariant ) @@ -988,8 +1041,12 @@ try { if ($FixtureCleanupRoot) { if ($usingProductionWorker) { throw 'production worker cannot use a fixture cleanup scope' } $FixtureCleanupRoot = (Resolve-Path -LiteralPath $FixtureCleanupRoot -ErrorAction Stop).Path - $fixtureNoMarkerDiagnostic = - [string]$env:PROPR_SUPERVISOR_FIXTURE_SCENARIO -ceq 'NO_MARKER' + $fixtureScenario = [string]$env:PROPR_SUPERVISOR_FIXTURE_SCENARIO + $fixtureNoMarkerDiagnostic = $fixtureScenario -in @( + 'NO_MARKER','NO_MARKER_WINDOWS_POWERSHELL' + ) + $fixtureWindowsPowerShellCleanup = + $fixtureScenario -ceq 'NO_MARKER_WINDOWS_POWERSHELL' } elseif (!$usingProductionWorker) { throw 'injected workers require a fixture cleanup scope' } 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 1f79cf68e..ee1de9bb9 100644 --- a/apps/desktop/scripts/test-installed-windows-app-supervisor-fixture.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app-supervisor-fixture.ps1 @@ -65,6 +65,7 @@ $scenario = $env:PROPR_SUPERVISOR_FIXTURE_SCENARIO $stateDirectory = $env:PROPR_SUPERVISOR_FIXTURE_STATE_DIRECTORY if ($scenario -notin @( 'NO_MARKER', + 'NO_MARKER_WINDOWS_POWERSHELL', 'VALID_THEN_DEADLINE', 'MALFORMED_MARKER', 'TORN_MARKER', @@ -798,6 +799,9 @@ switch ($scenario) { 'NO_MARKER' { Start-Sleep -Seconds 300 } + 'NO_MARKER_WINDOWS_POWERSHELL' { + Start-Sleep -Seconds 300 + } 'VALID_THEN_DEADLINE' { Write-FixtureMarker ('{0}|INITIALIZATION|PATHS|BEGIN' -f [DateTime]::UtcNow.AddSeconds(10).Ticks) Start-Sleep -Milliseconds 500 diff --git a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 index 9fcdf5d41..f0bf20d88 100644 --- a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 @@ -286,7 +286,8 @@ 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|IDENTIFIER_FORMATS|' + + '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)\r?$' ) $cleanupValidationPhase = if ($cleanupValidationPhaseMatch.Success) { @@ -608,7 +609,9 @@ function Invoke-FixtureScenario( $stopwatch = [Diagnostics.Stopwatch]::StartNew() if (!$process.Start()) { throw 'supervisor test process did not start' } try { - $completionBound = if ($Scenario -ceq 'NO_MARKER') { + $completionBound = if ($Scenario -in @( + 'NO_MARKER','NO_MARKER_WINDOWS_POWERSHELL' + )) { 60000 } elseif ($Scenario -in @( 'OWNED_RESOURCES_THEN_DEADLINE', @@ -716,6 +719,8 @@ function Test-MsiTransactionInterruptionGates { function Test-BootstrapTimeout { $result = Invoke-FixtureScenario 'NO_MARKER' $diagnostic = Get-SanitizedSupervisorMarkerDiagnostic $result + Assert-True ([string]::IsNullOrEmpty([string]$result.Error)) ` + 'missing-marker native pwsh fixture emitted stderr' Assert-True ($result.ExitCode -eq 124) ` "missing-marker bootstrap did not fail with the watchdog code:$diagnostic" Assert-True ($result.ElapsedMilliseconds -ge 9000) 'bootstrap timeout ignored the injected deadline' @@ -739,6 +744,22 @@ function Test-BootstrapTimeout { 'missing-marker bootstrap did not complete bounded cleanup' } +function Test-WindowsPowerShellCleanupCompatibility { + $result = Invoke-FixtureScenario 'NO_MARKER_WINDOWS_POWERSHELL' + $diagnostic = Get-SanitizedSupervisorMarkerDiagnostic $result + Assert-True ([string]::IsNullOrEmpty([string]$result.Error)) ` + 'Windows PowerShell cleanup compatibility fixture emitted stderr' + Assert-True ($result.ExitCode -eq 124) ` + "Windows PowerShell cleanup compatibility did not preserve watchdog exit:$diagnostic" + Assert-Contains $result.Output ` + ('PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:FIXTURE_FINALIZATION:' + + 'CLEANUP_CHILD_EXIT:0') ` + 'Windows PowerShell cleanup compatibility did not consume exact identifiers' + Assert-Contains $result.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:COMPLETE' ` + 'Windows PowerShell cleanup compatibility did not complete' +} + function Test-OperationDeadlineAndTreeTermination { $result = Invoke-FixtureScenario 'VALID_THEN_DEADLINE' Assert-True ($result.ExitCode -eq 124) 'operation deadline did not fail with the watchdog code' @@ -2011,6 +2032,7 @@ Initialize-TestInstaller try { Test-WorkflowCleanupStartupProtocol Test-BootstrapTimeout + Test-WindowsPowerShellCleanupCompatibility Test-OperationDeadlineAndTreeTermination Test-NegativeWorkerExitFinalization Test-FailClosedMarkers diff --git a/apps/desktop/src/release-workflow.test.ts b/apps/desktop/src/release-workflow.test.ts index 72366be07..9b05d0447 100644 --- a/apps/desktop/src/release-workflow.test.ts +++ b/apps/desktop/src/release-workflow.test.ts @@ -753,12 +753,20 @@ 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','IDENTIFIER_FORMATS',[\s\S]*'INITIAL_ACTIVE_MATCH'/, + /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'/, ); assert.match( installedWindowsAppCleanup, /\$manifest\.Fixture\.PSObject\.BaseObject\.GetType\(\) -ne \[bool\][\s\S]*\$manifest\.BaselineClean\.PSObject\.BaseObject\.GetType\(\) -ne \[bool\][\s\S]*\$manifest\.InstallAttempted\.PSObject\.BaseObject\.GetType\(\) -ne \[bool\]/, ); + assert.match( + installedWindowsAppCleanup, + /\$manifest\.RunId\.PSObject\.BaseObject[\s\S]*GetType\(\) -ne \[string\][\s\S]*\$manifest\.InstallerEntryIdentity\.PSObject\.BaseObject[\s\S]*\$manifest\.InstallerSha256\.PSObject\.BaseObject[\s\S]*\$manifest\.InstallerProductCode\.PSObject\.BaseObject/, + ); + assert.match( + installedWindowsAppCleanup, + /\$manifest\.RunId = \[string\]\$runIdBaseObject[\s\S]*\$manifest\.InstallerProductCode = \[string\]\$installerProductCodeBaseObject/, + ); assert.match( installedWindowsAppCleanup, /\[IO\.File\]::Replace\(\$temporaryPath, \$Path, \$null, \$true\)/, @@ -794,9 +802,23 @@ describe('desktop trusted release workflow', () => { installedWindowsAppSupervisor, /\\ACLEANUP_VALIDATION_PHASE:[\s\S]*INITIAL_ACTIVE_MATCH\)\\r\?\\n\\z/, ); + assert.match(installedWindowsAppSupervisor, /\$cleanupHostPath = \$hostPath/); + assert.match( + installedWindowsAppSupervisor, + /if \(\$fixtureWindowsPowerShellCleanup\)[\s\S]*System32\\WindowsPowerShell\\v1\.0\\powershell\.exe/, + ); + assert.match( + installedWindowsAppSupervisor, + /function Get-CanonicalManifestIdentifiers[\s\S]*ToLowerInvariant\(\)[\s\S]*\[Guid\]::TryParseExact\([\s\S]*ToString\('B'\)\.ToUpperInvariant\(\)/, + ); + assert.doesNotMatch( + installedWindowsAppSupervisor, + /InstallerEntryIdentity = \[string\]\$InstallerAuthority\.EntryIdentity[\s\S]*InstallerProductCode = \[string\]\$InstallerAuthority\.ProductCode/, + 'the 3af4800 capture/display representation must not be persisted as the identifier wire format', + ); assert.match( installedWindowsAppSupervisor, - /System32\\WindowsPowerShell\\v1\.0\\powershell\.exe/, + /\$roundTrip = ConvertFrom-Json[\s\S]*\$roundTrip\.RunId -cne \$identifiers\.RunId[\s\S]*\$roundTrip\.InstallerProductCode -cne[\s\S]*\$identifiers\.InstallerProductCode/, ); assert.match( installedWindowsAppCleanup, @@ -808,8 +830,10 @@ 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\|IDENTIFIER_FORMATS\|/, + /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\|/, ); + assert.match(installedWindowsAppSupervisorBehaviorTest, /Test-WindowsPowerShellCleanupCompatibility/); + assert.match(installedWindowsAppSupervisorBehaviorTest, /NO_MARKER_WINDOWS_POWERSHELL/); assert.doesNotMatch( installedWindowsAppCleanup, /Start-Process msiexec\.exe[\s\S]{0,180}`"\$resolvedInstaller`"/, From faf1d69041ecf7919449a7a5704fb3068162879a Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:37:46 +0000 Subject: [PATCH 23/29] feat(ai): Implemented the post-validation NO_MARKER correction on exact head `1a40502b4c0df8f508642de892eafb5f8e341ca3`. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented the post-validation NO_MARKER correction on exact head `1a40502b4c0df8f508642de892eafb5f8e341ca3`. Key changes: - Added bounded `INITIAL_INSTALLER_AUTHORITY_RECHECK` and `EMPTY_RECEIPT_WRITE` phases for exits 20/21. - Restored native pwsh atomic `File.Move(..., overwrite)` while retaining PS5.1 `File.Replace`. - Moved `manifestValidated` after installer authority succeeds. - Builds EMPTY receipts from a copy, preserving canonical ACTIVE authority on write failure. - Preserved zero stderr, one ≤96-byte stdout line, timeout, and Job Object ordering. - Explicitly documented that earlier identifier evidence was from PS5.1, while current exit 21 is native pwsh. Files changed: - [cleanup-installed-windows-app.ps1](/tmp/git-processor/worktrees/integry/propr/pr-2042-followup-2026-09-01T18-30-01/apps/desktop/scripts/cleanup-installed-windows-app.ps1:1027) - [run-installed-windows-app-harness.ps1](/tmp/git-processor/worktrees/integry/propr/pr-2042-followup-2026-09-01T18-30-01/apps/desktop/scripts/run-installed-windows-app-harness.ps1:939) - [test-installed-windows-app-supervisor.ps1](/tmp/git-processor/worktrees/integry/propr/pr-2042-followup-2026-09-01T18-30-01/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1:748) - [release-workflow.test.ts](/tmp/git-processor/worktrees/integry/propr/pr-2042-followup-2026-09-01T18-30-01/apps/desktop/src/release-workflow.test.ts:753) Validation: - Focused workflow tests: 23 passed - Desktop suite: 177 passed, 6 platform skips - Desktop typecheck: passed - `git diff --check`: passed - No lockfiles, commits, or ancestry changes Native x64/ARM64 execution remains for the Windows CI code gate. PR: #2042 Comment by: @integry (ID: 5498530554) Model: gpt-5.6-sol --- .../scripts/cleanup-installed-windows-app.ps1 | 52 ++++++++++++------- .../run-installed-windows-app-harness.ps1 | 12 +++-- .../test-installed-windows-app-supervisor.ps1 | 6 ++- apps/desktop/src/release-workflow.test.ts | 28 ++++++++-- 4 files changed, 68 insertions(+), 30 deletions(-) diff --git a/apps/desktop/scripts/cleanup-installed-windows-app.ps1 b/apps/desktop/scripts/cleanup-installed-windows-app.ps1 index f4d325478..9653baeaf 100644 --- a/apps/desktop/scripts/cleanup-installed-windows-app.ps1 +++ b/apps/desktop/scripts/cleanup-installed-windows-app.ps1 @@ -20,7 +20,8 @@ $cleanupValidationPhases = @( 'HANDSHAKE','FILE_AUTHORITY','UTF8_DECODE','JSON_PARSE','EXACT_KEY_SET', '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' + 'LIFETIME','RUN_ID','INSTALLER_PATH','FIXTURE_SCOPE','INITIAL_ACTIVE_MATCH', + 'INITIAL_INSTALLER_AUTHORITY_RECHECK','EMPTY_RECEIPT_WRITE' ) function Write-FixtureCleanupValidationPhase([string]$Phase) { @@ -28,8 +29,9 @@ function Write-FixtureCleanupValidationPhase([string]$Phase) { $cleanupValidationPhases -cnotcontains $Phase) { return } - # Diagnostic success is deliberately silent; only validation exit 20 emits - # this single bounded child-protocol line for supervisor parsing. + # Diagnostic success is deliberately silent; validation exit 20 and + # post-validation exit 21 emit this single bounded child-protocol line for + # supervisor parsing. [Console]::Out.WriteLine( 'CLEANUP_VALIDATION_PHASE:' + $Phase ) @@ -1025,24 +1027,32 @@ function Write-DurableOwnershipManifest([string]$Path, $Manifest) { } finally { $stream.Dispose() } - # File.Move(source, destination, overwrite) is not available on the .NET - # Framework used by Windows PowerShell 5.1. The canonical manifest exists, - # so File.Replace retains the same atomic same-volume replacement contract. - [IO.File]::Replace($temporaryPath, $Path, $null, $true) + if ($PSVersionTable.PSEdition -ceq 'Core') { + # Native pwsh provides the atomic same-directory overwrite overload. + [IO.File]::Move($temporaryPath, $Path, $true) + } else { + # File.Move(source, destination, overwrite) is not available on the .NET + # Framework used by Windows PowerShell 5.1. The canonical manifest exists, + # so File.Replace retains the same atomic same-volume replacement contract. + [IO.File]::Replace($temporaryPath, $Path, $null, $true) + } } function Write-EmptyOwnershipReceipt([string]$Path, $Manifest) { - $Manifest.State = 'EMPTY' - $Manifest.BaselineClean = $false - $Manifest.InstallAttempted = $false - $Manifest.MsiTransactionState = 'NONE' - $Manifest.Directories = @() - $Manifest.Files = @() - $Manifest.RegistryKeys = @() - $Manifest.RegistryValues = @() - $Manifest.Users = @() - $Manifest.Profiles = @() - Write-DurableOwnershipManifest $Path $Manifest + # Build the final receipt independently. If serialization or replacement + # fails, the caller and canonical pathname both retain ACTIVE authority. + $emptyReceipt = $Manifest.PSObject.Copy() + $emptyReceipt.State = 'EMPTY' + $emptyReceipt.BaselineClean = $false + $emptyReceipt.InstallAttempted = $false + $emptyReceipt.MsiTransactionState = 'NONE' + $emptyReceipt.Directories = @() + $emptyReceipt.Files = @() + $emptyReceipt.RegistryKeys = @() + $emptyReceipt.RegistryValues = @() + $emptyReceipt.Users = @() + $emptyReceipt.Profiles = @() + Write-DurableOwnershipManifest $Path $emptyReceipt } function Resolve-ProvisionalOwnedUser($Record) { @@ -1434,8 +1444,10 @@ try { throw 'initial fixture ownership authority does not match' } if ($initialActiveFixtureManifest) { - $manifestValidated = $true + $cleanupValidationPhase = 'INITIAL_INSTALLER_AUTHORITY_RECHECK' Assert-InstallerArtifactAuthority $manifest + $manifestValidated = $true + $cleanupValidationPhase = 'EMPTY_RECEIPT_WRITE' Write-EmptyOwnershipReceipt $manifestPath $manifest exit 0 } @@ -1745,8 +1757,8 @@ try { } if ($cleanupFailed) { - if ($manifestValidated) { exit 21 } Write-FixtureCleanupValidationPhase $cleanupValidationPhase + if ($manifestValidated) { exit 21 } exit 20 } exit 0 diff --git a/apps/desktop/scripts/run-installed-windows-app-harness.ps1 b/apps/desktop/scripts/run-installed-windows-app-harness.ps1 index a0353b0e1..5d623555e 100644 --- a/apps/desktop/scripts/run-installed-windows-app-harness.ps1 +++ b/apps/desktop/scripts/run-installed-windows-app-harness.ps1 @@ -939,9 +939,10 @@ function Invoke-PostTerminationCleanup([string]$InstallerPath, [string]$Authoriz [Globalization.CultureInfo]::InvariantCulture) } else { 'OTHER' } if ($fixtureNoMarkerDiagnostic) { - # The fixture protocol permits exactly one bounded phase line for exit 20. - # Exit 0 is the explicitly defined zero-byte success protocol. Any other - # child output leaves recovery authority in place and fails closed. + # The fixture protocol permits exactly one bounded phase line for + # validation exit 20 or post-validation exit 21. Exit 0 is the explicitly + # defined zero-byte success protocol. Any other child output leaves + # recovery authority in place and fails closed. $diagnosticDrainResult = $cleanupDiagnosticDrain.Finish( $WatchdogTerminationMilliseconds) if ($null -eq $diagnosticDrainResult -or @@ -960,7 +961,7 @@ function Invoke-PostTerminationCleanup([string]$InstallerPath, [string]$Authoriz Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:FAILED' return $false } - } elseif ($cleanupProcess.ExitCode -eq 20) { + } elseif ($cleanupProcess.ExitCode -in @(20,21)) { $diagnosticBytes = [byte[]]$diagnosticDrainResult.StandardOutput if ($diagnosticDrainResult.StandardOutputLines -ne 1 -or @($diagnosticBytes | Where-Object { $_ -gt 0x7f }).Count -ne 0) { @@ -974,7 +975,8 @@ function Invoke-PostTerminationCleanup([string]$InstallerPath, [string]$Authoriz '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)\r?\n\z'), + 'INITIAL_ACTIVE_MATCH|INITIAL_INSTALLER_AUTHORITY_RECHECK|' + + 'EMPTY_RECEIPT_WRITE)\r?\n\z'), [Text.RegularExpressions.RegexOptions]::CultureInvariant ) if (!$diagnosticMatch.Success) { diff --git a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 index f0bf20d88..0d010bf7f 100644 --- a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 @@ -288,7 +288,8 @@ function Get-SanitizedSupervisorMarkerDiagnostic($Result) { '(HANDSHAKE|FILE_AUTHORITY|UTF8_DECODE|JSON_PARSE|EXACT_KEY_SET|' + '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)\r?$' + 'LIFETIME|RUN_ID|INSTALLER_PATH|FIXTURE_SCOPE|INITIAL_ACTIVE_MATCH|' + + 'INITIAL_INSTALLER_AUTHORITY_RECHECK|EMPTY_RECEIPT_WRITE)\r?$' ) $cleanupValidationPhase = if ($cleanupValidationPhaseMatch.Success) { $cleanupValidationPhaseMatch.Groups[1].Value @@ -745,6 +746,9 @@ function Test-BootstrapTimeout { } function Test-WindowsPowerShellCleanupCompatibility { + # The earlier split identifier-format evidence came from this PS5.1 cleanup + # child. Current NO_MARKER exit-21 evidence belongs to the principal native + # pwsh supervisor/cleanup path exercised by Test-BootstrapTimeout. $result = Invoke-FixtureScenario 'NO_MARKER_WINDOWS_POWERSHELL' $diagnostic = Get-SanitizedSupervisorMarkerDiagnostic $result Assert-True ([string]::IsNullOrEmpty([string]$result.Error)) ` diff --git a/apps/desktop/src/release-workflow.test.ts b/apps/desktop/src/release-workflow.test.ts index 9b05d0447..3437fa378 100644 --- a/apps/desktop/src/release-workflow.test.ts +++ b/apps/desktop/src/release-workflow.test.ts @@ -753,7 +753,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'/, + /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'/, ); assert.match( installedWindowsAppCleanup, @@ -767,14 +767,22 @@ describe('desktop trusted release workflow', () => { installedWindowsAppCleanup, /\$manifest\.RunId = \[string\]\$runIdBaseObject[\s\S]*\$manifest\.InstallerProductCode = \[string\]\$installerProductCodeBaseObject/, ); + assert.match( + installedWindowsAppCleanup, + /if \(\$PSVersionTable\.PSEdition -ceq 'Core'\) \{[\s\S]*\[IO\.File\]::Move\(\$temporaryPath, \$Path, \$true\)[\s\S]*\} else \{[\s\S]*\[IO\.File\]::Replace\(\$temporaryPath, \$Path, \$null, \$true\)/, + ); assert.match( installedWindowsAppCleanup, /\[IO\.File\]::Replace\(\$temporaryPath, \$Path, \$null, \$true\)/, ); - assert.doesNotMatch( + assert.match( installedWindowsAppCleanup, /\[IO\.File\]::Move\(\$temporaryPath, \$Path, \$true\)/, ); + assert.match( + installedWindowsAppCleanup, + /\$emptyReceipt = \$Manifest\.PSObject\.Copy\(\)[\s\S]*\$emptyReceipt\.State = 'EMPTY'[\s\S]*Write-DurableOwnershipManifest \$Path \$emptyReceipt/, + ); assert.match( installedWindowsAppSupervisor, /if \(\$fixtureNoMarkerDiagnostic\)[\s\S]*-FixtureValidationDiagnostic/, @@ -800,7 +808,7 @@ describe('desktop trusted release workflow', () => { assert.match(installedWindowsAppSupervisor, /StandardErrorLineLimit = 0/); assert.match( installedWindowsAppSupervisor, - /\\ACLEANUP_VALIDATION_PHASE:[\s\S]*INITIAL_ACTIVE_MATCH\)\\r\?\\n\\z/, + /\\ACLEANUP_VALIDATION_PHASE:[\s\S]*INITIAL_INSTALLER_AUTHORITY_RECHECK\|[\s\S]*EMPTY_RECEIPT_WRITE\)\\r\?\\n\\z/, ); assert.match(installedWindowsAppSupervisor, /\$cleanupHostPath = \$hostPath/); assert.match( @@ -830,7 +838,19 @@ 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\|/, + /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/, + ); + assert.match( + installedWindowsAppSupervisor, + /\$cleanupProcess\.ExitCode -in @\(20,21\)/, + ); + assert.match( + installedWindowsAppCleanup, + /\$cleanupValidationPhase = 'INITIAL_INSTALLER_AUTHORITY_RECHECK'\n\s+Assert-InstallerArtifactAuthority \$manifest\n\s+\$manifestValidated = \$true\n\s+\$cleanupValidationPhase = 'EMPTY_RECEIPT_WRITE'\n\s+Write-EmptyOwnershipReceipt/, + ); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /earlier split identifier-format evidence came from this PS5\.1 cleanup[\s\S]*Current NO_MARKER exit-21 evidence belongs to the principal native/, ); assert.match(installedWindowsAppSupervisorBehaviorTest, /Test-WindowsPowerShellCleanupCompatibility/); assert.match(installedWindowsAppSupervisorBehaviorTest, /NO_MARKER_WINDOWS_POWERSHELL/); From 4a9d15e95605cfc21fb95ac378dbbffa02a71087 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:47:26 +0000 Subject: [PATCH 24/29] feat(ai): Implemented the PS5.1 receipt replacement correction on exact head `faf1d69041ecf7919449a7a5704fb3068162879a`. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented the PS5.1 receipt replacement correction on exact head `faf1d69041ecf7919449a7a5704fb3068162879a`. - Replaced PS5.1 `File.Replace` with same-directory `MoveFileExW` using `MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH`, with immediate Win32 error capture. [cleanup-installed-windows-app.ps1](/tmp/git-processor/worktrees/integry/propr/pr-2042-followup-2026-09-01T18-41-08/apps/desktop/scripts/cleanup-installed-windows-app.ps1:170) - Enforced existing source/destination files and identical directories; no cross-volume copy, delete-then-move, or missing-path window. - Added failure-only temporary-file cleanup while retaining canonical ACTIVE authority. - Preserved the Core `File.Move(..., overwrite)` path and exact EMPTY receipt construction. - Updated focused contract coverage and PS5.1 scenario documentation. [release-workflow.test.ts](/tmp/git-processor/worktrees/integry/propr/pr-2042-followup-2026-09-01T18-41-08/apps/desktop/src/release-workflow.test.ts:770) Validation: - Focused workflow tests: 23 passed - Desktop suite: 177 passed, 6 platform skips - Desktop typecheck: passed - `git diff --check`: passed - HEAD and ancestry unchanged; no commit created Native x64/ARM64 PS5.1 execution requires Windows CI. The replacement flags follow Microsoft’s documented [`MoveFileExW` contract](https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-movefileexw). PR: #2042 Comment by: @integry (ID: 5498668928) Model: gpt-5.6-sol --- .../scripts/cleanup-installed-windows-app.ps1 | 87 ++++++++++++++----- .../test-installed-windows-app-supervisor.ps1 | 5 +- apps/desktop/src/release-workflow.test.ts | 11 ++- 3 files changed, 76 insertions(+), 27 deletions(-) diff --git a/apps/desktop/scripts/cleanup-installed-windows-app.ps1 b/apps/desktop/scripts/cleanup-installed-windows-app.ps1 index 9653baeaf..414edbefa 100644 --- a/apps/desktop/scripts/cleanup-installed-windows-app.ps1 +++ b/apps/desktop/scripts/cleanup-installed-windows-app.ps1 @@ -166,6 +166,43 @@ public static class ProPRDirectoryIdentity public static string Read(string path) { return ReadEntry(path, true); } } + +public static class ProPRAtomicFile +{ + 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); + string temporaryDirectory = System.IO.Path.GetDirectoryName(temporaryFullPath); + string destinationDirectory = System.IO.Path.GetDirectoryName(destinationFullPath); + if (String.IsNullOrEmpty(temporaryDirectory) || + !String.Equals(temporaryDirectory, destinationDirectory, + StringComparison.OrdinalIgnoreCase) || + !System.IO.File.Exists(temporaryFullPath) || + !System.IO.File.Exists(destinationFullPath)) + { + throw new InvalidOperationException( + "atomic ownership receipt replacement precondition failed"); + } + + if (!MoveFileExW(temporaryFullPath, destinationFullPath, + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) + { + int error = Marshal.GetLastWin32Error(); + throw new Win32Exception(error, + "atomic ownership receipt replacement failed"); + } + } +} '@ function Test-SamePath([string]$Left, [string]$Right) { @@ -1012,29 +1049,37 @@ function Restore-OwnedRegistryValue($Record) { function Write-DurableOwnershipManifest([string]$Path, $Manifest) { $temporaryPath = "$Path.new" - $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 - ) + $replacementCompleted = $false try { - $stream.Write($bytes, 0, $bytes.Length) - $stream.Flush($true) + $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) + } 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) + } + $replacementCompleted = $true } finally { - $stream.Dispose() - } - if ($PSVersionTable.PSEdition -ceq 'Core') { - # Native pwsh provides the atomic same-directory overwrite overload. - [IO.File]::Move($temporaryPath, $Path, $true) - } else { - # File.Move(source, destination, overwrite) is not available on the .NET - # Framework used by Windows PowerShell 5.1. The canonical manifest exists, - # so File.Replace retains the same atomic same-volume replacement contract. - [IO.File]::Replace($temporaryPath, $Path, $null, $true) + if (!$replacementCompleted) { [IO.File]::Delete($temporaryPath) } } } diff --git a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 index 0d010bf7f..01de60672 100644 --- a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 @@ -746,9 +746,8 @@ function Test-BootstrapTimeout { } function Test-WindowsPowerShellCleanupCompatibility { - # The earlier split identifier-format evidence came from this PS5.1 cleanup - # child. Current NO_MARKER exit-21 evidence belongs to the principal native - # pwsh supervisor/cleanup path exercised by Test-BootstrapTimeout. + # This separate scenario runs the same supervisor-written initial ACTIVE + # receipt through the Windows PowerShell 5.1 cleanup reader/finalizer. $result = Invoke-FixtureScenario 'NO_MARKER_WINDOWS_POWERSHELL' $diagnostic = Get-SanitizedSupervisorMarkerDiagnostic $result Assert-True ([string]::IsNullOrEmpty([string]$result.Error)) ` diff --git a/apps/desktop/src/release-workflow.test.ts b/apps/desktop/src/release-workflow.test.ts index 3437fa378..10d46312d 100644 --- a/apps/desktop/src/release-workflow.test.ts +++ b/apps/desktop/src/release-workflow.test.ts @@ -769,16 +769,21 @@ 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]*\[IO\.File\]::Replace\(\$temporaryPath, \$Path, \$null, \$true\)/, + /if \(\$PSVersionTable\.PSEdition -ceq 'Core'\) \{[\s\S]*\[IO\.File\]::Move\(\$temporaryPath, \$Path, \$true\)[\s\S]*\} else \{[\s\S]*\[ProPRAtomicFile\]::ReplaceSameDirectory\(\$temporaryPath, \$Path\)/, ); assert.match( installedWindowsAppCleanup, - /\[IO\.File\]::Replace\(\$temporaryPath, \$Path, \$null, \$true\)/, + /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.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\) \}/, + ); assert.match( installedWindowsAppCleanup, /\$emptyReceipt = \$Manifest\.PSObject\.Copy\(\)[\s\S]*\$emptyReceipt\.State = 'EMPTY'[\s\S]*Write-DurableOwnershipManifest \$Path \$emptyReceipt/, @@ -850,7 +855,7 @@ describe('desktop trusted release workflow', () => { ); assert.match( installedWindowsAppSupervisorBehaviorTest, - /earlier split identifier-format evidence came from this PS5\.1 cleanup[\s\S]*Current NO_MARKER exit-21 evidence belongs to the principal native/, + /separate scenario runs the same supervisor-written initial ACTIVE[\s\S]*Windows PowerShell 5\.1 cleanup reader\/finalizer/, ); assert.match(installedWindowsAppSupervisorBehaviorTest, /Test-WindowsPowerShellCleanupCompatibility/); assert.match(installedWindowsAppSupervisorBehaviorTest, /NO_MARKER_WINDOWS_POWERSHELL/); From ccea5c2cd615e176541243ab08a7338e862f0f68 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:00:22 +0000 Subject: [PATCH 25/29] feat(ai): Implemented diagnostic-only changes on exact head `4a9d15e95605cfc21fb95ac378dbbffa02a71087`. Implemented diagnostic-only changes on exact head `4a9d15e95605cfc21fb95ac378dbbffa02a71087`. - ARM64 assertion now reports bounded, allowlisted process exit, MSI transaction, cleanup outcome, and provisional/nonprovisional authority state. Input is capped at 4096 bytes, 32 lines, and 192 bytes per line. - X64 retry assertion now reports validated `ExitCode`, `Result`, `ControllerStatus`, and `ReportedExitCode`, capped at 256 bytes. - Added source-contract coverage in [release-workflow.test.ts](/tmp/git-processor/worktrees/integry/propr/pr-2042-followup-2026-09-01T18-52-04/apps/desktop/src/release-workflow.test.ts:1022). - No production cleanup, cancellation, Job Object, authority, or replacement semantics changed. Validation: - Focused workflow tests: 23 passed. - Full desktop tests: 177 passed, 6 platform-specific skipped. - `git diff --check`: clean. - Native Windows execution was unavailable on this Linux host. PR: #2042 Comment by: @integry (ID: 5498804736) Model: gpt-5.6-sol --- .../test-installed-windows-app-supervisor.ps1 | 210 +++++++++++++++++- apps/desktop/src/release-workflow.test.ts | 31 +++ 2 files changed, 237 insertions(+), 4 deletions(-) diff --git a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 index 01de60672..ff9821ada 100644 --- a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 @@ -303,6 +303,205 @@ function Get-SanitizedSupervisorMarkerDiagnostic($Result) { $postTerminationOutcome, $subphase, $cleanupChildExit, $cleanupValidationPhase } +function Get-SanitizedCriticalCancellationDiagnostic($Result) { + $processExit = 0 + if (![int]::TryParse( + [string]$Result.ExitCode, + [Globalization.NumberStyles]::AllowLeadingSign, + [Globalization.CultureInfo]::InvariantCulture, + [ref]$processExit + )) { + $processExit = [int]::MinValue + } + + $msiTransaction = 'INVALID' + $postTerminationCleanup = 'INVALID' + $authorityState = 'INVALID' + $output = [string]$Result.Output + $outputByteLimit = 4096 + $outputLineLimit = 32 + $outputLineByteLimit = 192 + $protocolValid = [Text.Encoding]::UTF8.GetByteCount($output) -le $outputByteLimit + $lines = [Collections.Generic.List[string]]::new() + if ($protocolValid) { + $rawLines = @([regex]::Split($output, '\r?\n')) + $lineCount = $rawLines.Count + if ($lineCount -gt 0 -and $rawLines[$lineCount - 1] -ceq '') { + $lineCount-- + } + if ($lineCount -gt $outputLineLimit) { + $protocolValid = $false + } else { + for ($index = 0; $index -lt $lineCount; $index++) { + $line = [string]$rawLines[$index] + if ([string]::IsNullOrEmpty($line) -or + $line.IndexOf("`r", [StringComparison]::Ordinal) -ge 0 -or + [Text.Encoding]::ASCII.GetByteCount($line) -gt $outputLineByteLimit -or + [regex]::IsMatch($line, '[^\x20-\x7e]')) { + $protocolValid = $false + break + } + $lines.Add($line) + } + } + } + + if ($protocolValid) { + $msiEvents = [Collections.Generic.List[string]]::new() + $cleanupEvents = [Collections.Generic.List[string]]::new() + $authorityEvents = [Collections.Generic.List[string]]::new() + $msiPrefix = 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:MSI_TRANSACTION:' + $cleanupPrefix = + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:' + $lastValidPrefix = 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:LAST_VALID:' + $lastValidPattern = + '^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|' + + '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|' + + 'APPLICATION_WAIT|STREAM_DRAIN|EVIDENCE_INSPECTION|MSI_UNINSTALL|' + + 'INSTALL_TREE_ASSERTION|PROTOCOL_ABSENCE_ASSERTION|' + + 'APP_PATH_ABSENCE_ASSERTION|HKCU_INSTALLED_ABSENCE_ASSERTION|' + + 'SHORTCUT_FILE_ASSERTION|SHORTCUT_FOLDER_ASSERTION|' + + 'SHORTCUT_ABSENCE_PROBE|SMOKE_DATA_REMOVE|PROFILE_LOOKUP|' + + 'PROFILE_REMOVE|USER_LOOKUP|USER_REMOVE|INSTALL_ROOT_FALLBACK|' + + 'PROTOCOL_FALLBACK|APP_PATH_FALLBACK|HKCU_INSTALLED_FALLBACK|' + + 'SHORTCUT_FALLBACK):(BEGIN|COMPLETE|FAILED)$' + + foreach ($line in $lines) { + if ($line.StartsWith($msiPrefix, [StringComparison]::Ordinal)) { + $match = [regex]::Match( + $line, + '^PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:MSI_TRANSACTION:' + + '(GRACE|COMMITTED|ROLLED_BACK_CLEAN|UNPROVEN)$' + ) + if (!$match.Success) { $protocolValid = $false; break } + $msiEvents.Add($match.Groups[1].Value) + } elseif ($line.StartsWith($cleanupPrefix, [StringComparison]::Ordinal)) { + $match = [regex]::Match( + $line, + '^PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:' + + 'POST_TERMINATION_CLEANUP:(COMPLETE|FAILED|TIMED_OUT)$' + ) + if (!$match.Success) { $protocolValid = $false; break } + $cleanupEvents.Add($match.Groups[1].Value) + } elseif ($line.StartsWith($lastValidPrefix, [StringComparison]::Ordinal)) { + if ($line -ceq ($lastValidPrefix + 'NONE')) { + $authorityEvents.Add('NONE') + continue + } + $match = [regex]::Match($line, $lastValidPattern) + if (!$match.Success) { $protocolValid = $false; break } + if ($match.Groups[1].Value -ceq 'INSTALL' -and + $match.Groups[2].Value -ceq 'OWNERSHIP_CAPTURE') { + $authorityEvents.Add((switch ($match.Groups[3].Value) { + 'BEGIN' { 'PROVISIONAL' } + 'COMPLETE' { 'NONPROVISIONAL' } + 'FAILED' { 'FAILED' } + })) + } else { + $authorityEvents.Add('OTHER') + } + } + } + + if ($protocolValid) { + if ($msiEvents.Count -eq 0) { + $msiTransaction = 'NONE' + } elseif ($msiEvents.Count -eq 1 -and $msiEvents[0] -ceq 'GRACE') { + $msiTransaction = 'GRACE' + } elseif ($msiEvents.Count -eq 2 -and $msiEvents[0] -ceq 'GRACE' -and + $msiEvents[1] -cin @('COMMITTED','ROLLED_BACK_CLEAN','UNPROVEN')) { + $msiTransaction = $msiEvents[1] + } + if ($cleanupEvents.Count -eq 0) { + $postTerminationCleanup = 'NONE' + } elseif ($cleanupEvents.Count -eq 1) { + $postTerminationCleanup = $cleanupEvents[0] + } + if ($authorityEvents.Count -eq 0) { + $authorityState = 'ABSENT' + } elseif ($authorityEvents.Count -eq 1) { + $authorityState = $authorityEvents[0] + } + } + } + + $diagnostic = ('PROCESS_EXIT:{0}:MSI_TRANSACTION:{1}:' + + 'POST_TERMINATION_CLEANUP:{2}:AUTHORITY_STATE:{3}') -f ` + $processExit.ToString([Globalization.CultureInfo]::InvariantCulture), + $msiTransaction, $postTerminationCleanup, $authorityState + if ($diagnostic.IndexOf("`r", [StringComparison]::Ordinal) -ge 0 -or + $diagnostic.IndexOf("`n", [StringComparison]::Ordinal) -ge 0 -or + [Text.Encoding]::ASCII.GetByteCount($diagnostic) -gt 192) { + return ('PROCESS_EXIT:{0}:MSI_TRANSACTION:INVALID:' + + 'POST_TERMINATION_CLEANUP:INVALID:AUTHORITY_STATE:INVALID') -f ` + $processExit.ToString([Globalization.CultureInfo]::InvariantCulture) + } + return $diagnostic +} + +function Get-SanitizedWorkflowCleanupResultDiagnostic($Result) { + $processExit = 0 + if (![int]::TryParse( + [string]$Result.ExitCode, + [Globalization.NumberStyles]::AllowLeadingSign, + [Globalization.CultureInfo]::InvariantCulture, + [ref]$processExit + )) { + $processExit = [int]::MinValue + } + $reportedExitCode = 0 + if (![int]::TryParse( + [string]$Result.ReportedExitCode, + [Globalization.NumberStyles]::None, + [Globalization.CultureInfo]::InvariantCulture, + [ref]$reportedExitCode + ) -or $reportedExitCode -notin @(0,20,21,122,123,124,125)) { + $reportedExitCode = -1 + } + $resultName = if ([string]$Result.Result -cin @('COMPLETE','FAILED','TIMED_OUT')) { + [string]$Result.Result + } else { 'INVALID' } + $fixedStatuses = @( + 'CONTROLLER_FAILURE','TIMEOUT','TERMINATION_FAILURE', + 'ACTIVE_PROCESS_AFTER_ROOT_EXIT','EMPTY_OR_CLEANED', + 'MANIFEST_VALIDATION_FAILURE','OWNED_RESOURCE_CLEANUP_FAILURE', + 'PROCESS_FINALIZATION_TIMEOUT','PROCESS_FINALIZATION_FAILURE', + 'STREAM_DRAIN_TIMEOUT','CHILD_STDERR_LIMIT','CHILD_STDERR', + 'CHILD_STDOUT_LIMIT','CHILD_STDOUT','STREAM_DRAIN_FAILURE', + 'RESOURCE_FINALIZATION_FAILURE','AUTHORITY_FINALIZATION_FAILURE', + 'STARTUP_FAILURE' + ) + $controllerStatus = [string]$Result.ControllerStatus + if ($controllerStatus -cnotin $fixedStatuses -and + $controllerStatus -cnotmatch ( + '^CONTROLLER_(INITIALIZATION|PARAMETER_VALIDATION|PATH_VALIDATION|' + + 'PROCESS_START|PROCESS_WAIT|PROCESS_FINALIZATION|STREAM_FINALIZATION|' + + 'RESOURCE_FINALIZATION|AUTHORITY_FINALIZATION|RESULT_EMISSION)_' + + '(TYPE_LOAD|PARAMETERS|PATHS|START|WAIT|TERMINATE|DRAIN|DISPOSE|' + + 'AUTHORITY|EMIT)_(AUTHENTICATION|CLOSE|INVALID_ARGUMENT|INVALID_DATA|' + + 'INVALID_OPERATION|LIMIT|NOT_ENABLED|NOT_FOUND|OPEN|STOPPED|' + + 'PERMISSION|READ|BUSY|UNAVAILABLE|SECURITY|WRITE|UNCLASSIFIED)$')) { + $controllerStatus = 'INVALID' + } + $diagnostic = ('EXIT_CODE:{0}:RESULT:{1}:CONTROLLER_STATUS:{2}:' + + 'REPORTED_EXIT_CODE:{3}') -f ` + $processExit.ToString([Globalization.CultureInfo]::InvariantCulture), + $resultName, $controllerStatus, + $reportedExitCode.ToString([Globalization.CultureInfo]::InvariantCulture) + if ($diagnostic.IndexOf("`r", [StringComparison]::Ordinal) -ge 0 -or + $diagnostic.IndexOf("`n", [StringComparison]::Ordinal) -ge 0 -or + [Text.Encoding]::ASCII.GetByteCount($diagnostic) -gt 256) { + return ('EXIT_CODE:{0}:RESULT:INVALID:CONTROLLER_STATUS:INVALID:' + + 'REPORTED_EXIT_CODE:-1') -f ` + $processExit.ToString([Globalization.CultureInfo]::InvariantCulture) + } + return $diagnostic +} + function Assert-OwnedResourcesGone($Owned) { foreach ($ownedPath in @( $Owned.OwnedRoot, $Owned.InstallRoot, $Owned.ShortcutFolder, @@ -705,14 +904,15 @@ function Test-MsiTransactionInterruptionGates { 'DURING_MSI rollback did not retain the exact clean fixture baseline' $duringCapture = Invoke-CriticalCancellationScenario 'DURING_OWNERSHIP_CAPTURE' + $duringCaptureDiagnostic = Get-SanitizedCriticalCancellationDiagnostic $duringCapture Assert-True ($duringCapture.ExitCode -eq 125) ` - 'DURING_OWNERSHIP_CAPTURE cancellation did not preserve cancellation status' + "DURING_OWNERSHIP_CAPTURE cancellation did not preserve cancellation status:$duringCaptureDiagnostic" Assert-Contains $duringCapture.Output ` 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:MSI_TRANSACTION:COMMITTED' ` - 'DURING_OWNERSHIP_CAPTURE did not publish durable nonprovisional authority' + "DURING_OWNERSHIP_CAPTURE did not publish durable nonprovisional authority:$duringCaptureDiagnostic" Assert-Contains $duringCapture.Output ` 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:COMPLETE' ` - 'DURING_OWNERSHIP_CAPTURE durable authority did not complete cleanup' + "DURING_OWNERSHIP_CAPTURE durable authority did not complete cleanup:$duringCaptureDiagnostic" $capturedOwned = Read-FixtureResourceState $duringCapture.StateDirectory Assert-OwnedResourcesGone $capturedOwned } @@ -1072,9 +1272,11 @@ function Test-PreExistingCleanupOwnership { Restore-ReplacedFixtureAuthority $replacementOwned $replacementRetry = Invoke-WorkflowCleanupController ` $replacementOwned.ManifestPath $replacementOwned.RunId $replacementStateDirectory + $replacementRetryDiagnostic = + Get-SanitizedWorkflowCleanupResultDiagnostic $replacementRetry Assert-True ($replacementRetry.ExitCode -eq 0 -and $replacementRetry.Result -ceq 'COMPLETE') ` - 'standalone cleanup did not retry to exact success after authority restoration' + "standalone cleanup did not retry to exact success after authority restoration:$replacementRetryDiagnostic" Assert-OwnedResourcesGone $replacementOwned Assert-True (!(Test-Path -LiteralPath $replacementOwned.ManifestPath)) ` 'successful standalone cleanup retry did not consume recovery authority' diff --git a/apps/desktop/src/release-workflow.test.ts b/apps/desktop/src/release-workflow.test.ts index 10d46312d..79762ffcf 100644 --- a/apps/desktop/src/release-workflow.test.ts +++ b/apps/desktop/src/release-workflow.test.ts @@ -1022,6 +1022,37 @@ describe('desktop trusted release workflow', () => { installedWindowsAppSupervisorBehaviorTest, /POST_TERMINATION_CLEANUP:\{3\}:SUBPHASE:\{4\}:CLEANUP_CHILD_EXIT:\{5\}/, ); + const laterNativeDiagnostics = installedWindowsAppSupervisorBehaviorTest.slice( + installedWindowsAppSupervisorBehaviorTest.indexOf( + 'function Get-SanitizedCriticalCancellationDiagnostic', + ), + installedWindowsAppSupervisorBehaviorTest.indexOf('function Assert-OwnedResourcesGone'), + ); + assert.match(laterNativeDiagnostics, /\$outputByteLimit = 4096/); + assert.match(laterNativeDiagnostics, /\$outputLineLimit = 32/); + assert.match(laterNativeDiagnostics, /\$outputLineByteLimit = 192/); + assert.match( + laterNativeDiagnostics, + /MSI_TRANSACTION:\{1\}:' \+\s*'POST_TERMINATION_CLEANUP:\{2\}:AUTHORITY_STATE:\{3\}/, + ); + assert.match( + laterNativeDiagnostics, + /'GRACE','ROLLED_BACK_CLEAN'|GRACE\|COMMITTED\|ROLLED_BACK_CLEAN\|UNPROVEN/, + ); + assert.match(laterNativeDiagnostics, /'PROVISIONAL'[\s\S]*'NONPROVISIONAL'/); + assert.match( + laterNativeDiagnostics, + /EXIT_CODE:\{0\}:RESULT:\{1\}:CONTROLLER_STATUS:\{2\}:' \+\s*'REPORTED_EXIT_CODE:\{3\}/, + ); + assert.match(laterNativeDiagnostics, /ASCII\.GetByteCount\(\$diagnostic\) -gt 256/); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /did not publish durable nonprovisional authority:\$duringCaptureDiagnostic/, + ); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /standalone cleanup did not retry to exact success after authority restoration:\$replacementRetryDiagnostic/, + ); assert.match( installedWindowsAppSupervisor, /FIXTURE_FINALIZATION:' \+\s*'WORKER_TREE_TERMINATION:\{0\}'\) -f/, From 280786004979b3374f2b3edd2039f508ea35a3e9 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:06:15 +0000 Subject: [PATCH 26/29] feat(ai): Implemented the parser-only fix in [test-installed-windows-app-supervisor.ps1](/tmp/git-processor/worktrees/integry/propr/pr-2042-followup-2026-09-01T19-02-32/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1:399). Implemented the parser-only fix in [test-installed-windows-app-supervisor.ps1](/tmp/git-processor/worktrees/integry/propr/pr-2042-followup-2026-09-01T19-02-32/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1:399). - Captures the `switch` output first. - Requires exactly one allowlisted value: `PROVISIONAL`, `NONPROVISIONAL`, or `FAILED`. - Adds only the validated string. - No production cleanup, cancellation, diagnostics, or bounds changed. Validation: - Whole-file PowerShell 7.6.5 parser check: passed, zero errors. - Focused release workflow tests: 23/23 passed. - `git diff --check`: passed. - No commit created. PR: #2042 Comment by: @integry (ID: 5498943044) Model: gpt-5.6-sol --- .../scripts/test-installed-windows-app-supervisor.ps1 | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 index ff9821ada..a498c1d9d 100644 --- a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 @@ -396,11 +396,17 @@ function Get-SanitizedCriticalCancellationDiagnostic($Result) { if (!$match.Success) { $protocolValid = $false; break } if ($match.Groups[1].Value -ceq 'INSTALL' -and $match.Groups[2].Value -ceq 'OWNERSHIP_CAPTURE') { - $authorityEvents.Add((switch ($match.Groups[3].Value) { + $authorityEvent = @(switch ($match.Groups[3].Value) { 'BEGIN' { 'PROVISIONAL' } 'COMPLETE' { 'NONPROVISIONAL' } 'FAILED' { 'FAILED' } - })) + }) + if ($authorityEvent.Count -ne 1 -or + $authorityEvent[0] -cnotin @('PROVISIONAL','NONPROVISIONAL','FAILED')) { + $protocolValid = $false + break + } + $authorityEvents.Add([string]$authorityEvent[0]) } else { $authorityEvents.Add('OTHER') } From 6747a030420cca163499af1633c7ec98db41b08f Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:17:39 +0000 Subject: [PATCH 27/29] feat(ai): Implemented the diagnostic-only follow-up on exact head `280786004979b3374f2b3edd2039f508ea35a3e9`. Implemented the diagnostic-only follow-up on exact head `280786004979b3374f2b3edd2039f508ea35a3e9`. - Added `STARTUP_FAILURE`-only sanitized metadata with allowlisted class, signed exit, bounded line `1..999999`, and fixed `INVALID` sentinels. - Preserved the existing fields and 256-byte cap. - Added valid/invalid parser and sanitizer regressions, including non-startup exclusion. - Changed no cleanup, retry, authority, cancellation, watchdog, or replacement semantics. Files: - [test-installed-windows-app-supervisor.ps1](/tmp/git-processor/worktrees/integry/propr/pr-2042-followup-2026-09-01T19-11-16/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1:452) - [release-workflow.test.ts](/tmp/git-processor/worktrees/integry/propr/pr-2042-followup-2026-09-01T19-11-16/apps/desktop/src/release-workflow.test.ts:978) Validation: - Desktop suite: 177 passed, 6 platform-specific skipped. - Targeted workflow suite: 23 passed. - `git diff --check`: clean. - Native PowerShell execution awaits the next x64/ARM64 jobs; `pwsh` is unavailable locally. PR: #2042 Comment by: @integry (ID: 5499049144) Model: gpt-5.6-sol --- .../test-installed-windows-app-supervisor.ps1 | 134 ++++++++++++++++-- apps/desktop/src/release-workflow.test.ts | 29 +++- 2 files changed, 151 insertions(+), 12 deletions(-) diff --git a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 index a498c1d9d..0e8906e4e 100644 --- a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 @@ -493,11 +493,50 @@ function Get-SanitizedWorkflowCleanupResultDiagnostic($Result) { 'PERMISSION|READ|BUSY|UNAVAILABLE|SECURITY|WRITE|UNCLASSIFIED)$')) { $controllerStatus = 'INVALID' } + $startupDiagnostic = '' + if ($controllerStatus -ceq 'STARTUP_FAILURE') { + $startupClass = [string]$Result.StartupClass + if ($startupClass -cnotin @('PARSER','PARAMETER_BINDING','TYPE_LOAD','OTHER')) { + $startupClass = 'INVALID' + } + + $startupProcessExit = 'INVALID' + $startupProcessExitCandidate = [string]$Result.StartupProcessExit + $parsedStartupProcessExit = 0 + if ($startupProcessExitCandidate -cmatch '^(?:0|-?[1-9][0-9]*)$' -and + [int]::TryParse( + $startupProcessExitCandidate, + [Globalization.NumberStyles]::AllowLeadingSign, + [Globalization.CultureInfo]::InvariantCulture, + [ref]$parsedStartupProcessExit + )) { + $startupProcessExit = + $parsedStartupProcessExit.ToString([Globalization.CultureInfo]::InvariantCulture) + } + + $startupLine = 'INVALID' + $startupLineCandidate = [string]$Result.StartupLine + $parsedStartupLine = 0 + if ($startupLineCandidate -cmatch '^[1-9][0-9]{0,5}$' -and + [int]::TryParse( + $startupLineCandidate, + [Globalization.NumberStyles]::None, + [Globalization.CultureInfo]::InvariantCulture, + [ref]$parsedStartupLine + ) -and $parsedStartupLine -le 999999) { + $startupLine = + $parsedStartupLine.ToString([Globalization.CultureInfo]::InvariantCulture) + } + + $startupDiagnostic = (':STARTUP_CLASS:{0}:STARTUP_PROCESS_EXIT:{1}:' + + 'STARTUP_LINE:{2}') -f $startupClass, $startupProcessExit, $startupLine + } $diagnostic = ('EXIT_CODE:{0}:RESULT:{1}:CONTROLLER_STATUS:{2}:' + - 'REPORTED_EXIT_CODE:{3}') -f ` + 'REPORTED_EXIT_CODE:{3}{4}') -f ` $processExit.ToString([Globalization.CultureInfo]::InvariantCulture), $resultName, $controllerStatus, - $reportedExitCode.ToString([Globalization.CultureInfo]::InvariantCulture) + $reportedExitCode.ToString([Globalization.CultureInfo]::InvariantCulture), + $startupDiagnostic if ($diagnostic.IndexOf("`r", [StringComparison]::Ordinal) -ge 0 -or $diagnostic.IndexOf("`n", [StringComparison]::Ordinal) -ge 0 -or [Text.Encoding]::ASCII.GetByteCount($diagnostic) -gt 256) { @@ -508,6 +547,16 @@ function Get-SanitizedWorkflowCleanupResultDiagnostic($Result) { return $diagnostic } +function Get-WorkflowCleanupControllerStatusMatch([string]$StatusLine) { + return [regex]::Match( + $StatusLine, + ('^PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:STATUS:([A-Z_]+):' + + 'EXIT_CODE:([0-9]+)(?::STARTUP_CLASS:' + + '(PARSER|PARAMETER_BINDING|TYPE_LOAD|OTHER):PROCESS_EXIT:(-?[0-9]+):' + + 'LINE:([0-9]+))?$') + ) +} + function Assert-OwnedResourcesGone($Owned) { foreach ($ownedPath in @( $Owned.OwnedRoot, $Owned.InstallRoot, $Owned.ShortcutFolder, @@ -692,13 +741,7 @@ function Invoke-WorkflowCleanupController( $lineCount, $stderrCount, $startupDiagnostic) } $resultName = $resultMatch.Groups[1].Value - $statusMatch = [regex]::Match( - $outputLines[1], - ('^PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:STATUS:([A-Z_]+):' + - 'EXIT_CODE:([0-9]+)(?::STARTUP_CLASS:' + - '(PARSER|PARAMETER_BINDING|TYPE_LOAD|OTHER):PROCESS_EXIT:(-?[0-9]+):' + - 'LINE:([0-9]+))?$') - ) + $statusMatch = Get-WorkflowCleanupControllerStatusMatch $outputLines[1] if (!$statusMatch.Success) { $startupDiagnostic = Get-SanitizedControllerStartupDiagnostic ` $errorOutput ([int]$process.ExitCode) @@ -742,9 +785,80 @@ function Test-WorkflowCleanupStartupProtocol { $result.ControllerStatus -ceq 'STARTUP_FAILURE' -and $result.StartupClass -ceq $failureClass -and $result.StartupProcessExit -match '^-?[0-9]+$' -and - $result.StartupLine -match '^[0-9]+$') ` + $result.StartupLine -match '^[1-9][0-9]{0,5}$') ` "native $failureClass startup fixture did not emit the fixed two-line protocol" + $startupDiagnostic = Get-SanitizedWorkflowCleanupResultDiagnostic $result + $expectedStartupDiagnostic = (( + 'EXIT_CODE:125:RESULT:FAILED:CONTROLLER_STATUS:STARTUP_FAILURE:' + + 'REPORTED_EXIT_CODE:125:STARTUP_CLASS:{0}:STARTUP_PROCESS_EXIT:{1}:' + + 'STARTUP_LINE:{2}') -f ` + $failureClass, $result.StartupProcessExit, $result.StartupLine) + Assert-True ($startupDiagnostic -ceq $expectedStartupDiagnostic) ` + "native $failureClass startup metadata was not preserved by the bounded diagnostic" + } + + foreach ($invalidStatusLine in @( + 'PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:STATUS:STARTUP_FAILURE:EXIT_CODE:125:STARTUP_CLASS:INVALID:PROCESS_EXIT:125:LINE:12', + 'PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:STATUS:STARTUP_FAILURE:EXIT_CODE:125:STARTUP_CLASS:PARSER:PROCESS_EXIT:+125:LINE:12', + 'PROPR_WINDOWS_INSTALLED_SMOKE:WORKFLOW_CLEANUP:STATUS:STARTUP_FAILURE:EXIT_CODE:125:STARTUP_CLASS:PARSER:PROCESS_EXIT:125:LINE:-1' + )) { + Assert-True (!(Get-WorkflowCleanupControllerStatusMatch $invalidStatusLine).Success) ` + 'workflow cleanup parser accepted malformed startup metadata' + } + + $validStartupMetadata = [PSCustomObject]@{ + ExitCode = 125 + Result = 'FAILED' + ControllerStatus = 'STARTUP_FAILURE' + ReportedExitCode = 125 + StartupClass = 'PARSER' + StartupProcessExit = '-2147483648' + StartupLine = '999999' + } + Assert-True ((Get-SanitizedWorkflowCleanupResultDiagnostic $validStartupMetadata) -ceq ( + 'EXIT_CODE:125:RESULT:FAILED:CONTROLLER_STATUS:STARTUP_FAILURE:' + + 'REPORTED_EXIT_CODE:125:STARTUP_CLASS:PARSER:' + + 'STARTUP_PROCESS_EXIT:-2147483648:STARTUP_LINE:999999' + )) 'valid bounded startup metadata was not preserved' + + foreach ($invalidStartupMetadata in @( + [PSCustomObject]@{}, + [PSCustomObject]@{ + StartupClass = 'parser' + StartupProcessExit = '+125' + StartupLine = '0' + }, + [PSCustomObject]@{ + StartupClass = "PARSER`nDISCLOSURE" + StartupProcessExit = '2147483648' + StartupLine = '1000000' + } + )) { + $invalidStartupMetadata | Add-Member -NotePropertyName ExitCode -NotePropertyValue 125 + $invalidStartupMetadata | Add-Member -NotePropertyName Result -NotePropertyValue 'FAILED' + $invalidStartupMetadata | Add-Member ` + -NotePropertyName ControllerStatus -NotePropertyValue 'STARTUP_FAILURE' + $invalidStartupMetadata | Add-Member -NotePropertyName ReportedExitCode -NotePropertyValue 125 + Assert-True ((Get-SanitizedWorkflowCleanupResultDiagnostic $invalidStartupMetadata) -ceq ( + 'EXIT_CODE:125:RESULT:FAILED:CONTROLLER_STATUS:STARTUP_FAILURE:' + + 'REPORTED_EXIT_CODE:125:STARTUP_CLASS:INVALID:' + + 'STARTUP_PROCESS_EXIT:INVALID:STARTUP_LINE:INVALID' + )) 'invalid startup metadata did not fail closed to fixed sentinels' + } + + $nonStartupMetadata = [PSCustomObject]@{ + ExitCode = 21 + Result = 'FAILED' + ControllerStatus = 'OWNED_RESOURCE_CLEANUP_FAILURE' + ReportedExitCode = 21 + StartupClass = "PARSER`nDISCLOSURE" + StartupProcessExit = 'not-an-exit' + StartupLine = 'not-a-line' } + Assert-True ((Get-SanitizedWorkflowCleanupResultDiagnostic $nonStartupMetadata) -ceq ( + 'EXIT_CODE:21:RESULT:FAILED:' + + 'CONTROLLER_STATUS:OWNED_RESOURCE_CLEANUP_FAILURE:REPORTED_EXIT_CODE:21' + )) 'non-startup cleanup diagnostic included startup-only metadata' Write-Host 'PROPR_WINDOWS_SUPERVISOR_CONTROLLER_STARTUP:FIXED_PROTOCOL:PASSED' [Console]::Out.Flush() } diff --git a/apps/desktop/src/release-workflow.test.ts b/apps/desktop/src/release-workflow.test.ts index 79762ffcf..bf0a28a03 100644 --- a/apps/desktop/src/release-workflow.test.ts +++ b/apps/desktop/src/release-workflow.test.ts @@ -975,8 +975,12 @@ describe('desktop trusted release workflow', () => { installedWindowsAppSupervisorFixture, /'OWNED_RESOURCES_THEN_DEADLINE' \{[\s\S]*Write-FixtureMarker[\s\S]*New-OwnedFixtureResources/, ); + const controllerStatusParser = installedWindowsAppSupervisorBehaviorTest.indexOf( + '$statusMatch = Get-WorkflowCleanupControllerStatusMatch', + ); + assert.notEqual(controllerStatusParser, -1); assert.ok( - installedWindowsAppSupervisorBehaviorTest.indexOf('$statusMatch = [regex]::Match(') + controllerStatusParser < installedWindowsAppSupervisorBehaviorTest.indexOf('if ($errorOutput.Length -ne 0)'), 'controller fixed stdout must be parsed before bounded stderr classification', ); @@ -1042,9 +1046,30 @@ describe('desktop trusted release workflow', () => { assert.match(laterNativeDiagnostics, /'PROVISIONAL'[\s\S]*'NONPROVISIONAL'/); assert.match( laterNativeDiagnostics, - /EXIT_CODE:\{0\}:RESULT:\{1\}:CONTROLLER_STATUS:\{2\}:' \+\s*'REPORTED_EXIT_CODE:\{3\}/, + /EXIT_CODE:\{0\}:RESULT:\{1\}:CONTROLLER_STATUS:\{2\}:' \+\s*'REPORTED_EXIT_CODE:\{3\}\{4\}/, ); assert.match(laterNativeDiagnostics, /ASCII\.GetByteCount\(\$diagnostic\) -gt 256/); + assert.match(laterNativeDiagnostics, /if \(\$controllerStatus -ceq 'STARTUP_FAILURE'\)/); + assert.match( + laterNativeDiagnostics, + /\$startupClass -cnotin @\('PARSER','PARAMETER_BINDING','TYPE_LOAD','OTHER'\)/, + ); + assert.match(laterNativeDiagnostics, /\$startupProcessExit = 'INVALID'/); + assert.match(laterNativeDiagnostics, /\$startupLine = 'INVALID'/); + assert.match(laterNativeDiagnostics, /\^\[1-9\]\[0-9\]\{0,5\}\$/); + assert.match(laterNativeDiagnostics, /\$parsedStartupLine -le 999999/); + assert.match( + laterNativeDiagnostics, + /STARTUP_CLASS:\{0\}:STARTUP_PROCESS_EXIT:\{1\}:' \+\s*'STARTUP_LINE:\{2\}/, + ); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /Get-WorkflowCleanupControllerStatusMatch[\s\S]*workflow cleanup parser accepted malformed startup metadata/, + ); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /valid bounded startup metadata was not preserved[\s\S]*invalid startup metadata did not fail closed to fixed sentinels[\s\S]*non-startup cleanup diagnostic included startup-only metadata/, + ); assert.match( installedWindowsAppSupervisorBehaviorTest, /did not publish durable nonprovisional authority:\$duringCaptureDiagnostic/, From 01b133f5131af2f569b7f0b252566cfdddee9dc8 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:26:46 +0000 Subject: [PATCH 28/29] feat(ai): Implemented the exact parser-only fix. Implemented the exact parser-only fix. - Split cleanup-worker path construction and resolution into two syntactically complete lines while preserving `-LiteralPath`, `-ErrorAction Stop`, and path semantics. - Added a whole-file AST parser regression to the native `pwsh` supervisor suite. - Preserved the existing Windows PowerShell 5.1 compatibility scenario. - No cleanup or lifecycle semantics changed. Validation: - Release-workflow tests: 23/23 passed. - `git diff --check`: passed. - Native x64/ARM64 execution was unavailable in this Linux environment, so no later bounded assertion was observed. - Changes remain uncommitted as requested. PR: #2042 Comment by: @integry (ID: 5499191816) Model: gpt-5.6-sol --- ...nstalled-windows-app-workflow-cleanup-body.ps1 | 4 ++-- .../test-installed-windows-app-supervisor.ps1 | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup-body.ps1 b/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup-body.ps1 index 2f331ff0d..76e6eeeea 100644 --- a/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup-body.ps1 +++ b/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup-body.ps1 @@ -363,8 +363,8 @@ $TerminationTimeoutMilliseconds = $terminationTimeout } $validatedManifestPath = $manifestPath $installerPath = (Resolve-Path -LiteralPath $Installer -ErrorAction Stop).Path - $cleanupWorkerPath = (Resolve-Path -LiteralPath - (Join-Path $PSScriptRoot 'cleanup-installed-windows-app.ps1') -ErrorAction Stop).Path + $cleanupWorkerCandidatePath = Join-Path $PSScriptRoot 'cleanup-installed-windows-app.ps1' + $cleanupWorkerPath = (Resolve-Path -LiteralPath $cleanupWorkerCandidatePath -ErrorAction Stop).Path $hostPath = (Get-Process -Id $PID -ErrorAction Stop).Path if ([IO.Path]::GetFileName($hostPath) -notin @('pwsh.exe', 'powershell.exe')) { throw 'PowerShell host resolution failed' diff --git a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 index 0e8906e4e..e76a10ecb 100644 --- a/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 @@ -36,6 +36,20 @@ function Assert-NotContains([string]$Text, [string]$Forbidden, [string]$Message) Assert-True (!$Text.Contains($Forbidden, [StringComparison]::OrdinalIgnoreCase)) $Message } +function Test-WorkflowCleanupBodyParserRegression { + $cleanupBodyPath = Join-Path $PSScriptRoot ` + 'run-installed-windows-app-workflow-cleanup-body.ps1' + $tokens = $null + $parseErrors = $null + [void][System.Management.Automation.Language.Parser]::ParseFile( + $cleanupBodyPath, + [ref]$tokens, + [ref]$parseErrors + ) + Assert-True ($parseErrors.Count -eq 0) ` + 'workflow cleanup production body failed whole-file parser regression' +} + function New-StateDirectory([string]$Name) { $path = Join-Path $testRoot $Name [void](New-Item -ItemType Directory -Path $path -ErrorAction Stop) @@ -2352,6 +2366,7 @@ $actualArchitecture = [Runtime.InteropServices.RuntimeInformation]::ProcessArchi Assert-True ($actualArchitecture -ceq $Architecture) ` "supervisor behavior tests expected $Architecture but are running on $actualArchitecture" +Test-WorkflowCleanupBodyParserRegression [void](New-Item -ItemType Directory -Path $testRoot -ErrorAction Stop) Initialize-TestInstaller try { From 4fda55411000bc8a0d5a85daed372b9eff6c703f Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:11:53 +0000 Subject: [PATCH 29/29] fix(ai): Resolve issue #2078 - Restore canonical qs and fast-uri runtime lock res Implemented by ProPR AI using gpt-5.6-sol model. Implementation completed successfully. --- package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index cc0fb8e58..427f433d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7421,9 +7421,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", + "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", "funding": [ { "type": "github", @@ -12013,9 +12013,9 @@ } }, "node_modules/qs": { - "version": "6.15.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", - "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", "dependencies": { "es-define-property": "^1.0.1",