Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 114 additions & 31 deletions apps/desktop/scripts/cleanup-installed-windows-app.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ $authorizedRunId = $null
$cleanupValidationPhase = 'HANDSHAKE'
$cleanupValidationPhases = @(
'HANDSHAKE','FILE_AUTHORITY','UTF8_DECODE','JSON_PARSE','EXACT_KEY_SET',
'BOOLEAN_TYPES','TRANSACTION_ENUM','SCHEMA_TYPE_STATE','RUN_ID_FORMAT',
'GENERATION','BOOLEAN_TYPES','TRANSACTION_ENUM','SCHEMA_TYPE_STATE','RUN_ID_FORMAT',
'INSTALLER_ENTRY_ID_FORMAT','INSTALLER_SHA256_FORMAT','INSTALLER_PRODUCT_CODE_FORMAT',
'LIFETIME','RUN_ID','INSTALLER_PATH','FIXTURE_SCOPE','INITIAL_ACTIVE_MATCH',
'INITIAL_INSTALLER_AUTHORITY_RECHECK','EMPTY_RECEIPT_WRITE'
Expand Down Expand Up @@ -1047,39 +1047,104 @@ function Restore-OwnedRegistryValue($Record) {
}
}

function Wait-FixtureDurablePublicationDeathGate(
[ValidateSet('BEFORE','DURING','AFTER')][string]$Checkpoint
) {
$requestedCheckpoint = [string]$env:PROPR_SUPERVISOR_FIXTURE_PUBLICATION_CHECKPOINT
$eventName = [string]$env:PROPR_SUPERVISOR_FIXTURE_PUBLICATION_EVENT
if ([string]::IsNullOrEmpty($requestedCheckpoint) -and
[string]::IsNullOrEmpty($eventName)) {
return
}
if (!$FixtureRoot -or $requestedCheckpoint -cnotin @('BEFORE','DURING','AFTER') -or
$eventName -notmatch
'^Local\\ProPRInstalledAppPublication-[a-f0-9]{32}$') {
throw 'fixture durable publication death gate is invalid'
}
if ($Checkpoint -cne $requestedCheckpoint) { return }

$publicationEvent = [Threading.EventWaitHandle]::OpenExisting($eventName)
try {
[void]$publicationEvent.Set()
# The parent terminates this real cleanup process tree. Reaching this bound
# means the deterministic process-death fixture did not take ownership.
Start-Sleep -Seconds 300
throw 'fixture durable publication death gate was not terminated'
} finally {
$publicationEvent.Dispose()
}
}

function Write-DurableOwnershipManifest([string]$Path, $Manifest) {
$temporaryPath = "$Path.new"
$replacementCompleted = $false
$previousGeneration = [int64]$Manifest.Generation
if ($previousGeneration -lt 0 -or $previousGeneration -eq [int64]::MaxValue) {
throw 'ownership receipt generation cannot advance'
}
$Manifest.Generation = $previousGeneration + 1
try {
$bytes = [Text.Encoding]::UTF8.GetBytes((
$Manifest | ConvertTo-Json -Depth 6 -Compress
))
$stream = [IO.FileStream]::new(
$temporaryPath,
[IO.FileMode]::Create,
[IO.FileAccess]::Write,
[IO.FileShare]::None,
4096,
[IO.FileOptions]::WriteThrough
)
try {
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush($true)
} finally {
$stream.Dispose()
}
if ($PSVersionTable.PSEdition -ceq 'Core') {
# Native pwsh provides the atomic same-directory overwrite overload.
[IO.File]::Move($temporaryPath, $Path, $true)
$json = $Manifest | ConvertTo-Json -Depth 6 -Compress
$roundTrip = ConvertFrom-Json -InputObject $json -ErrorAction Stop
if (($roundTrip.Generation -isnot [long] -and
$roundTrip.Generation -isnot [int]) -or
[int64]$roundTrip.Generation -ne [int64]$Manifest.Generation -or
[string]$roundTrip.AuthorityState -cnotin @('PROVISIONAL','NONPROVISIONAL')) {
throw 'ownership receipt generation round trip failed'
}
$bytes = [Text.Encoding]::UTF8.GetBytes($json)
Wait-FixtureDurablePublicationDeathGate 'BEFORE'
if (Test-Path -LiteralPath $temporaryPath) {
# A prior cleanup may have died after FlushFileBuffers but before rename.
# Resume only the byte-identical next-generation candidate; a partial,
# malformed, foreign, or stale collision remains retained and fail closed.
$candidate = Get-Item -LiteralPath $temporaryPath -Force -ErrorAction Stop
if ($candidate.PSIsContainer -or
($candidate.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or
$candidate.Length -ne $bytes.Length -or
[IO.File]::ReadAllText($temporaryPath, [Text.Encoding]::UTF8) -cne $json) {
throw 'ownership receipt replacement collision is invalid'
}
} else {
# .NET Framework File.Replace is unsuitable for the real PS5.1 reader
# flow. Use one same-directory Windows rename with no cross-volume-copy
# flag, replacing the existing pathname and waiting for durable completion.
[ProPRAtomicFile]::ReplaceSameDirectory($temporaryPath, $Path)
}
$replacementCompleted = $true
} finally {
if (!$replacementCompleted) { [IO.File]::Delete($temporaryPath) }
$stream = [IO.FileStream]::new(
$temporaryPath,
[IO.FileMode]::CreateNew,
[IO.FileAccess]::Write,
[IO.FileShare]::None,
4096,
[IO.FileOptions]::WriteThrough
)
try {
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush($true)
} finally {
$stream.Dispose()
}
# The DURING fixture gate is deliberately after the write-through stream's
# durable flush and close, but before either the generation recheck or the
# atomic replacement. A test parent can therefore prove recovery from real
# process death at the only resumable publication boundary.
Wait-FixtureDurablePublicationDeathGate 'DURING'
}
$currentJson = [IO.File]::ReadAllText($Path, [Text.Encoding]::UTF8)
$current = ConvertFrom-Json -InputObject $currentJson -ErrorAction Stop
if (($current.Generation -isnot [long] -and $current.Generation -isnot [int]) -or
[int64]$current.Generation -ne $previousGeneration) {
throw 'ownership receipt generation is stale'
}
# MoveFileEx with REPLACE_EXISTING and WRITE_THROUGH gives both PowerShell
# hosts the same atomic, same-directory durable publication boundary.
[ProPRAtomicFile]::ReplaceSameDirectory($temporaryPath, $Path)
$publishedJson = [IO.File]::ReadAllText($Path, [Text.Encoding]::UTF8)
$published = ConvertFrom-Json -InputObject $publishedJson -ErrorAction Stop
if ($publishedJson -cne $json -or
($published.Generation -isnot [long] -and $published.Generation -isnot [int]) -or
[int64]$published.Generation -ne [int64]$Manifest.Generation) {
throw 'ownership receipt durable publication re-read failed'
}
Wait-FixtureDurablePublicationDeathGate 'AFTER'
} catch {
$Manifest.Generation = $previousGeneration
throw
}
}

Expand All @@ -1088,6 +1153,7 @@ function Write-EmptyOwnershipReceipt([string]$Path, $Manifest) {
# fails, the caller and canonical pathname both retain ACTIVE authority.
$emptyReceipt = $Manifest.PSObject.Copy()
$emptyReceipt.State = 'EMPTY'
$emptyReceipt.AuthorityState = 'NONPROVISIONAL'
$emptyReceipt.BaselineClean = $false
$emptyReceipt.InstallAttempted = $false
$emptyReceipt.MsiTransactionState = 'NONE'
Expand Down Expand Up @@ -1343,7 +1409,8 @@ try {
$cleanupValidationPhase = 'EXACT_KEY_SET'
$manifestKeys = @($manifest.PSObject.Properties | ForEach-Object { $_.Name })
$expectedManifestKeys = @(
'SchemaVersion','ManifestType','State','RunId','CreatedUtcTicks','ExpiresUtcTicks',
'SchemaVersion','ManifestType','State','Generation','AuthorityState',
'RunId','CreatedUtcTicks','ExpiresUtcTicks',
'InstallerPath','InstallerEntryIdentity','InstallerSha256','InstallerProductCode','Fixture',
'FixtureRoot','BaselineClean','InstallAttempted','MsiTransactionState',
'Directories','Files','RegistryKeys',
Expand All @@ -1356,6 +1423,14 @@ try {
throw 'ownership manifest key set is invalid'
}

$cleanupValidationPhase = 'GENERATION'
if (($manifest.Generation -isnot [long] -and $manifest.Generation -isnot [int]) -or
$manifest.Generation -lt 0 -or
[string]$manifest.AuthorityState -cnotin @('PROVISIONAL','NONPROVISIONAL')) {
throw 'ownership manifest generation or authority state is invalid'
}
$manifest.Generation = [int64]$manifest.Generation

$cleanupValidationPhase = 'BOOLEAN_TYPES'
# Windows PowerShell 5.1 can retain an incidental PSObject wrapper around a
# JSON primitive. Inspect the explicit base object while still rejecting
Expand All @@ -1375,6 +1450,14 @@ try {
)) {
throw 'ownership manifest transaction enum is invalid'
}
if (([string]$manifest.MsiTransactionState -in @('COMMITTED','ROLLED_BACK_CLEAN') -and
[string]$manifest.AuthorityState -cne 'NONPROVISIONAL') -or
([string]$manifest.MsiTransactionState -ceq 'PENDING' -and
[string]$manifest.AuthorityState -cne 'PROVISIONAL') -or
([string]$manifest.State -ceq 'EMPTY' -and
[string]$manifest.AuthorityState -cne 'NONPROVISIONAL')) {
throw 'ownership manifest publication state is inconsistent'
}

$cleanupValidationPhase = 'SCHEMA_TYPE_STATE'
if (
Expand Down
56 changes: 45 additions & 11 deletions apps/desktop/scripts/run-installed-windows-app-harness.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ $watchdogSubstages = @(
'PATHS',
'BASELINE',
'MSI_INSTALL',
'AUTHORITY_PUBLICATION',
'OWNERSHIP_CAPTURE',
'INSTALL_TREE_SCAN',
'APPLICATION_IMAGE',
Expand Down Expand Up @@ -259,20 +260,27 @@ public static class ProPRInstallerEntryIdentity
private static extern bool GetFileInformationByHandle(
SafeFileHandle handle, out BY_HANDLE_FILE_INFORMATION information);

public static string ReadHandle(SafeFileHandle handle)
{
if (handle == null || handle.IsInvalid)
throw new InvalidOperationException("installer identity handle is invalid");
BY_HANDLE_FILE_INFORMATION information;
if (!GetFileInformationByHandle(handle, out information))
throw new Win32Exception(Marshal.GetLastWin32Error(), "installer identity read failed");
if ((information.FileAttributes & (0x10 | 0x400)) != 0)
throw new InvalidOperationException("installer entry is not an ordinary file");
return string.Format("{0:x8}{1:x8}{2:x8}", information.VolumeSerialNumber,
information.FileIndexHigh, information.FileIndexLow);
}

public static string Read(string path)
{
using (SafeFileHandle handle = CreateFile(
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);
return ReadHandle(handle);
}
}
}
Expand Down Expand Up @@ -702,6 +710,8 @@ function Write-InitialOwnershipManifest(
SchemaVersion = 3
ManifestType = 'PROPR_WINDOWS_INSTALLED_APP_OWNERSHIP'
State = 'ACTIVE'
Generation = [int64]0
AuthorityState = 'PROVISIONAL'
RunId = $identifiers.RunId
CreatedUtcTicks = $createdUtcTicks
ExpiresUtcTicks = $createdUtcTicks + ([TimeSpan]::TicksPerHour * 3)
Expand All @@ -724,6 +734,9 @@ function Write-InitialOwnershipManifest(
$manifestJson = $manifest | ConvertTo-Json -Depth 6 -Compress
$roundTrip = ConvertFrom-Json -InputObject $manifestJson -ErrorAction Stop
if ([string]$roundTrip.RunId -cne $identifiers.RunId -or
($roundTrip.Generation -isnot [long] -and $roundTrip.Generation -isnot [int]) -or
$roundTrip.Generation -ne 0 -or
[string]$roundTrip.AuthorityState -cne 'PROVISIONAL' -or
[string]$roundTrip.InstallerEntryIdentity -cne
$identifiers.InstallerEntryIdentity -or
[string]$roundTrip.InstallerSha256 -cne $identifiers.InstallerSha256 -or
Expand All @@ -750,7 +763,9 @@ function Write-InitialOwnershipManifest(

function Test-MsiCriticalMarker($Marker) {
return $null -ne $Marker -and [string]$Marker.Stage -ceq 'INSTALL' -and
[string]$Marker.Substage -in @('MSI_INSTALL','OWNERSHIP_CAPTURE') -and
[string]$Marker.Substage -in @(
'MSI_INSTALL','AUTHORITY_PUBLICATION','OWNERSHIP_CAPTURE'
) -and
!([string]$Marker.Substage -ceq 'OWNERSHIP_CAPTURE' -and
[string]$Marker.Status -ceq 'COMPLETE')
}
Expand All @@ -770,13 +785,21 @@ function Get-DurableMsiTransactionReceipt {
[IO.FileOptions]::SequentialScan
)
try {
$manifestEntryIdentity = [ProPRInstallerEntryIdentity]::ReadHandle(
$stream.SafeFileHandle)
$offset = 0
while ($offset -lt $bytes.Length) {
$read = $stream.Read($bytes, $offset, $bytes.Length - $offset)
if ($read -eq 0) { return 'UNAVAILABLE' }
$offset += $read
}
if ($stream.ReadByte() -ne -1) { return 'UNAVAILABLE' }
$currentItem = Get-Item -LiteralPath $ownershipManifestPath -Force -ErrorAction Stop
if ($currentItem.Length -ne $bytes.Length -or
[ProPRInstallerEntryIdentity]::Read($ownershipManifestPath) -cne
$manifestEntryIdentity) {
return 'UNAVAILABLE'
}
} finally {
$stream.Dispose()
}
Expand All @@ -785,7 +808,8 @@ function Get-DurableMsiTransactionReceipt {
-ErrorAction Stop
$manifestKeys = @($manifest.PSObject.Properties | ForEach-Object { $_.Name })
$expectedManifestKeys = @(
'SchemaVersion','ManifestType','State','RunId','CreatedUtcTicks','ExpiresUtcTicks',
'SchemaVersion','ManifestType','State','Generation','AuthorityState',
'RunId','CreatedUtcTicks','ExpiresUtcTicks',
'InstallerPath','InstallerEntryIdentity','InstallerSha256','InstallerProductCode',
'Fixture','FixtureRoot','BaselineClean','InstallAttempted','MsiTransactionState',
'Directories','Files','RegistryKeys','RegistryValues','Users','Profiles'
Expand All @@ -795,21 +819,31 @@ function Get-DurableMsiTransactionReceipt {
$manifestKeys -cnotcontains $_
}).Count -ne 0 -or
$manifest.SchemaVersion -ne 3 -or
($manifest.Generation -isnot [long] -and $manifest.Generation -isnot [int]) -or
$manifest.Generation -lt 0 -or
[string]$manifest.AuthorityState -cnotin @('PROVISIONAL','NONPROVISIONAL') -or
[string]$manifest.RunId -cne $ownershipRunId -or
!(Test-InstallerArtifactAuthority $manifest) -or
[string]$manifest.State -notin @('ACTIVE','EMPTY')) { return 'UNAVAILABLE' }
if ([string]$manifest.State -ceq 'EMPTY' -and
[string]$manifest.MsiTransactionState -ceq 'NONE' -and
!$manifest.InstallAttempted) { return 'ROLLED_BACK_CLEAN' }
!$manifest.InstallAttempted -and
[string]$manifest.AuthorityState -ceq 'NONPROVISIONAL') {
return 'ROLLED_BACK_CLEAN'
}
if ([string]$manifest.MsiTransactionState -ceq 'ROLLED_BACK_CLEAN' -and
[string]$manifest.AuthorityState -ceq 'NONPROVISIONAL' -and
@($manifest.Directories).Count -eq 0 -and @($manifest.Files).Count -eq 0 -and
@($manifest.RegistryKeys).Count -eq 0 -and
(($manifest.Fixture -and @($manifest.RegistryValues).Count -eq 0) -or
(!$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' }
if ([string]$manifest.MsiTransactionState -cne 'COMMITTED' -or
[string]$manifest.AuthorityState -cne 'NONPROVISIONAL') {
return 'UNAVAILABLE'
}
$ownedDirectories = @($manifest.Directories | Where-Object {
$_.Owned -and [string]$_.Kind -in @('INSTALL_ROOT','SHORTCUT_FOLDER') -and
!$_.Provisional -and
Expand Down
Loading
Loading