diff --git a/.github/workflows/desktop-release-guard.yml b/.github/workflows/desktop-release-guard.yml index 3b5f93e63..d121602e4 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: | @@ -186,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' } - & apps/desktop/scripts/test-installed-windows-app.ps1 ` + $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 @@ -422,6 +446,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: | @@ -624,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' } - & apps/desktop/scripts/test-installed-windows-app.ps1 ` + $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 new file mode 100644 index 000000000..414edbefa --- /dev/null +++ b/apps/desktop/scripts/cleanup-installed-windows-app.ps1 @@ -0,0 +1,1809 @@ +param( + [Parameter(Mandatory=$true)][string]$OwnershipManifest, + [Parameter(Mandatory=$true)][string]$Installer, + [Parameter(Mandatory=$true)][string]$ExpectedRunId, + [Parameter(Mandatory=$true)][string]$OwnershipReadyEvent, + [string]$FixtureRoot, + [switch]$FixtureValidationDiagnostic, + [switch]$FixtureEarlyInitializationChild +) + +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +$ownerFileName = '.propr-installed-app-owner' +$ownerRegistryValue = 'ProPRInstalledAppOwner' +$cleanupFailed = $false +$manifestValidated = $false +$authorizedRunId = $null +$cleanupValidationPhase = 'HANDSHAKE' +$cleanupValidationPhases = @( + 'HANDSHAKE','FILE_AUTHORITY','UTF8_DECODE','JSON_PARSE','EXACT_KEY_SET', + 'BOOLEAN_TYPES','TRANSACTION_ENUM','SCHEMA_TYPE_STATE','RUN_ID_FORMAT', + 'INSTALLER_ENTRY_ID_FORMAT','INSTALLER_SHA256_FORMAT','INSTALLER_PRODUCT_CODE_FORMAT', + 'LIFETIME','RUN_ID','INSTALLER_PATH','FIXTURE_SCOPE','INITIAL_ACTIVE_MATCH', + 'INITIAL_INSTALLER_AUTHORITY_RECHECK','EMPTY_RECEIPT_WRITE' +) + +function Write-FixtureCleanupValidationPhase([string]$Phase) { + if (!$FixtureValidationDiagnostic -or !$FixtureRoot -or + $cleanupValidationPhases -cnotcontains $Phase) { + return + } + # 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 + ) + [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-CleanupHandshakeFailure } + if ($OwnershipReadyEvent -notmatch '^Local\\ProPRInstalledAppCleanup-[a-f0-9]{32}$') { + Exit-CleanupHandshakeFailure + } + $ownershipReady = [Threading.EventWaitHandle]::OpenExisting($OwnershipReadyEvent) + try { + if (!$ownershipReady.WaitOne(5000)) { Exit-CleanupHandshakeFailure } + } finally { + $ownershipReady.Dispose() + } +} catch { + Exit-CleanupHandshakeFailure +} + +# 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; +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 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( + path, 0x80, 0x7, IntPtr.Zero, 3, 0x02200000, IntPtr.Zero)) + { + if (handle == null || handle.IsInvalid) + throw new Win32Exception(Marshal.GetLastWin32Error(), "directory identity open failed"); + return ReadHandle(handle, expectDirectory); + } + } + + 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) { + 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" + } + } + $resolved = (Resolve-Path -LiteralPath $fullPath -ErrorAction Stop).ProviderPath.TrimEnd('\') + if (![string]::Equals( + [IO.Path]::GetFullPath($resolved).TrimEnd('\'), + $fullPath, + [StringComparison]::OrdinalIgnoreCase + )) { + throw "$Label path is not canonical" + } + 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) { + $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 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-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-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 ($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 + } 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 + $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 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 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]$ProductCode) { + $installerCom = $null + try { + 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) { + 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-InstallerArtifactAuthority $Manifest + Assert-MsiProductIsUnregistered ([string]$Manifest.InstallerProductCode) +} + +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-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' + $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 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' } + 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' + } + 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' + } + $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' } +} + +function Remove-OwnedFile($Record) { + 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' + } + 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 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' } +} + +function Remove-OwnedRegistryKey($Record) { + if (!$Record.Owned) { return } + $path = [string]$Record.Path + $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 (!$productionPaths.ContainsKey($kind) -or + ![string]::Equals($path, $productionPaths[$kind], [StringComparison]::OrdinalIgnoreCase)) { + throw 'registry cleanup scope is invalid' + } + if (!(Test-Path -LiteralPath $path)) { return } + 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' } + 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 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 ([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' + } + + 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-DurableOwnershipManifest([string]$Path, $Manifest) { + $temporaryPath = "$Path.new" + $replacementCompleted = $false + try { + $bytes = [Text.Encoding]::UTF8.GetBytes(( + $Manifest | ConvertTo-Json -Depth 6 -Compress + )) + $stream = [IO.FileStream]::new( + $temporaryPath, + [IO.FileMode]::Create, + [IO.FileAccess]::Write, + [IO.FileShare]::None, + 4096, + [IO.FileOptions]::WriteThrough + ) + try { + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush($true) + } finally { + $stream.Dispose() + } + if ($PSVersionTable.PSEdition -ceq 'Core') { + # Native pwsh provides the atomic same-directory overwrite overload. + [IO.File]::Move($temporaryPath, $Path, $true) + } 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 { + if (!$replacementCompleted) { [IO.File]::Delete($temporaryPath) } + } +} + +function Write-EmptyOwnershipReceipt([string]$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) { + 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 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-ValidatedOwnedProfilePath ` + ([string]$profile.LocalPath) $name + 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}$') { + throw 'owned local-user identity is invalid' + } + $sid = [string]$UserRecord.Sid + if ($sid -notmatch '^S-\d+(?:-\d+)+$') { + 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 { + $_.SID -ceq $sid + }) + if ($profiles.Count -eq 0) { return } + try { + foreach ($profile in $profiles) { + 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 { + if ($attempt -eq 9) { throw } + Start-Sleep -Milliseconds 500 + } + } + throw 'owned profile cleanup did not complete' +} + +function Remove-ExplicitOwnedProfile($Record, $UserRecord) { + if (!$Record.Owned) { return } + $sid = [string]$Record.Sid + $localPath = [string]$Record.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-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 + } +} + +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 } + $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+)+$') { + 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 + if (Get-LocalUser -Name $name -ErrorAction SilentlyContinue) { + throw 'owned local-user cleanup did not complete' + } +} + +try { + $cleanupValidationPhase = 'FILE_AUTHORITY' + $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' + } + # 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]'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( + $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' } + $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_DECODE' + $strictUtf8 = [Text.UTF8Encoding]::new($false, $true) + $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', + '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) { + 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 -cnotin @('ACTIVE','EMPTY')) { + throw 'ownership manifest schema version, type, or state is 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 + ([string]$manifest.MsiTransactionState -in @( + 'PENDING','COMMITTED','ROLLED_BACK_CLEAN' + ) -and (!([bool]$manifest.BaselineClean) -or + !([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 + 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' + } + $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)) { + throw 'ownership manifest fixture scope is invalid' + } + } elseif ($manifest.Fixture) { + 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. + $cleanupValidationPhase = 'INITIAL_ACTIVE_MATCH' + $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 ($FixtureValidationDiagnostic -and !$initialActiveFixtureManifest) { + throw 'initial fixture ownership authority does not match' + } + if ($initialActiveFixtureManifest) { + $cleanupValidationPhase = 'INITIAL_INSTALLER_AUTHORITY_RECHECK' + Assert-InstallerArtifactAuthority $manifest + $manifestValidated = $true + $cleanupValidationPhase = 'EMPTY_RECEIPT_WRITE' + 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 + @($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 + } + + foreach ($record in @($manifest.Directories)) { + if ($record.Owned -and + !(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 + !(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 + $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' + } + if ($record.Owned -and !$record.Provisional -and + [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 + ![IO.Path]::IsPathRooted([string]$record.LocalPath))) { + throw 'profile manifest identity is invalid' + } + } + + $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 + $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 ([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' + } + } + } + foreach ($record in @($manifest.RegistryValues)) { + $recordKeys = @($record.PSObject.Properties | ForEach-Object { $_.Name }) + $expectedRecordKeys = @( + 'Kind','Path','Name','Owned','Provisional','BaselineKeyExisted', + 'BaselineValueExisted','BaselineValueKind','BaselineValueData', + 'IdentityValueKind','IdentityValueData','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 ($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 + @($manifest.RegistryValues).Count -ne 1) -or + ($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 + # 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' + } + 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 + } + } + $ownershipPromoted = $false + foreach ($record in @($manifest.Users)) { + if (Resolve-ProvisionalOwnedUser $record) { $ownershipPromoted = $true } + if (Promote-UncapturedOwnedProfiles $record $manifest) { + $ownershipPromoted = $true + } + } + if ($ownershipPromoted) { + 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 -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 ([string]$manifest.MsiTransactionState -ceq 'COMMITTED') { + Assert-MsiManagedFileSystemAuthority $manifest + } + 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 } + Assert-MsiManagedFileSystemAuthority $manifest + Assert-InstallerArtifactAuthority $manifest + $msi = Start-Process msiexec.exe -ArgumentList @( + '/x', [string]$manifest.InstallerProductCode, '/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 } catch { $cleanupFailed = $true } + } + foreach ($record in @($manifest.RegistryKeys)) { + try { Remove-OwnedRegistryKey $record } catch { $cleanupFailed = $true } + } + foreach ($record in @($manifest.RegistryValues)) { + try { Restore-OwnedRegistryValue $record } catch { $cleanupFailed = $true } + } + $profileCleanupFailed = $false + foreach ($record in @($manifest.Profiles)) { + 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 + } + } + foreach ($record in @($manifest.Users)) { + try { Remove-OwnedProfiles $record $manifest.Profiles } catch { + $profileCleanupFailed = $true + $cleanupFailed = $true + } + } + if (!$profileCleanupFailed) { + 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 } catch { + $cleanupFailed = $true + } + } + if (!$cleanupFailed) { Write-EmptyOwnershipReceipt $manifestPath $manifest } +} catch { + $cleanupFailed = $true +} + +if ($cleanupFailed) { + 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 new file mode 100644 index 000000000..5d623555e --- /dev/null +++ b/apps/desktop/scripts/run-installed-windows-app-harness.ps1 @@ -0,0 +1,1276 @@ +param( + [Parameter(Mandatory=$true)][string]$Installer, + [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(1000,600000)][int]$PostTerminationCleanupMilliseconds = 4 * 60 * 1000, + [ValidateRange(1,5000)][int]$MarkerReadTimeoutMilliseconds = 250, + [string]$CancellationEventName, + [string]$FixtureCleanupRoot, + [string]$OwnershipManifest, + [string]$ExpectedRunId, + [switch]$InjectTerminationFailure +) + +$ErrorActionPreference = 'Stop' +$maximumMarkerDeadlineMilliseconds = 11 * 60 * 1000 +$msiCriticalTransactionGraceMilliseconds = 30 * 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', + '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' +) +$markerName = "propr-installed-app-watchdog-$([Guid]::NewGuid().ToString('N')).marker" +$markerPath = Join-Path ([IO.Path]::GetTempPath()) $markerName +$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' +$worker = $null +$job = $null +$ownershipReadyEvent = $null +$cancellationEvent = $null +$lastValidMarker = $null +$exitCode = 125 +$terminateOwnedTree = $false +$workerStarted = $false +$supervisorOutcomeComplete = $false +$postTerminationCleanupAuthorized = $true +$fixtureNoMarkerDiagnostic = $false +$fixtureWindowsPowerShellCleanup = $false +$fixtureWorkerTreeTerminationOutcome = 'FAILED' +$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; + +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); + + [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); + 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"); + } + + 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 == 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() + { + if (handle != null) handle.Dispose(); + } +} + +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, + 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)); + } + + 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); } + } +} + +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) { + $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() +} + +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 -eq $job) { return $false } + if ($InjectTerminationFailure) { + try { + $job.Dispose() + $script:job = $null + if ($null -ne $worker) { + [void]$worker.WaitForExit($WatchdogTerminationMilliseconds) + } + } catch {} + return $false + } + 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 ($null -ne $job) { + $job.Dispose() + $script:job = $null + } + if ($null -ne $worker) { + [void]$worker.WaitForExit($WatchdogTerminationMilliseconds) + } + } catch {} + return $false + } +} + +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, + [bool]$Fixture, + [string]$AuthorizedFixtureRoot +) { + $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 = $identifiers.RunId + CreatedUtcTicks = $createdUtcTicks + ExpiresUtcTicks = $createdUtcTicks + ([TimeSpan]::TicksPerHour * 3) + InstallerPath = [string]$InstallerAuthority.Path + InstallerEntryIdentity = $identifiers.InstallerEntryIdentity + InstallerSha256 = $identifiers.InstallerSha256 + InstallerProductCode = $identifiers.InstallerProductCode + Fixture = $Fixture + FixtureRoot = if ($Fixture) { $AuthorizedFixtureRoot } else { $null } + BaselineClean = $false + InstallAttempted = $false + MsiTransactionState = 'NONE' + Directories = @() + Files = @() + RegistryKeys = @() + RegistryValues = @() + Users = @() + Profiles = @() + } + $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, + [IO.FileAccess]::Write, + [IO.FileShare]::Read, + 4096, + [IO.FileOptions]::WriteThrough + ) + try { + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush($true) + } finally { + $stream.Dispose() + } +} + +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 + $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 + !$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 + $cleanupReadyEvent = $null + $cleanupDiagnosticDrain = $null + try { + $cleanupReadyEventName = "Local\ProPRInstalledAppCleanup-$([Guid]::NewGuid().ToString('N'))" + $cleanupReadyEvent = [Threading.EventWaitHandle]::new( + $false, + [Threading.EventResetMode]::ManualReset, + $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 ($fixtureWindowsPowerShellCleanup) { + $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 @( + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-File', $cleanupWorkerPath, + '-OwnershipManifest', $ownershipManifestPath, + '-Installer', $InstallerPath, + '-ExpectedRunId', $ownershipRunId, + '-OwnershipReadyEvent', $cleanupReadyEventName + )) { + $cleanupStartInfo.ArgumentList.Add($argument) + } + if ($AuthorizedFixtureRoot) { + $cleanupStartInfo.ArgumentList.Add('-FixtureRoot') + $cleanupStartInfo.ArgumentList.Add($AuthorizedFixtureRoot) + } + 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' + } + if (!$cleanupProcess.WaitForExit($PostTerminationCleanupMilliseconds)) { + $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 + } + $script:fixtureCleanupChildExitCategory = if ($cleanupProcess.ExitCode -in @(0,20,21)) { + ([int]$cleanupProcess.ExitCode).ToString( + [Globalization.CultureInfo]::InvariantCulture) + } else { 'OTHER' } + if ($fixtureNoMarkerDiagnostic) { + # 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 + $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 -in @(20,21)) { + $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_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|INITIAL_INSTALLER_AUTHORITY_RECHECK|' + + 'EMPTY_RECEIPT_WRITE)\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 + } + 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 { + foreach ($resource in @( + $cleanupDiagnosticDrain, $cleanupJob, $cleanupProcess, $cleanupReadyEvent + )) { + if ($null -ne $resource) { try { $resource.Dispose() } catch {} } + } + } +} + +try { + $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' + } + $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 + $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 + $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' + } + 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' + } + if ($CancellationEventName) { + if ($CancellationEventName -notmatch '^Local\\ProPRInstalledAppCancellation-[a-f0-9]{32}$') { + throw 'supervisor cancellation event name is invalid' + } + $cancellationEvent = [Threading.EventWaitHandle]::OpenExisting($CancellationEventName) + } + Write-InitialOwnershipManifest ` + $ownershipManifestPath $installerAuthority (!$usingProductionWorker) $FixtureCleanupRoot + + $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', $selectedWorkerPath, + '-Installer', $installerPath, + '-Architecture', $Architecture, + '-WatchdogMarker', $markerPath, + '-OwnershipReadyEvent', $ownershipReadyEventName, + '-OwnershipManifest', $ownershipManifestPath + )) { + $startInfo.ArgumentList.Add($argument) + } + + $job = [ProPRKillOnCloseJob]::new() + $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) + [void]$ownershipReadyEvent.Set() + } catch { + try { $worker.Kill($true) } catch {} + throw 'installed-app worker ownership failed' + } + + $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 + } + + $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 + } + } + $marker = Read-WatchdogMarker $markerPath ([Math]::Max(1, $readTimeout)) + if ($marker.State -eq 'Valid' -and !(Test-WatchdogMarkerSchema $marker)) { + $marker = [PSCustomObject]@{ State = 'Invalid' } + } + + 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 + if (Test-MsiCriticalMarker $marker) { + $postTerminationCleanupAuthorized = Wait-MsiCriticalTransactionReceipt + } + $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 + $supervisorOutcomeComplete = $exitCode -eq 0 + break + } + } +} catch { + Write-WatchdogLine 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:SUPERVISOR:FAILED' + $exitCode = 125 + $terminateOwnedTree = $true +} finally { + $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) { + # 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 ($fixtureNoMarkerDiagnostic) { + $fixtureWorkerTreeTerminationOutcome = if ($workerTreeTerminated) { + 'COMPLETE' + } else { 'FAILED' } + } + if ($workerTreeTerminated -and $postTerminationCleanupAuthorized) { + $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 } + } + + 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 + (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' + } + + 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 {} + if ($fixedCleanupResult -eq $true -and !$workflowManagedManifest) { + 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/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..76e6eeeea --- /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 + $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' + } + $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 new file mode 100644 index 000000000..e96daa0e8 --- /dev/null +++ b/apps/desktop/scripts/run-installed-windows-app-workflow-cleanup.ps1 @@ -0,0 +1,90 @@ +param( + [object]$OwnershipManifest, + [object]$Installer, + [object]$ExpectedRunId, + [object]$CleanupTimeoutMilliseconds = 4 * 60 * 1000, + [object]$TerminationTimeoutMilliseconds = 30 * 1000, + [object]$FixtureRoot, + [object]$FixtureEarlyInitializationChild, + [object]$StartupFailureClass +) + +$ErrorActionPreference = 'Stop' +$bodyPath = Join-Path $PSScriptRoot 'run-installed-windows-app-workflow-cleanup-body.ps1' + +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' + } + if ($exception -is [TypeLoadException] -or + $exception -is [TypeInitializationException] -or + $exception -is [IO.FileLoadException]) { + return 'TYPE_LOAD' + } + $exception = $exception.InnerException + } + return 'OTHER' +} + +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 { + 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') } + } + } + $bodyParameters = @{ + OwnershipManifest = $OwnershipManifest + Installer = $Installer + ExpectedRunId = $ExpectedRunId + CleanupTimeoutMilliseconds = $CleanupTimeoutMilliseconds + TerminationTimeoutMilliseconds = $TerminationTimeoutMilliseconds + FixtureRoot = $FixtureRoot + } + if ([bool]$FixtureEarlyInitializationChild) { + $bodyParameters.FixtureEarlyInitializationChild = $true + } + $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') + } + exit $bodyExitCode +} catch { + Write-StartupFailure $_ + exit 125 +} 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..ee1de9bb9 --- /dev/null +++ b/apps/desktop/scripts/test-installed-windows-app-supervisor-fixture.ps1 @@ -0,0 +1,1016 @@ +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]$OwnershipManifest +) + +$ErrorActionPreference = 'Stop' + +function Initialize-FixtureDirectoryIdentity { + if ('ProPRFixtureDirectoryIdentity' -as [type]) { return } + 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 ReadEntry(string path, bool expectDirectory) + { + 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()); + bool isDirectory = (information.FileAttributes & 0x10) != 0; + if ((information.FileAttributes & 0x400) != 0 || + 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 @( + 'NO_MARKER', + 'NO_MARKER_WINDOWS_POWERSHELL', + 'VALID_THEN_DEADLINE', + 'MALFORMED_MARKER', + 'TORN_MARKER', + 'STALE_MARKER', + 'INACCESSIBLE_MARKER', + 'NEGATIVE_EXIT', + 'CANCELLATION', + 'DURING_MSI', + 'DURING_OWNERSHIP_CAPTURE', + '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_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' + )) { + 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 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-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( + $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 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 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') + $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', + [bool]$PublishCommittedReceipt = $true +) { + Initialize-FixtureDirectoryIdentity + $manifest = [IO.File]::ReadAllText($OwnershipManifest, [Text.Encoding]::UTF8) | + ConvertFrom-Json -ErrorAction Stop + 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' + } + $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' + [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)) { + [void](New-Item -ItemType Directory -Path $directory -Force -ErrorAction Stop) + Write-FixtureOwnershipToken (Join-Path $directory '.propr-installed-app-owner') $token + } + [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" + [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' + } + $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 + + $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 + 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 = @( + $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 = 'FIXTURE_FILE'; Path = $executable + Owned = $true; Token = $null + 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) + EntryIdentity = (Get-FixtureEntryIdentity $shortcut $false) + Provisional = $false + } + ) + 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 } + ) + $manifest.RegistryValues = @() + 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 = @($provisionalUserRecord) + 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 = @() + $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 + 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' } + $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 + Executable = $executable + ShortcutFolder = $shortcutFolder + Shortcut = $shortcut + SmokeDirectory = $smokeDirectory + RegistryPath = $registryPath + RegistryRoot = Split-Path -Parent $registryPath + UserName = $userName + UserSid = $userSid + ProfilePath = $canonicalProfilePath + ManifestPath = $OwnershipManifest + RunId = [string]$manifest.RunId + Token = $token + } + $resourceState | ConvertTo-Json -Compress | Set-Content -LiteralPath ` + (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 +) { + Initialize-FixtureDirectoryIdentity + $manifest = [IO.File]::ReadAllText($OwnershipManifest, [Text.Encoding]::UTF8) | + ConvertFrom-Json -ErrorAction Stop + 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' + } + $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 + foreach ($directory in @($state.OwnedRoot, $state.ShortcutFolder)) { + [IO.File]::WriteAllText( + (Join-Path $directory '.propr-installed-app-owner'), + 'foreign-owner', + [Text.Encoding]::ASCII + ) + } + $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-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 + $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 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 + [IO.File]::WriteAllText( + (Join-Path $state.InstallRoot 'foreign-in-place.txt'), + 'foreign-in-place', + [Text.Encoding]::ASCII + ) +} + +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)) { + $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 + 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() + $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 } +$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' { + 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 + Write-FixtureMarker ('{0}|VALIDATION|INSTALL_TREE_SCAN|BEGIN' -f [DateTime]::UtcNow.AddMilliseconds(2500).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 + } + '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 + exit -1 + } + '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 + } + '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 + } + '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 + Replace-FixtureOwnedResources + Write-FixtureMarker ('{0}|CLEANUP|SMOKE_DATA_REMOVE|BEGIN' -f ` + [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_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 + Replace-FixtureShortcut + Write-FixtureMarker ('{0}|CLEANUP|SMOKE_DATA_REMOVE|BEGIN' -f ` + [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 + 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 + 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 new file mode 100644 index 000000000..e76a10ecb --- /dev/null +++ b/apps/desktop/scripts/test-installed-windows-app-supervisor.ps1 @@ -0,0 +1,2393 @@ +param( + [Parameter(Mandatory=$true)][ValidateSet('x64','arm64')][string]$Architecture +) + +$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()) ` + "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 +$dummyInstallerProductCode = ('{' + [Guid]::NewGuid().ToString().ToUpperInvariant() + '}') +$dummyInstallerEntryIdentity = $null +$dummyInstallerSha256 = $null + +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 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) + 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 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, + [string]$CancellationEventName, + [bool]$UseProductionWorker, + [string]$WorkflowManifest = '', + [string]$ExpectedRunId = '', + [bool]$InjectTerminationFailure = $false +) { + $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', '10000', + '-WatchdogPollMilliseconds', '25', + '-WatchdogTerminationMilliseconds', '3000', + '-PostTerminationCleanupMilliseconds', '30000', + '-MarkerReadTimeoutMilliseconds', '200' + )) { + $startInfo.ArgumentList.Add([string]$argument) + } + 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 ($InjectTerminationFailure) { + $startInfo.ArgumentList.Add('-InjectTerminationFailure') + } + if ($CancellationEventName) { + $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 +} + +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 15000) { + throw 'fixture did not publish process state' + } + Start-Sleep -Milliseconds 25 + } + return Get-Content -LiteralPath $statePath -Raw -Encoding ASCII | ConvertFrom-Json +} + +function Read-FixtureResourceState([string]$StateDirectory) { + $statePath = Join-Path $StateDirectory 'resources.json' + $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 +} + +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 Get-SanitizedSupervisorMarkerDiagnostic($Result) { + $bootstrapTimedOutPresent = [regex]::IsMatch( + [string]$Result.Output, + '(?m)^PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:BOOTSTRAP:TIMED_OUT\r?$' + ) + $lastValidNonePresent = [regex]::IsMatch( + [string]$Result.Output, + '(?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' } + $cleanupValidationPhaseMatch = [regex]::Match( + [string]$Result.Output, + '(?m)^PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:CLEANUP_VALIDATION_PHASE:' + + '(HANDSHAKE|FILE_AUTHORITY|UTF8_DECODE|JSON_PARSE|EXACT_KEY_SET|' + + 'BOOLEAN_TYPES|TRANSACTION_ENUM|SCHEMA_TYPE_STATE|RUN_ID_FORMAT|' + + 'INSTALLER_ENTRY_ID_FORMAT|INSTALLER_SHA256_FORMAT|INSTALLER_PRODUCT_CODE_FORMAT|' + + 'LIFETIME|RUN_ID|INSTALLER_PATH|FIXTURE_SCOPE|INITIAL_ACTIVE_MATCH|' + + 'INITIAL_INSTALLER_AUTHORITY_RECHECK|EMPTY_RECEIPT_WRITE)\r?$' + ) + $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}:' + + 'CLEANUP_VALIDATION_PHASE:{6}') -f ` + $signedExit, ([int]$bootstrapTimedOutPresent), ([int]$lastValidNonePresent), + $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') { + $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') + } + } + } + + 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' + } + $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}{4}') -f ` + $processExit.ToString([Globalization.CultureInfo]::InvariantCulture), + $resultName, $controllerStatus, + $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) { + return ('EXIT_CODE:{0}:RESULT:INVALID:CONTROLLER_STATUS:INVALID:' + + 'REPORTED_EXIT_CODE:-1') -f ` + $processExit.ToString([Globalization.CultureInfo]::InvariantCulture) + } + 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, + $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 Restore-ReplacedFixtureAuthority($Owned) { + [IO.File]::WriteAllText( + (Join-Path $Owned.OwnedRoot '.propr-installed-app-owner'), + [string]$Owned.Token, + [Text.Encoding]::ASCII + ) + 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 + } + [IO.File]::WriteAllText( + (Join-Path $Owned.ShortcutFolder '.propr-installed-app-owner'), + [string]$Owned.Token, + [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) +} + +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 Assert-ReplacedExecutableSurvives($Owned) { + $expected = if ($Owned.PSObject.Properties['ByteIdenticalReplacement']) { + 'owned-executable' + } else { 'foreign-executable' } + Assert-True ((Get-Content -LiteralPath $Owned.Executable -Raw).Trim() -ceq + $expected) '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 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, + [string]$FixtureRoot, + [object]$CleanupTimeoutMilliseconds = 30000, + [bool]$FixtureEarlyInitializationChild = $false, + [string]$StartupFailureClass = '' +) { + $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', [string]$CleanupTimeoutMilliseconds, + '-TerminationTimeoutMilliseconds', '3000' + )) { + $startInfo.ArgumentList.Add($argument) + } + if ($FixtureRoot) { + $startInfo.ArgumentList.Add('-FixtureRoot') + $startInfo.ArgumentList.Add($FixtureRoot) + } + if ($FixtureEarlyInitializationChild) { + $startInfo.ArgumentList.Add('-FixtureEarlyInitializationChild') + } + if ($StartupFailureClass) { + $startInfo.ArgumentList.Add('-StartupFailureClass') + $startInfo.ArgumentList.Add($StartupFailureClass) + } + $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' + $output = $process.StandardOutput.ReadToEnd() + $errorOutput = $process.StandardError.ReadToEnd() + $outputLines = @($output -split '\r?\n' | Where-Object { $_ }) + $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) { + $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) { + $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 = Get-WorkflowCleanupControllerStatusMatch $outputLines[1] + if (!$statusMatch.Success) { + $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 + 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}:' + + 'LINE_COUNT:{3}:STDERR_COUNT:{4}' -f ` + $stderrCode, $controllerStatus, $reportedExitCode, $lineCount, $stderrCount) + } + return [PSCustomObject]@{ + ExitCode = $process.ExitCode + 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 { + if (!$process.HasExited) { try { $process.Kill($true) } catch {} } + $process.Dispose() + } +} + +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 '^[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() +} + +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 10000 -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 = '', + [bool]$InjectTerminationFailure = $false +) { + $stateDirectory = if ($ExistingStateDirectory) { + $ExistingStateDirectory + } else { + New-StateDirectory $Scenario.ToLowerInvariant() + } + $process = [Diagnostics.Process]::new() + $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 @( + 'NO_MARKER','NO_MARKER_WINDOWS_POWERSHELL' + )) { + 60000 + } elseif ($Scenario -in @( + '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_PROFILE_PATH_MISMATCH_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 {} + 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 + StateDirectory = $stateDirectory + } + } finally { + $process.Dispose() + } +} + +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' + $duringCaptureDiagnostic = Get-SanitizedCriticalCancellationDiagnostic $duringCapture + Assert-True ($duringCapture.ExitCode -eq 125) ` + "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:$duringCaptureDiagnostic" + Assert-Contains $duringCapture.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:COMPLETE' ` + "DURING_OWNERSHIP_CAPTURE durable authority did not complete cleanup:$duringCaptureDiagnostic" + $capturedOwned = Read-FixtureResourceState $duringCapture.StateDirectory + Assert-OwnedResourcesGone $capturedOwned +} + +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' + 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' + 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-WindowsPowerShellCleanupCompatibility { + # 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)) ` + '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' + 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:VALIDATION:INSTALL_TREE_SCAN:BEGIN' ` + 'operation transition was not accepted and flushed by the supervisor' + Assert-Contains $result.Output ` + 'PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:VALIDATION:INSTALL_TREE_SCAN:BEGIN:TIMED_OUT' ` + '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' }, + @{ 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() + } +} + +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 + } + + 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() } + } +} + +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 + $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 + $registryCreated = $false + $userSid = $null + try { + 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 = $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' + + 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' + } + + $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' + + $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 + $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:$replacementRetryDiagnostic" + Assert-OwnedResourcesGone $replacementOwned + 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 + } + + $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' + + # 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 ` + $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 + 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 + 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 ` + 'owned-before-run') 'pre-existing registry tree was removed or changed' + 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 | + 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' + $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' + $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 + $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' + + $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 ` + $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 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' + 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() + } + + $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 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 + @($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()) ` + "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 = 3 + ManifestType = 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP'; State = 'ACTIVE' + RunId = $badRunId + CreatedUtcTicks = $createdTicks + ExpiresUtcTicks = $createdTicks + ([TimeSpan]::TicksPerHour * 3) + InstallerPath = $dummyInstaller + InstallerEntryIdentity = $dummyInstallerEntryIdentity + InstallerSha256 = $dummyInstallerSha256 + InstallerProductCode = $dummyInstallerProductCode + Fixture = $true + FixtureRoot = $workflowStateDirectory; BaselineClean = $false + InstallAttempted = $false; MsiTransactionState = 'NONE' + Directories = @(); Files = @() + RegistryKeys = @(); RegistryValues = @(); Users = @(); Profiles = @() + } + [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-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" + 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 ` + '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 + $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 + } + $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 + 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' + } + } + $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' + [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 + $diagnostic = Get-SanitizedSupervisorMarkerDiagnostic $result + Assert-True ($result.ExitCode -eq 0) ` + "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 ` + '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' + $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 = 3 + ManifestType = 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP'; State = 'ACTIVE' + RunId = $mismatchRunId + CreatedUtcTicks = $createdTicks + ExpiresUtcTicks = $createdTicks + ([TimeSpan]::TicksPerHour * 3) + InstallerPath = $dummyInstaller + InstallerEntryIdentity = $dummyInstallerEntryIdentity + InstallerSha256 = $dummyInstallerSha256 + InstallerProductCode = $dummyInstallerProductCode + Fixture = $false; FixtureRoot = $null + BaselineClean = $true; InstallAttempted = $true + MsiTransactionState = 'COMMITTED' + 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 + IdentityValueKind = $null; IdentityValueData = $null; KeyCreatedByRun = $false + }) + 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-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' + 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() +} + +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, + [bool]$Provisional = $false, + [bool]$InstallAttempted = $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 = 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 + InstallAttempted = $InstallAttempted + MsiTransactionState = if ($InstallAttempted) { 'PENDING' } else { 'NONE' } + Directories = @() + Files = @() + RegistryKeys = @() + RegistryValues = @([ordered]@{ + Kind = 'HKCU_INSTALLED'; Path = $desktopKey; Name = $installedName + 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 = @() + 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' + + $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') ` + '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) ` + 'rejected pending MSI receipt changed the unchanged HKCU baseline' + Assert-True (Test-Path -LiteralPath $unchangedManifest.Path -PathType Leaf) ` + 'rejected pending MSI receipt 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( + $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' + 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 + } + } + Write-Host 'PROPR_WINDOWS_SUPERVISOR_OWNERSHIP:HKCU_INSTALLED_VALUE:PRESERVED' + [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 = 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 + InstallAttempted = $false + MsiTransactionState = 'NONE' + 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) ` + "supervisor behavior tests expected $Architecture but are running on $actualArchitecture" + +Test-WorkflowCleanupBodyParserRegression +[void](New-Item -ItemType Directory -Path $testRoot -ErrorAction Stop) +Initialize-TestInstaller +try { + Test-WorkflowCleanupStartupProtocol + Test-BootstrapTimeout + Test-WindowsPowerShellCleanupCompatibility + Test-OperationDeadlineAndTreeTermination + Test-NegativeWorkerExitFinalization + Test-FailClosedMarkers + Test-LiveCancellationAndRedaction + Test-MsiTransactionInterruptionGates + Test-PrimaryWorkerFallbackForeignDescendants + Test-PreExistingCleanupOwnership + Test-SmokePromotionInterruptionAuthority + Test-PreExistingAppPathsAuthority + Test-HkcuInstalledValueOwnership + Test-ProvisionalUserMarkerOwnership + 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/scripts/test-installed-windows-app.ps1 b/apps/desktop/scripts/test-installed-windows-app.ps1 index dc9b440e7..557dda2d4 100644 --- a/apps/desktop/scripts/test-installed-windows-app.ps1 +++ b/apps/desktop/scripts/test-installed-windows-app.ps1 @@ -1,6 +1,9 @@ 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, + [Parameter(Mandatory=$true)][string]$OwnershipManifest ) enum SmokeEvidenceInspectionPhase { @@ -14,6 +17,61 @@ 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' +} +$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) +$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 @@ -22,17 +80,52 @@ 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' +$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 +$passwordText = $null $credential = New-Object Management.Automation.PSCredential("$env:COMPUTERNAME\$testUser", $password) $installAttempted = $false +$msiInstallCompleted = $false +$installerArtifactAuthorityValid = $true +$testUserCreatedByRun = $false $testUserSid = $null $smokeUserDataDirectory = $null +$smokeOwnershipRecord = $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 +$installRootOwnedIdentity = $null +$installRootOwnedTreeIdentity = $null +$shortcutFolderOwnedIdentity = $null +$shortcutFolderOwnedTreeIdentity = $null +$hkcuInstalledOwnedKind = $null +$hkcuInstalledOwnedData = $null +$shortcutOwnedIdentity = $null +$shortcutOwnedEntryIdentity = $null +$hkcuDesktopKeyCreatedByRun = $false $msiTimeoutMilliseconds = 10 * 60 * 1000 +$msiCaptureRollbackGraceMilliseconds = 30 * 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,11 +177,715 @@ 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 $protocolRegistryPath +$appPathsExistedBeforeInstall = Test-Path -LiteralPath $appPathsRegistryPath +$hkcuDesktopKeyExistedBeforeInstall = Test-Path -LiteralPath $hkcuDesktopRegistryPath $startMenuShortcutExistedBeforeInstall = Test-Path -LiteralPath $startMenuShortcut $startMenuShortcutFolderExistedBeforeInstall = Test-Path -LiteralPath $startMenuShortcutFolder $startMenuShortcutCreatedByRun = $false $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 +$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 + [string]$initialOwnershipState.RunId -cne $ownershipRunId -or + ![string]::Equals( + [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 = 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 + InstallAttempted = $false + MsiTransactionState = 'NONE' + Directories = @() + Files = @() + RegistryKeys = @() + RegistryValues = @() + 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 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" + } + } + $resolved = (Resolve-Path -LiteralPath $fullPath -ErrorAction Stop).ProviderPath.TrimEnd('\') + if (![string]::Equals( + [IO.Path]::GetFullPath($resolved).TrimEnd('\'), + $fullPath, + [StringComparison]::OrdinalIgnoreCase + )) { + throw "$Label path is not canonical" + } + 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) { + $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() + } +} + +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 ReadEntry(string path, bool expectDirectory) + { + using (SafeFileHandle handle = CreateFile( + 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); } +} +'@ + +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-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-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 + $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 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 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 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]$ProductCode) { + $installerCom = $null + try { + 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) { + 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-InstallerArtifactAuthority + Assert-MsiProductIsUnregistered ([string]$ownershipState.InstallerProductCode) +} + +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 + $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 + $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' + } + + 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 + IdentityValueKind = $null + IdentityValueData = $null + KeyCreatedByRun = $false +}) + +Write-OwnershipManifest + +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', + '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' + )][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; @@ -113,11 +910,30 @@ public static class ProPRWindowsLogon } '@ +Write-WatchdogMarker 'INITIALIZATION' 'PATHS' $bootstrapWatchdogTimeoutMilliseconds 'COMPLETE' +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' + } + Assert-InstallerArtifactAuthority + Assert-MsiProductIsUnregistered ([string]$ownershipState.InstallerProductCode) + $ownershipState.BaselineClean = $true + Write-OwnershipManifest + 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( @@ -126,6 +942,8 @@ function Write-CleanupSubstage( 'MSI_UNINSTALL', 'INSTALL_TREE', 'PROTOCOL', + 'APP_PATH', + 'HKCU_INSTALLED', 'SHORTCUT_FILE', 'SHORTCUT_FOLDER', 'ORDINARY_USER_ABSENCE_PROBE', @@ -134,12 +952,15 @@ function Write-CleanupSubstage( 'USER', 'INSTALL_ROOT_FALLBACK', 'PROTOCOL_FALLBACK', + 'APP_PATH_FALLBACK', + 'HKCU_INSTALLED_FALLBACK', 'SHORTCUT_FALLBACK', 'FINAL_AGGREGATION' )][string]$Substage, [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( @@ -494,10 +1315,23 @@ 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'))" - New-Item -ItemType Directory -Path $path | Out-Null +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) { + 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 @@ -526,36 +1360,234 @@ function New-SmokeUserDataDirectory([Security.Principal.SecurityIdentifier]$User $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 { - Remove-Item -LiteralPath $path -Recurse -Force -ErrorAction SilentlyContinue + if ($createdByRun) { + 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' } - 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 + $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() + } + 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 + }) + } } } - if (Test-Path -LiteralPath $fullPath) { throw 'smoke user-data directory cleanup did not complete' } + + 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 Get-SmokeEventEvidence( @@ -707,13 +1739,209 @@ try { Write-Stage 'INSTALL' 'BEGIN' try { $installAttempted = $true + $ownershipState.InstallAttempted = $true + $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 + Token = $null; Identity = $null; TreeIdentity = $null; Provisional = $true + }, + [ordered]@{ + Kind = 'SHORTCUT_FOLDER'; Path = $startMenuShortcutFolder + Owned = $true; Token = $null; Identity = $null; TreeIdentity = $null + Provisional = $true + } + ) + $ownershipState.Files = @([ordered]@{ + Kind = 'SHORTCUT_FILE'; Path = $startMenuShortcut; Owned = $true + Token = $null; Identity = $null; EntryIdentity = $null; Provisional = $true + }) + $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 + } + ) + $ownershipState.RegistryValues = @([ordered]@{ + Kind = 'HKCU_INSTALLED' + Path = $hkcuDesktopRegistryPath + Name = $hkcuInstalledValueName + Owned = $true + Provisional = $true + BaselineKeyExisted = $hkcuDesktopKeyExistedBeforeInstall + BaselineValueExisted = $hkcuInstalledValueExistedBeforeInstall + BaselineValueKind = $hkcuInstalledBaselineKind + BaselineValueData = $hkcuInstalledBaselineData + IdentityValueKind = $null + IdentityValueData = $null + KeyCreatedByRun = $false + }) + Write-OwnershipManifest + $msiTransactionFailure = $null try { - 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 'MSI_INSTALL' ` + -TimeoutMilliseconds ($msiTimeoutMilliseconds + $terminationTimeoutMilliseconds + 5000) ` + -Operation { + Assert-InstallerArtifactAuthority + Invoke-Msi @('/i', "`"$installerPath`"", '/qn', '/norestart') 'machine install' + $script:msiInstallCompleted = $true + } + } 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) { + throw 'MSI transaction commit status is unavailable' + } + $script:installRootCreatedByRun = + !$installRootExistedBeforeInstall -and (Test-Path -LiteralPath $installRoot) + $script:protocolCreatedByRun = + !$protocolExistedBeforeInstall -and + (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 = + !$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 + $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 + $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 + $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 { @() } + $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 + Provisional = $false + } + } + 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 + Provisional = $false + } + } + $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 + Name = $hkcuInstalledValueName + Owned = $true + Provisional = $false + BaselineKeyExisted = $hkcuDesktopKeyExistedBeforeInstall + BaselineValueExisted = $hkcuInstalledValueExistedBeforeInstall + BaselineValueKind = $hkcuInstalledBaselineKind + BaselineValueData = $hkcuInstalledBaselineData + IdentityValueKind = $script:hkcuInstalledOwnedKind + IdentityValueData = $script:hkcuInstalledOwnedData + KeyCreatedByRun = $script:hkcuDesktopKeyCreatedByRun + }) + $ownershipState.MsiTransactionState = 'COMMITTED' + Write-OwnershipManifest + } } Write-Stage 'INSTALL' 'COMPLETE' } catch { @@ -723,36 +1951,68 @@ 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 ` + "$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' '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 + 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 +2021,70 @@ 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' + } + $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 + $provisionalUser.Sid = $script:testUserSid.Value + $provisionalUser.Provisional = $false + Write-OwnershipManifest + } + $testUserSid = Invoke-BoundedExternalOperation 'USER_SETUP' 'USER_SID' ` + $externalOperationTimeoutMilliseconds { + $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; 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 + $smokeUserDataDirectory = Invoke-BoundedExternalOperation ` + 'USER_SETUP' 'SMOKE_DATA_CREATE' $recursiveOperationTimeoutMilliseconds { + $ownedSmokeDirectory = New-SmokeUserDataDirectory $testUserSid $smokeUserDataCandidate + Write-DurableOwnershipToken ` + -Path (Join-Path $ownedSmokeDirectory '.propr-installed-app-owner') ` + -Token $ownershipToken + if (!(Promote-SmokeOwnershipRecord $smokeOwnershipRecord)) { + throw 'smoke user-data ownership promotion did not complete' + } + $ownedSmokeDirectory + } + 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 +2100,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 +2124,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 +2149,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 +2163,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() } } @@ -847,22 +2179,55 @@ try { throw } finally { $cleanupFailed = $false - if ($installAttempted) { + $profileCleanupFailed = $false + if ($installerArtifactAuthorityValid) { + Assert-InstallerArtifactAuthority + if ($installAttempted -and + [string]$ownershipState.MsiTransactionState -ceq 'COMMITTED') { Write-Stage 'UNINSTALL' 'BEGIN' $uninstallFailed = $false Write-CleanupSubstage 'UNINSTALL' 'MSI_UNINSTALL' 'BEGIN' try { - Invoke-Msi @('/x', "`"$installerPath`"", '/qn', '/norestart') 'machine uninstall' + 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)) { + 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' + } + if (!(Test-MsiInstalledValue $hkcuDesktopRegistryPath $hkcuInstalledValueName)) { + throw 'refusing to uninstall over current-user metadata with mismatched ownership' + } + 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 { - 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,20 +2236,55 @@ 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 $protocolRegistryPath) { + throw 'machine uninstall left protocol discovery metadata behind' + } + } Write-CleanupSubstage 'UNINSTALL' 'PROTOCOL' 'COMPLETE' } catch { Write-CleanupSubstage 'UNINSTALL' 'PROTOCOL' 'FAILED' $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' '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 { - 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 +2293,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 +2308,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 +2338,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 $smokeOwnershipRecord + } Write-CleanupSubstage 'CLEANUP' 'SMOKE_DATA' 'COMPLETE' } catch { Write-CleanupSubstage 'CLEANUP' 'SMOKE_DATA' 'FAILED' @@ -941,22 +2350,109 @@ 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 + if ($testUserCreatedByRun -and $null -ne $testUserSid) { + $profiles = @(Invoke-BoundedExternalOperation ` + 'CLEANUP' 'PROFILE_LOOKUP' $externalOperationTimeoutMilliseconds { + @(Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop | Where-Object { + $_.SID -ceq $testUserSid.Value + }) + }) + $ownedUserRecords = @($ownershipState.Users | Where-Object { + $_.Owned -and [string]$_.Sid -ceq $testUserSid.Value }) - foreach ($profile in $profiles) { Remove-CimInstance -InputObject $profile -ErrorAction Stop } + 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-ValidatedOwnedProfilePath ` + ([string]$profile.LocalPath) $testUser + 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 ([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 + } + } } Write-CleanupSubstage 'CLEANUP' 'PROFILE' 'COMPLETE' } catch { Write-CleanupSubstage 'CLEANUP' 'PROFILE' 'FAILED' + $profileCleanupFailed = $true $cleanupFailed = $true } Write-CleanupSubstage 'CLEANUP' 'USER' 'BEGIN' try { - if (Get-LocalUser -Name $testUser -ErrorAction SilentlyContinue) { - Remove-LocalUser -Name $testUser -ErrorAction Stop + if ($profileCleanupFailed) { + throw 'profile cleanup failed; retaining authenticated local-user authority' + } + 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 +2462,24 @@ 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' + } + if (!$installRootOwnedIdentity -or + (Get-DirectoryIdentity $installRoot) -cne $installRootOwnedIdentity) { + throw 'refusing to remove an install tree with a mismatched ownership identity' + } + 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' } catch { Write-CleanupSubstage 'CLEANUP' 'INSTALL_ROOT_FALLBACK' 'FAILED' @@ -977,36 +2488,83 @@ 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 $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' } catch { Write-CleanupSubstage 'CLEANUP' 'PROTOCOL_FALLBACK' 'FAILED' $cleanupFailed = $true } - Write-CleanupSubstage 'CLEANUP' 'SHORTCUT_FALLBACK' 'BEGIN' - $shortcutFallbackFailed = $false + Write-CleanupSubstage 'CLEANUP' 'APP_PATH_FALLBACK' 'BEGIN' try { - if ($startMenuShortcutCreatedByRun -and (Test-Path -LiteralPath $startMenuShortcut)) { - Remove-Item -LiteralPath $startMenuShortcut -Force -ErrorAction Stop - } + 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 { - $shortcutFallbackFailed = $true + Write-CleanupSubstage 'CLEANUP' 'APP_PATH_FALLBACK' 'FAILED' + $cleanupFailed = $true } + + Write-CleanupSubstage 'CLEANUP' 'HKCU_INSTALLED_FALLBACK' 'BEGIN' 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' + Invoke-BoundedExternalOperation ` + 'CLEANUP' 'HKCU_INSTALLED_FALLBACK' $externalOperationTimeoutMilliseconds { + Restore-HkcuInstalledBaseline } - $ownedShortcutFolderContents = @(Get-ChildItem -LiteralPath $startMenuShortcutFolder -Force -ErrorAction Stop) - if ($ownedShortcutFolderContents.Count -eq 0) { - Remove-Item -LiteralPath $startMenuShortcutFolder -Force -ErrorAction Stop + 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 { + 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 + (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' + } + if (!$shortcutFolderOwnedIdentity -or + (Get-DirectoryIdentity $startMenuShortcutFolder) -cne $shortcutFolderOwnedIdentity) { + throw 'refusing to remove a shortcut folder with a mismatched ownership identity' + } + 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 { $shortcutFallbackFailed = $true } @@ -1025,7 +2583,19 @@ try { throw 'installed Windows cleanup did not complete' } } else { + $ownershipState.State = 'EMPTY' + $ownershipState.BaselineClean = $false + $ownershipState.InstallAttempted = $false + $ownershipState.MsiTransactionState = 'NONE' + $ownershipState.Directories = @() + $ownershipState.Files = @() + $ownershipState.RegistryKeys = @() + $ownershipState.RegistryValues = @() + $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 13f7ca1a1..bf0a28a03 100644 --- a/apps/desktop/src/release-workflow.test.ts +++ b/apps/desktop/src/release-workflow.test.ts @@ -42,6 +42,30 @@ 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 installedWindowsAppCleanup = normalizeWorkflowText(readFileSync( + fileURLToPath(new URL('../scripts/cleanup-installed-windows-app.ps1', import.meta.url)), + 'utf8', +)); +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', +)); +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)] @@ -323,7 +347,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/); @@ -363,7 +387,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\)/); @@ -394,7 +418,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/); @@ -453,7 +477,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\}/, ); @@ -496,7 +524,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 `') @@ -529,23 +557,770 @@ 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]*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/); - 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/); 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); + 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/); } }); - test('uses bounded network logon impersonation with secure native credential cleanup', () => { - const nativeLogon = installedWindowsAppTest.match( - /Add-Type -TypeDefinition @'\n([\s\S]*?)\n'@/, + 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/); + 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( + 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|userenv\.dll/, + ); + 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\)/); + 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, /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\.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 -and \$postTerminationCleanupAuthorized\) \{[\s\S]*Invoke-PostTerminationCleanup/, + ); + assert.match(installedWindowsAppSupervisor, /Invoke-PostTerminationCleanup/); + assert.match(installedWindowsAppSupervisor, /\$cleanupRequired = \$terminateOwnedTree -or \$workerStarted/); + assert.match( + installedWindowsAppSupervisor, + /PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:POST_TERMINATION_CLEANUP:COMPLETE/, + ); + assert.match(installedWindowsAppCleanup, /Remove-OwnedProfiles/); + assert.match(installedWindowsAppCleanup, /Promote-UncapturedOwnedProfiles/); + assert.match( + installedWindowsAppCleanup, + /\$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/); + 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(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+Assert-InstallerArtifactAuthority \$manifest\n\s+\$msi = Start-Process msiexec\.exe/, + ); + assert.doesNotMatch(installedWindowsAppCleanup, /AllowProvisionalProductOwnership/); + assert.doesNotMatch(installedWindowsAppCleanup, /allowProvisionalMsiUninstall/); + assert.match( + installedWindowsAppCleanup, + /\$allowAuthenticatedMsiUninstall[\s\S]*MsiTransactionState -ceq 'COMMITTED'[\s\S]*Start-Process msiexec\.exe/, + ); + assert.match( + installedWindowsAppCleanup, + /provisional registry evidence cannot authorize manual cleanup/, + ); + assert.match( + installedWindowsAppTest, + /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 \(\[string\]\$ownershipState\.InstallerProductCode\)/, + ); + 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/, + ); + 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 @\([\s\S]*'\/x', \[string\]\$ownershipState\.InstallerProductCode/, + ); + assert.match( + installedWindowsAppTest, + /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.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_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, + /\$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, + /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, + /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/, + ); + assert.match( + 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_INSTALLER_AUTHORITY_RECHECK\|[\s\S]*EMPTY_RECEIPT_WRITE\)\\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, + /\$roundTrip = ConvertFrom-Json[\s\S]*\$roundTrip\.RunId -cne \$identifiers\.RunId[\s\S]*\$roundTrip\.InstallerProductCode -cne[\s\S]*\$identifiers\.InstallerProductCode/, + ); + 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_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, + /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/); + 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/); + 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/); + assert.match(installedWindowsAppWorkflowCleanup, /StreamReader reader/); + 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.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.match( + installedWindowsAppWorkflowCleanup, + /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( + installedWindowsAppWorkflowCleanup, + /add_(?:Output|Error)DataReceived|Begin(?:Output|Error)ReadLine/, + ); + assert.match( + installedWindowsAppWorkflowCleanup, + /if \(\$fixedResult -ceq 'COMPLETE' -and \$cleanupTreeZeroVerified -and/, + ); + assert.match( + installedWindowsAppSupervisor, + /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 \{[\s\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/, + ); + const controllerStatusParser = installedWindowsAppSupervisorBehaviorTest.indexOf( + '$statusMatch = Get-WorkflowCleanupControllerStatusMatch', + ); + assert.notEqual(controllerStatusParser, -1); + assert.ok( + controllerStatusParser + < 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, /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/, + ); + 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, /Get-SanitizedSupervisorMarkerDiagnostic/); + assert.match( + installedWindowsAppSupervisorBehaviorTest, + /SUPERVISOR_EXIT:\{0\}:BOOTSTRAP_TIMED_OUT:\{1\}:LAST_VALID_NONE:\{2\}/, + ); + assert.match( + 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\}\{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/, + ); + 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/, + ); + 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'), + 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/); + 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(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/); + 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/, + ); + 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/, + ); + const fixedResultWrite = installedWindowsAppWorkflowCleanup.indexOf( + 'Write-FixedResult $fixedResult', + ); + assert.ok( + fixedResultWrite > installedWindowsAppWorkflowCleanup.indexOf('$resource.Dispose()') + && fixedResultWrite > installedWindowsAppWorkflowCleanup.indexOf( + 'if ($fixedResult -ceq \'COMPLETE\' -and $validatedManifestPath)', + ), + 'fixed controller evidence must be emitted after bounded finalization', + ); + assert.doesNotMatch( + installedWindowsAppSupervisorBehaviorTest, + /workflowCleanup\.(?:Error|StandardError)|failedCleanup\.(?:Error|StandardError)/, + ); + 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, + /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); + 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:BOOTSTRAP:TIMED_OUT/, + ); + assert.match( + installedWindowsAppSupervisor, + /PROPR_WINDOWS_INSTALLED_SMOKE:WATCHDOG:BOOTSTRAP:FAILED/, + ); + assert.match( + installedWindowsAppSupervisor, + /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( + /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', + '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', + ]); + 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('supplementary lint retains fail-closed installed-app cleanup guards', () => { + assert.match( + installedWindowsAppTest, + /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( + installedWindowsAppTest, + /if \(\$testUserCreatedByRun -and \$null -ne \$testUserSid\)[\s\S]*!\$ownedUser\.SID\.Equals\(\$testUserSid\)[\s\S]*Remove-LocalUser/, + ); + assert.match( + installedWindowsAppTest, + /\$matchingRecords = @\(\)[\s\S]*foreach \(\$record in \$ownedProfileRecords\)[\s\S]*\$matchingRecords\.Count -ne 1[\s\S]*Remove-CimInstance -InputObject \$profile/, + ); + assert.match( + installedWindowsAppTest, + /if \(\$installRootCreatedByRun -and \(Test-Path -LiteralPath \$installRoot\)\)[\s\S]*Get-ChildItem -LiteralPath \$installRoot -Force[\s\S]*Remove-Item -LiteralPath \$installRoot -Force/, + ); + assert.match( + installedWindowsAppTest, + /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, + /if \(\$createdByRun\) \{[\s\S]*Get-ChildItem -LiteralPath \$path -Force[\s\S]*Remove-Item -LiteralPath \$path -Force/, + ); + }); + + test('uses bounded network logon impersonation with secure native credential cleanup', () => { + 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;/); @@ -676,6 +1451,8 @@ describe('desktop trusted release workflow', () => { 'MSI_UNINSTALL', 'INSTALL_TREE', 'PROTOCOL', + 'APP_PATH', + 'HKCU_INSTALLED', 'SHORTCUT_FILE', 'SHORTCUT_FOLDER', 'ORDINARY_USER_ABSENCE_PROBE', @@ -684,6 +1461,8 @@ describe('desktop trusted release workflow', () => { 'USER', 'INSTALL_ROOT_FALLBACK', 'PROTOCOL_FALLBACK', + 'APP_PATH_FALLBACK', + 'HKCU_INSTALLED_FALLBACK', 'SHORTCUT_FALLBACK', 'FINAL_AGGREGATION', ]); @@ -707,7 +1486,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', '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)); } @@ -723,6 +1510,8 @@ describe('desktop trusted release workflow', () => { 'USER', 'INSTALL_ROOT_FALLBACK', 'PROTOCOL_FALLBACK', + 'APP_PATH_FALLBACK', + 'HKCU_INSTALLED_FALLBACK', 'SHORTCUT_FALLBACK', 'FINAL_AGGREGATION', ]) { @@ -740,7 +1529,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); @@ -762,11 +1551,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'"); @@ -774,19 +1563,25 @@ 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/, + /if \(\$startMenuShortcutFolderCreatedByRun[\s\S]*Get-DirectoryIdentity \$startMenuShortcutFolder[\s\S]*Get-ChildItem -LiteralPath \$startMenuShortcutFolder -Force[\s\S]*Remove-Item -LiteralPath \$startMenuShortcutFolder -Force -ErrorAction Stop/, ); - assert.doesNotMatch( - cleanup, - /Remove-Item -LiteralPath \$startMenuShortcut(?:Folder)?[^\n]*-Recurse/, + 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|\$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/); diff --git a/package-lock.json b/package-lock.json index 9b8e9b63c..427f433d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4521,11 +4521,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": { @@ -4710,7 +4715,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": [ { @@ -4728,11 +4735,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" @@ -4856,7 +4863,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": [ { @@ -6134,7 +6143,9 @@ } }, "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" }, @@ -7410,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", @@ -10995,9 +11006,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", @@ -11997,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", @@ -14341,7 +14357,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": [ {